This commit is contained in:
Timothy Jaeryang Baek 2026-10-13 18:07:32 +04:00
parent be6f0a6656
commit 303cf180fc
2 changed files with 185 additions and 86 deletions

View file

@ -354,18 +354,16 @@
showImport = true;
}}
/>
{#key bundleFiles}
<SkillImport
bind:show={showImport}
files={bundleFiles}
onImported={async () => {
toast.success($i18n.t('Skill imported successfully'));
page = 1;
await loadSkillItems();
_skills.set(await getSkills(localStorage.token));
}}
/>
{/key}
<SkillImport
bind:show={showImport}
files={bundleFiles}
onImported={async () => {
toast.success($i18n.t('Skill imported successfully'));
page = 1;
await loadSkillItems();
_skills.set(await getSkills(localStorage.token));
}}
/>
<input
bind:this={importInputElement}
type="file"

View file

@ -1,39 +1,58 @@
<script lang="ts">
import { getContext } from 'svelte';
import Modal from '$lib/components/common/Modal.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import { toast } from 'svelte-sonner';
import { importSkillBundles, skillError } from '$lib/apis/skills';
import { formatFileSize } from '$lib/utils';
const i18n = getContext<typeof import('$lib/i18n').default>('i18n');
export let show = false;
export let files: File[] = [];
export let onImported: () => Promise<void> = async () => {};
let loading = false;
let error = '';
let items: any[] = [];
let results: any[] = [];
let loadedFiles: File[] | null = null;
$: selectedCount = items.filter((item) => item.action !== 'skip').length;
$: if (show && files !== loadedFiles) {
loadedFiles = files;
preview();
}
const preview = async () => {
const previewFiles = files;
loading = true;
error = '';
results = [];
items = [];
try {
items = (await importSkillBundles(localStorage.token, files)).map((item: any) => ({
const previewItems = await importSkillBundles(localStorage.token, previewFiles);
if (files !== previewFiles) return;
items = previewItems.map((item: any) => ({
...item,
originalId: item.id,
action: item.id_taken || item.name_taken ? 'skip' : 'create'
}));
} catch (error) {
toast.error(skillError(error));
} catch (err) {
if (files === previewFiles) error = skillError(err);
} finally {
loading = false;
if (files === previewFiles) loading = false;
}
};
const save = async () => {
if (loading || !selectedCount) return;
const importFiles = files;
loading = true;
try {
results = await importSkillBundles(
const importResults: any[] = await importSkillBundles(
localStorage.token,
files,
importFiles,
items.map(({ action, id, name, expected_version_id }) => ({
action,
id,
@ -41,82 +60,164 @@
expected_version_id
}))
);
results.forEach((result, index) => {
if (result.status === 'saved') items[index].action = 'skip';
});
items = [...items];
if (results.some((r) => r.status === 'saved')) await onImported();
} catch (error) {
toast.error(skillError(error));
// A closed import can finish after a different bundle has opened.
if (files === importFiles) {
results = importResults.map((result, index) =>
results[index]?.status === 'saved' ? results[index] : result
);
results.forEach((result, index) => {
if (result.status === 'saved') items[index].action = 'skip';
});
items = [...items];
}
if (importResults.some((result) => result.status === 'saved')) await onImported();
} catch (err) {
toast.error(skillError(err));
} finally {
loading = false;
if (files === importFiles) loading = false;
}
};
</script>
<Modal bind:show size="lg">
<div class="p-5">
<div class="mb-3 flex justify-between">
<h2 class="font-medium">Import skills</h2>
<button type="button" on:click={() => (show = false)}>Close</button>
<Modal bind:show size="md" className="rounded-2xl bg-white dark:bg-gray-900">
<div class="flex max-h-[calc(100dvh-3rem)] flex-col text-gray-900 dark:text-gray-100">
<div class="flex shrink-0 items-center justify-between gap-3 px-4 pt-3 pb-2">
<h2 class="text-sm font-medium">{$i18n.t('Import skills')}</h2>
<button
type="button"
class="rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
aria-label={$i18n.t('Close')}
on:click={() => (show = false)}
>
<XMark className="size-4" />
</button>
</div>
<p class="mb-3 text-xs text-gray-500">
New skills and copies are private. Replace saves a new version and preserves existing sharing.
<p class="shrink-0 px-4 pb-3 text-xs leading-5 text-gray-500 dark:text-gray-400">
{$i18n.t(
'New skills and copies are private. Replace saves a new version and preserves existing sharing.'
)}
</p>
<div class="max-h-[60vh] space-y-3 overflow-auto">
{#each items as item, index}
<div class="rounded-lg border p-3 dark:border-gray-800">
<div class="flex flex-wrap gap-2">
<input
aria-label="Skill name"
class="min-w-0 flex-1 bg-transparent"
bind:value={item.name}
disabled={loading}
/>
<select
aria-label="Import action"
class="rounded bg-transparent text-xs"
bind:value={item.action}
disabled={loading}
>
<option value="create">Create</option><option value="copy">Create copy</option
>{#if item.can_replace}<option value="replace">Replace</option>{/if}<option
value="skip">Skip</option
>
</select>
</div>
<input
aria-label="Skill ID"
class="mt-2 w-full bg-transparent font-mono text-xs"
bind:value={item.id}
disabled={loading || item.action === 'replace'}
/>
{#if item.id_taken || item.name_taken}<p class="mt-1 text-xs text-amber-600">
ID or name already exists. Choose a unique ID and name for a copy.
</p>{/if}
<details class="mt-2 text-xs">
<summary>{item.files.length} files</summary>{#each item.files as file}<div
class="font-mono"
>
{file.path} ({file.size} bytes)
</div>{/each}
</details>
{#if results[index]}<p
class="mt-2 text-xs"
class:text-red-500={results[index].status === 'error'}
>
{results[index].status}{results[index].error
? `: ${skillError(results[index].error)}`
: ''}
</p>{/if}
<div class="min-h-0 overflow-y-auto overscroll-contain px-4" aria-busy={loading}>
{#if loading && !items.length}
<div
class="flex items-center justify-center gap-2 py-10 text-xs text-gray-500 dark:text-gray-400"
role="status"
>
<Spinner className="size-4" />
{$i18n.t('Loading...')}
</div>
{/each}
{:else if error}
<div class="py-6 text-center">
<p class="text-xs text-red-600 dark:text-red-400" role="alert">{error}</p>
<button
type="button"
class="mt-3 rounded-lg px-2.5 py-1.5 text-xs transition hover:bg-gray-100 dark:hover:bg-gray-800"
on:click={preview}
>
{$i18n.t('Retry')}
</button>
</div>
{:else if !items.length}
<p class="py-10 text-center text-xs text-gray-500 dark:text-gray-400" role="status">
{$i18n.t('No skills found')}
</p>
{:else}
<div class="divide-y divide-gray-100 dark:divide-gray-850">
{#each items as item, index}
<div class="py-3 first:pt-0">
<div class="flex items-start gap-3">
<div class="min-w-0 flex-1 space-y-1">
<input
aria-label={$i18n.t('Skill name')}
class="w-full min-w-0 rounded-md bg-transparent px-1.5 py-1 text-sm font-medium outline-hidden transition hover:bg-gray-50 focus:bg-gray-50 focus:ring-1 focus:ring-gray-300 disabled:opacity-60 dark:hover:bg-gray-850 dark:focus:bg-gray-850 dark:focus:ring-gray-600"
bind:value={item.name}
disabled={loading || results[index]?.status === 'saved'}
/>
<label
class="flex min-w-0 items-center gap-2 px-1.5 text-xs text-gray-500 dark:text-gray-400"
>
<span class="shrink-0">{$i18n.t('ID')}</span>
<input
aria-label={$i18n.t('Skill ID')}
class="min-w-0 flex-1 rounded bg-transparent py-0.5 font-mono outline-hidden focus:ring-1 focus:ring-gray-300 disabled:opacity-60 dark:focus:ring-gray-600"
bind:value={item.id}
disabled={loading ||
item.action === 'replace' ||
results[index]?.status === 'saved'}
/>
</label>
</div>
<select
aria-label={$i18n.t('Import action')}
class="h-7 w-28 shrink-0 rounded-lg border border-gray-100 bg-gray-50 px-2 text-xs outline-hidden focus:ring-1 focus:ring-gray-300 disabled:opacity-60 dark:border-gray-800 dark:bg-gray-850 dark:focus:ring-gray-600"
bind:value={item.action}
on:change={(event) => {
if (event.currentTarget.value === 'replace') item.id = item.originalId;
}}
disabled={loading || results[index]?.status === 'saved'}
>
<option value="create">{$i18n.t('Create')}</option>
<option value="copy">{$i18n.t('Create copy')}</option>
{#if item.can_replace}<option value="replace">{$i18n.t('Replace')}</option>{/if}
<option value="skip">{$i18n.t('Skip')}</option>
</select>
</div>
{#if (item.id_taken || item.name_taken) && item.action !== 'replace' && results[index]?.status !== 'saved'}
<p class="mt-1.5 px-1.5 text-xs leading-5 text-amber-700 dark:text-amber-400">
{$i18n.t('ID or name already exists. Choose a unique ID and name for a copy.')}
</p>
{/if}
<details class="group mt-1.5 px-1.5 text-xs text-gray-500 dark:text-gray-400">
<summary
class="flex w-fit cursor-pointer list-none items-center gap-1 rounded py-0.5 hover:text-gray-900 dark:hover:text-gray-200 [&::-webkit-details-marker]:hidden"
>
{$i18n.t('{{count}} files', { count: item.files.length })}
<ChevronDown className="size-3 transition-transform group-open:rotate-180" />
</summary>
<div
class="mt-1 max-h-32 space-y-1 overflow-y-auto rounded-lg bg-gray-50 p-2 dark:bg-gray-850"
>
{#each item.files as file}
<div class="flex items-start justify-between gap-3">
<span class="min-w-0 break-all font-mono">{file.path}</span>
<span class="shrink-0 tabular-nums">{formatFileSize(file.size)}</span>
</div>
{/each}
</div>
</details>
{#if results[index]}
<p
class="mt-1.5 break-words px-1.5 text-xs leading-5"
class:text-red-500={results[index].status === 'error'}
role="status"
>
{results[index].status === 'saved'
? $i18n.t('Saved')
: results[index].status === 'skipped'
? $i18n.t('Skipped')
: $i18n.t('Error')}{results[index].error
? `: ${skillError(results[index].error)}`
: ''}
</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<div class="flex shrink-0 items-center justify-between gap-3 px-4 py-3">
<span class="text-xs text-gray-500 dark:text-gray-400" aria-live="polite"
>{$i18n.t('{{count}} selected', { count: selectedCount })}</span
>
<button
type="button"
class="flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs font-normal text-white transition hover:bg-black disabled:cursor-not-allowed disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
disabled={loading || !selectedCount}
on:click={save}
>
{$i18n.t('Import selected')}
{#if loading && items.length}<Spinner className="size-3" />{/if}
</button>
</div>
<button
type="button"
class="mt-4 rounded-lg bg-black px-3 py-2 text-sm text-white disabled:opacity-50 dark:bg-white dark:text-black"
disabled={loading || !items.length || items.every((i) => i.action === 'skip')}
on:click={save}>{loading ? 'Loading…' : 'Import selected'}</button
>
</div>
</Modal>