Rename to Grimoired, update domain to grimoi.red, add resource system
- Rename Grimaired -> Grimoired everywhere (title, nav, descriptions, token keys) - Update domain from skills.here.run.place to grimoi.red - Add Grimoired logo with description on homepage - Add accordion behavior for Quick install / Quick push sections - Add generic resource system (skills, agents, output-styles, rules) - Add resource registry, editor, search, and file manager components
This commit is contained in:
committed by
Alejandro Martinez
parent
aa477a553b
commit
17423fb3b9
@@ -9,12 +9,15 @@ FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/data/skills ./data/skills
|
||||
COPY --from=build /app/data ./data
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
ENV SKILLS_DIR=/app/data/skills
|
||||
ENV SITE_URL=https://skills.here.run.place
|
||||
ENV AGENTS_DIR=/app/data/agents
|
||||
ENV OUTPUT_STYLES_DIR=/app/data/output-styles
|
||||
ENV RULES_DIR=/app/data/rules
|
||||
ENV SITE_URL=https://grimoi.red
|
||||
|
||||
EXPOSE 4321
|
||||
CMD ["node", "./dist/server/entry.mjs"]
|
||||
|
||||
@@ -4,7 +4,7 @@ import vue from '@astrojs/vue';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
site: process.env.SITE_URL || 'https://skills.here.run.place',
|
||||
site: process.env.SITE_URL || 'https://grimoi.red',
|
||||
output: 'server',
|
||||
adapter: node({ mode: 'standalone' }),
|
||||
integrations: [vue()],
|
||||
|
||||
18
grimoired.svg
Normal file
18
grimoired.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 12 KiB |
18
public/grimoired.svg
Normal file
18
public/grimoired.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 12 KiB |
@@ -11,13 +11,13 @@
|
||||
{{ deleting ? 'Deleting...' : 'Delete' }}
|
||||
</button>
|
||||
|
||||
<!-- Token modal for protected skills -->
|
||||
<!-- Token modal for protected resources -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showModal = false">
|
||||
<div class="w-full max-w-md rounded-2xl border border-white/[0.08] bg-[var(--color-surface-200)] p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-semibold text-red-400 mb-1">Delete Skill</h3>
|
||||
<h3 class="text-lg font-semibold text-red-400 mb-1">Delete Resource</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
This skill is owned by <strong class="text-gray-300">{{ authorName || authorEmail }}</strong>. Enter your token to delete it.
|
||||
This resource is owned by <strong class="text-gray-300">{{ authorName || authorEmail }}</strong>. Enter your token to delete it.
|
||||
</p>
|
||||
|
||||
<form @submit.prevent="verifyAndDelete">
|
||||
@@ -65,8 +65,11 @@ const props = defineProps<{
|
||||
authorEmail?: string;
|
||||
authorName?: string;
|
||||
authorHasToken?: boolean;
|
||||
resourceType?: string;
|
||||
}>();
|
||||
|
||||
const type = props.resourceType || 'skills';
|
||||
|
||||
const deleting = ref(false);
|
||||
const showModal = ref(false);
|
||||
const token = ref('');
|
||||
@@ -75,8 +78,7 @@ const tokenInput = ref<HTMLInputElement>();
|
||||
|
||||
async function handleClick() {
|
||||
if (props.authorEmail && props.authorHasToken) {
|
||||
// Try saved token first
|
||||
const saved = localStorage.getItem('skillshere-token') || '';
|
||||
const saved = localStorage.getItem('grimoired-token') || '';
|
||||
if (saved) {
|
||||
try {
|
||||
const res = await fetch('/api/auth/verify', {
|
||||
@@ -100,7 +102,6 @@ async function handleClick() {
|
||||
}
|
||||
|
||||
async function verifyAndDelete() {
|
||||
// Verify token first
|
||||
error.value = '';
|
||||
deleting.value = true;
|
||||
|
||||
@@ -123,7 +124,7 @@ async function verifyAndDelete() {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('skillshere-token', token.value);
|
||||
localStorage.setItem('grimoired-token', token.value);
|
||||
doDelete(token.value);
|
||||
}
|
||||
|
||||
@@ -142,7 +143,7 @@ async function doDelete(authToken: string) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/skills/${props.slug}`, { method: 'DELETE', headers });
|
||||
const res = await fetch(`/api/resources/${type}/${props.slug}`, { method: 'DELETE', headers });
|
||||
if (res.status === 403) {
|
||||
const data = await res.json();
|
||||
error.value = data.error || 'Permission denied';
|
||||
@@ -156,7 +157,7 @@ async function doDelete(authToken: string) {
|
||||
}
|
||||
window.location.href = '/';
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to delete skill.';
|
||||
error.value = err instanceof Error ? err.message : 'Failed to delete.';
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="w-full max-w-md rounded-2xl border border-white/[0.08] bg-[var(--color-surface-200)] p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Author Verification</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
This skill is owned by <strong class="text-gray-300">{{ authorName || authorEmail }}</strong>. Enter your token to edit.
|
||||
This resource is owned by <strong class="text-gray-300">{{ authorName || authorEmail }}</strong>. Enter your token to edit.
|
||||
</p>
|
||||
|
||||
<form @submit.prevent="verify">
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="forkSkill"
|
||||
@click="forkResource"
|
||||
class="text-sm text-[var(--color-accent-400)] hover:text-[var(--color-accent-300)] transition-colors"
|
||||
>
|
||||
Fork instead
|
||||
@@ -72,8 +72,11 @@ const props = defineProps<{
|
||||
authorEmail?: string;
|
||||
authorName?: string;
|
||||
authorHasToken?: boolean;
|
||||
resourceType?: string;
|
||||
}>();
|
||||
|
||||
const type = props.resourceType || 'skills';
|
||||
|
||||
const showModal = ref(false);
|
||||
const token = ref('');
|
||||
const error = ref('');
|
||||
@@ -82,12 +85,11 @@ const tokenInput = ref<HTMLInputElement>();
|
||||
|
||||
async function handleClick() {
|
||||
if (!props.authorEmail || !props.authorHasToken) {
|
||||
window.location.href = `/${props.slug}/edit`;
|
||||
window.location.href = `/${type}/${props.slug}/edit`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Try saved token first
|
||||
const saved = localStorage.getItem('skillshere-token') || '';
|
||||
const saved = localStorage.getItem('grimoired-token') || '';
|
||||
if (saved) {
|
||||
try {
|
||||
const res = await fetch('/api/auth/verify', {
|
||||
@@ -96,8 +98,8 @@ async function handleClick() {
|
||||
body: JSON.stringify({ email: props.authorEmail, token: saved }),
|
||||
});
|
||||
if (res.ok) {
|
||||
localStorage.setItem('skillshere-token', saved);
|
||||
window.location.href = `/${props.slug}/edit`;
|
||||
localStorage.setItem('grimoired-token', saved);
|
||||
window.location.href = `/${type}/${props.slug}/edit`;
|
||||
return;
|
||||
}
|
||||
} catch { /* fall through to modal */ }
|
||||
@@ -126,9 +128,8 @@ async function verify() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store token for the editor to use
|
||||
localStorage.setItem('skillshere-token', token.value);
|
||||
window.location.href = `/${props.slug}/edit`;
|
||||
localStorage.setItem('grimoired-token', token.value);
|
||||
window.location.href = `/${type}/${props.slug}/edit`;
|
||||
} catch {
|
||||
error.value = 'Could not verify token';
|
||||
} finally {
|
||||
@@ -136,8 +137,8 @@ async function verify() {
|
||||
}
|
||||
}
|
||||
|
||||
function forkSkill() {
|
||||
function forkResource() {
|
||||
showModal.value = false;
|
||||
window.location.href = `/new?from=${encodeURIComponent(props.slug)}`;
|
||||
window.location.href = `/${type}/new?from=${encodeURIComponent(props.slug)}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
267
src/components/FieldRenderer.vue
Normal file
267
src/components/FieldRenderer.vue
Normal file
@@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<!-- Text input -->
|
||||
<div v-if="field.type === 'text'">
|
||||
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">{{ field.label }}</label>
|
||||
<input
|
||||
:value="modelValue"
|
||||
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
type="text"
|
||||
:placeholder="field.placeholder || ''"
|
||||
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 v-if="field.hint" class="mt-1.5 text-xs text-gray-600">{{ field.hint }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Number input -->
|
||||
<div v-else-if="field.type === 'number'">
|
||||
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">{{ field.label }}</label>
|
||||
<input
|
||||
:value="modelValue"
|
||||
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
type="number"
|
||||
:placeholder="field.placeholder || ''"
|
||||
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 v-if="field.hint" class="mt-1.5 text-xs text-gray-600">{{ field.hint }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Select -->
|
||||
<div v-else-if="field.type === 'select'">
|
||||
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">{{ field.label }}</label>
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
|
||||
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-2.5 text-sm text-white focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all"
|
||||
>
|
||||
<option v-if="field.dynamicOptions === 'models'" value="">Default</option>
|
||||
<option v-for="opt in resolvedOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
<p v-if="field.hint" class="mt-1.5 text-xs text-gray-600">{{ field.hint }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Toggle -->
|
||||
<div v-else-if="field.type === 'toggle'">
|
||||
<label class="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="Boolean(modelValue)"
|
||||
@change="$emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div class="h-5 w-9 rounded-full bg-white/[0.06] border border-white/[0.06] peer-checked:bg-[var(--color-accent-500)] peer-checked:border-[var(--color-accent-500)] relative transition-all after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:h-4 after:w-4 after:rounded-full after:bg-gray-400 after:transition-all peer-checked:after:translate-x-4 peer-checked:after:bg-white"></div>
|
||||
<span class="text-xs text-gray-500 group-hover:text-gray-300 transition-colors">
|
||||
{{ field.label }}
|
||||
<span v-if="field.hint" class="text-gray-600">({{ field.hint }})</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Toggle grid (tools, etc) -->
|
||||
<div v-else-if="field.type === 'toggle-grid'">
|
||||
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">{{ field.label }}</label>
|
||||
<div class="flex flex-wrap gap-1.5 rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-3 py-2.5 min-h-[42px]">
|
||||
<button
|
||||
v-for="opt in gridOptions"
|
||||
:key="opt"
|
||||
type="button"
|
||||
@click="toggleGridItem(opt)"
|
||||
:class="[
|
||||
'rounded-md px-2.5 py-1 text-xs font-medium transition-all',
|
||||
selectedSet.has(opt)
|
||||
? 'bg-[var(--color-accent-500)] text-white shadow-sm'
|
||||
: 'bg-white/[0.04] border border-white/[0.06] text-gray-500 hover:text-gray-300 hover:bg-white/[0.08]'
|
||||
]"
|
||||
>
|
||||
{{ opt }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="field.hint" class="mt-1.5 text-xs text-gray-600">{{ field.hint }}</p>
|
||||
</div>
|
||||
|
||||
<!-- JSON editor -->
|
||||
<div v-else-if="field.type === 'json'">
|
||||
<details class="group">
|
||||
<summary class="text-xs font-medium uppercase tracking-wider text-gray-500 cursor-pointer hover:text-gray-400 transition-colors">{{ field.label }} (advanced)</summary>
|
||||
<div class="mt-3">
|
||||
<textarea
|
||||
:value="modelValue"
|
||||
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
||||
rows="4"
|
||||
:placeholder="field.placeholder || ''"
|
||||
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-3 font-mono text-xs text-white placeholder-gray-700 focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all resize-y"
|
||||
/>
|
||||
<p v-if="field.hint" class="mt-1.5 text-xs text-gray-600">{{ field.hint }}</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Tags input -->
|
||||
<div v-else-if="field.type === 'tags'" class="relative">
|
||||
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">{{ field.label }}</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="tagInputRef?.focus()"
|
||||
>
|
||||
<span
|
||||
v-for="(tag, i) in tagsList"
|
||||
: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="removeTagItem(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="tagInputRef"
|
||||
v-model="tagInput"
|
||||
type="text"
|
||||
:placeholder="field.placeholder || 'Add item...'"
|
||||
class="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-gray-600 outline-none"
|
||||
@keydown="onTagKeydown"
|
||||
@focus="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"
|
||||
>
|
||||
{{ s }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="field.hint" class="mt-1.5 text-xs text-gray-600">{{ field.hint }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from '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' | 'skills';
|
||||
defaultValue?: unknown;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
field: FieldDef;
|
||||
modelValue: unknown;
|
||||
tools?: string[];
|
||||
models?: Array<{ id: string; display_name: string }>;
|
||||
skills?: string[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: unknown];
|
||||
}>();
|
||||
|
||||
// --- Select options ---
|
||||
const resolvedOptions = computed(() => {
|
||||
if (props.field.dynamicOptions === 'models' && props.models) {
|
||||
return props.models.map(m => ({ value: m.id, label: m.display_name }));
|
||||
}
|
||||
return props.field.options || [];
|
||||
});
|
||||
|
||||
// --- Toggle grid ---
|
||||
const gridOptions = computed(() => {
|
||||
if (props.field.dynamicOptions === 'tools' && props.tools) {
|
||||
return props.tools;
|
||||
}
|
||||
return (props.field.options || []).map(o => o.value);
|
||||
});
|
||||
|
||||
const selectedSet = computed(() => {
|
||||
const val = props.modelValue;
|
||||
if (val instanceof Set) return val as Set<string>;
|
||||
if (Array.isArray(val)) return new Set(val as string[]);
|
||||
if (typeof val === 'string') return new Set(val.split(',').map(t => t.trim()).filter(Boolean));
|
||||
return new Set<string>();
|
||||
});
|
||||
|
||||
function toggleGridItem(item: string) {
|
||||
const current = new Set(selectedSet.value);
|
||||
if (current.has(item)) {
|
||||
current.delete(item);
|
||||
} else {
|
||||
current.add(item);
|
||||
}
|
||||
emit('update:modelValue', [...current]);
|
||||
}
|
||||
|
||||
// --- Tags input ---
|
||||
const tagInput = ref('');
|
||||
const tagInputRef = ref<HTMLInputElement>();
|
||||
const tagSuggestionsOpen = ref(false);
|
||||
|
||||
const tagsList = computed(() => {
|
||||
const val = props.modelValue;
|
||||
if (Array.isArray(val)) return val as string[];
|
||||
if (typeof val === 'string') return val.split(',').map(t => t.trim()).filter(Boolean);
|
||||
return [];
|
||||
});
|
||||
|
||||
const knownTagOptions = computed(() => {
|
||||
if (props.field.dynamicOptions === 'skills' && props.skills) return props.skills;
|
||||
return [];
|
||||
});
|
||||
|
||||
const tagSuggestions = computed(() => {
|
||||
if (knownTagOptions.value.length === 0) return [];
|
||||
const q = tagInput.value.toLowerCase().trim();
|
||||
const current = new Set(tagsList.value.map(t => t.toLowerCase()));
|
||||
return knownTagOptions.value.filter(t =>
|
||||
!current.has(t.toLowerCase()) && (!q || t.toLowerCase().includes(q))
|
||||
);
|
||||
});
|
||||
|
||||
function addTagItem(val: string) {
|
||||
const trimmed = val.trim();
|
||||
if (trimmed && !tagsList.value.includes(trimmed)) {
|
||||
emit('update:modelValue', [...tagsList.value, trimmed]);
|
||||
}
|
||||
tagInput.value = '';
|
||||
tagInputRef.value?.focus();
|
||||
}
|
||||
|
||||
function removeTagItem(idx: number) {
|
||||
const list = [...tagsList.value];
|
||||
list.splice(idx, 1);
|
||||
emit('update:modelValue', list);
|
||||
}
|
||||
|
||||
let tagBlurTimer: ReturnType<typeof setTimeout>;
|
||||
function onTagBlur() {
|
||||
tagBlurTimer = setTimeout(() => { tagSuggestionsOpen.value = false; }, 200);
|
||||
}
|
||||
function onTagSuggestionClick(tag: string) {
|
||||
clearTimeout(tagBlurTimer);
|
||||
addTagItem(tag);
|
||||
}
|
||||
|
||||
function onTagKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
addTagItem(tagInput.value);
|
||||
} else if (e.key === 'Backspace' && !tagInput.value && tagsList.value.length) {
|
||||
const list = [...tagsList.value];
|
||||
list.pop();
|
||||
emit('update:modelValue', list);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
357
src/components/FileManager.vue
Normal file
357
src/components/FileManager.vue
Normal file
@@ -0,0 +1,357 @@
|
||||
<template>
|
||||
<div class="rounded-xl border border-white/[0.06] bg-[var(--color-surface-50)] p-4 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-white">Files</h3>
|
||||
<div class="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
@click="addMode = addMode === 'create' ? '' : 'create'"
|
||||
:class="['inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all',
|
||||
addMode === '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="addMode = addMode === 'upload' ? '' : 'upload'"
|
||||
:class="['inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all',
|
||||
addMode === '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="addMode === '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>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">Directory</label>
|
||||
<select v-model="dir" 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="newFileName"
|
||||
type="text"
|
||||
placeholder="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="createInlineFile"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" @click="createInlineFile" :disabled="!newFileName.trim() || saving" 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">Create</button>
|
||||
</div>
|
||||
<p 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>
|
||||
<span v-if="createError" class="text-xs text-red-400">{{ createError }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Upload file -->
|
||||
<div v-if="addMode === 'upload'" 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>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">Directory</label>
|
||||
<select v-model="dir" 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
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
@change="onFileSelected"
|
||||
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>
|
||||
<button type="button" @click="uploadFile" :disabled="!selectedFile || saving" 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">{{ saving ? 'Uploading...' : 'Upload' }}</button>
|
||||
</div>
|
||||
<span v-if="uploadError" class="text-xs text-red-400">{{ uploadError }}</span>
|
||||
</div>
|
||||
|
||||
<!-- File list -->
|
||||
<div v-if="fileList.length > 0" class="space-y-2">
|
||||
<div v-for="f in fileList" :key="f.relativePath" 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="isTextFile(f.relativePath)" type="button" @click="toggleExpand(f.relativePath)" class="shrink-0 text-gray-600 hover:text-gray-400 transition-colors">
|
||||
<svg :class="['h-3.5 w-3.5 transition-transform', expanded[f.relativePath] ? '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.relativePath }}</span>
|
||||
<span class="shrink-0 text-xs text-gray-600">{{ formatSize(f.size) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="deleteFile(f.relativePath)"
|
||||
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"
|
||||
title="Delete file"
|
||||
>
|
||||
<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 for text files -->
|
||||
<div v-if="isTextFile(f.relativePath) && expanded[f.relativePath]" class="border-t border-white/[0.06]">
|
||||
<div v-if="fileContents[f.relativePath] === undefined" class="px-4 py-3 text-xs text-gray-600">Loading...</div>
|
||||
<template v-else>
|
||||
<textarea
|
||||
v-model="fileContents[f.relativePath]"
|
||||
rows="10"
|
||||
:placeholder="filePlaceholder(f.relativePath)"
|
||||
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 class="flex items-center gap-2 px-3 py-2 border-t border-white/[0.06] bg-white/[0.02]">
|
||||
<button
|
||||
type="button"
|
||||
@click="saveFileContent(f.relativePath)"
|
||||
:disabled="savingFile === f.relativePath"
|
||||
class="rounded-lg bg-[var(--color-accent-500)] px-3 py-1 text-xs font-semibold text-white hover:bg-[var(--color-accent-600)] disabled:opacity-50 transition-all"
|
||||
>{{ savingFile === f.relativePath ? 'Saving...' : 'Save' }}</button>
|
||||
<span v-if="fileSaveStatus[f.relativePath]" class="text-xs" :class="fileSaveStatus[f.relativePath] === 'saved' ? 'text-emerald-400' : 'text-red-400'">
|
||||
{{ fileSaveStatus[f.relativePath] === 'saved' ? 'Saved' : fileSaveStatus[f.relativePath] }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-gray-600">No files yet. Create scripts, docs, or upload assets.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
|
||||
interface FileEntry {
|
||||
relativePath: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const BINARY_EXTENSIONS = new Set([
|
||||
'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'svg', 'bmp', 'tiff',
|
||||
'zip', 'gz', 'tar', 'bz2', 'xz', '7z', 'rar',
|
||||
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
|
||||
'woff', 'woff2', 'ttf', 'otf', 'eot',
|
||||
'mp3', 'mp4', 'wav', 'ogg', 'webm', 'avi', 'mov',
|
||||
'exe', 'dll', 'so', 'dylib', 'bin', 'dat', 'wasm',
|
||||
]);
|
||||
|
||||
function isTextFile(name: string): boolean {
|
||||
const ext = name.split('.').pop()?.toLowerCase() || '';
|
||||
return !BINARY_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
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...`;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
resourceType: string;
|
||||
slug: string;
|
||||
}>();
|
||||
|
||||
const fileList = ref<FileEntry[]>([]);
|
||||
const addMode = ref<'' | 'create' | 'upload'>('');
|
||||
const dir = ref('scripts');
|
||||
const newFileName = ref('');
|
||||
const selectedFile = ref<File | null>(null);
|
||||
const saving = ref(false);
|
||||
const uploadError = ref('');
|
||||
const createError = ref('');
|
||||
const fileInput = ref<HTMLInputElement>();
|
||||
|
||||
// Inline editing state
|
||||
const expanded = reactive<Record<string, boolean>>({});
|
||||
const fileContents = reactive<Record<string, string>>({});
|
||||
const savingFile = ref('');
|
||||
const fileSaveStatus = reactive<Record<string, string>>({});
|
||||
|
||||
const apiBase = `/api/resources/${props.resourceType}/${props.slug}/files`;
|
||||
|
||||
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 getAuthHeaders(): Record<string, string> {
|
||||
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('grimoired-token') || '' : '';
|
||||
if (token) return { 'Authorization': `Bearer ${token}` };
|
||||
return {};
|
||||
}
|
||||
|
||||
async function loadFiles() {
|
||||
try {
|
||||
const res = await fetch(apiBase);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
fileList.value = data.files || [];
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function toggleExpand(path: string) {
|
||||
if (expanded[path]) {
|
||||
expanded[path] = false;
|
||||
return;
|
||||
}
|
||||
expanded[path] = true;
|
||||
// Load content if not yet loaded
|
||||
if (fileContents[path] === undefined) {
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/${path}`);
|
||||
if (res.ok) {
|
||||
fileContents[path] = await res.text();
|
||||
} else {
|
||||
fileContents[path] = '';
|
||||
}
|
||||
} catch {
|
||||
fileContents[path] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFileContent(path: string) {
|
||||
savingFile.value = path;
|
||||
delete fileSaveStatus[path];
|
||||
try {
|
||||
const body = new Blob([fileContents[path]], { type: 'text/plain' });
|
||||
const res = await fetch(`${apiBase}/${path}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'Save failed');
|
||||
}
|
||||
fileSaveStatus[path] = 'saved';
|
||||
setTimeout(() => { delete fileSaveStatus[path]; }, 2000);
|
||||
await loadFiles();
|
||||
} catch (err) {
|
||||
fileSaveStatus[path] = err instanceof Error ? err.message : 'Save failed';
|
||||
} finally {
|
||||
savingFile.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function createInlineFile() {
|
||||
const fname = newFileName.value.trim();
|
||||
if (!fname) return;
|
||||
saving.value = true;
|
||||
createError.value = '';
|
||||
const path = `${dir.value}/${fname}`;
|
||||
try {
|
||||
const body = new Blob([''], { type: 'text/plain' });
|
||||
const res = await fetch(`${apiBase}/${path}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'Create failed');
|
||||
}
|
||||
newFileName.value = '';
|
||||
addMode.value = '';
|
||||
await loadFiles();
|
||||
// Auto-expand the new file
|
||||
fileContents[path] = '';
|
||||
expanded[path] = true;
|
||||
} catch (err) {
|
||||
createError.value = err instanceof Error ? err.message : 'Create failed';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onFileSelected(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
selectedFile.value = input.files?.[0] || null;
|
||||
}
|
||||
|
||||
async function uploadFile() {
|
||||
if (!selectedFile.value) return;
|
||||
saving.value = true;
|
||||
uploadError.value = '';
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile.value);
|
||||
formData.append('path', `${dir.value}/${selectedFile.value.name}`);
|
||||
|
||||
const res = await fetch(apiBase, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
|
||||
selectedFile.value = null;
|
||||
if (fileInput.value) fileInput.value.value = '';
|
||||
addMode.value = '';
|
||||
await loadFiles();
|
||||
} catch (err) {
|
||||
uploadError.value = err instanceof Error ? err.message : 'Upload failed';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFile(relativePath: string) {
|
||||
if (!confirm(`Delete ${relativePath}?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/${relativePath}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'Delete failed');
|
||||
}
|
||||
delete expanded[relativePath];
|
||||
delete fileContents[relativePath];
|
||||
await loadFiles();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Delete failed');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadFiles);
|
||||
</script>
|
||||
98
src/components/FolderTree.astro
Normal file
98
src/components/FolderTree.astro
Normal file
@@ -0,0 +1,98 @@
|
||||
---
|
||||
interface FileEntry {
|
||||
relativePath: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
files: FileEntry[];
|
||||
slug: string;
|
||||
type: string;
|
||||
mainFileName: string;
|
||||
mainFileSize: number;
|
||||
}
|
||||
|
||||
const { files, slug, type, mainFileName, mainFileSize } = Astro.props;
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
const DIR_HINTS: Record<string, string> = {
|
||||
scripts: 'executable code',
|
||||
references: 'docs & context',
|
||||
assets: 'templates & files',
|
||||
};
|
||||
|
||||
// Group files by top-level directory
|
||||
const groups: Record<string, FileEntry[]> = {};
|
||||
for (const f of files) {
|
||||
const dir = f.relativePath.split('/')[0];
|
||||
if (!groups[dir]) groups[dir] = [];
|
||||
groups[dir].push(f);
|
||||
}
|
||||
---
|
||||
|
||||
<div class="space-y-2">
|
||||
<!-- Main file -->
|
||||
<button data-file-main class="tree-item active flex items-center gap-2 py-1 text-sm w-full text-left rounded-md px-1.5 -mx-1.5">
|
||||
<svg class="h-4 w-4 shrink-0 text-[var(--color-accent-500)]" 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="font-medium text-[var(--color-accent-400)]">{mainFileName}</span>
|
||||
<span class="text-xs text-gray-600">{formatSize(mainFileSize)}</span>
|
||||
<span class="rounded px-1.5 py-0.5 text-[10px] font-medium bg-[var(--color-accent-500)]/10 text-[var(--color-accent-500)]">main</span>
|
||||
</button>
|
||||
|
||||
<!-- Subdirectory groups -->
|
||||
{Object.entries(groups).map(([dir, entries]) => (
|
||||
<details open class="group">
|
||||
<summary class="flex items-center gap-2 cursor-pointer select-none text-sm text-gray-400 hover:text-gray-200 transition-colors py-1">
|
||||
<svg class="h-4 w-4 text-gray-600 group-open:rotate-90 transition-transform" 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>
|
||||
<svg class="h-4 w-4 text-[var(--color-accent-500)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" />
|
||||
</svg>
|
||||
<span class="font-medium">{dir}/</span>
|
||||
{DIR_HINTS[dir] && <span class="text-xs text-gray-600">— {DIR_HINTS[dir]}</span>}
|
||||
<span class="text-xs text-gray-600 ml-auto">{entries.length} file{entries.length !== 1 ? 's' : ''}</span>
|
||||
</summary>
|
||||
<ul class="ml-6 mt-1 space-y-0.5 border-l border-white/[0.06] pl-3">
|
||||
{entries.map((f) => {
|
||||
const fileName = f.relativePath.split('/').slice(1).join('/');
|
||||
const downloadUrl = `/api/resources/${type}/${slug}/files/${f.relativePath}`;
|
||||
return (
|
||||
<li>
|
||||
<button data-file-path={f.relativePath} class="tree-item flex items-center gap-2 py-0.5 text-sm w-full text-left rounded-md px-1.5 -mx-1.5">
|
||||
<svg 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="text-gray-400 truncate" title={f.relativePath}>{fileName}</span>
|
||||
<span class="shrink-0 text-xs text-gray-600">{formatSize(f.size)}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tree-item {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.tree-item:hover {
|
||||
background: rgba(255,255,255,0.04);
|
||||
}
|
||||
.tree-item.active {
|
||||
background: rgba(255,255,255,0.06);
|
||||
}
|
||||
.tree-item.active span:first-of-type {
|
||||
color: var(--color-accent-400);
|
||||
}
|
||||
</style>
|
||||
106
src/components/ResourceCard.astro
Normal file
106
src/components/ResourceCard.astro
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
interface Props {
|
||||
resourceType: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tags?: string[];
|
||||
author?: string;
|
||||
forkCount?: number;
|
||||
downloads?: number;
|
||||
pushes?: number;
|
||||
lastPushedAt?: string | null;
|
||||
typeLabel: string;
|
||||
typeColor: string;
|
||||
/** For skills: allowed tools badges */
|
||||
tools?: string[];
|
||||
}
|
||||
|
||||
const {
|
||||
resourceType,
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
tags = [],
|
||||
author,
|
||||
forkCount = 0,
|
||||
downloads = 0,
|
||||
pushes = 0,
|
||||
lastPushedAt,
|
||||
typeLabel,
|
||||
typeColor,
|
||||
tools = [],
|
||||
} = Astro.props;
|
||||
|
||||
const updatedLabel = lastPushedAt ? new Date(lastPushedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : null;
|
||||
const truncated = description.length > 120 ? description.slice(0, 120) + '...' : description;
|
||||
---
|
||||
|
||||
<a
|
||||
href={`/${resourceType}/${slug}`}
|
||||
class="group relative block rounded-2xl border border-white/[0.06] bg-surface-100 p-6 hover:border-accent-500/30 hover:bg-surface-200/80 transition-all duration-300"
|
||||
>
|
||||
<div class="absolute inset-0 rounded-2xl bg-gradient-to-br from-accent-500/[0.03] to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
<div class="relative">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded-full px-2 py-0.5 text-[10px] font-semibold" style={`background: ${typeColor}20; color: ${typeColor}; border: 1px solid ${typeColor}40;`}>
|
||||
{typeLabel}
|
||||
</span>
|
||||
<h2 class="text-[15px] font-semibold text-white group-hover:text-accent-400 transition-colors">{name}</h2>
|
||||
</div>
|
||||
<svg class="h-4 w-4 text-gray-600 group-hover:text-accent-500 group-hover:translate-x-0.5 transition-all shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</div>
|
||||
{truncated && <p class="text-sm text-gray-500 leading-relaxed mb-3">{truncated}</p>}
|
||||
{tools.length > 0 && (
|
||||
<div class="flex flex-wrap gap-1.5 mb-3">
|
||||
{tools.map((tool) => (
|
||||
<span class="rounded-md bg-white/[0.04] border border-white/[0.06] px-2 py-0.5 text-xs font-medium text-gray-400">
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{tags.length > 0 && (
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{tags.map((tag) => (
|
||||
<span class="rounded-full bg-[var(--color-accent-500)]/10 border border-[var(--color-accent-500)]/20 px-2.5 py-0.5 text-[11px] font-medium text-[var(--color-accent-400)]">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div class="flex items-center gap-3 mt-3">
|
||||
{author && <p class="text-xs text-gray-600">by {author}</p>}
|
||||
{forkCount > 0 && (
|
||||
<span class="inline-flex items-center gap-1 text-xs text-gray-600">
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0-12.814a2.25 2.25 0 1 0 0-2.186m0 2.186a2.25 2.25 0 1 0 0 2.186" />
|
||||
</svg>
|
||||
{forkCount}
|
||||
</span>
|
||||
)}
|
||||
{downloads > 0 && (
|
||||
<span class="inline-flex items-center gap-1 text-xs text-gray-600">
|
||||
<svg class="h-3 w-3" 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.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
{downloads}
|
||||
</span>
|
||||
)}
|
||||
{pushes > 0 && (
|
||||
<span class="inline-flex items-center gap-1 text-xs text-gray-600">
|
||||
<svg class="h-3 w-3" 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>
|
||||
{pushes}
|
||||
</span>
|
||||
)}
|
||||
{updatedLabel && (
|
||||
<span class="text-[11px] text-gray-600">{updatedLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
732
src/components/ResourceEditor.vue
Normal file
732
src/components/ResourceEditor.vue
Normal file
@@ -0,0 +1,732 @@
|
||||
<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) -->
|
||||
<div v-if="mode === 'create' && !isFork">
|
||||
<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) -->
|
||||
<div v-if="mode === 'create' && !isFork && format === 'folder'" 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">Folder files</p>
|
||||
<p class="text-[11px] text-gray-600 mt-0.5"><code class="text-gray-500">{{ computedSlug }}/{{ mainFileName }}</code> is generated from the body. Add scripts, docs, or assets here.</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>
|
||||
<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="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 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>
|
||||
</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>
|
||||
<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">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>
|
||||
<FieldRenderer
|
||||
v-else
|
||||
:field="field"
|
||||
:modelValue="fieldValues[field.key]"
|
||||
@update:modelValue="fieldValues[field.key] = $event"
|
||||
:tools="availableTools"
|
||||
:models="availableModels"
|
||||
:skills="availableSkills"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 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 -->
|
||||
<div 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>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';
|
||||
|
||||
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 name = ref(props.initialName || '');
|
||||
const description = ref(props.initialDescription || '');
|
||||
const body = ref(props.initialBody || '');
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const format = ref<'file' | 'folder'>(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',
|
||||
};
|
||||
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...',
|
||||
};
|
||||
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
|
||||
for (const field of props.typeFields) {
|
||||
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 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()}`);
|
||||
}
|
||||
|
||||
// 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()}`);
|
||||
}
|
||||
window.location.href = `/${props.resourceType}/${props.slug}`;
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Something went wrong';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
402
src/components/ResourceSearch.vue
Normal file
402
src/components/ResourceSearch.vue
Normal file
@@ -0,0 +1,402 @@
|
||||
<template>
|
||||
<div class="mb-6 space-y-4">
|
||||
<!-- Type tabs -->
|
||||
<div class="flex items-center gap-1 rounded-xl border border-white/[0.06] p-1 w-fit">
|
||||
<button
|
||||
v-for="tab in typeTabs"
|
||||
:key="tab.value"
|
||||
@click="setTypeFilter(tab.value)"
|
||||
:class="[
|
||||
'rounded-lg px-3.5 py-1.5 text-sm font-medium transition-all',
|
||||
typeFilter === tab.value
|
||||
? 'bg-white/[0.08] text-white'
|
||||
: 'text-gray-500 hover:text-gray-300 hover:bg-white/[0.03]'
|
||||
]"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span class="ml-1 text-xs text-gray-600">{{ tab.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters row -->
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<!-- Text search -->
|
||||
<div class="w-full sm:w-auto sm:flex-1 sm:min-w-[180px]">
|
||||
<label class="block text-xs font-medium uppercase tracking-wider text-gray-600 mb-1">Search</label>
|
||||
<div class="relative">
|
||||
<svg class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="query"
|
||||
type="text"
|
||||
placeholder="Name, description, tools..."
|
||||
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] pl-10 pr-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>
|
||||
|
||||
<!-- Author filter -->
|
||||
<div class="w-[calc(50%-6px)] sm:w-auto sm:flex-1 sm:min-w-[160px] relative">
|
||||
<label class="block text-xs font-medium uppercase tracking-wider text-gray-600 mb-1">Authors</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] focus-within:border-[var(--color-accent-500)]/50 focus-within:ring-1 focus-within:ring-[var(--color-accent-500)]/20 transition-all">
|
||||
<span
|
||||
v-for="a in selectedAuthors"
|
||||
:key="a"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-[var(--color-accent-500)]/15 text-[var(--color-accent-400)] px-2.5 py-0.5 text-xs font-medium"
|
||||
>
|
||||
{{ a }}
|
||||
<button @click="removeAuthor(a)" class="hover:text-white transition-colors">×</button>
|
||||
</span>
|
||||
<input
|
||||
ref="authorInputEl"
|
||||
v-model="authorQuery"
|
||||
type="text"
|
||||
:placeholder="selectedAuthors.length ? '' : 'Filter by author...'"
|
||||
@focus="authorOpen = true"
|
||||
@click="authorOpen = true"
|
||||
@input="authorOpen = true"
|
||||
@blur="onAuthorBlur"
|
||||
@keydown.enter.prevent="onAuthorEnter"
|
||||
@keydown.backspace="onAuthorBackspace"
|
||||
class="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-gray-600 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="authorOpen && authorSuggestions.length > 0"
|
||||
class="absolute z-20 mt-1 w-full max-h-48 overflow-y-auto rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] shadow-xl"
|
||||
>
|
||||
<button
|
||||
v-for="a in authorSuggestions"
|
||||
:key="a"
|
||||
@mousedown.prevent="addAuthor(a)"
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-400 hover:bg-white/[0.06] hover:text-white transition-colors"
|
||||
>
|
||||
{{ a }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tag filter -->
|
||||
<div class="w-[calc(50%-6px)] sm:w-auto sm:flex-1 sm:min-w-[160px] relative">
|
||||
<label class="block text-xs font-medium uppercase tracking-wider text-gray-600 mb-1">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] focus-within:border-[var(--color-accent-500)]/50 focus-within:ring-1 focus-within:ring-[var(--color-accent-500)]/20 transition-all">
|
||||
<span
|
||||
v-for="t in selectedTags"
|
||||
:key="t"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-[var(--color-accent-500)]/15 text-[var(--color-accent-400)] px-2.5 py-0.5 text-xs font-medium"
|
||||
>
|
||||
{{ t }}
|
||||
<button @click="removeTag(t)" class="hover:text-white transition-colors">×</button>
|
||||
</span>
|
||||
<input
|
||||
ref="tagInputEl"
|
||||
v-model="tagQuery"
|
||||
type="text"
|
||||
:placeholder="selectedTags.length ? '' : 'Filter by tag...'"
|
||||
@focus="tagOpen = true"
|
||||
@click="tagOpen = true"
|
||||
@input="tagOpen = true"
|
||||
@blur="onTagBlur"
|
||||
@keydown.enter.prevent="onTagEnter"
|
||||
@keydown.backspace="onTagBackspace"
|
||||
class="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-gray-600 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="tagOpen && tagSuggestions.length > 0"
|
||||
class="absolute z-20 mt-1 w-full max-h-48 overflow-y-auto rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] shadow-xl"
|
||||
>
|
||||
<button
|
||||
v-for="t in tagSuggestions"
|
||||
:key="t"
|
||||
@mousedown.prevent="addTag(t)"
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-400 hover:bg-white/[0.06] hover:text-white transition-colors"
|
||||
>
|
||||
{{ t }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset -->
|
||||
<button
|
||||
v-if="hasActiveFilters"
|
||||
@click="reset"
|
||||
class="rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-2.5 text-sm text-gray-500 hover:text-white hover:bg-white/[0.06] transition-all"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
|
||||
<!-- View toggle -->
|
||||
<div class="flex rounded-xl border border-white/[0.06] overflow-hidden">
|
||||
<button
|
||||
@click="setView('grid')"
|
||||
:class="['px-3 py-2.5 transition-all', viewMode === 'grid' ? 'bg-white/[0.08] text-white' : 'text-gray-600 hover:text-gray-400 hover:bg-white/[0.03]']"
|
||||
title="Grid view"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="setView('table')"
|
||||
:class="['px-3 py-2.5 transition-all', viewMode === 'table' ? 'bg-white/[0.08] text-white' : 'text-gray-600 hover:text-gray-400 hover:bg-white/[0.03]']"
|
||||
title="Table view"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 0 1 0 3.75H5.625a1.875 1.875 0 0 1 0-3.75Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination controls -->
|
||||
<div v-if="totalPages > 1" class="flex flex-wrap items-center justify-between gap-3">
|
||||
<span class="text-sm text-gray-500">
|
||||
Showing {{ rangeStart }}–{{ rangeEnd }} of {{ filteredCount }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
@click="goToPage(currentPage - 1)"
|
||||
:disabled="currentPage === 1"
|
||||
class="rounded-lg border border-white/[0.06] px-3 py-1.5 text-sm transition-all disabled:opacity-30 disabled:cursor-not-allowed text-gray-400 hover:text-white hover:bg-white/[0.06]"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
v-for="p in visiblePages"
|
||||
:key="p"
|
||||
@click="goToPage(p)"
|
||||
:class="['rounded-lg px-3 py-1.5 text-sm transition-all', p === currentPage ? 'bg-[var(--color-accent-500)] text-white font-medium' : 'border border-white/[0.06] text-gray-400 hover:text-white hover:bg-white/[0.06]']"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
<button
|
||||
@click="goToPage(currentPage + 1)"
|
||||
:disabled="currentPage === totalPages"
|
||||
class="rounded-lg border border-white/[0.06] px-3 py-1.5 text-sm transition-all disabled:opacity-30 disabled:cursor-not-allowed text-gray-400 hover:text-white hover:bg-white/[0.06]"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onMounted } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
authors?: string;
|
||||
tags?: string;
|
||||
totalCount?: number;
|
||||
typeCounts?: string; // JSON: { skills: N, agents: N, ... }
|
||||
}>();
|
||||
|
||||
const authorList = props.authors
|
||||
? props.authors.split(',').map(a => a.trim()).filter(Boolean)
|
||||
: [];
|
||||
const tagList = props.tags
|
||||
? props.tags.split(',').map(t => t.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
const counts = props.typeCounts ? JSON.parse(props.typeCounts) as Record<string, number> : {};
|
||||
const totalAll = props.totalCount || 0;
|
||||
|
||||
const typeTabs = computed(() => [
|
||||
{ value: '', label: 'All', count: totalAll },
|
||||
{ value: 'skills', label: 'Skills', count: counts.skills || 0 },
|
||||
{ value: 'agents', label: 'Agents', count: counts.agents || 0 },
|
||||
{ value: 'output-styles', label: 'Output Styles', count: counts['output-styles'] || 0 },
|
||||
{ value: 'rules', label: 'Rules', count: counts.rules || 0 },
|
||||
]);
|
||||
|
||||
const query = ref('');
|
||||
const typeFilter = ref('');
|
||||
const viewMode = ref<'grid' | 'table'>('grid');
|
||||
const currentPage = ref(1);
|
||||
const filteredCount = ref(totalAll);
|
||||
|
||||
const PER_PAGE_GRID = 12;
|
||||
const PER_PAGE_TABLE = 20;
|
||||
|
||||
const perPage = computed(() => viewMode.value === 'grid' ? PER_PAGE_GRID : PER_PAGE_TABLE);
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredCount.value / perPage.value)));
|
||||
|
||||
const rangeStart = computed(() => filteredCount.value === 0 ? 0 : (currentPage.value - 1) * perPage.value + 1);
|
||||
const rangeEnd = computed(() => Math.min(currentPage.value * perPage.value, filteredCount.value));
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const pages: number[] = [];
|
||||
const total = totalPages.value;
|
||||
const cur = currentPage.value;
|
||||
const maxVisible = 7;
|
||||
if (total <= maxVisible) {
|
||||
for (let i = 1; i <= total; i++) pages.push(i);
|
||||
} else {
|
||||
const half = Math.floor(maxVisible / 2);
|
||||
let start = Math.max(1, cur - half);
|
||||
let end = start + maxVisible - 1;
|
||||
if (end > total) {
|
||||
end = total;
|
||||
start = end - maxVisible + 1;
|
||||
}
|
||||
for (let i = start; i <= end; i++) pages.push(i);
|
||||
}
|
||||
return pages;
|
||||
});
|
||||
|
||||
// --- Author pills ---
|
||||
const selectedAuthors = ref<string[]>([]);
|
||||
const authorQuery = ref('');
|
||||
const authorOpen = ref(false);
|
||||
const authorInputEl = ref<HTMLInputElement>();
|
||||
|
||||
const authorSuggestions = computed(() => {
|
||||
const q = authorQuery.value.toLowerCase().trim();
|
||||
const selected = new Set(selectedAuthors.value.map(a => a.toLowerCase()));
|
||||
return authorList.filter(a => !selected.has(a.toLowerCase()) && (!q || a.toLowerCase().includes(q)));
|
||||
});
|
||||
|
||||
let authorBlurTimer: ReturnType<typeof setTimeout>;
|
||||
function onAuthorBlur() { authorBlurTimer = setTimeout(() => { authorOpen.value = false; }, 200); }
|
||||
function addAuthor(author: string) {
|
||||
clearTimeout(authorBlurTimer);
|
||||
if (!selectedAuthors.value.some(a => a.toLowerCase() === author.toLowerCase())) {
|
||||
selectedAuthors.value.push(author);
|
||||
}
|
||||
authorQuery.value = '';
|
||||
authorInputEl.value?.focus();
|
||||
}
|
||||
function removeAuthor(author: string) { selectedAuthors.value = selectedAuthors.value.filter(a => a !== author); }
|
||||
function onAuthorEnter() { if (authorSuggestions.value.length > 0) addAuthor(authorSuggestions.value[0]); }
|
||||
function onAuthorBackspace() { if (!authorQuery.value && selectedAuthors.value.length > 0) selectedAuthors.value.pop(); }
|
||||
|
||||
// --- Tag pills ---
|
||||
const selectedTags = ref<string[]>([]);
|
||||
const tagQuery = ref('');
|
||||
const tagOpen = ref(false);
|
||||
const tagInputEl = ref<HTMLInputElement>();
|
||||
|
||||
const tagSuggestions = computed(() => {
|
||||
const q = tagQuery.value.toLowerCase().trim();
|
||||
const selected = new Set(selectedTags.value.map(t => t.toLowerCase()));
|
||||
return tagList.filter(t => !selected.has(t.toLowerCase()) && (!q || t.toLowerCase().includes(q)));
|
||||
});
|
||||
|
||||
let tagBlurTimer: ReturnType<typeof setTimeout>;
|
||||
function onTagBlur() { tagBlurTimer = setTimeout(() => { tagOpen.value = false; }, 200); }
|
||||
function addTag(tag: string) {
|
||||
clearTimeout(tagBlurTimer);
|
||||
if (!selectedTags.value.some(t => t.toLowerCase() === tag.toLowerCase())) {
|
||||
selectedTags.value.push(tag);
|
||||
}
|
||||
tagQuery.value = '';
|
||||
tagInputEl.value?.focus();
|
||||
}
|
||||
function removeTag(tag: string) { selectedTags.value = selectedTags.value.filter(t => t !== tag); }
|
||||
function onTagEnter() { if (tagSuggestions.value.length > 0) addTag(tagSuggestions.value[0]); }
|
||||
function onTagBackspace() { if (!tagQuery.value && selectedTags.value.length > 0) selectedTags.value.pop(); }
|
||||
|
||||
// --- View toggle ---
|
||||
function setView(mode: 'grid' | 'table') {
|
||||
viewMode.value = mode;
|
||||
localStorage.setItem('skillsViewMode', mode);
|
||||
const grid = document.getElementById('resources-grid');
|
||||
const table = document.getElementById('resources-table');
|
||||
if (grid && table) {
|
||||
grid.classList.toggle('hidden', mode !== 'grid');
|
||||
table.classList.toggle('hidden', mode !== 'table');
|
||||
}
|
||||
currentPage.value = 1;
|
||||
nextTick(() => applyFilters());
|
||||
}
|
||||
|
||||
function setTypeFilter(type: string) {
|
||||
typeFilter.value = type;
|
||||
currentPage.value = 1;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const saved = localStorage.getItem('skillsViewMode') as 'grid' | 'table' | null;
|
||||
if (saved === 'table') setView('table');
|
||||
});
|
||||
|
||||
// --- Filtering + Pagination ---
|
||||
const hasActiveFilters = computed(() =>
|
||||
query.value || selectedAuthors.value.length > 0 || selectedTags.value.length > 0 || typeFilter.value
|
||||
);
|
||||
|
||||
function applyFilters() {
|
||||
const q = query.value.toLowerCase().trim();
|
||||
const authors = selectedAuthors.value.map(a => a.toLowerCase());
|
||||
const tags = selectedTags.value.map(t => t.toLowerCase());
|
||||
const tf = typeFilter.value;
|
||||
|
||||
const activeId = viewMode.value === 'grid' ? 'resources-grid' : 'resources-table';
|
||||
const inactiveId = viewMode.value === 'grid' ? 'resources-table' : 'resources-grid';
|
||||
const activeItems = Array.from(document.querySelectorAll<HTMLElement>(`#${activeId} [data-resource]`));
|
||||
const inactiveItems = document.querySelectorAll<HTMLElement>(`#${inactiveId} [data-resource]`);
|
||||
|
||||
inactiveItems.forEach(el => el.style.display = 'none');
|
||||
|
||||
const matching: HTMLElement[] = [];
|
||||
activeItems.forEach((card) => {
|
||||
const name = card.dataset.name || '';
|
||||
const desc = card.dataset.description || '';
|
||||
const tools = card.dataset.tools || '';
|
||||
const cardAuthor = card.dataset.author || '';
|
||||
const cardTags = (card.dataset.tags || '').split(',').filter(Boolean);
|
||||
const cardType = card.dataset.type || '';
|
||||
|
||||
const matchText = !q || name.includes(q) || desc.includes(q) || tools.includes(q) || cardTags.some(t => t.includes(q));
|
||||
const matchAuthor = authors.length === 0 || authors.some(a => cardAuthor.includes(a));
|
||||
const matchTag = tags.length === 0 || tags.every(t => cardTags.includes(t));
|
||||
const matchType = !tf || cardType === tf;
|
||||
|
||||
if (matchText && matchAuthor && matchTag && matchType) {
|
||||
matching.push(card);
|
||||
}
|
||||
});
|
||||
|
||||
filteredCount.value = matching.length;
|
||||
|
||||
const maxPage = Math.max(1, Math.ceil(matching.length / perPage.value));
|
||||
if (currentPage.value > maxPage) currentPage.value = maxPage;
|
||||
|
||||
const start = (currentPage.value - 1) * perPage.value;
|
||||
const end = start + perPage.value;
|
||||
|
||||
activeItems.forEach((card) => {
|
||||
const idx = matching.indexOf(card);
|
||||
if (idx === -1) {
|
||||
card.style.display = 'none';
|
||||
} else if (idx >= start && idx < end) {
|
||||
card.style.display = '';
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
if (page < 1 || page > totalPages.value) return;
|
||||
currentPage.value = page;
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
query.value = '';
|
||||
selectedAuthors.value = [];
|
||||
authorQuery.value = '';
|
||||
selectedTags.value = [];
|
||||
tagQuery.value = '';
|
||||
typeFilter.value = '';
|
||||
currentPage.value = 1;
|
||||
}
|
||||
|
||||
watch([query, selectedAuthors, selectedTags, typeFilter], () => {
|
||||
currentPage.value = 1;
|
||||
applyFilters();
|
||||
}, { deep: true });
|
||||
</script>
|
||||
@@ -327,7 +327,7 @@ const forkAuthorEmail = ref('');
|
||||
// Load token from localStorage (set by EditGate modal)
|
||||
const authorToken = ref(
|
||||
typeof localStorage !== 'undefined'
|
||||
? localStorage.getItem('skillshere-token') || ''
|
||||
? localStorage.getItem('grimoired-token') || ''
|
||||
: ''
|
||||
);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ interface Props {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const { title = 'Skills Here' } = Astro.props;
|
||||
const { title = 'Grimoired' } = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -28,23 +28,58 @@ const { title = 'Skills Here' } = Astro.props;
|
||||
<nav class="relative z-50 border-b border-white/[0.06] bg-surface-50/80 backdrop-blur-xl">
|
||||
<div class="mx-auto max-w-6xl flex items-center justify-between px-6 py-4">
|
||||
<a href="/" class="group flex items-center gap-2.5">
|
||||
<img src="/favicon.svg" alt="Skills Here" class="h-8 w-8" />
|
||||
<span class="text-lg font-bold tracking-tight text-white group-hover:text-accent-400 transition-colors">Skills Here</span>
|
||||
</a>
|
||||
<a
|
||||
href="/new"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-accent-500 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-accent-500/20 hover:bg-accent-600 hover:shadow-accent-500/30 active:scale-[0.97] transition-all"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
New Skill
|
||||
<img src="/favicon.svg" alt="Grimoired" class="h-8 w-8" />
|
||||
<span class="text-lg font-bold tracking-tight text-white group-hover:text-accent-400 transition-colors">Grimoired</span>
|
||||
</a>
|
||||
<!-- New dropdown -->
|
||||
<div class="relative" id="new-dropdown">
|
||||
<button
|
||||
id="new-btn"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-accent-500 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-accent-500/20 hover:bg-accent-600 hover:shadow-accent-500/30 active:scale-[0.97] transition-all"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
New
|
||||
<svg class="h-3 w-3 ml-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div id="new-menu" class="hidden absolute right-0 mt-2 w-48 rounded-xl border border-white/[0.08] bg-[var(--color-surface-200)] shadow-2xl overflow-hidden z-50">
|
||||
<a href="/skills/new" class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-gray-300 hover:bg-white/[0.06] hover:text-white transition-colors">
|
||||
<span class="h-2 w-2 rounded-full" style="background: #fb923c;"></span>
|
||||
New Skill
|
||||
</a>
|
||||
<a href="/agents/new" class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-gray-300 hover:bg-white/[0.06] hover:text-white transition-colors">
|
||||
<span class="h-2 w-2 rounded-full" style="background: #818cf8;"></span>
|
||||
New Agent
|
||||
</a>
|
||||
<a href="/output-styles/new" class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-gray-300 hover:bg-white/[0.06] hover:text-white transition-colors">
|
||||
<span class="h-2 w-2 rounded-full" style="background: #34d399;"></span>
|
||||
New Output Style
|
||||
</a>
|
||||
<a href="/rules/new" class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-gray-300 hover:bg-white/[0.06] hover:text-white transition-colors">
|
||||
<span class="h-2 w-2 rounded-full" style="background: #f472b6;"></span>
|
||||
New Rule
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="relative mx-auto max-w-6xl px-6 py-10">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Dropdown toggle
|
||||
const btn = document.getElementById('new-btn')!;
|
||||
const menu = document.getElementById('new-menu')!;
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
menu.classList.toggle('hidden');
|
||||
});
|
||||
document.addEventListener('click', () => menu.classList.add('hidden'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
236
src/lib/registry.ts
Normal file
236
src/lib/registry.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import path from 'node:path';
|
||||
|
||||
export const RESOURCE_TYPES = ['skills', 'agents', 'output-styles', 'rules'] as const;
|
||||
export type ResourceType = (typeof RESOURCE_TYPES)[number];
|
||||
|
||||
export function isValidResourceType(type: string): type is ResourceType {
|
||||
return (RESOURCE_TYPES as readonly string[]).includes(type);
|
||||
}
|
||||
|
||||
export 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 }>;
|
||||
/** For toggle-grid: dynamic options fetched from external source */
|
||||
dynamicOptions?: 'tools' | 'models' | 'skills';
|
||||
defaultValue?: unknown;
|
||||
}
|
||||
|
||||
export interface ResourceTypeConfig {
|
||||
slug: ResourceType;
|
||||
label: string;
|
||||
labelSingular: string;
|
||||
/** Directory inside .claude/ where Claude Code reads these */
|
||||
claudeDir: string;
|
||||
/** Directory on server where data is stored */
|
||||
dataDir: string;
|
||||
/** Color for UI badges */
|
||||
color: string;
|
||||
/** Main file name inside a folder-based resource */
|
||||
mainFileName: string;
|
||||
/** Type-specific frontmatter fields */
|
||||
fields: FieldDef[];
|
||||
}
|
||||
|
||||
/** Allowed subdirectories inside a folder-based resource */
|
||||
export const FOLDER_SUBDIRS = ['scripts', 'references', 'assets'] as const;
|
||||
|
||||
const DATA_ROOT = process.env.DATA_DIR || 'data';
|
||||
|
||||
export const REGISTRY: Record<ResourceType, ResourceTypeConfig> = {
|
||||
skills: {
|
||||
slug: 'skills',
|
||||
label: 'Skills',
|
||||
labelSingular: 'Skill',
|
||||
claudeDir: 'skills',
|
||||
dataDir: path.resolve(process.env.SKILLS_DIR || `${DATA_ROOT}/skills`),
|
||||
color: '#fb923c', // orange
|
||||
mainFileName: 'SKILL.md',
|
||||
fields: [
|
||||
{
|
||||
key: 'allowed-tools',
|
||||
label: 'Allowed Tools',
|
||||
type: 'toggle-grid',
|
||||
dynamicOptions: 'tools',
|
||||
hint: 'Select which tools this skill can use',
|
||||
},
|
||||
{
|
||||
key: 'argument-hint',
|
||||
label: 'Argument Hint',
|
||||
type: 'text',
|
||||
placeholder: 'e.g. <file-path>',
|
||||
},
|
||||
{
|
||||
key: 'model',
|
||||
label: 'Model',
|
||||
type: 'select',
|
||||
dynamicOptions: 'models',
|
||||
},
|
||||
{
|
||||
key: 'user-invocable',
|
||||
label: 'User Invocable',
|
||||
type: 'toggle',
|
||||
hint: 'Show in /menu',
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
key: 'disable-model-invocation',
|
||||
label: 'Disable Model Invocation',
|
||||
type: 'toggle',
|
||||
hint: 'Manual only',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'context',
|
||||
label: 'Context',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: '', label: 'Inline (default)' },
|
||||
{ value: 'fork', label: 'Fork (run in subagent)' },
|
||||
],
|
||||
hint: 'Fork runs the skill in an isolated subagent context',
|
||||
},
|
||||
{
|
||||
key: 'agent',
|
||||
label: 'Agent',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: '', label: 'general-purpose (default)' },
|
||||
{ value: 'Explore', label: 'Explore' },
|
||||
{ value: 'Plan', label: 'Plan' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'hooks',
|
||||
label: 'Hooks',
|
||||
type: 'json',
|
||||
placeholder: '{ "preToolExecution": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo pre" }] }] }',
|
||||
hint: 'JSON object. Leave empty to omit.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
agents: {
|
||||
slug: 'agents',
|
||||
label: 'Agents',
|
||||
labelSingular: 'Agent',
|
||||
claudeDir: 'agents',
|
||||
dataDir: path.resolve(process.env.AGENTS_DIR || `${DATA_ROOT}/agents`),
|
||||
color: '#818cf8', // indigo
|
||||
mainFileName: 'AGENT.md',
|
||||
fields: [
|
||||
{
|
||||
key: 'tools',
|
||||
label: 'Tools',
|
||||
type: 'toggle-grid',
|
||||
dynamicOptions: 'tools',
|
||||
hint: 'Tools this agent can use',
|
||||
},
|
||||
{
|
||||
key: 'disallowedTools',
|
||||
label: 'Disallowed Tools',
|
||||
type: 'toggle-grid',
|
||||
dynamicOptions: 'tools',
|
||||
hint: 'Tools explicitly denied',
|
||||
},
|
||||
{
|
||||
key: 'model',
|
||||
label: 'Model',
|
||||
type: 'select',
|
||||
dynamicOptions: 'models',
|
||||
},
|
||||
{
|
||||
key: 'permissionMode',
|
||||
label: 'Permission Mode',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: '', label: 'Default' },
|
||||
{ value: 'default', label: 'default' },
|
||||
{ value: 'plan', label: 'plan' },
|
||||
{ value: 'bypassPermissions', label: 'bypassPermissions' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'maxTurns',
|
||||
label: 'Max Turns',
|
||||
type: 'number',
|
||||
placeholder: 'e.g. 10',
|
||||
},
|
||||
{
|
||||
key: 'skills',
|
||||
label: 'Preloaded Skills',
|
||||
type: 'tags',
|
||||
hint: 'Skill slugs to preload',
|
||||
dynamicOptions: 'skills',
|
||||
},
|
||||
{
|
||||
key: 'mcpServers',
|
||||
label: 'MCP Servers',
|
||||
type: 'json',
|
||||
hint: 'JSON object with MCP server configs',
|
||||
},
|
||||
{
|
||||
key: 'memory',
|
||||
label: 'Memory',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: '', label: 'Default' },
|
||||
{ value: 'true', label: 'Enabled' },
|
||||
{ value: 'false', label: 'Disabled' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'hooks',
|
||||
label: 'Hooks',
|
||||
type: 'json',
|
||||
placeholder: '{ "preToolExecution": [...] }',
|
||||
hint: 'JSON object. Leave empty to omit.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'output-styles': {
|
||||
slug: 'output-styles',
|
||||
label: 'Output Styles',
|
||||
labelSingular: 'Output Style',
|
||||
claudeDir: 'output-styles',
|
||||
dataDir: path.resolve(process.env.OUTPUT_STYLES_DIR || `${DATA_ROOT}/output-styles`),
|
||||
color: '#34d399', // emerald
|
||||
mainFileName: 'OUTPUT-STYLE.md',
|
||||
fields: [
|
||||
{
|
||||
key: 'keep-coding-instructions',
|
||||
label: 'Keep Coding Instructions',
|
||||
type: 'toggle',
|
||||
hint: 'Preserve default coding behavior instructions',
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
rules: {
|
||||
slug: 'rules',
|
||||
label: 'Rules',
|
||||
labelSingular: 'Rule',
|
||||
claudeDir: 'rules',
|
||||
dataDir: path.resolve(process.env.RULES_DIR || `${DATA_ROOT}/rules`),
|
||||
color: '#f472b6', // pink
|
||||
mainFileName: 'RULE.md',
|
||||
fields: [
|
||||
{
|
||||
key: 'paths',
|
||||
label: 'Paths',
|
||||
type: 'tags',
|
||||
hint: 'Glob patterns for files this rule applies to (e.g. src/**/*.ts)',
|
||||
placeholder: 'Add glob pattern...',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function getTypeConfig(type: ResourceType): ResourceTypeConfig {
|
||||
return REGISTRY[type];
|
||||
}
|
||||
358
src/lib/resources.ts
Normal file
358
src/lib/resources.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import matter from 'gray-matter';
|
||||
import { type ResourceType, getTypeConfig, RESOURCE_TYPES, FOLDER_SUBDIRS } from './registry';
|
||||
|
||||
const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
||||
const MAX_SLUG_LENGTH = 64;
|
||||
|
||||
export type ResourceFormat = 'file' | 'folder';
|
||||
|
||||
export interface ResourceFileEntry {
|
||||
relativePath: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ResourceMeta {
|
||||
slug: string;
|
||||
resourceType: ResourceType;
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
author: string;
|
||||
'author-email': string;
|
||||
'fork-of': string;
|
||||
format: ResourceFormat;
|
||||
/** All frontmatter fields (type-specific included) */
|
||||
fields: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Resource extends ResourceMeta {
|
||||
content: string;
|
||||
raw: string;
|
||||
files: ResourceFileEntry[];
|
||||
}
|
||||
|
||||
export function isValidSlug(slug: string): boolean {
|
||||
return slug.length >= 2 && slug.length <= MAX_SLUG_LENGTH && SLUG_RE.test(slug);
|
||||
}
|
||||
|
||||
function parseList(val: unknown): string[] {
|
||||
if (Array.isArray(val)) return val.map(String);
|
||||
if (typeof val === 'string') return val.split(',').map(t => t.trim()).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseResource(
|
||||
type: ResourceType,
|
||||
slug: string,
|
||||
raw: string,
|
||||
format: ResourceFormat = 'file',
|
||||
files: ResourceFileEntry[] = [],
|
||||
): Resource {
|
||||
const { data, content } = matter(raw);
|
||||
const fields: Record<string, unknown> = { ...data };
|
||||
|
||||
return {
|
||||
slug,
|
||||
resourceType: type,
|
||||
name: (data.name as string) || slug,
|
||||
description: (data.description as string) || '',
|
||||
tags: parseList(data.tags),
|
||||
author: (data.author as string) || '',
|
||||
'author-email': (data['author-email'] as string) || '',
|
||||
'fork-of': (data['fork-of'] as string) || '',
|
||||
format,
|
||||
fields,
|
||||
content: content.trim(),
|
||||
raw,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Path helpers ---
|
||||
|
||||
function filePath(type: ResourceType, slug: string): string {
|
||||
const config = getTypeConfig(type);
|
||||
return path.join(config.dataDir, `${slug}.md`);
|
||||
}
|
||||
|
||||
function folderPath(type: ResourceType, slug: string): string {
|
||||
const config = getTypeConfig(type);
|
||||
return path.join(config.dataDir, slug);
|
||||
}
|
||||
|
||||
function mainFilePath(type: ResourceType, slug: string): string {
|
||||
const config = getTypeConfig(type);
|
||||
return path.join(config.dataDir, slug, config.mainFileName);
|
||||
}
|
||||
|
||||
// --- Format detection ---
|
||||
|
||||
export interface ResolvedResource {
|
||||
format: ResourceFormat;
|
||||
contentPath: string;
|
||||
}
|
||||
|
||||
export async function resolveResource(type: ResourceType, slug: string): Promise<ResolvedResource | null> {
|
||||
// Folder has priority
|
||||
const mfp = mainFilePath(type, slug);
|
||||
try {
|
||||
await fs.access(mfp);
|
||||
return { format: 'folder', contentPath: mfp };
|
||||
} catch { /* not a folder */ }
|
||||
|
||||
const fp = filePath(type, slug);
|
||||
try {
|
||||
await fs.access(fp);
|
||||
return { format: 'file', contentPath: fp };
|
||||
} catch { /* not found */ }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Sub-file helpers ---
|
||||
|
||||
async function collectFiles(dirPath: string): Promise<ResourceFileEntry[]> {
|
||||
const entries: ResourceFileEntry[] = [];
|
||||
|
||||
async function walk(current: string, prefix: string) {
|
||||
let items: import('node:fs').Dirent[];
|
||||
try {
|
||||
items = await fs.readdir(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
const rel = prefix ? `${prefix}/${item.name}` : item.name;
|
||||
if (item.isDirectory()) {
|
||||
await walk(path.join(current, item.name), rel);
|
||||
} else {
|
||||
const stat = await fs.stat(path.join(current, item.name));
|
||||
entries.push({ relativePath: rel, size: stat.size });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(dirPath, '');
|
||||
return entries.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
||||
}
|
||||
|
||||
function validateRelativePath(relativePath: string): void {
|
||||
if (relativePath.includes('..') || path.isAbsolute(relativePath)) {
|
||||
throw new Error('Invalid file path: must be relative and cannot contain ..');
|
||||
}
|
||||
const parts = relativePath.split('/');
|
||||
if (parts.length < 2 || !FOLDER_SUBDIRS.includes(parts[0] as typeof FOLDER_SUBDIRS[number])) {
|
||||
throw new Error(`File path must start with one of: ${FOLDER_SUBDIRS.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- CRUD ---
|
||||
|
||||
export async function listResources(type: ResourceType): Promise<ResourceMeta[]> {
|
||||
const config = getTypeConfig(type);
|
||||
await fs.mkdir(config.dataDir, { recursive: true });
|
||||
const entries = await fs.readdir(config.dataDir, { withFileTypes: true });
|
||||
const resources: ResourceMeta[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
// Simple file resource
|
||||
const slug = entry.name.replace(/\.md$/, '');
|
||||
// Skip if a valid folder with same slug exists (folder takes priority)
|
||||
const folderMainFile = path.join(config.dataDir, slug, config.mainFileName);
|
||||
try {
|
||||
await fs.access(folderMainFile);
|
||||
continue; // Folder resource exists, skip the .md file
|
||||
} catch { /* no folder, fine */ }
|
||||
|
||||
const raw = await fs.readFile(path.join(config.dataDir, entry.name), 'utf-8');
|
||||
const { content: _, raw: __, files: ___, ...meta } = parseResource(type, slug, raw, 'file');
|
||||
resources.push(meta);
|
||||
} else if (entry.isDirectory()) {
|
||||
// Folder resource — must have mainFileName
|
||||
const mfp = path.join(config.dataDir, entry.name, config.mainFileName);
|
||||
try {
|
||||
const raw = await fs.readFile(mfp, 'utf-8');
|
||||
const { content: _, raw: __, files: ___, ...meta } = parseResource(type, entry.name, raw, 'folder');
|
||||
resources.push(meta);
|
||||
} catch {
|
||||
// Directory without mainFileName — skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resources.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export async function listAllResources(): Promise<ResourceMeta[]> {
|
||||
const all: ResourceMeta[] = [];
|
||||
for (const type of RESOURCE_TYPES) {
|
||||
const resources = await listResources(type);
|
||||
all.push(...resources);
|
||||
}
|
||||
return all.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export async function getAllTags(type?: ResourceType): Promise<string[]> {
|
||||
if (type) {
|
||||
const all = await listResources(type);
|
||||
return [...new Set(all.flatMap(r => r.tags))].sort();
|
||||
}
|
||||
const all = await listAllResources();
|
||||
return [...new Set(all.flatMap(r => r.tags))].sort();
|
||||
}
|
||||
|
||||
export async function getForksOf(type: ResourceType, slug: string): Promise<ResourceMeta[]> {
|
||||
const all = await listResources(type);
|
||||
return all.filter(r => r['fork-of'] === slug);
|
||||
}
|
||||
|
||||
export async function getResource(type: ResourceType, slug: string): Promise<Resource | null> {
|
||||
const resolved = await resolveResource(type, slug);
|
||||
if (!resolved) return null;
|
||||
|
||||
const raw = await fs.readFile(resolved.contentPath, 'utf-8');
|
||||
let files: ResourceFileEntry[] = [];
|
||||
if (resolved.format === 'folder') {
|
||||
const dir = folderPath(type, slug);
|
||||
const allFiles = await collectFiles(dir);
|
||||
const config = getTypeConfig(type);
|
||||
// Exclude the main file from the files list
|
||||
files = allFiles.filter(f => f.relativePath !== config.mainFileName);
|
||||
}
|
||||
return parseResource(type, slug, raw, resolved.format, files);
|
||||
}
|
||||
|
||||
export async function createResource(
|
||||
type: ResourceType,
|
||||
slug: string,
|
||||
content: string,
|
||||
format: ResourceFormat = 'file',
|
||||
): Promise<Resource> {
|
||||
if (!isValidSlug(slug)) {
|
||||
throw new Error(`Invalid slug: ${slug}`);
|
||||
}
|
||||
|
||||
const config = getTypeConfig(type);
|
||||
await fs.mkdir(config.dataDir, { recursive: true });
|
||||
|
||||
// Check both formats to prevent duplicates
|
||||
const existing = await resolveResource(type, slug);
|
||||
if (existing) {
|
||||
throw new Error(`${config.labelSingular} already exists: ${slug}`);
|
||||
}
|
||||
|
||||
if (format === 'folder') {
|
||||
const dir = folderPath(type, slug);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const mfp = mainFilePath(type, slug);
|
||||
await fs.writeFile(mfp, content, 'utf-8');
|
||||
return parseResource(type, slug, content, 'folder');
|
||||
}
|
||||
|
||||
const dest = filePath(type, slug);
|
||||
await fs.writeFile(dest, content, 'utf-8');
|
||||
return parseResource(type, slug, content, 'file');
|
||||
}
|
||||
|
||||
export async function updateResource(type: ResourceType, slug: string, content: string): Promise<Resource> {
|
||||
const config = getTypeConfig(type);
|
||||
const resolved = await resolveResource(type, slug);
|
||||
if (!resolved) {
|
||||
throw new Error(`${config.labelSingular} not found: ${slug}`);
|
||||
}
|
||||
|
||||
await fs.writeFile(resolved.contentPath, content, 'utf-8');
|
||||
|
||||
let files: ResourceFileEntry[] = [];
|
||||
if (resolved.format === 'folder') {
|
||||
const dir = folderPath(type, slug);
|
||||
const allFiles = await collectFiles(dir);
|
||||
files = allFiles.filter(f => f.relativePath !== config.mainFileName);
|
||||
}
|
||||
return parseResource(type, slug, content, resolved.format, files);
|
||||
}
|
||||
|
||||
export async function deleteResource(type: ResourceType, slug: string): Promise<void> {
|
||||
const config = getTypeConfig(type);
|
||||
const resolved = await resolveResource(type, slug);
|
||||
if (!resolved) {
|
||||
throw new Error(`${config.labelSingular} not found: ${slug}`);
|
||||
}
|
||||
|
||||
if (resolved.format === 'folder') {
|
||||
await fs.rm(folderPath(type, slug), { recursive: true });
|
||||
} else {
|
||||
await fs.unlink(resolved.contentPath);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sub-file operations (folder resources only) ---
|
||||
|
||||
export async function listResourceFiles(type: ResourceType, slug: string): Promise<ResourceFileEntry[]> {
|
||||
const config = getTypeConfig(type);
|
||||
const resolved = await resolveResource(type, slug);
|
||||
if (!resolved || resolved.format !== 'folder') {
|
||||
return [];
|
||||
}
|
||||
const dir = folderPath(type, slug);
|
||||
const allFiles = await collectFiles(dir);
|
||||
return allFiles.filter(f => f.relativePath !== config.mainFileName);
|
||||
}
|
||||
|
||||
export async function getResourceFile(type: ResourceType, slug: string, relativePath: string): Promise<Buffer | null> {
|
||||
validateRelativePath(relativePath);
|
||||
const resolved = await resolveResource(type, slug);
|
||||
if (!resolved || resolved.format !== 'folder') return null;
|
||||
|
||||
const target = path.join(folderPath(type, slug), relativePath);
|
||||
try {
|
||||
return await fs.readFile(target);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function addResourceFile(type: ResourceType, slug: string, relativePath: string, data: Buffer): Promise<void> {
|
||||
validateRelativePath(relativePath);
|
||||
const resolved = await resolveResource(type, slug);
|
||||
if (!resolved || resolved.format !== 'folder') {
|
||||
throw new Error('Resource is not a folder');
|
||||
}
|
||||
|
||||
const target = path.join(folderPath(type, slug), relativePath);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.writeFile(target, data);
|
||||
}
|
||||
|
||||
export async function deleteResourceFile(type: ResourceType, slug: string, relativePath: string): Promise<void> {
|
||||
validateRelativePath(relativePath);
|
||||
const resolved = await resolveResource(type, slug);
|
||||
if (!resolved || resolved.format !== 'folder') {
|
||||
throw new Error('Resource is not a folder');
|
||||
}
|
||||
|
||||
const target = path.join(folderPath(type, slug), relativePath);
|
||||
await fs.unlink(target);
|
||||
}
|
||||
|
||||
export async function convertToFolder(type: ResourceType, slug: string): Promise<Resource> {
|
||||
const config = getTypeConfig(type);
|
||||
const fp = filePath(type, slug);
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(fp, 'utf-8');
|
||||
} catch {
|
||||
throw new Error(`${config.labelSingular} not found or already a folder: ${slug}`);
|
||||
}
|
||||
|
||||
const dir = folderPath(type, slug);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const mfp = mainFilePath(type, slug);
|
||||
await fs.writeFile(mfp, raw, 'utf-8');
|
||||
await fs.unlink(fp);
|
||||
|
||||
return parseResource(type, slug, raw, 'folder');
|
||||
}
|
||||
373
src/lib/sync.ts
373
src/lib/sync.ts
@@ -1,19 +1,30 @@
|
||||
import { listResources, getResource } from './resources';
|
||||
import { listSkills } from './skills';
|
||||
import { type ResourceType, REGISTRY, RESOURCE_TYPES, getTypeConfig } from './registry';
|
||||
|
||||
export function isPowerShell(request: Request): boolean {
|
||||
const ua = request.headers.get('user-agent') || '';
|
||||
return /PowerShell/i.test(ua);
|
||||
}
|
||||
|
||||
// --- Push scripts (type-aware) ---
|
||||
|
||||
export async function buildPushScript(baseUrl: string, skillsDir: string): Promise<string> {
|
||||
return buildPushScriptForType(baseUrl, skillsDir, 'skills');
|
||||
}
|
||||
|
||||
export async function buildPushScriptForType(baseUrl: string, resourceDir: string, type: ResourceType): Promise<string> {
|
||||
const config = getTypeConfig(type);
|
||||
const lines = [
|
||||
'#!/usr/bin/env bash',
|
||||
'set -euo pipefail',
|
||||
'',
|
||||
`SKILLS_DIR="${skillsDir}"`,
|
||||
`RESOURCE_DIR="${resourceDir}"`,
|
||||
`BASE_URL="${baseUrl}"`,
|
||||
`RESOURCE_TYPE="${type}"`,
|
||||
`MAIN_FILE_NAME="${config.mainFileName}"`,
|
||||
'FILTER="${1:-}"',
|
||||
'TOKEN_FILE="$HOME/.claude/skills.here-token"',
|
||||
'TOKEN_FILE="$HOME/.claude/grimoired-token"',
|
||||
'',
|
||||
'# Get git author if available',
|
||||
'AUTHOR_NAME=$(git config user.name 2>/dev/null || echo "")',
|
||||
@@ -40,15 +51,15 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
|
||||
' echo " Token saved to $TOKEN_FILE"',
|
||||
' elif [ "$REGISTER_STATUS" = "409" ]; then',
|
||||
' echo " Email already registered. Place your token in $TOKEN_FILE"',
|
||||
' echo " Continuing without token (unprotected skills only)..."',
|
||||
' echo " Continuing without token (unprotected resources only)..."',
|
||||
' else',
|
||||
' echo " Registration failed ($REGISTER_STATUS): $REGISTER_BODY"',
|
||||
' echo " Continuing without token (unprotected skills only)..."',
|
||||
' echo " Continuing without token (unprotected resources only)..."',
|
||||
' fi',
|
||||
'fi',
|
||||
'',
|
||||
'if [ ! -d "$SKILLS_DIR" ]; then',
|
||||
' echo "No skills directory found at $SKILLS_DIR"',
|
||||
'if [ ! -d "$RESOURCE_DIR" ]; then',
|
||||
' echo "No directory found at $RESOURCE_DIR"',
|
||||
' exit 1',
|
||||
'fi',
|
||||
'',
|
||||
@@ -57,7 +68,7 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
|
||||
' AUTH_HEADER="Authorization: Bearer $TOKEN"',
|
||||
'fi',
|
||||
'',
|
||||
'push_skill() {',
|
||||
'push_file_resource() {',
|
||||
' local file="$1"',
|
||||
' local slug=$(basename "$file" .md)',
|
||||
' local content=$(cat "$file")',
|
||||
@@ -67,12 +78,6 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
|
||||
' content=$(echo "$content" | awk -v name="$AUTHOR_NAME" -v email="$AUTHOR_EMAIL" \'NR==1 && /^---$/{print; if (name) print "author: " name; print "author-email: " email; next} {print}\')',
|
||||
' fi',
|
||||
'',
|
||||
' # Build curl auth args',
|
||||
' local auth_args=""',
|
||||
' if [ -n "$AUTH_HEADER" ]; then',
|
||||
' auth_args="-H \\"$AUTH_HEADER\\""',
|
||||
' fi',
|
||||
'',
|
||||
' # Try PUT (update), fallback to POST (create)',
|
||||
' local response',
|
||||
' if [ -n "$TOKEN" ]; then',
|
||||
@@ -80,12 +85,12 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
|
||||
' -H "Content-Type: application/json" \\',
|
||||
' -H "Authorization: Bearer $TOKEN" \\',
|
||||
' -d "{\\"content\\": $(echo "$content" | jq -Rs .)}" \\',
|
||||
' "$BASE_URL/api/skills/$slug")',
|
||||
' "$BASE_URL/api/resources/$RESOURCE_TYPE/$slug")',
|
||||
' else',
|
||||
' response=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT \\',
|
||||
' -H "Content-Type: application/json" \\',
|
||||
' -d "{\\"content\\": $(echo "$content" | jq -Rs .)}" \\',
|
||||
' "$BASE_URL/api/skills/$slug")',
|
||||
' "$BASE_URL/api/resources/$RESOURCE_TYPE/$slug")',
|
||||
' fi',
|
||||
'',
|
||||
' if [ "$response" = "403" ]; then',
|
||||
@@ -99,12 +104,12 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
|
||||
' -H "Content-Type: application/json" \\',
|
||||
' -H "Authorization: Bearer $TOKEN" \\',
|
||||
' -d "{\\"slug\\": \\"$slug\\", \\"content\\": $(echo "$content" | jq -Rs .)}" \\',
|
||||
' "$BASE_URL/api/skills")',
|
||||
' "$BASE_URL/api/resources/$RESOURCE_TYPE")',
|
||||
' else',
|
||||
' local post_status=$(curl -sS -o /dev/null -w "%{http_code}" -X POST \\',
|
||||
' -H "Content-Type: application/json" \\',
|
||||
' -d "{\\"slug\\": \\"$slug\\", \\"content\\": $(echo "$content" | jq -Rs .)}" \\',
|
||||
' "$BASE_URL/api/skills")',
|
||||
' "$BASE_URL/api/resources/$RESOURCE_TYPE")',
|
||||
' fi',
|
||||
' if [ "$post_status" = "403" ]; then',
|
||||
' echo " ✗ $slug (permission denied — token missing or invalid)"',
|
||||
@@ -115,25 +120,120 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
|
||||
' echo " ✓ $slug"',
|
||||
'}',
|
||||
'',
|
||||
'push_folder_resource() {',
|
||||
' local dir="$1"',
|
||||
' local slug=$(basename "$dir")',
|
||||
' local main_file="$dir/$MAIN_FILE_NAME"',
|
||||
'',
|
||||
' if [ ! -f "$main_file" ]; then',
|
||||
' echo " ✗ $slug (no $MAIN_FILE_NAME found)"',
|
||||
' return 1',
|
||||
' fi',
|
||||
'',
|
||||
' local content=$(cat "$main_file")',
|
||||
'',
|
||||
' # Inject author',
|
||||
' if [ -n "$AUTHOR_EMAIL" ] && ! echo "$content" | grep -q "^author-email:"; then',
|
||||
' content=$(echo "$content" | awk -v name="$AUTHOR_NAME" -v email="$AUTHOR_EMAIL" \'NR==1 && /^---$/{print; if (name) print "author: " name; print "author-email: " email; next} {print}\')',
|
||||
' fi',
|
||||
'',
|
||||
' # Create as folder format, try PUT first then POST',
|
||||
' local response',
|
||||
' if [ -n "$TOKEN" ]; then',
|
||||
' response=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT \\',
|
||||
' -H "Content-Type: application/json" \\',
|
||||
' -H "Authorization: Bearer $TOKEN" \\',
|
||||
' -d "{\\"content\\": $(echo "$content" | jq -Rs .)}" \\',
|
||||
' "$BASE_URL/api/resources/$RESOURCE_TYPE/$slug")',
|
||||
' else',
|
||||
' response=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT \\',
|
||||
' -H "Content-Type: application/json" \\',
|
||||
' -d "{\\"content\\": $(echo "$content" | jq -Rs .)}" \\',
|
||||
' "$BASE_URL/api/resources/$RESOURCE_TYPE/$slug")',
|
||||
' fi',
|
||||
'',
|
||||
' if [ "$response" = "403" ]; then',
|
||||
' echo " ✗ $slug (permission denied)"',
|
||||
' return 1',
|
||||
' fi',
|
||||
'',
|
||||
' if [ "$response" = "404" ]; then',
|
||||
' if [ -n "$TOKEN" ]; then',
|
||||
' curl -sS -o /dev/null -w "%{http_code}" -X POST \\',
|
||||
' -H "Content-Type: application/json" \\',
|
||||
' -H "Authorization: Bearer $TOKEN" \\',
|
||||
' -d "{\\"slug\\": \\"$slug\\", \\"content\\": $(echo "$content" | jq -Rs .), \\"format\\": \\"folder\\"}" \\',
|
||||
' "$BASE_URL/api/resources/$RESOURCE_TYPE" > /dev/null',
|
||||
' else',
|
||||
' curl -sS -o /dev/null -w "%{http_code}" -X POST \\',
|
||||
' -H "Content-Type: application/json" \\',
|
||||
' -d "{\\"slug\\": \\"$slug\\", \\"content\\": $(echo "$content" | jq -Rs .), \\"format\\": \\"folder\\"}" \\',
|
||||
' "$BASE_URL/api/resources/$RESOURCE_TYPE" > /dev/null',
|
||||
' fi',
|
||||
' fi',
|
||||
'',
|
||||
' # Upload sub-files',
|
||||
' local file_count=0',
|
||||
' for subdir in scripts references assets; do',
|
||||
' if [ -d "$dir/$subdir" ]; then',
|
||||
' find "$dir/$subdir" -type f | while read -r subfile; do',
|
||||
' local rel_path="${subfile#$dir/}"',
|
||||
' local auth_flag=""',
|
||||
' if [ -n "$TOKEN" ]; then',
|
||||
' auth_flag="-H \\"Authorization: Bearer $TOKEN\\""',
|
||||
' fi',
|
||||
' if [ -n "$TOKEN" ]; then',
|
||||
' curl -sS -X PUT \\',
|
||||
' -H "Authorization: Bearer $TOKEN" \\',
|
||||
' --data-binary "@$subfile" \\',
|
||||
' "$BASE_URL/api/resources/$RESOURCE_TYPE/$slug/files/$rel_path" > /dev/null',
|
||||
' else',
|
||||
' curl -sS -X PUT \\',
|
||||
' --data-binary "@$subfile" \\',
|
||||
' "$BASE_URL/api/resources/$RESOURCE_TYPE/$slug/files/$rel_path" > /dev/null',
|
||||
' fi',
|
||||
' done',
|
||||
' fi',
|
||||
' done',
|
||||
'',
|
||||
' echo " ✓ $slug (folder)"',
|
||||
'}',
|
||||
'',
|
||||
'count=0',
|
||||
'failed=0',
|
||||
'if [ -n "$FILTER" ]; then',
|
||||
' # Push a specific skill',
|
||||
' file="$SKILLS_DIR/${FILTER%.md}.md"',
|
||||
' if [ ! -f "$file" ]; then',
|
||||
' echo "Skill not found: $file"',
|
||||
' # Check if filter matches a directory (folder resource)',
|
||||
' dir_path="$RESOURCE_DIR/${FILTER%.md}"',
|
||||
' dir_path="${dir_path%.md}"',
|
||||
' file_path="$RESOURCE_DIR/${FILTER%.md}.md"',
|
||||
' if [ -d "$dir_path" ] && [ -f "$dir_path/$MAIN_FILE_NAME" ]; then',
|
||||
' if push_folder_resource "$dir_path"; then count=1; else failed=1; fi',
|
||||
' elif [ -f "$file_path" ]; then',
|
||||
' if push_file_resource "$file_path"; then count=1; else failed=1; fi',
|
||||
' else',
|
||||
' echo "Resource not found: $FILTER"',
|
||||
' exit 1',
|
||||
' fi',
|
||||
' if push_skill "$file"; then',
|
||||
' count=1',
|
||||
' else',
|
||||
' failed=1',
|
||||
' fi',
|
||||
'else',
|
||||
' # Push all skills',
|
||||
' for file in "$SKILLS_DIR"/*.md; do',
|
||||
' # Push all .md files (simple format)',
|
||||
' for file in "$RESOURCE_DIR"/*.md; do',
|
||||
' [ -f "$file" ] || continue',
|
||||
' if push_skill "$file"; then',
|
||||
' slug_name=$(basename "$file" .md)',
|
||||
' # Skip if folder version exists',
|
||||
' if [ -d "$RESOURCE_DIR/$slug_name" ] && [ -f "$RESOURCE_DIR/$slug_name/$MAIN_FILE_NAME" ]; then',
|
||||
' continue',
|
||||
' fi',
|
||||
' if push_file_resource "$file"; then',
|
||||
' count=$((count + 1))',
|
||||
' else',
|
||||
' failed=$((failed + 1))',
|
||||
' fi',
|
||||
' done',
|
||||
' # Push all directories (folder format)',
|
||||
' for dir in "$RESOURCE_DIR"/*/; do',
|
||||
' [ -d "$dir" ] || continue',
|
||||
' [ -f "$dir/$MAIN_FILE_NAME" ] || continue',
|
||||
' if push_folder_resource "$dir"; then',
|
||||
' count=$((count + 1))',
|
||||
' else',
|
||||
' failed=$((failed + 1))',
|
||||
@@ -141,9 +241,9 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
|
||||
' done',
|
||||
'fi',
|
||||
'',
|
||||
'echo "Pushed $count skill(s) to $BASE_URL"',
|
||||
`echo "Pushed $count resource(s) to $BASE_URL"`,
|
||||
'if [ "$failed" -gt 0 ]; then',
|
||||
' echo "$failed skill(s) failed (permission denied)"',
|
||||
' echo "$failed resource(s) failed (permission denied)"',
|
||||
'fi',
|
||||
'',
|
||||
];
|
||||
@@ -151,63 +251,120 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// --- Sync scripts (type-aware) ---
|
||||
|
||||
export async function buildSyncScript(baseUrl: string, skillsDir: string): Promise<string> {
|
||||
const skills = await listSkills();
|
||||
return buildSyncScriptForType(baseUrl, skillsDir, 'skills');
|
||||
}
|
||||
|
||||
export async function buildSyncScriptForType(baseUrl: string, targetDir: string, type: ResourceType): Promise<string> {
|
||||
const resources = await listResources(type);
|
||||
const config = getTypeConfig(type);
|
||||
|
||||
const lines = [
|
||||
'#!/usr/bin/env bash',
|
||||
'set -euo pipefail',
|
||||
'',
|
||||
`SKILLS_DIR="${skillsDir}"`,
|
||||
'mkdir -p "$SKILLS_DIR"',
|
||||
`TARGET_DIR="${targetDir}"`,
|
||||
'mkdir -p "$TARGET_DIR"',
|
||||
'',
|
||||
];
|
||||
|
||||
if (skills.length === 0) {
|
||||
lines.push('echo "No skills available to sync."');
|
||||
if (resources.length === 0) {
|
||||
lines.push(`echo "No ${type} available to sync."`);
|
||||
} else {
|
||||
lines.push(`echo "Syncing ${skills.length} skill(s) from ${baseUrl}..."`);
|
||||
lines.push(`echo "Syncing ${resources.length} ${type} from ${baseUrl}..."`);
|
||||
lines.push('');
|
||||
|
||||
for (const skill of skills) {
|
||||
const skillUrl = `${baseUrl}/${skill.slug}`;
|
||||
lines.push(`curl -fsSL "${skillUrl}" -o "$SKILLS_DIR/${skill.slug}.md"`);
|
||||
lines.push(`echo " ✓ ${skill.name}"`);
|
||||
for (const r of resources) {
|
||||
if (r.format === 'folder') {
|
||||
// Fetch full resource to get file list
|
||||
const full = await getResource(type, r.slug);
|
||||
if (!full) continue;
|
||||
|
||||
lines.push(`mkdir -p "$TARGET_DIR/${r.slug}"`);
|
||||
lines.push(`curl -fsSL "${baseUrl}/${type}/${r.slug}" -o "$TARGET_DIR/${r.slug}/${config.mainFileName}"`);
|
||||
|
||||
for (const f of full.files) {
|
||||
const dir = f.relativePath.split('/').slice(0, -1).join('/');
|
||||
if (dir) {
|
||||
lines.push(`mkdir -p "$TARGET_DIR/${r.slug}/${dir}"`);
|
||||
}
|
||||
lines.push(`curl -fsSL "${baseUrl}/api/resources/${type}/${r.slug}/files/${f.relativePath}" -o "$TARGET_DIR/${r.slug}/${f.relativePath}"`);
|
||||
}
|
||||
|
||||
const scriptFiles = full.files.filter(f => f.relativePath.startsWith('scripts/'));
|
||||
for (const f of scriptFiles) {
|
||||
lines.push(`chmod +x "$TARGET_DIR/${r.slug}/${f.relativePath}"`);
|
||||
}
|
||||
|
||||
lines.push(`echo " ✓ ${r.name} (folder, ${full.files.length + 1} files)"`);
|
||||
} else {
|
||||
const resourceUrl = `${baseUrl}/${type}/${r.slug}`;
|
||||
lines.push(`curl -fsSL "${resourceUrl}" -o "$TARGET_DIR/${r.slug}.md"`);
|
||||
lines.push(`echo " ✓ ${r.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('echo "Done! Skills synced to $SKILLS_DIR"');
|
||||
lines.push('echo "Done! Synced to $TARGET_DIR"');
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// --- PowerShell variants ---
|
||||
|
||||
export async function buildSyncScriptPS(baseUrl: string, skillsDir: string): Promise<string> {
|
||||
const skills = await listSkills();
|
||||
return buildSyncScriptPSForType(baseUrl, skillsDir, 'skills');
|
||||
}
|
||||
|
||||
export async function buildSyncScriptPSForType(baseUrl: string, targetDir: string, type: ResourceType): Promise<string> {
|
||||
const resources = await listResources(type);
|
||||
const config = getTypeConfig(type);
|
||||
|
||||
const lines = [
|
||||
'$ErrorActionPreference = "Stop"',
|
||||
'',
|
||||
`$SkillsDir = "${skillsDir}"`,
|
||||
'New-Item -ItemType Directory -Force -Path $SkillsDir | Out-Null',
|
||||
`$TargetDir = "${targetDir}"`,
|
||||
'New-Item -ItemType Directory -Force -Path $TargetDir | Out-Null',
|
||||
'',
|
||||
];
|
||||
|
||||
if (skills.length === 0) {
|
||||
lines.push('Write-Host "No skills available to sync."');
|
||||
if (resources.length === 0) {
|
||||
lines.push(`Write-Host "No ${type} available to sync."`);
|
||||
} else {
|
||||
lines.push(`Write-Host "Syncing ${skills.length} skill(s) from ${baseUrl}..."`);
|
||||
lines.push(`Write-Host "Syncing ${resources.length} ${type} from ${baseUrl}..."`);
|
||||
lines.push('');
|
||||
|
||||
for (const skill of skills) {
|
||||
const skillUrl = `${baseUrl}/${skill.slug}`;
|
||||
lines.push(`Invoke-WebRequest -Uri "${skillUrl}" -OutFile (Join-Path $SkillsDir "${skill.slug}.md")`);
|
||||
lines.push(`Write-Host " ✓ ${skill.name}"`);
|
||||
for (const r of resources) {
|
||||
if (r.format === 'folder') {
|
||||
const full = await getResource(type, r.slug);
|
||||
if (!full) continue;
|
||||
|
||||
lines.push(`New-Item -ItemType Directory -Force -Path (Join-Path $TargetDir "${r.slug}") | Out-Null`);
|
||||
lines.push(`Invoke-WebRequest -Uri "${baseUrl}/${type}/${r.slug}" -OutFile (Join-Path $TargetDir "${r.slug}\\${config.mainFileName}")`);
|
||||
|
||||
for (const f of full.files) {
|
||||
const dir = f.relativePath.split('/').slice(0, -1).join('\\');
|
||||
if (dir) {
|
||||
lines.push(`New-Item -ItemType Directory -Force -Path (Join-Path $TargetDir "${r.slug}\\${dir}") | Out-Null`);
|
||||
}
|
||||
const winPath = f.relativePath.replace(/\//g, '\\');
|
||||
lines.push(`Invoke-WebRequest -Uri "${baseUrl}/api/resources/${type}/${r.slug}/files/${f.relativePath}" -OutFile (Join-Path $TargetDir "${r.slug}\\${winPath}")`);
|
||||
}
|
||||
|
||||
lines.push(`Write-Host " ✓ ${r.name} (folder, ${full.files.length + 1} files)"`);
|
||||
} else {
|
||||
const resourceUrl = `${baseUrl}/${type}/${r.slug}`;
|
||||
lines.push(`Invoke-WebRequest -Uri "${resourceUrl}" -OutFile (Join-Path $TargetDir "${r.slug}.md")`);
|
||||
lines.push(`Write-Host " ✓ ${r.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Write-Host "Done! Skills synced to $SkillsDir"');
|
||||
lines.push('Write-Host "Done! Synced to $TargetDir"');
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
@@ -215,13 +372,20 @@ export async function buildSyncScriptPS(baseUrl: string, skillsDir: string): Pro
|
||||
}
|
||||
|
||||
export async function buildPushScriptPS(baseUrl: string, skillsDir: string): Promise<string> {
|
||||
return buildPushScriptPSForType(baseUrl, skillsDir, 'skills');
|
||||
}
|
||||
|
||||
export async function buildPushScriptPSForType(baseUrl: string, resourceDir: string, type: ResourceType): Promise<string> {
|
||||
const config = getTypeConfig(type);
|
||||
const lines = [
|
||||
'$ErrorActionPreference = "Stop"',
|
||||
'',
|
||||
`$SkillsDir = "${skillsDir}"`,
|
||||
`$ResourceDir = "${resourceDir}"`,
|
||||
`$BaseUrl = "${baseUrl}"`,
|
||||
`$ResourceType = "${type}"`,
|
||||
`$MainFileName = "${config.mainFileName}"`,
|
||||
'$Filter = if ($args.Count -gt 0) { $args[0] } else { "" }',
|
||||
'$TokenFile = Join-Path $HOME ".claude\\skills.here-token"',
|
||||
'$TokenFile = Join-Path $HOME ".claude\\grimoired-token"',
|
||||
'',
|
||||
'# Get git author if available',
|
||||
'$AuthorName = try { git config user.name 2>$null } catch { "" }',
|
||||
@@ -249,19 +413,19 @@ export async function buildPushScriptPS(baseUrl: string, skillsDir: string): Pro
|
||||
' } else {',
|
||||
' Write-Host " Registration failed: $_"',
|
||||
' }',
|
||||
' Write-Host " Continuing without token (unprotected skills only)..."',
|
||||
' Write-Host " Continuing without token (unprotected resources only)..."',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
'if (-not (Test-Path $SkillsDir)) {',
|
||||
' Write-Host "No skills directory found at $SkillsDir"',
|
||||
'if (-not (Test-Path $ResourceDir)) {',
|
||||
' Write-Host "No directory found at $ResourceDir"',
|
||||
' exit 1',
|
||||
'}',
|
||||
'',
|
||||
'$headers = @{ "Content-Type" = "application/json" }',
|
||||
'if ($Token) { $headers["Authorization"] = "Bearer $Token" }',
|
||||
'',
|
||||
'function Push-Skill($file) {',
|
||||
'function Push-FileResource($file) {',
|
||||
' $slug = [IO.Path]::GetFileNameWithoutExtension($file)',
|
||||
' $content = Get-Content $file -Raw',
|
||||
'',
|
||||
@@ -275,7 +439,7 @@ export async function buildPushScriptPS(baseUrl: string, skillsDir: string): Pro
|
||||
'',
|
||||
' $body = @{ content = $content } | ConvertTo-Json',
|
||||
' try {',
|
||||
' Invoke-WebRequest -Uri "$BaseUrl/api/skills/$slug" -Method PUT -Headers $headers -Body $body | Out-Null',
|
||||
' Invoke-WebRequest -Uri "$BaseUrl/api/resources/$ResourceType/$slug" -Method PUT -Headers $headers -Body $body | Out-Null',
|
||||
' Write-Host " ✓ $slug"',
|
||||
' return $true',
|
||||
' } catch {',
|
||||
@@ -287,7 +451,7 @@ export async function buildPushScriptPS(baseUrl: string, skillsDir: string): Pro
|
||||
' if ($code -eq 404) {',
|
||||
' $postBody = @{ slug = $slug; content = $content } | ConvertTo-Json',
|
||||
' try {',
|
||||
' Invoke-WebRequest -Uri "$BaseUrl/api/skills" -Method POST -Headers $headers -Body $postBody | Out-Null',
|
||||
' Invoke-WebRequest -Uri "$BaseUrl/api/resources/$ResourceType" -Method POST -Headers $headers -Body $postBody | Out-Null',
|
||||
' Write-Host " ✓ $slug"',
|
||||
' return $true',
|
||||
' } catch {',
|
||||
@@ -303,19 +467,92 @@ export async function buildPushScriptPS(baseUrl: string, skillsDir: string): Pro
|
||||
' return $true',
|
||||
'}',
|
||||
'',
|
||||
'function Push-FolderResource($dir) {',
|
||||
' $slug = Split-Path $dir -Leaf',
|
||||
' $mainFile = Join-Path $dir $MainFileName',
|
||||
' if (-not (Test-Path $mainFile)) {',
|
||||
' Write-Host " ✗ $slug (no $MainFileName found)"',
|
||||
' return $false',
|
||||
' }',
|
||||
' $content = Get-Content $mainFile -Raw',
|
||||
'',
|
||||
' if ($AuthorEmail -and $content -notmatch "(?m)^author-email:") {',
|
||||
' $inject = ""',
|
||||
' if ($AuthorName) { $inject += "author: $AuthorName`n" }',
|
||||
' $inject += "author-email: $AuthorEmail"',
|
||||
' $content = $content -replace "^---$", "---`n$inject" -replace "^---`n", "---`n"',
|
||||
' }',
|
||||
'',
|
||||
' $body = @{ content = $content } | ConvertTo-Json',
|
||||
' try {',
|
||||
' Invoke-WebRequest -Uri "$BaseUrl/api/resources/$ResourceType/$slug" -Method PUT -Headers $headers -Body $body | Out-Null',
|
||||
' } catch {',
|
||||
' $code = $_.Exception.Response.StatusCode.value__',
|
||||
' if ($code -eq 403) {',
|
||||
' Write-Host " ✗ $slug (permission denied)"',
|
||||
' return $false',
|
||||
' }',
|
||||
' if ($code -eq 404) {',
|
||||
' $postBody = @{ slug = $slug; content = $content; format = "folder" } | ConvertTo-Json',
|
||||
' try {',
|
||||
' Invoke-WebRequest -Uri "$BaseUrl/api/resources/$ResourceType" -Method POST -Headers $headers -Body $postBody | Out-Null',
|
||||
' } catch {',
|
||||
' $postCode = $_.Exception.Response.StatusCode.value__',
|
||||
' if ($postCode -eq 403) {',
|
||||
' Write-Host " ✗ $slug (permission denied)"',
|
||||
' return $false',
|
||||
' }',
|
||||
' }',
|
||||
' }',
|
||||
' }',
|
||||
'',
|
||||
' # Upload sub-files',
|
||||
' foreach ($subdir in @("scripts", "references", "assets")) {',
|
||||
' $subdirPath = Join-Path $dir $subdir',
|
||||
' if (Test-Path $subdirPath) {',
|
||||
' Get-ChildItem -Path $subdirPath -Recurse -File | ForEach-Object {',
|
||||
' $relPath = $_.FullName.Substring($dir.Length + 1).Replace("\\", "/")',
|
||||
' $fileHeaders = @{}',
|
||||
' if ($Token) { $fileHeaders["Authorization"] = "Bearer $Token" }',
|
||||
' $fileBytes = [IO.File]::ReadAllBytes($_.FullName)',
|
||||
' Invoke-WebRequest -Uri "$BaseUrl/api/resources/$ResourceType/$slug/files/$relPath" -Method PUT -Headers $fileHeaders -Body $fileBytes | Out-Null',
|
||||
' }',
|
||||
' }',
|
||||
' }',
|
||||
'',
|
||||
' Write-Host " ✓ $slug (folder)"',
|
||||
' return $true',
|
||||
'}',
|
||||
'',
|
||||
'$count = 0; $failed = 0',
|
||||
'if ($Filter) {',
|
||||
' $file = Join-Path $SkillsDir "$($Filter -replace \'\\.md$\',\'\').md"',
|
||||
' if (-not (Test-Path $file)) { Write-Host "Skill not found: $file"; exit 1 }',
|
||||
' if (Push-Skill $file) { $count++ } else { $failed++ }',
|
||||
' $dirPath = Join-Path $ResourceDir ($Filter -replace "\\.md$","")',
|
||||
' $filePath = Join-Path $ResourceDir "$($Filter -replace \'\\.md$\',\'\').md"',
|
||||
' if ((Test-Path $dirPath -PathType Container) -and (Test-Path (Join-Path $dirPath $MainFileName))) {',
|
||||
' if (Push-FolderResource $dirPath) { $count++ } else { $failed++ }',
|
||||
' } elseif (Test-Path $filePath) {',
|
||||
' if (Push-FileResource $filePath) { $count++ } else { $failed++ }',
|
||||
' } else {',
|
||||
' Write-Host "Resource not found: $Filter"; exit 1',
|
||||
' }',
|
||||
'} else {',
|
||||
' Get-ChildItem -Path $SkillsDir -Filter "*.md" | ForEach-Object {',
|
||||
' if (Push-Skill $_.FullName) { $count++ } else { $failed++ }',
|
||||
' # Push .md files (skip if folder version exists)',
|
||||
' Get-ChildItem -Path $ResourceDir -Filter "*.md" | ForEach-Object {',
|
||||
' $slugName = $_.BaseName',
|
||||
' $folderPath = Join-Path $ResourceDir $slugName',
|
||||
' if ((Test-Path $folderPath -PathType Container) -and (Test-Path (Join-Path $folderPath $MainFileName))) { return }',
|
||||
' if (Push-FileResource $_.FullName) { $count++ } else { $failed++ }',
|
||||
' }',
|
||||
' # Push directories',
|
||||
' Get-ChildItem -Path $ResourceDir -Directory | ForEach-Object {',
|
||||
' if (Test-Path (Join-Path $_.FullName $MainFileName)) {',
|
||||
' if (Push-FolderResource $_.FullName) { $count++ } else { $failed++ }',
|
||||
' }',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
'Write-Host "Pushed $count skill(s) to $BaseUrl"',
|
||||
'if ($failed -gt 0) { Write-Host "$failed skill(s) failed (permission denied)" }',
|
||||
'Write-Host "Pushed $count resource(s) to $BaseUrl"',
|
||||
'if ($failed -gt 0) { Write-Host "$failed resource(s) failed (permission denied)" }',
|
||||
'',
|
||||
];
|
||||
|
||||
|
||||
344
src/pages/[type]/[slug].astro
Normal file
344
src/pages/[type]/[slug].astro
Normal file
@@ -0,0 +1,344 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import EditGate from '../../components/EditGate.vue';
|
||||
import DeleteButton from '../../components/DeleteButton.vue';
|
||||
import FolderTree from '../../components/FolderTree.astro';
|
||||
import { isValidResourceType, getTypeConfig, type ResourceType } from '../../lib/registry';
|
||||
import { getResource, getForksOf } from '../../lib/resources';
|
||||
import { hasToken } from '../../lib/tokens';
|
||||
import { recordDownload, getStatsForSlug } from '../../lib/stats';
|
||||
import { marked } from 'marked';
|
||||
|
||||
const { type, slug } = Astro.params;
|
||||
|
||||
if (!type || !isValidResourceType(type)) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const resourceType = type as ResourceType;
|
||||
const config = getTypeConfig(resourceType);
|
||||
const resource = await getResource(resourceType, slug!);
|
||||
|
||||
if (!resource) {
|
||||
return Astro.redirect('/');
|
||||
}
|
||||
|
||||
// curl / wget → raw markdown
|
||||
const accept = Astro.request.headers.get('accept') || '';
|
||||
if (!accept.includes('text/html')) {
|
||||
recordDownload(slug!, resourceType);
|
||||
return new Response(resource.raw, {
|
||||
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
|
||||
const authorHasToken = resource['author-email'] ? await hasToken(resource['author-email']) : false;
|
||||
const forks = await getForksOf(resourceType, slug!);
|
||||
const stats = await getStatsForSlug(slug!, resourceType);
|
||||
const html = await marked(resource.content);
|
||||
const origin = Astro.url.origin;
|
||||
const cmds = {
|
||||
unix: `curl -fsSL ${origin}/${type}/${slug}/i | bash`,
|
||||
unixGlobal: `curl -fsSL ${origin}/${type}/${slug}/gi | bash`,
|
||||
win: `irm ${origin}/${type}/${slug}/i | iex`,
|
||||
winGlobal: `irm ${origin}/${type}/${slug}/gi | iex`,
|
||||
};
|
||||
|
||||
// Extract display fields from frontmatter
|
||||
const fields = resource.fields;
|
||||
const allowedTools = Array.isArray(fields['allowed-tools'] ?? fields.allowedTools)
|
||||
? (fields['allowed-tools'] ?? fields.allowedTools) as string[]
|
||||
: typeof (fields['allowed-tools'] ?? fields.allowedTools) === 'string'
|
||||
? (fields['allowed-tools'] ?? fields.allowedTools as string).split(',').map((t: string) => t.trim()).filter(Boolean)
|
||||
: [];
|
||||
---
|
||||
|
||||
<Base title={`${resource.name} — Grimoired`}>
|
||||
<a href="/" class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-300 transition-colors mb-4">
|
||||
<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="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
Back to {config.label}
|
||||
</a>
|
||||
|
||||
<!-- Header + Install -->
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 2rem;">
|
||||
<div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-6" style="min-width: 0;">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="rounded-full px-2.5 py-0.5 text-[11px] font-semibold" style={`background: ${config.color}20; color: ${config.color}; border: 1px solid ${config.color}40;`}>
|
||||
{config.labelSingular}
|
||||
</span>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-white mb-1">{resource.name}</h1>
|
||||
{resource.description && <p class="text-gray-500 leading-relaxed mb-3">{resource.description}</p>}
|
||||
{allowedTools.length > 0 && (
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{allowedTools.map((tool: string) => (
|
||||
<span class="rounded-md bg-white/[0.04] border border-white/[0.06] px-2.5 py-1 text-xs font-medium text-gray-400">
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{resource.tags.length > 0 && (
|
||||
<div class="flex flex-wrap gap-1.5 mt-3">
|
||||
{resource.tags.map((tag) => (
|
||||
<span class="rounded-full bg-[var(--color-accent-500)]/10 px-2.5 py-0.5 text-xs font-medium text-[var(--color-accent-400)]">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{resource.author && (
|
||||
<p class="text-xs text-gray-600 mt-3">by {resource.author}</p>
|
||||
)}
|
||||
{resource['fork-of'] && (
|
||||
<p class="text-xs text-gray-600 mt-1">forked from <a href={`/${type}/${resource['fork-of']}`} class="text-[var(--color-accent-500)] hover:text-[var(--color-accent-400)] transition-colors">{resource['fork-of']}</a></p>
|
||||
)}
|
||||
{forks.length > 0 && (
|
||||
<details class="mt-3">
|
||||
<summary class="text-xs text-gray-500 cursor-pointer hover:text-gray-300 transition-colors select-none">
|
||||
{forks.length} fork{forks.length !== 1 ? 's' : ''}
|
||||
</summary>
|
||||
<ul class="mt-1.5 space-y-1 pl-3 border-l border-white/[0.06]">
|
||||
{forks.map((f) => (
|
||||
<li>
|
||||
<a href={`/${type}/${f.slug}`} class="text-xs text-[var(--color-accent-500)] hover:text-[var(--color-accent-400)] transition-colors">
|
||||
{f.name}
|
||||
{f.author && <span class="text-gray-600"> by {f.author}</span>}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
<div class="flex flex-wrap items-center gap-4 mt-4 pt-3 border-t border-white/[0.06]">
|
||||
<span class="inline-flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<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.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
{stats.downloads} download{stats.downloads !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<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>
|
||||
{stats.pushes} push{stats.pushes !== 1 ? 'es' : ''}
|
||||
</span>
|
||||
{stats.lastPushedAt && (
|
||||
<span class="text-xs text-gray-600">
|
||||
Last updated {new Date(stats.lastPushedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-6 space-y-4" style="min-width: 0;">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-white">Install this {config.labelSingular.toLowerCase()}</h2>
|
||||
<div class="flex rounded-lg border border-white/[0.06] overflow-hidden" id="os-tabs">
|
||||
<button data-os="unix" class="os-tab px-2.5 py-1 text-[11px] font-medium transition-all">macOS / Linux</button>
|
||||
<button data-os="win" class="os-tab px-2.5 py-1 text-[11px] font-medium transition-all">Windows</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 leading-relaxed">Run in your project root to add this {config.labelSingular.toLowerCase()}.</p>
|
||||
<div class="flex items-center gap-3 rounded-xl bg-surface-50 border border-white/[0.06] px-4 py-3">
|
||||
<code data-cmd="unix" class="flex-1 text-xs font-mono text-gray-500 select-all truncate">{cmds.unix}</code>
|
||||
<code data-cmd="win" class="flex-1 text-xs font-mono text-gray-500 select-all truncate hidden">{cmds.win}</code>
|
||||
<button data-copy class="shrink-0 rounded-md bg-white/[0.06] border border-white/[0.06] px-2.5 py-1 text-xs font-medium text-gray-400 hover:text-white hover:bg-white/[0.1] transition-all">Copy</button>
|
||||
</div>
|
||||
<details class="group">
|
||||
<summary class="text-xs text-gray-600 cursor-pointer hover:text-gray-400 transition-colors">More options</summary>
|
||||
<div class="mt-3 space-y-3 text-xs text-gray-500">
|
||||
<div>
|
||||
<p class="mb-1.5">Install globally (available in all projects):</p>
|
||||
<div class="flex items-center gap-3 rounded-lg bg-surface-50 border border-white/[0.06] px-3 py-2">
|
||||
<code data-cmd="unix" class="flex-1 font-mono text-gray-500 select-all truncate">{cmds.unixGlobal}</code>
|
||||
<code data-cmd="win" class="flex-1 font-mono text-gray-500 select-all truncate hidden">{cmds.winGlobal}</code>
|
||||
<button data-copy class="shrink-0 rounded bg-white/[0.06] border border-white/[0.06] px-2 py-0.5 font-medium text-gray-500 hover:text-white hover:bg-white/[0.1] transition-all">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content + Files -->
|
||||
{resource.format === 'folder' ? (
|
||||
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1rem; align-items: start;">
|
||||
<div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-5 sticky top-4">
|
||||
<h2 class="text-sm font-semibold text-white mb-3">Files</h2>
|
||||
<FolderTree
|
||||
files={resource.files}
|
||||
slug={slug!}
|
||||
type={type}
|
||||
mainFileName={config.mainFileName}
|
||||
mainFileSize={new TextEncoder().encode(resource.raw).length}
|
||||
/>
|
||||
</div>
|
||||
<div id="content-panel" class="relative rounded-2xl border border-white/[0.06] bg-surface-100 p-8" style="min-width: 0;">
|
||||
<div class="absolute top-4 right-4 flex items-center gap-2">
|
||||
<span class="rounded-full px-2 py-0.5 text-[10px] font-medium bg-white/[0.06] text-gray-500 border border-white/[0.06]">folder</span>
|
||||
<EditGate slug={slug!} authorEmail={resource['author-email']} authorName={resource.author} authorHasToken={authorHasToken} resourceType={type} client:load />
|
||||
<DeleteButton slug={slug!} authorEmail={resource['author-email']} authorName={resource.author} authorHasToken={authorHasToken} resourceType={type} client:load />
|
||||
</div>
|
||||
<article id="content-body" class="skill-prose" set:html={html} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="relative rounded-2xl border border-white/[0.06] bg-surface-100 p-8">
|
||||
<div class="absolute top-4 right-4 flex items-center gap-2">
|
||||
<EditGate slug={slug!} authorEmail={resource['author-email']} authorName={resource.author} authorHasToken={authorHasToken} resourceType={type} client:load />
|
||||
<DeleteButton slug={slug!} authorEmail={resource['author-email']} authorName={resource.author} authorHasToken={authorHasToken} resourceType={type} client:load />
|
||||
</div>
|
||||
<article class="skill-prose" set:html={html} />
|
||||
</div>
|
||||
)}
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.os-tab { color: var(--color-gray-600); }
|
||||
.os-tab.active { background: rgba(255,255,255,0.06); color: white; }
|
||||
</style>
|
||||
<script>
|
||||
const isWin = /Win/.test(navigator.platform);
|
||||
function setOS(os: string) {
|
||||
document.querySelectorAll<HTMLElement>('[data-cmd]').forEach(el => {
|
||||
el.classList.toggle('hidden', el.dataset.cmd !== os);
|
||||
});
|
||||
document.querySelectorAll<HTMLElement>('.os-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.os === os);
|
||||
});
|
||||
}
|
||||
setOS(isWin ? 'win' : 'unix');
|
||||
document.querySelectorAll<HTMLButtonElement>('.os-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => setOS(tab.dataset.os!));
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-copy]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const container = btn.parentElement!;
|
||||
const visible = container.querySelector<HTMLElement>('[data-cmd]:not(.hidden)');
|
||||
const code = visible?.textContent?.trim();
|
||||
if (code) {
|
||||
navigator.clipboard.writeText(code);
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => btn.textContent = 'Copy', 1500);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// File viewer for folder resources
|
||||
const contentBody = document.getElementById('content-body');
|
||||
if (contentBody) {
|
||||
const mainHtml = contentBody.innerHTML;
|
||||
const treeItems = document.querySelectorAll<HTMLButtonElement>('.tree-item');
|
||||
function setActive(btn: HTMLButtonElement) {
|
||||
treeItems.forEach(t => t.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
}
|
||||
|
||||
function isTextResponse(contentType: string): boolean {
|
||||
if (contentType.startsWith('text/')) return true;
|
||||
const textTypes = ['application/json', 'application/javascript', 'application/xml', 'application/yaml', 'application/toml', 'application/x-sh'];
|
||||
return textTypes.some(t => contentType.startsWith(t));
|
||||
}
|
||||
|
||||
function isMdFile(path: string): boolean {
|
||||
return path.split('.').pop()?.toLowerCase() === 'md';
|
||||
}
|
||||
|
||||
// Main file button
|
||||
document.querySelector<HTMLButtonElement>('[data-file-main]')?.addEventListener('click', (e) => {
|
||||
setActive(e.currentTarget as HTMLButtonElement);
|
||||
contentBody.innerHTML = mainHtml;
|
||||
contentBody.className = 'skill-prose';
|
||||
});
|
||||
|
||||
// Sub-file buttons
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-file-path]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const filePath = btn.dataset.filePath!;
|
||||
setActive(btn);
|
||||
|
||||
const fileName = filePath.split('/').pop() || filePath;
|
||||
contentBody.className = '';
|
||||
contentBody.innerHTML = `<p class="text-sm text-gray-500">Loading ${fileName}...</p>`;
|
||||
|
||||
try {
|
||||
const [, rType, rSlug] = window.location.pathname.split('/');
|
||||
const apiUrl = `/api/resources/${rType}/${rSlug}/files/${filePath}`;
|
||||
const res = await fetch(apiUrl);
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
const treatAsText = isTextResponse(ct) || ct === 'application/octet-stream';
|
||||
|
||||
if (treatAsText) {
|
||||
const text = await res.text();
|
||||
|
||||
// If octet-stream, verify it looks like text (no null bytes)
|
||||
if (ct === 'application/octet-stream' && /\0/.test(text)) {
|
||||
// Actually binary — show download
|
||||
const blob = new Blob([text]);
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
contentBody.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<svg class="h-12 w-12 text-gray-600 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<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>
|
||||
<p class="text-sm text-gray-400 mb-1">${fileName}</p>
|
||||
<p class="text-xs text-gray-600 mb-4">${filePath}</p>
|
||||
<a href="${blobUrl}" download="${fileName}" class="rounded-lg bg-[var(--color-accent-500)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-accent-400)] transition-colors">Download file</a>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'flex items-center gap-2 mb-4 pb-3 border-b border-white/[0.06]';
|
||||
header.innerHTML = `
|
||||
<span class="text-xs font-mono text-gray-500">${filePath}</span>
|
||||
<a href="${apiUrl}" download class="ml-auto text-xs text-gray-600 hover:text-gray-400 transition-colors">Download</a>
|
||||
`;
|
||||
|
||||
if (isMdFile(filePath)) {
|
||||
const { marked: clientMarked } = await import('marked');
|
||||
const rendered = await clientMarked(text);
|
||||
contentBody.innerHTML = '';
|
||||
contentBody.appendChild(header);
|
||||
const article = document.createElement('article');
|
||||
article.className = 'skill-prose';
|
||||
article.innerHTML = rendered;
|
||||
contentBody.appendChild(article);
|
||||
} else {
|
||||
contentBody.innerHTML = '';
|
||||
contentBody.appendChild(header);
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'rounded-xl bg-surface-50 border border-white/[0.06] p-4 overflow-x-auto';
|
||||
const code = document.createElement('code');
|
||||
code.className = 'text-sm font-mono text-gray-300 whitespace-pre';
|
||||
code.textContent = text;
|
||||
pre.appendChild(code);
|
||||
contentBody.appendChild(pre);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Binary file — show download link
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
contentBody.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<svg class="h-12 w-12 text-gray-600 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<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>
|
||||
<p class="text-sm text-gray-400 mb-1">${fileName}</p>
|
||||
<p class="text-xs text-gray-600 mb-4">${filePath}</p>
|
||||
<a href="${blobUrl}" download="${fileName}" class="rounded-lg bg-[var(--color-accent-500)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-accent-400)] transition-colors">Download file</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (err) {
|
||||
contentBody.innerHTML = `<p class="text-sm text-red-400">Failed to load file.</p>`;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
77
src/pages/[type]/[slug]/edit.astro
Normal file
77
src/pages/[type]/[slug]/edit.astro
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
import Base from '../../../layouts/Base.astro';
|
||||
import ResourceEditor from '../../../components/ResourceEditor.vue';
|
||||
import { isValidResourceType, getTypeConfig, type ResourceType } from '../../../lib/registry';
|
||||
import { getResource, getAllTags, listResources } from '../../../lib/resources';
|
||||
import { getAvailableTools } from '../../../lib/tools';
|
||||
import { getAvailableModels } from '../../../lib/models';
|
||||
|
||||
const { type, slug } = Astro.params;
|
||||
|
||||
if (!type || !isValidResourceType(type)) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const resourceType = type as ResourceType;
|
||||
const config = getTypeConfig(resourceType);
|
||||
const resource = await getResource(resourceType, slug!);
|
||||
|
||||
if (!resource) {
|
||||
return Astro.redirect('/');
|
||||
}
|
||||
|
||||
const availableTools = await getAvailableTools();
|
||||
const availableModels = await getAvailableModels();
|
||||
const availableTags = await getAllTags(resourceType);
|
||||
const skillSlugs = (await listResources('skills')).map(r => r.slug);
|
||||
|
||||
// Build initial field values
|
||||
const initialFieldValues: Record<string, unknown> = {};
|
||||
for (const field of config.fields) {
|
||||
const val = resource.fields[field.key];
|
||||
if (val !== undefined && val !== null) {
|
||||
if (field.type === 'toggle-grid' || field.type === 'tags') {
|
||||
if (Array.isArray(val)) {
|
||||
initialFieldValues[field.key] = val;
|
||||
} else if (typeof val === 'string') {
|
||||
initialFieldValues[field.key] = val.split(',').map((t: string) => t.trim()).filter(Boolean);
|
||||
}
|
||||
} else if (field.type === 'json') {
|
||||
initialFieldValues[field.key] = typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val);
|
||||
} else {
|
||||
initialFieldValues[field.key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
<Base title={`Edit ${resource.name} — Grimoired`}>
|
||||
<a href={`/${type}/${slug}`} class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-300 transition-colors mb-4">
|
||||
<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="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
Back to {resource.name}
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-white mb-2">Edit {config.labelSingular}</h1>
|
||||
<p class="text-sm text-gray-500 mb-8">Editing <strong class="text-gray-400">{resource.name}</strong>. Users who already installed this will get the updated version on their next sync.</p>
|
||||
<ResourceEditor
|
||||
resourceType={type}
|
||||
typeSingular={config.labelSingular}
|
||||
typeFields={config.fields}
|
||||
mode="edit"
|
||||
slug={slug}
|
||||
initialName={resource.name}
|
||||
initialDescription={resource.description}
|
||||
initialTags={resource.tags.join(', ')}
|
||||
initialBody={resource.content}
|
||||
initialAuthor={resource.author}
|
||||
initialAuthorEmail={resource['author-email']}
|
||||
initialFormat={resource.format}
|
||||
initialFieldValues={JSON.stringify(initialFieldValues)}
|
||||
availableTools={availableTools}
|
||||
availableModels={availableModels}
|
||||
availableSkills={skillSlugs}
|
||||
availableTags={availableTags.join(',')}
|
||||
client:load
|
||||
/>
|
||||
</Base>
|
||||
181
src/pages/[type]/[slug]/gi.ts
Normal file
181
src/pages/[type]/[slug]/gi.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { isValidResourceType, getTypeConfig, type ResourceType } from '../../../lib/registry';
|
||||
import { getResource } from '../../../lib/resources';
|
||||
import { isPowerShell } from '../../../lib/sync';
|
||||
|
||||
function parseList(val: unknown): string[] {
|
||||
if (Array.isArray(val)) return val.map(String);
|
||||
if (typeof val === 'string') return val.split(',').map(t => t.trim()).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async ({ params, url, request }) => {
|
||||
const { type, slug } = params;
|
||||
|
||||
if (!type || !isValidResourceType(type)) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const resourceType = type as ResourceType;
|
||||
const config = getTypeConfig(resourceType);
|
||||
const resource = await getResource(resourceType, slug!);
|
||||
if (!resource) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const origin = url.origin;
|
||||
const ps = isPowerShell(request);
|
||||
const claudeDir = config.claudeDir;
|
||||
|
||||
// Check for preloaded skills (agents can reference skills)
|
||||
const preloadedSlugs = parseList(resource.fields.skills);
|
||||
const depSkills: Array<{ slug: string; name: string }> = [];
|
||||
for (const skillSlug of preloadedSlugs) {
|
||||
const skill = await getResource('skills', skillSlug);
|
||||
if (skill) {
|
||||
depSkills.push({ slug: skill.slug, name: skill.name });
|
||||
}
|
||||
}
|
||||
|
||||
const skillsClaudeDir = getTypeConfig('skills').claudeDir;
|
||||
const files = resource.files;
|
||||
|
||||
const script = ps
|
||||
? buildPS(origin, type!, slug!, resource.name, claudeDir, resource.format, files, depSkills, skillsClaudeDir)
|
||||
: buildBash(origin, type!, slug!, resource.name, claudeDir, resource.format, files, depSkills, skillsClaudeDir);
|
||||
|
||||
return new Response(script, {
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
});
|
||||
};
|
||||
|
||||
function buildBash(
|
||||
origin: string, type: string, slug: string, name: string, claudeDir: string,
|
||||
format: string, files: Array<{ relativePath: string }>,
|
||||
depSkills: Array<{ slug: string; name: string }>, skillsClaudeDir: string,
|
||||
): string {
|
||||
if (format === 'folder') {
|
||||
const lines = [
|
||||
'#!/usr/bin/env bash',
|
||||
'set -euo pipefail',
|
||||
`DEST=~/.claude/${claudeDir}/${slug}`,
|
||||
'mkdir -p "$DEST"',
|
||||
`curl -fsSL "${origin}/${type}/${slug}" -o "$DEST/${getMainFileName(type)}"`,
|
||||
];
|
||||
|
||||
for (const f of files) {
|
||||
const dir = f.relativePath.split('/').slice(0, -1).join('/');
|
||||
if (dir) {
|
||||
lines.push(`mkdir -p "$DEST/${dir}"`);
|
||||
}
|
||||
lines.push(`curl -fsSL "${origin}/api/resources/${type}/${slug}/files/${f.relativePath}" -o "$DEST/${f.relativePath}"`);
|
||||
}
|
||||
|
||||
const scriptFiles = files.filter(f => f.relativePath.startsWith('scripts/'));
|
||||
for (const f of scriptFiles) {
|
||||
lines.push(`chmod +x "$DEST/${f.relativePath}"`);
|
||||
}
|
||||
|
||||
lines.push(`echo "✓ Installed ${name} globally to ~/.claude/${claudeDir}/${slug}/ (${files.length + 1} files)"`);
|
||||
|
||||
if (depSkills.length > 0) {
|
||||
lines.push(`mkdir -p ~/.claude/${skillsClaudeDir}`);
|
||||
for (const s of depSkills) {
|
||||
lines.push(`curl -fsSL "${origin}/skills/${s.slug}" -o ~/.claude/${skillsClaudeDir}/${s.slug}.md`);
|
||||
lines.push(`echo " ↳ Installed skill ${s.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Simple file format
|
||||
const lines = [
|
||||
'#!/usr/bin/env bash',
|
||||
'set -euo pipefail',
|
||||
`mkdir -p ~/.claude/${claudeDir}`,
|
||||
`curl -fsSL "${origin}/${type}/${slug}" -o ~/.claude/${claudeDir}/${slug}.md`,
|
||||
`echo "✓ Installed ${name} globally to ~/.claude/${claudeDir}/${slug}.md"`,
|
||||
];
|
||||
|
||||
if (depSkills.length > 0) {
|
||||
lines.push(`mkdir -p ~/.claude/${skillsClaudeDir}`);
|
||||
for (const s of depSkills) {
|
||||
lines.push(`curl -fsSL "${origin}/skills/${s.slug}" -o ~/.claude/${skillsClaudeDir}/${s.slug}.md`);
|
||||
lines.push(`echo " ↳ Installed skill ${s.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildPS(
|
||||
origin: string, type: string, slug: string, name: string, claudeDir: string,
|
||||
format: string, files: Array<{ relativePath: string }>,
|
||||
depSkills: Array<{ slug: string; name: string }>, skillsClaudeDir: string,
|
||||
): string {
|
||||
if (format === 'folder') {
|
||||
const lines = [
|
||||
'$ErrorActionPreference = "Stop"',
|
||||
`$Dest = Join-Path $HOME ".claude\\${claudeDir}\\${slug}"`,
|
||||
'New-Item -ItemType Directory -Force -Path $Dest | Out-Null',
|
||||
`Invoke-WebRequest -Uri "${origin}/${type}/${slug}" -OutFile (Join-Path $Dest "${getMainFileName(type)}")`,
|
||||
];
|
||||
|
||||
for (const f of files) {
|
||||
const dir = f.relativePath.split('/').slice(0, -1).join('\\');
|
||||
if (dir) {
|
||||
lines.push(`New-Item -ItemType Directory -Force -Path (Join-Path $Dest "${dir}") | Out-Null`);
|
||||
}
|
||||
const winPath = f.relativePath.replace(/\//g, '\\');
|
||||
lines.push(`Invoke-WebRequest -Uri "${origin}/api/resources/${type}/${slug}/files/${f.relativePath}" -OutFile (Join-Path $Dest "${winPath}")`);
|
||||
}
|
||||
|
||||
lines.push(`Write-Host "✓ Installed ${name} globally to $Dest\\ (${files.length + 1} files)"`);
|
||||
|
||||
if (depSkills.length > 0) {
|
||||
lines.push(`$SkillsDir = Join-Path $HOME ".claude\\${skillsClaudeDir}"`);
|
||||
lines.push('New-Item -ItemType Directory -Force -Path $SkillsDir | Out-Null');
|
||||
for (const s of depSkills) {
|
||||
lines.push(`Invoke-WebRequest -Uri "${origin}/skills/${s.slug}" -OutFile (Join-Path $SkillsDir "${s.slug}.md")`);
|
||||
lines.push(`Write-Host " ↳ Installed skill ${s.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Simple file format
|
||||
const lines = [
|
||||
'$ErrorActionPreference = "Stop"',
|
||||
`$Dir = Join-Path $HOME ".claude\\${claudeDir}"`,
|
||||
'New-Item -ItemType Directory -Force -Path $Dir | Out-Null',
|
||||
`Invoke-WebRequest -Uri "${origin}/${type}/${slug}" -OutFile (Join-Path $Dir "${slug}.md")`,
|
||||
`Write-Host "✓ Installed ${name} globally to $Dir\\${slug}.md"`,
|
||||
];
|
||||
|
||||
if (depSkills.length > 0) {
|
||||
lines.push(`$SkillsDir = Join-Path $HOME ".claude\\${skillsClaudeDir}"`);
|
||||
lines.push('New-Item -ItemType Directory -Force -Path $SkillsDir | Out-Null');
|
||||
for (const s of depSkills) {
|
||||
lines.push(`Invoke-WebRequest -Uri "${origin}/skills/${s.slug}" -OutFile (Join-Path $SkillsDir "${s.slug}.md")`);
|
||||
lines.push(`Write-Host " ↳ Installed skill ${s.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function getMainFileName(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
skills: 'SKILL.md',
|
||||
agents: 'AGENT.md',
|
||||
'output-styles': 'OUTPUT-STYLE.md',
|
||||
rules: 'RULE.md',
|
||||
};
|
||||
return map[type] || `${type.toUpperCase()}.md`;
|
||||
}
|
||||
182
src/pages/[type]/[slug]/i.ts
Normal file
182
src/pages/[type]/[slug]/i.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { isValidResourceType, getTypeConfig, type ResourceType } from '../../../lib/registry';
|
||||
import { getResource } from '../../../lib/resources';
|
||||
import { isPowerShell } from '../../../lib/sync';
|
||||
|
||||
function parseList(val: unknown): string[] {
|
||||
if (Array.isArray(val)) return val.map(String);
|
||||
if (typeof val === 'string') return val.split(',').map(t => t.trim()).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async ({ params, url, request }) => {
|
||||
const { type, slug } = params;
|
||||
|
||||
if (!type || !isValidResourceType(type)) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const resourceType = type as ResourceType;
|
||||
const config = getTypeConfig(resourceType);
|
||||
const resource = await getResource(resourceType, slug!);
|
||||
if (!resource) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const origin = url.origin;
|
||||
const ps = isPowerShell(request);
|
||||
const claudeDir = config.claudeDir;
|
||||
|
||||
// Check for preloaded skills (agents can reference skills)
|
||||
const preloadedSlugs = parseList(resource.fields.skills);
|
||||
const depSkills: Array<{ slug: string; name: string }> = [];
|
||||
for (const skillSlug of preloadedSlugs) {
|
||||
const skill = await getResource('skills', skillSlug);
|
||||
if (skill) {
|
||||
depSkills.push({ slug: skill.slug, name: skill.name });
|
||||
}
|
||||
}
|
||||
|
||||
const skillsClaudeDir = getTypeConfig('skills').claudeDir;
|
||||
const files = resource.files;
|
||||
|
||||
const script = ps
|
||||
? buildPS(origin, type!, slug!, resource.name, claudeDir, resource.format, files, depSkills, skillsClaudeDir)
|
||||
: buildBash(origin, type!, slug!, resource.name, claudeDir, resource.format, files, depSkills, skillsClaudeDir);
|
||||
|
||||
return new Response(script, {
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
});
|
||||
};
|
||||
|
||||
function buildBash(
|
||||
origin: string, type: string, slug: string, name: string, claudeDir: string,
|
||||
format: string, files: Array<{ relativePath: string }>,
|
||||
depSkills: Array<{ slug: string; name: string }>, skillsClaudeDir: string,
|
||||
): string {
|
||||
if (format === 'folder') {
|
||||
const lines = [
|
||||
'#!/usr/bin/env bash',
|
||||
'set -euo pipefail',
|
||||
`DEST=".claude/${claudeDir}/${slug}"`,
|
||||
'mkdir -p "$DEST"',
|
||||
`curl -fsSL "${origin}/${type}/${slug}" -o "$DEST/${getMainFileName(type)}"`,
|
||||
];
|
||||
|
||||
for (const f of files) {
|
||||
const dir = f.relativePath.split('/').slice(0, -1).join('/');
|
||||
if (dir) {
|
||||
lines.push(`mkdir -p "$DEST/${dir}"`);
|
||||
}
|
||||
lines.push(`curl -fsSL "${origin}/api/resources/${type}/${slug}/files/${f.relativePath}" -o "$DEST/${f.relativePath}"`);
|
||||
}
|
||||
|
||||
// chmod +x for scripts
|
||||
const scriptFiles = files.filter(f => f.relativePath.startsWith('scripts/'));
|
||||
for (const f of scriptFiles) {
|
||||
lines.push(`chmod +x "$DEST/${f.relativePath}"`);
|
||||
}
|
||||
|
||||
lines.push(`echo "✓ Installed ${name} to .claude/${claudeDir}/${slug}/ (${files.length + 1} files)"`);
|
||||
|
||||
if (depSkills.length > 0) {
|
||||
lines.push(`mkdir -p .claude/${skillsClaudeDir}`);
|
||||
for (const s of depSkills) {
|
||||
lines.push(`curl -fsSL "${origin}/skills/${s.slug}" -o ".claude/${skillsClaudeDir}/${s.slug}.md"`);
|
||||
lines.push(`echo " ↳ Installed skill ${s.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Simple file format
|
||||
const lines = [
|
||||
'#!/usr/bin/env bash',
|
||||
'set -euo pipefail',
|
||||
`mkdir -p .claude/${claudeDir}`,
|
||||
`curl -fsSL "${origin}/${type}/${slug}" -o ".claude/${claudeDir}/${slug}.md"`,
|
||||
`echo "✓ Installed ${name} to .claude/${claudeDir}/${slug}.md"`,
|
||||
];
|
||||
|
||||
if (depSkills.length > 0) {
|
||||
lines.push(`mkdir -p .claude/${skillsClaudeDir}`);
|
||||
for (const s of depSkills) {
|
||||
lines.push(`curl -fsSL "${origin}/skills/${s.slug}" -o ".claude/${skillsClaudeDir}/${s.slug}.md"`);
|
||||
lines.push(`echo " ↳ Installed skill ${s.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildPS(
|
||||
origin: string, type: string, slug: string, name: string, claudeDir: string,
|
||||
format: string, files: Array<{ relativePath: string }>,
|
||||
depSkills: Array<{ slug: string; name: string }>, skillsClaudeDir: string,
|
||||
): string {
|
||||
if (format === 'folder') {
|
||||
const lines = [
|
||||
'$ErrorActionPreference = "Stop"',
|
||||
`$Dest = ".claude\\${claudeDir}\\${slug}"`,
|
||||
'New-Item -ItemType Directory -Force -Path $Dest | Out-Null',
|
||||
`Invoke-WebRequest -Uri "${origin}/${type}/${slug}" -OutFile (Join-Path $Dest "${getMainFileName(type)}")`,
|
||||
];
|
||||
|
||||
for (const f of files) {
|
||||
const dir = f.relativePath.split('/').slice(0, -1).join('\\');
|
||||
if (dir) {
|
||||
lines.push(`New-Item -ItemType Directory -Force -Path (Join-Path $Dest "${dir}") | Out-Null`);
|
||||
}
|
||||
const winPath = f.relativePath.replace(/\//g, '\\');
|
||||
lines.push(`Invoke-WebRequest -Uri "${origin}/api/resources/${type}/${slug}/files/${f.relativePath}" -OutFile (Join-Path $Dest "${winPath}")`);
|
||||
}
|
||||
|
||||
lines.push(`Write-Host "✓ Installed ${name} to .claude\\${claudeDir}\\${slug}\\ (${files.length + 1} files)"`);
|
||||
|
||||
if (depSkills.length > 0) {
|
||||
lines.push(`$SkillsDir = ".claude\\${skillsClaudeDir}"`);
|
||||
lines.push('New-Item -ItemType Directory -Force -Path $SkillsDir | Out-Null');
|
||||
for (const s of depSkills) {
|
||||
lines.push(`Invoke-WebRequest -Uri "${origin}/skills/${s.slug}" -OutFile (Join-Path $SkillsDir "${s.slug}.md")`);
|
||||
lines.push(`Write-Host " ↳ Installed skill ${s.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Simple file format
|
||||
const lines = [
|
||||
'$ErrorActionPreference = "Stop"',
|
||||
`$Dir = ".claude\\${claudeDir}"`,
|
||||
'New-Item -ItemType Directory -Force -Path $Dir | Out-Null',
|
||||
`Invoke-WebRequest -Uri "${origin}/${type}/${slug}" -OutFile (Join-Path $Dir "${slug}.md")`,
|
||||
`Write-Host "✓ Installed ${name} to $Dir\\${slug}.md"`,
|
||||
];
|
||||
|
||||
if (depSkills.length > 0) {
|
||||
lines.push(`$SkillsDir = ".claude\\${skillsClaudeDir}"`);
|
||||
lines.push('New-Item -ItemType Directory -Force -Path $SkillsDir | Out-Null');
|
||||
for (const s of depSkills) {
|
||||
lines.push(`Invoke-WebRequest -Uri "${origin}/skills/${s.slug}" -OutFile (Join-Path $SkillsDir "${s.slug}.md")`);
|
||||
lines.push(`Write-Host " ↳ Installed skill ${s.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function getMainFileName(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
skills: 'SKILL.md',
|
||||
agents: 'AGENT.md',
|
||||
'output-styles': 'OUTPUT-STYLE.md',
|
||||
rules: 'RULE.md',
|
||||
};
|
||||
return map[type] || `${type.toUpperCase()}.md`;
|
||||
}
|
||||
85
src/pages/[type]/new.astro
Normal file
85
src/pages/[type]/new.astro
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import ResourceEditor from '../../components/ResourceEditor.vue';
|
||||
import { isValidResourceType, getTypeConfig, REGISTRY, type ResourceType } from '../../lib/registry';
|
||||
import { getResource, getAllTags, listResources } from '../../lib/resources';
|
||||
import { getAvailableTools } from '../../lib/tools';
|
||||
import { getAvailableModels } from '../../lib/models';
|
||||
|
||||
const { type } = Astro.params;
|
||||
|
||||
if (!type || !isValidResourceType(type)) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const resourceType = type as ResourceType;
|
||||
const config = getTypeConfig(resourceType);
|
||||
const availableTools = await getAvailableTools();
|
||||
const availableModels = await getAvailableModels();
|
||||
const availableTags = await getAllTags(resourceType);
|
||||
const skillSlugs = (await listResources('skills')).map(r => r.slug);
|
||||
|
||||
// Fork support: /skills/new?from=original-slug
|
||||
const fromSlug = Astro.url.searchParams.get('from');
|
||||
let forkSource: Awaited<ReturnType<typeof getResource>> = null;
|
||||
if (fromSlug) {
|
||||
forkSource = await getResource(resourceType, fromSlug);
|
||||
}
|
||||
|
||||
const isFork = Boolean(forkSource);
|
||||
const title = isFork ? `Fork ${forkSource!.name} — Grimoired` : `New ${config.labelSingular} — Grimoired`;
|
||||
|
||||
// Build initial field values from fork source
|
||||
const initialFieldValues: Record<string, unknown> = {};
|
||||
if (forkSource) {
|
||||
for (const field of config.fields) {
|
||||
const val = forkSource.fields[field.key];
|
||||
if (val !== undefined && val !== null) {
|
||||
if (field.type === 'toggle-grid' || field.type === 'tags') {
|
||||
// Normalize to arrays
|
||||
if (Array.isArray(val)) {
|
||||
initialFieldValues[field.key] = val;
|
||||
} else if (typeof val === 'string') {
|
||||
initialFieldValues[field.key] = val.split(',').map((t: string) => t.trim()).filter(Boolean);
|
||||
}
|
||||
} else if (field.type === 'json') {
|
||||
initialFieldValues[field.key] = typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val);
|
||||
} else {
|
||||
initialFieldValues[field.key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
<Base title={title}>
|
||||
<a href={isFork ? `/${type}/${fromSlug}` : '/'} class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-300 transition-colors mb-4">
|
||||
<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="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
{isFork ? `Back to ${forkSource!.name}` : 'Back'}
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-white mb-2">{isFork ? `Fork ${config.labelSingular}` : `New ${config.labelSingular}`}</h1>
|
||||
{isFork ? (
|
||||
<p class="text-sm text-gray-500 mb-8">Creating an independent copy of <strong class="text-gray-400">{forkSource!.name}</strong>. Change the <strong class="text-gray-400">name</strong> to generate a new slug before saving.</p>
|
||||
) : (
|
||||
<p class="text-sm text-gray-500 mb-8 max-w-xl">Create a new {config.labelSingular.toLowerCase()}. Write instructions in Markdown in the <strong class="text-gray-400">body</strong> section.</p>
|
||||
)}
|
||||
<ResourceEditor
|
||||
resourceType={type}
|
||||
typeSingular={config.labelSingular}
|
||||
typeFields={config.fields}
|
||||
mode="create"
|
||||
forkOf={isFork ? fromSlug! : undefined}
|
||||
initialName={forkSource?.name || ''}
|
||||
initialDescription={forkSource?.description || ''}
|
||||
initialTags={forkSource?.tags.join(', ') || ''}
|
||||
initialBody={forkSource?.content || ''}
|
||||
initialFieldValues={JSON.stringify(initialFieldValues)}
|
||||
availableTools={availableTools}
|
||||
availableModels={availableModels}
|
||||
availableSkills={skillSlugs}
|
||||
availableTags={availableTags.join(',')}
|
||||
client:load
|
||||
/>
|
||||
</Base>
|
||||
129
src/pages/api/resources/[type]/[slug].ts
Normal file
129
src/pages/api/resources/[type]/[slug].ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { isValidResourceType } from '../../../../lib/registry';
|
||||
import { getResource, updateResource, deleteResource } from '../../../../lib/resources';
|
||||
import { verifyToken, extractBearerToken, hasToken } from '../../../../lib/tokens';
|
||||
import { recordPush } from '../../../../lib/stats';
|
||||
|
||||
export const GET: APIRoute = async ({ params, request }) => {
|
||||
const type = params.type!;
|
||||
if (!isValidResourceType(type)) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const resource = await getResource(type, params.slug!);
|
||||
if (!resource) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
// If JSON requested, include format and files metadata
|
||||
const accept = request.headers.get('accept') || '';
|
||||
if (accept.includes('application/json')) {
|
||||
return new Response(JSON.stringify(resource), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(resource.raw, {
|
||||
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
|
||||
});
|
||||
};
|
||||
|
||||
export const PUT: APIRoute = async ({ params, request }) => {
|
||||
const type = params.type!;
|
||||
if (!isValidResourceType(type)) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid resource type' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
let body: { content?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Invalid JSON' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (!body.content) {
|
||||
return new Response(JSON.stringify({ error: 'content is required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await getResource(type, params.slug!);
|
||||
if (!existing) {
|
||||
return new Response(JSON.stringify({ error: 'Not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (existing['author-email'] && await hasToken(existing['author-email'])) {
|
||||
const token = extractBearerToken(request);
|
||||
const valid = await verifyToken(existing['author-email'], token);
|
||||
if (!valid) {
|
||||
return new Response(JSON.stringify({ error: `Only ${existing.author || existing['author-email']} can update this resource.` }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const resource = await updateResource(type, params.slug!, body.content);
|
||||
recordPush(params.slug!, type);
|
||||
return new Response(JSON.stringify(resource), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ params, request }) => {
|
||||
const type = params.type!;
|
||||
if (!isValidResourceType(type)) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid resource type' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await getResource(type, params.slug!);
|
||||
if (!existing) {
|
||||
return new Response(JSON.stringify({ error: 'Not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (existing['author-email'] && await hasToken(existing['author-email'])) {
|
||||
const token = extractBearerToken(request);
|
||||
const valid = await verifyToken(existing['author-email'], token);
|
||||
if (!valid) {
|
||||
return new Response(JSON.stringify({ error: `Only ${existing.author || existing['author-email']} can delete this resource.` }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await deleteResource(type, params.slug!);
|
||||
return new Response(null, { status: 204 });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
};
|
||||
113
src/pages/api/resources/[type]/[slug]/files/[...filePath].ts
Normal file
113
src/pages/api/resources/[type]/[slug]/files/[...filePath].ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { isValidResourceType } from '../../../../../../lib/registry';
|
||||
import { getResource, getResourceFile, addResourceFile, deleteResourceFile } from '../../../../../../lib/resources';
|
||||
import { verifyToken, extractBearerToken, hasToken } from '../../../../../../lib/tokens';
|
||||
import { lookup } from 'mrmime';
|
||||
|
||||
async function checkAuth(request: Request, resource: { 'author-email': string; author: string }): Promise<Response | null> {
|
||||
if (resource['author-email'] && await hasToken(resource['author-email'])) {
|
||||
const token = extractBearerToken(request);
|
||||
const valid = await verifyToken(resource['author-email'], token);
|
||||
if (!valid) {
|
||||
return new Response(JSON.stringify({ error: 'Permission denied' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const { type, slug, filePath } = params;
|
||||
if (!type || !isValidResourceType(type) || !filePath) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const data = await getResourceFile(type, slug!, filePath);
|
||||
if (!data) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
const EXTRA_TEXT: Record<string, string> = {
|
||||
sh: 'text/x-shellscript', bash: 'text/x-shellscript', zsh: 'text/x-shellscript',
|
||||
py: 'text/x-python', rb: 'text/x-ruby', go: 'text/x-go', rs: 'text/x-rust',
|
||||
ts: 'text/typescript', tsx: 'text/typescript', jsx: 'text/javascript',
|
||||
yml: 'text/yaml', toml: 'text/toml', cfg: 'text/plain', conf: 'text/plain',
|
||||
ini: 'text/plain', env: 'text/plain', tpl: 'text/plain', tmpl: 'text/plain',
|
||||
hbs: 'text/plain', ejs: 'text/plain', sql: 'text/sql', txt: 'text/plain',
|
||||
log: 'text/plain', csv: 'text/csv',
|
||||
};
|
||||
const mime = lookup(filePath) || EXTRA_TEXT[ext] || 'application/octet-stream';
|
||||
return new Response(data, {
|
||||
headers: { 'Content-Type': mime },
|
||||
});
|
||||
};
|
||||
|
||||
export const PUT: APIRoute = async ({ params, request }) => {
|
||||
const { type, slug, filePath } = params;
|
||||
if (!type || !isValidResourceType(type) || !filePath) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid params' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const resource = await getResource(type, slug!);
|
||||
if (!resource) {
|
||||
return new Response(JSON.stringify({ error: 'Not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const authErr = await checkAuth(request, resource);
|
||||
if (authErr) return authErr;
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(await request.arrayBuffer());
|
||||
await addResourceFile(type, slug!, filePath, buffer);
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ params, request }) => {
|
||||
const { type, slug, filePath } = params;
|
||||
if (!type || !isValidResourceType(type) || !filePath) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid params' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const resource = await getResource(type, slug!);
|
||||
if (!resource) {
|
||||
return new Response(JSON.stringify({ error: 'Not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const authErr = await checkAuth(request, resource);
|
||||
if (authErr) return authErr;
|
||||
|
||||
try {
|
||||
await deleteResourceFile(type, slug!, filePath);
|
||||
return new Response(null, { status: 204 });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
};
|
||||
83
src/pages/api/resources/[type]/[slug]/files/index.ts
Normal file
83
src/pages/api/resources/[type]/[slug]/files/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { isValidResourceType } from '../../../../../../lib/registry';
|
||||
import { listResourceFiles, addResourceFile, getResource } from '../../../../../../lib/resources';
|
||||
import { verifyToken, extractBearerToken, hasToken } from '../../../../../../lib/tokens';
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const { type, slug } = params;
|
||||
if (!type || !isValidResourceType(type)) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid resource type' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const files = await listResourceFiles(type, slug!);
|
||||
return new Response(JSON.stringify({ files }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ params, request }) => {
|
||||
const { type, slug } = params;
|
||||
if (!type || !isValidResourceType(type)) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid resource type' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Auth check
|
||||
const resource = await getResource(type, slug!);
|
||||
if (!resource) {
|
||||
return new Response(JSON.stringify({ error: 'Not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (resource.format !== 'folder') {
|
||||
return new Response(JSON.stringify({ error: 'Resource is not a folder' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (resource['author-email'] && await hasToken(resource['author-email'])) {
|
||||
const token = extractBearerToken(request);
|
||||
const valid = await verifyToken(resource['author-email'], token);
|
||||
if (!valid) {
|
||||
return new Response(JSON.stringify({ error: 'Permission denied' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const relativePath = formData.get('path') as string | null;
|
||||
|
||||
if (!file || !relativePath) {
|
||||
return new Response(JSON.stringify({ error: 'file and path are required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await addResourceFile(type, slug!, relativePath, buffer);
|
||||
|
||||
return new Response(JSON.stringify({ ok: true, path: relativePath }), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
};
|
||||
91
src/pages/api/resources/[type]/index.ts
Normal file
91
src/pages/api/resources/[type]/index.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import matter from 'gray-matter';
|
||||
import { isValidResourceType } from '../../../../lib/registry';
|
||||
import { listResources, createResource, isValidSlug } from '../../../../lib/resources';
|
||||
import { verifyToken, extractBearerToken, hasToken } from '../../../../lib/tokens';
|
||||
import { recordPush } from '../../../../lib/stats';
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const type = params.type!;
|
||||
if (!isValidResourceType(type)) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid resource type' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const resources = await listResources(type);
|
||||
return new Response(JSON.stringify(resources), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
export const POST: APIRoute = async ({ params, request }) => {
|
||||
const type = params.type!;
|
||||
if (!isValidResourceType(type)) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid resource type' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
let body: { slug?: string; content?: string; format?: 'file' | 'folder' };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Invalid JSON' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const { slug, content, format } = body;
|
||||
|
||||
if (!slug || !content) {
|
||||
return new Response(JSON.stringify({ error: 'slug and content are required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (!isValidSlug(slug)) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid slug. Use lowercase alphanumeric and hyphens, 2-64 chars.' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = matter(content);
|
||||
const authorEmail = (parsed.data['author-email'] as string) || '';
|
||||
if (authorEmail && await hasToken(authorEmail)) {
|
||||
const token = extractBearerToken(request);
|
||||
const valid = await verifyToken(authorEmail, token);
|
||||
if (!valid) {
|
||||
return new Response(JSON.stringify({ error: 'Valid token required. Register first via POST /api/auth/register.' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const resource = await createResource(type, slug, content, format);
|
||||
recordPush(slug, type);
|
||||
return new Response(JSON.stringify(resource), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
if (message.includes('already exists')) {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 409,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import SkillCard from '../components/SkillCard.astro';
|
||||
import SkillSearch from '../components/SkillSearch.vue';
|
||||
import { listSkills } from '../lib/skills';
|
||||
import ResourceCard from '../components/ResourceCard.astro';
|
||||
import ResourceSearch from '../components/ResourceSearch.vue';
|
||||
import { listResources, listAllResources } from '../lib/resources';
|
||||
import { RESOURCE_TYPES, REGISTRY } from '../lib/registry';
|
||||
import { getAllStats } from '../lib/stats';
|
||||
import { buildSyncScript, buildSyncScriptPS, isPowerShell } from '../lib/sync';
|
||||
|
||||
@@ -17,38 +18,62 @@ if (!accept.includes('text/html')) {
|
||||
});
|
||||
}
|
||||
|
||||
const skills = await listSkills();
|
||||
// Fetch all resources grouped by type
|
||||
const resourcesByType: Record<string, Awaited<ReturnType<typeof listResources>>> = {};
|
||||
const typeCounts: Record<string, number> = {};
|
||||
for (const type of RESOURCE_TYPES) {
|
||||
const resources = await listResources(type);
|
||||
resourcesByType[type] = resources;
|
||||
typeCounts[type] = resources.length;
|
||||
}
|
||||
|
||||
// Compute fork counts and unique authors
|
||||
const allResources = Object.entries(resourcesByType).flatMap(([type, resources]) =>
|
||||
resources.map(r => ({ ...r, type }))
|
||||
).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Compute fork counts
|
||||
const forkCounts = new Map<string, number>();
|
||||
for (const s of skills) {
|
||||
if (s['fork-of']) {
|
||||
forkCounts.set(s['fork-of'], (forkCounts.get(s['fork-of']) || 0) + 1);
|
||||
for (const r of allResources) {
|
||||
if (r['fork-of']) {
|
||||
const key = `${r.type}:${r['fork-of']}`;
|
||||
forkCounts.set(key, (forkCounts.get(key) || 0) + 1);
|
||||
}
|
||||
}
|
||||
const authors = [...new Set(skills.map(s => s.author).filter(Boolean))].sort();
|
||||
const allTags = [...new Set(skills.flatMap(s => s.tags))].sort();
|
||||
const allStats = await getAllStats();
|
||||
|
||||
const authors = [...new Set(allResources.map(r => r.author).filter(Boolean))].sort();
|
||||
const allTags = [...new Set(allResources.flatMap(r => r.tags))].sort();
|
||||
|
||||
// Get stats for all types
|
||||
const allStatsMap: Record<string, Record<string, { downloads: number; pushes: number; lastPushedAt: string | null }>> = {};
|
||||
for (const type of RESOURCE_TYPES) {
|
||||
allStatsMap[type] = await getAllStats(type);
|
||||
}
|
||||
|
||||
function parseTools(val: unknown): string[] {
|
||||
if (Array.isArray(val)) return val.map(String);
|
||||
if (typeof val === 'string') return val.split(',').map(t => t.trim()).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
---
|
||||
|
||||
<Base title="Skills">
|
||||
{skills.length === 0 ? (
|
||||
<Base title="Grimoired">
|
||||
{allResources.length === 0 ? (
|
||||
<div class="text-center py-24">
|
||||
<div class="inline-flex h-16 w-16 items-center justify-center rounded-2xl bg-surface-200 border border-white/[0.06] mb-6">
|
||||
<svg class="h-7 w-7 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.25m3.75 9v6m3-3H9m1.5-12H5.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>
|
||||
</div>
|
||||
<p class="text-gray-500 text-lg mb-2">No skills yet</p>
|
||||
<p class="text-gray-600 text-sm mb-6">Create your first skill to get started.</p>
|
||||
<p class="text-gray-500 text-lg mb-2">No resources yet</p>
|
||||
<p class="text-gray-600 text-sm mb-6">Create your first skill, agent, output style, or rule to get started.</p>
|
||||
<a
|
||||
href="/new"
|
||||
href="/skills/new"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-accent-500 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-accent-500/20 hover:bg-accent-600 transition-all"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Create your first skill
|
||||
Create your first resource
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
@@ -56,17 +81,19 @@ const allStats = await getAllStats();
|
||||
<!-- Hero / Quick install -->
|
||||
<div class="mb-10 grid gap-6 lg:grid-cols-2 lg:items-start">
|
||||
<div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-6">
|
||||
<h1 class="text-3xl font-extrabold tracking-tight text-white mb-2">Skills</h1>
|
||||
<p class="text-gray-500 mb-3">Manage and distribute Claude Code skills. Skills are prompt files (<code class="text-gray-400 font-mono bg-white/[0.04] px-1 py-0.5 rounded text-xs">.md</code>) that Claude loads automatically to learn custom behaviors and workflows.</p>
|
||||
<p class="text-gray-600 text-sm leading-relaxed">Create reusable skills to standardize how Claude handles commits, code reviews, testing, deployments, and more across your team. Share them instantly with a single curl command.</p>
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<img src="/grimoired.svg" alt="Grimoired logo" class="h-12 w-12" />
|
||||
<h1 class="text-3xl font-extrabold tracking-tight text-white">Grimoired</h1>
|
||||
</div>
|
||||
<p class="text-gray-500 mb-3"><strong class="text-gray-400">Grimoired</strong> (from <em>grimoire</em> — a book of spells, originally French) is a shared registry for Claude Code resources: skills, agents, output styles, and rules. Resources can be simple prompt files (<code class="text-gray-400 font-mono bg-white/[0.04] px-1 py-0.5 rounded text-xs">.md</code>) or full packages with scripts, references, and assets that Claude picks up automatically.</p>
|
||||
<p class="text-gray-600 text-sm leading-relaxed">Create, browse, and share reusable prompts that standardize how Claude handles tasks across your team. Install them instantly with a single curl command.</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick install + Quick push -->
|
||||
<div class="space-y-2">
|
||||
<!-- Quick install -->
|
||||
<details class="group rounded-2xl border border-white/[0.06] bg-surface-100">
|
||||
<details open data-accordion class="group rounded-2xl border border-white/[0.06] bg-surface-100">
|
||||
<summary class="flex items-center justify-between cursor-pointer px-6 py-4 select-none">
|
||||
<h2 class="text-sm font-semibold text-white">Quick install</h2>
|
||||
<h2 class="text-sm font-semibold text-white">Quick install (skills)</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="hidden group-open:flex rounded-lg border border-white/[0.06] overflow-hidden os-tabs" onclick="event.stopPropagation()">
|
||||
<button data-os="unix" class="os-tab px-2.5 py-1 text-[11px] font-medium transition-all">macOS / Linux</button>
|
||||
@@ -97,10 +124,9 @@ const allStats = await getAllStats();
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Quick push -->
|
||||
<details class="group rounded-2xl border border-white/[0.06] bg-surface-100">
|
||||
<details data-accordion class="group rounded-2xl border border-white/[0.06] bg-surface-100">
|
||||
<summary class="flex items-center justify-between cursor-pointer px-6 py-4 select-none">
|
||||
<h2 class="text-sm font-semibold text-white">Quick push</h2>
|
||||
<h2 class="text-sm font-semibold text-white">Quick push (skills)</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="hidden group-open:flex rounded-lg border border-white/[0.06] overflow-hidden os-tabs" onclick="event.stopPropagation()">
|
||||
<button data-os="unix" class="os-tab px-2.5 py-1 text-[11px] font-medium transition-all">macOS / Linux</button>
|
||||
@@ -118,87 +144,106 @@ const allStats = await getAllStats();
|
||||
<code data-cmd="win" class="flex-1 text-sm font-mono text-gray-400 select-all truncate hidden">irm {Astro.url.origin}/p | iex</code>
|
||||
<button data-copy class="shrink-0 rounded-md bg-white/[0.06] border border-white/[0.06] px-2.5 py-1 text-xs font-medium text-gray-400 hover:text-white hover:bg-white/[0.1] transition-all">Copy</button>
|
||||
</div>
|
||||
<details class="group/inner">
|
||||
<summary class="text-xs text-gray-600 cursor-pointer hover:text-gray-400 transition-colors">Push a specific skill</summary>
|
||||
<div class="mt-2">
|
||||
<div class="flex items-center gap-3 rounded-lg bg-surface-50 border border-white/[0.06] px-3 py-2">
|
||||
<code data-cmd="unix" class="flex-1 text-xs font-mono text-gray-400 select-all truncate">curl -fsSL {Astro.url.origin}/p | bash -s skill-name</code>
|
||||
<code data-cmd="win" class="flex-1 text-xs font-mono text-gray-400 select-all truncate hidden">irm {Astro.url.origin}/p | iex -Args skill-name</code>
|
||||
<button data-copy class="shrink-0 rounded bg-white/[0.06] border border-white/[0.06] px-2 py-0.5 text-xs font-medium text-gray-500 hover:text-white hover:bg-white/[0.1] transition-all">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search + Grid + Table -->
|
||||
<SkillSearch authors={authors.join(',')} tags={allTags.join(',')} totalCount={skills.length} client:load />
|
||||
<div id="skills-grid" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{skills.map((skill) => (
|
||||
<div
|
||||
data-skill
|
||||
data-name={skill.name.toLowerCase()}
|
||||
data-description={skill.description.toLowerCase()}
|
||||
data-tools={skill['allowed-tools'].join(' ').toLowerCase()}
|
||||
data-author={skill.author.toLowerCase()}
|
||||
data-tags={skill.tags.join(',').toLowerCase()}
|
||||
data-forks={String(forkCounts.get(skill.slug) || 0)}
|
||||
>
|
||||
<SkillCard {...skill} forkCount={forkCounts.get(skill.slug) || 0} downloads={allStats[skill.slug]?.downloads || 0} pushes={allStats[skill.slug]?.pushes || 0} lastPushedAt={allStats[skill.slug]?.lastPushedAt || null} />
|
||||
</div>
|
||||
))}
|
||||
<ResourceSearch
|
||||
authors={authors.join(',')}
|
||||
tags={allTags.join(',')}
|
||||
totalCount={allResources.length}
|
||||
typeCounts={JSON.stringify(typeCounts)}
|
||||
client:load
|
||||
/>
|
||||
|
||||
<div id="resources-grid" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{allResources.map((r) => {
|
||||
const config = REGISTRY[r.type as keyof typeof REGISTRY];
|
||||
const stats = allStatsMap[r.type]?.[r.slug] || { downloads: 0, pushes: 0, lastPushedAt: null };
|
||||
const fc = forkCounts.get(`${r.type}:${r.slug}`) || 0;
|
||||
const tools = parseTools(r.fields['allowed-tools'] ?? r.fields.allowedTools);
|
||||
return (
|
||||
<div
|
||||
data-resource
|
||||
data-name={r.name.toLowerCase()}
|
||||
data-description={r.description.toLowerCase()}
|
||||
data-tools={tools.join(' ').toLowerCase()}
|
||||
data-author={r.author.toLowerCase()}
|
||||
data-tags={r.tags.join(',').toLowerCase()}
|
||||
data-type={r.type}
|
||||
data-forks={String(fc)}
|
||||
>
|
||||
<ResourceCard
|
||||
resourceType={r.type}
|
||||
slug={r.slug}
|
||||
name={r.name}
|
||||
description={r.description}
|
||||
tags={r.tags}
|
||||
author={r.author}
|
||||
forkCount={fc}
|
||||
downloads={stats.downloads}
|
||||
pushes={stats.pushes}
|
||||
lastPushedAt={stats.lastPushedAt}
|
||||
typeLabel={config.labelSingular}
|
||||
typeColor={config.color}
|
||||
tools={tools}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div id="skills-table" class="hidden overflow-x-auto rounded-2xl border border-white/[0.06]">
|
||||
<div id="resources-table" class="hidden overflow-x-auto rounded-2xl border border-white/[0.06]">
|
||||
<table class="w-full text-sm text-left">
|
||||
<thead class="bg-surface-100 border-b border-white/[0.06]">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Type</th>
|
||||
<th class="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Name</th>
|
||||
<th class="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Description</th>
|
||||
<th class="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Tools</th>
|
||||
<th class="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Tags</th>
|
||||
<th class="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Author</th>
|
||||
<th class="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/[0.04]">
|
||||
{skills.map((skill) => {
|
||||
const desc = skill.description.length > 80 ? skill.description.slice(0, 80) + '...' : skill.description;
|
||||
const fc = forkCounts.get(skill.slug) || 0;
|
||||
const st = allStats[skill.slug] || { downloads: 0, pushes: 0 };
|
||||
{allResources.map((r) => {
|
||||
const config = REGISTRY[r.type as keyof typeof REGISTRY];
|
||||
const stats = allStatsMap[r.type]?.[r.slug] || { downloads: 0, pushes: 0, lastPushedAt: null };
|
||||
const fc = forkCounts.get(`${r.type}:${r.slug}`) || 0;
|
||||
const desc = r.description.length > 80 ? r.description.slice(0, 80) + '...' : r.description;
|
||||
const tools = parseTools(r.fields['allowed-tools'] ?? r.fields.allowedTools);
|
||||
return (
|
||||
<tr
|
||||
data-skill
|
||||
data-name={skill.name.toLowerCase()}
|
||||
data-description={skill.description.toLowerCase()}
|
||||
data-tools={skill['allowed-tools'].join(' ').toLowerCase()}
|
||||
data-author={skill.author.toLowerCase()}
|
||||
data-tags={skill.tags.join(',').toLowerCase()}
|
||||
data-resource
|
||||
data-name={r.name.toLowerCase()}
|
||||
data-description={r.description.toLowerCase()}
|
||||
data-tools={tools.join(' ').toLowerCase()}
|
||||
data-author={r.author.toLowerCase()}
|
||||
data-tags={r.tags.join(',').toLowerCase()}
|
||||
data-type={r.type}
|
||||
data-forks={String(fc)}
|
||||
class="bg-surface-50 hover:bg-surface-100 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<a href={`/${skill.slug}`} class="font-medium text-white hover:text-accent-400 transition-colors">{skill.name}</a>
|
||||
<span class="rounded-full px-2 py-0.5 text-[10px] font-semibold" style={`background: ${config.color}20; color: ${config.color};`}>
|
||||
{config.labelSingular}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<a href={`/${r.type}/${r.slug}`} class="font-medium text-white hover:text-accent-400 transition-colors">{r.name}</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 max-w-xs truncate">{desc}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{skill['allowed-tools'].map((tool) => (
|
||||
<span class="rounded-md bg-white/[0.04] border border-white/[0.06] px-1.5 py-0.5 text-[11px] font-medium text-gray-400">{tool}</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{skill.tags.map((tag) => (
|
||||
{r.tags.map((tag) => (
|
||||
<span class="rounded-full bg-[var(--color-accent-500)]/10 border border-[var(--color-accent-500)]/20 px-2 py-0.5 text-[11px] font-medium text-[var(--color-accent-400)]">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 whitespace-nowrap">{skill.author || '—'}</td>
|
||||
<td class="px-4 py-3 text-gray-500 whitespace-nowrap text-xs">{allStats[skill.slug]?.lastPushedAt ? new Date(allStats[skill.slug].lastPushedAt!).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</td>
|
||||
<td class="px-4 py-3 text-gray-500 whitespace-nowrap">{r.author || '—'}</td>
|
||||
<td class="px-4 py-3 text-gray-500 whitespace-nowrap text-xs">{stats.lastPushedAt ? new Date(stats.lastPushedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -214,7 +259,6 @@ const allStats = await getAllStats();
|
||||
.os-tab.active { background: rgba(255,255,255,0.06); color: white; }
|
||||
</style>
|
||||
<script>
|
||||
// OS detection + tab switching
|
||||
const isWin = /Win/.test(navigator.platform);
|
||||
function setOS(os: string) {
|
||||
document.querySelectorAll<HTMLElement>('[data-cmd]').forEach(el => {
|
||||
@@ -229,7 +273,6 @@ const allStats = await getAllStats();
|
||||
tab.addEventListener('click', () => setOS(tab.dataset.os!));
|
||||
});
|
||||
|
||||
// Copy buttons
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-copy]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const container = btn.parentElement!;
|
||||
@@ -242,4 +285,15 @@ const allStats = await getAllStats();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Accordion: only one details[data-accordion] open at a time
|
||||
document.querySelectorAll<HTMLDetailsElement>('details[data-accordion]').forEach((detail) => {
|
||||
detail.addEventListener('toggle', () => {
|
||||
if (detail.open) {
|
||||
document.querySelectorAll<HTMLDetailsElement>('details[data-accordion]').forEach((other) => {
|
||||
if (other !== detail) other.removeAttribute('open');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user