diff --git a/README.md b/README.md new file mode 100644 index 0000000..007f2e7 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# Image Library + +A single-page, **backend-free** landing page that lists every image in `img/`, +lets you full-text search by filename, and copies an image's URL to your +clipboard when you click it. Built to be dropped onto an nginx server as plain +static files. + +``` +index.html the page +assets/styles.css styling (auto light/dark) +assets/app.js search, rendering, clipboard logic +scripts/generate-manifest.mjs scans img/ -> images.json +images.json generated manifest the page fetches +img/ put your images here +``` + +## Why a manifest? + +A browser cannot list a directory on its own, and "no backend" rules out an API +that would do it. So the file list is produced by a tiny build step: +`scripts/generate-manifest.mjs` walks `img/` and writes `images.json`. The page +fetches that file at runtime. Everything the server does is serve static files. + +## Quick start + +```sh +# 1. Add images (subfolders are fine) +cp ~/pictures/*.png img/ + +# 2. Build the manifest +node scripts/generate-manifest.mjs + +# 3. Preview locally +python3 -m http.server 8080 +# -> open http://localhost:8080 +``` + +Re-run step 2 whenever you add, rename, or delete images. + +## Features + +- **Search** — case-insensitive, multi-word with AND semantics. Every word must + appear somewhere in the filename; prefix and word-boundary matches rank first. + Matches are highlighted. Press `/` to focus the search box, `Esc` to clear. +- **Click to copy** — clicking a card copies the image's absolute URL + (e.g. `https://your-host/img/logo.png`). A toast confirms it. +- **Lazy loading** — thumbnails load as you scroll, so large libraries stay fast. +- **No dependencies** — vanilla JS/CSS, no framework, no build tooling. + +## Deploying on nginx + +Copy the whole project (including the generated `images.json`) into a web root +and point a server block at it. See [`nginx.conf.example`](nginx.conf.example). + +```sh +# build the manifest locally, then upload everything +node scripts/generate-manifest.mjs +rsync -av --delete ./ user@server:/var/www/image-library/ +``` + +If you'd rather generate the manifest on the server, install Node there and run +the script after uploading images. Node is only needed for that step — nginx +never runs it. + +### Clipboard over plain HTTP + +`navigator.clipboard` only works in a **secure context** (HTTPS, or +`localhost`). The app detects this and falls back to a legacy copy method, which +works on plain HTTP in most browsers. For guaranteed behavior on a LAN host, +serve over HTTPS (e.g. a self-signed cert or a reverse proxy) — or just use +`http://localhost` while testing. + +## Alternative: nginx autoindex + +If you don't want a build step at all, you can enable nginx's directory listing +and have the page parse it, but that requires `autoindex on`, produces fragile +HTML scraping, and loses the manifest metadata. The manifest approach is +recommended. diff --git a/generate-manifest.py b/generate-manifest.py new file mode 100755 index 0000000..0dfef56 --- /dev/null +++ b/generate-manifest.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Build the image manifest and commit it. + +Scans ./img recursively, writes ./images.json (the file the single-page app +fetches at runtime), then stages and commits the result with git. + +Usage: + ./generate-manifest.py # generate + commit + ./generate-manifest.py --no-commit # generate only + ./generate-manifest.py -m "Add logos" # custom commit message + +Run this after adding, renaming, or deleting images. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +IMG_DIR = ROOT / "img" +OUT_FILE = ROOT / "images.json" + +IMAGE_EXTS = { + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", + ".svg", ".bmp", ".ico", ".tif", ".tiff", +} + + +def natural_key(text: str) -> list: + """Sort key so 'img2' comes before 'img10'.""" + return [int(part) if part.isdigit() else part.lower() for part in re.split(r"(\d+)", text)] + + +def find_images() -> list[dict]: + if not IMG_DIR.is_dir(): + print(f"✗ Image folder not found: {IMG_DIR}", file=sys.stderr) + print(" Create it and add some images, then re-run.", file=sys.stderr) + sys.exit(1) + + images = [] + for path in IMG_DIR.rglob("*"): + if not path.is_file(): + continue + # Skip dotfiles and anything inside a dot-directory. + if any(part.startswith(".") for part in path.relative_to(IMG_DIR).parts): + continue + if path.suffix.lower() not in IMAGE_EXTS: + continue + + stat = path.stat() + images.append({ + "name": path.name, + "path": path.relative_to(ROOT).as_posix(), # POSIX-style for URLs + "size": stat.st_size, + "mtime": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc) + .isoformat() + .replace("+00:00", "Z"), + }) + + images.sort(key=lambda img: natural_key(img["path"])) + return images + + +def write_manifest(images: list[dict]) -> None: + manifest = { + "generatedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "count": len(images), + "images": images, + } + OUT_FILE.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + plural = "" if len(images) == 1 else "s" + print(f"✓ Wrote {len(images)} image{plural} to {OUT_FILE.name}") + + +def git(*args: str, check: bool = True) -> subprocess.CompletedProcess: + return subprocess.run(["git", *args], cwd=ROOT, check=check, text=True, + capture_output=True) + + +def commit(message: str) -> None: + """Stage everything and commit, if this is a git repo with changes.""" + if git("rev-parse", "--is-inside-work-tree", check=False).returncode != 0: + print("• Not a git repository — skipping commit.") + return + + git("add", "-A") + + # Nothing staged? Then there's nothing to commit. + if git("diff", "--cached", "--quiet", check=False).returncode == 0: + print("• No changes to commit.") + return + + result = git("commit", "-m", message, check=False) + if result.returncode != 0: + print(f"✗ git commit failed:\n{result.stderr.strip()}", file=sys.stderr) + sys.exit(1) + print(f"✓ Committed: {message}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build images.json and commit it.") + parser.add_argument("--no-commit", action="store_true", + help="generate the manifest without committing") + parser.add_argument("-m", "--message", default=None, + help="commit message (default: auto-generated)") + args = parser.parse_args() + + images = find_images() + write_manifest(images) + + if args.no_commit: + return + + message = args.message or f"Update image manifest ({len(images)} images)" + commit(message) + + +if __name__ == "__main__": + main() diff --git a/images.json b/images.json index 7d9bd16..0fc03b0 100644 --- a/images.json +++ b/images.json @@ -1,5 +1,12 @@ { - "generatedAt": "2026-09-27T14:19:09.052Z", - "count": 0, - "images": [] + "generatedAt": "2026-09-27T14:23:41.878271Z", + "count": 1, + "images": [ + { + "name": "oca_module_status.png", + "path": "img/oca_module_status.png", + "size": 34048, + "mtime": "2026-09-27T14:20:34.636407Z" + } + ] } diff --git a/img/.gitkeep b/img/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/img/oca_module_status.png b/img/oca_module_status.png new file mode 100644 index 0000000..3ff2bcb Binary files /dev/null and b/img/oca_module_status.png differ diff --git a/nginx.conf.example b/nginx.conf.example new file mode 100644 index 0000000..33201cd --- /dev/null +++ b/nginx.conf.example @@ -0,0 +1,47 @@ +# Example nginx server block for the Image Library. +# +# Copy the project into /var/www/image-library (including the generated +# images.json), then adapt root/server_name and include this file from your +# nginx config (e.g. /etc/nginx/conf.d/image-library.conf). + +server { + listen 80; + server_name images.example.com; + + root /var/www/image-library; + index index.html; + + # Serve the SPA and its assets; fall back to index.html for unknown paths. + location / { + try_files $uri $uri/ /index.html; + } + + # The manifest changes whenever images are added — don't let clients cache it. + location = /images.json { + add_header Cache-Control "no-cache"; + try_files $uri =404; + } + + # Images are immutable in practice; cache them aggressively. + location /img/ { + add_header Cache-Control "public, max-age=2592000"; + try_files $uri =404; + } + + # Long-cache the static assets (bump the filename or use a build hash if you + # change them often). + location /assets/ { + add_header Cache-Control "public, max-age=604800"; + try_files $uri =404; + } + + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + gzip_min_length 1024; + + # Optional: only needed if you enable HTTPS. navigator.clipboard requires a + # secure context; the app falls back to a legacy copy method otherwise. + # listen 443 ssl; + # ssl_certificate /etc/nginx/ssl/images.example.com.crt; + # ssl_certificate_key /etc/nginx/ssl/images.example.com.key; +} diff --git a/pushAll b/pushAll new file mode 100755 index 0000000..c8aed8e --- /dev/null +++ b/pushAll @@ -0,0 +1,26 @@ +#!/usr/bin/env sh +# +# One command to publish everything: +# 1. regenerate images.json from ./img +# 2. stage + commit the changes +# 3. push to the remote +# +# Usage: +# ./pushAll +# +# Tip: make it a real command by adding this folder to your PATH, or create a +# git alias once with: git config alias.pushAll '!./pushAll' +# then run it as: git pushAll + +set -e +cd "$(dirname "$0")" + +# 1 + 2: rebuild the manifest and commit (handled by the Python script). +./generate-manifest.py + +# 3: push, setting the upstream on first push. +if git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then + git push +else + git push -u origin HEAD +fi diff --git a/scripts/generate-manifest.mjs b/scripts/generate-manifest.mjs deleted file mode 100644 index 0c88074..0000000 --- a/scripts/generate-manifest.mjs +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env node -/** - * Scans ./img recursively and writes ./images.json — the manifest the - * single-page app fetches at runtime. No backend required. - * - * Usage: - * node scripts/generate-manifest.mjs - * - * Re-run this whenever you add, rename, or remove images. - */ -import { readdir, stat, writeFile } from "node:fs/promises"; -import { join, relative, sep, extname } from "node:path"; -import { fileURLToPath } from "node:url"; - -const ROOT = fileURLToPath(new URL("..", import.meta.url)); -const IMG_DIR = join(ROOT, "img"); -const OUT_FILE = join(ROOT, "images.json"); - -const IMAGE_EXTS = new Set([ - ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", - ".svg", ".bmp", ".ico", ".tif", ".tiff", -]); - -const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); - -async function walk(dir) { - const entries = await readdir(dir, { withFileTypes: true }); - const files = []; - - for (const entry of entries) { - if (entry.name.startsWith(".")) continue; // skip dotfiles/dirs - const full = join(dir, entry.name); - - if (entry.isDirectory()) { - files.push(...(await walk(full))); - } else if (entry.isFile() && IMAGE_EXTS.has(extname(entry.name).toLowerCase())) { - const info = await stat(full); - files.push({ - name: entry.name, - path: relative(ROOT, full).split(sep).join("/"), // POSIX-style for URLs - size: info.size, - mtime: info.mtime.toISOString(), - }); - } - } - return files; -} - -async function main() { - let files; - try { - files = await walk(IMG_DIR); - } catch (err) { - if (err.code === "ENOENT") { - console.error(`✗ Image folder not found: ${IMG_DIR}`); - console.error(" Create it and add some images, then re-run."); - process.exit(1); - } - throw err; - } - - files.sort((a, b) => collator.compare(a.path, b.path)); - - const manifest = { - generatedAt: new Date().toISOString(), - count: files.length, - images: files, - }; - - await writeFile(OUT_FILE, JSON.stringify(manifest, null, 2) + "\n", "utf8"); - console.log(`✓ Wrote ${files.length} image${files.length === 1 ? "" : "s"} to images.json`); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -});