Fixes to Add Melody Page, minor UI Tweaks

This commit is contained in:
2026-02-17 18:11:04 +02:00
parent dff1ec921d
commit bec0e606e6
21 changed files with 863 additions and 899 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -15,8 +15,8 @@ function ProtectedRoute({ children }) {
if (loading) {
return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
<p className="text-gray-500">Loading...</p>
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--bg-primary)" }}>
<p style={{ color: "var(--text-muted)" }}>Loading...</p>
</div>
);
}
@@ -32,10 +32,10 @@ function DashboardPage() {
const { user } = useAuth();
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-4">Dashboard</h1>
<p className="text-gray-600">
<h1 className="text-2xl font-bold mb-4" style={{ color: "var(--text-heading)" }}>Dashboard</h1>
<p style={{ color: "var(--text-secondary)" }}>
Welcome, {user?.name}. You are logged in as{" "}
<span className="font-medium">{user?.role}</span>.
<span className="font-medium" style={{ color: "var(--accent)" }}>{user?.role}</span>.
</p>
</div>
);

View File

@@ -25,21 +25,41 @@ export default function LoginPage() {
};
return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
<div className="bg-white p-8 rounded-lg shadow-md w-full max-w-sm">
<h1 className="text-2xl font-bold text-gray-900 text-center mb-6">
BellSystems Admin
</h1>
<div
className="min-h-screen flex items-center justify-center"
style={{ backgroundColor: "var(--bg-primary)" }}
>
<div
className="p-8 rounded-lg shadow-xl w-full max-w-sm border"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
}}
>
<div className="flex justify-center mb-6">
<img src="/logo-dark.png" alt="BellSystems" className="h-12 w-auto" />
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-md p-3 mb-4">
<div
className="text-sm rounded-md p-3 mb-4 border"
style={{
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
>
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
<label
htmlFor="email"
className="block text-sm font-medium mb-1"
style={{ color: "var(--text-secondary)" }}
>
Email
</label>
<input
@@ -48,13 +68,17 @@ export default function LoginPage() {
value={email}
onChange={(e) => setEmail(e.target.value)}
required
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"
className="w-full px-3 py-2 rounded-md text-sm border"
placeholder="admin@bellsystems.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
<label
htmlFor="password"
className="block text-sm font-medium mb-1"
style={{ color: "var(--text-secondary)" }}
>
Password
</label>
<input
@@ -63,14 +87,20 @@ export default function LoginPage() {
value={password}
onChange={(e) => setPassword(e.target.value)}
required
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"
className="w-full px-3 py-2 rounded-md text-sm border"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 text-white py-2 rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="w-full py-2 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
style={{
backgroundColor: "var(--btn-primary)",
color: "var(--text-heading)",
}}
onMouseEnter={(e) => !isLoading && (e.target.style.backgroundColor = "var(--btn-primary-hover)")}
onMouseLeave={(e) => (e.target.style.backgroundColor = "var(--btn-primary)")}
>
{isLoading ? "Signing in..." : "Sign in"}
</button>

View File

@@ -3,24 +3,41 @@ export default function ConfirmDialog({ open, title, message, onConfirm, onCance
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onCancel} />
<div className="relative bg-white rounded-lg shadow-xl p-6 w-full max-w-sm mx-4">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
<div className="fixed inset-0 bg-black/60" onClick={onCancel} />
<div
className="relative rounded-lg shadow-xl p-6 w-full max-w-sm mx-4 border"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
}}
>
<h3
className="text-lg font-semibold mb-2"
style={{ color: "var(--text-heading)" }}
>
{title || "Confirm"}
</h3>
<p className="text-sm text-gray-600 mb-6">
<p className="text-sm mb-6" style={{ color: "var(--text-secondary)" }}>
{message || "Are you sure?"}
</p>
<div className="flex justify-end gap-3">
<button
onClick={onCancel}
className="px-4 py-2 text-sm text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{
backgroundColor: "var(--bg-card-hover)",
color: "var(--text-primary)",
}}
>
Cancel
</button>
<button
onClick={onConfirm}
className="px-4 py-2 text-sm text-white bg-red-600 rounded-md hover:bg-red-700 transition-colors"
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{
backgroundColor: "var(--danger)",
color: "#fff",
}}
>
Delete
</button>

View File

