Characters live on the sequence with per-scene on/off and outfit picks; Copy scene exports only the active tab; legacy saves still open.
589 lines
24 KiB
Vue
589 lines
24 KiB
Vue
<template>
|
|
<div class="min-h-dvh min-w-0">
|
|
<AppHeader subtitle="Prompt Builder" current="prompt-builder" />
|
|
|
|
<main class="mx-auto grid min-w-0 max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,22rem)]">
|
|
<section class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 space-y-5">
|
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
<div>
|
|
<h2 class="font-display text-2xl font-bold">Prompt Builder</h2>
|
|
<p class="mt-1 max-w-2xl text-sm text-zinc-400">
|
|
Build multi-image prompt sequences. Copy the preview into Generate with Batch on — scenes split on the double blank line.
|
|
</p>
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<button
|
|
type="button"
|
|
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:border-amber-300/50 hover:text-white"
|
|
@click="startNew"
|
|
>
|
|
New
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:border-amber-300/50 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
|
:disabled="busy || !name.trim()"
|
|
@click="save"
|
|
>
|
|
{{ busy === 'save' ? 'Saving…' : selectedId ? 'Save' : 'Save sequence' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="error" class="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm text-red-200">{{ error }}</p>
|
|
<p v-else-if="notice" class="rounded-2xl border border-amber-300/30 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">{{ notice }}</p>
|
|
|
|
<label class="block text-sm">
|
|
<span class="mb-1 block font-medium text-zinc-300">Sequence name</span>
|
|
<input
|
|
v-model="name"
|
|
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
|
maxlength="80"
|
|
placeholder="e.g. Badge set A"
|
|
>
|
|
<p class="mt-1 text-[11px] text-zinc-500">Save key only — never copied into the prompt text.</p>
|
|
</label>
|
|
|
|
<div class="space-y-3">
|
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
<div>
|
|
<span class="text-sm font-medium text-zinc-300">Characters</span>
|
|
<p class="text-[11px] text-zinc-500">Sequence-wide. Toggle and outfit per scene below.</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="rounded-full border border-white/10 px-3 py-1 text-[11px] text-zinc-300 hover:border-amber-300/50 hover:text-white"
|
|
@click="addCharacter"
|
|
>
|
|
Add character
|
|
</button>
|
|
</div>
|
|
<div
|
|
v-for="(character, cIndex) in characters"
|
|
:key="character.id"
|
|
class="rounded-2xl border border-white/10 bg-zinc-950/40 p-3 space-y-3"
|
|
>
|
|
<div class="flex items-center justify-between gap-2">
|
|
<span class="text-[11px] uppercase tracking-wide text-zinc-500">Character {{ cIndex + 1 }}</span>
|
|
<div class="flex items-center gap-2">
|
|
<button type="button" class="text-[11px] text-zinc-500 hover:text-amber-200" @click="clearCharacter(cIndex)">Clear</button>
|
|
<button
|
|
v-if="characters.length > 1"
|
|
type="button"
|
|
class="text-[11px] text-zinc-500 hover:text-red-300"
|
|
@click="removeCharacter(cIndex)"
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<input
|
|
v-model="character.name"
|
|
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
|
maxlength="120"
|
|
placeholder="Name"
|
|
>
|
|
<textarea
|
|
v-model="character.description"
|
|
rows="2"
|
|
class="w-full rounded-xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
|
placeholder="Description"
|
|
/>
|
|
<div class="space-y-2 border-t border-white/5 pt-3">
|
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
<span class="text-[11px] font-medium uppercase tracking-wide text-zinc-500">Outfits</span>
|
|
<button
|
|
type="button"
|
|
class="text-[11px] text-zinc-400 hover:text-amber-200"
|
|
@click="addOutfit(cIndex)"
|
|
>
|
|
Add outfit
|
|
</button>
|
|
</div>
|
|
<p v-if="!character.outfits.length" class="text-[11px] text-zinc-600">No outfits yet.</p>
|
|
<div
|
|
v-for="(outfit, oIndex) in character.outfits"
|
|
:key="outfit.id"
|
|
class="rounded-xl border border-white/5 bg-zinc-950/50 p-2 space-y-2"
|
|
>
|
|
<div class="flex items-center justify-between gap-2">
|
|
<span class="text-[11px] text-zinc-500">Outfit {{ oIndex + 1 }}</span>
|
|
<button type="button" class="text-[11px] text-zinc-500 hover:text-red-300" @click="removeOutfit(cIndex, oIndex)">Remove</button>
|
|
</div>
|
|
<input
|
|
v-model="outfit.name"
|
|
class="w-full rounded-lg border border-white/10 bg-zinc-950 px-2.5 py-1.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
|
maxlength="120"
|
|
placeholder="Outfit name"
|
|
>
|
|
<textarea
|
|
v-model="outfit.description"
|
|
rows="2"
|
|
class="w-full rounded-lg border border-white/10 bg-zinc-950/70 px-2.5 py-1.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
|
placeholder="What they are wearing"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center gap-2 border-b border-white/10 pb-2">
|
|
<button
|
|
v-for="(scene, index) in scenes"
|
|
:key="scene.id"
|
|
type="button"
|
|
class="rounded-full px-3 py-1.5 text-xs transition"
|
|
:class="index === activeIndex
|
|
? 'bg-amber-400/15 text-amber-100 ring-1 ring-amber-300/40'
|
|
: 'text-zinc-400 hover:bg-white/5 hover:text-zinc-200'"
|
|
@click="activeIndex = index"
|
|
>
|
|
{{ scene.title || `Scene ${index + 1}` }}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:border-amber-300/50 hover:text-white"
|
|
@click="addNextScene"
|
|
>
|
|
Next scene
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="activeScene" class="space-y-4">
|
|
<label class="block text-sm">
|
|
<span class="mb-1 flex items-center justify-between gap-2 font-medium text-zinc-300">
|
|
Scene tab name
|
|
<span class="text-[11px] font-normal text-zinc-500">optional</span>
|
|
</span>
|
|
<input
|
|
v-model="activeScene.title"
|
|
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
|
maxlength="60"
|
|
:placeholder="`Scene ${activeIndex + 1}`"
|
|
>
|
|
</label>
|
|
|
|
<div class="space-y-1">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<span class="text-sm font-medium text-zinc-300">Style</span>
|
|
<button type="button" class="text-[11px] text-zinc-500 hover:text-amber-200" @click="activeScene.style = ''">Clear</button>
|
|
</div>
|
|
<textarea
|
|
v-model="activeScene.style"
|
|
rows="3"
|
|
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
|
placeholder="Visual style for this image"
|
|
/>
|
|
</div>
|
|
|
|
<div class="space-y-1">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<span class="text-sm font-medium text-zinc-300">Settings</span>
|
|
<button type="button" class="text-[11px] text-zinc-500 hover:text-amber-200" @click="activeScene.settings = ''">Clear</button>
|
|
</div>
|
|
<textarea
|
|
v-model="activeScene.settings"
|
|
rows="3"
|
|
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
|
placeholder="Lighting, framing, technical notes"
|
|
/>
|
|
</div>
|
|
|
|
<div class="space-y-3">
|
|
<div>
|
|
<span class="text-sm font-medium text-zinc-300">In this scene</span>
|
|
<p class="text-[11px] text-zinc-500">Toggle who appears. Pick an outfit for each on character.</p>
|
|
</div>
|
|
<div
|
|
v-for="character in characters"
|
|
:key="'scene-'+character.id"
|
|
class="rounded-2xl border border-white/10 bg-zinc-950/40 px-3 py-2.5 space-y-2"
|
|
>
|
|
<label class="flex items-center justify-between gap-3 text-sm text-zinc-200">
|
|
<span class="min-w-0 truncate">{{ character.name || 'Unnamed character' }}</span>
|
|
<input
|
|
type="checkbox"
|
|
class="accent-amber-400"
|
|
:checked="isCharacterOn(character.id)"
|
|
@change="setCharacterOn(character.id, ($event.target as HTMLInputElement).checked)"
|
|
>
|
|
</label>
|
|
<label v-if="isCharacterOn(character.id)" class="block text-[11px] text-zinc-500">
|
|
Outfit
|
|
<select
|
|
class="mt-1 w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm text-zinc-200 outline-none ring-amber-300/40 focus:ring-2"
|
|
:value="activeOutfitId(character.id) || ''"
|
|
@change="setCharacterOutfit(character.id, ($event.target as HTMLSelectElement).value)"
|
|
>
|
|
<option value="">None</option>
|
|
<option v-for="outfit in character.outfits" :key="outfit.id" :value="outfit.id">
|
|
{{ outfit.name || 'Untitled outfit' }}
|
|
</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-1">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<span class="text-sm font-medium text-zinc-300">Prompt</span>
|
|
<button type="button" class="text-[11px] text-zinc-500 hover:text-amber-200" @click="activeScene.prompt = ''">Clear</button>
|
|
</div>
|
|
<textarea
|
|
v-model="activeScene.prompt"
|
|
rows="5"
|
|
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
|
placeholder="What happens in this image"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<section class="rounded-2xl border border-white/10 bg-zinc-950/30">
|
|
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-white/5 px-4 py-3">
|
|
<div>
|
|
<h3 class="text-sm font-medium text-zinc-200">Preview</h3>
|
|
<p class="text-[11px] text-zinc-500">Assembled text ready to copy. Sequence name is omitted.</p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:border-amber-300/50 hover:text-white disabled:opacity-50"
|
|
:disabled="!activeSceneText"
|
|
@click="copyScene"
|
|
>
|
|
{{ copiedScene ? 'Scene copied' : 'Copy scene' }}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:border-amber-300/50 hover:text-white disabled:opacity-50"
|
|
:disabled="!previewText"
|
|
@click="copyPreview"
|
|
>
|
|
{{ copied ? 'Copied' : 'Copy' }}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:border-amber-300/50 hover:text-white"
|
|
@click="previewOpen = !previewOpen"
|
|
>
|
|
{{ previewOpen ? 'Hide' : 'Show' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<pre
|
|
v-if="previewOpen"
|
|
class="max-h-[min(28rem,50vh)] overflow-auto whitespace-pre-wrap px-4 py-3 text-xs text-zinc-300 [overflow-wrap:anywhere]"
|
|
>{{ previewText || 'Fill a scene to see the assembled prompt.' }}</pre>
|
|
</section>
|
|
</section>
|
|
|
|
<aside class="min-w-0 space-y-4">
|
|
<section class="glass rounded-3xl p-5 sm:p-6">
|
|
<h3 class="font-display text-lg font-bold">Saved sequences</h3>
|
|
<p class="mt-1 text-xs text-zinc-500">Stored with your other library data. Open one to edit.</p>
|
|
<p v-if="!sequences.length" class="mt-4 rounded-2xl border border-white/5 bg-zinc-950/40 px-4 py-6 text-center text-sm text-zinc-500">
|
|
Nothing saved yet.
|
|
</p>
|
|
<div v-else class="mt-4 space-y-2">
|
|
<button
|
|
v-for="sequence in sequences"
|
|
:key="sequence.id"
|
|
type="button"
|
|
class="w-full rounded-2xl border p-3 text-left transition"
|
|
:class="sequence.id === selectedId
|
|
? 'border-amber-300/30 bg-amber-400/5'
|
|
: 'border-white/5 bg-zinc-950/40 hover:border-white/15'"
|
|
@click="select(sequence)"
|
|
>
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="min-w-0">
|
|
<p class="truncate text-sm font-medium text-zinc-100">{{ sequence.name }}</p>
|
|
<p class="mt-0.5 text-[11px] text-zinc-500">
|
|
{{ sequence.scenes?.length || 0 }} scene{{ (sequence.scenes?.length || 0) === 1 ? '' : 's' }}
|
|
· {{ sequence.characters?.length || 0 }} character{{ (sequence.characters?.length || 0) === 1 ? '' : 's' }}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="shrink-0 text-xs text-zinc-500 hover:text-red-300"
|
|
@click.stop="remove(sequence)"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</aside>
|
|
</main>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type {
|
|
PromptSequence,
|
|
PromptSequenceCharacter,
|
|
PromptSequenceScene
|
|
} from '~/utils/promptSequences'
|
|
import {
|
|
assembleSceneText,
|
|
assembleSequenceText,
|
|
cloneSceneForNext,
|
|
emptyCharacter,
|
|
emptyOutfit,
|
|
emptyScene,
|
|
ensureSceneStatesForCharacters,
|
|
normalizePromptSequence
|
|
} from '~/utils/promptSequences'
|
|
|
|
useHead({ title: 'Prompt Builder' })
|
|
|
|
const sequences = ref<PromptSequence[]>([])
|
|
const selectedId = ref('')
|
|
const name = ref('')
|
|
const characters = ref<PromptSequenceCharacter[]>([emptyCharacter()])
|
|
const scenes = ref<PromptSequenceScene[]>([emptyScene(1, characters.value)])
|
|
const activeIndex = ref(0)
|
|
const previewOpen = ref(true)
|
|
const busy = ref('')
|
|
const error = ref('')
|
|
const notice = ref('')
|
|
const copied = ref(false)
|
|
const copiedScene = ref(false)
|
|
let copyTimer: ReturnType<typeof setTimeout> | null = null
|
|
let copySceneTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
const activeScene = computed(() => scenes.value[activeIndex.value] || null)
|
|
const previewText = computed(() => assembleSequenceText({
|
|
characters: characters.value,
|
|
scenes: scenes.value
|
|
}))
|
|
const activeSceneText = computed(() => {
|
|
if (!activeScene.value) return ''
|
|
return assembleSceneText(activeScene.value, characters.value)
|
|
})
|
|
|
|
function startNew() {
|
|
selectedId.value = ''
|
|
name.value = ''
|
|
characters.value = [emptyCharacter()]
|
|
scenes.value = [emptyScene(1, characters.value)]
|
|
activeIndex.value = 0
|
|
error.value = ''
|
|
notice.value = ''
|
|
}
|
|
|
|
function select(sequence: PromptSequence) {
|
|
const normalized = normalizePromptSequence(sequence) || sequence
|
|
selectedId.value = normalized.id
|
|
name.value = normalized.name
|
|
characters.value = (normalized.characters?.length ? normalized.characters : [emptyCharacter()]).map(character => ({
|
|
...emptyCharacter(),
|
|
...character,
|
|
outfits: (character.outfits || []).map(outfit => ({ ...emptyOutfit(), ...outfit }))
|
|
}))
|
|
scenes.value = (normalized.scenes?.length ? normalized.scenes : [emptyScene(1, characters.value)]).map((scene, index) => ({
|
|
...emptyScene(index + 1, characters.value),
|
|
...scene,
|
|
characterStates: (scene.characterStates || []).map(state => ({
|
|
characterId: state.characterId,
|
|
on: state.on !== false,
|
|
outfitId: state.outfitId || null
|
|
}))
|
|
}))
|
|
ensureSceneStatesForCharacters(scenes.value, characters.value)
|
|
activeIndex.value = 0
|
|
error.value = ''
|
|
notice.value = ''
|
|
}
|
|
|
|
function addNextScene() {
|
|
const previous = scenes.value[scenes.value.length - 1] || emptyScene(1, characters.value)
|
|
const next = cloneSceneForNext(previous, scenes.value.length + 1)
|
|
scenes.value.push(next)
|
|
activeIndex.value = scenes.value.length - 1
|
|
}
|
|
|
|
function addCharacter() {
|
|
const character = emptyCharacter()
|
|
characters.value.push(character)
|
|
ensureSceneStatesForCharacters(scenes.value, characters.value, {
|
|
newCharacterId: character.id,
|
|
turnOnSceneIndex: activeIndex.value
|
|
})
|
|
}
|
|
|
|
function clearCharacter(index: number) {
|
|
const character = characters.value[index]
|
|
if (!character) return
|
|
character.name = ''
|
|
character.description = ''
|
|
character.outfits = []
|
|
for (const scene of scenes.value) {
|
|
const state = scene.characterStates.find(item => item.characterId === character.id)
|
|
if (state) state.outfitId = null
|
|
}
|
|
}
|
|
|
|
function removeCharacter(index: number) {
|
|
if (characters.value.length <= 1) return
|
|
const [removed] = characters.value.splice(index, 1)
|
|
if (!removed) return
|
|
for (const scene of scenes.value) {
|
|
scene.characterStates = scene.characterStates.filter(state => state.characterId !== removed.id)
|
|
}
|
|
}
|
|
|
|
function addOutfit(characterIndex: number) {
|
|
const character = characters.value[characterIndex]
|
|
if (!character) return
|
|
character.outfits.push(emptyOutfit())
|
|
}
|
|
|
|
function removeOutfit(characterIndex: number, outfitIndex: number) {
|
|
const character = characters.value[characterIndex]
|
|
if (!character) return
|
|
const [removed] = character.outfits.splice(outfitIndex, 1)
|
|
if (!removed) return
|
|
for (const scene of scenes.value) {
|
|
const state = scene.characterStates.find(item => item.characterId === character.id)
|
|
if (state?.outfitId === removed.id) state.outfitId = null
|
|
}
|
|
}
|
|
|
|
function sceneState(characterId: string) {
|
|
return activeScene.value?.characterStates.find(state => state.characterId === characterId)
|
|
}
|
|
|
|
function isCharacterOn(characterId: string) {
|
|
const state = sceneState(characterId)
|
|
return state ? !!state.on : true
|
|
}
|
|
|
|
function setCharacterOn(characterId: string, on: boolean) {
|
|
const state = sceneState(characterId)
|
|
if (!state) {
|
|
activeScene.value?.characterStates.push({ characterId, on, outfitId: null })
|
|
return
|
|
}
|
|
state.on = on
|
|
}
|
|
|
|
function activeOutfitId(characterId: string) {
|
|
return sceneState(characterId)?.outfitId || ''
|
|
}
|
|
|
|
function setCharacterOutfit(characterId: string, outfitId: string) {
|
|
const state = sceneState(characterId)
|
|
if (!state) {
|
|
activeScene.value?.characterStates.push({
|
|
characterId,
|
|
on: true,
|
|
outfitId: outfitId || null
|
|
})
|
|
return
|
|
}
|
|
state.outfitId = outfitId || null
|
|
}
|
|
|
|
async function loadSequences() {
|
|
const listed = await $fetch<{ sequences?: PromptSequence[] }>('/api/prompt-sequences')
|
|
sequences.value = listed.sequences || []
|
|
}
|
|
|
|
async function save() {
|
|
const nextName = name.value.trim()
|
|
if (!nextName) return
|
|
const existingByName = sequences.value.find(item => item.name.toLowerCase() === nextName.toLowerCase())
|
|
const updateExisting = Boolean(selectedId.value) && (!existingByName || existingByName.id === selectedId.value)
|
|
if (existingByName && existingByName.id !== selectedId.value && !window.confirm(`Overwrite sequence “${existingByName.name}”?`)) return
|
|
busy.value = 'save'
|
|
error.value = ''
|
|
notice.value = ''
|
|
const payload = {
|
|
name: nextName,
|
|
characters: characters.value,
|
|
scenes: scenes.value
|
|
}
|
|
try {
|
|
if (updateExisting && selectedId.value) {
|
|
const saved = await $fetch<{ sequence: PromptSequence; sequences: PromptSequence[] }>(`/api/prompt-sequences/${selectedId.value}`, {
|
|
method: 'PATCH',
|
|
body: payload
|
|
})
|
|
sequences.value = saved.sequences
|
|
select(saved.sequence)
|
|
notice.value = `Saved “${saved.sequence.name}”.`
|
|
} else {
|
|
const saved = await $fetch<{ sequence: PromptSequence; sequences: PromptSequence[] }>('/api/prompt-sequences', {
|
|
method: 'POST',
|
|
body: { ...payload, overwrite: Boolean(existingByName) }
|
|
})
|
|
sequences.value = saved.sequences
|
|
select(saved.sequence)
|
|
notice.value = existingByName ? `Overwrote “${saved.sequence.name}”.` : `Saved “${saved.sequence.name}”.`
|
|
}
|
|
} catch (err: any) {
|
|
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not save that sequence'
|
|
} finally {
|
|
busy.value = ''
|
|
}
|
|
}
|
|
|
|
async function remove(sequence: PromptSequence) {
|
|
if (!window.confirm(`Delete sequence “${sequence.name}”?`)) return
|
|
busy.value = 'delete'
|
|
error.value = ''
|
|
notice.value = ''
|
|
try {
|
|
const saved = await $fetch<{ sequences: PromptSequence[] }>(`/api/prompt-sequences/${sequence.id}`, { method: 'DELETE' })
|
|
sequences.value = saved.sequences
|
|
if (selectedId.value === sequence.id) startNew()
|
|
notice.value = `Deleted “${sequence.name}”.`
|
|
} catch (err: any) {
|
|
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not delete that sequence'
|
|
} finally {
|
|
busy.value = ''
|
|
}
|
|
}
|
|
|
|
async function writeClipboard(text: string, kind: 'all' | 'scene') {
|
|
if (!text) return
|
|
try {
|
|
await navigator.clipboard.writeText(text)
|
|
if (kind === 'scene') {
|
|
copiedScene.value = true
|
|
if (copySceneTimer) clearTimeout(copySceneTimer)
|
|
copySceneTimer = setTimeout(() => { copiedScene.value = false }, 1500)
|
|
} else {
|
|
copied.value = true
|
|
if (copyTimer) clearTimeout(copyTimer)
|
|
copyTimer = setTimeout(() => { copied.value = false }, 1500)
|
|
}
|
|
} catch {
|
|
error.value = 'Could not copy to the clipboard'
|
|
}
|
|
}
|
|
|
|
async function copyPreview() {
|
|
await writeClipboard(previewText.value, 'all')
|
|
}
|
|
|
|
async function copyScene() {
|
|
await writeClipboard(activeSceneText.value, 'scene')
|
|
}
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
await loadSequences()
|
|
} catch (err: any) {
|
|
error.value = err?.data?.statusMessage || err?.message || 'Could not load saved sequences'
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (copyTimer) clearTimeout(copyTimer)
|
|
if (copySceneTimer) clearTimeout(copySceneTimer)
|
|
})
|
|
</script>
|