diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a6d4f88..9738ff1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,9 @@ "allow": [ "Bash(npm create:*)", "Bash(npm install:*)", - "Bash(npm run build:*)" + "Bash(npm run build:*)", + "Bash(python -c:*)", + "Bash(npx vite build:*)" ] } } diff --git a/.env.example b/.env.example index 147a609..9378b3a 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ # Firebase FIREBASE_SERVICE_ACCOUNT_PATH=./firebase-service-account.json +FIREBASE_STORAGE_BUCKET=your-project-id.appspot.com # JWT JWT_SECRET_KEY=your-secret-key-here diff --git a/backend/config.py b/backend/config.py index 3bd158b..722fdab 100644 --- a/backend/config.py +++ b/backend/config.py @@ -6,6 +6,7 @@ import json class Settings(BaseSettings): # Firebase firebase_service_account_path: str = "./firebase-service-account.json" + firebase_storage_bucket: str = "" # JWT jwt_secret_key: str = "change-me-in-production" diff --git a/backend/main.py b/backend/main.py index 208f5f2..451eab1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,6 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware from config import settings from shared.firebase import init_firebase, firebase_initialized from auth.router import router as auth_router +from melodies.router import router as melodies_router app = FastAPI( title="BellSystems Admin Panel", @@ -20,6 +21,7 @@ app.add_middleware( ) app.include_router(auth_router) +app.include_router(melodies_router) @app.on_event("startup") diff --git a/backend/melodies/models.py b/backend/melodies/models.py index d14cd61..dfcf320 100644 --- a/backend/melodies/models.py +++ b/backend/melodies/models.py @@ -1 +1,70 @@ -# TODO: Melody Pydantic schemas +from pydantic import BaseModel +from typing import List, Optional +from enum import Enum + + +class MelodyType(str, Enum): + orthodox = "orthodox" + catholic = "catholic" + all = "all" + + +class MelodyTone(str, Enum): + normal = "normal" + festive = "festive" + cheerful = "cheerful" + lamentation = "lamentation" + + +class MelodyInfo(BaseModel): + name: str + description: str = "" + melodyTone: MelodyTone = MelodyTone.normal + customTags: List[str] = [] + minSpeed: int = 0 + maxSpeed: int = 0 + totalNotes: int = 1 + steps: int = 0 + color: str = "" + isTrueRing: bool = False + previewURL: str = "" + notes: List[int] = [] + + +class MelodyAttributes(BaseModel): + speed: int = 0 + duration: int = 0 + totalRunDuration: int = 0 + pauseDuration: int = 0 + infiniteLoop: bool = False + echoRing: List[int] = [] + noteAssignments: List[int] = [] + + +# --- Request / Response schemas --- + +class MelodyCreate(BaseModel): + information: MelodyInfo + default_settings: MelodyAttributes + type: MelodyType = MelodyType.all + url: str = "" + uid: str = "" + pid: str = "" + + +class MelodyUpdate(BaseModel): + information: Optional[MelodyInfo] = None + default_settings: Optional[MelodyAttributes] = None + type: Optional[MelodyType] = None + url: Optional[str] = None + uid: Optional[str] = None + pid: Optional[str] = None + + +class MelodyInDB(MelodyCreate): + id: str + + +class MelodyListResponse(BaseModel): + melodies: List[MelodyInDB] + total: int diff --git a/backend/melodies/router.py b/backend/melodies/router.py index 2434feb..63adc76 100644 --- a/backend/melodies/router.py +++ b/backend/melodies/router.py @@ -1 +1,118 @@ -# TODO: CRUD endpoints for melodies +from fastapi import APIRouter, Depends, UploadFile, File, Query, HTTPException +from typing import Optional +from auth.models import TokenPayload +from auth.dependencies import require_melody_access, require_viewer +from melodies.models import ( + MelodyCreate, MelodyUpdate, MelodyInDB, MelodyListResponse, MelodyInfo, +) +from melodies import service + +router = APIRouter(prefix="/api/melodies", tags=["melodies"]) + + +@router.get("", response_model=MelodyListResponse) +async def list_melodies( + search: Optional[str] = Query(None), + type: Optional[str] = Query(None), + tone: Optional[str] = Query(None), + total_notes: Optional[int] = Query(None), + _user: TokenPayload = Depends(require_viewer), +): + melodies = service.list_melodies( + search=search, + melody_type=type, + tone=tone, + total_notes=total_notes, + ) + return MelodyListResponse(melodies=melodies, total=len(melodies)) + + +@router.get("/{melody_id}", response_model=MelodyInDB) +async def get_melody( + melody_id: str, + _user: TokenPayload = Depends(require_viewer), +): + return service.get_melody(melody_id) + + +@router.post("", response_model=MelodyInDB, status_code=201) +async def create_melody( + body: MelodyCreate, + _user: TokenPayload = Depends(require_melody_access), +): + return service.create_melody(body) + + +@router.put("/{melody_id}", response_model=MelodyInDB) +async def update_melody( + melody_id: str, + body: MelodyUpdate, + _user: TokenPayload = Depends(require_melody_access), +): + return service.update_melody(melody_id, body) + + +@router.delete("/{melody_id}", status_code=204) +async def delete_melody( + melody_id: str, + _user: TokenPayload = Depends(require_melody_access), +): + service.delete_melody(melody_id) + + +@router.post("/{melody_id}/upload/{file_type}") +async def upload_file( + melody_id: str, + file_type: str, + file: UploadFile = File(...), + _user: TokenPayload = Depends(require_melody_access), +): + """Upload a binary or preview file. file_type must be 'binary' or 'preview'.""" + if file_type not in ("binary", "preview"): + raise HTTPException(status_code=400, detail="file_type must be 'binary' or 'preview'") + + # Verify melody exists + melody = service.get_melody(melody_id) + + contents = await file.read() + content_type = file.content_type or "application/octet-stream" + + if file_type == "binary": + content_type = "application/octet-stream" + + url = service.upload_file(melody_id, contents, file.filename, content_type) + + # Update the melody document with the new URL if it's a preview + if file_type == "preview": + service.update_melody(melody_id, MelodyUpdate( + information=MelodyInfo( + name=melody.information.name, + previewURL=url, + ) + )) + + return {"url": url, "file_type": file_type} + + +@router.delete("/{melody_id}/files/{file_type}", status_code=204) +async def delete_file( + melody_id: str, + file_type: str, + _user: TokenPayload = Depends(require_melody_access), +): + """Delete a binary or preview file. file_type must be 'binary' or 'preview'.""" + if file_type not in ("binary", "preview"): + raise HTTPException(status_code=400, detail="file_type must be 'binary' or 'preview'") + + service.get_melody(melody_id) + service.delete_file(melody_id, file_type) + + +@router.get("/{melody_id}/files") +async def get_files( + melody_id: str, + _user: TokenPayload = Depends(require_viewer), +): + """Get storage file URLs for a melody.""" + service.get_melody(melody_id) + return service.get_storage_files(melody_id) diff --git a/backend/melodies/service.py b/backend/melodies/service.py index c569e13..bd3ccf9 100644 --- a/backend/melodies/service.py +++ b/backend/melodies/service.py @@ -1 +1,174 @@ -# TODO: Melody Firestore operations +from shared.firebase import get_db, get_bucket +from shared.exceptions import NotFoundError +from melodies.models import MelodyCreate, MelodyUpdate, MelodyInDB + +COLLECTION = "melodies" + + +def _doc_to_melody(doc) -> MelodyInDB: + """Convert a Firestore document snapshot to a MelodyInDB model.""" + data = doc.to_dict() + return MelodyInDB(id=doc.id, **data) + + +def list_melodies( + search: str | None = None, + melody_type: str | None = None, + tone: str | None = None, + total_notes: int | None = None, +) -> list[MelodyInDB]: + """List melodies with optional filters.""" + db = get_db() + ref = db.collection(COLLECTION) + + # Firestore doesn't support full-text search, so we fetch and filter in-memory + # for the name search. Type/tone/totalNotes can be queried server-side. + query = ref + + if melody_type: + query = query.where("type", "==", melody_type) + + docs = query.stream() + results = [] + + for doc in docs: + melody = _doc_to_melody(doc) + + # Client-side filters + if tone and melody.information.melodyTone.value != tone: + continue + if total_notes is not None and melody.information.totalNotes != total_notes: + continue + if search: + search_lower = search.lower() + name_match = search_lower in melody.information.name.lower() + desc_match = search_lower in melody.information.description.lower() + tag_match = any(search_lower in t.lower() for t in melody.information.customTags) + if not (name_match or desc_match or tag_match): + continue + + results.append(melody) + + return results + + +def get_melody(melody_id: str) -> MelodyInDB: + """Get a single melody by document ID.""" + db = get_db() + doc = db.collection(COLLECTION).document(melody_id).get() + if not doc.exists: + raise NotFoundError("Melody") + return _doc_to_melody(doc) + + +def create_melody(data: MelodyCreate) -> MelodyInDB: + """Create a new melody document in Firestore.""" + db = get_db() + doc_data = data.model_dump() + _, doc_ref = db.collection(COLLECTION).add(doc_data) + return MelodyInDB(id=doc_ref.id, **doc_data) + + +def update_melody(melody_id: str, data: MelodyUpdate) -> MelodyInDB: + """Update an existing melody document. Only provided fields are updated.""" + db = get_db() + doc_ref = db.collection(COLLECTION).document(melody_id) + doc = doc_ref.get() + if not doc.exists: + raise NotFoundError("Melody") + + update_data = data.model_dump(exclude_none=True) + + # For nested structs, merge with existing data rather than replacing + existing = doc.to_dict() + for key in ("information", "default_settings"): + if key in update_data and key in existing: + merged = {**existing[key], **update_data[key]} + update_data[key] = merged + + doc_ref.update(update_data) + + updated_doc = doc_ref.get() + return _doc_to_melody(updated_doc) + + +def delete_melody(melody_id: str) -> None: + """Delete a melody document and its associated storage files.""" + db = get_db() + doc_ref = db.collection(COLLECTION).document(melody_id) + doc = doc_ref.get() + if not doc.exists: + raise NotFoundError("Melody") + + # Delete associated storage files + _delete_storage_files(melody_id) + + doc_ref.delete() + + +def upload_file(melody_id: str, file_bytes: bytes, filename: str, content_type: str) -> str: + """Upload a file to Firebase Storage under melodies/{melody_id}/.""" + bucket = get_bucket() + if not bucket: + raise RuntimeError("Firebase Storage not initialized") + + # Determine subfolder based on content type + if content_type in ("application/octet-stream", "application/macbinary"): + storage_path = f"melodies/{melody_id}/binary.bin" + else: + # Audio preview files + ext = filename.rsplit(".", 1)[-1] if "." in filename else "mp3" + storage_path = f"melodies/{melody_id}/preview.{ext}" + + blob = bucket.blob(storage_path) + blob.upload_from_string(file_bytes, content_type=content_type) + blob.make_public() + return blob.public_url + + +def delete_file(melody_id: str, file_type: str) -> None: + """Delete a specific file from storage. file_type is 'binary' or 'preview'.""" + bucket = get_bucket() + if not bucket: + return + + prefix = f"melodies/{melody_id}/" + blobs = list(bucket.list_blobs(prefix=prefix)) + + for blob in blobs: + if file_type == "binary" and "binary" in blob.name: + blob.delete() + elif file_type == "preview" and "preview" in blob.name: + blob.delete() + + +def _delete_storage_files(melody_id: str) -> None: + """Delete all storage files for a melody.""" + bucket = get_bucket() + if not bucket: + return + + prefix = f"melodies/{melody_id}/" + blobs = list(bucket.list_blobs(prefix=prefix)) + for blob in blobs: + blob.delete() + + +def get_storage_files(melody_id: str) -> dict: + """List storage files for a melody, returning URLs.""" + bucket = get_bucket() + if not bucket: + return {"binary_url": None, "preview_url": None} + + prefix = f"melodies/{melody_id}/" + blobs = list(bucket.list_blobs(prefix=prefix)) + + result = {"binary_url": None, "preview_url": None} + for blob in blobs: + blob.make_public() + if "binary" in blob.name: + result["binary_url"] = blob.public_url + elif "preview" in blob.name: + result["preview_url"] = blob.public_url + + return result diff --git a/backend/shared/firebase.py b/backend/shared/firebase.py index 6ed0898..183c7c1 100644 --- a/backend/shared/firebase.py +++ b/backend/shared/firebase.py @@ -1,18 +1,22 @@ import firebase_admin -from firebase_admin import credentials, firestore +from firebase_admin import credentials, firestore, storage from config import settings db = None +bucket = None firebase_initialized = False def init_firebase(): """Initialize Firebase Admin SDK. Call once at app startup.""" - global db, firebase_initialized + global db, bucket, firebase_initialized try: cred = credentials.Certificate(settings.firebase_service_account_path) - firebase_admin.initialize_app(cred) + firebase_admin.initialize_app(cred, { + "storageBucket": settings.firebase_storage_bucket, + }) db = firestore.client() + bucket = storage.bucket() firebase_initialized = True except Exception as e: print(f"[WARNING] Firebase init failed: {e}") @@ -22,3 +26,8 @@ def init_firebase(): def get_db(): """Return the Firestore client. None if Firebase is not initialized.""" return db + + +def get_bucket(): + """Return the Firebase Storage bucket. None if Firebase is not initialized.""" + return bucket diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d161d7a..23eecf0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,6 +2,9 @@ import { Routes, Route, Navigate } from "react-router-dom"; import { useAuth } from "./auth/AuthContext"; import LoginPage from "./auth/LoginPage"; import MainLayout from "./layout/MainLayout"; +import MelodyList from "./melodies/MelodyList"; +import MelodyDetail from "./melodies/MelodyDetail"; +import MelodyForm from "./melodies/MelodyForm"; function ProtectedRoute({ children }) { const { user, loading } = useAuth(); @@ -46,8 +49,11 @@ export default function App() { } > } /> - {/* Phase 2+ routes: } /> + } /> + } /> + } /> + {/* Phase 3+ routes: } /> } /> } /> diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 86a1c87..7c7fc49 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -57,6 +57,39 @@ class ApiClient { delete(endpoint) { return this.request(endpoint, { method: "DELETE" }); } + + async upload(endpoint, file) { + const url = `${API_BASE}${endpoint}`; + const token = this.getToken(); + + const formData = new FormData(); + formData.append("file", file); + + const headers = {}; + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const response = await fetch(url, { + method: "POST", + headers, + body: formData, + }); + + if (response.status === 401) { + localStorage.removeItem("access_token"); + localStorage.removeItem("user"); + window.location.href = "/login"; + throw new Error("Session expired"); + } + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || `Upload failed: ${response.status}`); + } + + return response.json(); + } } const api = new ApiClient(); diff --git a/frontend/src/components/ConfirmDialog.jsx b/frontend/src/components/ConfirmDialog.jsx index f9ada8d..176a0b6 100644 --- a/frontend/src/components/ConfirmDialog.jsx +++ b/frontend/src/components/ConfirmDialog.jsx @@ -1 +1,31 @@ -// TODO: Confirmation dialog component +export default function ConfirmDialog({ open, title, message, onConfirm, onCancel }) { + if (!open) return null; + + return ( +
+
+
+

