From fd225c256583599bf492bdafd1a8536b9e5206a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=B0=BD=ED=98=84?= Date: Sat, 3 Oct 2026 10:53:28 +0900 Subject: [PATCH] =?UTF-8?q?=ED=9A=8C=EC=9B=90=20=EB=A9=94=EB=89=B4?= =?UTF-8?q?=EC=99=80=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=A7=84=EC=9E=85?= =?UTF-8?q?=EC=9D=84=20=EC=83=81=EB=8B=A8=EC=97=90=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 곡 보관함과 로그인·회원가입과 관리자 버튼을 항상 보이도록 분리했다. 관리자 버튼은 권한에 따라 로그인 화면 또는 회원 관리 화면으로 이동한다. PWA 시작 문서와 서비스 워커의 캐시 정책을 보완해 배포 뒤 예전 화면이 오래 남지 않게 했다. 검사: pnpm 9 lint-staged와 프런트엔드 프로덕션 빌드 통과. --- frontend/nginx.conf | 12 + .../src/components/Library/LibraryPanel.tsx | 667 ++++++++++++++---- frontend/src/index.css | 354 ++++++++-- frontend/src/views/PracticeView.tsx | 32 +- frontend/vite.config.ts | 5 + 5 files changed, 879 insertions(+), 191 deletions(-) diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 781a96c..735b618 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -26,6 +26,18 @@ server { try_files $uri $uri/ /index.html; } + # PWA 시작 문서와 서비스 워커는 매번 최신 상태를 확인한다. 해시가 붙은 + # 정적 자산만 장기 캐시하므로 배포 뒤 예전 화면이 계속 남지 않는다. + location = /index.html { + expires -1; + add_header Cache-Control "no-store, no-cache, must-revalidate" always; + } + + location ~ ^/(sw\.js|registerSW\.js|manifest\.webmanifest)$ { + expires -1; + add_header Cache-Control "no-store, no-cache, must-revalidate" always; + } + # API proxy, strips the /api prefix on the way through location /api/ { proxy_pass http://backend:8000/; diff --git a/frontend/src/components/Library/LibraryPanel.tsx b/frontend/src/components/Library/LibraryPanel.tsx index 04bace3..938ef3a 100644 --- a/frontend/src/components/Library/LibraryPanel.tsx +++ b/frontend/src/components/Library/LibraryPanel.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; - type User = { id: number; username: string; @@ -35,7 +34,7 @@ type MidiItem = { }; type AuthResult = { user: User | null; csrf_token: string | null }; -type Tab = "browse" | "upload" | "account" | "admin"; +export type LibraryTab = "browse" | "upload" | "account" | "admin"; const TIER_LABELS: Record = { 0: "비회원", @@ -86,12 +85,14 @@ export function LibraryPanel({ open, onClose, onLoad, + initialTab = "browse", }: { open: boolean; onClose: () => void; onLoad: (file: File) => void; + initialTab?: LibraryTab; }) { - const [tab, setTab] = useState("browse"); + const [tab, setTab] = useState("browse"); const [user, setUser] = useState(null); const [csrf, setCsrf] = useState(null); const [items, setItems] = useState([]); @@ -109,6 +110,7 @@ export function LibraryPanel({ const result = await apiJson("/api/library/auth/me"); setUser(result.user); setCsrf(result.csrf_token); + return result; }, []); const refreshItems = useCallback(async () => { @@ -118,9 +120,10 @@ export function LibraryPanel({ if (difficulty) params.set("difficulty", difficulty); if (arrangement) params.set("arrangement", arrangement); if (activeTag) params.set("tag", activeTag); - const result = await apiJson<{ items: MidiItem[]; facets: { genres: string[]; tags: string[] } }>( - `/api/library/midi?${params}` - ); + const result = await apiJson<{ + items: MidiItem[]; + facets: { genres: string[]; tags: string[] }; + }>(`/api/library/midi?${params}`); setItems(result.items); setGenres(result.facets.genres); setTags(result.facets.tags); @@ -128,12 +131,30 @@ export function LibraryPanel({ useEffect(() => { if (!open) return; - refreshAuth().catch((error) => setMessage(error.message)); - }, [open, refreshAuth]); + const timer = window.setTimeout(() => { + refreshAuth() + .then((result) => { + if (initialTab === "admin" && result.user?.role !== "admin") { + setTab("account"); + setMessage( + result.user ? "관리자 권한이 필요합니다" : "관리자 계정으로 로그인해 주세요" + ); + return; + } + setTab(initialTab); + setMessage(""); + }) + .catch((error) => setMessage(error.message)); + }, 0); + return () => window.clearTimeout(timer); + }, [open, initialTab, refreshAuth]); useEffect(() => { if (!open) return; - const timer = window.setTimeout(() => refreshItems().catch((error) => setMessage(error.message)), 180); + const timer = window.setTimeout( + () => refreshItems().catch((error) => setMessage(error.message)), + 180 + ); return () => window.clearTimeout(timer); }, [open, refreshItems, user]); @@ -175,9 +196,15 @@ export function LibraryPanel({ run(async () => { let shared_usernames: string[] | undefined; if (visibility === "shared") { - const text = window.prompt("공유할 회원 아이디를 쉼표로 구분해 입력하세요", item.shared_usernames.join(", ")); + const text = window.prompt( + "공유할 회원 아이디를 쉼표로 구분해 입력하세요", + item.shared_usernames.join(", ") + ); if (text === null) return; - shared_usernames = text.split(",").map((value) => value.trim()).filter(Boolean); + shared_usernames = text + .split(",") + .map((value) => value.trim()) + .filter(Boolean); } await apiJson(`/api/library/midi/${item.id}`, { method: "PATCH", @@ -199,20 +226,48 @@ export function LibraryPanel({
- 곡 보관함 - {user ? `${user.display_name} · ${TIER_LABELS[user.tier]}` : "비회원은 공개 곡을 바로 연주할 수 있습니다"} + + {tab === "account" + ? "로그인과 회원 가입" + : tab === "admin" + ? "관리자 패널" + : "곡 보관함"} + + + {user + ? `${user.display_name} · ${TIER_LABELS[user.tier]}` + : "비회원은 공개 곡을 바로 연주할 수 있습니다"} +
- +
- {message &&
{message}
} + {message && ( +
+ {message} +
+ )}
{tab === "browse" && ( @@ -242,9 +297,18 @@ export function LibraryPanel({ busy={busy} run={run} onAuthenticated={async (result) => { - setUser(result.user); + const authenticatedUser = result.user; + if (!authenticatedUser) throw new Error("로그인 정보를 확인하지 못했습니다"); + setUser(authenticatedUser); setCsrf(result.csrf_token); - setTab("browse"); + setTab( + initialTab === "admin" && authenticatedUser.role === "admin" ? "admin" : "browse" + ); + setMessage( + initialTab === "admin" && authenticatedUser.role !== "admin" + ? "관리자 권한이 필요합니다" + : "" + ); await refreshItems(); }} onSignedOut={() => { @@ -255,11 +319,16 @@ export function LibraryPanel({ /> )} {tab === "upload" && user && ( - { - await refreshItems(); - setTab("browse"); - setMessage("MIDI를 업로드했습니다"); - }} /> + { + await refreshItems(); + setTab("browse"); + setMessage("MIDI를 업로드했습니다"); + }} + /> )} {tab === "admin" && user?.role === "admin" && ( @@ -270,8 +339,20 @@ export function LibraryPanel({ ); } -function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) { - return ; +function TabButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); } function BrowseTab(props: { @@ -295,24 +376,55 @@ function BrowseTab(props: { return (
- props.setSearch(event.target.value)} placeholder="곡명·가수·태그 검색" /> + props.setSearch(event.target.value)} + placeholder="곡명·가수·태그 검색" + /> - props.setDifficulty(event.target.value)} + > - {Object.entries(DIFFICULTY_LABELS).map(([value, label]) => )} + {Object.entries(DIFFICULTY_LABELS).map(([value, label]) => ( + + ))} - props.setArrangement(event.target.value)} + > - {Object.entries(ARRANGEMENT_LABELS).map(([value, label]) => )} + {Object.entries(ARRANGEMENT_LABELS).map(([value, label]) => ( + + ))}
- {props.activeTag && } + {props.activeTag && ( + + )} {props.tags.slice(0, 20).map((tag) => ( - + ))}
{props.groups.length === 0 &&
조건에 맞는 곡이 없습니다.
} @@ -320,7 +432,10 @@ function BrowseTab(props: { {props.groups.map((group) => (
-
{group[0].title}{group[0].artist || "아티스트 미지정"}
+
+ {group[0].title} + {group[0].artist || "아티스트 미지정"} +
{group.length}개 버전
@@ -328,16 +443,43 @@ function BrowseTab(props: {
{ARRANGEMENT_LABELS[item.arrangement]} - {DIFFICULTY_LABELS[item.difficulty]} · {item.genre || "장르 미지정"} + + {DIFFICULTY_LABELS[item.difficulty]} · {item.genre || "장르 미지정"} +

{item.description || "설명이 없습니다"}

-
{item.tags.map((tag) => )}
- {VISIBILITY_LABELS[item.visibility]} · {item.owner}{item.min_tier > 1 ? ` · ${TIER_LABELS[item.min_tier]} 이상` : ""} +
+ {item.tags.map((tag) => ( + + ))} +
+ + {VISIBILITY_LABELS[item.visibility]} · {item.owner} + {item.min_tier > 1 ? ` · ${TIER_LABELS[item.min_tier]} 이상` : ""} +
- + {item.can_edit && ( - + props.updateVisibility(item, event.target.value as MidiItem["visibility"]) + } + > + {Object.entries(VISIBILITY_LABELS).map(([value, label]) => ( + + ))} )}
@@ -373,27 +515,85 @@ function AccountTab(props: { return (
- - + +
-
{ - event.preventDefault(); - props.run(async () => { - const body = mode === "login" ? { username, password } : { username, password, display_name: displayName, email: email || null }; - const result = await apiJson(`/api/library/auth/${mode === "login" ? "login" : "register"}`, { - method: "POST", headers: authHeaders(null), body: JSON.stringify(body), + { + event.preventDefault(); + props.run(async () => { + const body = + mode === "login" + ? { username, password } + : { username, password, display_name: displayName, email: email || null }; + const result = await apiJson( + `/api/library/auth/${mode === "login" ? "login" : "register"}`, + { + method: "POST", + headers: authHeaders(null), + body: JSON.stringify(body), + } + ); + setPassword(""); + await props.onAuthenticated(result); }); - setPassword(""); - await props.onAuthenticated(result); - }); - }}> - - {mode === "register" && <> - - - } - - + }} + > + + {mode === "register" && ( + <> + + + + )} + +
); @@ -403,102 +603,313 @@ function AccountTab(props: {

{props.user.display_name}

-

@{props.user.username} · {TIER_LABELS[props.user.tier]} · {props.user.role === "admin" ? "관리자" : props.user.role === "moderator" ? "운영자" : "회원"}

- +

+ @{props.user.username} · {TIER_LABELS[props.user.tier]} ·{" "} + {props.user.role === "admin" + ? "관리자" + : props.user.role === "moderator" + ? "운영자" + : "회원"} +

+

비밀번호 변경

-
{ - event.preventDefault(); - props.run(async () => { - await apiJson("/api/library/auth/change-password", { - method: "POST", headers: authHeaders(props.csrf), body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }), + { + event.preventDefault(); + props.run(async () => { + await apiJson("/api/library/auth/change-password", { + method: "POST", + headers: authHeaders(props.csrf), + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword, + }), + }); + setCurrentPassword(""); + setNewPassword(""); + props.onSignedOut(); }); - setCurrentPassword(""); setNewPassword(""); props.onSignedOut(); - }); - }}> - - - + }} + > + + +

회원 탈퇴

본인이 올린 MIDI와 공유 설정이 모두 삭제되며 되돌릴 수 없습니다.

-
{ - event.preventDefault(); - if (!window.confirm("회원 계정과 업로드한 MIDI를 모두 삭제할까요?")) return; - props.run(async () => { - await apiJson("/api/library/auth/withdraw", { - method: "POST", headers: authHeaders(props.csrf), body: JSON.stringify({ password: withdrawPassword, confirmation: withdrawConfirmation }), + { + event.preventDefault(); + if (!window.confirm("회원 계정과 업로드한 MIDI를 모두 삭제할까요?")) return; + props.run(async () => { + await apiJson("/api/library/auth/withdraw", { + method: "POST", + headers: authHeaders(props.csrf), + body: JSON.stringify({ + password: withdrawPassword, + confirmation: withdrawConfirmation, + }), + }); + props.onSignedOut(); }); - props.onSignedOut(); - }); - }}> - - - + }} + > + + +
); } -function UploadTab({ csrf, busy, run, onUploaded }: { csrf: string | null; busy: boolean; run: (action: () => Promise) => Promise; onUploaded: () => Promise }) { +function UploadTab({ + csrf, + busy, + run, + onUploaded, +}: { + csrf: string | null; + busy: boolean; + run: (action: () => Promise) => Promise; + onUploaded: () => Promise; +}) { const [visibility, setVisibility] = useState("private"); return ( -
{ - event.preventDefault(); - const form = new FormData(event.currentTarget); - if (visibility === "public") form.set("min_tier", "0"); - run(async () => { - const response = await fetch("/api/library/midi", { method: "POST", headers: authHeaders(csrf, false), body: form }); - const data = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(data.detail || "업로드하지 못했습니다"); - event.currentTarget.reset(); - setVisibility("private"); - await onUploaded(); - }); - }}> + { + event.preventDefault(); + const form = new FormData(event.currentTarget); + if (visibility === "public") form.set("min_tier", "0"); + run(async () => { + const response = await fetch("/api/library/midi", { + method: "POST", + headers: authHeaders(csrf, false), + body: form, + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.detail || "업로드하지 못했습니다"); + event.currentTarget.reset(); + setVisibility("private"); + await onUploaded(); + }); + }} + >

MIDI 업로드

-

파일은 MIDI 헤더와 크기를 검사합니다. 저작권을 보유하거나 공유 허가를 받은 곡만 올려 주세요.

- - - - - - - - - - {visibility === "shared" && } -