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:
@@ -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)}
|
||||
>
|
||||
<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
|
||||
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'
|
||||
? 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'}
|
||||
</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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user