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:
2026-08-04 18:40:07 +09:00
co-authored by Claude Sonnet 5
parent ef2a3ea082
commit 54c971875c
24 changed files with 2485 additions and 7 deletions
+3
View File
@@ -18,5 +18,8 @@ class Settings(BaseSettings):
session_cookie_name: str = "habit_session"
timezone: str = "Asia/Seoul"
journal_media_root: str = "app/media/journal"
journal_max_upload_mb: int = 20
settings = Settings()
+5 -1
View File
@@ -1,5 +1,6 @@
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
@@ -9,7 +10,7 @@ from starlette.middleware.sessions import SessionMiddleware
from app.config import settings
from app.database import SessionLocal
from app.routers import auth, habits, logs, pages, push
from app.routers import auth, habits, journal, journal_pages, logs, pages, push
from app.routers.pages import templates
from app.security import get_current_user_optional
from app.services import scheduler_service
@@ -19,6 +20,7 @@ logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
Path(settings.journal_media_root).mkdir(parents=True, exist_ok=True)
scheduler_service.start_scheduler()
yield
scheduler_service.shutdown_scheduler()
@@ -44,8 +46,10 @@ app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(auth.router)
app.include_router(habits.router)
app.include_router(journal.router)
app.include_router(logs.router)
app.include_router(push.router)
app.include_router(journal_pages.router)
app.include_router(pages.router)
+18
View File
@@ -1,5 +1,15 @@
from app.models.habit import Habit, HabitStatus, HabitType
from app.models.habit_log import HabitLog
from app.models.journal import (
JournalAttachment,
JournalAttachmentType,
JournalCategory,
JournalEntry,
JournalEntryMood,
JournalMood,
JournalPrompt,
JournalTag,
)
from app.models.notification_log import HabitNotificationLog, SummaryNotificationLog
from app.models.push_subscription import PushSubscription
from app.models.user import User
@@ -9,6 +19,14 @@ __all__ = [
"HabitStatus",
"HabitType",
"HabitLog",
"JournalAttachment",
"JournalAttachmentType",
"JournalCategory",
"JournalEntry",
"JournalEntryMood",
"JournalMood",
"JournalPrompt",
"JournalTag",
"HabitNotificationLog",
"SummaryNotificationLog",
"PushSubscription",
+131
View File
@@ -0,0 +1,131 @@
import enum
from datetime import date, datetime
from sqlalchemy import Column, Date, Enum, ForeignKey, Integer, String, Table, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
from app.models.habit import _by_value
class JournalMood(str, enum.Enum):
PAIN = "pain" # 아픔
ACHIEVEMENT = "achievement" # 성취
ANGER = "anger" # 분노
EXCITED = "excited" # 신남
CALM = "calm" # 평온
HAPPY = "happy" # 행복
WORRY = "worry" # 걱정
TIRED = "tired" # 피곤
SAD = "sad" # 슬픔
class JournalAttachmentType(str, enum.Enum):
IMAGE = "image"
VIDEO = "video"
journal_entry_tag = Table(
"journal_entry_tag",
Base.metadata,
Column("entry_id", ForeignKey("journal_entry.id", ondelete="CASCADE"), primary_key=True),
Column("tag_id", ForeignKey("journal_tag.id", ondelete="CASCADE"), primary_key=True),
)
class JournalCategory(Base):
__tablename__ = "journal_category"
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_journal_category_user_name"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(50), nullable=False)
color: Mapped[str | None] = mapped_column(String(20), nullable=True)
sort_order: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
entries: Mapped[list["JournalEntry"]] = relationship(
back_populates="category", cascade="all, delete-orphan", passive_deletes=True
)
class JournalEntry(Base):
__tablename__ = "journal_entry"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
category_id: Mapped[int] = mapped_column(
ForeignKey("journal_category.id", ondelete="CASCADE"), nullable=False
)
entry_date: Mapped[date] = mapped_column(Date, nullable=False)
title: Mapped[str | None] = mapped_column(String(200), nullable=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
server_default=func.now(), onupdate=func.now(), nullable=False
)
category: Mapped["JournalCategory"] = relationship(back_populates="entries")
tags: Mapped[list["JournalTag"]] = relationship(
secondary=journal_entry_tag, back_populates="entries"
)
moods: Mapped[list["JournalEntryMood"]] = relationship(
back_populates="entry", cascade="all, delete-orphan", passive_deletes=True
)
attachments: Mapped[list["JournalAttachment"]] = relationship(
back_populates="entry", cascade="all, delete-orphan", passive_deletes=True
)
class JournalEntryMood(Base):
"""엔트리 하나에 여러 감정을 태그처럼 붙일 수 있게 하는 연결 테이블. mood 자체가 고정 enum이라
JournalTag처럼 별도 엔티티(이름 등)를 둘 필요가 없어 journal_entry_tag와 달리 값 자체를 PK로 쓴다."""
__tablename__ = "journal_entry_mood"
entry_id: Mapped[int] = mapped_column(ForeignKey("journal_entry.id", ondelete="CASCADE"), primary_key=True)
mood: Mapped[JournalMood] = mapped_column(
Enum(JournalMood, native_enum=False, length=20, values_callable=_by_value), primary_key=True
)
entry: Mapped["JournalEntry"] = relationship(back_populates="moods")
class JournalTag(Base):
__tablename__ = "journal_tag"
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_journal_tag_user_name"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(50), nullable=False)
entries: Mapped[list["JournalEntry"]] = relationship(
secondary=journal_entry_tag, back_populates="tags"
)
class JournalAttachment(Base):
__tablename__ = "journal_attachment"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
entry_id: Mapped[int] = mapped_column(ForeignKey("journal_entry.id", ondelete="CASCADE"), nullable=False)
media_type: Mapped[JournalAttachmentType] = mapped_column(
Enum(JournalAttachmentType, native_enum=False, length=10, values_callable=_by_value), nullable=False
)
file_path: Mapped[str] = mapped_column(String(500), nullable=False)
thumbnail_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
file_size: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
entry: Mapped["JournalEntry"] = relationship(back_populates="attachments")
class JournalPrompt(Base):
"""카테고리 무관 전역 질문 뱅크. 특정 카테고리 전용 프롬프트는 v1 범위 밖(향후 확장 여지)."""
__tablename__ = "journal_prompt"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
question_text: Mapped[str] = mapped_column(String(300), nullable=False)
+36
View File
@@ -0,0 +1,36 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.user import User
from app.schemas.journal import JournalCategoryReorderRequest
from app.security import require_login
from app.services import journal_service
router = APIRouter(prefix="/api/journal", tags=["journal"], dependencies=[Depends(require_login)])
@router.get("/media/{attachment_id}")
def get_media(
attachment_id: int,
thumbnail: bool = Query(False),
db: Session = Depends(get_db),
current_user: User = Depends(require_login),
):
attachment = journal_service.get_attachment(db, attachment_id, current_user.id)
if attachment is None:
raise HTTPException(status_code=404, detail="첨부파일을 찾을 수 없습니다")
path = attachment.thumbnail_path if (thumbnail and attachment.thumbnail_path) else attachment.file_path
return FileResponse(path, filename=attachment.original_filename)
@router.post("/categories/reorder")
def reorder_categories(
data: JournalCategoryReorderRequest,
db: Session = Depends(get_db),
current_user: User = Depends(require_login),
):
journal_service.reorder_categories(db, current_user.id, data.category_ids)
return {"ok": True}
+346
View File
@@ -0,0 +1,346 @@
import calendar
from datetime import date
from fastapi import APIRouter, Depends, File, Form, Request, Response, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import ValidationError
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.journal import JournalMood
from app.routers.pages import _current_user_or_redirect, templates
from app.schemas.journal import JournalCategoryCreate, JournalEntryCreate, JournalEntryUpdate
from app.services import journal_service
router = APIRouter()
# (enum 값, 이모지, 한글 라벨). 기분 선택 pill(작성/수정 폼)과 표시(day-detail)에서 공유해서 쓴다.
# 여러 개를 동시에 고를 수 있어서(다중 선택) 라벨은 전부 명사형으로 통일한다.
JOURNAL_MOOD_OPTIONS = [
(JournalMood.PAIN, "🤕", "아픔"),
(JournalMood.ACHIEVEMENT, "🏆", "성취"),
(JournalMood.ANGER, "😠", "분노"),
(JournalMood.EXCITED, "🤩", "신남"),
(JournalMood.CALM, "😌", "평온"),
(JournalMood.HAPPY, "😊", "행복"),
(JournalMood.WORRY, "😟", "걱정"),
(JournalMood.TIRED, "😪", "피곤"),
(JournalMood.SAD, "😢", "슬픔"),
]
templates.env.globals["journal_mood_options"] = JOURNAL_MOOD_OPTIONS
templates.env.globals["journal_mood_emoji"] = {m.value: emoji for m, emoji, _ in JOURNAL_MOOD_OPTIONS}
def _parse_moods(raw: str) -> list[JournalMood]:
moods = []
for v in raw.split(","):
v = v.strip()
if not v:
continue
try:
moods.append(JournalMood(v))
except ValueError:
continue
return moods
def _parse_tags(raw: str) -> list[str]:
return [t for t in raw.split(",")]
def _month_context(db: Session, user_id: int, year: int, month: int, category_id: int | None) -> dict:
summary_map = journal_service.get_monthly_journal_summary(db, user_id, year, month, category_id)
weeks = calendar.Calendar(firstweekday=6).monthdatescalendar(year, month)
prev_year, prev_month = (year - 1, 12) if month == 1 else (year, month - 1)
next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1)
return {
"year": year,
"month": month,
"weeks": weeks,
"summary_map": summary_map,
"prev_year": prev_year,
"prev_month": prev_month,
"next_year": next_year,
"next_month": next_month,
}
def _render_day_detail(request: Request, db: Session, user_id: int, entry_date: date, **extra):
return templates.TemplateResponse(
request,
"partials/journal_day_detail.html",
{
"entry_date": entry_date,
"items": journal_service.get_day_entries(db, user_id, entry_date),
"categories": journal_service.list_categories(db, user_id),
**extra,
},
)
@router.get("/journal")
def journal_page(
request: Request,
tab: str = "calendar",
year: int | None = None,
month: int | None = None,
category_id: int | None = None,
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
journal_service.ensure_default_category(db, current.id)
categories = journal_service.list_categories(db, current.id)
today = date.today()
year = year or today.year
month = month or today.month
tab = "manage" if tab == "manage" else "calendar"
return templates.TemplateResponse(
request,
"journal.html",
{
"logged_in": True,
"current_user": current,
"tab": tab,
"categories": categories,
"category_id": category_id,
"entry_counts": journal_service.count_entries_by_category(db, current.id) if tab == "manage" else {},
"on_this_day": journal_service.get_on_this_day(db, current.id, today),
"prompt": journal_service.get_random_prompt(db),
"today_iso": today.isoformat(),
**_month_context(db, current.id, year, month, category_id),
},
)
@router.get("/journal/day/{entry_date}")
def journal_day_detail(request: Request, entry_date: date, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
return _render_day_detail(request, db, current.id, entry_date)
@router.post("/journal/new")
def create_entry_page(
request: Request,
category_id: int = Form(...),
entry_date: str = Form(...),
title: str | None = Form(None),
content: str = Form(""),
moods: str = Form(""),
tags: str = Form(""),
files: list[UploadFile] = File(default=[]),
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
try:
parsed_date = date.fromisoformat(entry_date)
data = JournalEntryCreate(
category_id=category_id,
entry_date=parsed_date,
title=title,
content=content,
moods=_parse_moods(moods),
tags=_parse_tags(tags),
)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return HTMLResponse(message)
except ValueError:
return HTMLResponse("입력값을 확인해주세요")
entry = journal_service.create_entry(db, current.id, data)
for upload in files:
if not upload.filename:
continue
try:
journal_service.save_attachment(db, entry, upload)
except ValueError as exc:
return HTMLResponse(str(exc))
response = Response(status_code=200)
response.headers["HX-Redirect"] = f"/journal?year={parsed_date.year}&month={parsed_date.month}"
return response
@router.post("/journal/{entry_id}/edit")
def edit_entry_page(
request: Request,
entry_id: int,
category_id: int = Form(...),
entry_date: str = Form(...),
title: str | None = Form(None),
content: str = Form(""),
moods: str = Form(""),
tags: str = Form(""),
files: list[UploadFile] = File(default=[]),
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
entry = journal_service.get_entry(db, entry_id, current.id)
if entry is None:
return Response(status_code=404)
original_date = entry.entry_date
try:
parsed_date = date.fromisoformat(entry_date)
data = JournalEntryUpdate(
category_id=category_id,
entry_date=parsed_date,
title=title,
content=content,
moods=_parse_moods(moods),
tags=_parse_tags(tags),
)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return _render_day_detail(
request, db, current.id, original_date, edit_error=message, editing_entry_id=entry_id
)
except ValueError:
return _render_day_detail(
request,
db,
current.id,
original_date,
edit_error="입력값을 확인해주세요",
editing_entry_id=entry_id,
)
journal_service.update_entry(db, entry, data)
for upload in files:
if not upload.filename:
continue
try:
journal_service.save_attachment(db, entry, upload)
except ValueError:
pass # 첨부 실패는 조용히 넘어간다 — 본문 수정은 이미 반영됐다
return _render_day_detail(request, db, current.id, parsed_date)
@router.post("/journal/{entry_id}/delete")
def delete_entry_page(request: Request, entry_id: int, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
entry = journal_service.get_entry(db, entry_id, current.id)
if entry is None:
return Response(status_code=404)
entry_date_value = entry.entry_date
journal_service.delete_entry(db, entry)
return _render_day_detail(request, db, current.id, entry_date_value)
@router.post("/journal/{entry_id}/attachments/{attachment_id}/delete")
def delete_attachment_page(
request: Request, entry_id: int, attachment_id: int, db: Session = Depends(get_db)
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
entry = journal_service.get_entry(db, entry_id, current.id)
if entry is None:
return Response(status_code=404)
attachment = journal_service.get_attachment(db, attachment_id, current.id)
if attachment is None or attachment.entry_id != entry.id:
return Response(status_code=404)
entry_date_value = entry.entry_date
journal_service.delete_attachment(db, attachment)
return _render_day_detail(request, db, current.id, entry_date_value)
@router.post("/journal/categories/new")
def create_category_page(
request: Request, name: str = Form(""), color: str | None = Form(None), db: Session = Depends(get_db)
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
try:
data = JournalCategoryCreate(name=name, color=color)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return HTMLResponse(message)
try:
journal_service.create_category(db, current.id, data)
except IntegrityError:
db.rollback()
return HTMLResponse("이미 같은 이름의 카테고리가 있어요")
response = Response(status_code=200)
response.headers["HX-Redirect"] = "/journal?tab=manage"
return response
@router.post("/journal/categories/{category_id}/edit")
def edit_category_page(
request: Request,
category_id: int,
name: str = Form(""),
color: str | None = Form(None),
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
category = journal_service.get_category(db, category_id, current.id)
if category is None:
return Response(status_code=404)
try:
data = JournalCategoryCreate(name=name, color=color)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return HTMLResponse(message)
try:
journal_service.update_category(db, category, data)
except IntegrityError:
db.rollback()
return HTMLResponse("이미 같은 이름의 카테고리가 있어요")
response = Response(status_code=200)
response.headers["HX-Redirect"] = "/journal?tab=manage"
return response
@router.post("/journal/categories/{category_id}/delete")
def delete_category_page(request: Request, category_id: int, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
category = journal_service.get_category(db, category_id, current.id)
if category is None:
return Response(status_code=404)
journal_service.delete_category(db, category)
response = Response(status_code=200)
response.headers["HX-Redirect"] = "/journal?tab=manage"
return response
+126
View File
@@ -0,0 +1,126 @@
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, field_validator
from app.models.journal import JournalAttachmentType, JournalMood
class JournalCategoryBase(BaseModel):
name: str
color: str | None = None
@field_validator("name")
@classmethod
def name_not_blank(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("카테고리 이름을 입력해주세요")
return v
@field_validator("color")
@classmethod
def blank_color_to_none(cls, v: str | None) -> str | None:
if v is None:
return None
v = v.strip()
return v or None
class JournalCategoryCreate(JournalCategoryBase):
pass
class JournalCategoryOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
color: str | None
sort_order: int | None
created_at: datetime
class JournalCategoryReorderRequest(BaseModel):
category_ids: list[int]
class JournalEntryBase(BaseModel):
category_id: int
entry_date: date
title: str | None = None
content: str
moods: list[JournalMood] = []
tags: list[str] = []
@field_validator("content")
@classmethod
def content_not_blank(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("내용을 입력해주세요")
return v
@field_validator("title")
@classmethod
def blank_title_to_none(cls, v: str | None) -> str | None:
if v is None:
return None
v = v.strip()
return v or None
@field_validator("moods")
@classmethod
def dedupe_moods(cls, v: list[JournalMood]) -> list[JournalMood]:
cleaned: list[JournalMood] = []
for mood in v:
if mood not in cleaned:
cleaned.append(mood)
return cleaned
@field_validator("tags")
@classmethod
def clean_tags(cls, v: list[str]) -> list[str]:
cleaned: list[str] = []
for raw in v:
name = raw.strip()
if name and name not in cleaned:
cleaned.append(name)
return cleaned
class JournalEntryCreate(JournalEntryBase):
pass
class JournalEntryUpdate(JournalEntryBase):
pass
class JournalAttachmentOut(BaseModel):
id: int
media_type: JournalAttachmentType
original_filename: str
has_thumbnail: bool
class JournalCalendarDay(BaseModel):
entry_date: date
total_count: int
category_colors: list[str] # 그 날 엔트리가 있는 카테고리들의 색상(점 표시용, 중복 제거)
class JournalDayDetailItem(BaseModel):
id: int
category_id: int
category_name: str
category_color: str | None
title: str | None
content: str
moods: list[JournalMood]
tags: list[str]
attachments: list[JournalAttachmentOut]
class JournalOnThisDayItem(JournalDayDetailItem):
entry_date: date
years_ago: int
+372
View File
@@ -0,0 +1,372 @@
import calendar
import mimetypes
import random
import uuid
from datetime import date
from pathlib import Path
from fastapi import UploadFile
from PIL import Image
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.config import settings
from app.models.journal import (
JournalAttachment,
JournalAttachmentType,
JournalCategory,
JournalEntry,
JournalEntryMood,
JournalPrompt,
JournalTag,
)
from app.schemas.journal import (
JournalAttachmentOut,
JournalCalendarDay,
JournalCategoryCreate,
JournalDayDetailItem,
JournalEntryCreate,
JournalEntryUpdate,
JournalOnThisDayItem,
)
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
ALLOWED_VIDEO_TYPES = {"video/mp4", "video/quicktime"}
THUMBNAIL_WIDTH = 400
DEFAULT_CATEGORY_NAME = "일상"
# ---- 카테고리 ----
def list_categories(db: Session, user_id: int) -> list[JournalCategory]:
stmt = select(JournalCategory).where(JournalCategory.user_id == user_id)
stmt = stmt.order_by(
JournalCategory.sort_order.is_(None), JournalCategory.sort_order, JournalCategory.created_at
)
return list(db.scalars(stmt))
def get_category(db: Session, category_id: int, user_id: int) -> JournalCategory | None:
return db.scalar(
select(JournalCategory).where(JournalCategory.id == category_id, JournalCategory.user_id == user_id)
)
def create_category(db: Session, user_id: int, data: JournalCategoryCreate) -> JournalCategory:
category = JournalCategory(user_id=user_id, name=data.name, color=data.color)
db.add(category)
db.commit()
db.refresh(category)
return category
def update_category(db: Session, category: JournalCategory, data: JournalCategoryCreate) -> JournalCategory:
category.name = data.name
category.color = data.color
db.commit()
db.refresh(category)
return category
def ensure_default_category(db: Session, user_id: int) -> JournalCategory:
"""유저가 카테고리를 하나도 만든 적 없으면 기본 카테고리를 자동 생성한다."""
existing = db.scalar(
select(JournalCategory)
.where(JournalCategory.user_id == user_id)
.order_by(JournalCategory.sort_order.is_(None), JournalCategory.sort_order, JournalCategory.created_at)
.limit(1)
)
if existing is not None:
return existing
return create_category(db, user_id, JournalCategoryCreate(name=DEFAULT_CATEGORY_NAME))
def reorder_categories(db: Session, user_id: int, ordered_ids: list[int]) -> None:
categories = db.scalars(
select(JournalCategory).where(JournalCategory.id.in_(ordered_ids), JournalCategory.user_id == user_id)
).all()
category_map = {c.id: c for c in categories}
for index, category_id in enumerate(ordered_ids):
category = category_map.get(category_id)
if category is not None:
category.sort_order = index
db.commit()
def count_entries_by_category(db: Session, user_id: int) -> dict[int, int]:
rows = db.execute(
select(JournalEntry.category_id, func.count(JournalEntry.id))
.where(JournalEntry.user_id == user_id)
.group_by(JournalEntry.category_id)
).all()
return {row[0]: row[1] for row in rows}
def delete_category(db: Session, category: JournalCategory) -> None:
"""카테고리를 지우면 안의 엔트리도 함께 지워진다 — 첨부파일 디스크 삭제까지 하려면
ORM cascade에만 맡기지 않고 delete_entry를 하나씩 거쳐야 한다."""
entries = list(db.scalars(select(JournalEntry).where(JournalEntry.category_id == category.id)))
for entry in entries:
delete_entry(db, entry)
db.delete(category)
db.commit()
# ---- 태그 ----
def get_or_create_tags(db: Session, user_id: int, names: list[str]) -> list[JournalTag]:
tags = []
for name in names:
tag = db.scalar(select(JournalTag).where(JournalTag.user_id == user_id, JournalTag.name == name))
if tag is None:
tag = JournalTag(user_id=user_id, name=name)
db.add(tag)
db.flush()
tags.append(tag)
return tags
# ---- 엔트리 ----
def list_entries(
db: Session,
user_id: int,
category_id: int | None = None,
start: date | None = None,
end: date | None = None,
) -> list[JournalEntry]:
stmt = select(JournalEntry).where(JournalEntry.user_id == user_id)
if category_id is not None:
stmt = stmt.where(JournalEntry.category_id == category_id)
if start is not None:
stmt = stmt.where(JournalEntry.entry_date >= start)
if end is not None:
stmt = stmt.where(JournalEntry.entry_date <= end)
stmt = stmt.order_by(JournalEntry.entry_date.desc(), JournalEntry.created_at.desc())
return list(db.scalars(stmt))
def get_entry(db: Session, entry_id: int, user_id: int) -> JournalEntry | None:
return db.scalar(select(JournalEntry).where(JournalEntry.id == entry_id, JournalEntry.user_id == user_id))
def create_entry(db: Session, user_id: int, data: JournalEntryCreate) -> JournalEntry:
tags = get_or_create_tags(db, user_id, data.tags)
entry = JournalEntry(
user_id=user_id,
category_id=data.category_id,
entry_date=data.entry_date,
title=data.title,
content=data.content,
tags=tags,
moods=[JournalEntryMood(mood=m) for m in data.moods],
)
db.add(entry)
db.commit()
db.refresh(entry)
return entry
def update_entry(db: Session, entry: JournalEntry, data: JournalEntryUpdate) -> JournalEntry:
tags = get_or_create_tags(db, entry.user_id, data.tags)
entry.category_id = data.category_id
entry.entry_date = data.entry_date
entry.title = data.title
entry.content = data.content
entry.tags = tags
entry.moods = [JournalEntryMood(mood=m) for m in data.moods]
db.commit()
db.refresh(entry)
return entry
def delete_entry(db: Session, entry: JournalEntry) -> None:
for attachment in list(entry.attachments):
_delete_attachment_files(attachment)
db.delete(entry)
db.commit()
# ---- 첨부파일 ----
def _media_dir(user_id: int, entry_id: int) -> Path:
return Path(settings.journal_media_root) / str(user_id) / str(entry_id)
def _delete_attachment_files(attachment: JournalAttachment) -> None:
for path_str in (attachment.file_path, attachment.thumbnail_path):
if path_str:
Path(path_str).unlink(missing_ok=True)
def save_attachment(db: Session, entry: JournalEntry, upload_file: UploadFile) -> JournalAttachment:
content_type = upload_file.content_type or ""
if content_type in ALLOWED_IMAGE_TYPES:
media_type = JournalAttachmentType.IMAGE
elif content_type in ALLOWED_VIDEO_TYPES:
media_type = JournalAttachmentType.VIDEO
else:
raise ValueError("지원하지 않는 파일 형식이에요 (사진: jpg/png/webp/gif, 영상: mp4/mov)")
data = upload_file.file.read()
max_bytes = settings.journal_max_upload_mb * 1024 * 1024
if len(data) > max_bytes:
raise ValueError(f"파일 용량은 {settings.journal_max_upload_mb}MB를 넘을 수 없어요")
target_dir = _media_dir(entry.user_id, entry.id)
target_dir.mkdir(parents=True, exist_ok=True)
ext = mimetypes.guess_extension(content_type) or Path(upload_file.filename or "").suffix or ""
stored_name = f"{uuid.uuid4().hex}{ext}"
file_path = target_dir / stored_name
file_path.write_bytes(data)
thumbnail_path: Path | None = None
if media_type == JournalAttachmentType.IMAGE:
thumbnail_path = target_dir / f"{uuid.uuid4().hex}_thumb.jpg"
try:
with Image.open(file_path) as img:
img = img.convert("RGB")
w, h = img.size
if w > THUMBNAIL_WIDTH:
img = img.resize((THUMBNAIL_WIDTH, round(h * THUMBNAIL_WIDTH / w)))
img.save(thumbnail_path, "JPEG", quality=85)
except Exception:
# 손상되었거나 Pillow가 못 읽는 이미지여도 원본 업로드 자체는 실패시키지 않는다.
thumbnail_path.unlink(missing_ok=True)
thumbnail_path = None
attachment = JournalAttachment(
entry_id=entry.id,
media_type=media_type,
file_path=str(file_path),
thumbnail_path=str(thumbnail_path) if thumbnail_path else None,
original_filename=upload_file.filename or stored_name,
file_size=len(data),
)
db.add(attachment)
db.commit()
db.refresh(attachment)
return attachment
def get_attachment(db: Session, attachment_id: int, user_id: int) -> JournalAttachment | None:
return db.scalar(
select(JournalAttachment)
.join(JournalEntry, JournalAttachment.entry_id == JournalEntry.id)
.where(JournalAttachment.id == attachment_id, JournalEntry.user_id == user_id)
)
def delete_attachment(db: Session, attachment: JournalAttachment) -> None:
_delete_attachment_files(attachment)
db.delete(attachment)
db.commit()
# ---- 캘린더 / day-detail / 회상 ----
def _to_day_detail_item(entry: JournalEntry) -> JournalDayDetailItem:
return JournalDayDetailItem(
id=entry.id,
category_id=entry.category_id,
category_name=entry.category.name,
category_color=entry.category.color,
title=entry.title,
content=entry.content,
moods=[m.mood for m in entry.moods],
tags=[t.name for t in entry.tags],
attachments=[
JournalAttachmentOut(
id=a.id,
media_type=a.media_type,
original_filename=a.original_filename,
has_thumbnail=a.thumbnail_path is not None,
)
for a in entry.attachments
],
)
def get_monthly_journal_summary(
db: Session, user_id: int, year: int, month: int, category_id: int | None = None
) -> dict[date, JournalCalendarDay]:
"""해당 월의 날짜별 엔트리 개수와, 그날 등장한 카테고리 색상 목록(점 표시용)을 집계한다."""
days_in_month = calendar.monthrange(year, month)[1]
first_day = date(year, month, 1)
last_day = date(year, month, days_in_month)
stmt = (
select(JournalEntry.entry_date, JournalCategory.color)
.join(JournalCategory, JournalEntry.category_id == JournalCategory.id)
.where(JournalEntry.user_id == user_id, JournalEntry.entry_date.between(first_day, last_day))
)
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:
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)
return {
d: JournalCalendarDay(entry_date=d, total_count=counts[d], category_colors=colors_by_date[d])
for d in counts
}
def get_day_entries(
db: Session, user_id: int, target_date: date, category_id: int | None = None
) -> list[JournalDayDetailItem]:
stmt = select(JournalEntry).where(
JournalEntry.user_id == user_id, JournalEntry.entry_date == target_date
)
if category_id is not None:
stmt = stmt.where(JournalEntry.category_id == category_id)
stmt = stmt.order_by(JournalEntry.created_at)
return [_to_day_detail_item(e) for e in db.scalars(stmt)]
def get_on_this_day(db: Session, user_id: int, today: date) -> list[JournalOnThisDayItem]:
"""오늘과 월/일이 같은 과거 연도의 엔트리를 반환한다("1년 전 오늘" 회상 카드).
MONTH()/DAY() 같은 DB 종속 함수 대신 파이썬에서 필터링해 SQLite(테스트)/MariaDB(운영)
양쪽에서 동일하게 동작하게 한다 — 개인 규모 데이터라 전체 스캔 비용도 무시할 만하다.
"""
stmt = select(JournalEntry).where(JournalEntry.user_id == user_id).order_by(JournalEntry.entry_date.desc())
matches = [
e
for e in db.scalars(stmt)
if e.entry_date.month == today.month
and e.entry_date.day == today.day
and e.entry_date.year != today.year
]
items = []
for e in matches:
base = _to_day_detail_item(e)
items.append(
JournalOnThisDayItem(
**base.model_dump(),
entry_date=e.entry_date,
years_ago=today.year - e.entry_date.year,
)
)
return items
# ---- 프롬프트 ----
def get_random_prompt(db: Session) -> JournalPrompt | None:
prompts = list(db.scalars(select(JournalPrompt)))
return random.choice(prompts) if prompts else None
+166 -1
View File
@@ -325,7 +325,10 @@ p {
/* 입력 */
input[type="text"],
input[type="time"],
input[type="password"] {
input[type="date"],
input[type="password"],
select,
textarea {
width: 100%;
font-family: inherit;
font-size: 15px;
@@ -336,6 +339,33 @@ input[type="password"] {
color: var(--color-text);
}
textarea {
resize: vertical;
min-height: 90px;
line-height: 1.5;
}
select {
cursor: pointer;
}
input[type="file"] {
width: 100%;
font-family: inherit;
font-size: 14px;
color: var(--color-text-muted);
}
input[type="color"] {
width: 56px;
height: 40px;
padding: 2px;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-bg);
cursor: pointer;
}
label {
display: block;
font-size: 13px;
@@ -466,6 +496,32 @@ label {
color: #fff;
}
/* 저널 기분 선택 pill */
.mood-picker {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.mood-pill {
border-radius: 999px;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-text-muted);
font-size: 14px;
padding: 8px 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
}
.mood-pill.selected {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
}
.difficulty-hint {
font-size: 12px;
color: var(--color-text-muted);
@@ -810,6 +866,115 @@ label {
color: var(--color-border);
}
/* 저널링 */
.journal-day-dots {
display: flex;
gap: 2px;
}
.journal-day-dot {
width: 5px;
height: 5px;
border-radius: 50%;
}
.journal-prompt-card,
.on-this-day-card {
margin-top: var(--space-2);
padding: var(--space-2);
border-radius: var(--radius-control);
background: var(--color-success-tint);
font-size: 14px;
}
.journal-entry-item {
padding: 10px 0;
border-bottom: 1px solid var(--color-border);
}
.journal-entry-item:last-child {
border-bottom: none;
}
.journal-entry-title {
font-weight: 600;
margin-top: 6px;
}
.journal-entry-content {
white-space: pre-wrap;
margin-top: 4px;
}
.journal-entry-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.tag-chip {
font-size: 12px;
color: var(--color-text-muted);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 2px 8px;
}
.mood-icon {
font-size: 18px;
}
.attachment-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 8px;
margin-top: 8px;
}
.attachment-thumb {
position: relative;
aspect-ratio: 1;
border-radius: var(--radius-control);
overflow: hidden;
border: 1px solid var(--color-border);
}
.attachment-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.attachment-video-link {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 12px;
text-align: center;
color: var(--color-text-muted);
text-decoration: none;
padding: 4px;
}
.attachment-delete-btn {
position: absolute;
top: 2px;
right: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 14px;
line-height: 1;
cursor: pointer;
}
/* 랜딩 페이지 (/) */
.landing {
max-width: 480px;
+27
View File
@@ -0,0 +1,27 @@
(function () {
function initSortable() {
var list = document.getElementById("journal-category-list");
if (!list || typeof Sortable === "undefined") return;
Sortable.create(list, {
handle: ".drag-handle",
animation: 150,
forceFallback: true, // iOS Safari는 네이티브 HTML5 D&D 터치 지원이 불안정해서 자체 포인터 시뮬레이션을 강제한다
fallbackTolerance: 3,
onEnd: function () {
var ids = Array.from(list.children)
.map(function (el) { return el.getAttribute("data-category-id"); })
.filter(Boolean)
.map(Number);
fetch("/api/journal/categories/reorder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ category_ids: ids }),
});
},
});
}
document.addEventListener("DOMContentLoaded", initSortable);
})();
+2 -1
View File
@@ -1,9 +1,10 @@
const CACHE_NAME = "habit-tracker-v4";
const CACHE_NAME = "habit-tracker-v5";
const APP_SHELL = [
"/static/css/style.css",
"/static/js/app.js",
"/static/js/push-register.js",
"/static/js/habit-reorder.js",
"/static/js/journal-category-reorder.js",
"/static/js/vendor/htmx.min.js",
"/static/js/vendor/alpine.min.js",
"/static/js/vendor/sortable.min.js",
+6
View File
@@ -20,6 +20,7 @@
<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/alpine.min.js" defer></script>
<script src="/static/js/app.js" defer></script>
</head>
@@ -36,6 +37,7 @@
<a href="/today" class="{% block nav_today %}{% endblock %}">오늘</a>
<a href="/habits" class="{% block nav_habits %}{% endblock %}">습관 관리</a>
<a href="/history" class="{% block nav_history %}{% endblock %}">기록</a>
<a href="/journal" class="{% block nav_journal %}{% endblock %}">일기</a>
</div>
{% if current_user %}
<div class="nav-user">
@@ -60,6 +62,10 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2" /><line x1="3" y1="10" x2="21" y2="10" /><line x1="8" y1="3" x2="8" y2="7" /><line x1="16" y1="3" x2="16" y2="7" /></svg>
<span>기록</span>
</a>
<a href="/journal" class="tab-item {{ self.nav_journal() }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" /><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" /></svg>
<span>일기</span>
</a>
</nav>
{% endif %}
{% block content %}{% endblock %}
+230
View File
@@ -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 %}
@@ -0,0 +1,135 @@
{% set weekday_names = ["월", "화", "수", "목", "금", "토", "일"] %}
<div class="card day-detail-card">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-2);">
<h3 style="margin:0;">{{ entry_date.strftime("%Y.%m.%d") }} ({{ weekday_names[entry_date.weekday()] }})</h3>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('day-detail').innerHTML=''">닫기</button>
</div>
{% if items %}
<div class="day-detail-list">
{% for item in items %}
<div
class="journal-entry-item"
x-data="{
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) },
}"
>
<div x-show="!editing">
<div style="display:flex; align-items:center; justify-content:space-between;">
<span class="badge" style="border-color: {{ item.category_color or 'var(--color-accent)' }};">{{ item.category_name }}</span>
{% if item.moods %}
<span class="mood-icon">{% for m in item.moods %}{{ journal_mood_emoji[m.value] }}{% endfor %}</span>
{% endif %}
</div>
{% if item.title %}<div class="journal-entry-title">{{ item.title }}</div>{% endif %}
<div class="journal-entry-content">{{ item.content }}</div>
{% if item.tags %}
<div class="journal-entry-tags">
{% for tag in item.tags %}<span class="tag-chip">#{{ tag }}</span>{% endfor %}
</div>
{% endif %}
{% if item.attachments %}
<div class="attachment-grid">
{% for attachment in item.attachments %}
<div class="attachment-thumb">
{% if attachment.media_type.value == "image" %}
<a href="/api/journal/media/{{ attachment.id }}" target="_blank">
<img src="/api/journal/media/{{ attachment.id }}?thumbnail=true" alt="{{ attachment.original_filename }}" />
</a>
{% else %}
<a href="/api/journal/media/{{ attachment.id }}" target="_blank" class="attachment-video-link">▶ {{ attachment.original_filename }}</a>
{% endif %}
<button
type="button"
class="attachment-delete-btn"
hx-post="/journal/{{ item.id }}/attachments/{{ attachment.id }}/delete"
hx-target="#day-detail"
hx-swap="innerHTML"
hx-confirm="이 첨부파일을 삭제할까요?"
aria-label="첨부파일 삭제"
>&times;</button>
</div>
{% endfor %}
</div>
{% endif %}
<div class="habit-item-actions" style="margin-top: 8px;">
<button type="button" class="btn btn-secondary" @click="editing = true">수정</button>
<button
type="button"
class="btn btn-danger-ghost"
hx-post="/journal/{{ item.id }}/delete"
hx-target="#day-detail"
hx-swap="innerHTML"
hx-confirm="이 기록을 삭제할까요?"
>삭제</button>
</div>
</div>
<form
x-show="editing"
x-cloak
style="padding: 12px 4px; border-bottom: 1px solid var(--color-border);"
hx-post="/journal/{{ item.id }}/edit"
hx-target="#day-detail"
hx-swap="innerHTML"
hx-encoding="multipart/form-data"
>
<div class="field">
<label>카테고리</label>
<select name="category_id">
{% for category in categories %}
<option value="{{ category.id }}" {{ 'selected' if category.id == item.category_id }}>{{ category.name }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>날짜</label>
<input type="date" name="entry_date" value="{{ entry_date.isoformat() }}" required />
</div>
<div class="field">
<label>제목</label>
<input type="text" name="title" value="{{ item.title or '' }}" />
</div>
<div class="field">
<label>내용</label>
<textarea name="content" rows="4" required>{{ item.content }}</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>태그 (쉼표로 구분)</label>
<input type="text" name="tags" value="{{ item.tags|join(', ') }}" />
</div>
<div class="field">
<label>사진/영상 추가</label>
<input type="file" name="files" accept="image/*,video/*" multiple />
</div>
{% if edit_error and editing_entry_id == item.id %}
<div class="form-error-text">{{ edit_error }}</div>
{% endif %}
<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>
{% else %}
<div class="empty-state">이 날 작성한 기록이 없어요.</div>
{% endif %}
</div>