projects: drive list selection from a ?selected= url param, not a global

mirror of the dashboards change: the /projects list row was a <div onClick> setting
SELECTED_PROJECT (a global) with no url change on-page. selection now lives in the url
as ?selected=<id>, read by the list, the screen (mobile panel), and the preview. rows
are real <Link>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 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 05:35:24 +00:00
co-authored by Claude Opus 4.8
parent 1b6b980af0
commit 63f45de869
3 changed files with 77 additions and 51 deletions
@@ -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<LayoutNode>('screens/projects', defaultLayout);
const isMobile = useIsMobile();
const [selected, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
// Selection lives in the URL (?selected=<id>); on mobile it (or an active create) drives the panel shown.
const [searchParams, setSearchParams] = useSearchParams();
const selected = searchParams.get('selected');
const [creating] = useGlobal<boolean>(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 },
);
}}
/>
</div>
@@ -48,13 +56,12 @@ export const NewProjectRedirect = () => {
const [, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
const [, setEditing] = useGlobal<string | null>(EDITING_PROJECT, null);
const [, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
const [, setProjectType] = useGlobal<ProjectType>(NEW_PROJ_TYPE, 'app');
const [, setHasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
const [, setHasAuth] = useGlobal<boolean>(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('');
@@ -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<ProjectType, string> = {
'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<ProjectDefinition[]>('projects', []);
const [selected, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
// Selection is URL state now (?selected=<id>), 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<boolean>(CREATING_PROJECT, false);
const [, setEditing] = useGlobal<string | null>(EDITING_PROJECT, null);
const [, setName] = useGlobal<string>(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 (
<div className="flex flex-col h-full overflow-y-auto">
<div className="p-3 pb-0 flex flex-col gap-2">
@@ -130,7 +129,7 @@ export const ProjectListApp = () => {
<button
type="button"
onClick={() => {
setSelected(null);
clearSelected();
setEditing(null);
setName(generateSlug());
setDescription('');
@@ -161,18 +160,22 @@ export const ProjectListApp = () => {
{filtered.map((p) => (
<div
key={p.id}
className={`flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium cursor-pointer group ${
className={`flex items-center rounded-lg text-sm font-medium group ${
isProjectsPage && selected === p.id
? 'bg-emerald-500/10 text-emerald-400'
: 'text-duck-dark/80 dark:text-white/80 hover:bg-duck-dark/5'
}`}
onClick={() => handleClick(p)}
>
<Link
to={isProjectsPage ? `/projects?selected=${p.id}` : `/projects/${p.id}`}
className="flex flex-1 items-center gap-2.5 py-2 px-3 min-w-0 cursor-pointer"
>
<FolderKanban className="h-4 w-4 shrink-0" />
<span className="flex-1 text-left truncate">{p.name}</span>
<span className="text-[10px] text-duck-dark/30 shrink-0">{PROJECT_TYPE_LABELS[p.projectType]}</span>
</Link>
{isProjectsPage && (
<>
<div className="flex shrink-0 items-center pr-2">
<button
type="button"
onClick={(ev) => handlePublish(ev, p)}
@@ -195,18 +198,21 @@ export const ProjectListApp = () => {
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
</div>
)}
</div>
))}
{filtered.length === 0 && (
<p className="text-xs text-gray-500 px-3 py-4 text-center">
{search ? 'No matches' : 'No projects yet'}
</p>
<p className="text-xs text-gray-500 px-3 py-4 text-center">{search ? 'No matches' : 'No projects yet'}</p>
)}
</div>
<AlertDialog open={deleting !== null} onOpenChange={(open) => { if (!open) setDeleting(null); }}>
<AlertDialog
open={deleting !== null}
onOpenChange={(open) => {
if (!open) setDeleting(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete project</AlertDialogTitle>
@@ -226,7 +232,9 @@ export const ProjectListApp = () => {
{publishing && (
<PublishDialog
open={true}
onOpenChange={(open) => { if (!open) setPublishing(null); }}
onOpenChange={(open) => {
if (!open) setPublishing(null);
}}
projectSlug={publishing.id}
projectName={publishing.name}
/>
@@ -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 }) => {
<div className="w-full flex-1 min-h-0 rounded border border-duck-dark/20 overflow-hidden">
<ThumbnailNode node={node} />
</div>
<span className={`text-xs font-medium ${isSelected ? 'text-emerald-400' : 'text-duck-dark/60'}`}>{tpl.name}</span>
<span className={`text-xs font-medium ${isSelected ? 'text-emerald-400' : 'text-duck-dark/60'}`}>
{tpl.name}
</span>
</button>
</div>
);
@@ -253,7 +254,10 @@ const tplPanelLayout: LayoutNode = {
};
const tplRegistry = Object.fromEntries(
templates.map((tpl, i) => [`ptpl-${i}`, { name: tpl.name, icon: Layout, component: () => <TemplateCell index={i} /> }]),
templates.map((tpl, i) => [
`ptpl-${i}`,
{ name: tpl.name, icon: Layout, component: () => <TemplateCell index={i} /> },
]),
);
const TemplatePanel = () => (
@@ -383,7 +387,7 @@ const CreatePanel = () => {
const [hasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
const [editingId, setEditingId] = useGlobal<string | null>(EDITING_PROJECT, null);
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
const [, setSelected] = useGlobal<string | null>(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'
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'}
</Button>
{isEditing && (
<Button
onClick={() => navigate(`/projects/${editingId}`)}
variant="outline"
className="cursor-pointer"
>
<Button onClick={() => navigate(`/projects/${editingId}`)} variant="outline" className="cursor-pointer">
<ArrowRight className="h-4 w-4 mr-1" />
Open Project
</Button>
@@ -581,7 +591,8 @@ const ProjectPreviewInner = ({ project }: { project: ProjectDefinition }) => {
};
export const ProjectPreview = () => {
const [selectedId] = useGlobal<string | null>(SELECTED_PROJECT, null);
const [searchParams] = useSearchParams();
const selectedId = searchParams.get('selected');
const { value: projects } = useDashboardState<ProjectDefinition[]>('projects', []);
const project = selectedId ? projects.find((p) => p.id === selectedId) : null;