From bdc00a172cdfd756afc307eab0b3d2266ebddaff Mon Sep 17 00:00:00 2001 From: cowneko Date: Thu, 5 Mar 2026 23:56:48 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20release=20v1.0.0=20=E2=80=94?= =?UTF-8?q?=20CWK=20Prompt=20Composer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 33 ++++ CHANGELOG.md | 28 +++ LICENSE | 21 +++ README.md | 109 ++++++++++++ __init__.py | 5 + nodes.py | 80 +++++++++ web/index.js | 174 +++++++++++++++++++ web/panel_dialog.js | 225 ++++++++++++++++++++++++ web/pill_canvas.js | 375 ++++++++++++++++++++++++++++++++++++++++ web/preset_manager.js | 236 +++++++++++++++++++++++++ web/tag_browser.js | 261 ++++++++++++++++++++++++++++ web/tag_editor.js | 195 +++++++++++++++++++++ web/tags/aesthetic.json | 13 ++ web/tags/lucky.json | 48 +++++ web/tags/main.json | 68 ++++++++ web/tags/negative.json | 12 ++ web/tags/quality.json | 11 ++ 17 files changed, 1894 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 __init__.py create mode 100644 nodes.py create mode 100644 web/index.js create mode 100644 web/panel_dialog.js create mode 100644 web/pill_canvas.js create mode 100644 web/preset_manager.js create mode 100644 web/tag_browser.js create mode 100644 web/tag_editor.js create mode 100644 web/tags/aesthetic.json create mode 100644 web/tags/lucky.json create mode 100644 web/tags/main.json create mode 100644 web/tags/negative.json create mode 100644 web/tags/quality.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac79a72 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg + +# Virtual environments +.venv/ +venv/ +env/ + +# OS +.DS_Store +Thumbs.db +desktop.ini + +# Editors +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Logs +*.log + +# Node / npm (if ever used) +node_modules/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..47f5141 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +All notable changes to **CWK Prompt Composer** will be documented here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +--- + +## [1.0.0] - 2026-03-05 + +### Added +- Initial release of CWK Prompt Composer custom node for ComfyUI +- Four prompt panels: Quality, Main, Aesthetic, Negative +- Pill canvas with drag-to-reorder, selection, weight control (right-click) +- Tag browser: flat 2-level (Quality/Aesthetic/Negative) and 3-level nested (Main) +- Live filter search in tag browser +- Free-type custom tag input with Enter-to-add +- Join / Split selected pills +- Full undo / redo history (up to 100 steps) +- Preset save / load / rename / delete / export / import (JSON) +- 🎲 I Feel Lucky random prompt generator (Main panel) +- πŸ”ž NSFW toggle for Lucky generator (opt-in, stored in localStorage) +- πŸ“Œ Add-to-Tag-List: persist custom tags back to JSON files via `/cwk/add_tag` API +- Live positive/negative preview drawn directly on the ComfyUI node canvas +- Node serialisation: state saved and restored with workflow JSON +- Keyboard shortcuts: Ctrl+Enter confirm, Escape cancel, Ctrl+Z/Y undo/redo +- Curated tag libraries: quality.json, aesthetic.json, main.json, negative.json, lucky.json +- Resizable & draggable panel windows \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1097fd3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 cowneko + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c870a15 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# CWK Prompt Composer + +A custom node for [ComfyUI](https://github.com/comfyanonymous/ComfyUI) that lets you compose positive and negative prompts using a visual **pill-based editor** with tag browsing, presets, drag-to-reorder, weight control, and a lucky prompt generator. + +--- + +## ✨ Features + +- **4 prompt panels** β€” Quality, Main, Aesthetic, and Negative prompts, each with their own curated tag library +- **Pill canvas** β€” Each tag is a draggable, reorderable pill; click to select, drag to rearrange +- **Weight control** β€” Right-click any pill to set its emphasis weight (e.g. `(tag:1.3)`) +- **Tag browser** β€” Browse tags organised by category and subcategory with a live filter + - Flat 2-level browser for Quality / Aesthetic / Negative panels + - 3-level nested browser for the Main panel +- **Free-type input** β€” Type any custom tag and press Enter to add it instantly +- **Join / Split** β€” Merge selected pills with `_` or split underscore-joined pills apart +- **Undo / Redo** β€” Full history (Ctrl+Z / Ctrl+Shift+Z or Ctrl+Y) +- **Presets** β€” Save, load, rename, export (JSON) and import presets per panel +- **🎲 I Feel Lucky** β€” Generate a random coherent prompt from subject / clothing / expression / action / environment pools +- **πŸ”ž NSFW toggle** β€” Opt-in to include adult content in the Lucky generator +- **πŸ“Œ Add to Tag List** β€” Pin any custom or free-typed tag back into the persistent JSON tag library +- **Live preview** β€” The node canvas shows live previews of the assembled positive and negative prompts +- **Keyboard shortcuts** β€” `Esc` to cancel, `Ctrl+Enter` to confirm, `Ctrl+Z/Y` for undo/redo + +--- + +## πŸ“¦ Installation + +### Via ComfyUI Manager (recommended) +Search for **CWK Prompt Composer** in the ComfyUI Manager and install directly. + +### Manual installation +```bash +cd ComfyUI/custom_nodes +git clone https://github.com/cowneko/CWK_Prompt_Composer.git +``` +Then restart ComfyUI. + +--- + +## πŸ—‚οΈ File Structure + +``` +CWK_Prompt_Composer/ +β”œβ”€β”€ __init__.py ← Node registration + web directory +β”œβ”€β”€ nodes.py ← Python node + /cwk/add_tag API route +└── web/ + β”œβ”€β”€ index.js ← Extension entry, node draw & state + β”œβ”€β”€ pill_canvas.js ← PillCanvas widget + category colours + β”œβ”€β”€ tag_browser.js ← Flat & 3-level nested tag browsers + β”œβ”€β”€ panel_dialog.js ← Panel dialog, Lucky generator, NSFW toggle + β”œβ”€β”€ preset_manager.js ← Preset manager, window builder, storage helpers + └── tags/ + β”œβ”€β”€ quality.json ← Quality prompt tags + β”œβ”€β”€ aesthetic.json ← Aesthetic / style tags + β”œβ”€β”€ main.json ← Main subject / clothing / action / environment tags + β”œβ”€β”€ negative.json ← Negative prompt tags + └── lucky.json ← Lucky prompt pools (SFW + NSFW) +``` + +--- + +## πŸ–±οΈ Usage + +1. Add the **CWK Prompt Composer** node to your workflow. +2. Click any of the four buttons on the node (⭐ Quality, πŸ–ΌοΈ Main, 🎨 Aesthetic, ❌ Negative) to open its panel. +3. Browse or filter tags in the bottom browser and click to add them as pills. +4. Type a custom tag in the input bar and press **Enter**. +5. Drag pills to reorder. Right-click a pill to set its weight or add it to the tag library. +6. Click **βœ… Confirm** β€” the assembled prompt string is written back to the node widget. +7. The node outputs `positive_prompt` (Quality + Main + Aesthetic joined) and `negative_prompt`. + +### Keyboard shortcuts (inside a panel) +| Shortcut | Action | +|---|---| +| `Ctrl + Enter` | Confirm | +| `Escape` | Cancel | +| `Ctrl + Z` | Undo | +| `Ctrl + Shift + Z` / `Ctrl + Y` | Redo | + +--- + +## 🎨 Tag Categories + +| Panel | Categories | +|---|---| +| **Quality** | Resolution, Detail, Rendering, Award Winning | +| **Aesthetic** | Framing, Art Style, Era, Mood, Color Palette, Lighting | +| **Main** | Characters, Clothing, Action, Environment, NSFW | +| **Negative** | Quality Issues, Anatomy, Faces, Hands, Composition | + +--- + +## πŸ”§ Adding Tags Permanently + +Right-click any pill in the canvas β†’ **πŸ“Œ Add to Tag List**, or click the **πŸ“Œ** button next to the free-type input. Choose the category and subcategory, then click **Add Tag**. The tag is written directly to the corresponding JSON file on disk. + +--- + +## πŸ“‹ Presets + +- **πŸ’Ύ Save Preset** β€” saves the current pill set under a name (stored in `localStorage`) +- **πŸ“‹ Manage Presets** β€” load, rename, delete, export or import presets as JSON files + +--- + +## πŸͺͺ License + +[MIT License](LICENSE) β€” Β© 2026 cowneko \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..ebcde07 --- /dev/null +++ b/__init__.py @@ -0,0 +1,5 @@ +from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +WEB_DIRECTORY = "./web" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] \ No newline at end of file diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..98f69b2 --- /dev/null +++ b/nodes.py @@ -0,0 +1,80 @@ +import json +import os + +import server +from aiohttp import web + +# ── Tag directory ───────────────────────────────────────────────────────────── +TAG_DIR = os.path.join(os.path.dirname(__file__), "web", "tags") + +# ── Route: add a tag to a JSON file ────────────────────────────────────────── +@server.PromptServer.instance.routes.post("/cwk/add_tag") +async def add_tag(request): + try: + data = await request.json() + panel_key = data.get("panelKey") + category = data.get("category") + subcategory = data.get("subcategory") + tag = data.get("tag", "").strip() + + if not all([panel_key, category, subcategory, tag]): + return web.json_response({"ok": False, "error": "Missing fields"}, status=400) + + filepath = os.path.join(TAG_DIR, f"{panel_key}.json") + if not os.path.exists(filepath): + return web.json_response({"ok": False, "error": f"File not found: {panel_key}.json"}, status=404) + + with open(filepath, "r", encoding="utf-8") as f: + tag_data = json.load(f) + + for group in tag_data: + if group.get("category") == category: + for sub in group.get("subcategories", []): + if sub.get("name") == subcategory: + if tag in sub["tags"]: + return web.json_response({ + "ok": False, + "duplicate": True, + "where": f"{category} β€Ί {subcategory}" + }) + sub["tags"].append(tag) + sub["tags"].sort() + with open(filepath, "w", encoding="utf-8") as f: + json.dump(tag_data, f, indent=2, ensure_ascii=False) + return web.json_response({"ok": True}) + + return web.json_response( + {"ok": False, "error": "Category or subcategory not found"}, status=404 + ) + + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=500) + + +# ── Node ────────────────────────────────────────────────────────────────────── +class CWKPromptComposerNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "quality_prompt": ("STRING", {"default": "", "multiline": True}), + "main_prompt": ("STRING", {"default": "", "multiline": True}), + "aesthetic_prompt": ("STRING", {"default": "", "multiline": True}), + "negative_prompt": ("STRING", {"default": "", "multiline": True}), + } + } + + RETURN_TYPES = ("STRING", "STRING") + RETURN_NAMES = ("positive_prompt", "negative_prompt") + FUNCTION = "compose" + CATEGORY = "CWK" + + def compose(self, quality_prompt, main_prompt, aesthetic_prompt, negative_prompt): + positive = ", ".join(p for p in [quality_prompt, main_prompt, aesthetic_prompt] if p.strip()) + negative = negative_prompt.strip() + return (positive, negative) + + +# ── Mappings ────────────────────────────────────────────────────────────────── +NODE_CLASS_MAPPINGS = {"CWKPromptComposerNode": CWKPromptComposerNode} +NODE_DISPLAY_NAME_MAPPINGS = {"CWKPromptComposerNode": "CWK Prompt Composer"} \ No newline at end of file diff --git a/web/index.js b/web/index.js new file mode 100644 index 0000000..933df75 --- /dev/null +++ b/web/index.js @@ -0,0 +1,174 @@ +import { app } from "../../scripts/app.js"; +import { TAG_DATA, LUCKY_DATA, panelDialog } from "./panel_dialog.js"; + +// ── Load tag + lucky data ──────────────────────────────────────────────────── +async function loadTagData() { + const keys = ["quality", "aesthetic", "main", "negative"]; + await Promise.all(keys.map(async (key) => { + try { + const res = await fetch(`/extensions/CWK_Prompt_Composer/tags/${key}.json`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + TAG_DATA[key] = await res.json(); + } catch (e) { + console.error(`[CWK] Failed to load tags/${key}.json:`, e); + TAG_DATA[key] = []; + } + })); + try { + const res = await fetch(`/extensions/CWK_Prompt_Composer/tags/lucky.json`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + // LUCKY_DATA is exported as `let` so we mutate its keys + const data = await res.json(); + Object.assign(LUCKY_DATA, data); + } catch (e) { + console.error(`[CWK] Failed to load tags/lucky.json:`, e); + } +} + +// ── Node State ─────────────────────────────────────────────────────────────── +const nodeState = {}; +function getState(nodeId) { + if (!nodeState[nodeId]) nodeState[nodeId] = { quality: "", main: "", aesthetic: "", negative: "" }; + return nodeState[nodeId]; +} + +const BUTTONS = [ + { key: "quality", label: "⭐ Quality Prompt" }, + { key: "main", label: "πŸ–ΌοΈ Main Prompt" }, + { key: "aesthetic", label: "🎨 Aesthetic Prompt" }, + { key: "negative", label: "❌ Negative Prompt" }, +]; + +const BTN_H = 28, BTN_GAP = 6, BTN_PAD = 10; + +function getTopOffset(node) { + const slotH = LiteGraph.NODE_SLOT_HEIGHT ?? 20; + const numSlots = Math.max(node.outputs?.length ?? 0, node.inputs?.length ?? 0); + return numSlots > 0 ? Math.ceil((numSlots - 1 + 0.7) * slotH) + 18 : 8; +} + +function getButtonRects(node) { + const w = node.size[0], top = getTopOffset(node); + return BUTTONS.map((_, i) => ({ x: BTN_PAD, y: top + i * (BTN_H + BTN_GAP), w: w - BTN_PAD * 2, h: BTN_H })); +} + +function syncWidgets(node) { + const s = getState(node.id); + const map = { quality_prompt: s.quality, main_prompt: s.main, aesthetic_prompt: s.aesthetic, negative_prompt: s.negative }; + for (const w of (node.widgets ?? [])) { if (w.name in map) w.value = map[w.name]; } +} + +function getPositive(s) { return [s.quality, s.main, s.aesthetic].filter(Boolean).join(", "); } + +async function openPanel(node, key) { + const s = getState(node.id); + const btn = BUTTONS.find(b => b.key === key); + const result = await panelDialog.show({ title: btn.label, panelKey: key, defaultValue: s[key] ?? "" }); + if (result !== null) { s[key] = result; syncWidgets(node); app.graph.setDirtyCanvas(true, true); } +} + +// ── Draw ───────────────────────────────────────────────────────────────────── +function drawNode(node, ctx) { + const s = getState(node.id), rects = getButtonRects(node), w = node.size[0], top = getTopOffset(node); + ctx.save(); + ctx.strokeStyle = "#313244"; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(BTN_PAD, top - 8); ctx.lineTo(w - BTN_PAD, top - 8); ctx.stroke(); + + for (let i = 0; i < BUTTONS.length; i++) { + const { key, label } = BUTTONS[i], r = rects[i], hasValue = !!s[key]; + const grad = ctx.createLinearGradient(r.x, r.y, r.x, r.y + r.h); + grad.addColorStop(0, hasValue ? "#2a3a2a" : "#313244"); + grad.addColorStop(1, hasValue ? "#1a2a1a" : "#252535"); + ctx.fillStyle = grad; ctx.strokeStyle = hasValue ? "#a6e3a1" : "#45475a"; ctx.lineWidth = 1; + ctx.beginPath(); ctx.roundRect(r.x, r.y, r.w, r.h, 6); ctx.fill(); ctx.stroke(); + ctx.fillStyle = hasValue ? "#a6e3a1" : "#cdd6f4"; + ctx.font = "bold 13px sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; + ctx.fillText(label, r.x + r.w / 2, r.y + r.h / 2, r.w - 10); + } + + const lastRect = rects[rects.length - 1]; + const previewY = lastRect.y + lastRect.h + BTN_GAP * 2; + const previewH = node.size[1] - previewY - 8; + if (previewH < 30) { ctx.restore(); return; } + const halfH = Math.floor((previewH - BTN_GAP) / 2); + drawPreviewBox(ctx, { x: BTN_PAD, y: previewY, w: w - BTN_PAD * 2, h: halfH, label: "βœ… POSITIVE", text: getPositive(s) || "(empty)", color: "#a6e3a1" }); + drawPreviewBox(ctx, { x: BTN_PAD, y: previewY + halfH + BTN_GAP, w: w - BTN_PAD * 2, h: halfH, label: "❌ NEGATIVE", text: s.negative || "(empty)", color: "#f38ba8" }); + ctx.restore(); +} + +function drawPreviewBox(ctx, { x, y, w, h, label, text, color }) { + ctx.fillStyle = "#11111b"; ctx.strokeStyle = "#313244"; ctx.lineWidth = 1; + ctx.beginPath(); ctx.roundRect(x, y, w, h, 5); ctx.fill(); ctx.stroke(); + ctx.fillStyle = "#6c7086"; ctx.font = "bold 9px sans-serif"; ctx.textAlign = "left"; ctx.textBaseline = "top"; + ctx.fillText(label, x + 6, y + 5); + ctx.save(); + ctx.beginPath(); ctx.roundRect(x + 1, y + 1, w - 2, h - 2, 5); ctx.clip(); + ctx.fillStyle = color; ctx.font = "11px sans-serif"; ctx.textBaseline = "top"; + const lineH = 15, maxW = w - 12, startY = y + 18, maxLines = Math.max(1, Math.floor((h - 22) / lineH)); + const words = text.split(/,\s*/); + let line = "", lineN = 0; + for (const word of words) { + const test = line ? line + ", " + word : word; + if (ctx.measureText(test).width > maxW && line) { + if (lineN < maxLines) ctx.fillText(line + (lineN === maxLines - 1 ? "…" : ","), x + 6, startY + lineN * lineH); + line = word; lineN++; + } else line = test; + } + if (line && lineN < maxLines) ctx.fillText(line, x + 6, startY + lineN * lineH); + ctx.restore(); +} + +// ── Extension ──────────────────────────────────────────────────────────────── +app.registerExtension({ + name: "CWK.PromptComposer", + + async setup() { + await loadTagData(); + }, + + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== "CWKPromptComposerNode") return; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + onNodeCreated?.apply?.(this, arguments); + this.size = [340, 420]; + getState(this.id); + const HIDDEN = ["quality_prompt", "main_prompt", "aesthetic_prompt", "negative_prompt"]; + for (const w of (this.widgets ?? [])) { + if (HIDDEN.includes(w.name)) { + w.type = "converted-widget"; + w.hidden = true; + w.computeSize = () => [0, -4]; + } + } + }; + + const onDrawForeground = nodeType.prototype.onDrawForeground; + nodeType.prototype.onDrawForeground = function (ctx) { + onDrawForeground?.apply?.(this, arguments); + if (this.flags?.collapsed) return; + drawNode(this, ctx); + }; + + const onMouseDown = nodeType.prototype.onMouseDown; + nodeType.prototype.onMouseDown = function (e, localPos) { + if (onMouseDown?.apply?.(this, arguments)) return true; + if (this.flags?.collapsed) return false; + const [mx, my] = localPos, rects = getButtonRects(this); + for (let i = 0; i < BUTTONS.length; i++) { + const r = rects[i]; + if (mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h) { + openPanel(this, BUTTONS[i].key); + return true; + } + } + return false; + }; + + nodeType.prototype.onSerialize = function (o) { o.cwk_state = getState(this.id); }; + nodeType.prototype.onConfigure = function (o) { + if (o.cwk_state) { nodeState[this.id] = { ...o.cwk_state }; syncWidgets(this); } + }; + }, +}); \ No newline at end of file diff --git a/web/panel_dialog.js b/web/panel_dialog.js new file mode 100644 index 0000000..e4eeee5 --- /dev/null +++ b/web/panel_dialog.js @@ -0,0 +1,225 @@ +import { PillCanvas } from "./pill_canvas.js"; +import { buildTagBrowser } from "./tag_browser.js"; +import { presetManager, loadPresets, savePresets, makeWindow } from "./preset_manager.js"; +import { tagEditor } from "./tag_editor.js"; + +// ── Tag data store (filled by index.js) ───────────────────────────────────── +export const TAG_DATA = { quality: [], aesthetic: [], main: [], negative: [] }; +export let LUCKY_DATA = {}; + +// ── NSFW toggle ────────────────────────────────────────────────────────────── +const NSFW_KEY = "cwk_nsfw_enabled"; +function getNsfw() { return localStorage.getItem(NSFW_KEY) === "true"; } +function setNsfw(val) { localStorage.setItem(NSFW_KEY, val ? "true" : "false"); } + +// ── Lucky prompt generator ─────────────────────────────────────────────────── +function pickRandom(arr) { return arr[Math.floor(Math.random() * arr.length)]; } + +function generateLuckyPrompt(nsfw) { + const d = LUCKY_DATA; + const pick = (sfwKey, nsfwKey) => { + const pool = (nsfw && nsfwKey && d[nsfwKey]?.length) + ? [...(d[sfwKey] || []), ...(d[nsfwKey] || [])] + : (d[sfwKey] || []); + return pool.length ? pickRandom(pool) : null; + }; + return [ + pick("subject", "nsfw_subject"), + pick("clothing", "nsfw_clothing"), + pick("expression", null), + pick("action", "nsfw_action"), + pick("environment", null), + ].filter(Boolean).map(p => ({ id: Math.random().toString(36).slice(2), text: p, category: "custom", weight: 1.0 })); +} + +// ── Panel Dialog ───────────────────────────────────────────────────────────── +export class PanelDialog { + constructor() { + this._resolve = null; + this._canvas = null; + this._panelKey = null; + + const { win, backdrop, body, closeBtn, titleLabel } = makeWindow({ + title: "", + width: "700px", + height: "600px", + minWidth: "480px", + minHeight: "400px", + zIndex: "9999", + }); + this._win = win; + this._backdrop = backdrop; + this._body = body; + this._titleLabel = titleLabel; + closeBtn.addEventListener("click", () => this._cancel()); + } + + show({ title, panelKey, defaultValue = "" }) { + this._panelKey = panelKey; + this._canvas = new PillCanvas(() => {}); + this._canvas.setValue(defaultValue); + this._titleLabel.textContent = title; + this._body.innerHTML = ""; + + // wire up "Add to Tag List" callback + this._canvas.onAddToList = (tag) => { + tagEditor.show(tag, TAG_DATA[panelKey], panelKey); + }; + + const mkBtn = (text, bg, onClick) => { + const btn = document.createElement("button"); + btn.textContent = text; + Object.assign(btn.style, { padding: "5px 12px", background: bg, color: "#cdd6f4", border: "none", borderRadius: "6px", cursor: "pointer", fontSize: "12px", whiteSpace: "nowrap" }); + btn.addEventListener("click", onClick); + return btn; + }; + + // ── Free-type input ──────────────────────────────────────────────── + const freeInput = document.createElement("input"); + freeInput.type = "text"; + freeInput.placeholder = "✏️ Type a custom tag and press Enter…"; + Object.assign(freeInput.style, { flex: "1", padding: "5px 10px", background: "#1e1e2e", color: "#cdd6f4", border: "1px solid #45475a", borderRadius: "6px", fontSize: "12px", minWidth: "0" }); + freeInput.addEventListener("keydown", (e) => { + if (e.key !== "Enter") return; + e.stopPropagation(); + const val = freeInput.value.trim(); + if (val) { this._canvas.addTag(val, "custom"); freeInput.value = ""; } + }); + + // πŸ“Œ add free-typed tag to list + const pinBtn = mkBtn("πŸ“Œ", "#2a2a3b", () => { + const val = freeInput.value.trim(); + if (!val) return; + tagEditor.show(val, TAG_DATA[panelKey], panelKey); + }); + pinBtn.title = "Add this tag to the JSON list"; + + const undoBtn = mkBtn("↩ Undo", "#2a2a3b", () => this._canvas.undo()); + const redoBtn = mkBtn("β†ͺ Redo", "#2a2a3b", () => this._canvas.redo()); + + const freeRow = document.createElement("div"); + Object.assign(freeRow.style, { display: "flex", gap: "6px", alignItems: "center", flexShrink: "0" }); + freeRow.append(freeInput, pinBtn, undoBtn, redoBtn); + + // ── Lucky row (main panel only) ──────────────────────────────────── + const extraRows = []; + if (panelKey === "main") { + let nsfwOn = getNsfw(); + const nsfwToggle = document.createElement("button"); + const updateNsfwBtn = () => { + nsfwToggle.textContent = nsfwOn ? "πŸ”ž NSFW: ON" : "πŸ”’ NSFW: OFF"; + Object.assign(nsfwToggle.style, { + padding: "5px 12px", borderRadius: "6px", cursor: "pointer", + fontSize: "12px", fontWeight: "bold", whiteSpace: "nowrap", border: "1px solid", + background: nsfwOn ? "#3b1a2a" : "#2a2a3b", + color: nsfwOn ? "#f38ba8" : "#6c7086", + borderColor: nsfwOn ? "#f38ba8" : "#45475a", + transition: "all 0.15s", + }); + }; + updateNsfwBtn(); + nsfwToggle.addEventListener("click", () => { nsfwOn = !nsfwOn; setNsfw(nsfwOn); updateNsfwBtn(); }); + + const luckyBtn = document.createElement("button"); + luckyBtn.textContent = "🎲 I Feel Lucky"; + Object.assign(luckyBtn.style, { padding: "5px 14px", background: "#2a1f3b", color: "#cba6f7", border: "1px solid #cba6f7", borderRadius: "6px", cursor: "pointer", fontSize: "12px", fontWeight: "bold", whiteSpace: "nowrap" }); + luckyBtn.addEventListener("click", () => this._canvas.setLucky(generateLuckyPrompt(getNsfw()))); + + const luckyRow = document.createElement("div"); + Object.assign(luckyRow.style, { display: "flex", gap: "8px", alignItems: "center", flexShrink: "0" }); + luckyRow.append(luckyBtn, nsfwToggle); + extraRows.push(luckyRow); + } + + // ── Toolbar ──────────────────────────────────────────────────────── + const toolbar = document.createElement("div"); + Object.assign(toolbar.style, { display: "flex", gap: "6px", flexWrap: "wrap", alignItems: "center", flexShrink: "0" }); + toolbar.append( + mkBtn("πŸ—‘ Clear", "#3b1f1f", () => this._canvas.clear()), + mkBtn("πŸ”— Join", "#1f2d3b", () => this._canvas.joinSelected()), + mkBtn("βœ‚ Split", "#1f2d3b", () => this._canvas.splitSelected()), + mkBtn("πŸ’Ύ Save Preset", "#1f3b2a", () => this._savePreset()), + mkBtn("πŸ“‹ Manage Presets", "#2a1f3b", () => presetManager.show(this._panelKey, this._canvas)), + ); + + // ── Browser ──────────────────────────────────────────────────────── + const browser = buildTagBrowser(TAG_DATA[panelKey], panelKey, (tag, category) => this._canvas.addTag(tag, category)); + const browserScroll = document.createElement("div"); + Object.assign(browserScroll.style, { overflowY: "auto", flex: "1", minHeight: "0" }); + browserScroll.appendChild(browser); + + // ── Bottom bar ───────────────────────────────────────────────────── + const cancelBtn = document.createElement("button"); + cancelBtn.textContent = "Cancel"; + Object.assign(cancelBtn.style, { padding: "8px 20px", background: "#313244", color: "#cdd6f4", border: "none", borderRadius: "6px", cursor: "pointer", fontSize: "14px" }); + cancelBtn.addEventListener("click", () => this._cancel()); + + const confirmBtn = document.createElement("button"); + confirmBtn.textContent = "βœ… Confirm"; + Object.assign(confirmBtn.style, { padding: "8px 24px", background: "#89b4fa", color: "#1e1e2e", border: "none", borderRadius: "6px", cursor: "pointer", fontWeight: "bold", fontSize: "14px" }); + confirmBtn.addEventListener("click", () => this._confirm()); + + const bottomBar = document.createElement("div"); + Object.assign(bottomBar.style, { display: "flex", justifyContent: "flex-end", gap: "8px", flexShrink: "0" }); + bottomBar.append(cancelBtn, confirmBtn); + + const hr = document.createElement("hr"); + Object.assign(hr.style, { border: "none", borderTop: "1px solid #313244", margin: "0", flexShrink: "0" }); + + this._body.append( + this._canvas.el, + freeRow, + ...extraRows, + toolbar, + hr, + browserScroll, + bottomBar, + ); + + // ── Keyboard shortcuts ───────────────────────────────────────────── + this._keyHandler = (e) => { + if (e.key === "Escape") { this._cancel(); return; } + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { this._confirm(); return; } + if (e.key === "z" && (e.ctrlKey || e.metaKey) && !e.shiftKey) { e.preventDefault(); this._canvas.undo(); return; } + if ((e.key === "y" && (e.ctrlKey || e.metaKey)) || + (e.key === "z" && (e.ctrlKey || e.metaKey) && e.shiftKey)) { e.preventDefault(); this._canvas.redo(); return; } + }; + document.addEventListener("keydown", this._keyHandler); + + this._backdrop.style.display = "block"; + this._win.style.display = "flex"; + this._win.style.left = "50%"; + this._win.style.top = "60px"; + this._win.style.transform = "translateX(-50%)"; + + return new Promise((resolve) => { this._resolve = resolve; }); + } + + _confirm() { + document.removeEventListener("keydown", this._keyHandler); + const value = this._canvas?.getValue() ?? ""; + this._backdrop.style.display = "none"; + this._win.style.display = "none"; + this._resolve?.(value); + this._resolve = null; + } + + _cancel() { + document.removeEventListener("keydown", this._keyHandler); + this._backdrop.style.display = "none"; + this._win.style.display = "none"; + this._resolve?.(null); + this._resolve = null; + } + + _savePreset() { + const name = window.prompt("Preset name:"); + if (!name?.trim()) return; + const presets = loadPresets(this._panelKey); + presets[name.trim()] = this._canvas.getValue(); + savePresets(this._panelKey, presets); + alert(`βœ… Preset "${name.trim()}" saved!`); + } +} + +export const panelDialog = new PanelDialog(); \ No newline at end of file diff --git a/web/pill_canvas.js b/web/pill_canvas.js new file mode 100644 index 0000000..df518c5 --- /dev/null +++ b/web/pill_canvas.js @@ -0,0 +1,375 @@ +// ── Category β†’ color map ───────────────────────────────────────────────────── +export const CATEGORY_COLORS = { + quality: "#f9e2af", + aesthetic: "#cba6f7", + main: "#89dceb", + negative: "#fab387", + custom: "#cdd6f4", +}; + +export function categoryColor(category) { + if (!category) return CATEGORY_COLORS.custom; + const key = category.toLowerCase(); + for (const [k, v] of Object.entries(CATEGORY_COLORS)) { + if (key.includes(k)) return v; + } + return CATEGORY_COLORS.custom; +} + +// ── Pill Canvas ────────────────────────────────────────────────────────────── +export class PillCanvas { + constructor(onChange) { + this.pills = []; + this.selected = new Set(); + this.onChange = onChange; + this._dragIdx = null; + this._dragOver = null; + this._weightPopover = null; + this._ctxMenu = null; + this._undoStack = []; + this._redoStack = []; + + // callbacks set by PanelDialog + this.onAddToList = null; // (tag) => void + + this.el = document.createElement("div"); + Object.assign(this.el.style, { + minHeight: "80px", + maxHeight: "160px", + overflowY: "auto", + background: "#11111b", + border: "1px solid #313244", + borderRadius: "8px", + padding: "8px", + display: "flex", + flexWrap: "wrap", + gap: "6px", + alignContent: "flex-start", + flexShrink: "0", + }); + + document.addEventListener("mousedown", (e) => { + if (this._weightPopover && !this._weightPopover.contains(e.target)) this._closePopover(); + if (this._ctxMenu && !this._ctxMenu.contains(e.target)) this._closeCtxMenu(); + }); + } + + // ── Snapshot helpers ─────────────────────────────────────────────────── + _snapshot() { + this._undoStack.push(JSON.stringify(this.pills)); + if (this._undoStack.length > 100) this._undoStack.shift(); + this._redoStack = []; + } + + undo() { + if (!this._undoStack.length) return; + this._redoStack.push(JSON.stringify(this.pills)); + this.pills = JSON.parse(this._undoStack.pop()); + this.selected.clear(); this._closePopover(); this._closeCtxMenu(); + this._render(); this.onChange(this.getValue()); + } + + redo() { + if (!this._redoStack.length) return; + this._undoStack.push(JSON.stringify(this.pills)); + this.pills = JSON.parse(this._redoStack.pop()); + this.selected.clear(); this._closePopover(); this._closeCtxMenu(); + this._render(); this.onChange(this.getValue()); + } + + // ── Mutations ────────────────────────────────────────────────────────── + _nextId() { return Math.random().toString(36).slice(2); } + + addTag(text, category = "custom") { + this._snapshot(); + this.pills.push({ id: this._nextId(), text, category, weight: 1.0 }); + this._render(); this.onChange(this.getValue()); + } + + removeId(id) { + this._snapshot(); + this.pills = this.pills.filter(p => p.id !== id); + this.selected.delete(id); + this._render(); this.onChange(this.getValue()); + } + + clear() { + this._snapshot(); + this.pills = []; this.selected.clear(); + this._closePopover(); this._closeCtxMenu(); + this._render(); this.onChange(this.getValue()); + } + + toggleSelect(id) { + if (this.selected.has(id)) this.selected.delete(id); + else this.selected.add(id); + this._render(); + } + + joinSelected() { + const ids = [...this.selected]; + if (ids.length < 2) return; + this._snapshot(); + const indices = ids.map(id => this.pills.findIndex(p => p.id === id)).sort((a, b) => a - b); + const texts = indices.map(i => this.pills[i].text); + const joined = { id: this._nextId(), text: texts.join("_"), category: this.pills[indices[0]].category, weight: 1.0 }; + this.pills[indices[0]] = joined; + for (let i = indices.length - 1; i >= 1; i--) this.pills.splice(indices[i], 1); + this.selected.clear(); this._render(); this.onChange(this.getValue()); + } + + splitSelected() { + if (this.selected.size !== 1) return; + const id = [...this.selected][0]; + const idx = this.pills.findIndex(p => p.id === id); + if (idx === -1) return; + const parts = this.pills[idx].text.split("_").filter(Boolean); + if (parts.length < 2) return; + this._snapshot(); + const cat = this.pills[idx].category; + this.pills.splice(idx, 1, ...parts.map(t => ({ id: this._nextId(), text: t, category: cat, weight: 1.0 }))); + this.selected.clear(); this._render(); this.onChange(this.getValue()); + } + + setLucky(pills) { + this._snapshot(); + this.pills = pills; this.selected.clear(); + this._closePopover(); this._closeCtxMenu(); + this._render(); this.onChange(this.getValue()); + } + + getValue() { + return this.pills.map(p => { + const w = Math.round(p.weight * 10) / 10; + return w !== 1.0 ? `(${p.text}:${w.toFixed(1)})` : p.text; + }).join(", "); + } + + setValue(str) { + this._undoStack = []; this._redoStack = []; + this.pills = str + ? str.split(/,\s*/).filter(Boolean).map(token => { + const weighted = token.match(/^\((.+):(\d+(?:\.\d+)?)\)$/); + if (weighted) return { id: this._nextId(), text: weighted[1], category: "custom", weight: parseFloat(weighted[2]) }; + return { id: this._nextId(), text: token.trim(), category: "custom", weight: 1.0 }; + }) + : []; + this.selected.clear(); this._closePopover(); this._closeCtxMenu(); this._render(); + } + + // ── Context menu ─────────────────────────────────────────────────────── + _closeCtxMenu() { + if (this._ctxMenu) { this._ctxMenu.remove(); this._ctxMenu = null; } + } + + _openCtxMenu(pill, pillEl, e) { + this._closeCtxMenu(); + this._closePopover(); + + const menu = document.createElement("div"); + this._ctxMenu = menu; + Object.assign(menu.style, { + position: "fixed", + zIndex: "99999", + background: "#1e1e2e", + border: "1px solid #45475a", + borderRadius: "8px", + padding: "4px", + boxShadow: "0 4px 20px rgba(0,0,0,0.6)", + display: "flex", + flexDirection: "column", + gap: "2px", + minWidth: "180px", + }); + + const mkItem = (icon, label, onClick) => { + const item = document.createElement("div"); + Object.assign(item.style, { + display: "flex", + alignItems: "center", + gap: "8px", + padding: "7px 10px", + borderRadius: "5px", + cursor: "pointer", + fontSize: "12px", + color: "#cdd6f4", + userSelect: "none", + }); + const iconEl = document.createElement("span"); + iconEl.textContent = icon; + const labelEl = document.createElement("span"); + labelEl.textContent = label; + item.append(iconEl, labelEl); + item.addEventListener("mouseenter", () => item.style.background = "#313244"); + item.addEventListener("mouseleave", () => item.style.background = "transparent"); + item.addEventListener("click", () => { this._closeCtxMenu(); onClick(); }); + return item; + }; + + // divider + const divider = document.createElement("hr"); + Object.assign(divider.style, { border: "none", borderTop: "1px solid #313244", margin: "2px 0" }); + + // pill name header + const header = document.createElement("div"); + header.textContent = pill.text; + Object.assign(header.style, { + padding: "5px 10px 3px", + fontSize: "11px", + color: "#6c7086", + fontStyle: "italic", + userSelect: "none", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }); + + menu.appendChild(header); + menu.appendChild(divider.cloneNode()); + + menu.appendChild(mkItem("βš–", "Set Weight", () => this._openWeightPopover(pill, pillEl))); + + if (this.onAddToList) { + menu.appendChild(mkItem("πŸ“Œ", "Add to Tag List", () => this.onAddToList(pill.text))); + } + + document.body.appendChild(menu); + + // position near cursor + const mw = 180; + let left = e.clientX + 4, top = e.clientY + 4; + if (left + mw > window.innerWidth - 8) left = e.clientX - mw - 4; + if (top + 120 > window.innerHeight - 8) top = e.clientY - 120; + menu.style.left = left + "px"; + menu.style.top = top + "px"; + } + + // ── Weight popover ───────────────────────────────────────────────────── + _closePopover() { + if (this._weightPopover) { this._weightPopover.remove(); this._weightPopover = null; } + } + + _openWeightPopover(pill, pillEl) { + this._closePopover(); + const popover = document.createElement("div"); + this._weightPopover = popover; + Object.assign(popover.style, { + position: "fixed", zIndex: "99999", background: "#1e1e2e", + border: "1px solid #45475a", borderRadius: "8px", padding: "10px 14px", + boxShadow: "0 4px 20px rgba(0,0,0,0.6)", display: "flex", + flexDirection: "column", gap: "8px", minWidth: "200px", + }); + + const label = document.createElement("div"); + Object.assign(label.style, { color: "#cdd6f4", fontSize: "12px", fontWeight: "bold" }); + label.textContent = `βš– Weight: ${pill.text}`; + + const row = document.createElement("div"); + Object.assign(row.style, { display: "flex", alignItems: "center", gap: "8px" }); + + const slider = document.createElement("input"); + slider.type = "range"; slider.min = "0.1"; slider.max = "2.0"; slider.step = "0.1"; + slider.value = pill.weight.toFixed(1); + Object.assign(slider.style, { flex: "1", accentColor: "#89b4fa", cursor: "pointer" }); + + const valueLabel = document.createElement("span"); + Object.assign(valueLabel.style, { color: "#89b4fa", fontSize: "13px", fontWeight: "bold", minWidth: "28px", textAlign: "right" }); + valueLabel.textContent = pill.weight.toFixed(1); + + let _snapDone = false; + slider.addEventListener("mousedown", () => { _snapDone = false; }); + slider.addEventListener("input", () => { + if (!_snapDone) { this._snapshot(); _snapDone = true; } + const w = parseFloat(slider.value); + pill.weight = w; valueLabel.textContent = w.toFixed(1); + this._render(); this.onChange(this.getValue()); + }); + + const resetBtn = document.createElement("button"); + resetBtn.textContent = "Reset"; + Object.assign(resetBtn.style, { padding: "3px 8px", background: "#313244", color: "#cdd6f4", border: "none", borderRadius: "4px", cursor: "pointer", fontSize: "11px" }); + resetBtn.addEventListener("click", () => { + this._snapshot(); + pill.weight = 1.0; slider.value = "1.0"; valueLabel.textContent = "1.0"; + this._render(); this.onChange(this.getValue()); + }); + + row.append(slider, valueLabel); + popover.append(label, row, resetBtn); + document.body.appendChild(popover); + + const rect = pillEl.getBoundingClientRect(); + let left = rect.left, top = rect.bottom + 6; + if (left + 200 > window.innerWidth - 10) left = window.innerWidth - 210; + if (top + 110 > window.innerHeight - 10) top = rect.top - 116; + popover.style.left = left + "px"; + popover.style.top = top + "px"; + } + + // ── Render ───────────────────────────────────────────────────────────── + _render() { + this.el.innerHTML = ""; + if (this.pills.length === 0) { + const placeholder = document.createElement("span"); + Object.assign(placeholder.style, { color: "#45475a", fontSize: "12px", padding: "4px" }); + placeholder.textContent = "Click tags below to add them here…"; + this.el.appendChild(placeholder); + return; + } + + this.pills.forEach((pill, idx) => { + const selected = this.selected.has(pill.id); + const color = categoryColor(pill.category); + const hasWeight = Math.round(pill.weight * 10) / 10 !== 1.0; + + const pillEl = document.createElement("div"); + pillEl.draggable = true; + Object.assign(pillEl.style, { + display: "inline-flex", alignItems: "center", gap: "4px", + padding: "3px 8px", borderRadius: "12px", + background: selected ? `${color}22` : "#1e1e2e", + border: `1px solid ${selected ? color : color + "88"}`, + cursor: "grab", fontSize: "12px", + color: selected ? color : color + "cc", + userSelect: "none", transition: "all 0.1s", + }); + + const labelEl = document.createElement("span"); + labelEl.textContent = pill.text; + labelEl.addEventListener("click", (e) => { e.stopPropagation(); this.toggleSelect(pill.id); }); + + if (hasWeight) { + const badge = document.createElement("span"); + badge.textContent = pill.weight.toFixed(1); + Object.assign(badge.style, { fontSize: "9px", background: "#313244", color: "#89b4fa", borderRadius: "4px", padding: "1px 3px", fontWeight: "bold", lineHeight: "1.2" }); + pillEl.appendChild(badge); + } + + const xBtn = document.createElement("span"); + xBtn.textContent = "Γ—"; + Object.assign(xBtn.style, { cursor: "pointer", color: "#f38ba8", fontWeight: "bold", fontSize: "14px", lineHeight: "1", padding: "0 2px" }); + xBtn.addEventListener("click", (e) => { e.stopPropagation(); this.removeId(pill.id); }); + + pillEl.append(labelEl, xBtn); + + // right-click β†’ context menu + pillEl.addEventListener("contextmenu", (e) => { + e.preventDefault(); e.stopPropagation(); + this._openCtxMenu(pill, pillEl, e); + }); + + pillEl.addEventListener("dragstart", () => { this._dragIdx = idx; }); + pillEl.addEventListener("dragover", (e) => { e.preventDefault(); this._dragOver = idx; }); + pillEl.addEventListener("drop", () => { + if (this._dragIdx === null || this._dragIdx === this._dragOver) return; + this._snapshot(); + const moved = this.pills.splice(this._dragIdx, 1)[0]; + this.pills.splice(this._dragOver, 0, moved); + this._dragIdx = this._dragOver = null; + this._render(); this.onChange(this.getValue()); + }); + + this.el.appendChild(pillEl); + }); + } +} \ No newline at end of file diff --git a/web/preset_manager.js b/web/preset_manager.js new file mode 100644 index 0000000..982a74f --- /dev/null +++ b/web/preset_manager.js @@ -0,0 +1,236 @@ +// ── Preset Storage ─────────────────────────────────────────────────────────── +const PRESET_KEY = (k) => `cwk_presets_${k}`; +export function loadPresets(k) { try { return JSON.parse(localStorage.getItem(PRESET_KEY(k)) || "{}"); } catch { return {}; } } +export function savePresets(k, presets) { localStorage.setItem(PRESET_KEY(k), JSON.stringify(presets)); } + +// ── Shared window builder helper ───────────────────────────────────────────── +export function makeWindow({ title, width = "500px", height = "500px", minWidth = "380px", minHeight = "300px", zIndex = "10001" }) { + const backdrop = document.createElement("div"); + Object.assign(backdrop.style, { position: "fixed", inset: "0", zIndex: String(parseInt(zIndex) - 1), display: "none" }); + + const win = document.createElement("div"); + Object.assign(win.style, { + position: "fixed", top: "120px", left: "50%", transform: "translateX(-50%)", + width, height, minWidth, minHeight, + zIndex, display: "none", flexDirection: "column", + background: "#181825", border: "1px solid #45475a", borderRadius: "10px", + boxShadow: "0 8px 40px rgba(0,0,0,0.8)", overflow: "hidden", boxSizing: "border-box", + }); + + // Title bar + const titleBar = document.createElement("div"); + Object.assign(titleBar.style, { + height: "36px", flexShrink: "0", cursor: "move", background: "#1e1e2e", + borderBottom: "1px solid #313244", display: "flex", alignItems: "center", + padding: "0 12px", userSelect: "none", borderRadius: "10px 10px 0 0", gap: "6px", + }); + ["#f38ba8","#f9e2af","#a6e3a1"].forEach(c => { + const dot = document.createElement("div"); + Object.assign(dot.style, { width: "10px", height: "10px", borderRadius: "50%", background: c, flexShrink: "0" }); + titleBar.appendChild(dot); + }); + const titleLabel = document.createElement("span"); + Object.assign(titleLabel.style, { color: "#cdd6f4", fontSize: "13px", fontWeight: "bold", marginLeft: "4px", flex: "1" }); + titleLabel.textContent = title; + titleBar.appendChild(titleLabel); + + const closeBtn = document.createElement("div"); + closeBtn.textContent = "βœ•"; + Object.assign(closeBtn.style, { cursor: "pointer", color: "#6c7086", fontSize: "16px", lineHeight: "1", padding: "2px 6px", borderRadius: "4px", userSelect: "none" }); + closeBtn.addEventListener("mouseenter", () => { closeBtn.style.color = "#cdd6f4"; closeBtn.style.background = "#f38ba820"; }); + closeBtn.addEventListener("mouseleave", () => { closeBtn.style.color = "#6c7086"; closeBtn.style.background = "transparent"; }); + titleBar.appendChild(closeBtn); + + // Body + const body = document.createElement("div"); + Object.assign(body.style, { + display: "flex", flexDirection: "column", flex: "1", minHeight: "0", + padding: "14px", gap: "10px", boxSizing: "border-box", overflowY: "auto", + }); + + // SE resize handle + const resizeHandle = document.createElement("div"); + Object.assign(resizeHandle.style, { position: "absolute", right: "0", bottom: "0", width: "0", height: "0", borderStyle: "solid", borderWidth: "0 0 18px 18px", borderColor: "transparent transparent #45475a transparent", cursor: "nwse-resize", zIndex: "10" }); + const resizeInner = document.createElement("div"); + Object.assign(resizeInner.style, { position: "absolute", right: "1px", bottom: "-17px", width: "0", height: "0", borderStyle: "solid", borderWidth: "0 0 12px 12px", borderColor: "transparent transparent #89b4fa transparent" }); + resizeHandle.appendChild(resizeInner); + + win.appendChild(titleBar); + win.appendChild(body); + win.appendChild(resizeHandle); + document.body.appendChild(backdrop); + document.body.appendChild(win); + + // Drag + let startX, startY, startL, startT; + const onDragMove = (e) => { win.style.left = (startL + e.clientX - startX) + "px"; win.style.top = (startT + e.clientY - startY) + "px"; win.style.transform = ""; }; + const onDragUp = () => { document.removeEventListener("mousemove", onDragMove); document.removeEventListener("mouseup", onDragUp); document.body.style.userSelect = ""; }; + titleBar.addEventListener("mousedown", (e) => { + e.preventDefault(); + if (win.style.transform) { const r = win.getBoundingClientRect(); win.style.left = r.left + "px"; win.style.top = r.top + "px"; win.style.transform = ""; } + startX = e.clientX; startY = e.clientY; + startL = parseFloat(win.style.left) || 0; startT = parseFloat(win.style.top) || 0; + document.body.style.userSelect = "none"; + document.addEventListener("mousemove", onDragMove); document.addEventListener("mouseup", onDragUp); + }); + + // Resize + let rStartX, rStartY, rStartW, rStartH; + const onResizeMove = (e) => { win.style.width = Math.max(parseInt(minWidth), rStartW + e.clientX - rStartX) + "px"; win.style.height = Math.max(parseInt(minHeight), rStartH + e.clientY - rStartY) + "px"; }; + const onResizeUp = () => { document.removeEventListener("mousemove", onResizeMove); document.removeEventListener("mouseup", onResizeUp); document.body.style.userSelect = ""; }; + resizeHandle.addEventListener("mousedown", (e) => { + e.preventDefault(); e.stopPropagation(); + const r = win.getBoundingClientRect(); + rStartX = e.clientX; rStartY = e.clientY; rStartW = r.width; rStartH = r.height; + win.style.left = r.left + "px"; win.style.top = r.top + "px"; win.style.transform = ""; + document.body.style.userSelect = "none"; + document.addEventListener("mousemove", onResizeMove); document.addEventListener("mouseup", onResizeUp); + }); + + return { win, backdrop, body, closeBtn, titleLabel }; +} + +// ── Preset Manager ─────────────────────────────────────────────────────────── +export class PresetManager { + constructor() { + this._panelKey = null; + this._canvas = null; + const { win, backdrop, body, closeBtn } = makeWindow({ title: "πŸ“‹ Manage Presets", zIndex: "10001" }); + this._win = win; + this._backdrop = backdrop; + this._body = body; + closeBtn.addEventListener("click", () => this.hide()); + } + + show(panelKey, canvas) { + this._panelKey = panelKey; + this._canvas = canvas; + this._render(); + this._backdrop.style.display = "block"; + this._win.style.display = "flex"; + this._win.style.left = "50%"; + this._win.style.top = "120px"; + this._win.style.transform = "translateX(-50%)"; + } + + hide() { + this._backdrop.style.display = "none"; + this._win.style.display = "none"; + } + + _render() { + this._body.innerHTML = ""; + const presets = loadPresets(this._panelKey); + const names = Object.keys(presets); + + const topBar = document.createElement("div"); + Object.assign(topBar.style, { display: "flex", gap: "8px", flexShrink: "0", flexWrap: "wrap" }); + topBar.appendChild(this._mkBtn("πŸ“¦ Export All", "#1f2d3b", () => this._exportJSON(presets, `cwk_presets_${this._panelKey}_all.json`))); + topBar.appendChild(this._mkBtn("πŸ“‚ Import from JSON", "#2a1f3b", () => this._importJSON())); + this._body.appendChild(topBar); + + const hr = document.createElement("hr"); + Object.assign(hr.style, { border: "none", borderTop: "1px solid #313244", margin: "0", flexShrink: "0" }); + this._body.appendChild(hr); + + if (names.length === 0) { + const empty = document.createElement("div"); + Object.assign(empty.style, { color: "#45475a", fontSize: "13px", textAlign: "center", padding: "20px 0" }); + empty.textContent = "No presets saved yet."; + this._body.appendChild(empty); + return; + } + + const list = document.createElement("div"); + Object.assign(list.style, { display: "flex", flexDirection: "column", gap: "8px" }); + + names.forEach(name => { + const wrapper = document.createElement("div"); + Object.assign(wrapper.style, { background: "#1e1e2e", borderRadius: "6px", border: "1px solid #313244", overflow: "hidden" }); + + const row = document.createElement("div"); + Object.assign(row.style, { display: "flex", alignItems: "center", gap: "6px", padding: "8px 10px" }); + + let folded = true; + const arrow = document.createElement("span"); + arrow.textContent = "β–Ά"; + Object.assign(arrow.style, { fontSize: "10px", color: "#6c7086", cursor: "pointer", transition: "transform 0.15s", display: "inline-block", flexShrink: "0", userSelect: "none", padding: "2px 4px" }); + + const preview = document.createElement("div"); + Object.assign(preview.style, { display: "none", padding: "8px 12px", borderTop: "1px solid #313244", fontSize: "11px", color: "#a6adc8", lineHeight: "1.6", wordBreak: "break-word", whiteSpace: "pre-wrap", background: "#11111b", maxHeight: "120px", overflowY: "auto" }); + preview.textContent = presets[name] || "(empty)"; + + const toggleFold = () => { folded = !folded; preview.style.display = folded ? "none" : "block"; arrow.style.transform = folded ? "" : "rotate(90deg)"; arrow.style.color = folded ? "#6c7086" : "#89b4fa"; }; + arrow.addEventListener("click", toggleFold); + + const nameEl = document.createElement("span"); + Object.assign(nameEl.style, { color: "#cdd6f4", fontSize: "13px", flex: "1", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", cursor: "pointer" }); + nameEl.textContent = name; + nameEl.addEventListener("click", toggleFold); + + const loadBtn = this._mkBtn("Load", "#1f3b2a", () => { this._canvas.setValue(presets[name]); this.hide(); }); + const renameBtn = this._mkBtn("✏️ Rename", "#2a2a3b", () => { + const newName = window.prompt("New name:", name); + if (!newName?.trim() || newName.trim() === name) return; + const fresh = loadPresets(this._panelKey); + if (fresh[newName.trim()]) { alert("A preset with that name already exists."); return; } + fresh[newName.trim()] = fresh[name]; delete fresh[name]; + savePresets(this._panelKey, fresh); this._render(); + }); + const exportBtn = this._mkBtn("πŸ’Ύ Export", "#1f2d3b", () => this._exportJSON({ [name]: presets[name] }, `cwk_preset_${name}.json`)); + const delBtn = this._mkBtn("πŸ—‘ Delete", "#3b1f1f", () => { + if (!window.confirm(`Delete preset "${name}"?`)) return; + const fresh = loadPresets(this._panelKey); delete fresh[name]; + savePresets(this._panelKey, fresh); this._render(); + }); + delBtn.style.color = "#f38ba8"; + + row.append(arrow, nameEl, loadBtn, renameBtn, exportBtn, delBtn); + wrapper.appendChild(row); + wrapper.appendChild(preview); + list.appendChild(wrapper); + }); + + this._body.appendChild(list); + } + + _mkBtn(text, bg, onClick) { + const btn = document.createElement("button"); + btn.textContent = text; + Object.assign(btn.style, { padding: "4px 10px", background: bg, color: "#cdd6f4", border: "none", borderRadius: "5px", cursor: "pointer", fontSize: "12px", whiteSpace: "nowrap", flexShrink: "0" }); + btn.addEventListener("click", onClick); + return btn; + } + + _exportJSON(data, filename) { + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); + URL.revokeObjectURL(url); + } + + _importJSON() { + const input = document.createElement("input"); input.type = "file"; input.accept = ".json"; + input.addEventListener("change", async () => { + const file = input.files[0]; if (!file) return; + try { + const text = await file.text(); + const imported = JSON.parse(text); + if (typeof imported !== "object" || Array.isArray(imported)) throw new Error("Invalid format"); + const existing = loadPresets(this._panelKey); + let imported_count = 0, skipped = 0; + for (const [k, v] of Object.entries(imported)) { + if (typeof v !== "string") continue; + if (existing[k]) { skipped++; continue; } + existing[k] = v; imported_count++; + } + savePresets(this._panelKey, existing); + alert(`βœ… Imported ${imported_count} preset(s).${skipped ? ` Skipped ${skipped} duplicate(s).` : ""}`); + this._render(); + } catch (e) { alert("❌ Failed to import: " + e.message); } + }); + input.click(); + } +} + +export const presetManager = new PresetManager(); \ No newline at end of file diff --git a/web/tag_browser.js b/web/tag_browser.js new file mode 100644 index 0000000..b782aea --- /dev/null +++ b/web/tag_browser.js @@ -0,0 +1,261 @@ +import { categoryColor } from "./pill_canvas.js"; + +// ── Flat browser (quality / aesthetic / negative) ──────────────────────────── +function buildFlatBrowser(tagData, onTagClick) { + const filterInput = document.createElement("input"); + filterInput.type = "text"; + filterInput.placeholder = "πŸ” Filter tags…"; + Object.assign(filterInput.style, { + width: "100%", + padding: "6px 10px", + background: "#1e1e2e", + color: "#cdd6f4", + border: "1px solid #45475a", + borderRadius: "6px", + fontSize: "13px", + boxSizing: "border-box", + marginBottom: "8px", + }); + + const browserContent = document.createElement("div"); + Object.assign(browserContent.style, { display: "flex", flexDirection: "column", gap: "6px" }); + + function renderBrowser(filter = "") { + browserContent.innerHTML = ""; + const q = filter.toLowerCase().trim(); + + for (const group of tagData) { + const groupColor = categoryColor(group.category); + for (const sub of group.subcategories) { + const matchedTags = q ? sub.tags.filter(t => t.toLowerCase().includes(q)) : sub.tags; + if (matchedTags.length === 0) continue; + + let collapsed = !q; + + const arrow = document.createElement("span"); + arrow.textContent = "β–Ό"; + Object.assign(arrow.style, { + fontSize: "10px", transition: "transform 0.15s", + display: "inline-block", transform: collapsed ? "rotate(-90deg)" : "", + color: groupColor, + }); + + const header = document.createElement("div"); + Object.assign(header.style, { + display: "flex", alignItems: "center", gap: "6px", cursor: "pointer", + padding: "4px 6px", borderRadius: "4px", background: "#1e1e2e", + fontSize: "12px", fontWeight: "bold", userSelect: "none", + borderLeft: `3px solid ${groupColor}`, + }); + + const catLabel = document.createElement("span"); + catLabel.textContent = `${group.category} β€Ί ${sub.name}`; + catLabel.style.color = groupColor; + + const countLabel = document.createElement("span"); + countLabel.textContent = `(${matchedTags.length})`; + Object.assign(countLabel.style, { color: "#6c7086", fontWeight: "normal" }); + + header.append(arrow, catLabel, countLabel); + + const tagsEl = document.createElement("div"); + Object.assign(tagsEl.style, { + display: collapsed ? "none" : "flex", + flexWrap: "wrap", gap: "4px", padding: "4px 4px 4px 16px", + }); + + matchedTags.forEach(tag => { + const chip = document.createElement("div"); + chip.textContent = tag; + Object.assign(chip.style, { + padding: "2px 8px", borderRadius: "10px", background: "#1e1e2e", + border: `1px solid ${groupColor}88`, color: groupColor + "cc", + fontSize: "11px", cursor: "pointer", userSelect: "none", transition: "all 0.1s", + }); + chip.addEventListener("mouseenter", () => { chip.style.background = groupColor + "22"; chip.style.borderColor = groupColor; chip.style.color = groupColor; }); + chip.addEventListener("mouseleave", () => { chip.style.background = "#1e1e2e"; chip.style.borderColor = groupColor + "88"; chip.style.color = groupColor + "cc"; }); + chip.addEventListener("click", () => onTagClick(tag, group.category)); + tagsEl.appendChild(chip); + }); + + header.addEventListener("click", () => { + collapsed = !collapsed; + tagsEl.style.display = collapsed ? "none" : "flex"; + arrow.style.transform = collapsed ? "rotate(-90deg)" : ""; + }); + + browserContent.appendChild(header); + browserContent.appendChild(tagsEl); + } + } + } + + renderBrowser(); + filterInput.addEventListener("input", () => renderBrowser(filterInput.value)); + + const wrapper = document.createElement("div"); + Object.assign(wrapper.style, { display: "flex", flexDirection: "column" }); + wrapper.append(filterInput, browserContent); + return wrapper; +} + +// ── 3-level nested browser (main panel) ────────────────────────────────────── +function buildNestedBrowser(tagData, onTagClick) { + const filterInput = document.createElement("input"); + filterInput.type = "text"; + filterInput.placeholder = "πŸ” Filter tags…"; + Object.assign(filterInput.style, { + width: "100%", + padding: "6px 10px", + background: "#1e1e2e", + color: "#cdd6f4", + border: "1px solid #45475a", + borderRadius: "6px", + fontSize: "13px", + boxSizing: "border-box", + marginBottom: "8px", + }); + + const browserContent = document.createElement("div"); + Object.assign(browserContent.style, { display: "flex", flexDirection: "column", gap: "4px" }); + + function renderBrowser(filter = "") { + browserContent.innerHTML = ""; + const q = filter.toLowerCase().trim(); + + for (const group of tagData) { + const groupColor = categoryColor(group.category); + + // ── count total matching tags across all subcategories ──────── + const totalMatch = group.subcategories.reduce((n, s) => { + return n + (q ? s.tags.filter(t => t.toLowerCase().includes(q)).length : s.tags.length); + }, 0); + if (totalMatch === 0) continue; + + // ── Level 1 : Category header ───────────────────────────────── + let catCollapsed = !q; + + const catArrow = document.createElement("span"); + catArrow.textContent = "β–Ό"; + Object.assign(catArrow.style, { + fontSize: "10px", transition: "transform 0.15s", display: "inline-block", + transform: catCollapsed ? "rotate(-90deg)" : "", color: groupColor, + }); + + const catLabel = document.createElement("span"); + catLabel.textContent = group.category; + catLabel.style.color = groupColor; + + const catCount = document.createElement("span"); + catCount.textContent = `(${totalMatch})`; + Object.assign(catCount.style, { color: "#6c7086", fontWeight: "normal", fontSize: "11px" }); + + const catHeader = document.createElement("div"); + Object.assign(catHeader.style, { + display: "flex", alignItems: "center", gap: "6px", cursor: "pointer", + padding: "5px 8px", borderRadius: "6px", background: "#1e1e2e", + fontSize: "12px", fontWeight: "bold", userSelect: "none", + borderLeft: `3px solid ${groupColor}`, + }); + catHeader.append(catArrow, catLabel, catCount); + + // ─��� Level 1 body (holds all subcategories) ──────────────────── + const catBody = document.createElement("div"); + Object.assign(catBody.style, { + display: catCollapsed ? "none" : "flex", + flexDirection: "column", + gap: "3px", + paddingLeft: "12px", + marginBottom: "2px", + }); + + catHeader.addEventListener("click", () => { + catCollapsed = !catCollapsed; + catBody.style.display = catCollapsed ? "none" : "flex"; + catArrow.style.transform = catCollapsed ? "rotate(-90deg)" : ""; + }); + + // ── Level 2 : Subcategory headers ───────────────────────────── + for (const sub of group.subcategories) { + const matchedTags = q ? sub.tags.filter(t => t.toLowerCase().includes(q)) : sub.tags; + if (matchedTags.length === 0) continue; + + let subCollapsed = !q; + + const subArrow = document.createElement("span"); + subArrow.textContent = "β–Ό"; + Object.assign(subArrow.style, { + fontSize: "9px", transition: "transform 0.15s", display: "inline-block", + transform: subCollapsed ? "rotate(-90deg)" : "", color: groupColor + "aa", + }); + + const subLabel = document.createElement("span"); + subLabel.textContent = sub.name; + Object.assign(subLabel.style, { color: groupColor + "bb" }); + + const subCount = document.createElement("span"); + subCount.textContent = `(${matchedTags.length})`; + Object.assign(subCount.style, { color: "#6c7086", fontWeight: "normal", fontSize: "10px" }); + + const subHeader = document.createElement("div"); + Object.assign(subHeader.style, { + display: "flex", alignItems: "center", gap: "5px", cursor: "pointer", + padding: "3px 6px", borderRadius: "4px", background: "#181825", + fontSize: "11px", fontWeight: "bold", userSelect: "none", + borderLeft: `2px solid ${groupColor}55`, + }); + subHeader.append(subArrow, subLabel, subCount); + + // ── Level 3 : Tags ───────────────────────────────────────── + const tagsEl = document.createElement("div"); + Object.assign(tagsEl.style, { + display: subCollapsed ? "none" : "flex", + flexWrap: "wrap", + gap: "4px", + padding: "4px 4px 4px 12px", + }); + + matchedTags.forEach(tag => { + const chip = document.createElement("div"); + chip.textContent = tag; + Object.assign(chip.style, { + padding: "2px 8px", borderRadius: "10px", background: "#1e1e2e", + border: `1px solid ${groupColor}66`, color: groupColor + "aa", + fontSize: "11px", cursor: "pointer", userSelect: "none", transition: "all 0.1s", + }); + chip.addEventListener("mouseenter", () => { chip.style.background = groupColor + "22"; chip.style.borderColor = groupColor; chip.style.color = groupColor; }); + chip.addEventListener("mouseleave", () => { chip.style.background = "#1e1e2e"; chip.style.borderColor = groupColor + "66"; chip.style.color = groupColor + "aa"; }); + chip.addEventListener("click", () => onTagClick(tag, group.category)); + tagsEl.appendChild(chip); + }); + + subHeader.addEventListener("click", () => { + subCollapsed = !subCollapsed; + tagsEl.style.display = subCollapsed ? "none" : "flex"; + subArrow.style.transform = subCollapsed ? "rotate(-90deg)" : ""; + }); + + catBody.appendChild(subHeader); + catBody.appendChild(tagsEl); + } + + browserContent.appendChild(catHeader); + browserContent.appendChild(catBody); + } + } + + renderBrowser(); + filterInput.addEventListener("input", () => renderBrowser(filterInput.value)); + + const wrapper = document.createElement("div"); + Object.assign(wrapper.style, { display: "flex", flexDirection: "column" }); + wrapper.append(filterInput, browserContent); + return wrapper; +} + +// ── Public entry point ────���─────────────────────────────────────────────────── +export function buildTagBrowser(tagData, panelKey, onTagClick) { + return panelKey === "main" + ? buildNestedBrowser(tagData, onTagClick) + : buildFlatBrowser(tagData, onTagClick); +} \ No newline at end of file diff --git a/web/tag_editor.js b/web/tag_editor.js new file mode 100644 index 0000000..a1cb331 --- /dev/null +++ b/web/tag_editor.js @@ -0,0 +1,195 @@ +import { makeWindow } from "./preset_manager.js"; + +// ── Add-to-Tag-List panel ──────────────────────────────────────────────────── +class TagEditor { + constructor() { + const { win, backdrop, body, closeBtn } = makeWindow({ + title: "πŸ“Œ Add Tag to List", + width: "420px", + height: "auto", + minWidth: "340px", + minHeight: "200px", + zIndex: "10002", + }); + this._win = win; + this._backdrop = backdrop; + this._body = body; + + // auto height + Object.assign(this._win.style, { height: "auto" }); + + closeBtn.addEventListener("click", () => this.hide()); + this._resolve = null; + } + + // tagData = TAG_DATA[panelKey], panelKey for the POST + show(tag, tagData, panelKey) { + this._body.innerHTML = ""; + this._panelKey = panelKey; + + const mkLabel = (text) => { + const l = document.createElement("label"); + l.textContent = text; + Object.assign(l.style, { color: "#a6adc8", fontSize: "12px", fontWeight: "bold", marginBottom: "2px" }); + return l; + }; + + const mkSelect = () => { + const s = document.createElement("select"); + Object.assign(s.style, { + padding: "6px 10px", + background: "#1e1e2e", + color: "#cdd6f4", + border: "1px solid #45475a", + borderRadius: "6px", + fontSize: "13px", + cursor: "pointer", + width: "100%", + }); + return s; + }; + + // ── Tag name ─────────────────────────────────────────────────────── + const tagInput = document.createElement("input"); + tagInput.type = "text"; + tagInput.value = tag; + Object.assign(tagInput.style, { + padding: "6px 10px", + background: "#1e1e2e", + color: "#cdd6f4", + border: "1px solid #45475a", + borderRadius: "6px", + fontSize: "13px", + width: "100%", + boxSizing: "border-box", + }); + + // ── Category select ──────────────────────────────────────────────── + const catSelect = mkSelect(); + tagData.forEach(group => { + const opt = document.createElement("option"); + opt.value = group.category; + opt.textContent = group.category; + catSelect.appendChild(opt); + }); + + // ── Subcategory select ───────────────────────────────────────────── + const subSelect = mkSelect(); + + const populateSubs = (categoryName) => { + subSelect.innerHTML = ""; + const group = tagData.find(g => g.category === categoryName); + if (!group) return; + group.subcategories.forEach(sub => { + const opt = document.createElement("option"); + opt.value = sub.name; + opt.textContent = `${sub.name} (${sub.tags.length})`; + subSelect.appendChild(opt); + }); + }; + populateSubs(catSelect.value); + catSelect.addEventListener("change", () => populateSubs(catSelect.value)); + + // ── Status message ───────────────────────────────────────────────── + const status = document.createElement("div"); + Object.assign(status.style, { fontSize: "12px", minHeight: "18px", textAlign: "center" }); + + // ── Buttons ──────────────────────────────────────────────────────── + const confirmBtn = document.createElement("button"); + confirmBtn.textContent = "πŸ“Œ Add Tag"; + Object.assign(confirmBtn.style, { + padding: "8px 20px", background: "#89b4fa", color: "#1e1e2e", + border: "none", borderRadius: "6px", cursor: "pointer", + fontWeight: "bold", fontSize: "13px", flex: "1", + }); + + const cancelBtn = document.createElement("button"); + cancelBtn.textContent = "Cancel"; + Object.assign(cancelBtn.style, { + padding: "8px 20px", background: "#313244", color: "#cdd6f4", + border: "none", borderRadius: "6px", cursor: "pointer", + fontSize: "13px", flex: "1", + }); + cancelBtn.addEventListener("click", () => this.hide()); + + confirmBtn.addEventListener("click", async () => { + const tagVal = tagInput.value.trim(); + const catVal = catSelect.value; + const subVal = subSelect.value; + if (!tagVal) { status.textContent = "⚠️ Tag name cannot be empty."; status.style.color = "#f9e2af"; return; } + + confirmBtn.disabled = true; + confirmBtn.textContent = "Saving…"; + status.textContent = ""; + + try { + const res = await fetch("/cwk/add_tag", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ panelKey: this._panelKey, category: catVal, subcategory: subVal, tag: tagVal }), + }); + const json = await res.json(); + + if (json.ok) { + status.style.color = "#a6e3a1"; + status.textContent = `βœ… "${tagVal}" added to ${catVal} β€Ί ${subVal}`; + confirmBtn.textContent = "πŸ“Œ Add Tag"; + confirmBtn.disabled = false; + // brief pause then close + setTimeout(() => this.hide(), 1200); + } else if (json.duplicate) { + status.style.color = "#f9e2af"; + status.textContent = `⚠️ Already exists in ${json.where}`; + confirmBtn.textContent = "πŸ“Œ Add Tag"; + confirmBtn.disabled = false; + } else { + status.style.color = "#f38ba8"; + status.textContent = `❌ ${json.error}`; + confirmBtn.textContent = "πŸ“Œ Add Tag"; + confirmBtn.disabled = false; + } + } catch (e) { + status.style.color = "#f38ba8"; + status.textContent = `❌ Network error: ${e.message}`; + confirmBtn.textContent = "πŸ“Œ Add Tag"; + confirmBtn.disabled = false; + } + }); + + const btnRow = document.createElement("div"); + Object.assign(btnRow.style, { display: "flex", gap: "8px" }); + btnRow.append(confirmBtn, cancelBtn); + + // ── Layout ───────────────────────────────────────────────────────── + const grid = document.createElement("div"); + Object.assign(grid.style, { display: "flex", flexDirection: "column", gap: "10px" }); + + const tagGroup = document.createElement("div"); + Object.assign(tagGroup.style, { display: "flex", flexDirection: "column", gap: "4px" }); + tagGroup.append(mkLabel("Tag"), tagInput); + + const catGroup = document.createElement("div"); + Object.assign(catGroup.style, { display: "flex", flexDirection: "column", gap: "4px" }); + catGroup.append(mkLabel("Category"), catSelect); + + const subGroup = document.createElement("div"); + Object.assign(subGroup.style, { display: "flex", flexDirection: "column", gap: "4px" }); + subGroup.append(mkLabel("Subcategory"), subSelect); + + grid.append(tagGroup, catGroup, subGroup, status, btnRow); + this._body.appendChild(grid); + + this._backdrop.style.display = "block"; + this._win.style.display = "flex"; + this._win.style.left = "50%"; + this._win.style.top = "160px"; + this._win.style.transform = "translateX(-50%)"; + } + + hide() { + this._backdrop.style.display = "none"; + this._win.style.display = "none"; + } +} + +export const tagEditor = new TagEditor(); \ No newline at end of file diff --git a/web/tags/aesthetic.json b/web/tags/aesthetic.json new file mode 100644 index 0000000..c535267 --- /dev/null +++ b/web/tags/aesthetic.json @@ -0,0 +1,13 @@ +[ + { + "category": "Aesthetic", + "subcategories": [ + { "name": "Framing", "tags": ["aerial","ass_focus","between_breasts","between_fingers","between_legs","bust","cinematic_angle","close-up","cowboy_shot","dutch_angle","eyes_focus","face_focus","fisheye","foot_focus","foreshortening","from_above","from_back","from_behind","from_below","from_outside","from_side","front_view","full-length_portrait","full_body","full_shot","half-length_portrait","head_and_shoulders_portrait","head_portrait","hip_focus","landscape","macro_shot","medium_shot","midriff_peek","mirror_selfie","multiple_views","panorama","panty_peek","pantyshot","peeking","phone_screen","portrait","profile","pussy_peek","solo_focus","three-quarter_view","upper_body","upskirt","vehicle_focus","wide_shot","x-ray"] }, + { "name": "Art Style", "tags": ["16mm","35mm","3d_render","anime","cartoon","collage","digital_art","dreamworks","hyper_realism","illustration","imax","lomography","manga","oil_painting","panavision","photography","pixar","vector_art","watercolor"] }, + { "name": "Era", "tags": ["ancient","ancient_china","ancient_japan","art_deco","cyberpunk","futuristic","medieval","modern_china","renaissance","retro","steampunk","vintage"] }, + { "name": "Mood", "tags": ["dark","dreamy","ethereal","gritty","joyful","melancholic","romantic","surreal","whimsical"] }, + { "name": "Color Palette", "tags": ["cool_colors","golden_hour","monochrome","neon_colors","pastel_colors","sepia","vibrant_colors","warm_colors"] }, + { "name": "Lighting", "tags": ["backlighting","blue_light","cinematic_lighting","dramatic_lighting","golden_light","neon_light","rim_light","soft_lighting"] } + ] + } +] \ No newline at end of file diff --git a/web/tags/lucky.json b/web/tags/lucky.json new file mode 100644 index 0000000..e428207 --- /dev/null +++ b/web/tags/lucky.json @@ -0,0 +1,48 @@ +{ + "subject": [ + "1girl", "1boy", "1woman", "1man", "young woman", "young man", + "fantasy warrior", "cyberpunk hacker", "space explorer", "medieval knight", + "witch", "wizard", "elf", "android", "detective", "ninja", "samurai", + "pirate", "scientist", "artist", "musician", "dancer", "archer" + ], + "clothing": [ + "school uniform", "armor", "casual outfit", "kimono", "sundress", + "leather jacket", "hoodie", "elegant dress", "military uniform", + "fantasy robe", "cyberpunk suit", "Victorian dress", "tracksuit", + "trench coat", "crop top and jeans", "traditional hanbok", "wedding dress", + "space suit", "cloak", "priest robes", "maid outfit", "business suit" + ], + "expression": [ + "smiling", "serious", "surprised", "shy", "confident", "sad", + "angry", "laughing", "blushing", "focused", "dreamy", "nervous", + "curious", "determined", "melancholic", "joyful", "stoic", "winking" + ], + "action": [ + "standing", "sitting", "running", "looking at viewer", "walking", + "reading a book", "holding a sword", "casting a spell", "playing guitar", + "leaning against a wall", "arms crossed", "reaching out", "jumping", + "looking away", "lying down", "drinking coffee", "writing", "dancing", + "meditating", "drawing", "looking out a window", "holding a flower" + ], + "environment": [ + "forest", "cyberpunk city at night", "beach at sunset", "ancient library", + "snowy mountain", "futuristic space station", "medieval castle", "coffee shop", + "cherry blossom park", "underwater ruins", "desert oasis", "haunted mansion", + "floating sky islands", "neon-lit alley", "cozy bedroom", "rooftop at night", + "misty lake", "volcano crater", "crystal cave", "bamboo forest", + "abandoned factory", "magical garden", "train station", "cathedral" + ], + "nsfw_subject": [ + "1girl", "1boy", "1woman", "1man", "two girls", "two boys", + "elf woman", "android", "succubus", "vampire woman", "catgirl", "foxgirl" + ], + "nsfw_clothing": [ + "lingerie", "bikini", "partially undressed", "see-through dress", + "revealing outfit", "open shirt", "stockings and garter belt", + "naked", "topless", "underwear only" + ], + "nsfw_action": [ + "posing seductively", "lying on bed", "looking at viewer seductively", + "undressing", "embracing", "kissing", "sitting on lap" + ] +} \ No newline at end of file diff --git a/web/tags/main.json b/web/tags/main.json new file mode 100644 index 0000000..68122e4 --- /dev/null +++ b/web/tags/main.json @@ -0,0 +1,68 @@ +[ + { + "category": "Characters", + "subcategories": [ + { "name": "Subject", "tags": ["1boy_solo","1girl","1girl_solo","2boys","2girls","3boys","3girls","multiple_boys","multiple_girls"] }, + { "name": "Gender", "tags": ["androgynous","boy","feminine","girl","man","masculine","non-binary","woman"] }, + { "name": "Age", "tags": ["adult","aged","child","elderly","loli","mature","old","teenager","young","young_adult"] }, + { "name": "Ethnicity", "tags": ["Chinese","Japanese","Korean","african","asian","caucasian","diverse","indian","middle_eastern","mixed_race"] }, + { "name": "Body Type", "tags": ["athletic","average_build","curvy","muscular","petite","slender","slim","tall","thick"] }, + { "name": "Facial Features", "tags": ["Ears_that_stick_out","asian_eyes","beautiful_face","delicate_features","expressive_face","large_ears","large_eyes","large_mouth","sharp_features","small_ears","small_eyes","small_mouth","soft_features","symmetrical_face"] }, + { "name": "Breasts & Nipples", "tags": ["asian_nipples","dark_nipples","flat_chest","hanging_breasts","huge_breasts","large_breasts","large_nipples","medium_breasts","nipples","perky_breasts","small_breasts","small_nipples"] }, + { "name": "Hair Style", "tags": ["bangs","bob_cut","braid","chignon","cornrows","curly_bob","curly_frizzy_hair","double_buns_","dragon_phoenix_bun_","dreadlocks_","hanfu_hairstyle_","high_ponytail","hime_cut_","long_curly_hair","long_straight_hair","long_twintails","loose_curly_afro","low_ponytail","messy_bun_","pixie_cut","short_straight_hair","short_wavy_hair","side_braid","tomboy_cut","twin_braids","twintails","updo"] }, + { "name": "Lips", "tags": ["black_lips","glossy_lips","pink_lips","pulpy_lips","purple_lips","red_lips","rose-red_lips","thick_lips","thin_lips"] }, + { "name": "Eyes Color", "tags": ["albinos_eyes","black_eyes","blue_eyes","brown_eyes","chestnut_eyes","green_eyes","lavender_eyes","orange_eyes","red_eyes","silver_eyes"] }, + { "name": "Profession", "tags": ["acrobat","assassin","astronaut","athletes","ballerina","barista","boxer","bride","cashier","cheerleader","chef","cleric","coser","dancer","detective","diver","doll","firefighter","flight_stewardess","goalkeeper","harem","idol","king","knight","lifeguard","magician","maid","mailman","makihitsuji","miko","motorcyclist","nun","nurse","office lady","pilot","police","priest","queen","race_queen","racer","samurai","scientist","soldier","student","teacher","wa_maid","waitress","witch"] } + ] + }, + { + "category": "Clothing", + "subcategories": [ + { "name": "Nudity", "tags": ["bottomless","completely_nude","nude_breasts","partial_nudity","topless","unclothed","unclothing"] }, + { "name": "Casual", "tags": ["athleisure","casual_wear","hoodie","jeans","nightgown","nightie","pajamas","street_wear","sweater","t-shirt"] }, + { "name": "Uniform / Sport / Profession", "tags": ["aviator_uniform","band_uniform","baseball_uniform","business_suit","cheerleader","chef_uniform","fireman_uniform","flight-stewardess_uniform","gym_uniform","military_uniform","nurse_uniform","police_uniform","school_uniform","space_suit","tennis_uniform","volleyball_uniform"] }, + { "name": "Tops", "tags": ["aran_sweater","blouse","bomber_jacket","camisole","cardigan","casual_tshirt","collared_shirt","crop_top","cropped_shirt","dress_shirt","hawaiian_shirt","hoodie","jacket","lab_coat","polo_shirt","pullover","sailor_shirt","shirt","sportbra","sweater","sweatshirt","tank_top","taoist_coat","tshirt","undershirt","vest","windbreaker","zip-up_hoodie","pullover_hoodie","crewneck_sweatshirt"] }, + { "name": "Bottoms", "tags": ["a-line_skirt","ankle-length_skirt","ankle_pants","asymmetrical_skirt","baggy_pants","ball_gown_skirt","bell_bottoms","biker_pants","bikini_panties","bootcut_pants","boyfriend_jeans","boyshorts","briefs","bubble_skirt","cargo_pants","cargo_skirt","carpenter_pants","cheeky_panties","cheongsam_skirt","chiffon_skirt","chinos","cigarette_pants","control_panties","corduroy_pants","cotton_panties","crop_pants","culottes","cycling_pants","dance_belt","dress_pants","flared_pants","flared_skirt","full_coverage_panties","g-string","gaucho_pants","godet_skirt","harem_pants","high-cut_panties","high-low_skirt","high-waisted_panties","high-waisted_pants","hiphugger_panties","hot_pants","jodhpurs","joggers","karate_pants","khakis","kilt","leggings","loincloth","long_johns","loose-fit_pants","maxi_skirt","mesh_panties","micro_panties","micro_skirt","midi_skirt","mini_skirt","mom_jeans","no-show_panties","palazzo_pants","pantyhose","paperbag_pants","parachute_pants","peg-leg_pants","pencil_pants","pencil_skirt","peplum_skirt","pleated_pants","pleated_skirt","riding_pants","sailor_pants","sarong_skirt","seamless_panties","shapewear","skater_skirt","skinny_jeans","slim-fit_pants","slit_skirt","snow_pants","sport_panties","straight_skirt","string_bikini","sweatpants","tanga_panties","tapered_pants","tartan_skirt","thong","tiered_skirt","track_pants","trousers","trunks","tulip_skirt","tummy_control_panties","tutu_skirt","underwear_shorts","v-string","wide-leg_pants","womens_boxers","wrap_skirt","yoga_pants"] }, + { "name": "Clothes Style", "tags": ["asymmetric","backless_back_cutout","bell_sleeves","criss-cross_halter","frilled","fur-trimmed","high-low_hem","lace-trimmed","layered","off-shoulder","one-shoulder","peplum","pleated","puffed_sleeves","ribbon-trimmed","ruffled","see-through_transparent","short_sleeve","sleeveless","sling_strap_suspender","strapless","taut_tight","turtleneck","unbuttoned_open_clothes","wrapped"] }, + { "name": "Ornamentation", "tags": ["Camouflage_pattern","Paisley_pattern","argyle_pattern","checked-pattern","chevron_pattern","cloud_pattern","coin_pattern","diamond_pattern","dotted_pattern","floral_pattern","geometric_pattern","golden_sun_pattern","gradient_pattern","herringbone_pattern","hundstooth_pattern","ikat_pattern","liberty_pattern","lighting_pattern","moon_pattern","multi-colored","pheonix_pattern","plaid","polka_dot_pattern","quatrefoil_pattern","ribbed","rose_pattern","silver_star_pattern","striped_pattern","swirl_pattern","tartan_pattern","tie-dye_pattern","triangle_pattern","wave_pattern","zigzag_pattern"] }, + { "name": "Dress", "tags": ["armored_dress","ballgown","bohemian_dress","business_casual_dress","business_dress","business_formal_dress","business_professional_dress","casual_chic_dress","casual_dress","club_dress","cocktail_dress","edgy_dress","evening_gown","formal_dress","gothic_dress","gown","highleg_dress","hipster_dress","jumpsuit","long_dress","party_dress","pencil_dress","preppy_dress","punk_dress","retro_dress","sailor_dress","short_dress","short_sweater_dress","smart_casual_dress","sporty_dress","streetwear_dress","sundress","vintage_dress","wedding_dress"] }, + { "name": "Swimsuits", "tags": ["bikini","japanese_school_swimsuit","monokini","one-piece_swimsuit","skirted_swimsuit","slingshot_swimsuit","swim_dress","tankini","wetsuit"] }, + { "name": "Asian Clothes", "tags": ["aikidogi","ao_dai","cheongsam","gakuran","hadagi","hagoromo","hanbok","hanfu","japanese_school_uniform","jinbei","judogi","jujutsugi","karategi","kendogi","kimono","kimono_robe","nagajuban","nagajuban_robe","samue","yukata"] }, + { "name": "Japanese Tops", "tags": ["Hanten","Haori","happi","michiyuki","tanzen","tombi_coat"] }, + { "name": "Japanese Bottoms", "tags": ["Hakama","fundoshi","maekake","tobi_pants"] }, + { "name": "Japanese Accessories & Shoes", "tags": ["geta","hachimaki","obi","obiage","obidome","obijime","setta","tabi","tenugui","zori"] } + ] + }, + { + "category": "Action", + "subcategories": [ + { "name": "Body Posture / Pose", "tags": ["attacking","bathing","battle_stance","bent_over","bowing","casting_spell","climbing","combat_ready","cooking","dancing","defending","drawing","drawing_bow","drinking","driving","dynamic_pose","exercise","fetal_position","fighting","firing","fishing","flying","high_five","holding_hands","hugging","jumping","kissing","leaning_back","leaning_forward","leaning_to_the_side","licking_lips","lip_biting","looking_back","lying_down","lying_on_back","lying_on_grass","lying_on_side","lying_on_stomach","playing_instrument","pointing","pole_dancing","reading","riding","running","showering_shower_caddy","singing","sitting","smoking","standing","stretching","swimming","talking","talking_on_phone","using_weapon","walking","waving","writing"] }, + { "name": "Hand Movements", "tags": ["adjusting_eyewear","adjusting_hair","arm_above_head","arm_at_side","arm_behind_back","arm_behind_head","arm_up","arms_at_sides","arms_behind_back","arms_behind_head","arms_crossed","arms_up","beckoning","bunching_hair","caramelldansen","cheek_pinching","cheek_poking","chin_rest","chin_stroking","clapping","claw_pose","covering_eyes","covering_face","covering_mouth","crossed_arms","crossed_fingers","double_v","finger_biting","finger_gun","finger_in_mouth","finger_to_mouth","fingers_together","fist","hair_pull","hair_twirling","hand_in_hair","hand_in_pocket","hand_on_chest","hand_on_head","hand_on_hip","hand_on_own_cheek","hand_on_own_chin","hand_on_own_stomach","hand_to_mouth","hands_clasped","hands_on_hips","hands_on_lap","hands_on_own_cheeks","hands_on_own_face","hands_on_own_head","hands_on_own_knees","hands_on_own_stomach","hands_on_own_thighs","head_rest","heart_sig","index_finger_raised","middle_finger","ok_sign","outstretched_arms","palms_together","paw_pose","peace_sign","pointing_at_self","pointing_at_viewer","pointing_up","poking","praying","reaching_out","salute","shading_eyes","shushing","snapping_fingers","spread_fingers","thumbs_up","v","v_arms","w","w_arms","waving","wiping_tear"] }, + { "name": "Leg Movements", "tags": ["all_fours","butterfly_sitting","crossed_ankles","crossed_legs","curtsy","folded","high_kick","hugging_own_legs","kicking","knee_up","kneeling","knees_to_chest","knees_together_feet_apart","leg_up","legs_apart","legs_over_head","legs_together","legs_up","lotus_position","m_legs","one_knee","open_stance","pigeon-toed","sidesaddle","sitting","sitting_backwards","sitting_in_tree","sitting_on_bed","sitting_on_chair","sitting_on_desk","sitting_on_railing","sitting_on_rock","sitting_on_stairs","sitting_on_table","sitting_on_water","spread_legs","squatting","squatting_open_legs","standing_split_leg_up","straddling","tiptoes","toe-point","upright_straddle","wariza","yokozuwari"] }, + { "name": "Glance", "tags": ["head_down","head_tilt","looking_afar","looking_ahead","looking_at_viewer","looking_away","looking_down","looking_over_shoulder","looking_to_the_side","looking_up"] }, + { "name": "Expression", "tags": ["angry","annoyed","blush","confused","contempt","crazy_smile","crying_with_eyes_open","evil_smile","flustered","frustrated","furrowed_brow","giggling","glaring","grin","happy_tears","laughing","moaning","neutral","sad","scared","screaming","seductive_smile","serious","shouting","shy","smile","smiling","smirk","stare","surprised","wink"] } + ] + }, + { + "category": "Environment", + "subcategories": [ + { "name": "Nature", "tags": ["a_lawn_covered_with_petals","beach","botanical_garden","desert","forest","garden","jungle","meadow","mountain","park","river","treehouse","valley","waterfall"] }, + { "name": "Urban", "tags": ["airport","alley","amusement_park","arena","avenue","bridge","bus_stop","castle","cathedral","church","city","cityscape","cyberpunk_city","downtown","east_asian_architecture","ferris_wheel","office_building","fountain","graveyard","harbor","industrial_area","japanese_garden","playground","railroad_tracks","rooftop","ruins","skyscraper","stadium","street","subway","swimming_pool","train_station","tunnel","under_bridge","urban_setting","waterpark"] }, + { "name": "Interior", "tags": ["airplane_interior","aquarium","art_gallery","art_room","auditorium","bakery","bar","basement","bathhouse","bathroom","bed","bedroom","bench","bench_seats_in_the_train","bowling_alley","cafe","casino","classroom","clubroom","coffee_house","concert_hall","convenience_store","courtroom","dining_hall","dormitory","dungeon","electrical_room","engine_room","greenhouse","gym","hospital","hotel","infirmary","intersection","izakaya","kitchen","laboratory","library","market_stall","metro_station","mosque","museum","onsen","pharmacy","planetarium","prison","reservoir","restaurant","school","server_room","shop","stage","supermarket","temple","theater","throne_room","warehouse","workshop"] }, + { "name": "Fantasy", "tags": ["dragon_lair","enchanted_forest","fantasy_world","magical_realm","mystical_landscape","wizard_tower"] }, + { "name": "Weather", "tags": ["aurora","cloudy","dawn","foggy","golden_hour","night","overcast","rainy","snowy","stormy","sunny","sunrise","sunset"] } + ] + }, + { + "category": "NSFW", + "subcategories": [ + { "name": "Stimulation", "tags": ["footjob","double_footjob","implied_footjob","cooperative_footjob","shoejob","bootjob","feet","licking_foot","foot_worship","smelling_feet","frottage","armpit_sex","grinding","backjob","buttjob","cooperative_grinding","kneepit_sex","paizuri","autopaizuri","cooperative_paizuri","handsfree_paizuri","naizuri","paizuri_over_clothes","paizuri_on_lap","paizuri_under_clothes","pecjob","perpendicular_paizuri","stealth_paizuri","straddling_paizuri","thigh_sex","glansjob","cloth_glansjob","groping","grabbing_another's_ass","grabbing_another's_breast","guided_breast_grab","pectoral_grab","guided_pectoral_grab","nipple_tweak","crotch_grab","guided_crotch_grab","grabbing_own_breast","torso_grab","hairjob","handjob","caressing_testicles","double_handjob","cooperative_handjob","nursing_handjob","cuddling_handjob","reverse_nursing_handjob","reach-around","two-handed_handjob","masturbation","clothed_masturbation","crotch_rub","building_sex","pillow_humping","teddy_bear_sex","table_humping","female_masturbation","futanari_masturbation","implied_masturbation","male_masturbation","mutual_masturbation","stealth_masturbation","tail_masturbation","tail","tail_insertion","tailjob","oral","anilingus","rusty_trombone","breast_sucking","cunnilingus","autocunnilingus","implied_cunnilingus","fellatio","autofellatio","cum_swap","deepthroat","implied_fellatio","irrumatio","cooperative_fellatio","multiple_penis_fellatio","hug_and_suck","licking_testicle","sitting_on_face","testicle_sucking"] }, + { "name": "Group Sex", "tags": ["group_sex","bisexual","bisexual_female","bisexual_male","oral_sandwich","daisy_chain","gangbang","double_penetration","triple_penetration","love_train","cooperative_fellatio","cooperative_footjob","cooperative_breast_smother","orgy","reverse_spitroast","spitroast","teamwork","threesome","MMF_threesome","FFM_threesome","MMM_threesome","FFF_threesome"] }, + { "name": "Penetration", "tags": ["animal_insertion","cervical_penetration","covered_penetration","deep_penetration","food_insertion","inflation","cum_inflation","big_belly","enema","large_insertion","stomach_bulge","male_penetrated","multiple_insertions","nipple_penetration","nosejob","object_insertion","vaginal_object_insertion","anal_object_insertion","urethral_insertion","sounding","fingering","anal_fingering","prostate_milking","fingering_through_clothes","fingering_through_panties","implied_fingering","fisting","anal_fisting","self_fisting","sex","after_sex","after_anal","after_buttjob","after_fellatio","after_fingering","after_frottage","after_insertion","after_masturbation","after_oral","after_paizuri","after_rape","after_urethral","after_vaginal","afterglow","clothed_after_sex","anal","double_anal","imminent_anal","pegging","triple_anal","clothed_sex","guided_penetration","happy_sex","imminent_penetration","implied_sex","navel_sex","rough_sex","sex_from_behind","skull_fucking","ear_sex","penis_in_eye","tentacle_sex","underwater_sex","vaginal","double_vaginal","imminent_vaginal","triple_vaginal"] }, + { "name": "Same Sex Acts","tags": ["yaoi","bara","implied_yaoi","bulges_touching","penises_touching","testicles_touching","yuri","implied_yuri","tribadism"] }, + { "name": "Cum", "tags": ["cum","bukkake","cumdump","cum_bath","cumdrip","cum_pool","cum_inflation","cum_in_ass","cum_in_clothes","cum_in_cup","cum_in_mouth","cum_in_throat","cum_on_tongue","cum_in_pussy","cum_in_urethra","cum_on_body","cum_on_armpits","cum_on_ass","cum_on_back","cum_on_breasts","cum_on_chest","cum_on_feet","cum_on_fingers","cum_on_hair","cum_on_pectorals","cum_on_pussy","cum_on_stomach","cum_in_navel","cum_on_clothes","cum_on_eyewear","cum_on_food","ejaculation","ejaculating_while_penetrated","pull_out","facial","autofacial","felching","gokkun"] }, + { "name": "Exhibitionism","tags": ["exhibitionism","clothed_sex","public_indecency","public_nudity","zenra","public_vibrator","stealth_bondage","stealth_masturbation","stealth_paizuri","stealth_sex","cheating","netorare","reverse_netorare","prostitution","public_use","voyeurism","caught","peeking","walk-in"] }, + { "name": "Expressions", "tags": [":>=","fucked_silly","ahegao","torogao","naughty_face"] } + ] + } +] \ No newline at end of file diff --git a/web/tags/negative.json b/web/tags/negative.json new file mode 100644 index 0000000..91e8e6f --- /dev/null +++ b/web/tags/negative.json @@ -0,0 +1,12 @@ +[ + { + "category": "Negative", + "subcategories": [ + { "name": "Quality Issues", "tags": ["worst_quality","low_quality","blurry","distorted","artifacts","noise","pixelated","low_resolution"] }, + { "name": "Anatomy", "tags": ["bad_anatomy","wrong_anatomy","malformed","deformed","twisted","contorted","broken_limbs","extra_limbs","missing_limbs"] }, + { "name": "Faces", "tags": ["bad_face","ugly_face","deformed_face","bad_teeth","bad_eyes","crossed_eyes","asymmetrical_face"] }, + { "name": "Hands", "tags": ["bad_hands","weird_hands","deformed_hands","extra_fingers","missing_fingers","twisted_fingers","fused_fingers"] }, + { "name": "Composition", "tags": ["poorly_composed","cluttered","confusing","amateur","unbalanced","off-center"] } + ] + } +] \ No newline at end of file diff --git a/web/tags/quality.json b/web/tags/quality.json new file mode 100644 index 0000000..04d364e --- /dev/null +++ b/web/tags/quality.json @@ -0,0 +1,11 @@ +[ + { + "category": "Quality", + "subcategories": [ + { "name": "Resolution", "tags": ["8k_uhd","4k","1080p","highres","ultra_highres","masterpiece","best_quality"] }, + { "name": "Detail", "tags": ["hyper-detailed","intricate_details","extremely_detailed","fine_details","sharp_focus","crystal_clear","crisp_details"] }, + { "name": "Rendering", "tags": ["raytracing","path_tracing","ambient_occlusion","photorealistic","realistic_shading","physically_accurate","global_illumination"] }, + { "name": "Award Winning", "tags": ["award-winning","trending_on_artstation","featured_on_pixiv","museum_quality","professionally_done","gallery_quality"] } + ] + } +] \ No newline at end of file