Bootstrap project and safe work-item workflow
This commit is contained in:
91
nsfwtag/webapp.py
Normal file
91
nsfwtag/webapp.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Render the review page.
|
||||
|
||||
The HTML/CSS/JS lives in review.html (loaded once at import). This module fills
|
||||
in the %%TOKENS%%: the folder-tree sidebar, the image cards, totals. The server
|
||||
contract is preserved: each .nsfw checkbox's value is the image's absolute path,
|
||||
thumbnails load via GET /img?path=, actions POST to /apply /untag /tagged.
|
||||
"""
|
||||
import html
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
# Inline Lucide-style SVG icons (no emoji, theme-able via currentColor).
|
||||
_SVG_EXPAND = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3M8 21H5a2 2 0 0 1-2-2v-3m18 0v3a2 2 0 0 1-2 2h-3"/></svg>'
|
||||
_SVG_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>'
|
||||
|
||||
_PAGE = (Path(__file__).parent / "review.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def folder_tree_html(cands):
|
||||
"""Indented folder list for the sidebar file browser, built from the candidate
|
||||
paths. Each row filters the grid to that folder + its subfolders (prefix match);
|
||||
'All folders' resets. Counts are images directly in that folder."""
|
||||
folders = {}
|
||||
for p, _ in cands:
|
||||
folders[str(p.parent)] = folders.get(str(p.parent), 0) + 1
|
||||
if not folders:
|
||||
return ""
|
||||
paths = sorted(folders)
|
||||
root = os.path.commonpath(paths) if len(paths) > 1 else paths[0]
|
||||
rows = [
|
||||
f'<button type="button" class="fitem on" data-folder="" onclick="pickFolder(this)">'
|
||||
f'<span class="fname">All folders</span><span class="fcount">{sum(folders.values())}</span></button>'
|
||||
]
|
||||
for folder in paths:
|
||||
rel = os.path.relpath(folder, root)
|
||||
depth = 0 if rel == "." else rel.count(os.sep) + 1
|
||||
name = os.path.basename(folder) or folder
|
||||
rows.append(
|
||||
f'<button type="button" class="fitem" data-folder="{html.escape(folder.lower(), quote=True)}" '
|
||||
f'style="padding-left:{depth * 14 + 12}px" title="{html.escape(folder, quote=True)}" '
|
||||
f'onclick="pickFolder(this)"><span class="fname">{html.escape(name)}</span>'
|
||||
f'<span class="fcount">{folders[folder]}</span></button>'
|
||||
)
|
||||
return "".join(rows)
|
||||
|
||||
|
||||
def render_page(cands, threshold):
|
||||
"""Build the review page. cands: list of (Path, score) sorted desc."""
|
||||
cards, seen = [], set()
|
||||
for p, score in cands:
|
||||
sp = str(p)
|
||||
if sp in seen: # never list the same path twice
|
||||
continue
|
||||
seen.add(sp)
|
||||
# No pre-selection at render. The per-folder scan decides state so persisted
|
||||
# marks win: nsfw -> pink, sfw -> green, undecided high-score -> red.
|
||||
# (A dismissed/sfw image must never come back pre-selected red on reload.)
|
||||
checked = ""
|
||||
src = f"/img?path={quote(sp)}"
|
||||
esc = html.escape(sp, quote=True)
|
||||
name = html.escape(p.name)
|
||||
folder = html.escape(str(p.parent))
|
||||
folder_attr = html.escape(str(p.parent), quote=True)
|
||||
name_low = html.escape(p.name.lower(), quote=True)
|
||||
folder_low = html.escape(str(p.parent).lower(), quote=True)
|
||||
cards.append(
|
||||
f'<div class="card" data-score="{score:.4f}" data-name="{name_low}" '
|
||||
f'data-folder="{folder_low}" data-hay="{name_low} {folder_low}" '
|
||||
f'data-tagged="?" data-sfw="?">'
|
||||
f'<div class="thumb">'
|
||||
f'<img loading="lazy" src="{src}" alt="">'
|
||||
f'<span class="score" data-s="{score:.4f}">{score:.2f}</span>'
|
||||
f'<button type="button" class="icon zoom" aria-label="View full size" '
|
||||
f'data-src="{src}" data-name="{name}" data-score="{score:.2f}" '
|
||||
f'onclick="openLb(this)">{_SVG_EXPAND}</button>'
|
||||
f'<span class="tagged" hidden>{_SVG_CHECK}<span>tagged</span></span>'
|
||||
f'<span class="safe" hidden>{_SVG_CHECK}<span>tagged</span></span>'
|
||||
f'<button type="button" class="tick" aria-label="Mark {name} NSFW or SFW" '
|
||||
f'onclick="toggleCard(this)">{_SVG_CHECK}</button>'
|
||||
f'<input type="checkbox" class="nsfw" value="{esc}" {checked} tabindex="-1" aria-hidden="true">'
|
||||
f'</div>'
|
||||
f'<div class="info"><div class="name" title="{esc}">{name}</div>'
|
||||
f'<div class="path" title="{folder_attr}">{folder}</div></div>'
|
||||
f'</div>'
|
||||
)
|
||||
return (_PAGE
|
||||
.replace("%%TREE%%", folder_tree_html(cands))
|
||||
.replace("%%CARDS%%", "".join(cards))
|
||||
.replace("%%TOTAL%%", str(len(cards)))
|
||||
.replace("%%THRESHOLD%%", f"{threshold:.2f}"))
|
||||
Reference in New Issue
Block a user