Compare commits
8
Commits
b173911269
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74bfe34d3f | ||
|
|
6000de166f | ||
|
|
f524157326 | ||
|
|
ac98cf4f99 | ||
|
|
291813820b | ||
|
|
779bbb58aa | ||
|
|
46c412f8f3 | ||
|
|
8a49757882 |
@@ -116,10 +116,15 @@ class JournalAttachmentOut(BaseModel):
|
||||
has_thumbnail: bool
|
||||
|
||||
|
||||
class JournalCalendarEntry(BaseModel):
|
||||
color: str # 그 엔트리가 속한 카테고리 색상 (점 표시용)
|
||||
title: str # 제목(없으면 내용 일부)
|
||||
|
||||
|
||||
class JournalCalendarDay(BaseModel):
|
||||
entry_date: date
|
||||
total_count: int
|
||||
category_colors: list[str] # 그 날 엔트리가 있는 카테고리들의 색상(점 표시용, 중복 제거)
|
||||
entries: list[JournalCalendarEntry] # 엔트리별 (색상, 제목) 쌍 — 점과 제목이 한 줄에 붙어 나오도록 1:1로 매칭
|
||||
|
||||
|
||||
class JournalDayDetailItem(BaseModel):
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.models.journal import (
|
||||
from app.schemas.journal import (
|
||||
JournalAttachmentOut,
|
||||
JournalCalendarDay,
|
||||
JournalCalendarEntry,
|
||||
JournalCategoryCreate,
|
||||
JournalDayDetailItem,
|
||||
JournalEntryCreate,
|
||||
@@ -363,25 +364,33 @@ def get_monthly_journal_summary(
|
||||
last_day = date(year, month, days_in_month)
|
||||
|
||||
stmt = (
|
||||
select(JournalEntry.entry_date, JournalCategory.color)
|
||||
select(JournalEntry.entry_date, JournalCategory.color, JournalEntry.title, JournalEntry.content)
|
||||
.join(JournalCategory, JournalEntry.category_id == JournalCategory.id)
|
||||
.where(JournalEntry.user_id == user_id, JournalEntry.entry_date.between(first_day, last_day))
|
||||
.order_by(JournalEntry.entry_date, JournalEntry.created_at)
|
||||
)
|
||||
if category_id is not None:
|
||||
stmt = stmt.where(JournalEntry.category_id == category_id)
|
||||
rows = db.execute(stmt).all()
|
||||
|
||||
counts: dict[date, int] = {}
|
||||
colors_by_date: dict[date, list[str]] = {}
|
||||
for entry_date, color in rows:
|
||||
entries_by_date: dict[date, list[JournalCalendarEntry]] = {}
|
||||
for entry_date, color, title, content in rows:
|
||||
counts[entry_date] = counts.get(entry_date, 0) + 1
|
||||
colors = colors_by_date.setdefault(entry_date, [])
|
||||
color = color or "var(--color-accent)"
|
||||
if color not in colors:
|
||||
colors.append(color)
|
||||
entries = entries_by_date.setdefault(entry_date, [])
|
||||
entries.append(
|
||||
JournalCalendarEntry(
|
||||
color=color or "var(--color-accent)",
|
||||
title=title or (content[:12] + ("…" if len(content) > 12 else "")),
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
d: JournalCalendarDay(entry_date=d, total_count=counts[d], category_colors=colors_by_date[d])
|
||||
d: JournalCalendarDay(
|
||||
entry_date=d,
|
||||
total_count=counts[d],
|
||||
entries=entries_by_date[d],
|
||||
)
|
||||
for d in counts
|
||||
}
|
||||
|
||||
|
||||
@@ -872,15 +872,48 @@ label {
|
||||
}
|
||||
|
||||
/* 저널링 */
|
||||
.journal-day-dots {
|
||||
.calendar-cell-journal {
|
||||
aspect-ratio: auto;
|
||||
min-height: 76px;
|
||||
padding: 6px 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-cell-journal .calendar-date {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.journal-day-entries {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.journal-day-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.journal-day-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.journal-day-title {
|
||||
min-width: 0;
|
||||
font-size: 10px;
|
||||
line-height: 1.3;
|
||||
color: var(--color-text-muted);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.journal-prompt-card,
|
||||
@@ -946,7 +979,10 @@ label {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
/* EasyMDE(CodeMirror) 컨테이너를 이 앱의 입력 필드 톤에 맞춘다 */
|
||||
/* EasyMDE(CodeMirror) 컨테이너를 이 앱의 입력 필드 톤에 맞춘다. iOS에서는
|
||||
app/static/js/journal-editor.js가 EasyMDE 대신 순수 <textarea>로 폴백하므로(한글 IME
|
||||
자소분리 회피) 이 규칙은 그 경우 그냥 매칭되지 않는다 — .markdown-editor 자체는 위 공통
|
||||
textarea 규칙을 그대로 물려받아 별도 스타일 없이도 정상적으로 보인다. */
|
||||
.markdown-editor + .EasyMDEContainer .CodeMirror {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-control);
|
||||
|
||||
+252
-63
@@ -1,15 +1,243 @@
|
||||
(function () {
|
||||
// 내장 툴바(toolbar: false)는 안 쓴다 — EasyMDE 기본 툴바 아이콘은 Font Awesome CDN을 전제로
|
||||
// 하는데, 이 앱은 CDN을 안 쓰는 게 원칙이라 대신 journal.html/journal_day_detail.html에서
|
||||
// 직접 만든 텍스트 버튼이 아래 EDITOR_ACTIONS로 EasyMDE 인스턴스 메서드를 호출한다.
|
||||
function initEditors(root) {
|
||||
if (typeof EasyMDE === "undefined") return;
|
||||
var scope = root instanceof Element ? root : document;
|
||||
var textareas = scope.querySelectorAll("textarea.markdown-editor:not([data-easymde-initialized])");
|
||||
// 저널 본문 입력창(.markdown-editor)의 편집 엔진을 기기별로 나눈다.
|
||||
//
|
||||
// EasyMDE(CodeMirror 5 기반)는 타이핑 중 문법에 색을 입혀 보여주는 진짜 마크다운 에디터
|
||||
// 경험을 주지만, iOS Safari에서 한글처럼 여러 keystroke를 조합해 한 글자를 완성하는 IME
|
||||
// 입력 중에 CodeMirror가 화면을 다시 그리면서 조합 버퍼를 끊어버려 자소가 분리된 채로
|
||||
// 남는 문제(자소분리)가 있다. inputStyle을 contenteditable로 강제해도 아이폰에서까지
|
||||
// 재현되는 걸 확인했다 — CodeMirror5 자체의 CJK IME 한계로 보고, iOS에서만 순수
|
||||
// <textarea>로 폴백한다(브라우저 네이티브 입력 처리를 그대로 쓰면 IME가 깨질 이유가
|
||||
// 없다). PC/안드로이드 등 iOS가 아닌 환경은 지금까지처럼 EasyMDE를 그대로 쓴다.
|
||||
//
|
||||
// 굵게/기울임 같은 툴바 버튼과 카테고리 템플릿 자동 채우기는 window.JournalEditor를
|
||||
// 통해 호출되는데, 이 객체가 각 textarea에 EasyMDE가 붙어있는지(t._easymde) 보고
|
||||
// EasyMDE 명령 또는 아래의 직접 선택 영역 조작 중 알맞은 쪽으로 위임한다 — 호출하는
|
||||
// 템플릿 쪽(journal_editor_toolbar.html, journal.html)은 어느 엔진이 쓰이는지 몰라도 된다.
|
||||
|
||||
textareas.forEach(function (textarea) {
|
||||
textarea.setAttribute("data-easymde-initialized", "true");
|
||||
function isIOS() {
|
||||
var ua = navigator.userAgent || "";
|
||||
if (/iPad|iPhone|iPod/.test(ua)) return true;
|
||||
// iPadOS 13+는 기본 설정에서 데스크톱 Safari인 척하는 UA를 보낸다("Macintosh"로 위장,
|
||||
// "Request Mobile Website"를 켜지 않는 한). 이런 위장 아이패드를 잡아내는 표준적인
|
||||
// 방법은 "Mac인데 멀티터치가 된다"는 조합을 보는 것이다(실제 맥은 터치스크린이 없음).
|
||||
return navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1;
|
||||
}
|
||||
|
||||
function fireInput(textarea) {
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
// ---- 순수 textarea 모드에서 툴바가 쓰는 선택 영역 조작 ----
|
||||
|
||||
function insertAtCursor(textarea, text) {
|
||||
var start = textarea.selectionStart;
|
||||
var end = textarea.selectionEnd;
|
||||
var value = textarea.value;
|
||||
textarea.value = value.slice(0, start) + text + value.slice(end);
|
||||
var pos = start + text.length;
|
||||
textarea.selectionStart = textarea.selectionEnd = pos;
|
||||
fireInput(textarea);
|
||||
}
|
||||
|
||||
function replaceMarker(textarea, marker, replacement) {
|
||||
var idx = textarea.value.indexOf(marker);
|
||||
if (idx === -1) return;
|
||||
var before = textarea.value.slice(0, idx);
|
||||
var after = textarea.value.slice(idx + marker.length);
|
||||
textarea.value = before + replacement + after;
|
||||
var pos = before.length + replacement.length;
|
||||
textarea.selectionStart = textarea.selectionEnd = pos;
|
||||
fireInput(textarea);
|
||||
}
|
||||
|
||||
function wrapSelection(textarea, prefix, suffix) {
|
||||
if (suffix === undefined) suffix = prefix;
|
||||
var start = textarea.selectionStart;
|
||||
var end = textarea.selectionEnd;
|
||||
var value = textarea.value;
|
||||
var selected = value.slice(start, end);
|
||||
textarea.value = value.slice(0, start) + prefix + selected + suffix + value.slice(end);
|
||||
textarea.selectionStart = start + prefix.length;
|
||||
textarea.selectionEnd = start + prefix.length + selected.length;
|
||||
textarea.focus();
|
||||
fireInput(textarea);
|
||||
}
|
||||
|
||||
function currentLineRange(textarea) {
|
||||
var start = textarea.selectionStart;
|
||||
var end = textarea.selectionEnd;
|
||||
var value = textarea.value;
|
||||
var lineStart = value.lastIndexOf("\n", start - 1) + 1;
|
||||
var lineEnd = value.indexOf("\n", end);
|
||||
if (lineEnd === -1) lineEnd = value.length;
|
||||
return { lineStart: lineStart, lineEnd: lineEnd, block: value.slice(lineStart, lineEnd) };
|
||||
}
|
||||
|
||||
function replaceBlock(textarea, range, newBlock) {
|
||||
var value = textarea.value;
|
||||
textarea.value = value.slice(0, range.lineStart) + newBlock + value.slice(range.lineEnd);
|
||||
textarea.selectionStart = range.lineStart;
|
||||
textarea.selectionEnd = range.lineStart + newBlock.length;
|
||||
textarea.focus();
|
||||
fireInput(textarea);
|
||||
}
|
||||
|
||||
function toggleLinePrefix(textarea, prefix) {
|
||||
var range = currentLineRange(textarea);
|
||||
var lines = range.block.split("\n");
|
||||
var allPrefixed = lines.every(function (line) { return line.indexOf(prefix) === 0; });
|
||||
var newLines = lines.map(function (line) {
|
||||
if (allPrefixed) return line.slice(prefix.length);
|
||||
return line.indexOf(prefix) === 0 ? line : prefix + line;
|
||||
});
|
||||
replaceBlock(textarea, range, newLines.join("\n"));
|
||||
}
|
||||
|
||||
function toggleOrderedListPlain(textarea) {
|
||||
var range = currentLineRange(textarea);
|
||||
var lines = range.block.split("\n");
|
||||
var re = /^\d+\.\s/;
|
||||
var allNumbered = lines.every(function (line) { return re.test(line); });
|
||||
var newLines = lines.map(function (line, i) {
|
||||
return allNumbered ? line.replace(re, "") : (i + 1) + ". " + line.replace(re, "");
|
||||
});
|
||||
replaceBlock(textarea, range, newLines.join("\n"));
|
||||
}
|
||||
|
||||
function toggleHeadingPlain(textarea) {
|
||||
var range = currentLineRange(textarea);
|
||||
var re = /^#{1,6}\s/;
|
||||
var newBlock = re.test(range.block) ? range.block.replace(re, "") : "### " + range.block;
|
||||
replaceBlock(textarea, range, newBlock);
|
||||
}
|
||||
|
||||
function toggleCodePlain(textarea) {
|
||||
var start = textarea.selectionStart;
|
||||
var end = textarea.selectionEnd;
|
||||
var selected = textarea.value.slice(start, end);
|
||||
if (selected.indexOf("\n") === -1) {
|
||||
wrapSelection(textarea, "`");
|
||||
return;
|
||||
}
|
||||
var value = textarea.value;
|
||||
textarea.value = value.slice(0, start) + "```\n" + selected + "\n```" + value.slice(end);
|
||||
textarea.selectionStart = start + 4;
|
||||
textarea.selectionEnd = start + 4 + selected.length;
|
||||
textarea.focus();
|
||||
fireInput(textarea);
|
||||
}
|
||||
|
||||
function insertLinkPlain(textarea) {
|
||||
var start = textarea.selectionStart;
|
||||
var end = textarea.selectionEnd;
|
||||
var selected = textarea.value.slice(start, end) || "링크 텍스트";
|
||||
var url = window.prompt("링크 주소를 입력하세요", "https://");
|
||||
if (!url) return;
|
||||
var value = textarea.value;
|
||||
var markdown = "[" + selected + "](" + url + ")";
|
||||
textarea.value = value.slice(0, start) + markdown + value.slice(end);
|
||||
textarea.selectionStart = textarea.selectionEnd = start + markdown.length;
|
||||
textarea.focus();
|
||||
fireInput(textarea);
|
||||
}
|
||||
|
||||
// journal_editor_toolbar.html의 버튼들과 journal.html의 카테고리 템플릿 자동 채우기가
|
||||
// 호출하는 공개 API — 어느 엔진(EasyMDE/순수 textarea)이 붙어있는지는 t._easymde 유무로
|
||||
// 판단해서 알맞은 쪽으로 위임한다.
|
||||
window.JournalEditor = {
|
||||
bold: function (t) { if (t) (t._easymde ? t._easymde.toggleBold() : wrapSelection(t, "**")); },
|
||||
italic: function (t) { if (t) (t._easymde ? t._easymde.toggleItalic() : wrapSelection(t, "*")); },
|
||||
strike: function (t) { if (t) (t._easymde ? t._easymde.toggleStrikethrough() : wrapSelection(t, "~~")); },
|
||||
heading: function (t) { if (t) (t._easymde ? t._easymde.toggleHeadingSmaller() : toggleHeadingPlain(t)); },
|
||||
quote: function (t) { if (t) (t._easymde ? t._easymde.toggleBlockquote() : toggleLinePrefix(t, "> ")); },
|
||||
ul: function (t) { if (t) (t._easymde ? t._easymde.toggleUnorderedList() : toggleLinePrefix(t, "- ")); },
|
||||
ol: function (t) { if (t) (t._easymde ? t._easymde.toggleOrderedList() : toggleOrderedListPlain(t)); },
|
||||
code: function (t) { if (t) (t._easymde ? t._easymde.toggleCodeBlock() : toggleCodePlain(t)); },
|
||||
link: function (t) { if (t) (t._easymde ? t._easymde.drawLink() : insertLinkPlain(t)); },
|
||||
setValue: function (t, value) {
|
||||
if (!t) return;
|
||||
t.value = value;
|
||||
if (t._easymde) t._easymde.value(value);
|
||||
fireInput(t);
|
||||
},
|
||||
};
|
||||
|
||||
// ---- 클립보드 이미지 붙여넣기 ----
|
||||
// 스크린샷/사진을 그대로 붙여넣기 대신 서버에 업로드하고 그 자리에 마크다운 이미지
|
||||
// 문법()을 끼워넣는다. 두 엔진 다 업로드 로직은 같고, "지금 커서 위치를 어떻게
|
||||
// 표시해뒀다가 나중에 찾아서 바꿔치기하는지"만 다르다.
|
||||
|
||||
function uploadPastedImage(file) {
|
||||
var formData = new FormData();
|
||||
formData.append("file", file, file.name || "pasted-image.png");
|
||||
return fetch("/api/journal/paste-image", { method: "POST", body: formData }).then(function (res) {
|
||||
if (!res.ok) return res.json().then(function (body) { throw new Error(body.detail || "업로드 실패"); });
|
||||
return res.json();
|
||||
});
|
||||
}
|
||||
|
||||
function extractImageFile(event) {
|
||||
var items = event.clipboardData && event.clipboardData.items;
|
||||
if (!items) return null;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].type.indexOf("image/") === 0) return items[i].getAsFile();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 순수 textarea 모드: 업로드 중 표식을 값에서 찾아 최종 링크로 바꾼다. 고정된 커서
|
||||
// 좌표 대신 텍스트 표식으로 위치를 추적하므로, 업로드가 끝나기 전에 사용자가 다른 곳을
|
||||
// 계속 타이핑해도 자리를 잃지 않는다.
|
||||
function handlePlainImagePaste(textarea, event) {
|
||||
var file = extractImageFile(event);
|
||||
if (!file) return;
|
||||
event.preventDefault();
|
||||
|
||||
var marker = "![업로드 중… #" + Math.random().toString(36).slice(2, 8) + "]()";
|
||||
insertAtCursor(textarea, marker);
|
||||
|
||||
uploadPastedImage(file)
|
||||
.then(function (data) { replaceMarker(textarea, marker, ""); })
|
||||
.catch(function (err) { replaceMarker(textarea, marker, "(이미지 붙여넣기 실패: " + err.message + ")"); });
|
||||
}
|
||||
|
||||
// EasyMDE 모드: CodeMirror 북마크로 위치를 추적한다(원본 로직 그대로).
|
||||
function handleCodeMirrorImagePaste(cm, event) {
|
||||
var file = extractImageFile(event);
|
||||
if (!file) return;
|
||||
event.preventDefault();
|
||||
|
||||
var doc = cm.getDoc();
|
||||
var from = doc.getCursor();
|
||||
var placeholder = "![업로드 중...]()";
|
||||
doc.replaceRange(placeholder, from);
|
||||
var to = { line: from.line, ch: from.ch + placeholder.length };
|
||||
var startMark = doc.setBookmark(from);
|
||||
var endMark = doc.setBookmark(to);
|
||||
|
||||
uploadPastedImage(file)
|
||||
.then(function (data) {
|
||||
var start = startMark.find();
|
||||
var end = endMark.find();
|
||||
if (start && end) doc.replaceRange("", start, end);
|
||||
})
|
||||
.catch(function (err) {
|
||||
var start = startMark.find();
|
||||
var end = endMark.find();
|
||||
if (start && end) doc.replaceRange("(이미지 붙여넣기 실패: " + err.message + ")", start, end);
|
||||
})
|
||||
.finally(function () {
|
||||
startMark.clear();
|
||||
endMark.clear();
|
||||
cm.save();
|
||||
cm.getTextArea().dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function initEasyMDE(textarea) {
|
||||
// 내장 툴바(toolbar: false)는 안 쓴다 — EasyMDE 기본 툴바 아이콘은 Font Awesome CDN을
|
||||
// 전제로 하는데, 이 앱은 CDN을 안 쓰는 게 원칙이라 대신 journal_editor_toolbar.html의
|
||||
// 자체 버튼이 위 JournalEditor를 통해 EasyMDE 인스턴스 메서드를 호출한다.
|
||||
var easymde = new EasyMDE({
|
||||
element: textarea,
|
||||
toolbar: false,
|
||||
@@ -29,70 +257,31 @@
|
||||
// Alpine x-model이 둘 다 반응하도록 input 이벤트를 합성해서 던진다.
|
||||
easymde.codemirror.on("change", function () {
|
||||
easymde.codemirror.save();
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
fireInput(textarea);
|
||||
});
|
||||
|
||||
easymde.codemirror.on("paste", function (cm, event) {
|
||||
handleImagePaste(cm, event);
|
||||
handleCodeMirrorImagePaste(cm, event);
|
||||
});
|
||||
|
||||
textarea._easymde = easymde;
|
||||
});
|
||||
}
|
||||
|
||||
// 클립보드에 이미지가 있으면(스크린샷/사진 복사 등) 그대로 붙여넣기 대신 서버에 업로드하고
|
||||
// 그 자리에 마크다운 이미지 문법()을 끼워넣는다. 이미지가 아니면 그냥 통과시켜서
|
||||
// CodeMirror 기본 텍스트 붙여넣기가 그대로 동작하게 둔다.
|
||||
function handleImagePaste(cm, event) {
|
||||
var items = event.clipboardData && event.clipboardData.items;
|
||||
if (!items) return;
|
||||
|
||||
var imageItem = null;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].type.indexOf("image/") === 0) {
|
||||
imageItem = items[i];
|
||||
break;
|
||||
function initPlainTextarea(textarea) {
|
||||
textarea.addEventListener("paste", function (event) { handlePlainImagePaste(textarea, event); });
|
||||
}
|
||||
|
||||
function initEditors(root) {
|
||||
var scope = root instanceof Element ? root : document;
|
||||
var textareas = scope.querySelectorAll("textarea.markdown-editor:not([data-journal-editor-initialized])");
|
||||
|
||||
textareas.forEach(function (textarea) {
|
||||
textarea.setAttribute("data-journal-editor-initialized", "true");
|
||||
if (typeof EasyMDE !== "undefined" && !isIOS()) {
|
||||
initEasyMDE(textarea);
|
||||
} else {
|
||||
initPlainTextarea(textarea);
|
||||
}
|
||||
if (!imageItem) return;
|
||||
|
||||
event.preventDefault();
|
||||
var file = imageItem.getAsFile();
|
||||
if (!file) return;
|
||||
|
||||
var doc = cm.getDoc();
|
||||
var from = doc.getCursor();
|
||||
var placeholder = "![업로드 중...]()";
|
||||
doc.replaceRange(placeholder, from);
|
||||
var to = { line: from.line, ch: from.ch + placeholder.length };
|
||||
// 업로드가 끝나기 전에 사용자가 다른 곳에서 계속 타이핑해도(줄 추가 등) 자리를 잃지
|
||||
// 않도록 고정 좌표 대신 CodeMirror 북마크로 추적한다.
|
||||
var startMark = doc.setBookmark(from);
|
||||
var endMark = doc.setBookmark(to);
|
||||
|
||||
var formData = new FormData();
|
||||
formData.append("file", file, file.name || "pasted-image.png");
|
||||
|
||||
fetch("/api/journal/paste-image", { method: "POST", body: formData })
|
||||
.then(function (res) {
|
||||
if (!res.ok) return res.json().then(function (body) { throw new Error(body.detail || "업로드 실패"); });
|
||||
return res.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
var start = startMark.find();
|
||||
var end = endMark.find();
|
||||
if (start && end) { doc.replaceRange("", start, end); }
|
||||
})
|
||||
.catch(function (err) {
|
||||
var start = startMark.find();
|
||||
var end = endMark.find();
|
||||
if (start && end) { doc.replaceRange("(이미지 붙여넣기 실패: " + err.message + ")", start, end); }
|
||||
})
|
||||
.finally(function () {
|
||||
startMark.clear();
|
||||
endMark.clear();
|
||||
cm.save();
|
||||
cm.getTextArea().dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = "habit-tracker-v6";
|
||||
const CACHE_NAME = "habit-tracker-v8";
|
||||
const APP_SHELL = [
|
||||
"/static/css/style.css",
|
||||
"/static/css/vendor/easymde.min.css",
|
||||
|
||||
@@ -134,10 +134,7 @@
|
||||
if (this.content === this.lastAutoFilled) {
|
||||
this.content = tmpl;
|
||||
this.lastAutoFilled = tmpl;
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.contentField._easymde) { this.$refs.contentField._easymde.value(tmpl); }
|
||||
this.$refs.contentField.dispatchEvent(new Event('input'));
|
||||
});
|
||||
this.$nextTick(() => { JournalEditor.setValue(this.$refs.contentField, tmpl); });
|
||||
}
|
||||
},
|
||||
init() { this.onCategoryChange(this.$refs.categorySelect.value); },
|
||||
@@ -252,16 +249,19 @@
|
||||
{% for d in week %}
|
||||
{% set day_summary = summary_map.get(d) %}
|
||||
<div
|
||||
class="calendar-cell clickable{{ '' if d.month == month else ' muted' }}"
|
||||
class="calendar-cell calendar-cell-journal clickable{{ '' if d.month == month else ' muted' }}"
|
||||
hx-get="/journal/day/{{ d.isoformat() }}"
|
||||
hx-target="#day-detail"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<span class="calendar-date">{{ d.day }}</span>
|
||||
{% if day_summary %}
|
||||
<span class="journal-day-dots">
|
||||
{% for color in day_summary.category_colors %}
|
||||
<span class="journal-day-dot" style="background: {{ color }};"></span>
|
||||
<span class="journal-day-entries">
|
||||
{% for entry in day_summary.entries %}
|
||||
<span class="journal-day-entry">
|
||||
<span class="journal-day-dot" style="background: {{ entry.color }};"></span>
|
||||
<span class="journal-day-title">{{ entry.title }}</span>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<div class="markdown-toolbar">
|
||||
<span x-show="!previewMode" style="display:flex; gap:4px; flex-wrap:wrap;">
|
||||
<button type="button" class="md-tool-btn" title="굵게" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleBold()"><strong>B</strong></button>
|
||||
<button type="button" class="md-tool-btn" title="기울임" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleItalic()"><em>I</em></button>
|
||||
<button type="button" class="md-tool-btn" title="취소선" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleStrikethrough()"><s>S</s></button>
|
||||
<button type="button" class="md-tool-btn" title="제목" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleHeadingSmaller()">H</button>
|
||||
<button type="button" class="md-tool-btn" title="인용" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleBlockquote()">❝</button>
|
||||
<button type="button" class="md-tool-btn" title="글머리 목록" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleUnorderedList()">•</button>
|
||||
<button type="button" class="md-tool-btn" title="번호 목록" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleOrderedList()">1.</button>
|
||||
<button type="button" class="md-tool-btn" title="코드" @click="$el.closest('.field').querySelector('textarea')._easymde?.toggleCodeBlock()"></></button>
|
||||
<button type="button" class="md-tool-btn" title="링크" @click="$el.closest('.field').querySelector('textarea')._easymde?.drawLink()">🔗</button>
|
||||
<button type="button" class="md-tool-btn" title="굵게" @click="JournalEditor.bold($el.closest('.field').querySelector('textarea'))"><strong>B</strong></button>
|
||||
<button type="button" class="md-tool-btn" title="기울임" @click="JournalEditor.italic($el.closest('.field').querySelector('textarea'))"><em>I</em></button>
|
||||
<button type="button" class="md-tool-btn" title="취소선" @click="JournalEditor.strike($el.closest('.field').querySelector('textarea'))"><s>S</s></button>
|
||||
<button type="button" class="md-tool-btn" title="제목" @click="JournalEditor.heading($el.closest('.field').querySelector('textarea'))">H</button>
|
||||
<button type="button" class="md-tool-btn" title="인용" @click="JournalEditor.quote($el.closest('.field').querySelector('textarea'))">❝</button>
|
||||
<button type="button" class="md-tool-btn" title="글머리 목록" @click="JournalEditor.ul($el.closest('.field').querySelector('textarea'))">•</button>
|
||||
<button type="button" class="md-tool-btn" title="번호 목록" @click="JournalEditor.ol($el.closest('.field').querySelector('textarea'))">1.</button>
|
||||
<button type="button" class="md-tool-btn" title="코드" @click="JournalEditor.code($el.closest('.field').querySelector('textarea'))"></></button>
|
||||
<button type="button" class="md-tool-btn" title="링크" @click="JournalEditor.link($el.closest('.field').querySelector('textarea'))">🔗</button>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -285,7 +285,7 @@ def test_get_monthly_journal_summary_counts_entries_per_day(db_session, test_use
|
||||
|
||||
summary = journal_service.get_monthly_journal_summary(db_session, test_user.id, today.year, today.month)
|
||||
assert summary[today].total_count == 2
|
||||
assert summary[today].category_colors == ["#ff0000"]
|
||||
assert [e.color for e in summary[today].entries] == ["#ff0000", "#ff0000"]
|
||||
|
||||
|
||||
def test_get_day_entries_returns_entries_for_that_date(db_session, test_user):
|
||||
|
||||
Reference in New Issue
Block a user