Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changelog/NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

- **Destructive actions now show a clear "Delete? / Cancel" prompt instead of a hidden second click.** Deleting a round, universe, series, share bucket, issue/episode, or a Writers Room work/folder — and removing an Ask conversation or deleting a world in the Universe Builder — used to silently re-arm the same button on the first click, with nothing on screen telling you a second click was needed. Each of these now pops an explicit inline confirm/cancel affordance right where you clicked, so it's obvious what will happen and easy to back out. The pipeline's "replace existing scenes / pages / audio lines" extract buttons got the same treatment (an inline "Replace? / Cancel" row) instead of arming via a fleeting toast. Delete/confirm controls in the Writers Room library were also enlarged to a comfortable 44px touch target for mobile.

## Deep-linkable selections

- **Picking an author, music artist/album/track, share bucket, prompt stage/variable, or JIRA report now lives in the URL — so it's shareable, bookmarkable, reload-safe, and reachable from ⌘K / voice / the back button (#2025).** These master-detail pages previously kept the open record in local state, so a pasted link or a reload always dropped you back on an empty list. Authors now open at `/authors/:id` (`/authors/new` to create), the Music tabs at `/music/:tab/:id`, Sharing buckets at `/sharing/buckets/:bucketId`, the Prompt Manager via `?stage=` / `?var=`, and JIRA Reports via `?reportApp=&reportDate=`. Deleting or clearing a selection returns to the index, and a stale/deleted id now shows a "could not be found" fallback instead of a blank pane. (OpenClaw's session picker stays local by design — its sessions are ephemeral runtime state, not user-owned records.)

## Accessibility

- **Config form labels now focus their field when clicked and read correctly to screen readers.** Many settings/config forms (AI Providers, DataDog, feature-agent config, message & calendar account setup, scheduled-task provider/model pickers, the agent world/schedule tabs, MeatSpace nicotine + POST drills, and more) rendered the label as a plain sibling of its input with no association, so clicking the label did nothing and assistive tech couldn't announce the pairing. These fields now flow through a shared `FormField` wrapper that generates a stable id and wires `htmlFor`/`id` automatically, keeping the exact same styling (#2027). Remaining forms are tracked for a follow-up sweep (#2051).
Expand Down
9 changes: 9 additions & 0 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -326,14 +326,23 @@ export default function App() {
<Route path="writers-room/guide" element={<WritersRoomGuide />} />
<Route path="sharing" element={<Sharing />} />
<Route path="sharing/:section" element={<Sharing />} />
<Route path="sharing/:section/:bucketId" element={<Sharing />} />
<Route path="importer" element={<Importer />} />
<Route path="start-story" element={<StartStory />} />
<Route path="story-builder" element={<StoryBuilder />} />
<Route path="story-builder/:storyId" element={<Navigate to="idea" replace />} />
<Route path="story-builder/:storyId/:step" element={<StoryBuilder />} />
{/* Authors is a single master-detail page: the index + editor share one
component, so selection rides `:authorId` (which also captures the
`new` create-mode sentinel — author ids are UUIDs, so it can't
collide). A bare `/authors` is the idle index. */}
<Route path="authors" element={<Authors />} />
<Route path="authors/:authorId" element={<Authors />} />
{/* Music is tabbed (`:tab`); each tab's master-detail selection lives
at `/music/:tab/:id` (`new` = create sentinel). */}
<Route path="music" element={<Music />} />
<Route path="music/:tab" element={<Music />} />
<Route path="music/:tab/:id" element={<Music />} />
<Route path="pipeline" element={<Pipeline />} />
<Route path="pipeline/editorial-checks" element={<PipelineEditorialChecks />} />
<Route path="pipeline/findings/:commentId" element={<PipelineFindingRedirect />} />
Expand Down
56 changes: 37 additions & 19 deletions client/src/components/music/AlbumsManager.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Plus, Loader2, Trash2, Save, Upload, ImageIcon, Sparkles, X, ArrowUp, ArrowDown } from 'lucide-react';
import toast from '../ui/Toast';
import GalleryImagePicker from '../imageGen/GalleryImagePicker';
Expand Down Expand Up @@ -65,10 +66,13 @@ function Field({ label, hint, children }) {
}

export default function AlbumsManager() {
const navigate = useNavigate();
// Selection lives in the URL (`/music/albums/:id`, `/music/albums/new`) so it's
// deep-linkable and reload-safe. `id === 'new'` = create; a real id = edit.
const { id } = useParams();
const [albums, setAlbums] = useState([]);
const [allTracks, setAllTracks] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState(null); // 'new' | id | null
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
Expand Down Expand Up @@ -109,26 +113,33 @@ export default function AlbumsManager() {
}
}, [genJobId, gen.status, gen.filename, gen.path, gen.error]);

