257 lines
11 KiB
Vue
257 lines
11 KiB
Vue
|
|
<script setup>
|
|||
|
|
import { Link, router } from '@inertiajs/vue3';
|
|||
|
|
import { computed, ref } from 'vue';
|
|||
|
|
|
|||
|
|
import ConfirmDialog from '../../Components/ConfirmDialog.vue';
|
|||
|
|
import TagPill from '../../Components/TagPill.vue';
|
|||
|
|
import { formatDate } from '../../lib/format.js';
|
|||
|
|
|
|||
|
|
const props = defineProps({
|
|||
|
|
prompts: { type: Object, required: true },
|
|||
|
|
tags: { type: Array, required: true },
|
|||
|
|
filters: { type: Object, required: true },
|
|||
|
|
sort: { type: String, required: true },
|
|||
|
|
direction: { type: String, required: true },
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const search = ref(props.filters.search ?? '');
|
|||
|
|
const deleting = ref(null);
|
|||
|
|
|
|||
|
|
const rows = computed(() => props.prompts.data ?? []);
|
|||
|
|
const currentPage = computed(() => props.prompts.current_page ?? 1);
|
|||
|
|
const lastPage = computed(() => props.prompts.last_page ?? 1);
|
|||
|
|
const total = computed(() => props.prompts.total ?? 0);
|
|||
|
|
|
|||
|
|
const params = (extra = {}) => ({
|
|||
|
|
search: props.filters.search || undefined,
|
|||
|
|
tag: props.filters.tag || undefined,
|
|||
|
|
favorites: props.filters.favorites ? '1' : undefined,
|
|||
|
|
sort: props.sort,
|
|||
|
|
direction: props.direction,
|
|||
|
|
...extra,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const refresh = (extra = {}) =>
|
|||
|
|
router.get(
|
|||
|
|
'/prompts',
|
|||
|
|
params(extra),
|
|||
|
|
{ preserveState: true, preserveScroll: true },
|
|||
|
|
{ only: ['prompts', 'filters', 'sort', 'direction'] }
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const onSearch = (event) => {
|
|||
|
|
event.preventDefault();
|
|||
|
|
refresh({ search: search.value });
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const onFilter = (key, value) => {
|
|||
|
|
refresh({ [key]: value });
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const favoritePrompt = (prompt) => {
|
|||
|
|
router.post(`/prompts/${prompt.id}/toggle-favorite`, {}, {
|
|||
|
|
preserveScroll: true,
|
|||
|
|
only: ['prompts'],
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const duplicatePrompt = (prompt) => {
|
|||
|
|
router.post(`/prompts/${prompt.id}/duplicate`);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const confirmDelete = () => {
|
|||
|
|
const target = deleting.value;
|
|||
|
|
|
|||
|
|
if (!target) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
deleting.value = null;
|
|||
|
|
router.delete(`/prompts/${target.id}`, {
|
|||
|
|
preserveScroll: true,
|
|||
|
|
onFinish: () => refresh({}),
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div>
|
|||
|
|
<div class="mb-5 flex flex-wrap items-center justify-between gap-3">
|
|||
|
|
<div>
|
|||
|
|
<h1 class="text-xl font-semibold tracking-tight">Prompts</h1>
|
|||
|
|
<p class="text-sm text-slate-500">{{ total }} prompt(s) in your vault</p>
|
|||
|
|
</div>
|
|||
|
|
<form class="relative" @submit="onSearch">
|
|||
|
|
<input
|
|||
|
|
v-model="search"
|
|||
|
|
type="search"
|
|||
|
|
name="search"
|
|||
|
|
placeholder="Search name, description, body…"
|
|||
|
|
class="w-72 rounded-md border border-slate-300 bg-white py-2 pl-3 pr-3 text-sm shadow-sm focus:border-slate-500 focus:outline-none"
|
|||
|
|
/>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="mb-4 flex flex-wrap items-center gap-2 text-sm">
|
|||
|
|
<label class="flex items-center gap-1.5 text-slate-600">
|
|||
|
|
Tag
|
|||
|
|
<select
|
|||
|
|
:value="filters.tag ?? ''"
|
|||
|
|
class="rounded-md border border-slate-300 bg-white py-1.5 pl-2 pr-7 text-sm shadow-sm"
|
|||
|
|
@change="onFilter('tag', $event.target.value)"
|
|||
|
|
>
|
|||
|
|
<option value="">All tags</option>
|
|||
|
|
<option v-for="tag in tags" :key="tag" :value="tag">
|
|||
|
|
{{ tag }}
|
|||
|
|
</option>
|
|||
|
|
</select>
|
|||
|
|
</label>
|
|||
|
|
<label class="flex items-center gap-1.5 text-slate-600">
|
|||
|
|
<input
|
|||
|
|
type="checkbox"
|
|||
|
|
class="h-4 w-4 rounded border-slate-300"
|
|||
|
|
:checked="filters.favorites"
|
|||
|
|
@change="onFilter('favorites', $event.target.checked ? '1' : undefined)"
|
|||
|
|
/>
|
|||
|
|
★ Favorites
|
|||
|
|
</label>
|
|||
|
|
<label class="flex items-center gap-1.5 text-slate-600">
|
|||
|
|
Sort
|
|||
|
|
<select
|
|||
|
|
:value="sort"
|
|||
|
|
class="rounded-md border border-slate-300 bg-white py-1.5 pl-2 pr-7 text-sm shadow-sm"
|
|||
|
|
@change="onFilter('sort', $event.target.value)"
|
|||
|
|
>
|
|||
|
|
<option value="created_at">Newest</option>
|
|||
|
|
<option value="name">Name</option>
|
|||
|
|
<option value="usage_count">Most used</option>
|
|||
|
|
<option value="last_used_at">Recently used</option>
|
|||
|
|
<option value="updated_at">Recently updated</option>
|
|||
|
|
</select>
|
|||
|
|
<select
|
|||
|
|
:value="direction"
|
|||
|
|
class="rounded-md border border-slate-300 bg-white py-1.5 pl-2 pr-7 text-sm shadow-sm"
|
|||
|
|
@change="onFilter('direction', $event.target.value)"
|
|||
|
|
>
|
|||
|
|
<option value="asc">A→Z</option>
|
|||
|
|
<option value="desc">Z→A</option>
|
|||
|
|
</select>
|
|||
|
|
</label>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
|
|||
|
|
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
|||
|
|
<thead class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
|
|||
|
|
<tr>
|
|||
|
|
<th class="px-4 py-3 font-medium">Name</th>
|
|||
|
|
<th class="px-4 py-3 font-medium">Tags</th>
|
|||
|
|
<th class="px-4 py-3 text-right font-medium">Usage</th>
|
|||
|
|
<th class="px-4 py-3 font-medium">Last used</th>
|
|||
|
|
<th class="px-4 py-3 text-right font-medium">Actions</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody class="divide-y divide-slate-100">
|
|||
|
|
<tr v-if="rows.length === 0" class="bg-white">
|
|||
|
|
<td colspan="5" class="px-4 py-10 text-center text-slate-500">
|
|||
|
|
No prompts match.
|
|||
|
|
<Link href="/prompts/create" class="text-slate-900 underline">Create one</Link>.
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
<tr v-for="prompt in rows" :key="prompt.id" class="hover:bg-slate-50">
|
|||
|
|
<td class="max-w-xs px-4 py-3">
|
|||
|
|
<div class="flex items-center gap-1.5">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
class="shrink-0 text-base leading-none"
|
|||
|
|
:class="prompt.is_favorite ? 'text-amber-500' : 'text-slate-300 hover:text-slate-400'"
|
|||
|
|
:aria-label="prompt.is_favorite ? 'Unfavorite' : 'Favorite'"
|
|||
|
|
@click="favoritePrompt(prompt)"
|
|||
|
|
>
|
|||
|
|
★
|
|||
|
|
</button>
|
|||
|
|
<Link
|
|||
|
|
:href="`/prompts/${prompt.id}`"
|
|||
|
|
class="font-medium text-slate-900 hover:underline"
|
|||
|
|
>
|
|||
|
|
{{ prompt.name }}
|
|||
|
|
</Link>
|
|||
|
|
</div>
|
|||
|
|
<p v-if="prompt.description" class="mt-0.5 truncate text-xs text-slate-500">
|
|||
|
|
{{ prompt.description }}
|
|||
|
|
</p>
|
|||
|
|
</td>
|
|||
|
|
<td class="px-4 py-3">
|
|||
|
|
<div class="flex flex-wrap gap-1">
|
|||
|
|
<TagPill v-for="tag in prompt.tags" :key="tag.id" :name="tag.name" />
|
|||
|
|
</div>
|
|||
|
|
</td>
|
|||
|
|
<td class="px-4 py-3 text-right tabular-nums text-slate-600">
|
|||
|
|
{{ prompt.usage_count }}×
|
|||
|
|
</td>
|
|||
|
|
<td class="px-4 py-3 text-slate-600">
|
|||
|
|
{{ formatDate(prompt.last_used_at) }}
|
|||
|
|
</td>
|
|||
|
|
<td class="px-4 py-3">
|
|||
|
|
<div class="flex items-center justify-end gap-1 text-xs">
|
|||
|
|
<Link
|
|||
|
|
:href="`/prompts/${prompt.id}/edit`"
|
|||
|
|
class="rounded px-2 py-1 text-slate-600 hover:bg-slate-100"
|
|||
|
|
>
|
|||
|
|
Edit
|
|||
|
|
</Link>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
class="rounded px-2 py-1 text-slate-600 hover:bg-slate-100"
|
|||
|
|
@click="duplicatePrompt(prompt)"
|
|||
|
|
>
|
|||
|
|
Duplicate
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
class="rounded px-2 py-1 text-red-600 hover:bg-red-50"
|
|||
|
|
@click="deleting = prompt"
|
|||
|
|
>
|
|||
|
|
Delete
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="lastPage > 1" class="mt-4 flex items-center justify-between text-sm">
|
|||
|
|
<span class="text-slate-500">
|
|||
|
|
Page {{ currentPage }} of {{ lastPage }}
|
|||
|
|
</span>
|
|||
|
|
<div class="flex gap-2">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
class="rounded-md border border-slate-300 bg-white px-3 py-1.5 shadow-sm disabled:cursor-not-allowed disabled:opacity-40"
|
|||
|
|
:disabled="currentPage <= 1"
|
|||
|
|
@click="refresh({ page: currentPage - 1 })"
|
|||
|
|
>
|
|||
|
|
← Prev
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
class="rounded-md border border-slate-300 bg-white px-3 py-1.5 shadow-sm disabled:cursor-not-allowed disabled:opacity-40"
|
|||
|
|
:disabled="currentPage >= lastPage"
|
|||
|
|
@click="refresh({ page: currentPage + 1 })"
|
|||
|
|
>
|
|||
|
|
Next →
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<ConfirmDialog
|
|||
|
|
:open="deleting !== null"
|
|||
|
|
title="Delete prompt?"
|
|||
|
|
:message="deleting ? `“${deleting.name}” will be permanently deleted.` : ''"
|
|||
|
|
confirm-label="Delete"
|
|||
|
|
@confirm="confirmDelete"
|
|||
|
|
@cancel="deleting = null"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</template>
|