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() {
}
>
+ {message || "Are you sure?"} +
+| + {col.label} + | + ))} +
|---|
| + {col.render ? col.render(row) : row[col.key]} + | + ))} +