chore: Update everything

This commit is contained in:
2026-07-24 15:58:31 +02:00
parent 5100ad8365
commit 6bd4d58373
34 changed files with 8829 additions and 1 deletions
+523
View File
@@ -0,0 +1,523 @@
<!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]">&#10003;&nbsp;saved</span>
</div>
<textarea
x-ref="textarea"
x-model="customPrompt"
@focus="selectedOption = 'custom'"
placeholder="Enter a prompt&#8230;"
rows="5"
></textarea>
<button class="btn-save" @click.stop="saveAndNext()">Save &amp; 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>