diff --git a/backend/settings/models.py b/backend/settings/models.py index 5d89fab..6673ad0 100644 --- a/backend/settings/models.py +++ b/backend/settings/models.py @@ -1,11 +1,19 @@ from pydantic import BaseModel from typing import List, Optional +DEFAULT_NOTE_ASSIGNMENT_COLORS: List[str] = [ + "#67E8F9", "#5EEAD4", "#6EE7B7", "#86EFAC", + "#BEF264", "#FDE68A", "#FCD34D", "#FBBF24", + "#FDBA74", "#FB923C", "#F97316", "#FB7185", + "#F87171", "#EF4444", "#DC2626", "#B91C1C", +] + class MelodySettings(BaseModel): available_languages: List[str] = ["en", "el", "sr"] primary_language: str = "en" quick_colors: List[str] = ["#FF5733", "#33FF57", "#3357FF", "#FFD700", "#FF69B4", "#8B4513"] + note_assignment_colors: List[str] = DEFAULT_NOTE_ASSIGNMENT_COLORS duration_values: List[int] = [ 0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165, 180, 240, 300, 360, 420, 480, 540, 600, 900, @@ -16,4 +24,5 @@ class MelodySettingsUpdate(BaseModel): available_languages: Optional[List[str]] = None primary_language: Optional[str] = None quick_colors: Optional[List[str]] = None + note_assignment_colors: Optional[List[str]] = None duration_values: Optional[List[int]] = None diff --git a/backend/settings/service.py b/backend/settings/service.py index cf0d079..d678ffd 100644 --- a/backend/settings/service.py +++ b/backend/settings/service.py @@ -10,7 +10,10 @@ def get_melody_settings() -> MelodySettings: db = get_db() doc = db.collection(COLLECTION).document(DOC_ID).get() if doc.exists: - return MelodySettings(**doc.to_dict()) + settings = MelodySettings(**doc.to_dict()) + # Backfill newly introduced defaults into stored settings. + db.collection(COLLECTION).document(DOC_ID).set(settings.model_dump()) + return settings # Create with defaults defaults = MelodySettings() db.collection(COLLECTION).document(DOC_ID).set(defaults.model_dump()) @@ -35,5 +38,6 @@ def update_melody_settings(data: MelodySettingsUpdate) -> MelodySettings: if "duration_values" in existing: existing["duration_values"] = sorted(existing["duration_values"]) - doc_ref.set(existing) - return MelodySettings(**existing) + normalized = MelodySettings(**existing) + doc_ref.set(normalized.model_dump()) + return normalized diff --git a/frontend/src/melodies/BinaryTableModal.jsx b/frontend/src/melodies/BinaryTableModal.jsx index 0bce158..f4a387f 100644 --- a/frontend/src/melodies/BinaryTableModal.jsx +++ b/frontend/src/melodies/BinaryTableModal.jsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import api from "../api/client"; const NOTE_LABELS = "ABCDEFGHIJKLMNOP"; @@ -51,6 +52,18 @@ function noteDotGlow(noteNumber) { return `hsla(${hue}, 78%, 56%, 0.45)`; } +function noteDotColorFromSettings(noteNumber, colorPalette) { + const n = Number(noteNumber || 1); + const custom = colorPalette?.[n - 1]; + return custom || noteDotColor(n); +} + +function noteDotGlowFromSettings(noteNumber, colorPalette) { + const n = Number(noteNumber || 1); + const custom = colorPalette?.[n - 1]; + return custom ? `${custom}66` : noteDotGlow(n); +} + function normalizeFileUrl(url) { if (!url || typeof url !== "string") return null; if (url.startsWith("http") || url.startsWith("/api")) return url; @@ -97,6 +110,7 @@ export default function BinaryTableModal({ open, melody, builtMelody, files, arc const [steps, setSteps] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const [noteColors, setNoteColors] = useState([]); useEffect(() => { if (!open) { @@ -138,6 +152,20 @@ export default function BinaryTableModal({ open, melody, builtMelody, files, arc setError("No binary or archetype data available for this melody."); }, [open]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (!open) return; + let canceled = false; + api.get("/settings/melody") + .then((s) => { + if (canceled) return; + setNoteColors((s?.note_assignment_colors || []).slice(0, 16)); + }) + .catch(() => { + if (!canceled) setNoteColors([]); + }); + return () => { canceled = true; }; + }, [open]); + if (!open) return null; const detectedNoteCount = steps.reduce((max, stepValue) => { @@ -226,10 +254,10 @@ export default function BinaryTableModal({ open, melody, builtMelody, files, arc width: "60%", height: "60%", borderRadius: "9999px", - backgroundColor: noteDotColor(noteIdx + 1), + backgroundColor: noteDotColorFromSettings(noteIdx + 1, noteColors), opacity: enabled ? 1 : 0, transform: enabled ? "scale(1)" : "scale(0.4)", - boxShadow: enabled ? `0 0 10px 3px ${noteDotGlow(noteIdx + 1)}` : "none", + boxShadow: enabled ? `0 0 10px 3px ${noteDotGlowFromSettings(noteIdx + 1, noteColors)}` : "none", transition: "opacity 140ms ease, transform 140ms ease, box-shadow 180ms ease", }} /> diff --git a/frontend/src/melodies/MelodyComposer.jsx b/frontend/src/melodies/MelodyComposer.jsx index 4c8b5c0..ab1394a 100644 --- a/frontend/src/melodies/MelodyComposer.jsx +++ b/frontend/src/melodies/MelodyComposer.jsx @@ -55,6 +55,18 @@ function noteDotGlow(noteNumber) { return `hsla(${hue}, 78%, 56%, 0.45)`; } +function noteDotColorFromSettings(noteNumber, colorPalette) { + const n = Number(noteNumber || 1); + const custom = colorPalette?.[n - 1]; + return custom || noteDotColor(n); +} + +function noteDotGlowFromSettings(noteNumber, colorPalette) { + const n = Number(noteNumber || 1); + const custom = colorPalette?.[n - 1]; + return custom ? `${custom}66` : noteDotGlow(n); +} + function playStep(audioCtx, stepValue, noteDurationMs) { if (!audioCtx) return; @@ -101,6 +113,7 @@ export default function MelodyComposer() { const [deployPid, setDeployPid] = useState(""); const [deployError, setDeployError] = useState(""); const [deploying, setDeploying] = useState(false); + const [noteColors, setNoteColors] = useState([]); const audioCtxRef = useRef(null); const playbackRef = useRef(null); @@ -181,6 +194,19 @@ export default function MelodyComposer() { }; }, [stopPlayback]); + useEffect(() => { + let canceled = false; + api.get("/settings/melody") + .then((s) => { + if (canceled) return; + setNoteColors((s?.note_assignment_colors || []).slice(0, 16)); + }) + .catch(() => { + if (!canceled) setNoteColors([]); + }); + return () => { canceled = true; }; + }, []); + const toggleCell = (noteIndex, stepIndex) => { const bit = 1 << noteIndex; setSteps((prev) => { @@ -560,10 +586,10 @@ export default function MelodyComposer() { width: "54%", height: "54%", borderRadius: "9999px", - backgroundColor: noteDotColor(noteIndex + 1), + backgroundColor: noteDotColorFromSettings(noteIndex + 1, noteColors), opacity: enabled ? 1 : 0, transform: enabled ? "scale(1)" : "scale(0.4)", - boxShadow: enabled ? `0 0 10px 3px ${noteDotGlow(noteIndex + 1)}` : "none", + boxShadow: enabled ? `0 0 10px 3px ${noteDotGlowFromSettings(noteIndex + 1, noteColors)}` : "none", transition: "opacity 140ms ease, transform 140ms ease, box-shadow 180ms ease", }} /> diff --git a/frontend/src/melodies/MelodySettings.jsx b/frontend/src/melodies/MelodySettings.jsx index e41ddb8..ae4ed26 100644 --- a/frontend/src/melodies/MelodySettings.jsx +++ b/frontend/src/melodies/MelodySettings.jsx @@ -7,6 +7,13 @@ import { normalizeColor, } from "./melodyUtils"; +const DEFAULT_NOTE_ASSIGNMENT_COLORS = [ + "#67E8F9", "#5EEAD4", "#6EE7B7", "#86EFAC", + "#BEF264", "#FDE68A", "#FCD34D", "#FBBF24", + "#FDBA74", "#FB923C", "#F97316", "#FB7185", + "#F87171", "#EF4444", "#DC2626", "#B91C1C", +]; + const sectionStyle = { backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", @@ -29,6 +36,9 @@ export default function MelodySettings() { const [colorToAdd, setColorToAdd] = useState(cssColorDefault); const [colorHexInput, setColorHexInput] = useState(cssColorDefault); const [durationToAdd, setDurationToAdd] = useState(""); + const [colorModalBell, setColorModalBell] = useState(null); + const [modalColor, setModalColor] = useState("#67E8F9"); + const [modalColorInput, setModalColorInput] = useState("#67E8F9"); useEffect(() => { loadSettings(); @@ -38,7 +48,10 @@ export default function MelodySettings() { setLoading(true); try { const data = await api.get("/settings/melody"); - setSettings(data); + setSettings({ + ...data, + note_assignment_colors: (data.note_assignment_colors || DEFAULT_NOTE_ASSIGNMENT_COLORS).slice(0, 16), + }); } catch (err) { setError(err.message); } finally { @@ -128,6 +141,30 @@ export default function MelodySettings() { saveSettings(updated); }; + const openNoteColorModal = (index) => { + const current = settings.note_assignment_colors?.[index] || DEFAULT_NOTE_ASSIGNMENT_COLORS[index]; + setModalColor(current); + setModalColorInput(current); + setColorModalBell(index); + }; + + const applyNoteColor = () => { + if (colorModalBell == null) return; + const candidate = modalColorInput.startsWith("#") ? modalColorInput : `#${modalColorInput}`; + if (!/^#[0-9A-Fa-f]{6}$/.test(candidate)) return; + const next = [...(settings.note_assignment_colors || DEFAULT_NOTE_ASSIGNMENT_COLORS)]; + next[colorModalBell] = candidate; + saveSettings({ ...settings, note_assignment_colors: next.slice(0, 16) }); + setColorModalBell(null); + }; + + const resetNoteColor = () => { + if (colorModalBell == null) return; + const fallback = DEFAULT_NOTE_ASSIGNMENT_COLORS[colorModalBell]; + setModalColor(fallback); + setModalColorInput(fallback); + }; + if (loading) { return
Loading...
; } @@ -278,7 +315,115 @@ export default function MelodySettings() { + + {/* --- Note Assignment Colors --- */} +
+

Note Assignment Color Coding

+

+ Colors used in Composer, Playback, and View table dots. Click a bell to customize. +

+
+ {Array.from({ length: 16 }, (_, idx) => { + const color = settings.note_assignment_colors?.[idx] || DEFAULT_NOTE_ASSIGNMENT_COLORS[idx]; + return ( + + ); + })} +
+
+ + {colorModalBell != null && ( +
setColorModalBell(null)} + > +
e.stopPropagation()} + > +

Bell {colorModalBell + 1} Color

+

Pick a custom color for this bell number.

+
+ { setModalColor(e.target.value); setModalColorInput(e.target.value); }} + className="w-14 h-10 rounded cursor-pointer border" + style={{ borderColor: "var(--border-primary)" }} + /> + { + const v = e.target.value; + setModalColorInput(v); + if (/^#[0-9A-Fa-f]{6}$/.test(v)) setModalColor(v); + }} + className="flex-1 px-3 py-2 rounded-md text-sm font-mono border" + /> + + {colorModalBell + 1} + +
+
+ + + +
+
+
+ )} ); } diff --git a/frontend/src/melodies/PlaybackModal.jsx b/frontend/src/melodies/PlaybackModal.jsx index 046c7ec..c653ec4 100644 --- a/frontend/src/melodies/PlaybackModal.jsx +++ b/frontend/src/melodies/PlaybackModal.jsx @@ -1,4 +1,6 @@ import { useState, useEffect, useRef, useCallback } from "react"; +import api from "../api/client"; + function bellFrequency(bellNumber) { return 880 * Math.pow(Math.pow(2, 1 / 12), -2 * (bellNumber - 1)); @@ -145,6 +147,11 @@ function bellDotGlow(assignedBell) { return `hsla(${hue}, 78%, 56%, 0.45)`; } +function bellCustomGlow(color) { + if (!color || typeof color !== "string") return null; + return `${color}66`; +} + const mutedStyle = { color: "var(--text-muted)" }; const labelStyle = { color: "var(--text-secondary)" }; const NOTE_LABELS = "ABCDEFGHIJKLMNOP"; @@ -165,6 +172,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet const [toneLengthMs, setToneLengthMs] = useState(80); const [loopEnabled, setLoopEnabled] = useState(true); const [activeBells, setActiveBells] = useState(new Set()); + const [noteColors, setNoteColors] = useState([]); const audioCtxRef = useRef(null); const playbackRef = useRef(null); @@ -181,6 +189,19 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet useEffect(() => { toneLengthRef.current = toneLengthMs; }, [toneLengthMs]); useEffect(() => { noteAssignmentsRef.current = noteAssignments; }, [noteAssignments]); useEffect(() => { loopEnabledRef.current = loopEnabled; }, [loopEnabled]); + useEffect(() => { + if (!open) return; + let canceled = false; + api.get("/settings/melody") + .then((s) => { + if (canceled) return; + setNoteColors((s?.note_assignment_colors || []).slice(0, 16)); + }) + .catch(() => { + if (!canceled) setNoteColors([]); + }); + return () => { canceled = true; }; + }, [open]); const stopPlayback = useCallback(() => { if (playbackRef.current) { @@ -495,6 +516,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet const enabled = Boolean(stepValue & (1 << noteIdx)); const isCurrent = currentStep === stepIdx; const assignedBell = Number(noteAssignments[noteIdx] || 0); + const assignedColor = assignedBell > 0 ? noteColors?.[assignedBell - 1] : null; const dotLabel = assignedBell > 0 ? assignedBell : ""; const isUnassigned = assignedBell <= 0; const dotVisible = enabled || (isUnassigned && Boolean(stepValue & (1 << noteIdx))); @@ -516,12 +538,12 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet width: "68%", height: "68%", borderRadius: "9999px", - backgroundColor: isUnassigned ? "rgba(100,116,139,0.7)" : bellDotColor(assignedBell), + backgroundColor: isUnassigned ? "rgba(100,116,139,0.7)" : (assignedColor || bellDotColor(assignedBell)), color: "#111827", opacity: dotVisible ? 1 : 0, transform: dotVisible ? "scale(1)" : "scale(0.4)", boxShadow: dotVisible - ? (isUnassigned ? "0 0 8px 2px rgba(100,116,139,0.35)" : `0 0 10px 3px ${bellDotGlow(assignedBell)}`) + ? (isUnassigned ? "0 0 8px 2px rgba(100,116,139,0.35)" : `0 0 10px 3px ${bellCustomGlow(assignedColor) || bellDotGlow(assignedBell)}`) : "none", transition: "opacity 140ms ease, transform 140ms ease, box-shadow 180ms ease", }} @@ -557,3 +579,5 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet ); } + +