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 type { LayoutNode, ProjectType } from 'officerdev';
|
||||||
import {
|
import {
|
||||||
WorkspaceView,
|
WorkspaceView,
|
||||||
SELECTED_PROJECT,
|
|
||||||
CREATING_PROJECT,
|
CREATING_PROJECT,
|
||||||
EDITING_PROJECT,
|
EDITING_PROJECT,
|
||||||
NEW_PROJ_NAME,
|
NEW_PROJ_NAME,
|
||||||
@@ -22,7 +21,9 @@ import { defaultLayout } from './defaultLayout';
|
|||||||
export const ProjectListScreen = () => {
|
export const ProjectListScreen = () => {
|
||||||
const workspace = useDashboardState<LayoutNode>('screens/projects', defaultLayout);
|
const workspace = useDashboardState<LayoutNode>('screens/projects', defaultLayout);
|
||||||
const isMobile = useIsMobile();
|
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 [creating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||||
const mobilePanelId = isMobile && (selected || creating) ? 'proj-home-right' : undefined;
|
const mobilePanelId = isMobile && (selected || creating) ? 'proj-home-right' : undefined;
|
||||||
|
|
||||||
@@ -33,7 +34,14 @@ export const ProjectListScreen = () => {
|
|||||||
locked
|
locked
|
||||||
mobilePanelId={mobilePanelId}
|
mobilePanelId={mobilePanelId}
|
||||||
onMobilePanelChange={(id) => {
|
onMobilePanelChange={(id) => {
|
||||||
if (!id) setSelected(null);
|
if (!id)
|
||||||
|
setSearchParams(
|
||||||
|
(p) => {
|
||||||
|
p.delete('selected');
|
||||||
|
return p;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,13 +56,12 @@ export const NewProjectRedirect = () => {
|
|||||||
const [, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
const [, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||||
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||||
const [, setEditing] = useGlobal<string | null>(EDITING_PROJECT, null);
|
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 [, setProjectType] = useGlobal<ProjectType>(NEW_PROJ_TYPE, 'app');
|
||||||
const [, setHasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
|
const [, setHasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
|
||||||
const [, setHasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
|
const [, setHasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelected(null);
|
// Redirect lands on a clean /projects (no ?selected), so selection is naturally cleared.
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
setName(params.get('name') || generateSlug());
|
setName(params.get('name') || generateSlug());
|
||||||
setDescription('');
|
setDescription('');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
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 { FolderKanban, Plus, Pencil, Trash2, Search, Rocket } from 'lucide-react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useGlobal } from 'hooks/useGlobal';
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import {
|
import {
|
||||||
SELECTED_PROJECT,
|
|
||||||
CREATING_PROJECT,
|
CREATING_PROJECT,
|
||||||
EDITING_PROJECT,
|
EDITING_PROJECT,
|
||||||
NEW_PROJ_NAME,
|
NEW_PROJ_NAME,
|
||||||
@@ -32,8 +31,8 @@ import {
|
|||||||
|
|
||||||
const PROJECT_TYPE_LABELS: Record<ProjectType, string> = {
|
const PROJECT_TYPE_LABELS: Record<ProjectType, string> = {
|
||||||
'landing-page': 'Landing Page',
|
'landing-page': 'Landing Page',
|
||||||
'website': 'Website',
|
website: 'Website',
|
||||||
'app': 'App',
|
app: 'App',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProjectListApp = () => {
|
export const ProjectListApp = () => {
|
||||||
@@ -42,7 +41,17 @@ export const ProjectListApp = () => {
|
|||||||
const client = useClient();
|
const client = useClient();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { value: projects } = useDashboardState<ProjectDefinition[]>('projects', []);
|
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 [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||||
const [, setEditing] = useGlobal<string | null>(EDITING_PROJECT, null);
|
const [, setEditing] = useGlobal<string | null>(EDITING_PROJECT, null);
|
||||||
const [, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
|
const [, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
|
||||||
@@ -67,7 +76,7 @@ export const ProjectListApp = () => {
|
|||||||
|
|
||||||
const handleEdit = (ev: React.MouseEvent, p: ProjectDefinition) => {
|
const handleEdit = (ev: React.MouseEvent, p: ProjectDefinition) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
setSelected(null);
|
clearSelected();
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
setEditing(p.id);
|
setEditing(p.id);
|
||||||
setName(p.name);
|
setName(p.name);
|
||||||
@@ -99,7 +108,7 @@ export const ProjectListApp = () => {
|
|||||||
delete optimistic[`proj-host-terminals-${deleting.id}`];
|
delete optimistic[`proj-host-terminals-${deleting.id}`];
|
||||||
queryClient.setQueryData(['DASHBOARD_STATE'], optimistic);
|
queryClient.setQueryData(['DASHBOARD_STATE'], optimistic);
|
||||||
|
|
||||||
if (selected === deleting.id) setSelected(null);
|
if (selected === deleting.id) clearSelected();
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
client
|
client
|
||||||
.patch('/dashboards', { [`proj-meta-${deleting.id}`]: null })
|
.patch('/dashboards', { [`proj-meta-${deleting.id}`]: null })
|
||||||
@@ -107,16 +116,6 @@ export const ProjectListApp = () => {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = (p: ProjectDefinition) => {
|
|
||||||
if (isProjectsPage) {
|
|
||||||
setSelected(p.id);
|
|
||||||
setCreating(false);
|
|
||||||
setEditing(null);
|
|
||||||
} else {
|
|
||||||
navigate(`/projects/${p.id}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-y-auto">
|
<div className="flex flex-col h-full overflow-y-auto">
|
||||||
<div className="p-3 pb-0 flex flex-col gap-2">
|
<div className="p-3 pb-0 flex flex-col gap-2">
|
||||||
@@ -130,7 +129,7 @@ export const ProjectListApp = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelected(null);
|
clearSelected();
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
setName(generateSlug());
|
setName(generateSlug());
|
||||||
setDescription('');
|
setDescription('');
|
||||||
@@ -161,18 +160,22 @@ export const ProjectListApp = () => {
|
|||||||
{filtered.map((p) => (
|
{filtered.map((p) => (
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
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
|
isProjectsPage && selected === p.id
|
||||||
? 'bg-emerald-500/10 text-emerald-400'
|
? 'bg-emerald-500/10 text-emerald-400'
|
||||||
: 'text-duck-dark/80 dark:text-white/80 hover:bg-duck-dark/5'
|
: 'text-duck-dark/80 dark:text-white/80 hover:bg-duck-dark/5'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleClick(p)}
|
|
||||||
>
|
>
|
||||||
<FolderKanban className="h-4 w-4 shrink-0" />
|
<Link
|
||||||
<span className="flex-1 text-left truncate">{p.name}</span>
|
to={isProjectsPage ? `/projects?selected=${p.id}` : `/projects/${p.id}`}
|
||||||
<span className="text-[10px] text-duck-dark/30 shrink-0">{PROJECT_TYPE_LABELS[p.projectType]}</span>
|
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 && (
|
{isProjectsPage && (
|
||||||
<>
|
<div className="flex shrink-0 items-center pr-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(ev) => handlePublish(ev, p)}
|
onClick={(ev) => handlePublish(ev, p)}
|
||||||
@@ -195,18 +198,21 @@ export const ProjectListApp = () => {
|
|||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<p className="text-xs text-gray-500 px-3 py-4 text-center">
|
<p className="text-xs text-gray-500 px-3 py-4 text-center">{search ? 'No matches' : 'No projects yet'}</p>
|
||||||
{search ? 'No matches' : 'No projects yet'}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AlertDialog open={deleting !== null} onOpenChange={(open) => { if (!open) setDeleting(null); }}>
|
<AlertDialog
|
||||||
|
open={deleting !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDeleting(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete project</AlertDialogTitle>
|
<AlertDialogTitle>Delete project</AlertDialogTitle>
|
||||||
@@ -226,7 +232,9 @@ export const ProjectListApp = () => {
|
|||||||
{publishing && (
|
{publishing && (
|
||||||
<PublishDialog
|
<PublishDialog
|
||||||
open={true}
|
open={true}
|
||||||
onOpenChange={(open) => { if (!open) setPublishing(null); }}
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setPublishing(null);
|
||||||
|
}}
|
||||||
projectSlug={publishing.id}
|
projectSlug={publishing.id}
|
||||||
projectName={publishing.name}
|
projectName={publishing.name}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
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 { FolderKanban, Plus, ArrowRight, Type, Rocket, FileText, Layout, Loader2 } from 'lucide-react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useGlobal } from 'hooks/useGlobal';
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
@@ -11,7 +11,6 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { generateSlug, slugify } from 'helpers/slug';
|
import { generateSlug, slugify } from 'helpers/slug';
|
||||||
import { useAppRegistry } from '../../AppRegistry';
|
import { useAppRegistry } from '../../AppRegistry';
|
||||||
import {
|
import {
|
||||||
SELECTED_PROJECT,
|
|
||||||
CREATING_PROJECT,
|
CREATING_PROJECT,
|
||||||
EDITING_PROJECT,
|
EDITING_PROJECT,
|
||||||
NEW_PROJ_NAME,
|
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">
|
<div className="w-full flex-1 min-h-0 rounded border border-duck-dark/20 overflow-hidden">
|
||||||
<ThumbnailNode node={node} />
|
<ThumbnailNode node={node} />
|
||||||
</div>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -253,7 +254,10 @@ const tplPanelLayout: LayoutNode = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tplRegistry = Object.fromEntries(
|
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 = () => (
|
const TemplatePanel = () => (
|
||||||
@@ -383,7 +387,7 @@ const CreatePanel = () => {
|
|||||||
const [hasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
|
const [hasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
|
||||||
const [editingId, setEditingId] = useGlobal<string | null>(EDITING_PROJECT, null);
|
const [editingId, setEditingId] = useGlobal<string | null>(EDITING_PROJECT, null);
|
||||||
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||||
const [, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
const [, setSearchParams] = useSearchParams();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const isEditing = !!editingId;
|
const isEditing = !!editingId;
|
||||||
@@ -410,19 +414,29 @@ const CreatePanel = () => {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setSelected(editingId);
|
setSearchParams(
|
||||||
|
(p) => {
|
||||||
|
p.set('selected', editingId);
|
||||||
|
return p;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const existingIds = new Set(projects.map((p) => p.id));
|
const existingIds = new Set(projects.map((p) => p.id));
|
||||||
let id = slugify(trimmed) || generateSlug();
|
let id = slugify(trimmed) || generateSlug();
|
||||||
while (existingIds.has(id)) id = `${id}-${generateSlug(1)}`;
|
while (existingIds.has(id)) id = `${id}-${generateSlug(1)}`;
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
const finalLayout = projectType === 'app'
|
const finalLayout =
|
||||||
? assignDefaultApps(previewLayout, ['officerdev/chat', 'officerdev/preview'])
|
projectType === 'app'
|
||||||
: previewLayout;
|
? assignDefaultApps(previewLayout, ['officerdev/chat', 'officerdev/preview'])
|
||||||
|
: previewLayout;
|
||||||
|
|
||||||
try {
|
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);
|
queryClient.setQueryData(['DASHBOARD_STATE'], res);
|
||||||
setName('');
|
setName('');
|
||||||
setDescription('');
|
setDescription('');
|
||||||
@@ -453,11 +467,7 @@ const CreatePanel = () => {
|
|||||||
{isSubmitting ? 'Creating...' : isEditing ? 'Update Project' : 'Create Project'}
|
{isSubmitting ? 'Creating...' : isEditing ? 'Update Project' : 'Create Project'}
|
||||||
</Button>
|
</Button>
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<Button
|
<Button onClick={() => navigate(`/projects/${editingId}`)} variant="outline" className="cursor-pointer">
|
||||||
onClick={() => navigate(`/projects/${editingId}`)}
|
|
||||||
variant="outline"
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
<ArrowRight className="h-4 w-4 mr-1" />
|
<ArrowRight className="h-4 w-4 mr-1" />
|
||||||
Open Project
|
Open Project
|
||||||
</Button>
|
</Button>
|
||||||
@@ -581,7 +591,8 @@ const ProjectPreviewInner = ({ project }: { project: ProjectDefinition }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ProjectPreview = () => {
|
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 { value: projects } = useDashboardState<ProjectDefinition[]>('projects', []);
|
||||||
const project = selectedId ? projects.find((p) => p.id === selectedId) : null;
|
const project = selectedId ? projects.find((p) => p.id === selectedId) : null;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user