524 lines
16 KiB
HTML
524 lines
16 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Prompter</title>
|
|
<!-- Persist plugin must load before Alpine core -->
|
|
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>
|
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
body {
|
|
display: flex;
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
font-family: system-ui, -apple-system, sans-serif;
|
|
background: #0f0f0f;
|
|
color: #e0e0e0;
|
|
}
|
|
|
|
/* ── Image panel (left 50%) ──────────────────────────────────────────── */
|
|
.image-panel {
|
|
width: 50%;
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: #111;
|
|
padding: 20px;
|
|
gap: 10px;
|
|
border-right: 1px solid #1e1e1e;
|
|
}
|
|
|
|
.image-panel img {
|
|
max-width: 100%;
|
|
max-height: calc(100vh - 72px);
|
|
object-fit: contain;
|
|
}
|
|
|
|
.image-meta {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.image-filename {
|
|
font-size: 12px;
|
|
color: #666;
|
|
word-break: break-all;
|
|
text-align: center;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.image-counter {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 12px;
|
|
color: #555;
|
|
}
|
|
|
|
.image-counter input[type="number"] {
|
|
background: transparent;
|
|
border: none;
|
|
border-bottom: 1px solid #444;
|
|
color: #999;
|
|
font-family: inherit;
|
|
font-size: 12px;
|
|
outline: none;
|
|
padding: 0 2px;
|
|
text-align: right;
|
|
width: 3.5ch;
|
|
-moz-appearance: textfield;
|
|
}
|
|
|
|
.image-counter input[type="number"]:focus {
|
|
border-bottom-color: #4a6cf7;
|
|
color: #e0e0e0;
|
|
}
|
|
|
|
.image-counter input::-webkit-outer-spin-button,
|
|
.image-counter input::-webkit-inner-spin-button { -webkit-appearance: none; }
|
|
|
|
/* ── Options panel (right 50%) ───────────────────────────────────────── */
|
|
.options-panel {
|
|
width: 50%;
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.panel-top,
|
|
.panel-bottom {
|
|
flex: 1 1 0;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
padding: 24px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.panel-bottom {
|
|
border-top: 1px solid #1e1e1e;
|
|
}
|
|
|
|
.section-title {
|
|
font-size: 11px;
|
|
color: #555;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.option {
|
|
border: 1px solid #2a2a2a;
|
|
border-radius: 8px;
|
|
padding: 12px 14px;
|
|
cursor: pointer;
|
|
transition: border-color 0.1s;
|
|
}
|
|
|
|
.option:hover { border-color: #3a3a3a; }
|
|
.option.active { border-color: #4a6cf7; background: rgba(74, 108, 247, 0.06); }
|
|
|
|
.option-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 10px;
|
|
font-size: 12px;
|
|
color: #666;
|
|
}
|
|
|
|
.saved-badge { color: #4caf50; margin-left: auto; font-size: 11px; }
|
|
|
|
.kbd {
|
|
display: inline-block;
|
|
background: #1e1e1e;
|
|
border: 1px solid #333;
|
|
border-radius: 3px;
|
|
padding: 1px 6px;
|
|
font-size: 11px;
|
|
font-family: monospace;
|
|
color: #888;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
textarea {
|
|
width: 100%;
|
|
background: #1a1a1a;
|
|
border: 1px solid #333;
|
|
border-radius: 6px;
|
|
color: #e0e0e0;
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
padding: 10px;
|
|
resize: vertical;
|
|
min-height: 90px;
|
|
font-family: inherit;
|
|
outline: none;
|
|
transition: border-color 0.1s;
|
|
}
|
|
|
|
textarea:focus { border-color: #4a6cf7; }
|
|
|
|
.btn-save {
|
|
margin-top: 10px;
|
|
padding: 8px 18px;
|
|
background: #4a6cf7;
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 6px;
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
transition: background 0.1s;
|
|
}
|
|
|
|
.btn-save:hover { background: #3b5de8; }
|
|
|
|
.history-section { margin-top: 8px; }
|
|
.history-item { margin-top: 8px; }
|
|
|
|
.history-text {
|
|
font-size: 13px;
|
|
color: #bbb;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.blocks-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 6px;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.block-pill {
|
|
background: #1e1e1e;
|
|
border: 1px solid #333;
|
|
border-radius: 8px;
|
|
color: #ccc;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
font-size: 12px;
|
|
line-height: 1.4;
|
|
padding: 6px 10px;
|
|
text-align: left;
|
|
transition: border-color 0.1s, background 0.1s;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.block-pill:hover {
|
|
background: #2a2a2a;
|
|
border-color: #4a6cf7;
|
|
color: #e0e0e0;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body x-data="prompter" @keydown.window="handleKey($event)">
|
|
|
|
<!-- ── Left: image ───────────────────────────────────────────────────── -->
|
|
<div class="image-panel">
|
|
<img :src="`/image/${currentIndex}`" :alt="currentImage" />
|
|
<div class="image-meta">
|
|
<div class="image-filename" x-text="currentImage.split('/').pop()"></div>
|
|
<div class="image-counter">
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
:max="images.length"
|
|
:value="currentIndex + 1"
|
|
@change="jumpTo($event.target.valueAsNumber - 1)"
|
|
@keydown.stop
|
|
@click.stop
|
|
/>
|
|
<span>/ <span x-text="images.length"></span></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Right: options ────────────────────────────────────────────────── -->
|
|
<div class="options-panel">
|
|
|
|
<!-- ── Top half: prompt + history ──────────────────────────────────── -->
|
|
<div class="panel-top">
|
|
<div class="section-title">Prompt</div>
|
|
|
|
<!-- Custom prompt -->
|
|
<div class="option" :class="{ active: selectedOption === 'custom' }" @click="selectCustom()">
|
|
<div class="option-meta">
|
|
<span class="kbd">alt+1</span>
|
|
<span>Custom prompt</span>
|
|
<span class="saved-badge" x-show="selections[currentImage]">✓ saved</span>
|
|
</div>
|
|
<textarea
|
|
x-ref="textarea"
|
|
x-model="customPrompt"
|
|
@focus="selectedOption = 'custom'"
|
|
placeholder="Enter a prompt…"
|
|
rows="5"
|
|
></textarea>
|
|
<button class="btn-save" @click.stop="saveAndNext()">Save & Next</button>
|
|
</div>
|
|
|
|
<!-- History -->
|
|
<div class="history-section" x-show="promptHistory.length > 0">
|
|
<div class="section-title">Previously used</div>
|
|
<template x-for="(prompt, i) in promptHistory" :key="prompt">
|
|
<div
|
|
class="option history-item"
|
|
:class="{ active: selectedOption === i }"
|
|
@click="selectHistory(i)"
|
|
>
|
|
<div class="option-meta">
|
|
<span class="kbd" x-text="`alt+${i + 2}`"></span>
|
|
</div>
|
|
<div class="history-text" x-text="prompt"></div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Bottom half: building blocks ────────────────────────────────── -->
|
|
<div class="panel-bottom" x-show="buildingBlocks.length > 0">
|
|
<div class="section-title">Building blocks</div>
|
|
<div class="blocks-grid">
|
|
<template x-for="block in buildingBlocks" :key="block">
|
|
<button class="block-pill" @click="appendBlock(block)" x-text="block"></button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
/**
|
|
* @typedef {{ images: string[], canned_prompts: string[], building_blocks: string[], existing_prompts: Record<string, string> }} ApiImages
|
|
*/
|
|
|
|
document.addEventListener('alpine:init', () => {
|
|
Alpine.data('prompter', () => ({
|
|
|
|
/** @type {string[]} Ordered list of absolute image paths from the server */
|
|
images: [],
|
|
|
|
/** @type {number} Zero-based index of the currently displayed image */
|
|
currentIndex: 0,
|
|
|
|
/** @type {Record<string, string>} Absolute image path → saved prompt text */
|
|
selections: {},
|
|
|
|
/** @type {string} Current value of the custom-prompt textarea */
|
|
customPrompt: '',
|
|
|
|
/** @type {'custom' | number | null} Which option row is highlighted */
|
|
selectedOption: null,
|
|
|
|
/** @type {string[]} Reusable text fragments loaded from the server */
|
|
buildingBlocks: [],
|
|
|
|
/**
|
|
* Prompt history persisted to localStorage via Alpine Persist.
|
|
* Ordered most-recent-first; no duplicates.
|
|
* @type {string[]}
|
|
*/
|
|
promptHistory: Alpine.$persist([]).as('prompter_history'),
|
|
|
|
/**
|
|
* Absolute path of the image currently on screen.
|
|
* @returns {string}
|
|
*/
|
|
get currentImage() {
|
|
return this.images[this.currentIndex] ?? '';
|
|
},
|
|
|
|
/**
|
|
* Fetch the image list from the server, merge canned prompts into
|
|
* history, and restore any previously saved prompt for the first image.
|
|
*/
|
|
async init() {
|
|
/** @type {ApiImages} */
|
|
const data = await fetch('/api/images').then(r => r.json());
|
|
|
|
this.images = data.images;
|
|
this.selections = { ...data.existing_prompts };
|
|
this.buildingBlocks = data.building_blocks ?? [];
|
|
|
|
// Append canned prompts not already present in the persisted history.
|
|
const inHistory = new Set(this.promptHistory);
|
|
for (const p of data.canned_prompts) {
|
|
if (!inHistory.has(p)) {
|
|
this.promptHistory.push(p);
|
|
}
|
|
}
|
|
|
|
// Restore a previously saved prompt for the first image (if any).
|
|
const saved = this.selections[this.currentImage];
|
|
if (saved) {
|
|
this.customPrompt = saved;
|
|
this.selectedOption = 'custom';
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Append a building block to the textarea, separated by a blank line.
|
|
* If the textarea is empty the block becomes the entire content.
|
|
* @param {string} block
|
|
*/
|
|
appendBlock(block) {
|
|
const cur = this.customPrompt.trimEnd();
|
|
this.customPrompt = cur ? cur + '\n' + block : block;
|
|
this.selectedOption = 'custom';
|
|
this.$nextTick(() => this.$refs.textarea.focus());
|
|
},
|
|
|
|
/** Highlight the custom-prompt option and focus the textarea. */
|
|
selectCustom() {
|
|
this.selectedOption = 'custom';
|
|
this.$nextTick(() => this.$refs.textarea.focus());
|
|
},
|
|
|
|
/**
|
|
* Copy a history entry into the textarea and focus it.
|
|
* @param {number} i - Index into promptHistory
|
|
*/
|
|
selectHistory(i) {
|
|
this.customPrompt = this.promptHistory[i];
|
|
this.selectedOption = 'custom';
|
|
this.$nextTick(() => this.$refs.textarea.focus());
|
|
},
|
|
|
|
/**
|
|
* POST the current textarea content to the server and update local
|
|
* state. Does nothing if the textarea is blank.
|
|
*/
|
|
async saveCurrentIfNeeded() {
|
|
const prompt = this.customPrompt.trim();
|
|
if (!prompt) return;
|
|
|
|
const filePath = this.currentImage;
|
|
this.selections[filePath] = prompt;
|
|
this._addToHistory(prompt);
|
|
|
|
await fetch('/api/save', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ file_path: filePath, prompt }),
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Prepend a prompt to history, removing any earlier duplicate entry.
|
|
* @param {string} prompt
|
|
*/
|
|
_addToHistory(prompt) {
|
|
this.promptHistory = [
|
|
prompt,
|
|
...this.promptHistory.filter(p => p !== prompt),
|
|
];
|
|
},
|
|
|
|
/**
|
|
* Populate the textarea with the saved prompt for the current image,
|
|
* or clear it if no prompt has been saved yet.
|
|
*/
|
|
_loadCurrentSelection() {
|
|
const saved = this.selections[this.currentImage];
|
|
this.customPrompt = saved ?? '';
|
|
this.selectedOption = saved ? 'custom' : null;
|
|
},
|
|
|
|
/** Save the current prompt (if any) then advance to the next image. */
|
|
async navigateNext() {
|
|
if (this.currentIndex >= this.images.length - 1) return;
|
|
await this.saveCurrentIfNeeded();
|
|
this.currentIndex++;
|
|
this._loadCurrentSelection();
|
|
},
|
|
|
|
/** Save the current prompt (if any) then go back to the previous image. */
|
|
async navigatePrev() {
|
|
if (this.currentIndex <= 0) return;
|
|
await this.saveCurrentIfNeeded();
|
|
this.currentIndex--;
|
|
this._loadCurrentSelection();
|
|
},
|
|
|
|
/** Save the current prompt (if any) and advance to the next image. */
|
|
async saveAndNext() {
|
|
await this.saveCurrentIfNeeded();
|
|
if (this.currentIndex < this.images.length - 1) {
|
|
this.currentIndex++;
|
|
this._loadCurrentSelection();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Save the current prompt (if any) then jump to a specific index.
|
|
* Out-of-range values are clamped silently.
|
|
* @param {number} idx - Zero-based target index
|
|
*/
|
|
async jumpTo(idx) {
|
|
const target = Math.max(0, Math.min(idx, this.images.length - 1));
|
|
if (target === this.currentIndex) return;
|
|
await this.saveCurrentIfNeeded();
|
|
this.currentIndex = target;
|
|
this._loadCurrentSelection();
|
|
},
|
|
|
|
/**
|
|
* Global keyboard handler (attached to window via @keydown.window).
|
|
*
|
|
* Shortcuts:
|
|
* Alt+1 → focus the custom-prompt textarea
|
|
* Alt+N (N≥2) → load history[N-2] into the textarea and focus it
|
|
* → / k → next image (k ignored while textarea is focused)
|
|
* ← / j → prev image (j ignored while textarea is focused)
|
|
*
|
|
* Arrow keys navigate from anywhere, including inside the textarea,
|
|
* and auto-save the current prompt if non-empty.
|
|
*
|
|
* @param {KeyboardEvent} e
|
|
*/
|
|
handleKey(e) {
|
|
const inTextarea =
|
|
this.$refs.textarea &&
|
|
document.activeElement === this.$refs.textarea;
|
|
if (inTextarea) return;
|
|
|
|
if (e.altKey) {
|
|
const m = e.code.match(/^Digit(\d)$/);
|
|
if (m) {
|
|
const n = parseInt(m[1], 10);
|
|
e.preventDefault();
|
|
if (n === 1) {
|
|
this.selectCustom();
|
|
} else if (n >= 2 && n - 2 < this.promptHistory.length) {
|
|
this.selectHistory(n - 2);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (e.key === 'ArrowRight' || e.key === 'k') {
|
|
e.preventDefault();
|
|
this.navigateNext();
|
|
} else if (e.key === 'ArrowLeft' || e.key === 'j') {
|
|
e.preventDefault();
|
|
this.navigatePrev();
|
|
}
|
|
},
|
|
}));
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|