Files
aigen/pages/prompt-builder.vue
T
TowstyandCursor b6eef6a511 Replace Prompt Builder beats with complete set framings.
Sets are places with labeled full-text framings; selecting a framing copies only that text, and legacy beat-only sets migrate to a wide framing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 22:28:44 -05:00

869 lines
38 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 bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
@click="startNew"
>
New
</button>
<button
type="button"
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 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 bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
@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="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="clearCharacter(cIndex)">Clear</button>
<button
v-if="characters.length > 1"
type="button"
class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10"
@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="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
@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="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @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="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<span class="text-sm font-medium text-zinc-300">Sets</span>
<p class="text-[11px] text-zinc-500">Sequence-wide locations and props. Toggle and framing per scene below.</p>
</div>
<button
type="button"
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
@click="addSet"
>
Add set
</button>
</div>
<p v-if="!sets.length" class="text-[11px] text-zinc-600">No sets yet.</p>
<div
v-for="(set, sIndex) in sets"
:key="set.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">Set {{ sIndex + 1 }}</span>
<div class="flex items-center gap-2">
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="clearSet(sIndex)">Clear</button>
<button
type="button"
class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10"
@click="removeSet(sIndex)"
>
Remove
</button>
</div>
</div>
<label class="block text-sm">
<input
v-model="set.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"
>
<p class="mt-1 text-[11px] text-zinc-500">Label only — not copied into the prompt.</p>
</label>
<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">Framings</span>
<button
type="button"
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
@click="addFraming(sIndex)"
>
Add framing
</button>
</div>
<div
v-for="(framing, fIndex) in set.framings"
:key="framing.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">Framing {{ fIndex + 1 }}</span>
<div class="flex items-center gap-2">
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="clearFraming(sIndex, fIndex)">Clear</button>
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="removeFraming(sIndex, fIndex)">Remove</button>
</div>
</div>
<input
v-model="framing.label"
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="framingLabelPlaceholder(fIndex)"
>
<textarea
v-model="framing.text"
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="Place / location text copied into the prompt"
/>
</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
? 'min-h-11 bg-amber-400/15 px-3 py-2 text-sm font-semibold text-amber-100 ring-1 ring-amber-300/40'
: 'min-h-11 border border-white/25 px-3 py-2 text-sm font-semibold text-zinc-300 hover:bg-white/5 hover:text-zinc-100'"
@click="activeIndex = index"
>
{{ scene.title || `Scene ${index + 1}` }}
</button>
<button
type="button"
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
@click="addNextScene"
>
Next scene
</button>
<button
type="button"
class="rounded-full border border-amber-300/70 px-4 min-h-11 py-2.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
title="Move scene earlier"
:disabled="activeIndex <= 0"
@click="moveScene(activeIndex, -1)"
>
←
</button>
<button
type="button"
class="rounded-full border border-amber-300/70 px-4 min-h-11 py-2.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
title="Move scene later"
:disabled="activeIndex >= scenes.length - 1"
@click="moveScene(activeIndex, 1)"
>
→
</button>
<button
type="button"
class="rounded-full border border-amber-300/70 px-4 min-h-11 py-2.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
:disabled="scenes.length <= 1"
@click="deleteScene(activeIndex)"
>
Delete 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="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @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="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @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 v-if="sets.length" class="space-y-3">
<div>
<span class="text-sm font-medium text-zinc-300">Sets in this scene</span>
<p class="text-[11px] text-zinc-500">Toggle which set blocks appear. Pick a framing for each on set.</p>
</div>
<div
v-for="set in sets"
:key="'scene-set-'+set.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">{{ set.name || 'Unnamed set' }}</span>
<input
type="checkbox"
class="accent-amber-400"
:checked="isSetOn(set.id)"
@change="setSetOn(set.id, ($event.target as HTMLInputElement).checked)"
>
</label>
<label v-if="isSetOn(set.id)" class="block text-[11px] text-zinc-500">
Framing
<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="activeSetFramingId(set.id) || ''"
@change="setSetFraming(set.id, ($event.target as HTMLSelectElement).value)"
>
<option value="">None</option>
<option v-for="framing in set.framings" :key="framing.id" :value="framing.id">
{{ framing.label || 'Untitled framing' }}
</option>
</select>
</label>
</div>
</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="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @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 bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!activeSceneText"
@click="copyScene"
>
{{ copiedScene ? 'Scene copied' : 'Copy scene' }}
</button>
<button
type="button"
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!previewText"
@click="copyPreview"
>
{{ copied ? 'Copied' : 'Copy' }}
</button>
<button
type="button"
class="rounded-full border border-amber-300/70 px-4 min-h-11 py-2.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
@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="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10"
@click.stop="remove(sequence)"
>
Delete
</button>
</div>
</button>
</div>
</section>
</aside>
</main>
</div>
</template>
<script setup lang="ts">
import type {
PromptSequence,
PromptSequenceCharacter,
PromptSequenceScene,
PromptSequenceSet
} from '~/utils/promptSequences'
import {
PROMPT_SEQUENCE_FRAMING_SUGGESTIONS,
assembleSceneText,
assembleSequenceText,
cloneSceneForNext,
emptyCharacter,
emptyFraming,
emptyOutfit,
emptyScene,
emptySet,
ensureSceneStatesForCharacters,
ensureSceneStatesForSets,
normalizePromptSequence
} from '~/utils/promptSequences'
useHead({ title: 'Prompt Builder' })
const sequences = ref<PromptSequence[]>([])
const selectedId = ref('')
const name = ref('')
const characters = ref<PromptSequenceCharacter[]>([emptyCharacter()])
const sets = ref<PromptSequenceSet[]>([])
const scenes = ref<PromptSequenceScene[]>([emptyScene(1, characters.value, sets.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,
sets: sets.value,
scenes: scenes.value
}))
const activeSceneText = computed(() => {
if (!activeScene.value) return ''
return assembleSceneText(activeScene.value, characters.value, sets.value)
})
function framingLabelPlaceholder(index: number) {
const suggestion = PROMPT_SEQUENCE_FRAMING_SUGGESTIONS[index % PROMPT_SEQUENCE_FRAMING_SUGGESTIONS.length]
return suggestion || 'wide / medium / narrow'
}
function startNew() {
selectedId.value = ''
name.value = ''
characters.value = [emptyCharacter()]
sets.value = []
scenes.value = [emptyScene(1, characters.value, sets.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 }))
}))
sets.value = (normalized.sets || []).map(set => ({
...emptySet(),
...set,
framings: (set.framings?.length ? set.framings : [emptyFraming()]).map(framing => ({ ...emptyFraming(), ...framing }))
}))
scenes.value = (normalized.scenes?.length ? normalized.scenes : [emptyScene(1, characters.value, sets.value)]).map((scene, index) => ({
...emptyScene(index + 1, characters.value, sets.value),
...scene,
setStates: (scene.setStates || []).map(state => ({
setId: state.setId,
on: state.on !== false,
framingId: state.framingId || null
})),
characterStates: (scene.characterStates || []).map(state => ({
characterId: state.characterId,
on: state.on !== false,
outfitId: state.outfitId || null
}))
}))
ensureSceneStatesForSets(scenes.value, sets.value)
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, sets.value)
const next = cloneSceneForNext(previous, scenes.value.length + 1)
scenes.value.push(next)
activeIndex.value = scenes.value.length - 1
}
function moveScene(index: number, delta: number) {
const newIndex = index + delta
if (newIndex < 0 || newIndex >= scenes.value.length) return
const [scene] = scenes.value.splice(index, 1)
if (!scene) return
scenes.value.splice(newIndex, 0, scene)
if (activeIndex.value === index) {
activeIndex.value = newIndex
} else if (index < activeIndex.value && newIndex >= activeIndex.value) {
activeIndex.value--
} else if (index > activeIndex.value && newIndex <= activeIndex.value) {
activeIndex.value++
}
}
function deleteScene(index: number) {
if (scenes.value.length <= 1) return
scenes.value.splice(index, 1)
if (activeIndex.value >= scenes.value.length) {
activeIndex.value = scenes.value.length - 1
} else if (activeIndex.value > index) {
activeIndex.value--
}
}
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 addSet() {
const set = emptySet()
sets.value.push(set)
ensureSceneStatesForSets(scenes.value, sets.value, {
newSetId: set.id,
turnOnSceneIndex: activeIndex.value
})
}
function clearSet(index: number) {
const set = sets.value[index]
if (!set) return
set.name = ''
set.framings = [emptyFraming()]
for (const scene of scenes.value) {
const state = scene.setStates.find(item => item.setId === set.id)
if (state) state.framingId = null
}
}
function removeSet(index: number) {
const [removed] = sets.value.splice(index, 1)
if (!removed) return
for (const scene of scenes.value) {
scene.setStates = (scene.setStates || []).filter(state => state.setId !== removed.id)
}
}
function addFraming(setIndex: number) {
const set = sets.value[setIndex]
if (!set) return
set.framings.push(emptyFraming())
}
function removeFraming(setIndex: number, framingIndex: number) {
const set = sets.value[setIndex]
if (!set) return
const [removed] = set.framings.splice(framingIndex, 1)
if (!removed) return
for (const scene of scenes.value) {
const state = scene.setStates.find(item => item.setId === set.id)
if (state?.framingId === removed.id) state.framingId = null
}
}
function clearFraming(setIndex: number, framingIndex: number) {
const framing = sets.value[setIndex]?.framings[framingIndex]
if (!framing) return
framing.label = ''
framing.text = ''
}
function sceneSetState(setId: string) {
return activeScene.value?.setStates?.find(state => state.setId === setId)
}
function isSetOn(setId: string) {
const state = sceneSetState(setId)
return state ? !!state.on : true
}
function setSetOn(setId: string, on: boolean) {
const state = sceneSetState(setId)
if (!state) {
activeScene.value?.setStates.push({ setId, on, framingId: null })
return
}
state.on = on
}
function activeSetFramingId(setId: string) {
return sceneSetState(setId)?.framingId || ''
}
function setSetFraming(setId: string, framingId: string) {
const state = sceneSetState(setId)
if (!state) {
activeScene.value?.setStates.push({
setId,
on: true,
framingId: framingId || null
})
return
}
state.framingId = framingId || 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,
sets: sets.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>