- This tab detects every <form> tag already on this page — write one by hand or have an AI agent add
- one, then wire it up to a Sheet here. Deleting a form removes its tag from the page; there's no way
- to recover it, so start a fresh form (with a new Sheet or the same one) if you need it back.
+ This category detects every <form> tag already on this page — write one by hand or have an AI agent
+ add one, then wire it up to a Sheet here. Deleting a form removes its tag from the page; there's no
+ way to recover it, so start a fresh form (with a new Sheet or the same one) if you need it back.
{detected.length === 0 && orphaned.length === 0 && (
diff --git a/apps/web/src/components/layout/middle-content/page-views/canvas/settings/CanvasHomePageSettingsSection.tsx b/apps/web/src/components/layout/middle-content/page-views/canvas/settings/CanvasHomePageSettingsSection.tsx
new file mode 100644
index 0000000000..e1cfa566e1
--- /dev/null
+++ b/apps/web/src/components/layout/middle-content/page-views/canvas/settings/CanvasHomePageSettingsSection.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import { useEffect } from 'react';
+import { useParams } from 'next/navigation';
+import { toast } from 'sonner';
+import { Home, FileQuestion } from 'lucide-react';
+import { patch } from '@/lib/auth/auth-fetch';
+import { Button } from '@/components/ui/button';
+import { useDriveStore } from '@/hooks/useDrive';
+import { usePublishStatusStore } from '@/stores/usePublishStatusStore';
+
+interface CanvasHomePageSettingsSectionProps {
+ pageId: string;
+}
+
+/**
+ * Home / 404 page category: lets this page be assigned as the drive's home
+ * page or default 404 page. Reuses the exact same
+ * `patch('/api/drives/:driveId', { homePageId | notFoundPageId })` +
+ * `useDriveStore().updateDrive` calls already used by the sidebar's "Set as
+ * home page" context-menu action and the domains settings page's 404-page
+ * picker, rather than inventing a new mechanism.
+ */
+export function CanvasHomePageSettingsSection({ pageId }: CanvasHomePageSettingsSectionProps) {
+ const params = useParams<{ driveId?: string }>();
+ const driveId = params?.driveId;
+
+ const fetchDrives = useDriveStore((state) => state.fetchDrives);
+ const updateDriveInStore = useDriveStore((state) => state.updateDrive);
+ const drive = useDriveStore((state) => state.drives.find((d) => d.id === driveId));
+
+ useEffect(() => {
+ fetchDrives();
+ }, [fetchDrives]);
+
+ const canManageDrive = !!drive && (drive.isOwned || drive.role === 'ADMIN');
+ const isHomePage = drive?.homePageId === pageId;
+ const isNotFoundPage = drive?.notFoundPageId === pageId;
+
+ const handleHomeToggle = async () => {
+ if (!driveId) return;
+ const next = isHomePage ? null : pageId;
+ const toastId = toast.loading(next ? 'Setting home page…' : 'Removing home page…');
+ try {
+ await patch(`/api/drives/${driveId}`, { homePageId: next });
+ updateDriveInStore(driveId, { homePageId: next });
+ // The page's published URL depends on whether it's the drive's home
+ // page (served at the root vs its slug — see the publish GET route),
+ // so the header's cached PublishControls status is now stale: refetch
+ // rather than leaving it showing (and letting Copy link copy) a URL
+ // that no longer matches what's actually being served. `force: true`
+ // because another consumer's mount-time fetch may still be in flight
+ // (reading the row from before this mutation) — reusing it here would
+ // apply that stale read once it resolves instead of a fresh one.
+ usePublishStatusStore.getState().fetchStatus(pageId, { force: true });
+ toast.success(next ? 'Home page set.' : 'Home page removed.', { id: toastId });
+ } catch {
+ toast.error('Error updating home page.', { id: toastId });
+ }
+ };
+
+ const handleNotFoundToggle = async () => {
+ if (!driveId) return;
+ const next = isNotFoundPage ? null : pageId;
+ const toastId = toast.loading(next ? 'Setting 404 page…' : 'Removing 404 page…');
+ try {
+ await patch(`/api/drives/${driveId}`, { notFoundPageId: next });
+ updateDriveInStore(driveId, { notFoundPageId: next });
+ toast.success(next ? '404 page set.' : '404 page removed.', { id: toastId });
+ } catch {
+ toast.error('Error updating 404 page.', { id: toastId });
+ }
+ };
+
+ if (!drive) {
+ return
Loading…
;
+ }
+
+ if (!canManageDrive) {
+ return (
+
+ Only the drive owner or an admin can assign the home or 404 page.
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
Home page
+
+ {isHomePage ? 'This is the drive\'s landing page.' : 'The page people land on when they enter this drive.'}
+
+
+
+
+ {isHomePage ? 'Remove' : 'Set as home page'}
+
+
+
+
+
+
+
404 page
+
+ {isNotFoundPage ? 'Shown for any unknown URL on this drive\'s published site.' : 'Falls back to the generic branded 404 page.'}
+
+
+
+
+ {isNotFoundPage ? 'Remove' : 'Set as 404 page'}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/layout/middle-content/page-views/canvas/settings/CanvasPublishSettingsSection.tsx b/apps/web/src/components/layout/middle-content/page-views/canvas/settings/CanvasPublishSettingsSection.tsx
new file mode 100644
index 0000000000..6eebc2db7f
--- /dev/null
+++ b/apps/web/src/components/layout/middle-content/page-views/canvas/settings/CanvasPublishSettingsSection.tsx
@@ -0,0 +1,190 @@
+"use client";
+
+import { useCallback, useEffect, useState } from 'react';
+import { useParams } from 'next/navigation';
+import { toast } from 'sonner';
+import { fetchWithAuth } from '@/lib/auth/auth-fetch';
+import { Button } from '@/components/ui/button';
+import { PublishSettingsFields, EMPTY_SETTINGS, type PublishSettings } from '../../../content-header/PublishSettingsFields';
+import { usePublishStatusStore } from '@/stores/usePublishStatusStore';
+
+interface CanvasPublishSettingsSectionProps {
+ pageId: string;
+}
+
+interface PublishStatusResponse {
+ published: boolean;
+ title?: string | null;
+ description?: string | null;
+ ogImageUrl?: string | null;
+ noindex?: boolean;
+ themeBridgeEnabled?: boolean;
+}
+
+const readError = async (res: Response): Promise
=> {
+ try {
+ const data = (await res.json()) as { error?: unknown };
+ return typeof data.error === 'string' ? data.error : 'Request failed';
+ } catch {
+ return 'Request failed';
+ }
+};
+
+/**
+ * Publish category: the author SEO overrides (title/description/share image/
+ * noindex) that used to live only in a dialog gated behind the page already
+ * being published. Same fields, same API — `PublishSettingsFields` — just
+ * surfaced here always, disabled with an explanatory note until the page has
+ * been published at least once (there's nothing to persist yet otherwise;
+ * see the plan's note on this deliberate scope choice over a fuller
+ * draft-settings-before-first-publish redesign).
+ *
+ * Reads/writes through `usePublishStatusStore`, shared with the header's
+ * PublishControls — publishing/unpublishing from the header while this
+ * category is open updates `published` here too, instead of this component's
+ * own mount-time snapshot going stale (it previously fetched independently).
+ */
+export function CanvasPublishSettingsSection({ pageId }: CanvasPublishSettingsSectionProps) {
+ const params = useParams<{ driveId?: string }>();
+ const driveId = params?.driveId;
+
+ const status = usePublishStatusStore((s) => s.statuses.get(pageId));
+ const fetchStatus = usePublishStatusStore((s) => s.fetchStatus);
+ const setStatus = usePublishStatusStore((s) => s.setStatus);
+
+ const [form, setForm] = useState(EMPTY_SETTINGS);
+ const [pickedImageId, setPickedImageId] = useState(null);
+ const [isSaving, setIsSaving] = useState(false);
+ // Whether the user has touched the form since it was last seeded from
+ // `status.settings`. Reseeding must skip a dirty form (so an in-progress
+ // edit here survives an unrelated status change, e.g. the header marking
+ // the page stale after a content save) but NOT skip a pristine one — a
+ // one-shot "seeded once ever" guard would let this category keep showing
+ // stale values (and silently roll them back on Save) after the header's
+ // own Publish settings dialog saves different ones while this stays open.
+ const [isDirty, setIsDirty] = useState(false);
+
+ useEffect(() => {
+ fetchStatus(pageId);
+ }, [pageId, fetchStatus]);
+
+ useEffect(() => {
+ if (status && !isDirty) {
+ setForm({
+ title: status.settings.title,
+ description: status.settings.description,
+ ogImageUrl: status.settings.ogImageUrl,
+ noindex: status.settings.noindex,
+ });
+ }
+ }, [status, isDirty]);
+
+ const handleFormChange = useCallback((next: PublishSettings) => {
+ setForm(next);
+ setIsDirty(true);
+ }, []);
+
+ const handleSave = useCallback(async () => {
+ if (!pickedImageId) {
+ const ogImageUrl = form.ogImageUrl.trim();
+ if (ogImageUrl) {
+ try {
+ new URL(ogImageUrl);
+ } catch {
+ toast.error('Enter a valid share image URL, including https://');
+ return;
+ }
+ }
+ }
+ setIsSaving(true);
+ try {
+ const res = await fetchWithAuth(`/api/pages/${pageId}/publish`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title: form.title,
+ description: form.description,
+ ogImageUrl: pickedImageId ? '' : form.ogImageUrl,
+ ogImageFileId: pickedImageId ?? undefined,
+ noindex: form.noindex,
+ }),
+ });
+ if (!res.ok) {
+ toast.error(await readError(res));
+ return;
+ }
+ const data = (await res.json()) as PublishStatusResponse & { url?: string };
+ // Preserve available/url/etc from the current status rather than
+ // reconstructing it from this POST response — unlike the GET status
+ // shape, PublishCanvasPageResult doesn't carry `available`, and this
+ // save path is only reachable once the page is already published (the
+ // fields are disabled otherwise), so `available` is already correct.
+ // Read fresh from the store rather than the outer `status` closure,
+ // which this useCallback doesn't depend on and could be stale.
+ const current = usePublishStatusStore.getState().statuses.get(pageId);
+ if (!current) return;
+ const nextSettings = {
+ title: data.title ?? '',
+ description: data.description ?? '',
+ ogImageUrl: data.ogImageUrl ?? '',
+ noindex: data.noindex ?? false,
+ themeBridgeEnabled: data.themeBridgeEnabled ?? current.settings.themeBridgeEnabled,
+ };
+ setStatus(pageId, { ...current, url: data.url ?? current.url, isStale: false, settings: nextSettings });
+ setForm({
+ title: nextSettings.title,
+ description: nextSettings.description,
+ ogImageUrl: nextSettings.ogImageUrl,
+ noindex: nextSettings.noindex,
+ });
+ setPickedImageId(null);
+ // The form now matches the just-saved (and just-committed-to-the-store)
+ // values, so it's pristine again — a later external status change is
+ // safe to reseed from without clobbering an edit that no longer exists.
+ setIsDirty(false);
+ toast.success('Publish settings saved');
+ } catch {
+ toast.error('Failed to save publish settings');
+ } finally {
+ setIsSaving(false);
+ }
+ }, [pageId, form, pickedImageId, setStatus]);
+
+ if (!status) {
+ return Loading…
;
+ }
+
+ // `published` and `canEdit` are independent: a view-only collaborator can
+ // see `published: true` (the GET route serves that much to any viewer —
+ // see its permission comment) but must never get an enabled Save button
+ // for fields it has no persisted values for anyway (view-only responses
+ // omit the SEO overrides).
+ const canEditFields = status.published && status.canEdit;
+
+ return (
+
+ {!status.published && (
+
+ Publish this page from the header first — these settings apply once it's live.
+
+ )}
+ {status.published && !status.canEdit && (
+
+ You don't have permission to edit this page's publish settings.
+
+ )}
+
{ setPickedImageId(id); setIsDirty(true); }}
+ driveId={driveId}
+ disabled={!canEditFields}
+ idPrefix="canvas-publish"
+ />
+
+ {isSaving ? 'Saving…' : 'Save'}
+
+
+ );
+}
diff --git a/apps/web/src/components/layout/middle-content/page-views/canvas/settings/CanvasSettingsMenu.tsx b/apps/web/src/components/layout/middle-content/page-views/canvas/settings/CanvasSettingsMenu.tsx
new file mode 100644
index 0000000000..1caf67c7a4
--- /dev/null
+++ b/apps/web/src/components/layout/middle-content/page-views/canvas/settings/CanvasSettingsMenu.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { ChevronRight, type LucideIcon } from 'lucide-react';
+
+export interface CanvasSettingsCategory {
+ key: string;
+ title: string;
+ description: string;
+ icon: LucideIcon;
+}
+
+interface CanvasSettingsMenuProps {
+ categories: CanvasSettingsCategory[];
+ onSelect: (key: string) => void;
+}
+
+/**
+ * Entry view for the canvas Settings tab — a list of category rows a user
+ * clicks into, visually modeled on the Drive/User settings row list
+ * (`@/app/settings/SettingsRow.tsx`). Not real routes (canvas pages don't
+ * render through file-based sub-routes), so rows are `onClick`-driven rather
+ * than `Link`s — the parent panel reflects the selection in the URL instead.
+ */
+export function CanvasSettingsMenu({ categories, onSelect }: CanvasSettingsMenuProps) {
+ return (
+
+ {categories.map((category, index) => (
+
onSelect(category.key)}
+ className={`
+ group flex w-full items-center gap-4 px-4 py-3 text-left transition-colors
+ hover:bg-accent hover:text-accent-foreground
+ ${index > 0 ? 'border-t' : ''}
+ `}
+ >
+
+
+
+
+
{category.title}
+
+ {category.description}
+
+
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/components/layout/middle-content/page-views/canvas/settings/CanvasSettingsPanel.tsx b/apps/web/src/components/layout/middle-content/page-views/canvas/settings/CanvasSettingsPanel.tsx
new file mode 100644
index 0000000000..2ba0cc13bc
--- /dev/null
+++ b/apps/web/src/components/layout/middle-content/page-views/canvas/settings/CanvasSettingsPanel.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { useCallback } from 'react';
+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
+import { ChevronLeft, Globe, Palette, Home, FormInput } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { CanvasSettingsMenu, type CanvasSettingsCategory } from './CanvasSettingsMenu';
+import { CanvasPublishSettingsSection } from './CanvasPublishSettingsSection';
+import { CanvasAppearanceSettingsSection } from './CanvasAppearanceSettingsSection';
+import { CanvasHomePageSettingsSection } from './CanvasHomePageSettingsSection';
+import CanvasFormsSettingsSection from './CanvasFormsSettingsSection';
+
+const CATEGORIES: CanvasSettingsCategory[] = [
+ { key: 'publish', title: 'Publish', description: 'Title, description, and share image for search and social', icon: Globe },
+ { key: 'appearance', title: 'Appearance', description: 'Whether this page follows PageSpace\'s light/dark theme', icon: Palette },
+ { key: 'home', title: 'Home & 404 page', description: 'Assign this page as the drive\'s landing or 404 page', icon: Home },
+ { key: 'forms', title: 'Forms', description: 'Wire up