mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 19:32:10 +00:00
feat(artifact): web UI for playbook/convention export & import (#757)
* feat(artifact): web UI for playbook/convention export & import Phase 4 of PLAN-1867. - API client: exportItemArtifact (GET, parses Content-Disposition filename) and importArtifact (POST raw text/markdown); ImportArtifactResult type. - Export buttons: playbook editor header, each playbook card, each convention row — client-side .pad.md download. - Import file-picker entry points on the playbooks list + conventions header; surfaces per-warning toasts + a deep link to the created draft item. Shared browser helpers in lib/utils/artifacts.ts. CONVE-1688/606 compliant. Implements TASK-1878, TASK-1879, TASK-1880. Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ * fix(artifact): web import race + RFC5987 filename parse Addresses Codex Phase-4 review: - parseContentDispositionFilename now decodes general filename*=charset'lang'value (percent-decoded), not just a UTF-8'' prefix. - import handlers snapshot workspace/username before the first await, so a mid-import workspace switch can't misroute the follow-up get/link/refresh. Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
This commit is contained in:
+116
-1
@@ -64,7 +64,8 @@ import type {
|
||||
AttachmentListFilters,
|
||||
AttachmentListResponse,
|
||||
ConnectedApp,
|
||||
ClaimCodeResponse
|
||||
ClaimCodeResponse,
|
||||
ImportArtifactResult
|
||||
} from '$lib/types';
|
||||
|
||||
const BASE = '/api/v1';
|
||||
@@ -288,6 +289,52 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the filename out of a Content-Disposition header value. Handles both
|
||||
* the RFC 5987 extended `filename*=charset'lang'value` form (preferred when
|
||||
* present) and the plain quoted/unquoted `filename="..."` form. Returns null
|
||||
* when no filename token is present so the caller can fall back to a computed
|
||||
* default.
|
||||
*
|
||||
* The extended form is `filename*=<charset>'<lang>'<percent-encoded-value>`,
|
||||
* e.g. `filename*=UTF-8'en'r%C3%A9sum%C3%A9.pad.md`. Per RFC 5987 we drop the
|
||||
* charset + language by splitting on the first two `'` characters, then
|
||||
* percent-decode the remaining value. Malformed input (missing the two `'`
|
||||
* delimiters, or a bad percent-encoding) falls through to the plain form.
|
||||
*/
|
||||
function parseContentDispositionFilename(disposition: string): string | null {
|
||||
if (!disposition) return null;
|
||||
// RFC 5987 extended form takes precedence — it's percent-encoded.
|
||||
const extended = disposition.match(/filename\*=([^;]+)/i);
|
||||
if (extended?.[1]) {
|
||||
// Strip surrounding whitespace/quotes the producer may have added.
|
||||
const raw = extended[1].trim().replace(/^"|"$/g, '');
|
||||
// Split on the first two `'` to drop `charset` and `lang`, leaving the
|
||||
// percent-encoded value. A well-formed header has exactly two before
|
||||
// the value (the value itself can't contain a bare `'`).
|
||||
const firstQuote = raw.indexOf("'");
|
||||
const secondQuote = firstQuote >= 0 ? raw.indexOf("'", firstQuote + 1) : -1;
|
||||
if (secondQuote >= 0) {
|
||||
const value = raw.slice(secondQuote + 1);
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
// Malformed percent-encoding — fall through to the plain form.
|
||||
}
|
||||
} else {
|
||||
// No charset'lang' prefix present — treat the whole token as the value.
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
// Fall through to the plain form on a malformed percent-encoding.
|
||||
}
|
||||
}
|
||||
}
|
||||
const plain = disposition.match(/filename="?([^";]+)"?/i);
|
||||
if (plain?.[1]) return plain[1].trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
function qs(params?: Record<string, string | number | boolean | undefined>): string {
|
||||
if (!params) return '';
|
||||
const filtered: Record<string, string> = {};
|
||||
@@ -1631,6 +1678,74 @@ export const api = {
|
||||
body: JSON.stringify({ url })
|
||||
}),
|
||||
|
||||
// ── Artifact Export / Import ───────────────────────────────────────────────
|
||||
//
|
||||
// Round-trip an item as a Markdown+frontmatter artifact (the `.pad.md`
|
||||
// shape). Both bypass the shared `request` helper: export consumes the
|
||||
// raw response body as text (not JSON) and reads the filename out of the
|
||||
// Content-Disposition header, and import sends raw Markdown bytes with a
|
||||
// text/markdown Content-Type rather than JSON.
|
||||
|
||||
/**
|
||||
* GET the export endpoint and return the artifact text plus the filename
|
||||
* the server suggested via Content-Disposition (falling back to
|
||||
* `<ref>.pad.md` when the header is missing or unparseable). Auth is by
|
||||
* item visibility; a 4xx surfaces as a PadApiError like every other call.
|
||||
*/
|
||||
exportItemArtifact: async (
|
||||
ws: string,
|
||||
ref: string
|
||||
): Promise<{ filename: string; text: string }> => {
|
||||
const resp = await fetch(`${BASE}/workspaces/${ws}/items/${ref}/export`, {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (resp.status === 401) {
|
||||
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new PadApiError({ code: 'unauthorized', message: 'Authentication required' });
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => null);
|
||||
if (body?.error) throw new PadApiError(body.error);
|
||||
throw new Error(`export failed: ${resp.status}`);
|
||||
}
|
||||
const text = await resp.text();
|
||||
const disposition = resp.headers.get('Content-Disposition') ?? '';
|
||||
const filename = parseContentDispositionFilename(disposition) ?? `${ref}.pad.md`;
|
||||
return { filename, text };
|
||||
},
|
||||
|
||||
/**
|
||||
* POST the raw artifact bytes (text/markdown) and parse the JSON result.
|
||||
* Editor-gated server-side; oversized / malformed / over-quota artifacts
|
||||
* 4xx with the standard `{ error: { code, message } }` envelope, surfaced
|
||||
* here as a PadApiError so callers can show `err.message` cleanly.
|
||||
*/
|
||||
importArtifact: async (ws: string, body: string): Promise<ImportArtifactResult> => {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'text/markdown' };
|
||||
const csrf = getCSRFToken();
|
||||
if (csrf) headers['X-CSRF-Token'] = csrf;
|
||||
const resp = await fetch(`${BASE}/workspaces/${ws}/import-artifact`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
body
|
||||
});
|
||||
if (resp.status === 401) {
|
||||
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new PadApiError({ code: 'unauthorized', message: 'Authentication required' });
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const errBody = await resp.json().catch(() => null);
|
||||
if (errBody?.error) throw new PadApiError(errBody.error);
|
||||
throw new Error(`import failed: ${resp.status}`);
|
||||
}
|
||||
return (await resp.json()) as ImportArtifactResult;
|
||||
},
|
||||
|
||||
// ── Admin ────────────────────────────────────────────────────────────────
|
||||
|
||||
admin: {
|
||||
|
||||
@@ -226,6 +226,16 @@ export interface ClaimCodeResponse {
|
||||
suppression_grant_name?: string;
|
||||
}
|
||||
|
||||
// ImportArtifactResult is the wire shape returned by
|
||||
// POST /workspaces/{ws}/import-artifact — the new item's ref + slug plus
|
||||
// any non-fatal warnings (coerced fields, renamed slug, forced-draft) the
|
||||
// server surfaced while importing the Markdown+frontmatter artifact.
|
||||
export interface ImportArtifactResult {
|
||||
ref: string;
|
||||
slug: string;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Artifact export/import helpers shared across the conventions + playbook
|
||||
// surfaces (Phase 4 web UI). Centralizes the browser-only Blob/object-URL
|
||||
// download dance and the file-read-then-import flow so each page only wires
|
||||
// up its own buttons + toasts.
|
||||
|
||||
import { api } from '$lib/api/client';
|
||||
import type { ImportArtifactResult } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Trigger a client-side download of `text` as `filename` using a Blob +
|
||||
* object URL + a synthetic `<a download>` click, revoking the URL afterward.
|
||||
* No-op outside the browser (guards against SSR).
|
||||
*/
|
||||
export function downloadTextFile(filename: string, text: string, mime = 'text/markdown'): void {
|
||||
if (typeof document === 'undefined' || typeof URL === 'undefined') return;
|
||||
const blob = new Blob([text], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export an item as a `.pad.md` artifact and immediately download it. Throws
|
||||
* the underlying PadApiError so callers can surface `err.message` in a toast.
|
||||
*/
|
||||
export async function exportAndDownloadArtifact(ws: string, ref: string): Promise<void> {
|
||||
const { filename, text } = await api.exportItemArtifact(ws, ref);
|
||||
downloadTextFile(filename, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a picked artifact File as text and POST it to the import endpoint.
|
||||
* Returns the server result (ref + slug + warnings); throws on read failure
|
||||
* or a 4xx import error (PadApiError) for the caller to surface.
|
||||
*/
|
||||
export async function importArtifactFile(
|
||||
ws: string,
|
||||
file: File
|
||||
): Promise<ImportArtifactResult> {
|
||||
const text = await file.text();
|
||||
return api.importArtifact(ws, text);
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
import { page } from '$app/state';
|
||||
import { api, isPlanLimitError, planLimitMessage } from '$lib/api/client';
|
||||
import type { Collection, Item, ItemConventionMetadata, ItemCreate } from '$lib/types';
|
||||
import { parseFields, parseSchema } from '$lib/types';
|
||||
import { parseFields, parseSchema, itemUrlId } from '$lib/types';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
import { createScrollRestoration } from '$lib/scroll/restore.svelte';
|
||||
import { exportAndDownloadArtifact, importArtifactFile } from '$lib/utils/artifacts';
|
||||
import { SvelteSet, SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
const TRIGGERS = ['always','on-task-start','on-task-complete','on-implement','on-commit','on-pr-create','on-plan-start','on-plan-complete','on-plan'] as const;
|
||||
@@ -62,6 +63,12 @@
|
||||
let editContent = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
// Artifact export/import state. `importing` gates the import button while a
|
||||
// POST is in flight; `exportingSlug` tracks which row's Export is busy.
|
||||
let exportingSlug = $state<string | null>(null);
|
||||
let importing = $state(false);
|
||||
let importInputEl = $state<HTMLInputElement | null>(null);
|
||||
|
||||
// Inline create form state
|
||||
let newTitle = $state('');
|
||||
let newCategory = $state<typeof CATEGORIES[number]>('custom');
|
||||
@@ -309,6 +316,78 @@
|
||||
editContent = '';
|
||||
}
|
||||
|
||||
async function handleExport(item: Item) {
|
||||
if (!workspace || exportingSlug) return;
|
||||
exportingSlug = item.slug;
|
||||
try {
|
||||
await exportAndDownloadArtifact(workspace, itemUrlId(item));
|
||||
toastStore.show('Convention exported', 'success');
|
||||
} catch (err: unknown) {
|
||||
toastStore.show(
|
||||
err instanceof Error ? err.message : 'Failed to export convention',
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
exportingSlug = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Open the hidden file-picker. The selected file is handled entirely in
|
||||
// `onImportFileChange` (the change-event handler) — we never route the
|
||||
// file through a `$state` that an `$effect` reads (CONVE-1688).
|
||||
function openImportPicker() {
|
||||
importInputEl?.click();
|
||||
}
|
||||
|
||||
async function onImportFileChange(e: Event) {
|
||||
const input = e.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
// Reset the input synchronously so picking the same file again still
|
||||
// fires a change event next time.
|
||||
input.value = '';
|
||||
if (!file || !workspace || importing) return;
|
||||
// Snapshot the reactive workspace/username BEFORE the first await. If the
|
||||
// user switches workspace mid-import, the follow-up get, the deep link,
|
||||
// and the list refresh must target the workspace the import went to —
|
||||
// not whatever `workspace`/`username` have since become.
|
||||
const ws = workspace;
|
||||
const user = username;
|
||||
importing = true;
|
||||
try {
|
||||
const result = await importArtifactFile(ws, file);
|
||||
// Surface each warning (coerced fields, renamed slug, forced-draft)
|
||||
// as its own info toast so none get lost behind the success line.
|
||||
for (const warning of result.warnings) {
|
||||
toastStore.show(warning, 'info', 6000);
|
||||
}
|
||||
// The import endpoint returns ref + slug but not the destination
|
||||
// collection, and item-detail routes are collection-scoped
|
||||
// (/{user}/{ws}/{collection}/{slug}). Resolve the created item to
|
||||
// build an accurate deep link; if that lookup fails, still report
|
||||
// success without a link rather than 404-ing the user.
|
||||
let link: string | undefined;
|
||||
try {
|
||||
const created = await api.items.get(ws, result.slug);
|
||||
if (created.collection_slug) {
|
||||
link = `/${user}/${ws}/${created.collection_slug}/${created.slug}`;
|
||||
}
|
||||
} catch {
|
||||
link = undefined;
|
||||
}
|
||||
toastStore.show(`Imported ${result.ref} as a draft`, 'success', 6000, link);
|
||||
// Refresh the list so a newly-imported convention shows up.
|
||||
loadConventions(ws);
|
||||
} catch (err: unknown) {
|
||||
toastStore.show(
|
||||
err instanceof Error ? err.message : 'Failed to import artifact',
|
||||
'error',
|
||||
6000
|
||||
);
|
||||
} finally {
|
||||
importing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkToggleGroup(group: { trigger: string; items: Item[] }, enable: boolean) {
|
||||
if (!workspace) return;
|
||||
const targetStatus = enable ? 'active' : 'disabled';
|
||||
@@ -406,9 +485,24 @@
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a href="/{username}/{workspace}/library" class="btn btn-secondary">Browse Library</a>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
disabled={importing}
|
||||
onclick={openImportPicker}
|
||||
title="Import a convention or playbook from a .pad.md artifact"
|
||||
>
|
||||
{importing ? 'Importing…' : 'Import artifact'}
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick={() => (showCreate = !showCreate)}>
|
||||
{showCreate ? 'Cancel' : '+ New Convention'}
|
||||
</button>
|
||||
<input
|
||||
bind:this={importInputEl}
|
||||
type="file"
|
||||
accept=".md,text/markdown"
|
||||
class="visually-hidden-input"
|
||||
onchange={onImportFileChange}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -602,6 +696,14 @@
|
||||
{/if}
|
||||
<div class="expanded-actions">
|
||||
<button class="btn btn-small btn-secondary" onclick={() => startEditing(item)}>Edit</button>
|
||||
<button
|
||||
class="btn btn-small btn-secondary"
|
||||
disabled={exportingSlug === item.slug}
|
||||
onclick={() => handleExport(item)}
|
||||
title="Download as a .pad.md artifact"
|
||||
>
|
||||
{exportingSlug === item.slug ? 'Exporting…' : 'Export'}
|
||||
</button>
|
||||
{#if confirmDelete === item.slug}
|
||||
<span class="confirm-text">Delete this convention?</span>
|
||||
<button class="btn btn-small btn-danger" onclick={() => deleteConvention(item)}>Confirm</button>
|
||||
@@ -632,7 +734,8 @@
|
||||
.page-header { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-4); margin-bottom: var(--space-6); flex-wrap: wrap; }
|
||||
.header-text h1 { font-size: 1.6em; margin-bottom: var(--space-1); }
|
||||
.subtitle { color: var(--text-secondary); font-size: 0.9em; }
|
||||
.header-actions { display: flex; gap: var(--space-2); flex-shrink: 0; }
|
||||
.header-actions { display: flex; gap: var(--space-2); flex-shrink: 0; flex-wrap: wrap; }
|
||||
.visually-hidden-input { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||
|
||||
/* Buttons */
|
||||
.btn { padding: var(--space-1) var(--space-4); border-radius: var(--radius); font-size: 0.85em; font-weight: 600; cursor: pointer; border: none; white-space: nowrap; text-decoration: none; display: inline-flex; align-items: center; }
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { parseFields, parseSchema, itemUrlId, type Collection, type Item } from '$lib/types';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
import { createScrollRestoration } from '$lib/scroll/restore.svelte';
|
||||
import { exportAndDownloadArtifact, importArtifactFile } from '$lib/utils/artifacts';
|
||||
import PlaybookFormFields from '$lib/components/playbooks/PlaybookFormFields.svelte';
|
||||
import {
|
||||
PLAYBOOK_SKELETON_BODY,
|
||||
@@ -40,6 +41,9 @@
|
||||
let confirmDeleteSlug = $state<string | null>(null);
|
||||
let togglingStatus = $state<string | null>(null);
|
||||
let duplicating = $state<string | null>(null);
|
||||
let exportingSlug = $state<string | null>(null);
|
||||
let importing = $state(false);
|
||||
let importInputEl = $state<HTMLInputElement | null>(null);
|
||||
let searchQuery = $state('');
|
||||
let filterTrigger = $state<string>('');
|
||||
let filterScope = $state<string>('');
|
||||
@@ -292,6 +296,71 @@
|
||||
} finally { duplicating = null; }
|
||||
}
|
||||
|
||||
async function exportPlaybook(item: Item) {
|
||||
if (exportingSlug) return;
|
||||
exportingSlug = item.slug;
|
||||
try {
|
||||
await exportAndDownloadArtifact(wsSlug, itemUrlId(item));
|
||||
toastStore.show('Playbook exported', 'success');
|
||||
} catch (err: unknown) {
|
||||
toastStore.show(
|
||||
err instanceof Error ? err.message : 'Failed to export playbook',
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
exportingSlug = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Open the hidden file-picker; the selected file is handled entirely in the
|
||||
// change-event handler so the File never routes through a `$state` an
|
||||
// `$effect` reads (CONVE-1688).
|
||||
function openImportPicker() {
|
||||
importInputEl?.click();
|
||||
}
|
||||
|
||||
async function onImportFileChange(e: Event) {
|
||||
const input = e.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
// Reset synchronously so re-picking the same file fires change again.
|
||||
input.value = '';
|
||||
if (!file || importing) return;
|
||||
// Snapshot the reactive wsSlug/username BEFORE the first await. If the
|
||||
// user switches workspace mid-import, the follow-up get, the deep link,
|
||||
// and the list refresh must target the workspace the import went to —
|
||||
// not whatever `wsSlug`/`username` have since become.
|
||||
const ws = wsSlug;
|
||||
const user = username;
|
||||
importing = true;
|
||||
try {
|
||||
const result = await importArtifactFile(ws, file);
|
||||
for (const warning of result.warnings) {
|
||||
toastStore.show(warning, 'info', 6000);
|
||||
}
|
||||
// Resolve the created item to build a collection-scoped deep link
|
||||
// (the import response carries ref + slug but not the collection).
|
||||
let link: string | undefined;
|
||||
try {
|
||||
const created = await api.items.get(ws, result.slug);
|
||||
if (created.collection_slug) {
|
||||
link = `/${user}/${ws}/${created.collection_slug}/${created.slug}`;
|
||||
}
|
||||
} catch {
|
||||
link = undefined;
|
||||
}
|
||||
toastStore.show(`Imported ${result.ref} as a draft`, 'success', 6000, link);
|
||||
await loadPlaybooks(ws);
|
||||
} catch (err: unknown) {
|
||||
toastStore.show(
|
||||
err instanceof Error ? err.message : 'Failed to import artifact',
|
||||
'error',
|
||||
6000
|
||||
);
|
||||
} finally {
|
||||
importing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() { searchQuery = ''; filterTrigger = ''; filterScope = ''; }
|
||||
|
||||
function resetForm() {
|
||||
@@ -319,9 +388,23 @@
|
||||
<p class="subtitle">Multi-step workflows that agents follow for specific actions</p>
|
||||
</div>
|
||||
{#if !showNewForm}
|
||||
<div style="display:flex;gap:var(--space-2);align-items:center;">
|
||||
<div style="display:flex;gap:var(--space-2);align-items:center;flex-wrap:wrap;">
|
||||
<a href="/{username}/{wsSlug}/library?tab=playbooks" class="new-btn" style="background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border);">📚 Browse Library</a>
|
||||
<button
|
||||
class="new-btn"
|
||||
style="background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border);"
|
||||
disabled={importing}
|
||||
onclick={openImportPicker}
|
||||
title="Import a playbook or convention from a .pad.md artifact"
|
||||
>{importing ? 'Importing…' : 'Import artifact'}</button>
|
||||
<button class="new-btn" onclick={() => (showNewForm = true)}>+ New Playbook</button>
|
||||
<input
|
||||
bind:this={importInputEl}
|
||||
type="file"
|
||||
accept=".md,text/markdown"
|
||||
class="visually-hidden-input"
|
||||
onchange={onImportFileChange}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
@@ -461,6 +544,9 @@
|
||||
<button class="action-btn" disabled={duplicating === item.slug} onclick={() => duplicatePlaybook(item)}>
|
||||
{duplicating === item.slug ? '...' : 'Duplicate'}
|
||||
</button>
|
||||
<button class="action-btn" disabled={exportingSlug === item.slug} onclick={() => exportPlaybook(item)} title="Download as a .pad.md artifact">
|
||||
{exportingSlug === item.slug ? '...' : 'Export'}
|
||||
</button>
|
||||
{#if confirmDeleteSlug === item.slug}
|
||||
<span class="delete-confirm">
|
||||
Delete?
|
||||
@@ -488,6 +574,8 @@
|
||||
.subtitle { color: var(--text-secondary); font-size: 0.95em; }
|
||||
.new-btn { background: var(--accent-blue); color: #fff; padding: var(--space-2) var(--space-5); border-radius: var(--radius); font-size: 0.85em; font-weight: 600; white-space: nowrap; flex-shrink: 0; transition: opacity 0.15s; }
|
||||
.new-btn:hover { opacity: 0.85; }
|
||||
.new-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.visually-hidden-input { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||
.empty-state { text-align: center; padding: var(--space-10) var(--space-6); color: var(--text-secondary); }
|
||||
.empty-icon { font-size: 3em; margin-bottom: var(--space-4); opacity: 0.6; }
|
||||
.empty-state h2 { font-size: 1.2em; font-weight: 600; margin-bottom: var(--space-2); color: var(--text-primary); }
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api/client';
|
||||
import { parseFields, parseSchema, type Collection, type Item } from '$lib/types';
|
||||
import { parseFields, parseSchema, itemUrlId, type Collection, type Item } from '$lib/types';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
import { createScrollRestoration } from '$lib/scroll/restore.svelte';
|
||||
import { exportAndDownloadArtifact } from '$lib/utils/artifacts';
|
||||
import PlaybookFormFields from '$lib/components/playbooks/PlaybookFormFields.svelte';
|
||||
import {
|
||||
argumentsFromJSON,
|
||||
@@ -35,6 +36,7 @@
|
||||
let existingPlaybooks = $state<Item[]>([]);
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let exporting = $state(false);
|
||||
|
||||
// Scroll position restoration (BUG-1425). Form pages are usually short
|
||||
// enough that scroll position isn't critical, but if the body grows the
|
||||
@@ -223,6 +225,22 @@
|
||||
function cancel() {
|
||||
goto(`/${username}/${wsSlug}/playbooks`);
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
if (!item || exporting) return;
|
||||
exporting = true;
|
||||
try {
|
||||
await exportAndDownloadArtifact(wsSlug, itemUrlId(item));
|
||||
toastStore.show('Playbook exported', 'success');
|
||||
} catch (err: unknown) {
|
||||
toastStore.show(
|
||||
err instanceof Error ? err.message : 'Failed to export playbook',
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
exporting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="edit-page">
|
||||
@@ -244,6 +262,15 @@
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick={cancel}>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
disabled={exporting}
|
||||
onclick={handleExport}
|
||||
title="Download this playbook as a .pad.md artifact"
|
||||
>
|
||||
{exporting ? 'Exporting…' : 'Export'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
|
||||
Reference in New Issue
Block a user