Added Draft Melodies. Further improvements to the UI
This commit is contained in:
@@ -32,6 +32,8 @@ export default function MelodyDetail() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [showUnpublish, setShowUnpublish] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [displayLang, setDisplayLang] = useState("en");
|
||||
const [melodySettings, setMelodySettings] = useState(null);
|
||||
|
||||
@@ -72,6 +74,32 @@ export default function MelodyDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await api.post(`/melodies/${id}/publish`);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpublish = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await api.post(`/melodies/${id}/unpublish`);
|
||||
setShowUnpublish(false);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setShowUnpublish(false);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
|
||||
}
|
||||
@@ -116,6 +144,15 @@ export default function MelodyDetail() {
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>{displayName}</h1>
|
||||
<span
|
||||
className="px-2.5 py-0.5 text-xs font-semibold rounded-full"
|
||||
style={melody.status === "published"
|
||||
? { backgroundColor: "rgba(22,163,74,0.15)", color: "#22c55e" }
|
||||
: { backgroundColor: "rgba(156,163,175,0.15)", color: "#9ca3af" }
|
||||
}
|
||||
>
|
||||
{melody.status === "published" ? "LIVE" : "DRAFT"}
|
||||
</span>
|
||||
{languages.length > 1 && (
|
||||
<select
|
||||
value={displayLang}
|
||||
@@ -133,6 +170,25 @@ export default function MelodyDetail() {
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="flex gap-2">
|
||||
{melody.status === "draft" ? (
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
disabled={actionLoading}
|
||||
className="px-4 py-2 text-sm rounded-md transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: "#16a34a", color: "#fff" }}
|
||||
>
|
||||
{actionLoading ? "Publishing..." : "Publish"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowUnpublish(true)}
|
||||
disabled={actionLoading}
|
||||
className="px-4 py-2 text-sm rounded-md transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: "#ea580c", color: "#fff" }}
|
||||
>
|
||||
Unpublish
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate(`/melodies/${id}/edit`)}
|
||||
className="px-4 py-2 text-sm rounded-md transition-colors"
|
||||
@@ -326,6 +382,14 @@ export default function MelodyDetail() {
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setShowDelete(false)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showUnpublish}
|
||||
title="Unpublish Melody"
|
||||
message={`This melody may be in use by devices. Unpublishing will remove it from Firestore and devices will no longer have access to "${displayName}". The melody will be kept as a draft. Continue?`}
|
||||
onConfirm={handleUnpublish}
|
||||
onCancel={() => setShowUnpublish(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ export default function MelodyForm() {
|
||||
const [url, setUrl] = useState("");
|
||||
const [uid, setUid] = useState("");
|
||||
const [pid, setPid] = useState("");
|
||||
const [melodyStatus, setMelodyStatus] = useState("draft");
|
||||
|
||||
const [binaryFile, setBinaryFile] = useState(null);
|
||||
const [previewFile, setPreviewFile] = useState(null);
|
||||
@@ -116,6 +117,7 @@ export default function MelodyForm() {
|
||||
setUrl(melody.url || "");
|
||||
setUid(melody.uid || "");
|
||||
setPid(melody.pid || "");
|
||||
setMelodyStatus(melody.status || "published");
|
||||
setExistingFiles(files);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -150,33 +152,39 @@ export default function MelodyForm() {
|
||||
updateInfo(fieldKey, serializeLocalizedString(dict));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const buildBody = () => {
|
||||
const { notes, ...infoWithoutNotes } = information;
|
||||
return {
|
||||
information: infoWithoutNotes,
|
||||
default_settings: settings,
|
||||
type, url, uid, pid,
|
||||
};
|
||||
};
|
||||
|
||||
const uploadFiles = async (melodyId) => {
|
||||
if (binaryFile || previewFile) {
|
||||
setUploading(true);
|
||||
if (binaryFile) await api.upload(`/melodies/${melodyId}/upload/binary`, binaryFile);
|
||||
if (previewFile) await api.upload(`/melodies/${melodyId}/upload/preview`, previewFile);
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (publish) => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const { notes, ...infoWithoutNotes } = information;
|
||||
const body = {
|
||||
information: infoWithoutNotes,
|
||||
default_settings: settings,
|
||||
type, url, uid, pid,
|
||||
};
|
||||
|
||||
const body = buildBody();
|
||||
let melodyId = id;
|
||||
|
||||
if (isEdit) {
|
||||
await api.put(`/melodies/${id}`, body);
|
||||
} else {
|
||||
const created = await api.post("/melodies", body);
|
||||
const created = await api.post(`/melodies?publish=${publish}`, body);
|
||||
melodyId = created.id;
|
||||
}
|
||||
|
||||
if (binaryFile || previewFile) {
|
||||
setUploading(true);
|
||||
if (binaryFile) await api.upload(`/melodies/${melodyId}/upload/binary`, binaryFile);
|
||||
if (previewFile) await api.upload(`/melodies/${melodyId}/upload/preview`, previewFile);
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
await uploadFiles(melodyId);
|
||||
navigate(`/melodies/${melodyId}`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -186,6 +194,46 @@ export default function MelodyForm() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublishAction = async () => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const body = buildBody();
|
||||
await api.put(`/melodies/${id}`, body);
|
||||
await uploadFiles(id);
|
||||
await api.post(`/melodies/${id}/publish`);
|
||||
navigate(`/melodies/${id}`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setUploading(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpublishAction = async () => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.post(`/melodies/${id}/unpublish`);
|
||||
navigate(`/melodies/${id}`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
// Default form submit: save as draft for new, update for existing
|
||||
if (isEdit) {
|
||||
await handleSave(false);
|
||||
} else {
|
||||
await handleSave(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-8" style={mutedStyle}>Loading...</div>;
|
||||
}
|
||||
@@ -214,15 +262,70 @@ export default function MelodyForm() {
|
||||
>
|
||||
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-white)" }}
|
||||
>
|
||||
{uploading ? "Uploading files..." : saving ? "Saving..." : isEdit ? "Update Melody" : "Create Melody"}
|
||||
</button>
|
||||
{!isEdit ? (
|
||||
<>
|
||||
<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(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
|
||||
>
|
||||
{saving ? "Saving..." : "Save as Draft"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || uploading}
|
||||
onClick={() => handleSave(true)}
|
||||
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-white)" }}
|
||||
>
|
||||
{uploading ? "Uploading files..." : "Publish Melody"}
|
||||
</button>
|
||||
</>
|
||||
) : melodyStatus === "draft" ? (
|
||||
<>
|
||||
<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(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
|
||||
>
|
||||
{saving ? "Saving..." : "Update Draft"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || uploading}
|
||||
onClick={handlePublishAction}
|
||||
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
style={{ backgroundColor: "#16a34a", color: "#fff" }}
|
||||
>
|
||||
{uploading ? "Uploading files..." : "Publish"}
|
||||
</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-white)" }}
|
||||
>
|
||||
{uploading ? "Uploading files..." : saving ? "Saving..." : "Update Melody"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || uploading}
|
||||
onClick={handleUnpublishAction}
|
||||
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
style={{ backgroundColor: "#ea580c", color: "#fff" }}
|
||||
>
|
||||
Unpublish
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ const MELODY_TONES = ["", "normal", "festive", "cheerful", "lamentation"];
|
||||
|
||||
// All available columns with their defaults
|
||||
const ALL_COLUMNS = [
|
||||
{ key: "status", label: "Status", defaultOn: true },
|
||||
{ key: "color", label: "Color", defaultOn: true },
|
||||
{ key: "name", label: "Name", defaultOn: true, alwaysOn: true },
|
||||
{ key: "description", label: "Description", defaultOn: false },
|
||||
@@ -56,9 +57,12 @@ export default function MelodyList() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState("");
|
||||
const [toneFilter, setToneFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [displayLang, setDisplayLang] = useState("en");
|
||||
const [melodySettings, setMelodySettings] = useState(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const [unpublishTarget, setUnpublishTarget] = useState(null);
|
||||
const [actionLoading, setActionLoading] = useState(null);
|
||||
const [visibleColumns, setVisibleColumns] = useState(getDefaultVisibleColumns);
|
||||
const [showColumnPicker, setShowColumnPicker] = useState(false);
|
||||
const columnPickerRef = useRef(null);
|
||||
@@ -94,6 +98,7 @@ export default function MelodyList() {
|
||||
if (search) params.set("search", search);
|
||||
if (typeFilter) params.set("type", typeFilter);
|
||||
if (toneFilter) params.set("tone", toneFilter);
|
||||
if (statusFilter) params.set("status", statusFilter);
|
||||
const qs = params.toString();
|
||||
const data = await api.get(`/melodies${qs ? `?${qs}` : ""}`);
|
||||
setMelodies(data.melodies);
|
||||
@@ -107,7 +112,7 @@ export default function MelodyList() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchMelodies();
|
||||
}, [search, typeFilter, toneFilter]);
|
||||
}, [search, typeFilter, toneFilter, statusFilter]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
@@ -121,6 +126,33 @@ export default function MelodyList() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async (row) => {
|
||||
setActionLoading(row.id);
|
||||
try {
|
||||
await api.post(`/melodies/${row.id}/publish`);
|
||||
fetchMelodies();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpublish = async () => {
|
||||
if (!unpublishTarget) return;
|
||||
setActionLoading(unpublishTarget.id);
|
||||
try {
|
||||
await api.post(`/melodies/${unpublishTarget.id}/unpublish`);
|
||||
setUnpublishTarget(null);
|
||||
fetchMelodies();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setUnpublishTarget(null);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleColumn = (key) => {
|
||||
const col = ALL_COLUMNS.find((c) => c.key === key);
|
||||
if (col?.alwaysOn) return;
|
||||
@@ -142,6 +174,18 @@ export default function MelodyList() {
|
||||
const info = row.information || {};
|
||||
const ds = row.default_settings || {};
|
||||
switch (key) {
|
||||
case "status":
|
||||
return (
|
||||
<span
|
||||
className="px-2 py-0.5 text-xs font-semibold rounded-full"
|
||||
style={row.status === "published"
|
||||
? { backgroundColor: "rgba(22,163,74,0.15)", color: "#22c55e" }
|
||||
: { backgroundColor: "rgba(156,163,175,0.15)", color: "#9ca3af" }
|
||||
}
|
||||
>
|
||||
{row.status === "published" ? "Live" : "Draft"}
|
||||
</span>
|
||||
);
|
||||
case "color":
|
||||
return info.color ? (
|
||||
<span
|
||||
@@ -293,6 +337,15 @@ export default function MelodyList() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="published">Published (Live)</option>
|
||||
<option value="draft">Drafts</option>
|
||||
</select>
|
||||
{languages.length > 1 && (
|
||||
<select
|
||||
value={displayLang}
|
||||
@@ -440,6 +493,25 @@ export default function MelodyList() {
|
||||
className="flex gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{row.status === "draft" ? (
|
||||
<button
|
||||
onClick={() => handlePublish(row)}
|
||||
disabled={actionLoading === row.id}
|
||||
className="text-xs cursor-pointer disabled:opacity-50"
|
||||
style={{ color: "#22c55e" }}
|
||||
>
|
||||
{actionLoading === row.id ? "..." : "Publish"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setUnpublishTarget(row)}
|
||||
disabled={actionLoading === row.id}
|
||||
className="text-xs cursor-pointer disabled:opacity-50"
|
||||
style={{ color: "#ea580c" }}
|
||||
>
|
||||
Unpublish
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate(`/melodies/${row.id}/edit`)}
|
||||
className="text-xs cursor-pointer"
|
||||
@@ -472,6 +544,14 @@ export default function MelodyList() {
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!unpublishTarget}
|
||||
title="Unpublish Melody"
|
||||
message={`This melody may be in use by devices. Unpublishing will remove "${getDisplayName(unpublishTarget?.information?.name)}" from Firestore and devices will no longer have access. The melody will be kept as a draft. Continue?`}
|
||||
onConfirm={handleUnpublish}
|
||||
onCancel={() => setUnpublishTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user