199 lines
6.8 KiB
Vue
199 lines
6.8 KiB
Vue
<script setup>
|
||
import { Link, useForm, usePage } from '@inertiajs/vue3';
|
||
import { computed, ref } from 'vue';
|
||
|
||
const props = defineProps({
|
||
initial: { type: Object, required: true },
|
||
suggestedTags: { type: Array, required: true },
|
||
method: { type: String, default: 'post' },
|
||
action: { type: String, required: true },
|
||
submitLabel: { type: String, default: 'Submit' },
|
||
cancelHref: { type: String, default: '/prompts' },
|
||
});
|
||
|
||
const page = usePage();
|
||
|
||
const PLACEHOLDER = '{{variable_name}}';
|
||
|
||
const form = useForm({
|
||
name: props.initial.name ?? '',
|
||
description: props.initial.description ?? '',
|
||
body: props.initial.body ?? '',
|
||
tags: (props.initial.tags ?? []).map((tag) => String(tag)),
|
||
});
|
||
|
||
const tagInput = ref('');
|
||
|
||
const errors = computed(() => page.props.errors ?? {});
|
||
|
||
const availableSuggestions = computed(() =>
|
||
props.suggestedTags.filter((tag) => !form.tags.includes(tag))
|
||
);
|
||
|
||
const addTag = (raw) => {
|
||
const value = String(raw ?? '').trim().toLowerCase();
|
||
|
||
if (value === '' || form.tags.includes(value)) {
|
||
return;
|
||
}
|
||
|
||
form.tags.push(value);
|
||
};
|
||
|
||
const removeTag = (index) => {
|
||
form.tags.splice(index, 1);
|
||
};
|
||
|
||
const onTagKeydown = (event) => {
|
||
if (event.key === 'Enter' || event.key === ',') {
|
||
event.preventDefault();
|
||
addTag(tagInput.value);
|
||
tagInput.value = '';
|
||
} else if (event.key === 'Backspace' && tagInput.value === '' && form.tags.length > 0) {
|
||
removeTag(form.tags.length - 1);
|
||
}
|
||
};
|
||
|
||
const submit = () => {
|
||
if (tagInput.value.trim() !== '') {
|
||
addTag(tagInput.value);
|
||
tagInput.value = '';
|
||
}
|
||
|
||
form.transform(() => ({
|
||
name: form.name,
|
||
description: form.description?.trim() === '' ? null : form.description.trim(),
|
||
body: form.body,
|
||
tags: [...form.tags],
|
||
}));
|
||
|
||
const submitter = props.method === 'put' ? form.put.bind(form) : form.post.bind(form);
|
||
|
||
submitter(props.action, {
|
||
preserveScroll: true,
|
||
onError: () => form.clearErrors(),
|
||
});
|
||
};
|
||
</script>
|
||
|
||
<template>
|
||
<form
|
||
class="space-y-4 rounded-lg border border-slate-200 bg-white p-5 shadow-sm"
|
||
@submit.prevent="submit"
|
||
>
|
||
<div>
|
||
<label for="prompt-name" class="mb-1 block text-sm font-medium text-slate-700">
|
||
Name
|
||
</label>
|
||
<input
|
||
id="prompt-name"
|
||
v-model="form.name"
|
||
type="text"
|
||
maxlength="120"
|
||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-slate-500 focus:outline-none"
|
||
/>
|
||
<p v-if="errors.name" class="mt-1 text-xs text-red-600">{{ errors.name }}</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label
|
||
for="prompt-description"
|
||
class="mb-1 block text-sm font-medium text-slate-700"
|
||
>
|
||
Description <span class="font-normal text-slate-400">(optional)</span>
|
||
</label>
|
||
<textarea
|
||
id="prompt-description"
|
||
v-model="form.description"
|
||
rows="2"
|
||
maxlength="500"
|
||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-slate-500 focus:outline-none"
|
||
/>
|
||
<p v-if="errors.description" class="mt-1 text-xs text-red-600">
|
||
{{ errors.description }}
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label for="prompt-body" class="mb-1 block text-sm font-medium text-slate-700">
|
||
Prompt body
|
||
</label>
|
||
<textarea
|
||
id="prompt-body"
|
||
v-model="form.body"
|
||
rows="8"
|
||
spellcheck="false"
|
||
class="w-full rounded-md border border-slate-300 px-3 py-2 font-mono text-sm shadow-sm focus:border-slate-500 focus:outline-none"
|
||
/>
|
||
<p class="mt-1 text-xs text-slate-500">
|
||
Wrap placeholders like <code class="rounded bg-slate-100 px-1 py-0.5">{{ PLACEHOLDER }}</code>
|
||
</p>
|
||
<p v-if="errors.body" class="mt-1 text-xs text-red-600">{{ errors.body }}</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label for="prompt-tags" class="mb-1 block text-sm font-medium text-slate-700">
|
||
Tags
|
||
</label>
|
||
<div
|
||
class="flex flex-wrap items-center gap-1.5 rounded-md border border-slate-300 px-2 py-1.5 shadow-sm focus-within:border-slate-500"
|
||
>
|
||
<span
|
||
v-for="(tag, index) in form.tags"
|
||
:key="tag"
|
||
class="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700"
|
||
>
|
||
{{ tag }}
|
||
<button
|
||
type="button"
|
||
class="text-slate-400 hover:text-slate-700"
|
||
:aria-label="`Remove tag ${tag}`"
|
||
@click="removeTag(index)"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
<input
|
||
id="prompt-tags"
|
||
v-model="tagInput"
|
||
type="text"
|
||
placeholder="Type a tag, press Enter…"
|
||
class="min-w-28 flex-1 border-0 bg-transparent p-0 text-sm focus:outline-none"
|
||
@keydown="onTagKeydown"
|
||
@blur="addTag(tagInput)"
|
||
/>
|
||
</div>
|
||
<p v-if="errors.tags" class="mt-1 text-xs text-red-600">
|
||
{{ Array.isArray(errors.tags) ? errors.tags[0] : errors.tags }}
|
||
</p>
|
||
<div v-if="availableSuggestions.length > 0" class="mt-2 flex flex-wrap gap-1.5">
|
||
<button
|
||
v-for="tag in availableSuggestions"
|
||
:key="tag"
|
||
type="button"
|
||
class="rounded-full border border-dashed border-slate-300 px-2 py-0.5 text-xs text-slate-500 hover:border-slate-400 hover:text-slate-700"
|
||
@click="addTag(tag)"
|
||
>
|
||
+ {{ tag }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center justify-between border-t border-slate-100 pt-4">
|
||
<Link
|
||
:href="cancelHref"
|
||
class="text-sm text-slate-500 hover:text-slate-800 hover:underline"
|
||
>
|
||
Cancel
|
||
</Link>
|
||
<button
|
||
type="submit"
|
||
:disabled="form.processing"
|
||
class="rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
{{ form.processing ? 'Saving…' : submitLabel }}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</template>
|