From d72e91d9c1904da0c78446ee3cfe7e11a8e766a9 Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 27 Sep 2026 11:23:41 -0300 Subject: [PATCH] Update image manifest (1 images) --- README.md | 78 +++++++++++++++++++++ generate-manifest.py | 125 ++++++++++++++++++++++++++++++++++ images.json | 13 +++- img/.gitkeep | 0 img/oca_module_status.png | Bin 0 -> 34048 bytes nginx.conf.example | 47 +++++++++++++ pushAll | 26 +++++++ scripts/generate-manifest.mjs | 77 --------------------- 8 files changed, 286 insertions(+), 80 deletions(-) create mode 100644 README.md create mode 100755 generate-manifest.py create mode 100644 img/.gitkeep create mode 100644 img/oca_module_status.png create mode 100644 nginx.conf.example create mode 100755 pushAll delete mode 100644 scripts/generate-manifest.mjs 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 0000000000000000000000000000000000000000..3ff2bcb2ac22ac779c5d9250ce6ce60e52406852 GIT binary patch literal 34048 zcmdSAWl)?=*DeYnKnNa!y9amo;K731;O_1g+})kv1b27W!QE|ew?Plj_rA~by+6+0 zb*fI)u5WkM%yiw-)Aw4d`|j&nSBL(T6-WGx^BDpH0#Q;zL;(WgV>|=|r12-1_ajSR zS3=)^;A|z-9Uvf(`u_SrCek3`LO>8fNQww4xn`WKxww9v+a9<)A3L$e`5l0v@d;lf z?dFeyNHxF%w%-F?w9;K!>E~CG$^~s7%-iX*oZN3A`URmxy2T`HfWRSfPS`*$4`F7-d_ zpnH1L)Su+bWZWM}dJubfH>)EAYAs3rU9Nbbt&-b_c6VD0ce`E@U{}C9$9(&YR3u|W z4Pip{KPXyfwgekSD;)g=C;OecMR(l7|J{KM2^g3lNbP8$Cd^z?M4Y~~6G@lF<_&Vn z^Lr4-KsE0;T}69TTZ25lPPz#2ney-Rl-YIC!RCq5Wz0cx-1_#3rtxcs#mfJ*Zq{d~=i=_ou9QYv zC?+l_))4oPC1zyA8l)UW4zKZ0DMSNscVWKcwrl?V?62JRP%x-l1)%TZ0g`$IpFYkp zOaA-Zt|Qu~mH@XO*`SbNg3&G~$gkf*|Ms;kV4=$(kzX#9l)CwI+~1AK^87aZZp3>c z^Y?+>DrWcY7f2XZ%Y&HKHAC_BseQW=MPPrnWz7n`8$J^o-jQg_u5qg6l2wcTNwyZyG(Sc?4E#6U3 zt7y`-q3eU&U@ZO;%^`da8QwiZQ$|1*64QR;WI**x{V&Jz5^w6SB zOolbeQLerFwP*`#@fpe)mgSOOoc0JZ<)2)sH7rcZ8Qm3a|5by-jJ%Cv32>mKm{N=n z!i}PxEa&|M6I@c= zsN^yEn(TnNkt z=+34}q7o;^BBDEiil1MGJ!;*p=hp+fchFs}netRWgsq-WP4K*iGeMS8fhfUSbC2Gk_!e)+tM16L zLTi0CQ!;2OUjYjlNdF-0g2-%-;K#!k@iel%DKpoE z#&admHGabG6}mo{W(E5E4)szc5PnL3zVZ!b#q1(t8SEdgOSx)}t}h@HQJ(l@>&z-r zhdizSJ6XrcLRlm#RBGoAvclTz;%@c%+PGFB#)ARgURQ-PFByk3Na_vO{sF`W`Q;5b zSo6h$u8H*^ZIIY(YpUzONlczW2BDZx6x- zw4ULzLtAZ=WMvv!W_>@v>`BPV`!rhMfS6RlQmyrA5iA~4^@v2r2ZQVKb+yY0ai2nM zSEYuFqhpseUVVH&+Y67~3x8m!lJB_%NTl0)c1p0Gi8oQ24yb726hw3S81tnm6=SD! z7&xs^8Q#P>o2=@v=FGHIMG`D4h^=1%vp_Wn8JP&Fd+y}IYMGG}imlj$^Nm+0KP z#o$Dn110D50ax>GVBL5jPY?uO?q?a+9Ft0-Q~`IjI9N9QRblAM{-v8T(<=8oWqq_> zWg6am93(Fd9=t~$C#g>%0JWNlKBQVR&7za5mf%;#gNQbRREG+Eow2IK4oX8 z5A^Q=s0Mf)&p0)TAKc2V{XFLai(Zi~ot!qMTZDZ=4~TC51&4}Ck4~a>q~rj{ zpdt@Gf;}X(zS@#T7C3P^T&9w6QZ^>BkMI+M!rYm3b6^Da+iR zC%HmS=Qza-!1A_T*SZ;&`mAg|owr$*?@uyRJl_e+8i`FE3t{46X3)mh`8Bp(vU};% z7J8Y~v~;w(B55l@n1$qf;M%<7;^ zZaf>_d!7Fr{~kBUS6qNqqyS`bcsmfEiEMEnb>!npkv)?vcrae zqt3Qib^Nze-ZL!h0~C2M5lag?uf}48I-;Oz?nR4*P7+Q)*!}%2l}A? zTf})Sz&*<>DX)a+Uzw&vd|ZX4jnaCAm4rzg6~Q?Yw=ux>WfOnRm>FUP@svcdPnX7w3@3w zQ8eP`$>J=jqYT>tEAFcgk1sQVHX*F%>4%Sty*S7>-=4r{ZT2xZol zRiPn;#d7(9udU|cg3W;FQ_3dSQNi!<1t=jON#wjq8>RfFD>xa%;Fd%L=K`pT?vrMW zVgC2PSQeQ>vR8HrA09=(ThLWaizTE$f0id)G^-kPG6UQX*(E`YnFb$GkWpZIea|Qg z=qOPxZ5H9Az*T@>uj+;=BSj*DNrLX)JSX(pm_ij65qbSI^V`P+c5uh`sf#xYFJX{W z3*Y_%$IsnSwnAp2&!d`|oki3?;cP*<@G$-ZfhZ&a+T}l*d)qWZ^^({CqwF7zU zCYS}VZq3l%BPO=eJLVOgR@;*_!zYUG+5LbAtyT6H{O9O5ldnp`@19B3qjj`;^_GlT z{GOOB|3mb>-+XI5c0UZV?X93n-Y3#%{DfGskokP4K1edq&#!mGl-13 zzp_>OJ-XHCwSf0Cqz8J-)U|yCl%YM5xzSsdV@#Ls4DG))(119p7u{dB zo4Zn@NM}s(eFsTs;+t95C)C(4DJtHo8a9{dtxbKbw=6ZCqm}P*=ppsnh#?4X49Ufd2*}`TI$45+5inC7Kre1xg5T1YJASchr9DuZ2IQ`?pc7buV|K07hjTR{!ln#lwESJMdh7oT`}p9+VY*Zcs^ zUi8AfU3Q4minRQxUne+*0~L(n^>YDbFy7kjqeQ>L(HX&~9EA5?=GEnkKc8qi@0h4* zhMc^vuv`+u4>=^4%`eY9wD*k&GDZvRp)qG{v0Odqq1x&7FbrwocY?@Db<A8{0Ik?%SN|ohl%ghy(7i z%T_JpG$|#r2MD}hWOn#NB$o7Z@w*Dlp`q?}J55BnZVvWA`}2%8=2+(yEMa&X5faG_ zk@#&=dJ`Sb_sos%ZWoC0G!t;P+MPtzu`sEJFr_LFO)ee%Np0Jj7+huF(FF2f7nK?| zSF?gwFpMgzusS*4-ZBxw7hW+Zj&+u}5L@{{=iI}EF6|*&HmpvXdWZ$QeY3z6Q z0Fz7ZWO1ywMuywz>t8rG)&Na~a>#1U%3Qo>{+7U)wqAmNo9pP^J~b8#L%PE)+l^ev zG}%SX{{;kx%qSU_dMyYKzX*%$R?6;Jg#8;HN(_Ca_!}zz-&5x$Z(nNhb!ZzU{s(hI zm(G@u2T5AQdG0Cvmx2B=5?6D5Sff;Cg|+qH*L`6Ql>DDt2ZqxB`nO|3@Nijr3KboK zsYTP^nhgux)tlCc)S^~YQvN0~?7xIo7|&G2g&Tcs+xmXTAMW&oxa!xqd^2t=;&C5B zZ&D#J6wV;Z%v1C{QpETVRbZ&gebd4j+`6(HfGbMb+)R+=K_2G@O+1x^NZ?$Lh+=`B z39JI-wh3|tN#d~la~m1T)GRQjfqhHRI;z|Eq_;A2n85h~8QAJ$^)cFepL~w%b&oci zaky;!mO*-RMQ*^>005VM!MYX2L3w#P8{G75fYaWcNo=noBIDuVHcfK6cWiN7saTMKa{Y4aU1gZ;@qpI26@Q?|UXYuZ$CI4A@61SoaShkEG>h9+LK z-7bA^MX-K>5MEz*H!eD_Y0oRFA(PA2xPl)s-p5@=TpK<%_-xP8=LVI++OdmIQ2+Y} z`1=#`pO*7o6^OKew(xOmdfn-Z<*BySO{oeucVe!t^~h21@KTSqaukVzHc6YShuBuh zE=oo=>J_*%Waw~&wHsoU=;IF_aTY+DGjQv2oqbABAvZYWQRok0QF_2076r6=r#p+? z8u!dYFWEf)AFj5HsTW#ai}|Vo^$-`zs|LoMaXRR?Zv{inFgy*0Uo8u{Q!63`jBwN0 zlR;Sf?_-8CCcQt*3{4&KtBoz5^lNDK8{b3ac4Had9qnsCGDflooanCO-sR>Yc3{!x zMX8!kCrb8oBv(a@fHcQ(J*8|Sbl-9*QfzP{tj;Jv>}_1G91y?ewx=0jZM}YP$%n+J}J2?OLZZ3h^ zc?fN<7Yz;9Uzikj$~~MF9{N<7Vr^sS=>xW)gk6$rO_>V4=ai!e!Uu|19&ID(>~9EX zx{r0EV8e$`g&aO99LsNgbkROQ0@!cm6FpLES*msm^=qceGvf1i%FSm1i@p$;;@? zw?Az=J}X_>1S%C@ZrTccoZxVdc!kH2$>h z?R;vL&G<81BE9ac7s%H1;d3pu$$iD}cD0<{po7C=3DNtk6xAd75+!a;2<&OolJ?mq zuy*w(hW-A-{sS#8hhK0}QB3j$I|s72Un9rY)+(C33Fc$1u8>xZ+WUA+@Ka1IZ#z+3 z=~;V(3J%Lpktki=+2}q@o9(k<@+#h(Np+E3x#?mHjJzk9l8iS zCqwja_6ZiFWEqa$kCz}^qQA`TfCT^q(b>UaS}Zu|Nb{6%mwq=*e7 zj(dLyAdPwN*jW~!f0bK$>k;CnK@U;asnl+#B4FE*+J(PG>t$P!BQusd?S)xCrFd=_ zc1YR%GF&Ir%yh22%U3={cGOx@L;=IhMoiV39mcgWM1QnzXxj-(LMP|1oO1Z7A_P_^4m z+pv5gUPYTZ%@fG>#u#O2cMeEVS}XidzfC77W=g%taz&h(D8fmoV|Nc>6F6t*z)!4=)<+urRXn3_Z=;fomw-21>?tPs-+Z&IDr~lBW+rtXGyjJJ6-pcy?~Msd26kmP?W=L(u}Dw@&EI zlHNV!4awRIFhY7=+Kt~%f-*xC%od{AU5^hktVv41Q;lSqc^7@xI5C};$@cqlNk389 z1bTkLK||aB)hhnMb6Q8w-QKHrs0(~ z|B2@)zSY6S%J`*%w>~CbG2MtI$0TM1Cch#|cNe7XjE)r7hJ?QY11EJ=Lp1m8e#3(+ zSaQ?2Vl&u?gSpN^F}EDa@e|p_O{Mp$Mxb%cc9Zd#ZN*iY;e2!_(|t0RbZC1B=rO_N z5TeJ->)L>Ki6z`^gJCPVx$|NPGZLG*O|si@rx>kaWKW-oAthC|}cdoA~U!PvVh7z6Q62B!#au|UO#d?IwtX|v%?kT-#5znZwv z8?#u8lC{#Dw%YVZcwR~7W!+|(2S>Lkpn^wP74qsN)6bP^%9Z5^+A|@_I#olF?#0OM09soi3`sL zWLuVm>T1yTuncCjfBBdMcDke=j6@qEXQ6p2uFUx5Cj+KoT)eZuipOx3q$8HQaMhxP zLSP6;Tq4JTy8~=G+!tbji$lk&OsX7 zyO~`ZjMAE{(_~nl4=?Z@&01w@f}Q)!k8QH>y^#LXP8<)colq$!4*Pjmj?db#me&!Q zb5)R|769*VEW1zYnxy$^-NIC-yKAXF28f}$f8+sc_nLYeVqr^>yz0gvx4s_k&V7&Z zZj+PKv6LFD+Kr#DEOi2)IvdgCT3Fk+Vjo-Qb77Hk)*X2z*nwvqh(HS zOj_iub@VC{{1YRt#b(MDT@n(7tdaI~TnM*LCXXra$9nCN)or~G z%ZV@^^JB(6$rhZ~Cp$mcO$c?O@#UViR9TJ+N~>Ke^p|Is%39lV($mwsdpNQ{iSO_1 z9sg*B-Z96_gA zUN+dIHf{kNEj~S`y`X?5?_fR$0TXkOCibg7<-Pz*#C^Est00wA5`6gj&W4lq_B?TK za|=m6dHHUw$@)%ud&>%qy#_o?l_G%m62=vkNGJd0ZojmwAdzZSVlOIR%5BV(*{8v*$=Zze&Z#TTQc&lw_T3D#>8CfCo8p$*R z#d2@@9WernY(4*3e2Ug+VW_DcX5)WlGM}BAx&F#0z9P7VZna&-i#}Gve5XIzINF(O z&LSNsp+`1P{RgTr+ae};VfK&1r#qBo}4N zSMj3%qma!s3jy@E7U2H}Jd~5PQHgMjcH#ObsrvP&eu^W}3g`OXf2aF@wfqZ{znlJl zHsa=`Y=QV2E8AO+eM*-d$N%%yjAAz~^~h+;*{?5>M*?1E*=j{Lp$p;sT?5scZ`KZ){qK@mj(;pOE3sAeu0*moM%nZjdn zOMv(MwH}rCF*R8U^pIS;s!8Fd2>?F$WW%y#?Vh8tfBANfNFd%Z;lk+Ts!D>;g}Qij?3n15pf@AjOu&4T!i?uROR|J6+aqlqY`C zm|u+m-oTN)OIQL0ddAr4O5x7L!0M+lTi&qwT?b^{CoHw}a*n4ZV1hyBv za7t~T4h>Psu{7Cr0|)ySqyhuqAxVIGytS5jjg zz;S!AR)mx170@v%KgjGZ}XfK+Q>sJ?^t?x*-JAWtkz}VK>SD<| zf3Yj4*Tj05)?3UkMEyZCS?ub-BNpLpOxzwN122AUZ>>XD>7a7R*%qJMk+(Sbh1>H zP-5dVmW*3_CC+d^x4r5Mh3)EWVD8E+33$Zcp&Zi}O@qoJZMAYs(*Xty(p=8xR6idy zZ24X;#PgX^bX+s!x%7TRvdjQBsq*Vi$i`2&bdIF(%VVu}-&Z)vo7g(quAnb+HsxIg^#0E_=KWPN$Z+eY37*l3%G2smkmSSvo_l3i(%& zyQX#9>1TRVbSc+6*(9%fDjUzHh)j01cNn>vmzesf!VN;;PG_M8FSQ)7WD)YD_xTaZ zZtZ!g57R!~v8<=HWMm4(FDLW&;GX(yCk=L*$W^Z~(r|#nx>fDOXXxFjs z5iRl;~V;<*zxHiq|g<-4D+xSH$YvwL1J-$%J-4zFy3 zpN2FKwE92heb5Tx?dn`U*@?%@+Rmb+s4W|Tj_^M4JPKZh82KZ%0gqd?9Z8Ah2nzQ^ zKN;bBI}k%B?W(aOb#-;`A!aJ@LDD0o`WqTO`bd!VF`F|(xx^0w@kzJYp1xw zLJAd`Qp+#-`%K}oVbmwv^OR^cR0qhs;M=?kxBw30i=p2)4L;j};@O+s)$m&xJXj9G z21pGa%+z%|(|0A(uDe3k?vC888ahBeDE;fsMUv6ztt}2rgFAu|v<3Dq)n$IH#6u4d zA3)IyDn5%~+=_Hv&+ViDp|b)zYxdlA_wJH*Y^DE(@@y*zglV6%C{swXaqKU5d6Nul zDsba{c7)R3U6E0cq$#rNXrz*7k?ck+)j7GtIso0uPhS6811(hrSne&brZ!Ad(DCi`%W zoIj6+<#`lg%}=O_wj6I$Jolve00i#`2y2Zq5nau^rri1}Uqdwc0cirov-_73Kg{In z@!8dGn=WkVB(u2VI6!B8knE8jnVFsQ6yxXmD(550d>_VKPpph9;2|_6J(>Il-tIZ* zEngAev@m}VTdBxqk7SIiH=TfX%-{g-a%ec}*Xn8ijk+UHvWiVML(9a{r5K7_`rKa| zsbTXp2rCX6qKEHg_FKQgO6&zaqu%enVY=$T&(c`ZKo@UyeR#{>#F4#H!hw5Pna zU7?3NKr~!t$S1?O+~>^<;)3D2mIhP_C-xfokWNy^cey~9p5Z|Oa#H=&dSDah^BjMM z@*6F?nTNA}R9d#_#pKfuJSlg=B*|F|F*s#L9{)`f;B{%5Zhh%kphI(Gx}$n^u%o0P zAEPLrwLjnGd_H?0_CJhoogJg)jJA$jZEe*$OcD2EqUzvuCVAc!Y?s3 z%zm*fyeIrZbd95Q(ly&FGv$xQ#$@@R==hk14Cv#^oiO@KX+UFWj(RH0EvckNUDl1T z&w@|`!&B-hM68pUnu9>8>Nz*>gkNoNBK#GPp<8-CYI0*NRcLVVjpNKhEeTgz)a6vl z?*K_zc8{4Wq%|tLn%dz@Ws+wsvF{wzH`^t0?)ch9#w0A-9^p23+-fHMQ$%gV&@(uVt=QjW2f>>z!O*A@92vR(${NCU-Al%k$rlL2 zH!?ETeH(~sj5W)t@0Oe~KANiYGfF`KXGM6;8suPh{n$76N78@b^1<2W+g43Z%9qXs z2wYVgqDhu0&iLo>#JnWj8mA1**qJ!VNZC`fjFyN6mt8`_TGi+8elA3OK!hDoft#9B zbp84_$5oEfVCQ#VnGK%*#E@G7cKENjI1G;kRWa&#zsNKA}3o*;cAN0u&fg zxu~#UvhHHl^yu$^ON*(LErM4ICMYW9{4y2+`1OfaXBFdR8;M02_V(trmM}L63QNCc z=TIY_SEXWM4c+m%8xX%F?HURFkw5FaxBnb3jpN84-lFG(^*H}Ah`@7&(YDkj=h7ox z?HD8Cr}hy+OMx`|ck4JE(^%5^w_t&-P4jge{=k5o;GpEyelI$X>zOOQ#b2TS&3~K^ zH3`++uT1_a^ZMpyeo2yihy6mYg0~QXJ)xYHIQCBA?Y}YyQ&nSL0v|3n6Xe+4RGTn!NHzGn!w>A)Y9@?KUA9tV>5*&9gaN3veIXts7$=HUiaXAC# zFi0plpji0vW=FE_V0vcK!kPuV1`cstc6bV96bhpX%D zDzSsjW0Pm~gBOSP-FR7vekFc9rUWl4PY66-VdoC~k-Dd6lLTKW2d!U!6aK92!V6~8 zEX@{;4#MCxBcm`vb1_Dv#^;v0{n>fVbyG|j8k;P=U4f-hU*_EuW9+nifBL<4gu>bW zi)Js*6!)twfw7Y1k7Mq44QHJ1&VDTJry~78{~5%dQdPRTa)IFV*68x|_{BP|*zr+Ai7k0hLl5<+An@>9z5R*|mIv3sg-OyoJJDuj18+j`noLHT$J%Bc5)t zajv|{cux`y)*?Gy*!1u>`zTuV;ZcsWG3Zl$9T}JM*zmlT~i8FfYflXGe zCm{o6_t$8rYiVY7BV3PEB5ChZF`l%K2klhe(wpu*39mcU0EW7Z@4367eDgshR=KBC zpotBil8v?G^1vSkliI$8G;NcIsOI%=?yC$>4=4GShE^(y6>S9^CTtOT%Gt-wzlB{7 zP61Nogd^LOdh@(@E=_4qZ}Vwlw?}sKfgEk8V)eOx1#JHG?w=e0u+wD;+Hcin|)I29kDP4U`@nG+4D8s6YNRXS+`-=TxGjF!; z9T;$j59*}#tKbs(H=_;CJf9sm9fzJ7Qqmhv7de=*)|rPqZB?0^fuv78X?k@|`kvqG z&E&7l$xD}~l%7so{s?U~*zg18M*k}kiuCSxP-^$Skq(RbYNMsguhm;=ES99Ma#hBY zlG0B=N#d>6YTn1Kpl5N;Hs_o!vUUqU+AG4wdb+|UL0@PXOt+RIPv@S4h zN&3Ma(IiWYk*_IAZbNfmNRwbI@+{l->4HlX1Nq&5P$kD;?~`k3wTu!TB@z;HCYq^c zDPd+b<0^JF$GG(Bu07a>9sZy5mrHLnHl57zIE^~PDMkL4n#}$# z#@2gVD#T~v*tf#!SM%N@JCz!%4!!to?xZK$cj!Ass)lNV1{#qecyiqk&SBw{>k!f5 z>2bn8Wu`<$Uauk!xXZ%<8!ndndUF_T-!R`9>>Z#%>c>-m%v=g5dXIqWQ*Zr?osC*m z7cvo9S!L2s$GA~uD<3;}1R3+Eo<&7=CFt$iYuxG^BU{-rc-Ak2IPYSGPAk;g=79r? z7!#AB{9%o!Tq@<;R!L>>1Wql>QSbXBh9rrf$Bx}z&X&j0AK(PY_ED5Op*RqVNB0?g z3_AVDV&fOq@t$X{yXRrI63^iD}t-wqrG&Ty{sculTVc4lhF5o>Xy5Tzjhg|1D~vgqr!i(9gBHHczqojeB_f_F|PLc8umzXoKZNRjJi4 zwbBF6puI19&?8LJ)b~vBZFCi&OtW}bP||?wsE5H|rjSUx^(z0BWT7-RFqyEe?+vjv zL6$9HDl}y1+(_YkM1CyojvLwOv%pj!*kDWpz`oB$vE~k=N7+J4V!w?ABNl;K8Wd!K zj!Gr<|AC{+h(5rOX{qZ^ufw+)lx5{AV8)0KT8Pqnyr>Ytvx=h5Y_hqL$w=hNUKn^z z*8|EKsNZn$-aX9KML~>F1DN;ewA-58LkJz0R(Pk{%3&_M`?~7{3Oc0TIqZ=CB)jaU z=V8gOoDFvqim+Fka^@Y7V^U(edg?vkG8W@PheE$>^{-TZ+43S`lxaKxzk?c!|Gg%} z49_WWuh6rXtQ3-uTeRvqoGQXLE>3pTeq z>+(h5ZDnHPO(mm$iMm8Ht|l3Wl=}}_6s&o}+L;9xt-4(V?{=r1Y4U_5|sO zod?O|EpletReE-1WKv|FdnYn^#;J@Dau>o>Mv^#eW^ABX2}kTN(-6<&^&d!Q~jmj#+^aCbaxyrm?>$SR$C zfG@I~U47p?r{kS$1bWu8&we`tOPh|Dt?T|Al3F}b7j|&@M*EC@FM=l_863%10p0c4 zyrve6DwuAsi$$w!(E>6&T$8>#={Oxjq$j<0^@Tz^5pI^OC*j((`oU}TZ;$>kUTVy~ zoS)r0LD;W+vu(!}C34~u@Z?Q}dv_I78N~vlx3tp$vS%jP_ZfVw{fhNw#h08e2HY3i zvB0`FH7e{#oZ#}G*pK+(-9Y~s0UDezmKuZ5_Wob0Pz=qrA3xdDX^gQPYcM`+C-t>> zsBv3;VDN14jqv0CBQ`kNY%?8AqsdqUszktM3ja#3KvIPAAF>nPC{@n) zeGE=PQt=h<$`iDFk}4-NGSbegO^ejggHCWsqD2i)rDyohD7tuB949mJu@-dN(J>Oe zVG4b2mmIU-Fg#iApzFmvkfPn;6Yuln3@-;wc$Dz@&Yg2CR*KTO7p@#)S#J=s_Wn9* zC5xLTuMeRce0yn%u2Rlu4D5qyx|iowkK*cepJ9@6^RFr7Ou3hzxX|iz2OfG*Ds7h^ z{m(pOEB&;ZL4<3?`XANUtv@2m$q_SbSy>Z2f3&grrVZJWPnS9lDB>Y~b-?sF-$*J{ z_QEsQGPc=o-^xpAVvoL!7k5~jbODn?bQmWy9aS_Kda*>op1Ebn>ljX5XZv$?wrFj$ z^W9E>E`x#Hk9HsK1ukq}Sy(dfgx~rFH1G+;duQ5ctQ7gTx_$U3u1k+?SK_1Xg=cxQ z+U_CGp3mL4t(!tekJ5tqucsGGrmNnXl`=V!h=mDWeD2Oz_`~dNb{=dR@~1pxh!X;+ z=^SZwAuU}6Ma_Cpg)UIhB-xE> zS%Be_a8)6{4{l#PuSi-I&r)Ufv)cEw-YzB{{px%=NKs!g%t|SyyvA^33&pSQW*8Vv zg-AX&?A?NnY){hdo@?)at^@G9@)&r(pLS~d(xS#bo6tzkQ26$}G^FN_t(C1HM5YBe z?1%UWZQS=1S1u{F(UHQg3yb1zss_GqqM9DLrZEVP*shn1UDUYUiFN zz92(NKx12I`} zrVc7Yi{H-I-O=hDI6%2tUlI6apNJMIQo>IBKxXt5l!{PRbaUI}@vO=F%*P#YWJXNV zhzCW=36YJ0J3l48io4b_^o`_-^3OD_KkEJNPb~k3Z$3eE_O`15y_(Oiuf2XZ;Kghw zFdoI;1~`>3WIr;j)?gh&Rt-aC*2dDOg>JlbO#ew1c{6_k>i$d3t%bXHy{{pBNM=t+ z089e!E~E$1y#OHIzc!fJtY1%B7r1+gx2}?vUGS6q zq<4m_(=*7^Zk8WBz5(0^EjrT1rQT|NJ$+IwsPRI_1yD*z0td#N?Rd%AfFuVhBQ|aK zM?5D-i&4&UuY}R}m$t4u%!M+Uy}-*1^kj7$%rM=S+UG;kWEF{RWh-EM4aJc7QztYex z%fjtqvSgN*&dK7=36ev6l+f>xhB}(B!kVE-V$;-l6W8Ux%jqY=C8P1lBRDq4rl%wAdUM?xKUoov`WR^qG z$coZq2J<+|3}QXGOnBf3Hj|ran|xTud&3M?Qoo-zlv8yYVF(+G?lsuFDLwz`7^Ufd ztJ9(-S@7>fGvT8QT9LV1mX=2R5$9>VXt@|ErGrsTixyoXq4_~Sl$c6?e9 z5eZ_;^#IAja04oJlZu2CdQ5D7QF{01^}SuZjqX)BXGRx$2z2vV(E1%Ri>{-(C#>ht zwt-v4*HT;bv(_SRz3#nls>C5x)3WpXY-uAZ2|v0;zp2*~vZQCe99KO(?ksBieJ@%z zGj6iY^qyg^rsYsqpHJQ3EA;KvgVH2sNHU4DKly0#_-u)JNI>}PdA&|`hEJ2~VhaJq zk0(^R(NJxt_?2dUU>Ah;=^fnVS|(V}{-O?@iV#Tfh;X)gjQnPzsxg0Mv=&5rg3fUE zG?swnt)9%|qgL>lQ-8nubWCWGgw&a|6vDFL>cC~K&vGrY)d)q1y$wE|my*-{*^)3W zE6I0nC%m$IspG)DwusE(`R1`)dK$2Kd>p?5f__A6Jj=LKr}y3y8A@TZ%;3)-jU1{a{w-!LLyzZP_e94%izlMfNigb4u!vuBpn}6*6 zE{r5x`U^HUusar435MuBp^ck}vrv?41O4dDoY+yM!;Vm#ZN7(+O^fB;ZYcL%5XW6g-_#luGiB_k78IeYf z9W(9DCrCfpye77HLjC;We2;R%;!k4Yj~ZyB%9_+<9qqGnTh)3gX8i}pNkNtQ67jqi zHnaI#^cMcM7*(C@-iZ?$;QRimUerwr4-X`zPhIleU11Je>qXy9?D^CT zDJiQsUZ&9jyh|-}3a=kd2$t)|eiuWAYQEcLL2|k|SiMM6WDU=AC!f&TO6WK5|$KZEi`Z z5ONN!=WoxJs()TNQCJKozN<8;%w6`nbkQ&brRXlvKS1CI zu$4qR?D=4^n^~M$S$jK~X>EQfWmF7VJKwIh7nP6+BR*BKn~s|^|GB*g_ocF7SQW6I zC}paJb-o0j^!vxKrGBnzHG?~P?S8|4rleI9jUxfi^*6P5>$}w^7E4{5W0ySwTRj`D zZ!TwYY-`Wnp&aqM3u=ffJ1R}F%8zj^HLBxU_KYf4E6@>mH$cfiMM#y+jPHw%N|?_Es`AWJ z(pG(#)8rl5)blz7RCW>Yc9)$p|+a}Drz$h%;kz_YR9sqKEVzr|?<~eBDtUTZqQfFd1ZRGs>4MZ26j59L)>;QwBPb(_ zGmEWyOuZYLW8TPu)bF|w;%6|W>sjP}(_Mw$V8#n5azfewO5P5Meo_8^&to7 zL7VlYLy>emtVsGMx;5ti%~dud_#E2aHerVA?NeR^;R*Nsof5Hsir3px-yp0C}1+oglK_* zdYM-KDKb4E4udTk9}^*`UZL^g_|E#q5+llHcK`aZW4b{}KhH}tOp+5jUPGo8!-)7)D}#j&mJzDNQgcyJHy z5ZoaI3mzcB3GVLh9xTvE@ZiBMxVzK1y9R5Zak_!qYkxayt$oJ+#@*+Rd&c?dui1m1 z)m2n6oBF-a^SsZ*uX)XUssg5b1vwALNVn#J+tbe>p5^r^%ki}Ud$Bk(3t*Q5tmN#A z^x*TUh0=PU0qto*>1iAYHYDJG)fU0HPGDxx`-xRrvZ9ETFY6hqOawdzDl=tphDRpy zmk8)AQ7F@lfc*3D(3*v{_VodMZP^XZA7>)?B4IY;YI4*LW+r%shrdV zlf|!Zv<%WTwfLGjsHhrvr-iSoaKxg5+)z_A_TM6fJ}4{x3OrJ2)BJ}NZ3UOJ0l44u zJoDGx46_TcY=5CSK5X}}n8s+5)i3fb_vRzN(|88gVc_?l z%o2A0{7)NGR}!EiYtFZj{V4@R9pq9FJm9Qw7y1)s;(uRW`Te5T`Y)vXKW`jMvI_V6 zm)#`D>yH`Jt@tIAPhpUXb#f>T5lP^#(e7`TEdCv>NCz z)sw9&q&caE46Q|hXm9p_U81}Bzd=|&#Pp#POtUi|Jxss;JA18EjI{??akuDQu5Sw& z&8(~1oZq8M*lKP7&fvfsMty6##i-Ef$3=})VOuIxbH$C?hU^08VAb25eK~t>9nUOM zp(KiZzu`D%G1DP;wNxQj8VQPW+4G~1Im1QgwWAMEOJY4jyrtk@kDLCUH;+dC&|vqO zjk{usb0^>3uT)fbJ<mdThV-g+lt&O_?Lo07oO7`v#575=b5m6gc0 zpTl)uB6K7D>ukK{uQrfP%m_1FG9LtdCc1KtYp3Z>&&&r&=_B{9?9JP05u_)*F8wbA zv`wTrA+^d8vKQ2ihSuaHmx6)Y(%|t*#+87RHYJ7YQL=%B-2sjh(ZsT;1iRHNbB8?# zq;Vmiz7w}OP=9pFar6mK%sTjjZUAb=+%G}BzBfMD)hc{CsDFEbk9~);I6P4%OSE-U zq}rk73Z>4wxX7K#9wF+Q06umFQU<5u8wa^2w5h0+33#F1#Mq@rtPnmbWw4@dFc>A& zGT{MZ?BXH@6~}TvT4Fv&b2vROn%`GSC?8R<)5K-8dS#$%mgbY7Y?y%QN`9hnR7Tr+ ze?M>Ix@+favg4H-i@x_@ck+Y$?)kFa?)RMYx!*A4C!#5dpVgV*sa)(O8K^^T|4^>r9>eCA_0I*)8(^hhe`|&qv8S96Ev0$r3I2 zImJvM?|hTasoCjPssi_j(H+rOt?-m0QK`8W#5)UlyPBOB{t3lBQD?FZC{Ux-{t-1n zz{MClZc8QW6&L>0X;StJl1oucGH9A&j;$vHOCUMb+jz07kPZ0e=nM`6z(`Q(2`t)96uf%7m$YVik zoySDAnvDbFz7p#NI$OxXJeERD)WbH3z8gG`-s@~gt#8uxG+KL&oESh@0yin&^U<%< zMW59l57v^AsIQJrrHmB%k)NjhvXNsR8l{0ho5PBlFSf| zY|*Q8wL)m-cAn|(IlscbYpGSKa727(G+#|A7+5mL_4CKcK-%DFE#NkByP`txz)J>C zZc(|$ya$G0bVdskSD5i)Rb3Cj3dl=yY*EVh;<^4AJ@mDFPW<%lcYRj%Ireeh5ighm zgd7P*%*IR(hl5SvuS?>%Og}Ofx{v{x+(DOkPaM&4M`hn3gS#1JV17V2uIV*BePiu1 z*$hL%*kheDA(x>5cGUsAQR8^CI}qSB>T?AQ8<(qUqqGmRBF4zGr}#fa^tpDzbWGaLqMdY`V5!Ixh_KN+07T2o>sAfVjdVU$adNQnC!LH@%9 zS>zeexAJ4DIpJy@LQ987Ho<2K;OH&Zqrgx4@-Kz8X1i`s5~&X(p7n}j>7fn|SAs^Z zhXm*^@tC7UL^8QieY(jFd!1cn^*K{hAeiR8P#5h-eaxjq&i6Vzks#wa2TqZMhHSq; zanx5FzNHh!gIwG~O^N;;yg`9DbDes=L~9%`_LDUHv|^y{B2E>_vZQcW%T{^aa+=?C_)JE`+xrpAlc zXkJuk6v`~wn8aN_$+V8s1QO`){rc_g(h0I*Lh1EyC9oG7=QEbM_|KRtJZYdUA#XlbwU#eYa&NA6NLPnF@=rly{vTj*{T`Bs zn2nrdUM>dT4oDb69}h}>>aS_=tNicHLuPz4AyhBHBU3BA1OFCKqVGJ0TRS;if1oQL zEVuk=4)(yXXI7T09~wfpj`V8AVYTuRpP}QcSG}@Sxx*6k_cU5_k#^{hgp%K=w`Vk`trB%s<3&HUB28zvM+vSgP?vb%klTIh&w$+|j?u^w z##4z?E7zdarVDLG7P1>bl4#u67M2aHb=GBhh_?iz?Q&Njfd2hR{h*wftkW0NX>!Ts z5kIA3!AY$64j!FiFK$d(ld)SG1qI%U)!1_PXAO6@CVF%Way+|}_;!2f>0LpYb_QbA z#&NYmql$t^p;Sv#6(4BqeXAk{(E>cyM4ckSL>U7Bf|x7vgL9P!ce7zDkkcDQW8KBc z)++<*LK#;2zI5rdbC!Fn=N%M0o4=gQ9(~x2OTxEqRt;6vbQ=}{OC8olW>HYtWSU-zha|bx@B+O_(1$fj-oCvPY~{+EU99Q@Nd#a^ z$qMPp(@FpXHLtT|Sy-oxudyhw#2p+-(wUyAa!`jood~rcC_~BJ3K|5RpD$nX|5zwP zKj{fZVbTbb`}FQiphoBvTTKI+0kc^RuBXOK#Snj>%~ay^OGZ#k*dgTg~X!R1X9-Ok&!INFAu>;z@?Zv+>ou>{S0E*9G5OzSxAmHcqpiXDWc6N0IucN~Bzd>RD zmAtO={CDJ4znkX5LKvcpsal#QwoY4^eWPrRi;}n6W*VvYGvOrFa}+9b>ISB%HaI3U z%hXt1>ds6KO7)(e!^0q-GhK79Hk>uf0CMLiuVcSUe_ms~+pE^FvfF3!6olys8K5bn zLci>DqF1zGIh?ed@^Tn8@eLdv3dLg)4W9)^&iZvllN}tA)Ws) z=8E#a%f#z=7)>#y*jSxQS^3KBqXa9c8rN@eTdESRf4A}xhU9RulOSbSXp?oIlbjpq z=A>V4t(J-a+8ap*ivW%!ydR*uD`6$aQK+Htx^EGqo zQFPc>7P^j@BlF}P3b%&wS-1mP3;agB2%n;6|ZM;Ir8{2ZTA3*hkfK*~Usus}99_4p#jKi?efDp{8ih ziMjJ*DLgRxtJfPiUEht9r^MtMuBcG=nwUB zg!zLU8Y-|1?p4p?0;ad3e@0*C?aW2j;AsEfFz=LJ3UtVUc>yLrj?27@@=a}<$)4Zm zXD42Hg_YG;dcsLGF|JsA^dW&idmOWN@$zb)<)SS`B`FI(iI+e}=c!GnL)?xl3ybzpEHoXT)PWQ75FQKuP75QJWbx^OBWyFM` zN2&Fynbio;iNsl#gi*H&f)(#>#Tocx*~h5x?31_C&hPk`Sz~4bzRbL2mN|ImraI4V zSnP_-F5=IQE-|83Y$ku-cFl=Mf!4UiWw^U2bm5Kye4uMu13cFq!ZZQO!WeshLCMNANn9b9~* zerdL!Mf{CE;r$?+^0azgv2MHYtd7ff`xa*60JM$JEyazd*Nt-#`F4*F7@u zVU3y@q|i)U`{oB+m66-baC+67cm5h+G$J>L96oWFEETH7skR3sbrl}ibKPpn zG_TA@if2NH1pESGE@0Wbfu2uIs8=Kx69A8NSB8_GGyqncKYKpq07at1F7idt9;Il zN4sp%{`LeEm!5Hb^OpjV5m2ecFB6Tj43;Vv3-Qj6I_ih`eyeLSS!3PjF-xOqk;b>* zbqJbUIYa&O+<_jnRfJ;vjo8zw*XsWiXeP~^hn>dmKR7dc zY{U!aD${JF#Qi?42U)s~&(VxPztQ2JI=&kfG2jzquIjGJOVY<_Pj~deKF?~r%HZyFeq9{zzr2*)&=tzNnO?2S9 zgD(LW`EVEi&cKu6^`^G-!xAO=-05i34un_;gzjyKP#WUoT~x?F|Cn;s;PVw`Tz9%S zFTJAK&SnP=Tdr5x*dsj;BAIH^lQJTBRH|wI9G^a4jaJhEM40X!ZU*nb-fvmxS#n*K zEhXL(I#TNVI=K6>i0)E%8gad?@XZkFwcHJ-&!sQ!!WGV`Z*@{MGw=_ds=??C@e$RX zh|E%MdsmboYC=DNQ+Fu_J%UJhZ50hFjt@`x7e@@%!RWLFb>HO&PJY0UL}fi(ruY^m ziA+TgrB=hAX7Uv@v?#A~z1EdciTF~_w8-Z}j_uKR!zqdt*7jj`f9imn@HCMo1-&e;=gyPll@Q)1of}yv6ZcWdB{fYShUwC^Jj^MlBT7dtW{eO?o zZ%gyN{f*cwV#n4c@*Dt0`{+0|@8w-;_5#F}L@WO0##hY$Ad8tJ#d6$VOu*tm)=@N1g zV7K_I#kw7`tm|rGUA9=JVFSa)Gcbq3aCVw(Jy=-4jY(|$;S!pCx>lgv_%M8-AJ}%d zt?&dE&=zFpk%GHHdkl%pL})6kyPMyi1s2wNj3Pc;+nOza9=YyMAFy8-Eb93D5$p=K zdFDpiwRPHmVMX0Rvii;1n0bIYPt?e(lK6J*S3PVYFeW7I%UqB@68 z7JJRrvyp#v4BQOHz%b3ux^Of}7|LqeCTxN7n>R3rCrV^Nb#}8y zJKQ7=i~4XhDk#TBSa(K(`8IIwC2T_6h=(9a_~6@g>p{2`*%mFFR7sl>*FKoFc?YQ` zDMt&y*?X#7;8%NiDCXHsu)yfXgV85x$kHwdT64`w!;^2(tFEab!7lk zmeXdaoO{srKKX<}h*LI?>~ZWSZ38ocJ2*GDNmgINDz~q67QMLHJHneN$hNB2uxGB~ z%*I#&bb6g~=F{%e3od!flvI3SJ%M?q@T7g6wW4Gl+}H{ZS~8uRC3}giqvcPNWv)G!2hDhf@ok@_){Nx- zx-mTBJ6sLMsmbDT<+p0^>r^;tIKvy)+#71_kE~mpzd-1tSsYQJ90yH(H9o_R8uJw{ zZFw&X$SjXns}dR22x=Moglqi;kBOu@*teZ4H~5dv4=>sBA0c?6YkS_Q4=GB$7ZjW& zuJCACyO7hzF23N;+Z|c43Phj2FzOA|4mZc76bVOK-y`@e3BN0yK*)&J>f6C=-`MYI znvaSOzykR_!3&8MKb9}w{2^rjAXc1rQD zKm8X<9PdqXm)hQ)DdgMdkG8!n$oxyxNr1joczlOaD)nIq1$69&%=5*h*8haGUqN%c zee1----o_LD4=@UliP|@xwSe_x-uIC#854KFrb@@-8speJvcW|9M+auJX`$;%TQJX zb!w3mP(O-4*}1D0m1R<^Tzso)5|q%f%ey&>l5PA{`6~j-O0+vuJ#FHttI&J&TW}!kYD8z?4o~YY6yi(Y_#$ilX8ch57$nJs`dT}4eAy0L z8aDnQv;7$or)Tws3txG0*UrQ~dtWK8>18NMuBZG`V~0Z|DbN<_`Bj0p@X!ZoJ-hcX z>kmHz=7?B+8b$gS$U6NLlPXDIJu;}VjCJh##iRIPpUw+>)OziUpryj)Q8GX>x{xWe zZ_7TkdZS@9-OKlD&Ym>NDQ)WMzzmlQE)R?KzIBi*2l>|KA5fU+=fV3@BR+%U3>f5% z{CALd?J($ZJ5)u#w%9{Q8lyG*3vXxTOOdeaf~0+oehi{MYXDVs-QfsHCevombKiO9 zJWuCMS>j8wPG@w~jT7uO2FGm0ZF|#_LoH$ z-*_@cx2@9tEzlhYY8}9bVOm61TQRX?`h+XHeaw*w{~*FDxsz;c@Cblg z8l$~^FnUU0Ba~#@$UAB-y+c_OSf|fwxZQ^Np*ASSmE*tRoZ<)pNkf%fVy0cmHZWj$ zN>I=*Q*MdV99_(@U*98}i44lQOglhJs}*PfN&a2c8sizQ1kQ1r7Ubvk?=M7qgOjV} zyJ4u!%{F#?iebFx{HlsEK18S;60m3ii zy%(SlHh!^+15~M6>r#{L{-2nZHLyRmGM=QHys;lNXIV=^?By_YBC_gOUTO)0d2c9; zm_NnsQAU;#Wrsu#_17&|U&U_lcEps8&;TE)-HsjI!kc6VzJHL^S;G6WQ;vy`ow~ev zejP8teJLB*dSCJXMIqbSr#xI0D+S)oIHB(2!i7|dch)~v zxHIG3y%`WlSAGGjo@2yja(M|I<;O_O=a9`ez_}|inOke7=aULK*1yoNk8+8_^lNWI zhM(U4ZdA+r6BCo}hb|G*CzP)JK~QZ`LDqv!oB#=8s*HQ>l?8GT^KEyNVL~}WQc%=~ z8Y?j~UZIxollDjc`jqcq;Ro1+D{epLNg-13Dndl~d^mA5aXi1#r_U8ZS3*g@!zLj{ z=(I%PnD34KtNb=gjeS^ncyCq)-XZKcb|PL+iZ?Ksx=!iXXu9j2WC(6|{aE^ob0-Ke z3bEi|OH*zxl~FMz3N4%JmdK!z@cjofH2uD;w#+e{j;Qr%(A2CUcD#>qo=YCmN7-mI zXwJ%7Pu&4uZib$v)<}vgNlleTYva?K{~p6K#kG$pj?S$%>L6j8^t8Mgat_KH0A3dz zSQ2T9w%-zOfX%KGO=7b@cO=QCq5AmxIzf)!`ftr;PK%E8oi1;m|2!)+gsE%Zppl{@ zR6(>o=91PMp4;hpsUAOHpERW`Ey3v|ihGeX4u>_Hrz5!>gyk<146_eN( z;CBihhFh6HSV@7J*CU_b{ty>FTo~Qb_cQOa7rQWBMCSK5i0-=Eya?^czKH!xysA@_ zl$2&}>XLnkZmG)>@QVE*ypKJz)<>CFafHQ8-N;V7Y=i=KQ@7fB_zHFH&O5|7>s!Hx zp{MU|LeJK^Rm)$(JO{?iN&vc~bM%t_fm#Z|iyFhu$imt4Kc5)-nxFmCAWM!ajjV4YEpJ38t9sFj=MvvtEogZEW>{0ltG0D-BoZ!UG#Qvqs2F zKH-!S-Ytt(ITCGb=Iis^I-%m(pKMY_?fbLmN8@3N*9PPyO91>B`SY=i?c*5d$tLwr zUwpD%tca1jVsx-P_!DN4F>3gV`LC>!yX%EG_!MgYy(%{Lk>f8_Y_GIgvQ*ByrsZRJ z8!36!rTN!az?I3A4FR8skbuIO7eA{hd2cGG)!7oQLT!6LhhM?MbI2CuW(qv((9|FH z^+cCFy;h3FH_cW(OBE#!Z*5H^K0nAzzR{lNTli^2AAU{tdVk!(>b6qUHJ#*E!*Vn; z^tx8~UX$E61;E5px*>dqcuuFesAWR@oI!QswL5}P!3b`f=yUur6AYS0l$V$VovqYG zFP$JN<=W4WCH3R*Ve6jC45qoUm`x$#{-#2-mndQCqKS<8(MXFfU!f3y`zEOfr>Vg6 zcvSybURs4DYEDqL^>roY3*gqQo87Tvm31?w?41wR_`rn(;`-KZu`c7UjO;Io;12LN z(hJE~D2uz6OC`KXBPm5*Q5cuD8k?=nV#x=ux2i$H4mwyln=zivPr=Vlwfix>*;^5| z%r0_GX?PC1OUaIK@HGPuS;Gs?%rdyhUL zOo~S;b9-hNP}<&EpTxj8p4TiiA@?^TeH#Q6`GzQ+I0M~H%f%Db1j_55lP2UgS61^d zgIlDrED*UY(Q7pz;13;kKg-D^3#`x&?u}%u2G~s>V@Y3!eBRMRNttnO>+!u+{Bo!3d4F;3-%Y${%hFH%kd^Q1@jR%MXDho6l%G+75f!n6 zLqbbM{3@#^wrS)h`^0-|Yq7>b;K=vT!5ck0>OOAbEX>8bngUpd(LZ1FJ_Z^nbr5rK zv*IiH5rc*$m#h*EN*_0a*0~eZ&deWif8)9z$}3uyK{3REGa5qZmama#3{;=NM9+0Q zqCZ%f0752zsteNVN)>BUhkT($r~uPYr~l|1;IU>4lR;dk;;kY_sif& z!P_Mt%S(hQlXSUsu!;FlXj!eug`CFyN90z!E0Rnmb+Vsgu>kui^f*Z}{X z02C9D(cur2MsHVxjbAe7jS-q#EE9XaJ z)gtP=wNXmH2VqgYs_c0y?)nm*KrcNSJ-<7Bcher z%IyB1+=MWuCB_drFwFIklt4=TE$m!=rQ}1fP5^g3h7IxC0uNF%(xEa)Tsdo%mxPbg zeVC(I@#zvM{ywMIJ+M%@&t3@`rA-lxR8;#_|EXSePZ#VPLzWV?C^u<$b|jn#6~WoJ zqm6tuV>zE?mSx*YR>};EMotieT8RwZ?vKQ= zj=Y9ISYV0C_T+LbR8%P@X+`92TjnYgcAMZs(!uXlPsJ!A4kBUx7A6ThLk&j8%{Lxj z=Xgfc7Lvy|Gds?&X4thz(~FJ1{PAHnp~V*l^j`I)GIhVfp$^^X(kM3i7d%)XKnMK# zFp?C{kT(#e3!$1=YWr2yne&IjnO#cjCg7s-XK&^IBj$g+u?Y zX2|tqY{K@>wh0XgBd@(I;j^@DRVy4oI7=16m>xAYH2H+{!c;>#f9XB#s6c2WjzoOT zcq!N6pzhz<1Mwa7e`zYWc>br2Wj?dxV^sZ%p7pEwH-BY*UAaRiO?O&)hV*ant z(*Jp5Qod&R-@Mkz)#j0$I5Zts$o|_`DeYVU4>Fn)*m3pmmn>Ay>jkTkA)iv4LEuqi zX66vmTyctB%W~fldRdg6Ky8gP;mv2b5iW40@86~&@dB19MmO%7$)V8~>ouHYtq^fI zX5~;xh?~&+lB2@12j~G>ixfP)!${~KnD7qN@k@o){XBV>lKBqqh+upt&%PdDvp1qG zB`ImAB|Ry0d$Z#|HR1Vqw0EBEQ9h66HUk>Ze=PWRwZy+i*8WcBun|ULehVEx!@5>A zZqJ?zC;cJFCyv_Rn%kgQNCYX@ZI7qzGT3C%@OnMxG8?7mWb{W;AdyPvG+8ljEJE8p znJqT2Xp9`xhvjqPIvY59oY5Ra1w;()-fD~#y2Av!Z_UEQuLbC(4p9E!i4P+G;EA2T zWL6TJ#__8EjufBP*D_^fKJ8hOKCNl1;U#=Ur#95jqzfv0iD5y_#F=N%AY+KDqk@gg zj~0Q-{d%BFeMVheO62vFj47!t?lQiLI<|!*<=eL(P_PEyy)dCJ{#bIjc1Qh!(nRVN zjcB$rSb291(w+oRyD8s)XglRWM~*MA*vdlg=io4BQmz=9eyt)YnNP(W&i*-LWGZ;K z-R3#!&q6o;Ef-=E-~JhnUUIw~S` z`7w}VA2<1PDSsA3yiLwE!j4b-hf)>`f#;cQX2ms!vq8j**Q~RFMXp~xym|IBWYC+< z6HK)U!^E{L%&^urwaFXzk?!3rq&;Mjbdo!Ot(dC(b_mymUh5nQkc!iHrE~=*1!bm> ztpzp$pLsjoxR1sMCmT3u7|FfGk%mgewyrSi0p$m<+JR-rG1;lXrrJ3R>D|jC!~miA zBT#!(3(`;exlv<%Ffo2xT@cw_(leEMSd`Hq5ZUJ*PS$1nrA+cE%ng$Nl3KDKg zj8R#xZ7X=Nej54Xe0v^A97`);W4(j!zCZofCL|igK9tMZj6{@}FWgi{`Fe5{1~}qq zjV5u;Y;Ax$VEGF)_Dp-y@!*E*LgSAS85AA6J$)2si^@`_N%F3qPgG9ugbQ9_+tKhB z15^mb#Q9MX8GK<<1MQ+&t!(dU9L{vur&EL+Tp11LCREv2s~ulo{ow=?VA{WM0ewPUN*BY0s| zD9RNJv1<)ds%9G>%(9wpXHB+7`rOTTVa|2kRF{9cI;tVFe17VimPPE-*m7Cez!l1) z6UD@0TZ=8`z+Edt2j2E(=Wsk^W)YYPgA~RY8T>4dJ=K>a&I+7diYkpkXD$kP>-2U+ zLrQWy597f{*wgpxdo&E&t2f3?!P|5uCuqjqD|FGGV?BFb7=XiS&Y^Kr>Zk4^cla=} z@V3MU=Cz$&` z2s+jijzN*<0t&NKu=^joXV3zYt2n2hO?kTe39s&7o4@VWeq?W1>AIdnBcIBe!7QwC zcu-L=SU;Mev*Z*h`4yIQL&h%38%|+I(*5;`7)WOP&V;toY*L4!_WA>gbomly)m4xC zt@$tzbva72VUwY!;!yk;oJ7i_=W?=)2bt@r-VJfxi|AcTcp_X1y~HfK=x;8o5oPxX zEz=ttVy?6Ddm<`Pde!t@SSe#Q6V@f7;EAbgW)5bgjEt0-@A8~rVbCJLZ{`STr8v8_m#Z|^&}d-!*nLIt5I&27WM@+Yz*xn? zLLCN@Kyva7m(-@=E;2^OEYRm49@lG0%bZ{BvWAJ8l5JaVLCdT@nW_kc-1g}a4W1f% zEaV& z{RC|r&s~{{tL|Z7PCspK{Cb`yQ96&_D1T8a;Bve3_Uz2?@i5N3>(aW(>D*1-6Zwtv zxWlXK3iH|ea+=EfM7nw9EfByQ8N+wzXSmx`ojaeG+sSaIGMaI3T9blq70H_yAdl?x zO8cVAXgc{p^pD4|g6g1DZZmo#(7*uCes`Af6rk&e(Dza`gQUcR!N6fJq+C3S8uVq< zXiBZ!Y%+_5^n%>jAnt`aMY#lImp!RH)HVDG@aUTcmNH}as*NFVOZ@2g^V8V(rg8*U?jsNf_qn2)a0`mPDN9gLt!Ce;iSmo^ocukfY-~mM;VgZxKhmXE&B_X{$aPj*Zfans+Si|clc5o2c)Ha^wB0+q8mgiyXP{Hu-YOr=fk>Odz*GSq zAox(l81r^;yePZn&Y>W@m_lf%#@uRyagb1G1bwgsn0p302xZAWJleexDfMZb%=^)1g7v5 znYL7*H5DROM7wal!w=PIHz;sL2hgp&t#li+TrLp96nlK3W@jg(i3pv9m8jWhxV&*F z{HGI_>DR_K@{@mBrT4O8D)0FBqkT8+8|VtdIJ?`2Sb9T(SLQnqPnO&^_c$aDEV)W= za|vF1t~TIa5qCwa%ujac(6gNi1gONV0}UX&>OSs4AM}>QYWGcSmqy!_f}4s`h6=C5 zva}3+N`RI=^)`Hw4I-Tnh=NlUC#Go4?r77}*M$ZDSmqDsBeBei|8TwrDpq+bzFYPQ zDPa=C#g2D2aj-~9Z{Xt>P#xMy?XfiE>vRZefp?1c(%m!5dzg@I<;AU1ct`6=x?m|# zR09Y1EJ#*T{No*-G7u7tNLS^}2y7Sof`X~Ejqgq==5vKjVQb^>G(8C?wC0->db2+C zgGd?MntjhV>oZ!%!j17g=S`(aqi?MT~t(DW?!t-OsPCeW@w- z93B{{sDE%g1&UV1;%a#tAShfRi*mbpLvu^0O9ZL)Dih zV+OqR#R3&Ki2m-%I9U0yv51AFWL(Qo)hgd?ss#+e2&N%h7ljX z;WyTGttaL@cGY^CHr4xIWk2Eo@Ew;Db^f`h9izP}@uVm%Dq0Ye`tJz9CI;B~ky&C>Xw@&e+1|V_`JrU^Dqz6&E0!%5fhl#z z!uLVscGcjifNt7U z@mmPl{(e#XHyEP&H{)#n++T#@efi%)4YeWumb;Yzlv&&(IM^mDr6gJL&LHUj0sqg< A>Hq)$ literal 0 HcmV?d00001 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); -});