Update image manifest (1 images)
This commit is contained in:
1 parent
b41cd3784f
commit
d72e91d9c1
8 files changed
+286
-80
No files matched your search
@@ -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.
|
||||
Executable
+125
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Whitespace-only changes.
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user