회원 메뉴와 관리자 진입을 상단에 표시한다
곡 보관함과 로그인·회원가입과 관리자 버튼을 항상 보이도록 분리했다. 관리자 버튼은 권한에 따라 로그인 화면 또는 회원 관리 화면으로 이동한다. PWA 시작 문서와 서비스 워커의 캐시 정책을 보완해 배포 뒤 예전 화면이 오래 남지 않게 했다. 검사: pnpm 9 lint-staged와 프런트엔드 프로덕션 빌드 통과.
This commit is contained in:
@@ -26,6 +26,18 @@ server {
|
|||||||
try_files $uri $uri/ /index.html;
|
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
|
# API proxy, strips the /api prefix on the way through
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8000/;
|
proxy_pass http://backend:8000/;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
|
||||||
type User = {
|
type User = {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -35,7 +34,7 @@ type MidiItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type AuthResult = { user: User | null; csrf_token: string | null };
|
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<number, string> = {
|
const TIER_LABELS: Record<number, string> = {
|
||||||
0: "비회원",
|
0: "비회원",
|
||||||
@@ -86,12 +85,14 @@ export function LibraryPanel({
|
|||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
onLoad,
|
onLoad,
|
||||||
|
initialTab = "browse",
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onLoad: (file: File) => void;
|
onLoad: (file: File) => void;
|
||||||
|
initialTab?: LibraryTab;
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<Tab>("browse");
|
const [tab, setTab] = useState<LibraryTab>("browse");
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [csrf, setCsrf] = useState<string | null>(null);
|
const [csrf, setCsrf] = useState<string | null>(null);
|
||||||
const [items, setItems] = useState<MidiItem[]>([]);
|
const [items, setItems] = useState<MidiItem[]>([]);
|
||||||
@@ -109,6 +110,7 @@ export function LibraryPanel({
|
|||||||
const result = await apiJson<AuthResult>("/api/library/auth/me");
|
const result = await apiJson<AuthResult>("/api/library/auth/me");
|
||||||
setUser(result.user);
|
setUser(result.user);
|
||||||
setCsrf(result.csrf_token);
|
setCsrf(result.csrf_token);
|
||||||
|
return result;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refreshItems = useCallback(async () => {
|
const refreshItems = useCallback(async () => {
|
||||||
@@ -118,9 +120,10 @@ export function LibraryPanel({
|
|||||||
if (difficulty) params.set("difficulty", difficulty);
|
if (difficulty) params.set("difficulty", difficulty);
|
||||||
if (arrangement) params.set("arrangement", arrangement);
|
if (arrangement) params.set("arrangement", arrangement);
|
||||||
if (activeTag) params.set("tag", activeTag);
|
if (activeTag) params.set("tag", activeTag);
|
||||||
const result = await apiJson<{ items: MidiItem[]; facets: { genres: string[]; tags: string[] } }>(
|
const result = await apiJson<{
|
||||||
`/api/library/midi?${params}`
|
items: MidiItem[];
|
||||||
);
|
facets: { genres: string[]; tags: string[] };
|
||||||
|
}>(`/api/library/midi?${params}`);
|
||||||
setItems(result.items);
|
setItems(result.items);
|
||||||
setGenres(result.facets.genres);
|
setGenres(result.facets.genres);
|
||||||
setTags(result.facets.tags);
|
setTags(result.facets.tags);
|
||||||
@@ -128,12 +131,30 @@ export function LibraryPanel({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
refreshAuth().catch((error) => setMessage(error.message));
|
const timer = window.setTimeout(() => {
|
||||||
}, [open, refreshAuth]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
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);
|
return () => window.clearTimeout(timer);
|
||||||
}, [open, refreshItems, user]);
|
}, [open, refreshItems, user]);
|
||||||
|
|
||||||
@@ -175,9 +196,15 @@ export function LibraryPanel({
|
|||||||
run(async () => {
|
run(async () => {
|
||||||
let shared_usernames: string[] | undefined;
|
let shared_usernames: string[] | undefined;
|
||||||
if (visibility === "shared") {
|
if (visibility === "shared") {
|
||||||
const text = window.prompt("공유할 회원 아이디를 쉼표로 구분해 입력하세요", item.shared_usernames.join(", "));
|
const text = window.prompt(
|
||||||
|
"공유할 회원 아이디를 쉼표로 구분해 입력하세요",
|
||||||
|
item.shared_usernames.join(", ")
|
||||||
|
);
|
||||||
if (text === null) return;
|
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}`, {
|
await apiJson(`/api/library/midi/${item.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -199,20 +226,48 @@ export function LibraryPanel({
|
|||||||
<section className="library-panel">
|
<section className="library-panel">
|
||||||
<header className="library-header">
|
<header className="library-header">
|
||||||
<div>
|
<div>
|
||||||
<strong>곡 보관함</strong>
|
<strong>
|
||||||
<span>{user ? `${user.display_name} · ${TIER_LABELS[user.tier]}` : "비회원은 공개 곡을 바로 연주할 수 있습니다"}</span>
|
{tab === "account"
|
||||||
|
? "로그인과 회원 가입"
|
||||||
|
: tab === "admin"
|
||||||
|
? "관리자 패널"
|
||||||
|
: "곡 보관함"}
|
||||||
|
</strong>
|
||||||
|
<span>
|
||||||
|
{user
|
||||||
|
? `${user.display_name} · ${TIER_LABELS[user.tier]}`
|
||||||
|
: "비회원은 공개 곡을 바로 연주할 수 있습니다"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={onClose} aria-label="곡 보관함 닫기">닫기</button>
|
<button type="button" onClick={onClose} aria-label="곡 보관함 닫기">
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav className="library-tabs" aria-label="곡 보관함 메뉴">
|
<nav className="library-tabs" aria-label="곡 보관함 메뉴">
|
||||||
<TabButton active={tab === "browse"} onClick={() => setTab("browse")}>곡 찾기</TabButton>
|
<TabButton active={tab === "browse"} onClick={() => setTab("browse")}>
|
||||||
{user && <TabButton active={tab === "upload"} onClick={() => setTab("upload")}>업로드</TabButton>}
|
곡 찾기
|
||||||
<TabButton active={tab === "account"} onClick={() => setTab("account")}>{user ? "내 계정" : "로그인"}</TabButton>
|
</TabButton>
|
||||||
{user?.role === "admin" && <TabButton active={tab === "admin"} onClick={() => setTab("admin")}>회원 관리</TabButton>}
|
{user && (
|
||||||
|
<TabButton active={tab === "upload"} onClick={() => setTab("upload")}>
|
||||||
|
업로드
|
||||||
|
</TabButton>
|
||||||
|
)}
|
||||||
|
<TabButton active={tab === "account"} onClick={() => setTab("account")}>
|
||||||
|
{user ? "내 계정" : "로그인"}
|
||||||
|
</TabButton>
|
||||||
|
{user?.role === "admin" && (
|
||||||
|
<TabButton active={tab === "admin"} onClick={() => setTab("admin")}>
|
||||||
|
회원 관리
|
||||||
|
</TabButton>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{message && <div className="library-message" role="status">{message}</div>}
|
{message && (
|
||||||
|
<div className="library-message" role="status">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="library-body" aria-busy={busy}>
|
<div className="library-body" aria-busy={busy}>
|
||||||
{tab === "browse" && (
|
{tab === "browse" && (
|
||||||
@@ -242,9 +297,18 @@ export function LibraryPanel({
|
|||||||
busy={busy}
|
busy={busy}
|
||||||
run={run}
|
run={run}
|
||||||
onAuthenticated={async (result) => {
|
onAuthenticated={async (result) => {
|
||||||
setUser(result.user);
|
const authenticatedUser = result.user;
|
||||||
|
if (!authenticatedUser) throw new Error("로그인 정보를 확인하지 못했습니다");
|
||||||
|
setUser(authenticatedUser);
|
||||||
setCsrf(result.csrf_token);
|
setCsrf(result.csrf_token);
|
||||||
setTab("browse");
|
setTab(
|
||||||
|
initialTab === "admin" && authenticatedUser.role === "admin" ? "admin" : "browse"
|
||||||
|
);
|
||||||
|
setMessage(
|
||||||
|
initialTab === "admin" && authenticatedUser.role !== "admin"
|
||||||
|
? "관리자 권한이 필요합니다"
|
||||||
|
: ""
|
||||||
|
);
|
||||||
await refreshItems();
|
await refreshItems();
|
||||||
}}
|
}}
|
||||||
onSignedOut={() => {
|
onSignedOut={() => {
|
||||||
@@ -255,11 +319,16 @@ export function LibraryPanel({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tab === "upload" && user && (
|
{tab === "upload" && user && (
|
||||||
<UploadTab csrf={csrf} busy={busy} run={run} onUploaded={async () => {
|
<UploadTab
|
||||||
await refreshItems();
|
csrf={csrf}
|
||||||
setTab("browse");
|
busy={busy}
|
||||||
setMessage("MIDI를 업로드했습니다");
|
run={run}
|
||||||
}} />
|
onUploaded={async () => {
|
||||||
|
await refreshItems();
|
||||||
|
setTab("browse");
|
||||||
|
setMessage("MIDI를 업로드했습니다");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{tab === "admin" && user?.role === "admin" && (
|
{tab === "admin" && user?.role === "admin" && (
|
||||||
<AdminTab csrf={csrf} busy={busy} run={run} currentUserId={user.id} />
|
<AdminTab csrf={csrf} busy={busy} run={run} currentUserId={user.id} />
|
||||||
@@ -270,8 +339,20 @@ export function LibraryPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) {
|
function TabButton({
|
||||||
return <button type="button" className={active ? "active" : ""} onClick={onClick}>{children}</button>;
|
active,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button type="button" className={active ? "active" : ""} onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BrowseTab(props: {
|
function BrowseTab(props: {
|
||||||
@@ -295,24 +376,55 @@ function BrowseTab(props: {
|
|||||||
return (
|
return (
|
||||||
<div className="library-browse">
|
<div className="library-browse">
|
||||||
<div className="library-filters">
|
<div className="library-filters">
|
||||||
<input value={props.search} onChange={(event) => props.setSearch(event.target.value)} placeholder="곡명·가수·태그 검색" />
|
<input
|
||||||
|
value={props.search}
|
||||||
|
onChange={(event) => props.setSearch(event.target.value)}
|
||||||
|
placeholder="곡명·가수·태그 검색"
|
||||||
|
/>
|
||||||
<select value={props.genre} onChange={(event) => props.setGenre(event.target.value)}>
|
<select value={props.genre} onChange={(event) => props.setGenre(event.target.value)}>
|
||||||
<option value="">모든 장르</option>
|
<option value="">모든 장르</option>
|
||||||
{props.genres.map((value) => <option key={value}>{value}</option>)}
|
{props.genres.map((value) => (
|
||||||
|
<option key={value}>{value}</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
<select value={props.difficulty} onChange={(event) => props.setDifficulty(event.target.value)}>
|
<select
|
||||||
|
value={props.difficulty}
|
||||||
|
onChange={(event) => props.setDifficulty(event.target.value)}
|
||||||
|
>
|
||||||
<option value="">모든 난이도</option>
|
<option value="">모든 난이도</option>
|
||||||
{Object.entries(DIFFICULTY_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
{Object.entries(DIFFICULTY_LABELS).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
<select value={props.arrangement} onChange={(event) => props.setArrangement(event.target.value)}>
|
<select
|
||||||
|
value={props.arrangement}
|
||||||
|
onChange={(event) => props.setArrangement(event.target.value)}
|
||||||
|
>
|
||||||
<option value="">모든 형태</option>
|
<option value="">모든 형태</option>
|
||||||
{Object.entries(ARRANGEMENT_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
{Object.entries(ARRANGEMENT_LABELS).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="library-tag-filter">
|
<div className="library-tag-filter">
|
||||||
{props.activeTag && <button type="button" className="active" onClick={() => props.setActiveTag("")}>#{props.activeTag} 지우기</button>}
|
{props.activeTag && (
|
||||||
|
<button type="button" className="active" onClick={() => props.setActiveTag("")}>
|
||||||
|
#{props.activeTag} 지우기
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{props.tags.slice(0, 20).map((tag) => (
|
{props.tags.slice(0, 20).map((tag) => (
|
||||||
<button type="button" key={tag} className={props.activeTag === tag ? "active" : ""} onClick={() => props.setActiveTag(tag)}>#{tag}</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
key={tag}
|
||||||
|
className={props.activeTag === tag ? "active" : ""}
|
||||||
|
onClick={() => props.setActiveTag(tag)}
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{props.groups.length === 0 && <div className="library-empty">조건에 맞는 곡이 없습니다.</div>}
|
{props.groups.length === 0 && <div className="library-empty">조건에 맞는 곡이 없습니다.</div>}
|
||||||
@@ -320,7 +432,10 @@ function BrowseTab(props: {
|
|||||||
{props.groups.map((group) => (
|
{props.groups.map((group) => (
|
||||||
<article className="library-group" key={group[0].group_key}>
|
<article className="library-group" key={group[0].group_key}>
|
||||||
<div className="library-group-title">
|
<div className="library-group-title">
|
||||||
<div><strong>{group[0].title}</strong><span>{group[0].artist || "아티스트 미지정"}</span></div>
|
<div>
|
||||||
|
<strong>{group[0].title}</strong>
|
||||||
|
<span>{group[0].artist || "아티스트 미지정"}</span>
|
||||||
|
</div>
|
||||||
<small>{group.length}개 버전</small>
|
<small>{group.length}개 버전</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="library-variants">
|
<div className="library-variants">
|
||||||
@@ -328,16 +443,43 @@ function BrowseTab(props: {
|
|||||||
<div className="library-variant" key={item.id}>
|
<div className="library-variant" key={item.id}>
|
||||||
<div className="library-variant-main">
|
<div className="library-variant-main">
|
||||||
<strong>{ARRANGEMENT_LABELS[item.arrangement]}</strong>
|
<strong>{ARRANGEMENT_LABELS[item.arrangement]}</strong>
|
||||||
<span>{DIFFICULTY_LABELS[item.difficulty]} · {item.genre || "장르 미지정"}</span>
|
<span>
|
||||||
|
{DIFFICULTY_LABELS[item.difficulty]} · {item.genre || "장르 미지정"}
|
||||||
|
</span>
|
||||||
<p>{item.description || "설명이 없습니다"}</p>
|
<p>{item.description || "설명이 없습니다"}</p>
|
||||||
<div className="library-tag-row">{item.tags.map((tag) => <button type="button" key={tag} onClick={() => props.setActiveTag(tag)}>#{tag}</button>)}</div>
|
<div className="library-tag-row">
|
||||||
<small>{VISIBILITY_LABELS[item.visibility]} · {item.owner}{item.min_tier > 1 ? ` · ${TIER_LABELS[item.min_tier]} 이상` : ""}</small>
|
{item.tags.map((tag) => (
|
||||||
|
<button type="button" key={tag} onClick={() => props.setActiveTag(tag)}>
|
||||||
|
#{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<small>
|
||||||
|
{VISIBILITY_LABELS[item.visibility]} · {item.owner}
|
||||||
|
{item.min_tier > 1 ? ` · ${TIER_LABELS[item.min_tier]} 이상` : ""}
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="library-variant-actions">
|
<div className="library-variant-actions">
|
||||||
<button type="button" className="primary" disabled={props.busy} onClick={() => props.loadItem(item)}>연주하기</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary"
|
||||||
|
disabled={props.busy}
|
||||||
|
onClick={() => props.loadItem(item)}
|
||||||
|
>
|
||||||
|
연주하기
|
||||||
|
</button>
|
||||||
{item.can_edit && (
|
{item.can_edit && (
|
||||||
<select value={item.visibility} onChange={(event) => props.updateVisibility(item, event.target.value as MidiItem["visibility"])}>
|
<select
|
||||||
{Object.entries(VISIBILITY_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
value={item.visibility}
|
||||||
|
onChange={(event) =>
|
||||||
|
props.updateVisibility(item, event.target.value as MidiItem["visibility"])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Object.entries(VISIBILITY_LABELS).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -373,27 +515,85 @@ function AccountTab(props: {
|
|||||||
return (
|
return (
|
||||||
<div className="account-card">
|
<div className="account-card">
|
||||||
<div className="account-mode">
|
<div className="account-mode">
|
||||||
<button type="button" className={mode === "login" ? "active" : ""} onClick={() => setMode("login")}>로그인</button>
|
<button
|
||||||
<button type="button" className={mode === "register" ? "active" : ""} onClick={() => setMode("register")}>회원 가입</button>
|
type="button"
|
||||||
|
className={mode === "login" ? "active" : ""}
|
||||||
|
onClick={() => setMode("login")}
|
||||||
|
>
|
||||||
|
로그인
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={mode === "register" ? "active" : ""}
|
||||||
|
onClick={() => setMode("register")}
|
||||||
|
>
|
||||||
|
회원 가입
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={(event) => {
|
<form
|
||||||
event.preventDefault();
|
onSubmit={(event) => {
|
||||||
props.run(async () => {
|
event.preventDefault();
|
||||||
const body = mode === "login" ? { username, password } : { username, password, display_name: displayName, email: email || null };
|
props.run(async () => {
|
||||||
const result = await apiJson<AuthResult>(`/api/library/auth/${mode === "login" ? "login" : "register"}`, {
|
const body =
|
||||||
method: "POST", headers: authHeaders(null), body: JSON.stringify(body),
|
mode === "login"
|
||||||
|
? { username, password }
|
||||||
|
: { username, password, display_name: displayName, email: email || null };
|
||||||
|
const result = await apiJson<AuthResult>(
|
||||||
|
`/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);
|
>
|
||||||
});
|
<label>
|
||||||
}}>
|
아이디
|
||||||
<label>아이디<input autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} required /></label>
|
<input
|
||||||
{mode === "register" && <>
|
autoComplete="username"
|
||||||
<label>표시 이름<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} required /></label>
|
value={username}
|
||||||
<label>이메일 선택<input type="email" value={email} onChange={(event) => setEmail(event.target.value)} /></label>
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
</>}
|
required
|
||||||
<label>비밀번호<input type="password" autoComplete={mode === "login" ? "current-password" : "new-password"} value={password} onChange={(event) => setPassword(event.target.value)} minLength={10} required /></label>
|
/>
|
||||||
<button type="submit" className="primary" disabled={props.busy}>{mode === "login" ? "로그인" : "가입하고 로그인"}</button>
|
</label>
|
||||||
|
{mode === "register" && (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
표시 이름
|
||||||
|
<input
|
||||||
|
value={displayName}
|
||||||
|
onChange={(event) => setDisplayName(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
이메일 선택
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<label>
|
||||||
|
비밀번호
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
minLength={10}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="primary" disabled={props.busy}>
|
||||||
|
{mode === "login" ? "로그인" : "가입하고 로그인"}
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -403,102 +603,313 @@ function AccountTab(props: {
|
|||||||
<div className="account-sections">
|
<div className="account-sections">
|
||||||
<section className="account-card">
|
<section className="account-card">
|
||||||
<h3>{props.user.display_name}</h3>
|
<h3>{props.user.display_name}</h3>
|
||||||
<p>@{props.user.username} · {TIER_LABELS[props.user.tier]} · {props.user.role === "admin" ? "관리자" : props.user.role === "moderator" ? "운영자" : "회원"}</p>
|
<p>
|
||||||
<button type="button" onClick={() => props.run(async () => {
|
@{props.user.username} · {TIER_LABELS[props.user.tier]} ·{" "}
|
||||||
await apiJson("/api/library/auth/logout", { method: "POST", headers: authHeaders(props.csrf, false) });
|
{props.user.role === "admin"
|
||||||
props.onSignedOut();
|
? "관리자"
|
||||||
})}>로그아웃</button>
|
: props.user.role === "moderator"
|
||||||
|
? "운영자"
|
||||||
|
: "회원"}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
props.run(async () => {
|
||||||
|
await apiJson("/api/library/auth/logout", {
|
||||||
|
method: "POST",
|
||||||
|
headers: authHeaders(props.csrf, false),
|
||||||
|
});
|
||||||
|
props.onSignedOut();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
로그아웃
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
<section className="account-card">
|
<section className="account-card">
|
||||||
<h3>비밀번호 변경</h3>
|
<h3>비밀번호 변경</h3>
|
||||||
<form onSubmit={(event) => {
|
<form
|
||||||
event.preventDefault();
|
onSubmit={(event) => {
|
||||||
props.run(async () => {
|
event.preventDefault();
|
||||||
await apiJson("/api/library/auth/change-password", {
|
props.run(async () => {
|
||||||
method: "POST", headers: authHeaders(props.csrf), body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
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();
|
}}
|
||||||
});
|
>
|
||||||
}}>
|
<label>
|
||||||
<label>현재 비밀번호<input type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} required /></label>
|
현재 비밀번호
|
||||||
<label>새 비밀번호<input type="password" autoComplete="new-password" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} minLength={10} required /></label>
|
<input
|
||||||
<button type="submit" disabled={props.busy}>변경하고 다시 로그인</button>
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
새 비밀번호
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(event) => setNewPassword(event.target.value)}
|
||||||
|
minLength={10}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={props.busy}>
|
||||||
|
변경하고 다시 로그인
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
<section className="account-card danger-zone">
|
<section className="account-card danger-zone">
|
||||||
<h3>회원 탈퇴</h3>
|
<h3>회원 탈퇴</h3>
|
||||||
<p>본인이 올린 MIDI와 공유 설정이 모두 삭제되며 되돌릴 수 없습니다.</p>
|
<p>본인이 올린 MIDI와 공유 설정이 모두 삭제되며 되돌릴 수 없습니다.</p>
|
||||||
<form onSubmit={(event) => {
|
<form
|
||||||
event.preventDefault();
|
onSubmit={(event) => {
|
||||||
if (!window.confirm("회원 계정과 업로드한 MIDI를 모두 삭제할까요?")) return;
|
event.preventDefault();
|
||||||
props.run(async () => {
|
if (!window.confirm("회원 계정과 업로드한 MIDI를 모두 삭제할까요?")) return;
|
||||||
await apiJson("/api/library/auth/withdraw", {
|
props.run(async () => {
|
||||||
method: "POST", headers: authHeaders(props.csrf), body: JSON.stringify({ password: withdrawPassword, confirmation: withdrawConfirmation }),
|
await apiJson("/api/library/auth/withdraw", {
|
||||||
|
method: "POST",
|
||||||
|
headers: authHeaders(props.csrf),
|
||||||
|
body: JSON.stringify({
|
||||||
|
password: withdrawPassword,
|
||||||
|
confirmation: withdrawConfirmation,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
props.onSignedOut();
|
||||||
});
|
});
|
||||||
props.onSignedOut();
|
}}
|
||||||
});
|
>
|
||||||
}}>
|
<label>
|
||||||
<label>비밀번호<input type="password" value={withdrawPassword} onChange={(event) => setWithdrawPassword(event.target.value)} required /></label>
|
비밀번호
|
||||||
<label>확인 문구<input value={withdrawConfirmation} onChange={(event) => setWithdrawConfirmation(event.target.value)} placeholder="회원 탈퇴" required /></label>
|
<input
|
||||||
<button type="submit" className="danger" disabled={props.busy}>회원 탈퇴</button>
|
type="password"
|
||||||
|
value={withdrawPassword}
|
||||||
|
onChange={(event) => setWithdrawPassword(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
확인 문구
|
||||||
|
<input
|
||||||
|
value={withdrawConfirmation}
|
||||||
|
onChange={(event) => setWithdrawConfirmation(event.target.value)}
|
||||||
|
placeholder="회원 탈퇴"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="danger" disabled={props.busy}>
|
||||||
|
회원 탈퇴
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UploadTab({ csrf, busy, run, onUploaded }: { csrf: string | null; busy: boolean; run: (action: () => Promise<void>) => Promise<void>; onUploaded: () => Promise<void> }) {
|
function UploadTab({
|
||||||
|
csrf,
|
||||||
|
busy,
|
||||||
|
run,
|
||||||
|
onUploaded,
|
||||||
|
}: {
|
||||||
|
csrf: string | null;
|
||||||
|
busy: boolean;
|
||||||
|
run: (action: () => Promise<void>) => Promise<void>;
|
||||||
|
onUploaded: () => Promise<void>;
|
||||||
|
}) {
|
||||||
const [visibility, setVisibility] = useState<MidiItem["visibility"]>("private");
|
const [visibility, setVisibility] = useState<MidiItem["visibility"]>("private");
|
||||||
return (
|
return (
|
||||||
<form className="upload-form" onSubmit={(event) => {
|
<form
|
||||||
event.preventDefault();
|
className="upload-form"
|
||||||
const form = new FormData(event.currentTarget);
|
onSubmit={(event) => {
|
||||||
if (visibility === "public") form.set("min_tier", "0");
|
event.preventDefault();
|
||||||
run(async () => {
|
const form = new FormData(event.currentTarget);
|
||||||
const response = await fetch("/api/library/midi", { method: "POST", headers: authHeaders(csrf, false), body: form });
|
if (visibility === "public") form.set("min_tier", "0");
|
||||||
const data = await response.json().catch(() => ({}));
|
run(async () => {
|
||||||
if (!response.ok) throw new Error(data.detail || "업로드하지 못했습니다");
|
const response = await fetch("/api/library/midi", {
|
||||||
event.currentTarget.reset();
|
method: "POST",
|
||||||
setVisibility("private");
|
headers: authHeaders(csrf, false),
|
||||||
await onUploaded();
|
body: form,
|
||||||
});
|
});
|
||||||
}}>
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(data.detail || "업로드하지 못했습니다");
|
||||||
|
event.currentTarget.reset();
|
||||||
|
setVisibility("private");
|
||||||
|
await onUploaded();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
<h3>MIDI 업로드</h3>
|
<h3>MIDI 업로드</h3>
|
||||||
<p>파일은 MIDI 헤더와 크기를 검사합니다. 저작권을 보유하거나 공유 허가를 받은 곡만 올려 주세요.</p>
|
<p>
|
||||||
<label className="wide">MIDI 파일<input type="file" name="file" accept=".mid,.midi" required /></label>
|
파일은 MIDI 헤더와 크기를 검사합니다. 저작권을 보유하거나 공유 허가를 받은 곡만 올려 주세요.
|
||||||
<label>곡 제목<input name="title" maxLength={120} required /></label>
|
</p>
|
||||||
<label>아티스트·작곡가<input name="artist" maxLength={80} /></label>
|
<label className="wide">
|
||||||
<label>난이도<select name="difficulty" defaultValue="beginner">{Object.entries(DIFFICULTY_LABELS).map(([value, label]) => <option value={value} key={value}>{label}</option>)}</select></label>
|
MIDI 파일
|
||||||
<label>형태<select name="arrangement" defaultValue="melody">{Object.entries(ARRANGEMENT_LABELS).map(([value, label]) => <option value={value} key={value}>{label}</option>)}</select></label>
|
<input type="file" name="file" accept=".mid,.midi" required />
|
||||||
<label>장르<input name="genre" placeholder="가요풍, 클래식, 재즈" maxLength={40} /></label>
|
</label>
|
||||||
<label>태그<input name="tags" placeholder="왼손, 겨울, 발라드" /></label>
|
<label>
|
||||||
<label>공개 범위<select name="visibility" value={visibility} onChange={(event) => setVisibility(event.target.value as MidiItem["visibility"])}>{Object.entries(VISIBILITY_LABELS).map(([value, label]) => <option value={value} key={value}>{label}</option>)}</select></label>
|
곡 제목
|
||||||
<label>최소 회원 등급<select name="min_tier" disabled={visibility === "public"} defaultValue="1"><option value="1">일반 회원</option><option value="2">우수 회원</option><option value="3">지도 회원</option><option value="4">관리자</option></select></label>
|
<input name="title" maxLength={120} required />
|
||||||
{visibility === "shared" && <label className="wide">공유할 회원 아이디<input name="shared_usernames" placeholder="user1, user2" /></label>}
|
</label>
|
||||||
<label className="wide">설명<textarea name="description" rows={3} maxLength={500} /></label>
|
<label>
|
||||||
<button type="submit" className="primary wide" disabled={busy}>업로드</button>
|
아티스트·작곡가
|
||||||
|
<input name="artist" maxLength={80} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
난이도
|
||||||
|
<select name="difficulty" defaultValue="beginner">
|
||||||
|
{Object.entries(DIFFICULTY_LABELS).map(([value, label]) => (
|
||||||
|
<option value={value} key={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
형태
|
||||||
|
<select name="arrangement" defaultValue="melody">
|
||||||
|
{Object.entries(ARRANGEMENT_LABELS).map(([value, label]) => (
|
||||||
|
<option value={value} key={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
장르
|
||||||
|
<input name="genre" placeholder="가요풍, 클래식, 재즈" maxLength={40} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
태그
|
||||||
|
<input name="tags" placeholder="왼손, 겨울, 발라드" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
공개 범위
|
||||||
|
<select
|
||||||
|
name="visibility"
|
||||||
|
value={visibility}
|
||||||
|
onChange={(event) => setVisibility(event.target.value as MidiItem["visibility"])}
|
||||||
|
>
|
||||||
|
{Object.entries(VISIBILITY_LABELS).map(([value, label]) => (
|
||||||
|
<option value={value} key={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
최소 회원 등급
|
||||||
|
<select name="min_tier" disabled={visibility === "public"} defaultValue="1">
|
||||||
|
<option value="1">일반 회원</option>
|
||||||
|
<option value="2">우수 회원</option>
|
||||||
|
<option value="3">지도 회원</option>
|
||||||
|
<option value="4">관리자</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{visibility === "shared" && (
|
||||||
|
<label className="wide">
|
||||||
|
공유할 회원 아이디
|
||||||
|
<input name="shared_usernames" placeholder="user1, user2" />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="wide">
|
||||||
|
설명
|
||||||
|
<textarea name="description" rows={3} maxLength={500} />
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="primary wide" disabled={busy}>
|
||||||
|
업로드
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdminTab({ csrf, busy, run, currentUserId }: { csrf: string | null; busy: boolean; run: (action: () => Promise<void>) => Promise<void>; currentUserId: number }) {
|
function AdminTab({
|
||||||
|
csrf,
|
||||||
|
busy,
|
||||||
|
run,
|
||||||
|
currentUserId,
|
||||||
|
}: {
|
||||||
|
csrf: string | null;
|
||||||
|
busy: boolean;
|
||||||
|
run: (action: () => Promise<void>) => Promise<void>;
|
||||||
|
currentUserId: number;
|
||||||
|
}) {
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const load = useCallback(() => apiJson<{ users: User[] }>("/api/library/admin/users").then((result) => setUsers(result.users)), []);
|
const load = useCallback(
|
||||||
useEffect(() => { load().catch(() => setUsers([])); }, [load]);
|
() =>
|
||||||
const change = (id: number, patch: Partial<User>) => run(async () => {
|
apiJson<{ users: User[] }>("/api/library/admin/users").then((result) =>
|
||||||
const updated = await apiJson<User>(`/api/library/admin/users/${id}`, { method: "PATCH", headers: authHeaders(csrf), body: JSON.stringify(patch) });
|
setUsers(result.users)
|
||||||
setUsers((current) => current.map((item) => item.id === id ? updated : item));
|
),
|
||||||
});
|
[]
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
load().catch(() => setUsers([]));
|
||||||
|
}, [load]);
|
||||||
|
const change = (id: number, patch: Partial<User>) =>
|
||||||
|
run(async () => {
|
||||||
|
const updated = await apiJson<User>(`/api/library/admin/users/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: authHeaders(csrf),
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
setUsers((current) => current.map((item) => (item.id === id ? updated : item)));
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<div className="admin-users">
|
<div className="admin-users">
|
||||||
<h3>회원 관리</h3>
|
<h3>회원 관리</h3>
|
||||||
<p>비밀번호 원문은 관리자도 볼 수 없습니다. 등급과 역할 및 이용 상태만 변경할 수 있습니다.</p>
|
<p>비밀번호 원문은 관리자도 볼 수 없습니다. 등급과 역할 및 이용 상태만 변경할 수 있습니다.</p>
|
||||||
{users.map((account) => (
|
{users.map((account) => (
|
||||||
<div className="admin-user-row" key={account.id}>
|
<div className="admin-user-row" key={account.id}>
|
||||||
<div><strong>{account.display_name}</strong><span>@{account.username}{account.id === currentUserId ? " · 현재 계정" : ""}</span></div>
|
<div>
|
||||||
<select value={account.tier} disabled={busy} onChange={(event) => change(account.id, { tier: Number(event.target.value) })}>{[1, 2, 3, 4].map((tier) => <option value={tier} key={tier}>{TIER_LABELS[tier]}</option>)}</select>
|
<strong>{account.display_name}</strong>
|
||||||
<select value={account.role} disabled={busy || account.id === currentUserId} onChange={(event) => change(account.id, { role: event.target.value as User["role"] })}><option value="member">회원</option><option value="moderator">운영자</option><option value="admin">관리자</option></select>
|
<span>
|
||||||
<select value={account.status} disabled={busy || account.id === currentUserId} onChange={(event) => change(account.id, { status: event.target.value as User["status"] })}><option value="active">정상</option><option value="suspended">이용 중지</option></select>
|
@{account.username}
|
||||||
|
{account.id === currentUserId ? " · 현재 계정" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={account.tier}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) => change(account.id, { tier: Number(event.target.value) })}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4].map((tier) => (
|
||||||
|
<option value={tier} key={tier}>
|
||||||
|
{TIER_LABELS[tier]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={account.role}
|
||||||
|
disabled={busy || account.id === currentUserId}
|
||||||
|
onChange={(event) => change(account.id, { role: event.target.value as User["role"] })}
|
||||||
|
>
|
||||||
|
<option value="member">회원</option>
|
||||||
|
<option value="moderator">운영자</option>
|
||||||
|
<option value="admin">관리자</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={account.status}
|
||||||
|
disabled={busy || account.id === currentUserId}
|
||||||
|
onChange={(event) =>
|
||||||
|
change(account.id, { status: event.target.value as User["status"] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="active">정상</option>
|
||||||
|
<option value="suspended">이용 중지</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+293
-61
@@ -58,6 +58,15 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.library-account-button {
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-admin-button {
|
||||||
|
background: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
.library-overlay {
|
.library-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -92,9 +101,18 @@
|
|||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-header > div { display: flex; flex-direction: column; gap: 4px; }
|
.library-header > div {
|
||||||
.library-header strong { font-size: 20px; }
|
display: flex;
|
||||||
.library-header span { color: var(--color-text-muted); font-size: 12px; }
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.library-header strong {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.library-header span {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
.library-header button,
|
.library-header button,
|
||||||
.library-tabs button,
|
.library-tabs button,
|
||||||
.library-tag-filter button,
|
.library-tag-filter button,
|
||||||
@@ -107,13 +125,40 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-header button { padding: 8px 14px; }
|
.library-header button {
|
||||||
.library-tabs { display: flex; gap: 6px; padding: 10px 18px; overflow-x: auto; border-bottom: 1px solid var(--color-border); }
|
padding: 8px 14px;
|
||||||
.library-tabs button { padding: 8px 14px; white-space: nowrap; }
|
}
|
||||||
|
.library-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.library-tabs button {
|
||||||
|
padding: 8px 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.library-tabs button.active,
|
.library-tabs button.active,
|
||||||
.account-mode button.active { background: var(--color-accent); color: #fff; border-color: transparent; }
|
.account-mode button.active {
|
||||||
.library-message { margin: 10px 18px 0; padding: 10px 12px; border-radius: 8px; background: rgba(147, 51, 234, 0.12); color: var(--color-text); font-size: 13px; }
|
background: var(--color-accent);
|
||||||
.library-body { flex: 1; min-height: 0; overflow: auto; padding: 18px; }
|
color: #fff;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.library-message {
|
||||||
|
margin: 10px 18px 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(147, 51, 234, 0.12);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.library-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.library-filters {
|
.library-filters {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -135,30 +180,106 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.library-tag-filter,
|
.library-tag-filter,
|
||||||
.library-tag-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
.library-tag-row {
|
||||||
.library-tag-filter { padding: 12px 0; }
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.library-tag-filter {
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
.library-tag-filter button,
|
.library-tag-filter button,
|
||||||
.library-tag-row button { padding: 5px 8px; font-size: 11px; }
|
.library-tag-row button {
|
||||||
.library-tag-filter button.active { background: var(--color-accent); color: #fff; }
|
padding: 5px 8px;
|
||||||
.library-groups { display: grid; gap: 14px; }
|
font-size: 11px;
|
||||||
.library-group { border: 1px solid var(--color-border); border-radius: 14px; overflow: hidden; background: var(--color-surface-2); }
|
}
|
||||||
.library-group-title { display: flex; justify-content: space-between; gap: 14px; padding: 14px 16px; border-bottom: 1px solid var(--color-border); }
|
.library-tag-filter button.active {
|
||||||
.library-group-title > div { display: flex; flex-direction: column; gap: 3px; }
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.library-groups {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.library-group {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
}
|
||||||
|
.library-group-title {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.library-group-title > div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
.library-group-title span,
|
.library-group-title span,
|
||||||
.library-group-title small { color: var(--color-text-muted); font-size: 12px; }
|
.library-group-title small {
|
||||||
.library-variants { display: grid; }
|
color: var(--color-text-muted);
|
||||||
.library-variant { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 14px 16px; }
|
font-size: 12px;
|
||||||
.library-variant + .library-variant { border-top: 1px solid var(--color-border); }
|
}
|
||||||
.library-variant-main { min-width: 0; }
|
.library-variants {
|
||||||
.library-variant-main > strong { display: block; font-size: 14px; }
|
display: grid;
|
||||||
|
}
|
||||||
|
.library-variant {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.library-variant + .library-variant {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.library-variant-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.library-variant-main > strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
.library-variant-main > span,
|
.library-variant-main > span,
|
||||||
.library-variant-main > small { display: block; margin-top: 3px; color: var(--color-text-muted); font-size: 11px; }
|
.library-variant-main > small {
|
||||||
.library-variant-main p { margin: 7px 0; font-size: 12px; line-height: 1.5; }
|
display: block;
|
||||||
.library-variant-actions { width: 150px; display: flex; flex-direction: column; gap: 7px; flex: 0 0 auto; }
|
margin-top: 3px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.library-variant-main p {
|
||||||
|
margin: 7px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.library-variant-actions {
|
||||||
|
width: 150px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
.library-variant-actions button,
|
.library-variant-actions button,
|
||||||
.upload-form button { min-height: 38px; border: 0; border-radius: 8px; cursor: pointer; }
|
.upload-form button {
|
||||||
.library-panel .primary { background: var(--color-accent); color: #fff; font-weight: 700; }
|
min-height: 38px;
|
||||||
.library-empty { padding: 48px; text-align: center; color: var(--color-text-muted); }
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.library-panel .primary {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.library-empty {
|
||||||
|
padding: 48px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.upload-form {
|
.upload-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -169,45 +290,156 @@
|
|||||||
}
|
}
|
||||||
.upload-form h3,
|
.upload-form h3,
|
||||||
.upload-form p,
|
.upload-form p,
|
||||||
.upload-form .wide { grid-column: 1 / -1; }
|
.upload-form .wide {
|
||||||
.upload-form p { margin: -4px 0 4px; color: var(--color-text-muted); font-size: 12px; }
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.upload-form p {
|
||||||
|
margin: -4px 0 4px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
.upload-form label,
|
.upload-form label,
|
||||||
.account-card label { display: flex; flex-direction: column; gap: 6px; color: var(--color-text-muted); font-size: 12px; }
|
.account-card label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.account-sections { max-width: 720px; margin: 0 auto; display: grid; gap: 14px; }
|
.account-sections {
|
||||||
.account-card { max-width: 520px; margin: 0 auto; padding: 20px; border: 1px solid var(--color-border); border-radius: 14px; background: var(--color-surface-2); }
|
max-width: 720px;
|
||||||
.account-card h3 { margin: 0 0 8px; }
|
margin: 0 auto;
|
||||||
.account-card p { color: var(--color-text-muted); font-size: 12px; line-height: 1.5; }
|
display: grid;
|
||||||
.account-card form { display: grid; gap: 12px; }
|
gap: 14px;
|
||||||
.account-card button { min-height: 38px; padding: 0 14px; }
|
}
|
||||||
.account-mode { display: flex; gap: 6px; margin-bottom: 16px; }
|
.account-card {
|
||||||
.account-mode button { flex: 1; }
|
max-width: 520px;
|
||||||
.danger-zone { border-color: rgba(220, 38, 38, 0.45); }
|
margin: 0 auto;
|
||||||
.library-panel .danger { background: #b91c1c; color: #fff; border: 0; }
|
padding: 20px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
}
|
||||||
|
.account-card h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
.account-card p {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.account-card form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.account-card button {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
.account-mode {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.account-mode button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.danger-zone {
|
||||||
|
border-color: rgba(220, 38, 38, 0.45);
|
||||||
|
}
|
||||||
|
.library-panel .danger {
|
||||||
|
background: #b91c1c;
|
||||||
|
color: #fff;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-users { display: grid; gap: 10px; }
|
.admin-users {
|
||||||
.admin-users > p { margin: 0 0 8px; color: var(--color-text-muted); font-size: 12px; }
|
display: grid;
|
||||||
.admin-user-row { display: grid; grid-template-columns: minmax(180px, 2fr) repeat(3, minmax(110px, 1fr)); gap: 8px; align-items: center; padding: 12px; border: 1px solid var(--color-border); border-radius: 10px; }
|
gap: 10px;
|
||||||
.admin-user-row > div { display: flex; flex-direction: column; gap: 3px; }
|
}
|
||||||
.admin-user-row span { color: var(--color-text-muted); font-size: 11px; }
|
.admin-users > p {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.admin-user-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 2fr) repeat(3, minmax(110px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.admin-user-row > div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.admin-user-row span {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.library-open-button { padding: 0 8px; font-size: 11px; }
|
.library-open-button {
|
||||||
.library-overlay { padding: 0; }
|
padding: 0 8px;
|
||||||
.library-panel { width: 100%; height: 100dvh; border: 0; border-radius: 0; }
|
font-size: 11px;
|
||||||
.library-header { padding: 13px 14px; }
|
}
|
||||||
.library-header strong { font-size: 17px; }
|
.library-account-button {
|
||||||
.library-body { padding: 12px; }
|
max-width: 82px;
|
||||||
.library-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
overflow: hidden;
|
||||||
.library-filters input { grid-column: 1 / -1; }
|
text-overflow: ellipsis;
|
||||||
.library-variant { align-items: stretch; flex-direction: column; gap: 12px; }
|
}
|
||||||
.library-variant-actions { width: 100%; display: grid; grid-template-columns: 1fr 1fr; }
|
.library-overlay {
|
||||||
.upload-form { grid-template-columns: 1fr; }
|
padding: 0;
|
||||||
|
}
|
||||||
|
.library-panel {
|
||||||
|
width: 100%;
|
||||||
|
height: 100dvh;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.library-header {
|
||||||
|
padding: 13px 14px;
|
||||||
|
}
|
||||||
|
.library-header strong {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
.library-body {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.library-filters {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.library-filters input {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.library-variant {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.library-variant-actions {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.upload-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.upload-form h3,
|
.upload-form h3,
|
||||||
.upload-form p,
|
.upload-form p,
|
||||||
.upload-form .wide { grid-column: auto; }
|
.upload-form .wide {
|
||||||
.admin-user-row { grid-template-columns: 1fr 1fr; }
|
grid-column: auto;
|
||||||
.admin-user-row > div { grid-column: 1 / -1; }
|
}
|
||||||
|
.admin-user-row {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.admin-user-row > div {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] {
|
[data-theme="light"] {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { TransportControls } from "../components/Transport/TransportControls";
|
|||||||
import { SettingsPanel } from "../components/Transport/SettingsPanel";
|
import { SettingsPanel } from "../components/Transport/SettingsPanel";
|
||||||
import { DevicePanel } from "../components/DevicePanel/DevicePanel";
|
import { DevicePanel } from "../components/DevicePanel/DevicePanel";
|
||||||
import { LibraryPanel } from "../components/Library/LibraryPanel";
|
import { LibraryPanel } from "../components/Library/LibraryPanel";
|
||||||
|
import type { LibraryTab } from "../components/Library/LibraryPanel";
|
||||||
import { useAppStore } from "../store/useAppStore";
|
import { useAppStore } from "../store/useAppStore";
|
||||||
import type { LayoutMode } from "../store/useAppStore";
|
import type { LayoutMode } from "../store/useAppStore";
|
||||||
import bus from "../engine/EventBus";
|
import bus from "../engine/EventBus";
|
||||||
@@ -29,6 +30,12 @@ const TAB_TITLES: Record<LayoutMode, string> = {
|
|||||||
export function PracticeView() {
|
export function PracticeView() {
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [libraryOpen, setLibraryOpen] = useState(false);
|
const [libraryOpen, setLibraryOpen] = useState(false);
|
||||||
|
const [libraryInitialTab, setLibraryInitialTab] = useState<LibraryTab>("browse");
|
||||||
|
|
||||||
|
const openLibrary = (tab: LibraryTab) => {
|
||||||
|
setLibraryInitialTab(tab);
|
||||||
|
setLibraryOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
loadMidiFile,
|
loadMidiFile,
|
||||||
@@ -236,13 +243,29 @@ export function PracticeView() {
|
|||||||
{/* Right: MIDI device chip + theme toggle + settings gear */}
|
{/* Right: MIDI device chip + theme toggle + settings gear */}
|
||||||
<div className="flex items-center gap-1.5" style={{ marginLeft: "auto" }}>
|
<div className="flex items-center gap-1.5" style={{ marginLeft: "auto" }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setLibraryOpen(true)}
|
onClick={() => openLibrary("browse")}
|
||||||
title="곡 보관함 열기"
|
title="곡 보관함 열기"
|
||||||
aria-label="곡 보관함 열기"
|
aria-label="곡 보관함 열기"
|
||||||
className="library-open-button"
|
className="library-open-button"
|
||||||
>
|
>
|
||||||
곡 보관함
|
곡 보관함
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openLibrary("account")}
|
||||||
|
title="로그인 또는 회원 가입"
|
||||||
|
aria-label="로그인 또는 회원 가입"
|
||||||
|
className="library-open-button library-account-button"
|
||||||
|
>
|
||||||
|
로그인·회원가입
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openLibrary("admin")}
|
||||||
|
title="관리자 패널 열기"
|
||||||
|
aria-label="관리자 패널 열기"
|
||||||
|
className="library-open-button library-admin-button"
|
||||||
|
>
|
||||||
|
관리자
|
||||||
|
</button>
|
||||||
<DevicePanel />
|
<DevicePanel />
|
||||||
<button
|
<button
|
||||||
onClick={() => updateSettings({ theme: settings.theme === "dark" ? "light" : "dark" })}
|
onClick={() => updateSettings({ theme: settings.theme === "dark" ? "light" : "dark" })}
|
||||||
@@ -344,7 +367,12 @@ export function PracticeView() {
|
|||||||
{/* ── Settings panel (managed here, gear is in header) ───────────────── */}
|
{/* ── Settings panel (managed here, gear is in header) ───────────────── */}
|
||||||
<SettingsPanel open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
<SettingsPanel open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||||
|
|
||||||
<LibraryPanel open={libraryOpen} onClose={() => setLibraryOpen(false)} onLoad={loadFile} />
|
<LibraryPanel
|
||||||
|
open={libraryOpen}
|
||||||
|
initialTab={libraryInitialTab}
|
||||||
|
onClose={() => setLibraryOpen(false)}
|
||||||
|
onLoad={loadFile}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
workbox: {
|
workbox: {
|
||||||
|
// 새 배포를 발견하면 대기 단계 없이 바로 활성화하고 열린 화면을
|
||||||
|
// 즉시 관리한다. 사용자가 오래된 회원 메뉴를 계속 보지 않게 한다.
|
||||||
|
skipWaiting: true,
|
||||||
|
clientsClaim: true,
|
||||||
|
cleanupOutdatedCaches: true,
|
||||||
// Precache built static assets so the shell loads instantly + works offline.
|
// Precache built static assets so the shell loads instantly + works offline.
|
||||||
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
|
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user