회원과 곡 보관함 기능을 추가한다
회원 가입과 로그인 및 등급별 접근과 공개 범위를 구현했다.`nMIDI 업로드와 검색 및 같은 곡의 버전 묶음을 추가했다.`n비밀번호 변경과 회원 탈퇴 및 관리자 회원 관리를 포함했다.`n직접 만든 공개 샘플 MIDI 네 곡과 운영 문서를 추가했다.
This commit is contained in:
@@ -103,6 +103,7 @@ lerna-debug.log*
|
||||
|
||||
# ── Docker / runtime data ─────────────────────────────────────────────────────
|
||||
.docker-data/
|
||||
/data/
|
||||
*.pid
|
||||
|
||||
# ── Misc ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
+5
-1
@@ -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를 올리고 전체 공개와 회원 공개 및 지정 공유와 나만 보기 중 하나를 선택할 수 있다.
|
||||
- 회원은 현재 비밀번호를 확인한 뒤 비밀번호를 변경하거나 탈퇴할 수 있다.
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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.
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user