@aion0/forge 0.3.0 → 0.3.1
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/CLAUDE.md +9 -2
- package/README.md +9 -9
- package/app/api/claude-templates/route.ts +145 -0
- package/app/api/docs/sessions/route.ts +3 -3
- package/app/api/monitor/route.ts +2 -2
- package/app/api/pipelines/route.ts +2 -2
- package/app/api/preview/[...path]/route.ts +2 -2
- package/app/api/preview/route.ts +3 -4
- package/app/api/projects/route.ts +19 -0
- package/app/api/skills/local/route.ts +228 -0
- package/app/api/skills/route.ts +36 -11
- package/app/api/terminal-state/route.ts +2 -2
- package/app/login/page.tsx +1 -1
- package/bin/forge-server.mjs +49 -33
- package/cli/mw.ts +27 -9
- package/components/Dashboard.tsx +14 -0
- package/components/ProjectManager.tsx +581 -42
- package/components/SessionView.tsx +1 -1
- package/components/SettingsModal.tsx +18 -1
- package/components/SkillsPanel.tsx +515 -29
- package/components/WebTerminal.tsx +50 -5
- package/instrumentation.ts +2 -2
- package/lib/claude-sessions.ts +2 -2
- package/lib/claude-templates.ts +227 -0
- package/lib/cloudflared.ts +4 -3
- package/lib/crypto.ts +3 -4
- package/lib/dirs.ts +34 -0
- package/lib/flows.ts +2 -2
- package/lib/init.ts +2 -2
- package/lib/password.ts +2 -2
- package/lib/pipeline.ts +3 -3
- package/lib/session-watcher.ts +2 -2
- package/lib/settings.ts +4 -2
- package/lib/skills.ts +444 -79
- package/lib/telegram-bot.ts +12 -5
- package/lib/terminal-standalone.ts +3 -2
- package/package.json +1 -1
- package/src/config/index.ts +6 -7
- package/src/core/db/database.ts +17 -5
|
@@ -2,16 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback } from 'react';
|
|
4
4
|
|
|
5
|
+
type ItemType = 'skill' | 'command';
|
|
6
|
+
|
|
5
7
|
interface Skill {
|
|
6
8
|
name: string;
|
|
9
|
+
type: ItemType;
|
|
7
10
|
displayName: string;
|
|
8
11
|
description: string;
|
|
9
12
|
author: string;
|
|
10
13
|
version: string;
|
|
11
14
|
tags: string[];
|
|
12
15
|
score: number;
|
|
16
|
+
rating: number;
|
|
13
17
|
sourceUrl: string;
|
|
14
18
|
installedGlobal: boolean;
|
|
19
|
+
installedVersion: string;
|
|
20
|
+
hasUpdate: boolean;
|
|
15
21
|
installedProjects: string[];
|
|
16
22
|
}
|
|
17
23
|
|
|
@@ -26,6 +32,22 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
26
32
|
const [syncing, setSyncing] = useState(false);
|
|
27
33
|
const [loading, setLoading] = useState(true);
|
|
28
34
|
const [installTarget, setInstallTarget] = useState<{ skill: string; show: boolean }>({ skill: '', show: false });
|
|
35
|
+
const [typeFilter, setTypeFilter] = useState<'all' | 'skill' | 'command' | 'local' | 'rules'>('all');
|
|
36
|
+
const [localItems, setLocalItems] = useState<{ name: string; type: string; scope: string; fileCount: number; projectPath?: string }[]>([]);
|
|
37
|
+
// Rules (CLAUDE.md templates)
|
|
38
|
+
const [rulesTemplates, setRulesTemplates] = useState<{ id: string; name: string; description: string; tags: string[]; builtin: boolean; isDefault: boolean; content: string }[]>([]);
|
|
39
|
+
const [rulesProjects, setRulesProjects] = useState<{ name: string; path: string }[]>([]);
|
|
40
|
+
const [rulesSelectedTemplate, setRulesSelectedTemplate] = useState<string | null>(null);
|
|
41
|
+
const [rulesEditing, setRulesEditing] = useState(false);
|
|
42
|
+
const [rulesEditId, setRulesEditId] = useState('');
|
|
43
|
+
const [rulesEditName, setRulesEditName] = useState('');
|
|
44
|
+
const [rulesEditDesc, setRulesEditDesc] = useState('');
|
|
45
|
+
const [rulesEditContent, setRulesEditContent] = useState('');
|
|
46
|
+
const [rulesEditDefault, setRulesEditDefault] = useState(false);
|
|
47
|
+
const [rulesShowNew, setRulesShowNew] = useState(false);
|
|
48
|
+
const [rulesBatchProjects, setRulesBatchProjects] = useState<Set<string>>(new Set());
|
|
49
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
50
|
+
const [collapsedLocalSections, setCollapsedLocalSections] = useState<Set<string>>(new Set());
|
|
29
51
|
const [expandedSkill, setExpandedSkill] = useState<string | null>(null);
|
|
30
52
|
const [skillFiles, setSkillFiles] = useState<{ name: string; path: string; type: string }[]>([]);
|
|
31
53
|
const [activeFile, setActiveFile] = useState<string | null>(null);
|
|
@@ -33,16 +55,78 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
33
55
|
|
|
34
56
|
const fetchSkills = useCallback(async () => {
|
|
35
57
|
try {
|
|
36
|
-
const
|
|
37
|
-
|
|
58
|
+
const [registryRes, localRes] = await Promise.all([
|
|
59
|
+
fetch('/api/skills'),
|
|
60
|
+
fetch('/api/skills/local?action=scan&all=1'),
|
|
61
|
+
]);
|
|
62
|
+
const data = await registryRes.json();
|
|
38
63
|
setSkills(data.skills || []);
|
|
39
64
|
setProjects(data.projects || []);
|
|
65
|
+
const localData = await localRes.json();
|
|
66
|
+
// Filter out items already in registry
|
|
67
|
+
const registryNames = new Set((data.skills || []).map((s: any) => s.name));
|
|
68
|
+
setLocalItems((localData.items || []).filter((i: any) => !registryNames.has(i.name)));
|
|
40
69
|
} catch {}
|
|
41
70
|
setLoading(false);
|
|
42
71
|
}, []);
|
|
43
72
|
|
|
44
73
|
useEffect(() => { fetchSkills(); }, [fetchSkills]);
|
|
45
74
|
|
|
75
|
+
const fetchRules = useCallback(async () => {
|
|
76
|
+
try {
|
|
77
|
+
const res = await fetch('/api/claude-templates?action=list');
|
|
78
|
+
const data = await res.json();
|
|
79
|
+
setRulesTemplates(data.templates || []);
|
|
80
|
+
setRulesProjects(data.projects || []);
|
|
81
|
+
} catch {}
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
useEffect(() => { if (typeFilter === 'rules') fetchRules(); }, [typeFilter, fetchRules]);
|
|
85
|
+
|
|
86
|
+
const saveRule = async () => {
|
|
87
|
+
if (!rulesEditId || !rulesEditName || !rulesEditContent) return;
|
|
88
|
+
await fetch('/api/claude-templates', {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'Content-Type': 'application/json' },
|
|
91
|
+
body: JSON.stringify({ action: 'save', id: rulesEditId, name: rulesEditName, description: rulesEditDesc, tags: [], content: rulesEditContent, isDefault: rulesEditDefault }),
|
|
92
|
+
});
|
|
93
|
+
setRulesEditing(false);
|
|
94
|
+
setRulesShowNew(false);
|
|
95
|
+
fetchRules();
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const deleteRule = async (id: string) => {
|
|
99
|
+
if (!confirm(`Delete template "${id}"?`)) return;
|
|
100
|
+
await fetch('/api/claude-templates', {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: { 'Content-Type': 'application/json' },
|
|
103
|
+
body: JSON.stringify({ action: 'delete', id }),
|
|
104
|
+
});
|
|
105
|
+
if (rulesSelectedTemplate === id) setRulesSelectedTemplate(null);
|
|
106
|
+
fetchRules();
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const toggleDefault = async (id: string, isDefault: boolean) => {
|
|
110
|
+
await fetch('/api/claude-templates', {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'Content-Type': 'application/json' },
|
|
113
|
+
body: JSON.stringify({ action: 'set-default', id, isDefault }),
|
|
114
|
+
});
|
|
115
|
+
fetchRules();
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const batchInject = async (templateId: string) => {
|
|
119
|
+
const projects = [...rulesBatchProjects];
|
|
120
|
+
if (!projects.length) return;
|
|
121
|
+
await fetch('/api/claude-templates', {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { 'Content-Type': 'application/json' },
|
|
124
|
+
body: JSON.stringify({ action: 'inject', templateId, projects }),
|
|
125
|
+
});
|
|
126
|
+
setRulesBatchProjects(new Set());
|
|
127
|
+
fetchRules();
|
|
128
|
+
};
|
|
129
|
+
|
|
46
130
|
const sync = async () => {
|
|
47
131
|
setSyncing(true);
|
|
48
132
|
await fetch('/api/skills', {
|
|
@@ -85,11 +169,17 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
85
169
|
} catch { setSkillFiles([]); }
|
|
86
170
|
};
|
|
87
171
|
|
|
88
|
-
const loadFile = async (skillName: string, filePath: string) => {
|
|
172
|
+
const loadFile = async (skillName: string, filePath: string, isLocalItem?: boolean, localType?: string, localProject?: string) => {
|
|
89
173
|
setActiveFile(filePath);
|
|
90
174
|
setFileContent('Loading...');
|
|
91
175
|
try {
|
|
92
|
-
|
|
176
|
+
let res;
|
|
177
|
+
if (isLocalItem) {
|
|
178
|
+
const projectParam = localProject ? `&project=${encodeURIComponent(localProject)}` : '';
|
|
179
|
+
res = await fetch(`/api/skills/local?action=read&name=${encodeURIComponent(skillName)}&type=${localType || 'command'}&path=${encodeURIComponent(filePath)}${projectParam}`);
|
|
180
|
+
} else {
|
|
181
|
+
res = await fetch(`/api/skills?action=file&name=${encodeURIComponent(skillName)}&path=${encodeURIComponent(filePath)}`);
|
|
182
|
+
}
|
|
93
183
|
const data = await res.json();
|
|
94
184
|
setFileContent(data.content || '(Empty)');
|
|
95
185
|
} catch { setFileContent('(Failed to load)'); }
|
|
@@ -104,10 +194,37 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
104
194
|
fetchSkills();
|
|
105
195
|
};
|
|
106
196
|
|
|
107
|
-
// Filter
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
:
|
|
197
|
+
// Filter by project, type, and search
|
|
198
|
+
const q = searchQuery.toLowerCase();
|
|
199
|
+
const filtered = typeFilter === 'local' ? [] : skills
|
|
200
|
+
.filter(s => projectFilter ? (s.installedGlobal || s.installedProjects.includes(projectFilter)) : true)
|
|
201
|
+
.filter(s => typeFilter === 'all' ? true : s.type === typeFilter)
|
|
202
|
+
.filter(s => !q || s.name.toLowerCase().includes(q) || s.displayName.toLowerCase().includes(q) || s.description.toLowerCase().includes(q));
|
|
203
|
+
|
|
204
|
+
const filteredLocal = localItems
|
|
205
|
+
.filter(item => typeFilter === 'local' || typeFilter === 'all' || item.type === typeFilter)
|
|
206
|
+
.filter(item => !q || item.name.toLowerCase().includes(q));
|
|
207
|
+
|
|
208
|
+
// Group local items by scope
|
|
209
|
+
const localGroups = new Map<string, typeof localItems>();
|
|
210
|
+
for (const item of filteredLocal) {
|
|
211
|
+
const key = item.scope;
|
|
212
|
+
if (!localGroups.has(key)) localGroups.set(key, []);
|
|
213
|
+
localGroups.get(key)!.push(item);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const toggleLocalSection = (section: string) => {
|
|
217
|
+
setCollapsedLocalSections(prev => {
|
|
218
|
+
const next = new Set(prev);
|
|
219
|
+
if (next.has(section)) next.delete(section);
|
|
220
|
+
else next.add(section);
|
|
221
|
+
return next;
|
|
222
|
+
});
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const skillCount = skills.filter(s => s.type === 'skill').length;
|
|
226
|
+
const commandCount = skills.filter(s => s.type === 'command').length;
|
|
227
|
+
const localCount = localItems.length;
|
|
111
228
|
|
|
112
229
|
if (loading) {
|
|
113
230
|
return <div className="p-4 text-xs text-[var(--text-secondary)]">Loading skills...</div>;
|
|
@@ -118,8 +235,22 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
118
235
|
{/* Header */}
|
|
119
236
|
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--border)] shrink-0">
|
|
120
237
|
<div className="flex items-center gap-2">
|
|
121
|
-
<span className="text-xs font-semibold text-[var(--text-primary)]">
|
|
122
|
-
<
|
|
238
|
+
<span className="text-xs font-semibold text-[var(--text-primary)]">Marketplace</span>
|
|
239
|
+
<div className="flex items-center bg-[var(--bg-tertiary)] rounded p-0.5">
|
|
240
|
+
{([['all', `All (${skills.length})`], ['skill', `Skills (${skillCount})`], ['command', `Commands (${commandCount})`], ['local', `Local (${localCount})`], ['rules', 'Rules']] as const).map(([value, label]) => (
|
|
241
|
+
<button
|
|
242
|
+
key={value}
|
|
243
|
+
onClick={() => setTypeFilter(value)}
|
|
244
|
+
className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
|
|
245
|
+
typeFilter === value
|
|
246
|
+
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
|
|
247
|
+
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
248
|
+
}`}
|
|
249
|
+
>
|
|
250
|
+
{label}
|
|
251
|
+
</button>
|
|
252
|
+
))}
|
|
253
|
+
</div>
|
|
123
254
|
</div>
|
|
124
255
|
<button
|
|
125
256
|
onClick={sync}
|
|
@@ -129,8 +260,18 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
129
260
|
{syncing ? 'Syncing...' : 'Sync'}
|
|
130
261
|
</button>
|
|
131
262
|
</div>
|
|
263
|
+
{/* Search — hide on rules tab */}
|
|
264
|
+
{typeFilter !== 'rules' && <div className="px-3 py-1.5 border-b border-[var(--border)] shrink-0">
|
|
265
|
+
<input
|
|
266
|
+
type="text"
|
|
267
|
+
value={searchQuery}
|
|
268
|
+
onChange={e => setSearchQuery(e.target.value)}
|
|
269
|
+
placeholder="Search skills & commands..."
|
|
270
|
+
className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] focus:outline-none focus:border-[var(--accent)]"
|
|
271
|
+
/>
|
|
272
|
+
</div>}
|
|
132
273
|
|
|
133
|
-
{skills.length === 0 ? (
|
|
274
|
+
{typeFilter === 'rules' ? null : skills.length === 0 ? (
|
|
134
275
|
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-[var(--text-secondary)]">
|
|
135
276
|
<p className="text-xs">No skills yet</p>
|
|
136
277
|
<button onClick={sync} className="text-xs px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90">
|
|
@@ -141,6 +282,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
141
282
|
<div className="flex-1 flex min-h-0">
|
|
142
283
|
{/* Left: skill list */}
|
|
143
284
|
<div className="w-56 border-r border-[var(--border)] overflow-y-auto shrink-0">
|
|
285
|
+
{/* Registry items */}
|
|
144
286
|
{filtered.map(skill => {
|
|
145
287
|
const isInstalled = skill.installedGlobal || skill.installedProjects.length > 0;
|
|
146
288
|
const isActive = expandedSkill === skill.name;
|
|
@@ -155,40 +297,216 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
155
297
|
<div className="flex items-center gap-2">
|
|
156
298
|
<span className="text-[11px] font-semibold text-[var(--text-primary)] truncate flex-1">{skill.displayName}</span>
|
|
157
299
|
<span className="text-[8px] text-[var(--text-secondary)] font-mono shrink-0">v{skill.version}</span>
|
|
158
|
-
{skill.
|
|
159
|
-
<span className="text-[8px] text-[var(--yellow)] shrink-0"
|
|
300
|
+
{skill.rating > 0 && (
|
|
301
|
+
<span className="text-[8px] text-[var(--yellow)] shrink-0" title={`Rating: ${skill.rating}/5`}>
|
|
302
|
+
{'★'.repeat(Math.round(skill.rating))}{'☆'.repeat(5 - Math.round(skill.rating))}
|
|
303
|
+
</span>
|
|
304
|
+
)}
|
|
305
|
+
{skill.score > 0 && !skill.rating && (
|
|
306
|
+
<span className="text-[8px] text-[var(--text-secondary)] shrink-0">{skill.score}pt</span>
|
|
160
307
|
)}
|
|
161
308
|
</div>
|
|
162
309
|
<p className="text-[9px] text-[var(--text-secondary)] mt-0.5 line-clamp-1">{skill.description}</p>
|
|
163
310
|
<div className="flex items-center gap-1.5 mt-1">
|
|
311
|
+
<span className={`text-[7px] px-1 rounded font-medium ${
|
|
312
|
+
skill.type === 'skill' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
|
|
313
|
+
}`}>{skill.type === 'skill' ? 'SKILL' : 'CMD'}</span>
|
|
164
314
|
<span className="text-[8px] text-[var(--text-secondary)]">{skill.author}</span>
|
|
165
315
|
{skill.tags.slice(0, 2).map(t => (
|
|
166
316
|
<span key={t} className="text-[7px] px-1 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">{t}</span>
|
|
167
317
|
))}
|
|
168
|
-
{
|
|
318
|
+
{skill.hasUpdate && <span className="text-[8px] text-[var(--yellow)] ml-auto">update</span>}
|
|
319
|
+
{isInstalled && !skill.hasUpdate && <span className="text-[8px] text-[var(--green)] ml-auto">installed</span>}
|
|
169
320
|
</div>
|
|
170
321
|
</div>
|
|
171
322
|
);
|
|
172
323
|
})}
|
|
324
|
+
{/* Local items — collapsible by scope group */}
|
|
325
|
+
{(typeFilter === 'all' || typeFilter === 'local') && filteredLocal.length > 0 && (
|
|
326
|
+
<>
|
|
327
|
+
{/* Local section header — collapsible */}
|
|
328
|
+
{typeFilter !== 'local' && (
|
|
329
|
+
<button
|
|
330
|
+
onClick={() => toggleLocalSection('__local__')}
|
|
331
|
+
className="w-full px-3 py-1 text-[8px] text-[var(--text-secondary)] uppercase bg-[var(--bg-tertiary)] border-b border-[var(--border)]/50 flex items-center gap-1 hover:text-[var(--text-primary)]"
|
|
332
|
+
>
|
|
333
|
+
<span>{collapsedLocalSections.has('__local__') ? '▸' : '▾'}</span>
|
|
334
|
+
Local ({filteredLocal.length})
|
|
335
|
+
</button>
|
|
336
|
+
)}
|
|
337
|
+
{(typeFilter === 'local' || !collapsedLocalSections.has('__local__')) && (
|
|
338
|
+
<>
|
|
339
|
+
{[...localGroups.entries()].sort(([a], [b]) => a === 'global' ? -1 : b === 'global' ? 1 : a.localeCompare(b)).map(([scope, items]) => (
|
|
340
|
+
<div key={scope}>
|
|
341
|
+
{/* Scope group header — collapsible */}
|
|
342
|
+
<button
|
|
343
|
+
onClick={() => toggleLocalSection(scope)}
|
|
344
|
+
className="w-full px-3 py-1 text-[8px] text-[var(--text-secondary)] border-b border-[var(--border)]/30 flex items-center gap-1.5 hover:bg-[var(--bg-tertiary)]"
|
|
345
|
+
>
|
|
346
|
+
<span className="text-[7px]">{collapsedLocalSections.has(scope) ? '▸' : '▾'}</span>
|
|
347
|
+
<span className={scope === 'global' ? 'text-green-400' : 'text-[var(--accent)]'}>{scope}</span>
|
|
348
|
+
<span className="text-[var(--text-secondary)]">({items.length})</span>
|
|
349
|
+
</button>
|
|
350
|
+
{!collapsedLocalSections.has(scope) && items.map(item => {
|
|
351
|
+
const key = `local:${item.name}:${item.scope}`;
|
|
352
|
+
const isActive = expandedSkill === key;
|
|
353
|
+
const projectParam = item.projectPath ? encodeURIComponent(item.projectPath) : '';
|
|
354
|
+
return (
|
|
355
|
+
<div
|
|
356
|
+
key={key}
|
|
357
|
+
className={`px-3 py-2 border-b border-[var(--border)]/50 cursor-pointer ${
|
|
358
|
+
isActive ? 'bg-[var(--accent)]/10 border-l-2 border-l-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] border-l-2 border-l-transparent'
|
|
359
|
+
}`}
|
|
360
|
+
onClick={() => {
|
|
361
|
+
if (expandedSkill === key) { setExpandedSkill(null); return; }
|
|
362
|
+
setExpandedSkill(key);
|
|
363
|
+
setSkillFiles([]);
|
|
364
|
+
setActiveFile(null);
|
|
365
|
+
setFileContent('');
|
|
366
|
+
const fetchUrl = `/api/skills/local?action=files&name=${encodeURIComponent(item.name)}&type=${item.type}${projectParam ? `&project=${projectParam}` : ''}`;
|
|
367
|
+
fetch(fetchUrl)
|
|
368
|
+
.then(r => r.json())
|
|
369
|
+
.then(d => {
|
|
370
|
+
const files = (d.files || []).map((f: any) => ({ name: f.path.split('/').pop(), path: f.path, type: 'file' }));
|
|
371
|
+
setSkillFiles(files);
|
|
372
|
+
const first = files.find((f: any) => f.name?.endsWith('.md'));
|
|
373
|
+
if (first) {
|
|
374
|
+
setActiveFile(first.path);
|
|
375
|
+
fetch(`/api/skills/local?action=read&name=${encodeURIComponent(item.name)}&type=${item.type}&path=${encodeURIComponent(first.path)}${projectParam ? `&project=${projectParam}` : ''}`)
|
|
376
|
+
.then(r => r.json())
|
|
377
|
+
.then(rd => setFileContent(rd.content || ''))
|
|
378
|
+
.catch(() => {});
|
|
379
|
+
}
|
|
380
|
+
})
|
|
381
|
+
.catch(() => {});
|
|
382
|
+
}}
|
|
383
|
+
>
|
|
384
|
+
<div className="flex items-center gap-2">
|
|
385
|
+
<span className={`text-[7px] px-1 rounded font-medium ${
|
|
386
|
+
item.type === 'skill' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
|
|
387
|
+
}`}>{item.type === 'skill' ? 'S' : 'C'}</span>
|
|
388
|
+
<span className="text-[10px] text-[var(--text-primary)] truncate flex-1">{item.name}</span>
|
|
389
|
+
<span className="text-[8px] text-[var(--text-secondary)]">{item.fileCount}</span>
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
);
|
|
393
|
+
})}
|
|
394
|
+
</div>
|
|
395
|
+
))}
|
|
396
|
+
</>
|
|
397
|
+
)}
|
|
398
|
+
</>
|
|
399
|
+
)}
|
|
173
400
|
</div>
|
|
174
401
|
|
|
175
402
|
{/* Right: detail panel */}
|
|
176
403
|
<div className="flex-1 flex flex-col min-w-0">
|
|
177
404
|
{expandedSkill ? (() => {
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
const
|
|
405
|
+
const isLocal = expandedSkill.startsWith('local:');
|
|
406
|
+
// Key format: "local:<name>:<scope>" — extract name (could contain colons in scope)
|
|
407
|
+
const localParts = isLocal ? expandedSkill.slice(6).split(':') : [];
|
|
408
|
+
const itemName = isLocal ? localParts[0] : expandedSkill;
|
|
409
|
+
const localScope = isLocal ? localParts.slice(1).join(':') : '';
|
|
410
|
+
const skill = isLocal ? null : skills.find(s => s.name === expandedSkill);
|
|
411
|
+
const localItem = isLocal ? localItems.find(i => i.name === itemName && i.scope === localScope) : null;
|
|
412
|
+
if (!skill && !localItem) return null;
|
|
413
|
+
const isInstalled = skill ? (skill.installedGlobal || skill.installedProjects.length > 0) : true;
|
|
181
414
|
return (
|
|
182
415
|
<>
|
|
183
416
|
{/* Skill header */}
|
|
184
417
|
<div className="px-4 py-2 border-b border-[var(--border)] shrink-0">
|
|
185
418
|
<div className="flex items-center gap-2">
|
|
186
|
-
<span className="text-sm font-semibold text-[var(--text-primary)]">{skill
|
|
187
|
-
<span className=
|
|
188
|
-
|
|
419
|
+
<span className="text-sm font-semibold text-[var(--text-primary)]">{skill?.displayName || localItem?.name || itemName}</span>
|
|
420
|
+
<span className={`text-[8px] px-1.5 py-0.5 rounded font-medium ${
|
|
421
|
+
(skill?.type || localItem?.type) === 'skill' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
|
|
422
|
+
}`}>{(skill?.type || localItem?.type) === 'skill' ? 'Skill' : 'Command'}</span>
|
|
423
|
+
{isLocal && <span className="text-[7px] px-1 rounded bg-green-500/10 text-green-400">local</span>}
|
|
424
|
+
{skill && <span className="text-[9px] text-[var(--text-secondary)] font-mono">v{skill.version}</span>}
|
|
425
|
+
{skill?.installedVersion && skill.installedVersion !== skill.version && (
|
|
426
|
+
<span className="text-[9px] text-[var(--yellow)] font-mono">installed: v{skill.installedVersion}</span>
|
|
427
|
+
)}
|
|
428
|
+
{skill && skill.rating > 0 && (
|
|
429
|
+
<span className="text-[9px] text-[var(--yellow)]" title={`Rating: ${skill.rating}/5`}>
|
|
430
|
+
{'★'.repeat(Math.round(skill.rating))}{'☆'.repeat(5 - Math.round(skill.rating))}
|
|
431
|
+
</span>
|
|
432
|
+
)}
|
|
433
|
+
{skill && skill.score > 0 && <span className="text-[9px] text-[var(--text-secondary)]">{skill.score}pt</span>}
|
|
434
|
+
|
|
435
|
+
{/* Update button */}
|
|
436
|
+
{skill?.hasUpdate && (
|
|
437
|
+
<button
|
|
438
|
+
onClick={async () => {
|
|
439
|
+
if (skill.installedGlobal) await install(skill.name, 'global');
|
|
440
|
+
for (const pp of skill.installedProjects) await install(skill.name, pp);
|
|
441
|
+
}}
|
|
442
|
+
className="text-[9px] px-2 py-1 bg-[var(--yellow)]/20 text-[var(--yellow)] border border-[var(--yellow)]/50 rounded hover:bg-[var(--yellow)]/30 transition-colors"
|
|
443
|
+
>
|
|
444
|
+
Update
|
|
445
|
+
</button>
|
|
446
|
+
)}
|
|
189
447
|
|
|
190
|
-
{/*
|
|
191
|
-
|
|
448
|
+
{/* Local item actions: install to other projects, delete */}
|
|
449
|
+
{isLocal && localItem && (
|
|
450
|
+
<>
|
|
451
|
+
<div className="relative ml-auto">
|
|
452
|
+
<button
|
|
453
|
+
onClick={() => setInstallTarget(prev =>
|
|
454
|
+
prev.skill === itemName && prev.show ? { skill: '', show: false } : { skill: itemName, show: true }
|
|
455
|
+
)}
|
|
456
|
+
className="text-[9px] px-2 py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white transition-colors"
|
|
457
|
+
>
|
|
458
|
+
Install to...
|
|
459
|
+
</button>
|
|
460
|
+
{installTarget.skill === itemName && installTarget.show && (
|
|
461
|
+
<>
|
|
462
|
+
<div className="fixed inset-0 z-40" onClick={() => setInstallTarget({ skill: '', show: false })} />
|
|
463
|
+
<div className="absolute right-0 top-7 w-[200px] bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl z-50 py-1">
|
|
464
|
+
<button
|
|
465
|
+
onClick={async () => {
|
|
466
|
+
const res = await fetch('/api/skills/local', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
467
|
+
body: JSON.stringify({ action: 'install-local', name: itemName, type: localItem.type, sourceProject: localItem.projectPath, target: 'global', force: true }) });
|
|
468
|
+
const data = await res.json();
|
|
469
|
+
if (!data.ok) alert(data.error);
|
|
470
|
+
setInstallTarget({ skill: '', show: false });
|
|
471
|
+
fetchSkills();
|
|
472
|
+
}}
|
|
473
|
+
className="w-full text-left text-[10px] px-3 py-1.5 hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)]"
|
|
474
|
+
>Global (~/.claude)</button>
|
|
475
|
+
<div className="border-t border-[var(--border)] my-0.5" />
|
|
476
|
+
{projects.map(p => (
|
|
477
|
+
<button
|
|
478
|
+
key={p.path}
|
|
479
|
+
onClick={async () => {
|
|
480
|
+
const res = await fetch('/api/skills/local', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
481
|
+
body: JSON.stringify({ action: 'install-local', name: itemName, type: localItem.type, sourceProject: localItem.projectPath, target: p.path, force: true }) });
|
|
482
|
+
const data = await res.json();
|
|
483
|
+
if (!data.ok) alert(data.error);
|
|
484
|
+
setInstallTarget({ skill: '', show: false });
|
|
485
|
+
fetchSkills();
|
|
486
|
+
}}
|
|
487
|
+
className="w-full text-left text-[10px] px-3 py-1.5 hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)] truncate"
|
|
488
|
+
title={p.path}
|
|
489
|
+
>{p.name}</button>
|
|
490
|
+
))}
|
|
491
|
+
</div>
|
|
492
|
+
</>
|
|
493
|
+
)}
|
|
494
|
+
</div>
|
|
495
|
+
<button
|
|
496
|
+
onClick={async () => {
|
|
497
|
+
if (!confirm(`Delete "${itemName}" from ${localScope}?`)) return;
|
|
498
|
+
await fetch('/api/skills/local', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
499
|
+
body: JSON.stringify({ action: 'delete-local', name: itemName, type: localItem.type, project: localItem.projectPath }) });
|
|
500
|
+
setExpandedSkill(null);
|
|
501
|
+
fetchSkills();
|
|
502
|
+
}}
|
|
503
|
+
className="text-[9px] text-[var(--red)] hover:underline"
|
|
504
|
+
>Delete</button>
|
|
505
|
+
</>
|
|
506
|
+
)}
|
|
507
|
+
|
|
508
|
+
{/* Install dropdown — registry items only */}
|
|
509
|
+
{skill && <div className="relative ml-auto">
|
|
192
510
|
<button
|
|
193
511
|
onClick={() => setInstallTarget(prev =>
|
|
194
512
|
prev.skill === skill.name && prev.show ? { skill: '', show: false } : { skill: skill.name, show: true }
|
|
@@ -228,22 +546,22 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
228
546
|
</div>
|
|
229
547
|
</>
|
|
230
548
|
)}
|
|
231
|
-
</div>
|
|
549
|
+
</div>}
|
|
232
550
|
</div>
|
|
233
|
-
<p className="text-[10px] text-[var(--text-secondary)] mt-0.5">{skill
|
|
551
|
+
<p className="text-[10px] text-[var(--text-secondary)] mt-0.5">{skill?.description || ''}</p>
|
|
234
552
|
{/* Installed indicators */}
|
|
235
|
-
{isInstalled && (
|
|
553
|
+
{skill && isInstalled && (
|
|
236
554
|
<div className="flex items-center gap-2 mt-1">
|
|
237
555
|
{skill.installedGlobal && (
|
|
238
556
|
<span className="flex items-center gap-1 text-[8px] text-[var(--green)]">
|
|
239
557
|
Global
|
|
240
|
-
<button onClick={() => uninstall(skill.name, 'global')} className="text-[var(--text-secondary)] hover:text-[var(--red)]">x</button>
|
|
558
|
+
<button onClick={() => { if (confirm(`Uninstall "${skill.name}" from global?`)) uninstall(skill.name, 'global'); }} className="text-[var(--text-secondary)] hover:text-[var(--red)]">x</button>
|
|
241
559
|
</span>
|
|
242
560
|
)}
|
|
243
561
|
{skill.installedProjects.map(pp => (
|
|
244
562
|
<span key={pp} className="flex items-center gap-1 text-[8px] text-[var(--accent)]">
|
|
245
563
|
{pp.split('/').pop()}
|
|
246
|
-
<button onClick={() => uninstall(skill.name, pp)} className="text-[var(--text-secondary)] hover:text-[var(--red)]">x</button>
|
|
564
|
+
<button onClick={() => { if (confirm(`Uninstall "${skill.name}" from ${pp.split('/').pop()}?`)) uninstall(skill.name, pp); }} className="text-[var(--text-secondary)] hover:text-[var(--red)]">x</button>
|
|
247
565
|
</span>
|
|
248
566
|
))}
|
|
249
567
|
</div>
|
|
@@ -261,7 +579,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
261
579
|
f.type === 'file' ? (
|
|
262
580
|
<button
|
|
263
581
|
key={f.path}
|
|
264
|
-
onClick={() => loadFile(
|
|
582
|
+
onClick={() => loadFile(itemName, f.path, isLocal, localItem?.type, localItem?.projectPath)}
|
|
265
583
|
className={`w-full text-left px-2 py-1 text-[10px] truncate ${
|
|
266
584
|
activeFile === f.path
|
|
267
585
|
? 'bg-[var(--accent)]/15 text-[var(--accent)]'
|
|
@@ -278,7 +596,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
278
596
|
)
|
|
279
597
|
))
|
|
280
598
|
)}
|
|
281
|
-
{skill
|
|
599
|
+
{skill?.sourceUrl && (
|
|
282
600
|
<div className="border-t border-[var(--border)] p-2">
|
|
283
601
|
<a
|
|
284
602
|
href={skill.sourceUrl.replace(/\/blob\/main\/.*/, '')}
|
|
@@ -315,6 +633,174 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
315
633
|
</div>
|
|
316
634
|
</div>
|
|
317
635
|
)}
|
|
636
|
+
|
|
637
|
+
{/* Rules (CLAUDE.md Templates) — full-page view */}
|
|
638
|
+
{typeFilter === 'rules' && (
|
|
639
|
+
<div className="flex-1 flex min-h-0">
|
|
640
|
+
{/* Left: template list */}
|
|
641
|
+
<div className="w-56 border-r border-[var(--border)] overflow-y-auto shrink-0 flex flex-col">
|
|
642
|
+
<div className="px-3 py-1.5 border-b border-[var(--border)] flex items-center justify-between">
|
|
643
|
+
<span className="text-[9px] text-[var(--text-secondary)] uppercase">Rule Templates</span>
|
|
644
|
+
<button
|
|
645
|
+
onClick={() => { setRulesShowNew(true); setRulesEditing(true); setRulesEditId(''); setRulesEditName(''); setRulesEditDesc(''); setRulesEditContent(''); setRulesEditDefault(false); setRulesSelectedTemplate(null); }}
|
|
646
|
+
className="text-[9px] text-[var(--accent)] hover:underline"
|
|
647
|
+
>+ New</button>
|
|
648
|
+
</div>
|
|
649
|
+
<div className="flex-1 overflow-y-auto">
|
|
650
|
+
{rulesTemplates.map(t => {
|
|
651
|
+
const isActive = rulesSelectedTemplate === t.id;
|
|
652
|
+
return (
|
|
653
|
+
<div
|
|
654
|
+
key={t.id}
|
|
655
|
+
className={`px-3 py-2 border-b border-[var(--border)]/50 cursor-pointer ${
|
|
656
|
+
isActive ? 'bg-[var(--accent)]/10 border-l-2 border-l-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] border-l-2 border-l-transparent'
|
|
657
|
+
}`}
|
|
658
|
+
onClick={() => { setRulesSelectedTemplate(t.id); setRulesEditing(false); setRulesShowNew(false); }}
|
|
659
|
+
>
|
|
660
|
+
<div className="flex items-center gap-1.5">
|
|
661
|
+
<span className="text-[10px] text-[var(--text-primary)] truncate flex-1">{t.name}</span>
|
|
662
|
+
{t.builtin && <span className="text-[7px] text-[var(--text-secondary)]">built-in</span>}
|
|
663
|
+
<button
|
|
664
|
+
onClick={(e) => { e.stopPropagation(); toggleDefault(t.id, !t.isDefault); }}
|
|
665
|
+
className={`text-[7px] px-1 rounded ${t.isDefault ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
|
|
666
|
+
title={t.isDefault ? 'Default: auto-applied to new projects' : 'Click to set as default'}
|
|
667
|
+
>{t.isDefault ? 'default' : 'set default'}</button>
|
|
668
|
+
</div>
|
|
669
|
+
<p className="text-[8px] text-[var(--text-secondary)] mt-0.5 line-clamp-1">{t.description}</p>
|
|
670
|
+
</div>
|
|
671
|
+
);
|
|
672
|
+
})}
|
|
673
|
+
</div>
|
|
674
|
+
</div>
|
|
675
|
+
|
|
676
|
+
{/* Right: template detail / editor / batch apply */}
|
|
677
|
+
<div className="flex-1 flex flex-col min-w-0">
|
|
678
|
+
{rulesShowNew || rulesEditing ? (
|
|
679
|
+
/* Edit / New form */
|
|
680
|
+
<div className="flex-1 flex flex-col overflow-hidden">
|
|
681
|
+
<div className="px-4 py-2 border-b border-[var(--border)] shrink-0">
|
|
682
|
+
<div className="text-[11px] font-semibold text-[var(--text-primary)]">{rulesShowNew ? 'New Rule Template' : 'Edit Template'}</div>
|
|
683
|
+
</div>
|
|
684
|
+
<div className="flex-1 overflow-auto p-4 space-y-3">
|
|
685
|
+
<div className="flex gap-2">
|
|
686
|
+
<input
|
|
687
|
+
type="text"
|
|
688
|
+
value={rulesEditId}
|
|
689
|
+
onChange={e => setRulesEditId(e.target.value.replace(/[^a-z0-9-]/g, ''))}
|
|
690
|
+
placeholder="template-id (kebab-case)"
|
|
691
|
+
disabled={!rulesShowNew}
|
|
692
|
+
className="flex-1 px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)] font-mono disabled:opacity-50"
|
|
693
|
+
/>
|
|
694
|
+
<input
|
|
695
|
+
type="text"
|
|
696
|
+
value={rulesEditName}
|
|
697
|
+
onChange={e => setRulesEditName(e.target.value)}
|
|
698
|
+
placeholder="Display Name"
|
|
699
|
+
className="flex-1 px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
|
|
700
|
+
/>
|
|
701
|
+
</div>
|
|
702
|
+
<input
|
|
703
|
+
type="text"
|
|
704
|
+
value={rulesEditDesc}
|
|
705
|
+
onChange={e => setRulesEditDesc(e.target.value)}
|
|
706
|
+
placeholder="Description"
|
|
707
|
+
className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
|
|
708
|
+
/>
|
|
709
|
+
<textarea
|
|
710
|
+
value={rulesEditContent}
|
|
711
|
+
onChange={e => setRulesEditContent(e.target.value)}
|
|
712
|
+
placeholder="Template content (markdown)..."
|
|
713
|
+
className="w-full flex-1 min-h-[200px] p-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] font-mono text-[var(--text-primary)] resize-none"
|
|
714
|
+
spellCheck={false}
|
|
715
|
+
/>
|
|
716
|
+
<div className="flex items-center gap-3">
|
|
717
|
+
<label className="flex items-center gap-1.5 text-[10px] text-[var(--text-secondary)] cursor-pointer">
|
|
718
|
+
<input type="checkbox" checked={rulesEditDefault} onChange={e => setRulesEditDefault(e.target.checked)} className="accent-[var(--accent)]" />
|
|
719
|
+
Auto-apply to new projects
|
|
720
|
+
</label>
|
|
721
|
+
<div className="flex gap-2 ml-auto">
|
|
722
|
+
<button onClick={saveRule} className="text-[10px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90">Save</button>
|
|
723
|
+
<button onClick={() => { setRulesEditing(false); setRulesShowNew(false); }} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]">Cancel</button>
|
|
724
|
+
</div>
|
|
725
|
+
</div>
|
|
726
|
+
</div>
|
|
727
|
+
</div>
|
|
728
|
+
) : rulesSelectedTemplate ? (() => {
|
|
729
|
+
const tmpl = rulesTemplates.find(t => t.id === rulesSelectedTemplate);
|
|
730
|
+
if (!tmpl) return null;
|
|
731
|
+
return (
|
|
732
|
+
<div className="flex-1 flex flex-col overflow-hidden">
|
|
733
|
+
{/* Template header */}
|
|
734
|
+
<div className="px-4 py-2 border-b border-[var(--border)] shrink-0">
|
|
735
|
+
<div className="flex items-center gap-2">
|
|
736
|
+
<span className="text-sm font-semibold text-[var(--text-primary)]">{tmpl.name}</span>
|
|
737
|
+
{tmpl.builtin && <span className="text-[8px] px-1 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">built-in</span>}
|
|
738
|
+
<div className="ml-auto flex gap-1.5">
|
|
739
|
+
<button
|
|
740
|
+
onClick={() => { setRulesEditing(true); setRulesShowNew(false); setRulesEditId(tmpl.id); setRulesEditName(tmpl.name); setRulesEditDesc(tmpl.description); setRulesEditContent(tmpl.content); setRulesEditDefault(tmpl.isDefault); }}
|
|
741
|
+
className="text-[9px] text-[var(--accent)] hover:underline"
|
|
742
|
+
>Edit</button>
|
|
743
|
+
{!tmpl.builtin && (
|
|
744
|
+
<button onClick={() => deleteRule(tmpl.id)} className="text-[9px] text-[var(--red)] hover:underline">Delete</button>
|
|
745
|
+
)}
|
|
746
|
+
</div>
|
|
747
|
+
</div>
|
|
748
|
+
<p className="text-[9px] text-[var(--text-secondary)] mt-0.5">{tmpl.description}</p>
|
|
749
|
+
</div>
|
|
750
|
+
|
|
751
|
+
{/* Content + batch apply */}
|
|
752
|
+
<div className="flex-1 flex min-h-0 overflow-hidden">
|
|
753
|
+
{/* Template content */}
|
|
754
|
+
<div className="flex-1 min-w-0 overflow-auto">
|
|
755
|
+
<pre className="p-3 text-[11px] font-mono text-[var(--text-primary)] whitespace-pre-wrap break-words">
|
|
756
|
+
{tmpl.content}
|
|
757
|
+
</pre>
|
|
758
|
+
</div>
|
|
759
|
+
|
|
760
|
+
{/* Batch apply panel */}
|
|
761
|
+
<div className="w-48 border-l border-[var(--border)] overflow-y-auto shrink-0 flex flex-col">
|
|
762
|
+
<div className="px-2 py-1.5 border-b border-[var(--border)] text-[9px] text-[var(--text-secondary)] uppercase">Apply to Projects</div>
|
|
763
|
+
<div className="flex-1 overflow-y-auto">
|
|
764
|
+
{rulesProjects.map(p => (
|
|
765
|
+
<label key={p.path} className="flex items-center gap-1.5 px-2 py-1 hover:bg-[var(--bg-tertiary)] cursor-pointer">
|
|
766
|
+
<input
|
|
767
|
+
type="checkbox"
|
|
768
|
+
checked={rulesBatchProjects.has(p.path)}
|
|
769
|
+
onChange={() => {
|
|
770
|
+
setRulesBatchProjects(prev => {
|
|
771
|
+
const next = new Set(prev);
|
|
772
|
+
if (next.has(p.path)) next.delete(p.path); else next.add(p.path);
|
|
773
|
+
return next;
|
|
774
|
+
});
|
|
775
|
+
}}
|
|
776
|
+
className="accent-[var(--accent)]"
|
|
777
|
+
/>
|
|
778
|
+
<span className="text-[9px] text-[var(--text-primary)] truncate">{p.name}</span>
|
|
779
|
+
</label>
|
|
780
|
+
))}
|
|
781
|
+
</div>
|
|
782
|
+
{rulesBatchProjects.size > 0 && (
|
|
783
|
+
<div className="p-2 border-t border-[var(--border)]">
|
|
784
|
+
<button
|
|
785
|
+
onClick={() => batchInject(tmpl.id)}
|
|
786
|
+
className="w-full text-[9px] px-2 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90"
|
|
787
|
+
>
|
|
788
|
+
Apply to {rulesBatchProjects.size} project{rulesBatchProjects.size > 1 ? 's' : ''}
|
|
789
|
+
</button>
|
|
790
|
+
</div>
|
|
791
|
+
)}
|
|
792
|
+
</div>
|
|
793
|
+
</div>
|
|
794
|
+
</div>
|
|
795
|
+
);
|
|
796
|
+
})() : (
|
|
797
|
+
<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
|
|
798
|
+
<p className="text-xs">Select a template or create a new one</p>
|
|
799
|
+
</div>
|
|
800
|
+
)}
|
|
801
|
+
</div>
|
|
802
|
+
</div>
|
|
803
|
+
)}
|
|
318
804
|
</div>
|
|
319
805
|
);
|
|
320
806
|
}
|