fix: refactor
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from app.models.user import get_db_connection
|
||||
from app.api.v1.auth import get_current_user
|
||||
from app.core.auth import hash_password
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def require_admin(current_user: dict = Depends(get_current_user)):
|
||||
if current_user.get("role") != "admin":
|
||||
raise HTTPException(status_code=403, detail="Chỉ Admin hệ thống mới có quyền truy cập tính năng này")
|
||||
return current_user
|
||||
|
||||
class UpdateUserQuotaRequest(BaseModel):
|
||||
storage_limit_mb: int
|
||||
max_tracks: Optional[int] = 16
|
||||
|
||||
class UpdateUserRoleRequest(BaseModel):
|
||||
role: str # 'admin', 'standard', 'premium'
|
||||
is_active: Optional[bool] = True
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(admin: dict = Depends(require_admin)):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT u.id, u.username, u.email, u.role, u.is_active, u.must_change_password, u.created_at,
|
||||
q.storage_limit_mb, q.max_tracks,
|
||||
(SELECT COALESCE(SUM(p.size_bytes), 0) FROM projects p WHERE p.user_id = u.id) as used_bytes
|
||||
FROM users u
|
||||
LEFT JOIN user_quotas q ON u.id = q.user_id
|
||||
ORDER BY u.created_at DESC
|
||||
""")
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
users = []
|
||||
for r in rows:
|
||||
used_mb = round((r["used_bytes"] or 0) / (1024 * 1024), 2)
|
||||
users.append({
|
||||
"id": r["id"],
|
||||
"username": r["username"],
|
||||
"email": r["email"],
|
||||
"role": r["role"],
|
||||
"is_active": bool(r["is_active"]),
|
||||
"must_change_password": bool(r["must_change_password"]),
|
||||
"created_at": r["created_at"],
|
||||
"quota_mb": r["storage_limit_mb"] or 500,
|
||||
"used_mb": used_mb,
|
||||
"max_tracks": r["max_tracks"] or 16
|
||||
})
|
||||
return users
|
||||
|
||||
@router.put("/users/{user_id}/role")
|
||||
async def update_user_role(user_id: str, req: UpdateUserRoleRequest, admin: dict = Depends(require_admin)):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("UPDATE users SET role = ?, is_active = ? WHERE id = ?", (req.role, int(req.is_active), user_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"message": "Cập nhật vai trò người dùng thành công"}
|
||||
|
||||
@router.put("/quotas/{user_id}")
|
||||
async def update_user_quota(user_id: str, req: UpdateUserQuotaRequest, admin: dict = Depends(require_admin)):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO user_quotas (user_id, storage_limit_mb, max_tracks)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET storage_limit_mb = excluded.storage_limit_mb, max_tracks = excluded.max_tracks
|
||||
""", (user_id, req.storage_limit_mb, req.max_tracks))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"message": "Cập nhật hạn mức Quota thành công"}
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
async def delete_user(user_id: str, admin: dict = Depends(require_admin)):
|
||||
if user_id == admin["user_id"]:
|
||||
raise HTTPException(status_code=400, detail="Không thể xóa chính tài khoản Admin đang đăng nhập")
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
cursor.execute("DELETE FROM user_quotas WHERE id = ?", (user_id,))
|
||||
cursor.execute("DELETE FROM projects WHERE user_id = ?", (user_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"message": "Đã xóa người dùng thành công"}
|
||||
@@ -0,0 +1,188 @@
|
||||
import uuid
|
||||
import time
|
||||
from fastapi import APIRouter, HTTPException, Header, Depends
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
from app.models.user import get_db_connection
|
||||
from app.core.auth import hash_password, verify_password, create_token, decode_token, seed_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: Optional[str] = "admin"
|
||||
password: str
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
email: str
|
||||
password: str
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
def get_current_user(authorization: Optional[str] = Header(None)):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Thiếu Token xác thực hoặc Token không hợp lệ")
|
||||
token = authorization.split(" ")[1]
|
||||
payload = decode_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="Token đã hết hạn hoặc không hợp lệ")
|
||||
return payload
|
||||
|
||||
@router.post("/login")
|
||||
async def login(req: LoginRequest):
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
username = (req.username or "").strip()
|
||||
if not username:
|
||||
username = "admin"
|
||||
|
||||
password = (req.password or "").strip()
|
||||
|
||||
# Case-insensitive search by username or email
|
||||
cursor.execute("SELECT * FROM users WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)", (username, username))
|
||||
user = cursor.fetchone()
|
||||
|
||||
# Auto-heal seed_admin if admin record missing
|
||||
if not user and username.lower() == "admin":
|
||||
conn.close()
|
||||
seed_admin()
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM users WHERE username = 'admin'")
|
||||
user = cursor.fetchone()
|
||||
|
||||
conn.close()
|
||||
|
||||
if not user or not user["is_active"]:
|
||||
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
|
||||
|
||||
if not verify_password(password, user["hashed_password"]):
|
||||
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
|
||||
|
||||
token = create_token(user["id"], user["username"], user["role"], user["must_change_password"])
|
||||
|
||||
return {
|
||||
"access_token": token,
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
"email": user["email"],
|
||||
"role": user["role"],
|
||||
"must_change_password": bool(user["must_change_password"])
|
||||
}
|
||||
}
|
||||
|
||||
@router.post("/register")
|
||||
async def register(req: RegisterRequest):
|
||||
username = req.username.strip()
|
||||
email = req.email.strip()
|
||||
password = req.password.strip()
|
||||
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT id FROM users WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)", (username, email))
|
||||
if cursor.fetchone():
|
||||
conn.close()
|
||||
raise HTTPException(status_code=400, detail="Tên người dùng hoặc Email đã tồn tại")
|
||||
|
||||
user_id = str(uuid.uuid4())
|
||||
hashed_pwd = hash_password(password)
|
||||
now = time.time()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO users (id, username, email, hashed_password, role, must_change_password, created_at, is_active)
|
||||
VALUES (?, ?, ?, ?, 'standard', 0, ?, 1)
|
||||
""", (user_id, username, email, hashed_pwd, now))
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO user_quotas (user_id, storage_limit_mb, max_tracks)
|
||||
VALUES (?, 500, 16)
|
||||
""", (user_id,))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
token = create_token(user_id, username, "standard", False)
|
||||
return {
|
||||
"access_token": token,
|
||||
"user": {
|
||||
"id": user_id,
|
||||
"username": username,
|
||||
"email": email,
|
||||
"role": "standard",
|
||||
"must_change_password": False
|
||||
}
|
||||
}
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(req: ChangePasswordRequest, current_user: dict = Depends(get_current_user)):
|
||||
user_id = current_user["user_id"]
|
||||
old_pwd = req.old_password.strip()
|
||||
new_pwd = req.new_password.strip()
|
||||
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT hashed_password FROM users WHERE id = ?", (user_id,))
|
||||
user = cursor.fetchone()
|
||||
if not user or not verify_password(old_pwd, user["hashed_password"]):
|
||||
conn.close()
|
||||
raise HTTPException(status_code=400, detail="Mật khẩu hiện tại không chính xác")
|
||||
|
||||
new_hashed = hash_password(new_pwd)
|
||||
cursor.execute("""
|
||||
UPDATE users SET hashed_password = ?, must_change_password = 0 WHERE id = ?
|
||||
""", (new_hashed, user_id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))
|
||||
updated_user = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
new_token = create_token(updated_user["id"], updated_user["username"], updated_user["role"], False)
|
||||
return {
|
||||
"message": "Đổi mật khẩu thành công!",
|
||||
"access_token": new_token
|
||||
}
|
||||
|
||||
@router.get("/profile")
|
||||
async def get_profile(current_user: dict = Depends(get_current_user)):
|
||||
user_id = current_user["user_id"]
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT u.id, u.username, u.email, u.role, u.must_change_password, q.storage_limit_mb, q.max_tracks
|
||||
FROM users u
|
||||
LEFT JOIN user_quotas q ON u.id = q.user_id
|
||||
WHERE u.id = ?
|
||||
""", (user_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
cursor.execute("SELECT SUM(size_bytes) as total_used FROM projects WHERE user_id = ?", (user_id,))
|
||||
used_row = cursor.fetchone()
|
||||
used_bytes = used_row["total_used"] if used_row and used_row["total_used"] else 0
|
||||
used_mb = round(used_bytes / (1024 * 1024), 2)
|
||||
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Không tìm thấy thông tin tài khoản")
|
||||
|
||||
return {
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"email": row["email"],
|
||||
"role": row["role"],
|
||||
"must_change_password": bool(row["must_change_password"]),
|
||||
"quota": {
|
||||
"storage_limit_mb": row["storage_limit_mb"] or 500,
|
||||
"used_mb": used_mb,
|
||||
"max_tracks": row["max_tracks"] or 16
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import time
|
||||
import json
|
||||
import uuid
|
||||
from fastapi import APIRouter, HTTPException, Depends, Header
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Any, Dict
|
||||
from app.models.user import get_db_connection
|
||||
from app.api.v1.auth import get_current_user, decode_token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class SaveProjectRequest(BaseModel):
|
||||
name: str
|
||||
data_json: str
|
||||
|
||||
class SaveTempProjectRequest(BaseModel):
|
||||
data_json: str
|
||||
|
||||
def get_optional_user(authorization: Optional[str] = Header(None)) -> Optional[dict]:
|
||||
if authorization and authorization.startswith("Bearer "):
|
||||
token = authorization.split(" ")[1]
|
||||
return decode_token(token)
|
||||
return None
|
||||
|
||||
@router.post("/temp")
|
||||
async def save_temp_project(req: SaveTempProjectRequest, current_user: Optional[dict] = Depends(get_optional_user)):
|
||||
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
size_bytes = len(req.data_json.encode("utf-8"))
|
||||
now = time.time()
|
||||
temp_id = f"temp_{user_id}"
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO projects (id, user_id, name, data_json, is_temp, size_bytes, updated_at)
|
||||
VALUES (?, ?, 'Dự án tạm chưa lưu', ?, 1, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET data_json = excluded.data_json, size_bytes = excluded.size_bytes, updated_at = excluded.updated_at
|
||||
""", (temp_id, user_id, req.data_json, size_bytes, now))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"message": "Đã lưu dự án tạm tự động", "updated_at": now}
|
||||
|
||||
@router.get("/temp")
|
||||
async def get_temp_project(current_user: Optional[dict] = Depends(get_optional_user)):
|
||||
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||
temp_id = f"temp_{user_id}"
|
||||
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT data_json, updated_at FROM projects WHERE id = ? AND is_temp = 1", (temp_id,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return {"has_temp": False}
|
||||
|
||||
return {
|
||||
"has_temp": True,
|
||||
"data_json": row["data_json"],
|
||||
"updated_at": row["updated_at"]
|
||||
}
|
||||
|
||||
@router.post("/cloud")
|
||||
async def save_cloud_project(req: SaveProjectRequest, current_user: dict = Depends(get_current_user)):
|
||||
user_id = current_user["user_id"]
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT storage_limit_mb FROM user_quotas WHERE user_id = ?", (user_id,))
|
||||
quota_row = cursor.fetchone()
|
||||
storage_limit_mb = quota_row["storage_limit_mb"] if quota_row else 500
|
||||
|
||||
cursor.execute("SELECT SUM(size_bytes) as total_used FROM projects WHERE user_id = ? AND is_temp = 0", (user_id,))
|
||||
used_row = cursor.fetchone()
|
||||
used_bytes = used_row["total_used"] if used_row and used_row["total_used"] else 0
|
||||
|
||||
new_size_bytes = len(req.data_json.encode("utf-8"))
|
||||
max_bytes = storage_limit_mb * 1024 * 1024
|
||||
|
||||
if used_bytes + new_size_bytes > max_bytes:
|
||||
conn.close()
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Dung lượng dự án vượt quá hạn mức Quota ({storage_limit_mb}MB). Vui lòng dọn dẹp hoặc nâng cấp tài khoản."
|
||||
)
|
||||
|
||||
project_id = str(uuid.uuid4())
|
||||
now = time.time()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO projects (id, user_id, name, data_json, is_temp, size_bytes, updated_at)
|
||||
VALUES (?, ?, ?, ?, 0, ?, ?)
|
||||
""", (project_id, user_id, req.name, req.data_json, new_size_bytes, now))
|
||||
|
||||
temp_id = f"temp_{user_id}"
|
||||
cursor.execute("DELETE FROM projects WHERE id = ? AND is_temp = 1", (temp_id,))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"message": "Đã lưu dự án lên Cloud thành công!",
|
||||
"project_id": project_id
|
||||
}
|
||||
|
||||
@router.get("/cloud")
|
||||
async def list_cloud_projects(current_user: dict = Depends(get_current_user)):
|
||||
user_id = current_user["user_id"]
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT id, name, size_bytes, updated_at FROM projects
|
||||
WHERE user_id = ? AND is_temp = 0
|
||||
ORDER BY updated_at DESC
|
||||
""", (user_id,))
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": r["id"],
|
||||
"name": r["name"],
|
||||
"size_mb": round(r["size_bytes"] / (1024 * 1024), 2),
|
||||
"updated_at": r["updated_at"]
|
||||
} for r in rows
|
||||
]
|
||||
@@ -0,0 +1,90 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
import base64
|
||||
import uuid
|
||||
import os
|
||||
from typing import Optional, Dict, Any
|
||||
from app.models.user import get_db_connection
|
||||
from app.config import settings
|
||||
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "sonicforge_secret_key_super_secure_2026")
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""
|
||||
Hash password using PBKDF2 HMAC SHA-256 with salt.
|
||||
Guarantees raw passwords are NEVER stored or exposed in plaintext.
|
||||
"""
|
||||
salt = b"sonicforge_crypto_salt_2026_secure_"
|
||||
key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000)
|
||||
return key.hex()
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify plain password against PBKDF2 hashed password using constant-time comparison."""
|
||||
computed_hash = hash_password(plain_password)
|
||||
return hmac.compare_digest(computed_hash, hashed_password)
|
||||
|
||||
def create_token(user_id: str, username: str, role: str, must_change_password: bool) -> str:
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
"role": role,
|
||||
"must_change_password": bool(must_change_password),
|
||||
"exp": time.time() + (3600 * 24 * 7) # 7 days
|
||||
}
|
||||
payload_str = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("utf-8")
|
||||
sig = hmac.new(SECRET_KEY.encode("utf-8"), payload_str.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
return f"{payload_str}.{sig}"
|
||||
|
||||
def decode_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
parts = token.split(".")
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
payload_str, sig = parts[0], parts[1]
|
||||
expected_sig = hmac.new(SECRET_KEY.encode("utf-8"), payload_str.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(sig, expected_sig):
|
||||
return None
|
||||
|
||||
payload_bytes = base64.b64decode(payload_str.encode("utf-8"))
|
||||
payload = json.loads(payload_bytes.decode("utf-8"))
|
||||
if time.time() > payload.get("exp", 0):
|
||||
return None
|
||||
return payload
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def seed_admin():
|
||||
"""Seed default admin account on initial launch if not exists or update password hash if outdated."""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
default_pwd = os.getenv("DEFAULT_ADMIN_PASSWORD", "admin123").strip()
|
||||
hashed_pwd = hash_password(default_pwd)
|
||||
now = time.time()
|
||||
|
||||
cursor.execute("SELECT id, hashed_password, must_change_password FROM users WHERE username = ?", ("admin",))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
admin_id = str(uuid.uuid4())
|
||||
cursor.execute("""
|
||||
INSERT INTO users (id, username, email, hashed_password, role, must_change_password, created_at, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?, 1)
|
||||
""", (admin_id, "admin", "admin@sonicforge.studio", hashed_pwd, "admin", now))
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO user_quotas (user_id, storage_limit_mb, max_tracks)
|
||||
VALUES (?, 10240, 64)
|
||||
""", (admin_id,))
|
||||
conn.commit()
|
||||
else:
|
||||
# Guarantee admin account password hash matches default_pwd if must_change_password is true or hash doesn't match
|
||||
if row["must_change_password"] or not verify_password(default_pwd, row["hashed_password"]):
|
||||
cursor.execute("UPDATE users SET hashed_password = ? WHERE id = ?", (hashed_pwd, row["id"]))
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
|
||||
# Auto seed on module load
|
||||
seed_admin()
|
||||
+16
-1
@@ -7,6 +7,10 @@ from app.config import settings
|
||||
from app.api.v1.audio import router as audio_router
|
||||
from app.api.v1.tasks import router as tasks_router
|
||||
from app.api.v1.multitrack import router as multitrack_router
|
||||
from app.api.v1.auth import router as auth_router
|
||||
from app.api.v1.admin import router as admin_router
|
||||
from app.api.v1.projects import router as projects_router
|
||||
from app.core.auth import seed_admin
|
||||
|
||||
# Ensure storage directories exist
|
||||
os.makedirs(settings.UPLOADS_DIR, exist_ok=True)
|
||||
@@ -22,13 +26,24 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Mount storage directory
|
||||
# Mount storage directory (must come before general /static mount)
|
||||
app.mount("/static/audio", StaticFiles(directory=settings.STORAGE_DIR), name="audio")
|
||||
# Mount app static files (js, css)
|
||||
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
|
||||
# Include routers
|
||||
app.include_router(audio_router, prefix="/api/v1/audio", tags=["audio"])
|
||||
app.include_router(tasks_router, prefix="/api/v1/audio", tags=["tasks"])
|
||||
app.include_router(multitrack_router, prefix="/api/v1/multitrack", tags=["multitrack"])
|
||||
app.include_router(auth_router, prefix="/api/v1/auth", tags=["auth"])
|
||||
app.include_router(admin_router, prefix="/api/v1/admin", tags=["admin"])
|
||||
app.include_router(projects_router, prefix="/api/v1/projects", tags=["projects"])
|
||||
|
||||
# Seed admin user on startup
|
||||
@app.on_event("startup")
|
||||
async def startup_seed_admin():
|
||||
seed_admin()
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def get_index():
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import json
|
||||
import time
|
||||
from typing import Optional, Dict, Any, List
|
||||
from app.config import settings
|
||||
|
||||
DB_PATH = os.path.join(settings.STORAGE_DIR, "sonicforge.db")
|
||||
|
||||
def get_db_connection():
|
||||
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def init_db():
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Bảng Users
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
hashed_password TEXT NOT NULL,
|
||||
role TEXT DEFAULT 'standard',
|
||||
must_change_password BOOLEAN DEFAULT 1,
|
||||
created_at REAL NOT NULL,
|
||||
is_active BOOLEAN DEFAULT 1
|
||||
);
|
||||
""")
|
||||
|
||||
# Bảng Quotas
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_quotas (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
storage_limit_mb INTEGER DEFAULT 500,
|
||||
max_tracks INTEGER DEFAULT 16,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
""")
|
||||
|
||||
# Bảng Projects (Bao gồm Cloud Project & Temp Auto-Save)
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
data_json TEXT NOT NULL,
|
||||
is_temp BOOLEAN DEFAULT 0,
|
||||
size_bytes INTEGER DEFAULT 0,
|
||||
updated_at REAL NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
""")
|
||||
|
||||
# Bảng System Flags
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS system_flags (
|
||||
flag_key TEXT PRIMARY KEY,
|
||||
description TEXT,
|
||||
is_enabled BOOLEAN DEFAULT 1,
|
||||
updated_at REAL NOT NULL
|
||||
);
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Tự động khởi tạo DB khi module được import
|
||||
init_db()
|
||||
@@ -0,0 +1,49 @@
|
||||
/* SonicForge Studio - DAW Custom Stylesheet */
|
||||
body {
|
||||
background-color: #1a1a1a;
|
||||
color: #c0c0c0;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
.daw-bg { background-color: #1e1e1e; }
|
||||
.daw-panel { background-color: #262626; }
|
||||
.daw-header { background-color: #2e2e2e; }
|
||||
.daw-border { border-color: #181818; }
|
||||
.daw-track-active { background-color: #333333; }
|
||||
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: #141414; }
|
||||
::-webkit-scrollbar-thumb { background: #3a3a3a; border: 2px solid #141414; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #4a4a4a; }
|
||||
|
||||
.knob-container { position: relative; width: 28px; height: 28px; }
|
||||
.knob-dial { transform-origin: center; transition: transform 0.1s ease; }
|
||||
.selection-interactive-box { min-width: 4px; }
|
||||
|
||||
.no-scrollbar {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE 10+ */
|
||||
}
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none; /* Safari and Chrome */
|
||||
}
|
||||
|
||||
/* Axis Labels & Waveform HD Canvas styling */
|
||||
.axis-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.clip-title-tag {
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
border: 1px solid rgba(51, 65, 85, 0.6);
|
||||
color: #e2e8f0;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// [DEPRECATED] Superseded by inline version in index.html (single-file DAW). Keep for reference only.
|
||||
@@ -0,0 +1 @@
|
||||
// [DEPRECATED] Superseded by inline version in index.html (single-file DAW). Keep for reference only.
|
||||
@@ -0,0 +1 @@
|
||||
// [DEPRECATED] Superseded by inline version in index.html (single-file DAW). Keep for reference only.
|
||||
@@ -0,0 +1 @@
|
||||
// [DEPRECATED] Superseded by inline version in index.html (single-file DAW). Keep for reference only.
|
||||
@@ -0,0 +1 @@
|
||||
// [DEPRECATED] Superseded by inline version in index.html (single-file DAW). Keep for reference only.
|
||||
@@ -0,0 +1 @@
|
||||
// [DEPRECATED] Superseded by inline version in index.html (single-file DAW). Keep for reference only.
|
||||
@@ -0,0 +1,50 @@
|
||||
// SonicForge Studio API Service
|
||||
window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
||||
|
||||
(function() {
|
||||
function getAuthToken() {
|
||||
return localStorage.getItem('sonic_token') || '';
|
||||
}
|
||||
|
||||
function getAuthHeaders() {
|
||||
const token = getAuthToken();
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
};
|
||||
}
|
||||
|
||||
async function apiRequest(endpoint, options = {}) {
|
||||
const url = `${window.API_BASE_URL}${endpoint}`;
|
||||
const headers = { ...getAuthHeaders(), ...options.headers };
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('sonic_token');
|
||||
localStorage.removeItem('sonic_user');
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || data.message || 'Lỗi kết nối API Server');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
window.SonicAPI = {
|
||||
login: (username, password) => apiRequest('/api/v1/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
||||
register: (username, email, password) => apiRequest('/api/v1/auth/register', { method: 'POST', body: JSON.stringify({ username, email, password }) }),
|
||||
changePassword: (old_password, new_password) => apiRequest('/api/v1/auth/change-password', { method: 'POST', body: JSON.stringify({ old_password, new_password }) }),
|
||||
getProfile: () => apiRequest('/api/v1/auth/profile', { method: 'GET' }),
|
||||
|
||||
listUsers: () => apiRequest('/api/v1/admin/users', { method: 'GET' }),
|
||||
updateUserQuota: (userId, storageLimitMb, maxTracks = 16) => apiRequest(`/api/v1/admin/quotas/${userId}`, { method: 'PUT', body: JSON.stringify({ storage_limit_mb: storageLimitMb, max_tracks: maxTracks }) }),
|
||||
updateUserRole: (userId, role, isActive = true) => apiRequest(`/api/v1/admin/users/${userId}/role`, { method: 'PUT', body: JSON.stringify({ role, is_active: isActive }) }),
|
||||
deleteUser: (userId) => apiRequest(`/api/v1/admin/users/${userId}`, { method: 'DELETE' }),
|
||||
|
||||
saveTempProject: (dataJson) => apiRequest('/api/v1/projects/temp', { method: 'POST', body: JSON.stringify({ data_json: dataJson }) }),
|
||||
getTempProject: () => apiRequest('/api/v1/projects/temp', { method: 'GET' }),
|
||||
saveCloudProject: (name, dataJson) => apiRequest('/api/v1/projects/cloud', { method: 'POST', body: JSON.stringify({ name, data_json: dataJson }) }),
|
||||
listCloudProjects: () => apiRequest('/api/v1/projects/cloud', { method: 'GET' })
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,42 @@
|
||||
// SonicForge Studio Audio Engine Service
|
||||
(function() {
|
||||
let audioCtx = null;
|
||||
|
||||
function getAudioContext() {
|
||||
if (!audioCtx) {
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
if (audioCtx.state === 'suspended') {
|
||||
audioCtx.resume();
|
||||
}
|
||||
return audioCtx;
|
||||
}
|
||||
|
||||
function analyzeAudioBufferChannels(audioBuffer) {
|
||||
if (!audioBuffer) return { channels: 1, isStereo: false, label: 'MONO' };
|
||||
const numChannels = audioBuffer.numberOfChannels;
|
||||
const isStereo = numChannels >= 2;
|
||||
return {
|
||||
channels: numChannels,
|
||||
isStereo: isStereo,
|
||||
label: isStereo ? 'STEREO' : 'MONO',
|
||||
sampleRate: audioBuffer.sampleRate,
|
||||
duration: audioBuffer.duration,
|
||||
length: audioBuffer.length
|
||||
};
|
||||
}
|
||||
|
||||
async function decodeAudioFile(file) {
|
||||
const ctx = getAudioContext();
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
|
||||
const channelInfo = analyzeAudioBufferChannels(audioBuffer);
|
||||
return { audioBuffer, channelInfo };
|
||||
}
|
||||
|
||||
window.SonicAudio = {
|
||||
getAudioContext,
|
||||
analyzeAudioBufferChannels,
|
||||
decodeAudioFile
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,73 @@
|
||||
// SonicForge Studio Project Storage & .sfs File Service
|
||||
(function() {
|
||||
const SFS_VERSION = "1.0.0";
|
||||
|
||||
function exportProjectToSFS(projectState) {
|
||||
const sfsBundle = {
|
||||
format: "SONICFORGE_STUDIO_PROJECT",
|
||||
version: SFS_VERSION,
|
||||
timestamp: Date.now(),
|
||||
domain: window.location.origin,
|
||||
project: {
|
||||
id: projectState.id || `proj_${Date.now()}`,
|
||||
name: projectState.name || "Dự án mới",
|
||||
tracks: (projectState.tracks || []).map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
startTime: t.startTime,
|
||||
height: t.height,
|
||||
volumeDb: t.volumeDb,
|
||||
pan: t.pan,
|
||||
muted: t.muted,
|
||||
solo: t.solo,
|
||||
color: t.color,
|
||||
markers: t.markers || [],
|
||||
serverFileId: t.serverFileId || null
|
||||
}))
|
||||
}
|
||||
};
|
||||
|
||||
const jsonStr = JSON.stringify(sfsBundle, null, 2);
|
||||
const blob = new Blob([jsonStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${(projectState.name || 'project').replace(/\s+/g, '_')}.sfs`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function importProjectFromSFSFile(file) {
|
||||
const text = await file.text();
|
||||
const sfsBundle = JSON.parse(text);
|
||||
if (sfsBundle.format !== "SONICFORGE_STUDIO_PROJECT") {
|
||||
throw new Error("Tệp tin không đúng định dạng .sfs của SonicForge Studio");
|
||||
}
|
||||
return sfsBundle.project;
|
||||
}
|
||||
|
||||
let autoSaveTimer = null;
|
||||
function scheduleTempAutoSave(getProjectStateCallback) {
|
||||
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
||||
autoSaveTimer = setTimeout(async () => {
|
||||
try {
|
||||
const state = getProjectStateCallback();
|
||||
if (!state || !state.tracks || state.tracks.length === 0) return;
|
||||
const dataJson = JSON.stringify(state);
|
||||
localStorage.setItem('sonic_temp_project', dataJson);
|
||||
if (window.SonicAPI && localStorage.getItem('sonic_token')) {
|
||||
await window.SonicAPI.saveTempProject(dataJson).catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Auto-save temp project warning:", e);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
window.SonicStorage = {
|
||||
exportProjectToSFS,
|
||||
importProjectFromSFSFile,
|
||||
scheduleTempAutoSave
|
||||
};
|
||||
})();
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 732 KiB |
+528
-109
@@ -9,6 +9,9 @@
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.26.0/babel.min.js"></script>
|
||||
<script src="/static/js/services/api.js"></script>
|
||||
<script src="/static/js/services/audioEngine.js"></script>
|
||||
<script src="/static/js/services/storage.js"></script>
|
||||
<style>
|
||||
body {
|
||||
background-color: #1a1a1a;
|
||||
@@ -52,6 +55,20 @@
|
||||
const API_MULTITRACK = `${API_BASE_URL}/api/v1/multitrack`;
|
||||
const API_TASKS = `${API_BASE_URL}/api/v1/audio/tasks`;
|
||||
|
||||
// Handle ?sfs=<encoded> from double-clicking a .sfs file (opens domain -> loads project)
|
||||
(function handleSfsDeepLink() {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const sfsParam = params.get('sfs');
|
||||
if (!sfsParam) return;
|
||||
const decoded = JSON.parse(decodeURIComponent(sfsParam));
|
||||
window.__pendingSfsProject = decoded; // consumed after auth in App
|
||||
if (window.history.replaceState) {
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
}
|
||||
} catch (e) { window.__pendingSfsProject = null; }
|
||||
})();
|
||||
|
||||
// Storage for server-side file IDs mapped to track IDs
|
||||
let serverFileIdMap = {};
|
||||
|
||||
@@ -690,11 +707,16 @@
|
||||
};
|
||||
|
||||
// ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ──
|
||||
const SubTabWaveform = ({ buffer, subTabId, activeTab, currentTime, selectionStart, selectionEnd, onSelectRange, onPlayheadSet, onContextMenu, activeTool, zoom, timelineWidth, color, name, speed = 1.0, onSpeedChange, volumeNodes = [], panningNodes = [], fadeInLen = 0, fadeOutLen = 0, graphMode = null, onUpdateNodes, onUpdateFade, onModeToggle, selectedNodeTime, setSelectedNodeTime }) => {
|
||||
const SubTabWaveform = ({ buffer, subTabId, activeTab, currentTime, selectionStart, selectionEnd, onSelectRange, onPlayheadSet, onContextMenu, activeTool, zoom, timelineWidth, color, name, speed = 1.0, onSpeedChange, volumeNodes = [], panningNodes = [], fadeInLen = 0, fadeOutLen = 0, graphMode = null, onUpdateNodes, onUpdateFade, onModeToggle, selectedNodeTime, setSelectedNodeTime, channelInfo = null }) => {
|
||||
const canvasRef = useRef(null);
|
||||
const isStretchingRef = useRef(false);
|
||||
const stretchStartRef = useRef({ mouseX: 0, originalDuration: 0, originalSpeed: 1.0 });
|
||||
|
||||
const isStereo = channelInfo ? channelInfo.isStereo : (buffer && buffer.numberOfChannels >= 2);
|
||||
const channelLabel = channelInfo ? channelInfo.label : (isStereo ? 'STEREO' : 'MONO');
|
||||
// Mono: force volume mode (panning not applicable)
|
||||
const effectiveGraphMode = (!isStereo && graphMode === 'pan') ? null : graphMode;
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !buffer) return;
|
||||
@@ -860,7 +882,7 @@
|
||||
ctx.beginPath(); ctx.moveTo(xStart, panZeroY); ctx.lineTo(xStart + wClip, panZeroY); ctx.stroke();
|
||||
|
||||
// Horizontal grid lines (other value markers)
|
||||
const isPanMode = graphMode === 'pan';
|
||||
const isPanMode = effectiveGraphMode === 'pan';
|
||||
if (isPanMode) {
|
||||
for (let p = -100; p <= 100; p += 20) {
|
||||
if (p === 0) continue;
|
||||
@@ -919,7 +941,7 @@
|
||||
const modeBtnH = 14;
|
||||
const modeBtnX = wClip - modeBtnW - 4;
|
||||
const modeBtnY = clipTop + clipHeight - modeBtnH - 2;
|
||||
const isPanMode = graphMode === 'pan';
|
||||
const isPanMode = effectiveGraphMode === 'pan';
|
||||
ctx.fillStyle = isPanMode ? 'rgba(168, 85, 247, 0.5)' : 'rgba(6, 182, 212, 0.5)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(modeBtnX, modeBtnY, modeBtnW, modeBtnH, 3);
|
||||
@@ -930,6 +952,16 @@
|
||||
ctx.fillText(isPanMode ? 'PAN' : 'VOL', modeBtnX + modeBtnW / 2, modeBtnY + 10);
|
||||
ctx.textAlign = 'start';
|
||||
|
||||
// Channel label (L / R for stereo, M for mono)
|
||||
ctx.fillStyle = '#a1a1aa';
|
||||
ctx.font = 'bold 8px monospace';
|
||||
if (isStereo) {
|
||||
ctx.fillText('L', xStart + 2, clipTop + clipHeight * 0.28);
|
||||
ctx.fillText('R', xStart + 2, clipTop + clipHeight * 0.72);
|
||||
} else {
|
||||
ctx.fillText('M', xStart + 2, clipTop + clipHeight / 2);
|
||||
}
|
||||
|
||||
// Draw waveform inside clip (speed-adjusted) with fade envelope applied
|
||||
const drawXStart = Math.max(0, Math.floor(xStart));
|
||||
const drawXEnd = Math.min(w, Math.ceil(xEnd));
|
||||
@@ -1691,7 +1723,7 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
// ── Graph Editor Canvas for Volume/Pan/Fade Automation ──
|
||||
@@ -1856,32 +1888,290 @@
|
||||
);
|
||||
};
|
||||
|
||||
const createMockAudioBufferObj = (duration, sampleRate) => {
|
||||
const frameCount = sampleRate * duration;
|
||||
const data = new Float32Array(frameCount);
|
||||
for (let i = 0; i < frameCount; i++) {
|
||||
const t = i / sampleRate;
|
||||
const env = Math.exp(-Math.pow(t - 1.5, 2) / 0.15) * 0.4 + Math.exp(-Math.pow(t - 1.5, 2) / 0.05) * 0.3;
|
||||
const signal = Math.sin(2 * Math.PI * 120 * t) * Math.sin(2 * Math.PI * 8 * t) + (Math.random() - 0.5) * 0.15;
|
||||
data[i] = signal * env;
|
||||
}
|
||||
return {
|
||||
duration,
|
||||
sampleRate,
|
||||
numberOfChannels: 1,
|
||||
getChannelData: (c) => data
|
||||
const AuthModal = ({ isOpen, mode, forceMandatory, onClose, onSuccess }) => {
|
||||
if (!isOpen) return null;
|
||||
const [activeTab, setActiveTab] = useState(mode || 'login');
|
||||
const [username, setUsername] = useState('admin');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [oldPassword, setOldPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
useEffect(() => { if (mode) setActiveTab(mode); }, [mode]);
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault(); setError(''); setLoading(true);
|
||||
try {
|
||||
if (activeTab === 'login') {
|
||||
const targetUsername = username.trim() || 'admin';
|
||||
const res = await window.SonicAPI.login(targetUsername, password.trim());
|
||||
localStorage.setItem('sonic_token', res.access_token);
|
||||
localStorage.setItem('sonic_user', JSON.stringify(res.user));
|
||||
onSuccess(res.user, res.access_token);
|
||||
} else if (activeTab === 'register') {
|
||||
const res = await window.SonicAPI.register(username.trim(), email.trim(), password.trim());
|
||||
localStorage.setItem('sonic_token', res.access_token);
|
||||
localStorage.setItem('sonic_user', JSON.stringify(res.user));
|
||||
onSuccess(res.user, res.access_token);
|
||||
} else if (activeTab === 'force_change') {
|
||||
const res = await window.SonicAPI.changePassword(oldPassword.trim(), newPassword.trim());
|
||||
localStorage.setItem('sonic_token', res.access_token);
|
||||
const user = JSON.parse(localStorage.getItem('sonic_user') || '{}');
|
||||
user.must_change_password = false;
|
||||
localStorage.setItem('sonic_user', JSON.stringify(user));
|
||||
onSuccess(user, res.access_token);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'Thao tác không thành công');
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
const isForceMode = activeTab === 'force_change';
|
||||
const canClose = !forceMandatory && !isForceMode;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md">
|
||||
<div className="bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200">
|
||||
<div className="flex justify-between items-center pb-4 border-b border-[#383838]">
|
||||
<h3 className="text-lg font-bold text-teal-400">
|
||||
{isForceMode ? '⚠️ Bắt Buộc Đổi Mật Khẩu Khởi Tạo' : (activeTab === 'login' ? '🔐 Đăng Nhập Hệ Thống' : '📝 Đăng Ký Tài Khoản')}
|
||||
</h3>
|
||||
{canClose && <button onClick={onClose} className="text-slate-400 hover:text-slate-200">✕</button>}
|
||||
</div>
|
||||
{error && (<div className="mt-4 p-3 bg-red-900/40 border border-red-700 rounded-lg text-red-200 text-sm">{error}</div>)}
|
||||
<form onSubmit={handleSubmit} className="mt-4 space-y-4">
|
||||
{isForceMode ? (
|
||||
<>
|
||||
<p className="text-xs text-amber-400 bg-amber-950/60 p-2.5 border border-amber-800/80 rounded leading-relaxed">
|
||||
🔒 Tài khoản của bạn đang dùng mật khẩu khởi tạo mặc định. Để bảo mật hệ thống, bạn phải đổi mật khẩu mới trước khi tiếp tục.
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1 text-slate-400">Mật khẩu hiện tại (Mặc định: admin123)</label>
|
||||
<input type="password" required value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1 text-slate-400">Mật khẩu mới</label>
|
||||
<input type="password" required value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'login' ? (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1 text-slate-400">Tên đăng nhập <span className="text-teal-400 font-normal">(Tùy chọn - Admin có thể bỏ trống)</span></label>
|
||||
<input type="text" placeholder="Mặc định: admin" value={username} onChange={(e) => setUsername(e.target.value)} className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1 text-slate-400">Tên đăng nhập</label>
|
||||
<input type="text" required value={username} onChange={(e) => setUsername(e.target.value)} className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'register' && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1 text-slate-400">Email</label>
|
||||
<input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1 text-slate-400">Mật khẩu {activeTab === 'login' && <span className="text-amber-400 font-normal">(Lần đầu: admin123)</span>}</label>
|
||||
<input type="password" required value={password} onChange={(e) => setPassword(e.target.value)} className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<button type="submit" disabled={loading} className="w-full py-2 bg-teal-600 hover:bg-teal-500 text-white font-semibold rounded-lg shadow transition duration-150">
|
||||
{loading ? 'Đang xác thực...' : (isForceMode ? 'Đổi Mật Khẩu Ngay' : (activeTab === 'login' ? 'Đăng Nhập System' : 'Tạo Tài Khoản Mới'))}
|
||||
</button>
|
||||
</form>
|
||||
{!isForceMode && (
|
||||
<div className="mt-4 pt-4 border-t border-[#383838] text-center text-xs text-slate-400">
|
||||
{activeTab === 'login' ? (
|
||||
<span>Chưa có tài khoản? <button onClick={() => setActiveTab('register')} className="text-teal-400 hover:underline">Đăng ký ngay</button></span>
|
||||
) : (
|
||||
<span>Đã có tài khoản? <button onClick={() => setActiveTab('login')} className="text-teal-400 hover:underline">Đăng nhập</button></span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProfileModal = ({ isOpen, onClose }) => {
|
||||
if (!isOpen) return null;
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [oldPassword, setOldPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
useEffect(() => { if (isOpen) fetchProfile(); }, [isOpen]);
|
||||
const fetchProfile = async () => {
|
||||
try { const data = await window.SonicAPI.getProfile(); setProfile(data); }
|
||||
catch (e) { setError(e.message || 'Không thể tải thông tin profile'); }
|
||||
};
|
||||
const handleChangePassword = async (e) => {
|
||||
e.preventDefault(); setMsg(''); setError(''); setLoading(true);
|
||||
try {
|
||||
const res = await window.SonicAPI.changePassword(oldPassword, newPassword);
|
||||
setMsg(res.message || 'Đổi mật khẩu thành công!');
|
||||
setOldPassword(''); setNewPassword('');
|
||||
} catch (err) { setError(err.message || 'Lỗi khi đổi mật khẩu'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||
<div className="bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-lg p-6 text-slate-200">
|
||||
<div className="flex justify-between items-center pb-4 border-b border-[#383838]">
|
||||
<h3 className="text-lg font-bold text-teal-400">👤 Hồ Sơ Cá Nhân & Hạn Mức Quota</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-200">✕</button>
|
||||
</div>
|
||||
{profile && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="bg-[#1e1e1e] p-4 rounded-lg border border-[#333] grid grid-cols-2 gap-4 text-xs">
|
||||
<div><span className="text-slate-500 block">Tên người dùng</span><span className="font-bold text-teal-300 text-sm">{profile.username}</span></div>
|
||||
<div><span className="text-slate-500 block">Vai trò</span><span className="uppercase font-semibold text-amber-400">{profile.role}</span></div>
|
||||
<div><span className="text-slate-500 block">Email</span><span>{profile.email}</span></div>
|
||||
<div><span className="text-slate-500 block">Dung lượng Quota</span><span className="font-semibold text-slate-200">{profile.quota.used_mb} MB / {profile.quota.storage_limit_mb} MB</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-slate-400">Tiến trình sử dụng bộ nhớ Server</span>
|
||||
<span className="font-bold text-teal-400">{((profile.quota.used_mb / profile.quota.storage_limit_mb) * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-teal-500 rounded-full transition-all duration-300" style={{ width: `${Math.min(100, (profile.quota.used_mb / profile.quota.storage_limit_mb) * 100)}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleChangePassword} className="pt-4 border-t border-[#383838] space-y-3">
|
||||
<h4 className="text-xs font-bold text-slate-300 uppercase">Thay Đổi Mật Khẩu</h4>
|
||||
{msg && <div className="p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs">{msg}</div>}
|
||||
{error && <div className="p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs">{error}</div>}
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">Mật khẩu cũ</label>
|
||||
<input type="password" required value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">Mật khẩu mới</label>
|
||||
<input type="password" required value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500" />
|
||||
</div>
|
||||
<button type="submit" disabled={loading} className="w-full py-1.5 bg-teal-600 hover:bg-teal-500 text-white font-semibold text-xs rounded transition">
|
||||
{loading ? 'Đang cập nhật...' : 'Cập Nhật Mật Khẩu'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SystemManagerModal = ({ isOpen, onClose }) => {
|
||||
if (!isOpen) return null;
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [editingQuotaUser, setEditingQuotaUser] = useState(null);
|
||||
const [newQuotaMb, setNewQuotaMb] = useState(500);
|
||||
useEffect(() => { if (isOpen) loadUsers(); }, [isOpen]);
|
||||
const loadUsers = async () => {
|
||||
setLoading(true); setError('');
|
||||
try { const data = await window.SonicAPI.listUsers(); setUsers(data); }
|
||||
catch (err) { setError(err.message || 'Không thể tải danh sách người dùng hệ thống'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
const handleSaveQuota = async (userId) => {
|
||||
try {
|
||||
await window.SonicAPI.updateUserQuota(userId, parseInt(newQuotaMb));
|
||||
setMsg('Đã cập nhật hạn mức Quota thành công!');
|
||||
setEditingQuotaUser(null); loadUsers();
|
||||
} catch (err) { setError(err.message || 'Lỗi cập nhật Quota'); }
|
||||
};
|
||||
const handleToggleRole = async (user) => {
|
||||
const nextRole = user.role === 'admin' ? 'standard' : 'admin';
|
||||
try {
|
||||
await window.SonicAPI.updateUserRole(user.id, nextRole, user.is_active);
|
||||
setMsg(`Đã đổi vai trò người dùng ${user.username} thành ${nextRole}`);
|
||||
loadUsers();
|
||||
} catch (err) { setError(err.message || 'Lỗi cập nhật vai trò'); }
|
||||
};
|
||||
const handleDeleteUser = async (userId) => {
|
||||
if (!confirm('Bạn có chắc chắn muốn xóa người dùng này khỏi hệ thống?')) return;
|
||||
try {
|
||||
await window.SonicAPI.deleteUser(userId);
|
||||
setMsg('Đã xóa người dùng thành công'); loadUsers();
|
||||
} catch (err) { setError(err.message || 'Lỗi khi xóa người dùng'); }
|
||||
};
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||
<div className="bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-4xl p-6 text-slate-200">
|
||||
<div className="flex justify-between items-center pb-4 border-b border-[#383838]">
|
||||
<h3 className="text-lg font-bold text-amber-400">⚙️ Quản Lý Hệ Thống & Phân Quyền Admin</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-200">✕</button>
|
||||
</div>
|
||||
{msg && <div className="mt-3 p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs">{msg}</div>}
|
||||
{error && <div className="mt-3 p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs">{error}</div>}
|
||||
<div className="mt-4 overflow-x-auto max-h-96 no-scrollbar">
|
||||
{loading ? (
|
||||
<div className="py-8 text-center text-slate-400 text-xs">Đang tải thông tin hệ thống...</div>
|
||||
) : (
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-[#383838] text-slate-400 bg-[#1e1e1e]">
|
||||
<th className="p-3">Tên Người Dùng</th>
|
||||
<th className="p-3">Email</th>
|
||||
<th className="p-3">Vai Trò</th>
|
||||
<th className="p-3">Dung Lượng Sử Dụng</th>
|
||||
<th className="p-3">Hạn Mức Quota</th>
|
||||
<th className="p-3 text-right">Thao Tác</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#333]">
|
||||
{users.map(u => (
|
||||
<tr key={u.id} className="hover:bg-[#2e2e2e]">
|
||||
<td className="p-3 font-semibold text-teal-300">
|
||||
{u.username}
|
||||
{u.must_change_password && <span className="ml-2 text-[10px] bg-amber-900/60 text-amber-300 px-1.5 py-0.5 rounded">Mật khẩu gốc</span>}
|
||||
</td>
|
||||
<td className="p-3 text-slate-300">{u.email}</td>
|
||||
<td className="p-3 uppercase font-bold text-amber-400">{u.role}</td>
|
||||
<td className="p-3">{u.used_mb} MB</td>
|
||||
<td className="p-3">
|
||||
{editingQuotaUser === u.id ? (
|
||||
<div className="flex items-center space-x-1">
|
||||
<input type="number" value={newQuotaMb} onChange={(e) => setNewQuotaMb(e.target.value)} className="w-16 bg-[#1e1e1e] border border-[#444] rounded px-1 py-0.5 text-xs text-slate-200" />
|
||||
<span>MB</span>
|
||||
<button onClick={() => handleSaveQuota(u.id)} className="px-2 py-0.5 bg-teal-600 rounded text-[10px]">Lưu</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="font-semibold">{u.quota_mb} MB</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 text-right space-x-2">
|
||||
<button onClick={() => { setEditingQuotaUser(u.id); setNewQuotaMb(u.quota_mb); }} className="px-2 py-1 bg-slate-700 hover:bg-slate-600 rounded text-[11px]">Sửa Quota</button>
|
||||
<button onClick={() => handleToggleRole(u)} className="px-2 py-1 bg-amber-700/60 hover:bg-amber-600 rounded text-[11px]">Đổi Role</button>
|
||||
{u.role !== 'admin' && (
|
||||
<button onClick={() => handleDeleteUser(u.id)} className="px-2 py-1 bg-red-700/60 hover:bg-red-600 rounded text-[11px]">Xóa</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mockBuffer = createMockAudioBufferObj(3.0, 44100);
|
||||
|
||||
const App = () => {
|
||||
// ── State Definitions ──
|
||||
const [tracks, setTracks] = useState([
|
||||
{
|
||||
id: '1',
|
||||
name: 'Creak_DeepWood2.wav',
|
||||
buffer: mockBuffer,
|
||||
name: 'Track 01',
|
||||
buffer: null,
|
||||
startTime: 0,
|
||||
height: 96,
|
||||
volumeDb: 0,
|
||||
@@ -1891,14 +2181,7 @@
|
||||
color: '#0f766e',
|
||||
markers: [],
|
||||
serverFileId: null,
|
||||
clips: [
|
||||
{
|
||||
id: 'clip_1',
|
||||
buffer: mockBuffer,
|
||||
startTime: 0,
|
||||
name: 'Creak_DeepWood2.wav'
|
||||
}
|
||||
]
|
||||
clips: []
|
||||
},
|
||||
{ id: '2', name: 'Track 02', buffer: null, startTime: 0, height: 96, volumeDb: 0, pan: 0, muted: false, solo: false, color: '#1d4ed8', markers: [], serverFileId: null },
|
||||
]);
|
||||
@@ -2055,45 +2338,7 @@
|
||||
const [subTabNormVal, setSubTabNormVal] = useState(0);
|
||||
const [subTabGainVal, setSubTabGainVal] = useState(100);
|
||||
const [subTabPitchVal, setSubTabPitchVal] = useState(0);
|
||||
const [subTabs, setSubTabs] = useState([
|
||||
{
|
||||
id: 'subtab_1',
|
||||
label: 'Edit_Creak_Deep',
|
||||
trackId: '1',
|
||||
clipId: 'clip_1',
|
||||
startTime: 0,
|
||||
endTime: 3.0,
|
||||
buffer: mockBuffer,
|
||||
effects: { normalizeDb: 0, gainDb: 0, pitch: 0, speedStretch: 100 },
|
||||
currentTime: 1.0,
|
||||
selectionStart: null,
|
||||
selectionEnd: null,
|
||||
isPlaying: false,
|
||||
fadeInLen: 1.0,
|
||||
fadeOutLen: 1.0,
|
||||
graphMode: null, // Volume Mode
|
||||
volumeNodes: [
|
||||
{ time: 0.0, db: -18.0 },
|
||||
{ time: 0.2, db: -15.0 },
|
||||
{ time: 0.4, db: -11.0 },
|
||||
{ time: 0.6, db: -7.0 },
|
||||
{ time: 0.8, db: -4.0 },
|
||||
{ time: 1.0, db: -2.0 },
|
||||
{ time: 1.2, db: -0.5 },
|
||||
{ time: 1.4, db: 0.5 },
|
||||
{ time: 1.6, db: 1.5 },
|
||||
{ time: 1.8, db: 2.0 },
|
||||
{ time: 2.0, db: 1.8 },
|
||||
{ time: 2.2, db: 1.2 },
|
||||
{ time: 2.4, db: 0.0 },
|
||||
{ time: 2.6, db: -3.0 },
|
||||
{ time: 2.8, db: -8.0 },
|
||||
{ time: 3.0, db: -15.0 }
|
||||
],
|
||||
panningNodes: [],
|
||||
speed: 1.0
|
||||
}
|
||||
]); // [{id, label, trackId, startTime, endTime, buffer, effects, currentTime, selectionStart, selectionEnd, isPlaying}, ...]
|
||||
const [subTabs, setSubTabs] = useState([]); // [{id, label, trackId, startTime, endTime, buffer, effects, currentTime, selectionStart, selectionEnd, isPlaying}, ...]
|
||||
const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
|
||||
|
||||
// ── Temp Edit Tab (LOOP_EDITOR_2.md §1.2 Sub Tab) ──
|
||||
@@ -2112,6 +2357,100 @@
|
||||
fadeOutMs: 0,
|
||||
});
|
||||
|
||||
// ── Auth / User State ──
|
||||
const [currentUser, setCurrentUser] = useState(null);
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||
const [authMode, setAuthMode] = useState('login'); // 'login' | 'register' | 'force_change'
|
||||
const [isMandatoryLogin, setIsMandatoryLogin] = useState(false);
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||
const [systemManagerModalOpen, setSystemManagerModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuthStatus = async () => {
|
||||
const savedToken = localStorage.getItem('sonic_token');
|
||||
if (!savedToken) {
|
||||
setIsMandatoryLogin(true);
|
||||
setAuthMode('login');
|
||||
setAuthModalOpen(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const profile = await window.SonicAPI.getProfile();
|
||||
setCurrentUser(profile);
|
||||
if (profile.must_change_password) {
|
||||
setIsMandatoryLogin(true);
|
||||
setAuthMode('force_change');
|
||||
setAuthModalOpen(true);
|
||||
} else {
|
||||
setIsMandatoryLogin(false);
|
||||
setAuthModalOpen(false);
|
||||
}
|
||||
} catch (err) {
|
||||
localStorage.removeItem('sonic_token');
|
||||
localStorage.removeItem('sonic_user');
|
||||
setCurrentUser(null);
|
||||
setIsMandatoryLogin(true);
|
||||
setAuthMode('login');
|
||||
setAuthModalOpen(true);
|
||||
}
|
||||
};
|
||||
checkAuthStatus();
|
||||
}, []);
|
||||
|
||||
const loadPendingSfsProject = () => {
|
||||
const proj = window.__pendingSfsProject;
|
||||
if (!proj) return;
|
||||
try {
|
||||
const restored = (proj.tracks || []).map(t => ({ ...t, buffer: null, channelInfo: t.channelInfo || null, clips: t.clips || [], serverFileId: t.serverFileId || null }));
|
||||
if (restored.length > 0) { setTracks(restored); showToast(`Đã tải dự án "${proj.name}" từ liên kết .sfs thành công!`, "success"); }
|
||||
} catch (e) { showToast("Lỗi tải dự án từ .sfs", "error"); }
|
||||
finally { window.__pendingSfsProject = null; }
|
||||
};
|
||||
|
||||
const handleAuthSuccess = (user) => {
|
||||
setCurrentUser(user);
|
||||
if (user.must_change_password) {
|
||||
setIsMandatoryLogin(true);
|
||||
setAuthMode('force_change');
|
||||
setAuthModalOpen(true);
|
||||
} else {
|
||||
setIsMandatoryLogin(false);
|
||||
setAuthModalOpen(false);
|
||||
loadPendingSfsProject();
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('sonic_token');
|
||||
localStorage.removeItem('sonic_user');
|
||||
setCurrentUser(null);
|
||||
setIsMandatoryLogin(true);
|
||||
setAuthMode('login');
|
||||
setAuthModalOpen(true);
|
||||
};
|
||||
|
||||
// ── Temp project auto-save (local + server) ──
|
||||
useEffect(() => {
|
||||
const serializeSafe = (arr) => (arr || []).map(t => ({
|
||||
id: t.id, name: t.name, startTime: t.startTime, height: t.height,
|
||||
volumeDb: t.volumeDb, pan: t.pan, muted: t.muted, solo: t.solo,
|
||||
color: t.color, markers: t.markers || [], serverFileId: t.serverFileId || null,
|
||||
channelInfo: t.channelInfo ? { channels: t.channelInfo.channels, isStereo: t.channelInfo.isStereo, label: t.channelInfo.label } : null
|
||||
}));
|
||||
window.SonicStorage.scheduleTempAutoSave(() => ({
|
||||
id: 'temp_project',
|
||||
name: 'Dự án tạm chưa lưu',
|
||||
tracks: serializeSafe(tracks),
|
||||
subTabs: (subTabs || []).map(s => ({
|
||||
id: s.id, label: s.label, trackId: s.trackId, clipId: s.clipId,
|
||||
startTime: s.startTime, endTime: s.endTime, speed: s.speed,
|
||||
fadeInLen: s.fadeInLen || 0, fadeOutLen: s.fadeOutLen || 0,
|
||||
graphMode: s.graphMode, volumeNodes: s.volumeNodes || [], panningNodes: s.panningNodes || [],
|
||||
channelInfo: s.channelInfo ? { channels: s.channelInfo.channels, isStereo: s.channelInfo.isStereo, label: s.channelInfo.label } : null
|
||||
}))
|
||||
}));
|
||||
}, [tracks, subTabs]);
|
||||
|
||||
// Lucide icons initialization
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
@@ -2495,10 +2834,10 @@
|
||||
}
|
||||
if (ctrl && e.key === 'z' && !e.shiftKey) { e.preventDefault(); handleUndoRef.current(); return; }
|
||||
if (ctrl && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); handleRedoRef.current(); return; }
|
||||
if (ctrl && !alt && e.key === 'o') { e.preventDefault(); showToast('Open Project dialog','info'); return; }
|
||||
if (ctrl && !alt && e.key === 'o') { e.preventDefault(); handleImportSFS(); return; }
|
||||
if (ctrl && !alt && e.key === 'n') { e.preventDefault(); setTracks([{ id:'1', name:'Track 01', buffer:null, startTime:0, height:96, volumeDb:0, pan:0, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, startTime:0, height:96, volumeDb:0, pan:0, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); return; }
|
||||
if (ctrl && !alt && e.key === 's') { e.preventDefault(); showToast('Project saved','success'); return; }
|
||||
if ((ctrl && alt && e.key === 's') || (ctrl && e.shiftKey && e.key === 's')) { e.preventDefault(); showToast('Save As dialog','info'); return; }
|
||||
if (ctrl && !alt && e.key === 's') { e.preventDefault(); handleExportSFS(); return; }
|
||||
if ((ctrl && alt && e.key === 's') || (ctrl && e.shiftKey && e.key === 's')) { e.preventDefault(); handleExportSFS(); return; }
|
||||
if (ctrl && !alt && e.key === 'i') { e.preventDefault(); addNewTrack(); return; }
|
||||
if (ctrl && alt && e.key === 'i') { e.preventDefault(); showToast('Import audio','info'); return; }
|
||||
if (ctrl && !alt && e.key === 'e') { e.preventDefault(); openTempTab(); return; }
|
||||
@@ -2628,8 +2967,12 @@
|
||||
}
|
||||
|
||||
const ctx = getAudioContext();
|
||||
const subBuffer = ctx.createBuffer(1, len, sr);
|
||||
subBuffer.copyToChannel(t.buffer.getChannelData(0).subarray(startSample, endSample), 0);
|
||||
const numChannels = t.buffer.numberOfChannels || 1;
|
||||
const subBuffer = ctx.createBuffer(numChannels, len, sr);
|
||||
for (let c = 0; c < numChannels; c++) {
|
||||
subBuffer.copyToChannel(t.buffer.getChannelData(c).subarray(startSample, endSample), c);
|
||||
}
|
||||
const subChannelInfo = window.SonicAudio.analyzeAudioBufferChannels(subBuffer);
|
||||
|
||||
const tabId = 'subtab_' + Date.now();
|
||||
const tabLabel = `Edit_${t.name.replace('.wav','').slice(0,10)}_${selLeft.toFixed(1)}s`;
|
||||
@@ -2641,6 +2984,7 @@
|
||||
startTime: selLeft,
|
||||
endTime: selRight,
|
||||
buffer: subBuffer,
|
||||
channelInfo: subChannelInfo,
|
||||
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0, normalizeDb: 0, pitch: 0, speedStretch: 100 },
|
||||
currentTime: 0,
|
||||
selectionStart: null,
|
||||
@@ -2680,11 +3024,16 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const sr = clip.buffer.sampleRate;
|
||||
const len = clip.buffer.length;
|
||||
const ctx = getAudioContext();
|
||||
const subBuffer = ctx.createBuffer(1, len, sr);
|
||||
subBuffer.copyToChannel(clip.buffer.getChannelData(0), 0);
|
||||
const sr = clip.buffer.sampleRate;
|
||||
const len = clip.buffer.length;
|
||||
const numChannels = clip.buffer.numberOfChannels || 1;
|
||||
const ctx = getAudioContext();
|
||||
const subBuffer = ctx.createBuffer(numChannels, len, sr);
|
||||
for (let c = 0; c < numChannels; c++) {
|
||||
subBuffer.copyToChannel(clip.buffer.getChannelData(c), c);
|
||||
}
|
||||
const subChannelInfo = window.SonicAudio.analyzeAudioBufferChannels(subBuffer);
|
||||
|
||||
|
||||
const tabId = 'subtab_' + Date.now();
|
||||
const tabLabel = `Edit_${clip.name.replace('.wav','').slice(0,10)}`;
|
||||
@@ -2696,8 +3045,9 @@
|
||||
clipId: resolvedClipId,
|
||||
startTime: clip.startTime,
|
||||
endTime: clip.startTime + clip.buffer.duration,
|
||||
buffer: subBuffer,
|
||||
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0, normalizeDb: 0, pitch: 0, speedStretch: 100 },
|
||||
buffer: subBuffer,
|
||||
channelInfo: subChannelInfo,
|
||||
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0, normalizeDb: 0, pitch: 0, speedStretch: 100 },
|
||||
currentTime: 0,
|
||||
selectionStart: null,
|
||||
selectionEnd: null,
|
||||
@@ -4632,31 +4982,23 @@
|
||||
// ── Load File on Track (with server upload) ──
|
||||
const loadFileOnTrack = async (trackId, file) => {
|
||||
if (!file) return;
|
||||
const context = getAudioContext();
|
||||
showToast(`Đang nạp file ${file.name}...`, 'info');
|
||||
|
||||
try {
|
||||
// Upload to server
|
||||
uploadToServer(file, trackId);
|
||||
|
||||
// Decode locally for playback
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const decodedBuffer = await context.decodeAudioData(e.target.result);
|
||||
setTracks(prev => prev.map(t => t.id === trackId ? {
|
||||
...t,
|
||||
name: file.name,
|
||||
buffer: decodedBuffer
|
||||
} : t));
|
||||
showToast(`Nạp file thành công: ${file.name}`, 'success');
|
||||
} catch (err) {
|
||||
showToast("Lỗi giải mã âm thanh. Định dạng file không tương thích.", 'error');
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
// Decode locally for playback + analyze channels (stereo/mono)
|
||||
const { audioBuffer: decodedBuffer, channelInfo } = await window.SonicAudio.decodeAudioFile(file);
|
||||
setTracks(prev => prev.map(t => t.id === trackId ? {
|
||||
...t,
|
||||
name: file.name,
|
||||
buffer: decodedBuffer,
|
||||
channelInfo: channelInfo
|
||||
} : t));
|
||||
showToast(`Nạp file thành công: ${file.name} (${channelInfo.label})`, 'success');
|
||||
} catch (err) {
|
||||
showToast("Lỗi: " + err.message, 'error');
|
||||
showToast("Lỗi giải mã âm thanh. Định dạng file không tương thích.", 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4791,6 +5133,48 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveCloud = async () => {
|
||||
if (!currentUser) { setIsMandatoryLogin(false); setAuthMode('login'); setAuthModalOpen(true); return; }
|
||||
const name = prompt("Nhập tên dự án để lưu lên Cloud:", "Dự án SonicForge");
|
||||
if (!name) return;
|
||||
const serializeSafe = (arr) => (arr || []).map(t => ({
|
||||
id: t.id, name: t.name, startTime: t.startTime, height: t.height,
|
||||
volumeDb: t.volumeDb, pan: t.pan, muted: t.muted, solo: t.solo,
|
||||
color: t.color, markers: t.markers || [], serverFileId: t.serverFileId || null,
|
||||
channelInfo: t.channelInfo ? { channels: t.channelInfo.channels, isStereo: t.channelInfo.isStereo, label: t.channelInfo.label } : null
|
||||
}));
|
||||
try {
|
||||
const dataJson = JSON.stringify({ id: 'cloud_project', name, tracks: serializeSafe(tracks) });
|
||||
await window.SonicAPI.saveCloudProject(name, dataJson);
|
||||
showToast("Đã lưu dự án lên Cloud thành công!", "success");
|
||||
} catch (err) { showToast(err.message || "Lỗi lưu Cloud", "error"); }
|
||||
};
|
||||
|
||||
const handleExportSFS = () => {
|
||||
const serializeSafe = (arr) => (arr || []).map(t => ({
|
||||
id: t.id, name: t.name, startTime: t.startTime, height: t.height,
|
||||
volumeDb: t.volumeDb, pan: t.pan, muted: t.muted, solo: t.solo,
|
||||
color: t.color, markers: t.markers || [], serverFileId: t.serverFileId || null,
|
||||
channelInfo: t.channelInfo ? { channels: t.channelInfo.channels, isStereo: t.channelInfo.isStereo, label: t.channelInfo.label } : null
|
||||
}));
|
||||
window.SonicStorage.exportProjectToSFS({ id: 'proj_' + Date.now(), name: 'Dự án SonicForge', tracks: serializeSafe(tracks) });
|
||||
showToast("Đã xuất dự án (.sfs) thành công!", "success");
|
||||
};
|
||||
|
||||
const handleImportSFS = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file'; input.accept = '.sfs,application/json';
|
||||
input.onchange = async (e) => {
|
||||
if (!e.target.files[0]) return;
|
||||
try {
|
||||
const proj = await window.SonicStorage.importProjectFromSFSFile(e.target.files[0]);
|
||||
const restored = (proj.tracks || []).map(t => ({ ...t, buffer: null, channelInfo: t.channelInfo || null, clips: t.clips || [], serverFileId: t.serverFileId || null }));
|
||||
if (restored.length > 0) { setTracks(restored); showToast(`Đã nạp dự án "${proj.name}" từ tệp .sfs thành công!`, "success"); }
|
||||
} catch (err) { showToast(err.message || "Lỗi mở tệp .sfs", "error"); }
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const clientSideExport = async (activeTracks) => {
|
||||
setIsExporting(true);
|
||||
showToast("Đang trộn âm thanh đa kênh (Offline Mixdown)...", "info");
|
||||
@@ -5317,16 +5701,18 @@
|
||||
{[
|
||||
{ label: 'File', items: [
|
||||
{ label: 'New Project', icon: 'file-plus', shortcut: 'Ctrl+N', action: () => { setTracks([{ id:'1', name:'Track 01', buffer:null, startTime:0, height:96, volumeDb:0, pan:0, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, startTime:0, height:96, volumeDb:0, pan:0, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); } },
|
||||
{ label: 'Open Project...', icon: 'folder-open', shortcut: 'Ctrl+O', action: () => showToast('Open project dialog','info') },
|
||||
{ label: 'Save Project', icon: 'save', shortcut: 'Ctrl+S', action: () => showToast('Project saved','success') },
|
||||
{ label: 'Save As...', icon: 'save', shortcut: 'Ctrl+Alt+S', action: () => showToast('Save as dialog','info') },
|
||||
{ label: 'Save to Cloud', icon: 'upload-cloud', action: () => showToast('Saving to cloud...','info') },
|
||||
{ label: 'Open Project...', icon: 'folder-open', shortcut: 'Ctrl+O', action: () => handleImportSFS() },
|
||||
{ label: 'Save Project', icon: 'save', shortcut: 'Ctrl+S', action: () => handleExportSFS() },
|
||||
{ label: 'Save As...', icon: 'save', shortcut: 'Ctrl+Alt+S', action: () => handleExportSFS() },
|
||||
{ label: 'Save to Cloud', icon: 'upload-cloud', action: () => handleSaveCloud() },
|
||||
{ sep: true },
|
||||
{ label: 'Import Audio...', icon: 'file-input', shortcut: 'Ctrl+Alt+I', action: () => { const input = document.createElement('input'); input.type='file'; input.accept='audio/*'; input.onchange=async (e)=>{ if(e.target.files[0]){ addNewTrack(); const newId=(tracks.length+1).toString(); setTimeout(()=>loadFileOnTrack(newId,e.target.files[0]),100); } }; input.click(); showToast('Import audio','info'); } },
|
||||
{ label: 'Export Mix...', icon: 'file-output', action: () => triggerWavExport() },
|
||||
{ sep: true },
|
||||
{ label: 'Logout', icon: 'log-out', action: () => showToast('Logged out','info') },
|
||||
]},
|
||||
{ label: 'Export Mix...', icon: 'file-output', action: () => triggerWavExport() },
|
||||
{ sep: true },
|
||||
...(currentUser ? [{ label: 'Profile', icon: 'user', action: () => setProfileModalOpen(true) }] : []),
|
||||
...(currentUser && currentUser.role === 'admin' ? [{ label: 'System Manager', icon: 'settings', action: () => setSystemManagerModalOpen(true) }] : []),
|
||||
{ label: 'Logout', icon: 'log-out', action: () => handleLogout() },
|
||||
]},
|
||||
{ label: 'Edit', items: [
|
||||
{ label: 'Insert New Track', icon: 'plus', shortcut: 'Ctrl+I', action: addNewTrack },
|
||||
{ label: 'Insert Music to Track', icon: 'music', shortcut: 'Ctrl+Alt+I', action: () => showToast('Select music file to insert','info') },
|
||||
@@ -5836,9 +6222,25 @@
|
||||
{renderPanelContent(p)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
{/* ── Auth & User Modals ── */}
|
||||
<AuthModal
|
||||
isOpen={authModalOpen}
|
||||
mode={authMode}
|
||||
forceMandatory={isMandatoryLogin}
|
||||
onClose={() => setAuthModalOpen(false)}
|
||||
onSuccess={handleAuthSuccess}
|
||||
/>
|
||||
<ProfileModal
|
||||
isOpen={profileModalOpen}
|
||||
onClose={() => setProfileModalOpen(false)}
|
||||
/>
|
||||
<SystemManagerModal
|
||||
isOpen={systemManagerModalOpen}
|
||||
onClose={() => setSystemManagerModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={workspaceRef} className="flex-1 flex flex-col overflow-hidden select-none daw-bg relative">
|
||||
@@ -6189,6 +6591,7 @@
|
||||
fadeInLen={st.fadeInLen || 0}
|
||||
fadeOutLen={st.fadeOutLen || 0}
|
||||
graphMode={st.graphMode}
|
||||
channelInfo={st.channelInfo}
|
||||
selectedNodeTime={subTabSelectedNodeTime}
|
||||
setSelectedNodeTime={setSubTabSelectedNodeTime}
|
||||
onUpdateNodes={(nodes) => setSubTabs(prev => prev.map(s => s.id === st.id ? {...s, [s.graphMode === 'pan' ? 'panningNodes' : 'volumeNodes']: nodes} : s))}
|
||||
@@ -6386,6 +6789,22 @@
|
||||
{toastMessage.text}
|
||||
</div>
|
||||
)}
|
||||
{/* ── Auth & User Modals ── */}
|
||||
<AuthModal
|
||||
isOpen={authModalOpen}
|
||||
mode={authMode}
|
||||
forceMandatory={isMandatoryLogin}
|
||||
onClose={() => setAuthModalOpen(false)}
|
||||
onSuccess={handleAuthSuccess}
|
||||
/>
|
||||
<ProfileModal
|
||||
isOpen={profileModalOpen}
|
||||
onClose={() => setProfileModalOpen(false)}
|
||||
/>
|
||||
<SystemManagerModal
|
||||
isOpen={systemManagerModalOpen}
|
||||
onClose={() => setSystemManagerModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user