add journaling feature: categories, tags, attachments, calendar, and multi-select emotions
Adds an 8th development stage that lets users keep a free-form journal alongside habit tracking, reusing the existing Google OAuth/DB/PWA infrastructure instead of a separate project. Users organize entries into custom categories, filter by a month calendar with day-detail drill-down, attach photos/videos (served via an authenticated route, never /static), tag entries, and pick multiple emotions per entry from a curated 9-option set. Includes a global journal prompt bank for lightweight guided journaling. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}일기 · 해빗랩{% endblock %}
|
||||
{% block nav_journal %}active{% endblock %}
|
||||
{% block shell_class %} wide{% endblock %}
|
||||
{% block content %}
|
||||
<h1>일기</h1>
|
||||
|
||||
<div style="display:flex; align-items:center; gap: var(--space-1);">
|
||||
<div class="tabs" style="flex:1; min-width:0;">
|
||||
<a href="/journal?year={{ year }}&month={{ month }}" class="{{ 'active' if tab != 'manage' and not category_id }}">전체</a>
|
||||
{% for category in categories %}
|
||||
<a
|
||||
href="/journal?year={{ year }}&month={{ month }}&category_id={{ category.id }}"
|
||||
class="{{ 'active' if tab != 'manage' and category_id == category.id }}"
|
||||
>{{ category.name }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<a href="/journal?tab=manage" class="btn btn-secondary{{ ' active' if tab == 'manage' }}" style="flex-shrink:0; margin-bottom: var(--space-2); text-decoration:none;">⚙ 관리</a>
|
||||
</div>
|
||||
|
||||
{% if tab == "manage" %}
|
||||
{% if categories %}
|
||||
<div class="card">
|
||||
<div class="day-detail-list" id="journal-category-list">
|
||||
{% for category in categories %}
|
||||
<div class="journal-entry-item" data-category-id="{{ category.id }}" x-data="{ editing: false }">
|
||||
<div class="day-detail-item" x-show="!editing">
|
||||
<span style="display:flex; align-items:center; gap: 8px;">
|
||||
<span class="drag-handle" aria-hidden="true">⠿</span>
|
||||
<span class="badge" style="border-color: {{ category.color or 'var(--color-accent)' }};">{{ category.name }}</span>
|
||||
<span class="badge">기록 {{ entry_counts.get(category.id, 0) }}개</span>
|
||||
</span>
|
||||
<span style="display:flex; gap:8px;">
|
||||
<button type="button" class="btn btn-secondary" @click="editing = true">수정</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger-ghost"
|
||||
hx-post="/journal/categories/{{ category.id }}/delete"
|
||||
hx-target="body"
|
||||
hx-swap="none"
|
||||
hx-confirm="'{{ category.name }}' 카테고리를 삭제할까요? 이 카테고리의 모든 기록({{ entry_counts.get(category.id, 0) }}개)과 첨부파일도 함께 삭제됩니다."
|
||||
>삭제</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form
|
||||
x-show="editing"
|
||||
x-cloak
|
||||
style="padding: 8px 0;"
|
||||
hx-post="/journal/categories/{{ category.id }}/edit"
|
||||
hx-target="#journal-category-edit-error-{{ category.id }}"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<div class="field">
|
||||
<label for="edit-category-name-{{ category.id }}">카테고리 이름</label>
|
||||
<input type="text" id="edit-category-name-{{ category.id }}" name="name" required value="{{ category.name }}" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="edit-category-color-{{ category.id }}">색상</label>
|
||||
<input type="color" id="edit-category-color-{{ category.id }}" name="color" value="{{ category.color or '#d97757' }}" />
|
||||
</div>
|
||||
<div id="journal-category-edit-error-{{ category.id }}" class="form-error-text"></div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button type="submit" class="btn btn-primary" style="flex:1;">저장</button>
|
||||
<button type="button" class="btn btn-secondary" @click="editing = false">취소</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card" x-data="{ open: false }">
|
||||
<button type="button" class="btn btn-secondary btn-block" @click="open = !open">
|
||||
<span x-show="!open">+ 카테고리 추가</span>
|
||||
<span x-show="open" x-cloak>닫기</span>
|
||||
</button>
|
||||
<form x-show="open" x-cloak style="margin-top: var(--space-2);" hx-post="/journal/categories/new" hx-target="#journal-category-error" hx-swap="innerHTML">
|
||||
<div class="field">
|
||||
<label for="new-category-name">카테고리 이름</label>
|
||||
<input type="text" id="new-category-name" name="name" required placeholder="예: 투자" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-category-color">색상 (선택)</label>
|
||||
<input type="color" id="new-category-color" name="color" value="#d97757" />
|
||||
</div>
|
||||
<div id="journal-category-error" class="form-error-text"></div>
|
||||
<button type="submit" class="btn btn-primary btn-block">추가하기</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
|
||||
{% if on_this_day %}
|
||||
<div class="card on-this-day-card">
|
||||
<h3 style="margin-top:0;">📅 {{ on_this_day|length }}개의 지난 오늘</h3>
|
||||
{% for item in on_this_day %}
|
||||
<div class="day-detail-item">
|
||||
<span>
|
||||
<span class="badge" style="border-color: {{ item.category_color or 'var(--color-accent)' }};">{{ item.category_name }}</span>
|
||||
{{ item.entry_date.year }}년 · {{ item.years_ago }}년 전
|
||||
</span>
|
||||
<span>{{ item.title or (item.content[:30] ~ ('…' if item.content|length > 30 else '')) }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div
|
||||
class="card"
|
||||
x-data="{
|
||||
open: false,
|
||||
moods: [],
|
||||
toggleMood(v) { this.moods.includes(v) ? this.moods = this.moods.filter(m => m !== v) : this.moods.push(v) },
|
||||
}"
|
||||
>
|
||||
<button type="button" class="btn btn-primary btn-block" @click="open = !open">
|
||||
<span x-show="!open">+ 새 기록 쓰기</span>
|
||||
<span x-show="open" x-cloak>닫기</span>
|
||||
</button>
|
||||
|
||||
{% if prompt %}
|
||||
<div class="journal-prompt-card">💭 {{ prompt.question_text }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form
|
||||
x-show="open"
|
||||
x-cloak
|
||||
style="margin-top: var(--space-2);"
|
||||
hx-post="/journal/new"
|
||||
hx-target="#journal-form-error"
|
||||
hx-swap="innerHTML"
|
||||
hx-encoding="multipart/form-data"
|
||||
>
|
||||
<div class="field">
|
||||
<label for="new-entry-category">카테고리</label>
|
||||
<select id="new-entry-category" name="category_id">
|
||||
{% for category in categories %}
|
||||
<option value="{{ category.id }}" {{ 'selected' if category_id == category.id }}>{{ category.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="new-entry-date">날짜</label>
|
||||
<input type="date" id="new-entry-date" name="entry_date" value="{{ today_iso }}" required />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="new-entry-title">제목 (선택)</label>
|
||||
<input type="text" id="new-entry-title" name="title" placeholder="제목을 입력하세요" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="new-entry-content">내용</label>
|
||||
<textarea id="new-entry-content" name="content" rows="5" required placeholder="오늘 하루는 어땠나요?"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>기분 (선택, 여러 개 선택 가능)</label>
|
||||
<input type="hidden" name="moods" :value="moods.join(',')" />
|
||||
<div class="mood-picker">
|
||||
{% for value, emoji, label in journal_mood_options %}
|
||||
<button
|
||||
type="button"
|
||||
class="mood-pill"
|
||||
:class="{ selected: moods.includes('{{ value.value }}') }"
|
||||
@click="toggleMood('{{ value.value }}')"
|
||||
>{{ emoji }} {{ label }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="new-entry-tags">태그 (선택, 쉼표로 구분)</label>
|
||||
<input type="text" id="new-entry-tags" name="tags" placeholder="예: 투자, 회고" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="new-entry-files">사진/영상 첨부 (선택)</label>
|
||||
<input type="file" id="new-entry-files" name="files" accept="image/*,video/*" multiple />
|
||||
</div>
|
||||
|
||||
<div id="journal-form-error" class="form-error-text"></div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">기록 저장</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-2);">
|
||||
<a href="/journal?year={{ prev_year }}&month={{ prev_month }}{{ '&category_id=' ~ category_id if category_id }}" class="btn btn-secondary">‹</a>
|
||||
<h2 style="margin:0;">{{ year }}년 {{ month }}월</h2>
|
||||
<a href="/journal?year={{ next_year }}&month={{ next_month }}{{ '&category_id=' ~ category_id if category_id }}" class="btn btn-secondary">›</a>
|
||||
</div>
|
||||
|
||||
<div class="calendar-grid calendar-grid-header">
|
||||
{% for wd in ["일", "월", "화", "수", "목", "금", "토"] %}
|
||||
<div class="calendar-weekday">{{ wd }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% for week in weeks %}
|
||||
<div class="calendar-grid">
|
||||
{% for d in week %}
|
||||
{% set day_summary = summary_map.get(d) %}
|
||||
<div
|
||||
class="calendar-cell 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>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div id="day-detail"></div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user