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",
|
"generatedAt": "2026-09-27T14:23:41.878271Z",
|
||||||
"count": 0,
|
"count": 1,
|
||||||
"images": []
|
"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