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:
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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",
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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="첨부파일 삭제"
|
||||
>×</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>
|
||||
Reference in New Issue
Block a user