const isCreate = selectedId === 'new';
const isCreate = id === 'new';
const selected = useMemo(
() => (isCreate || !selectedId ? null : albums.find((a) => a.id === selectedId) || null),
[albums, selectedId, isCreate],
() => (isCreate || !id ? null : albums.find((a) => a.id === id) || null),
[albums, id, isCreate],
);
const notFound = !isCreate && !!id && !loading && !selected;
const tracksById = useMemo(() => new Map(allTracks.map((t) => [t.id, t])), [allTracks]);
const canGenerate = !!(form.title.trim() || form.genre.trim() || form.description.trim());

const selectAlbum = (a) => {
setSelectedId(a.id);
setForm(formFromAlbum(a));
setConfirmDelete(false);
clearGeneration();
};
const startCreate = () => {
setSelectedId('new');
setForm(emptyForm());
const selectAlbum = (a) => navigate(`/music/albums/${encodeURIComponent(a.id)}`);
const startCreate = () => navigate('/music/albums/new');

// Hydrate the editor form from the URL-selected album. Keyed on the id so a
// list refresh doesn't clobber the open form; resets run for every selection
// change (incl. idle / not-found) so a stray render can't land on the previous
// album (see Authors.jsx for the base pattern).
const hydratedRef = useRef(null);
const selectionKey = id ?? null;
useEffect(() => {
if (loading) return;
if (hydratedRef.current === selectionKey) return;
hydratedRef.current = selectionKey;
setConfirmDelete(false);
clearGeneration();
};
if (isCreate) setForm(emptyForm());
else if (selected) setForm(formFromAlbum(selected));
}, [selectionKey, isCreate, selected, loading]);

