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 @@ + + + + + + + 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 @@ + + + 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} - {character.server} { - isWorldLogoLoadFailed = true; - }} - /> - {:else} - {character.server} - {/if} -
- - -
- {character.name} -
- - -
- {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 @@

+ + + +{:else} + + +{/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"} +
- - - - - + {#if isMainSearchOpen} +
+ + +
+ {:else} + +
+ +
+ {/if}
{:else if variant === "detail"}
{#if title} - {title} {/if} {#if subtitle} - {subtitle} + {subtitle} {/if}
{: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 @@ + + + + + + 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} -
- - -
- {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 @@ + + + + {item.title} + + + + + +
+

+ {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.name} + + + + 결산 {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}
-

{error}

+

{error}

{:else} -
+ + + + {#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기 -
+
+ +
COMMUNITY_PROJECT @@ -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}

+
+

{error}

{:else if character} -
-
history.back()} - /> +
+ +
+ + +
- -
+ + + + +
{character.name}
-
-
- {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} +
+ +
+ {/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 + ? "운영진 정보가 없습니다." + : "등록된 결산이 없습니다."} +

-
-
- - -
- COMMUNITY_PROJECT + {/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} - 내 캐릭터 카드를 저장해 보세요! -
- -
- COMMUNITY_PROJECT -
- - -
- - 배경 - ((e.currentTarget as HTMLImageElement).src = - "https://via.placeholder.com/300x450/EEE/999?text=Park+Background")} - /> - - -
- -
- {character.name} -
- - -
-

- {character.name} -

-

- {character.server} · {character.nickname || - character.name} · {character.job} -

- -
- COMMUNITY_PROJECT COHORT -
-
-
-
-
- - -
- -
- {/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} -
-

{error}

-
- {:else if settlement} -
-
- {settlement.title} -
- -
-
- {isAdminTeam ? "직책" : "획득 일자"} - {isAdminTeam - ? settlement.title - : formatDate(settlement.acquiredAt)} -
-
- -
- -
-
- 상세 내용 - {settlement.title || settlement.description} -
-
- -
-
- -
- COMMUNITY_PROJECT -
- {:else} -
-

결산 항목을 찾을 수 없습니다.

-
- {/if} +
+

{error ?? "로딩 중..."}

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} +
+ + + + +
+
+ + +
+ {/if} + + {#if isSettingsOpen} + + + {/if} +
+ + {#if loading} +
+

로딩 중...

+
+ {:else if error} +
+

{error}

+ +
+ {:else if settlements.length === 0} + +
+ {#if character} +
+ {character.name} +
+

아직 등록된 결산이 없습니다.

+ + 프로필 보기 + + {/if} +
+ {:else} + +
+
+ + + + {#if settlements.length > 0} +
+
+
+ {/if} +
+ + + {#if currentItem} + + {/if} + + + +
+ {/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 - - -
- -
- - - - ACTIVITY_RECAP 톡 - - -
- {#if $authStore.isAuthenticated} - - {/if} -
-
- - -
-
- - {comments.length.toLocaleString()} -
-
- - -
- {#if loading} -
-

로딩 중...

-
- {:else if error} -
-

{error}

-
- {:else} -
- {#each comments as comment (comment.id)} - { - comments = comments.filter((c) => c.id !== id); - }} - /> - {/each} -
- {/if} -
- - -
- {#if !$authStore.isAuthenticated} - - - {:else} - -
- -
- - {/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}
-

{error}

+

{error}

{:else if teamMessage} -
-
+
+
{teamMessage.name}
직책닉네임 - {teamMessage.role}@{teamMessage.nickname}
+
+ 직위 + {teamMessage.role} +
-
+
상세 내용 {teamMessage.content || teamMessage.title}
-
+
COMMUNITY_PROJECT
{: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() {