212 lines
7.3 KiB
Vue
212 lines
7.3 KiB
Vue
|
|
<script setup>
|
|||
|
|
import { Head, Link, router } from '@inertiajs/vue3';
|
|||
|
|
import { ref } from 'vue';
|
|||
|
|
|
|||
|
|
import ConfirmDialog from '../../Components/ConfirmDialog.vue';
|
|||
|
|
import RenderPreview from '../../Components/RenderPreview.vue';
|
|||
|
|
import TagPill from '../../Components/TagPill.vue';
|
|||
|
|
import { formatDate } from '../../lib/format.js';
|
|||
|
|
|
|||
|
|
const props = defineProps({
|
|||
|
|
prompt: { type: Object, required: true },
|
|||
|
|
variables: { type: Array, required: true },
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const rendered = ref(null);
|
|||
|
|
const useError = ref(null);
|
|||
|
|
const using = ref(false);
|
|||
|
|
const showDelete = ref(false);
|
|||
|
|
const bodyCopied = ref(false);
|
|||
|
|
|
|||
|
|
const favorite = () => {
|
|||
|
|
router.post(`/prompts/${props.prompt.id}/toggle-favorite`, {}, {
|
|||
|
|
preserveScroll: true,
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const duplicate = () => {
|
|||
|
|
router.post(`/prompts/${props.prompt.id}/duplicate`);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const usePrompt = (values) => {
|
|||
|
|
using.value = true;
|
|||
|
|
rendered.value = null;
|
|||
|
|
useError.value = null;
|
|||
|
|
|
|||
|
|
fetch(`/prompts/${props.prompt.id}/use`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/json',
|
|||
|
|
Accept: 'application/json',
|
|||
|
|
},
|
|||
|
|
body: JSON.stringify({ values }),
|
|||
|
|
})
|
|||
|
|
.then(async (response) => {
|
|||
|
|
const payload = await response.json().catch(() => ({}));
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
useError.value = {
|
|||
|
|
message: payload.message ?? 'Could not render the prompt.',
|
|||
|
|
missing: payload.missing ?? [],
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
rendered.value = payload.rendered;
|
|||
|
|
router.reload({ preserveScroll: true });
|
|||
|
|
})
|
|||
|
|
.catch(() => {
|
|||
|
|
useError.value = { message: 'Request failed.' };
|
|||
|
|
})
|
|||
|
|
.finally(() => {
|
|||
|
|
using.value = false;
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const confirmDelete = () => {
|
|||
|
|
showDelete.value = false;
|
|||
|
|
router.delete(`/prompts/${props.prompt.id}`);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const copyBody = async () => {
|
|||
|
|
try {
|
|||
|
|
await navigator.clipboard.writeText(props.prompt.body);
|
|||
|
|
bodyCopied.value = true;
|
|||
|
|
setTimeout(() => (bodyCopied.value = false), 1500);
|
|||
|
|
} catch {
|
|||
|
|
// Clipboard unavailable — ignore.
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<Head :title="prompt.name" />
|
|||
|
|
<div>
|
|||
|
|
<Link href="/prompts" class="text-sm text-slate-500 hover:text-slate-800 hover:underline">
|
|||
|
|
← All prompts
|
|||
|
|
</Link>
|
|||
|
|
|
|||
|
|
<div class="mt-2 flex flex-wrap items-start justify-between gap-3">
|
|||
|
|
<div class="min-w-0">
|
|||
|
|
<div class="flex items-center gap-2">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
class="text-xl leading-none"
|
|||
|
|
:class="prompt.is_favorite ? 'text-amber-500' : 'text-slate-300 hover:text-slate-400'"
|
|||
|
|
:aria-label="prompt.is_favorite ? 'Unfavorite' : 'Favorite'"
|
|||
|
|
@click="favorite"
|
|||
|
|
>
|
|||
|
|
★
|
|||
|
|
</button>
|
|||
|
|
<h1 class="truncate text-2xl font-semibold tracking-tight text-slate-900">
|
|||
|
|
{{ prompt.name }}
|
|||
|
|
</h1>
|
|||
|
|
</div>
|
|||
|
|
<p v-if="prompt.description" class="mt-1 text-sm text-slate-600">
|
|||
|
|
{{ prompt.description }}
|
|||
|
|
</p>
|
|||
|
|
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
|||
|
|
<TagPill
|
|||
|
|
v-for="tag in prompt.tags"
|
|||
|
|
:key="tag.id"
|
|||
|
|
:name="tag.name"
|
|||
|
|
filterable
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="flex items-center gap-2 text-sm">
|
|||
|
|
<Link
|
|||
|
|
:href="`/prompts/${prompt.id}/edit`"
|
|||
|
|
class="rounded-md border border-slate-300 bg-white px-3 py-1.5 shadow-sm hover:bg-slate-50"
|
|||
|
|
>
|
|||
|
|
✎ Edit
|
|||
|
|
</Link>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
class="rounded-md border border-slate-300 bg-white px-3 py-1.5 shadow-sm hover:bg-slate-50"
|
|||
|
|
@click="duplicate"
|
|||
|
|
>
|
|||
|
|
Duplicate
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
class="rounded-md border border-red-200 bg-white px-3 py-1.5 text-red-600 shadow-sm hover:bg-red-50"
|
|||
|
|
@click="showDelete = true"
|
|||
|
|
>
|
|||
|
|
Delete
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<dl class="mt-4 flex flex-wrap gap-x-8 gap-y-1 text-xs text-slate-500">
|
|||
|
|
<div>
|
|||
|
|
<dt class="inline font-medium">Usage:</dt>
|
|||
|
|
<dd class="inline">{{ prompt.usage_count }}×</dd>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<dt class="inline font-medium">Last used:</dt>
|
|||
|
|
<dd class="inline">{{ formatDate(prompt.last_used_at) }}</dd>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<dt class="inline font-medium">Created:</dt>
|
|||
|
|
<dd class="inline">{{ formatDate(prompt.created_at) }}</dd>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<dt class="inline font-medium">Updated:</dt>
|
|||
|
|
<dd class="inline">{{ formatDate(prompt.updated_at) }}</dd>
|
|||
|
|
</div>
|
|||
|
|
</dl>
|
|||
|
|
|
|||
|
|
<div class="mt-6 grid gap-6 lg:grid-cols-2">
|
|||
|
|
<div class="space-y-2">
|
|||
|
|
<div class="flex items-center justify-between">
|
|||
|
|
<h2 class="text-sm font-semibold text-slate-900">Template</h2>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
class="text-xs text-slate-500 hover:text-slate-800 hover:underline"
|
|||
|
|
@click="copyBody"
|
|||
|
|
>
|
|||
|
|
{{ bodyCopied ? '✓ Copied' : 'Copy template' }}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<pre
|
|||
|
|
class="max-h-[28rem] overflow-auto whitespace-pre-wrap rounded-lg border border-slate-200 bg-white p-4 font-mono text-xs leading-relaxed text-slate-700 shadow-sm"
|
|||
|
|
>{{ prompt.body }}</pre>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="space-y-3">
|
|||
|
|
<RenderPreview
|
|||
|
|
:body="prompt.body"
|
|||
|
|
:variables="variables"
|
|||
|
|
use-label="Use prompt (counts usage)"
|
|||
|
|
:busy="using"
|
|||
|
|
@use="usePrompt"
|
|||
|
|
/>
|
|||
|
|
<p
|
|||
|
|
v-if="useError"
|
|||
|
|
class="rounded-md bg-red-50 px-3 py-2 text-xs text-red-700"
|
|||
|
|
>
|
|||
|
|
{{ useError.message }}
|
|||
|
|
<template v-if="useError.missing?.length">
|
|||
|
|
({{ useError.missing.join(', ') }})
|
|||
|
|
</template>
|
|||
|
|
</p>
|
|||
|
|
<pre
|
|||
|
|
v-if="rendered"
|
|||
|
|
class="max-h-60 overflow-auto whitespace-pre-wrap rounded-md bg-emerald-50 p-3 font-mono text-xs leading-relaxed text-emerald-900"
|
|||
|
|
>{{ rendered }}</pre>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<ConfirmDialog
|
|||
|
|
:open="showDelete"
|
|||
|
|
title="Delete prompt?"
|
|||
|
|
:message="`“${prompt.name}” will be permanently deleted.`"
|
|||
|
|
confirm-label="Delete"
|
|||
|
|
@confirm="confirmDelete"
|
|||
|
|
@cancel="showDelete = false"
|
|||
|
|
/>
|
|||
|
|
</template>
|