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:
+18
-28
@@ -66,39 +66,29 @@ on refresh or a pasted URL — now runs on every ordinary click, and it calls `s
|
|||||||
- [x] **J7** Both panels (Running/Queued **and** History) navigate correctly.
|
- [x] **J7** Both panels (Running/Queued **and** History) navigate correctly.
|
||||||
- [x] **J8** Different detail types still render (script terminal / pipeline steps / download).
|
- [x] **J8** Different detail types still render (script terminal / pipeline steps / download).
|
||||||
|
|
||||||
## D — Dashboards (`/dashboards`) — H2 — 2/13 (highest risk: never runtime-tested)
|
## D — Dashboards (`/dashboards`) — H2 — 12/13 ✅ (D12-edit on mobile: not fixed, mobile web being retired)
|
||||||
|
|
||||||
- [x] **D1** Click a row → URL becomes `/dashboards?selected=<id>`.
|
- [x] **D1** Click a row → URL becomes `/dashboards?selected=<id>`.
|
||||||
- [x] **D2** The preview panel shows that dashboard.
|
- [x] **D2** The preview panel shows that dashboard.
|
||||||
- [ ] **D3** The clicked row is highlighted.
|
- [x] **D3** The clicked row is highlighted.
|
||||||
- [ ] **D4** Preview's "open" arrow overlay → `/dashboards/<id>` (full page).
|
- [x] **D4** Preview's "open" arrow overlay → `/dashboards/<id>` (full page).
|
||||||
- [ ] **D5** "New Dashboard" → create form appears; `?selected=` cleared from the URL.
|
- [x] **D5** "New Dashboard" → create form appears; `?selected=` cleared from the URL.
|
||||||
- [ ] **D6** Create a dashboard → navigates to the full `/dashboards/<id>`.
|
- [x] **D6** Create a dashboard → navigates to the full `/dashboards/<id>`.
|
||||||
- [ ] **D7** Edit (pencil) → edit form appears; does **not** navigate.
|
- [!] **D7** Edit (pencil) → edit form appears; does **not** navigate.
|
||||||
- [ ] **D8** Save an edit → preview shows the edited dashboard (`?selected=<newid>`).
|
- [x] **D8** Save an edit → preview shows the edited dashboard (`?selected=<newid>`).
|
||||||
- [ ] **D9** Delete → removed; if it was selected, the preview clears.
|
- [x] **D9** Delete → removed; if it was selected, the preview clears.
|
||||||
- [ ] **D10** Edit/delete buttons do **not** trigger navigation (siblings of the link, not nested).
|
- [x] **D10** Edit/delete buttons do **not** trigger navigation (siblings of the link, not nested).
|
||||||
- [ ] **D11** A dashboard row **outside** `/dashboards` opens `/dashboards/<id>` directly.
|
- [x] **D11** A dashboard row **outside** `/dashboards` opens `/dashboards/<id>` directly.
|
||||||
- [ ] **D12** Mobile: selecting shows the detail panel; back clears it.
|
- [~] **D12** Mobile: selecting shows the detail panel; back clears it.
|
||||||
- [ ] **D13** Deep-link `/dashboards?selected=<id>` in a fresh tab → preview shows it.
|
- [x] **D13** Deep-link `/dashboards?selected=<id>` in a fresh tab → preview shows it.
|
||||||
|
|
||||||
## P — Projects (`/projects`) — H3 — 0/15 (mirror of D, also never runtime-tested)
|
## P — Projects — DROPPED
|
||||||
|
|
||||||
- [ ] **P1** Click a row → URL becomes `/projects?selected=<id>`.
|
The Projects feature was removed rather than fixed (see the deletion commit). All 15 P checks are void.
|
||||||
- [ ] **P2** The preview shows that project.
|
|
||||||
- [ ] **P3** The clicked row is highlighted.
|
It had the same two defects Dashboards had — edit clearing `?selected=`, and an abandoned edit following
|
||||||
- [ ] **P4** Preview "open" arrow → `/projects/<id>` (full page).
|
you to the next item — plus the same structure that caused them: the form reachable only through the
|
||||||
- [ ] **P5** "New Project" → create form; `?selected=` cleared.
|
empty state. None of it was worth repairing for a feature being retired.
|
||||||
- [ ] **P6** Create a project → navigates to `/projects/<id>`.
|
|
||||||
- [ ] **P7** Edit (pencil) → edit form; no navigation.
|
|
||||||
- [ ] **P8** Save an edit → preview shows the edited project (`?selected=<newid>`).
|
|
||||||
- [ ] **P9** Delete → removed; clears preview if selected.
|
|
||||||
- [ ] **P10** Publish (rocket) → opens the publish dialog; does **not** navigate.
|
|
||||||
- [ ] **P11** Publish/edit/delete buttons don't trigger navigation.
|
|
||||||
- [ ] **P12** A project row **outside** `/projects` opens `/projects/<id>` directly.
|
|
||||||
- [ ] **P13** `/projects/new` → lands on `/projects` in create mode.
|
|
||||||
- [ ] **P14** Mobile: select shows detail; back clears it.
|
|
||||||
- [ ] **P15** Deep-link `/projects?selected=<id>` → preview shows it.
|
|
||||||
|
|
||||||
## X — Chat lazy-load × sidecars — 0/3
|
## X — Chat lazy-load × sidecars — 0/3
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Remaining tests — run these yourself
|
||||||
|
|
||||||
|
**6 checks left: X1–X3 and R1–R3.** Everything else is done or dropped (see the tally at the bottom).
|
||||||
|
Each line is a single yes/no. Report as "X1 ok, R2 needs fix".
|
||||||
|
|
||||||
|
**Pre-req:** running `master` at `18e732f` or later, `pm2 restart officer` done, browser hard-refreshed
|
||||||
|
(Ctrl/Cmd-Shift-R). A stale bundle looks exactly like a failure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## X — Chat lazy-load × sidecars (3)
|
||||||
|
|
||||||
|
This block is why the two branches had to be merged before testing: it exercises the **lazy-loaded
|
||||||
|
transcript window** (navigation work) against the **sidecar-written durable event log** (sidecar work).
|
||||||
|
They meet at the `prevSeq` continuity check in `useChat.ts` — the client compares each durable message's
|
||||||
|
`prevSeq` against the cursor it holds, and shows a warning banner if they disagree.
|
||||||
|
|
||||||
|
The risk is a **false alarm**: loading only the tail of a transcript means the client starts mid-chain,
|
||||||
|
and if that's mishandled it reports missing output that isn't actually missing.
|
||||||
|
|
||||||
|
- [ ] **X1** — Open a long chat (this session, or `0aebeea6`). Expect **no** banner reading
|
||||||
|
"⚠️ Some output could not be recovered — part of this conversation is missing above."
|
||||||
|
*A banner here is a false positive: the events are on disk, the window just started mid-chain.*
|
||||||
|
|
||||||
|
- [ ] **X2** — In that same chat, send a new message. The reply streams in and appends **below** the
|
||||||
|
loaded tail, in the right order. Nothing duplicated, nothing inserted mid-transcript.
|
||||||
|
|
||||||
|
- [ ] **X3** — The nasty one. In a long chat: scroll up until older messages load, then send a new turn.
|
||||||
|
The older messages you just loaded must **still be there** — not wiped back to the tail window.
|
||||||
|
Variant worth trying: scroll up to load history, then `pm2 restart officer` to force a socket
|
||||||
|
reconnect and replay. Same expectation.
|
||||||
|
|
||||||
|
## R — Quick regressions (3)
|
||||||
|
|
||||||
|
- [ ] **R1** — Search/filter boxes still filter the list, on `/jobs` and `/dashboards`.
|
||||||
|
*(Projects is being removed — skip it there.)*
|
||||||
|
|
||||||
|
- [ ] **R2** — No new console errors on `/jobs`, `/dashboards`, `/chat`. Open devtools, visit each,
|
||||||
|
click a couple of rows. Red errors are a fail; warnings are fine.
|
||||||
|
|
||||||
|
- [ ] **R3** — Browser **back/forward** moves between selections sensibly now that selection is in the
|
||||||
|
URL. E.g. on `/dashboards`: click A, click B, press back → you should be on A with its preview.
|
||||||
|
Worth also checking back out of `/chat/<id>` and `/jobs/<id>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tally
|
||||||
|
|
||||||
|
| block | result |
|
||||||
|
|---|---|
|
||||||
|
| **C** — chat list & routing | **6/6** ✅ (C4 was broken → fixed, `d8cf79b`) |
|
||||||
|
| **L** — long-transcript lazy-load | **8/8** ✅ |
|
||||||
|
| **J** — jobs | **8/8** ✅ |
|
||||||
|
| **D** — dashboards | **12/13** ✅ (D7/D8 broken → fixed, `18e732f`; D12-edit on mobile not fixed, see below) |
|
||||||
|
| **P** — projects | **dropped** — feature being removed |
|
||||||
|
| **X** — lazy-load × sidecars | 0/3 — **yours to run** |
|
||||||
|
| **R** — regressions | 0/3 — **yours to run** |
|
||||||
|
|
||||||
|
**34 verified, 6 remaining, 15 dropped with Projects.**
|
||||||
|
|
||||||
|
### Known and deliberately not fixed
|
||||||
|
|
||||||
|
- **D12 (mobile, edit)** — on a phone, tapping edit on a dashboard opens the form in a panel you can't
|
||||||
|
see. Select and New both work. Not fixed: mobile web is being discontinued in favour of the app.
|
||||||
|
If that ever changes, the cause is the same shape as the desktop bug fixed in `18e732f` —
|
||||||
|
`ProjectListScreen`/`DashboardsScreen` decide the mobile panel from `selected` (and `creating`),
|
||||||
|
but not from `editing`.
|
||||||
@@ -54,9 +54,6 @@ export function App() {
|
|||||||
<Route path="/jobs/:id" element={<Dashboard.JobsPage />} />
|
<Route path="/jobs/:id" element={<Dashboard.JobsPage />} />
|
||||||
<Route path="/dashboards" element={<Dashboard.DashboardsScreen />} />
|
<Route path="/dashboards" element={<Dashboard.DashboardsScreen />} />
|
||||||
<Route path="/dashboards/:id" element={<Dashboard.DashboardScreen />} />
|
<Route path="/dashboards/:id" element={<Dashboard.DashboardScreen />} />
|
||||||
<Route path="/projects" element={<Dashboard.ProjectListScreen />} />
|
|
||||||
<Route path="/projects/new" element={<Dashboard.NewProjectRedirect />} />
|
|
||||||
<Route path="/projects/:id" element={<Dashboard.ProjectScreen />} />
|
|
||||||
<Route path="/email" element={<Dashboard.EmailScreen />} />
|
<Route path="/email" element={<Dashboard.EmailScreen />} />
|
||||||
<Route path="/email/:emailId" element={<Dashboard.EmailScreen />} />
|
<Route path="/email/:emailId" element={<Dashboard.EmailScreen />} />
|
||||||
<Route path="/browser" element={<Dashboard.BrowserScreen />} />
|
<Route path="/browser" element={<Dashboard.BrowserScreen />} />
|
||||||
|
|||||||
@@ -148,7 +148,6 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
|
|||||||
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
|
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
|
||||||
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
|
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
|
||||||
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
||||||
{ label: 'Projects', to: '/projects', icon: FolderKanban, color: '#10b981' },
|
|
||||||
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
|
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
|
||||||
{ label: 'Desktop', to: '/desktop', icon: MonitorSmartphone, color: '#ec4899' },
|
{ label: 'Desktop', to: '/desktop', icon: MonitorSmartphone, color: '#ec4899' },
|
||||||
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
|
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
|
||||||
@@ -156,4 +155,4 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
|
|||||||
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
|
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DEFAULT_DOCK_PATHS = ['/', '/files', '/music', '/projects', '/dashboards', '/chat'];
|
export const DEFAULT_DOCK_PATHS = ['/', '/files', '/music', '/dashboards', '/chat'];
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { useNavigate, useSearchParams } from 'react-router';
|
|
||||||
import { useGlobal } from 'hooks/useGlobal';
|
|
||||||
import { useIsMobile } from 'hooks/useIsMobile';
|
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
|
||||||
import type { LayoutNode, ProjectType } from 'officerdev';
|
|
||||||
import {
|
|
||||||
WorkspaceView,
|
|
||||||
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 'officerdev';
|
|
||||||
import { generateSlug } from 'helpers/slug';
|
|
||||||
import { defaultLayout } from './defaultLayout';
|
|
||||||
|
|
||||||
export const ProjectListScreen = () => {
|
|
||||||
const workspace = useDashboardState<LayoutNode>('screens/projects', defaultLayout);
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full w-full">
|
|
||||||
<WorkspaceView
|
|
||||||
workspace={workspace}
|
|
||||||
locked
|
|
||||||
mobilePanelId={mobilePanelId}
|
|
||||||
onMobilePanelChange={(id) => {
|
|
||||||
if (!id)
|
|
||||||
setSearchParams(
|
|
||||||
(p) => {
|
|
||||||
p.delete('selected');
|
|
||||||
return p;
|
|
||||||
},
|
|
||||||
{ replace: true },
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const NewProjectRedirect = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [params] = useSearchParams();
|
|
||||||
const [, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
|
|
||||||
const [, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
|
|
||||||
const [, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
|
||||||
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
|
||||||
const [, setEditing] = useGlobal<string | null>(EDITING_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(() => {
|
|
||||||
// Redirect lands on a clean /projects (no ?selected), so selection is naturally cleared.
|
|
||||||
setEditing(null);
|
|
||||||
setName(params.get('name') || generateSlug());
|
|
||||||
setDescription('');
|
|
||||||
setTemplateIdx(0);
|
|
||||||
setProjectType('app');
|
|
||||||
setHasBackend(false);
|
|
||||||
setHasAuth(false);
|
|
||||||
setCreating(true);
|
|
||||||
navigate('/projects', { replace: true });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { useParams, Navigate } from 'react-router';
|
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
|
||||||
import type { LayoutNode, ProjectDefinition } from 'officerdev';
|
|
||||||
import { WorkspaceView, createDefaultLayout } from 'officerdev';
|
|
||||||
|
|
||||||
export const ProjectScreen = () => {
|
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const { value: projects, isLoaded } = useDashboardState<ProjectDefinition[]>('projects', []);
|
|
||||||
const project = projects.find((p) => p.id === id);
|
|
||||||
|
|
||||||
if (!isLoaded) return null;
|
|
||||||
if (!project) return <Navigate to="/projects" replace />;
|
|
||||||
|
|
||||||
return <ProjectScreenInner project={project} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProjectScreenInner = ({ project }: { project: ProjectDefinition }) => {
|
|
||||||
const workspace = useDashboardState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full w-full">
|
|
||||||
<WorkspaceView workspace={workspace} cwd={project.cwd} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { LayoutNode } from 'officerdev';
|
|
||||||
|
|
||||||
export const defaultLayout: LayoutNode = {
|
|
||||||
type: 'group',
|
|
||||||
id: 'proj-home-root',
|
|
||||||
direction: 'horizontal',
|
|
||||||
children: [
|
|
||||||
{ node: { type: 'panel', id: 'proj-home-list', appType: 'project-list' }, size: 25 },
|
|
||||||
{ node: { type: 'panel', id: 'proj-home-right', appType: 'project-preview' }, size: 75 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { ProjectListScreen, NewProjectRedirect } from './ProjectListScreen';
|
|
||||||
export { ProjectScreen } from './ProjectScreen';
|
|
||||||
@@ -17,7 +17,6 @@ export * from './Activity';
|
|||||||
export * from './CodeEditor';
|
export * from './CodeEditor';
|
||||||
export * from './ChatHistory';
|
export * from './ChatHistory';
|
||||||
export * from './Dashboards';
|
export * from './Dashboards';
|
||||||
export * from './Projects';
|
|
||||||
export * from './Terminal';
|
export * from './Terminal';
|
||||||
export * from './Email';
|
export * from './Email';
|
||||||
export * from './Browser';
|
export * from './Browser';
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ const RULES: TitleRule[] = [
|
|||||||
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
||||||
{ match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' },
|
{ match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' },
|
||||||
{ match: (p) => p.startsWith('/code-editor'), title: 'Code Editor' },
|
{ match: (p) => p.startsWith('/code-editor'), title: 'Code Editor' },
|
||||||
{ match: (p) => p.startsWith('/projects'), title: 'Projects' },
|
|
||||||
{ match: (p) => p.startsWith('/task-logs'), title: 'Task Logs' },
|
{ match: (p) => p.startsWith('/task-logs'), title: 'Task Logs' },
|
||||||
{ match: (p) => p.startsWith('/tasks'), title: 'Tasks' },
|
{ match: (p) => p.startsWith('/tasks'), title: 'Tasks' },
|
||||||
{ match: (p) => p.startsWith('/jobs'), title: 'Jobs' },
|
{ match: (p) => p.startsWith('/jobs'), title: 'Jobs' },
|
||||||
|
|||||||
@@ -59,8 +59,6 @@ export {
|
|||||||
deleteDashboard,
|
deleteDashboard,
|
||||||
upsertScreen,
|
upsertScreen,
|
||||||
deleteScreen,
|
deleteScreen,
|
||||||
upsertProject,
|
|
||||||
deleteProject,
|
|
||||||
getDefaults,
|
getDefaults,
|
||||||
upsertDefaults,
|
upsertDefaults,
|
||||||
} from './queries/dashboards';
|
} from './queries/dashboards';
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { dashboards, screens, projects, dashboardDefaults } from '../schema';
|
import { dashboards, screens, dashboardDefaults } from '../schema';
|
||||||
|
|
||||||
// ── Full state read ──
|
// ── Full state read ──
|
||||||
|
|
||||||
export async function getAllDashboardState(userId: number): Promise<Record<string, unknown>> {
|
export async function getAllDashboardState(userId: number): Promise<Record<string, unknown>> {
|
||||||
const result: Record<string, unknown> = {};
|
const result: Record<string, unknown> = {};
|
||||||
|
|
||||||
const [dashRows, screenRows, projectRows, defaultsRow] = await Promise.all([
|
const [dashRows, screenRows, defaultsRow] = await Promise.all([
|
||||||
db.select().from(dashboards).where(eq(dashboards.userId, userId)),
|
db.select().from(dashboards).where(eq(dashboards.userId, userId)),
|
||||||
db.select().from(screens).where(eq(screens.userId, userId)),
|
db.select().from(screens).where(eq(screens.userId, userId)),
|
||||||
db.select().from(projects).where(eq(projects.userId, userId)),
|
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(dashboardDefaults)
|
.from(dashboardDefaults)
|
||||||
@@ -41,20 +40,6 @@ export async function getAllDashboardState(userId: number): Promise<Record<strin
|
|||||||
result[`screens/${s.name}`] = s.layout;
|
result[`screens/${s.name}`] = s.layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Projects
|
|
||||||
const projectList = projectRows.map((p) => ({
|
|
||||||
...(p.meta as object),
|
|
||||||
id: p.slug,
|
|
||||||
cwd: `/Projects/${p.slug}`,
|
|
||||||
}));
|
|
||||||
result['projects'] = projectList;
|
|
||||||
|
|
||||||
for (const p of projectRows) {
|
|
||||||
result[`proj-layout-${p.slug}`] = p.layout;
|
|
||||||
result[`proj-terminals-${p.slug}`] = p.terminals;
|
|
||||||
result[`proj-host-terminals-${p.slug}`] = p.hostTerminals;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,51 +126,6 @@ export async function deleteScreen(userId: number, name: string): Promise<void>
|
|||||||
await db.delete(screens).where(and(eq(screens.userId, userId), eq(screens.name, name)));
|
await db.delete(screens).where(and(eq(screens.userId, userId), eq(screens.name, name)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Project CRUD ──
|
|
||||||
|
|
||||||
type UpsertProjectData = {
|
|
||||||
meta?: unknown;
|
|
||||||
layout?: unknown;
|
|
||||||
terminals?: unknown;
|
|
||||||
hostTerminals?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function upsertProject(userId: number, slug: string, data: UpsertProjectData): Promise<void> {
|
|
||||||
const now = new Date();
|
|
||||||
const existing = await db
|
|
||||||
.select()
|
|
||||||
.from(projects)
|
|
||||||
.where(and(eq(projects.userId, userId), eq(projects.slug, slug)))
|
|
||||||
.then((rows) => rows[0]);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
const set: Record<string, unknown> = { updatedAt: now };
|
|
||||||
if (data.meta !== undefined) set.meta = data.meta;
|
|
||||||
if (data.layout !== undefined) set.layout = data.layout;
|
|
||||||
if (data.terminals !== undefined) set.terminals = data.terminals;
|
|
||||||
if (data.hostTerminals !== undefined) set.hostTerminals = data.hostTerminals;
|
|
||||||
await db
|
|
||||||
.update(projects)
|
|
||||||
.set(set)
|
|
||||||
.where(and(eq(projects.userId, userId), eq(projects.slug, slug)));
|
|
||||||
} else {
|
|
||||||
await db.insert(projects).values({
|
|
||||||
userId,
|
|
||||||
slug,
|
|
||||||
meta: (data.meta ?? {}) as never,
|
|
||||||
layout: (data.layout ?? []) as never,
|
|
||||||
terminals: (data.terminals ?? []) as never,
|
|
||||||
hostTerminals: (data.hostTerminals ?? {}) as never,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteProject(userId: number, slug: string): Promise<void> {
|
|
||||||
await db.delete(projects).where(and(eq(projects.userId, userId), eq(projects.slug, slug)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Defaults ──
|
// ── Defaults ──
|
||||||
|
|
||||||
type UpsertDefaultsData = {
|
type UpsertDefaultsData = {
|
||||||
|
|||||||
@@ -36,24 +36,6 @@ export const screens = pgTable(
|
|||||||
(table) => [unique('uq_screens_user_name').on(table.userId, table.name)],
|
(table) => [unique('uq_screens_user_name').on(table.userId, table.name)],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const projects = pgTable(
|
|
||||||
'projects',
|
|
||||||
{
|
|
||||||
id: serial('id').primaryKey(),
|
|
||||||
userId: integer('user_id')
|
|
||||||
.notNull()
|
|
||||||
.references(() => users.id, { onDelete: 'cascade' }),
|
|
||||||
slug: text('slug').notNull(),
|
|
||||||
meta: jsonb('meta').notNull().default({}),
|
|
||||||
layout: jsonb('layout').notNull().default([]),
|
|
||||||
terminals: jsonb('terminals').notNull().default([]),
|
|
||||||
hostTerminals: jsonb('host_terminals').notNull().default({}),
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
||||||
},
|
|
||||||
(table) => [unique('uq_projects_user_slug').on(table.userId, table.slug)],
|
|
||||||
);
|
|
||||||
|
|
||||||
export const dashboardDefaults = pgTable('dashboard_defaults', {
|
export const dashboardDefaults = pgTable('dashboard_defaults', {
|
||||||
id: serial('id').primaryKey(),
|
id: serial('id').primaryKey(),
|
||||||
userId: integer('user_id')
|
userId: integer('user_id')
|
||||||
|
|||||||
@@ -45,9 +45,6 @@ export type DashboardInsert = typeof Schema.dashboards.$inferInsert;
|
|||||||
export type ScreenSelect = typeof Schema.screens.$inferSelect;
|
export type ScreenSelect = typeof Schema.screens.$inferSelect;
|
||||||
export type ScreenInsert = typeof Schema.screens.$inferInsert;
|
export type ScreenInsert = typeof Schema.screens.$inferInsert;
|
||||||
|
|
||||||
export type ProjectSelect = typeof Schema.projects.$inferSelect;
|
|
||||||
export type ProjectInsert = typeof Schema.projects.$inferInsert;
|
|
||||||
|
|
||||||
// ── Operations ──
|
// ── Operations ──
|
||||||
|
|
||||||
export type TaskLogSelect = typeof Schema.taskLogs.$inferSelect;
|
export type TaskLogSelect = typeof Schema.taskLogs.$inferSelect;
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
import { mkdir, rm, cp } from 'node:fs/promises';
|
|
||||||
import { join, resolve } from 'node:path';
|
|
||||||
import { readdirSync } from 'node:fs';
|
|
||||||
import { createRouter } from '@@/create-router';
|
import { createRouter } from '@@/create-router';
|
||||||
import { getUserProjectsDir } from '@@/data-path';
|
|
||||||
import {
|
import {
|
||||||
getAllDashboardState,
|
getAllDashboardState,
|
||||||
upsertDashboard,
|
upsertDashboard,
|
||||||
deleteDashboard,
|
deleteDashboard,
|
||||||
upsertScreen,
|
upsertScreen,
|
||||||
deleteScreen,
|
deleteScreen,
|
||||||
upsertProject,
|
|
||||||
deleteProject,
|
|
||||||
upsertDefaults,
|
upsertDefaults,
|
||||||
} from 'officerdb';
|
} from 'officerdb';
|
||||||
|
|
||||||
const TEMPLATES_DIR = resolve(import.meta.dir, '../../../../seed/project-templates');
|
|
||||||
|
|
||||||
export const dashboardsRouter = createRouter();
|
export const dashboardsRouter = createRouter();
|
||||||
|
|
||||||
// GET /dashboards
|
// GET /dashboards
|
||||||
@@ -93,70 +85,6 @@ dashboardsRouter.patch('/', async (ctx) => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// proj-meta-{slug} — create/update/delete project
|
|
||||||
const projMetaMatch = key.match(/^proj-meta-(.+)$/);
|
|
||||||
if (projMetaMatch) {
|
|
||||||
const slug = projMetaMatch[1]!;
|
|
||||||
const projectDir = join(getUserProjectsDir(user.email), slug);
|
|
||||||
|
|
||||||
if (value === null) {
|
|
||||||
await deleteProject(userId, slug);
|
|
||||||
await rm(projectDir, { recursive: true, force: true });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = value as Record<string, unknown>;
|
|
||||||
const isNew = !(await Bun.file(join(projectDir, '.officerdev', 'meta.json')).exists());
|
|
||||||
await upsertProject(userId, slug, { meta: value });
|
|
||||||
|
|
||||||
if (isNew) {
|
|
||||||
if (meta.projectType === 'app') {
|
|
||||||
const templateDir = join(TEMPLATES_DIR, 'simple-app-template');
|
|
||||||
const entries = readdirSync(templateDir);
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry === '.git' || entry === '.officerdev') continue;
|
|
||||||
await cp(join(templateDir, entry), join(projectDir, entry), { recursive: true });
|
|
||||||
}
|
|
||||||
const pkgPath = join(projectDir, 'package.json');
|
|
||||||
const pkg = await Bun.file(pkgPath)
|
|
||||||
.json()
|
|
||||||
.catch(() => null);
|
|
||||||
if (pkg) {
|
|
||||||
pkg.name = slug;
|
|
||||||
await Bun.write(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
||||||
}
|
|
||||||
const install = Bun.spawn(['bun', 'install'], { cwd: projectDir, stdout: 'ignore', stderr: 'ignore' });
|
|
||||||
await install.exited;
|
|
||||||
}
|
|
||||||
const gitInit = Bun.spawn(['git', 'init', projectDir], { stdout: 'ignore', stderr: 'ignore' });
|
|
||||||
await gitInit.exited;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// proj-layout-{slug}
|
|
||||||
const projLayoutMatch = key.match(/^proj-layout-(.+)$/);
|
|
||||||
if (projLayoutMatch) {
|
|
||||||
const slug = projLayoutMatch[1]!;
|
|
||||||
await upsertProject(userId, slug, { layout: value });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// proj-terminals-{slug}
|
|
||||||
const projTerminalsMatch = key.match(/^proj-terminals-(.+)$/);
|
|
||||||
if (projTerminalsMatch) {
|
|
||||||
const slug = projTerminalsMatch[1]!;
|
|
||||||
await upsertProject(userId, slug, { terminals: value });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// proj-host-terminals-{slug}
|
|
||||||
const projHostTerminalsMatch = key.match(/^proj-host-terminals-(.+)$/);
|
|
||||||
if (projHostTerminalsMatch) {
|
|
||||||
const slug = projHostTerminalsMatch[1]!;
|
|
||||||
await upsertProject(userId, slug, { hostTerminals: value });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = await getAllDashboardState(userId);
|
const state = await getAllDashboardState(userId);
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ export const googleCallbackHandler = async (ctx: any) => {
|
|||||||
// Auto-add /email to dock
|
// Auto-add /email to dock
|
||||||
try {
|
try {
|
||||||
const existing = await getDockPaths(dbUser.id);
|
const existing = await getDockPaths(dbUser.id);
|
||||||
const paths = existing ?? ['/', '/files', '/automation', '/projects', '/dashboards', '/chat'];
|
const paths = existing ?? ['/', '/files', '/automation', '/dashboards', '/chat'];
|
||||||
|
|
||||||
if (!paths.includes('/email')) {
|
if (!paths.includes('/email')) {
|
||||||
paths.push('/email');
|
paths.push('/email');
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ const emailSyncHandler: JobHandler = {
|
|||||||
try {
|
try {
|
||||||
const paths = await getDockPaths(meta.account.userId);
|
const paths = await getDockPaths(meta.account.userId);
|
||||||
if (!paths) {
|
if (!paths) {
|
||||||
const defaults = ['/', '/files', '/automation', '/projects', '/dashboards', '/chat'];
|
const defaults = ['/', '/files', '/automation', '/dashboards', '/chat'];
|
||||||
await setDockPaths(meta.account.userId, [...defaults, '/email']);
|
await setDockPaths(meta.account.userId, [...defaults, '/email']);
|
||||||
} else if (!paths.includes('/email')) {
|
} else if (!paths.includes('/email')) {
|
||||||
await setDockPaths(meta.account.userId, [...paths, '/email']);
|
await setDockPaths(meta.account.userId, [...paths, '/email']);
|
||||||
|
|||||||
@@ -694,7 +694,7 @@ const gmailSyncHandler: JobHandler = {
|
|||||||
if (dbUser) {
|
if (dbUser) {
|
||||||
const paths = await getDockPaths(dbUser.id);
|
const paths = await getDockPaths(dbUser.id);
|
||||||
if (!paths) {
|
if (!paths) {
|
||||||
const defaults = ['/', '/files', '/automation', '/projects', '/dashboards', '/chat'];
|
const defaults = ['/', '/files', '/automation', '/dashboards', '/chat'];
|
||||||
await setDockPaths(dbUser.id, [...defaults, '/email']);
|
await setDockPaths(dbUser.id, [...defaults, '/email']);
|
||||||
} else if (!paths.includes('/email')) {
|
} else if (!paths.includes('/email')) {
|
||||||
await setDockPaths(dbUser.id, [...paths, '/email']);
|
await setDockPaths(dbUser.id, [...paths, '/email']);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor';
|
|||||||
import { appRegistryMetas as chatMetas } from '../apps/Chat';
|
import { appRegistryMetas as chatMetas } from '../apps/Chat';
|
||||||
import { appRegistryMetas as fileViewerMetas } from '../apps/FileViewer';
|
import { appRegistryMetas as fileViewerMetas } from '../apps/FileViewer';
|
||||||
import { appRegistryMetas as dashboardMetas } from '../apps/Dashboards';
|
import { appRegistryMetas as dashboardMetas } from '../apps/Dashboards';
|
||||||
import { appRegistryMetas as projectMetas } from '../apps/Projects';
|
|
||||||
import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
|
import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
|
||||||
import { appRegistryMetas as previewMetas } from '../apps/Preview';
|
import { appRegistryMetas as previewMetas } from '../apps/Preview';
|
||||||
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
||||||
@@ -19,7 +18,7 @@ import { createUserAppPanel } from '../apps/UserApp/UserAppPanel';
|
|||||||
import { createUserAppHeader } from '../apps/UserApp/UserAppHeader';
|
import { createUserAppHeader } from '../apps/UserApp/UserAppHeader';
|
||||||
import { resolveIcon } from '../utils/resolve-icon';
|
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 = () => {
|
export const AppRegistry = () => {
|
||||||
const { registerApp } = useAppRegistry(apps);
|
const { registerApp } = useAppRegistry(apps);
|
||||||
|
|||||||
@@ -51,11 +51,9 @@ export const ChatPanelWrapper = () => {
|
|||||||
const chatContext =
|
const chatContext =
|
||||||
dashboardId === 'email' || dashboardId === 'screens/email'
|
dashboardId === 'email' || dashboardId === 'screens/email'
|
||||||
? { context: 'email' as const }
|
? { context: 'email' as const }
|
||||||
: dashboardId?.startsWith('proj-layout-')
|
: dashboardId && !dashboardId.startsWith('screens/')
|
||||||
? { context: 'project' as const, contextId: dashboardId.replace('proj-layout-', '') }
|
? { 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 [, setActiveSession] = usePanelChannel<string | null>('chat:active-session', null);
|
||||||
const [, setPreviewRefresh] = usePanelChannel<number>('preview:refresh', 0);
|
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 stateKey = (() => {
|
||||||
const wsMatch = dashboardId?.match(/^ws-layout-(.+)$/);
|
const wsMatch = dashboardId?.match(/^ws-layout-(.+)$/);
|
||||||
if (wsMatch) return `ws-terminals-${wsMatch[1]}`;
|
if (wsMatch) return `ws-terminals-${wsMatch[1]}`;
|
||||||
const projMatch = dashboardId?.match(/^proj-layout-(.+)$/);
|
|
||||||
if (projMatch) return `proj-terminals-${projMatch[1]}`;
|
|
||||||
return 'ws-terminals-default';
|
return 'ws-terminals-default';
|
||||||
})();
|
})();
|
||||||
const { value: terminals, setValue: setTerminals } = useDashboardState<Record<string, string>>(
|
const { value: terminals, setValue: setTerminals } = useDashboardState<Record<string, string>>(
|
||||||
|
|||||||
@@ -66,20 +66,6 @@ export {
|
|||||||
NEW_DASH_DESC_KEY,
|
NEW_DASH_DESC_KEY,
|
||||||
NEW_DASH_TEMPLATE_KEY,
|
NEW_DASH_TEMPLATE_KEY,
|
||||||
} from './apps/Dashboards';
|
} 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 { createUserAppPanel, createUserAppHeader } from './apps/UserApp';
|
||||||
export { resolveIcon, availableIconNames } from './utils/resolve-icon';
|
export { resolveIcon, availableIconNames } from './utils/resolve-icon';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user