From 63f45de869aeedc3e494e80d0a4563a9aa27e69b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 30 Jul 2026 05:35:24 +0000 Subject: [PATCH] projects: drive list selection from a ?selected= url param, not a global MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mirror of the dashboards change: the /projects list row was a
setting SELECTED_PROJECT (a global) with no url change on-page. selection now lives in the url as ?selected=, read by the list, the screen (mobile panel), and the preview. rows are real s (publish/edit/delete kept as sibling buttons); edit-save and delete update/clear the param; NewProjectRedirect drops its now-redundant setSelected. NOT runtime-tested yet (server not restarted) — verify /projects preview, create/edit/delete/publish, and mobile panel flows on next restart. Co-Authored-By: Claude Opus 4.8 --- .../Dashboard/Projects/ProjectListScreen.tsx | 17 +++-- .../src/apps/Projects/ProjectListApp.tsx | 68 +++++++++++-------- .../src/apps/Projects/ProjectPreview.tsx | 43 +++++++----- 3 files changed, 77 insertions(+), 51 deletions(-) diff --git a/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx index 427b8f27..846c2238 100644 --- a/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx @@ -6,7 +6,6 @@ import { useDashboardState } from 'state/useDashboardState'; import type { LayoutNode, ProjectType } from 'officerdev'; import { WorkspaceView, - SELECTED_PROJECT, CREATING_PROJECT, EDITING_PROJECT, NEW_PROJ_NAME, @@ -22,7 +21,9 @@ import { defaultLayout } from './defaultLayout'; export const ProjectListScreen = () => { const workspace = useDashboardState('screens/projects', defaultLayout); const isMobile = useIsMobile(); - const [selected, setSelected] = useGlobal(SELECTED_PROJECT, null); + // Selection lives in the URL (?selected=); on mobile it (or an active create) drives the panel shown. + const [searchParams, setSearchParams] = useSearchParams(); + const selected = searchParams.get('selected'); const [creating] = useGlobal(CREATING_PROJECT, false); const mobilePanelId = isMobile && (selected || creating) ? 'proj-home-right' : undefined; @@ -33,7 +34,14 @@ export const ProjectListScreen = () => { locked mobilePanelId={mobilePanelId} onMobilePanelChange={(id) => { - if (!id) setSelected(null); + if (!id) + setSearchParams( + (p) => { + p.delete('selected'); + return p; + }, + { replace: true }, + ); }} />
@@ -48,13 +56,12 @@ export const NewProjectRedirect = () => { const [, setTemplateIdx] = useGlobal(NEW_PROJ_TEMPLATE, 0); const [, setCreating] = useGlobal(CREATING_PROJECT, false); const [, setEditing] = useGlobal(EDITING_PROJECT, null); - const [, setSelected] = useGlobal(SELECTED_PROJECT, null); const [, setProjectType] = useGlobal(NEW_PROJ_TYPE, 'app'); const [, setHasBackend] = useGlobal(NEW_PROJ_HAS_BACKEND, false); const [, setHasAuth] = useGlobal(NEW_PROJ_HAS_AUTH, false); useEffect(() => { - setSelected(null); + // Redirect lands on a clean /projects (no ?selected), so selection is naturally cleared. setEditing(null); setName(params.get('name') || generateSlug()); setDescription(''); diff --git a/src/workspaces/officerdev/src/apps/Projects/ProjectListApp.tsx b/src/workspaces/officerdev/src/apps/Projects/ProjectListApp.tsx index 322d1e45..6b40ef92 100644 --- a/src/workspaces/officerdev/src/apps/Projects/ProjectListApp.tsx +++ b/src/workspaces/officerdev/src/apps/Projects/ProjectListApp.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Link, useLocation, useNavigate } from 'react-router'; +import { Link, useLocation, useNavigate, useSearchParams } from 'react-router'; import { FolderKanban, Plus, Pencil, Trash2, Search, Rocket } from 'lucide-react'; import { useQueryClient } from '@tanstack/react-query'; import { useGlobal } from 'hooks/useGlobal'; @@ -19,7 +19,6 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { - SELECTED_PROJECT, CREATING_PROJECT, EDITING_PROJECT, NEW_PROJ_NAME, @@ -32,8 +31,8 @@ import { const PROJECT_TYPE_LABELS: Record = { 'landing-page': 'Landing Page', - 'website': 'Website', - 'app': 'App', + website: 'Website', + app: 'App', }; export const ProjectListApp = () => { @@ -42,7 +41,17 @@ export const ProjectListApp = () => { const client = useClient(); const queryClient = useQueryClient(); const { value: projects } = useDashboardState('projects', []); - const [selected, setSelected] = useGlobal(SELECTED_PROJECT, null); + // Selection is URL state now (?selected=), not a global — so it's deep-linkable and the rows are real links. + const [searchParams, setSearchParams] = useSearchParams(); + const selected = searchParams.get('selected'); + const clearSelected = () => + setSearchParams( + (p) => { + p.delete('selected'); + return p; + }, + { replace: true }, + ); const [, setCreating] = useGlobal(CREATING_PROJECT, false); const [, setEditing] = useGlobal(EDITING_PROJECT, null); const [, setName] = useGlobal(NEW_PROJ_NAME, ''); @@ -67,7 +76,7 @@ export const ProjectListApp = () => { const handleEdit = (ev: React.MouseEvent, p: ProjectDefinition) => { ev.stopPropagation(); - setSelected(null); + clearSelected(); setCreating(false); setEditing(p.id); setName(p.name); @@ -99,7 +108,7 @@ export const ProjectListApp = () => { delete optimistic[`proj-host-terminals-${deleting.id}`]; queryClient.setQueryData(['DASHBOARD_STATE'], optimistic); - if (selected === deleting.id) setSelected(null); + if (selected === deleting.id) clearSelected(); setDeleting(null); client .patch('/dashboards', { [`proj-meta-${deleting.id}`]: null }) @@ -107,16 +116,6 @@ export const ProjectListApp = () => { .catch(() => {}); }; - const handleClick = (p: ProjectDefinition) => { - if (isProjectsPage) { - setSelected(p.id); - setCreating(false); - setEditing(null); - } else { - navigate(`/projects/${p.id}`); - } - }; - return (
@@ -130,7 +129,7 @@ export const ProjectListApp = () => { - +
)}
))} {filtered.length === 0 && ( -

- {search ? 'No matches' : 'No projects yet'} -

+

{search ? 'No matches' : 'No projects yet'}

)} - { if (!open) setDeleting(null); }}> + { + if (!open) setDeleting(null); + }} + > Delete project @@ -226,7 +232,9 @@ export const ProjectListApp = () => { {publishing && ( { if (!open) setPublishing(null); }} + onOpenChange={(open) => { + if (!open) setPublishing(null); + }} projectSlug={publishing.id} projectName={publishing.name} /> diff --git a/src/workspaces/officerdev/src/apps/Projects/ProjectPreview.tsx b/src/workspaces/officerdev/src/apps/Projects/ProjectPreview.tsx index 2e790547..ae829229 100644 --- a/src/workspaces/officerdev/src/apps/Projects/ProjectPreview.tsx +++ b/src/workspaces/officerdev/src/apps/Projects/ProjectPreview.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Link, useNavigate } from 'react-router'; +import { Link, useNavigate, useSearchParams } from 'react-router'; import { FolderKanban, Plus, ArrowRight, Type, Rocket, FileText, Layout, Loader2 } from 'lucide-react'; import { useQueryClient } from '@tanstack/react-query'; import { useGlobal } from 'hooks/useGlobal'; @@ -11,7 +11,6 @@ import { Button } from '@/components/ui/button'; import { generateSlug, slugify } from 'helpers/slug'; import { useAppRegistry } from '../../AppRegistry'; import { - SELECTED_PROJECT, CREATING_PROJECT, EDITING_PROJECT, NEW_PROJ_NAME, @@ -210,7 +209,9 @@ const TemplateCell = ({ index }: { index: number }) => {
- {tpl.name} + + {tpl.name} + ); @@ -253,7 +254,10 @@ const tplPanelLayout: LayoutNode = { }; const tplRegistry = Object.fromEntries( - templates.map((tpl, i) => [`ptpl-${i}`, { name: tpl.name, icon: Layout, component: () => }]), + templates.map((tpl, i) => [ + `ptpl-${i}`, + { name: tpl.name, icon: Layout, component: () => }, + ]), ); const TemplatePanel = () => ( @@ -383,7 +387,7 @@ const CreatePanel = () => { const [hasAuth] = useGlobal(NEW_PROJ_HAS_AUTH, false); const [editingId, setEditingId] = useGlobal(EDITING_PROJECT, null); const [, setCreating] = useGlobal(CREATING_PROJECT, false); - const [, setSelected] = useGlobal(SELECTED_PROJECT, null); + const [, setSearchParams] = useSearchParams(); const [isSubmitting, setIsSubmitting] = useState(false); const isEditing = !!editingId; @@ -410,19 +414,29 @@ const CreatePanel = () => { .catch(() => {}); setEditingId(null); - setSelected(editingId); + setSearchParams( + (p) => { + p.set('selected', editingId); + return p; + }, + { replace: true }, + ); } else { const existingIds = new Set(projects.map((p) => p.id)); let id = slugify(trimmed) || generateSlug(); while (existingIds.has(id)) id = `${id}-${generateSlug(1)}`; setIsSubmitting(true); - const finalLayout = projectType === 'app' - ? assignDefaultApps(previewLayout, ['officerdev/chat', 'officerdev/preview']) - : previewLayout; + const finalLayout = + projectType === 'app' + ? assignDefaultApps(previewLayout, ['officerdev/chat', 'officerdev/preview']) + : previewLayout; try { - const res = await client.patch('/dashboards', { [`proj-meta-${id}`]: meta, [`proj-layout-${id}`]: finalLayout }); + const res = await client.patch('/dashboards', { + [`proj-meta-${id}`]: meta, + [`proj-layout-${id}`]: finalLayout, + }); queryClient.setQueryData(['DASHBOARD_STATE'], res); setName(''); setDescription(''); @@ -453,11 +467,7 @@ const CreatePanel = () => { {isSubmitting ? 'Creating...' : isEditing ? 'Update Project' : 'Create Project'} {isEditing && ( - @@ -581,7 +591,8 @@ const ProjectPreviewInner = ({ project }: { project: ProjectDefinition }) => { }; export const ProjectPreview = () => { - const [selectedId] = useGlobal(SELECTED_PROJECT, null); + const [searchParams] = useSearchParams(); + const selectedId = searchParams.get('selected'); const { value: projects } = useDashboardState('projects', []); const project = selectedId ? projects.find((p) => p.id === selectedId) : null;