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

회원 가입과 로그인 및 등급별 접근과 공개 범위를 구현했다.`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
+1
View File
@@ -103,6 +103,7 @@ lerna-debug.log*
# ── Docker / runtime data ─────────────────────────────────────────────────────
.docker-data/
/data/
*.pid
# ── Misc ──────────────────────────────────────────────────────────────────────
+5 -1
View File
@@ -13,6 +13,7 @@
| 기본 주소 | `https://learn.itosm.co.kr` |
| 별칭 주소 | `https://learn.itosm.com` |
| MusicXML 보관 폴더 | `/home/syskiller/piano/music/musicxml` |
| 회원 DB와 MIDI 보관 | `/home/syskiller/piano/bach-to-basics/data` |
백엔드는 호스트 포트를 열지 않는다. 프론트엔드 nginx만 Docker 내부 네트워크에서 백엔드에 접근한다. 외부 요청은 aaPanel nginx와 프론트엔드 nginx를 차례로 거친다.
@@ -42,6 +43,8 @@ docker compose down
프론트엔드 컨테이너가 비밀 파일을 읽어 백엔드로 보내는 내부 요청에만 인증 헤더를 추가한다.
회원용 비밀번호 원문은 저장하지 않는다. 초기 관리자 비밀번호만 `secrets/library_admin_password`에 두고 파일 권한을 600으로 유지한다. 회원 DB와 업로드 MIDI는 `data`에 보관하며 Git에서 제외한다. 상세 권한과 백업 방법은 `docs/LIBRARY-AUTH-KO.md`에 기록했다.
## 업데이트
1. `git status --short --branch`로 로컬 변경을 확인한다.
@@ -83,4 +86,5 @@ MIDI와 MusicXML은 브라우저 파일 선택이나 끌어 놓기로 불러온
- MusicXML과 MIDI 및 기본 악보 변환 기능은 설치되어 있다.
- 실제 USB MIDI 입력과 따라치기 동작은 안드로이드 Chrome과 실제 전자피아노에서 최종 확인해야 한다.
- iPhone과 iPad의 Safari는 Web MIDI를 지원하지 않는다.
- 관리자 곡 업로드와 사용자 곡 목록 선택은 기본 운영 안정화 뒤 진행할 후속 기능이다.
- 회원 가입과 로그인 및 등급별 곡 보관함을 제공한다. 회원은 MIDI를 올리고 전체 공개와 회원 공개 및 지정 공유와 나만 보기 중 하나를 선택할 수 있다.
- 회원은 현재 비밀번호를 확인한 뒤 비밀번호를 변경하거나 탈퇴할 수 있다.
+267
View File
@@ -0,0 +1,267 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import secrets
import shutil
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Iterator
DATA_ROOT = Path(os.environ.get("LIBRARY_DATA_DIR", "/data"))
DATABASE_PATH = DATA_ROOT / "library.sqlite3"
MIDI_ROOT = DATA_ROOT / "midi"
SESSION_DAYS = int(os.environ.get("SESSION_DAYS", "30"))
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@contextmanager
def connect() -> Iterator[sqlite3.Connection]:
connection = sqlite3.connect(DATABASE_PATH, timeout=20)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
connection.execute("PRAGMA journal_mode = WAL")
try:
yield connection
connection.commit()
except Exception:
connection.rollback()
raise
finally:
connection.close()
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
digest = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=2**14, r=8, p=1, dklen=32)
return "scrypt$16384$8$1$%s$%s" % (
base64.urlsafe_b64encode(salt).decode("ascii"),
base64.urlsafe_b64encode(digest).decode("ascii"),
)
def verify_password(password: str, stored: str) -> bool:
try:
algorithm, n, r, p, salt_text, digest_text = stored.split("$", 5)
if algorithm != "scrypt":
return False
salt = base64.urlsafe_b64decode(salt_text.encode("ascii"))
expected = base64.urlsafe_b64decode(digest_text.encode("ascii"))
actual = hashlib.scrypt(
password.encode("utf-8"), salt=salt, n=int(n), r=int(r), p=int(p), dklen=len(expected)
)
return hmac.compare_digest(actual, expected)
except (ValueError, TypeError):
return False
def token_digest(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def create_session(user_id: int) -> tuple[str, str]:
token = secrets.token_urlsafe(36)
csrf_token = secrets.token_urlsafe(24)
expires_at = (datetime.now(timezone.utc) + timedelta(days=SESSION_DAYS)).isoformat()
with connect() as connection:
connection.execute("DELETE FROM sessions WHERE expires_at <= ?", (utc_now(),))
connection.execute(
"INSERT INTO sessions (token_hash, user_id, csrf_token, expires_at, created_at) VALUES (?, ?, ?, ?, ?)",
(token_digest(token), user_id, csrf_token, expires_at, utc_now()),
)
return token, csrf_token
def get_user_for_session(token: str | None) -> dict | None:
if not token:
return None
with connect() as connection:
row = connection.execute(
"""
SELECT u.id, u.username, u.display_name, u.email, u.tier, u.role, u.status,
s.csrf_token, s.expires_at
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.expires_at > ?
""",
(token_digest(token), utc_now()),
).fetchone()
if row is None or row["status"] != "active":
return None
return dict(row)
def delete_session(token: str | None) -> None:
if not token:
return
with connect() as connection:
connection.execute("DELETE FROM sessions WHERE token_hash = ?", (token_digest(token),))
SAMPLES = (
{
"storage_name": "first-snow-melody.mid",
"title": "첫눈이 내리는 밤",
"artist": "ITOSM 연습곡",
"description": "오른손으로 천천히 익히는 다장조 멜로디 연습곡",
"difficulty": "beginner",
"genre": "연습곡",
"arrangement": "melody",
"tags": ["다장조", "오른손", "겨울", "입문"],
},
{
"storage_name": "first-snow-accompaniment.mid",
"title": "첫눈이 내리는 밤",
"artist": "ITOSM 연습곡",
"description": "같은 곡을 양손 반주형으로 편곡한 중급 연습 버전",
"difficulty": "intermediate",
"genre": "연습곡",
"arrangement": "accompaniment",
"tags": ["다장조", "양손", "반주", "겨울"],
},
{
"storage_name": "blue-breeze-walk.mid",
"title": "파란 바람 산책",
"artist": "ITOSM 연습곡",
"description": "가벼운 리듬으로 연주하는 쉬운 멜로디",
"difficulty": "easy",
"genre": "가요풍",
"arrangement": "melody",
"tags": ["멜로디", "리듬", "쉬움", "가요풍"],
},
{
"storage_name": "little-morning-waltz.mid",
"title": "아침의 작은 왈츠",
"artist": "ITOSM 연습곡",
"description": "3박자와 왼손 화음을 함께 익히는 피아노 독주곡",
"difficulty": "intermediate",
"genre": "왈츠",
"arrangement": "piano_solo",
"tags": ["3박자", "왈츠", "양손", "화음"],
},
)
def _seed_samples() -> None:
source_root = Path(__file__).with_name("sample_midi")
with connect() as connection:
for sample in SAMPLES:
source = source_root / sample["storage_name"]
if not source.exists():
continue
target = MIDI_ROOT / sample["storage_name"]
if not target.exists():
shutil.copyfile(source, target)
existing = connection.execute(
"SELECT id FROM midi_items WHERE seed_key = ?", (sample["storage_name"],)
).fetchone()
if existing:
continue
content = target.read_bytes()
connection.execute(
"""
INSERT INTO midi_items (
id, owner_id, seed_key, title, artist, description, original_name,
storage_name, size_bytes, sha256, visibility, min_tier, difficulty,
genre, arrangement, tags_json, created_at, updated_at
) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, 'public', 0, ?, ?, ?, ?, ?, ?)
""",
(
secrets.token_hex(16), sample["storage_name"], sample["title"], sample["artist"],
sample["description"], sample["storage_name"], sample["storage_name"], len(content),
hashlib.sha256(content).hexdigest(), sample["difficulty"], sample["genre"],
sample["arrangement"], json.dumps(sample["tags"], ensure_ascii=False), utc_now(), utc_now(),
),
)
def initialize_database() -> None:
DATA_ROOT.mkdir(parents=True, exist_ok=True)
MIDI_ROOT.mkdir(parents=True, exist_ok=True)
DATA_ROOT.chmod(0o750)
MIDI_ROOT.chmod(0o750)
with connect() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
display_name TEXT NOT NULL,
email TEXT UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
tier INTEGER NOT NULL DEFAULT 1 CHECK (tier BETWEEN 1 AND 4),
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('member', 'moderator', 'admin')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended')),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
csrf_token TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE TABLE IF NOT EXISTS midi_items (
id TEXT PRIMARY KEY,
owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
seed_key TEXT UNIQUE,
title TEXT NOT NULL,
artist TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
original_name TEXT NOT NULL,
storage_name TEXT NOT NULL UNIQUE,
size_bytes INTEGER NOT NULL,
sha256 TEXT NOT NULL,
visibility TEXT NOT NULL CHECK (visibility IN ('public', 'members', 'shared', 'private')),
min_tier INTEGER NOT NULL DEFAULT 0 CHECK (min_tier BETWEEN 0 AND 4),
difficulty TEXT NOT NULL CHECK (difficulty IN ('beginner', 'easy', 'intermediate', 'advanced', 'expert')),
genre TEXT NOT NULL DEFAULT '',
arrangement TEXT NOT NULL CHECK (arrangement IN ('melody', 'accompaniment', 'piano_solo', 'duet', 'exercise')),
tags_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_midi_title ON midi_items(title COLLATE NOCASE);
CREATE INDEX IF NOT EXISTS idx_midi_owner ON midi_items(owner_id);
CREATE TABLE IF NOT EXISTS midi_shares (
midi_id TEXT NOT NULL REFERENCES midi_items(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (midi_id, user_id)
);
"""
)
DATABASE_PATH.chmod(0o600)
admin_username = os.environ.get("BOOTSTRAP_ADMIN_USERNAME", "").strip()
password_file = os.environ.get("BOOTSTRAP_ADMIN_PASSWORD_FILE", "").strip()
if admin_username and password_file:
existing = connection.execute(
"SELECT id FROM users WHERE username = ? COLLATE NOCASE", (admin_username,)
).fetchone()
if existing is None:
password = Path(password_file).read_text(encoding="utf-8").strip()
if len(password) < 10:
raise RuntimeError("초기 관리자 비밀번호는 10자 이상이어야 합니다")
connection.execute(
"""
INSERT INTO users (
username, display_name, password_hash, tier, role, status, created_at
) VALUES (?, ?, ?, 4, 'admin', 'active', ?)
""",
(
admin_username,
os.environ.get("BOOTSTRAP_ADMIN_DISPLAY_NAME", "관리자").strip() or "관리자",
hash_password(password),
utc_now(),
),
)
_seed_samples()
+7 -4
View File
@@ -33,7 +33,8 @@ except ImportError:
from fastapi import Depends, FastAPI, HTTPException, Request, Response, Security
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import APIKeyHeader
from routers import export_routes, fingering, omr, transcribe, youtube
from library_db import initialize_database
from routers import export_routes, fingering, library, omr, transcribe, youtube
logging.basicConfig(level=logging.WARNING)
@@ -106,6 +107,7 @@ def _startup_checks() -> None:
@asynccontextmanager
async def _lifespan(_app: FastAPI):
_startup_checks()
initialize_database()
yield
@@ -131,9 +133,9 @@ _allowed_origins = [o.strip() for o in _raw_origins.split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=_allowed_origins,
allow_credentials=False,
allow_methods=["GET", "POST"],
allow_headers=["Content-Type", "X-API-Key"],
allow_credentials=True,
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["Content-Type", "X-API-Key", "X-CSRF-Token"],
)
# ── Security-headers middleware ───────────────────────────────────────────────
@@ -266,6 +268,7 @@ app.include_router(omr.router, prefix="/omr", tags=["omr"])
app.include_router(youtube.router, prefix="/youtube", tags=["youtube"])
app.include_router(fingering.router, prefix="/fingering", tags=["fingering"])
app.include_router(export_routes.router, prefix="/export", tags=["export"])
app.include_router(library.router, prefix="/library", tags=["library"])
@app.get("/health")
+593
View File
@@ -0,0 +1,593 @@
from __future__ import annotations
import hashlib
import hmac
import json
import re
import secrets
from pathlib import Path
from urllib.parse import quote
from fastapi import APIRouter, Cookie, File, Form, HTTPException, Query, Request, Response, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from library_db import (
MIDI_ROOT,
connect,
create_session,
delete_session,
get_user_for_session,
hash_password,
utc_now,
verify_password,
)
router = APIRouter()
COOKIE_NAME = "b2b_session"
MAX_MIDI_BYTES = 10 * 1024 * 1024
MAX_USER_FILES = 100
MAX_USER_BYTES = 500 * 1024 * 1024
USERNAME_RE = re.compile(r"^[A-Za-z0-9_-]{3,30}$")
DIFFICULTIES = {"beginner", "easy", "intermediate", "advanced", "expert"}
ARRANGEMENTS = {"melody", "accompaniment", "piano_solo", "duet", "exercise"}
VISIBILITIES = {"public", "members", "shared", "private"}
class AuthPayload(BaseModel):
username: str = Field(min_length=3, max_length=30)
password: str = Field(min_length=10, max_length=128)
class RegisterPayload(AuthPayload):
display_name: str = Field(min_length=2, max_length=30)
email: str | None = Field(default=None, max_length=120)
class PasswordChangePayload(BaseModel):
current_password: str = Field(min_length=1, max_length=128)
new_password: str = Field(min_length=10, max_length=128)
class WithdrawPayload(BaseModel):
password: str = Field(min_length=1, max_length=128)
confirmation: str
class MidiUpdatePayload(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=120)
artist: str | None = Field(default=None, max_length=80)
description: str | None = Field(default=None, max_length=500)
visibility: str | None = None
min_tier: int | None = Field(default=None, ge=0, le=4)
difficulty: str | None = None
genre: str | None = Field(default=None, max_length=40)
arrangement: str | None = None
tags: list[str] | None = None
shared_usernames: list[str] | None = None
class UserUpdatePayload(BaseModel):
tier: int | None = Field(default=None, ge=1, le=4)
role: str | None = None
status: str | None = None
def _public_user(user: dict | None) -> dict | None:
if user is None:
return None
return {
"id": user["id"],
"username": user["username"],
"display_name": user["display_name"],
"email": user.get("email"),
"tier": user["tier"],
"role": user["role"],
}
def _session_user(session_token: str | None) -> dict | None:
return get_user_for_session(session_token)
def _require_user(session_token: str | None) -> dict:
user = _session_user(session_token)
if user is None:
raise HTTPException(status_code=401, detail="로그인이 필요합니다")
return user
def _require_csrf(request: Request, user: dict) -> None:
supplied = request.headers.get("x-csrf-token", "")
if not supplied or not hmac.compare_digest(supplied, user["csrf_token"]):
raise HTTPException(status_code=403, detail="요청 확인 값이 올바르지 않습니다")
def _require_admin(session_token: str | None) -> dict:
user = _require_user(session_token)
if user["role"] != "admin":
raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다")
return user
def _normalize_tags(tags: str | list[str]) -> list[str]:
values = tags.split(",") if isinstance(tags, str) else tags
cleaned: list[str] = []
for value in values:
item = value.strip()
if not item or item in cleaned:
continue
if len(item) > 30:
raise HTTPException(status_code=400, detail="태그는 30자 이하로 입력하세요")
cleaned.append(item)
if len(cleaned) > 12:
raise HTTPException(status_code=400, detail="태그는 최대 12개까지 사용할 수 있습니다")
return cleaned
def _validate_metadata(visibility: str, min_tier: int, difficulty: str, arrangement: str) -> None:
if visibility not in VISIBILITIES:
raise HTTPException(status_code=400, detail="공개 범위가 올바르지 않습니다")
if difficulty not in DIFFICULTIES:
raise HTTPException(status_code=400, detail="난이도가 올바르지 않습니다")
if arrangement not in ARRANGEMENTS:
raise HTTPException(status_code=400, detail="편곡 형태가 올바르지 않습니다")
if not 0 <= min_tier <= 4:
raise HTTPException(status_code=400, detail="회원 등급 범위가 올바르지 않습니다")
if visibility == "public" and min_tier != 0:
raise HTTPException(status_code=400, detail="전체 공개 곡은 등급 제한을 사용할 수 없습니다")
if visibility != "public" and min_tier < 1:
raise HTTPException(status_code=400, detail="회원 전용 곡은 최소 등급이 1 이상이어야 합니다")
def _resolve_share_ids(connection, usernames: list[str]) -> list[int]:
normalized = [name.strip() for name in usernames if name.strip()]
if not normalized:
return []
placeholders = ",".join("?" for _ in normalized)
rows = connection.execute(
f"SELECT id, username FROM users WHERE username COLLATE NOCASE IN ({placeholders}) AND status = 'active'",
normalized,
).fetchall()
found = {row["username"].lower(): row["id"] for row in rows}
missing = [name for name in normalized if name.lower() not in found]
if missing:
raise HTTPException(status_code=400, detail="존재하지 않는 회원: " + ", ".join(missing))
return list(dict.fromkeys(found.values()))
def _can_access(row: dict, user: dict | None, shared_ids: set[int]) -> bool:
if user and user["role"] == "admin":
return True
if user and row["owner_id"] == user["id"]:
return True
if row["visibility"] == "public":
return True
if user is None or user["tier"] < row["min_tier"]:
return False
if row["visibility"] == "members":
return True
if row["visibility"] == "shared":
return user["id"] in shared_ids
return False
def _serialize_item(row: dict, user: dict | None, shared_usernames: list[str]) -> dict:
tags = json.loads(row["tags_json"] or "[]")
can_edit = bool(user and (user["role"] == "admin" or row["owner_id"] == user["id"]))
return {
"id": row["id"],
"title": row["title"],
"artist": row["artist"],
"description": row["description"],
"original_name": row["original_name"],
"size_bytes": row["size_bytes"],
"visibility": row["visibility"],
"min_tier": row["min_tier"],
"difficulty": row["difficulty"],
"genre": row["genre"],
"arrangement": row["arrangement"],
"tags": tags,
"owner": row["owner_name"] or "ITOSM 피아노 학습실",
"owner_username": row["owner_username"],
"shared_usernames": shared_usernames if can_edit else [],
"can_edit": can_edit,
"created_at": row["created_at"],
"group_key": f"{row['artist'].strip().lower()}::{row['title'].strip().lower()}",
}
def _load_item(item_id: str) -> tuple[dict, set[int], list[str]]:
with connect() as connection:
row = connection.execute(
"""
SELECT m.*, u.display_name AS owner_name, u.username AS owner_username
FROM midi_items m LEFT JOIN users u ON u.id = m.owner_id
WHERE m.id = ?
""",
(item_id,),
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="곡을 찾을 수 없습니다")
shares = connection.execute(
"SELECT s.user_id, u.username FROM midi_shares s JOIN users u ON u.id = s.user_id WHERE s.midi_id = ?",
(item_id,),
).fetchall()
return dict(row), {share["user_id"] for share in shares}, [share["username"] for share in shares]
@router.post("/auth/register")
def register(payload: RegisterPayload, response: Response):
username = payload.username.strip()
display_name = payload.display_name.strip()
email = payload.email.strip().lower() if payload.email else None
if not USERNAME_RE.fullmatch(username):
raise HTTPException(status_code=400, detail="아이디는 영문과 숫자 및 밑줄과 하이픈으로 3~30자까지 입력하세요")
if email and ("@" not in email or email.startswith("@") or email.endswith("@")):
raise HTTPException(status_code=400, detail="이메일 형식이 올바르지 않습니다")
with connect() as connection:
exists = connection.execute(
"SELECT 1 FROM users WHERE username = ? COLLATE NOCASE OR (? IS NOT NULL AND email = ? COLLATE NOCASE)",
(username, email, email),
).fetchone()
if exists:
raise HTTPException(status_code=409, detail="이미 사용 중인 아이디 또는 이메일입니다")
cursor = connection.execute(
"INSERT INTO users (username, display_name, email, password_hash, created_at) VALUES (?, ?, ?, ?, ?)",
(username, display_name, email, hash_password(payload.password), utc_now()),
)
user_id = cursor.lastrowid
token, csrf_token = create_session(user_id)
_set_cookie(response, token)
user = get_user_for_session(token)
return {"user": _public_user(user), "csrf_token": csrf_token}
@router.post("/auth/login")
def login(payload: AuthPayload, response: Response):
with connect() as connection:
row = connection.execute(
"SELECT * FROM users WHERE username = ? COLLATE NOCASE", (payload.username.strip(),)
).fetchone()
if row is None or not verify_password(payload.password, row["password_hash"]):
raise HTTPException(status_code=401, detail="아이디 또는 비밀번호가 올바르지 않습니다")
if row["status"] != "active":
raise HTTPException(status_code=403, detail="사용이 중지된 계정입니다")
token, csrf_token = create_session(row["id"])
_set_cookie(response, token)
user = get_user_for_session(token)
return {"user": _public_user(user), "csrf_token": csrf_token}
def _set_cookie(response: Response, token: str) -> None:
response.set_cookie(
COOKIE_NAME,
token,
max_age=30 * 24 * 60 * 60,
httponly=True,
secure=True,
samesite="lax",
path="/",
)
@router.get("/auth/me")
def me(session_token: str | None = Cookie(default=None, alias=COOKIE_NAME)):
user = _session_user(session_token)
return {"user": _public_user(user), "csrf_token": user["csrf_token"] if user else None}
@router.post("/auth/logout")
def logout(request: Request, response: Response, session_token: str | None = Cookie(default=None, alias=COOKIE_NAME)):
user = _require_user(session_token)
_require_csrf(request, user)
delete_session(session_token)
response.delete_cookie(COOKIE_NAME, path="/")
return {"ok": True}
@router.post("/auth/change-password")
def change_password(
payload: PasswordChangePayload,
request: Request,
response: Response,
session_token: str | None = Cookie(default=None, alias=COOKIE_NAME),
):
user = _require_user(session_token)
_require_csrf(request, user)
with connect() as connection:
row = connection.execute("SELECT password_hash FROM users WHERE id = ?", (user["id"],)).fetchone()
if row is None or not verify_password(payload.current_password, row["password_hash"]):
raise HTTPException(status_code=400, detail="현재 비밀번호가 올바르지 않습니다")
if verify_password(payload.new_password, row["password_hash"]):
raise HTTPException(status_code=400, detail="새 비밀번호는 현재 비밀번호와 달라야 합니다")
connection.execute(
"UPDATE users SET password_hash = ? WHERE id = ?",
(hash_password(payload.new_password), user["id"]),
)
connection.execute("DELETE FROM sessions WHERE user_id = ?", (user["id"],))
response.delete_cookie(COOKIE_NAME, path="/")
return {"ok": True, "message": "비밀번호가 변경되었습니다. 다시 로그인해 주세요."}
@router.post("/auth/withdraw")
def withdraw(
payload: WithdrawPayload,
request: Request,
response: Response,
session_token: str | None = Cookie(default=None, alias=COOKIE_NAME),
):
user = _require_user(session_token)
_require_csrf(request, user)
if payload.confirmation != "회원 탈퇴":
raise HTTPException(status_code=400, detail="확인란에 회원 탈퇴를 정확히 입력하세요")
with connect() as connection:
row = connection.execute("SELECT password_hash FROM users WHERE id = ?", (user["id"],)).fetchone()
if row is None or not verify_password(payload.password, row["password_hash"]):
raise HTTPException(status_code=400, detail="비밀번호가 올바르지 않습니다")
files = connection.execute(
"SELECT storage_name FROM midi_items WHERE owner_id = ?", (user["id"],)
).fetchall()
connection.execute("DELETE FROM midi_items WHERE owner_id = ?", (user["id"],))
connection.execute("DELETE FROM users WHERE id = ?", (user["id"],))
for item in files:
(MIDI_ROOT / item["storage_name"]).unlink(missing_ok=True)
response.delete_cookie(COOKIE_NAME, path="/")
return {"ok": True, "message": "회원 탈퇴가 완료되었습니다"}
@router.get("/midi")
def list_midi(
search: str = Query(default="", max_length=100),
genre: str = Query(default="", max_length=40),
difficulty: str = Query(default="", max_length=20),
arrangement: str = Query(default="", max_length=30),
tag: str = Query(default="", max_length=30),
session_token: str | None = Cookie(default=None, alias=COOKIE_NAME),
):
user = _session_user(session_token)
clauses = ["1=1"]
params: list[object] = []
if search:
clauses.append("(m.title LIKE ? OR m.artist LIKE ? OR m.description LIKE ? OR m.tags_json LIKE ?)")
needle = f"%{search.strip()}%"
params.extend([needle, needle, needle, needle])
if genre:
clauses.append("m.genre = ?")
params.append(genre)
if difficulty:
clauses.append("m.difficulty = ?")
params.append(difficulty)
if arrangement:
clauses.append("m.arrangement = ?")
params.append(arrangement)
if tag:
clauses.append("m.tags_json LIKE ?")
params.append(f'%"{tag}"%')
with connect() as connection:
rows = connection.execute(
f"""
SELECT m.*, u.display_name AS owner_name, u.username AS owner_username
FROM midi_items m LEFT JOIN users u ON u.id = m.owner_id
WHERE {' AND '.join(clauses)}
ORDER BY m.artist COLLATE NOCASE, m.title COLLATE NOCASE, m.difficulty, m.created_at
""",
params,
).fetchall()
share_rows = connection.execute("SELECT midi_id, user_id FROM midi_shares").fetchall()
username_rows = connection.execute(
"SELECT s.midi_id, u.username FROM midi_shares s JOIN users u ON u.id = s.user_id"
).fetchall()
shared_ids: dict[str, set[int]] = {}
shared_names: dict[str, list[str]] = {}
for share in share_rows:
shared_ids.setdefault(share["midi_id"], set()).add(share["user_id"])
for share in username_rows:
shared_names.setdefault(share["midi_id"], []).append(share["username"])
items = []
for source in rows:
row = dict(source)
if _can_access(row, user, shared_ids.get(row["id"], set())):
items.append(_serialize_item(row, user, shared_names.get(row["id"], [])))
facets = {
"genres": sorted({item["genre"] for item in items if item["genre"]}),
"tags": sorted({tag_value for item in items for tag_value in item["tags"]}),
}
return {"items": items, "facets": facets}
@router.get("/midi/{item_id}/file")
def download_midi(item_id: str, session_token: str | None = Cookie(default=None, alias=COOKIE_NAME)):
user = _session_user(session_token)
row, shared_ids, _ = _load_item(item_id)
if not _can_access(row, user, shared_ids):
raise HTTPException(status_code=403, detail="이 곡을 사용할 권한이 없습니다")
path = MIDI_ROOT / row["storage_name"]
if not path.is_file() or path.parent.resolve() != MIDI_ROOT.resolve():
raise HTTPException(status_code=404, detail="MIDI 파일을 찾을 수 없습니다")
encoded = quote(row["original_name"].replace("\r", "").replace("\n", ""))
return FileResponse(
path,
media_type="audio/midi",
headers={"Content-Disposition": f"inline; filename*=UTF-8''{encoded}"},
)
@router.post("/midi")
async def upload_midi(
request: Request,
file: UploadFile = File(...),
title: str = Form(...),
artist: str = Form(""),
description: str = Form(""),
visibility: str = Form("private"),
min_tier: int = Form(1),
difficulty: str = Form("beginner"),
genre: str = Form(""),
arrangement: str = Form("melody"),
tags: str = Form(""),
shared_usernames: str = Form(""),
session_token: str | None = Cookie(default=None, alias=COOKIE_NAME),
):
user = _require_user(session_token)
_require_csrf(request, user)
clean_title = title.strip()
if not clean_title or len(clean_title) > 120:
raise HTTPException(status_code=400, detail="곡 제목은 1~120자로 입력하세요")
_validate_metadata(visibility, min_tier, difficulty, arrangement)
original_name = Path(file.filename or "upload.mid").name
if Path(original_name).suffix.lower() not in {".mid", ".midi"}:
raise HTTPException(status_code=400, detail="MIDI 파일만 업로드할 수 있습니다")
content = await file.read(MAX_MIDI_BYTES + 1)
if len(content) > MAX_MIDI_BYTES:
raise HTTPException(status_code=413, detail="MIDI 파일은 10MB 이하만 업로드할 수 있습니다")
if len(content) < 14 or not content.startswith(b"MThd"):
raise HTTPException(status_code=400, detail="올바른 MIDI 파일이 아닙니다")
item_id = secrets.token_hex(16)
storage_name = f"{item_id}.mid"
target = MIDI_ROOT / storage_name
tag_values = _normalize_tags(tags)
share_names = [name.strip() for name in shared_usernames.split(",") if name.strip()]
with connect() as connection:
usage = connection.execute(
"SELECT COUNT(*) AS file_count, COALESCE(SUM(size_bytes), 0) AS total_bytes FROM midi_items WHERE owner_id = ?",
(user["id"],),
).fetchone()
if usage["file_count"] >= MAX_USER_FILES or usage["total_bytes"] + len(content) > MAX_USER_BYTES:
raise HTTPException(status_code=413, detail="회원별 보관 한도를 초과했습니다")
share_ids = _resolve_share_ids(connection, share_names) if visibility == "shared" else []
target.write_bytes(content)
target.chmod(0o640)
try:
connection.execute(
"""
INSERT INTO midi_items (
id, owner_id, title, artist, description, original_name, storage_name,
size_bytes, sha256, visibility, min_tier, difficulty, genre, arrangement,
tags_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
item_id, user["id"], clean_title, artist.strip()[:80], description.strip()[:500],
original_name, storage_name, len(content), hashlib.sha256(content).hexdigest(),
visibility, min_tier, difficulty, genre.strip()[:40], arrangement,
json.dumps(tag_values, ensure_ascii=False), utc_now(), utc_now(),
),
)
connection.executemany(
"INSERT INTO midi_shares (midi_id, user_id) VALUES (?, ?)",
[(item_id, share_id) for share_id in share_ids],
)
except Exception:
target.unlink(missing_ok=True)
raise
row, _, names = _load_item(item_id)
return _serialize_item(row, user, names)
@router.patch("/midi/{item_id}")
def update_midi(
item_id: str,
payload: MidiUpdatePayload,
request: Request,
session_token: str | None = Cookie(default=None, alias=COOKIE_NAME),
):
user = _require_user(session_token)
_require_csrf(request, user)
row, _, _ = _load_item(item_id)
if user["role"] != "admin" and row["owner_id"] != user["id"]:
raise HTTPException(status_code=403, detail="본인이 올린 곡만 수정할 수 있습니다")
values = payload.model_dump(exclude_unset=True)
visibility = values.get("visibility", row["visibility"])
min_tier = values.get("min_tier", row["min_tier"])
difficulty = values.get("difficulty", row["difficulty"])
arrangement = values.get("arrangement", row["arrangement"])
_validate_metadata(visibility, min_tier, difficulty, arrangement)
if "tags" in values:
values["tags_json"] = json.dumps(_normalize_tags(values.pop("tags")), ensure_ascii=False)
share_names = values.pop("shared_usernames", None)
allowed_columns = {
"title", "artist", "description", "visibility", "min_tier", "difficulty",
"genre", "arrangement", "tags_json",
}
updates = {key: value.strip() if isinstance(value, str) else value for key, value in values.items() if key in allowed_columns}
if "title" in updates and not updates["title"]:
raise HTTPException(status_code=400, detail="곡 제목은 비워둘 수 없습니다")
with connect() as connection:
if updates:
assignments = ", ".join(f"{column} = ?" for column in updates)
connection.execute(
f"UPDATE midi_items SET {assignments}, updated_at = ? WHERE id = ?",
[*updates.values(), utc_now(), item_id],
)
if share_names is not None or visibility != "shared":
connection.execute("DELETE FROM midi_shares WHERE midi_id = ?", (item_id,))
if visibility == "shared":
ids = _resolve_share_ids(connection, share_names or [])
connection.executemany(
"INSERT INTO midi_shares (midi_id, user_id) VALUES (?, ?)",
[(item_id, user_id) for user_id in ids],
)
updated, _, names = _load_item(item_id)
return _serialize_item(updated, user, names)
@router.delete("/midi/{item_id}")
def delete_midi(
item_id: str,
request: Request,
session_token: str | None = Cookie(default=None, alias=COOKIE_NAME),
):
user = _require_user(session_token)
_require_csrf(request, user)
row, _, _ = _load_item(item_id)
if row["seed_key"]:
raise HTTPException(status_code=400, detail="기본 공개 샘플은 삭제할 수 없습니다")
if user["role"] != "admin" and row["owner_id"] != user["id"]:
raise HTTPException(status_code=403, detail="본인이 올린 곡만 삭제할 수 있습니다")
with connect() as connection:
connection.execute("DELETE FROM midi_items WHERE id = ?", (item_id,))
(MIDI_ROOT / row["storage_name"]).unlink(missing_ok=True)
return {"ok": True}
@router.get("/admin/users")
def list_users(session_token: str | None = Cookie(default=None, alias=COOKIE_NAME)):
_require_admin(session_token)
with connect() as connection:
rows = connection.execute(
"SELECT id, username, display_name, email, tier, role, status, created_at FROM users ORDER BY created_at"
).fetchall()
return {"users": [dict(row) for row in rows]}
@router.patch("/admin/users/{user_id}")
def update_user(
user_id: int,
payload: UserUpdatePayload,
request: Request,
session_token: str | None = Cookie(default=None, alias=COOKIE_NAME),
):
admin = _require_admin(session_token)
_require_csrf(request, admin)
values = payload.model_dump(exclude_unset=True)
if values.get("role") not in {None, "member", "moderator", "admin"}:
raise HTTPException(status_code=400, detail="회원 역할이 올바르지 않습니다")
if values.get("status") not in {None, "active", "suspended"}:
raise HTTPException(status_code=400, detail="회원 상태가 올바르지 않습니다")
if user_id == admin["id"] and ("role" in values or values.get("status") == "suspended"):
raise HTTPException(status_code=400, detail="현재 관리자 계정의 역할이나 상태는 직접 변경할 수 없습니다")
if not values:
raise HTTPException(status_code=400, detail="변경할 값이 없습니다")
with connect() as connection:
exists = connection.execute("SELECT id FROM users WHERE id = ?", (user_id,)).fetchone()
if not exists:
raise HTTPException(status_code=404, detail="회원을 찾을 수 없습니다")
assignments = ", ".join(f"{column} = ?" for column in values)
connection.execute(f"UPDATE users SET {assignments} WHERE id = ?", [*values.values(), user_id])
row = connection.execute(
"SELECT id, username, display_name, email, tier, role, status, created_at FROM users WHERE id = ?",
(user_id,),
).fetchone()
return dict(row)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
import importlib
import os
import tempfile
import unittest
from pathlib import Path
class LibraryDatabaseTest(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
os.environ["LIBRARY_DATA_DIR"] = self.temp.name
os.environ.pop("BOOTSTRAP_ADMIN_USERNAME", None)
os.environ.pop("BOOTSTRAP_ADMIN_PASSWORD_FILE", None)
import library_db
self.db = importlib.reload(library_db)
self.db.initialize_database()
def tearDown(self) -> None:
self.temp.cleanup()
def test_password_hash_is_salted_and_verifiable(self) -> None:
first = self.db.hash_password("safe-password-123")
second = self.db.hash_password("safe-password-123")
self.assertNotEqual(first, second)
self.assertTrue(self.db.verify_password("safe-password-123", first))
self.assertFalse(self.db.verify_password("wrong-password", first))
def test_session_round_trip(self) -> None:
with self.db.connect() as connection:
cursor = connection.execute(
"""
INSERT INTO users (username, display_name, password_hash, created_at)
VALUES ('tester', '테스터', ?, ?)
""",
(self.db.hash_password("safe-password-123"), self.db.utc_now()),
)
user_id = cursor.lastrowid
token, csrf = self.db.create_session(user_id)
user = self.db.get_user_for_session(token)
self.assertIsNotNone(user)
self.assertEqual(user["username"], "tester")
self.assertEqual(user["csrf_token"], csrf)
self.db.delete_session(token)
self.assertIsNone(self.db.get_user_for_session(token))
def test_database_directory_is_private_project_data(self) -> None:
self.assertTrue(Path(self.temp.name, "library.sqlite3").is_file())
self.assertTrue(Path(self.temp.name, "midi").is_dir())
if __name__ == "__main__":
unittest.main()
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
from pathlib import Path
OUTPUT = Path(__file__).resolve().parents[1] / "sample_midi"
def variable_length(value: int) -> bytes:
data = [value & 0x7F]
value >>= 7
while value:
data.insert(0, (value & 0x7F) | 0x80)
value >>= 7
return bytes(data)
def track(events: list[tuple[int, bytes]], tempo: int = 500_000, program: int = 0) -> bytes:
body = bytearray()
body.extend(b"\x00\xff\x51\x03" + tempo.to_bytes(3, "big"))
body.extend(b"\x00\xff\x58\x04\x04\x02\x18\x08")
body.extend(bytes([0, 0xC0, program]))
for delta, message in events:
body.extend(variable_length(delta))
body.extend(message)
body.extend(b"\x00\xff\x2f\x00")
return b"MTrk" + len(body).to_bytes(4, "big") + body
def midi_file(events: list[tuple[int, bytes]], tempo: int = 500_000) -> bytes:
header = b"MThd" + (6).to_bytes(4, "big") + (0).to_bytes(2, "big") + (1).to_bytes(2, "big") + (480).to_bytes(2, "big")
return header + track(events, tempo)
def melody(notes: list[int], beats: int = 1, velocity: int = 82) -> list[tuple[int, bytes]]:
result: list[tuple[int, bytes]] = []
for note in notes:
result.append((0, bytes([0x90, note, velocity])))
result.append((480 * beats, bytes([0x80, note, 0])))
return result
def chord_progression(chords: list[list[int]], melody_notes: list[int]) -> list[tuple[int, bytes]]:
result: list[tuple[int, bytes]] = []
for index, note in enumerate(melody_notes):
chord = chords[(index // 4) % len(chords)]
for chord_note in chord:
result.append((0, bytes([0x90, chord_note, 52])))
result.append((0, bytes([0x90, note, 86])))
result.append((480, bytes([0x80, note, 0])))
for chord_note in chord:
result.append((0, bytes([0x80, chord_note, 0])))
return result
def main() -> None:
OUTPUT.mkdir(parents=True, exist_ok=True)
first_snow = [64, 67, 69, 67, 64, 62, 60, 62, 64, 67, 72, 71, 69, 67, 64, 60]
blue_breeze = [60, 62, 64, 67, 69, 67, 64, 62, 64, 65, 67, 72, 69, 67, 64, 60]
morning_waltz = [64, 67, 72, 71, 67, 64, 62, 65, 69, 67, 64, 62, 60, 64, 67, 72]
(OUTPUT / "first-snow-melody.mid").write_bytes(midi_file(melody(first_snow), 560_000))
(OUTPUT / "first-snow-accompaniment.mid").write_bytes(
midi_file(chord_progression([[48, 52, 55], [45, 48, 52], [41, 45, 48], [43, 47, 50]], first_snow), 560_000)
)
(OUTPUT / "blue-breeze-walk.mid").write_bytes(midi_file(melody(blue_breeze), 470_000))
(OUTPUT / "little-morning-waltz.mid").write_bytes(
midi_file(chord_progression([[48, 52, 55], [43, 47, 50], [45, 48, 52]], morning_waltz), 620_000)
)
if __name__ == "__main__":
main()
+8
View File
@@ -27,11 +27,17 @@ services:
- RATE_LIMIT_PER_MIN=${RATE_LIMIT_PER_MIN:-60}
- MUSIC21_TIMEOUT_S=${MUSIC21_TIMEOUT_S:-60}
- FINGERING_TIMEOUT_S=${FINGERING_TIMEOUT_S:-120}
- LIBRARY_DATA_DIR=/data
- BOOTSTRAP_ADMIN_USERNAME=syskiller
- BOOTSTRAP_ADMIN_DISPLAY_NAME=관리자
- BOOTSTRAP_ADMIN_PASSWORD_FILE=/run/secrets/library_admin_password
volumes:
# Mount audiveris.jar from host (optional - PDF import is disabled if absent)
- ./backend/bin:/app/bin:ro
- ./data:/data
secrets:
- backend_api_key
- library_admin_password
frontend:
build:
@@ -52,3 +58,5 @@ services:
secrets:
backend_api_key:
file: ./secrets/backend_api_key
library_admin_password:
file: ./secrets/library_admin_password
+87
View File
@@ -0,0 +1,87 @@
# Bach to Basics 회원과 곡 보관함 운영
## 기능 범위
Bach to Basics의 기존 파일 가져오기 기능을 유지하면서 서버 곡 보관함을 추가했다. 비회원은 전체 공개 MIDI를 바로 연주할 수 있다. 회원은 MIDI를 올리고 공개 범위와 회원 등급을 지정할 수 있다.
공개 범위는 다음 네 단계다.
| 공개 범위 | 접근 대상 |
|---|---|
| 전체 공개 | 비회원 포함 모든 사용자 |
| 회원 공개 | 지정한 최소 등급 이상의 로그인 회원 |
| 지정 회원 공유 | 최소 등급을 만족하며 업로더가 지정한 회원 |
| 나만 보기 | 업로더 본인과 관리자 |
회원 등급은 일반 회원 1단계와 우수 회원 2단계 및 지도 회원 3단계와 관리자 4단계로 구성한다. 역할은 회원과 운영자 및 관리자로 구분한다. 등급은 곡 접근 범위에 사용하며 역할은 운영 권한에 사용한다.
## 회원 기능
- 회원 가입과 로그인 및 로그아웃
- 현재 비밀번호를 확인한 뒤 비밀번호 변경
- 모든 세션을 종료한 뒤 새 비밀번호로 다시 로그인
- 비밀번호와 확인 문구를 입력한 회원 탈퇴
- 탈퇴 시 본인이 올린 MIDI와 공유 정보 제거
- 관리자 화면에서 회원 등급과 역할 및 이용 상태 변경
비밀번호는 `scrypt`로 해시한다. 원문은 DB와 로그에 저장하지 않는다. 로그인 세션 토큰도 SHA-256 해시만 DB에 보관한다. 상태 변경 요청에는 로그인 세션과 CSRF 확인 값을 함께 검사한다.
## MIDI 업로드
- 허용 확장자: `.mid`, `.midi`
- 최대 파일 크기: 10MB
- 회원별 최대 파일 수: 100개
- 회원별 최대 보관 용량: 500MB
- 파일 이름에서 디렉터리 요소 제거
- `MThd` 헤더 확인
- 임의 저장 이름과 SHA-256 해시 기록
- 곡 제목과 아티스트 및 설명
- 난이도와 장르 및 편곡 형태
- 최대 12개 검색 태그
- 전체 공개 곡은 등급 제한 사용 불가
저장 위치는 `/home/syskiller/piano/bach-to-basics/data`다. SQLite DB와 MIDI 파일은 Git에 넣지 않는다. DB 파일 권한은 600이며 데이터 디렉터리는 750으로 유지한다.
## 검색과 버전 묶음
곡명과 아티스트 및 설명과 태그를 함께 검색한다. 장르와 난이도 및 편곡 형태와 개별 태그로 결과를 좁힐 수 있다. 같은 아티스트와 같은 제목은 한 묶음으로 표시한다. 묶음 안에서 멜로디와 반주 및 피아노 독주와 듀엣과 연습 패턴을 선택한다.
## 공개 샘플
상업 가요를 복제하지 않고 직접 만든 다음 연습곡을 전체 공개 샘플로 제공한다.
- 첫눈이 내리는 밤: 입문 멜로디
- 첫눈이 내리는 밤: 중급 양손 반주
- 파란 바람 산책: 쉬운 가요풍 멜로디
- 아침의 작은 왈츠: 중급 피아노 독주
같은 제목의 난이도와 편곡 형태를 한 묶음에서 고르는 동작은 `첫눈이 내리는 밤`의 두 버전으로 확인한다.
## 초기 관리자
초기 관리자 아이디는 Compose 환경 변수로 지정한다. 비밀번호는 `secrets/library_admin_password`에서만 읽는다. DB에 같은 아이디가 없을 때만 최초 관리자를 생성한다. 컨테이너 재시작 때 기존 비밀번호를 덮어쓰지 않는다.
## 백업과 복구
다음 항목을 같은 시점에 백업한다.
- `data/library.sqlite3`
- `data/midi`
- `secrets/library_admin_password`
- 소스 저장소 Git 번들
비밀 파일은 일반 소스 백업과 분리하고 권한 600을 유지한다. SQLite를 복사하기 전에는 백엔드의 쓰기 요청을 잠시 막거나 SQLite 온라인 백업 명령을 사용한다. 단순 파일 복사 중 DB가 변경되면 일관된 백업이 아닐 수 있다.
## 운영 확인
배포 후 다음을 검사한다.
1. 비회원에게 공개 샘플만 표시되는지 확인한다.
2. 회원 가입과 로그인 및 로그아웃을 확인한다.
3. 비공개 곡이 다른 회원과 비회원에게 보이지 않는지 확인한다.
4. 회원 공개와 지정 공유 및 최소 등급 제한을 확인한다.
5. MIDI 업로드와 한글 제목 및 태그 검색을 확인한다.
6. 같은 제목의 멜로디와 반주 버전을 각각 불러온다.
7. 비밀번호 변경 후 기존 세션이 끝나는지 확인한다.
8. 시험 회원 탈퇴 후 계정과 업로드가 제거되는지 확인한다.
9. 모바일 화면에서 검색과 업로드 및 계정 화면이 가로로 넘치지 않는지 확인한다.
@@ -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>
);
}