@atercates/claude-deck 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/app/api/sessions/[id]/fork/route.ts +0 -1
  2. package/app/api/sessions/[id]/route.ts +0 -5
  3. package/app/api/sessions/[id]/summarize/route.ts +2 -3
  4. package/app/api/sessions/route.ts +2 -11
  5. package/app/api/sessions/status/acknowledge/route.ts +8 -0
  6. package/app/api/sessions/status/route.ts +2 -233
  7. package/app/page.tsx +6 -13
  8. package/components/ClaudeProjects/ClaudeProjectCard.tsx +19 -31
  9. package/components/ClaudeProjects/ClaudeSessionCard.tsx +20 -31
  10. package/components/NewSessionDialog/AdvancedSettings.tsx +3 -12
  11. package/components/NewSessionDialog/NewSessionDialog.types.ts +0 -10
  12. package/components/NewSessionDialog/ProjectSelector.tsx +2 -7
  13. package/components/NewSessionDialog/hooks/useNewSessionForm.ts +3 -36
  14. package/components/NewSessionDialog/index.tsx +0 -7
  15. package/components/Pane/DesktopTabBar.tsx +62 -28
  16. package/components/Pane/index.tsx +5 -0
  17. package/components/Projects/index.ts +0 -1
  18. package/components/QuickSwitcher.tsx +63 -11
  19. package/components/SessionList/ActiveSessionsSection.tsx +116 -0
  20. package/components/SessionList/hooks/useSessionListMutations.ts +0 -35
  21. package/components/SessionList/index.tsx +9 -1
  22. package/components/SessionStatusBar.tsx +155 -0
  23. package/components/WaitingBanner.tsx +122 -0
  24. package/components/views/DesktopView.tsx +27 -8
  25. package/components/views/MobileView.tsx +6 -1
  26. package/components/views/types.ts +2 -0
  27. package/data/sessions/index.ts +0 -1
  28. package/data/sessions/queries.ts +1 -27
  29. package/data/statuses/queries.ts +68 -34
  30. package/hooks/useSessions.ts +0 -12
  31. package/lib/claude/watcher.ts +28 -5
  32. package/lib/db/queries.ts +4 -64
  33. package/lib/db/types.ts +0 -8
  34. package/lib/hooks/reporter.ts +116 -0
  35. package/lib/hooks/setup.ts +164 -0
  36. package/lib/orchestration.ts +16 -23
  37. package/lib/providers/registry.ts +3 -57
  38. package/lib/providers.ts +19 -100
  39. package/lib/status-monitor.ts +303 -0
  40. package/package.json +1 -1
  41. package/server.ts +5 -1
  42. package/app/api/groups/[...path]/route.ts +0 -136
  43. package/app/api/groups/route.ts +0 -93
  44. package/components/NewSessionDialog/AgentSelector.tsx +0 -37
  45. package/components/Projects/ProjectCard.tsx +0 -276
  46. package/components/TmuxSessions.tsx +0 -132
  47. package/data/groups/index.ts +0 -1
  48. package/data/groups/mutations.ts +0 -95
  49. package/hooks/useGroups.ts +0 -37
  50. package/hooks/useKeybarVisibility.ts +0 -42
  51. package/lib/claude/process-manager.ts +0 -278
  52. package/lib/status-detector.ts +0 -375
package/lib/providers.ts CHANGED
@@ -1,25 +1,9 @@
1
- import {
2
- type ProviderId,
3
- getProviderDefinition,
4
- isValidProviderId,
5
- } from "./providers/registry";
1
+ import type { ProviderId } from "./providers/registry";
6
2
 
7
3
  export type AgentType = ProviderId;
8
4
 
9
- export interface AgentProvider {
10
- id: AgentType;
11
- name: string;
12
- description: string;
13
- command: string;
14
- supportsResume: boolean;
15
- supportsFork: boolean;
16
- buildFlags(options: BuildFlagsOptions): string[];
17
- waitingPatterns: RegExp[];
18
- runningPatterns: RegExp[];
19
- idlePatterns: RegExp[];
20
- getSessionId?: (projectPath: string) => string | null;
21
- configDir: string;
22
- }
5
+ export const CLAUDE_COMMAND = "claude";
6
+ export const CLAUDE_AUTO_APPROVE_FLAG = "--dangerously-skip-permissions";
23
7
 