@@ -21,13 +21,14 @@ export default function SearchBar({ onSearch, placeholder = "Search..." }) {
value={value}
onChange={(e) => 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"
className="w-full px-3 py-2 rounded-md text-sm border"
/>
{value && (
<button
type="button"
onClick={handleClear}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
className="absolute right-2 top-1/2 -translate-y-1/2"
style={{ color: "var(--text-muted)" }}
>
&times;
</button>
@@ -35,7 +36,11 @@ export default function SearchBar({ onSearch, placeholder = "Search..." }) {
</div>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 transition-colors"
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{
backgroundColor: "var(--btn-primary)",
color: "var(--text-heading)",
}}
>
Search
</button>

View File

@@ -7,10 +7,15 @@ import ConfirmDialog from "../components/ConfirmDialog";
function Field({ label, children }) {
return (
<div>
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">
<dt
className="text-xs font-medium uppercase tracking-wide"
style={{ color: "var(--text-muted)" }}
>
{label}
</dt>
<dd className="mt-1 text-sm text-gray-900">{children || "-"}</dd>
<dd className="mt-1 text-sm" style={{ color: "var(--text-primary)" }}>
{children || "-"}
</dd>
</div>
);
}
@@ -18,9 +23,12 @@ function Field({ label, children }) {
function BoolBadge({ value, yesLabel = "Yes", noLabel = "No" }) {
return (
<span
className={`px-2 py-0.5 text-xs rounded-full ${
value ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
}`}
className="px-2 py-0.5 text-xs rounded-full"
style={
value
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
: { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }
}
>
{value ? yesLabel : noLabel}
</span>
@@ -65,12 +73,23 @@ export default function DeviceDetail() {
};
if (loading) {
return <div className="text-center py-8 text-gray-500">Loading...</div>;
return (
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>
Loading...
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-md p-3">
<div
className="text-sm rounded-md p-3 border"
style={{
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
>
{error}
</div>
);
@@ -90,18 +109,23 @@ export default function DeviceDetail() {
<div>
<button
onClick={() => navigate("/devices")}
className="text-sm text-blue-600 hover:text-blue-800 mb-2 inline-block"
className="text-sm hover:underline mb-2 inline-block"
style={{ color: "var(--accent)" }}
>
&larr; Back to Devices
</button>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900">
<h1
className="text-2xl font-bold"
style={{ color: "var(--text-heading)" }}
>
{device.device_name || "Unnamed Device"}
</h1>
<span
className={`inline-block w-3 h-3 rounded-full ${
device.is_Online ? "bg-green-500" : "bg-gray-300"
device.is_Online ? "bg-green-500" : ""
}`}
style={!device.is_Online ? { backgroundColor: "var(--border-primary)" } : undefined}
title={device.is_Online ? "Online" : "Offline"}
/>
</div>
@@ -110,13 +134,15 @@ export default function DeviceDetail() {
<div className="flex gap-2">
<button
onClick={() => navigate(`/devices/${id}/edit`)}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 transition-colors"
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-heading)" }}
>
Edit
</button>
<button
onClick={() => setShowDelete(true)}
className="px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700 transition-colors"
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--danger)", color: "#fff" }}
>
Delete
</button>
@@ -128,8 +154,14 @@ export default function DeviceDetail() {
{/* Left column */}
<div className="space-y-6">
{/* Basic Info */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Basic Information
</h2>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
@@ -137,7 +169,9 @@ export default function DeviceDetail() {
<span className="font-mono">{device.device_id}</span>
</Field>
<Field label="Document ID">
<span className="font-mono text-xs text-gray-500">{device.id}</span>
<span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>
{device.id}
</span>
</Field>
<Field label="Status">
<BoolBadge value={device.is_Online} yesLabel="Online" noLabel="Offline" />
@@ -157,8 +191,14 @@ export default function DeviceDetail() {
</section>
{/* Device Attributes */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Device Attributes
</h2>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
@@ -184,8 +224,14 @@ export default function DeviceDetail() {
</section>
{/* Network */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Network Settings
</h2>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
@@ -198,13 +244,22 @@ export default function DeviceDetail() {
{/* Right column */}
<div className="space-y-6">
{/* Subscription */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Subscription
</h2>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Tier">
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-50 text-blue-700 capitalize">
<span
className="px-2 py-0.5 text-xs rounded-full capitalize"
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}
>
{sub.subscrTier}
</span>
</Field>
@@ -216,8 +271,14 @@ export default function DeviceDetail() {
</section>
{/* Clock Settings */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Clock Settings
</h2>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
@@ -239,8 +300,16 @@ export default function DeviceDetail() {
</Field>
</dl>
{(clock.isDaySilenceOn || clock.isNightSilenceOn) && (
<div className="mt-4 pt-4 border-t border-gray-100">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Silence Periods</h3>
<div
className="mt-4 pt-4 border-t"
style={{ borderColor: "var(--border-secondary)" }}
>
<h3
className="text-sm font-semibold mb-3"
style={{ color: "var(--text-primary)" }}
>
Silence Periods
</h3>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
{clock.isDaySilenceOn && (
<>
@@ -262,8 +331,14 @@ export default function DeviceDetail() {
</section>
{/* Statistics */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Statistics & Warranty
</h2>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
@@ -282,8 +357,14 @@ export default function DeviceDetail() {
</section>
{/* Melodies & Users summary */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2
className="text-lg font-semibold mb-4"
style={{ color: "var(--text-heading)" }}
>
Melodies & Users
</h2>
<dl className="grid grid-cols-2 gap-4">

View File

@@ -200,36 +200,62 @@ export default function DeviceForm() {
};
if (loading) {
return <div className="text-center py-8 text-gray-500">Loading...</div>;
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
}
const inputClass =
"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";
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-6">
{isEdit ? "Edit Device" : "Add Device"}
</h1>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
{isEdit ? "Edit Device" : "Add Device"}
</h1>
<div className="flex gap-3">
<button
type="button"
onClick={() => navigate(isEdit ? `/devices/${id}` : "/devices")}
className="px-4 py-2 text-sm rounded-md hover:opacity-80 transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
>
Cancel
</button>
<button
type="submit"
form="device-form"
disabled={saving}
className="px-4 py-2 text-sm rounded-md hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-heading)" }}
>
{saving ? "Saving..." : isEdit ? "Update Device" : "Create Device"}
</button>
</div>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-md p-3 mb-4">
<div
className="text-sm rounded-md p-3 mb-4 border"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<form id="device-form" onSubmit={handleSubmit}>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* ===== Left Column ===== */}
<div className="space-y-6">
{/* --- Basic Info --- */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Basic Information
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Device Name *
</label>
<input
@@ -241,7 +267,7 @@ export default function DeviceForm() {
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Location
</label>
<input
@@ -253,7 +279,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Location Coordinates
</label>
<input
@@ -265,7 +291,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Device Photo URL
</label>
<input
@@ -283,15 +309,18 @@ export default function DeviceForm() {
onChange={(e) => setEventsOn(e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<span className="text-sm font-medium text-gray-700">Events On</span>
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>Events On</span>
</label>
</div>
</div>
</section>
{/* --- Device Attributes --- */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Device Attributes
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -303,7 +332,7 @@ export default function DeviceForm() {
onChange={(e) => updateAttr("hasAssistant", e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<span className="text-sm text-gray-700">Has Assistant</span>
<span className="text-sm" style={{ color: "var(--text-primary)" }}>Has Assistant</span>
</label>
<label className="flex items-center gap-2">
<input
@@ -312,7 +341,7 @@ export default function DeviceForm() {
onChange={(e) => updateAttr("hasClock", e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<span className="text-sm text-gray-700">Has Clock</span>
<span className="text-sm" style={{ color: "var(--text-primary)" }}>Has Clock</span>
</label>
<label className="flex items-center gap-2">
<input
@@ -321,7 +350,7 @@ export default function DeviceForm() {
onChange={(e) => updateAttr("hasBells", e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<span className="text-sm text-gray-700">Has Bells</span>
<span className="text-sm" style={{ color: "var(--text-primary)" }}>Has Bells</span>
</label>
<label className="flex items-center gap-2">
<input
@@ -330,7 +359,7 @@ export default function DeviceForm() {
onChange={(e) => updateAttr("bellGuardOn", e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<span className="text-sm text-gray-700">Bell Guard</span>
<span className="text-sm" style={{ color: "var(--text-primary)" }}>Bell Guard</span>
</label>
<label className="flex items-center gap-2">
<input
@@ -339,7 +368,7 @@ export default function DeviceForm() {
onChange={(e) => updateAttr("bellGuardSafetyOn", e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<span className="text-sm text-gray-700">Bell Guard Safety</span>
<span className="text-sm" style={{ color: "var(--text-primary)" }}>Bell Guard Safety</span>
</label>
<label className="flex items-center gap-2">
<input
@@ -348,11 +377,11 @@ export default function DeviceForm() {
onChange={(e) => updateAttr("warningsOn", e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<span className="text-sm text-gray-700">Warnings On</span>
<span className="text-sm" style={{ color: "var(--text-primary)" }}>Warnings On</span>
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Total Bells
</label>
<input
@@ -364,7 +393,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Device Locale
</label>
<select
@@ -380,7 +409,7 @@ export default function DeviceForm() {
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Bell Outputs (comma-separated)
</label>
<input
@@ -392,7 +421,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Hammer Timings (comma-separated)
</label>
<input
@@ -404,7 +433,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Serial Log Level
</label>
<input
@@ -416,7 +445,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
SD Log Level
</label>
<input
@@ -431,13 +460,16 @@ export default function DeviceForm() {
</section>
{/* --- Network Settings --- */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Network Settings
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Hostname
</label>
<input
@@ -455,12 +487,12 @@ export default function DeviceForm() {
onChange={(e) => updateNetwork("useStaticIP", e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<label htmlFor="useStaticIP" className="text-sm font-medium text-gray-700">
<label htmlFor="useStaticIP" className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>
Use Static IP
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
WebSocket URL
</label>
<input
@@ -471,7 +503,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Church Assistant URL
</label>
<input
@@ -488,13 +520,16 @@ export default function DeviceForm() {
{/* ===== Right Column ===== */}
<div className="space-y-6">
{/* --- Subscription --- */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Subscription
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Tier
</label>
<select
@@ -510,7 +545,7 @@ export default function DeviceForm() {
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Start Date
</label>
<input
@@ -521,7 +556,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Duration (months)
</label>
<input
@@ -533,7 +568,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Max Users
</label>
<input
@@ -545,7 +580,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Max Outputs
</label>
<input
@@ -560,13 +595,16 @@ export default function DeviceForm() {
</section>
{/* --- Clock Settings --- */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Clock Settings
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Ring Alerts
</label>
<select
@@ -582,7 +620,7 @@ export default function DeviceForm() {
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Ring Intervals
</label>
<input
@@ -600,10 +638,10 @@ export default function DeviceForm() {
onChange={(e) => updateClock("ringAlertsMasterOn", e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<span className="text-sm text-gray-700">Ring Alerts Master On</span>
<span className="text-sm" style={{ color: "var(--text-primary)" }}>Ring Alerts Master On</span>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Clock Outputs (comma-separated)
</label>
<input
@@ -614,7 +652,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Clock Timings (comma-separated)
</label>
<input
@@ -625,7 +663,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Hour Alerts Bell
</label>
<input
@@ -637,7 +675,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Half-hour Alerts Bell
</label>
<input
@@ -649,7 +687,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Quarter Alerts Bell
</label>
<input
@@ -661,7 +699,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Backlight Output
</label>
<input
@@ -679,12 +717,12 @@ export default function DeviceForm() {
onChange={(e) => updateClock("isBacklightAutomationOn", e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<span className="text-sm text-gray-700">Backlight Automation</span>
<span className="text-sm" style={{ color: "var(--text-primary)" }}>Backlight Automation</span>
</div>
{/* Silence settings */}
<div className="md:col-span-2 border-t border-gray-100 pt-4 mt-2">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Silence Periods</h3>
<div className="md:col-span-2 border-t pt-4 mt-2" style={{ borderColor: "var(--border-secondary)" }}>
<h3 className="text-sm font-semibold mb-3" style={{ color: "var(--text-secondary)" }}>Silence Periods</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center gap-2">
<input
@@ -693,7 +731,7 @@ export default function DeviceForm() {
onChange={(e) => updateClock("isDaySilenceOn", e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<span className="text-sm text-gray-700">Day Silence</span>
<span className="text-sm" style={{ color: "var(--text-primary)" }}>Day Silence</span>
</div>
<div className="flex items-center gap-2">
<input
@@ -702,10 +740,10 @@ export default function DeviceForm() {
onChange={(e) => updateClock("isNightSilenceOn", e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<span className="text-sm text-gray-700">Night Silence</span>
<span className="text-sm" style={{ color: "var(--text-primary)" }}>Night Silence</span>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Day Silence From</label>
<label className="block text-xs mb-1" style={{ color: "var(--text-muted)" }}>Day Silence From</label>
<input
type="time"
value={attributes.clockSettings.daySilenceFrom}
@@ -714,7 +752,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Day Silence To</label>
<label className="block text-xs mb-1" style={{ color: "var(--text-muted)" }}>Day Silence To</label>
<input
type="time"
value={attributes.clockSettings.daySilenceTo}
@@ -723,7 +761,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Night Silence From</label>
<label className="block text-xs mb-1" style={{ color: "var(--text-muted)" }}>Night Silence From</label>
<input
type="time"
value={attributes.clockSettings.nightSilenceFrom}
@@ -732,7 +770,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Night Silence To</label>
<label className="block text-xs mb-1" style={{ color: "var(--text-muted)" }}>Night Silence To</label>
<input
type="time"
value={attributes.clockSettings.nightSilenceTo}
@@ -746,13 +784,16 @@ export default function DeviceForm() {
</section>
{/* --- Statistics --- */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
<section
className="rounded-lg border p-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Statistics & Warranty
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Total Playbacks
</label>
<input
@@ -764,7 +805,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Total Hammer Strikes
</label>
<input
@@ -776,7 +817,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Total Warnings Given
</label>
<input
@@ -788,7 +829,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Per-Bell Strikes (comma-separated)
</label>
<input
@@ -805,10 +846,10 @@ export default function DeviceForm() {
onChange={(e) => updateStats("warrantyActive", e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<span className="text-sm font-medium text-gray-700">Warranty Active</span>
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>Warranty Active</span>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Warranty Start
</label>
<input
@@ -819,7 +860,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Warranty Period (months)
</label>
<input
@@ -831,7 +872,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Last Maintained On
</label>
<input
@@ -842,7 +883,7 @@ export default function DeviceForm() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Maintenance Period (months)
</label>
<input
@@ -858,23 +899,6 @@ export default function DeviceForm() {
</div>
</div>
{/* --- Actions --- */}
<div className="flex gap-3 mt-6">
<button
type="submit"
disabled={saving}
className="px-6 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{saving ? "Saving..." : isEdit ? "Update Device" : "Create Device"}
</button>
<button
type="button"
onClick={() => navigate(isEdit ? `/devices/${id}` : "/devices")}
className="px-6 py-2 bg-gray-100 text-gray-700 text-sm rounded-md hover:bg-gray-200 transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
);

View File

@@ -16,6 +16,7 @@ export default function DeviceList() {
const [onlineFilter, setOnlineFilter] = useState("");
const [tierFilter, setTierFilter] = useState("");
const [deleteTarget, setDeleteTarget] = useState(null);
const [hoveredRow, setHoveredRow] = useState(null);
const navigate = useNavigate();
const { hasRole } = useAuth();
const canEdit = hasRole("superadmin", "device_manager");
@@ -59,11 +60,12 @@ export default function DeviceList() {
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Devices</h1>
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Devices</h1>
{canEdit && (
<button
onClick={() => navigate("/devices/new")}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 transition-colors cursor-pointer"
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-heading)" }}
>
Add Device
</button>
@@ -79,7 +81,12 @@ export default function DeviceList() {
<select
value={onlineFilter}
onChange={(e) => setOnlineFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
className="px-3 py-2 rounded-md text-sm cursor-pointer border"
style={{
backgroundColor: "var(--bg-card)",
color: "var(--text-primary)",
borderColor: "var(--border-primary)",
}}
>
<option value="">All Status</option>
<option value="true">Online</option>
@@ -88,7 +95,12 @@ export default function DeviceList() {
<select
value={tierFilter}
onChange={(e) => setTierFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
className="px-3 py-2 rounded-md text-sm cursor-pointer border"
style={{
backgroundColor: "var(--bg-card)",
color: "var(--text-primary)",
borderColor: "var(--border-primary)",
}}
>
<option value="">All Tiers</option>
{TIER_OPTIONS.filter(Boolean).map((t) => (
@@ -97,75 +109,105 @@ export default function DeviceList() {
</option>
))}
</select>
<span className="flex items-center text-sm text-gray-500">
<span className="flex items-center text-sm" style={{ color: "var(--text-muted)" }}>
{total} {total === 1 ? "device" : "devices"}
</span>
</div>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-md p-3 mb-4">
<div
className="text-sm rounded-md p-3 mb-4 border"
style={{
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
>
{error}
</div>
)}
{loading ? (
<div className="text-center py-8 text-gray-500">Loading...</div>
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : devices.length === 0 ? (
<div className="bg-white rounded-lg border border-gray-200 p-8 text-center text-gray-500 text-sm">
<div
className="rounded-lg p-8 text-center text-sm border"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
color: "var(--text-muted)",
}}
>
No devices found.
</div>
) : (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div
className="rounded-lg overflow-hidden border"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
}}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="px-4 py-3 text-left font-medium text-gray-600 w-10">Status</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Name</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Serial Number</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Location</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Tier</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Bells</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Users</th>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-4 py-3 text-left font-medium w-10" style={{ color: "var(--text-secondary)" }}>Status</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Name</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Serial Number</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Location</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Tier</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Bells</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Users</th>
{canEdit && (
<th className="px-4 py-3 text-left font-medium text-gray-600 w-24" />
<th className="px-4 py-3 text-left font-medium w-24" style={{ color: "var(--text-secondary)" }} />
)}
</tr>
</thead>
<tbody>
{devices.map((device) => (
{devices.map((device, index) => (
<tr
key={device.id}
onClick={() => navigate(`/devices/${device.id}`)}
className="border-b border-gray-100 last:border-0 cursor-pointer hover:bg-gray-50"
className="cursor-pointer"
style={{
borderBottom: index < devices.length - 1 ? "1px solid var(--border-primary)" : "none",
backgroundColor: hoveredRow === device.id ? "var(--bg-card-hover)" : "transparent",
}}
onMouseEnter={() => setHoveredRow(device.id)}
onMouseLeave={() => setHoveredRow(null)}
>
<td className="px-4 py-3">
<span
className={`inline-block w-2.5 h-2.5 rounded-full ${
device.is_Online ? "bg-green-500" : "bg-gray-300"
device.is_Online ? "bg-green-500" : ""
}`}
style={!device.is_Online ? { backgroundColor: "var(--border-primary)" } : undefined}
title={device.is_Online ? "Online" : "Offline"}
/>
</td>
<td className="px-4 py-3 font-medium text-gray-900">
<td className="px-4 py-3 font-medium" style={{ color: "var(--text-heading)" }}>
{device.device_name || "Unnamed Device"}
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-500">
<td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--text-muted)" }}>
{device.device_id || "-"}
</td>
<td className="px-4 py-3 text-gray-700">
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>
{device.device_location || "-"}
</td>
<td className="px-4 py-3">
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-50 text-blue-700 capitalize">
<span
className="px-2 py-0.5 text-xs rounded-full capitalize"
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}
>
{device.device_subscription?.subscrTier || "basic"}
</span>
</td>
<td className="px-4 py-3 text-gray-700">
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>
{device.device_attributes?.totalBells ?? 0}
</td>
<td className="px-4 py-3 text-gray-700">
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>
{device.user_list?.length ?? 0}
</td>
{canEdit && (
@@ -176,13 +218,15 @@ export default function DeviceList() {
>
<button
onClick={() => navigate(`/devices/${device.id}/edit`)}
className="text-blue-600 hover:text-blue-800 text-xs cursor-pointer"
className="hover:opacity-80 text-xs cursor-pointer"
style={{ color: "var(--accent)" }}
>
Edit
</button>
<button
onClick={() => setDeleteTarget(device)}
className="text-red-600 hover:text-red-800 text-xs cursor-pointer"
className="hover:opacity-80 text-xs cursor-pointer"
style={{ color: "var(--danger)" }}
>
Delete
</button>

View File

@@ -1,5 +1,43 @@
@import "tailwindcss";
/* BellSystems Dark Theme - Custom Properties */
:root {
--bg-primary: #2f3e46;
--bg-secondary: #354f52;
--bg-card: #354f52;
--bg-card-hover: #3d5a5e;
--bg-input: #2f3e46;
--bg-sidebar: #2f3e46;
--bg-header: #354f52;
--border-primary: #52796f;
--border-secondary: #4a6b63;
--border-input: #52796f;
--text-primary: #cad2c5;
--text-secondary: #a3b8a0;
--text-muted: #84a98c;
--text-heading: #e0e7dc;
--accent: #84a98c;
--accent-hover: #9abea2;
--btn-primary: #52796f;
--btn-primary-hover: #5d8a7f;
--danger: #e57373;
--danger-hover: #ef5350;
--danger-bg: #4a2c2c;
--danger-text: #ffcdd2;
--success: #81c784;
--success-bg: #2c4a2e;
--success-text: #c8e6c9;
--badge-blue-bg: #2e4a52;
--badge-blue-text: #80cbc4;
}
/* Ensure all interactive elements show pointer cursor */
button,
[role="button"],
@@ -13,3 +51,68 @@ input[type="color"],
summary {
cursor: pointer;
}
/* Global dark background */
body {
background-color: var(--bg-primary);
color: var(--text-primary);
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--border-primary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent);
}
/* Form input dark styling */
input[type="text"],
input[type="email"],
input[type="password"],
input[type="number"],
input[type="date"],
input[type="time"],
input[type="url"],
textarea,
select {
background-color: var(--bg-input) !important;
border-color: var(--border-input) !important;
color: var(--text-primary) !important;
}
input::placeholder,
textarea::placeholder {
color: var(--text-muted) !important;
opacity: 0.6;
}
input:focus,
textarea:focus,
select:focus {
border-color: var(--accent) !important;
box-shadow: 0 0 0 2px rgba(132, 169, 140, 0.3) !important;
}
/* Checkbox dark styling */
input[type="checkbox"] {
accent-color: var(--accent);
}
/* Range slider */
input[type="range"] {
accent-color: var(--accent) !important;
}
/* File input */
input[type="file"]::file-selector-button {
background-color: var(--bg-card) !important;
color: var(--accent) !important;
border: 1px solid var(--border-primary) !important;
}

View File

@@ -4,21 +4,36 @@ export default function Header() {
const { user, logout } = useAuth();
return (
<header className="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-800">
BellSystems Admin Panel
<header
className="px-6 py-3 flex items-center justify-between border-b"
style={{
backgroundColor: "var(--bg-header)",
borderColor: "var(--border-primary)",
}}
>
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
Admin Panel
</h2>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
{user?.name}
<span className="ml-2 px-2 py-0.5 bg-gray-100 text-gray-500 text-xs rounded-full">
<span
className="ml-2 px-2 py-0.5 text-xs rounded-full"
style={{
backgroundColor: "var(--badge-blue-bg)",
color: "var(--badge-blue-text)",
}}
>
{user?.role}
</span>
</span>
<button
onClick={logout}
className="text-sm text-red-600 hover:text-red-800 transition-colors"
className="text-sm transition-colors"
style={{ color: "var(--danger)" }}
onMouseEnter={(e) => (e.target.style.color = "var(--danger-hover)")}
onMouseLeave={(e) => (e.target.style.color = "var(--danger)")}
>
Sign out
</button>

View File

@@ -4,7 +4,7 @@ import Sidebar from "./Sidebar";
export default function MainLayout() {
return (
<div className="flex min-h-screen bg-gray-100">
<div className="flex min-h-screen" style={{ backgroundColor: "var(--bg-primary)" }}>
<Sidebar />
<div className="flex-1 flex flex-col">
<Header />

View File

@@ -20,8 +20,8 @@ const navItems = [
const linkClass = (isActive) =>
`block px-3 py-2 rounded-md text-sm transition-colors ${
isActive
? "bg-gray-700 text-white"
: "text-gray-300 hover:bg-gray-800 hover:text-white"
? "bg-[var(--accent)] text-[var(--bg-primary)] font-medium"
: "text-[var(--text-secondary)] hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-heading)]"
}`;
export default function Sidebar() {
@@ -33,8 +33,14 @@ export default function Sidebar() {
);
return (
<aside className="w-56 bg-gray-900 text-white min-h-screen p-4">
<div className="text-xl font-bold mb-8 px-2">BellSystems</div>
<aside className="w-56 min-h-screen p-4 border-r" style={{ backgroundColor: "var(--bg-sidebar)", borderColor: "var(--border-primary)" }}>
<div className="mb-8 px-2">
<img
src="/logo-dark.png"
alt="BellSystems"
className="h-10 w-auto"
/>
</div>
<nav className="space-y-1">
{visibleItems.map((item) =>
item.children ? (
@@ -68,7 +74,6 @@ function CollapsibleGroup({ label, children, currentPath }) {
);
const [open, setOpen] = useState(isChildActive);
// Auto-expand when a child becomes active
const shouldBeOpen = open || isChildActive;
return (
@@ -78,8 +83,8 @@ function CollapsibleGroup({ label, children, currentPath }) {
onClick={() => setOpen(!shouldBeOpen)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors ${
isChildActive
? "text-white"
: "text-gray-300 hover:bg-gray-800 hover:text-white"
? "text-[var(--text-heading)]"
: "text-[var(--text-secondary)] hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-heading)]"
}`}
>
<span>{label}</span>
@@ -102,8 +107,8 @@ function CollapsibleGroup({ label, children, currentPath }) {
className={({ isActive }) =>
`block pl-4 pr-3 py-1.5 rounded-md text-sm transition-colors ${
isActive
? "bg-gray-700 text-white"
: "text-gray-400 hover:bg-gray-800 hover:text-white"
? "bg-[var(--accent)] text-[var(--bg-primary)] font-medium"
: "text-[var(--text-muted)] hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-heading)]"
}`
}
>

View File

@@ -13,10 +13,10 @@ import {
function Field({ label, children }) {
return (
<div>
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">
<dt className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
{label}
</dt>
<dd className="mt-1 text-sm text-gray-900">{children || "-"}</dd>
<dd className="mt-1 text-sm" style={{ color: "var(--text-primary)" }}>{children || "-"}</dd>
</div>
);
}
@@ -73,12 +73,19 @@ export default function MelodyDetail() {
};
if (loading) {
return <div className="text-center py-8 text-gray-500">Loading...</div>;
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-md p-3">
<div
className="text-sm rounded-md p-3 border"
style={{
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
>
{error}
</div>
);
@@ -91,23 +98,29 @@ export default function MelodyDetail() {
const languages = melodySettings?.available_languages || ["en"];
const displayName = getLocalizedValue(info.name, displayLang, "Untitled Melody");
const badgeStyle = (active) => ({
backgroundColor: active ? "var(--success-bg)" : "var(--bg-card-hover)",
color: active ? "var(--success-text)" : "var(--text-muted)",
});
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<button
onClick={() => navigate("/melodies")}
className="text-sm text-blue-600 hover:text-blue-800 mb-2 inline-block"
className="text-sm mb-2 inline-block"
style={{ color: "var(--accent)" }}
>
&larr; Back to Melodies
</button>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900">{displayName}</h1>
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>{displayName}</h1>
{languages.length > 1 && (
<select
value={displayLang}
onChange={(e) => setDisplayLang(e.target.value)}
className="text-xs px-2 py-1 border border-gray-200 rounded text-gray-500"
className="text-xs px-2 py-1 rounded border"
>
{languages.map((l) => (
<option key={l} value={l}>
@@ -122,13 +135,15 @@ export default function MelodyDetail() {
<div className="flex gap-2">
<button
onClick={() => navigate(`/melodies/${id}/edit`)}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 transition-colors"
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-heading)" }}
>
Edit
</button>
<button
onClick={() => setShowDelete(true)}
className="px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700 transition-colors"
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--danger)", color: "#fff" }}
>
Delete
</button>
@@ -140,8 +155,11 @@ export default function MelodyDetail() {
{/* Left column */}
<div className="space-y-6">
{/* Melody Information */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
<section
className="rounded-lg p-6 border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Melody Information
</h2>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
@@ -159,8 +177,8 @@ export default function MelodyDetail() {
{info.color ? (
<span className="inline-flex items-center gap-2">
<span
className="w-4 h-4 rounded border border-gray-300 inline-block"
style={{ backgroundColor: normalizeColor(info.color) }}
className="w-4 h-4 rounded inline-block border"
style={{ backgroundColor: normalizeColor(info.color), borderColor: "var(--border-primary)" }}
/>
{info.color}
</span>
@@ -169,13 +187,7 @@ export default function MelodyDetail() {
)}
</Field>
<Field label="True Ring">
<span
className={`px-2 py-0.5 text-xs rounded-full ${
info.isTrueRing
? "bg-green-100 text-green-700"
: "bg-gray-100 text-gray-500"
}`}
>
<span className="px-2 py-0.5 text-xs rounded-full" style={badgeStyle(info.isTrueRing)}>
{info.isTrueRing ? "Yes" : "No"}
</span>
</Field>
@@ -191,7 +203,8 @@ export default function MelodyDetail() {
{info.customTags.map((tag) => (
<span
key={tag}
className="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs rounded-full"
className="px-2 py-0.5 text-xs rounded-full"
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}
>
{tag}
</span>
@@ -206,14 +219,17 @@ export default function MelodyDetail() {
</section>
{/* Identifiers */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
<section
className="rounded-lg p-6 border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Identifiers
</h2>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Document ID">{melody.id}</Field>
<Field label="UID">{melody.uid}</Field>
<Field label="PID (Playback ID)">{melody.pid}</Field>
<Field label="UID">{melody.uid}</Field>
<div className="col-span-2 md:col-span-3">
<Field label="URL">{melody.url}</Field>
</div>
@@ -224,8 +240,11 @@ export default function MelodyDetail() {
{/* Right column */}
<div className="space-y-6">
{/* Default Settings */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
<section
className="rounded-lg p-6 border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Default Settings
</h2>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
@@ -234,13 +253,7 @@ export default function MelodyDetail() {
<Field label="Total Run Duration">{settings.totalRunDuration}</Field>
<Field label="Pause Duration">{settings.pauseDuration}</Field>
<Field label="Infinite Loop">
<span
className={`px-2 py-0.5 text-xs rounded-full ${
settings.infiniteLoop
? "bg-green-100 text-green-700"
: "bg-gray-100 text-gray-500"
}`}
>
<span className="px-2 py-0.5 text-xs rounded-full" style={badgeStyle(settings.infiniteLoop)}>
{settings.infiniteLoop ? "Yes" : "No"}
</span>
</Field>
@@ -262,8 +275,11 @@ export default function MelodyDetail() {
</section>
{/* Files */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">Files</h2>
<section
className="rounded-lg p-6 border"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Files</h2>
<dl className="space-y-4">
<Field label="Binary File">
{files.binary_url ? (
@@ -271,12 +287,13 @@ export default function MelodyDetail() {
href={files.binary_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline"
className="underline"
style={{ color: "var(--accent)" }}
>
Download binary
</a>
) : (
<span className="text-gray-400">Not uploaded</span>
<span style={{ color: "var(--text-muted)" }}>Not uploaded</span>
)}
</Field>
<Field label="Audio Preview">
@@ -287,13 +304,14 @@ export default function MelodyDetail() {
href={files.preview_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline text-xs"
className="underline text-xs"
style={{ color: "var(--accent)" }}
>
Download preview
</a>
</div>
) : (
<span className="text-gray-400">Not uploaded</span>
<span style={{ color: "var(--text-muted)" }}>Not uploaded</span>
)}
</Field>
</dl>

View File

@@ -26,7 +26,6 @@ const defaultInfo = {
color: "",
isTrueRing: false,
previewURL: "",
notes: [],
};
const defaultSettings = {
@@ -39,6 +38,15 @@ const defaultSettings = {
noteAssignments: [],
};
// Dark-themed styles
const sectionStyle = {
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
};
const headingStyle = { color: "var(--text-heading)" };
const labelStyle = { color: "var(--text-secondary)" };
const mutedStyle = { color: "var(--text-muted)" };
export default function MelodyForm() {
const { id } = useParams();
const isEdit = Boolean(id);
@@ -60,14 +68,10 @@ export default function MelodyForm() {
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
// Tag input state
const [tagInput, setTagInput] = useState("");
// Melody settings (languages, colors, durations from backend)
const [melodySettings, setMelodySettings] = useState(null);
const [editLang, setEditLang] = useState("en");
// Translation modal state
const [translationModal, setTranslationModal] = useState({
open: false,
field: "",
@@ -83,12 +87,9 @@ export default function MelodyForm() {
}, []);
useEffect(() => {
if (isEdit) {
loadMelody();
}
if (isEdit) loadMelody();
}, [id]);
// Sync noteAssignments length when totalNotes changes
useEffect(() => {
const count = information.totalNotes || 1;
setSettings((prev) => {
@@ -123,39 +124,26 @@ export default function MelodyForm() {
}
};
const updateInfo = (field, value) => {
const updateInfo = (field, value) =>
setInformation((prev) => ({ ...prev, [field]: value }));
};
const updateSettings = (field, value) => {
const updateSettings = (field, value) =>
setSettings((prev) => ({ ...prev, [field]: value }));
};
const addTag = () => {
const tag = tagInput.trim();
if (tag && !information.customTags.includes(tag)) {
if (tag && !information.customTags.includes(tag))
updateInfo("customTags", [...information.customTags, tag]);
}
setTagInput("");
};
const removeTag = (tag) => {
updateInfo(
"customTags",
information.customTags.filter((t) => t !== tag)
);
};
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));
return str.split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
};
// Update a localized field for the current edit language
const updateLocalizedField = (fieldKey, text) => {
const dict = parseLocalizedString(information[fieldKey]);
dict[editLang] = text;
@@ -166,19 +154,15 @@ export default function MelodyForm() {
e.preventDefault();
setSaving(true);
setError("");
try {
const { notes, ...infoWithoutNotes } = information;
const body = {
information,
information: infoWithoutNotes,
default_settings: settings,
type,
url,
uid,
pid,
type, url, uid, pid,
};
let melodyId = id;
if (isEdit) {
await api.put(`/melodies/${id}`, body);
} else {
@@ -186,15 +170,10 @@ export default function MelodyForm() {
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);
}
if (binaryFile) await api.upload(`/melodies/${melodyId}/upload/binary`, binaryFile);
if (previewFile) await api.upload(`/melodies/${melodyId}/upload/preview`, previewFile);
setUploading(false);
}
@@ -208,328 +187,174 @@ export default function MelodyForm() {
};
if (loading) {
return <div className="text-center py-8 text-gray-500">Loading...</div>;
return <div className="text-center py-8" style={mutedStyle}>Loading...</div>;
}
const languages = melodySettings?.available_languages || ["en"];
const durationValues = melodySettings?.duration_values || [0];
const quickColors = melodySettings?.quick_colors || [];
// Duration slider helpers
const durationIndex = durationValues.indexOf(settings.duration);
const currentDurationIdx = durationIndex >= 0 ? durationIndex : 0;
const inputClass =
"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";
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-6">
{isEdit ? "Edit Melody" : "Add Melody"}
</h1>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={headingStyle}>
{isEdit ? "Edit Melody" : "Add Melody"}
</h1>
<div className="flex gap-3">
<button
type="button"
onClick={() => navigate(isEdit ? `/melodies/${id}` : "/melodies")}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
>
Cancel
</button>
<button
type="submit"
form="melody-form"
disabled={saving || uploading}
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-heading)" }}
>
{uploading ? "Uploading files..." : saving ? "Saving..." : isEdit ? "Update Melody" : "Create Melody"}
</button>
</div>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-md p-3 mb-4">
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<form id="melody-form" onSubmit={handleSubmit}>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* ===== Left Column ===== */}
<div className="space-y-6">
{/* --- Melody Info Section --- */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
Melody Information
</h2>
<section className="rounded-lg p-6 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Melody Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Name (localized) */}
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium text-gray-700">
Name *
</label>
<label className="block text-sm font-medium" style={labelStyle}>Name *</label>
<button
type="button"
onClick={() =>
setTranslationModal({
open: true,
field: "Name",
fieldKey: "name",
multiline: false,
})
}
className="text-gray-400 hover:text-blue-600 transition-colors"
title="Edit translations"
onClick={() => setTranslationModal({ open: true, field: "Name", fieldKey: "name", multiline: false })}
className="transition-colors" style={mutedStyle} title="Edit translations"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
</button>
{languages.length > 1 && (
<select
value={editLang}
onChange={(e) => setEditLang(e.target.value)}
className="ml-auto text-xs px-2 py-1 border border-gray-200 rounded text-gray-500"
>
{languages.map((l) => (
<option key={l} value={l}>
{getLanguageName(l)}
</option>
))}
<select value={editLang} onChange={(e) => setEditLang(e.target.value)} className="ml-auto text-xs px-2 py-1 rounded border">
{languages.map((l) => (<option key={l} value={l}>{getLanguageName(l)}</option>))}
</select>
)}
</div>
<input
type="text"
required
value={getLocalizedValue(information.name, editLang, "")}
onChange={(e) => updateLocalizedField("name", e.target.value)}
className={inputClass}
/>
<input type="text" required value={getLocalizedValue(information.name, editLang, "")} onChange={(e) => updateLocalizedField("name", e.target.value)} className={inputClass} />
</div>
{/* Description (localized) */}
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium text-gray-700">
Description
</label>
<label className="block text-sm font-medium" style={labelStyle}>Description</label>
<button
type="button"
onClick={() =>
setTranslationModal({
open: true,
field: "Description",
fieldKey: "description",
multiline: true,
})
}
className="text-gray-400 hover:text-blue-600 transition-colors"
title="Edit translations"
onClick={() => setTranslationModal({ open: true, field: "Description", fieldKey: "description", multiline: true })}
className="transition-colors" style={mutedStyle} title="Edit translations"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
</button>
</div>
<textarea
value={getLocalizedValue(information.description, editLang, "")}
onChange={(e) => updateLocalizedField("description", e.target.value)}
rows={3}
className={inputClass}
/>
<textarea value={getLocalizedValue(information.description, editLang, "")} onChange={(e) => updateLocalizedField("description", e.target.value)} rows={3} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Melody Tone
</label>
<select
value={information.melodyTone}
onChange={(e) => updateInfo("melodyTone", e.target.value)}
className={inputClass}
>
{MELODY_TONES.map((t) => (
<option key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</option>
))}
<label className="block text-sm font-medium mb-1" style={labelStyle}>Melody Tone</label>
<select value={information.melodyTone} onChange={(e) => updateInfo("melodyTone", e.target.value)} className={inputClass}>
{MELODY_TONES.map((t) => (<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Type
</label>
<select
value={type}
onChange={(e) => setType(e.target.value)}
className={inputClass}
>
{MELODY_TYPES.map((t) => (
<option key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</option>
))}
<label className="block text-sm font-medium mb-1" style={labelStyle}>Type</label>
<select value={type} onChange={(e) => setType(e.target.value)} className={inputClass}>
{MELODY_TYPES.map((t) => (<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Total Active Notes (bells)
</label>
<input
type="number"
min="1"
max="16"
value={information.totalNotes}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
updateInfo("totalNotes", Math.max(1, Math.min(16, val || 1)));
}}
className={inputClass}
/>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Total Active Notes (bells)</label>
<input type="number" min="1" max="16" value={information.totalNotes} onChange={(e) => { const val = parseInt(e.target.value, 10); updateInfo("totalNotes", Math.max(1, Math.min(16, val || 1))); }} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Steps
</label>
<input
type="number"
min="0"
value={information.steps}
onChange={(e) =>
updateInfo("steps", parseInt(e.target.value, 10) || 0)
}
className={inputClass}
/>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Steps</label>
<input type="number" min="0" value={information.steps} onChange={(e) => updateInfo("steps", parseInt(e.target.value, 10) || 0)} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Min Speed
</label>
<input
type="number"
min="0"
value={information.minSpeed}
onChange={(e) =>
updateInfo("minSpeed", parseInt(e.target.value, 10) || 0)
}
className={inputClass}
/>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Min Speed</label>
<input type="number" min="0" value={information.minSpeed} onChange={(e) => updateInfo("minSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Max Speed
</label>
<input
type="number"
min="0"
value={information.maxSpeed}
onChange={(e) =>
updateInfo("maxSpeed", parseInt(e.target.value, 10) || 0)
}
className={inputClass}
/>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Max Speed</label>
<input type="number" min="0" value={information.maxSpeed} onChange={(e) => updateInfo("maxSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} />
</div>
{/* Color with picker, preview, and quick colors */}
{/* Color */}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Color
</label>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Color</label>
<div className="flex items-center gap-2 mb-2">
<span
className="w-8 h-8 rounded border border-gray-300 flex-shrink-0"
style={{
backgroundColor: information.color
? normalizeColor(information.color)
: "transparent",
}}
/>
<input
type="text"
value={information.color}
onChange={(e) => updateInfo("color", e.target.value)}
placeholder="e.g. #FF5733 or 0xFF5733"
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
/>
<span className="w-8 h-8 rounded flex-shrink-0 border" style={{ backgroundColor: information.color ? normalizeColor(information.color) : "transparent", borderColor: "var(--border-primary)" }} />
<input type="text" value={information.color} onChange={(e) => updateInfo("color", e.target.value)} placeholder="e.g. #FF5733 or 0xFF5733" className="flex-1 px-3 py-2 rounded-md text-sm border" />
</div>
<div className="flex flex-wrap gap-2 items-center">
{quickColors.map((color) => (
<button
key={color}
type="button"
onClick={() => updateInfo("color", color)}
className={`w-7 h-7 rounded-md border-2 transition-all cursor-pointer ${
information.color === color
? "border-blue-500 ring-2 ring-blue-200"
: "border-gray-200 hover:border-gray-400"
}`}
style={{ backgroundColor: normalizeColor(color) }}
<button key={color} type="button" onClick={() => updateInfo("color", color)}
className="w-7 h-7 rounded-md border-2 transition-all cursor-pointer"
style={{ backgroundColor: normalizeColor(color), borderColor: information.color === color ? "var(--accent)" : "var(--border-secondary)" }}
title={color}
/>
))}
<label
className="relative inline-flex items-center gap-1.5 px-3 py-1.5 border-2 border-dashed border-gray-300 rounded-md text-xs text-gray-500 hover:border-gray-400 hover:text-gray-700 transition-colors cursor-pointer"
title="Pick a custom color"
>
<label className="relative inline-flex items-center gap-1.5 px-3 py-1.5 border-2 border-dashed rounded-md text-xs transition-colors cursor-pointer" style={{ borderColor: "var(--border-primary)", color: "var(--text-muted)" }} title="Pick a custom color">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
Custom
<input
type="color"
value={information.color ? normalizeColor(information.color) : "#000000"}
onChange={(e) => updateInfo("color", e.target.value)}
className="absolute inset-0 opacity-0 w-full h-full cursor-pointer"
/>
<input type="color" value={information.color ? normalizeColor(information.color) : "#000000"} onChange={(e) => updateInfo("color", e.target.value)} className="absolute inset-0 opacity-0 w-full h-full cursor-pointer" />
</label>
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<input
type="checkbox"
id="isTrueRing"
checked={information.isTrueRing}
onChange={(e) => updateInfo("isTrueRing", e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<label
htmlFor="isTrueRing"
className="text-sm font-medium text-gray-700"
>
True Ring
</label>
<input type="checkbox" id="isTrueRing" checked={information.isTrueRing} onChange={(e) => updateInfo("isTrueRing", e.target.checked)} className="h-4 w-4 rounded" />
<label htmlFor="isTrueRing" className="text-sm font-medium" style={labelStyle}>True Ring</label>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Custom Tags
</label>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Custom Tags</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addTag();
}
}}
placeholder="Add a tag and press Enter"
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
/>
<button
type="button"
onClick={addTag}
className="px-3 py-2 bg-gray-100 text-gray-700 text-sm rounded-md hover:bg-gray-200 transition-colors"
>
Add
</button>
<input type="text" value={tagInput} onChange={(e) => setTagInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addTag(); } }} placeholder="Add a tag and press Enter" className="flex-1 px-3 py-2 rounded-md text-sm border" />
<button type="button" onClick={addTag} className="px-3 py-2 text-sm rounded-md transition-colors" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}>Add</button>
</div>
{information.customTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{information.customTags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded-full"
>
<span key={tag} className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full" style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="text-blue-400 hover:text-blue-600"
>
&times;
</button>
<button type="button" onClick={() => removeTag(tag)} style={{ color: "var(--badge-blue-text)" }}>&times;</button>
</span>
))}
</div>
@@ -539,45 +364,23 @@ export default function MelodyForm() {
</section>
{/* --- Identifiers Section --- */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
Identifiers
</h2>
<section className="rounded-lg p-6 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Identifiers</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
UID (leave empty for now)
</label>
<input
type="text"
value={uid}
onChange={(e) => setUid(e.target.value)}
className={inputClass}
/>
<label className="block text-sm font-medium mb-1" style={labelStyle}>PID (Playback ID)</label>
<input type="text" value={pid} onChange={(e) => setPid(e.target.value)} placeholder="eg. builtin_festive_vesper" className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
PID (Playback ID)
</label>
<input
type="text"
value={pid}
onChange={(e) => setPid(e.target.value)}
placeholder="eg. builtin_festive_vesper"
className={inputClass}
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
URL
</label>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
className={inputClass}
/>
<label className="block text-sm font-medium mb-1" style={labelStyle}>UID (leave empty for now)</label>
<input type="text" value={uid} onChange={(e) => setUid(e.target.value)} className={inputClass} />
</div>
{isEdit && url && (
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1" style={labelStyle}>URL (auto-set from binary upload)</label>
<input type="text" value={url} readOnly className={inputClass} style={{ opacity: 0.7 }} />
</div>
)}
</div>
</section>
</div>
@@ -585,254 +388,92 @@ export default function MelodyForm() {
{/* ===== Right Column ===== */}
<div className="space-y-6">
{/* --- Default Settings Section --- */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
Default Settings
</h2>
<section className="rounded-lg p-6 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Default Settings</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Speed slider */}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Speed
</label>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Speed</label>
<div className="flex items-center gap-3">
<input
type="range"
min="1"
max="100"
value={settings.speed}
onChange={(e) =>
updateSettings("speed", parseInt(e.target.value, 10))
}
className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
<span className="text-sm font-medium text-gray-700 w-12 text-right">
{settings.speed}%
</span>
<input type="range" min="1" max="100" value={settings.speed} onChange={(e) => updateSettings("speed", parseInt(e.target.value, 10))} className="flex-1 h-2 rounded-lg appearance-none cursor-pointer" />
<span className="text-sm font-medium w-12 text-right" style={labelStyle}>{settings.speed}%</span>
</div>
</div>
{/* Duration slider */}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Duration
</label>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Duration</label>
<div className="flex items-center gap-3">
<input
type="range"
min="0"
max={Math.max(0, durationValues.length - 1)}
value={currentDurationIdx}
onChange={(e) =>
updateSettings(
"duration",
durationValues[parseInt(e.target.value, 10)] ?? 0
)
}
className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
<span className="text-sm font-medium text-gray-700 w-24 text-right">
{formatDuration(settings.duration)}
</span>
</div>
<div className="text-xs text-gray-400 mt-1">
{settings.duration}s
<input type="range" min="0" max={Math.max(0, durationValues.length - 1)} value={currentDurationIdx} onChange={(e) => updateSettings("duration", durationValues[parseInt(e.target.value, 10)] ?? 0)} className="flex-1 h-2 rounded-lg appearance-none cursor-pointer" />
<span className="text-sm font-medium w-24 text-right" style={labelStyle}>{formatDuration(settings.duration)}</span>
</div>
<div className="text-xs mt-1" style={mutedStyle}>{settings.duration}s</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Total Run Duration
</label>
<input
type="number"
min="0"
value={settings.totalRunDuration}
onChange={(e) =>
updateSettings(
"totalRunDuration",
parseInt(e.target.value, 10) || 0
)
}
className={inputClass}
/>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Total Run Duration</label>
<input type="number" min="0" value={settings.totalRunDuration} onChange={(e) => updateSettings("totalRunDuration", parseInt(e.target.value, 10) || 0)} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Pause Duration
</label>
<input
type="number"
min="0"
value={settings.pauseDuration}
onChange={(e) =>
updateSettings(
"pauseDuration",
parseInt(e.target.value, 10) || 0
)
}
className={inputClass}
/>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Pause Duration</label>
<input type="number" min="0" value={settings.pauseDuration} onChange={(e) => updateSettings("pauseDuration", parseInt(e.target.value, 10) || 0)} className={inputClass} />
</div>
<div className="flex items-center gap-2 pt-6">
<input
type="checkbox"
id="infiniteLoop"
checked={settings.infiniteLoop}
onChange={(e) =>
updateSettings("infiniteLoop", e.target.checked)
}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<label
htmlFor="infiniteLoop"
className="text-sm font-medium text-gray-700"
>
Infinite Loop
</label>
<input type="checkbox" id="infiniteLoop" checked={settings.infiniteLoop} onChange={(e) => updateSettings("infiniteLoop", e.target.checked)} className="h-4 w-4 rounded" />
<label htmlFor="infiniteLoop" className="text-sm font-medium" style={labelStyle}>Infinite Loop</label>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Echo Ring (comma-separated integers)
</label>
<input
type="text"
value={settings.echoRing.join(", ")}
onChange={(e) =>
updateSettings("echoRing", parseIntList(e.target.value))
}
placeholder="e.g. 0, 1, 0, 1"
className={inputClass}
/>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Echo Ring (comma-separated integers)</label>
<input type="text" value={settings.echoRing.join(", ")} onChange={(e) => updateSettings("echoRing", parseIntList(e.target.value))} placeholder="e.g. 0, 1, 0, 1" className={inputClass} />
</div>
{/* Note Assignments - dynamic fields */}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Note Assignments
</label>
<label className="block text-sm font-medium mb-2" style={labelStyle}>Note Assignments</label>
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2">
{Array.from(
{ length: information.totalNotes },
(_, i) => (
<div key={i}>
<label className="block text-xs text-gray-400 mb-0.5 text-left">
Note #{i + 1}
</label>
<input
type="number"
min="0"
required
value={settings.noteAssignments[i] ?? 0}
onChange={(e) => {
const newAssignments = [
...settings.noteAssignments,
];
while (newAssignments.length <= i)
newAssignments.push(0);
newAssignments[i] =
parseInt(e.target.value, 10) || 0;
updateSettings("noteAssignments", newAssignments);
}}
className="w-full px-2 py-1.5 border border-gray-300 rounded-md text-sm text-center focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
)
)}
{Array.from({ length: information.totalNotes }, (_, i) => (
<div key={i}>
<label className="block text-xs mb-0.5 text-left" style={mutedStyle}>Note #{i + 1}</label>
<input type="number" min="0" required value={settings.noteAssignments[i] ?? 0}
onChange={(e) => { const na = [...settings.noteAssignments]; while (na.length <= i) na.push(0); na[i] = parseInt(e.target.value, 10) || 0; updateSettings("noteAssignments", na); }}
className="w-full px-2 py-1.5 rounded-md text-sm text-center border"
/>
</div>
))}
</div>
<p className="text-xs text-gray-400 mt-1">
Assign which bell rings for each note (0 = none)
</p>
<p className="text-xs mt-1" style={mutedStyle}>Assign which bell rings for each note (0 = none)</p>
</div>
</div>
</section>
{/* --- File Upload Section --- */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
Files
</h2>
<section className="rounded-lg p-6 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Files</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Binary File (.bin)
</label>
{existingFiles.binary_url && (
<p className="text-xs text-green-600 mb-1">
Current file uploaded. Selecting a new file will replace
it.
</p>
)}
<input
type="file"
accept=".bin"
onChange={(e) => setBinaryFile(e.target.files[0] || null)}
className="w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
/>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Binary File (.bin)</label>
{existingFiles.binary_url && (<p className="text-xs mb-1" style={{ color: "var(--success)" }}>Current file uploaded. Selecting a new file will replace it.</p>)}
<input type="file" accept=".bin" onChange={(e) => setBinaryFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Audio Preview (.mp3)
</label>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Audio Preview (.mp3)</label>
{existingFiles.preview_url && (
<div className="mb-1">
<p className="text-xs text-green-600 mb-1">
Current preview uploaded. Selecting a new file will
replace it.
</p>
<audio
controls
src={existingFiles.preview_url}
className="h-8"
/>
<p className="text-xs mb-1" style={{ color: "var(--success)" }}>Current preview uploaded. Selecting a new file will replace it.</p>
<audio controls src={existingFiles.preview_url} className="h-8" />
</div>
)}
<input
type="file"
accept=".mp3,.wav,.ogg"
onChange={(e) => setPreviewFile(e.target.files[0] || null)}
className="w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
/>
<input type="file" accept=".mp3,.wav,.ogg" onChange={(e) => setPreviewFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} />
</div>
</div>
</section>
</div>
</div>
{/* --- Actions --- */}
<div className="flex gap-3 mt-6">
<button
type="submit"
disabled={saving || uploading}
className="px-6 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{uploading
? "Uploading files..."
: saving
? "Saving..."
: isEdit
? "Update Melody"
: "Create Melody"}
</button>
<button
type="button"
onClick={() => navigate(isEdit ? `/melodies/${id}` : "/melodies")}
className="px-6 py-2 bg-gray-100 text-gray-700 text-sm rounded-md hover:bg-gray-200 transition-colors"
>
Cancel
</button>
</div>
</form>
{/* Translation Modal */}
<TranslationModal
open={translationModal.open}
onClose={() =>
setTranslationModal((prev) => ({ ...prev, open: false }))
}
onClose={() => setTranslationModal((prev) => ({ ...prev, open: false }))}
field={translationModal.field}
value={parseLocalizedString(information[translationModal.fieldKey])}
onChange={(updated) => updateInfo(translationModal.fieldKey, serializeLocalizedString(updated))}

View File

@@ -150,16 +150,16 @@ export default function MelodyList() {
title={info.color}
/>
) : (
<span className="inline-block w-3 h-8 rounded-sm bg-gray-200" />
<span className="inline-block w-3 h-8 rounded-sm" style={{ backgroundColor: "var(--border-primary)" }} />
);
case "name":
return (
<div>
<span className="font-medium text-gray-900">
<span className="font-medium" style={{ color: "var(--text-heading)" }}>
{getDisplayName(info.name)}
</span>
{isVisible("description") && (
<p className="text-xs text-gray-500 mt-0.5 truncate max-w-xs">
<p className="text-xs mt-0.5 truncate max-w-xs" style={{ color: "var(--text-muted)" }}>
{getLocalizedValue(info.description, displayLang) || "-"}
</p>
)}
@@ -181,7 +181,8 @@ export default function MelodyList() {
{info.customTags.map((tag) => (
<span
key={tag}
className="px-1.5 py-0.5 bg-blue-50 text-blue-700 text-xs rounded-full"
className="px-1.5 py-0.5 text-xs rounded-full"
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}
>
{tag}
</span>
@@ -201,11 +202,11 @@ export default function MelodyList() {
case "infiniteLoop":
return (
<span
className={`px-2 py-0.5 text-xs rounded-full ${
ds.infiniteLoop
? "bg-green-100 text-green-700"
: "bg-gray-100 text-gray-500"
}`}
className="px-2 py-0.5 text-xs rounded-full"
style={{
backgroundColor: ds.infiniteLoop ? "var(--success-bg)" : "var(--bg-card-hover)",
color: ds.infiniteLoop ? "var(--success-text)" : "var(--text-muted)",
}}
>
{ds.infiniteLoop ? "Yes" : "No"}
</span>
@@ -217,18 +218,18 @@ export default function MelodyList() {
case "isTrueRing":
return (
<span
className={`px-2 py-0.5 text-xs rounded-full ${
info.isTrueRing
? "bg-green-100 text-green-700"
: "bg-gray-100 text-gray-500"
}`}
className="px-2 py-0.5 text-xs rounded-full"
style={{
backgroundColor: info.isTrueRing ? "var(--success-bg)" : "var(--bg-card-hover)",
color: info.isTrueRing ? "var(--success-text)" : "var(--text-muted)",
}}
>
{info.isTrueRing ? "Yes" : "No"}
</span>
);
case "docId":
return (
<span className="font-mono text-xs text-gray-500">{row.id}</span>
<span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{row.id}</span>
);
case "pid":
return row.pid || "-";
@@ -244,14 +245,18 @@ export default function MelodyList() {
const languages = melodySettings?.available_languages || ["en"];
const selectClass =
"px-3 py-2 rounded-md text-sm cursor-pointer border";
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Melodies</h1>
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Melodies</h1>
{canEdit && (
<button
onClick={() => navigate("/melodies/new")}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 transition-colors cursor-pointer"
className="px-4 py-2 text-sm rounded-md transition-colors cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-heading)" }}
>
Add Melody
</button>
@@ -267,7 +272,7 @@ export default function MelodyList() {
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
className={selectClass}
>
<option value="">All Types</option>
{MELODY_TYPES.filter(Boolean).map((t) => (
@@ -279,7 +284,7 @@ export default function MelodyList() {
<select
value={toneFilter}
onChange={(e) => setToneFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
className={selectClass}
>
<option value="">All Tones</option>
{MELODY_TONES.filter(Boolean).map((t) => (
@@ -292,7 +297,7 @@ export default function MelodyList() {
<select
value={displayLang}
onChange={(e) => setDisplayLang(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
className={selectClass}
>
{languages.map((l) => (
<option key={l} value={l}>
@@ -307,7 +312,11 @@ export default function MelodyList() {
<button
type="button"
onClick={() => setShowColumnPicker((prev) => !prev)}
className="px-3 py-2 border border-gray-300 rounded-md text-sm text-gray-600 hover:bg-gray-50 transition-colors cursor-pointer flex items-center gap-1.5"
className="px-3 py-2 rounded-md text-sm transition-colors cursor-pointer flex items-center gap-1.5 border"
style={{
borderColor: "var(--border-primary)",
color: "var(--text-secondary)",
}}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
@@ -315,20 +324,27 @@ export default function MelodyList() {
Columns
</button>
{showColumnPicker && (
<div className="absolute right-0 top-full mt-1 z-20 bg-white border border-gray-200 rounded-lg shadow-lg py-2 w-52">
<div
className="absolute right-0 top-full mt-1 z-20 rounded-lg shadow-lg py-2 w-52 border"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
}}
>
{ALL_COLUMNS.map((col) => (
<label
key={col.key}
className={`flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-gray-50 cursor-pointer ${
col.alwaysOn ? "text-gray-400" : "text-gray-700"
}`}
className="flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer"
style={{
color: col.alwaysOn ? "var(--text-muted)" : "var(--text-primary)",
}}
>
<input
type="checkbox"
checked={isVisible(col.key)}
onChange={() => toggleColumn(col.key)}
disabled={col.alwaysOn}
className="h-3.5 w-3.5 rounded border-gray-300 text-blue-600 cursor-pointer"
className="h-3.5 w-3.5 rounded cursor-pointer"
/>
{col.label}
</label>
@@ -337,42 +353,63 @@ export default function MelodyList() {
)}
</div>
<span className="flex items-center text-sm text-gray-500">
<span className="flex items-center text-sm" style={{ color: "var(--text-muted)" }}>
{total} {total === 1 ? "melody" : "melodies"}
</span>
</div>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-md p-3 mb-4">
<div
className="text-sm rounded-md p-3 mb-4 border"
style={{
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
>
{error}
</div>
)}
{loading ? (
<div className="text-center py-8 text-gray-500">Loading...</div>
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : melodies.length === 0 ? (
<div className="bg-white rounded-lg border border-gray-200 p-8 text-center text-gray-500 text-sm">
<div
className="rounded-lg p-8 text-center text-sm border"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
color: "var(--text-muted)",
}}
>
No melodies found.
</div>
) : (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div
className="rounded-lg overflow-hidden border"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
}}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
{activeColumns.map((col) => (
<th
key={col.key}
className={`px-4 py-3 text-left font-medium text-gray-600 ${
className={`px-4 py-3 text-left font-medium ${
col.key === "color" ? "w-8 px-2" : ""
}`}
style={{ color: "var(--text-muted)" }}
>
{col.key === "color" ? "" : col.label}
</th>
))}
{canEdit && (
<th className="px-4 py-3 text-left font-medium text-gray-600 w-24" />
<th className="px-4 py-3 text-left font-medium w-24" style={{ color: "var(--text-muted)" }} />
)}
</tr>
</thead>
@@ -381,14 +418,18 @@ export default function MelodyList() {
<tr
key={row.id}
onClick={() => navigate(`/melodies/${row.id}`)}
className="border-b border-gray-100 last:border-0 cursor-pointer hover:bg-gray-50"
className="cursor-pointer transition-colors"
style={{ borderBottom: "1px solid var(--border-secondary)" }}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card-hover)")}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "transparent")}
>
{activeColumns.map((col) => (
<td
key={col.key}
className={`px-4 py-3 text-gray-700 ${
className={`px-4 py-3 ${
col.key === "color" ? "w-8 px-2" : ""
}`}
style={{ color: "var(--text-primary)" }}
>
{renderCellValue(col.key, row)}
</td>
@@ -401,13 +442,15 @@ export default function MelodyList() {
>
<button
onClick={() => navigate(`/melodies/${row.id}/edit`)}
className="text-blue-600 hover:text-blue-800 text-xs cursor-pointer"
className="text-xs cursor-pointer"
style={{ color: "var(--accent)" }}
>
Edit
</button>
<button
onClick={() => setDeleteTarget(row)}
className="text-red-600 hover:text-red-800 text-xs cursor-pointer"
className="text-xs cursor-pointer"
style={{ color: "var(--danger)" }}
>
Delete
</button>

View File

@@ -7,6 +7,14 @@ import {
normalizeColor,
} from "./melodyUtils";
const sectionStyle = {
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
};
const headingStyle = { color: "var(--text-heading)" };
const labelStyle = { color: "var(--text-secondary)" };
const mutedStyle = { color: "var(--text-muted)" };
export default function MelodySettings() {
const [settings, setSettings] = useState(null);
const [loading, setLoading] = useState(true);
@@ -14,14 +22,9 @@ export default function MelodySettings() {
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
// Add language state
const [langToAdd, setLangToAdd] = useState("");
// Add color state
const [colorToAdd, setColorToAdd] = useState("#FF0000");
const [colorHexInput, setColorHexInput] = useState("#FF0000");
// Add duration state
const [durationToAdd, setDurationToAdd] = useState("");
useEffect(() => {
@@ -123,12 +126,12 @@ export default function MelodySettings() {
};
if (loading) {
return <div className="text-center py-8 text-gray-500">Loading...</div>;
return <div className="text-center py-8" style={mutedStyle}>Loading...</div>;
}
if (!settings) {
return (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-md p-3">
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error || "Failed to load settings."}
</div>
);
@@ -138,113 +141,78 @@ export default function MelodySettings() {
(l) => !settings.available_languages.includes(l.code)
);
const btnPrimary = "px-4 py-2 text-sm rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors";
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-6">Melody Settings</h1>
<h1 className="text-2xl font-bold mb-6" style={headingStyle}>Melody Settings</h1>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-md p-3 mb-4">
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
{success && (
<div className="bg-green-50 border border-green-200 text-green-700 text-sm rounded-md p-3 mb-4">
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--success-bg)", borderColor: "var(--success)", color: "var(--success-text)" }}>
{success}
</div>
)}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* --- Languages Section --- */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
Available Languages
</h2>
<section className="rounded-lg p-6 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Available Languages</h2>
<div className="space-y-2 mb-4">
{settings.available_languages.map((code) => (
<div
key={code}
className="flex items-center justify-between px-3 py-2 bg-gray-50 rounded-md"
className="flex items-center justify-between px-3 py-2 rounded-md"
style={{ backgroundColor: "var(--bg-primary)" }}
>
<div className="flex items-center gap-3">
<span className="text-sm font-mono text-gray-500 uppercase w-8">
{code}
</span>
<span className="text-sm text-gray-900">
{getLanguageName(code)}
</span>
<span className="text-sm font-mono uppercase w-8" style={mutedStyle}>{code}</span>
<span className="text-sm" style={{ color: "var(--text-primary)" }}>{getLanguageName(code)}</span>
{settings.primary_language === code && (
<span className="px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded-full">
Primary
</span>
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>Primary</span>
)}
</div>
<div className="flex items-center gap-2">
{settings.primary_language !== code && (
<button
type="button"
onClick={() => setPrimaryLanguage(code)}
className="text-xs text-blue-600 hover:text-blue-800"
disabled={saving}
>
Set Primary
</button>
<button type="button" onClick={() => setPrimaryLanguage(code)} className="text-xs" style={{ color: "var(--accent)" }} disabled={saving}>Set Primary</button>
)}
{settings.available_languages.length > 1 && (
<button
type="button"
onClick={() => removeLanguage(code)}
className="text-xs text-red-600 hover:text-red-800"
disabled={saving}
>
Remove
</button>
<button type="button" onClick={() => removeLanguage(code)} className="text-xs" style={{ color: "var(--danger)" }} disabled={saving}>Remove</button>
)}
</div>
</div>
))}
</div>
<div className="flex gap-2">
<select
value={langToAdd}
onChange={(e) => setLangToAdd(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<select value={langToAdd} onChange={(e) => setLangToAdd(e.target.value)} className="flex-1 px-3 py-2 rounded-md text-sm border">
<option value="">Select language...</option>
{availableLangsToAdd.map((l) => (
<option key={l.code} value={l.code}>
{l.name} ({l.code})
</option>
))}
{availableLangsToAdd.map((l) => (<option key={l.code} value={l.code}>{l.name} ({l.code})</option>))}
</select>
<button
type="button"
onClick={addLanguage}
disabled={!langToAdd || saving}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Add
</button>
<button type="button" onClick={addLanguage} disabled={!langToAdd || saving} className={btnPrimary} style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-heading)" }}>Add</button>
</div>
</section>
{/* --- Quick Colors Section --- */}
<section className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
Quick Selection Colors
</h2>
<section className="rounded-lg p-6 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Quick Selection Colors</h2>
<div className="flex flex-wrap gap-3 mb-4">
{settings.quick_colors.map((color) => (
<div key={color} className="relative group">
<div
className="w-10 h-10 rounded-lg border-2 border-gray-200 shadow-sm cursor-default"
style={{ backgroundColor: normalizeColor(color) }}
className="w-10 h-10 rounded-lg border-2 shadow-sm cursor-default"
style={{ backgroundColor: normalizeColor(color), borderColor: "var(--border-primary)" }}
title={color}
/>
<button
type="button"
onClick={() => removeColor(color)}
disabled={saving}
className="absolute -top-1.5 -right-1.5 w-5 h-5 bg-red-500 text-white rounded-full text-xs opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full text-xs opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
style={{ backgroundColor: "var(--danger)", color: "#fff" }}
>
&times;
</button>
@@ -255,55 +223,39 @@ export default function MelodySettings() {
<input
type="color"
value={colorToAdd}
onChange={(e) => {
setColorToAdd(e.target.value);
setColorHexInput(e.target.value);
}}
className="w-10 h-10 rounded cursor-pointer border border-gray-300"
onChange={(e) => { setColorToAdd(e.target.value); setColorHexInput(e.target.value); }}
className="w-10 h-10 rounded cursor-pointer border"
style={{ borderColor: "var(--border-primary)" }}
/>
<input
type="text"
value={colorHexInput}
onChange={(e) => {
setColorHexInput(e.target.value);
if (/^#[0-9A-Fa-f]{6}$/.test(e.target.value)) {
setColorToAdd(e.target.value);
}
}}
onChange={(e) => { setColorHexInput(e.target.value); if (/^#[0-9A-Fa-f]{6}$/.test(e.target.value)) setColorToAdd(e.target.value); }}
placeholder="#FF0000"
className="w-28 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
className="w-28 px-3 py-2 rounded-md text-sm font-mono border"
/>
<button
type="button"
onClick={addColor}
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Add
</button>
<button type="button" onClick={addColor} disabled={saving} className={btnPrimary} style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-heading)" }}>Add</button>
</div>
</section>
{/* --- Duration Presets Section --- */}
<section className="bg-white rounded-lg border border-gray-200 p-6 xl:col-span-2">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
Duration Presets (seconds)
</h2>
<section className="rounded-lg p-6 xl:col-span-2 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Duration Presets (seconds)</h2>
<div className="flex flex-wrap gap-2 mb-4">
{settings.duration_values.map((val) => (
<div
key={val}
className="group flex items-center gap-1 px-3 py-1.5 bg-gray-50 border border-gray-200 rounded-full"
className="group flex items-center gap-1 px-3 py-1.5 rounded-full border"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)" }}
>
<span className="text-sm text-gray-900">
{formatDuration(val)}
</span>
<span className="text-xs text-gray-400 ml-1">({val}s)</span>
<span className="text-sm" style={{ color: "var(--text-primary)" }}>{formatDuration(val)}</span>
<span className="text-xs ml-1" style={mutedStyle}>({val}s)</span>
<button
type="button"
onClick={() => removeDuration(val)}
disabled={saving}
className="ml-1 text-red-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity text-xs"
className="ml-1 opacity-0 group-hover:opacity-100 transition-opacity text-xs"
style={{ color: "var(--danger)" }}
>
&times;
</button>
@@ -316,23 +268,11 @@ export default function MelodySettings() {
min="0"
value={durationToAdd}
onChange={(e) => setDurationToAdd(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addDuration();
}
}}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addDuration(); } }}
placeholder="Seconds (e.g. 45)"
className="w-40 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-40 px-3 py-2 rounded-md text-sm border"
/>
<button
type="button"
onClick={addDuration}
disabled={saving || !durationToAdd}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Add
</button>
<button type="button" onClick={addDuration} disabled={saving || !durationToAdd} className={btnPrimary} style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-heading)" }}>Add</button>
</div>
</section>
</div>

View File

@@ -1,17 +1,6 @@
import { useState, useEffect } from "react";
import { getLanguageName } from "./melodyUtils";
/**
* Modal for editing translations of a field (Name or Description).
* Props:
* open - boolean
* onClose - function
* field - string label ("Name" or "Description")
* value - dict { lang_code: text }
* onChange - function(updatedDict)
* languages - array of lang codes ["en", "el", "sr"]
* multiline - boolean (use textarea instead of input)
*/
export default function TranslationModal({
open,
onClose,
@@ -42,18 +31,24 @@ export default function TranslationModal({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[80vh] overflow-y-auto">
<div className="fixed inset-0 bg-black/60" onClick={onClose} />
<div
className="relative rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[80vh] overflow-y-auto border"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
}}
>
<div className="p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
<h3 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>
Translations {field}
</h3>
<div className="space-y-3">
{languages.map((lang) => (
<div key={lang}>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
{getLanguageName(lang)}{" "}
<span className="text-gray-400 font-mono text-xs uppercase">
<span className="font-mono text-xs uppercase" style={{ color: "var(--text-muted)" }}>
({lang})
</span>
</label>
@@ -62,14 +57,14 @@ export default function TranslationModal({
value={draft[lang] || ""}
onChange={(e) => updateDraft(lang, e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
className="w-full px-3 py-2 rounded-md text-sm border"
/>
) : (
<input
type="text"
value={draft[lang] || ""}
onChange={(e) => updateDraft(lang, 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 text-sm"
className="w-full px-3 py-2 rounded-md text-sm border"
/>
)}
</div>
@@ -79,14 +74,16 @@ export default function TranslationModal({
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-gray-100 text-gray-700 text-sm rounded-md hover:bg-gray-200 transition-colors"
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 transition-colors"
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-heading)" }}
>
Save Translations
</button>