Introduces two new resource types (hooks, claude-md) with full CRUD, visual hook config editor, section-delimited CLAUDE.md installs, uninstall endpoints, and shell injection hardening in sync scripts.
899 lines
43 KiB
Vue
899 lines
43 KiB
Vue
<template>
|
|
<form @submit.prevent="save" class="space-y-6">
|
|
<!-- Fork: author identity -->
|
|
<div v-if="isFork" class="rounded-xl border border-[var(--color-accent-500)]/20 bg-[var(--color-accent-500)]/5 p-4 space-y-3">
|
|
<p class="text-sm text-[var(--color-accent-400)]">Claim this fork as yours. It will stay open for editing until you push from CLI, which registers a token and locks it to you.</p>
|
|
<div class="grid gap-3 sm:grid-cols-2">
|
|
<div>
|
|
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Your Name</label>
|
|
<input
|
|
v-model="forkAuthorName"
|
|
type="text"
|
|
placeholder="Jane Doe"
|
|
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-2.5 text-sm text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Your Email</label>
|
|
<input
|
|
v-model="forkAuthorEmail"
|
|
type="email"
|
|
placeholder="jane@example.com"
|
|
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-2.5 text-sm text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Fork slug warning -->
|
|
<div v-if="isFork && slugMatchesOriginal" class="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4">
|
|
<p class="text-sm text-amber-400">Change the <strong>name</strong> to generate a different slug. You can't save a fork with the same slug as the original.</p>
|
|
</div>
|
|
|
|
<!-- Shared fields: Name + Description -->
|
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">Name</label>
|
|
<input
|
|
v-model="name"
|
|
type="text"
|
|
required
|
|
maxlength="64"
|
|
placeholder="My Awesome Resource"
|
|
:class="[
|
|
'w-full rounded-xl border px-4 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-1 transition-all',
|
|
isFork && slugMatchesOriginal
|
|
? 'border-amber-500/30 bg-[var(--color-surface-100)] focus:border-amber-500/50 focus:ring-amber-500/20'
|
|
: 'border-white/[0.06] bg-[var(--color-surface-100)] focus:border-[var(--color-accent-500)]/50 focus:ring-[var(--color-accent-500)]/20'
|
|
]"
|
|
/>
|
|
<p class="mt-1.5 text-xs text-gray-600 flex justify-between">
|
|
<span>Slug: <code :class="['font-mono', isFork && slugMatchesOriginal ? 'text-amber-500' : 'text-gray-500']">{{ computedSlug }}</code></span>
|
|
<span :class="name.length > 58 ? 'text-amber-500' : ''">{{ name.length }}/64</span>
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">Description</label>
|
|
<input
|
|
v-model="description"
|
|
type="text"
|
|
maxlength="200"
|
|
placeholder="Brief description of what this does"
|
|
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-2.5 text-sm text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all"
|
|
/>
|
|
<p class="mt-1.5 text-xs text-gray-600 text-right" :class="description.length > 180 ? 'text-amber-500' : ''">{{ description.length }}/200</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tags (shared) -->
|
|
<div class="relative">
|
|
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">Tags</label>
|
|
<div
|
|
class="flex flex-wrap items-center gap-1.5 rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-3 py-2 min-h-[42px] cursor-text focus-within:border-[var(--color-accent-500)]/50 focus-within:ring-1 focus-within:ring-[var(--color-accent-500)]/20 transition-all"
|
|
@click="tagInputEl?.focus()"
|
|
>
|
|
<span
|
|
v-for="(tag, i) in tags"
|
|
:key="tag"
|
|
class="inline-flex items-center gap-1 rounded-full bg-[var(--color-accent-500)]/15 border border-[var(--color-accent-500)]/25 pl-2.5 pr-1.5 py-0.5 text-xs font-medium text-[var(--color-accent-400)]"
|
|
>
|
|
{{ tag }}
|
|
<button type="button" @click.stop="removeTag(i)" class="rounded-full p-0.5 hover:bg-[var(--color-accent-500)]/30 transition-colors">
|
|
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</span>
|
|
<input
|
|
ref="tagInputEl"
|
|
v-model="tagQuery"
|
|
type="text"
|
|
placeholder="Add tag..."
|
|
class="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-gray-600 outline-none"
|
|
@keydown="onTagKeydown"
|
|
@focus="tagSuggestionsOpen = true"
|
|
@click="tagSuggestionsOpen = true"
|
|
@input="tagSuggestionsOpen = true"
|
|
@blur="onTagBlur"
|
|
/>
|
|
</div>
|
|
<div
|
|
v-if="tagSuggestionsOpen && tagSuggestions.length > 0"
|
|
class="absolute z-10 mt-1 w-full max-h-40 overflow-auto rounded-xl border border-white/[0.08] bg-[var(--color-surface-200)] shadow-xl"
|
|
>
|
|
<button
|
|
v-for="s in tagSuggestions"
|
|
:key="s"
|
|
type="button"
|
|
@mousedown.prevent="onTagSuggestionClick(s)"
|
|
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-300 hover:bg-white/[0.06] hover:text-white transition-colors text-left"
|
|
>
|
|
<span v-if="isNewTag(s)" class="text-[var(--color-accent-500)] text-xs">+</span>
|
|
{{ s }}
|
|
<span v-if="isNewTag(s)" class="text-xs text-gray-600">(new)</span>
|
|
</button>
|
|
</div>
|
|
<p class="mt-1.5 text-xs text-gray-600">Type and press Enter or comma. Click suggestions to add.</p>
|
|
</div>
|
|
|
|
<!-- Format toggle (create mode only, not for forced-folder or forced-file types) -->
|
|
<div v-if="mode === 'create' && !isFork && !forceFolderType && !forceFileType">
|
|
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">Format</label>
|
|
<div class="flex rounded-xl border border-white/[0.06] overflow-hidden w-fit">
|
|
<button
|
|
type="button"
|
|
@click="format = 'file'"
|
|
:class="['px-4 py-2 text-sm font-medium transition-all', format === 'file' ? 'bg-[var(--color-accent-500)]/15 text-[var(--color-accent-400)] border-r border-white/[0.06]' : 'text-gray-500 hover:text-gray-300 border-r border-white/[0.06]']"
|
|
>Simple (.md)</button>
|
|
<button
|
|
type="button"
|
|
@click="format = 'folder'"
|
|
:class="['px-4 py-2 text-sm font-medium transition-all', format === 'folder' ? 'bg-[var(--color-accent-500)]/15 text-[var(--color-accent-400)]' : 'text-gray-500 hover:text-gray-300']"
|
|
>Folder</button>
|
|
</div>
|
|
<p class="mt-1.5 text-xs text-gray-600">
|
|
{{ format === 'file' ? 'Single markdown file.' : 'Directory with scripts/, references/, and assets/ subdirectories.' }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Folder files manager (create mode, folder selected, non-hooks types) -->
|
|
<div v-if="mode === 'create' && !isFork && format === 'folder' && !isHooksType" class="rounded-xl border border-white/[0.08] bg-[var(--color-surface-50)] p-4 space-y-4">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-xs font-medium text-gray-400">{{ isHooksType ? 'Scripts' : 'Folder files' }}</p>
|
|
<p class="text-[11px] text-gray-600 mt-0.5">
|
|
<template v-if="isHooksType">Add executable scripts that your hook commands reference.</template>
|
|
<template v-else><code class="text-gray-500">{{ computedSlug }}/{{ mainFileName }}</code> is generated from the body. Add scripts, docs, or assets here.</template>
|
|
</p>
|
|
</div>
|
|
<div class="flex gap-1.5">
|
|
<button
|
|
type="button"
|
|
@click="draftAddMode = draftAddMode === 'create' ? '' : 'create'"
|
|
:class="['inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all',
|
|
draftAddMode === 'create'
|
|
? 'bg-[var(--color-accent-500)]/15 border-[var(--color-accent-500)]/30 text-[var(--color-accent-400)]'
|
|
: 'bg-white/[0.06] border-white/[0.06] text-gray-400 hover:text-white hover:bg-white/[0.1]']"
|
|
>
|
|
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
|
|
</svg>
|
|
Create
|
|
</button>
|
|
<button
|
|
type="button"
|
|
@click="draftAddMode = draftAddMode === 'upload' ? '' : 'upload'"
|
|
:class="['inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all',
|
|
draftAddMode === 'upload'
|
|
? 'bg-[var(--color-accent-500)]/15 border-[var(--color-accent-500)]/30 text-[var(--color-accent-400)]'
|
|
: 'bg-white/[0.06] border-white/[0.06] text-gray-400 hover:text-white hover:bg-white/[0.1]']"
|
|
>
|
|
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
|
|
</svg>
|
|
Upload
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create inline file -->
|
|
<div v-if="draftAddMode === 'create'" class="rounded-lg border border-white/[0.08] bg-[var(--color-surface-100)] p-3 space-y-3">
|
|
<div class="flex items-end gap-3">
|
|
<div v-if="!isHooksType">
|
|
<label class="block text-xs font-medium text-gray-500 mb-1">Directory</label>
|
|
<select v-model="draftDir" class="rounded-lg border border-white/[0.06] bg-[var(--color-surface-50)] px-3 py-2 text-sm text-white focus:outline-none transition-all">
|
|
<option value="scripts">scripts/ — executable code</option>
|
|
<option value="references">references/ — docs & context</option>
|
|
<option value="assets">assets/ — templates & files</option>
|
|
</select>
|
|
</div>
|
|
<div class="flex-1">
|
|
<label class="block text-xs font-medium text-gray-500 mb-1">File name</label>
|
|
<input
|
|
v-model="draftFileName"
|
|
type="text"
|
|
:placeholder="isHooksType ? 'lint.sh' : 'run.sh'"
|
|
class="w-full rounded-lg border border-white/[0.06] bg-[var(--color-surface-50)] px-3 py-2 text-sm font-mono text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none transition-all"
|
|
@keydown.enter.prevent="addDraftInline"
|
|
/>
|
|
</div>
|
|
<button type="button" @click="addDraftInline" :disabled="!draftFileName.trim()" class="rounded-lg bg-[var(--color-accent-500)] px-4 py-2 text-xs font-semibold text-white hover:bg-[var(--color-accent-600)] disabled:opacity-50 transition-all">Add</button>
|
|
</div>
|
|
<p v-if="!isHooksType" class="text-[11px] text-gray-600"><strong class="text-gray-500">scripts/</strong> run via hooks or tool calls · <strong class="text-gray-500">references/</strong> Claude reads as context · <strong class="text-gray-500">assets/</strong> copied into the project</p>
|
|
<p v-else class="text-[11px] text-gray-600">Scripts are saved to <code class="text-gray-500">scripts/</code> and can be referenced in hook commands.</p>
|
|
</div>
|
|
|
|
<!-- Upload file -->
|
|
<div v-if="draftAddMode === 'upload'" class="rounded-lg border border-white/[0.08] bg-[var(--color-surface-100)] p-3">
|
|
<div class="flex items-end gap-3">
|
|
<div v-if="!isHooksType">
|
|
<label class="block text-xs font-medium text-gray-500 mb-1">Directory</label>
|
|
<select v-model="draftDir" class="rounded-lg border border-white/[0.06] bg-[var(--color-surface-50)] px-3 py-2 text-sm text-white focus:outline-none transition-all">
|
|
<option value="scripts">scripts/ — executable code</option>
|
|
<option value="references">references/ — docs & context</option>
|
|
<option value="assets">assets/ — templates & files</option>
|
|
</select>
|
|
</div>
|
|
<div class="flex-1">
|
|
<label class="block text-xs font-medium text-gray-500 mb-1">File</label>
|
|
<input
|
|
type="file"
|
|
@change="addDraftUpload"
|
|
class="w-full text-sm text-gray-400 file:mr-2 file:rounded-lg file:border-0 file:bg-white/[0.06] file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-gray-300 hover:file:bg-white/[0.1] file:cursor-pointer file:transition-all"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p class="text-[11px] text-gray-600 mt-2">Text files (.sh, .md, .py...) become editable inline. Binary files (images, fonts) are stored as-is.</p>
|
|
</div>
|
|
|
|
<!-- Files list -->
|
|
<div v-if="draftFiles.length > 0" class="space-y-2">
|
|
<div v-for="(f, idx) in draftFiles" :key="f.path" class="rounded-lg border border-white/[0.06] overflow-hidden">
|
|
<!-- File header -->
|
|
<div class="flex items-center gap-2 px-3 py-2 bg-white/[0.02] hover:bg-white/[0.04] transition-colors group">
|
|
<button v-if="f.kind === 'inline'" type="button" @click="f.expanded = !f.expanded" class="shrink-0 text-gray-600 hover:text-gray-400 transition-colors">
|
|
<svg :class="['h-3.5 w-3.5 transition-transform', f.expanded ? 'rotate-90' : '']" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
|
</svg>
|
|
</button>
|
|
<svg v-else class="h-3.5 w-3.5 shrink-0 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
|
</svg>
|
|
<span class="flex-1 text-sm text-gray-400 truncate font-mono">{{ f.path }}</span>
|
|
<span v-if="f.kind === 'inline'" class="shrink-0 text-[10px] text-gray-600 tabular-nums">{{ f.content.split('\n').length }} lines</span>
|
|
<span v-else class="shrink-0 text-xs text-gray-600">{{ formatSize(f.size) }}</span>
|
|
<span class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium" :class="f.kind === 'inline' ? 'bg-[var(--color-accent-500)]/10 text-[var(--color-accent-500)]' : 'bg-white/[0.06] text-gray-500'">{{ f.kind === 'inline' ? 'text' : 'binary' }}</span>
|
|
<button
|
|
type="button"
|
|
@click="removeDraftFile(idx)"
|
|
class="shrink-0 opacity-0 group-hover:opacity-100 rounded p-1 text-gray-600 hover:text-red-400 hover:bg-red-400/10 transition-all"
|
|
>
|
|
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<!-- Inline editor -->
|
|
<div v-if="f.kind === 'inline' && f.expanded" class="border-t border-white/[0.06]">
|
|
<textarea
|
|
v-model="f.content"
|
|
rows="8"
|
|
:placeholder="filePlaceholder(f.path)"
|
|
class="w-full bg-[var(--color-surface-100)] px-4 py-3 font-mono text-xs text-white placeholder-gray-600 focus:outline-none resize-y leading-relaxed"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p v-else class="text-xs text-gray-600">{{ isHooksType ? 'No scripts yet. Create or upload executable scripts for your hook commands.' : 'No extra files yet. Create scripts, docs, or upload assets.' }}</p>
|
|
</div>
|
|
|
|
<!-- Format badge (edit mode) -->
|
|
<div v-if="mode === 'edit' && initialFormat === 'folder'" class="flex items-center gap-2">
|
|
<span class="rounded-full px-2.5 py-0.5 text-[11px] font-medium bg-white/[0.06] text-gray-400 border border-white/[0.06]">folder</span>
|
|
<span class="text-xs text-gray-600">Folder resource. Manage sub-files below.</span>
|
|
</div>
|
|
|
|
<!-- Type-specific fields -->
|
|
<template v-for="field in typeFields" :key="field.key">
|
|
<!-- Group toggles on same row -->
|
|
<template v-if="field.type === 'toggle'">
|
|
<!-- Toggles are grouped below -->
|
|
</template>
|
|
<!-- hooks-config uses dedicated editor -->
|
|
<template v-else-if="field.key === 'hooks-config'">
|
|
<!-- Rendered separately below -->
|
|
</template>
|
|
<FieldRenderer
|
|
v-else
|
|
:field="field"
|
|
:modelValue="fieldValues[field.key]"
|
|
@update:modelValue="fieldValues[field.key] = $event"
|
|
:tools="availableTools"
|
|
:models="availableModels"
|
|
:skills="availableSkills"
|
|
/>
|
|
</template>
|
|
|
|
<!-- Hook config visual editor (hooks type only) -->
|
|
<HookConfigEditor
|
|
v-if="hasHooksConfig"
|
|
:modelValue="(fieldValues['hooks-config'] as string) || ''"
|
|
@update:modelValue="fieldValues['hooks-config'] = $event"
|
|
:tools="availableTools"
|
|
/>
|
|
|
|
<!-- Scripts section for hooks (after hook config) -->
|
|
<div v-if="mode === 'create' && !isFork && format === 'folder' && isHooksType" class="rounded-xl border border-white/[0.08] bg-[var(--color-surface-50)] p-4 space-y-4">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-xs font-medium text-gray-400">Scripts</p>
|
|
<p class="text-[11px] text-gray-600 mt-0.5">Add executable scripts that your hook commands reference.</p>
|
|
</div>
|
|
<div class="flex gap-1.5">
|
|
<button
|
|
type="button"
|
|
@click="draftAddMode = draftAddMode === 'create' ? '' : 'create'"
|
|
:class="['inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all',
|
|
draftAddMode === 'create'
|
|
? 'bg-[var(--color-accent-500)]/15 border-[var(--color-accent-500)]/30 text-[var(--color-accent-400)]'
|
|
: 'bg-white/[0.06] border-white/[0.06] text-gray-400 hover:text-white hover:bg-white/[0.1]']"
|
|
>
|
|
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
|
|
</svg>
|
|
Create
|
|
</button>
|
|
<button
|
|
type="button"
|
|
@click="draftAddMode = draftAddMode === 'upload' ? '' : 'upload'"
|
|
:class="['inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all',
|
|
draftAddMode === 'upload'
|
|
? 'bg-[var(--color-accent-500)]/15 border-[var(--color-accent-500)]/30 text-[var(--color-accent-400)]'
|
|
: 'bg-white/[0.06] border-white/[0.06] text-gray-400 hover:text-white hover:bg-white/[0.1]']"
|
|
>
|
|
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
|
|
</svg>
|
|
Upload
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create inline file -->
|
|
<div v-if="draftAddMode === 'create'" class="rounded-lg border border-white/[0.08] bg-[var(--color-surface-100)] p-3 space-y-3">
|
|
<div class="flex items-end gap-3">
|
|
<div class="flex-1">
|
|
<label class="block text-xs font-medium text-gray-500 mb-1">File name</label>
|
|
<input
|
|
v-model="draftFileName"
|
|
type="text"
|
|
placeholder="lint.sh"
|
|
class="w-full rounded-lg border border-white/[0.06] bg-[var(--color-surface-50)] px-3 py-2 text-sm font-mono text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none transition-all"
|
|
@keydown.enter.prevent="addDraftInline"
|
|
/>
|
|
</div>
|
|
<button type="button" @click="addDraftInline" :disabled="!draftFileName.trim()" class="rounded-lg bg-[var(--color-accent-500)] px-4 py-2 text-xs font-semibold text-white hover:bg-[var(--color-accent-600)] disabled:opacity-50 transition-all">Add</button>
|
|
</div>
|
|
<p class="text-[11px] text-gray-600">Scripts are saved to <code class="text-gray-500">scripts/</code> and can be referenced in hook commands.</p>
|
|
</div>
|
|
|
|
<!-- Upload file -->
|
|
<div v-if="draftAddMode === 'upload'" class="rounded-lg border border-white/[0.08] bg-[var(--color-surface-100)] p-3">
|
|
<div class="flex items-end gap-3">
|
|
<div class="flex-1">
|
|
<label class="block text-xs font-medium text-gray-500 mb-1">File</label>
|
|
<input
|
|
type="file"
|
|
@change="addDraftUpload"
|
|
class="w-full text-sm text-gray-400 file:mr-2 file:rounded-lg file:border-0 file:bg-white/[0.06] file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-gray-300 hover:file:bg-white/[0.1] file:cursor-pointer file:transition-all"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p class="text-[11px] text-gray-600 mt-2">Text files (.sh, .md, .py...) become editable inline. Binary files are stored as-is.</p>
|
|
</div>
|
|
|
|
<!-- Files list -->
|
|
<div v-if="draftFiles.length > 0" class="space-y-2">
|
|
<div v-for="(f, idx) in draftFiles" :key="f.path" class="rounded-lg border border-white/[0.06] overflow-hidden">
|
|
<div class="flex items-center gap-2 px-3 py-2 bg-white/[0.02] hover:bg-white/[0.04] transition-colors group">
|
|
<button v-if="f.kind === 'inline'" type="button" @click="f.expanded = !f.expanded" class="shrink-0 text-gray-600 hover:text-gray-400 transition-colors">
|
|
<svg :class="['h-3.5 w-3.5 transition-transform', f.expanded ? 'rotate-90' : '']" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
|
</svg>
|
|
</button>
|
|
<svg v-else class="h-3.5 w-3.5 shrink-0 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
|
</svg>
|
|
<span class="flex-1 text-sm text-gray-400 truncate font-mono">{{ f.path }}</span>
|
|
<span v-if="f.kind === 'inline'" class="shrink-0 text-[10px] text-gray-600 tabular-nums">{{ f.content.split('\n').length }} lines</span>
|
|
<span v-else class="shrink-0 text-xs text-gray-600">{{ formatSize(f.size) }}</span>
|
|
<button
|
|
type="button"
|
|
@click="removeDraftFile(idx)"
|
|
class="shrink-0 opacity-0 group-hover:opacity-100 rounded p-1 text-gray-600 hover:text-red-400 hover:bg-red-400/10 transition-all"
|
|
>
|
|
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div v-if="f.kind === 'inline' && f.expanded" class="border-t border-white/[0.06]">
|
|
<textarea
|
|
v-model="f.content"
|
|
rows="8"
|
|
:placeholder="filePlaceholder(f.path)"
|
|
class="w-full bg-[var(--color-surface-100)] px-4 py-3 font-mono text-xs text-white placeholder-gray-600 focus:outline-none resize-y leading-relaxed"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p v-else class="text-xs text-gray-600">No scripts yet. Create or upload executable scripts for your hook commands.</p>
|
|
</div>
|
|
|
|
<!-- Toggle fields grouped in a row -->
|
|
<div v-if="toggleFields.length > 0" class="flex flex-wrap gap-6">
|
|
<FieldRenderer
|
|
v-for="field in toggleFields"
|
|
:key="field.key"
|
|
:field="field"
|
|
:modelValue="fieldValues[field.key]"
|
|
@update:modelValue="fieldValues[field.key] = $event"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Body + Preview (hidden for hooks — Claude only reads hooks.json) -->
|
|
<div v-if="!isHooksType" class="grid gap-4 lg:grid-cols-2">
|
|
<div>
|
|
<label class="flex justify-between text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">
|
|
<span>{{ isClaudeMdType ? 'Template Content' : 'Body' }}</span>
|
|
<span :class="bodyLines > 400 ? 'text-amber-500' : ''">{{ bodyLines }}/500 lines</span>
|
|
</label>
|
|
<textarea
|
|
v-model="body"
|
|
rows="20"
|
|
:placeholder="bodyPlaceholder"
|
|
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-3 font-mono text-sm text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all resize-y leading-relaxed"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<p class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">Preview</p>
|
|
<div
|
|
class="skill-prose rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] p-5 min-h-[20rem] overflow-auto"
|
|
v-html="previewHtml"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- File Manager (edit mode, folder format) -->
|
|
<FileManager
|
|
v-if="mode === 'edit' && initialFormat === 'folder' && slug"
|
|
:resourceType="resourceType"
|
|
:slug="slug"
|
|
/>
|
|
|
|
<div class="flex items-center gap-4 pt-2">
|
|
<button
|
|
type="submit"
|
|
:disabled="saving || (isFork && slugMatchesOriginal)"
|
|
:title="isFork && slugMatchesOriginal ? 'Change the name to generate a different slug' : ''"
|
|
class="inline-flex items-center gap-2 rounded-xl bg-[var(--color-accent-500)] px-6 py-2.5 text-sm font-semibold text-white shadow-lg shadow-[var(--color-accent-500)]/20 hover:bg-[var(--color-accent-600)] hover:shadow-[var(--color-accent-500)]/30 disabled:opacity-50 active:scale-[0.97] transition-all"
|
|
>
|
|
<svg v-if="saving" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
{{ saving ? 'Saving...' : (isFork ? 'Create Fork' : (mode === 'create' ? `Create ${typeSingular}` : 'Save Changes')) }}
|
|
</button>
|
|
|
|
<a href="/" class="text-sm text-gray-600 hover:text-gray-300 transition-colors">Cancel</a>
|
|
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
|
</div>
|
|
</form>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, reactive } from 'vue';
|
|
import { marked } from 'marked';
|
|
import FieldRenderer from './FieldRenderer.vue';
|
|
import FileManager from './FileManager.vue';
|
|
import HookConfigEditor from './HookConfigEditor.vue';
|
|
|
|
interface FieldDef {
|
|
key: string;
|
|
label: string;
|
|
type: 'text' | 'select' | 'toggle-grid' | 'toggle' | 'number' | 'json' | 'tags';
|
|
placeholder?: string;
|
|
hint?: string;
|
|
options?: Array<{ value: string; label: string }>;
|
|
dynamicOptions?: 'tools' | 'models';
|
|
defaultValue?: unknown;
|
|
}
|
|
|
|
const props = defineProps<{
|
|
resourceType: string;
|
|
typeSingular: string;
|
|
typeFields: FieldDef[];
|
|
mode: 'create' | 'edit';
|
|
slug?: string;
|
|
forkOf?: string;
|
|
initialName?: string;
|
|
initialDescription?: string;
|
|
initialTags?: string;
|
|
initialBody?: string;
|
|
initialAuthor?: string;
|
|
initialAuthorEmail?: string;
|
|
initialFormat?: 'file' | 'folder';
|
|
/** JSON-serialized initial field values for type-specific fields */
|
|
initialFieldValues?: string;
|
|
availableTools?: string[];
|
|
availableModels?: Array<{ id: string; display_name: string }>;
|
|
availableSkills?: string[];
|
|
availableTags?: string;
|
|
}>();
|
|
|
|
const isFork = computed(() => Boolean(props.forkOf));
|
|
const forceFolderType = computed(() => forceFolderTypes.has(props.resourceType));
|
|
const hasHooksConfig = computed(() => props.typeFields.some(f => f.key === 'hooks-config'));
|
|
const isHooksType = computed(() => props.resourceType === 'hooks');
|
|
const isClaudeMdType = computed(() => props.resourceType === 'claude-md');
|
|
const forceFileType = computed(() => forceFileTypes.has(props.resourceType));
|
|
|
|
const name = ref(props.initialName || '');
|
|
const description = ref(props.initialDescription || '');
|
|
const body = ref(props.initialBody || '');
|
|
const saving = ref(false);
|
|
const error = ref('');
|
|
const forceFolderTypes = new Set(['hooks']);
|
|
const forceFileTypes = new Set(['claude-md']);
|
|
const format = ref<'file' | 'folder'>(
|
|
forceFolderTypes.has(props.resourceType) ? 'folder' :
|
|
forceFileTypes.has(props.resourceType) ? 'file' :
|
|
(props.initialFormat || 'file')
|
|
);
|
|
|
|
// Draft files for folder creation (held in memory until save)
|
|
interface DraftFile {
|
|
path: string;
|
|
kind: 'inline' | 'upload';
|
|
content: string; // text content for inline files
|
|
file: File | null; // binary for uploads
|
|
size: number;
|
|
expanded: boolean; // UI: is the editor expanded
|
|
}
|
|
|
|
const draftFiles = ref<DraftFile[]>([]);
|
|
const draftDir = ref('scripts');
|
|
const draftFileName = ref('');
|
|
const draftAddMode = ref<'' | 'create' | 'upload'>('');
|
|
|
|
const TEXT_EXTENSIONS = new Set([
|
|
'md', 'txt', 'sh', 'bash', 'zsh', 'py', 'js', 'ts', 'json', 'yaml', 'yml',
|
|
'toml', 'xml', 'html', 'css', 'sql', 'rb', 'go', 'rs', 'lua', 'conf', 'cfg', 'ini',
|
|
]);
|
|
|
|
function isTextFile(name: string): boolean {
|
|
const ext = name.split('.').pop()?.toLowerCase() || '';
|
|
return TEXT_EXTENSIONS.has(ext);
|
|
}
|
|
|
|
function addDraftInline() {
|
|
const fname = draftFileName.value.trim();
|
|
if (!fname) return;
|
|
const path = `${draftDir.value}/${fname}`;
|
|
// Replace if same path
|
|
draftFiles.value = draftFiles.value.filter(f => f.path !== path);
|
|
draftFiles.value.push({ path, kind: 'inline', content: '', file: null, size: 0, expanded: true });
|
|
draftFileName.value = '';
|
|
draftAddMode.value = '';
|
|
}
|
|
|
|
function addDraftUpload(e: Event) {
|
|
const input = e.target as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
if (!file) return;
|
|
const path = `${draftDir.value}/${file.name}`;
|
|
draftFiles.value = draftFiles.value.filter(f => f.path !== path);
|
|
if (isTextFile(file.name)) {
|
|
// Read as text so it's editable
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
draftFiles.value.push({
|
|
path, kind: 'inline', content: reader.result as string, file: null,
|
|
size: file.size, expanded: false,
|
|
});
|
|
};
|
|
reader.readAsText(file);
|
|
} else {
|
|
draftFiles.value.push({ path, kind: 'upload', content: '', file, size: file.size, expanded: false });
|
|
}
|
|
input.value = '';
|
|
draftAddMode.value = '';
|
|
}
|
|
|
|
function removeDraftFile(idx: number) {
|
|
draftFiles.value.splice(idx, 1);
|
|
}
|
|
|
|
function formatSize(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
function draftFileToBlob(df: DraftFile): Blob {
|
|
if (df.kind === 'upload' && df.file) return df.file;
|
|
return new Blob([df.content], { type: 'text/plain' });
|
|
}
|
|
|
|
function filePlaceholder(filePath: string): string {
|
|
const dir = filePath.split('/')[0];
|
|
const fname = filePath.split('/').pop() || '';
|
|
if (dir === 'scripts') {
|
|
return `#!/usr/bin/env bash\n# Executable code that Claude runs via hooks or tool calls.\n# Example: a linter wrapper, a code generator, a deploy helper.\n\necho "Running ${fname}..."`;
|
|
}
|
|
if (dir === 'references') {
|
|
return `# ${fname}\n\nReference documentation that Claude reads for context.\nPut API docs, style guides, architecture notes, or\nany material Claude should consult while using this skill.`;
|
|
}
|
|
if (dir === 'assets') {
|
|
return `# ${fname}\n\nStatic resources: templates, config files, schemas,\nor any files the skill copies into the project.\nExample: a .eslintrc template, a Dockerfile, a JSON schema.`;
|
|
}
|
|
return `Write ${fname} content...`;
|
|
}
|
|
|
|
// Fork author fields
|
|
const forkAuthorName = ref('');
|
|
const forkAuthorEmail = ref('');
|
|
|
|
// Load token from localStorage
|
|
const authorToken = ref(
|
|
typeof localStorage !== 'undefined'
|
|
? localStorage.getItem('grimoired-token') || ''
|
|
: ''
|
|
);
|
|
|
|
// Tags
|
|
const tags = ref<string[]>(
|
|
props.initialTags
|
|
? props.initialTags.split(',').map(t => t.trim()).filter(Boolean)
|
|
: []
|
|
);
|
|
const tagQuery = ref('');
|
|
const tagSuggestionsOpen = ref(false);
|
|
const tagInputEl = ref<HTMLInputElement>();
|
|
const knownTags = props.availableTags
|
|
? props.availableTags.split(',').map(t => t.trim()).filter(Boolean)
|
|
: [];
|
|
|
|
const tagSuggestions = computed(() => {
|
|
const q = tagQuery.value.toLowerCase().trim();
|
|
const current = new Set(tags.value.map(t => t.toLowerCase()));
|
|
const matches = knownTags.filter(t => !current.has(t.toLowerCase()) && (!q || t.toLowerCase().includes(q)));
|
|
if (q && !current.has(q) && !matches.some(m => m.toLowerCase() === q)) {
|
|
matches.push(q);
|
|
}
|
|
return matches;
|
|
});
|
|
|
|
const isNewTag = (tag: string) => !knownTags.some(t => t.toLowerCase() === tag.toLowerCase());
|
|
|
|
function addTag(tag: string) {
|
|
const normalized = tag.trim().toLowerCase();
|
|
if (normalized && !tags.value.some(t => t.toLowerCase() === normalized)) {
|
|
tags.value.push(normalized);
|
|
}
|
|
tagQuery.value = '';
|
|
tagInputEl.value?.focus();
|
|
}
|
|
|
|
function removeTag(idx: number) {
|
|
tags.value.splice(idx, 1);
|
|
}
|
|
|
|
function onTagKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Enter' || e.key === ',') {
|
|
e.preventDefault();
|
|
if (tagQuery.value.trim()) addTag(tagQuery.value);
|
|
} else if (e.key === 'Backspace' && !tagQuery.value && tags.value.length) {
|
|
tags.value.pop();
|
|
}
|
|
}
|
|
|
|
let tagBlurTimer: ReturnType<typeof setTimeout>;
|
|
function onTagBlur() {
|
|
tagBlurTimer = setTimeout(() => { tagSuggestionsOpen.value = false; }, 200);
|
|
}
|
|
function onTagSuggestionClick(tag: string) {
|
|
clearTimeout(tagBlurTimer);
|
|
addTag(tag);
|
|
}
|
|
|
|
// Type-specific field values
|
|
const parsedInitial = props.initialFieldValues ? JSON.parse(props.initialFieldValues) : {};
|
|
|
|
const fieldValues = reactive<Record<string, unknown>>({});
|
|
for (const field of props.typeFields) {
|
|
fieldValues[field.key] = parsedInitial[field.key] ?? field.defaultValue ?? (
|
|
field.type === 'toggle' ? false :
|
|
field.type === 'toggle-grid' ? [] :
|
|
field.type === 'tags' ? [] :
|
|
''
|
|
);
|
|
}
|
|
|
|
// Separate toggles from other fields for layout grouping
|
|
const toggleFields = computed(() => props.typeFields.filter(f => f.type === 'toggle'));
|
|
|
|
const mainFileName = computed(() => {
|
|
const map: Record<string, string> = {
|
|
skills: 'SKILL.md',
|
|
agents: 'AGENT.md',
|
|
'output-styles': 'OUTPUT-STYLE.md',
|
|
rules: 'RULE.md',
|
|
hooks: 'HOOK.md',
|
|
'claude-md': 'CLAUDE-MD.md',
|
|
};
|
|
return map[props.resourceType] || 'MAIN.md';
|
|
});
|
|
|
|
const bodyPlaceholder = computed(() => {
|
|
const placeholders: Record<string, string> = {
|
|
skills: '# My Skill\n\nInstructions for Claude...',
|
|
agents: '# My Agent\n\nAgent system prompt...',
|
|
'output-styles': '# Output Style\n\nFormatting instructions...',
|
|
rules: '# Rule\n\nRule content...',
|
|
hooks: '# My Hook\n\nDocumentation for this hook.\nDescribe what it does, when it triggers, and any setup needed.',
|
|
'claude-md': '## Project Conventions\n\n- Use TypeScript strict mode\n- Prefer functional components\n- Run tests before committing',
|
|
};
|
|
return placeholders[props.resourceType] || '# Content\n\nInstructions...';
|
|
});
|
|
|
|
// Slug
|
|
const computedSlug = computed(() => {
|
|
if (props.mode === 'edit' && props.slug) return props.slug;
|
|
return name.value
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 64) || 'my-resource';
|
|
});
|
|
|
|
const slugMatchesOriginal = computed(() => {
|
|
if (!props.forkOf) return false;
|
|
return computedSlug.value === props.forkOf;
|
|
});
|
|
|
|
const bodyLines = computed(() => body.value.split('\n').length);
|
|
|
|
// Preview
|
|
let previewHtml = ref('');
|
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
|
|
|
watch(body, (val) => {
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = setTimeout(async () => {
|
|
previewHtml.value = await marked(val || '');
|
|
}, 300);
|
|
}, { immediate: true });
|
|
|
|
// Build frontmatter content
|
|
function buildContent(): string {
|
|
const lines: string[] = ['---'];
|
|
|
|
lines.push(`name: ${name.value}`);
|
|
if (description.value) lines.push(`description: ${description.value}`);
|
|
|
|
if (isFork.value) {
|
|
if (forkAuthorName.value) lines.push(`author: ${forkAuthorName.value}`);
|
|
if (forkAuthorEmail.value) lines.push(`author-email: ${forkAuthorEmail.value}`);
|
|
lines.push(`fork-of: ${props.forkOf}`);
|
|
} else {
|
|
if (props.initialAuthor) lines.push(`author: ${props.initialAuthor}`);
|
|
if (props.initialAuthorEmail) lines.push(`author-email: ${props.initialAuthorEmail}`);
|
|
}
|
|
|
|
if (tags.value.length > 0) lines.push(`tags: ${tags.value.join(', ')}`);
|
|
|
|
// Type-specific fields (skip hooks-config — stored in hooks.json, not frontmatter)
|
|
for (const field of props.typeFields) {
|
|
if (field.key === 'hooks-config') continue;
|
|
const val = fieldValues[field.key];
|
|
|
|
if (field.type === 'toggle') {
|
|
// Only write non-default values
|
|
const def = field.defaultValue ?? false;
|
|
if (val !== def) {
|
|
lines.push(`${field.key}: ${val}`);
|
|
}
|
|
} else if (field.type === 'toggle-grid') {
|
|
const arr = Array.isArray(val) ? val : [];
|
|
if (arr.length > 0) lines.push(`${field.key}: ${arr.join(', ')}`);
|
|
} else if (field.type === 'tags') {
|
|
const arr = Array.isArray(val) ? val : [];
|
|
if (arr.length > 0) lines.push(`${field.key}: ${arr.join(', ')}`);
|
|
} else if (field.type === 'json') {
|
|
const str = typeof val === 'string' ? val.trim() : '';
|
|
if (str) {
|
|
try {
|
|
const parsed = JSON.parse(str);
|
|
lines.push(`${field.key}: ${JSON.stringify(parsed)}`);
|
|
} catch { /* skip invalid JSON */ }
|
|
}
|
|
} else if (field.type === 'number') {
|
|
const num = typeof val === 'string' ? val.trim() : String(val || '');
|
|
if (num) lines.push(`${field.key}: ${num}`);
|
|
} else {
|
|
// text, select
|
|
const str = typeof val === 'string' ? val : '';
|
|
if (str) lines.push(`${field.key}: ${str}`);
|
|
}
|
|
}
|
|
|
|
lines.push('---');
|
|
return lines.join('\n') + '\n\n' + body.value.trim() + '\n';
|
|
}
|
|
|
|
async function saveHooksConfig(slug: string) {
|
|
const raw = typeof fieldValues['hooks-config'] === 'string' ? fieldValues['hooks-config'] as string : '';
|
|
const json = raw.trim() || '{}';
|
|
// Validate JSON
|
|
try { JSON.parse(json); } catch { return; }
|
|
const headers: Record<string, string> = {};
|
|
if (authorToken.value) headers['Authorization'] = `Bearer ${authorToken.value}`;
|
|
await fetch(`/api/resources/${props.resourceType}/${slug}/files/hooks.json`, {
|
|
method: 'PUT',
|
|
headers,
|
|
body: new Blob([json + '\n'], { type: 'application/json' }),
|
|
});
|
|
}
|
|
|
|
async function save() {
|
|
saving.value = true;
|
|
error.value = '';
|
|
|
|
try {
|
|
const content = buildContent();
|
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
|
|
if (!isFork.value && authorToken.value) {
|
|
headers['Authorization'] = `Bearer ${authorToken.value}`;
|
|
}
|
|
|
|
const apiBase = `/api/resources/${props.resourceType}`;
|
|
|
|
if (props.mode === 'create') {
|
|
const res = await fetch(apiBase, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify({ slug: computedSlug.value, content, format: format.value }),
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json();
|
|
throw new Error(data.error || `Failed to create ${props.typeSingular.toLowerCase()}`);
|
|
}
|
|
|
|
// Save hooks.json for hooks type
|
|
if (props.resourceType === 'hooks') {
|
|
await saveHooksConfig(computedSlug.value);
|
|
}
|
|
|
|
// Upload draft files if folder format
|
|
if (format.value === 'folder' && draftFiles.value.length > 0) {
|
|
const filesApi = `/api/resources/${props.resourceType}/${computedSlug.value}/files`;
|
|
const uploadHeaders: Record<string, string> = {};
|
|
if (authorToken.value) uploadHeaders['Authorization'] = `Bearer ${authorToken.value}`;
|
|
for (const df of draftFiles.value) {
|
|
const formData = new FormData();
|
|
const blob = draftFileToBlob(df);
|
|
formData.append('file', blob, df.path.split('/').pop());
|
|
formData.append('path', df.path);
|
|
await fetch(filesApi, { method: 'POST', headers: uploadHeaders, body: formData });
|
|
}
|
|
}
|
|
|
|
window.location.href = `/${props.resourceType}/${computedSlug.value}`;
|
|
} else {
|
|
const res = await fetch(`${apiBase}/${props.slug}`, {
|
|
method: 'PUT',
|
|
headers,
|
|
body: JSON.stringify({ content }),
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json();
|
|
throw new Error(data.error || `Failed to update ${props.typeSingular.toLowerCase()}`);
|
|
}
|
|
|
|
// Save hooks.json for hooks type
|
|
if (props.resourceType === 'hooks') {
|
|
await saveHooksConfig(props.slug!);
|
|
}
|
|
|
|
window.location.href = `/${props.resourceType}/${props.slug}`;
|
|
}
|
|
} catch (err) {
|
|
error.value = err instanceof Error ? err.message : 'Something went wrong';
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
</script>
|