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
|
has_thumbnail: bool
|
||||||
|
|
||||||
|
|
||||||
|
class JournalCalendarEntry(BaseModel):
|
||||||
|
color: str # 그 엔트리가 속한 카테고리 색상 (점 표시용)
|
||||||
|
title: str # 제목(없으면 내용 일부)
|
||||||
|
|
||||||
|
|
||||||
class JournalCalendarDay(BaseModel):
|
class JournalCalendarDay(BaseModel):
|
||||||
entry_date: date
|
entry_date: date
|
||||||
total_count: int
|
total_count: int
|
||||||
category_colors: list[str] # 그 날 엔트리가 있는 카테고리들의 색상(점 표시용, 중복 제거)
|
entries: list[JournalCalendarEntry] # 엔트리별 (색상, 제목) 쌍 — 점과 제목이 한 줄에 붙어 나오도록 1:1로 매칭
|
||||||
|
|
||||||
|
|
||||||
class JournalDayDetailItem(BaseModel):
|
class JournalDayDetailItem(BaseModel):
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from app.models.journal import (
|
|||||||
from app.schemas.journal import (
|
from app.schemas.journal import (
|
||||||
JournalAttachmentOut,
|
JournalAttachmentOut,
|
||||||
JournalCalendarDay,
|
JournalCalendarDay,
|
||||||
|
JournalCalendarEntry,
|
||||||
JournalCategoryCreate,
|
JournalCategoryCreate,
|
||||||
JournalDayDetailItem,
|
JournalDayDetailItem,
|
||||||
JournalEntryCreate,
|
JournalEntryCreate,
|
||||||
@@ -363,25 +364,33 @@ def get_monthly_journal_summary(
|
|||||||
last_day = date(year, month, days_in_month)
|
last_day = date(year, month, days_in_month)
|
||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(JournalEntry.entry_date, JournalCategory.color)
|
select(JournalEntry.entry_date, JournalCategory.color, JournalEntry.title, JournalEntry.content)
|
||||||
.join(JournalCategory, JournalEntry.category_id == JournalCategory.id)
|
.join(JournalCategory, JournalEntry.category_id == JournalCategory.id)
|
||||||
.where(JournalEntry.user_id == user_id, JournalEntry.entry_date.between(first_day, last_day))
|
.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:
|
if category_id is not None:
|
||||||
stmt = stmt.where(JournalEntry.category_id == category_id)
|
stmt = stmt.where(JournalEntry.category_id == category_id)
|
||||||
rows = db.execute(stmt).all()
|
rows = db.execute(stmt).all()
|
||||||
|
|
||||||
counts: dict[date, int] = {}
|
counts: dict[date, int] = {}
|
||||||
colors_by_date: dict[date, list[str]] = {}
|
entries_by_date: dict[date, list[JournalCalendarEntry]] = {}
|
||||||
for entry_date, color in rows:
|
for entry_date, color, title, content in rows:
|
||||||
counts[entry_date] = counts.get(entry_date, 0) + 1
|
counts[entry_date] = counts.get(entry_date, 0) + 1
|
||||||
colors = colors_by_date.setdefault(entry_date, [])
|
entries = entries_by_date.setdefault(entry_date, [])
|
||||||
color = color or "var(--color-accent)"
|
entries.append(
|
||||||
if color not in colors:
|
JournalCalendarEntry(
|
||||||
colors.append(color)
|
color=color or "var(--color-accent)",
|
||||||
|
title=title or (content[:12] + ("…" if len(content) > 12 else "")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
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
|
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;
|
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 {
|
.journal-day-dot {
|
||||||
width: 5px;
|
width: 6px;
|
||||||
height: 5px;
|
height: 6px;
|
||||||
border-radius: 50%;
|
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,
|
.journal-prompt-card,
|
||||||
@@ -946,7 +979,10 @@ label {
|
|||||||
background: var(--color-bg);
|
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 {
|
.markdown-editor + .EasyMDEContainer .CodeMirror {
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-control);
|
border-radius: var(--radius-control);
|
||||||
|
|||||||
+252
-63
@@ -1,15 +1,243 @@
|
|||||||
(function () {
|
(function () {
|
||||||
// 내장 툴바(toolbar: false)는 안 쓴다 — EasyMDE 기본 툴바 아이콘은 Font Awesome CDN을 전제로
|
// 저널 본문 입력창(.markdown-editor)의 편집 엔진을 기기별로 나눈다.
|
||||||
// 하는데, 이 앱은 CDN을 안 쓰는 게 원칙이라 대신 journal.html/journal_day_detail.html에서
|
//
|
||||||
// 직접 만든 텍스트 버튼이 아래 EDITOR_ACTIONS로 EasyMDE 인스턴스 메서드를 호출한다.
|
// EasyMDE(CodeMirror 5 기반)는 타이핑 중 문법에 색을 입혀 보여주는 진짜 마크다운 에디터
|
||||||
function initEditors(root) {
|
// 경험을 주지만, iOS Safari에서 한글처럼 여러 keystroke를 조합해 한 글자를 완성하는 IME
|
||||||
if (typeof EasyMDE === "undefined") return;
|
// 입력 중에 CodeMirror가 화면을 다시 그리면서 조합 버퍼를 끊어버려 자소가 분리된 채로
|
||||||
var scope = root instanceof Element ? root : document;
|
// 남는 문제(자소분리)가 있다. inputStyle을 contenteditable로 강제해도 아이폰에서까지
|
||||||
var textareas = scope.querySelectorAll("textarea.markdown-editor:not([data-easymde-initialized])");
|
// 재현되는 걸 확인했다 — 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) {
|
function isIOS() {
|
||||||
textarea.setAttribute("data-easymde-initialized", "true");
|
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({
|
var easymde = new EasyMDE({
|
||||||
element: textarea,
|
element: textarea,
|
||||||
toolbar: false,
|
toolbar: false,
|
||||||
@@ -29,70 +257,31 @@
|
|||||||
// Alpine x-model이 둘 다 반응하도록 input 이벤트를 합성해서 던진다.
|
// Alpine x-model이 둘 다 반응하도록 input 이벤트를 합성해서 던진다.
|
||||||
easymde.codemirror.on("change", function () {
|
easymde.codemirror.on("change", function () {
|
||||||
easymde.codemirror.save();
|
easymde.codemirror.save();
|
||||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
fireInput(textarea);
|
||||||
});
|
});
|
||||||
|
|
||||||
easymde.codemirror.on("paste", function (cm, event) {
|
easymde.codemirror.on("paste", function (cm, event) {
|
||||||
handleImagePaste(cm, event);
|
handleCodeMirrorImagePaste(cm, event);
|
||||||
});
|
});
|
||||||
|
|
||||||
textarea._easymde = easymde;
|
textarea._easymde = easymde;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 클립보드에 이미지가 있으면(스크린샷/사진 복사 등) 그대로 붙여넣기 대신 서버에 업로드하고
|
function initPlainTextarea(textarea) {
|
||||||
// 그 자리에 마크다운 이미지 문법()을 끼워넣는다. 이미지가 아니면 그냥 통과시켜서
|
textarea.addEventListener("paste", function (event) { handlePlainImagePaste(textarea, event); });
|
||||||
// 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 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 = [
|
const APP_SHELL = [
|
||||||
"/static/css/style.css",
|
"/static/css/style.css",
|
||||||
"/static/css/vendor/easymde.min.css",
|
"/static/css/vendor/easymde.min.css",
|
||||||
|
|||||||
@@ -134,10 +134,7 @@
|
|||||||
if (this.content === this.lastAutoFilled) {
|
if (this.content === this.lastAutoFilled) {
|
||||||
this.content = tmpl;
|
this.content = tmpl;
|
||||||
this.lastAutoFilled = tmpl;
|
this.lastAutoFilled = tmpl;
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => { JournalEditor.setValue(this.$refs.contentField, tmpl); });
|
||||||
if (this.$refs.contentField._easymde) { this.$refs.contentField._easymde.value(tmpl); }
|
|
||||||
this.$refs.contentField.dispatchEvent(new Event('input'));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
init() { this.onCategoryChange(this.$refs.categorySelect.value); },
|
init() { this.onCategoryChange(this.$refs.categorySelect.value); },
|
||||||
@@ -252,16 +249,19 @@
|
|||||||
{% for d in week %}
|
{% for d in week %}
|
||||||
{% set day_summary = summary_map.get(d) %}
|
{% set day_summary = summary_map.get(d) %}
|
||||||
<div
|
<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-get="/journal/day/{{ d.isoformat() }}"
|
||||||
hx-target="#day-detail"
|
hx-target="#day-detail"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
>
|
>
|
||||||
<span class="calendar-date">{{ d.day }}</span>
|
<span class="calendar-date">{{ d.day }}</span>
|
||||||
{% if day_summary %}
|
{% if day_summary %}
|
||||||
<span class="journal-day-dots">
|
<span class="journal-day-entries">
|
||||||
{% for color in day_summary.category_colors %}
|
{% for entry in day_summary.entries %}
|
||||||
<span class="journal-day-dot" style="background: {{ color }};"></span>
|
<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 %}
|
{% endfor %}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<div class="markdown-toolbar">
|
<div class="markdown-toolbar">
|
||||||
<span x-show="!previewMode" style="display:flex; gap:4px; flex-wrap:wrap;">
|
<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="JournalEditor.bold($el.closest('.field').querySelector('textarea'))"><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="JournalEditor.italic($el.closest('.field').querySelector('textarea'))"><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="JournalEditor.strike($el.closest('.field').querySelector('textarea'))"><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="JournalEditor.heading($el.closest('.field').querySelector('textarea'))">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="JournalEditor.quote($el.closest('.field').querySelector('textarea'))">❝</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="JournalEditor.ul($el.closest('.field').querySelector('textarea'))">•</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="JournalEditor.ol($el.closest('.field').querySelector('textarea'))">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="JournalEditor.code($el.closest('.field').querySelector('textarea'))"></></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.link($el.closest('.field').querySelector('textarea'))">🔗</button>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="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)
|
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].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):
|
def test_get_day_entries_returns_entries_for_that_date(db_session, test_user):
|
||||||
|
|||||||
Reference in New Issue
Block a user