journal: real markdown editor (EasyMDE) with live preview toggle, fix default template
- Replace the plain textarea with EasyMDE (vendored locally, no CDN) for markdown authoring: syntax highlighting, smart list continuation, and a custom text-based toolbar (built-in EasyMDE toolbar icons require Font Awesome from a CDN, which this app doesn't use). unorderedListStyle is set to "-" to match the app's own template convention. - Add a preview/edit toggle button that swaps the editor for the exact same server-rendered markdown (via /journal/preview) shown after saving, instead of always showing both. - Fix create/edit entry routes to verify the submitted category_id actually belongs to the current user before inserting -- every other write path in this app already checked ownership; this one didn't (found while manually testing the new editor with a typo'd category id that happened to belong to someone else's category, which surfaced as an IntegrityError 500 instead of a clean 404-equivalent). - Fix the default "일상" category template: bare "-" bullet lines don't parse as list items in the markdown renderer (they need a trailing space), and the content_template validator was silently stripping that trailing space off on every save. Backfill migration updates any category still holding the old, broken template text. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,11 +16,14 @@
|
||||
<link rel="icon" href="/static/icons/icon-192.png" />
|
||||
|
||||
<link rel="stylesheet" href="/static/css/style.css" />
|
||||
<link rel="stylesheet" href="/static/css/vendor/easymde.min.css" />
|
||||
<script src="/static/js/vendor/htmx.min.js" defer></script>
|
||||
<script src="/static/js/push-register.js" defer></script>
|
||||
<script src="/static/js/vendor/sortable.min.js" defer></script>
|
||||
<script src="/static/js/habit-reorder.js" defer></script>
|
||||
<script src="/static/js/journal-category-reorder.js" defer></script>
|
||||
<script src="/static/js/vendor/easymde.min.js" defer></script>
|
||||
<script src="/static/js/journal-editor.js" defer></script>
|
||||
<script src="/static/js/vendor/alpine.min.js" defer></script>
|
||||
<script src="/static/js/app.js" defer></script>
|
||||
</head>
|
||||
|
||||
@@ -124,11 +124,20 @@
|
||||
categoryTemplates: {{ category_templates|tojson|forceescape }},
|
||||
content: '',
|
||||
lastAutoFilled: '',
|
||||
previewMode: false,
|
||||
togglePreview() {
|
||||
this.previewMode = !this.previewMode;
|
||||
if (this.previewMode) { this.$refs.contentField.dispatchEvent(new Event('input')); }
|
||||
},
|
||||
onCategoryChange(categoryId) {
|
||||
const tmpl = this.categoryTemplates[categoryId] || '';
|
||||
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'));
|
||||
});
|
||||
}
|
||||
},
|
||||
init() { this.onCategoryChange(this.$refs.categorySelect.value); },
|
||||
@@ -173,7 +182,25 @@
|
||||
|
||||
<div class="field">
|
||||
<label for="new-entry-content">내용</label>
|
||||
<textarea id="new-entry-content" name="content" rows="8" required placeholder="오늘 하루는 어땠나요?" x-model="content"></textarea>
|
||||
{% include "partials/journal_editor_toolbar.html" %}
|
||||
<div x-show="!previewMode">
|
||||
<textarea
|
||||
id="new-entry-content"
|
||||
name="content"
|
||||
rows="8"
|
||||
required
|
||||
placeholder="오늘 하루는 어땠나요?"
|
||||
class="markdown-editor"
|
||||
x-model="content"
|
||||
x-ref="contentField"
|
||||
hx-post="/journal/preview"
|
||||
hx-trigger="input changed delay:400ms, load"
|
||||
hx-target="#new-entry-preview"
|
||||
hx-swap="innerHTML"
|
||||
hx-params="content"
|
||||
></textarea>
|
||||
</div>
|
||||
<div id="new-entry-preview" class="journal-preview journal-entry-content" x-show="previewMode" x-cloak></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
editing: {{ 'true' if editing_entry_id == item.id else 'false' }},
|
||||
moods: [{% for m in item.moods %}'{{ m.value }}'{{ ',' if not loop.last }}{% endfor %}],
|
||||
toggleMood(v) { this.moods.includes(v) ? this.moods = this.moods.filter(m => m !== v) : this.moods.push(v) },
|
||||
previewMode: false,
|
||||
togglePreview() {
|
||||
this.previewMode = !this.previewMode;
|
||||
if (this.previewMode) { this.$refs.contentField.dispatchEvent(new Event('input')); }
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div x-show="!editing">
|
||||
@@ -24,7 +29,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if item.title %}<div class="journal-entry-title">{{ item.title }}</div>{% endif %}
|
||||
<div class="journal-entry-content">{{ item.content }}</div>
|
||||
<div class="journal-entry-content">{{ item.content|markdown }}</div>
|
||||
{% if item.tags %}
|
||||
<div class="journal-entry-tags">
|
||||
{% for tag in item.tags %}<span class="tag-chip">#{{ tag }}</span>{% endfor %}
|
||||
@@ -94,7 +99,22 @@
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>내용</label>
|
||||
<textarea name="content" rows="4" required>{{ item.content }}</textarea>
|
||||
{% include "partials/journal_editor_toolbar.html" %}
|
||||
<div x-show="!previewMode">
|
||||
<textarea
|
||||
name="content"
|
||||
rows="6"
|
||||
required
|
||||
class="markdown-editor"
|
||||
x-ref="contentField"
|
||||
hx-post="/journal/preview"
|
||||
hx-trigger="input changed delay:400ms, load"
|
||||
hx-target="#journal-edit-preview-{{ item.id }}"
|
||||
hx-swap="innerHTML"
|
||||
hx-params="content"
|
||||
>{{ item.content }}</textarea>
|
||||
</div>
|
||||
<div id="journal-edit-preview-{{ item.id }}" class="journal-preview journal-entry-content" x-show="previewMode" x-cloak></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>기분 (여러 개 선택 가능)</label>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<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>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="md-tool-btn md-preview-toggle"
|
||||
style="margin-left:auto;"
|
||||
@click="togglePreview()"
|
||||
x-text="previewMode ? '✏️ 편집' : '👁 미리보기'"
|
||||
></button>
|
||||
</div>
|
||||
Reference in New Issue
Block a user