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] **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] **D2** The preview panel shows that dashboard.
|
||||
- [ ] **D3** The clicked row is highlighted.
|
||||
- [ ] **D4** Preview's "open" arrow overlay → `/dashboards/<id>` (full page).
|
||||
- [ ] **D5** "New Dashboard" → create form appears; `?selected=` cleared from the URL.
|
||||
- [ ] **D6** Create a dashboard → navigates to the full `/dashboards/<id>`.
|
||||
- [ ] **D7** Edit (pencil) → edit form appears; does **not** navigate.
|
||||
- [ ] **D8** Save an edit → preview shows the edited dashboard (`?selected=<newid>`).
|
||||
- [ ] **D9** Delete → removed; if it was selected, the preview clears.
|
||||
- [ ] **D10** Edit/delete buttons do **not** trigger navigation (siblings of the link, not nested).
|
||||
- [ ] **D11** A dashboard row **outside** `/dashboards` opens `/dashboards/<id>` directly.
|
||||
- [ ] **D12** Mobile: selecting shows the detail panel; back clears it.
|
||||
- [ ] **D13** Deep-link `/dashboards?selected=<id>` in a fresh tab → preview shows it.
|
||||
- [x] **D3** The clicked row is highlighted.
|
||||
- [x] **D4** Preview's "open" arrow overlay → `/dashboards/<id>` (full page).
|
||||
- [x] **D5** "New Dashboard" → create form appears; `?selected=` cleared from the URL.
|
||||
- [x] **D6** Create a dashboard → navigates to the full `/dashboards/<id>`.
|
||||
- [!] **D7** Edit (pencil) → edit form appears; does **not** navigate.
|
||||
- [x] **D8** Save an edit → preview shows the edited dashboard (`?selected=<newid>`).
|
||||
- [x] **D9** Delete → removed; if it was selected, the preview clears.
|
||||
- [x] **D10** Edit/delete buttons do **not** trigger navigation (siblings of the link, not nested).
|
||||
- [x] **D11** A dashboard row **outside** `/dashboards` opens `/dashboards/<id>` directly.
|
||||
- [~] **D12** Mobile: selecting shows the detail panel; back clears 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>`.
|
||||
- [ ] **P2** The preview shows that project.
|
||||
- [ ] **P3** The clicked row is highlighted.
|
||||
- [ ] **P4** Preview "open" arrow → `/projects/<id>` (full page).
|
||||
- [ ] **P5** "New Project" → create form; `?selected=` cleared.
|
||||
- [ ] **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.
|
||||
The Projects feature was removed rather than fixed (see the deletion commit). All 15 P checks are void.
|
||||
|
||||
It had the same two defects Dashboards had — edit clearing `?selected=`, and an abandoned edit following
|
||||
you to the next item — plus the same structure that caused them: the form reachable only through the
|
||||
empty state. None of it was worth repairing for a feature being retired.
|
||||
|
||||
## 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="/dashboards" element={<Dashboard.DashboardsScreen />} />
|
||||
<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/:emailId" element={<Dashboard.EmailScreen />} />
|
||||
<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: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
|
||||
{ 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: 'Desktop', to: '/desktop', icon: MonitorSmartphone, color: '#ec4899' },
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
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 './ChatHistory';
|
||||
export * from './Dashboards';
|
||||
export * from './Projects';
|
||||
export * from './Terminal';
|
||||
export * from './Email';
|
||||
export * from './Browser';
|
||||
|
||||
@@ -19,7 +19,6 @@ const RULES: TitleRule[] = [
|
||||
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
||||
{ match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' },
|
||||
{ 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('/tasks'), title: 'Tasks' },
|
||||
{ match: (p) => p.startsWith('/jobs'), title: 'Jobs' },
|
||||
|
||||
@@ -59,8 +59,6 @@ export {
|
||||
deleteDashboard,
|
||||
upsertScreen,
|
||||
deleteScreen,
|
||||
upsertProject,
|
||||
deleteProject,
|
||||
getDefaults,
|
||||
upsertDefaults,
|
||||
} from './queries/dashboards';
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { dashboards, screens, projects, dashboardDefaults } from '../schema';
|
||||
import { dashboards, screens, dashboardDefaults } from '../schema';
|
||||
|
||||
// ── Full state read ──
|
||||
|
||||
export async function getAllDashboardState(userId: number): Promise<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(screens).where(eq(screens.userId, userId)),
|
||||
db.select().from(projects).where(eq(projects.userId, userId)),
|
||||
db
|
||||
.select()
|
||||
.from(dashboardDefaults)
|
||||
@@ -41,20 +40,6 @@ export async function getAllDashboardState(userId: number): Promise<Record<strin
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
// ── 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 ──
|
||||
|
||||
type UpsertDefaultsData = {
|
||||
|
||||
@@ -36,24 +36,6 @@ export const screens = pgTable(
|
||||
(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', {
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
|
||||
@@ -45,9 +45,6 @@ export type DashboardInsert = typeof Schema.dashboards.$inferInsert;
|
||||
export type ScreenSelect = typeof Schema.screens.$inferSelect;
|
||||
export type ScreenInsert = typeof Schema.screens.$inferInsert;
|
||||
|
||||
export type ProjectSelect = typeof Schema.projects.$inferSelect;
|
||||
export type ProjectInsert = typeof Schema.projects.$inferInsert;
|
||||
|
||||
// ── Operations ──
|
||||
|
||||
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 { getUserProjectsDir } from '@@/data-path';
|
||||
import {
|
||||
getAllDashboardState,
|
||||
upsertDashboard,
|
||||
deleteDashboard,
|
||||
upsertScreen,
|
||||
deleteScreen,
|
||||
upsertProject,
|
||||
deleteProject,
|
||||
upsertDefaults,
|
||||
} from 'officerdb';
|
||||
|
||||
const TEMPLATES_DIR = resolve(import.meta.dir, '../../../../seed/project-templates');
|
||||
|
||||
export const dashboardsRouter = createRouter();
|
||||
|
||||
// GET /dashboards
|
||||
@@ -93,70 +85,6 @@ dashboardsRouter.patch('/', async (ctx) => {
|
||||
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);
|
||||
|
||||
@@ -308,7 +308,7 @@ export const googleCallbackHandler = async (ctx: any) => {
|
||||
// Auto-add /email to dock
|
||||
try {
|
||||
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')) {
|
||||
paths.push('/email');
|
||||
|
||||
@@ -362,7 +362,7 @@ const emailSyncHandler: JobHandler = {
|
||||
try {
|
||||
const paths = await getDockPaths(meta.account.userId);
|
||||
if (!paths) {
|
||||
const defaults = ['/', '/files', '/automation', '/projects', '/dashboards', '/chat'];
|
||||
const defaults = ['/', '/files', '/automation', '/dashboards', '/chat'];
|
||||
await setDockPaths(meta.account.userId, [...defaults, '/email']);
|
||||
} else if (!paths.includes('/email')) {
|
||||
await setDockPaths(meta.account.userId, [...paths, '/email']);
|
||||
|
||||
@@ -694,7 +694,7 @@ const gmailSyncHandler: JobHandler = {
|
||||
if (dbUser) {
|
||||
const paths = await getDockPaths(dbUser.id);
|
||||
if (!paths) {
|
||||
const defaults = ['/', '/files', '/automation', '/projects', '/dashboards', '/chat'];
|
||||
const defaults = ['/', '/files', '/automation', '/dashboards', '/chat'];
|
||||
await setDockPaths(dbUser.id, [...defaults, '/email']);
|
||||
} else if (!paths.includes('/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 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