@leanspec/ui 0.2.14 → 0.2.15-dev.21025278490
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/bin/leanspec-ui.js +191 -0
- package/dist/assets/_baseUniq-CRqreL7N.js +1 -0
- package/dist/assets/arc-DMhx9AJT.js +1 -0
- package/dist/assets/architectureDiagram-VXUJARFQ-DM0L0YzO.js +36 -0
- package/dist/assets/blockDiagram-VD42YOAC-DHQXDHsD.js +122 -0
- package/dist/assets/c4Diagram-YG6GDRKO-0L7o2gpH.js +10 -0
- package/dist/assets/channel-2tOl0nAZ.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-CwFT-Uaj.js +1 -0
- package/dist/assets/chunk-55IACEB6-CjvuUHHG.js +1 -0
- package/dist/assets/chunk-B4BG7PRW-BRJBysMK.js +165 -0
- package/dist/assets/chunk-DI55MBZ5-BnNEeoaA.js +220 -0
- package/dist/assets/chunk-FMBD7UC4-BK2l30pm.js +15 -0
- package/dist/assets/chunk-QN33PNHL-BN_cZkCU.js +1 -0
- package/dist/assets/chunk-QZHKN3VN-Brc3Yrub.js +1 -0
- package/dist/assets/chunk-TZMSLE5B-D2zzpLfO.js +1 -0
- package/dist/assets/classDiagram-2ON5EDUG-BB9CSNmS.js +1 -0
- package/dist/assets/classDiagram-v2-WZHVMYZB-BB9CSNmS.js +1 -0
- package/dist/assets/clone-BjxVFtyI.js +1 -0
- package/dist/assets/core-DV6XEvTN.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-CLJgM3XR.js +1 -0
- package/dist/assets/cytoscape.esm-5J0xJHOV.js +321 -0
- package/dist/assets/dagre-6UL2VRFP-_IFvBJKJ.js +4 -0
- package/dist/assets/diagram-PSM6KHXK--83HIYSQ.js +24 -0
- package/dist/assets/diagram-QEK2KX5R-6jAWnCnZ.js +43 -0
- package/dist/assets/diagram-S2PKOQOG-D5pwHvjZ.js +24 -0
- package/dist/assets/erDiagram-Q2GNP2WA-B4FV3mTd.js +60 -0
- package/dist/assets/flowDiagram-NV44I4VS-mtD2kF4M.js +162 -0
- package/dist/assets/ganttDiagram-JELNMOA3-BKALgqTK.js +267 -0
- package/dist/assets/gitGraphDiagram-NY62KEGX-Bd7r0pAf.js +65 -0
- package/dist/assets/graph-B2rEI7cK.js +1 -0
- package/dist/assets/index-Bekv_o1t.css +1 -0
- package/dist/assets/index-DSRxU-E5.js +389 -0
- package/dist/assets/infoDiagram-WHAUD3N6--nJOBKqh.js +2 -0
- package/dist/assets/journeyDiagram-XKPGCS4Q-BzGutKN3.js +139 -0
- package/dist/assets/kanban-definition-3W4ZIXB7-DyQO17vq.js +89 -0
- package/dist/assets/katex-XbL3y5x-.js +261 -0
- package/dist/assets/layout-iCSHU015.js +1 -0
- package/dist/assets/min-BK_AIJdo.js +1 -0
- package/dist/assets/mindmap-definition-VGOIOE7T-BZMj_6zo.js +68 -0
- package/dist/assets/pieDiagram-ADFJNKIX-CkAGsq9p.js +30 -0
- package/dist/assets/quadrantDiagram-AYHSOK5B-CWa93px1.js +7 -0
- package/dist/assets/requirementDiagram-UZGBJVZJ-CufFVR8c.js +64 -0
- package/dist/assets/sankeyDiagram-TZEHDZUN-BEPgVgU4.js +10 -0
- package/dist/assets/sequenceDiagram-WL72ISMW-BkdBWhel.js +145 -0
- package/dist/assets/stateDiagram-FKZM4ZOC-D5T73yx0.js +1 -0
- package/dist/assets/stateDiagram-v2-4FDKWEC3-9hJWG2n6.js +1 -0
- package/dist/assets/timeline-definition-IT6M3QCI-CX7kTdU2.js +61 -0
- package/dist/assets/treemap-KMMF4GRG-ftWCQ9lJ.js +128 -0
- package/dist/assets/xychartDiagram-PRI3JC2R-Ngrels4n.js +7 -0
- package/{index.html → dist/index.html} +2 -1
- package/package.json +12 -2
- package/eslint.config.js +0 -23
- package/package.json.backup +0 -83
- package/postcss.config.js +0 -6
- package/src/App.css +0 -42
- package/src/App.tsx +0 -17
- package/src/assets/react.svg +0 -1
- package/src/components/LanguageSwitcher.tsx +0 -67
- package/src/components/Layout.tsx +0 -88
- package/src/components/MainSidebar.tsx +0 -163
- package/src/components/MermaidDiagram.tsx +0 -85
- package/src/components/MinimalLayout.tsx +0 -51
- package/src/components/Navigation.tsx +0 -254
- package/src/components/PriorityBadge.tsx +0 -59
- package/src/components/ProjectSwitcher.tsx +0 -222
- package/src/components/QuickSearch.tsx +0 -225
- package/src/components/RootRedirect.tsx +0 -40
- package/src/components/SpecDetailLayout.context.ts +0 -10
- package/src/components/SpecDetailLayout.tsx +0 -14
- package/src/components/SpecsNavSidebar.tsx +0 -615
- package/src/components/StatusBadge.tsx +0 -59
- package/src/components/ThemeToggle.tsx +0 -25
- package/src/components/Tooltip.tsx +0 -29
- package/src/components/context/ContextClient.tsx +0 -471
- package/src/components/context/ContextFileDetail.tsx +0 -163
- package/src/components/dashboard/ActivityItem.tsx +0 -36
- package/src/components/dashboard/DashboardClient.tsx +0 -218
- package/src/components/dashboard/SpecListItem.tsx +0 -58
- package/src/components/dashboard/StatCard.tsx +0 -52
- package/src/components/dependencies/SpecNode.tsx +0 -128
- package/src/components/dependencies/SpecSidebar.tsx +0 -256
- package/src/components/dependencies/constants.ts +0 -25
- package/src/components/dependencies/types.ts +0 -38
- package/src/components/dependencies/utils.ts +0 -261
- package/src/components/metadata-editors/PriorityEditor.tsx +0 -89
- package/src/components/metadata-editors/StatusEditor.tsx +0 -85
- package/src/components/metadata-editors/TagsEditor.tsx +0 -207
- package/src/components/projects/CreateProjectDialog.tsx +0 -162
- package/src/components/projects/DirectoryPicker.tsx +0 -182
- package/src/components/shared/BackToTop.tsx +0 -39
- package/src/components/shared/ColorPicker.tsx +0 -68
- package/src/components/shared/EmptyState.tsx +0 -35
- package/src/components/shared/ErrorBoundary.tsx +0 -79
- package/src/components/shared/PageHeader.tsx +0 -23
- package/src/components/shared/PageTransition.tsx +0 -40
- package/src/components/shared/ProjectAvatar.tsx +0 -107
- package/src/components/shared/Skeletons.tsx +0 -184
- package/src/components/spec-detail/EditableMetadata.tsx +0 -129
- package/src/components/spec-detail/MarkdownRenderer.tsx +0 -47
- package/src/components/spec-detail/TableOfContents.tsx +0 -150
- package/src/components/specs/BoardView.tsx +0 -204
- package/src/components/specs/ListView.tsx +0 -62
- package/src/components/specs/SpecsFilters.tsx +0 -190
- package/src/contexts/KeyboardShortcutsContext.tsx +0 -95
- package/src/contexts/LayoutContext.tsx +0 -45
- package/src/contexts/ProjectContext.tsx +0 -163
- package/src/contexts/ThemeContext.tsx +0 -90
- package/src/contexts/index.ts +0 -7
- package/src/hooks/useKeyboardShortcuts.ts +0 -87
- package/src/index.css +0 -624
- package/src/lib/api.ts +0 -72
- package/src/lib/backend-adapter.ts +0 -382
- package/src/lib/date-utils.ts +0 -122
- package/src/lib/i18n.test.ts +0 -57
- package/src/lib/i18n.ts +0 -51
- package/src/lib/markdown-utils.ts +0 -38
- package/src/lib/sub-spec-utils.ts +0 -166
- package/src/lib/utils.ts +0 -6
- package/src/locales/en/common.json +0 -660
- package/src/locales/en/errors.json +0 -20
- package/src/locales/en/help.json +0 -8
- package/src/locales/zh-CN/common.json +0 -660
- package/src/locales/zh-CN/errors.json +0 -20
- package/src/locales/zh-CN/help.json +0 -8
- package/src/main.tsx +0 -12
- package/src/pages/ContextPage.tsx +0 -111
- package/src/pages/DashboardPage.tsx +0 -97
- package/src/pages/DependenciesPage.tsx +0 -881
- package/src/pages/ProjectsPage.tsx +0 -432
- package/src/pages/SpecDetailPage.tsx +0 -592
- package/src/pages/SpecsPage.tsx +0 -319
- package/src/pages/StatsPage.tsx +0 -307
- package/src/router/projectRoutes.tsx +0 -36
- package/src/router.tsx +0 -33
- package/src/test/setup.ts +0 -39
- package/src/types/api.ts +0 -185
- package/tailwind.config.ts +0 -57
- package/tsconfig.app.json +0 -29
- package/tsconfig.json +0 -7
- package/tsconfig.node.json +0 -26
- package/tsconfig.tsbuildinfo +0 -1
- package/vite.config.ts +0 -27
- package/vitest.config.ts +0 -18
- /package/{public → dist}/favicon.ico +0 -0
- /package/{public → dist}/github-mark-white.svg +0 -0
- /package/{public → dist}/github-mark.svg +0 -0
- /package/{public → dist}/logo-dark-bg.svg +0 -0
- /package/{public → dist}/logo-with-bg.svg +0 -0
- /package/{public → dist}/logo.svg +0 -0
- /package/{public → dist}/vite.svg +0 -0
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
2
|
-
import { Archive, CheckCircle2, Clock, Loader2, PlayCircle } from 'lucide-react';
|
|
3
|
-
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@leanspec/ui-components';
|
|
4
|
-
import { cn } from '../../lib/utils';
|
|
5
|
-
import { api } from '../../lib/api';
|
|
6
|
-
import type { Spec } from '../../types/api';
|
|
7
|
-
import { useTranslation } from 'react-i18next';
|
|
8
|
-
|
|
9
|
-
const STATUS_OPTIONS: Array<{ value: NonNullable<Spec['status']>; labelKey: `status.${string}`; className: string; Icon: React.ComponentType<{ className?: string }> }> = [
|
|
10
|
-
{ value: 'planned', labelKey: 'status.planned', className: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', Icon: Clock },
|
|
11
|
-
{ value: 'in-progress', labelKey: 'status.inProgress', className: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400', Icon: PlayCircle },
|
|
12
|
-
{ value: 'complete', labelKey: 'status.complete', className: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', Icon: CheckCircle2 },
|
|
13
|
-
{ value: 'archived', labelKey: 'status.archived', className: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-400', Icon: Archive },
|
|
14
|
-
];
|
|
15
|
-
|
|
16
|
-
interface StatusEditorProps {
|
|
17
|
-
specName: string;
|
|
18
|
-
value: Spec['status'];
|
|
19
|
-
onChange?: (status: NonNullable<Spec['status']>) => void;
|
|
20
|
-
disabled?: boolean;
|
|
21
|
-
className?: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function StatusEditor({ specName, value, onChange, disabled = false, className }: StatusEditorProps) {
|
|
25
|
-
const initial = value || 'planned';
|
|
26
|
-
const [status, setStatus] = useState<NonNullable<Spec['status']>>(initial as NonNullable<Spec['status']>);
|
|
27
|
-
const [updating, setUpdating] = useState(false);
|
|
28
|
-
const [error, setError] = useState<string | null>(null);
|
|
29
|
-
const { t } = useTranslation('common');
|
|
30
|
-
|
|
31
|
-
const option = STATUS_OPTIONS.find((opt) => opt.value === status) || STATUS_OPTIONS[0];
|
|
32
|
-
|
|
33
|
-
const handleChange = async (next: NonNullable<Spec['status']>) => {
|
|
34
|
-
if (next === status) return;
|
|
35
|
-
const previous = status;
|
|
36
|
-
setStatus(next);
|
|
37
|
-
setUpdating(true);
|
|
38
|
-
setError(null);
|
|
39
|
-
|
|
40
|
-
try {
|
|
41
|
-
await api.updateSpec(specName, { status: next });
|
|
42
|
-
onChange?.(next);
|
|
43
|
-
} catch (err) {
|
|
44
|
-
setStatus(previous);
|
|
45
|
-
const message = err instanceof Error ? err.message : t('editors.statusError');
|
|
46
|
-
setError(message);
|
|
47
|
-
} finally {
|
|
48
|
-
setUpdating(false);
|
|
49
|
-
}
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
return (
|
|
53
|
-
<div className="space-y-1">
|
|
54
|
-
<Select value={status} onValueChange={(value) => handleChange(value as NonNullable<Spec['status']>)} disabled={disabled || updating}>
|
|
55
|
-
<SelectTrigger
|
|
56
|
-
className={cn(
|
|
57
|
-
'h-7 w-fit min-w-[120px] border-0 px-2 text-xs font-medium justify-start',
|
|
58
|
-
option.className,
|
|
59
|
-
className,
|
|
60
|
-
updating && 'opacity-70'
|
|
61
|
-
)}
|
|
62
|
-
aria-label={t('editors.changeStatus')}
|
|
63
|
-
>
|
|
64
|
-
<div className="flex items-center gap-1.5">
|
|
65
|
-
{updating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <option.Icon className="h-3.5 w-3.5" />}
|
|
66
|
-
<SelectValue placeholder={t('specsPage.filters.status')}>
|
|
67
|
-
{t(option.labelKey)}
|
|
68
|
-
</SelectValue>
|
|
69
|
-
</div>
|
|
70
|
-
</SelectTrigger>
|
|
71
|
-
<SelectContent>
|
|
72
|
-
{STATUS_OPTIONS.map((opt) => (
|
|
73
|
-
<SelectItem key={opt.value} value={opt.value} className="flex items-center gap-2">
|
|
74
|
-
<div className="flex items-center gap-2">
|
|
75
|
-
<opt.Icon className="h-4 w-4" />
|
|
76
|
-
<span>{t(opt.labelKey)}</span>
|
|
77
|
-
</div>
|
|
78
|
-
</SelectItem>
|
|
79
|
-
))}
|
|
80
|
-
</SelectContent>
|
|
81
|
-
</Select>
|
|
82
|
-
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
83
|
-
</div>
|
|
84
|
-
);
|
|
85
|
-
}
|
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
import { useState, useEffect, useMemo } from 'react';
|
|
2
|
-
import { Loader2, Plus, X } from 'lucide-react';
|
|
3
|
-
import {
|
|
4
|
-
Badge,
|
|
5
|
-
Button,
|
|
6
|
-
Popover,
|
|
7
|
-
PopoverContent,
|
|
8
|
-
PopoverTrigger,
|
|
9
|
-
Command,
|
|
10
|
-
CommandEmpty,
|
|
11
|
-
CommandGroup,
|
|
12
|
-
CommandInput,
|
|
13
|
-
CommandItem,
|
|
14
|
-
CommandList,
|
|
15
|
-
} from '@leanspec/ui-components';
|
|
16
|
-
import { cn } from '../../lib/utils';
|
|
17
|
-
import { api } from '../../lib/api';
|
|
18
|
-
import type { Spec } from '../../types/api';
|
|
19
|
-
import { useTranslation } from 'react-i18next';
|
|
20
|
-
|
|
21
|
-
interface TagsEditorProps {
|
|
22
|
-
specName: string;
|
|
23
|
-
value: Spec['tags'];
|
|
24
|
-
onChange?: (tags: string[]) => void;
|
|
25
|
-
disabled?: boolean;
|
|
26
|
-
className?: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function TagsEditor({ specName, value, onChange, disabled = false, className }: TagsEditorProps) {
|
|
30
|
-
const [tags, setTags] = useState<string[]>(value || []);
|
|
31
|
-
const [allTags, setAllTags] = useState<string[]>([]);
|
|
32
|
-
const [isUpdating, setIsUpdating] = useState(false);
|
|
33
|
-
const [error, setError] = useState<string | null>(null);
|
|
34
|
-
const [isOpen, setIsOpen] = useState(false);
|
|
35
|
-
const [searchValue, setSearchValue] = useState('');
|
|
36
|
-
const { t } = useTranslation('common');
|
|
37
|
-
|
|
38
|
-
// Fetch all available tags for autocomplete when popover opens
|
|
39
|
-
useEffect(() => {
|
|
40
|
-
if (isOpen && allTags.length === 0) {
|
|
41
|
-
const fetchTags = async () => {
|
|
42
|
-
try {
|
|
43
|
-
const specs = await api.getSpecs();
|
|
44
|
-
const uniqueTags = new Set<string>();
|
|
45
|
-
specs.forEach(s => s.tags?.forEach(t => uniqueTags.add(t)));
|
|
46
|
-
setAllTags(Array.from(uniqueTags).sort());
|
|
47
|
-
} catch (err) {
|
|
48
|
-
console.error('Failed to fetch tags:', err);
|
|
49
|
-
}
|
|
50
|
-
};
|
|
51
|
-
void fetchTags();
|
|
52
|
-
}
|
|
53
|
-
}, [isOpen, allTags.length]);
|
|
54
|
-
|
|
55
|
-
const updateTags = async (newTags: string[]) => {
|
|
56
|
-
const previousTags = tags;
|
|
57
|
-
setTags(newTags); // Optimistic update
|
|
58
|
-
setIsUpdating(true);
|
|
59
|
-
setError(null);
|
|
60
|
-
|
|
61
|
-
try {
|
|
62
|
-
await api.updateSpec(specName, { tags: newTags });
|
|
63
|
-
onChange?.(newTags);
|
|
64
|
-
} catch (err) {
|
|
65
|
-
setTags(previousTags); // Rollback
|
|
66
|
-
const errorMessage = err instanceof Error ? err.message : t('editors.tagsError');
|
|
67
|
-
setError(errorMessage);
|
|
68
|
-
} finally {
|
|
69
|
-
setIsUpdating(false);
|
|
70
|
-
}
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
const handleAddTag = (tag: string) => {
|
|
74
|
-
const trimmedTag = tag.trim(); // Don't lowercase automatically to preserve user intent, or follow project convention
|
|
75
|
-
if (!trimmedTag) return;
|
|
76
|
-
if (tags.includes(trimmedTag)) {
|
|
77
|
-
setError(t('editors.tagExists'));
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const newTags = [...tags, trimmedTag];
|
|
82
|
-
void updateTags(newTags);
|
|
83
|
-
setSearchValue('');
|
|
84
|
-
setIsOpen(false);
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
const handleRemoveTag = (tagToRemove: string) => {
|
|
88
|
-
const newTags = tags.filter(t => t !== tagToRemove);
|
|
89
|
-
void updateTags(newTags);
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
// Filter available tags: show tags that aren't already added and match search
|
|
93
|
-
const availableTags = useMemo(() => {
|
|
94
|
-
const lowercaseSearch = searchValue.toLowerCase();
|
|
95
|
-
return allTags
|
|
96
|
-
.filter(tag => !tags.includes(tag))
|
|
97
|
-
.filter(tag => !lowercaseSearch || tag.toLowerCase().includes(lowercaseSearch));
|
|
98
|
-
}, [allTags, tags, searchValue]);
|
|
99
|
-
|
|
100
|
-
// Check if search value could be a new tag (not in available tags)
|
|
101
|
-
const canCreateNewTag = searchValue.trim() &&
|
|
102
|
-
!tags.includes(searchValue.trim()) &&
|
|
103
|
-
!allTags.includes(searchValue.trim());
|
|
104
|
-
|
|
105
|
-
return (
|
|
106
|
-
<div className={cn("relative", className)}>
|
|
107
|
-
<div className="flex gap-1 flex-wrap items-center">
|
|
108
|
-
{tags.map((tag) => (
|
|
109
|
-
<Badge
|
|
110
|
-
key={tag}
|
|
111
|
-
variant="outline"
|
|
112
|
-
className={cn(
|
|
113
|
-
"text-xs pr-1 gap-1",
|
|
114
|
-
disabled && "opacity-50"
|
|
115
|
-
)}
|
|
116
|
-
>
|
|
117
|
-
{tag}
|
|
118
|
-
{!disabled && (
|
|
119
|
-
<button
|
|
120
|
-
onClick={() => handleRemoveTag(tag)}
|
|
121
|
-
disabled={isUpdating}
|
|
122
|
-
className="ml-1 rounded-full hover:bg-muted p-0.5 transition-colors"
|
|
123
|
-
aria-label={t('editors.removeTag', { tag })}
|
|
124
|
-
>
|
|
125
|
-
<X className="h-3 w-3" />
|
|
126
|
-
</button>
|
|
127
|
-
)}
|
|
128
|
-
</Badge>
|
|
129
|
-
))}
|
|
130
|
-
|
|
131
|
-
{!disabled && (
|
|
132
|
-
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
|
133
|
-
<PopoverTrigger asChild>
|
|
134
|
-
<Button
|
|
135
|
-
variant="outline"
|
|
136
|
-
size="sm"
|
|
137
|
-
className="h-6 px-2 text-xs"
|
|
138
|
-
disabled={isUpdating}
|
|
139
|
-
aria-label={t('editors.addTag')}
|
|
140
|
-
>
|
|
141
|
-
{isUpdating ? (
|
|
142
|
-
<Loader2 className="h-3 w-3 animate-spin" />
|
|
143
|
-
) : (
|
|
144
|
-
<Plus className="h-3 w-3" />
|
|
145
|
-
)}
|
|
146
|
-
</Button>
|
|
147
|
-
</PopoverTrigger>
|
|
148
|
-
<PopoverContent className="w-56 p-0" align="start">
|
|
149
|
-
<Command>
|
|
150
|
-
<CommandInput
|
|
151
|
-
placeholder={t('editors.searchTag')}
|
|
152
|
-
value={searchValue}
|
|
153
|
-
onValueChange={setSearchValue}
|
|
154
|
-
/>
|
|
155
|
-
<CommandList>
|
|
156
|
-
<CommandEmpty>
|
|
157
|
-
{canCreateNewTag ? (
|
|
158
|
-
<CommandItem
|
|
159
|
-
onSelect={() => handleAddTag(searchValue)}
|
|
160
|
-
className="cursor-pointer"
|
|
161
|
-
>
|
|
162
|
-
<Plus className="mr-2 h-4 w-4" />
|
|
163
|
-
{t('editors.createTag', { tag: searchValue.trim() })}
|
|
164
|
-
</CommandItem>
|
|
165
|
-
) : (
|
|
166
|
-
<span className="text-muted-foreground px-2 py-1.5 text-sm">
|
|
167
|
-
{t('editors.noTagResults')}
|
|
168
|
-
</span>
|
|
169
|
-
)}
|
|
170
|
-
</CommandEmpty>
|
|
171
|
-
{availableTags.length > 0 && (
|
|
172
|
-
<CommandGroup heading={t('editors.existingTags')}>
|
|
173
|
-
{availableTags.slice(0, 10).map((tag) => (
|
|
174
|
-
<CommandItem
|
|
175
|
-
key={tag}
|
|
176
|
-
value={tag}
|
|
177
|
-
onSelect={() => handleAddTag(tag)}
|
|
178
|
-
className="cursor-pointer"
|
|
179
|
-
>
|
|
180
|
-
{tag}
|
|
181
|
-
</CommandItem>
|
|
182
|
-
))}
|
|
183
|
-
</CommandGroup>
|
|
184
|
-
)}
|
|
185
|
-
{canCreateNewTag && availableTags.length > 0 && (
|
|
186
|
-
<CommandGroup heading={t('editors.createSection')}>
|
|
187
|
-
<CommandItem
|
|
188
|
-
onSelect={() => handleAddTag(searchValue)}
|
|
189
|
-
className="cursor-pointer"
|
|
190
|
-
>
|
|
191
|
-
<Plus className="mr-2 h-4 w-4" />
|
|
192
|
-
{t('editors.createTag', { tag: searchValue.trim() })}
|
|
193
|
-
</CommandItem>
|
|
194
|
-
</CommandGroup>
|
|
195
|
-
)}
|
|
196
|
-
</CommandList>
|
|
197
|
-
</Command>
|
|
198
|
-
{error && (
|
|
199
|
-
<p className="text-xs text-destructive px-2 pb-2">{error}</p>
|
|
200
|
-
)}
|
|
201
|
-
</PopoverContent>
|
|
202
|
-
</Popover>
|
|
203
|
-
)}
|
|
204
|
-
</div>
|
|
205
|
-
</div>
|
|
206
|
-
);
|
|
207
|
-
}
|
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react';
|
|
2
|
-
import { FolderOpen } from 'lucide-react';
|
|
3
|
-
import {
|
|
4
|
-
Button,
|
|
5
|
-
Dialog,
|
|
6
|
-
DialogContent,
|
|
7
|
-
DialogDescription,
|
|
8
|
-
DialogFooter,
|
|
9
|
-
DialogHeader,
|
|
10
|
-
DialogTitle,
|
|
11
|
-
Input,
|
|
12
|
-
} from '@leanspec/ui-components';
|
|
13
|
-
import { useProject } from '../../contexts';
|
|
14
|
-
import { DirectoryPicker } from './DirectoryPicker';
|
|
15
|
-
import { useTranslation } from 'react-i18next';
|
|
16
|
-
import { useNavigate } from 'react-router-dom';
|
|
17
|
-
|
|
18
|
-
interface CreateProjectDialogProps {
|
|
19
|
-
open: boolean;
|
|
20
|
-
onOpenChange: (open: boolean) => void;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function CreateProjectDialog({ open, onOpenChange }: CreateProjectDialogProps) {
|
|
24
|
-
const { addProject } = useProject();
|
|
25
|
-
const navigate = useNavigate();
|
|
26
|
-
const [path, setPath] = useState('');
|
|
27
|
-
const [mode, setMode] = useState<'picker' | 'manual'>('picker');
|
|
28
|
-
const [isLoading, setIsLoading] = useState(false);
|
|
29
|
-
const [error, setError] = useState<string | null>(null);
|
|
30
|
-
const { t } = useTranslation('common');
|
|
31
|
-
|
|
32
|
-
useEffect(() => {
|
|
33
|
-
if (open) {
|
|
34
|
-
setMode('picker');
|
|
35
|
-
setPath('');
|
|
36
|
-
setError(null);
|
|
37
|
-
}
|
|
38
|
-
}, [open]);
|
|
39
|
-
|
|
40
|
-
const handleAddProject = async (projectPath: string) => {
|
|
41
|
-
try {
|
|
42
|
-
setIsLoading(true);
|
|
43
|
-
setError(null);
|
|
44
|
-
const project = await addProject(projectPath);
|
|
45
|
-
onOpenChange(false);
|
|
46
|
-
if (project?.id) {
|
|
47
|
-
navigate(`/projects/${project.id}/specs`);
|
|
48
|
-
} else {
|
|
49
|
-
navigate('/');
|
|
50
|
-
}
|
|
51
|
-
return project;
|
|
52
|
-
} catch (err) {
|
|
53
|
-
const message = err instanceof Error ? err.message : t('createProject.toastError');
|
|
54
|
-
setError(message);
|
|
55
|
-
return null;
|
|
56
|
-
} finally {
|
|
57
|
-
setIsLoading(false);
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
const handleSubmit = (event: React.FormEvent) => {
|
|
62
|
-
event.preventDefault();
|
|
63
|
-
if (!path.trim()) {
|
|
64
|
-
setError(t('createProject.pathRequired'));
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
void handleAddProject(path.trim());
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
return (
|
|
71
|
-
<Dialog
|
|
72
|
-
open={open}
|
|
73
|
-
onOpenChange={(next) => {
|
|
74
|
-
onOpenChange(next);
|
|
75
|
-
if (!next) setError(null);
|
|
76
|
-
}}
|
|
77
|
-
>
|
|
78
|
-
<DialogContent className="sm:max-w-[600px]">
|
|
79
|
-
<DialogHeader>
|
|
80
|
-
<DialogTitle>{t('createProject.title')}</DialogTitle>
|
|
81
|
-
<DialogDescription>
|
|
82
|
-
{mode === 'picker'
|
|
83
|
-
? t('createProject.descriptionPicker')
|
|
84
|
-
: t('createProject.descriptionManual')}
|
|
85
|
-
</DialogDescription>
|
|
86
|
-
</DialogHeader>
|
|
87
|
-
|
|
88
|
-
{mode === 'picker' ? (
|
|
89
|
-
<div className="space-y-2 min-w-0 overflow-hidden">
|
|
90
|
-
<DirectoryPicker
|
|
91
|
-
onSelect={handleAddProject}
|
|
92
|
-
onCancel={() => onOpenChange(false)}
|
|
93
|
-
initialPath={path}
|
|
94
|
-
actionLabel={isLoading ? t('createProject.adding') : t('createProject.action')}
|
|
95
|
-
isLoading={isLoading}
|
|
96
|
-
/>
|
|
97
|
-
<div className="flex justify-center">
|
|
98
|
-
<Button
|
|
99
|
-
variant="link"
|
|
100
|
-
size="sm"
|
|
101
|
-
onClick={() => setMode('manual')}
|
|
102
|
-
className="text-muted-foreground"
|
|
103
|
-
>
|
|
104
|
-
{t('createProject.enterManually')}
|
|
105
|
-
</Button>
|
|
106
|
-
</div>
|
|
107
|
-
</div>
|
|
108
|
-
) : (
|
|
109
|
-
<form onSubmit={handleSubmit} className="space-y-4">
|
|
110
|
-
<div className="grid gap-2">
|
|
111
|
-
<label
|
|
112
|
-
htmlFor="project-path"
|
|
113
|
-
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
114
|
-
>
|
|
115
|
-
{t('createProject.pathLabel')}
|
|
116
|
-
</label>
|
|
117
|
-
<div className="flex gap-2">
|
|
118
|
-
<Input
|
|
119
|
-
id="project-path"
|
|
120
|
-
value={path}
|
|
121
|
-
onChange={(event) => setPath(event.target.value)}
|
|
122
|
-
placeholder={t('createProject.pathPlaceholder')}
|
|
123
|
-
className="flex-1"
|
|
124
|
-
disabled={isLoading}
|
|
125
|
-
/>
|
|
126
|
-
</div>
|
|
127
|
-
<p className="text-xs text-muted-foreground">
|
|
128
|
-
{t('createProject.pathHelp')}
|
|
129
|
-
</p>
|
|
130
|
-
</div>
|
|
131
|
-
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
132
|
-
<DialogFooter className="flex-col sm:flex-row gap-2">
|
|
133
|
-
<div className="flex-1 flex justify-start">
|
|
134
|
-
<Button
|
|
135
|
-
type="button"
|
|
136
|
-
variant="ghost"
|
|
137
|
-
size="sm"
|
|
138
|
-
onClick={() => setMode('picker')}
|
|
139
|
-
>
|
|
140
|
-
<FolderOpen className="h-4 w-4 mr-2" />
|
|
141
|
-
{t('createProject.browseFolders')}
|
|
142
|
-
</Button>
|
|
143
|
-
</div>
|
|
144
|
-
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
|
145
|
-
{t('actions.cancel')}
|
|
146
|
-
</Button>
|
|
147
|
-
<Button type="submit" disabled={isLoading || !path.trim()}>
|
|
148
|
-
{isLoading ? t('createProject.adding') : t('createProject.action')}
|
|
149
|
-
</Button>
|
|
150
|
-
</DialogFooter>
|
|
151
|
-
</form>
|
|
152
|
-
)}
|
|
153
|
-
|
|
154
|
-
{error && (
|
|
155
|
-
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
|
156
|
-
{error}
|
|
157
|
-
</div>
|
|
158
|
-
)}
|
|
159
|
-
</DialogContent>
|
|
160
|
-
</Dialog>
|
|
161
|
-
);
|
|
162
|
-
}
|
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react';
|
|
2
|
-
import { ArrowLeft, ChevronRight, Folder, Home, Loader2 } from 'lucide-react';
|
|
3
|
-
import { Button } from '@leanspec/ui-components';
|
|
4
|
-
import { cn } from '../../lib/utils';
|
|
5
|
-
import { api } from '../../lib/api';
|
|
6
|
-
import type { DirectoryListResponse } from '../../types/api';
|
|
7
|
-
import { useTranslation } from 'react-i18next';
|
|
8
|
-
|
|
9
|
-
interface DirectoryPickerProps {
|
|
10
|
-
onSelect: (path: string) => void;
|
|
11
|
-
onCancel: () => void;
|
|
12
|
-
initialPath?: string;
|
|
13
|
-
actionLabel?: string;
|
|
14
|
-
isLoading?: boolean;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function DirectoryPicker({
|
|
18
|
-
onSelect,
|
|
19
|
-
onCancel,
|
|
20
|
-
initialPath,
|
|
21
|
-
actionLabel,
|
|
22
|
-
isLoading: externalLoading,
|
|
23
|
-
}: DirectoryPickerProps) {
|
|
24
|
-
const [currentPath, setCurrentPath] = useState(initialPath || '');
|
|
25
|
-
const [items, setItems] = useState<DirectoryListResponse['items']>([]);
|
|
26
|
-
const [internalLoading, setInternalLoading] = useState(false);
|
|
27
|
-
const [error, setError] = useState<string | null>(null);
|
|
28
|
-
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
29
|
-
const { t } = useTranslation('common');
|
|
30
|
-
|
|
31
|
-
const isLoading = externalLoading || internalLoading;
|
|
32
|
-
|
|
33
|
-
useEffect(() => {
|
|
34
|
-
void fetchDirectory(currentPath);
|
|
35
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
36
|
-
}, [currentPath]);
|
|
37
|
-
|
|
38
|
-
useEffect(() => {
|
|
39
|
-
if (scrollContainerRef.current) {
|
|
40
|
-
scrollContainerRef.current.scrollLeft = scrollContainerRef.current.scrollWidth;
|
|
41
|
-
}
|
|
42
|
-
}, [currentPath]);
|
|
43
|
-
|
|
44
|
-
const fetchDirectory = async (path: string) => {
|
|
45
|
-
try {
|
|
46
|
-
setInternalLoading(true);
|
|
47
|
-
setError(null);
|
|
48
|
-
const data = await api.listDirectory(path);
|
|
49
|
-
setItems(data.items || []);
|
|
50
|
-
if (!path && data.path) {
|
|
51
|
-
setCurrentPath(data.path);
|
|
52
|
-
}
|
|
53
|
-
} catch (err) {
|
|
54
|
-
const message = err instanceof Error ? err.message : t('directoryPicker.error');
|
|
55
|
-
setError(message);
|
|
56
|
-
} finally {
|
|
57
|
-
setInternalLoading(false);
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
const handleNavigate = (path: string) => {
|
|
62
|
-
setCurrentPath(path);
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
const parentItem = items.find((item: DirectoryListResponse['items'][number]) => item.name === '..');
|
|
66
|
-
const displayItems = items.filter((item: DirectoryListResponse['items'][number]) => item.name !== '..');
|
|
67
|
-
|
|
68
|
-
const getPathSegments = (path: string) => {
|
|
69
|
-
if (!path) return [];
|
|
70
|
-
const separator = path.includes('\\') ? '\\' : '/';
|
|
71
|
-
const parts = path.split(separator).filter(Boolean);
|
|
72
|
-
const isUnixRoot = path.startsWith('/');
|
|
73
|
-
|
|
74
|
-
return parts.map((part, index) => {
|
|
75
|
-
let segmentPath = parts.slice(0, index + 1).join(separator);
|
|
76
|
-
if (isUnixRoot) segmentPath = '/' + segmentPath;
|
|
77
|
-
return { name: part, path: segmentPath };
|
|
78
|
-
});
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
const segments = getPathSegments(currentPath);
|
|
82
|
-
const resolvedActionLabel = actionLabel ?? t('directoryPicker.action');
|
|
83
|
-
|
|
84
|
-
return (
|
|
85
|
-
<div className="flex flex-col h-[400px] gap-4 min-w-0 overflow-hidden">
|
|
86
|
-
<div className="flex items-center border rounded-md p-1 gap-1 bg-muted/30 min-w-0 overflow-hidden">
|
|
87
|
-
<Button
|
|
88
|
-
variant="ghost"
|
|
89
|
-
size="icon"
|
|
90
|
-
className="h-8 w-8 shrink-0"
|
|
91
|
-
disabled={!parentItem || isLoading}
|
|
92
|
-
onClick={() => parentItem && handleNavigate(parentItem.path)}
|
|
93
|
-
title={t('directoryPicker.parent')}
|
|
94
|
-
>
|
|
95
|
-
<ArrowLeft className="h-4 w-4" />
|
|
96
|
-
</Button>
|
|
97
|
-
|
|
98
|
-
<div className="w-px h-5 bg-border mx-1 shrink-0" />
|
|
99
|
-
|
|
100
|
-
<div
|
|
101
|
-
ref={scrollContainerRef}
|
|
102
|
-
className="flex-1 overflow-x-auto whitespace-nowrap flex items-center scrollbar-hide px-1 min-w-0"
|
|
103
|
-
title={currentPath}
|
|
104
|
-
>
|
|
105
|
-
<Button
|
|
106
|
-
onClick={() => handleNavigate('/')}
|
|
107
|
-
variant="ghost"
|
|
108
|
-
size="icon"
|
|
109
|
-
className="h-7 w-7 shrink-0"
|
|
110
|
-
title={t('directoryPicker.rootAction')}
|
|
111
|
-
>
|
|
112
|
-
<Home className="h-4 w-4 text-muted-foreground" />
|
|
113
|
-
</Button>
|
|
114
|
-
|
|
115
|
-
{segments.map((segment, index) => (
|
|
116
|
-
<div key={segment.path} className="flex items-center shrink-0">
|
|
117
|
-
<ChevronRight className="h-3 w-3 text-muted-foreground mx-0.5 shrink-0" />
|
|
118
|
-
<Button
|
|
119
|
-
onClick={() => handleNavigate(segment.path)}
|
|
120
|
-
variant="ghost"
|
|
121
|
-
size="sm"
|
|
122
|
-
className={cn(
|
|
123
|
-
'h-7 text-sm',
|
|
124
|
-
index === segments.length - 1 ? 'font-medium text-foreground' : 'text-muted-foreground'
|
|
125
|
-
)}
|
|
126
|
-
>
|
|
127
|
-
{segment.name}
|
|
128
|
-
</Button>
|
|
129
|
-
</div>
|
|
130
|
-
))}
|
|
131
|
-
</div>
|
|
132
|
-
</div>
|
|
133
|
-
|
|
134
|
-
<div className="flex-1 border rounded-md overflow-hidden relative bg-background min-h-0">
|
|
135
|
-
{isLoading && (
|
|
136
|
-
<div className="absolute inset-0 bg-background/50 flex items-center justify-center z-10">
|
|
137
|
-
<Loader2 className="h-6 w-6 animate-spin" />
|
|
138
|
-
</div>
|
|
139
|
-
)}
|
|
140
|
-
|
|
141
|
-
{error ? (
|
|
142
|
-
<div className="p-4 text-destructive text-sm text-center">
|
|
143
|
-
{error}
|
|
144
|
-
<Button variant="link" onClick={() => fetchDirectory(currentPath)} className="block mx-auto mt-2">
|
|
145
|
-
{t('actions.retry')}
|
|
146
|
-
</Button>
|
|
147
|
-
</div>
|
|
148
|
-
) : (
|
|
149
|
-
<div className="h-full overflow-auto">
|
|
150
|
-
<div className="p-1">
|
|
151
|
-
{displayItems.length === 0 && !isLoading ? (
|
|
152
|
-
<div className="p-4 text-center text-sm text-muted-foreground">{t('directoryPicker.empty')}</div>
|
|
153
|
-
) : (
|
|
154
|
-
displayItems.map((item: DirectoryListResponse['items'][number]) => (
|
|
155
|
-
<Button
|
|
156
|
-
key={item.path}
|
|
157
|
-
onClick={() => handleNavigate(item.path)}
|
|
158
|
-
variant="ghost"
|
|
159
|
-
size="sm"
|
|
160
|
-
className="w-full justify-start gap-3 h-9 group"
|
|
161
|
-
>
|
|
162
|
-
<Folder className="h-4 w-4 text-blue-500 fill-blue-500/20 group-hover:fill-blue-500/30 transition-colors shrink-0" />
|
|
163
|
-
<span className="truncate">{item.name}</span>
|
|
164
|
-
</Button>
|
|
165
|
-
))
|
|
166
|
-
)}
|
|
167
|
-
</div>
|
|
168
|
-
</div>
|
|
169
|
-
)}
|
|
170
|
-
</div>
|
|
171
|
-
|
|
172
|
-
<div className="flex justify-end gap-2">
|
|
173
|
-
<Button variant="outline" onClick={onCancel} disabled={isLoading}>
|
|
174
|
-
{t('actions.cancel')}
|
|
175
|
-
</Button>
|
|
176
|
-
<Button onClick={() => onSelect(currentPath)} disabled={isLoading || !currentPath}>
|
|
177
|
-
{resolvedActionLabel}
|
|
178
|
-
</Button>
|
|
179
|
-
</div>
|
|
180
|
-
</div>
|
|
181
|
-
);
|
|
182
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react';
|
|
2
|
-
import { ArrowUp } from 'lucide-react';
|
|
3
|
-
import { Button } from '@leanspec/ui-components';
|
|
4
|
-
import { useTranslation } from 'react-i18next';
|
|
5
|
-
|
|
6
|
-
export function BackToTop() {
|
|
7
|
-
const [isVisible, setIsVisible] = useState(false);
|
|
8
|
-
const { t } = useTranslation('common');
|
|
9
|
-
|
|
10
|
-
useEffect(() => {
|
|
11
|
-
const toggleVisibility = () => {
|
|
12
|
-
if (window.pageYOffset > 300) {
|
|
13
|
-
setIsVisible(true);
|
|
14
|
-
} else {
|
|
15
|
-
setIsVisible(false);
|
|
16
|
-
}
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
window.addEventListener('scroll', toggleVisibility);
|
|
20
|
-
return () => window.removeEventListener('scroll', toggleVisibility);
|
|
21
|
-
}, []);
|
|
22
|
-
|
|
23
|
-
const scrollToTop = () => {
|
|
24
|
-
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
if (!isVisible) return null;
|
|
28
|
-
|
|
29
|
-
return (
|
|
30
|
-
<Button
|
|
31
|
-
onClick={scrollToTop}
|
|
32
|
-
size="icon"
|
|
33
|
-
className="fixed bottom-6 right-6 h-12 w-12 rounded-full shadow-lg z-40 hover:scale-110 transition-transform"
|
|
34
|
-
aria-label={t('actions.backToTop')}
|
|
35
|
-
>
|
|
36
|
-
<ArrowUp className="h-5 w-5" />
|
|
37
|
-
</Button>
|
|
38
|
-
);
|
|
39
|
-
}
|