@leanspec/ui 0.2.5-dev.20251119032109 → 0.2.5-dev.20251120015656
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.
- package/.next/standalone/node_modules/.pnpm/tiktoken@1.0.22/node_modules/tiktoken/tiktoken_bg.wasm +0 -0
- package/.next/standalone/packages/ui/.next/BUILD_ID +1 -1
- package/.next/standalone/packages/ui/.next/app-path-routes-manifest.json +1 -0
- package/.next/standalone/packages/ui/.next/build-manifest.json +2 -2
- package/.next/standalone/packages/ui/.next/prerender-manifest.json +3 -3
- package/.next/standalone/packages/ui/.next/routes-manifest.json +8 -0
- package/.next/standalone/packages/ui/.next/server/app/_global-error/page.js +1 -1
- package/.next/standalone/packages/ui/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/_global-error.html +2 -2
- package/.next/standalone/packages/ui/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/packages/ui/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/packages/ui/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/packages/ui/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/packages/ui/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/packages/ui/.next/server/app/_not-found/page.js +2 -2
- package/.next/standalone/packages/ui/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/ui/.next/server/app/_not-found.html +2 -2
- package/.next/standalone/packages/ui/.next/server/app/_not-found.rsc +16 -16
- package/.next/standalone/packages/ui/.next/server/app/_not-found.segments/_full.segment.rsc +16 -16
- package/.next/standalone/packages/ui/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
- package/.next/standalone/packages/ui/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +3 -3
- package/.next/standalone/packages/ui/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/standalone/packages/ui/.next/server/app/_not-found.segments/_tree.segment.rsc +5 -5
- package/.next/standalone/packages/ui/.next/server/app/api/projects/[id]/specs/route.js +1 -1
- package/.next/standalone/packages/ui/.next/server/app/api/projects/[id]/specs/route.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/api/projects/route.js +1 -1
- package/.next/standalone/packages/ui/.next/server/app/api/projects/route.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/api/revalidate/route.js +1 -1
- package/.next/standalone/packages/ui/.next/server/app/api/revalidate/route.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/dependency-graph/route.js +1 -1
- package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/dependency-graph/route.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/route.js +1 -1
- package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/route.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/status/route/app-paths-manifest.json +3 -0
- package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/status/route/build-manifest.json +11 -0
- package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/status/route/server-reference-manifest.json +4 -0
- package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/status/route.js +7 -0
- package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/status/route.js.map +5 -0
- package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/status/route.js.nft.json +1 -0
- package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/status/route_client-reference-manifest.js +2 -0
- package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/subspecs/[file]/route.js +1 -1
- package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/subspecs/[file]/route.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/api/stats/route.js +1 -1
- package/.next/standalone/packages/ui/.next/server/app/api/stats/route.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/page.js +2 -2
- package/.next/standalone/packages/ui/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/ui/.next/server/app/specs/[id]/page.js +2 -2
- package/.next/standalone/packages/ui/.next/server/app/specs/[id]/page.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/specs/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/ui/.next/server/app/specs/page.js +2 -2
- package/.next/standalone/packages/ui/.next/server/app/specs/page.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/specs/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/ui/.next/server/app/stats/page.js +2 -2
- package/.next/standalone/packages/ui/.next/server/app/stats/page.js.nft.json +1 -1
- package/.next/standalone/packages/ui/.next/server/app/stats/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/ui/.next/server/app-paths-manifest.json +1 -0
- package/.next/standalone/packages/ui/.next/server/chunks/730ea_ui__next-internal_server_app_api_specs_[id]_status_route_actions_5d700407.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/{[root-of-the-server]__d1d92c7c._.js → [root-of-the-server]__175bef84._.js} +2 -2
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__3971eae5._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__6bca1621._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__87a3475a._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__9f0f4c0b._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__c1c9f5f5._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/{[root-of-the-server]__e09ce4bf._.js → [root-of-the-server]__c6689757._.js} +2 -2
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__cd1fb0a2._.js +4 -0
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__e2071b2e._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/e0876_tiktoken_3efea2dc._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/[root-of-the-server]__1d0c2012._.js +1 -1
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/{[root-of-the-server]__c65aedd0._.js → [root-of-the-server]__299c81cc._.js} +2 -2
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/{[root-of-the-server]__b3633d6e._.js → [root-of-the-server]__41f5b5c0._.js} +2 -2
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/{[root-of-the-server]__a9d7fd42._.js → [root-of-the-server]__5ca2e973._.js} +2 -2
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/[root-of-the-server]__7f55abaa._.js +1 -1
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/[root-of-the-server]__b2fe773d._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/[root-of-the-server]__e79a982a._.js +7 -0
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/{[root-of-the-server]__6e89d129._.js → [root-of-the-server]__ff03fc1e._.js} +2 -2
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/_000dd317._.js +1 -1
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/_6cd9a5e0._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/_b483c9fe._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/_b5040d31._.js +5 -0
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/_e1889307._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/packages_ui_src_app_specs_specs-client_tsx_0bb8f8f8._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/packages_ui_src_components_specs-nav-sidebar_tsx_8237ed13._.js +3 -0
- package/.next/standalone/packages/ui/.next/server/pages/404.html +2 -2
- package/.next/standalone/packages/ui/.next/server/pages/500.html +2 -2
- package/.next/standalone/packages/ui/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/packages/ui/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/packages/ui/.next/static/chunks/093ea4b175adb770.js +1 -0
- package/.next/standalone/packages/ui/.next/static/chunks/{f2151fb88f8fc70a.js → 3fc323b284db714f.js} +1 -1
- package/.next/standalone/packages/ui/.next/static/chunks/5f97d665a4d9dc62.js +3 -0
- package/.next/standalone/packages/ui/.next/static/chunks/9b7b7024c39c5f99.js +1 -0
- package/.next/standalone/packages/ui/.next/static/chunks/c61cbb6a0ba3b6ab.js +1 -0
- package/.next/standalone/packages/ui/.next/static/chunks/cff894805981b496.css +1 -0
- package/.next/standalone/packages/ui/.next/static/chunks/d79bf953f0dfb650.js +1 -0
- package/.next/standalone/packages/ui/.next/static/chunks/e50fb8d0b728cd35.js +5 -0
- package/.next/standalone/packages/ui/.next/static/chunks/e6e238dbf1d3e740.js +1 -0
- package/.next/standalone/packages/ui/package.json +1 -1
- package/.next/standalone/packages/ui/src/app/api/specs/[id]/status/route.ts +122 -0
- package/.next/standalone/packages/ui/src/app/globals.css +10 -0
- package/.next/standalone/packages/ui/src/app/specs/page.tsx +2 -2
- package/.next/standalone/packages/ui/src/app/specs/specs-client.tsx +489 -214
- package/.next/standalone/packages/ui/src/lib/db/service-queries.ts +23 -0
- package/.next/standalone/packages/ui/src/lib/specs/sources/filesystem-source.ts +46 -6
- package/.next/standalone/packages/ui/tsconfig.json +2 -1
- package/.next/standalone/packages/ui/tsconfig.tsbuildinfo +1 -1
- package/.next/standalone/packages/ui/vitest.config.ts +1 -0
- package/.next/static/chunks/093ea4b175adb770.js +1 -0
- package/.next/static/chunks/{f2151fb88f8fc70a.js → 3fc323b284db714f.js} +1 -1
- package/.next/static/chunks/5f97d665a4d9dc62.js +3 -0
- package/.next/static/chunks/9b7b7024c39c5f99.js +1 -0
- package/.next/static/chunks/c61cbb6a0ba3b6ab.js +1 -0
- package/.next/static/chunks/cff894805981b496.css +1 -0
- package/.next/static/chunks/d79bf953f0dfb650.js +1 -0
- package/.next/static/chunks/e50fb8d0b728cd35.js +5 -0
- package/.next/static/chunks/e6e238dbf1d3e740.js +1 -0
- package/package.json +1 -1
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__15e8a946._.js +0 -3
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__47b8cab4._.js +0 -3
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__9fda1bdb._.js +0 -3
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__a2c55ffb._.js +0 -3
- package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__d4193584._.js +0 -3
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/[root-of-the-server]__68015eee._.js +0 -3
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/[root-of-the-server]__8ae28367._.js +0 -7
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/_a527620e._.js +0 -3
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/_d7e4bd50._.js +0 -5
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/_f8b2190a._.js +0 -3
- package/.next/standalone/packages/ui/.next/server/chunks/ssr/packages_ui_src_components_ui_input_tsx_190a7701._.js +0 -3
- package/.next/standalone/packages/ui/.next/static/chunks/16b2f4261d3a374a.css +0 -1
- package/.next/standalone/packages/ui/.next/static/chunks/17ac52d909def83e.js +0 -1
- package/.next/standalone/packages/ui/.next/static/chunks/3385e291c49e782d.js +0 -1
- package/.next/standalone/packages/ui/.next/static/chunks/5057c3e1e7e13ca3.js +0 -1
- package/.next/standalone/packages/ui/.next/static/chunks/88f695d32910a4ac.js +0 -1
- package/.next/standalone/packages/ui/.next/static/chunks/dde5d8bddd7fbfcf.js +0 -1
- package/.next/standalone/packages/ui/.next/static/chunks/e237e00fd3a84178.js +0 -3
- package/.next/standalone/packages/ui/.next/static/chunks/ee4245fe6ba3d6d3.js +0 -5
- package/.next/static/chunks/16b2f4261d3a374a.css +0 -1
- package/.next/static/chunks/17ac52d909def83e.js +0 -1
- package/.next/static/chunks/3385e291c49e782d.js +0 -1
- package/.next/static/chunks/5057c3e1e7e13ca3.js +0 -1
- package/.next/static/chunks/88f695d32910a4ac.js +0 -1
- package/.next/static/chunks/dde5d8bddd7fbfcf.js +0 -1
- package/.next/static/chunks/e237e00fd3a84178.js +0 -3
- package/.next/static/chunks/ee4245fe6ba3d6d3.js +0 -5
- /package/.next/standalone/packages/ui/.next/static/{uj2-H8ytNCFa611gjl5uE → aJb7D9JtV_zRcRPZ0yHNE}/_buildManifest.js +0 -0
- /package/.next/standalone/packages/ui/.next/static/{uj2-H8ytNCFa611gjl5uE → aJb7D9JtV_zRcRPZ0yHNE}/_clientMiddlewareManifest.json +0 -0
- /package/.next/standalone/packages/ui/.next/static/{uj2-H8ytNCFa611gjl5uE → aJb7D9JtV_zRcRPZ0yHNE}/_ssgManifest.js +0 -0
- /package/.next/static/{uj2-H8ytNCFa611gjl5uE → aJb7D9JtV_zRcRPZ0yHNE}/_buildManifest.js +0 -0
- /package/.next/static/{uj2-H8ytNCFa611gjl5uE → aJb7D9JtV_zRcRPZ0yHNE}/_clientMiddlewareManifest.json +0 -0
- /package/.next/static/{uj2-H8ytNCFa611gjl5uE → aJb7D9JtV_zRcRPZ0yHNE}/_ssgManifest.js +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useMemo, useEffect, useRef } from 'react';
|
|
3
|
+
import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
|
4
|
+
import type { DragEvent } from 'react';
|
|
4
5
|
import Link from 'next/link';
|
|
5
6
|
import { useSearchParams, useRouter } from 'next/navigation';
|
|
6
7
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
@@ -15,26 +16,81 @@ import {
|
|
|
15
16
|
SelectValue
|
|
16
17
|
} from '@/components/ui/select';
|
|
17
18
|
import {
|
|
18
|
-
Search,
|
|
19
|
-
CheckCircle2,
|
|
20
|
-
PlayCircle,
|
|
19
|
+
Search,
|
|
20
|
+
CheckCircle2,
|
|
21
|
+
PlayCircle,
|
|
21
22
|
Clock,
|
|
23
|
+
Archive,
|
|
22
24
|
LayoutGrid,
|
|
23
|
-
List as ListIcon
|
|
25
|
+
List as ListIcon,
|
|
26
|
+
FileText,
|
|
27
|
+
GitBranch,
|
|
28
|
+
Maximize2,
|
|
29
|
+
Minimize2
|
|
24
30
|
} from 'lucide-react';
|
|
25
31
|
import { StatusBadge } from '@/components/status-badge';
|
|
26
32
|
import { PriorityBadge } from '@/components/priority-badge';
|
|
27
33
|
import { cn } from '@/lib/utils';
|
|
34
|
+
import { formatRelativeTime } from '@/lib/date-utils';
|
|
35
|
+
import { toast } from '@/components/ui/toast';
|
|
36
|
+
|
|
37
|
+
type SpecStatus = 'planned' | 'in-progress' | 'complete' | 'archived';
|
|
38
|
+
|
|
39
|
+
const STATUS_CONFIG: Record<SpecStatus, {
|
|
40
|
+
icon: typeof Clock;
|
|
41
|
+
title: string;
|
|
42
|
+
colorClass: string;
|
|
43
|
+
bgClass: string;
|
|
44
|
+
borderClass: string;
|
|
45
|
+
}> = {
|
|
46
|
+
'planned': {
|
|
47
|
+
icon: Clock,
|
|
48
|
+
title: 'Planned',
|
|
49
|
+
colorClass: 'text-blue-600 dark:text-blue-400',
|
|
50
|
+
bgClass: 'bg-blue-50 dark:bg-blue-900/20',
|
|
51
|
+
borderClass: 'border-blue-200 dark:border-blue-800'
|
|
52
|
+
},
|
|
53
|
+
'in-progress': {
|
|
54
|
+
icon: PlayCircle,
|
|
55
|
+
title: 'In Progress',
|
|
56
|
+
colorClass: 'text-orange-600 dark:text-orange-400',
|
|
57
|
+
bgClass: 'bg-orange-50 dark:bg-orange-900/20',
|
|
58
|
+
borderClass: 'border-orange-200 dark:border-orange-800'
|
|
59
|
+
},
|
|
60
|
+
'complete': {
|
|
61
|
+
icon: CheckCircle2,
|
|
62
|
+
title: 'Complete',
|
|
63
|
+
colorClass: 'text-green-600 dark:text-green-400',
|
|
64
|
+
bgClass: 'bg-green-50 dark:bg-green-900/20',
|
|
65
|
+
borderClass: 'border-green-200 dark:border-green-800'
|
|
66
|
+
},
|
|
67
|
+
'archived': {
|
|
68
|
+
icon: Archive,
|
|
69
|
+
title: 'Archived',
|
|
70
|
+
colorClass: 'text-gray-600 dark:text-gray-400',
|
|
71
|
+
bgClass: 'bg-gray-50 dark:bg-gray-900/20',
|
|
72
|
+
borderClass: 'border-gray-200 dark:border-gray-800'
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const BOARD_STATUSES: SpecStatus[] = ['planned', 'in-progress', 'complete', 'archived'];
|
|
77
|
+
|
|
78
|
+
interface SpecRelationships {
|
|
79
|
+
dependsOn: string[];
|
|
80
|
+
related: string[];
|
|
81
|
+
}
|
|
28
82
|
|
|
29
83
|
interface Spec {
|
|
30
84
|
id: string;
|
|
31
85
|
specNumber: number | null;
|
|
32
86
|
specName: string;
|
|
33
87
|
title: string | null;
|
|
34
|
-
status:
|
|
88
|
+
status: SpecStatus | null;
|
|
35
89
|
priority: string | null;
|
|
36
90
|
tags: string[] | null;
|
|
37
91
|
updatedAt: Date | null;
|
|
92
|
+
subSpecsCount?: number;
|
|
93
|
+
relationships?: SpecRelationships;
|
|
38
94
|
}
|
|
39
95
|
|
|
40
96
|
interface Stats {
|
|
@@ -54,32 +110,85 @@ type SortBy = 'id-desc' | 'id-asc' | 'updated-desc' | 'title-asc';
|
|
|
54
110
|
export function SpecsClient({ initialSpecs }: SpecsClientProps) {
|
|
55
111
|
const searchParams = useSearchParams();
|
|
56
112
|
const router = useRouter();
|
|
57
|
-
|
|
113
|
+
|
|
114
|
+
const [specs, setSpecs] = useState<Spec[]>(initialSpecs);
|
|
115
|
+
const [pendingSpecIds, setPendingSpecIds] = useState<Record<string, boolean>>({});
|
|
58
116
|
const [searchQuery, setSearchQuery] = useState('');
|
|
59
|
-
const [statusFilter, setStatusFilter] = useState<
|
|
117
|
+
const [statusFilter, setStatusFilter] = useState<'all' | SpecStatus>('all');
|
|
60
118
|
const [priorityFilter, setPriorityFilter] = useState<string>('all');
|
|
61
119
|
const [sortBy, setSortBy] = useState<SortBy>('id-desc');
|
|
120
|
+
const [showArchivedBoard, setShowArchivedBoard] = useState(false); // Start collapsed
|
|
121
|
+
const [isWideMode, setIsWideMode] = useState(false);
|
|
62
122
|
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
|
63
123
|
// Initialize from URL or localStorage
|
|
64
124
|
const urlView = searchParams.get('view');
|
|
65
125
|
if (urlView === 'board' || urlView === 'list') return urlView;
|
|
66
|
-
|
|
126
|
+
|
|
67
127
|
if (typeof window !== 'undefined') {
|
|
68
128
|
const stored = localStorage.getItem('specs-view-mode');
|
|
69
129
|
if (stored === 'board' || stored === 'list') return stored;
|
|
70
130
|
}
|
|
71
131
|
return 'list';
|
|
72
132
|
});
|
|
73
|
-
|
|
133
|
+
|
|
74
134
|
const isFirstRender = useRef(true);
|
|
75
135
|
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
setSpecs(initialSpecs);
|
|
138
|
+
}, [initialSpecs]);
|
|
139
|
+
|
|
140
|
+
const handleStatusChange = useCallback(async (spec: Spec, nextStatus: SpecStatus) => {
|
|
141
|
+
if (spec.status === nextStatus) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const previousStatus = spec.status;
|
|
146
|
+
setPendingSpecIds((prev) => ({ ...prev, [spec.id]: true }));
|
|
147
|
+
setSpecs((prev) => prev.map(item => item.id === spec.id ? { ...item, status: nextStatus } : item));
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const response = await fetch(`/api/specs/${encodeURIComponent(spec.specName)}/status`, {
|
|
151
|
+
method: 'PATCH',
|
|
152
|
+
headers: {
|
|
153
|
+
'Content-Type': 'application/json',
|
|
154
|
+
},
|
|
155
|
+
body: JSON.stringify({ status: nextStatus }),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
const message = await response.text();
|
|
160
|
+
throw new Error(message || 'Failed to update spec status');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const displayName = spec.specNumber ? `#${spec.specNumber}` : spec.specName;
|
|
164
|
+
toast.success(`Moved ${displayName} to ${STATUS_CONFIG[nextStatus].title}`);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
console.error('Failed to update spec status', error);
|
|
167
|
+
setSpecs((prev) => prev.map(item => item.id === spec.id ? { ...item, status: previousStatus } : item));
|
|
168
|
+
toast.error('Unable to update status. Please try again.');
|
|
169
|
+
} finally {
|
|
170
|
+
setPendingSpecIds((prev) => {
|
|
171
|
+
const next = { ...prev };
|
|
172
|
+
delete next[spec.id];
|
|
173
|
+
return next;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}, []);
|
|
177
|
+
|
|
178
|
+
// Auto-show archived column when filtering by archived status in board view
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (statusFilter === 'archived' && viewMode === 'board') {
|
|
181
|
+
setShowArchivedBoard(true);
|
|
182
|
+
}
|
|
183
|
+
}, [statusFilter, viewMode]);
|
|
184
|
+
|
|
76
185
|
// Update URL when view mode changes (skip on initial mount)
|
|
77
186
|
useEffect(() => {
|
|
78
187
|
if (isFirstRender.current) {
|
|
79
188
|
isFirstRender.current = false;
|
|
80
189
|
return;
|
|
81
190
|
}
|
|
82
|
-
|
|
191
|
+
|
|
83
192
|
const current = new URLSearchParams(window.location.search);
|
|
84
193
|
if (viewMode === 'board') {
|
|
85
194
|
current.set('view', 'board');
|
|
@@ -89,7 +198,7 @@ export function SpecsClient({ initialSpecs }: SpecsClientProps) {
|
|
|
89
198
|
const search = current.toString();
|
|
90
199
|
const query = search ? `?${search}` : '';
|
|
91
200
|
router.replace(`/specs${query}`, { scroll: false });
|
|
92
|
-
|
|
201
|
+
|
|
93
202
|
// Persist to localStorage
|
|
94
203
|
if (typeof window !== 'undefined') {
|
|
95
204
|
localStorage.setItem('specs-view-mode', viewMode);
|
|
@@ -97,147 +206,170 @@ export function SpecsClient({ initialSpecs }: SpecsClientProps) {
|
|
|
97
206
|
}, [viewMode, router]);
|
|
98
207
|
|
|
99
208
|
const filteredAndSortedSpecs = useMemo(() => {
|
|
100
|
-
const
|
|
209
|
+
const filtered = specs.filter(spec => {
|
|
101
210
|
const matchesSearch = !searchQuery ||
|
|
102
211
|
spec.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
103
212
|
spec.specName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
104
213
|
spec.tags?.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()));
|
|
105
214
|
|
|
106
|
-
const matchesStatus = statusFilter === 'all'
|
|
215
|
+
const matchesStatus = statusFilter === 'all'
|
|
216
|
+
? (viewMode === 'list' ? spec.status !== 'archived' : true)
|
|
217
|
+
: spec.status === statusFilter;
|
|
107
218
|
const matchesPriority = priorityFilter === 'all' || spec.priority === priorityFilter;
|
|
108
219
|
|
|
109
220
|
return matchesSearch && matchesStatus && matchesPriority;
|
|
110
221
|
});
|
|
111
222
|
|
|
223
|
+
const sorted = [...filtered];
|
|
224
|
+
|
|
112
225
|
// Sort
|
|
113
226
|
switch (sortBy) {
|
|
114
227
|
case 'id-desc':
|
|
115
|
-
|
|
228
|
+
sorted.sort((a, b) => (b.specNumber || 0) - (a.specNumber || 0));
|
|
116
229
|
break;
|
|
117
230
|
case 'id-asc':
|
|
118
|
-
|
|
231
|
+
sorted.sort((a, b) => (a.specNumber || 0) - (b.specNumber || 0));
|
|
119
232
|
break;
|
|
120
233
|
case 'updated-desc':
|
|
121
|
-
|
|
234
|
+
sorted.sort((a, b) => {
|
|
122
235
|
if (!a.updatedAt) return 1;
|
|
123
236
|
if (!b.updatedAt) return -1;
|
|
124
237
|
return b.updatedAt.getTime() - a.updatedAt.getTime();
|
|
125
238
|
});
|
|
126
239
|
break;
|
|
127
240
|
case 'title-asc':
|
|
128
|
-
|
|
241
|
+
sorted.sort((a, b) => {
|
|
129
242
|
const titleA = (a.title || a.specName).toLowerCase();
|
|
130
243
|
const titleB = (b.title || b.specName).toLowerCase();
|
|
131
244
|
return titleA.localeCompare(titleB);
|
|
132
245
|
});
|
|
133
246
|
break;
|
|
134
247
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}, [initialSpecs, searchQuery, statusFilter, priorityFilter, sortBy]);
|
|
248
|
+
return sorted;
|
|
249
|
+
}, [specs, searchQuery, statusFilter, priorityFilter, sortBy, viewMode]);
|
|
138
250
|
|
|
139
251
|
return (
|
|
140
|
-
<div className="
|
|
141
|
-
<div className=
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
252
|
+
<div className="h-[calc(100vh-3.5rem)] flex flex-col overflow-hidden bg-background p-4">
|
|
253
|
+
<div className={cn(
|
|
254
|
+
"flex flex-col h-full mx-auto transition-all duration-300",
|
|
255
|
+
isWideMode ? "w-full" : "max-w-7xl w-full"
|
|
256
|
+
)}>
|
|
257
|
+
{/* Unified Compact Header */}
|
|
258
|
+
<div className="flex-none mb-4">
|
|
259
|
+
<div className="flex flex-col gap-4">
|
|
260
|
+
<div className="flex items-center justify-between gap-4">
|
|
261
|
+
<div>
|
|
262
|
+
<h1 className="text-2xl font-bold tracking-tight">Specifications</h1>
|
|
263
|
+
<p className="text-sm text-muted-foreground">
|
|
264
|
+
{filteredAndSortedSpecs.length} specs
|
|
265
|
+
</p>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<div className="flex items-center gap-2">
|
|
269
|
+
<div className="flex items-center gap-2 bg-muted/50 p-1 rounded-lg">
|
|
270
|
+
<Button
|
|
271
|
+
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
|
|
272
|
+
size="sm"
|
|
273
|
+
onClick={() => setViewMode('list')}
|
|
274
|
+
className="h-8 px-2 lg:px-3"
|
|
275
|
+
>
|
|
276
|
+
<ListIcon className="h-4 w-4 lg:mr-2" />
|
|
277
|
+
<span className="hidden lg:inline">List</span>
|
|
278
|
+
</Button>
|
|
279
|
+
<Button
|
|
280
|
+
variant={viewMode === 'board' ? 'secondary' : 'ghost'}
|
|
281
|
+
size="sm"
|
|
282
|
+
onClick={() => setViewMode('board')}
|
|
283
|
+
className="h-8 px-2 lg:px-3"
|
|
284
|
+
>
|
|
285
|
+
<LayoutGrid className="h-4 w-4 lg:mr-2" />
|
|
286
|
+
<span className="hidden lg:inline">Board</span>
|
|
287
|
+
</Button>
|
|
288
|
+
</div>
|
|
148
289
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
className="pl-10"
|
|
160
|
-
/>
|
|
290
|
+
<Button
|
|
291
|
+
variant="ghost"
|
|
292
|
+
size="icon"
|
|
293
|
+
onClick={() => setIsWideMode(!isWideMode)}
|
|
294
|
+
className="h-10 w-10 text-muted-foreground hover:text-foreground"
|
|
295
|
+
title={isWideMode ? "Exit wide mode" : "Enter wide mode"}
|
|
296
|
+
>
|
|
297
|
+
{isWideMode ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
|
298
|
+
</Button>
|
|
299
|
+
</div>
|
|
161
300
|
</div>
|
|
162
301
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
<
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
<SelectItem value="archived">Archived</SelectItem>
|
|
174
|
-
</SelectContent>
|
|
175
|
-
</Select>
|
|
176
|
-
|
|
177
|
-
{/* Priority Filter */}
|
|
178
|
-
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
|
179
|
-
<SelectTrigger className="w-full sm:w-[180px]">
|
|
180
|
-
<SelectValue placeholder="Priority" />
|
|
181
|
-
</SelectTrigger>
|
|
182
|
-
<SelectContent>
|
|
183
|
-
<SelectItem value="all">All Priority</SelectItem>
|
|
184
|
-
<SelectItem value="critical">Critical</SelectItem>
|
|
185
|
-
<SelectItem value="high">High</SelectItem>
|
|
186
|
-
<SelectItem value="medium">Medium</SelectItem>
|
|
187
|
-
<SelectItem value="low">Low</SelectItem>
|
|
188
|
-
</SelectContent>
|
|
189
|
-
</Select>
|
|
190
|
-
</div>
|
|
302
|
+
<div className="flex items-center gap-2 overflow-x-auto pb-2">
|
|
303
|
+
<div className="relative flex-1 min-w-[200px] max-w-md">
|
|
304
|
+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
305
|
+
<Input
|
|
306
|
+
placeholder="Search specs..."
|
|
307
|
+
value={searchQuery}
|
|
308
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
309
|
+
className="pl-9 h-9"
|
|
310
|
+
/>
|
|
311
|
+
</div>
|
|
191
312
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
<
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
</
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
</
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
313
|
+
<Select value={statusFilter} onValueChange={(value) => setStatusFilter(value as SpecStatus | 'all')}>
|
|
314
|
+
<SelectTrigger className="w-[140px] h-9">
|
|
315
|
+
<SelectValue placeholder="Status" />
|
|
316
|
+
</SelectTrigger>
|
|
317
|
+
<SelectContent>
|
|
318
|
+
<SelectItem value="all">All Status</SelectItem>
|
|
319
|
+
<SelectItem value="planned">Planned</SelectItem>
|
|
320
|
+
<SelectItem value="in-progress">In Progress</SelectItem>
|
|
321
|
+
<SelectItem value="complete">Complete</SelectItem>
|
|
322
|
+
<SelectItem value="archived">Archived</SelectItem>
|
|
323
|
+
</SelectContent>
|
|
324
|
+
</Select>
|
|
325
|
+
|
|
326
|
+
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
|
327
|
+
<SelectTrigger className="w-[140px] h-9">
|
|
328
|
+
<SelectValue placeholder="Priority" />
|
|
329
|
+
</SelectTrigger>
|
|
330
|
+
<SelectContent>
|
|
331
|
+
<SelectItem value="all">All Priority</SelectItem>
|
|
332
|
+
<SelectItem value="critical">Critical</SelectItem>
|
|
333
|
+
<SelectItem value="high">High</SelectItem>
|
|
334
|
+
<SelectItem value="medium">Medium</SelectItem>
|
|
335
|
+
<SelectItem value="low">Low</SelectItem>
|
|
336
|
+
</SelectContent>
|
|
337
|
+
</Select>
|
|
338
|
+
|
|
339
|
+
<Select value={sortBy} onValueChange={(v) => setSortBy(v as SortBy)}>
|
|
340
|
+
<SelectTrigger className="w-[180px] h-9">
|
|
341
|
+
<SelectValue placeholder="Sort by" />
|
|
342
|
+
</SelectTrigger>
|
|
343
|
+
<SelectContent>
|
|
344
|
+
<SelectItem value="id-desc">Newest First</SelectItem>
|
|
345
|
+
<SelectItem value="id-asc">Oldest First</SelectItem>
|
|
346
|
+
<SelectItem value="updated-desc">Recently Updated</SelectItem>
|
|
347
|
+
<SelectItem value="title-asc">Title (A-Z)</SelectItem>
|
|
348
|
+
</SelectContent>
|
|
349
|
+
</Select>
|
|
226
350
|
</div>
|
|
227
351
|
</div>
|
|
228
352
|
</div>
|
|
229
353
|
|
|
230
|
-
{/*
|
|
231
|
-
<div className=
|
|
232
|
-
|
|
354
|
+
{/* Content Area */}
|
|
355
|
+
<div className={cn(
|
|
356
|
+
"flex-1 min-h-0",
|
|
357
|
+
viewMode === 'board' ? "overflow-x-auto overflow-y-hidden" : "overflow-y-auto"
|
|
358
|
+
)}>
|
|
359
|
+
{viewMode === 'list' ? (
|
|
360
|
+
<div className="w-full">
|
|
361
|
+
<ListView specs={filteredAndSortedSpecs} />
|
|
362
|
+
</div>
|
|
363
|
+
) : (
|
|
364
|
+
<BoardView
|
|
365
|
+
specs={filteredAndSortedSpecs}
|
|
366
|
+
onStatusChange={handleStatusChange}
|
|
367
|
+
pendingSpecIds={pendingSpecIds}
|
|
368
|
+
showArchived={showArchivedBoard}
|
|
369
|
+
onToggleArchived={() => setShowArchivedBoard(!showArchivedBoard)}
|
|
370
|
+
/>
|
|
371
|
+
)}
|
|
233
372
|
</div>
|
|
234
|
-
|
|
235
|
-
{/* Content based on view mode */}
|
|
236
|
-
{viewMode === 'list' ? (
|
|
237
|
-
<ListView specs={filteredAndSortedSpecs} />
|
|
238
|
-
) : (
|
|
239
|
-
<BoardView specs={filteredAndSortedSpecs} />
|
|
240
|
-
)}
|
|
241
373
|
</div>
|
|
242
374
|
</div>
|
|
243
375
|
);
|
|
@@ -245,7 +377,7 @@ export function SpecsClient({ initialSpecs }: SpecsClientProps) {
|
|
|
245
377
|
|
|
246
378
|
function ListView({ specs }: { specs: Spec[] }) {
|
|
247
379
|
return (
|
|
248
|
-
<div className="grid grid-cols-1 gap-4">
|
|
380
|
+
<div className="grid grid-cols-1 gap-4 pb-8">
|
|
249
381
|
{specs.map(spec => {
|
|
250
382
|
const priorityColors = {
|
|
251
383
|
'critical': 'border-l-red-500',
|
|
@@ -254,10 +386,12 @@ function ListView({ specs }: { specs: Spec[] }) {
|
|
|
254
386
|
'low': 'border-l-gray-400'
|
|
255
387
|
};
|
|
256
388
|
const borderColor = priorityColors[spec.priority as keyof typeof priorityColors] || 'border-l-gray-300';
|
|
389
|
+
const hasDependencies = spec.relationships && (spec.relationships.dependsOn.length > 0 || spec.relationships.related.length > 0);
|
|
390
|
+
const hasSubSpecs = !!(spec.subSpecsCount && spec.subSpecsCount > 0);
|
|
257
391
|
|
|
258
392
|
return (
|
|
259
|
-
<Card
|
|
260
|
-
key={spec.id}
|
|
393
|
+
<Card
|
|
394
|
+
key={spec.id}
|
|
261
395
|
className={cn(
|
|
262
396
|
"hover:shadow-lg transition-all duration-150 hover:scale-[1.01] border-l-4 cursor-pointer",
|
|
263
397
|
borderColor
|
|
@@ -268,12 +402,18 @@ function ListView({ specs }: { specs: Spec[] }) {
|
|
|
268
402
|
<div className="flex items-start justify-between gap-4">
|
|
269
403
|
<div className="flex-1 min-w-0">
|
|
270
404
|
<Link href={`/specs/${spec.specNumber || spec.id}`}>
|
|
271
|
-
<CardTitle className="text-lg font-semibold hover:text-primary transition-colors">
|
|
272
|
-
{spec.specNumber ?
|
|
273
|
-
|
|
405
|
+
<CardTitle className="text-lg font-semibold hover:text-primary transition-colors flex items-center">
|
|
406
|
+
{spec.specNumber ? (
|
|
407
|
+
<span className="font-mono text-base font-normal text-muted-foreground mr-3">
|
|
408
|
+
#{spec.specNumber.toString().padStart(3, '0')}
|
|
409
|
+
</span>
|
|
410
|
+
) : null}
|
|
274
411
|
{spec.title || spec.specName}
|
|
275
412
|
</CardTitle>
|
|
276
413
|
</Link>
|
|
414
|
+
{spec.title && spec.title !== spec.specName && (
|
|
415
|
+
<p className="text-xs font-mono text-muted-foreground mt-1.5 truncate">{spec.specName}</p>
|
|
416
|
+
)}
|
|
277
417
|
</div>
|
|
278
418
|
<div className="flex gap-2 shrink-0">
|
|
279
419
|
{spec.status && <StatusBadge status={spec.status} />}
|
|
@@ -281,17 +421,51 @@ function ListView({ specs }: { specs: Spec[] }) {
|
|
|
281
421
|
</div>
|
|
282
422
|
</div>
|
|
283
423
|
</CardHeader>
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
424
|
+
|
|
425
|
+
<CardContent className="flex items-center justify-between gap-4 pt-0">
|
|
426
|
+
{/* Metadata (Left) */}
|
|
427
|
+
<div className="flex items-center gap-4 text-sm text-muted-foreground flex-wrap">
|
|
428
|
+
{(spec.updatedAt || hasSubSpecs || hasDependencies) ? (
|
|
429
|
+
<>
|
|
430
|
+
{spec.updatedAt && (
|
|
431
|
+
<div className="flex items-center gap-1.5">
|
|
432
|
+
<Clock className="h-3.5 w-3.5" />
|
|
433
|
+
<span>Updated {formatRelativeTime(spec.updatedAt)}</span>
|
|
434
|
+
</div>
|
|
435
|
+
)}
|
|
436
|
+
{hasSubSpecs && (
|
|
437
|
+
<div className="flex items-center gap-1.5">
|
|
438
|
+
<FileText className="h-3.5 w-3.5" />
|
|
439
|
+
<span>+{spec.subSpecsCount} files</span>
|
|
440
|
+
</div>
|
|
441
|
+
)}
|
|
442
|
+
{hasDependencies && (
|
|
443
|
+
<div className="flex items-center gap-1.5">
|
|
444
|
+
<GitBranch className="h-3.5 w-3.5" />
|
|
445
|
+
<span>
|
|
446
|
+
{spec.relationships!.dependsOn.length > 0 && `${spec.relationships!.dependsOn.length} deps`}
|
|
447
|
+
{spec.relationships!.dependsOn.length > 0 && spec.relationships!.related.length > 0 && ', '}
|
|
448
|
+
{spec.relationships!.related.length > 0 && `${spec.relationships!.related.length} related`}
|
|
449
|
+
</span>
|
|
450
|
+
</div>
|
|
451
|
+
)}
|
|
452
|
+
</>
|
|
453
|
+
) : (
|
|
454
|
+
<span className="invisible">No metadata</span> /* Keep height consistent */
|
|
455
|
+
)}
|
|
456
|
+
</div>
|
|
457
|
+
|
|
458
|
+
{/* Tags (Right) */}
|
|
459
|
+
{spec.tags && spec.tags.length > 0 && (
|
|
460
|
+
<div className="flex flex-wrap gap-2 justify-end shrink-0">
|
|
287
461
|
{spec.tags.map(tag => (
|
|
288
|
-
<Badge key={tag} variant="
|
|
462
|
+
<Badge key={tag} variant="outline" className="text-xs font-mono text-muted-foreground hover:text-foreground transition-colors">
|
|
289
463
|
{tag}
|
|
290
464
|
</Badge>
|
|
291
465
|
))}
|
|
292
466
|
</div>
|
|
293
|
-
|
|
294
|
-
|
|
467
|
+
)}
|
|
468
|
+
</CardContent>
|
|
295
469
|
</Card>
|
|
296
470
|
);
|
|
297
471
|
})}
|
|
@@ -299,116 +473,217 @@ function ListView({ specs }: { specs: Spec[] }) {
|
|
|
299
473
|
);
|
|
300
474
|
}
|
|
301
475
|
|
|
302
|
-
|
|
476
|
+
interface BoardViewProps {
|
|
477
|
+
specs: Spec[];
|
|
478
|
+
onStatusChange: (spec: Spec, status: SpecStatus) => void;
|
|
479
|
+
pendingSpecIds: Record<string, boolean>;
|
|
480
|
+
showArchived: boolean;
|
|
481
|
+
onToggleArchived: () => void;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function BoardView({ specs, onStatusChange, pendingSpecIds, showArchived, onToggleArchived }: BoardViewProps) {
|
|
485
|
+
const [draggingId, setDraggingId] = useState<string | null>(null);
|
|
486
|
+
const [activeDropZone, setActiveDropZone] = useState<SpecStatus | null>(null);
|
|
487
|
+
|
|
303
488
|
const columns = useMemo(() => {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
icon: Clock,
|
|
307
|
-
title: 'Planned',
|
|
308
|
-
colorClass: 'text-blue-600 dark:text-blue-400',
|
|
309
|
-
bgClass: 'bg-blue-50 dark:bg-blue-900/20',
|
|
310
|
-
borderClass: 'border-blue-200 dark:border-blue-800'
|
|
311
|
-
},
|
|
312
|
-
'in-progress': {
|
|
313
|
-
icon: PlayCircle,
|
|
314
|
-
title: 'In Progress',
|
|
315
|
-
colorClass: 'text-orange-600 dark:text-orange-400',
|
|
316
|
-
bgClass: 'bg-orange-50 dark:bg-orange-900/20',
|
|
317
|
-
borderClass: 'border-orange-200 dark:border-orange-800'
|
|
318
|
-
},
|
|
319
|
-
'complete': {
|
|
320
|
-
icon: CheckCircle2,
|
|
321
|
-
title: 'Complete',
|
|
322
|
-
colorClass: 'text-green-600 dark:text-green-400',
|
|
323
|
-
bgClass: 'bg-green-50 dark:bg-green-900/20',
|
|
324
|
-
borderClass: 'border-green-200 dark:border-green-800'
|
|
325
|
-
}
|
|
326
|
-
} as const;
|
|
327
|
-
|
|
328
|
-
const statuses = ['planned', 'in-progress', 'complete'] as const;
|
|
329
|
-
|
|
330
|
-
return statuses.map(status => ({
|
|
489
|
+
// Always show all columns, including archived (it will be rendered as collapsed bar when showArchived=false)
|
|
490
|
+
return BOARD_STATUSES.map(status => ({
|
|
331
491
|
status,
|
|
332
|
-
config:
|
|
492
|
+
config: STATUS_CONFIG[status],
|
|
333
493
|
specs: specs.filter(spec => spec.status === status),
|
|
334
494
|
}));
|
|
335
495
|
}, [specs]);
|
|
336
496
|
|
|
497
|
+
const specLookup = useMemo(() => {
|
|
498
|
+
const map = new Map<string, Spec>();
|
|
499
|
+
specs.forEach(spec => map.set(spec.id, spec));
|
|
500
|
+
return map;
|
|
501
|
+
}, [specs]);
|
|
502
|
+
|
|
503
|
+
const handleDragStart = useCallback((specId: string, event: DragEvent<HTMLDivElement>) => {
|
|
504
|
+
event.dataTransfer.setData('text/plain', specId);
|
|
505
|
+
event.dataTransfer.effectAllowed = 'move';
|
|
506
|
+
setDraggingId(specId);
|
|
507
|
+
}, []);
|
|
508
|
+
|
|
509
|
+
const handleDragEnd = useCallback(() => {
|
|
510
|
+
setDraggingId(null);
|
|
511
|
+
setActiveDropZone(null);
|
|
512
|
+
}, []);
|
|
513
|
+
|
|
514
|
+
const handleDragOver = useCallback((status: SpecStatus, event: DragEvent<HTMLDivElement>) => {
|
|
515
|
+
if (!draggingId) return;
|
|
516
|
+
event.preventDefault();
|
|
517
|
+
event.dataTransfer.dropEffect = 'move';
|
|
518
|
+
setActiveDropZone(status);
|
|
519
|
+
}, [draggingId]);
|
|
520
|
+
|
|
521
|
+
const handleDragLeave = useCallback((status: SpecStatus, event: DragEvent<HTMLDivElement>) => {
|
|
522
|
+
if (!draggingId) return;
|
|
523
|
+
const related = event.relatedTarget as Node | null;
|
|
524
|
+
if (!related || !event.currentTarget.contains(related)) {
|
|
525
|
+
setActiveDropZone((current) => (current === status ? null : current));
|
|
526
|
+
}
|
|
527
|
+
}, [draggingId]);
|
|
528
|
+
|
|
529
|
+
const handleDrop = useCallback((status: SpecStatus, event: DragEvent<HTMLDivElement>) => {
|
|
530
|
+
event.preventDefault();
|
|
531
|
+
const draggedId = event.dataTransfer.getData('text/plain') || draggingId;
|
|
532
|
+
if (!draggedId) {
|
|
533
|
+
handleDragEnd();
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const spec = specLookup.get(draggedId);
|
|
538
|
+
if (spec && spec.status !== status) {
|
|
539
|
+
onStatusChange(spec, status);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
handleDragEnd();
|
|
543
|
+
}, [draggingId, handleDragEnd, onStatusChange, specLookup]);
|
|
544
|
+
|
|
337
545
|
return (
|
|
338
|
-
<div className="
|
|
546
|
+
<div className="flex gap-6 h-full pb-2">
|
|
339
547
|
{columns.map(column => {
|
|
340
548
|
const Icon = column.config.icon;
|
|
549
|
+
const isArchivedColumn = column.status === 'archived';
|
|
550
|
+
|
|
341
551
|
return (
|
|
342
|
-
<div key={column.status} className=
|
|
552
|
+
<div key={column.status} className={cn(
|
|
553
|
+
"flex flex-col h-full flex-1 min-w-[280px]",
|
|
554
|
+
isArchivedColumn && !showArchived && "w-14 min-w-[3.5rem] flex-none flex-shrink-0"
|
|
555
|
+
)}>
|
|
343
556
|
<div className={cn(
|
|
344
|
-
|
|
557
|
+
'flex-none mb-4 rounded-lg border-2 bg-background transition-all',
|
|
345
558
|
column.config.bgClass,
|
|
346
|
-
column.config.borderClass
|
|
347
|
-
|
|
559
|
+
column.config.borderClass,
|
|
560
|
+
isArchivedColumn ? 'cursor-pointer hover:opacity-80' : '',
|
|
561
|
+
isArchivedColumn && !showArchived ? 'py-6 px-2' : 'p-3'
|
|
562
|
+
)}
|
|
563
|
+
onClick={isArchivedColumn ? onToggleArchived : undefined}
|
|
564
|
+
>
|
|
348
565
|
<h2 className={cn(
|
|
349
|
-
|
|
350
|
-
column.config.colorClass
|
|
566
|
+
'text-lg font-semibold flex items-center gap-2',
|
|
567
|
+
column.config.colorClass,
|
|
568
|
+
isArchivedColumn && !showArchived && 'flex-col text-sm gap-3'
|
|
351
569
|
)}>
|
|
352
570
|
<Icon className="h-5 w-5" />
|
|
353
|
-
{
|
|
354
|
-
|
|
571
|
+
{isArchivedColumn && !showArchived ? (
|
|
572
|
+
<>
|
|
573
|
+
<span className="vertical-text text-sm whitespace-nowrap">
|
|
574
|
+
{column.config.title}
|
|
575
|
+
</span>
|
|
576
|
+
<Badge variant="outline" className="text-xs">{column.specs.length}</Badge>
|
|
577
|
+
</>
|
|
578
|
+
) : (
|
|
579
|
+
<>
|
|
580
|
+
{column.config.title}
|
|
581
|
+
<Badge variant="outline" className="ml-auto">{column.specs.length}</Badge>
|
|
582
|
+
</>
|
|
583
|
+
)}
|
|
355
584
|
</h2>
|
|
356
585
|
</div>
|
|
357
586
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
'
|
|
362
|
-
|
|
363
|
-
'
|
|
364
|
-
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
587
|
+
{(!isArchivedColumn || showArchived) && (
|
|
588
|
+
<div
|
|
589
|
+
className={cn(
|
|
590
|
+
'space-y-3 flex-1 rounded-xl border border-transparent p-1 transition-colors overflow-y-auto min-h-0',
|
|
591
|
+
draggingId && 'border-dashed border-muted-foreground/40',
|
|
592
|
+
draggingId && activeDropZone === column.status && 'bg-muted/40 border-primary/50'
|
|
593
|
+
)}
|
|
594
|
+
onDragOver={(event) => handleDragOver(column.status, event)}
|
|
595
|
+
onDragLeave={(event) => handleDragLeave(column.status, event)}
|
|
596
|
+
onDrop={(event) => handleDrop(column.status, event)}
|
|
597
|
+
>
|
|
598
|
+
{column.specs.map(spec => {
|
|
599
|
+
const priorityColors = {
|
|
600
|
+
'critical': 'border-l-red-500',
|
|
601
|
+
'high': 'border-l-orange-500',
|
|
602
|
+
'medium': 'border-l-blue-500',
|
|
603
|
+
'low': 'border-l-gray-400'
|
|
604
|
+
};
|
|
605
|
+
const borderColor = priorityColors[spec.priority as keyof typeof priorityColors] || 'border-l-gray-300';
|
|
606
|
+
const isUpdating = Boolean(pendingSpecIds[spec.id]);
|
|
607
|
+
|
|
608
|
+
return (
|
|
609
|
+
<Card
|
|
610
|
+
key={spec.id}
|
|
611
|
+
draggable={!isUpdating}
|
|
612
|
+
onDragStart={(event) => {
|
|
613
|
+
if (isUpdating) {
|
|
614
|
+
event.preventDefault();
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
handleDragStart(spec.id, event);
|
|
618
|
+
}}
|
|
619
|
+
onDragEnd={handleDragEnd}
|
|
620
|
+
aria-disabled={isUpdating}
|
|
621
|
+
className={cn(
|
|
622
|
+
'relative hover:shadow-lg transition-all duration-150 hover:scale-[1.02] border-l-4 cursor-pointer group flex flex-col',
|
|
623
|
+
borderColor,
|
|
624
|
+
isUpdating && 'opacity-60 cursor-wait'
|
|
625
|
+
)}
|
|
626
|
+
onClick={() => window.location.href = `/specs/${spec.specNumber || spec.id}`}
|
|
627
|
+
>
|
|
628
|
+
{isUpdating && (
|
|
629
|
+
<div className="absolute inset-0 rounded-lg bg-background/80 flex items-center justify-center text-xs font-medium z-10">
|
|
630
|
+
Updating...
|
|
631
|
+
</div>
|
|
632
|
+
)}
|
|
633
|
+
<CardHeader className="p-4 pb-2 space-y-1.5">
|
|
634
|
+
<div className="flex items-center justify-between">
|
|
635
|
+
<span className="font-mono text-xs text-muted-foreground/70 group-hover:text-primary/60 transition-colors">
|
|
636
|
+
{spec.specNumber ? `#${spec.specNumber}` : ''}
|
|
637
|
+
</span>
|
|
638
|
+
</div>
|
|
639
|
+
<Link href={`/specs/${spec.specNumber || spec.id}`} className="block">
|
|
640
|
+
<CardTitle className="text-sm font-semibold leading-snug hover:text-primary transition-colors line-clamp-3">
|
|
641
|
+
{spec.title || spec.specName}
|
|
642
|
+
</CardTitle>
|
|
643
|
+
</Link>
|
|
644
|
+
</CardHeader>
|
|
645
|
+
<CardContent className="p-4 pt-2 flex-1 flex flex-col justify-end">
|
|
646
|
+
<div className="flex flex-col gap-3">
|
|
647
|
+
{spec.title && spec.title !== spec.specName && (
|
|
648
|
+
<p className="text-xs font-mono text-muted-foreground truncate opacity-70">
|
|
649
|
+
{spec.specName}
|
|
650
|
+
</p>
|
|
651
|
+
)}
|
|
652
|
+
|
|
653
|
+
<div className="flex items-center justify-between gap-2 pt-1">
|
|
654
|
+
{spec.priority ? <PriorityBadge priority={spec.priority} /> : <div />}
|
|
655
|
+
|
|
656
|
+
{spec.tags && spec.tags.length > 0 && (
|
|
657
|
+
<div className="flex flex-wrap gap-1 justify-end">
|
|
658
|
+
{spec.tags.slice(0, 2).map(tag => (
|
|
659
|
+
<Badge key={tag} variant="outline" className="text-[10px] px-1.5 h-5 font-mono text-muted-foreground/80">
|
|
660
|
+
{tag}
|
|
661
|
+
</Badge>
|
|
662
|
+
))}
|
|
663
|
+
{spec.tags.length > 2 && (
|
|
664
|
+
<Badge variant="outline" className="text-[10px] px-1.5 h-5 font-mono text-muted-foreground/80">
|
|
665
|
+
+{spec.tags.length - 2}
|
|
666
|
+
</Badge>
|
|
667
|
+
)}
|
|
668
|
+
</div>
|
|
403
669
|
)}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
</
|
|
670
|
+
</div>
|
|
671
|
+
</div>
|
|
672
|
+
</CardContent>
|
|
673
|
+
</Card>
|
|
674
|
+
);
|
|
675
|
+
})}
|
|
676
|
+
|
|
677
|
+
{column.specs.length === 0 && (
|
|
678
|
+
<Card className="border-dashed border-gray-300 dark:border-gray-700 bg-transparent">
|
|
679
|
+
<CardContent className="py-8 text-center">
|
|
680
|
+
<Icon className={cn('mx-auto h-8 w-8 mb-2', column.config.colorClass, 'opacity-50')} />
|
|
681
|
+
<p className="text-sm text-muted-foreground">Drop here to move specs</p>
|
|
407
682
|
</CardContent>
|
|
408
683
|
</Card>
|
|
409
|
-
)
|
|
410
|
-
|
|
411
|
-
|
|
684
|
+
)}
|
|
685
|
+
</div>
|
|
686
|
+
)}
|
|
412
687
|
</div>
|
|
413
688
|
);
|
|
414
689
|
})}
|