24
8
  export interface BuildFlagsOptions {
25
9
  sessionId?: string | null;
@@ -30,92 +14,27 @@ export interface BuildFlagsOptions {
30
14
  initialPrompt?: string;
31
15
  }
32
16
 
33
- const SPINNER_CHARS = /⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏/;
34
-
35
- export const claudeProvider: AgentProvider = {
36
- id: "claude",
37
- name: "Claude Code",
38
- description: "Anthropic's official CLI",
39
- command: "claude",
40
- configDir: "~/.claude",
41
- supportsResume: true,
42
- supportsFork: true,
43
-
44
- buildFlags(options: BuildFlagsOptions): string[] {
45
- const def = getProviderDefinition("claude");
46
- const flags: string[] = [];
47
-
48
- if (
49
- (options.skipPermissions || options.autoApprove) &&
50
- def.autoApproveFlag
51
- ) {
52
- flags.push(def.autoApproveFlag);
53
- }
54
-
55
- if (options.sessionId && def.resumeFlag) {
56
- flags.push(`${def.resumeFlag} ${options.sessionId}`);
57
- } else if (options.parentSessionId && def.resumeFlag) {
58
- flags.push(`${def.resumeFlag} ${options.parentSessionId}`);
59
- flags.push("--fork-session");
60
- }
61
-
62
- if (options.initialPrompt?.trim() && def.initialPromptFlag !== undefined) {
63
- const prompt = options.initialPrompt.trim();
64
- const escapedPrompt = prompt.replace(/'/g, "'\\''");
65
- flags.push(`'${escapedPrompt}'`);
66
- }
17
+ export function buildClaudeFlags(options: BuildFlagsOptions): string[] {
18
+ const flags: string[] = [];
67
19
 
68
- return flags;
69
- },
20
+ if (options.skipPermissions || options.autoApprove) {
21
+ flags.push(CLAUDE_AUTO_APPROVE_FLAG);
22
+ }
70
23
 
71
- waitingPatterns: [
72
- /\[Y\/n\]/i,
73
- /\[y\/N\]/i,
74
- /Allow\?/i,
75
- /Approve\?/i,
76
- /Continue\?/i,
77
- /Press Enter/i,
78
- /waiting for/i,
79
- /\(yes\/no\)/i,
80
- /Do you want to/i,
81
- /Esc to cancel/i,
82
- />\s*1\.\s*Yes/,
83
- /Yes, allow all/i,
84
- /allow all edits/i,
85
- /allow all commands/i,
86
- ],
24
+ if (options.sessionId) {
25
+ flags.push("--resume", options.sessionId);
26
+ } else if (options.parentSessionId) {
27
+ flags.push("--resume", options.parentSessionId, "--fork-session");
28
+ }
87
29
 
88
- runningPatterns: [
89
- /thinking/i,
90
- /Working/i,
91
- /Reading/i,
92
- /Writing/i,
93
- /Searching/i,
94
- /Running/i,
95
- /Executing/i,
96
- SPINNER_CHARS,
97
- ],
30
+ if (options.initialPrompt?.trim()) {
31
+ const escapedPrompt = options.initialPrompt.trim().replace(/'/g, "'\\''");
32
+ flags.push(`'${escapedPrompt}'`);
33
+ }
98
34
 
99
- idlePatterns: [/^>\s*$/m, /claude.*>\s*$/im, /✻\s*Sautéed/i, /✻\s*Done/i],
100
- };
101
-
102
- export const providers: Record<AgentType, AgentProvider> = {
103
- claude: claudeProvider,
104
- };
105
-
106
- export function getProvider(agentType: AgentType): AgentProvider {
107
- return providers[agentType] || claudeProvider;
108
- }
109
-
110
- export function getAllProviders(): AgentProvider[] {
111
- return Object.values(providers);
35
+ return flags;
112
36
  }
113
37
 
114
38
  export function isValidAgentType(value: string): value is AgentType {
115
- return isValidProviderId(value);
39
+ return value === "claude";
116
40
  }
117
-
118
- export {
119
- getProviderDefinition,
120
- getAllProviderDefinitions,
121
- } from "./providers/registry";
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Session status monitor — hook-based detection.
3
+ *
4
+ * Claude Code hooks write state files to ~/.claude-deck/session-states/.
5
+ * This module reads those files and pushes updates to the frontend via WebSocket.
6
+ *
7
+ * The only periodic work is `tmux list-sessions` every 3s to detect dead sessions.
8
+ * All state transitions are event-driven via Chokidar watching the state files dir.
9
+ */
10
+
11
+ import { exec } from "child_process";
12
+ import { promisify } from "util";
13
+ import * as fs from "fs";
14
+ import * as path from "path";
15
+ import {
16
+ getManagedSessionPattern,
17
+ getSessionIdFromName,
18
+ getProviderIdFromSessionName,
19
+ } from "./providers/registry";
20
+ import type { AgentType } from "./providers";
21
+ import { broadcast } from "./claude/watcher";
22
+ import { getDb } from "./db";
23
+ import { STATES_DIR } from "./hooks/setup";
24
+ import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
25
+
26
+ const execAsync = promisify(exec);
27
+
28
+ const TICK_INTERVAL_MS = 3000;
29
+ const SESSION_NAME_CACHE_TTL = 10_000;
30
+ const UUID_PATTERN = getManagedSessionPattern();
31
+
32
+ // Cache for session metadata (name + cwd from SDK)
33
+ const sessionMetaCache = new Map<
34
+ string,
35
+ { name: string; cwd: string | null; cachedAt: number }
36
+ >();
37
+
38
+ // --- Types ---
39
+
40
+ export type SessionStatus = "running" | "waiting" | "idle" | "dead";
41
+
42
+ interface StateFile {
43
+ status: "running" | "waiting" | "idle";
44
+ lastLine: string;
45
+ waitingContext?: string;
46
+ ts: number;
47
+ }
48
+
49
+ export interface SessionStatusSnapshot {
50
+ sessionName: string;
51
+ cwd: string | null;
52
+ status: SessionStatus;
53
+ lastLine: string;
54
+ waitingContext?: string;
55
+ claudeSessionId: string | null;
56
+ agentType: AgentType;
57
+ }
58
+
59
+ // --- State ---
60
+
61
+ let currentSnapshot: Record<string, SessionStatusSnapshot> = {};
62
+ let monitorTimer: ReturnType<typeof setInterval> | null = null;
63
+
64
+ // --- State file reading ---
65
+
66
+ function readStateFile(sessionId: string): StateFile | null {
67
+ try {
68
+ const filePath = path.join(STATES_DIR, `${sessionId}.json`);
69
+ const raw = fs.readFileSync(filePath, "utf-8");
70
+ return JSON.parse(raw);
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ function listStateFiles(): Map<string, StateFile> {
77
+ const map = new Map<string, StateFile>();
78
+ try {
79
+ for (const file of fs.readdirSync(STATES_DIR)) {
80
+ if (!file.endsWith(".json")) continue;
81
+ const sessionId = file.replace(".json", "");
82
+ const state = readStateFile(sessionId);
83
+ if (state) map.set(sessionId, state);
84
+ }
85
+ } catch {
86
+ // dir may not exist yet
87
+ }
88
+ return map;
89
+ }
90
+
91
+ // --- tmux ---
92
+
93
+ async function listTmuxSessions(): Promise<Map<string, string>> {
94
+ // Returns Map<sessionId, sessionName>
95
+ try {
96
+ const { stdout } = await execAsync(
97
+ "tmux list-sessions -F '#{session_name}' 2>/dev/null || echo \"\""
98
+ );
99
+ const map = new Map<string, string>();
100
+ for (const name of stdout.trim().split("\n")) {
101
+ if (!name || !UUID_PATTERN.test(name)) continue;
102
+ map.set(getSessionIdFromName(name), name);
103
+ }
104
+ return map;
105
+ } catch {
106
+ return new Map();
107
+ }
108
+ }
109
+
110
+ // --- Session metadata resolution ---
111
+
112
+ async function resolveSessionMeta(
113
+ sessionId: string
114
+ ): Promise<{ name: string; cwd: string | null }> {
115
+ const cached = sessionMetaCache.get(sessionId);
116
+ if (cached && Date.now() - cached.cachedAt < SESSION_NAME_CACHE_TTL) {
117
+ return { name: cached.name, cwd: cached.cwd };
118
+ }
119
+
120
+ try {
121
+ const info = await getSessionInfo(sessionId);
122
+ if (info) {
123
+ const name = info.customTitle || info.summary || sessionId.slice(0, 8);
124
+ const cwd = info.cwd || null;
125
+ sessionMetaCache.set(sessionId, { name, cwd, cachedAt: Date.now() });
126
+ return { name, cwd };
127
+ }
128
+ } catch {
129
+ // SDK lookup failed — use short ID
130
+ }
131
+
132
+ const fallback = sessionId.slice(0, 8);
133
+ sessionMetaCache.set(sessionId, {
134
+ name: fallback,
135
+ cwd: null,
136
+ cachedAt: Date.now(),
137
+ });
138
+ return { name: fallback, cwd: null };
139
+ }
140
+
141
+ // --- Snapshot building ---
142
+
143
+ async function buildSnapshot(
144
+ tmuxSessions: Map<string, string>,
145
+ stateFiles: Map<string, StateFile>
146
+ ): Promise<Record<string, SessionStatusSnapshot>> {
147
+ const snap: Record<string, SessionStatusSnapshot> = {};
148
+
149
+ const entries = await Promise.all(
150
+ [...tmuxSessions.entries()].map(async ([sessionId, tmuxName]) => {
151
+ const meta = await resolveSessionMeta(sessionId);
152
+ return { sessionId, tmuxName, ...meta };
153
+ })
154
+ );
155
+
156
+ for (const { sessionId, tmuxName, name, cwd } of entries) {
157
+ const agentType = getProviderIdFromSessionName(tmuxName) || "claude";
158
+ const state = stateFiles.get(sessionId);
159
+
160
+ snap[sessionId] = {
161
+ sessionName: name,
162
+ cwd,
163
+ status: state?.status || "idle",
164
+ lastLine: state?.lastLine || "",
165
+ ...(state?.status === "waiting" && state.waitingContext
166
+ ? { waitingContext: state.waitingContext }
167
+ : {}),
168
+ claudeSessionId: sessionId,
169
+ agentType,
170
+ };
171
+ }
172
+
173
+ // Filter out hidden sessions
174
+ try {
175
+ const db = getDb();
176
+ const hiddenRows = db
177
+ .prepare("SELECT item_id FROM hidden_items WHERE item_type = 'session'")
178
+ .all() as { item_id: string }[];
179
+ for (const { item_id } of hiddenRows) {
180
+ delete snap[item_id];
181
+ }
182
+ } catch {
183
+ // DB errors shouldn't break the monitor
184
+ }
185
+
186
+ return snap;
187
+ }
188
+
189
+ function snapshotChanged(
190
+ prev: Record<string, SessionStatusSnapshot>,
191
+ next: Record<string, SessionStatusSnapshot>
192
+ ): boolean {
193
+ const prevKeys = Object.keys(prev);
194
+ const nextKeys = Object.keys(next);
195
+ if (prevKeys.length !== nextKeys.length) return true;
196
+ for (const id of nextKeys) {
197
+ const p = prev[id];
198
+ const n = next[id];
199
+ if (
200
+ !p ||
201
+ p.status !== n.status ||
202
+ p.lastLine !== n.lastLine ||
203
+ p.sessionName !== n.sessionName
204
+ )
205
+ return true;
206
+ }
207
+ return false;
208
+ }
209
+
210
+ function updateDb(
211
+ prev: Record<string, SessionStatusSnapshot>,
212
+ next: Record<string, SessionStatusSnapshot>
213
+ ): void {
214
+ try {
215
+ const db = getDb();
216
+ for (const [id, snap] of Object.entries(next)) {
217
+ if (prev[id]?.status === snap.status) continue;
218
+ db.prepare(
219
+ "UPDATE sessions SET updated_at = datetime('now') WHERE id = ?"
220
+ ).run(id);
221
+ if (snap.claudeSessionId) {
222
+ db.prepare(
223
+ "UPDATE sessions SET claude_session_id = ? WHERE id = ? AND (claude_session_id IS NULL OR claude_session_id != ?)"
224
+ ).run(snap.claudeSessionId, id, snap.claudeSessionId);
225
+ }
226
+ }
227
+ } catch {
228
+ // DB errors shouldn't break the monitor
229
+ }
230
+ }
231
+
232
+ // --- Core tick (only for dead detection + state file sync) ---
233
+
234
+ async function tick(): Promise<void> {
235
+ const tmuxSessions = await listTmuxSessions();
236
+ const stateFiles = listStateFiles();
237
+
238
+ // Clean up state files for sessions that no longer exist in tmux
239
+ for (const sessionId of stateFiles.keys()) {
240
+ if (!tmuxSessions.has(sessionId)) {
241
+ try {
242
+ fs.unlinkSync(path.join(STATES_DIR, `${sessionId}.json`));
243
+ } catch {
244
+ // ignore
245
+ }
246
+ }
247
+ }
248
+
249
+ const newSnapshot = await buildSnapshot(tmuxSessions, stateFiles);
250
+
251
+ if (snapshotChanged(currentSnapshot, newSnapshot)) {
252
+ updateDb(currentSnapshot, newSnapshot);
253
+ currentSnapshot = newSnapshot;
254
+ broadcast({ type: "session-statuses", statuses: newSnapshot });
255
+ }
256
+ }
257
+
258
+ // --- Public API ---
259
+
260
+ export function getStatusSnapshot(): Record<string, SessionStatusSnapshot> {
261
+ return currentSnapshot;
262
+ }
263
+
264
+ export function acknowledge(_sessionName: string): void {
265
+ // With hook-based detection, acknowledge is a no-op.
266
+ // Status is determined by Claude Code's hook events, not by us.
267
+ }
268
+
269
+ /**
270
+ * Called by Chokidar when a state file in ~/.claude-deck/session-states/ changes.
271
+ * Triggers an immediate re-read and broadcast.
272
+ */
273
+ export function onStateFileChange(): void {
274
+ tick().catch(console.error);
275
+ }
276
+
277
+ export function invalidateSessionName(sessionId: string): void {
278
+ sessionMetaCache.delete(sessionId);
279
+ }
280
+
281
+ export function startStatusMonitor(): void {
282
+ if (monitorTimer) return;
283
+
284
+ // Ensure states directory exists
285
+ fs.mkdirSync(STATES_DIR, { recursive: true });
286
+
287
+ // Initial tick
288
+ setTimeout(() => tick().catch(console.error), 500);
289
+
290
+ // Periodic fallback (catches tmux session death, missed events)
291
+ monitorTimer = setInterval(() => {
292
+ tick().catch(console.error);
293
+ }, TICK_INTERVAL_MS);
294
+
295
+ console.log("> Status monitor started (hook-based, 3s fallback tick)");
296
+ }
297
+
298
+ export function stopStatusMonitor(): void {
299
+ if (monitorTimer) {
300
+ clearInterval(monitorTimer);
301
+ monitorTimer = null;
302
+ }
303
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atercates/claude-deck",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "claude-deck": "./scripts/claude-deck"
package/server.ts CHANGED
@@ -5,6 +5,8 @@ import { WebSocketServer, WebSocket } from "ws";
5
5
  import * as pty from "node-pty";
6
6
  import { initDb } from "./lib/db";
7
7
  import { startWatcher, addUpdateClient } from "./lib/claude/watcher";
8
+ import { startStatusMonitor } from "./lib/status-monitor";
9
+ import { setupHooks } from "./lib/hooks/setup";
8
10
  import {
9
11
  validateSession,
10
12
  parseCookies,
@@ -13,7 +15,7 @@ import {
13
15
  } from "./lib/auth";
14
16
 
15
17
  const dev = process.env.NODE_ENV !== "production";
16
- const hostname = "0.0.0.0";
18
+ const hostname = process.env.HOST || (dev ? "localhost" : "0.0.0.0");
17
19
 
18
20
  // Support: npm run dev -- -p 3012
19
21
  const pFlagIndex = process.argv.indexOf("-p");
@@ -166,7 +168,9 @@ app.prepare().then(async () => {
166
168
  await initDb();
167
169
  console.log("> Database initialized");
168
170
 
171
+ setupHooks();
169
172
  startWatcher();
173
+ startStatusMonitor();
170
174
 
171
175
  server.listen(port, () => {
172
176
  console.log(`> ClaudeDeck ready on http://${hostname}:${port}`);
@@ -1,136 +0,0 @@
1
- import { NextResponse } from "next/server";
2
- import { queries, type Group } from "@/lib/db";
3
-
4
- // GET /api/groups/[...path] - Get a single group
5
- export async function GET(
6
- request: Request,
7
- { params }: { params: Promise<{ path: string[] }> }
8
- ) {
9
- const { path: pathParts } = await params;
10
- const path = pathParts.join("/");
11
-
12
- try {
13
- const group = await queries.getGroup(path) as Group | undefined;
14
-
15
- if (!group) {
16
- return NextResponse.json({ error: "Group not found" }, { status: 404 });
17
- }
18
-
19
- return NextResponse.json({
20
- group: { ...group, expanded: Boolean(group.expanded) },
21
- });
22
- } catch (error) {
23
- console.error("Error fetching group:", error);
24
- return NextResponse.json(
25
- { error: "Failed to fetch group" },
26
- { status: 500 }
27
- );
28
- }
29
- }
30
-
31
- // PATCH /api/groups/[...path] - Update group (name, expanded, order)
32
- export async function PATCH(
33
- request: Request,
34
- { params }: { params: Promise<{ path: string[] }> }
35
- ) {
36
- const { path: pathParts } = await params;
37
- const path = pathParts.join("/");
38
-
39
- try {
40
- const body = await request.json();
41
- const { name, expanded, sort_order } = body;
42
-
43
- // Check if group exists
44
- const group = await queries.getGroup(path) as Group | undefined;
45
- if (!group) {
46
- return NextResponse.json({ error: "Group not found" }, { status: 404 });
47
- }
48
-
49
- // Protect default group from being renamed
50
- if (path === "sessions" && name !== undefined && name !== "Sessions") {
51
- return NextResponse.json(
52
- { error: "Cannot rename the default group" },
53
- { status: 400 }
54
- );
55
- }
56
-
57
- // Update name
58
- if (name !== undefined) {
59
- await queries.updateGroupName(name, path);
60
- }
61
-
62
- // Update expanded state
63
- if (expanded !== undefined) {
64
- await queries.updateGroupExpanded(!!expanded, path);
65
- }
66
-
67
- // Update sort order
68
- if (sort_order !== undefined) {
69
- await queries.updateGroupOrder(sort_order, path);
70
- }
71
-
72
- const updatedGroup = await queries.getGroup(path) as Group;
73
- return NextResponse.json({
74
- group: { ...updatedGroup, expanded: Boolean(updatedGroup.expanded) },
75
- });
76
- } catch (error) {
77
- console.error("Error updating group:", error);
78
- return NextResponse.json(
79
- { error: "Failed to update group" },
80
- { status: 500 }
81
- );
82
- }
83
- }
84
-
85
- // DELETE /api/groups/[...path] - Delete group (moves sessions to parent or default)
86
- export async function DELETE(
87
- request: Request,
88
- { params }: { params: Promise<{ path: string[] }> }
89
- ) {
90
- const { path: pathParts } = await params;
91
- const path = pathParts.join("/");
92
-
93
- try {
94
- // Protect default group
95
- if (path === "sessions") {
96
- return NextResponse.json(
97
- { error: "Cannot delete the default group" },
98
- { status: 400 }
99
- );
100
- }
101
-
102
- // Check if group exists
103
- const group = await queries.getGroup(path) as Group | undefined;
104
- if (!group) {
105
- return NextResponse.json({ error: "Group not found" }, { status: 404 });
106
- }
107
-
108
- // Find parent group or use default
109
- const pathParts2 = path.split("/");
110
- pathParts2.pop();
111
- const parentPath =
112
- pathParts2.length > 0 ? pathParts2.join("/") : "sessions";
113
-
114
- // Move all sessions in this group to parent
115
- await queries.moveSessionsToGroup(parentPath, path);
116
-
117
- // Also move sessions from any subgroups
118
- const allGroups = await queries.getAllGroups() as Group[];
119
- const subgroups = allGroups.filter((g) => g.path.startsWith(path + "/"));
120
- for (const subgroup of subgroups) {
121
- await queries.moveSessionsToGroup(parentPath, subgroup.path);
122
- await queries.deleteGroup(subgroup.path);
123
- }
124
-
125
- // Delete the group
126
- await queries.deleteGroup(path);
127
-
128
- return NextResponse.json({ success: true, movedTo: parentPath });
129
- } catch (error) {
130
- console.error("Error deleting group:", error);
131
- return NextResponse.json(
132
- { error: "Failed to delete group" },
133
- { status: 500 }
134
- );
135
- }
136
- }
@@ -1,93 +0,0 @@
1
- import { NextResponse } from "next/server";
2
- import { queries, type Group } from "@/lib/db";
3
-
4
- // GET /api/groups - List all groups
5
- export async function GET() {
6
- try {
7
- const groups = await queries.getAllGroups() as Group[];
8
-
9
- // Convert expanded from 0/1 to boolean
10
- const formattedGroups = groups.map((g) => ({
11
- ...g,
12
- expanded: Boolean(g.expanded),
13
- }));
14
-
15
- return NextResponse.json({ groups: formattedGroups });
16
- } catch (error) {
17
- console.error("Error fetching groups:", error);
18
- return NextResponse.json(
19
- { error: "Failed to fetch groups" },
20
- { status: 500 }
21
- );
22
- }
23
- }
24
-
25
- // POST /api/groups - Create a new group
26
- export async function POST(request: Request) {
27
- try {
28
- const body = await request.json();
29
- const { name, parentPath } = body;
30
-
31
- if (!name || typeof name !== "string") {
32
- return NextResponse.json({ error: "Name is required" }, { status: 400 });
33
- }
34
-
35
- // Sanitize name to create path
36
- const sanitizedName = name
37
- .toLowerCase()
38
- .replace(/[^a-z0-9-]/g, "-")
39
- .replace(/-+/g, "-")
40
- .replace(/^-|-$/g, "");
41
-
42
- if (!sanitizedName) {
43
- return NextResponse.json(
44
- { error: "Invalid group name" },
45
- { status: 400 }
46
- );
47
- }
48
-
49
- // Build full path
50
- const path = parentPath ? `${parentPath}/${sanitizedName}` : sanitizedName;
51
-
52
- // Check if group already exists
53
- const existing = await queries.getGroup(path) as Group | undefined;
54
- if (existing) {
55
- return NextResponse.json(
56
- { error: "Group already exists", group: existing },
57
- { status: 409 }
58
- );
59
- }
60
-
61
- // If parent path specified, ensure parent exists
62
- if (parentPath) {
63
- const parent = await queries.getGroup(parentPath) as Group | undefined;
64
- if (!parent) {
65
- return NextResponse.json(
66
- { error: "Parent group does not exist" },
67
- { status: 400 }
68
- );
69
- }
70
- }
71
-
72
- // Get max sort order for new group
73
- const groups = await queries.getAllGroups() as Group[];
74
- const maxOrder = groups.reduce((max, g) => Math.max(max, g.sort_order), 0);
75
-
76
- // Create the group
77
- await queries.createGroup(path, name, maxOrder + 1);
78
-
79
- const newGroup = await queries.getGroup(path) as Group;
80
- return NextResponse.json(
81
- {
82
- group: { ...newGroup, expanded: Boolean(newGroup.expanded) },
83
- },
84
- { status: 201 }
85
- );
86
- } catch (error) {
87
- console.error("Error creating group:", error);
88
- return NextResponse.json(
89
- { error: "Failed to create group" },
90
- { status: 500 }
91
- );
92
- }
93
- }