remove the projects feature
Being retired, so it is deleted rather than fixed — it had both defects dashboards just had (edit dropping ?selected=, an abandoned edit following you onto the next item) and repairing them was work for something on its way out. Gone: the two screens and the panel apps, the three routes, the dock item and its entry in the default dock paths (client and the three server-side copies), the page-title rule, the app-registry entries, the barrel exports, the `projects` table with its queries and row types, and the proj-meta/proj-layout/proj-terminals/proj-host-terminals branches in the dashboards endpoint. The chat context and terminal state-key special cases for `proj-layout-` went with them. Deliberately kept: `getUserProjectsDir` in data-path.ts — the apps and dev-server routers resolve user apps under the same on-disk Projects/ directory and are unrelated to this feature. Nothing on disk is touched. Needs `bun db:push` to drop the table; the schema change is the only thing standing between the code and the database. One row was in it. tsgo clean, 56/56 tests, no references left in src. Not yet exercised at runtime — the restart is what will prove it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,6 @@ import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor';
|
||||
import { appRegistryMetas as chatMetas } from '../apps/Chat';
|
||||
import { appRegistryMetas as fileViewerMetas } from '../apps/FileViewer';
|
||||
import { appRegistryMetas as dashboardMetas } from '../apps/Dashboards';
|
||||
import { appRegistryMetas as projectMetas } from '../apps/Projects';
|
||||
import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
|
||||
import { appRegistryMetas as previewMetas } from '../apps/Preview';
|
||||
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
||||
@@ -19,7 +18,7 @@ import { createUserAppPanel } from '../apps/UserApp/UserAppPanel';
|
||||
import { createUserAppHeader } from '../apps/UserApp/UserAppHeader';
|
||||
import { resolveIcon } from '../utils/resolve-icon';
|
||||
|
||||
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...dashboardMetas, ...projectMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas, ...desktopMetas, ...musicMetas, ...soulseekMetas, ...monitorMetas];
|
||||
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...dashboardMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas, ...desktopMetas, ...musicMetas, ...soulseekMetas, ...monitorMetas];
|
||||
|
||||
export const AppRegistry = () => {
|
||||
const { registerApp } = useAppRegistry(apps);
|
||||
|
||||
@@ -51,11 +51,9 @@ export const ChatPanelWrapper = () => {
|
||||
const chatContext =
|
||||
dashboardId === 'email' || dashboardId === 'screens/email'
|
||||
? { context: 'email' as const }
|
||||
: dashboardId?.startsWith('proj-layout-')
|
||||
? { context: 'project' as const, contextId: dashboardId.replace('proj-layout-', '') }
|
||||
: dashboardId && !dashboardId.startsWith('screens/')
|
||||
? { context: 'dashboard' as const, contextId: dashboardId }
|
||||
: {};
|
||||
: dashboardId && !dashboardId.startsWith('screens/')
|
||||
? { context: 'dashboard' as const, contextId: dashboardId }
|
||||
: {};
|
||||
|
||||
const [, setActiveSession] = usePanelChannel<string | null>('chat:active-session', null);
|
||||
const [, setPreviewRefresh] = usePanelChannel<number>('preview:refresh', 0);
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
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';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { generateSlug } from 'helpers/slug';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import type { ProjectDefinition, ProjectType } from '../../components/Workspace';
|
||||
import { PublishDialog } from './PublishDialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
CREATING_PROJECT,
|
||||
EDITING_PROJECT,
|
||||
NEW_PROJ_NAME,
|
||||
NEW_PROJ_DESC,
|
||||
NEW_PROJ_TEMPLATE,
|
||||
NEW_PROJ_TYPE,
|
||||
NEW_PROJ_HAS_BACKEND,
|
||||
NEW_PROJ_HAS_AUTH,
|
||||
} from './constants';
|
||||
|
||||
const PROJECT_TYPE_LABELS: Record<ProjectType, string> = {
|
||||
'landing-page': 'Landing Page',
|
||||
website: 'Website',
|
||||
app: 'App',
|
||||
};
|
||||
|
||||
export const ProjectListApp = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const { value: projects } = useDashboardState<ProjectDefinition[]>('projects', []);
|
||||
// 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, '');
|
||||
const [, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
|
||||
const [, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||
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);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [deleting, setDeleting] = useState<ProjectDefinition | null>(null);
|
||||
const [publishing, setPublishing] = useState<ProjectDefinition | null>(null);
|
||||
const isProjectsPage = location.pathname === '/projects';
|
||||
const filtered = search
|
||||
? projects.filter((p) => {
|
||||
const q = search.toLowerCase();
|
||||
return [p.name, p.id, p.description ?? '', p.cwd ?? '', p.projectType].some((field) =>
|
||||
field.toLowerCase().includes(q),
|
||||
);
|
||||
})
|
||||
: projects;
|
||||
|
||||
const handleEdit = (ev: React.MouseEvent, p: ProjectDefinition) => {
|
||||
ev.stopPropagation();
|
||||
clearSelected();
|
||||
setCreating(false);
|
||||
setEditing(p.id);
|
||||
setName(p.name);
|
||||
setDescription(p.description ?? '');
|
||||
setTemplateIdx(p.templateIdx ?? 0);
|
||||
setProjectType(p.projectType);
|
||||
setHasBackend(p.hasBackend ?? false);
|
||||
setHasAuth(p.hasAuth ?? false);
|
||||
};
|
||||
|
||||
const handleDelete = (ev: React.MouseEvent, p: ProjectDefinition) => {
|
||||
ev.stopPropagation();
|
||||
setDeleting(p);
|
||||
};
|
||||
|
||||
const handlePublish = (ev: React.MouseEvent, p: ProjectDefinition) => {
|
||||
ev.stopPropagation();
|
||||
setPublishing(p);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleting) return;
|
||||
// Optimistic: remove from cache immediately
|
||||
const current = queryClient.getQueryData<Record<string, unknown>>(['DASHBOARD_STATE']) ?? {};
|
||||
const optimistic = { ...current };
|
||||
optimistic['projects'] = (optimistic['projects'] as ProjectDefinition[]).filter((p) => p.id !== deleting.id);
|
||||
delete optimistic[`proj-layout-${deleting.id}`];
|
||||
delete optimistic[`proj-terminals-${deleting.id}`];
|
||||
delete optimistic[`proj-host-terminals-${deleting.id}`];
|
||||
queryClient.setQueryData(['DASHBOARD_STATE'], optimistic);
|
||||
|
||||
if (selected === deleting.id) clearSelected();
|
||||
setDeleting(null);
|
||||
client
|
||||
.patch('/dashboards', { [`proj-meta-${deleting.id}`]: null })
|
||||
.then((res) => queryClient.setQueryData(['DASHBOARD_STATE'], res))
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-y-auto">
|
||||
<div className="p-3 pb-0 flex flex-col gap-2">
|
||||
<Link
|
||||
to="/projects"
|
||||
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-emerald-500/15 text-emerald-400"
|
||||
>
|
||||
<FolderKanban className="h-4 w-4" />
|
||||
Projects
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearSelected();
|
||||
setEditing(null);
|
||||
setName(generateSlug());
|
||||
setDescription('');
|
||||
setTemplateIdx(0);
|
||||
setProjectType('app');
|
||||
setHasBackend(false);
|
||||
setHasAuth(false);
|
||||
setCreating(true);
|
||||
if (!isProjectsPage) navigate('/projects');
|
||||
}}
|
||||
className="flex items-center justify-center gap-1 py-2 px-3 rounded-lg text-sm font-medium bg-emerald-500 hover:bg-emerald-500/90 text-white transition-all cursor-pointer"
|
||||
>
|
||||
<Plus className="h-4 w-4 shrink-0" />
|
||||
New Project
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative px-3 pt-2">
|
||||
<Search className="absolute left-6 top-1/2 h-3.5 w-3.5 text-gray-500 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
placeholder="Search projects"
|
||||
className="w-full rounded-lg border border-duck-dark/15 bg-transparent py-1.5 pl-8 pr-3 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-emerald-500/30 focus:border-emerald-500/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 px-3 pt-2">
|
||||
{filtered.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<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)}
|
||||
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-blue-400 transition-opacity cursor-pointer"
|
||||
title="Publish"
|
||||
>
|
||||
<Rocket className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => handleEdit(ev, p)}
|
||||
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-emerald-400 transition-opacity cursor-pointer"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => handleDelete(ev, p)}
|
||||
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-red-500 transition-opacity cursor-pointer"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={deleting !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleting(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete project</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete <strong>{deleting?.name}</strong>? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{publishing && (
|
||||
<PublishDialog
|
||||
open={true}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPublishing(null);
|
||||
}}
|
||||
projectSlug={publishing.id}
|
||||
projectName={publishing.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,612 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
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';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { WorkspaceLayout, WorkspaceView, createDefaultLayout } from '../../components/Workspace';
|
||||
import type { LayoutNode, ProjectDefinition, ProjectType } from '../../components/Workspace';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { generateSlug, slugify } from 'helpers/slug';
|
||||
import { useAppRegistry } from '../../AppRegistry';
|
||||
import {
|
||||
CREATING_PROJECT,
|
||||
EDITING_PROJECT,
|
||||
NEW_PROJ_NAME,
|
||||
NEW_PROJ_DESC,
|
||||
NEW_PROJ_TEMPLATE,
|
||||
NEW_PROJ_TYPE,
|
||||
NEW_PROJ_HAS_BACKEND,
|
||||
NEW_PROJ_HAS_AUTH,
|
||||
NEW_PROJ_PREVIEW_LAYOUT,
|
||||
} from './constants';
|
||||
|
||||
// --- Layout Templates ---
|
||||
|
||||
let tplCounter = 0;
|
||||
const tplUid = () => `ptpl-${++tplCounter}`;
|
||||
|
||||
type LayoutTemplate = {
|
||||
name: string;
|
||||
layout: () => LayoutNode;
|
||||
};
|
||||
|
||||
const templates: LayoutTemplate[] = [
|
||||
{
|
||||
name: '2 Columns',
|
||||
layout: () => ({
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Main + Side',
|
||||
layout: () => ({
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: 50,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Sidebar',
|
||||
layout: () => ({
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 25 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 75 },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: '2x2 Grid',
|
||||
layout: () => ({
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: 50,
|
||||
},
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: 50,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Cols + Bottom',
|
||||
layout: () => ({
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: 70,
|
||||
},
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 30 },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Cols + Split Bottom',
|
||||
layout: () => ({
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: 70,
|
||||
},
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: 30,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
// --- Template Thumbnails ---
|
||||
|
||||
const ThumbnailNode = ({ node }: { node: LayoutNode }) => {
|
||||
if (node.type === 'panel') {
|
||||
return <div className="h-full w-full bg-emerald-500/15 border border-emerald-500/20 rounded-sm" />;
|
||||
}
|
||||
const isH = node.direction === 'horizontal';
|
||||
return (
|
||||
<div className={`flex h-full w-full gap-1.5 ${isH ? 'flex-row' : 'flex-col'}`}>
|
||||
{node.children.map((child) => (
|
||||
<div key={child.node.id} style={{ flex: child.size }} className="min-h-0 min-w-0">
|
||||
<ThumbnailNode node={child.node} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TemplateCell = ({ index }: { index: number }) => {
|
||||
const tpl = templates[index]!;
|
||||
const [selected, setSelected] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||
const node = tpl.layout();
|
||||
const isSelected = selected === index;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(index)}
|
||||
className={`flex h-full w-full flex-col items-center justify-center gap-2 p-3 cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-emerald-500/10' : 'hover:bg-duck-dark/5'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Panel: Template Picker (3x2 workspace) ---
|
||||
|
||||
const tplPanelLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'ptpl-root',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'ptpl-row-0',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'ptpl-0', appType: 'ptpl-0' }, size: 33.33 },
|
||||
{ node: { type: 'panel', id: 'ptpl-1', appType: 'ptpl-1' }, size: 33.33 },
|
||||
{ node: { type: 'panel', id: 'ptpl-2', appType: 'ptpl-2' }, size: 33.34 },
|
||||
],
|
||||
},
|
||||
size: 50,
|
||||
},
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'ptpl-row-1',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'ptpl-3', appType: 'ptpl-3' }, size: 33.33 },
|
||||
{ node: { type: 'panel', id: 'ptpl-4', appType: 'ptpl-4' }, size: 33.33 },
|
||||
{ node: { type: 'panel', id: 'ptpl-5', appType: 'ptpl-5' }, size: 33.34 },
|
||||
],
|
||||
},
|
||||
size: 50,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const tplRegistry = Object.fromEntries(
|
||||
templates.map((tpl, i) => [
|
||||
`ptpl-${i}`,
|
||||
{ name: tpl.name, icon: Layout, component: () => <TemplateCell index={i} /> },
|
||||
]),
|
||||
);
|
||||
|
||||
const TemplatePanel = () => (
|
||||
<WorkspaceLayout layout={tplPanelLayout} onLayoutChange={() => {}} registry={tplRegistry} noHeader />
|
||||
);
|
||||
|
||||
// --- Panel: Details (Name + Description + Project Type + Backend/Auth toggles) ---
|
||||
|
||||
const PROJECT_TYPE_OPTIONS: { value: ProjectType; label: string }[] = [
|
||||
// { value: 'landing-page', label: 'Landing Page' },
|
||||
// { value: 'website', label: 'Website' },
|
||||
{ value: 'app', label: 'App' },
|
||||
];
|
||||
|
||||
const DetailsPanel = () => {
|
||||
const [name, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
|
||||
const [description, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
|
||||
const [projectType, setProjectType] = useGlobal<ProjectType>(NEW_PROJ_TYPE, 'app');
|
||||
const [hasBackend, setHasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
|
||||
const [hasAuth, setHasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-y-auto">
|
||||
<div className="flex h-full flex-col gap-4 p-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
|
||||
<Type className="h-3 w-3" />
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
placeholder="My Project"
|
||||
autoFocus
|
||||
className="rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
|
||||
<FileText className="h-3 w-3" />
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(ev) => setDescription(ev.target.value)}
|
||||
placeholder="What is this project for?"
|
||||
rows={3}
|
||||
className="resize-none rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-duck-dark/50">Project Type</label>
|
||||
<div className="flex gap-2">
|
||||
{PROJECT_TYPE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setProjectType(opt.value)}
|
||||
className={`flex-1 py-1.5 px-2 rounded-lg text-xs font-medium border transition-colors cursor-pointer ${
|
||||
projectType === opt.value
|
||||
? 'bg-emerald-500/15 border-emerald-500/40 text-emerald-400'
|
||||
: 'border-duck-dark/20 text-duck-dark/50 hover:border-duck-dark/30'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* {projectType === 'app' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasBackend}
|
||||
onChange={(ev) => setHasBackend(ev.target.checked)}
|
||||
className="accent-emerald-500"
|
||||
/>
|
||||
<span className="text-xs text-duck-dark/60">Has Backend</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasAuth}
|
||||
onChange={(ev) => setHasAuth(ev.target.checked)}
|
||||
className="accent-emerald-500"
|
||||
/>
|
||||
<span className="text-xs text-duck-dark/60">Has Auth</span>
|
||||
</label>
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Panel: Create ---
|
||||
|
||||
const assignDefaultApps = (layout: LayoutNode, apps: string[]): LayoutNode => {
|
||||
const remaining = [...apps];
|
||||
const walk = (node: LayoutNode): LayoutNode => {
|
||||
if (remaining.length === 0) return node;
|
||||
if (node.type === 'panel' && node.appType === null) {
|
||||
return { ...node, appType: remaining.shift()! };
|
||||
}
|
||||
if (node.type === 'group') {
|
||||
return { ...node, children: node.children.map((c) => ({ ...c, node: walk(c.node) })) };
|
||||
}
|
||||
return node;
|
||||
};
|
||||
return walk(layout);
|
||||
};
|
||||
|
||||
const CreatePanel = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const client = useClient();
|
||||
const [name, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
|
||||
const { value: projects } = useDashboardState<ProjectDefinition[]>('projects', []);
|
||||
|
||||
const [description, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
|
||||
const [templateIdx, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||
const [previewLayout] = useGlobal<LayoutNode>(NEW_PROJ_PREVIEW_LAYOUT, createDefaultLayout());
|
||||
const [projectType] = useGlobal<ProjectType>(NEW_PROJ_TYPE, 'app');
|
||||
const [hasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
|
||||
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 [, setSearchParams] = useSearchParams();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const isEditing = !!editingId;
|
||||
const slug = isEditing ? editingId : slugify(name.trim()) || generateSlug();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || isSubmitting) return;
|
||||
const desc = description.trim();
|
||||
|
||||
const meta = {
|
||||
name: trimmed,
|
||||
description: desc || undefined,
|
||||
projectType,
|
||||
hasBackend: projectType === 'app' ? hasBackend : undefined,
|
||||
hasAuth: projectType === 'app' ? hasAuth : undefined,
|
||||
templateIdx,
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
client
|
||||
.patch('/dashboards', { [`proj-meta-${editingId}`]: meta, [`proj-layout-${editingId}`]: previewLayout })
|
||||
.then((res) => queryClient.setQueryData(['DASHBOARD_STATE'], res))
|
||||
.catch(() => {});
|
||||
|
||||
setEditingId(null);
|
||||
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;
|
||||
|
||||
try {
|
||||
const res = await client.patch('/dashboards', {
|
||||
[`proj-meta-${id}`]: meta,
|
||||
[`proj-layout-${id}`]: finalLayout,
|
||||
});
|
||||
queryClient.setQueryData(['DASHBOARD_STATE'], res);
|
||||
setName('');
|
||||
setDescription('');
|
||||
setTemplateIdx(0);
|
||||
navigate(`/projects/${id}`);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 p-4">
|
||||
<Rocket className="h-8 w-8 text-emerald-500/40" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-duck-dark/70">{name.trim() || 'Untitled'}</p>
|
||||
<p className="text-xs font-mono text-duck-dark/40 mt-1">~/Projects/{slug}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!name.trim() || isSubmitting}
|
||||
className="bg-emerald-500 hover:bg-emerald-500/90 cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Plus className="h-4 w-4 mr-1" />}
|
||||
{isSubmitting ? 'Creating...' : isEditing ? 'Update Project' : 'Create Project'}
|
||||
</Button>
|
||||
{isEditing && (
|
||||
<Button onClick={() => navigate(`/projects/${editingId}`)} variant="outline" className="cursor-pointer">
|
||||
<ArrowRight className="h-4 w-4 mr-1" />
|
||||
Open Project
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Panel: Live Template Preview (full interactive sub-workspace) ---
|
||||
|
||||
const PREVIEW_LAYOUT_KEY = ['USE_GLOBAL', NEW_PROJ_PREVIEW_LAYOUT];
|
||||
|
||||
const TemplatePreviewInner = ({ templateIdx }: { templateIdx: number }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [layout, setLocalLayout] = useState<LayoutNode>(() => {
|
||||
const initial = templates[templateIdx]!.layout();
|
||||
queryClient.setQueryData(PREVIEW_LAYOUT_KEY, initial);
|
||||
return initial;
|
||||
});
|
||||
|
||||
const handleLayoutChange = (next: LayoutNode) => {
|
||||
setLocalLayout(next);
|
||||
queryClient.setQueryData(PREVIEW_LAYOUT_KEY, next);
|
||||
};
|
||||
|
||||
return <WorkspaceLayout layout={layout} onLayoutChange={handleLayoutChange} noHeader />;
|
||||
};
|
||||
|
||||
const TemplatePreviewPanel = () => {
|
||||
const [selectedIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||
return <TemplatePreviewInner key={selectedIdx} templateIdx={selectedIdx} />;
|
||||
};
|
||||
|
||||
// --- New Project Layout ---
|
||||
|
||||
const newProjLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'new-proj-root',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'new-proj-top',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'new-proj-details', appType: 'new-proj-details' }, size: 30 },
|
||||
{ node: { type: 'panel', id: 'new-proj-template', appType: 'new-proj-template' }, size: 70 },
|
||||
],
|
||||
},
|
||||
size: 50,
|
||||
},
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'new-proj-bottom',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'new-proj-placeholder', appType: 'new-proj-placeholder' }, size: 70 },
|
||||
{ node: { type: 'panel', id: 'new-proj-create', appType: 'new-proj-create' }, size: 30 },
|
||||
],
|
||||
},
|
||||
size: 50,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const NewProjectForm = () => {
|
||||
const { registry } = useAppRegistry();
|
||||
const newProjRegistry = {
|
||||
...registry,
|
||||
'new-proj-details': { name: 'Details', icon: Type, component: DetailsPanel },
|
||||
'new-proj-placeholder': { name: 'Preview', icon: Layout, component: TemplatePreviewPanel },
|
||||
'new-proj-template': { name: 'Template', icon: Layout, component: TemplatePanel },
|
||||
'new-proj-create': { name: 'Create', icon: Rocket, component: CreatePanel },
|
||||
};
|
||||
|
||||
return <WorkspaceLayout layout={newProjLayout} onLayoutChange={() => {}} registry={newProjRegistry} noHeader />;
|
||||
};
|
||||
|
||||
const ProjectPreviewEmpty = () => {
|
||||
const [creating, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||
const [editingId] = useGlobal<string | null>(EDITING_PROJECT, null);
|
||||
const [, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
|
||||
const [, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
|
||||
const [, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||
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);
|
||||
|
||||
if (creating || editingId) return <NewProjectForm />;
|
||||
|
||||
const handleCreate = () => {
|
||||
setName(generateSlug());
|
||||
setDescription('');
|
||||
setTemplateIdx(0);
|
||||
setProjectType('app');
|
||||
setHasBackend(false);
|
||||
setHasAuth(false);
|
||||
setCreating(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4">
|
||||
<FolderKanban className="h-10 w-10 text-white/80" />
|
||||
<p className="text-sm text-white/80">Select a project or create a new one</p>
|
||||
<Button onClick={handleCreate} className="bg-emerald-500 hover:bg-emerald-500/90 cursor-pointer">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New Project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProjectPreviewInner = ({ project }: { project: ProjectDefinition }) => {
|
||||
const ws = useDashboardState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
|
||||
return <WorkspaceView workspace={ws} cwd={project.cwd} />;
|
||||
};
|
||||
|
||||
export const ProjectPreview = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const selectedId = searchParams.get('selected');
|
||||
const { value: projects } = useDashboardState<ProjectDefinition[]>('projects', []);
|
||||
const project = selectedId ? projects.find((p) => p.id === selectedId) : null;
|
||||
|
||||
if (!project) return <ProjectPreviewEmpty />;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ProjectPreviewInner key={project.id} project={project} />
|
||||
<Link
|
||||
to={`/projects/${project.id}`}
|
||||
className="absolute inset-0 z-10 flex items-center justify-center bg-transparent hover:bg-duck-dark/10 transition-colors group"
|
||||
>
|
||||
<ArrowRight className="h-16 w-16 text-emerald-500/0 group-hover:text-emerald-500/60 transition-colors" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,177 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Rocket } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useUserApps } from 'state/useUserApps';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { resolveIcon, availableIconNames } from '../../utils/resolve-icon';
|
||||
|
||||
type PublishDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectSlug: string;
|
||||
projectName: string;
|
||||
};
|
||||
|
||||
export const PublishDialog = ({ open, onOpenChange, projectSlug, projectName }: PublishDialogProps) => {
|
||||
const client = useClient();
|
||||
const { refetch } = useUserApps();
|
||||
|
||||
const [name, setName] = useState(projectName);
|
||||
const [icon, setIcon] = useState('Globe');
|
||||
const [description, setDescription] = useState('');
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false);
|
||||
|
||||
// Fetch version from package.json when dialog opens
|
||||
const fetchVersion = async () => {
|
||||
try {
|
||||
const res = await client.get<{ content: string }>(`/file-browser/read?path=/Projects/${projectSlug}/package.json`);
|
||||
const pkg = JSON.parse(res.content);
|
||||
setVersion(pkg.version ?? '0.1.0');
|
||||
} catch {
|
||||
setVersion('0.1.0');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
setName(projectName);
|
||||
setIcon('Globe');
|
||||
setDescription('');
|
||||
setPublishing(false);
|
||||
fetchVersion();
|
||||
}
|
||||
onOpenChange(nextOpen);
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
setPublishing(true);
|
||||
try {
|
||||
await client.post('/apps/publish', {
|
||||
projectSlug,
|
||||
name,
|
||||
icon,
|
||||
description,
|
||||
});
|
||||
refetch();
|
||||
toast.success(`Published ${name} successfully`);
|
||||
onOpenChange(false);
|
||||
} catch (err: unknown) {
|
||||
const msg = err && typeof err === 'object' && 'message' in err ? String(err.message) : 'Publish failed';
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const SelectedIcon = resolveIcon(icon);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Rocket className="h-4 w-4" />
|
||||
Publish App
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Build and publish <strong>{projectSlug}</strong> as a standalone app.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">App Name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
className="rounded-md border border-duck-dark/15 bg-transparent px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-emerald-500/30"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Icon</span>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIconPickerOpen(!iconPickerOpen)}
|
||||
className="flex items-center gap-2 rounded-md border border-duck-dark/15 bg-transparent px-3 py-1.5 text-sm cursor-pointer hover:bg-duck-dark/5 w-full"
|
||||
>
|
||||
<SelectedIcon className="h-4 w-4" />
|
||||
<span>{icon}</span>
|
||||
</button>
|
||||
{iconPickerOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 z-50 bg-background border border-duck-dark/15 rounded-md shadow-lg p-2 grid grid-cols-8 gap-1 max-h-48 overflow-y-auto w-full">
|
||||
{availableIconNames.map((iconName) => {
|
||||
const IconComp = resolveIcon(iconName);
|
||||
return (
|
||||
<button
|
||||
key={iconName}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIcon(iconName);
|
||||
setIconPickerOpen(false);
|
||||
}}
|
||||
className={`p-1.5 rounded cursor-pointer transition-colors ${
|
||||
icon === iconName ? 'bg-emerald-500/20 text-emerald-400' : 'hover:bg-duck-dark/10'
|
||||
}`}
|
||||
title={iconName}
|
||||
>
|
||||
<IconComp className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Description</span>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(ev) => setDescription(ev.target.value)}
|
||||
rows={2}
|
||||
className="rounded-md border border-duck-dark/15 bg-transparent px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-emerald-500/30 resize-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{version && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Version:</span>
|
||||
<span className="font-mono">{version}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={publishing || !name.trim()}
|
||||
onClick={handlePublish}
|
||||
className="flex items-center justify-center gap-2 rounded-md bg-emerald-500 hover:bg-emerald-500/90 text-white py-2 px-4 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{publishing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Publishing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Rocket className="h-4 w-4" />
|
||||
Publish
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
export const SELECTED_PROJECT = 'SELECTED_PROJECT';
|
||||
export const CREATING_PROJECT = 'CREATING_PROJECT';
|
||||
export const EDITING_PROJECT = 'EDITING_PROJECT';
|
||||
export const NEW_PROJ_NAME = 'NEW_PROJ_NAME';
|
||||
export const NEW_PROJ_DESC = 'NEW_PROJ_DESC';
|
||||
export const NEW_PROJ_TEMPLATE = 'NEW_PROJ_TEMPLATE';
|
||||
export const NEW_PROJ_TYPE = 'NEW_PROJ_TYPE';
|
||||
export const NEW_PROJ_HAS_BACKEND = 'NEW_PROJ_HAS_BACKEND';
|
||||
export const NEW_PROJ_HAS_AUTH = 'NEW_PROJ_HAS_AUTH';
|
||||
export const NEW_PROJ_PREVIEW_LAYOUT = 'NEW_PROJ_PREVIEW_LAYOUT';
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { FolderKanban } from 'lucide-react';
|
||||
import { ProjectListApp } from './ProjectListApp';
|
||||
import { ProjectPreview } from './ProjectPreview';
|
||||
|
||||
export { ProjectListApp };
|
||||
export { ProjectPreview };
|
||||
export {
|
||||
SELECTED_PROJECT,
|
||||
CREATING_PROJECT,
|
||||
EDITING_PROJECT,
|
||||
NEW_PROJ_NAME,
|
||||
NEW_PROJ_DESC,
|
||||
NEW_PROJ_TEMPLATE,
|
||||
NEW_PROJ_TYPE,
|
||||
NEW_PROJ_HAS_BACKEND,
|
||||
NEW_PROJ_HAS_AUTH,
|
||||
NEW_PROJ_PREVIEW_LAYOUT,
|
||||
} from './constants';
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'project-list',
|
||||
name: 'Projects',
|
||||
icon: FolderKanban,
|
||||
component: ProjectListApp,
|
||||
},
|
||||
{
|
||||
key: 'project-preview',
|
||||
name: 'Project Preview',
|
||||
icon: FolderKanban,
|
||||
component: ProjectPreview,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
@@ -12,8 +12,6 @@ export const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||
const stateKey = (() => {
|
||||
const wsMatch = dashboardId?.match(/^ws-layout-(.+)$/);
|
||||
if (wsMatch) return `ws-terminals-${wsMatch[1]}`;
|
||||
const projMatch = dashboardId?.match(/^proj-layout-(.+)$/);
|
||||
if (projMatch) return `proj-terminals-${projMatch[1]}`;
|
||||
return 'ws-terminals-default';
|
||||
})();
|
||||
const { value: terminals, setValue: setTerminals } = useDashboardState<Record<string, string>>(
|
||||
|
||||
@@ -66,20 +66,6 @@ export {
|
||||
NEW_DASH_DESC_KEY,
|
||||
NEW_DASH_TEMPLATE_KEY,
|
||||
} from './apps/Dashboards';
|
||||
export {
|
||||
ProjectListApp,
|
||||
ProjectPreview,
|
||||
SELECTED_PROJECT,
|
||||
CREATING_PROJECT,
|
||||
EDITING_PROJECT,
|
||||
NEW_PROJ_NAME,
|
||||
NEW_PROJ_DESC,
|
||||
NEW_PROJ_TEMPLATE,
|
||||
NEW_PROJ_TYPE,
|
||||
NEW_PROJ_HAS_BACKEND,
|
||||
NEW_PROJ_HAS_AUTH,
|
||||
NEW_PROJ_PREVIEW_LAYOUT,
|
||||
} from './apps/Projects';
|
||||
export { createUserAppPanel, createUserAppHeader } from './apps/UserApp';
|
||||
export { resolveIcon, availableIconNames } from './utils/resolve-icon';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user