feat: skills frontend

This commit is contained in:
Timothy Jaeryang Baek
2026-02-11 14:22:26 -06:00
parent a38ad8fc42
commit 1973115678
9 changed files with 1198 additions and 0 deletions
@@ -0,0 +1,225 @@
<script lang="ts">
import { onMount, tick, getContext } from 'svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import { toast } from 'svelte-sonner';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import LockClosed from '$lib/components/icons/LockClosed.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import AccessControlModal from '../common/AccessControlModal.svelte';
import { user } from '$lib/stores';
import { slugify } from '$lib/utils';
import Spinner from '$lib/components/common/Spinner.svelte';
import { updateSkillAccessGrants } from '$lib/apis/skills';
import { goto } from '$app/navigation';
export let onSubmit: Function;
export let edit = false;
export let skill = null;
export let clone = false;
export let disabled = false;
const i18n = getContext('i18n');
let loading = false;
let name = '';
let id = '';
let description = '';
let content = '';
let accessGrants = [];
let showAccessControlModal = false;
let hasManualEdit = false;
$: if (!edit && !hasManualEdit) {
id = name !== '' ? slugify(name) : '';
}
function handleIdInput(e: Event) {
hasManualEdit = true;
}
const submitHandler = async () => {
if (disabled) {
toast.error($i18n.t('You do not have permission to edit this skill.'));
return;
}
loading = true;
await onSubmit({
id,
name,
description,
content,
is_active: true,
meta: { tags: [] },
access_grants: accessGrants
});
loading = false;
};
onMount(async () => {
if (skill) {
name = skill.name || '';
await tick();
id = skill.id || '';
description = skill.description || '';
content = skill.content || '';
accessGrants = skill?.access_grants === undefined ? [] : skill?.access_grants;
}
});
</script>
<AccessControlModal
bind:show={showAccessControlModal}
bind:accessGrants
accessRoles={['read', 'write']}
share={$user?.permissions?.sharing?.skills || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_skills || $user?.role === 'admin' || edit}
onChange={async () => {
if (edit && skill?.id) {
try {
await updateSkillAccessGrants(localStorage.token, skill.id, accessGrants);
toast.success($i18n.t('Saved'));
} catch (error) {
toast.error(`${error}`);
}
}
}}
/>
<div class=" flex flex-col justify-between w-full overflow-y-auto h-full">
<div class="mx-auto w-full md:px-0 h-full">
<form
class=" flex flex-col max-h-[100dvh] h-full"
on:submit|preventDefault={submitHandler}
>
<div class="flex flex-col flex-1 overflow-auto h-0 rounded-lg">
<div class="w-full mb-2 flex flex-col gap-0.5">
<div class="flex w-full items-center">
<div class=" shrink-0 mr-2">
<Tooltip content={$i18n.t('Back')}>
<button
class="w-full text-left text-sm py-1.5 px-1 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
on:click={() => {
goto('/workspace/skills');
}}
type="button"
>
<ChevronLeft strokeWidth="2.5" />
</button>
</Tooltip>
</div>
<div class="flex-1">
<Tooltip content={$i18n.t('e.g. Code Review Guidelines')} placement="top-start">
<input
class="w-full text-2xl font-medium bg-transparent outline-hidden font-primary"
type="text"
placeholder={$i18n.t('Skill Name')}
bind:value={name}
required
{disabled}
/>
</Tooltip>
</div>
<div class="self-center shrink-0">
{#if !disabled}
<button
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 rounded-full flex gap-1 items-center"
type="button"
on:click={() => (showAccessControlModal = true)}
>
<LockClosed strokeWidth="2.5" className="size-3.5" />
<div class="text-sm font-medium shrink-0">
{$i18n.t('Access')}
</div>
</button>
{:else}
<span class="text-xs text-gray-500 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded-full"
>{$i18n.t('Read Only')}</span
>
{/if}
</div>
</div>
<div class=" flex gap-2 px-1 items-center">
{#if edit}
<div class="text-sm text-gray-500 shrink-0">
{id}
</div>
{:else}
<Tooltip className="w-full" content={$i18n.t('e.g. code-review-guidelines')} placement="top-start">
<input
class="w-full text-sm disabled:text-gray-500 bg-transparent outline-hidden"
type="text"
placeholder={$i18n.t('Skill ID')}
bind:value={id}
on:input={handleIdInput}
required
disabled={edit}
/>
</Tooltip>
{/if}
<Tooltip
className="w-full self-center items-center flex"
content={$i18n.t('e.g. Step-by-step instructions for code reviews')}
placement="top-start"
>
<input
class="w-full text-sm bg-transparent outline-hidden"
type="text"
placeholder={$i18n.t('Skill Description')}
bind:value={description}
{disabled}
/>
</Tooltip>
</div>
</div>
<div class="mb-2 flex-1 overflow-auto h-0 rounded-lg">
<div class="h-full flex flex-col">
<div
class="bg-gray-50 dark:bg-gray-900 rounded-xl border border-gray-100/50 dark:border-gray-850/50 flex-1 min-h-0 overflow-hidden flex flex-col"
>
{#if disabled}
<div class="px-4 py-3 overflow-y-auto flex-1">
<pre class="text-xs whitespace-pre-wrap font-mono">{content}</pre>
</div>
{:else}
<textarea
class="w-full flex-1 text-xs bg-transparent outline-hidden resize-none font-mono px-4 py-3"
bind:value={content}
placeholder={$i18n.t('Enter skill instructions in markdown...')}
required
/>
{/if}
</div>
</div>
</div>
<div class="pb-3 flex justify-end">
{#if !disabled}
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center"
type="submit"
disabled={loading}
>
{$i18n.t(edit ? 'Save' : 'Save & Create')}
{#if loading}
<div class="ml-1.5">
<Spinner />
</div>
{/if}
</button>
{/if}
</div>
</div>
</form>
</div>
</div>
@@ -0,0 +1,105 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { flyAndScale } from '$lib/utils/transitions';
import { getContext } from 'svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
import Download from '$lib/components/icons/Download.svelte';
import { user } from '$lib/stores';
const i18n = getContext('i18n');
export let editHandler: Function;
export let cloneHandler: Function;
export let exportHandler: Function;
export let deleteHandler: Function;
export let onClose: Function;
let show = false;
</script>
<Dropdown
bind:show
on:change={(e) => {
if (e.detail === false) {
onClose();
}
}}
>
<Tooltip content={$i18n.t('More')}>
<slot />
</Tooltip>
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[170px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
sideOffset={-2}
side="bottom"
align="start"
transition={flyAndScale}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
editHandler();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
/>
</svg>
<div class="flex items-center">{$i18n.t('Edit')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
cloneHandler();
}}
>
<DocumentDuplicate />
<div class="flex items-center">{$i18n.t('Clone')}</div>
</DropdownMenu.Item>
{#if $user?.role === 'admin' || $user?.permissions?.workspace?.skills}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
exportHandler();
}}
>
<Download />
<div class="flex items-center">{$i18n.t('Export')}</div>
</DropdownMenu.Item>
{/if}
<hr class="border-gray-50 dark:border-gray-850/30 my-1" />
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
deleteHandler();
}}
>
<GarbageBin />
<div class="flex items-center">{$i18n.t('Delete')}</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</div>
</Dropdown>