Editor Command Center — Phase 4: Settings + Assets Tabs
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Replace the Settings and Assets tab placeholders in AdminDetailPanel with a section settings form (title, slug, icon, nav_visibility, display_type, sort_order, is_published) and a read-only assets grid showing images/videos referenced by content in the section.
Architecture: Settings tab uses a controlled form with existing useSectionMutations.updateSection and the existing IconPicker component. Display_type options are constrained by content_type using the existing CONTENT_TYPE_DISPLAYS mapping. Slug is editable — the backend auto-creates redirects on path changes. Assets tab parses <img> and <video> src attributes from content HTML (stories, projects) fetched for the section, deduplicates, and renders a thumbnail grid. No new backend endpoint needed — assets are extracted client-side from content already fetched by useSectionContent.
Tech Stack: shadcn/ui (Switch, Label), existing IconPicker, existing useSectionMutations, existing apiClient, Tailwind CSS
Task 1: Install shadcn Switch and Label
Files:
- Create:
frontend/src/components/admin-ui/switch.tsx - Create:
frontend/src/components/admin-ui/label.tsx
Step 1: Install components
cd frontend
npx shadcn@latest add switch label --yesVerify files land in src/components/admin-ui/. Add admin-theme class to any portaled elements (Switch and Label don’t portal, so likely no change needed — verify).
Step 2: Commit
git add frontend/src/components/admin-ui/ frontend/package.json frontend/package-lock.json
git commit -m "chore: add shadcn switch and label components"Task 2: Add slug to UpdateSectionRequest and shared constants
Files:
- Modify:
frontend/src/shared/types/api.ts - Modify:
frontend/src/shared/constants/sectionTypes.ts
Step 1: Fix UpdateSectionRequest to include slug
UpdateSectionRequest is currently Partial<CreateSectionRequest>, but CreateSectionRequest doesn’t have slug. The backend SectionUpdate model accepts slug. Add it:
// In frontend/src/shared/types/api.ts
// Change:
export type UpdateSectionRequest = Partial<CreateSectionRequest>;
// To:
export type UpdateSectionRequest = Partial<CreateSectionRequest> & { slug?: string };Step 2: Add CONTENT_TYPE_DISPLAYS and NAV_VISIBILITY_OPTIONS to shared constants
These already exist as local constants in frontend/src/pages/admin/sections.tsx. Extract them to the shared constants file created in Phase 3:
// Add to frontend/src/shared/constants/sectionTypes.ts
export const CONTENT_TYPE_DISPLAYS: Record<SectionContentType, DisplayType[]> = {
story: ["feed", "card-grid"],
project: ["feed", "card-grid"],
page: ["static-page"],
photo_essay: ["gallery"],
};
export const NAV_VISIBILITY_OPTIONS: { value: NavVisibility; label: string }[] = [
{ value: "main", label: "Main" },
{ value: "secondary", label: "Secondary" },
{ value: "hidden", label: "Hidden" },
];Import NavVisibility at the top of the file.
Step 3: Update admin/sections.tsx to use shared constants
Replace the local CONTENT_TYPE_DISPLAYS, CONTENT_TYPES, and NAV_VISIBILITIES in frontend/src/pages/admin/sections.tsx with imports from @/shared/constants/sectionTypes. Use CONTENT_TYPE_OPTIONS (already there) instead of the local CONTENT_TYPES array. Use NAV_VISIBILITY_OPTIONS.map(o => o.value) where the old NAV_VISIBILITIES string array was used, or just map the options in the select. Use the shared CONTENT_TYPE_DISPLAYS.
Step 4: Commit
git add frontend/src/shared/types/api.ts frontend/src/shared/constants/sectionTypes.ts frontend/src/pages/admin/sections.tsx
git commit -m "feat: add slug to UpdateSectionRequest, extract shared section constants"Task 3: Section settings form component
Files:
- Create:
frontend/src/modules/admin/components/SectionSettingsForm.tsx
This is the core of the Settings tab. A form with all editable section fields. Uses shadcn admin-ui components plus the existing IconPicker.
Step 1: Create the form
// frontend/src/modules/admin/components/SectionSettingsForm.tsx
import { useState } from "react";
import { Button } from "@/components/admin-ui/button";
import { Input } from "@/components/admin-ui/input";
import { Label } from "@/components/admin-ui/label";
import { Switch } from "@/components/admin-ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/admin-ui/select";
import IconPicker from "@/components/IconPicker";
import { SectionIcon } from "@/shared/lib/navIcons";
import {
Section,
UpdateSectionRequest,
DisplayType,
NavVisibility,
} from "@/shared/types/api";
import {
CONTENT_TYPE_DISPLAYS,
CONTENT_TYPE_LABELS,
DISPLAY_TYPE_OPTIONS,
NAV_VISIBILITY_OPTIONS,
} from "@/shared/constants/sectionTypes";
interface SectionSettingsFormProps {
section: Section;
onSubmit: (data: UpdateSectionRequest) => Promise<void>;
disabled?: boolean;
}
export function SectionSettingsForm({
section,
onSubmit,
disabled = false,
}: SectionSettingsFormProps) {
const [title, setTitle] = useState(section.title);
const [slug, setSlug] = useState(section.slug);
const [displayType, setDisplayType] = useState<DisplayType>(section.display_type);
const [navVisibility, setNavVisibility] = useState<NavVisibility>(section.nav_visibility);
const [sortOrder, setSortOrder] = useState(section.sort_order);
const [isPublished, setIsPublished] = useState(section.is_published);
const [icon, setIcon] = useState<SectionIcon>((section.icon || "default") as SectionIcon);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
// content_type is locked after creation — display only
const contentType = section.content_type;
const validDisplayTypes = contentType
? CONTENT_TYPE_DISPLAYS[contentType] ?? DISPLAY_TYPE_OPTIONS.map((o) => o.value)
: DISPLAY_TYPE_OPTIONS.map((o) => o.value);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
setSaving(true);
setSaved(false);
try {
await onSubmit({
title: title.trim(),
slug: slug.trim(),
display_type: displayType,
nav_visibility: navVisibility,
sort_order: sortOrder,
is_published: isPublished,
icon,
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} finally {
setSaving(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6 max-w-xl" data-testid="section-settings-form">
{/* Title */}
<div className="space-y-2">
<Label htmlFor="settings-title">Title</Label>
<Input
id="settings-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={disabled}
required
data-testid="settings-title"
/>
</div>
{/* Slug */}
<div className="space-y-2">
<Label htmlFor="settings-slug">Slug</Label>
<Input
id="settings-slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
disabled={disabled}
data-testid="settings-slug"
/>
<p className="text-xs text-muted-foreground">
Changing the slug creates an automatic redirect from the old URL.
</p>
</div>
{/* Content Type (read-only) */}
<div className="space-y-2">
<Label>Content Type</Label>
<p className="text-sm text-muted-foreground" data-testid="settings-content-type">
{contentType ? CONTENT_TYPE_LABELS[contentType] ?? contentType : "None"}
</p>
</div>
{/* Display Type — only show if multiple valid options */}
{validDisplayTypes.length > 1 && (
<div className="space-y-2">
<Label>Display Type</Label>
<Select
value={displayType}
onValueChange={(v) => setDisplayType(v as DisplayType)}
disabled={disabled}
>
<SelectTrigger className="w-full" data-testid="settings-display-type">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{validDisplayTypes.map((dt) => (
<SelectItem key={dt} value={dt}>
{DISPLAY_TYPE_OPTIONS.find((o) => o.value === dt)?.label ?? dt}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Nav Visibility */}
<div className="space-y-2">
<Label>Navigation Visibility</Label>
<Select
value={navVisibility}
onValueChange={(v) => setNavVisibility(v as NavVisibility)}
disabled={disabled}
>
<SelectTrigger className="w-full" data-testid="settings-nav-visibility">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{NAV_VISIBILITY_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Sort Order */}
<div className="space-y-2">
<Label htmlFor="settings-sort-order">Sort Order</Label>
<Input
id="settings-sort-order"
type="number"
min={0}
value={sortOrder}
onChange={(e) => setSortOrder(Number(e.target.value))}
disabled={disabled}
className="w-24"
data-testid="settings-sort-order"
/>
</div>
{/* Icon */}
<div className="space-y-2">
<Label>Icon</Label>
<IconPicker value={icon} onChange={setIcon} disabled={disabled} />
</div>
{/* Published Toggle */}
<div className="flex items-center gap-3">
<Switch
id="settings-published"
checked={isPublished}
onCheckedChange={setIsPublished}
disabled={disabled}
data-testid="settings-published"
/>
<Label htmlFor="settings-published">Published</Label>
</div>
{/* Submit */}
<div className="flex items-center gap-3">
<Button type="submit" disabled={disabled || saving || !title.trim()} data-testid="settings-save">
{saving ? "Saving..." : "Save Settings"}
</Button>
{saved && (
<span className="text-sm text-green-600" data-testid="settings-saved">
Saved
</span>
)}
</div>
</form>
);
}Key design decisions:
content_typeis displayed but not editable — matches the backendSectionUpdatemodel which excludes it.display_typeselect only appears whencontent_typehas multiple valid display types (story/project get feed+card-grid, page gets only static-page, photo_essay gets only gallery).- Slug edit shows a hint about automatic redirects.
- Form state initializes from the
sectionprop. When the selected section changes, the component unmounts/remounts because it’s inside the tabbed panel keyed by section.
Step 2: Commit
git add frontend/src/modules/admin/components/SectionSettingsForm.tsx
git commit -m "feat: add section settings form component"Task 4: Wire settings form into AdminDetailPanel
Files:
- Modify:
frontend/src/modules/admin/components/AdminDetailPanel.tsx
Step 1: Replace the settings placeholder
Import SectionSettingsForm and useSectionMutations. Wire the form’s onSubmit to updateSection, refetching the tree on success. Use section.id as a key on the form so it remounts when selection changes.
In AdminDetailPanel, the Settings tab content becomes:
<TabsContent value="settings" className="p-6 mt-0">
<SectionSettingsForm
key={section.id}
section={section}
onSubmit={handleUpdateSection}
/>
</TabsContent>Add the handler and hook inside AdminDetailPanel:
import { useSectionMutations } from "@/modules/sections/hooks/useSectionMutations";
import { SectionSettingsForm } from "./SectionSettingsForm";
import { UpdateSectionRequest } from "@/shared/types/api";
// Inside the component:
const { updateSection } = useSectionMutations();
const handleUpdateSection = async (data: UpdateSectionRequest) => {
await updateSection(section.id, data);
onRefetchTree();
};Step 2: Run type check and lint
cd frontend && npx tsc --noEmit && npm run lintStep 3: Commit
git add frontend/src/modules/admin/components/AdminDetailPanel.tsx
git commit -m "feat: wire section settings form into admin detail panel"Task 5: Assets extraction hook
Files:
- Create:
frontend/src/modules/admin/hooks/useSectionAssets.ts
This hook extracts image and video URLs from content HTML. It fetches content for the section (reusing the same API as useSectionContent), then parses <img src="..."> and <video><source src="..."> from the HTML content field. Returns a deduplicated list of asset URLs.
The content list endpoints return items without the full content HTML body — stories.list returns Story objects which DO include content. Projects.list returns ProjectCard which does NOT include content. For Phase 4, support stories only (the primary content type with embedded media). Projects can be added later when the endpoint supports it.
Step 1: Create the hook
// frontend/src/modules/admin/hooks/useSectionAssets.ts
import { useState, useEffect, useCallback } from "react";
import { useSession } from "next-auth/react";
import apiClient from "@/shared/lib/api-client";
import { SectionContentType } from "@/shared/types/api";
export interface AssetInfo {
url: string;
type: "image" | "video";
fromContentTitle: string;
}
function extractAssets(html: string, contentTitle: string): AssetInfo[] {
const assets: AssetInfo[] = [];
// Match <img src="..."> and <source src="..."> (inside <video>)
const imgRegex = /<img[^>]+src="([^"]+)"/g;
const videoRegex = /<(?:source|video)[^>]+src="([^"]+)"/g;
let match;
while ((match = imgRegex.exec(html)) !== null) {
assets.push({ url: match[1], type: "image", fromContentTitle: contentTitle });
}
while ((match = videoRegex.exec(html)) !== null) {
assets.push({ url: match[1], type: "video", fromContentTitle: contentTitle });
}
return assets;
}
interface UseSectionAssetsReturn {
assets: AssetInfo[];
loading: boolean;
error: string | null;
}
export function useSectionAssets(
sectionId: string | null,
contentType: SectionContentType | undefined
): UseSectionAssetsReturn {
const { data: session } = useSession();
const [assets, setAssets] = useState<AssetInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchAssets = useCallback(async () => {
// Only stories include full HTML content in list responses
if (!sectionId || contentType !== "story" || !session?.accessToken) {
setAssets([]);
return;
}
setLoading(true);
setError(null);
try {
const response = await apiClient.stories.list(session.accessToken, {
section_id: sectionId,
limit: 50,
offset: 0,
include_drafts: 1,
});
const allAssets: AssetInfo[] = [];
for (const story of response.items) {
if (story.content) {
allAssets.push(...extractAssets(story.content, story.title));
}
}
// Deduplicate by URL, keeping first occurrence
const seen = new Set<string>();
const deduped = allAssets.filter((a) => {
if (seen.has(a.url)) return false;
seen.add(a.url);
return true;
});
setAssets(deduped);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch assets");
} finally {
setLoading(false);
}
}, [sectionId, contentType, session?.accessToken]);
useEffect(() => {
fetchAssets();
}, [fetchAssets]);
return { assets, loading, error };
}Step 2: Commit
git add frontend/src/modules/admin/hooks/useSectionAssets.ts
git commit -m "feat: add useSectionAssets hook for extracting media from content HTML"Task 6: Assets grid component
Files:
- Create:
frontend/src/modules/admin/components/AssetsGrid.tsx
A simple grid of thumbnails. Each card shows the image (or a video icon placeholder), the filename extracted from the URL, and which content item references it.
Step 1: Create the component
// frontend/src/modules/admin/components/AssetsGrid.tsx
import { Film } from "lucide-react";
import { AssetInfo } from "../hooks/useSectionAssets";
interface AssetsGridProps {
assets: AssetInfo[];
}
function filenameFromUrl(url: string): string {
try {
const path = new URL(url, "https://placeholder.local").pathname;
return path.split("/").pop() ?? url;
} catch {
return url;
}
}
export function AssetsGrid({ assets }: AssetsGridProps) {
if (assets.length === 0) {
return (
<p className="text-muted-foreground text-sm" data-testid="assets-empty">
No media assets found in this section's content.
</p>
);
}
return (
<div
className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4"
data-testid="assets-grid"
>
{assets.map((asset) => (
<div
key={asset.url}
className="rounded-lg border border-border overflow-hidden bg-muted/30"
data-testid={`asset-card`}
>
{asset.type === "image" ? (
<img
src={asset.url}
alt=""
className="w-full h-32 object-cover"
loading="lazy"
/>
) : (
<div className="w-full h-32 flex items-center justify-center bg-muted">
<Film className="h-8 w-8 text-muted-foreground" />
</div>
)}
<div className="p-2 space-y-1">
<p className="text-xs font-medium truncate">
{filenameFromUrl(asset.url)}
</p>
<p className="text-xs text-muted-foreground truncate">
{asset.fromContentTitle}
</p>
</div>
</div>
))}
</div>
);
}Step 2: Commit
git add frontend/src/modules/admin/components/AssetsGrid.tsx
git commit -m "feat: add assets grid component for media thumbnails"Task 7: Wire assets tab into AdminDetailPanel
Files:
- Modify:
frontend/src/modules/admin/components/AdminDetailPanel.tsx
Step 1: Replace the assets placeholder
Import useSectionAssets and AssetsGrid. Wire into the Assets tab:
import { useSectionAssets } from "../hooks/useSectionAssets";
import { AssetsGrid } from "./AssetsGrid";
// Inside the component, before the return:
const { assets, loading: assetsLoading } = useSectionAssets(
section.id,
section.content_type as SectionContentType | undefined
);
// In the JSX, replace the assets placeholder:
<TabsContent value="assets" className="p-6 mt-0">
{assetsLoading ? (
<p className="text-muted-foreground text-sm">Loading...</p>
) : (
<AssetsGrid assets={assets} />
)}
</TabsContent>Import SectionContentType from @/shared/types/api if not already imported.
Step 2: Run type check and lint
cd frontend && npx tsc --noEmit && npm run lintStep 3: Commit
git add frontend/src/modules/admin/components/AdminDetailPanel.tsx
git commit -m "feat: wire assets grid into admin detail panel"Task 8: Manual QA and fixes
Step 1: Start dev environment
make dev-localStep 2: Test the settings tab
Navigate to /admin, select a section. Click the Settings tab. Verify:
- All fields populate from the section data
- Title and slug are editable
- Content type is displayed read-only
- Display type dropdown only shows valid options for the content type
- Nav visibility dropdown works
- Sort order accepts numbers
- Icon picker opens and selects icons
- Published toggle works
- Save button calls API, shows “Saved” confirmation
- After save, the sidebar tree reflects changes (title, published status)
- Slug change: verify no 422 error, old URL should redirect (test manually)
Step 3: Test the assets tab
Navigate to /admin, select a section that has stories with images. Click the Assets tab. Verify:
- Images appear in grid with thumbnails
- Filename and content title shown under each thumbnail
- Sections without stories or without media show “No media assets” message
- Video content shows film icon placeholder
Step 4: Run formatting and tests
make format
make test-frontend-unit
make testStep 5: Commit any fixes
git add -A
git commit -m "fix: address phase 4 QA issues"Task 9: Create PR
Step 1: Push branch
git push -u origin ghostmonk/125_editor-command-center-phase4Step 2: Create draft PR
gh pr create --draft \
--title "feat: editor command center settings and assets tabs (Phase 4)" \
--body "$(cat <<'EOF'
## Summary
- Section settings form: title, slug (with redirect hint), display type (constrained by content type), nav visibility, sort order, icon picker, published toggle
- Content type shown read-only (locked after creation)
- Assets grid extracts images and videos from story content HTML
- Thumbnails with filename and source content title
- Shared constants extracted: CONTENT_TYPE_DISPLAYS, NAV_VISIBILITY_OPTIONS
- UpdateSectionRequest type updated to include slug field
## Test plan
- [ ] Settings tab populates all fields from selected section
- [ ] Content type is displayed but not editable
- [ ] Display type dropdown shows only valid options for the content type
- [ ] Save updates section and refreshes sidebar tree
- [ ] Slug change doesn't error (backend creates redirect)
- [ ] Icon picker works within the settings form
- [ ] Published toggle reflects in sidebar draft badge
- [ ] Assets tab shows images from story sections
- [ ] Empty sections show "No media assets" message
- [ ] Non-story sections show empty state (projects don't return content in list)
EOF
)"