회원과 곡 보관함 기능을 추가한다

회원 가입과 로그인 및 등급별 접근과 공개 범위를 구현했다.`nMIDI 업로드와 검색 및 같은 곡의 버전 묶음을 추가했다.`n비밀번호 변경과 회원 탈퇴 및 관리자 회원 관리를 포함했다.`n직접 만든 공개 샘플 MIDI 네 곡과 운영 문서를 추가했다.
This commit is contained in:
이창현
2026-10-03 10:12:17 +09:00
parent e7acd97798
commit a41da4029f
16 changed files with 1779 additions and 5 deletions
@@ -0,0 +1,506 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
type User = {
id: number;
username: string;
display_name: string;
email?: string | null;
tier: number;
role: "member" | "moderator" | "admin";
status?: "active" | "suspended";
created_at?: string;
};
type MidiItem = {
id: string;
title: string;
artist: string;
description: string;
original_name: string;
size_bytes: number;
visibility: "public" | "members" | "shared" | "private";
min_tier: number;
difficulty: "beginner" | "easy" | "intermediate" | "advanced" | "expert";
genre: string;
arrangement: "melody" | "accompaniment" | "piano_solo" | "duet" | "exercise";
tags: string[];
owner: string;
owner_username: string | null;
shared_usernames: string[];
can_edit: boolean;
created_at: string;
group_key: string;
};
type AuthResult = { user: User | null; csrf_token: string | null };
type Tab = "browse" | "upload" | "account" | "admin";
const TIER_LABELS: Record<number, string> = {
0: "비회원",
1: "일반 회원",
2: "우수 회원",
3: "지도 회원",
4: "관리자",
};
const DIFFICULTY_LABELS: Record<MidiItem["difficulty"], string> = {
beginner: "입문",
easy: "쉬움",
intermediate: "중급",
advanced: "고급",
expert: "전문가",
};
const ARRANGEMENT_LABELS: Record<MidiItem["arrangement"], string> = {
melody: "멜로디",
accompaniment: "반주",
piano_solo: "피아노 독주",
duet: "연탄·듀엣",
exercise: "연습 패턴",
};
const VISIBILITY_LABELS: Record<MidiItem["visibility"], string> = {
public: "전체 공개",
members: "회원 공개",
shared: "지정 회원 공유",
private: "나만 보기",
};
async function apiJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init);
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.detail || "요청을 처리하지 못했습니다");
return data as T;
}
function authHeaders(csrf: string | null, json = true): HeadersInit {
const headers: Record<string, string> = {};
if (json) headers["Content-Type"] = "application/json";
if (csrf) headers["X-CSRF-Token"] = csrf;
return headers;
}
export function LibraryPanel({
open,
onClose,
onLoad,
}: {
open: boolean;
onClose: () => void;
onLoad: (file: File) => void;
}) {
const [tab, setTab] = useState<Tab>("browse");
const [user, setUser] = useState<User | null>(null);
const [csrf, setCsrf] = useState<string | null>(null);
const [items, setItems] = useState<MidiItem[]>([]);
const [genres, setGenres] = useState<string[]>([]);
const [tags, setTags] = useState<string[]>([]);
const [search, setSearch] = useState("");
const [genre, setGenre] = useState("");
const [difficulty, setDifficulty] = useState("");
const [arrangement, setArrangement] = useState("");
const [activeTag, setActiveTag] = useState("");
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
const refreshAuth = useCallback(async () => {
const result = await apiJson<AuthResult>("/api/library/auth/me");
setUser(result.user);
setCsrf(result.csrf_token);
}, []);
const refreshItems = useCallback(async () => {
const params = new URLSearchParams();
if (search.trim()) params.set("search", search.trim());
if (genre) params.set("genre", genre);
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}`
);
setItems(result.items);
setGenres(result.facets.genres);
setTags(result.facets.tags);
}, [search, genre, difficulty, arrangement, activeTag]);
useEffect(() => {
if (!open) return;
refreshAuth().catch((error) => setMessage(error.message));
}, [open, refreshAuth]);
useEffect(() => {
if (!open) return;
const timer = window.setTimeout(() => refreshItems().catch((error) => setMessage(error.message)), 180);
return () => window.clearTimeout(timer);
}, [open, refreshItems, user]);
const groups = useMemo(() => {
const result = new Map<string, MidiItem[]>();
for (const item of items) {
const list = result.get(item.group_key) ?? [];
list.push(item);
result.set(item.group_key, list);
}
return [...result.values()];
}, [items]);
const run = async (action: () => Promise<void>) => {
setBusy(true);
setMessage("");
try {
await action();
} catch (error) {
setMessage(error instanceof Error ? error.message : "요청을 처리하지 못했습니다");
} finally {
setBusy(false);
}
};
const loadItem = (item: MidiItem) =>
run(async () => {
const response = await fetch(`/api/library/midi/${item.id}/file`);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || "MIDI 파일을 불러오지 못했습니다");
}
const blob = await response.blob();
onLoad(new File([blob], item.original_name, { type: "audio/midi" }));
onClose();
});
const updateVisibility = (item: MidiItem, visibility: MidiItem["visibility"]) =>
run(async () => {
let shared_usernames: string[] | undefined;
if (visibility === "shared") {
const text = window.prompt("공유할 회원 아이디를 쉼표로 구분해 입력하세요", item.shared_usernames.join(", "));
if (text === null) return;
shared_usernames = text.split(",").map((value) => value.trim()).filter(Boolean);
}
await apiJson(`/api/library/midi/${item.id}`, {
method: "PATCH",
headers: authHeaders(csrf),
body: JSON.stringify({
visibility,
min_tier: visibility === "public" ? 0 : Math.max(1, item.min_tier),
shared_usernames,
}),
});
await refreshItems();
setMessage("공개 범위를 변경했습니다");
});
if (!open) return null;
return (
<div className="library-overlay" role="dialog" aria-modal="true" aria-label="곡 보관함">
<section className="library-panel">
<header className="library-header">
<div>
<strong>곡 보관함</strong>
<span>{user ? `${user.display_name} · ${TIER_LABELS[user.tier]}` : "비회원은 공개 곡을 바로 연주할 수 있습니다"}</span>
</div>
<button type="button" onClick={onClose} aria-label="곡 보관함 닫기">닫기</button>
</header>
<nav className="library-tabs" aria-label="곡 보관함 메뉴">
<TabButton active={tab === "browse"} onClick={() => setTab("browse")}>곡 찾기</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>
{message && <div className="library-message" role="status">{message}</div>}
<div className="library-body" aria-busy={busy}>
{tab === "browse" && (
<BrowseTab
groups={groups}
genres={genres}
tags={tags}
search={search}
genre={genre}
difficulty={difficulty}
arrangement={arrangement}
activeTag={activeTag}
setSearch={setSearch}
setGenre={setGenre}
setDifficulty={setDifficulty}
setArrangement={setArrangement}
setActiveTag={setActiveTag}
loadItem={loadItem}
updateVisibility={updateVisibility}
busy={busy}
/>
)}
{tab === "account" && (
<AccountTab
user={user}
csrf={csrf}
busy={busy}
run={run}
onAuthenticated={async (result) => {
setUser(result.user);
setCsrf(result.csrf_token);
setTab("browse");
await refreshItems();
}}
onSignedOut={() => {
setUser(null);
setCsrf(null);
setTab("browse");
}}
/>
)}
{tab === "upload" && user && (
<UploadTab csrf={csrf} busy={busy} run={run} onUploaded={async () => {
await refreshItems();
setTab("browse");
setMessage("MIDI를 업로드했습니다");
}} />
)}
{tab === "admin" && user?.role === "admin" && (
<AdminTab csrf={csrf} busy={busy} run={run} currentUserId={user.id} />
)}
</div>
</section>
</div>
);
}
function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) {
return <button type="button" className={active ? "active" : ""} onClick={onClick}>{children}</button>;
}
function BrowseTab(props: {
groups: MidiItem[][];
genres: string[];
tags: string[];
search: string;
genre: string;
difficulty: string;
arrangement: string;
activeTag: string;
setSearch: (value: string) => void;
setGenre: (value: string) => void;
setDifficulty: (value: string) => void;
setArrangement: (value: string) => void;
setActiveTag: (value: string) => void;
loadItem: (item: MidiItem) => void;
updateVisibility: (item: MidiItem, visibility: MidiItem["visibility"]) => void;
busy: boolean;
}) {
return (
<div className="library-browse">
<div className="library-filters">
<input value={props.search} onChange={(event) => props.setSearch(event.target.value)} placeholder="곡명·가수·태그 검색" />
<select value={props.genre} onChange={(event) => props.setGenre(event.target.value)}>
<option value="">모든 장르</option>
{props.genres.map((value) => <option key={value}>{value}</option>)}
</select>
<select value={props.difficulty} onChange={(event) => props.setDifficulty(event.target.value)}>
<option value="">모든 난이도</option>
{Object.entries(DIFFICULTY_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
<select value={props.arrangement} onChange={(event) => props.setArrangement(event.target.value)}>
<option value="">모든 형태</option>
{Object.entries(ARRANGEMENT_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
</div>
<div className="library-tag-filter">
{props.activeTag && <button type="button" className="active" onClick={() => props.setActiveTag("")}>#{props.activeTag} 지우기</button>}
{props.tags.slice(0, 20).map((tag) => (
<button type="button" key={tag} className={props.activeTag === tag ? "active" : ""} onClick={() => props.setActiveTag(tag)}>#{tag}</button>
))}
</div>
{props.groups.length === 0 && <div className="library-empty">조건에 맞는 곡이 없습니다.</div>}
<div className="library-groups">
{props.groups.map((group) => (
<article className="library-group" key={group[0].group_key}>
<div className="library-group-title">
<div><strong>{group[0].title}</strong><span>{group[0].artist || "아티스트 미지정"}</span></div>
<small>{group.length}개 버전</small>
</div>
<div className="library-variants">
{group.map((item) => (
<div className="library-variant" key={item.id}>
<div className="library-variant-main">
<strong>{ARRANGEMENT_LABELS[item.arrangement]}</strong>
<span>{DIFFICULTY_LABELS[item.difficulty]} · {item.genre || "장르 미지정"}</span>
<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>
<small>{VISIBILITY_LABELS[item.visibility]} · {item.owner}{item.min_tier > 1 ? ` · ${TIER_LABELS[item.min_tier]} 이상` : ""}</small>
</div>
<div className="library-variant-actions">
<button type="button" className="primary" disabled={props.busy} onClick={() => props.loadItem(item)}>연주하기</button>
{item.can_edit && (
<select 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>
)}
</div>
</div>
))}
</div>
</article>
))}
</div>
</div>
);
}
function AccountTab(props: {
user: User | null;
csrf: string | null;
busy: boolean;
run: (action: () => Promise<void>) => Promise<void>;
onAuthenticated: (result: AuthResult) => Promise<void>;
onSignedOut: () => void;
}) {
const [mode, setMode] = useState<"login" | "register">("login");
const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [withdrawPassword, setWithdrawPassword] = useState("");
const [withdrawConfirmation, setWithdrawConfirmation] = useState("");
if (!props.user) {
return (
<div className="account-card">
<div className="account-mode">
<button type="button" className={mode === "login" ? "active" : ""} onClick={() => setMode("login")}>로그인</button>
<button type="button" className={mode === "register" ? "active" : ""} onClick={() => setMode("register")}>회원 가입</button>
</div>
<form onSubmit={(event) => {
event.preventDefault();
props.run(async () => {
const 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);
});
}}>
<label>아이디<input autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} required /></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>
</div>
);
}
return (
<div className="account-sections">
<section className="account-card">
<h3>{props.user.display_name}</h3>
<p>@{props.user.username} · {TIER_LABELS[props.user.tier]} · {props.user.role === "admin" ? "관리자" : 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 className="account-card">
<h3>비밀번호 변경</h3>
<form onSubmit={(event) => {
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();
});
}}>
<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>
<button type="submit" disabled={props.busy}>변경하고 다시 로그인</button>
</form>
</section>
<section className="account-card danger-zone">
<h3>회원 탈퇴</h3>
<p>본인이 올린 MIDI와 공유 설정이 모두 삭제되며 되돌릴 수 없습니다.</p>
<form onSubmit={(event) => {
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();
});
}}>
<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>
<button type="submit" className="danger" disabled={props.busy}>회원 탈퇴</button>
</form>
</section>
</div>
);
}
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");
return (
<form className="upload-form" onSubmit={(event) => {
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();
});
}}>
<h3>MIDI 업로드</h3>
<p>파일은 MIDI 헤더와 크기를 검사합니다. 저작권을 보유하거나 공유 허가를 받은 곡만 올려 주세요.</p>
<label className="wide">MIDI 파일<input type="file" name="file" accept=".mid,.midi" required /></label>
<label>곡 제목<input name="title" maxLength={120} required /></label>
<label>아티스트·작곡가<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>
);
}
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 load = useCallback(() => apiJson<{ users: User[] }>("/api/library/admin/users").then((result) => setUsers(result.users)), []);
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 (
<div className="admin-users">
<h3>회원 관리</h3>
<p>비밀번호 원문은 관리자도 볼 수 없습니다. 등급과 역할 및 이용 상태만 변경할 수 있습니다.</p>
{users.map((account) => (
<div className="admin-user-row" key={account.id}>
<div><strong>{account.display_name}</strong><span>@{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>
);
}
+166
View File
@@ -44,6 +44,172 @@
--color-right-hand-text: #22d3ee;
}
/* ── ITOSM 곡 보관함과 회원 관리 ───────────────────────────────────────── */
.library-open-button {
min-height: 30px;
padding: 0 12px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-accent);
color: #fff;
font-size: 12px;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.library-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 18px;
background: rgba(10, 8, 20, 0.72);
backdrop-filter: blur(8px);
}
.library-panel {
width: min(1120px, 100%);
height: min(820px, calc(100vh - 36px));
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: 18px;
background: var(--color-surface);
color: var(--color-text);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
}
.library-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 22px;
border-bottom: 1px solid var(--color-border);
}
.library-header > div { display: flex; 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-tabs button,
.library-tag-filter button,
.library-tag-row button,
.account-card button {
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface-2);
color: var(--color-text);
cursor: pointer;
}
.library-header button { padding: 8px 14px; }
.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,
.account-mode button.active { background: var(--color-accent); 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 {
display: grid;
grid-template-columns: minmax(220px, 2fr) repeat(3, minmax(120px, 1fr));
gap: 8px;
}
.library-panel input,
.library-panel select,
.library-panel textarea {
width: 100%;
min-height: 38px;
padding: 8px 10px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface-2);
color: var(--color-text);
font: inherit;
}
.library-tag-filter,
.library-tag-row { display: flex; flex-wrap: wrap; gap: 6px; }
.library-tag-filter { padding: 12px 0; }
.library-tag-filter button,
.library-tag-row button { padding: 5px 8px; font-size: 11px; }
.library-tag-filter button.active { 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 small { color: var(--color-text-muted); font-size: 12px; }
.library-variants { 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 > small { display: block; 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,
.upload-form button { min-height: 38px; 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 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 13px;
max-width: 820px;
margin: 0 auto;
}
.upload-form h3,
.upload-form p,
.upload-form .wide { grid-column: 1 / -1; }
.upload-form p { margin: -4px 0 4px; color: var(--color-text-muted); font-size: 12px; }
.upload-form label,
.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-card { max-width: 520px; margin: 0 auto; 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 > 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) {
.library-open-button { padding: 0 8px; font-size: 11px; }
.library-overlay { 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 p,
.upload-form .wide { grid-column: auto; }
.admin-user-row { grid-template-columns: 1fr 1fr; }
.admin-user-row > div { grid-column: 1 / -1; }
}
[data-theme="light"] {
/* Surfaces */
--color-bg: #ffffff;
+12
View File
@@ -5,6 +5,7 @@ import { SheetMusicView } from "../components/SheetMusic/SheetMusicView";
import { TransportControls } from "../components/Transport/TransportControls";
import { SettingsPanel } from "../components/Transport/SettingsPanel";
import { DevicePanel } from "../components/DevicePanel/DevicePanel";
import { LibraryPanel } from "../components/Library/LibraryPanel";
import { useAppStore } from "../store/useAppStore";
import type { LayoutMode } from "../store/useAppStore";
import bus from "../engine/EventBus";
@@ -27,6 +28,7 @@ const TAB_TITLES: Record<LayoutMode, string> = {
export function PracticeView() {
const [settingsOpen, setSettingsOpen] = useState(false);
const [libraryOpen, setLibraryOpen] = useState(false);
const {
loadMidiFile,
@@ -233,6 +235,14 @@ export function PracticeView() {
{/* Right: MIDI device chip + theme toggle + settings gear */}
<div className="flex items-center gap-1.5" style={{ marginLeft: "auto" }}>
<button
onClick={() => setLibraryOpen(true)}
title="곡 보관함 열기"
aria-label="곡 보관함 열기"
className="library-open-button"
>
곡 보관함
</button>
<DevicePanel />
<button
onClick={() => updateSettings({ theme: settings.theme === "dark" ? "light" : "dark" })}
@@ -333,6 +343,8 @@ export function PracticeView() {
{/* ── Settings panel (managed here, gear is in header) ───────────────── */}
<SettingsPanel open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<LibraryPanel open={libraryOpen} onClose={() => setLibraryOpen(false)} onLoad={loadFile} />
</div>
);
}