const handleGenerateCover = async () => {
if (isGenerating || uploadingCover) return;
Expand Down Expand Up @@ -216,14 +227,14 @@ export default function AlbumsManager() {
setSaving(false);
if (!created) return;
setAlbums((prev) => [...prev, created].sort((a, b) => (a.title || '').localeCompare(b.title || '')));
setSelectedId(created.id);
navigate(`/music/albums/${encodeURIComponent(created.id)}`);
toast.success(`Created "${created.title}"`);
} else {
// Did the track list actually change vs the loaded record?
const original = selected?.trackIds || [];
const trackIdsChanged = original.length !== form.trackIds.length
|| original.some((id, i) => id !== form.trackIds[i]);
const updated = await updateAlbum(selectedId, buildPayload({ includeTrackIds: trackIdsChanged })).catch((err) => { toast.error(err.message || 'Failed to save album'); return null; });
const updated = await updateAlbum(id, buildPayload({ includeTrackIds: trackIdsChanged })).catch((err) => { toast.error(err.message || 'Failed to save album'); return null; });
setSaving(false);
if (!updated) return;
setAlbums((prev) => prev.map((a) => (a.id === updated.id ? updated : a)).sort((a, b) => (a.title || '').localeCompare(b.title || '')));
Expand All @@ -235,8 +246,8 @@ export default function AlbumsManager() {
if (!selected) return;
const prior = albums;
setAlbums((prev) => prev.filter((a) => a.id !== selected.id));
setSelectedId(null);
setConfirmDelete(false);
navigate('/music/albums');
await deleteAlbum(selected.id).catch((err) => { toast.error(err.message || 'Delete failed'); setAlbums(prior); });
};

Expand Down Expand Up @@ -272,7 +283,7 @@ export default function AlbumsManager() {
type="button"
onClick={() => selectAlbum(a)}
className={`w-full text-left px-2 py-2 rounded text-sm flex items-center gap-2 ${
a.id === selectedId ? 'bg-port-accent/20 text-white' : 'text-gray-300 hover:bg-port-bg'
a.id === id ? 'bg-port-accent/20 text-white' : 'text-gray-300 hover:bg-port-bg'
}`}
>
{a.coverImageUrl ? (
Expand All @@ -292,7 +303,14 @@ export default function AlbumsManager() {
</div>

<div className="bg-port-card border border-port-border rounded-lg p-4">
{!isCreate && !selected ? (
{notFound ? (
<div className="text-gray-500 text-sm">
That album could not be found — it may have been deleted.{' '}
<button type="button" onClick={() => navigate('/music/albums')} className="text-port-accent hover:underline">
Back to albums
</button>
</div>
) : !isCreate && !selected ? (
<div className="text-gray-500 text-sm">Select an album to edit, or create a new one.</div>
) : (
<div className="space-y-3">
Expand Down
57 changes: 37 additions & 20 deletions client/src/components/music/ArtistsManager.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Plus, Loader2, Trash2, Save, Upload, ImageIcon, Sparkles, X } from 'lucide-react';
import toast from '../ui/Toast';
import GalleryImagePicker from '../imageGen/GalleryImagePicker';
Expand Down Expand Up @@ -58,10 +59,13 @@ function Field({ label, hint, children }) {
}

export default function ArtistsManager() {
const navigate = useNavigate();
// Selection lives in the URL (`/music/artists/:id`, `/music/artists/new`) so
// it's deep-linkable and reload-safe. `id === 'new'` is create mode; a real id
// is edit mode; absent is idle.
const { id } = useParams();
const [artists, setArtists] = useState([]);
const [loading, setLoading] = useState(true);
// selectedId === 'new' is create mode; a real id is edit mode; null is idle.
const [selectedId, setSelectedId] = useState(null);
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
Expand Down Expand Up @@ -164,26 +168,32 @@ export default function ArtistsManager() {
.finally(() => setLoading(false));
}, []);

const isCreate = selectedId === 'new';
const isCreate = id === 'new';
const selected = useMemo(
() => (isCreate || !selectedId ? null : artists.find((a) => a.id === selectedId) || null),
[artists, selectedId, isCreate],
() => (isCreate || !id ? null : artists.find((a) => a.id === id) || null),
[artists, id, isCreate],
);
const notFound = !isCreate && !!id && !loading && !selected;
const canGenerate = !!(form.physicalDescription.trim() || form.portraitStyle.trim());

const selectArtist = (a) => {
setSelectedId(a.id);
setForm(formFromArtist(a));
setConfirmDelete(false);
clearGeneration();
};
const selectArtist = (a) => navigate(`/music/artists/${encodeURIComponent(a.id)}`);
const startCreate = () => navigate('/music/artists/new');

const startCreate = () => {
setSelectedId('new');
setForm(emptyForm());
// Hydrate the editor form from the URL-selected artist. Keyed on the id so a
// list refresh doesn't clobber the open form; resets run for every selection
// change (incl. idle / not-found) so a stray render can't land on the previous
// artist (see Authors.jsx for the base pattern).
const hydratedRef = useRef(null);
const selectionKey = id ?? null;
useEffect(() => {
if (loading) return;
if (hydratedRef.current === selectionKey) return;
hydratedRef.current = selectionKey;
setConfirmDelete(false);
clearGeneration();
};
if (isCreate) setForm(emptyForm());
else if (selected) setForm(formFromArtist(selected));
}, [selectionKey, isCreate, selected, loading]);

const handleSave = async () => {
const name = form.name.trim();
Expand All @@ -198,10 +208,10 @@ export default function ArtistsManager() {
setSaving(false);
if (!created) return;
setArtists((prev) => [...prev, created].sort((a, b) => (a.name || '').localeCompare(b.name || '')));
setSelectedId(created.id);
navigate(`/music/artists/${encodeURIComponent(created.id)}`);
toast.success(`Created "${created.name}"`);
} else {
const updated = await updateArtist(selectedId, payload).catch((err) => {
const updated = await updateArtist(id, payload).catch((err) => {
toast.error(err.message || 'Failed to save artist');
return null;
});
Expand All @@ -218,8 +228,8 @@ export default function ArtistsManager() {
if (!selected) return;
const prior = artists;
setArtists((prev) => prev.filter((a) => a.id !== selected.id));
setSelectedId(null);
setConfirmDelete(false);
navigate('/music/artists');
await deleteArtist(selected.id).catch((err) => {
toast.error(err.message || 'Delete failed');
setArtists(prior);
Expand Down Expand Up @@ -257,7 +267,7 @@ export default function ArtistsManager() {
type="button"
onClick={() => selectArtist(a)}
className={`w-full text-left px-3 py-2 rounded text-sm truncate ${
a.id === selectedId ? 'bg-port-accent/20 text-white' : 'text-gray-300 hover:bg-port-bg'
a.id === id ? 'bg-port-accent/20 text-white' : 'text-gray-300 hover:bg-port-bg'
}`}
>
{a.name}
Expand All @@ -270,7 +280,14 @@ export default function ArtistsManager() {
</div>

<div className="bg-port-card border border-port-border rounded-lg p-4">
{!isCreate && !selected ? (
{notFound ? (
<div className="text-gray-500 text-sm">
That artist could not be found — it may have been deleted.{' '}
<button type="button" onClick={() => navigate('/music/artists')} className="text-port-accent hover:underline">
Back to artists
</button>
</div>
) : !isCreate && !selected ? (
<div className="text-gray-500 text-sm">Select an artist to edit, or create a new one.</div>
) : (
<div className="space-y-3">
Expand Down
Loading