@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.
Files changed (150) hide show
  1. package/bin/leanspec-ui.js +191 -0
  2. package/dist/assets/_baseUniq-CRqreL7N.js +1 -0
  3. package/dist/assets/arc-DMhx9AJT.js +1 -0
  4. package/dist/assets/architectureDiagram-VXUJARFQ-DM0L0YzO.js +36 -0
  5. package/dist/assets/blockDiagram-VD42YOAC-DHQXDHsD.js +122 -0
  6. package/dist/assets/c4Diagram-YG6GDRKO-0L7o2gpH.js +10 -0
  7. package/dist/assets/channel-2tOl0nAZ.js +1 -0
  8. package/dist/assets/chunk-4BX2VUAB-CwFT-Uaj.js +1 -0
  9. package/dist/assets/chunk-55IACEB6-CjvuUHHG.js +1 -0
  10. package/dist/assets/chunk-B4BG7PRW-BRJBysMK.js +165 -0
  11. package/dist/assets/chunk-DI55MBZ5-BnNEeoaA.js +220 -0
  12. package/dist/assets/chunk-FMBD7UC4-BK2l30pm.js +15 -0
  13. package/dist/assets/chunk-QN33PNHL-BN_cZkCU.js +1 -0
  14. package/dist/assets/chunk-QZHKN3VN-Brc3Yrub.js +1 -0
  15. package/dist/assets/chunk-TZMSLE5B-D2zzpLfO.js +1 -0
  16. package/dist/assets/classDiagram-2ON5EDUG-BB9CSNmS.js +1 -0
  17. package/dist/assets/classDiagram-v2-WZHVMYZB-BB9CSNmS.js +1 -0
  18. package/dist/assets/clone-BjxVFtyI.js +1 -0
  19. package/dist/assets/core-DV6XEvTN.js +1 -0
  20. package/dist/assets/cose-bilkent-S5V4N54A-CLJgM3XR.js +1 -0
  21. package/dist/assets/cytoscape.esm-5J0xJHOV.js +321 -0
  22. package/dist/assets/dagre-6UL2VRFP-_IFvBJKJ.js +4 -0
  23. package/dist/assets/diagram-PSM6KHXK--83HIYSQ.js +24 -0
  24. package/dist/assets/diagram-QEK2KX5R-6jAWnCnZ.js +43 -0
  25. package/dist/assets/diagram-S2PKOQOG-D5pwHvjZ.js +24 -0
  26. package/dist/assets/erDiagram-Q2GNP2WA-B4FV3mTd.js +60 -0
  27. package/dist/assets/flowDiagram-NV44I4VS-mtD2kF4M.js +162 -0
  28. package/dist/assets/ganttDiagram-JELNMOA3-BKALgqTK.js +267 -0
  29. package/dist/assets/gitGraphDiagram-NY62KEGX-Bd7r0pAf.js +65 -0
  30. package/dist/assets/graph-B2rEI7cK.js +1 -0
  31. package/dist/assets/index-Bekv_o1t.css +1 -0
  32. package/dist/assets/index-DSRxU-E5.js +389 -0
  33. package/dist/assets/infoDiagram-WHAUD3N6--nJOBKqh.js +2 -0
  34. package/dist/assets/journeyDiagram-XKPGCS4Q-BzGutKN3.js +139 -0
  35. package/dist/assets/kanban-definition-3W4ZIXB7-DyQO17vq.js +89 -0
  36. package/dist/assets/katex-XbL3y5x-.js +261 -0
  37. package/dist/assets/layout-iCSHU015.js +1 -0
  38. package/dist/assets/min-BK_AIJdo.js +1 -0
  39. package/dist/assets/mindmap-definition-VGOIOE7T-BZMj_6zo.js +68 -0
  40. package/dist/assets/pieDiagram-ADFJNKIX-CkAGsq9p.js +30 -0
  41. package/dist/assets/quadrantDiagram-AYHSOK5B-CWa93px1.js +7 -0
  42. package/dist/assets/requirementDiagram-UZGBJVZJ-CufFVR8c.js +64 -0
  43. package/dist/assets/sankeyDiagram-TZEHDZUN-BEPgVgU4.js +10 -0
  44. package/dist/assets/sequenceDiagram-WL72ISMW-BkdBWhel.js +145 -0
  45. package/dist/assets/stateDiagram-FKZM4ZOC-D5T73yx0.js +1 -0
  46. package/dist/assets/stateDiagram-v2-4FDKWEC3-9hJWG2n6.js +1 -0
  47. package/dist/assets/timeline-definition-IT6M3QCI-CX7kTdU2.js +61 -0
  48. package/dist/assets/treemap-KMMF4GRG-ftWCQ9lJ.js +128 -0
  49. package/dist/assets/xychartDiagram-PRI3JC2R-Ngrels4n.js +7 -0
  50. package/{index.html → dist/index.html} +2 -1
  51. package/package.json +12 -2
  52. package/eslint.config.js +0 -23
  53. package/package.json.backup +0 -83
  54. package/postcss.config.js +0 -6
  55. package/src/App.css +0 -42
  56. package/src/App.tsx +0 -17
  57. package/src/assets/react.svg +0 -1
  58. package/src/components/LanguageSwitcher.tsx +0 -67
  59. package/src/components/Layout.tsx +0 -88
  60. package/src/components/MainSidebar.tsx +0 -163
  61. package/src/components/MermaidDiagram.tsx +0 -85
  62. package/src/components/MinimalLayout.tsx +0 -51
  63. package/src/components/Navigation.tsx +0 -254
  64. package/src/components/PriorityBadge.tsx +0 -59
  65. package/src/components/ProjectSwitcher.tsx +0 -222
  66. package/src/components/QuickSearch.tsx +0 -225
  67. package/src/components/RootRedirect.tsx +0 -40
  68. package/src/components/SpecDetailLayout.context.ts +0 -10
  69. package/src/components/SpecDetailLayout.tsx +0 -14
  70. package/src/components/SpecsNavSidebar.tsx +0 -615
  71. package/src/components/StatusBadge.tsx +0 -59
  72. package/src/components/ThemeToggle.tsx +0 -25
  73. package/src/components/Tooltip.tsx +0 -29
  74. package/src/components/context/ContextClient.tsx +0 -471
  75. package/src/components/context/ContextFileDetail.tsx +0 -163
  76. package/src/components/dashboard/ActivityItem.tsx +0 -36
  77. package/src/components/dashboard/DashboardClient.tsx +0 -218
  78. package/src/components/dashboard/SpecListItem.tsx +0 -58
  79. package/src/components/dashboard/StatCard.tsx +0 -52
  80. package/src/components/dependencies/SpecNode.tsx +0 -128
  81. package/src/components/dependencies/SpecSidebar.tsx +0 -256
  82. package/src/components/dependencies/constants.ts +0 -25
  83. package/src/components/dependencies/types.ts +0 -38
  84. package/src/components/dependencies/utils.ts +0 -261
  85. package/src/components/metadata-editors/PriorityEditor.tsx +0 -89
  86. package/src/components/metadata-editors/StatusEditor.tsx +0 -85
  87. package/src/components/metadata-editors/TagsEditor.tsx +0 -207
  88. package/src/components/projects/CreateProjectDialog.tsx +0 -162
  89. package/src/components/projects/DirectoryPicker.tsx +0 -182
  90. package/src/components/shared/BackToTop.tsx +0 -39
  91. package/src/components/shared/ColorPicker.tsx +0 -68
  92. package/src/components/shared/EmptyState.tsx +0 -35
  93. package/src/components/shared/ErrorBoundary.tsx +0 -79
  94. package/src/components/shared/PageHeader.tsx +0 -23
  95. package/src/components/shared/PageTransition.tsx +0 -40
  96. package/src/components/shared/ProjectAvatar.tsx +0 -107
  97. package/src/components/shared/Skeletons.tsx +0 -184
  98. package/src/components/spec-detail/EditableMetadata.tsx +0 -129
  99. package/src/components/spec-detail/MarkdownRenderer.tsx +0 -47
  100. package/src/components/spec-detail/TableOfContents.tsx +0 -150
  101. package/src/components/specs/BoardView.tsx +0 -204
  102. package/src/components/specs/ListView.tsx +0 -62
  103. package/src/components/specs/SpecsFilters.tsx +0 -190
  104. package/src/contexts/KeyboardShortcutsContext.tsx +0 -95
  105. package/src/contexts/LayoutContext.tsx +0 -45
  106. package/src/contexts/ProjectContext.tsx +0 -163
  107. package/src/contexts/ThemeContext.tsx +0 -90
  108. package/src/contexts/index.ts +0 -7
  109. package/src/hooks/useKeyboardShortcuts.ts +0 -87
  110. package/src/index.css +0 -624
  111. package/src/lib/api.ts +0 -72
  112. package/src/lib/backend-adapter.ts +0 -382
  113. package/src/lib/date-utils.ts +0 -122
  114. package/src/lib/i18n.test.ts +0 -57
  115. package/src/lib/i18n.ts +0 -51
  116. package/src/lib/markdown-utils.ts +0 -38
  117. package/src/lib/sub-spec-utils.ts +0 -166
  118. package/src/lib/utils.ts +0 -6
  119. package/src/locales/en/common.json +0 -660
  120. package/src/locales/en/errors.json +0 -20
  121. package/src/locales/en/help.json +0 -8
  122. package/src/locales/zh-CN/common.json +0 -660
  123. package/src/locales/zh-CN/errors.json +0 -20
  124. package/src/locales/zh-CN/help.json +0 -8
  125. package/src/main.tsx +0 -12
  126. package/src/pages/ContextPage.tsx +0 -111
  127. package/src/pages/DashboardPage.tsx +0 -97
  128. package/src/pages/DependenciesPage.tsx +0 -881
  129. package/src/pages/ProjectsPage.tsx +0 -432
  130. package/src/pages/SpecDetailPage.tsx +0 -592
  131. package/src/pages/SpecsPage.tsx +0 -319
  132. package/src/pages/StatsPage.tsx +0 -307
  133. package/src/router/projectRoutes.tsx +0 -36
  134. package/src/router.tsx +0 -33
  135. package/src/test/setup.ts +0 -39
  136. package/src/types/api.ts +0 -185
  137. package/tailwind.config.ts +0 -57
  138. package/tsconfig.app.json +0 -29
  139. package/tsconfig.json +0 -7
  140. package/tsconfig.node.json +0 -26
  141. package/tsconfig.tsbuildinfo +0 -1
  142. package/vite.config.ts +0 -27
  143. package/vitest.config.ts +0 -18
  144. /package/{public → dist}/favicon.ico +0 -0
  145. /package/{public → dist}/github-mark-white.svg +0 -0
  146. /package/{public → dist}/github-mark.svg +0 -0
  147. /package/{public → dist}/logo-dark-bg.svg +0 -0
  148. /package/{public → dist}/logo-with-bg.svg +0 -0
  149. /package/{public → dist}/logo.svg +0 -0
  150. /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
- }