Extend Prompt Builder with sequence characters, outfits, and scene toggles.
Characters live on the sequence with per-scene on/off and outfit picks; Copy scene exports only the active tab; legacy saves still open.
This commit is contained in:
+256
-63
@@ -44,6 +44,89 @@
|
||||
<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"
|
||||
@@ -107,47 +190,37 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="text-sm font-medium text-zinc-300">Characters</span>
|
||||
<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>
|
||||
<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, cIndex) in activeScene.characters"
|
||||
:key="character.id"
|
||||
class="rounded-2xl border border-white/10 bg-zinc-950/40 p-3 space-y-2"
|
||||
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"
|
||||
>
|
||||
<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="activeScene.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"
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -172,6 +245,14 @@
|
||||
<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"
|
||||
@@ -219,6 +300,7 @@
|
||||
<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
|
||||
@@ -238,12 +320,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PromptSequence, PromptSequenceScene } from '~/utils/promptSequences'
|
||||
import type {
|
||||
PromptSequence,
|
||||
PromptSequenceCharacter,
|
||||
PromptSequenceScene
|
||||
} from '~/utils/promptSequences'
|
||||
import {
|
||||
assembleSceneText,
|
||||
assembleSequenceText,
|
||||
cloneSceneForNext,
|
||||
emptyCharacter,
|
||||
emptyScene
|
||||
emptyOutfit,
|
||||
emptyScene,
|
||||
ensureSceneStatesForCharacters,
|
||||
normalizePromptSequence
|
||||
} from '~/utils/promptSequences'
|
||||
|
||||
useHead({ title: 'Prompt Builder' })
|
||||
@@ -251,65 +341,149 @@ useHead({ title: 'Prompt Builder' })
|
||||
const sequences = ref<PromptSequence[]>([])
|
||||
const selectedId = ref('')
|
||||
const name = ref('')
|
||||
const scenes = ref<PromptSequenceScene[]>([emptyScene(1)])
|
||||
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({ scenes: scenes.value }))
|
||||
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 = ''
|
||||
scenes.value = [emptyScene(1)]
|
||||
characters.value = [emptyCharacter()]
|
||||
scenes.value = [emptyScene(1, characters.value)]
|
||||
activeIndex.value = 0
|
||||
error.value = ''
|
||||
notice.value = ''
|
||||
}
|
||||
|
||||
function select(sequence: PromptSequence) {
|
||||
selectedId.value = sequence.id
|
||||
name.value = sequence.name
|
||||
scenes.value = (sequence.scenes?.length ? sequence.scenes : [emptyScene(1)]).map((scene, index) => ({
|
||||
...emptyScene(index + 1),
|
||||
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,
|
||||
characters: (scene.characters?.length ? scene.characters : [emptyCharacter()]).map(character => ({
|
||||
...emptyCharacter(),
|
||||
...character
|
||||
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)
|
||||
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() {
|
||||
if (!activeScene.value) return
|
||||
activeScene.value.characters.push(emptyCharacter())
|
||||
const character = emptyCharacter()
|
||||
characters.value.push(character)
|
||||
ensureSceneStatesForCharacters(scenes.value, characters.value, {
|
||||
newCharacterId: character.id,
|
||||
turnOnSceneIndex: activeIndex.value
|
||||
})
|
||||
}
|
||||
|
||||
function clearCharacter(index: number) {
|
||||
const character = activeScene.value?.characters[index]
|
||||
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 (!activeScene.value || activeScene.value.characters.length <= 1) return
|
||||
activeScene.value.characters.splice(index, 1)
|
||||
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() {
|
||||
@@ -326,11 +500,16 @@ async function save() {
|
||||
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: { name: nextName, scenes: scenes.value }
|
||||
body: payload
|
||||
})
|
||||
sequences.value = saved.sequences
|
||||
select(saved.sequence)
|
||||
@@ -338,7 +517,7 @@ async function save() {
|
||||
} else {
|
||||
const saved = await $fetch<{ sequence: PromptSequence; sequences: PromptSequence[] }>('/api/prompt-sequences', {
|
||||
method: 'POST',
|
||||
body: { name: nextName, scenes: scenes.value, overwrite: Boolean(existingByName) }
|
||||
body: { ...payload, overwrite: Boolean(existingByName) }
|
||||
})
|
||||
sequences.value = saved.sequences
|
||||
select(saved.sequence)
|
||||
@@ -368,19 +547,32 @@ async function remove(sequence: PromptSequence) {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPreview() {
|
||||
const text = previewText.value
|
||||
async function writeClipboard(text: string, kind: 'all' | 'scene') {
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copied.value = true
|
||||
if (copyTimer) clearTimeout(copyTimer)
|
||||
copyTimer = setTimeout(() => { copied.value = false }, 1500)
|
||||
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()
|
||||
@@ -391,5 +583,6 @@ onMounted(async () => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (copyTimer) clearTimeout(copyTimer)
|
||||
if (copySceneTimer) clearTimeout(copySceneTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user