Update image manifest (1 images)

This commit is contained in:
oliver committed 2026-09-27 11:23:41 -03:00
1 parent b41cd3784f
commit d72e91d9c1
8 files changed
+286 -80

No files matched your search

+78
View File
@@ -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.
+125
View File
@@ -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()
+10 -3
View File
@@ -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"
}
]
}
View File
Whitespace-only changes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+47
View File
@@ -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;
}
Executable
+26
View File
@@ -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
-77
View File
@@ -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);
});