diff --git a/.gitignore b/.gitignore
index ceb403a..fefc8a0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,7 @@ dist/
.env.*
!.env.example
!dpbr_front/app/.env.example
+!dpbr_front/app/.env.production
# Logs
logs
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..c92cfd1
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,90 @@
+# Design
+
+## Source of truth
+- Status: Active
+- Last refreshed: 2026-08-23
+- Primary product surfaces: 홈 피드, 멤버 쇼츠, 멤버 상세, 운영진 한마디, 결산별 댓글, 날짜순 재생, 이미지 저장, 쇼츠 BGM 재생·정보
+- Evidence reviewed: `README.md`, `prompt/dpbr_front/plan.md`, `dpbr_front/app/src/routes/+layout.svelte`, `dpbr_front/app/src/routes/+page.svelte`, `dpbr_front/app/src/routes/member/[id]/save/+page.svelte`, `dpbr_front/app/src/lib/components/Header.svelte`, `BottomNav.svelte`, `GuideRail.svelte`, `VideoCard.svelte`, `app.css`, 백엔드 캐릭터 컨트롤러·서비스·저장소와 결산 API 스키마
+
+## Brand
+- Personality: 동아리 구성원의 게임 활동을 가볍고 친근하게 돌아보는 커뮤니티
+- Trust signals: 실제 캐릭터 정보, 획득 일자, 운영진 전용 콘텐츠의 명확한 구분
+- Avoid: 같은 목적지의 중복 내비게이션, 핵심 콘텐츠를 가리는 장식, YouTube 명칭의 불필요한 직역
+
+## Product goals
+- Goals: 이름·닉네임·결산 내용 통합 검색, 멤버별 활동 결산 탐색, 운영진 공지 확인, 날짜순·랜덤 결산 감상, 각 결산에 귀속된 댓글 참여, 쇼츠 BGM의 직접 재생 제어와 곡 정보 확인, 멤버의 14기 활동을 한 장의 캐릭터 카드로 저장·공유
+- Non-goals: 범용 동영상 플랫폼, 전역 톡 또는 실시간 채팅 서비스 구현
+- Success signals: 모바일·데스크톱 상단에서 이름·닉네임·쇼츠 제목/설명으로 멤버를 찾을 수 있고, 모바일 날짜순 재생으로 오래된 결산부터 감상하며 각 쇼츠에서 해당 결산의 댓글만 조회·작성·삭제할 수 있음
+
+## Personas and jobs
+- Primary personas: COMMUNITY_PROJECT 동아리원과 활동 기록을 둘러보는 방문자
+- User jobs: 이름·닉네임·기억나는 결산 내용으로 구성원 기록 검색, 운영진 메시지 확인, 날짜순 또는 랜덤으로 결산 감상, 현재 결산에 댓글 작성·조회
+- Key contexts of use: 세로형 모바일 화면과 데스크톱 브라우저
+
+## Information architecture
+- Primary navigation: 모바일 하단의 홈, 날짜순 재생, 운영진 한마디, 내 페이지 4개 항목과 데스크톱 가이드 레일
+- Core routes/screens: `/`, `/shorts/[id]`, `/member/[id]`, `/member/admin-team`, `/member/[id]/save`
+- Content hierarchy: 홈 상단은 브랜드와 검색 진입 아이콘만 노출한다. 운영진 한마디는 모바일 하단과 데스크톱 가이드 레일에서 진입한다. 전역 톡 화면은 두지 않고 현재 쇼츠의 우측 액션 레일에서 결산별 댓글 시트를 연다. 영상 썸네일에는 서버·레벨 대신 `결산 N개`만 표시한다.
+
+## Design principles
+- 한 기능은 한 위치에서 명확하게 찾을 수 있도록 중복 진입점을 줄인다.
+- 운영진 한마디는 모바일 하단과 데스크톱 가이드 레일에서 찾을 수 있게 하고, 모바일 상단에는 중복 진입점을 두지 않는다.
+- 검색은 제출된 하나의 질의를 이름·닉네임·쇼츠 제목/설명에 동일하게 적용하며, URL의 `q` 파라미터로 공유·복원이 가능하게 한다.
+- 모바일은 YouTube의 축약형 헤더 패턴처럼 기본 상태에 돋보기만 두고, 클릭할 때 `뒤로가기 + 검색 입력 + 검색 버튼`으로 전환한다.
+- 영상 카드의 오버레이 정보는 콘텐츠 양처럼 탐색 결정에 직접 필요한 정보만 남긴다.
+- 모바일 하단은 빈 슬롯 없는 균등한 4버튼 구조로 구성한다. 네 탭은 동일한 64px 높이, 24px 아이콘, 라벨 기준선을 사용하며 특정 탭만 원형 배경이나 돌출 형태로 강조하지 않는다. 모바일 `날짜순 재생`은 가장 오래된 결산부터 시작하고, 데스크톱 `자유 재생`은 전체 결산 중 하나를 무작위로 골라 해당 쇼츠 딥링크를 연다.
+- 현재 쇼츠의 재생·일시정지는 영상 위의 명시적인 원형 버튼과 미디어 영역 탭으로 동일하게 제어하며, 음소거 상태와 독립적으로 동작한다. 사용자가 직접 멈춘 음원은 댓글 창이나 탭 전환 뒤에 임의로 다시 재생하지 않는다.
+- 현재 BGM은 하단 정보 스택의 `음표 + 곡명 · 아티스트` 필로 노출하고, 선택하면 전체 곡명·재생 구간·검증된 원본 주소를 확인한다. 파일명이나 YouTube ID를 사용자용 곡명으로 대신 표시하지 않는다.
+- 댓글은 전역 대화방이 아니라 현재 Settlement에 귀속한다. 쇼츠를 벗어나지 않고 YouTube 댓글 문법의 하단 시트로 조회·작성·삭제한다.
+- 캐릭터 저장 카드는 YouTube 채널의 `16:9 배너 + 원형 프로필 + 채널명/핸들 + 콘텐츠 통계` 문법을 9:16 공유 이미지 안에 재구성한다.
+- 캐릭터 원본 이미지를 세로 배경으로 과도하게 확대·크롭하지 않고, 대표 결산은 16:9 프레임에 `object-fit: contain`으로 온전히 보여준다.
+- Tradeoffs: 모바일 상단을 검색 중심으로 단순화하는 대신 운영진 콘텐츠는 하단 내비게이션에서 지속적으로 접근할 수 있게 한다.
+
+## Visual language
+- Color: `app.css`의 `--yt-*` 시맨틱 토큰을 사용하며 라이트·다크 테마를 함께 지원한다. 저장 카드는 테마와 무관하게 YouTube 다크 표면(`#0f0f0f`, `#212121`)과 단풍바람 레드 포인트를 사용한다.
+- Typography: GAME_API_PROVIDER Lv1 Gothic과 시스템 폰트 폴백
+- Spacing/layout rhythm: 모바일 하단 내비게이션과 데스크톱 가이드 레일의 기존 간격 유지
+- Shape/radius/elevation: 기존 원형 액션과 둥근 표면 토큰 재사용
+- Motion: 기존 hover, active, snap 동작 유지; 불필요한 전환 추가 금지
+- Imagery/iconography: Lucide 아이콘과 프로젝트 로고 자산 재사용. 빨간 파비콘 안의 단풍바람 심볼은 캔버스 가장자리와 충분한 여백을 두어 작은 브라우저 탭에서도 답답해 보이지 않게 한다. 탐색 헤더는 YouTube 워드마크 문법처럼 `빨간 단풍바람 심볼 + 굵은 단풍바람 텍스트`를 한 덩어리로 표시하며, 쇼츠 위에서는 텍스트를 흰색으로 전환한다.
+
+## Components
+- Existing components to reuse: `BrandWordmark`, `Header`, `BottomNav`, `GuideRail`, `VideoCard`, `ShortsThumbnail`, `CommentItem`, `BottomSheetLogin`
+- New/changed components: `BottomNav` 4버튼 구조와 날짜순 재생 액션, `Header`의 검색 중심 모바일 상단, `GuideRail` 자유 재생 행, 쇼츠 우측 댓글 액션과 `SettlementCommentsSheet`, 쇼츠 재생·일시정지 컨트롤과 `AudioInfoSheet`, `VideoCard`의 결산 개수 배지, 9:16 `단풍바람 14기 캐릭터 카드`
+- Variants and states: 모바일 검색 닫힘/열림, 활성 경로, 날짜순·자유 재생 조회 중, 댓글 시트 닫힘/열림, BGM 재생/일시정지/종료/자동재생 차단/오류, BGM 정보 시트 닫힘/열림, 댓글 로딩·비어 있음·오류·작성 중, 검색 중 로딩, 검색 결과 없음, API 오류 토스트
+- Token/component ownership: 색상은 `app.css`, 내비게이션 동작은 `src/lib/utils/nav.ts`
+
+## Accessibility
+- Target standard: 기존 의미론을 유지하며 WCAG 2.1 AA를 지향
+- Keyboard/focus behavior: 검색 폼은 Enter 제출을 지원하고, 링크와 버튼의 네이티브 키보드 동작 및 명확한 `aria-label` 유지
+- Contrast/readability: `--yt-text`, `--yt-text-muted`, `--yt-border` 토큰 사용
+- Screen-reader semantics: 운영진 한마디, 날짜순 재생, 현재 결산 댓글, BGM 재생 상태, BGM 정보와 `이름, 닉네임, 내용 검색`의 목적을 한국어 접근성 이름으로 제공
+- Reduced motion and sensory considerations: 새 자동 애니메이션을 추가하지 않음
+
+## Responsive behavior
+- Supported breakpoints/devices: 최소 320px 모바일부터 `lg` 이상 데스크톱
+- Layout adaptations: 모바일은 축약형 상단 검색과 하단 내비게이션, 데스크톱은 항상 보이는 상단 검색과 접이식 좌측 가이드 레일. 캐릭터 카드는 화면에서는 최대 360px로 축소하되 저장 결과는 720×1280 PNG를 기준으로 한다.
+- Touch/hover differences: 재생·음소거 버튼은 최소 44px 터치 영역을 보장하고 데스크톱에만 hover 피드백을 추가한다.
+
+## Interaction states
+- Loading: 검색 질의가 바뀌면 목록 영역에서 기존 로딩 안내 제공
+- Empty: 통합 검색 결과가 없으면 `검색 결과가 없습니다.`를 제공
+- Error: 쇼츠 조회 실패 토스트 및 콘솔 진단 정보 유지
+- Success: 날짜순 재생은 가장 오래된 Settlement부터 시작하고 회원 피드 종료 뒤에는 랜덤 피드를 이어 붙인다. 재생 버튼은 일시정지 위치에서 이어 재생하며, 음원 구간이 끝나면 0.5초 뒤 다음 결산으로 자동 이동한다. 사용자가 직접 일시정지한 경우에는 자동 이동하지 않는다. BGM 정보 버튼은 현재 Settlement의 메타데이터와 재생 구간을 표시한다. 댓글 작성 즉시 현재 결산 댓글 목록 맨 위에 표시하고, 캐릭터 카드는 저장 완료 토스트와 함께 PNG를 내려받는다.
+- Disabled: 날짜순·자유 재생 조회, 댓글 제출, 이미지 생성 중 중복 실행을 차단하고 시각적 진행 상태를 제공한다.
+- Offline/slow network, if applicable: 현재 화면을 유지하며 실패 토스트 또는 쇼츠 내부 음원 오류 상태를 표시한다.
+
+## Content voice
+- Tone: 짧고 친근한 한국어
+- Terminology: 사용자 노출 명칭은 `운영진 한마디`, 모바일 `날짜순 재생`, 데스크톱 `자유 재생`, `댓글`, `결산 N개`로 통일하고 `톡`은 사용하지 않는다.
+- Microcopy rules: 동작과 목적지가 한 번에 이해되는 명사형 라벨을 사용하고 검색 입력에는 `이름, 닉네임, 내용 검색`을 표시한다. 저장 결과의 명칭은 `단풍바람 14기 캐릭터 카드`, 동아리 설명은 `가천대 메이플스토리 동아리`로 통일한다.
+
+## Implementation constraints
+- Framework/styling system: SvelteKit 2, Svelte 5, TypeScript, Tailwind CSS 4
+- Design-token constraints: 기존 `--yt-*` 토큰과 컴포넌트 유틸리티 클래스를 우선 사용
+- Performance constraints: 카드별 결산 개수는 N+1 요청 없이 캐릭터 조회에 포함하며, 검색은 페이지네이션 쿼리에서 결산 콘텐츠 존재 조건으로 처리해 클라이언트 전체 로드를 피함
+- Compatibility constraints: 정적 어댑터와 클라이언트 내비게이션을 유지하고, BGM 곡명·아티스트·원본 주소는 백엔드 Settlement API의 nullable 메타데이터 필드에서 받는다.
+- Test/screenshot expectations: `npm run check`, `npm run build`, 모바일·데스크톱 브라우저 스모크 확인
+
+## Open questions
+- 현재 없음
diff --git a/dpbr_front/Dockerfile b/dpbr_front/Dockerfile
index 03da653..0a37442 100644
--- a/dpbr_front/Dockerfile
+++ b/dpbr_front/Dockerfile
@@ -5,6 +5,16 @@ FROM node:20-alpine AS builder
WORKDIR /app
+ARG PUBLIC_API_URL=/api
+ARG PUBLIC_API_PREFIX=/v1
+ARG PUBLIC_KAKAO_CLIENT_ID=
+ARG PUBLIC_KAKAO_REDIRECT_URI=
+
+ENV PUBLIC_API_URL=$PUBLIC_API_URL \
+ PUBLIC_API_PREFIX=$PUBLIC_API_PREFIX \
+ PUBLIC_KAKAO_CLIENT_ID=$PUBLIC_KAKAO_CLIENT_ID \
+ PUBLIC_KAKAO_REDIRECT_URI=$PUBLIC_KAKAO_REDIRECT_URI
+
# 의존성 파일 복사 및 설치
COPY app/package*.json ./
RUN npm ci
diff --git a/dpbr_front/app/.env.example b/dpbr_front/app/.env.example
index 0da7513..244b36f 100644
--- a/dpbr_front/app/.env.example
+++ b/dpbr_front/app/.env.example
@@ -1,6 +1,6 @@
# API 백엔드 URL
# 개발 환경: http://localhost:8000
-# 프록시 prefix 환경 예시: /msgs14_dev/api
+# 프록시 prefix 환경: /msgs13_dev/api
PUBLIC_API_URL=http://localhost:8000
# API 라우터 prefix (백엔드 API_V1_PREFIX와 동일하게 맞춤)
PUBLIC_API_PREFIX=/api/v1
diff --git a/dpbr_front/app/.gitignore b/dpbr_front/app/.gitignore
index 4c49bd7..785c362 100644
--- a/dpbr_front/app/.gitignore
+++ b/dpbr_front/app/.gitignore
@@ -1 +1,8 @@
.env
+.env.*
+!.env.example
+node_modules/
+.svelte-kit/
+build/
+dist/
+.DS_Store
diff --git a/dpbr_front/app/package-lock.json b/dpbr_front/app/package-lock.json
index 95f20d9..02a9e2d 100644
--- a/dpbr_front/app/package-lock.json
+++ b/dpbr_front/app/package-lock.json
@@ -8,8 +8,7 @@
"name": "dpbr-front",
"version": "0.0.1",
"dependencies": {
- "html-to-image": "^1.11.11",
- "html2canvas": "^1.4.1"
+ "html-to-image": "^1.11.11"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.0",
@@ -1308,15 +1307,6 @@
"node": ">= 0.4"
}
},
- "node_modules/base64-arraybuffer": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
- "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6.0"
- }
- },
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -1353,15 +1343,6 @@
"node": ">= 0.6"
}
},
- "node_modules/css-line-break": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
- "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
- "license": "MIT",
- "dependencies": {
- "utrie": "^1.0.2"
- }
- },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1526,19 +1507,6 @@
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
- "node_modules/html2canvas": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
- "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
- "license": "MIT",
- "dependencies": {
- "css-line-break": "^2.1.0",
- "text-segmentation": "^1.0.3"
- },
- "engines": {
- "node": ">=8.0.0"
- }
- },
"node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@@ -2129,15 +2097,6 @@
"url": "https://opencollective.com/webpack"
}
},
- "node_modules/text-segmentation": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
- "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
- "license": "MIT",
- "dependencies": {
- "utrie": "^1.0.2"
- }
- },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -2179,15 +2138,6 @@
"node": ">=14.17"
}
},
- "node_modules/utrie": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
- "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
- "license": "MIT",
- "dependencies": {
- "base64-arraybuffer": "^1.0.2"
- }
- },
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
diff --git a/dpbr_front/app/package.json b/dpbr_front/app/package.json
index ba32ee8..bb11d29 100644
--- a/dpbr_front/app/package.json
+++ b/dpbr_front/app/package.json
@@ -21,8 +21,7 @@
"vite": "^6.0.0"
},
"dependencies": {
- "html-to-image": "^1.11.11",
- "html2canvas": "^1.4.1"
+ "html-to-image": "^1.11.11"
},
"type": "module"
-}
\ No newline at end of file
+}
diff --git a/dpbr_front/app/src/app.css b/dpbr_front/app/src/app.css
index cdeea09..4d3cd0b 100644
--- a/dpbr_front/app/src/app.css
+++ b/dpbr_front/app/src/app.css
@@ -1,8 +1,6 @@
@import "tailwindcss";
-:root {
- color-scheme: light;
-}
+@custom-variant dark (&:where(.dark, .dark *));
@font-face {
font-family: 'GAME_API_PROVIDERLv1Gothic';
@@ -25,20 +23,62 @@
font-display: swap;
}
-@theme {
- --color-primary: #fac486;
- --color-primary-dark: #ff8e42;
- --color-secondary: #FFE0B2;
- --color-bg-app: #FFF3E0;
- --color-bg-card: #ffffff;
- --color-bg-light: #f7f9fa;
- --color-border: #edf1f5;
- --color-border-dark: #e1e8ee;
- --color-text-primary: #292e33;
- --color-text-secondary: #6c7680;
- --color-text-muted: #98a5b3;
- --color-text-accent: #525c66;
- --color-bg-overlay: #101010;
+/* 유튜브 스타일 시맨틱 토큰 — 라이트 */
+:root {
+ color-scheme: light;
+ --yt-bg: #ffffff;
+ --yt-surface: #f2f2f2;
+ --yt-surface-hover: #e5e5e5;
+ --yt-text: #0f0f0f;
+ --yt-text-muted: #606060;
+ --yt-border: #e5e5e5;
+ --yt-accent: #ff0000;
+ --yt-chip: #f2f2f2;
+ --yt-chip-active: #0f0f0f;
+ --yt-chip-active-text: #ffffff;
+}
+
+/* 유튜브 스타일 시맨틱 토큰 — 다크 */
+.dark {
+ color-scheme: dark;
+ --yt-bg: #0f0f0f;
+ --yt-surface: #212121;
+ --yt-surface-hover: #3d3d3d;
+ --yt-text: #f1f1f1;
+ --yt-text-muted: #aaaaaa;
+ --yt-border: #303030;
+ --yt-accent: #ff0000;
+ --yt-chip: #272727;
+ --yt-chip-active: #f1f1f1;
+ --yt-chip-active-text: #0f0f0f;
+}
+
+@theme inline {
+ --color-yt-bg: var(--yt-bg);
+ --color-yt-surface: var(--yt-surface);
+ --color-yt-surface-hover: var(--yt-surface-hover);
+ --color-yt-text: var(--yt-text);
+ --color-yt-text-muted: var(--yt-text-muted);
+ --color-yt-border: var(--yt-border);
+ --color-yt-accent: var(--yt-accent);
+ --color-yt-chip: var(--yt-chip);
+ --color-yt-chip-active: var(--yt-chip-active);
+ --color-yt-chip-active-text: var(--yt-chip-active-text);
+
+ /* 기존 토큰 재매핑 (미수정 화면 안전망) */
+ --color-primary: var(--yt-chip);
+ --color-primary-dark: var(--yt-accent);
+ --color-secondary: var(--yt-surface);
+ --color-bg-app: var(--yt-bg);
+ --color-bg-card: var(--yt-bg);
+ --color-bg-light: var(--yt-surface);
+ --color-border: var(--yt-border);
+ --color-border-dark: var(--yt-border);
+ --color-text-primary: var(--yt-text);
+ --color-text-secondary: var(--yt-text-muted);
+ --color-text-muted: var(--yt-text-muted);
+ --color-text-accent: var(--yt-text-muted);
+ --color-bg-overlay: #0f0f0f;
}
html,
@@ -46,8 +86,8 @@ body {
font-family: 'GAME_API_PROVIDERLv1Gothic', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
- background-color: white;
- color: var(--color-text-primary);
+ background-color: var(--yt-bg);
+ color: var(--yt-text);
}
/* 상호작용 가능한 요소들에 손가락 커서 강제 적용 */
@@ -64,4 +104,14 @@ input[type="button"]:disabled,
input[type="submit"]:disabled,
input[type="reset"]:disabled {
cursor: not-allowed;
-}
\ No newline at end of file
+}
+
+/* 쇼츠 뷰어 등 가로 snap 컨테이너의 스크롤바 숨김 */
+.no-scrollbar {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+
+.no-scrollbar::-webkit-scrollbar {
+ display: none;
+}
diff --git a/dpbr_front/app/src/app.html b/dpbr_front/app/src/app.html
index 959e96f..de5c551 100644
--- a/dpbr_front/app/src/app.html
+++ b/dpbr_front/app/src/app.html
@@ -1,27 +1,48 @@
-
+
-
+
+
+
- COMMUNITY_PROJECT
+ 단풍바람 14기
-
+
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
%sveltekit.head%
diff --git a/dpbr_front/app/src/lib/api.ts b/dpbr_front/app/src/lib/api.ts
index 30c0bf7..47978fa 100644
--- a/dpbr_front/app/src/lib/api.ts
+++ b/dpbr_front/app/src/lib/api.ts
@@ -1,5 +1,6 @@
import { env } from '$env/dynamic/public';
-import type { Character, SettlementItem, TalkComment, TeamMessageItem } from './types';
+import type { Character, SettlementComment, SettlementItem, TeamMessageItem } from './types';
+import { DEFAULT_AVATAR_URL } from './utils/image';
/**
* API 기본 URL
@@ -32,10 +33,20 @@ function buildApiUrl(endpoint: string): string {
}
function normalizeAssetUrl(url: string | null | undefined): string {
- if (!url) return '/default-avatar.png';
+ return normalizeOptionalAssetUrl(url) ?? DEFAULT_AVATAR_URL;
+}
+
+function normalizeOptionalAssetUrl(url: string | null | undefined): string | null {
+ if (!url) return null;
if (/^https?:\/\//i.test(url)) return url;
- if (url.startsWith('/')) return `${getApiBaseUrl()}${url}`;
- return `${getApiBaseUrl()}/${url}`;
+
+ const normalizedPath = url.startsWith('/') ? url : `/${url}`;
+ const apiBaseUrl = getApiBaseUrl();
+ if (/^https?:\/\//i.test(apiBaseUrl)) {
+ return `${new URL(apiBaseUrl).origin}${normalizedPath}`;
+ }
+
+ return normalizedPath;
}
function normalizeApiErrorDetail(detail: unknown): string {
@@ -107,11 +118,14 @@ function getAccessToken(): string | null {
interface CharacterResponse {
id: number;
name: string;
+ owner_name: string;
detail_txt: string | null;
level: number;
+ settlement_count: number;
job: string;
server: string;
avatar_url: string | null;
+ preview_image_url: string | null;
}
interface SettlementResponse {
@@ -120,6 +134,13 @@ interface SettlementResponse {
title: string;
description: string | null;
img_url: string | null;
+ audio_url?: string | null;
+ audio_title?: string | null;
+ audio_artist?: string | null;
+ audio_source_url?: string | null;
+ audio_start_seconds?: number;
+ audio_duration_seconds?: number | null;
+ audio_fade_out_seconds?: number | null;
acquired_at: string;
}
@@ -140,6 +161,7 @@ interface SettlementsPaginationResponse {
interface CommentResponse {
id: number;
user_id: number | null;
+ settlement_id: number | null;
author: string;
content: string;
created_at: string;
@@ -148,6 +170,7 @@ interface CommentResponse {
interface TeamMemberResponse {
id: number;
name: string;
+ nickname: string;
role: string;
profile_img_url: string | null;
message: {
@@ -165,6 +188,16 @@ interface TeamMemberDetailResponse extends TeamMemberResponse {
} | null;
}
+class ApiError extends Error {
+ constructor(
+ readonly status: number,
+ message: string
+ ) {
+ super(message);
+ this.name = 'ApiError';
+ }
+}
+
/**
* API 호출 유틸리티
*/
@@ -173,6 +206,7 @@ async function apiCall(endpoint: string, options?: RequestInit): Promise {
try {
const response = await fetch(url, {
+ cache: 'no-store',
...options,
headers: {
'Content-Type': 'application/json',
@@ -196,7 +230,8 @@ async function apiCall(endpoint: string, options?: RequestInit): Promise {
}
}
- throw new Error(
+ throw new ApiError(
+ response.status,
exposeDetail && detail
? `API Error: ${response.status} ${detail}`
: `API Error: ${response.status} ${response.statusText}`
@@ -230,10 +265,20 @@ export async function getCharacters(): Promise {
export async function getCharactersPaginated(
page: number = 1,
- limit: number = 10
+ limit: number = 10,
+ query: string = ''
): Promise<{ items: Character[]; total: number; page: number; limit: number }> {
+ const params = new URLSearchParams({
+ page: page.toString(),
+ limit: limit.toString()
+ });
+ const normalizedQuery = query.trim();
+ if (normalizedQuery) {
+ params.set('q', normalizedQuery);
+ }
+
const data = await apiCall(
- `/characters/pagination?page=${page}&limit=${limit}`
+ `/characters/pagination?${params.toString()}`
);
return {
@@ -247,10 +292,12 @@ export async function getCharactersPaginated(
function mapCharacterResponse(char: CharacterResponse): Character {
return {
id: char.id.toString(),
- name: char.name,
+ name: char.owner_name || char.name,
nickname: char.detail_txt || char.name,
avatarUrl: normalizeAssetUrl(char.avatar_url),
+ previewImageUrl: normalizeOptionalAssetUrl(char.preview_image_url),
level: char.level,
+ settlementCount: char.settlement_count ?? 0,
job: char.job,
club: 'COMMUNITY_PROJECT',
server: char.server
@@ -263,7 +310,14 @@ function mapSettlementResponse(settlement: SettlementResponse): SettlementItem {
characterId: settlement.character_id.toString(),
title: settlement.title,
description: settlement.description || '',
- imageUrl: settlement.img_url ? `${getApiBaseUrl()}${settlement.img_url}` : '/default-avatar.png',
+ imageUrl: normalizeAssetUrl(settlement.img_url),
+ audioUrl: normalizeOptionalAssetUrl(settlement.audio_url),
+ audioTitle: settlement.audio_title ?? null,
+ audioArtist: settlement.audio_artist ?? null,
+ audioSourceUrl: settlement.audio_source_url ?? null,
+ audioStartSeconds: settlement.audio_start_seconds ?? 0,
+ audioDurationSeconds: settlement.audio_duration_seconds ?? null,
+ audioFadeOutSeconds: settlement.audio_fade_out_seconds ?? null,
acquiredAt: settlement.acquired_at
};
}
@@ -277,22 +331,23 @@ export async function getCharacterById(id: string): Promise {
return {
id: data.id.toString(),
- name: data.name,
+ name: data.owner_name || data.name,
nickname: data.detail_txt || data.name,
avatarUrl: normalizeAssetUrl(data.avatar_url),
level: data.level,
+ settlementCount: data.settlement_count ?? 0,
job: data.job,
club: 'COMMUNITY_PROJECT',
server: data.server
};
} catch (error) {
- console.error('Failed to fetch character:', error);
- return null;
+ if (error instanceof ApiError && error.status === 404) return null;
+ throw error;
}
}
/**
- * 특정 캐릭터의 ACTIVITY_RECAP 목록 조회
+ * 특정 캐릭터의 결산 목록 조회
*/
export async function getSettlementsByCharacterId(characterId: string): Promise {
const data = await apiCall(
@@ -302,6 +357,22 @@ export async function getSettlementsByCharacterId(characterId: string): Promise<
return data.map(mapSettlementResponse);
}
+export async function getSettlementsPaginated(
+ page: number = 1,
+ limit: number = 10
+): Promise<{ items: SettlementItem[]; total: number; page: number; limit: number }> {
+ const data = await apiCall(
+ `/settlements/pagination?page=${page}&limit=${limit}`
+ );
+
+ return {
+ items: data.items.map(mapSettlementResponse),
+ total: data.total,
+ page: data.page,
+ limit: data.limit
+ };
+}
+
export async function getSettlementsByCharacterIdPaginated(
characterId: string,
page: number = 1,
@@ -320,54 +391,68 @@ export async function getSettlementsByCharacterIdPaginated(
}
/**
- * 특정 ACTIVITY_RECAP 상세 정보 조회
+ * 특정 결산 상세 정보 조회
*/
export async function getSettlementById(id: string): Promise {
try {
const data = await apiCall(`/settlements/${id}`);
- return {
- id: data.id.toString(),
- characterId: data.character_id.toString(),
- title: data.title,
- description: data.description || '',
- imageUrl: data.img_url ? `${getApiBaseUrl()}${data.img_url}` : '/default-avatar.png',
- acquiredAt: data.acquired_at
- };
+ return mapSettlementResponse(data);
} catch (error) {
- console.error('Failed to fetch settlement:', error);
- return null;
+ if (error instanceof ApiError && error.status === 404) return null;
+ throw error;
+ }
+}
+
+/**
+ * 전체 결산 중 하나를 무작위로 조회
+ */
+export async function getRandomSettlement(): Promise {
+ try {
+ const data = await apiCall('/settlements/random');
+ return mapSettlementResponse(data);
+ } catch (error) {
+ if (error instanceof ApiError && error.status === 404) return null;
+ throw error;
}
}
/**
- * 댓글 목록 조회
+ * 특정 결산의 댓글 목록 조회
*/
-export async function getComments(page: number = 1, limit: number = 20): Promise {
+export async function getSettlementComments(
+ settlementId: string,
+ page: number = 1,
+ limit: number = 20
+): Promise {
const data = await apiCall(
- `/comments?page=${page}&limit=${limit}`
+ `/settlements/${settlementId}/comments?page=${page}&limit=${limit}`
);
return data.map((comment) => ({
id: comment.id.toString(),
+ settlementId: (comment.settlement_id ?? settlementId).toString(),
userId: comment.user_id,
author: comment.author,
- authorAvatar: '/default-avatar.png',
+ authorAvatar: DEFAULT_AVATAR_URL,
content: comment.content,
createdAt: formatCommentDateTime(comment.created_at)
}));
}
/**
- * 댓글 작성
+ * 특정 결산에 댓글 작성
*/
-export async function createComment(content: string): Promise {
+export async function createSettlementComment(
+ settlementId: string,
+ content: string
+): Promise {
const accessToken = getAccessToken();
if (!accessToken) {
throw new Error('로그인이 필요합니다.');
}
- return await apiCall('/comments', {
+ return await apiCall(`/settlements/${settlementId}/comments`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`
@@ -377,7 +462,7 @@ export async function createComment(content: string): Promise {
}
/**
- * 댓글 삭제 (스켈레톤 함수 - 프론트엔드 작업용)
+ * 내 댓글 삭제
*/
export async function deleteComment(id: string): Promise {
const accessToken = getAccessToken();
@@ -399,6 +484,7 @@ export async function getTeamMembers(): Promise {
return data.map((member) => ({
id: member.id.toString(),
name: member.name,
+ nickname: member.nickname || member.name,
role: member.role,
title: member.message?.title || '',
content: member.message?.content || '',
@@ -413,14 +499,15 @@ export async function getTeamMessageDetail(memberId: string): Promise {
- try {
- return await apiCall<{ id: number | null; name?: string }>('/system/admin-character');
- } catch (error) {
- console.error('Failed to fetch admin character:', error);
- return { id: null };
- }
+ return apiCall<{ id: number | null; name?: string }>('/system/admin-character');
}
/**
diff --git a/dpbr_front/app/src/lib/components/AudioInfoSheet.svelte b/dpbr_front/app/src/lib/components/AudioInfoSheet.svelte
new file mode 100644
index 0000000..d2d596f
--- /dev/null
+++ b/dpbr_front/app/src/lib/components/AudioInfoSheet.svelte
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+
+
event.stopPropagation()}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="audio-info-title"
+ tabindex="-1"
+ >
+
+
+
+
+
+
+
+
현재 재생 중인 BGM
+
+ {settlement.audioTitle || "등록된 곡명 없음"}
+
+
+
+
+
+
+
+
+
+ {#if settlement.audioArtist}
+
+
아티스트
+
{settlement.audioArtist}
+
+ {/if}
+
+
쇼츠 재생 구간
+
{playbackRange}
+
+ {#if settlement.audioSourceUrl}
+
+ 원본 음원 보기
+
+
+ {:else}
+
+ 원본 음원 주소가 아직 등록되지 않았습니다.
+
+ {/if}
+
+
+
diff --git a/dpbr_front/app/src/lib/components/BottomNav.svelte b/dpbr_front/app/src/lib/components/BottomNav.svelte
new file mode 100644
index 0000000..6f37909
--- /dev/null
+++ b/dpbr_front/app/src/lib/components/BottomNav.svelte
@@ -0,0 +1,65 @@
+
+
+
+
+
+ 홈
+
+
+ void openChronologicalSettlements()}
+ aria-label="날짜순 재생: 오래된 결산부터 보기"
+ class={`${itemClass} text-yt-text-muted`}
+ >
+
+ 날짜순 재생
+
+
+
+
+ 운영진 한마디
+
+
+
+
+ 내 페이지
+
+
diff --git a/dpbr_front/app/src/lib/components/BottomSheetLogin.svelte b/dpbr_front/app/src/lib/components/BottomSheetLogin.svelte
index 0ddfaea..ad4c3d2 100644
--- a/dpbr_front/app/src/lib/components/BottomSheetLogin.svelte
+++ b/dpbr_front/app/src/lib/components/BottomSheetLogin.svelte
@@ -1,7 +1,7 @@
+
+
+
+
+
diff --git a/dpbr_front/app/src/lib/components/Button.svelte b/dpbr_front/app/src/lib/components/Button.svelte
index 84d72b5..01f679b 100644
--- a/dpbr_front/app/src/lib/components/Button.svelte
+++ b/dpbr_front/app/src/lib/components/Button.svelte
@@ -42,7 +42,9 @@
onmouseleave={handleMouseUp}
disabled={buttonState === "disabled"}
class="w-full py-3 px-4 rounded-lg font-medium text-base relative
- {variant === 'primary' ? 'bg-white text-text-primary' : 'bg-primary text-white'}
+ {variant === 'primary'
+ ? 'bg-yt-chip-active text-yt-chip-active-text'
+ : 'bg-yt-chip text-yt-text'}
{buttonState === 'disabled'
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer'} {className}"
diff --git a/dpbr_front/app/src/lib/components/CharacterCard.svelte b/dpbr_front/app/src/lib/components/CharacterCard.svelte
deleted file mode 100644
index 6240839..0000000
--- a/dpbr_front/app/src/lib/components/CharacterCard.svelte
+++ /dev/null
@@ -1,85 +0,0 @@
-
-
-
-
-
- {#if logoPath && !isWorldLogoLoadFailed}
-
{
- isWorldLogoLoadFailed = true;
- }}
- />
- {:else}
-
{character.server}
- {/if}
-
-
-
-
-
-
-
-
-
- {character.name}
- {character.nickname}
-
-
diff --git a/dpbr_front/app/src/lib/components/CommentItem.svelte b/dpbr_front/app/src/lib/components/CommentItem.svelte
index c72d0a7..33a4ea0 100644
--- a/dpbr_front/app/src/lib/components/CommentItem.svelte
+++ b/dpbr_front/app/src/lib/components/CommentItem.svelte
@@ -1,12 +1,12 @@
- {comment.author}
@@ -55,16 +55,16 @@
삭제
{/if}
-
-
{comment.content}
-
+ {comment.content}
+ {comment.createdAt}
@@ -72,7 +72,7 @@
{#if showDeletePopup}
{title}
@@ -63,14 +63,14 @@
{cancelText}
{confirmText}
diff --git a/dpbr_front/app/src/lib/components/GuideRail.svelte b/dpbr_front/app/src/lib/components/GuideRail.svelte
new file mode 100644
index 0000000..e62880c
--- /dev/null
+++ b/dpbr_front/app/src/lib/components/GuideRail.svelte
@@ -0,0 +1,185 @@
+
+
+{#if $guideCollapsed}
+
+
+
+
+ 홈
+
+
+
+ 운영진
+
+ void openRandomSettlement()}
+ class={miniClass(false)}
+ aria-label="자유 재생: 무작위 결산 보기"
+ >
+
+ 자유 재생
+
+ void openChronologicalSettlements()}
+ class={miniClass(false)}
+ aria-label="날짜순 재생: 오래된 결산부터 보기"
+ >
+
+ 날짜순 재생
+
+
+
+ 내 페이지
+
+
+{:else}
+
+
+
+
+ 홈
+
+
+
+ 운영진 한마디
+
+ void openRandomSettlement()}
+ class={rowClass(false)}
+ >
+
+ 자유 재생
+
+ void openChronologicalSettlements()}
+ class={rowClass(false)}
+ >
+
+ 날짜순 재생
+
+
+
+ 내 페이지
+
+
+
+
+ {#if !$authStore.isAuthenticated}
+
+
+
+ 로그인하면 결산에 댓글을 남기고 응원할 수 있습니다.
+
+
+
+ 로그인
+
+
+
+
+ {/if}
+
+
+ 디자인
+
+ {#each themeOptions as option (option.value)}
+ theme.set(option.value)}
+ class={`flex items-center gap-1 px-2.5 py-1.5 rounded-full text-xs whitespace-nowrap transition-colors ${
+ $theme === option.value
+ ? "bg-yt-chip-active text-yt-chip-active-text"
+ : "bg-yt-chip text-yt-text hover:bg-yt-surface-hover"
+ }`}
+ aria-pressed={$theme === option.value}
+ >
+ {#if option.value === "system"}
+
+ {:else if option.value === "light"}
+
+ {:else}
+
+ {/if}
+ {option.label}
+
+ {/each}
+
+
+
+ '26년 2월 28일 ~ '26년 8월 16일의 기록
+
+
+{/if}
diff --git a/dpbr_front/app/src/lib/components/Header.svelte b/dpbr_front/app/src/lib/components/Header.svelte
index cd667e0..1c0b0cd 100644
--- a/dpbr_front/app/src/lib/components/Header.svelte
+++ b/dpbr_front/app/src/lib/components/Header.svelte
@@ -1,29 +1,55 @@
{#if variant === "main"}
+
{:else if variant === "detail"}
{:else if variant === "close"}
-
+
{:else if variant === "save"}
-
+
{/if}
diff --git a/dpbr_front/app/src/lib/components/InputBox.svelte b/dpbr_front/app/src/lib/components/InputBox.svelte
index dbb1c44..ac98547 100644
--- a/dpbr_front/app/src/lib/components/InputBox.svelte
+++ b/dpbr_front/app/src/lib/components/InputBox.svelte
@@ -1,4 +1,6 @@
+
+
+
+
+
+
+
event.stopPropagation()}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="settlement-comments-title"
+ tabindex="-1"
+ >
+
+
+
+
+ {#if loading}
+
+ 댓글을 불러오는 중...
+
+ {:else if error}
+
+
{error}
+
void loadComments()}
+ class="rounded-full bg-yt-chip px-4 py-2 text-sm font-medium"
+ >
+ 다시 시도
+
+
+ {:else if comments.length === 0}
+
+
첫 댓글을 남겨 보세요.
+
이 결산에 대한 응원을 기다리고 있어요.
+
+ {:else}
+ {#each comments as comment (comment.id)}
+
{
+ comments = comments.filter((item) => item.id !== id);
+ }}
+ />
+ {/each}
+ {/if}
+
+
+
+ {#if !$authStore.isAuthenticated}
+
(showLoginBottomSheet = true)}
+ class="w-full rounded-full bg-yt-chip-active py-3 text-sm font-medium text-yt-chip-active-text active:opacity-80"
+ >
+ 로그인하고 댓글 남기기
+
+ {:else}
+
+
+
+
+
void submitComment()}
+ disabled={!inputText.trim() || submitting}
+ class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-yt-chip-active text-yt-chip-active-text disabled:opacity-40"
+ aria-label="댓글 보내기"
+ >
+ {#if submitting}
+ ...
+ {:else}
+
+ {/if}
+
+
+ {/if}
+
+
+ {#if showLoginBottomSheet}
+
(showLoginBottomSheet = false)}
+ onSuccess={loadComments}
+ />
+ {/if}
+
+
diff --git a/dpbr_front/app/src/lib/components/SettlementListItem.svelte b/dpbr_front/app/src/lib/components/SettlementListItem.svelte
deleted file mode 100644
index 73e7ac0..0000000
--- a/dpbr_front/app/src/lib/components/SettlementListItem.svelte
+++ /dev/null
@@ -1,45 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- {item.title}
- {formatDate(item.acquiredAt)}
-
-
diff --git a/dpbr_front/app/src/lib/components/ShortsThumbnail.svelte b/dpbr_front/app/src/lib/components/ShortsThumbnail.svelte
new file mode 100644
index 0000000..ca64c16
--- /dev/null
+++ b/dpbr_front/app/src/lib/components/ShortsThumbnail.svelte
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {getSettlementCaption(item)}
+
+
{formatDate(item.acquiredAt)}
+
+
diff --git a/dpbr_front/app/src/lib/components/Sidebar.svelte b/dpbr_front/app/src/lib/components/Sidebar.svelte
deleted file mode 100644
index e952a2a..0000000
--- a/dpbr_front/app/src/lib/components/Sidebar.svelte
+++ /dev/null
@@ -1,134 +0,0 @@
-
-
-
-
-
-{#if open}
-
-
-{/if}
diff --git a/dpbr_front/app/src/lib/components/TeamMessageListItem.svelte b/dpbr_front/app/src/lib/components/TeamMessageListItem.svelte
index 179f5f8..f186eb0 100644
--- a/dpbr_front/app/src/lib/components/TeamMessageListItem.svelte
+++ b/dpbr_front/app/src/lib/components/TeamMessageListItem.svelte
@@ -1,6 +1,9 @@
+
+
+
+
+
+
+
+
+ 결산 {character.settlementCount ?? 0}개
+
+
+
+
+
+
+
+
+
+ {character.name}
+ {character.nickname} · {character.server} · {character.job}
+
+
+
+
+
+
diff --git a/dpbr_front/app/src/lib/data.ts b/dpbr_front/app/src/lib/data.ts
index ccc7f89..d2b3969 100644
--- a/dpbr_front/app/src/lib/data.ts
+++ b/dpbr_front/app/src/lib/data.ts
@@ -1,4 +1,4 @@
-import type { Character, SettlementItem, TalkComment } from './types';
+import type { Character, SettlementItem } from './types';
export const characters: Character[] = [
{
@@ -147,7 +147,7 @@ export const settlementItems: SettlementItem[] = [
{
id: 'msg-1',
characterId: 'char-1',
- title: 'ACTIVITY_RECAP 내용(제목으로 사용)',
+ title: '결산 내용(제목으로 사용)',
description:
'길드원들과 함께한 보스레이드 최초 클리어! 모두가 힘을 합쳐 드디어 데미안을 처치했습니다. 정말 감격스러운 순간이었어요.',
imageUrl: 'https://placehold.co/400x300/FFE0B2/ff8e42?text=BOSS+CLEAR',
@@ -230,74 +230,6 @@ export const settlementItems: SettlementItem[] = [
}
];
-export const talkComments: TalkComment[] = [
- {
- id: 'talk-1',
- userId: 101,
- author: '단풍사랑',
- authorAvatar: 'https://placehold.co/40x40/FFE0B2/ff8e42?text=DS',
- content: '안녕하세요! COMMUNITY_PROJECT COHORT ACTIVITY_RECAP 축하합니다!',
- createdAt: '26. 01. 22. 23:11'
- },
- {
- id: 'talk-2',
- userId: 102,
- author: 'GAME러버',
- authorAvatar: 'https://placehold.co/40x40/E8F5E9/4CAF50?text=ML',
- content: '모두 수고하셨습니다~ 다음 기수도 화이팅!',
- createdAt: '26. 01. 22. 22:45'
- },
- {
- id: 'talk-3',
- userId: 103,
- author: '바람의나라',
- authorAvatar: 'https://placehold.co/40x40/E3F2FD/2196F3?text=BN',
- content: 'ACTIVITY_RECAP 정말 잘 만들었네요. 디자인이 너무 예뻐요!',
- createdAt: '26. 01. 22. 21:30'
- },
- {
- id: 'talk-4',
- userId: 104,
- author: '길드장최고',
- authorAvatar: 'https://placehold.co/40x40/F3E5F5/9C27B0?text=GJ',
- content:
- '우리 길드원들 최고! 다 같이 힘내서 이번 시즌도 잘 마무리합시다. 앞으로도 함께 즐거운 GAME 생활 해요~',
- createdAt: '26. 01. 21. 18:20'
- },
- {
- id: 'talk-5',
- userId: 105,
- author: '초보모험가',
- authorAvatar: 'https://placehold.co/40x40/FFF9C4/FFC107?text=CB',
- content: '저도 다음에 ACTIVITY_RECAP 받고 싶어요!',
- createdAt: '26. 01. 21. 15:05'
- },
- {
- id: 'talk-6',
- userId: 106,
- author: '어둠의기사',
- authorAvatar: 'https://placehold.co/40x40/FFCCBC/FF5722?text=DK',
- content: '멋진 결산이네요 ㅎㅎ',
- createdAt: '26. 01. 20. 09:33'
- },
- {
- id: 'talk-7',
- userId: 107,
- author: '별빛수호자',
- authorAvatar: 'https://placehold.co/40x40/FCE4EC/E91E63?text=BS',
- content: '와 진짜 대단하다... 부럽!',
- createdAt: '26. 01. 19. 20:12'
- },
- {
- id: 'talk-8',
- userId: 108,
- author: '나무늘보',
- authorAvatar: 'https://placehold.co/40x40/E0F2F1/009688?text=NB',
- content: '다음 기수에는 저도 꼭 참여할게요!',
- createdAt: '26. 01. 19. 14:40'
- }
-];
-
export function getCharacterById(id: string): Character | undefined {
return characters.find((c) => c.id === id);
}
diff --git a/dpbr_front/app/src/lib/stores/theme.ts b/dpbr_front/app/src/lib/stores/theme.ts
new file mode 100644
index 0000000..a4921fb
--- /dev/null
+++ b/dpbr_front/app/src/lib/stores/theme.ts
@@ -0,0 +1,65 @@
+import { writable } from 'svelte/store';
+import { browser } from '$app/environment';
+
+export type ThemePreference = 'light' | 'dark' | 'system';
+
+const STORAGE_KEY = 'theme';
+
+function getStoredPreference(): ThemePreference {
+ if (!browser) return 'system';
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored === 'light' || stored === 'dark' || stored === 'system') {
+ return stored;
+ }
+ } catch {
+ // localStorage 접근 불가 환경은 시스템 설정을 따름
+ }
+ return 'system';
+}
+
+function systemPrefersDark(): boolean {
+ if (!browser) return false;
+ return window.matchMedia('(prefers-color-scheme: dark)').matches;
+}
+
+function applyPreference(preference: ThemePreference) {
+ if (!browser) return;
+ const isDark = preference === 'dark' || (preference === 'system' && systemPrefersDark());
+ document.documentElement.classList.toggle('dark', isDark);
+}
+
+function createThemeStore() {
+ let current = getStoredPreference();
+ const { subscribe, set } = writable(current);
+
+ if (browser) {
+ applyPreference(current);
+
+ window
+ .matchMedia('(prefers-color-scheme: dark)')
+ .addEventListener('change', () => {
+ if (current === 'system') {
+ applyPreference(current);
+ }
+ });
+ }
+
+ return {
+ subscribe,
+ set(preference: ThemePreference) {
+ current = preference;
+ if (browser) {
+ try {
+ localStorage.setItem(STORAGE_KEY, preference);
+ } catch {
+ // 저장 실패해도 이번 세션에는 적용
+ }
+ applyPreference(preference);
+ }
+ set(preference);
+ },
+ };
+}
+
+export const theme = createThemeStore();
diff --git a/dpbr_front/app/src/lib/stores/ui.ts b/dpbr_front/app/src/lib/stores/ui.ts
new file mode 100644
index 0000000..aeeb073
--- /dev/null
+++ b/dpbr_front/app/src/lib/stores/ui.ts
@@ -0,0 +1,7 @@
+import { writable } from 'svelte/store';
+
+/** 데스크톱 가이드(좌측 패널) 접힘 상태 — 헤더 햄버거로 토글 */
+export const guideCollapsed = writable(false);
+
+/** 로그아웃 확인 팝업 열림 상태 — 헤더/내 페이지에서 제어 */
+export const logoutConfirmOpen = writable(false);
diff --git a/dpbr_front/app/src/lib/types.ts b/dpbr_front/app/src/lib/types.ts
index 650b728..a363f02 100644
--- a/dpbr_front/app/src/lib/types.ts
+++ b/dpbr_front/app/src/lib/types.ts
@@ -3,7 +3,9 @@ export interface Character {
name: string;
nickname: string;
avatarUrl: string;
+ previewImageUrl?: string | null;
level: number;
+ settlementCount?: number;
job: string;
club: string;
server: string;
@@ -15,20 +17,29 @@ export interface SettlementItem {
title: string;
description: string;
imageUrl: string;
+ audioUrl?: string | null;
+ audioTitle?: string | null;
+ audioArtist?: string | null;
+ audioSourceUrl?: string | null;
+ audioStartSeconds?: number;
+ audioDurationSeconds?: number | null;
+ audioFadeOutSeconds?: number | null;
acquiredAt: string;
}
export interface TeamMessageItem {
id: string;
name: string;
+ nickname: string;
role: string;
title: string;
content: string;
imageUrl: string;
}
-export interface TalkComment {
+export interface SettlementComment {
id: string;
+ settlementId: string;
userId: number | null;
author: string;
authorAvatar: string;
diff --git a/dpbr_front/app/src/lib/utils/capture.ts b/dpbr_front/app/src/lib/utils/capture.ts
index 9a2d936..faad9a7 100644
--- a/dpbr_front/app/src/lib/utils/capture.ts
+++ b/dpbr_front/app/src/lib/utils/capture.ts
@@ -1,72 +1,41 @@
-import html2canvas from 'html2canvas';
+import { toBlob } from 'html-to-image';
-/**
- * HTML 요소를 이미지로 캡쳐하고 다운로드합니다.
- *
- * @param element - 캡쳐할 HTML 요소
- * @param filename - 저장할 파일명 (확장자 포함)
- * @throws 캡쳐 또는 다운로드 실패 시 에러
- *
- * @example
- * ```typescript
- * const element = document.getElementById('capture-area');
- * await captureElementAsImage(element, 'my-image.png');
- * ```
- */
-export async function captureElementAsImage(
- element: HTMLElement,
- filename: string
-): Promise {
- try {
- const canvas = await html2canvas(element, {
- scale: 2, // 고해상도 (Retina 디스플레이 대응)
- useCORS: true, // 외부 이미지 허용
- allowTaint: false, // 보안 설정
- backgroundColor: '#f5f5f5', // 배경색
- scrollY: -window.scrollY, // 스크롤 위치 보정
- scrollX: -window.scrollX,
- windowWidth: element.scrollWidth,
- windowHeight: element.scrollHeight
- });
+export interface CaptureImageOptions {
+ backgroundColor?: string;
+ height?: number;
+ scale?: number;
+ width?: number;
+}
- // Canvas를 Blob으로 변환 후 다운로드
- return new Promise((resolve, reject) => {
- canvas.toBlob((blob) => {
- if (!blob) {
- reject(new Error('Failed to create image blob'));
- return;
- }
+export async function captureElementAsPngBlob(
+ element: HTMLElement,
+ options: CaptureImageOptions = {}
+): Promise {
+ const rect = element.getBoundingClientRect();
+ const width = options.width ?? Math.round(rect.width);
+ const height = options.height ?? Math.round(rect.height);
+ const blob = await toBlob(element, {
+ backgroundColor: options.backgroundColor ?? '#000000',
+ width,
+ height,
+ pixelRatio: options.scale ?? 2,
+ cacheBust: true
+ });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- resolve();
- }, 'image/png');
- });
- } catch (error) {
- console.error('Image capture failed:', error);
- throw error;
+ if (!blob) {
+ throw new Error('Failed to create image blob');
}
+
+ return blob;
}
-/**
- * 파일명 생성 헬퍼 함수
- *
- * @param baseName - 기본 파일명
- * @returns 타임스탬프가 포함된 파일명
- *
- * @example
- * ```typescript
- * generateFilename('character-담뫄') // 'character-담뫄-1234567890.png'
- * ```
- */
-export function generateFilename(baseName: string): string {
- const timestamp = Date.now();
- const sanitized = baseName.replace(/[^a-zA-Z0-9가-힣-_]/g, '-');
- return `${sanitized}-${timestamp}.png`;
+export function downloadBlob(blob: Blob, filename: string): void {
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
diff --git a/dpbr_front/app/src/lib/utils/image.ts b/dpbr_front/app/src/lib/utils/image.ts
index 4919fa1..348fa05 100644
--- a/dpbr_front/app/src/lib/utils/image.ts
+++ b/dpbr_front/app/src/lib/utils/image.ts
@@ -2,14 +2,14 @@
* 이미지 URL 유틸리티
*/
-const DEFAULT_AVATAR = '/default-avatar.png';
+export const DEFAULT_AVATAR_URL = '/images/icons/symbol-logo-color.svg';
/**
* 이미지 URL이 유효하지 않을 때 기본 이미지를 반환
*/
export function getImageUrl(url: string | null | undefined): string {
if (!url || url.trim() === '') {
- return DEFAULT_AVATAR;
+ return DEFAULT_AVATAR_URL;
}
return url;
}
@@ -17,9 +17,10 @@ export function getImageUrl(url: string | null | undefined): string {
/**
* 이미지 로드 에러 시 기본 이미지로 대체
*/
-export function handleImageError(event: Event) {
- const img = event.target as HTMLImageElement;
- if (img.src !== DEFAULT_AVATAR) {
- img.src = DEFAULT_AVATAR;
- }
+export function handleImageError(event: Event): void {
+ const img = event.currentTarget as HTMLImageElement;
+ if (img.dataset.fallbackApplied === 'true') return;
+
+ img.dataset.fallbackApplied = 'true';
+ img.src = DEFAULT_AVATAR_URL;
}
diff --git a/dpbr_front/app/src/lib/utils/nav.ts b/dpbr_front/app/src/lib/utils/nav.ts
new file mode 100644
index 0000000..6e50aef
--- /dev/null
+++ b/dpbr_front/app/src/lib/utils/nav.ts
@@ -0,0 +1,65 @@
+import { get } from 'svelte/store';
+import { goto } from '$app/navigation';
+import { authStore } from '$lib/stores/auth';
+import { logoutConfirmOpen } from '$lib/stores/ui';
+import { getRandomSettlement, getSettlementsPaginated } from '$lib/api';
+import { toast } from '$lib/stores/toast';
+
+let randomNavigationInFlight = false;
+let chronologicalNavigationInFlight = false;
+
+/** 내 페이지: 로그인 시 로그아웃 확인, 비로그인 시 로그인 페이지 */
+export function openMyPage() {
+ if (get(authStore).isAuthenticated) {
+ logoutConfirmOpen.set(true);
+ } else {
+ void goto('/login');
+ }
+}
+
+/** 전체 결산을 무작위로 계속 이어 보는 자유 재생 피드를 연다. */
+export async function openRandomSettlement() {
+ if (randomNavigationInFlight) return;
+ randomNavigationInFlight = true;
+
+ try {
+ const settlement = await getRandomSettlement();
+ if (!settlement) {
+ toast.show('재생할 결산이 없습니다.');
+ return;
+ }
+
+ await goto(
+ `/shorts/${settlement.characterId}?item=${settlement.id}&mode=random`
+ );
+ } catch (error) {
+ console.error('Failed to open a random settlement:', error);
+ toast.show('자유 재생을 시작하지 못했습니다.');
+ } finally {
+ randomNavigationInFlight = false;
+ }
+}
+
+/** 전체 결산을 획득일 오름차순으로 재생하고 마지막부터 랜덤 피드로 잇는다. */
+export async function openChronologicalSettlements() {
+ if (chronologicalNavigationInFlight) return;
+ chronologicalNavigationInFlight = true;
+
+ try {
+ const result = await getSettlementsPaginated(1, 1);
+ const firstSettlement = result.items[0];
+ if (!firstSettlement) {
+ toast.show('재생할 결산이 없습니다.');
+ return;
+ }
+
+ await goto(
+ `/shorts/${firstSettlement.characterId}?item=${firstSettlement.id}&mode=chronological`
+ );
+ } catch (error) {
+ console.error('Failed to open chronological settlements:', error);
+ toast.show('날짜순 재생을 시작하지 못했습니다.');
+ } finally {
+ chronologicalNavigationInFlight = false;
+ }
+}
diff --git a/dpbr_front/app/src/lib/utils/settlement.ts b/dpbr_front/app/src/lib/utils/settlement.ts
new file mode 100644
index 0000000..b0a3d9b
--- /dev/null
+++ b/dpbr_front/app/src/lib/utils/settlement.ts
@@ -0,0 +1,12 @@
+import type { SettlementItem } from '$lib/types';
+
+export function getSettlementCaption(item: SettlementItem): string {
+ const title = item.title.trim();
+ const description = item.description.trim();
+
+ if (!description || description === title) {
+ return title;
+ }
+
+ return `${title}\n${description}`;
+}
diff --git a/dpbr_front/app/src/routes/+layout.svelte b/dpbr_front/app/src/routes/+layout.svelte
index 551d2d5..7a7f589 100644
--- a/dpbr_front/app/src/routes/+layout.svelte
+++ b/dpbr_front/app/src/routes/+layout.svelte
@@ -4,10 +4,21 @@
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import type { Snippet } from "svelte";
+ import { Menu, Search, CircleUserRound } from "lucide-svelte";
import Toast from "$lib/components/Toast.svelte";
+ import ConfirmPopup from "$lib/components/ConfirmPopup.svelte";
+ import BottomNav from "$lib/components/BottomNav.svelte";
+ import GuideRail from "$lib/components/GuideRail.svelte";
+ import BrandWordmark from "$lib/components/BrandWordmark.svelte";
import { authStore } from "$lib/stores/auth";
+ import { guideCollapsed, logoutConfirmOpen } from "$lib/stores/ui";
+ import { theme } from "$lib/stores/theme";
+ import { toast } from "$lib/stores/toast";
import type { AuthState } from "$lib/types";
+ // 테마 스토어를 초기화해 시스템 테마 변경 구독을 활성화
+ void theme;
+
interface Props {
children: Snippet;
}
@@ -32,11 +43,26 @@
const currentPath = $derived(normalizePath($page.url.pathname));
const isLoginRoute = $derived(currentPath.startsWith("/login"));
const isSignupRoute = $derived(currentPath.startsWith("/auth/signup"));
- const MOBILE_MIN_WIDTH = 320;
- const MOBILE_MAX_WIDTH = 768;
- const MOBILE_BASE_HEIGHT = 874;
+ // 쇼츠 뷰어·로그인 등 풀스크린 화면에서는 하단 네비게이션 숨김
+ const showBottomNav = $derived(
+ !currentPath.startsWith("/shorts") &&
+ !currentPath.startsWith("/login") &&
+ !currentPath.startsWith("/auth"),
+ );
let hasCheckedAuth = $state(false);
let navigationInFlight = false;
+ let searchInput = $state("");
+
+ // URL의 ?q=와 검색창 동기화
+ $effect(() => {
+ searchInput = $page.url.searchParams.get("q") ?? "";
+ });
+
+ function submitSearch(e: Event) {
+ e.preventDefault();
+ const q = searchInput.trim();
+ void goto(q ? `/?q=${encodeURIComponent(q)}` : "/");
+ }
function navigateTo(path: string) {
const target = normalizePath(path);
@@ -90,16 +116,100 @@
});
-
-
+
+
+ {#if $logoutConfirmOpen}
+
{
+ logoutConfirmOpen.set(false);
+ await authStore.logout();
+ toast.show("로그아웃 되었습니다.");
+ }}
+ onCancel={() => logoutConfirmOpen.set(false)}
+ />
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ {@render children()}
+
+ {#if showBottomNav}
+
+ {/if}
+
diff --git a/dpbr_front/app/src/routes/+page.svelte b/dpbr_front/app/src/routes/+page.svelte
index b1883f1..6c3a0c2 100644
--- a/dpbr_front/app/src/routes/+page.svelte
+++ b/dpbr_front/app/src/routes/+page.svelte
@@ -1,13 +1,12 @@
- COMMUNITY_PROJECT - ACTIVITY_RECAP
+ 단풍바람 14기 - 메생결산
-
-
(sidebarOpen = false)} />
-
- (sidebarOpen = true)} />
+
+
+
+
+
-
+
{#if loading}
{:else if error}
{:else}
-
+
+
+
+ Shorts
+
+
+
+ {#if characters.length === 0}
+
+ {/if}
+
+
{#each characters as character (character.id)}
-
+
{/each}
{#if hasMore}
@@ -138,7 +202,7 @@
class="h-10 flex items-center justify-center"
>
{#if loadingMore}
-
불러오는 중...
+
불러오는 중...
{/if}
{/if}
diff --git a/dpbr_front/app/src/routes/auth/callback/+page.svelte b/dpbr_front/app/src/routes/auth/callback/+page.svelte
index b628242..b0059af 100644
--- a/dpbr_front/app/src/routes/auth/callback/+page.svelte
+++ b/dpbr_front/app/src/routes/auth/callback/+page.svelte
@@ -60,5 +60,9 @@
});
-
카카오 로그인 처리 중...
-
잠시만 기다려 주세요.
+
+
카카오 로그인 처리 중...
+
잠시만 기다려 주세요.
+
diff --git a/dpbr_front/app/src/routes/auth/signup/+page.svelte b/dpbr_front/app/src/routes/auth/signup/+page.svelte
index 071cb2a..39ce490 100644
--- a/dpbr_front/app/src/routes/auth/signup/+page.svelte
+++ b/dpbr_front/app/src/routes/auth/signup/+page.svelte
@@ -83,10 +83,10 @@
-
회원가입
+
회원가입
import { goto } from "$app/navigation";
import { onMount } from "svelte";
+ import { ArrowLeft } from "lucide-svelte";
import { env } from "$env/dynamic/public";
import InputBox from "$lib/components/InputBox.svelte";
import Button from "$lib/components/Button.svelte";
@@ -127,18 +128,27 @@
- 로그인 - COMMUNITY_PROJECT
+ 로그인 - 단풍바람 14기
-
+
+
history.back()}
+ class="absolute left-4 top-4 p-2 text-yt-text"
+ aria-label="뒤로가기"
+ >
+
+
+
@@ -158,7 +168,7 @@
onFocus={handleNameFocus}
onBlur={handleNameBlur}
onKeyDown={handleNameKeyDown}
- class="rounded-t-lg rounded-b-none bg-white/20 border-b border-white/30 backdrop-blur-sm text-white placeholder-white"
+ class="rounded-t-lg rounded-b-none border-b !border-b-yt-border"
/>
@@ -177,7 +187,7 @@
onBlur={handleStudentIdBlur}
onClear={handleStudentIdClear}
onKeyDown={handleStudentIdKeyDown}
- class="rounded-b-lg rounded-t-none bg-white/20 backdrop-blur-sm text-white placeholder-white"
+ class="rounded-b-lg rounded-t-none"
/>
@@ -196,12 +206,12 @@
{/if}
@@ -210,11 +220,11 @@
- 이름 저장
+ 이름 저장
-
- COMMUNITY_PROJECT 회원이 이용 가능한 서비스입니다.
+
+ 단풍바람 14기 회원이 이용 가능한 서비스입니다.
diff --git a/dpbr_front/app/src/routes/member/[id]/+page.svelte b/dpbr_front/app/src/routes/member/[id]/+page.svelte
index d22d283..2f2efd2 100644
--- a/dpbr_front/app/src/routes/member/[id]/+page.svelte
+++ b/dpbr_front/app/src/routes/member/[id]/+page.svelte
@@ -1,10 +1,11 @@
- {character?.name ?? "캐릭터"} - COMMUNITY_PROJECT
+ {character?.name ?? "캐릭터"} - 단풍바람 14기
{#if loading}
-
-
로딩 중...
+
{:else if error}
-
-
{error}
+
{:else if character}
-
-
history.back()}
- />
+
+
+
-
-
+
+
+
+
+
+
+
-
-
- {character.name}
- {#if !isAdminTeam}
- {character.nickname}
- {/if}
-
-
+
{character.name}
-
{isAdminTeam
- ? ADMIN_TEAM_INFO.generation
- : "Lv. " + character.level}
-
-
{isAdminTeam
- ? ADMIN_TEAM_INFO.university
- : character.server}
-
-
{isAdminTeam
- ? ADMIN_TEAM_INFO.role
- : character.job}
-
-
- {#if !isAdminTeam}
-
@{character.nickname}
- 저장
-
- {/if}
+
+ {#if isAdminTeam}
+ {ADMIN_TEAM_INFO.club}
+ {:else}
+ Lv.{character.level} · {character.server} · {character.job}
+ {/if}
+
+
+ {isAdminTeam
+ ? `한마디 ${teamMessages.length}개`
+ : `결산 ${settlementsTotal || settlements.length}개`}
+
+
-
-
+ {#if !isAdminTeam}
+
+
+ {imageSaving ? "이미지 저장 중..." : "캐릭터 이미지 저장"}
+
+
+ {/if}
+
+
+
+
홈
+
{isAdminTeam ? "한마디" : "Shorts"}
-
{isAdminTeam
- ? "COMMUNITY_PROJECT 운영팀 한마디 목록"
- : "획득한 ACTIVITY_RECAP 목록"}
+
+ {#if !isAdminTeam}
+
+
+
+ 오름차순
+
-
- {#if isAdminTeam ? teamMessages.length > 0 : settlements.length > 0}
- {#if isAdminTeam}
- {#each teamMessages as item (item.id)}
-
- {/each}
- {:else}
- {#each settlements as item (item.id)}
-
- {/each}
- {#if settlementsHasMore}
-
- {#if settlementsLoadingMore}
-
- 불러오는 중...
-
- {/if}
-
+ {/if}
+
+
+ {#if isAdminTeam ? teamMessages.length > 0 : settlements.length > 0}
+ {#if isAdminTeam}
+
+ {#each teamMessages as item (item.id)}
+
+ {/each}
+
+ {:else}
+
+ {#each sortedSettlements as item (item.id)}
+
+ {/each}
+
+ {#if settlementsHasMore}
+
+ {#if settlementsLoadingMore}
+
+ 불러오는 중...
+
{/if}
- {/if}
- {:else}
-
-
- {isAdminTeam
- ? "운영팀 정보가 없습니다."
- : "ACTIVITY_RECAP이 없습니다."}
-
{/if}
+ {/if}
+ {:else}
+
+
+ {isAdminTeam
+ ? "운영진 정보가 없습니다."
+ : "등록된 결산이 없습니다."}
+
-
-
-
-
-
-
+ {/if}
{:else}
-
-
캐릭터를 찾을 수 없습니다.
+
{/if}
diff --git a/dpbr_front/app/src/routes/member/[id]/save/+page.svelte b/dpbr_front/app/src/routes/member/[id]/save/+page.svelte
deleted file mode 100644
index 3a3b225..0000000
--- a/dpbr_front/app/src/routes/member/[id]/save/+page.svelte
+++ /dev/null
@@ -1,164 +0,0 @@
-
-
-
- 카드 저장 - {character?.name ?? ""}
-
-
-
-
-
-
-
-
- {#if loading}
-
로딩 중...
- {:else if character}
-
내 캐릭터 카드를 저장해 보세요!
-
-
-
-
-
-
-
-
-
-
- ((e.currentTarget as HTMLImageElement).src =
- "https://via.placeholder.com/300x450/EEE/999?text=Park+Background")}
- />
-
-
-
-
-
-
-
-
-
-
-
- {character.name}
-
-
- {character.server} · {character.nickname ||
- character.name} · {character.job}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 저장하기
-
-
- {/if}
-
-
diff --git a/dpbr_front/app/src/routes/msg/[id]/+page.svelte b/dpbr_front/app/src/routes/msg/[id]/+page.svelte
index c84d1a8..04a3579 100644
--- a/dpbr_front/app/src/routes/msg/[id]/+page.svelte
+++ b/dpbr_front/app/src/routes/msg/[id]/+page.svelte
@@ -1,125 +1,41 @@
- {settlement?.title ?? "결산 상세"} - COMMUNITY_PROJECT
+ 결산 상세 - 단풍바람 14기
-
-
-
- {#if loading}
-
- {:else if error}
-
- {:else if settlement}
-
-
-
-
-
-
-
- {isAdminTeam ? "직책" : "획득 일자"}
- {isAdminTeam
- ? settlement.title
- : formatDate(settlement.acquiredAt)}
-
-
-
-
-
-
-
- 상세 내용
- {settlement.title || settlement.description}
-
-
-
-
-
-
-
-
-
- {:else}
-
- {/if}
+
diff --git a/dpbr_front/app/src/routes/shorts/[id]/+page.svelte b/dpbr_front/app/src/routes/shorts/[id]/+page.svelte
new file mode 100644
index 0000000..71cd146
--- /dev/null
+++ b/dpbr_front/app/src/routes/shorts/[id]/+page.svelte
@@ -0,0 +1,1875 @@
+
+
+
+
+
+ {currentItemCharacter?.nickname ?? "Shorts"} - 단풍바람 14기 Shorts
+
+
+
+
+
+
+ {#if isShortsSearchOpen}
+
+ {:else}
+
+
+ void openShortsSearch()}
+ class="w-12 h-12 flex items-center justify-center rounded-full active:bg-white/10"
+ aria-label="검색 열기"
+ >
+
+
+
+
+
+
+ {/if}
+
+ {#if isSettingsOpen}
+
(isSettingsOpen = false)}
+ class="fixed inset-0 z-30 cursor-default"
+ aria-label="설정 메뉴 바깥 영역 닫기"
+ >
+
+
{
+ toggleAudioMute();
+ isSettingsOpen = false;
+ }}
+ class="flex w-full items-center gap-3 px-4 py-3 text-left text-sm active:bg-white/10"
+ role="menuitem"
+ >
+ {#if isAudioMuted}
+
+ 소리 켜기
+ {:else}
+
+ 소리 끄기
+ {/if}
+
+
void copyCurrentSettlementLink()}
+ class="flex w-full items-center gap-3 px-4 py-3 text-left text-sm active:bg-white/10"
+ role="menuitem"
+ >
+
+ 현재 결산 링크 복사
+
+
+
+ 프로필 보기
+
+
+
+ 홈으로
+
+
+ {/if}
+
+
+ {#if loading}
+
+ {:else if error}
+
+ {:else if settlements.length === 0}
+
+
+ {#if character}
+
+
+
+
아직 등록된 결산이 없습니다.
+
+ 프로필 보기
+
+ {/if}
+
+ {:else}
+
+
+
+
+
+
+ {#if settlements.length > 0}
+
+ {/if}
+
+
+
+ {#if currentItem}
+
+
+ toggleLike(currentItem.id)}
+ class="w-12 h-12 rounded-full bg-yt-surface hover:bg-yt-surface-hover text-yt-text flex items-center justify-center"
+ aria-label="좋아요"
+ aria-pressed={likedIds.has(currentItem.id)}
+ >
+
+
+ 좋아요
+
+
+ openComments(currentItem)}
+ class="w-12 h-12 rounded-full bg-yt-surface hover:bg-yt-surface-hover text-yt-text flex items-center justify-center"
+ aria-label="현재 결산 댓글"
+ >
+
+
+ 댓글
+
+
+ saveSettlement(currentItem)}
+ class="w-12 h-12 rounded-full bg-yt-surface hover:bg-yt-surface-hover text-yt-text flex items-center justify-center"
+ aria-label="현재 게시글 이미지 저장"
+ aria-busy={exportingSettlementId === currentItem.id}
+ >
+
+
+ 저장
+
+
+ shareSettlement(currentItem)}
+ class="w-12 h-12 rounded-full bg-yt-surface hover:bg-yt-surface-hover text-yt-text flex items-center justify-center"
+ aria-label="현재 게시글 공유"
+ aria-busy={exportingSettlementId === currentItem.id}
+ >
+
+
+ 공유
+
+ {#if currentItemCharacter}
+
+
+
+ {/if}
+
+ {/if}
+
+
+
+
+ scrollToIndex(Math.max(currentIndex - 1, 0), true)}
+ disabled={currentIndex === 0}
+ class="w-12 h-12 rounded-full bg-yt-surface hover:bg-yt-surface-hover text-yt-text disabled:opacity-40 flex items-center justify-center"
+ aria-label="이전 결산"
+ >
+
+
+ void scrollToNext()}
+ disabled={loadingMore && currentIndex >= settlements.length - 1}
+ class="w-12 h-12 rounded-full bg-yt-surface hover:bg-yt-surface-hover text-yt-text disabled:opacity-40 flex items-center justify-center"
+ aria-label="다음 결산"
+ >
+
+
+
+
+ {/if}
+
+ {#if commentsSettlement}
+
+ {/if}
+
+ {#if audioInfoSettlement}
+
(audioInfoSettlement = null)}
+ />
+ {/if}
+
+
+
diff --git a/dpbr_front/app/src/routes/talk/+page.svelte b/dpbr_front/app/src/routes/talk/+page.svelte
deleted file mode 100644
index c11c051..0000000
--- a/dpbr_front/app/src/routes/talk/+page.svelte
+++ /dev/null
@@ -1,247 +0,0 @@
-
-
-
- ACTIVITY_RECAP 톡 - COMMUNITY_PROJECT
-
-
-
-
-
-
-
-
-
- 톡
- {comments.length.toLocaleString()}
-
-
-
-
-
- {#if loading}
-
- {:else if error}
-
- {:else}
-
- {#each comments as comment (comment.id)}
- {
- comments = comments.filter((c) => c.id !== id);
- }}
- />
- {/each}
-
- {/if}
-
-
-
-
- {#if !$authStore.isAuthenticated}
-
-
- 이름/학번 입력하고 톡 등록하기 →
-
- {:else}
-
-
-
-
-
- {#if submitting}
- ...
- {:else}
-
- {/if}
-
- {/if}
-
-
-
- {#if showLoginBottomSheet}
-
- {/if}
-
diff --git a/dpbr_front/app/src/routes/team-message/[id]/+page.svelte b/dpbr_front/app/src/routes/team-message/[id]/+page.svelte
index faa22c5..d608103 100644
--- a/dpbr_front/app/src/routes/team-message/[id]/+page.svelte
+++ b/dpbr_front/app/src/routes/team-message/[id]/+page.svelte
@@ -2,7 +2,10 @@
import { page } from "$app/stores";
import Header from "$lib/components/Header.svelte";
import { getTeamMessageDetail } from "$lib/api";
- import { handleImageError } from "$lib/utils/image";
+ import {
+ DEFAULT_AVATAR_URL,
+ handleImageError,
+ } from "$lib/utils/image";
import type { TeamMessageItem } from "$lib/types";
const teamMessageId = $derived($page.params.id ?? "");
@@ -38,7 +41,7 @@
- {teamMessage?.name ?? "운영팀 한마디"} - COMMUNITY_PROJECT
+ {teamMessage?.name ?? "운영진 한마디"} - 단풍바람 14기
@@ -46,68 +49,75 @@
{#if loading}
{:else if error}
{:else if teamMessage}
-
-
+
+
직책 닉네임
- {teamMessage.role} @{teamMessage.nickname}
+
+ 직위
+ {teamMessage.role}
+
-
+
상세 내용
{teamMessage.content || teamMessage.title}
-
+
{:else}
-
운영팀 한마디를 찾을 수 없습니다.
+
운영진 한마디를 찾을 수 없습니다.
{/if}
diff --git a/dpbr_front/app/static/favicon.ico b/dpbr_front/app/static/favicon.ico
new file mode 100644
index 0000000..8145ab5
Binary files /dev/null and b/dpbr_front/app/static/favicon.ico differ
diff --git a/dpbr_front/app/static/images/icons/app_favicon.ico b/dpbr_front/app/static/images/icons/app_favicon.ico
index 5a89154..8145ab5 100644
Binary files a/dpbr_front/app/static/images/icons/app_favicon.ico and b/dpbr_front/app/static/images/icons/app_favicon.ico differ
diff --git a/dpbr_front/app/static/images/icons/app_favicon.png b/dpbr_front/app/static/images/icons/app_favicon.png
new file mode 100644
index 0000000..ffe4bdc
Binary files /dev/null and b/dpbr_front/app/static/images/icons/app_favicon.png differ
diff --git a/dpbr_front/app/static/images/thumbnail.png b/dpbr_front/app/static/images/thumbnail.png
new file mode 100644
index 0000000..23d97df
Binary files /dev/null and b/dpbr_front/app/static/images/thumbnail.png differ
diff --git a/dpbr_front/nginx/nginx.conf b/dpbr_front/nginx/nginx.conf
index 1d764be..b2e79e6 100644
--- a/dpbr_front/nginx/nginx.conf
+++ b/dpbr_front/nginx/nginx.conf
@@ -7,6 +7,19 @@ server {
root /usr/share/nginx/html;
index index.html;
+ # 새 배포의 해시 자산 목록과 런타임 API 설정은 항상 재검증한다.
+ location = /index.html {
+ add_header Cache-Control "no-cache, no-store, must-revalidate" always;
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ }
+
+ location = /_app/env.js {
+ add_header Cache-Control "no-cache, no-store, must-revalidate" always;
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ }
+
# SvelteKit 라우팅 처리 (SPA fallback)
location / {
try_files $uri $uri/ /index.html;
diff --git a/github-pages/app.js b/github-pages/app.js
index 59b98cc..2f65f2f 100644
--- a/github-pages/app.js
+++ b/github-pages/app.js
@@ -26,7 +26,8 @@ const homePreviewSettlement = settlements[Math.floor(Math.random() * settlements
const teamMessage = {
id: "1",
name: "강민",
- role: "인사팀장",
+ nickname: "마가다락방",
+ role: "비상대책위원장",
title: "단풍바람 14기를 함께하며",
content: "단풍바람 14기를 여러분과 함께해서 행복했습니다. 모두 화이팅!"
};
@@ -208,7 +209,7 @@ function mobileHeader({ title = "", back = "", close = "", main = false } = {})
${brandMark()}
@@ -232,7 +233,7 @@ function guide() {