+ {title || "Confirm"} +

+

+ {message || "Are you sure?"} +

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/DataTable.jsx b/frontend/src/components/DataTable.jsx index 0d10512..a781f57 100644 --- a/frontend/src/components/DataTable.jsx +++ b/frontend/src/components/DataTable.jsx @@ -1 +1,48 @@ -// TODO: Reusable table component +export default function DataTable({ columns, data, onRowClick, emptyMessage = "No data found." }) { + if (!data || data.length === 0) { + return ( +
+ {emptyMessage} +
+ ); + } + + return ( +
+
+ + + + {columns.map((col) => ( + + ))} + + + + {data.map((row, idx) => ( + onRowClick?.(row)} + className={`border-b border-gray-100 last:border-0 ${ + onRowClick ? "cursor-pointer hover:bg-gray-50" : "" + }`} + > + {columns.map((col) => ( + + ))} + + ))} + +
+ {col.label} +
+ {col.render ? col.render(row) : row[col.key]} +
+
+
+ ); +} diff --git a/frontend/src/components/SearchBar.jsx b/frontend/src/components/SearchBar.jsx index c2d53d1..78ac533 100644 --- a/frontend/src/components/SearchBar.jsx +++ b/frontend/src/components/SearchBar.jsx @@ -1 +1,44 @@ -// TODO: Search bar component +import { useState } from "react"; + +export default function SearchBar({ onSearch, placeholder = "Search..." }) { + const [value, setValue] = useState(""); + + const handleSubmit = (e) => { + e.preventDefault(); + onSearch(value); + }; + + const handleClear = () => { + setValue(""); + onSearch(""); + }; + + return ( +
+
+ setValue(e.target.value)} + placeholder={placeholder} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm" + /> + {value && ( + + )} +
+ +
+ ); +} diff --git a/frontend/src/melodies/MelodyDetail.jsx b/frontend/src/melodies/MelodyDetail.jsx index 5a22cc1..70a92c9 100644 --- a/frontend/src/melodies/MelodyDetail.jsx +++ b/frontend/src/melodies/MelodyDetail.jsx @@ -1 +1,278 @@ -// TODO: Melody detail view +import { useState, useEffect } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import api from "../api/client"; +import { useAuth } from "../auth/AuthContext"; +import ConfirmDialog from "../components/ConfirmDialog"; + +function Field({ label, children }) { + return ( +
+
+ {label} +
+
{children || "-"}
+
+ ); +} + +export default function MelodyDetail() { + const { id } = useParams(); + const navigate = useNavigate(); + const { hasRole } = useAuth(); + const canEdit = hasRole("superadmin", "melody_editor"); + + const [melody, setMelody] = useState(null); + const [files, setFiles] = useState({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [showDelete, setShowDelete] = useState(false); + + useEffect(() => { + loadData(); + }, [id]); + + const loadData = async () => { + setLoading(true); + try { + const [m, f] = await Promise.all([ + api.get(`/melodies/${id}`), + api.get(`/melodies/${id}/files`), + ]); + setMelody(m); + setFiles(f); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const handleDelete = async () => { + try { + await api.delete(`/melodies/${id}`); + navigate("/melodies"); + } catch (err) { + setError(err.message); + setShowDelete(false); + } + }; + + if (loading) { + return
Loading...
; + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!melody) return null; + + const info = melody.information || {}; + const settings = melody.default_settings || {}; + + return ( +
+
+
+ +

+ {info.name || "Untitled Melody"} +

+
+ {canEdit && ( +
+ + +
+ )} +
+ + {/* Melody Information */} +
+

+ Melody Information +

+
+ + {melody.type} + + + {info.melodyTone} + + {info.totalNotes} + {info.steps} + {info.minSpeed} + {info.maxSpeed} + + {info.color ? ( + + + {info.color} + + ) : ( + "-" + )} + + + + {info.isTrueRing ? "Yes" : "No"} + + +
+ {info.description} +
+
+ + {info.notes?.length > 0 ? info.notes.join(", ") : "-"} + +
+
+ + {info.customTags?.length > 0 ? ( +
+ {info.customTags.map((tag) => ( + + {tag} + + ))} +
+ ) : ( + "-" + )} +
+
+
+
+ + {/* Default Settings */} +
+

+ Default Settings +

+
+ {settings.speed} + {settings.duration} + {settings.totalRunDuration} + {settings.pauseDuration} + + + {settings.infiniteLoop ? "Yes" : "No"} + + +
+ + {settings.echoRing?.length > 0 + ? settings.echoRing.join(", ") + : "-"} + +
+
+ + {settings.noteAssignments?.length > 0 + ? settings.noteAssignments.join(", ") + : "-"} + +
+
+
+ + {/* Identifiers */} +
+

+ Identifiers +

+
+ {melody.id} + {melody.uid} + {melody.pid} +
+ {melody.url} +
+
+
+ + {/* Files */} +
+

Files

+
+ + {files.binary_url ? ( + + Download binary + + ) : ( + Not uploaded + )} + + + {files.preview_url ? ( + + ) : ( + Not uploaded + )} + +
+
+ + setShowDelete(false)} + /> +
+ ); +} diff --git a/frontend/src/melodies/MelodyForm.jsx b/frontend/src/melodies/MelodyForm.jsx index 8c025f5..1253c45 100644 --- a/frontend/src/melodies/MelodyForm.jsx +++ b/frontend/src/melodies/MelodyForm.jsx @@ -1 +1,631 @@ -// TODO: Add / Edit melody form +import { useState, useEffect } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import api from "../api/client"; + +const MELODY_TYPES = ["orthodox", "catholic", "all"]; +const MELODY_TONES = ["normal", "festive", "cheerful", "lamentation"]; + +const defaultInfo = { + name: "", + description: "", + melodyTone: "normal", + customTags: [], + minSpeed: 0, + maxSpeed: 0, + totalNotes: 1, + steps: 0, + color: "", + isTrueRing: false, + previewURL: "", + notes: [], +}; + +const defaultSettings = { + speed: 0, + duration: 0, + totalRunDuration: 0, + pauseDuration: 0, + infiniteLoop: false, + echoRing: [], + noteAssignments: [], +}; + +export default function MelodyForm() { + const { id } = useParams(); + const isEdit = Boolean(id); + const navigate = useNavigate(); + + const [information, setInformation] = useState({ ...defaultInfo }); + const [settings, setSettings] = useState({ ...defaultSettings }); + const [type, setType] = useState("all"); + const [url, setUrl] = useState(""); + const [uid, setUid] = useState(""); + const [pid, setPid] = useState(""); + + const [binaryFile, setBinaryFile] = useState(null); + const [previewFile, setPreviewFile] = useState(null); + const [existingFiles, setExistingFiles] = useState({}); + const [uploading, setUploading] = useState(false); + + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + + // Tag input state + const [tagInput, setTagInput] = useState(""); + + useEffect(() => { + if (isEdit) { + loadMelody(); + } + }, [id]); + + const loadMelody = async () => { + setLoading(true); + try { + const [melody, files] = await Promise.all([ + api.get(`/melodies/${id}`), + api.get(`/melodies/${id}/files`), + ]); + setInformation({ ...defaultInfo, ...melody.information }); + setSettings({ ...defaultSettings, ...melody.default_settings }); + setType(melody.type || "all"); + setUrl(melody.url || ""); + setUid(melody.uid || ""); + setPid(melody.pid || ""); + setExistingFiles(files); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const updateInfo = (field, value) => { + setInformation((prev) => ({ ...prev, [field]: value })); + }; + + const updateSettings = (field, value) => { + setSettings((prev) => ({ ...prev, [field]: value })); + }; + + const addTag = () => { + const tag = tagInput.trim(); + if (tag && !information.customTags.includes(tag)) { + updateInfo("customTags", [...information.customTags, tag]); + } + setTagInput(""); + }; + + const removeTag = (tag) => { + updateInfo( + "customTags", + information.customTags.filter((t) => t !== tag) + ); + }; + + // Parse comma-separated integers for list fields + const parseIntList = (str) => { + if (!str.trim()) return []; + return str + .split(",") + .map((s) => parseInt(s.trim(), 10)) + .filter((n) => !isNaN(n)); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setSaving(true); + setError(""); + + try { + const body = { + information, + default_settings: settings, + type, + url, + uid, + pid, + }; + + let melodyId = id; + + if (isEdit) { + await api.put(`/melodies/${id}`, body); + } else { + const created = await api.post("/melodies", body); + melodyId = created.id; + } + + // Upload files if selected + if (binaryFile || previewFile) { + setUploading(true); + if (binaryFile) { + await api.upload(`/melodies/${melodyId}/upload/binary`, binaryFile); + } + if (previewFile) { + await api.upload(`/melodies/${melodyId}/upload/preview`, previewFile); + } + setUploading(false); + } + + navigate(`/melodies/${melodyId}`); + } catch (err) { + setError(err.message); + setUploading(false); + } finally { + setSaving(false); + } + }; + + if (loading) { + return
Loading...
; + } + + return ( +
+

+ {isEdit ? "Edit Melody" : "Add Melody"} +

+ + {error && ( +
+ {error} +
+ )} + +
+ {/* --- Melody Info Section --- */} +
+

+ Melody Information +

+
+
+ + updateInfo("name", e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm" + /> +
+ +
+ +