add generate-viewer - helper for work
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
# DJI Returns Image Viewer
|
||||
|
||||
A self-contained local image reference tool for CMS Distribution's DJI returns inspection workflow. Built for warehouse staff to browse categorised damage photos without needing an internet connection or a web server.
|
||||
|
||||
## What it does
|
||||
|
||||
Opens as a single HTML file in any browser. Staff can browse 300+ reference photos organised by damage type, search by filename or category, and open images in a full-screen lightbox. Adding new photos is a one-step process.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `DJI_Returns_Image_Viewer.html` | The viewer — open this in a browser |
|
||||
| `viewer_template.html` | HTML/CSS/JS template used by the generator |
|
||||
| `generate_viewer.py` | Scans the Images folder and rebuilds the viewer |
|
||||
| `Refresh Image Viewer.bat` | Double-click shortcut to run the generator on Windows |
|
||||
| `Images/` | All reference photos, organised into subfolders by category |
|
||||
|
||||
## Adding new images
|
||||
|
||||
1. Drop the new images into the relevant subfolder under `Images/`
|
||||
2. Double-click **Refresh Image Viewer.bat**
|
||||
3. Open `DJI_Returns_Image_Viewer.html` — done
|
||||
|
||||
New subfolders are picked up automatically. If you add a folder that isn't in the category config, it will appear in the sidebar under the **Reference** section using the folder name as the label. No code changes required for basic additions.
|
||||
|
||||
## Customising category labels and sections
|
||||
|
||||
Open `generate_viewer.py` and edit the `CATEGORY_CONFIG` dictionary near the top of the file. Each entry looks like this:
|
||||
|
||||
```python
|
||||
'Folder Name': {
|
||||
'label': 'Display name shown in the sidebar',
|
||||
'section': 'accept', # accept | reject | borderline | reference
|
||||
'dot': '#2acc94', # colour of the dot indicator (hex)
|
||||
'desc': 'Short description shown in the category bar',
|
||||
},
|
||||
```
|
||||
|
||||
The key (left side) must exactly match the folder path relative to `Images/`, using forward slashes for subfolders:
|
||||
|
||||
```python
|
||||
'Excessive Crash Damage': {'label': 'Excessive Crash Damage', 'section': 'reject', ...},
|
||||
'Retail Images/Action': {'label': 'Action Cameras', 'section': 'reference', ...},
|
||||
```
|
||||
|
||||
After editing, run the generator to rebuild the viewer.
|
||||
|
||||
## Changing the design
|
||||
|
||||
All HTML, CSS, and JavaScript lives in `viewer_template.html`. Edit that file, then run the generator to apply changes to the output. Do not edit `DJI_Returns_Image_Viewer.html` directly — it is overwritten every time the generator runs.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3 (any recent version)
|
||||
- No third-party libraries — uses only the standard library
|
||||
- Any modern browser (Chrome, Edge, Firefox) to view the HTML
|
||||
|
||||
Python must be on the system PATH for the `.bat` file to work. If it isn't, run the script directly from a terminal:
|
||||
|
||||
```bash
|
||||
python generate_viewer.py
|
||||
```
|
||||
|
||||
## Sidebar sections
|
||||
|
||||
Images are grouped into four sections in the sidebar, controlled by the `section` field in the category config:
|
||||
|
||||
| Section | Used for |
|
||||
|---|---|
|
||||
| `accept` | Damage that is acceptable — product can proceed |
|
||||
| `reject` | Automatic reject criteria |
|
||||
| `borderline` | Edge cases requiring judgement |
|
||||
| `reference` | Retail product shots and other reference material |
|
||||
|
||||
## Viewer features
|
||||
|
||||
- **Search** — filters by filename or category name
|
||||
- **Grid sizes** — three density options (large / medium / small)
|
||||
- **Lightbox** — click any image to open full screen; navigate with arrow keys or Prev/Next buttons; close with Escape
|
||||
- **Category bar** — shows the active category name, description, and image count
|
||||
- **No server required** — runs entirely from the local filesystem
|
||||
|
||||
## Notes
|
||||
|
||||
- The viewer uses `object-fit: contain` on all thumbnails to prevent portrait-orientation photos from being cropped
|
||||
- Scrollbars use `overflow-y: scroll` (always visible) rather than `auto` to prevent layout jump when switching categories
|
||||
- Images are not embedded in the HTML — they are referenced by relative path, so the `Images/` folder must stay in the same location as the HTML file
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
python "%~dp0generate_viewer.py"
|
||||
pause
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DJI Returns Image Viewer -- Generator
|
||||
Run this whenever you add new images to the Images/ folder.
|
||||
New folders are auto-detected. Edit CATEGORY_CONFIG to customise labels.
|
||||
"""
|
||||
import os, json, sys
|
||||
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
IMAGES_DIR = os.path.join(BASE, 'Images')
|
||||
OUTPUT_HTML = os.path.join(BASE, 'DJI_Returns_Image_Viewer.html')
|
||||
TEMPLATE_FILE = os.path.join(BASE, 'viewer_template.html')
|
||||
|
||||
CATEGORY_CONFIG = {
|
||||
'Acceptable Damage': {'label': 'Acceptable Damage', 'section': 'accept', 'dot': '#2acc94', 'desc': 'Minor wear -- acceptable for resale'},
|
||||
'Acceptable Damage/RC': {'label': 'Acceptable -- RC Controllers','section': 'accept', 'dot': '#50f7bd', 'desc': 'Controller scuffs and minor marks'},
|
||||
'Excessive Crash Damage': {'label': 'Excessive Crash Damage', 'section': 'reject', 'dot': '#ff4d4d', 'desc': 'Automatic reject'},
|
||||
'Excessive Physical Damage': {'label': 'Excessive Physical Damage', 'section': 'reject', 'dot': '#ff6622', 'desc': 'Structural damage'},
|
||||
'Fire': {'label': 'Fire / Burn Damage', 'section': 'reject', 'dot': '#ff7700', 'desc': 'Fire or thermal damage'},
|
||||
'Mini 3-4Pro Cracked Gimbals': {'label': 'Cracked Gimbals', 'section': 'reject', 'dot': '#ff6b35', 'desc': 'Mini 3 / 4 Pro gimbal damage'},
|
||||
'P3 Gimbal Damage': {'label': 'P3 Gimbal Damage', 'section': 'reject', 'dot': '#ff6b35', 'desc': 'Phantom 3 gimbal'},
|
||||
'Subtle But Rejectable': {'label': 'Subtle But Rejectable', 'section': 'reject', 'dot': '#ffaa00', 'desc': 'Non-obvious -- still a reject'},
|
||||
'Tampering': {'label': 'Tampering / Modified', 'section': 'reject', 'dot': '#cc44ff', 'desc': 'Signs of tampering'},
|
||||
'Very Worn': {'label': 'Very Worn', 'section': 'borderline', 'dot': '#ffcc00', 'desc': 'Heavy use -- borderline case'},
|
||||
'Water Damage': {'label': 'Water Damage', 'section': 'reject', 'dot': '#3eafff', 'desc': 'Water ingress -- automatic reject'},
|
||||
'Strange': {'label': 'Strange / Unusual', 'section': 'reject', 'dot': '#9966cc', 'desc': 'Unusual damage patterns'},
|
||||
'Strange/mb_removed': {'label': 'Mainboard Removed', 'section': 'reject', 'dot': '#cc66aa', 'desc': 'Components stripped'},
|
||||
'Mavic Mini-4k': {'label': 'Mavic Mini 4K Ref', 'section': 'reference', 'dot': '#4488cc', 'desc': 'Reference images'},
|
||||
'Retail Images': {'label': 'All Retail Images', 'section': 'reference', 'dot': '#239aee', 'desc': 'Product retail shots'},
|
||||
'Retail Images/Action': {'label': 'Action Cameras', 'section': 'reference', 'dot': '#2acc94', 'desc': 'Action 4 / 5 / 6'},
|
||||
'Retail Images/Avata': {'label': 'Avata', 'section': 'reference', 'dot': '#2acc94', 'desc': 'Avata 2 range'},
|
||||
'Retail Images/Flip': {'label': 'Flip', 'section': 'reference', 'dot': '#2acc94', 'desc': 'DJI Flip range'},
|
||||
'Retail Images/Lito': {'label': 'Lito 1 / X1', 'section': 'reference', 'dot': '#2acc94', 'desc': 'Entry-level 249g drones'},
|
||||
'Retail Images/Mini': {'label': 'Mini Series', 'section': 'reference', 'dot': '#2acc94', 'desc': 'Mini 2 / 3 / 4 Pro / 5 Pro'},
|
||||
'Retail Images/Neo': {'label': 'Neo', 'section': 'reference', 'dot': '#2acc94', 'desc': 'DJI Neo / Neo 2'},
|
||||
'Retail Images/OM': {'label': 'Osmo Mobile', 'section': 'reference', 'dot': '#2acc94', 'desc': 'OM7 / OM7P / OM8 / OM8P'},
|
||||
'Retail Images/Osmo 360': {'label': 'Osmo 360', 'section': 'reference', 'dot': '#2acc94', 'desc': 'Osmo 360 camera'},
|
||||
'Retail Images/Osmo Nano': {'label': 'Osmo Nano', 'section': 'reference', 'dot': '#2acc94', 'desc': 'Osmo Nano camera'},
|
||||
}
|
||||
|
||||
SECTION_ORDER = ['accept','reject','borderline','reference']
|
||||
IMAGE_EXTS = {'.jpg','.jpeg','.png','.webp','.gif','.bmp'}
|
||||
|
||||
def scan():
|
||||
results = []
|
||||
if not os.path.isdir(IMAGES_DIR):
|
||||
print(f'ERROR: Images folder not found at {IMAGES_DIR}'); sys.exit(1)
|
||||
for dirpath, dirnames, filenames in os.walk(IMAGES_DIR):
|
||||
dirnames.sort()
|
||||
for fname in sorted(filenames):
|
||||
if os.path.splitext(fname)[1].lower() in IMAGE_EXTS:
|
||||
rel = os.path.relpath(os.path.join(dirpath, fname), BASE).replace('\\\\', '/')
|
||||
cat_key = os.path.relpath(dirpath, IMAGES_DIR).replace('\\\\', '/')
|
||||
if cat_key == '.': cat_key = ''
|
||||
results.append({'path': rel, 'cat': cat_key})
|
||||
return results
|
||||
|
||||
def build_cat_meta(images):
|
||||
seen = {}
|
||||
for img in images:
|
||||
k = img['cat']
|
||||
if k not in seen:
|
||||
cfg = CATEGORY_CONFIG.get(k)
|
||||
if cfg:
|
||||
seen[k] = dict(cfg, key=k, count=0)
|
||||
else:
|
||||
label = os.path.basename(k) if k else 'Uncategorised'
|
||||
seen[k] = {'key':k,'label':label,'section':'reference','dot':'#888','desc':'Auto-detected folder','count':0}
|
||||
seen[k]['count'] += 1
|
||||
def sort_key(c):
|
||||
try: si = SECTION_ORDER.index(c['section'])
|
||||
except ValueError: si = 99
|
||||
return (si, c['label'])
|
||||
return sorted(seen.values(), key=sort_key)
|
||||
|
||||
def main():
|
||||
print('Scanning images...')
|
||||
images = scan()
|
||||
print(f' Found {len(images)} images')
|
||||
cats = build_cat_meta(images)
|
||||
print(f' Found {len(cats)} categories')
|
||||
for c in cats:
|
||||
print(f' [{c["section"]:10}] {c["key"]} ({c["count"]} images)')
|
||||
with open(TEMPLATE_FILE, 'r', encoding='utf-8') as f:
|
||||
template = f.read()
|
||||
html = template.replace('__IMAGE_DATA__', json.dumps(images, indent=2))
|
||||
html = html.replace('__CAT_DATA__', json.dumps(cats, indent=2))
|
||||
with open(OUTPUT_HTML, 'w', encoding='utf-8') as f:
|
||||
f.write(html)
|
||||
print(f'\nDone! Saved: {OUTPUT_HTML}')
|
||||
print(f'Total: {len(images)} images across {len(cats)} categories')
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,387 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DJI Returns — Image Reference | CMS Distribution</title>
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
html,body{height:100%;overflow:hidden}
|
||||
body{font-family:"Segoe UI",Calibri,Arial,sans-serif;background:#0f1316;color:#c8d8ea;display:flex;flex-direction:column}
|
||||
|
||||
/* ── Header ── */
|
||||
.header{
|
||||
flex-shrink:0;height:54px;display:flex;align-items:center;gap:20px;
|
||||
padding:0 22px;z-index:10;
|
||||
background:linear-gradient(135deg,#14191b 0%,#0e1e28 60%,#0b2020 100%);
|
||||
border-bottom:2px solid transparent;
|
||||
border-image:linear-gradient(to right,#3eafff,#2acc94) 1;
|
||||
}
|
||||
.brand-name{font-size:18px;font-weight:800;letter-spacing:.3px;color:#fff}
|
||||
.brand-name em{
|
||||
font-style:normal;
|
||||
background:linear-gradient(90deg,#3eafff,#2acc94);
|
||||
-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text
|
||||
}
|
||||
.brand-pipe{color:#2a3a3a;font-size:13px;margin:0 2px}
|
||||
.brand-sub{font-size:12px;color:#4a6878}
|
||||
.header-right{margin-left:auto;display:flex;align-items:center;gap:12px}
|
||||
.stat-chip{
|
||||
background:rgba(62,175,255,.08);border:1px solid rgba(62,175,255,.18);
|
||||
border-radius:20px;padding:3px 12px;font-size:11px;color:#5a8898;white-space:nowrap
|
||||
}
|
||||
.stat-chip strong{color:#2acc94}
|
||||
|
||||
/* ── Layout ── */
|
||||
.layout{display:flex;flex:1;min-height:0;overflow:hidden}
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.sidebar{
|
||||
width:230px;flex-shrink:0;overflow-y:scroll;
|
||||
background:#0c1014;border-right:1px solid #1a2428;display:flex;flex-direction:column
|
||||
}
|
||||
.sidebar-section{padding:16px 0 4px}
|
||||
.sidebar-section-label{
|
||||
padding:0 14px 5px;font-size:9px;font-weight:700;
|
||||
letter-spacing:2px;text-transform:uppercase;color:#2a4048
|
||||
}
|
||||
.nav-item{
|
||||
display:flex;align-items:center;gap:8px;padding:8px 14px;
|
||||
cursor:pointer;border-left:3px solid transparent;
|
||||
font-size:12px;color:#6a8898;
|
||||
transition:background .1s,color .1s;user-select:none
|
||||
}
|
||||
.nav-item:hover{background:#111820;color:#a0c0d0}
|
||||
.nav-item.active{
|
||||
background:#0d181e;
|
||||
border-left:3px solid #2acc94;
|
||||
color:#d0e8f0;font-weight:600
|
||||
}
|
||||
.nav-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
|
||||
.nav-all-dot{width:18px;height:7px;border-radius:4px;flex-shrink:0;background:linear-gradient(90deg,#3eafff,#2acc94)}
|
||||
.nav-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.nav-badge{background:#141e24;border-radius:12px;padding:1px 7px;font-size:10px;color:#3a5060;flex-shrink:0}
|
||||
.nav-item.active .nav-badge{background:#0d2030;color:#3eafff}
|
||||
|
||||
/* ── Main ── */
|
||||
.main{flex:1;display:flex;flex-direction:column;min-width:0;overflow:hidden}
|
||||
|
||||
/* ── Toolbar ── */
|
||||
.toolbar{
|
||||
flex-shrink:0;padding:10px 16px;
|
||||
background:#0c1014;border-bottom:1px solid #1a2428;
|
||||
display:flex;align-items:center;gap:10px
|
||||
}
|
||||
.search-wrap{flex:1;position:relative}
|
||||
.search-icon{position:absolute;left:10px;top:50%;transform:translateY(-50%);color:#2a4048;font-size:14px;pointer-events:none}
|
||||
.search{
|
||||
width:100%;background:#111820;border:1px solid #1a2a30;border-radius:6px;
|
||||
padding:7px 32px 7px 32px;color:#c0d8e8;font-size:13px;outline:none;transition:border-color .15s
|
||||
}
|
||||
.search:focus{border-color:#3eafff;background:#0d1820}
|
||||
.search::placeholder{color:#2a3e48}
|
||||
.search-clear{
|
||||
position:absolute;right:8px;top:50%;transform:translateY(-50%);
|
||||
background:none;border:none;color:#2a4048;cursor:pointer;font-size:16px;display:none;line-height:1;padding:2px 4px
|
||||
}
|
||||
.search-clear:hover{color:#3eafff}
|
||||
.toolbar-divider{width:1px;height:24px;background:#1a2428}
|
||||
.view-btns{display:flex;gap:3px}
|
||||
.vbtn{
|
||||
background:#111820;border:1px solid #1a2a30;color:#2a4048;
|
||||
width:32px;height:32px;border-radius:5px;cursor:pointer;font-size:14px;
|
||||
display:flex;align-items:center;justify-content:center;transition:all .1s
|
||||
}
|
||||
.vbtn:hover{background:#141e28;color:#6a8898}
|
||||
.vbtn.active{background:#0d2030;color:#3eafff;border-color:#1a3a50}
|
||||
|
||||
/* ── Category bar ── */
|
||||
.cat-bar{
|
||||
flex-shrink:0;padding:8px 16px;border-bottom:1px solid #1a2428;
|
||||
background:#0c1014;display:flex;align-items:center;gap:10px
|
||||
}
|
||||
.cat-bar-dot{width:9px;height:9px;border-radius:50%;flex-shrink:0}
|
||||
.cat-bar-all{width:22px;height:9px;border-radius:5px;flex-shrink:0;background:linear-gradient(90deg,#3eafff,#2acc94)}
|
||||
.cat-bar-name{font-size:13px;font-weight:700;color:#c0d8e8}
|
||||
.cat-bar-desc{font-size:11px;color:#3a5060}
|
||||
.cat-bar-count{
|
||||
margin-left:auto;font-size:20px;font-weight:800;
|
||||
background:linear-gradient(90deg,#3eafff,#2acc94);
|
||||
-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text
|
||||
}
|
||||
|
||||
/* ── Grid ── */
|
||||
.grid-area{flex:1;overflow-y:scroll;padding:12px}
|
||||
.grid{display:grid;gap:8px}
|
||||
.grid.large {grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}
|
||||
.grid.medium{grid-template-columns:repeat(auto-fill,minmax(150px,1fr))}
|
||||
.grid.small {grid-template-columns:repeat(auto-fill,minmax(110px,1fr))}
|
||||
|
||||
/* ── Cards ── */
|
||||
.card{
|
||||
background:#0c1014;border-radius:6px;overflow:hidden;cursor:pointer;
|
||||
border:1px solid #1a2428;transition:transform .12s,border-color .12s,box-shadow .12s
|
||||
}
|
||||
.card:hover{transform:translateY(-2px);box-shadow:0 6px 20px rgba(0,0,0,.5)}
|
||||
.card:hover.accept {border-color:#1a6a50}
|
||||
.card:hover.reject {border-color:#882020}
|
||||
.card:hover.borderline{border-color:#886000}
|
||||
.card:hover.reference{border-color:#1a4a70}
|
||||
.card img{width:100%;height:160px;object-fit:contain;display:block;background:#080b0e;padding:4px}
|
||||
.card-foot{padding:5px 8px;display:flex;align-items:center;gap:5px}
|
||||
.card-dot{width:5px;height:5px;border-radius:50%;flex-shrink:0}
|
||||
.card-label{font-size:10px;color:#3a5060;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}
|
||||
|
||||
/* ── Empty ── */
|
||||
.empty{text-align:center;padding:80px 20px;color:#2a3a40}
|
||||
.empty-icon{font-size:48px;margin-bottom:12px;opacity:.4}
|
||||
.empty-text{font-size:15px}
|
||||
|
||||
/* ── Lightbox ── */
|
||||
.lb{display:none;position:fixed;inset:0;background:rgba(4,7,10,.96);z-index:1000;flex-direction:column;align-items:center;justify-content:center}
|
||||
.lb.open{display:flex}
|
||||
.lb-header{
|
||||
position:fixed;top:0;left:0;right:0;padding:12px 20px;
|
||||
display:flex;align-items:center;gap:10px;
|
||||
background:rgba(10,16,20,.92);backdrop-filter:blur(8px)
|
||||
}
|
||||
.lb-badge{padding:3px 10px;border-radius:12px;font-size:10px;font-weight:700;letter-spacing:.5px}
|
||||
.lb-filename{font-size:12px;color:#3a5060;flex:1}
|
||||
.lb-close{
|
||||
background:#141e24;border:none;color:#6a8898;width:32px;height:32px;
|
||||
border-radius:50%;cursor:pointer;font-size:18px;display:flex;align-items:center;
|
||||
justify-content:center;transition:background .1s,color .1s
|
||||
}
|
||||
.lb-close:hover{background:#882020;color:#fff}
|
||||
.lb-img-wrap{max-width:90vw;max-height:80vh}
|
||||
.lb-img-wrap img{max-width:90vw;max-height:80vh;object-fit:contain;border-radius:4px;display:block}
|
||||
.lb-footer{
|
||||
position:fixed;bottom:0;left:0;right:0;padding:10px 20px;
|
||||
display:flex;align-items:center;justify-content:center;gap:16px;
|
||||
background:rgba(10,16,20,.92);backdrop-filter:blur(8px)
|
||||
}
|
||||
.lb-nav-btn{
|
||||
background:#111820;border:1px solid #1a2a30;color:#6a8898;
|
||||
padding:7px 20px;border-radius:6px;cursor:pointer;font-size:13px;transition:all .1s
|
||||
}
|
||||
.lb-nav-btn:hover{background:#0d2030;color:#3eafff;border-color:#1a3a50}
|
||||
.lb-counter{font-size:13px;color:#3a5060;min-width:80px;text-align:center}
|
||||
|
||||
/* ── Scrollbars ── */
|
||||
::-webkit-scrollbar{width:6px;height:6px}
|
||||
::-webkit-scrollbar-track{background:#080b0e}
|
||||
::-webkit-scrollbar-thumb{background:#1a2a30;border-radius:3px}
|
||||
::-webkit-scrollbar-thumb:hover{background:#2a3e50}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div class="brand-name">CMS <em>Distribution</em></div>
|
||||
<span class="brand-pipe">|</span>
|
||||
<div class="brand-sub">DJI Returns — Damage Reference Library</div>
|
||||
<div class="header-right">
|
||||
<div class="stat-chip"><strong id="visCount">0</strong> images showing</div>
|
||||
<div class="stat-chip" id="catChip">All categories</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<nav class="sidebar" id="sidebar"></nav>
|
||||
<div class="main">
|
||||
<div class="toolbar">
|
||||
<div class="search-wrap">
|
||||
<span class="search-icon">⚲</span>
|
||||
<input class="search" id="search" type="text"
|
||||
placeholder="Search by category or filename…"
|
||||
oninput="onSearch(this.value)" autocomplete="off">
|
||||
<button class="search-clear" id="searchClear" onclick="clearSearch()">✕</button>
|
||||
</div>
|
||||
<div class="toolbar-divider"></div>
|
||||
<div class="view-btns">
|
||||
<button class="vbtn active" id="vLarge" onclick="setView('large')" title="Large">▮▮</button>
|
||||
<button class="vbtn" id="vMedium" onclick="setView('medium')" title="Medium">▮▮▮</button>
|
||||
<button class="vbtn" id="vSmall" onclick="setView('small')" title="Small">▮▮▮▮</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cat-bar" id="catBar"></div>
|
||||
<div class="grid-area">
|
||||
<div class="grid large" id="grid"></div>
|
||||
<div class="empty" id="empty" style="display:none">
|
||||
<div class="empty-icon">🔍</div>
|
||||
<div class="empty-text">No images match your search.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lb" id="lb">
|
||||
<div class="lb-header">
|
||||
<span class="lb-badge" id="lbBadge"></span>
|
||||
<span class="lb-filename" id="lbFilename"></span>
|
||||
<button class="lb-close" onclick="closeLb()">✕</button>
|
||||
</div>
|
||||
<div class="lb-img-wrap"><img id="lbImg" src="" alt=""></div>
|
||||
<div class="lb-footer">
|
||||
<button class="lb-nav-btn" onclick="lbNav(-1)">← Prev</button>
|
||||
<div class="lb-counter" id="lbCounter"></div>
|
||||
<button class="lb-nav-btn" onclick="lbNav(1)">Next →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Data (injected by generate_viewer.py) ─────────────────────────────────────
|
||||
const IMAGES = __IMAGE_DATA__;
|
||||
const CATS = __CAT_DATA__;
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────────
|
||||
let activeCat = null, query = '', viewSize = 'large', lbIdx = 0, lbFiltered = [];
|
||||
|
||||
const SECTION_ORDER = ['accept','reject','borderline','reference'];
|
||||
const SECTION_LABELS = {accept:'ACCEPTABLE',reject:'REJECT',borderline:'BORDERLINE',reference:'REFERENCE'};
|
||||
|
||||
// ── Sidebar ───────────────────────────────────────────────────────────────────
|
||||
function buildSidebar() {
|
||||
const sb = document.getElementById('sidebar');
|
||||
const sections = {};
|
||||
CATS.forEach(c => { (sections[c.section] = sections[c.section]||[]).push(c); });
|
||||
|
||||
let html = '<div class="sidebar-section"><div class="sidebar-section-label">OVERVIEW</div>'
|
||||
+ `<div class="nav-item active" data-cat="" onclick="selectCat(null)">
|
||||
<div class="nav-all-dot"></div>
|
||||
<span class="nav-label">All Images</span>
|
||||
<span class="nav-badge">${IMAGES.length}</span>
|
||||
</div></div>`;
|
||||
|
||||
SECTION_ORDER.forEach(sec => {
|
||||
const items = sections[sec];
|
||||
if (!items) return;
|
||||
html += `<div class="sidebar-section"><div class="sidebar-section-label">${SECTION_LABELS[sec]||sec.toUpperCase()}</div>`;
|
||||
items.forEach(c => {
|
||||
const safeKey = c.key.replace(/'/g,"\\'");
|
||||
html += `<div class="nav-item" data-cat="${c.key}" onclick="selectCat('${safeKey}')">
|
||||
<div class="nav-dot" style="background:${c.dot}"></div>
|
||||
<span class="nav-label">${c.label}</span>
|
||||
<span class="nav-badge">${c.count}</span>
|
||||
</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
});
|
||||
sb.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Category bar ──────────────────────────────────────────────────────────────
|
||||
function updateCatBar() {
|
||||
const bar = document.getElementById('catBar');
|
||||
if (activeCat === null) {
|
||||
bar.innerHTML = `<div class="cat-bar-all"></div>
|
||||
<span class="cat-bar-name">All Images</span>
|
||||
<span class="cat-bar-desc">Browse the full library</span>
|
||||
<span class="cat-bar-count">${IMAGES.length}</span>`;
|
||||
} else {
|
||||
const c = CATS.find(x => x.key === activeCat) || {dot:'#888',label:activeCat,desc:'',count:0};
|
||||
bar.innerHTML = `<div class="cat-bar-dot" style="background:${c.dot}"></div>
|
||||
<span class="cat-bar-name">${c.label}</span>
|
||||
<span class="cat-bar-desc">${c.desc}</span>
|
||||
<span class="cat-bar-count">${c.count}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Filtering ─────────────────────────────────────────────────────────────────
|
||||
function getFiltered() {
|
||||
const q = query.toLowerCase();
|
||||
return IMAGES.filter(img => {
|
||||
const catMatch = activeCat === null || img.cat === activeCat;
|
||||
const qMatch = !q || img.path.toLowerCase().includes(q) || img.cat.toLowerCase().includes(q);
|
||||
return catMatch && qMatch;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Render grid ───────────────────────────────────────────────────────────────
|
||||
function renderGrid() {
|
||||
const filtered = getFiltered();
|
||||
lbFiltered = filtered;
|
||||
const grid = document.getElementById('grid');
|
||||
const empty = document.getElementById('empty');
|
||||
document.getElementById('visCount').textContent = filtered.length;
|
||||
|
||||
if (!filtered.length) { grid.innerHTML = ''; empty.style.display = ''; return; }
|
||||
empty.style.display = 'none';
|
||||
|
||||
const catMap = {};
|
||||
CATS.forEach(c => catMap[c.key] = c);
|
||||
|
||||
grid.innerHTML = filtered.map((img, i) => {
|
||||
const c = catMap[img.cat] || {dot:'#888', section:'reference'};
|
||||
const fname = img.path.split('/').pop();
|
||||
return `<div class="card ${c.section}" onclick="openLb(${i})" title="${img.path}">
|
||||
<img src="${img.path}" alt="${fname}">
|
||||
<div class="card-foot">
|
||||
<div class="card-dot" style="background:${c.dot}"></div>
|
||||
<div class="card-label">${fname}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────────
|
||||
function selectCat(key) {
|
||||
activeCat = (key === null || key === '') ? null : key;
|
||||
document.querySelectorAll('.nav-item').forEach(el => {
|
||||
const match = activeCat === null ? (el.dataset.cat === '') : (el.dataset.cat === activeCat);
|
||||
el.classList.toggle('active', match);
|
||||
});
|
||||
const c = activeCat ? (CATS.find(c => c.key === activeCat) || {label: activeCat}) : null;
|
||||
document.getElementById('catChip').textContent = c ? c.label : 'All categories';
|
||||
updateCatBar();
|
||||
renderGrid();
|
||||
}
|
||||
|
||||
function setView(size) {
|
||||
viewSize = size;
|
||||
document.getElementById('grid').className = 'grid ' + size;
|
||||
['Large','Medium','Small'].forEach(s =>
|
||||
document.getElementById('v'+s).classList.toggle('active', s.toLowerCase() === size));
|
||||
}
|
||||
|
||||
function onSearch(val) {
|
||||
query = val;
|
||||
document.getElementById('searchClear').style.display = val ? '' : 'none';
|
||||
renderGrid();
|
||||
}
|
||||
function clearSearch() { document.getElementById('search').value=''; onSearch(''); }
|
||||
|
||||
// ── Lightbox ──────────────────────────────────────────────────────────────────
|
||||
function openLb(i) { lbIdx = i; showLb(); document.getElementById('lb').classList.add('open'); }
|
||||
function closeLb() { document.getElementById('lb').classList.remove('open'); }
|
||||
function showLb() {
|
||||
const img = lbFiltered[lbIdx];
|
||||
if (!img) return;
|
||||
document.getElementById('lbImg').src = img.path;
|
||||
document.getElementById('lbFilename').textContent = img.path.split('/').pop();
|
||||
document.getElementById('lbCounter').textContent = `${lbIdx+1} / ${lbFiltered.length}`;
|
||||
const catMap = {}; CATS.forEach(c => catMap[c.key] = c);
|
||||
const c = catMap[img.cat] || {label: img.cat, dot:'#888'};
|
||||
const badge = document.getElementById('lbBadge');
|
||||
badge.textContent = c.label;
|
||||
badge.style.background = c.dot + '22';
|
||||
badge.style.color = c.dot;
|
||||
badge.style.border = '1px solid ' + c.dot + '44';
|
||||
}
|
||||
function lbNav(dir) { lbIdx = (lbIdx + dir + lbFiltered.length) % lbFiltered.length; showLb(); }
|
||||
|
||||
document.getElementById('lb').addEventListener('click', e => { if (e.target.id==='lb') closeLb(); });
|
||||
document.addEventListener('keydown', e => {
|
||||
if (!document.getElementById('lb').classList.contains('open')) return;
|
||||
if (e.key==='ArrowRight'||e.key==='ArrowDown') lbNav(1);
|
||||
if (e.key==='ArrowLeft' ||e.key==='ArrowUp') lbNav(-1);
|
||||
if (e.key==='Escape') closeLb();
|
||||
});
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
buildSidebar();
|
||||
updateCatBar();
|
||||
renderGrid();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user