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:
2026-07-30 08:16:27 +00:00
co-authored by Claude Opus 5
parent 4970e7e70f
commit 5a1e3d7e0b
27 changed files with 95 additions and 1411 deletions
+18 -28
View File
@@ -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
+67
View File
@@ -0,0 +1,67 @@
# Remaining tests — run these yourself
**6 checks left: X1X3 and R1R3.** 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`.
-3
View File
@@ -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' },
-2
View File
@@ -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')
-3
View File
@@ -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;
-72
View File
@@ -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);
+1 -1
View File
@@ -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');
+1 -1
View File
@@ -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']);
+1 -1
View File
@@ -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>>(
-14
View File
@@ -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';