Browse Source

feat: add skill pages

Thomas Zhang 2 months ago
parent
commit
db58d47389

+ 73 - 0
src/lib/components/skills/card/card.svelte

@@ -0,0 +1,73 @@
+<script lang="ts">
+	import type { ClassValue } from 'svelte/elements';
+
+	import { resolve } from '$app/paths';
+
+	import { GlassPane } from '$lib/components/common/glass-pane';
+	import { Icon } from '$lib/components/common/icon';
+	import { Tags } from '$lib/components/common/tags';
+	import { type Skill } from '$lib/types/entities';
+
+	let {
+		skill,
+		selectedTags = new Set<string>(),
+		class: className = '',
+		onselect,
+	}: {
+		skill: Skill;
+		selectedTags?: Set<string>;
+		class?: ClassValue;
+		onselect?: () => void;
+	} = $props();
+</script>
+
+{#snippet content()}
+	<GlassPane
+		class={[
+			'relative flex h-36 gap-4 overflow-hidden rounded-xl px-4 py-2 hover:border-white/20 hover:bg-white/5',
+			className,
+		]}
+		reactive
+	>
+		<!-- icon: 48px square, vertically centered -->
+		<div class="flex shrink-0 items-center justify-center">
+			<Icon icon={skill.icon} />
+		</div>
+
+		<!-- content -->
+		<div class="flex flex-1 flex-col gap-1 overflow-hidden">
+			<!-- header: name -->
+			<div class="flex items-center justify-between gap-2">
+				<span class="truncate text-lg font-medium text-white/75 group-hover:text-white">
+					{skill.name}
+				</span>
+			</div>
+
+			<!-- second line: skill description -->
+			<div class="line-clamp-4 text-xs text-ellipsis text-white/50">
+				{skill.description}
+			</div>
+
+			<!-- tags -->
+			<div class="mt-auto">
+				<Tags activeTags={selectedTags} tags={skill.tags} />
+			</div>
+		</div>
+	</GlassPane>
+{/snippet}
+
+{#if onselect === undefined}
+	<a class="group block" href={resolve('/resources/skills/[id]', { id: skill.id })}>
+		{@render content()}
+	</a>
+{:else}
+	<div
+		class="group block"
+		role="button"
+		tabindex="0"
+		onclick={() => onselect()}
+		onkeydown={(e) => e.key === 'Enter' && onselect()}
+	>
+		{@render content()}
+	</div>
+{/if}

+ 3 - 0
src/lib/components/skills/card/index.ts

@@ -0,0 +1,3 @@
+import SkillCard from './card.svelte';
+
+export { SkillCard };

+ 300 - 0
src/lib/components/skills/editor/editor.svelte

@@ -0,0 +1,300 @@
+<script lang="ts">
+	import { Pencil, Plus, Save, SquarePen, Trash2 } from '@lucide/svelte';
+	import { tick } from 'svelte';
+
+	import { ConfirmDialog } from '$lib/components/common/confirmation';
+	import { Button, buttonVariants } from '$lib/components/controls/button';
+	import * as Dialog from '$lib/components/controls/dialog';
+	import * as Field from '$lib/components/controls/field';
+	import { Input } from '$lib/components/controls/input';
+	import * as Resizable from '$lib/components/controls/resizable';
+	import type { Skill, SkillFile } from '$lib/types/entities';
+
+	interface FileEntry {
+		path: string;
+		parents: string[];
+		isDirectory: boolean;
+		name: string;
+		content: string;
+	}
+
+	let {
+		skill,
+		editingContent = $bindable(false),
+		onSkillChange,
+		onFileChange,
+	}: {
+		skill: Skill;
+		editingContent?: boolean;
+		onSkillChange: (skill: Skill) => Promise<void>;
+		onFileChange: (filePath: string | undefined, newFile: SkillFile | undefined) => Promise<void>;
+	} = $props();
+	let fileFormShown = $state(false);
+	let addingFile = $state(false);
+	let inputFilePath = $state('');
+	let deleteConfirmationShown = $state(false);
+	let fileEntries: FileEntry[] = $derived.by(() => {
+		const entries: FileEntry[] = [
+			{
+				path: 'SKILL.md',
+				parents: [],
+				isDirectory: false,
+				name: 'SKILL.md',
+				content: `---
+name: ${skill.name}
+description: ${skill.description}
+---
+
+${skill.body}
+`,
+			},
+		];
+		// eslint-disable-next-line svelte/prefer-svelte-reactivity
+		const seenDirectories = new Set<string>();
+		for (const file of skill.files) {
+			const directories = file.path.split('/');
+			const name = directories.pop();
+			if (name === undefined) continue;
+			if (directories.length > 0) {
+				const dirPath = directories.join('/');
+				if (!seenDirectories.has(dirPath)) {
+					seenDirectories.add(dirPath);
+					entries.push({
+						path: dirPath,
+						parents: [...directories],
+						isDirectory: true,
+						name: '',
+						content: '',
+					});
+				}
+			}
+			entries.push({
+				path: file.path,
+				parents: directories,
+				isDirectory: false,
+				name,
+				content: file.content,
+			});
+		}
+
+		return entries.sort((a, b) => {
+			for (let i = 0; i < Math.min(a.parents.length, b.parents.length); i++) {
+				if (a.parents[i] !== b.parents[i]) {
+					return a.parents[i].localeCompare(b.parents[i]);
+				}
+			}
+			if (a.parents.length !== b.parents.length) {
+				return a.parents.length - b.parents.length;
+			}
+			return a.name.localeCompare(b.name);
+		});
+	});
+	let selectedFile: FileEntry | undefined = $derived($state.snapshot(fileEntries[0]));
+
+	const validateSkillMd = (content: string): boolean => {
+		const frontMatterRegex = /^---\n([\s\S]*?)\n---/;
+		const match = content.match(frontMatterRegex);
+		if (!match) return false;
+		const frontMatter = match[1];
+		return /name:/.test(frontMatter) && /description:/.test(frontMatter);
+	};
+
+	const onAddFile = () => {
+		addingFile = true;
+		inputFilePath = '';
+		fileFormShown = true;
+	};
+
+	const onEditFile = () => {
+		if (!selectedFile) return;
+		addingFile = false;
+		inputFilePath = selectedFile.path;
+		fileFormShown = true;
+	};
+
+	const onEditFileContent = () => {
+		if (!selectedFile) return;
+		editingContent = true;
+	};
+
+	const onUpdateFile = async () => {
+		if (selectedFile?.path === 'SKILL.md' || inputFilePath === 'SKILL.md') return;
+		await onFileChange(selectedFile?.path, {
+			path: inputFilePath,
+			content: selectedFile?.content || '',
+		});
+		await tick();
+		selectedFile = $state.snapshot(fileEntries.find((file) => file.path === inputFilePath));
+		fileFormShown = false;
+	};
+
+	const onSaveContent = async () => {
+		if (!selectedFile) return;
+		editingContent = false;
+		const filePath = selectedFile.path;
+		if (filePath === 'SKILL.md') {
+			const frontmatter = selectedFile.content.match(/^---\n([\s\S]*?)\n---/)?.[1] || '';
+			const name = frontmatter.match(/name:\s*(.*)/)?.[1] || '';
+			const description = frontmatter.match(/description:\s*(.*)/)?.[1] || '';
+			const body = selectedFile.content.replace(/^---\n([\s\S]*?)\n---/, '').trim();
+			await onSkillChange({
+				...skill,
+				name,
+				description,
+				body,
+			});
+		} else {
+			await onFileChange(selectedFile.path, {
+				path: filePath,
+				content: selectedFile.content,
+			});
+		}
+	};
+
+	const onDeleteFile = async () => {
+		if (!selectedFile) return;
+		await onFileChange(selectedFile.path, undefined);
+		deleteConfirmationShown = false;
+		selectedFile = undefined;
+	};
+</script>
+
+<Resizable.PaneGroup
+	class="min-h-96 rounded-lg border border-white/10 bg-black/50"
+	direction="horizontal"
+>
+	<Resizable.Pane defaultSize={25}>
+		<div class="flex h-16 items-center justify-between border-b border-b-white/10 p-4">
+			<div class="text-sm font-semibold">Files</div>
+			<Button size="icon-sm" title="New file" variant="ghost" onclick={() => onAddFile()}>
+				<Plus size={14} strokeWidth={2} />
+			</Button>
+		</div>
+		<div class="flex flex-col gap-1 p-2">
+			{#each fileEntries as file (file.path)}
+				{#if file.isDirectory}
+					<div class="pl-2 text-xs text-white/25">{file.parents.join('/') + '/'}</div>
+				{:else}
+					<Button
+						class="justify-start"
+						size="sm"
+						variant={selectedFile?.path === file.path ? 'outline' : 'ghost'}
+						onclick={() => (selectedFile = $state.snapshot(file))}
+					>
+						{file.name}
+					</Button>
+				{/if}
+			{/each}
+		</div>
+	</Resizable.Pane>
+	<Resizable.Handle />
+	<Resizable.Pane defaultSize={75}>
+		{#if selectedFile}
+			<div class="flex h-16 items-center justify-between border-b border-b-white/10 p-4">
+				<div class="flex flex-1 items-center gap-2 overflow-hidden">
+					<div>
+						<div class="text-[10px] leading-none text-white/25">
+							{selectedFile.parents.join('/') + '/'}
+						</div>
+						<div class="overflow-hidden text-sm font-semibold text-ellipsis whitespace-nowrap">
+							{selectedFile.name}
+						</div>
+					</div>
+					{#if selectedFile.path !== 'SKILL.md'}
+						<Button size="icon-xs" title="Rename" variant="ghost" onclick={() => onEditFile()}>
+							<Pencil size={14} strokeWidth={2} />
+						</Button>
+					{/if}
+				</div>
+				<div class="flex flex-none items-center gap-2">
+					{#if editingContent}
+						<Button
+							disabled={selectedFile.path === 'SKILL.md' && !validateSkillMd(selectedFile.content)}
+							size="icon-sm"
+							title="Save"
+							onclick={() => onSaveContent()}
+						>
+							<Save size={14} strokeWidth={2} />
+						</Button>
+					{:else}
+						<Button
+							size="icon-sm"
+							title="Edit"
+							variant="outline"
+							onclick={() => onEditFileContent()}
+						>
+							<SquarePen size={14} strokeWidth={2} />
+						</Button>
+					{/if}
+					{#if selectedFile.path !== 'SKILL.md'}
+						<Button
+							size="icon-sm"
+							title="Delete"
+							variant="destructive"
+							onclick={() => (deleteConfirmationShown = true)}
+						>
+							<Trash2 size={14} strokeWidth={2} />
+						</Button>
+					{/if}
+				</div>
+			</div>
+			{#if editingContent}
+				<textarea
+					class="block h-100 w-full resize-none border-none bg-white/10 p-4 font-mono text-xs outline-none"
+					bind:value={selectedFile.content}
+				></textarea>
+			{:else}
+				<pre class="p-4 text-xs whitespace-pre-wrap">{selectedFile.content}</pre>
+			{/if}
+		{/if}
+	</Resizable.Pane>
+</Resizable.PaneGroup>
+
+<!-- File form dialog -->
+<Dialog.Root bind:open={fileFormShown}>
+	<Dialog.Content class="sm:max-w-xl">
+		<Dialog.Header>
+			{#if addingFile}
+				<Dialog.Title>Add File</Dialog.Title>
+			{:else}
+				<Dialog.Title>Edit File - {selectedFile?.path}</Dialog.Title>
+			{/if}
+		</Dialog.Header>
+		<Field.Set>
+			<Field.Field>
+				<Field.Label for="filePath">File Path</Field.Label>
+				<Input id="filePath" bind:value={inputFilePath} />
+			</Field.Field>
+		</Field.Set>
+		<Dialog.Footer>
+			<Dialog.Close class={buttonVariants({ variant: 'outline' })} type="button">
+				Cancel
+			</Dialog.Close>
+			<Button
+				disabled={!!fileEntries.find((file) => file.path === inputFilePath)}
+				type="submit"
+				onclick={() => onUpdateFile()}
+			>
+				{#if addingFile}
+					Add
+				{:else}
+					Save
+				{/if}
+			</Button>
+		</Dialog.Footer>
+	</Dialog.Content>
+</Dialog.Root>
+
+<!-- Delete confirmation dialog -->
+<ConfirmDialog
+	confirmLabel="Delete"
+	confirmingLabel="Deleting..."
+	title="Delete file?"
+	variant="destructive"
+	onconfirm={() => onDeleteFile()}
+	bind:open={deleteConfirmationShown}
+>
+	{#snippet description()}
+		<strong class="text-white/90">"{selectedFile?.name}"</strong> will be permanently deleted.
+	{/snippet}
+</ConfirmDialog>

+ 3 - 0
src/lib/components/skills/editor/index.ts

@@ -0,0 +1,3 @@
+import SkillEditor from './editor.svelte';
+
+export { SkillEditor };

+ 105 - 0
src/lib/components/skills/select/dialog.svelte

@@ -0,0 +1,105 @@
+<script lang="ts">
+	import { Waypoints } from '@lucide/svelte';
+	import { untrack } from 'svelte';
+
+	import * as skillsApi from '$lib/api/skills';
+	import { Empty } from '$lib/components/common/empty';
+	import { SelectDialog } from '$lib/components/common/select-dialog';
+	import { Spinner } from '$lib/components/controls/spinner';
+	import { SkillCard } from '$lib/components/skills/card';
+	import type { IDRef, Skill } from '$lib/types/entities';
+
+	let {
+		workspaceId,
+		multiple = true,
+		open = $bindable(false),
+		value = $bindable([]),
+		onselect,
+	}: {
+		workspaceId: string;
+		multiple?: boolean;
+		open?: boolean;
+		value?: IDRef[];
+		onselect?: (skills: IDRef[]) => IDRef[];
+	} = $props();
+
+	let query = $state('');
+	let loading = $state(false);
+	let skills = $state<Skill[]>([]);
+
+	const loadSkills = async () => {
+		if (loading || skills.length > 0) {
+			return;
+		}
+		loading = true;
+		try {
+			skills = await skillsApi.list(workspaceId);
+		} catch (error) {
+			console.error('Failed to load tools', error);
+		}
+		loading = false;
+	};
+
+	$effect(() => {
+		if (open) {
+			untrack(() => {
+				loadSkills();
+			});
+		}
+	});
+
+	const select = (skill: Skill) => {
+		const newValue = {
+			id: skill.id,
+			name: skill.name,
+			icon: skill.icon,
+		};
+		if (multiple) {
+			if (value.find((ref) => ref.id === skill.id)) {
+				value = value.filter((t) => t.id !== skill.id);
+			} else {
+				value = [...value, newValue];
+			}
+		} else {
+			value = [newValue];
+		}
+		if (onselect) {
+			value = onselect(value);
+		}
+	};
+</script>
+
+<SelectDialog
+	selected={value.map((ref) => ref.id)}
+	title={multiple ? 'Select Skills' : 'Select Skill'}
+	bind:open
+	bind:query
+	onsave={multiple ? () => (open = false) : undefined}
+>
+	{#snippet content()}
+		{#if loading}
+			<div class="flex h-full w-full items-center justify-center gap-2">
+				<Spinner />
+				<div class="text-sm text-white/50">Loading Skills...</div>
+			</div>
+		{:else if skills.length === 0}
+			<Empty icon={Waypoints}>
+				{#snippet caption()}
+					<div>No skills found</div>
+				{/snippet}
+			</Empty>
+		{:else}
+			<div class="grid grid-cols-2 gap-2">
+				{#each skills as skill (skill.id)}
+					<SkillCard
+						class={value.find((ref) => ref.id === skill.id)
+							? 'border-primary/50 bg-primary/25 hover:border-primary/75 hover:bg-primary/50'
+							: ''}
+						{skill}
+						onselect={() => select(skill)}
+					/>
+				{/each}
+			</div>
+		{/if}
+	{/snippet}
+</SelectDialog>

+ 3 - 0
src/lib/components/skills/select/index.ts

@@ -0,0 +1,3 @@
+import SkillSelectDialog from './dialog.svelte';
+
+export { SkillSelectDialog };

+ 9 - 0
src/routes/resources/skills/+page.server.ts

@@ -0,0 +1,9 @@
+import * as skillsApi from '$lib/api/skills';
+
+import type { PageServerLoad } from './$types';
+
+const load: PageServerLoad = async ({ fetch, locals }) => ({
+	skills: await skillsApi.list(locals.workspaceId!, fetch),
+});
+
+export { load };

+ 80 - 0
src/routes/resources/skills/+page.svelte

@@ -0,0 +1,80 @@
+<script lang="ts">
+	import { Waypoints } from '@lucide/svelte';
+	import { SvelteSet } from 'svelte/reactivity';
+
+	import { resolve } from '$app/paths';
+
+	import { FilterList } from '$lib/components/common/filter-list';
+	import type { FilterRow } from '$lib/components/common/filters';
+	import { SkillCard } from '$lib/components/skills/card';
+	import { getBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
+	import { skillsStore } from '$lib/stores/skills.svelte';
+
+	import type { PageData } from './$types';
+
+	const { data }: { data: PageData } = $props();
+	let query = $state('');
+	const allTags = $derived([...new Set(skillsStore.list.flatMap((m) => m.tags ?? []))].sort());
+	const selectedTags = new SvelteSet<string>();
+
+	let filters: FilterRow[] = $derived([
+		...(allTags.length > 0
+			? [
+					{
+						id: 'tags',
+						type: 'multi' as const,
+						label: 'Tags',
+						options: allTags.map((t) => ({ value: t, label: t })),
+						selected: selectedTags,
+					},
+				]
+			: []),
+	]);
+
+	const filtered = $derived.by(() => {
+		let list = skillsStore.list;
+		const q = query.trim().toLowerCase();
+		if (q) list = list.filter((a) => a.name.toLowerCase().includes(q));
+		for (const filter of filters) {
+			if (filter.id === 'tags' && filter.type === 'multi') {
+				if (filter.selected.size > 0)
+					list = list.filter((a) => a.tags?.some((t) => filter.selected.has(t)));
+			}
+		}
+		return list;
+	});
+
+	$effect.root(() => {
+		skillsStore.hydrate(data.skills);
+	});
+
+	const breadcrumb = getBreadcrumbContext();
+	$effect(() => {
+		breadcrumb.set({
+			module: 'Resources',
+			pages: [{ name: 'Skills', href: resolve('/resources/skills') }],
+		});
+	});
+</script>
+
+<svelte:head>
+	<title>Skills - LocoStack</title>
+</svelte:head>
+
+<div class="mx-auto max-w-400">
+	<FilterList
+		creationLink={resolve('/resources/skills/new')}
+		itemCount={skillsStore.list.length}
+		itemIcon={Waypoints}
+		itemKey={(m) => m.id}
+		itemName="skill"
+		itemNamePlural="skills"
+		items={filtered}
+		bind:query
+		bind:filters
+	>
+		{#snippet itemRenderer(skill)}
+			<SkillCard {selectedTags} {skill} />
+		{/snippet}
+	</FilterList>
+</div>

+ 25 - 0
src/routes/resources/skills/[id]/+page.server.ts

@@ -0,0 +1,25 @@
+import { error } from '@sveltejs/kit';
+
+import * as skillsApi from '$lib/api/skills';
+import type { Skill } from '$lib/types/entities';
+
+import type { PageServerLoad } from './$types';
+
+const load: PageServerLoad = async ({ fetch, params, locals }) => {
+	if (params.id === 'new') {
+		const newSkill: Skill = {
+			id: '',
+			name: 'new-skill',
+			description: 'This is a new skill',
+			tags: [],
+			body: 'Add the content for this skill here',
+			files: [],
+		};
+		return { skill: newSkill };
+	}
+	const skill = await skillsApi.get(locals.workspaceId!, params.id, fetch).catch(() => null);
+	if (!skill) error(404);
+	return { skill };
+};
+
+export { load };

+ 125 - 0
src/routes/resources/skills/[id]/+page.svelte

@@ -0,0 +1,125 @@
+<script lang="ts">
+	import { Download, Save } from '@lucide/svelte';
+
+	import { goto } from '$app/navigation';
+	import { resolve } from '$app/paths';
+
+	import { ConfirmDialog } from '$lib/components/common/confirmation';
+	import { Icon } from '$lib/components/common/icon';
+	import { MoreMenu } from '$lib/components/common/more-menu';
+	import { Button } from '$lib/components/controls/button';
+	import { SkillEditor } from '$lib/components/skills/editor';
+	import { getBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
+	import { skillsStore } from '$lib/stores/skills.svelte';
+	import type { Skill, SkillFile } from '$lib/types/entities';
+
+	import type { PageData } from './$types';
+
+	let { data }: { data: PageData } = $props();
+	let creating = $derived(data.skill.id === '');
+	let editingContent = $derived(creating);
+
+	$effect.root(() => {
+		if (data.skill.id !== '') {
+			skillsStore.upsert(data.skill);
+		}
+	});
+
+	const skill = $derived(skillsStore.get(data.skill.id) ?? data.skill);
+	let deleteOpen = $state(false);
+
+	const breadcrumb = getBreadcrumbContext();
+	$effect(() => {
+		breadcrumb.set({
+			module: 'Resources',
+			pages: [
+				{ name: 'Skills', href: resolve('/resources/skills') },
+				creating
+					? {
+							name: '[New Skill]',
+							href: '#',
+						}
+					: {
+							name: skill.name,
+							href: resolve('/resources/skills/[id]', { id: skill.id }),
+						},
+			],
+		});
+	});
+
+	const onImport = () => {};
+
+	const onCreate = async () => {
+		await skillsStore.create(skill);
+		goto(resolve('/resources/skills'));
+	};
+
+	const onSkillChange = async (skill: Skill) => {
+		await skillsStore.update(skill);
+	};
+
+	const onFileChange = async (filePath: string | undefined, newFile: SkillFile | undefined) => {
+		if (filePath === undefined && newFile !== undefined) {
+			skill.files = [...skill.files, newFile];
+		} else if (filePath !== undefined && newFile !== undefined) {
+			skill.files = skill.files.map((file) => (file.path === filePath ? { ...newFile } : file));
+		} else if (filePath !== undefined && newFile === undefined) {
+			skill.files = skill.files.filter((file) => file.path !== filePath);
+		}
+		await skillsStore.update(skill);
+	};
+
+	const onDelete = async () => {
+		await skillsStore.remove(skill.id);
+		goto(resolve('/resources/skills'));
+	};
+</script>
+
+<svelte:head>
+	<title>{skill.name} - LocoStack</title>
+</svelte:head>
+
+<div class="mx-auto max-w-300 p-6">
+	<div class="mb-4 flex items-center justify-between gap-4">
+		<div class="flex items-center gap-4">
+			<Icon icon={skill.icon} />
+			<div class="flex flex-1 flex-col overflow-hidden">
+				<div class="truncate text-lg font-medium text-white/75">{skill.name}</div>
+			</div>
+		</div>
+		<div class="flex items-center gap-2">
+			<Button size="sm" variant="outline" onclick={() => onImport()}>
+				<Download size={14} strokeWidth={2} />
+				Import
+			</Button>
+			{#if creating}
+				<Button disabled={editingContent} size="sm" onclick={() => onCreate()}>
+					<Save size={14} strokeWidth={2} />
+					Create
+				</Button>
+			{:else}
+				<MoreMenu ondelete={() => (deleteOpen = true)} />
+			{/if}
+		</div>
+	</div>
+	<SkillEditor
+		onFileChange={(filePath, newFile) => onFileChange(filePath, newFile)}
+		onSkillChange={(skill) => onSkillChange(skill)}
+		{skill}
+		bind:editingContent
+	/>
+</div>
+
+<!-- Delete confirmation dialog -->
+<ConfirmDialog
+	confirmLabel="Delete"
+	confirmingLabel="Deleting..."
+	title="Delete skill?"
+	variant="destructive"
+	onconfirm={() => onDelete()}
+	bind:open={deleteOpen}
+>
+	{#snippet description()}
+		<strong class="text-white/90">"{skill.name}"</strong> will be permanently deleted.
+	{/snippet}
+</ConfirmDialog>