From 018c3dcd94731de17f6ebc22884c5fdf4f8e140d Mon Sep 17 00:00:00 2001 From: kodi Date: Fri, 13 Mar 2026 11:37:27 +0100 Subject: [PATCH] image file info toegevoegd bij CMD+ENTER --- project_docs/IMAGE_VIEWER_AND_INFO_V1.md | 217 ++++++++++++++++++ .../__pycache__/routes_files.cpython-313.pyc | Bin 4866 -> 5270 bytes .../api/__pycache__/schemas.cpython-313.pyc | Bin 9157 -> 9230 bytes webui/backend/app/api/routes_files.py | 13 ++ webui/backend/app/api/schemas.py | 2 + .../filesystem_adapter.cpython-313.pyc | Bin 10554 -> 17353 bytes webui/backend/app/fs/filesystem_adapter.py | 115 ++++++++++ .../file_ops_service.cpython-313.pyc | Bin 21987 -> 23669 bytes .../backend/app/services/file_ops_service.py | 48 ++++ .../test_api_image_golden.cpython-313.pyc | Bin 0 -> 7191 bytes .../test_api_info_golden.cpython-313.pyc | Bin 7977 -> 9192 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 21324 -> 22169 bytes .../tests/golden/test_api_image_golden.py | 102 ++++++++ .../tests/golden/test_api_info_golden.py | 24 ++ .../tests/golden/test_ui_smoke_golden.py | 11 + webui/html/app.js | 146 ++++++++++++ webui/html/base.css | 30 +++ webui/html/index.html | 18 ++ 18 files changed, 726 insertions(+) create mode 100644 project_docs/IMAGE_VIEWER_AND_INFO_V1.md create mode 100644 webui/backend/tests/golden/__pycache__/test_api_image_golden.cpython-313.pyc create mode 100644 webui/backend/tests/golden/test_api_image_golden.py diff --git a/project_docs/IMAGE_VIEWER_AND_INFO_V1.md b/project_docs/IMAGE_VIEWER_AND_INFO_V1.md new file mode 100644 index 0000000..0199f71 --- /dev/null +++ b/project_docs/IMAGE_VIEWER_AND_INFO_V1.md @@ -0,0 +1,217 @@ +# IMAGE_VIEWER_AND_INFO_V1.md + +## 1. Doel + +Een volledige image viewer voegt nu directe waarde toe omdat de app al image-bestanden kan tonen in de lijst, thumbnails kent, en type-specifieke viewers heeft voor tekst, video en PDF. Voor afbeeldingen ontbreekt nog de logische volgende stap: het geselecteerde bestand volledig bekijken zonder download- of externe viewerstap. + +Een kleine uitbreiding van File Info met image-specifieke metadata voegt ook waarde toe. Voor afbeeldingen zijn afmetingen vaak net zo relevant als naam, grootte en modified time. Dat helpt bij snelle selectie, kwaliteitscontrole en onderscheid tussen vergelijkbare bestanden. + +Dit past goed binnen de bestaande dual-pane workflow zolang: +- openen een lichte modalactie blijft +- de browse-flow niet verandert +- de info-uitbreiding read-only en goedkoop blijft + +## 2. Scope + +In scope voor v1: +- volledige image viewer voor: + - `jpg` + - `jpeg` + - `png` + - `webp` + - `gif` + - `bmp` + - `avif` als browser-native rendering zonder extra complexiteit werkt +- aparte image-modal +- read-only +- standaard `fit-to-view` +- basis zoom: + - zoom in + - zoom out + - reset +- File Info uitbreiding met: + - `width` + - `height` + +Niet in scope: +- edit +- crop/rotate +- slideshow +- metadata editor +- EXIF-inspectie als brede feature +- thumbnails in de viewer +- multi-image navigation + +Aanbevolen v1-richting: +- `jpg/jpeg/png/webp/gif/bmp` volwaardig ondersteunen +- `avif` best-effort, zonder extra garanties +- geen extra dependency alleen om `avif` of exotische metadata te forceren + +## 3. Startgedrag + +Aanbevolen v1-gedrag: +- `F3` opent de image viewer bij exact 1 geselecteerd image-bestand +- de bestaande `View`-knop gebruikt dezelfde centrale type-dispatch +- gewone `Enter`-semantiek blijft intact + +Concreet: +- `F3` / `View` dispatch: + - tekst -> text viewer + - video -> video viewer + - pdf -> pdf viewer + - image -> image viewer +- bij geen selectie of multi-select doet `F3` niets als `View` disabled zou zijn +- directory-open gedrag via gewone `Enter` of directorynaam blijft onaangetast + +## 4. Viewer-richting + +Aanbevolen v1-richting: aparte image-modal met browser-native afbeeldingselement (`img`) en lichte frontend-zoom. + +Waarom: +- geen extra dependency nodig +- laag regressierisico +- goed te combineren met bestaande modalarchitectuur +- voldoende voor een bruikbare eerste viewer + +Aanbevolen UX: +- afbeelding centraal in een aparte modal +- standaard `fit-to-view` +- controls: + - `Zoom in` + - `Zoom out` + - `Reset` +- sluiten via: + - `X` + - `Escape` +- overlay-click alleen meenemen als dat geen conflict geeft met zoom/pan-interactie; anders weglaten in v1 + +Pannen/slepen: +- optioneel in v1 +- alleen toevoegen als licht en stabiel +- geen ingewikkelde canvas/viewer-stack bouwen + +Aanbevolen minimalistische v1: +- CSS transform zoom +- centreren zolang mogelijk +- eventueel natuurlijke browser-scroll/pan bij grotere zoom, in plaats van custom drag-logica + +## 5. Backend-impact + +Aanbevolen backendrichting: +- nieuw read-only image endpoint, analoog aan PDF/video, bijvoorbeeld: + - `GET /api/files/image?path=...` + +Waarom een apart endpoint beter is dan hergebruik van random file-serving: +- consistente foutmapping +- duidelijke content-type-afhandeling +- hergebruik van bestaande `path_guard` +- expliciete scheiding van concerns per viewertype + +Eisen: +- padvalidatie via bestaand `path_guard` +- alleen files +- directory -> bestaande `type_conflict` +- path not found -> bestaande not-found fout +- traversal / invalid root alias / outside whitelist -> bestaande securityfouten +- streaming/serving zonder onnodige buffering +- passend `Content-Type` + +Geen nieuwe backendsemantiek nodig buiten een read-only route. + +## 6. Frontend-impact + +Aanbevolen frontendrichting: +- aparte image-modal +- geen hergebruik van text/video/pdf modalbody +- wel dezelfde modalstructuur en focusregels als bestaande viewers + +Waarom een aparte modal: +- image viewing heeft eigen interactie (fit/zoom) +- voorkomt rommelige uitzonderingslogica in de bestaande text viewer +- houdt type-dispatch helder + +Focusgedrag: +- terwijl image-modal open is, geen paneelkeyboardnavigatie +- `Escape` sluit modal +- `F3` en `View` blijven via dezelfde dispatch werken + +## 7. File Info uitbreiding + +Aanbevolen extra velden voor image-bestanden in v1: +- `width` +- `height` +- `content_type` + +Optioneel, maar niet nodig voor v1: +- kleurprofiel +- EXIF orientation +- camera metadata +- creation date uit EXIF + +Aanbevolen aanpak: +- alleen goedkope metadata +- afmetingen server-side afleiden zonder zware analyse +- geen brede EXIF feature + +Voor niet-image bestanden blijven `width` en `height` gewoon `null`. + +## 8. Regressierisico + +Belangrijkste risico's: +- view-dispatch wordt rommeliger als image niet netjes als eigen type wordt behandeld +- modalfocus kan bestaande keyboardflow blokkeren of laten lekken +- grote afbeeldingen kunnen trager laden of veel viewport-ruimte vragen +- File Info response-uitbreiding moet backward-compatible blijven + +Mitigatie: +- aparte image-modal +- eigen `isImageSelection(...)` helper in dezelfde dispatchstijl als video/pdf +- geen wijziging aan gewone `Enter` +- alleen extra velden aan File Info toevoegen, geen bestaande velden wijzigen +- zoom klein en beheersbaar houden + +## 9. Teststrategie + +Backend golden tests: +- image endpoint success voor ondersteund imagebestand +- directory -> `type_conflict` +- path not found +- traversal blocked +- invalid root alias +- non-image blocked of duidelijke unsupported fout +- File Info success voor imagebestand met `width`/`height` +- File Info voor niet-image met `width`/`height = null` + +UI smoke/regressietests: +- image-modal container aanwezig +- image viewer wiring aanwezig in `F3`/`View` dispatch +- text/video/pdf modal containers blijven aanwezig +- File Info modal blijft aanwezig +- geen extra zichtbare knop toegevoegd + +Handmatige validatie: +- `F3` opent image viewer bij exact 1 image +- `View` opent dezelfde image viewer +- zoom in/out/reset werkt +- sluiten via `X` en `Escape` werkt +- gewone `Enter` blijft directory/open-semantiek houden +- File Info toont width/height voor images +- grote afbeelding blijft bruikbaar zonder layoutbreuk + +## 10. Aanbeveling + +Aanbevolen v1-richting met laag regressierisico: +- nieuw read-only image endpoint +- aparte image-modal met browser-native `img` +- lichte zoombediening zonder externe image-viewer library +- `F3` en `View` gebruiken de bestaande centrale type-dispatch +- File Info uitbreiden met alleen goedkope image metadata: + - `width` + - `height` + - bestaand `content_type` +- `avif` alleen best-effort, zonder extra dependency of browsergarantie + +Dit houdt de stap klein, veilig en consistent met de bestaande architectuur: +- viewers blijven type-specifiek +- File Info blijft read-only +- browse- en keyboardflow blijven intact diff --git a/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc b/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc index e15c344280a4aa49f917242e37bd91de1c6a37ca..a3b0caa61ad3e43f2f472796dd33de81f81f1756 100644 GIT binary patch delta 536 zcmZoto2JS4nU|M~0SGvsZO)v*JCRR?eo_qnw zR+%ir6v(JLIgiPgQEl=bCSPH-U@0kvES4-rQ;;b@P^36{0;4#mv?(KmKZ!|NS1niu zO_5TJc#t7P0WujZ8zVS5j!|A*Jy;G+hjO~SrpD$RW`7QLHvP=p#Prn3Gr6)R8}M-O zi4+v279#zTa?c;vNN!OS8@py5G4Tx<^{UNST=eBfdb5Wmi^eUV>#gUAJb?F;;7 zlRLRJCLiEoo4lKQC#wrkUHs-s9xg@&Hz2b}3Z$6{NGK>2$$|vjVMNa4#k>uQ9w06s zkkDi+k_NFkK!gm45C#z*lU?|$jU#Bg?y zFb-{#Z}Q1=gAEq|sZ!a@%rDNU4tC>8hR;AD2C$=VaoFVMr=rB=zAEVsFXWoqRlZ_a?85JhiF$OX!PQCzS zD@~SR3S?BCoX6zLs4{sEldrf+u#^--7E2bRDaaHcC{m0O4>Fvb$R^Az9V0k7j!|A* zHCP5sjZ(U-rrPFR%>EpcIe2%nIs)|tZ9c}!#mMM9`68bvmkY=s5D`E5178E9>*PXy zc_2BFU(3i9BqazWG}($2Kx{V_VRn!(4sDaw1mwBF zssuo)ls5+mh%+j!WcUo^F%-!HiCY{tx%nxjIjMF^qAF7nNmWGw3wx)P1ia`0x%Md&1S^665qVzKD*wcGftgmMm*4O;*fRiljs8yz=nME1jK=dLMmo zS^nwSN3vc`gbif^Q>`+U&lNvoiy5V+-6y0-g3A`zdybDGO z%@>+rg`$PE7dE~vjnYqdzt~6TD#qxPH>R~A71&2f&oa?Q_njdc^YksuDPK#U-jUCD zo5#(Td~ZhXfwmQB2lfIfphJPSx+4ZFJaGN1z~QtsB0!Grc$-C|h^ z-3bw3;c1}x0h5+hR*Ftmf7RyFKN9Ix=Co$nDQRcY?I~O45#w4{aIHW3YxGm_6#zc2b{Hmb zn(6{14)czMU}IEYlRt2Dd*YkbI{YmV-bq;bK={0^{^LTq~|{ zGCH?7;x{Igzt7v9wd`H1^Z8s)O6E4$nV#L!LT>09aslY14R9;3{dvjzNKk(SfAJK^ zGOuF|AYhd#s6m*cpIcWxv|D!1m z#4sYjV;YL>6~lkd-4w49FKAmlpvE0lLf0hlf(GM$F~Y(p@syaPYIB8l3t?cAy3Bdv z7KaPLIK48f#T0!pBkDbRqD~uy5SXHsiLe-@9SOOazw0z2{5|0t;VI>L<00Y!;1+YM nxaM3!K0F_cx0j<7sE&%ew50l}(gBpaCDoO{l>_dM>oGug0m-1*hv&bXNHrEAIAx(y=} z8;-RYmy@yszGs2_0g$?Y3&3_lJ~un+P;N!KqE~3$SOY2gTv9K(IZ~@MtWUTKr z!oWdrD#Ze^s_Rezu$c}8m7(1=esW#+t`&OoX7jkv9hiX!)EEkiJNY=Fw(><7DgkaDR~W@|63B3pQxJhg@`o$M6mR~dRnthgH26g6 zb0wLSX`MT280l<6rcYSO?0MOby#c~r0De*eJg2mMNM*!tTZTTL1xK958dxSH;uF*C zd_VH2;W-SPY9ZGKv45ECA>cR z6+I?qw^JW4_XS=nV@~J#8&nevIrvZcaa^MW;ASFmOmTSoxz5q&@|;+ubnJ*W4_{!F zmSTIwyb4oG_d*&)VcL=jd9m+w(j3TGTd}aS9yo^fRV_dSu{@esDbhJlH&$JXJh9JOg~0d^g-1E{?O73v{6(A|BCb V#Y?*;B2$Ck3e?ZHAf4R(>JQBTJy8Gv diff --git a/webui/backend/app/api/routes_files.py b/webui/backend/app/api/routes_files.py index c25476d..5738ed0 100644 --- a/webui/backend/app/api/routes_files.py +++ b/webui/backend/app/api/routes_files.py @@ -79,6 +79,19 @@ async def pdf( ) +@router.get("/image") +async def image( + path: str, + service: FileOpsService = Depends(get_file_ops_service), +) -> StreamingResponse: + prepared = service.prepare_image_stream(path=path) + return StreamingResponse( + prepared["content"], + headers=prepared["headers"], + media_type=prepared["content_type"], + ) + + @router.get("/thumbnail") async def thumbnail( path: str, diff --git a/webui/backend/app/api/schemas.py b/webui/backend/app/api/schemas.py index 1a7b639..074d014 100644 --- a/webui/backend/app/api/schemas.py +++ b/webui/backend/app/api/schemas.py @@ -92,6 +92,8 @@ class FileInfoResponse(BaseModel): content_type: str | None = None owner: str | None = None group: str | None = None + width: int | None = None + height: int | None = None class SettingsResponse(BaseModel): diff --git a/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc b/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc index e5ed6bc89fb2fb578e0cd4dee4029b340004ee24..eb48fd5a4a3d8d172886f3af003897768ca1c868 100644 GIT binary patch literal 17353 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 delta 2775 zcmaJ?YiwK99lz(ke0^=li5)+Zw25Ov+e_LwDVRb+nx!;I6j4D=Ga*uDocN}>#EE zBQ(qU`Tsuu*E#;$lt(QPyW^qTqzVI6ck z?WY~60xk;N0FAb|PMV>Ew6n!^0hgs+EiQI6PH&(wjmO8uGjDa>}-Q z()H}hbg8)N+3as2uv%=DFB}Z)p(07->Q^%VGTzHaI}LtT{x4rp;{1v-92soImFDEx zEdLIq?`;e4DOC#%ZE9wB@L$NOfnA$?`*;4PO?q}W|6q5wdb>QE<6~wY8RH+{*l*4$ zbLyNnM`ri(FV#57L;PMI)nY1DM*`f^@+8U&+QfjoBuy#<(h{+>!|TMCy;J|uhet=p zM@N0~pcUZ1*Cu7FkB9W9B2hTe4>)dN35w_M>!9{k*Df#l>T1zjW=YVq7(yJO8_h|< z4Zts-3OuNXfpB~H8-X&nj7sFYsL^SDD;Yua-y2$7f#KKGELVjGS5BXY}Tcs!A=Am2GbBM1e^mK2Jm%g zP03-tUR+(Zt0jh0WH%!00I&=;!oLsiON%z)d9DGFX?-C5!;w50;%6d%C*zG;$M^Eu zB>GMu90X`p;j1eS1m6#mBfg5GxXTKPe)wjg;8a#?%qtXp^+d6}!lux98o*avk1-wD zI|W;|t5%4Opa6sW+VQ&0PWoEesW_hBS*@`OztS}ZTQU$EfSsC(jgg7Q@z{qXCY*BM zov2lK(`(W%=P{Po3W>?H75;zEZ57WIsW15ujb%!>LK~GyZyN0Ad%@ z2exgNQLmPr>dF@5ZRuQ6xZ?^oI}th?Iv0VXz_+jEtFa1f$`;p;(lf~tp4@hYCY}qk z@kUQiR1S&0AH)2X`OSTk(6{4#!{k`wwZ1##wbLlaHN*znjMbP63w~|4{h-I{)y1M` zm;AtDt?JoT4>nS4XA)g;McEmIrvUtLrB-s59J^F-+#18t`g+M;td(rbY;FxMfFGe|SWkb_8RbL#4usCz==Q60j~lhcK+x2WIy3{t4nWKJ8y1^cB!qaywm1 z^UV_sE_cDRAN97T`661Y?KId6AYd57RRQn>2>)beA34K6%*?=bB7?V+n@^(^N5H+c zG*K?TVD?J0Xr`29w*pgTU8r2>TU= zc?Us6(SXAAFu%u#a^!`^yF<5;IUM*8o-3pIlwvLa|H*-4aSX!z+PnRpLofb!LaTCL13OS~{OwAR*9w7MTbj8&}5WnfRG zy!vX{o-aGDH(zoVy@z&ZFv3F^AI(K%wDi@(wW@9DOf0StFJEWCehciTUL3V13WZ)wkMFza^4FQa-* zl!@!qEM53CfW6LC?3_6B$1DkZVynYj7*K?M`x+(I17$z#Tei71TgA=X@kA@hXQyvX z?8Pv5BZvoW0V#*SG`)T80+O#HyovB@gm(e_F#N4~&SJ&(mTM)kC4Qn%XbKBO&tuL4 z{4=_R0{Z}caA(*Ug0Ts>s_r7d)6#~V)Xa_1gqGbHNof5WBShP~p%d)}e)PbBTdvBo z=8$Fx^tM9y|Fl&1>dc1t_;j;p5wZyV2q}K#&Ov(p$-Z$1JVo`gy`OyqHuzWX?gIE$ mA%uJ(WxtjppG%#eOD25(D((D2O8-m$sZ1R5Pf7fU1pWtr%~5p# diff --git a/webui/backend/app/fs/filesystem_adapter.py b/webui/backend/app/fs/filesystem_adapter.py index 02fcf30..03ea6e6 100644 --- a/webui/backend/app/fs/filesystem_adapter.py +++ b/webui/backend/app/fs/filesystem_adapter.py @@ -2,6 +2,7 @@ from __future__ import annotations import shutil import mimetypes +import struct import grp import pwd from datetime import datetime, timezone @@ -23,6 +24,7 @@ class FilesystemAdapter: group = None content_type, _ = mimetypes.guess_type(path.name) + width, height = self._image_dimensions(path) if path.is_file() else (None, None) return { "name": path.name, "size": int(stat.st_size) if path.is_file() else None, @@ -31,6 +33,8 @@ class FilesystemAdapter: "group": group, "content_type": content_type, "extension": path.suffix.lower() or None, + "width": width, + "height": height, } def list_directory(self, directory: Path, show_hidden: bool) -> tuple[list[dict], list[dict]]: @@ -159,3 +163,114 @@ class FilesystemAdapter: def modified_iso(path: Path) -> str: stat = path.stat() return datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z") + + def _image_dimensions(self, path: Path) -> tuple[int | None, int | None]: + suffix = path.suffix.lower() + try: + if suffix == ".png": + return self._png_dimensions(path) + if suffix in {".jpg", ".jpeg"}: + return self._jpeg_dimensions(path) + if suffix == ".gif": + return self._gif_dimensions(path) + if suffix == ".bmp": + return self._bmp_dimensions(path) + if suffix == ".webp": + return self._webp_dimensions(path) + if suffix == ".avif": + return self._avif_dimensions(path) + except (OSError, ValueError, struct.error): + return None, None + return None, None + + @staticmethod + def _png_dimensions(path: Path) -> tuple[int | None, int | None]: + with path.open("rb") as handle: + header = handle.read(24) + if len(header) < 24 or header[:8] != b"\x89PNG\r\n\x1a\n": + return None, None + return struct.unpack(">II", header[16:24]) + + @staticmethod + def _jpeg_dimensions(path: Path) -> tuple[int | None, int | None]: + with path.open("rb") as handle: + if handle.read(2) != b"\xff\xd8": + return None, None + while True: + marker_prefix = handle.read(1) + if not marker_prefix: + return None, None + if marker_prefix != b"\xff": + continue + marker = handle.read(1) + while marker == b"\xff": + marker = handle.read(1) + if not marker or marker in {b"\xd8", b"\xd9"}: + return None, None + segment_length_bytes = handle.read(2) + if len(segment_length_bytes) != 2: + return None, None + segment_length = struct.unpack(">H", segment_length_bytes)[0] + if segment_length < 2: + return None, None + if marker in {b"\xc0", b"\xc1", b"\xc2", b"\xc3", b"\xc5", b"\xc6", b"\xc7", b"\xc9", b"\xca", b"\xcb", b"\xcd", b"\xce", b"\xcf"}: + payload = handle.read(5) + if len(payload) != 5: + return None, None + height, width = struct.unpack(">HH", payload[1:5]) + return width, height + handle.seek(segment_length - 2, 1) + + @staticmethod + def _gif_dimensions(path: Path) -> tuple[int | None, int | None]: + with path.open("rb") as handle: + header = handle.read(10) + if len(header) < 10 or header[:6] not in {b"GIF87a", b"GIF89a"}: + return None, None + width, height = struct.unpack(" tuple[int | None, int | None]: + with path.open("rb") as handle: + header = handle.read(26) + if len(header) < 26 or header[:2] != b"BM": + return None, None + width, height = struct.unpack(" tuple[int | None, int | None]: + with path.open("rb") as handle: + header = handle.read(64) + if len(header) < 30 or header[:4] != b"RIFF" or header[8:12] != b"WEBP": + return None, None + chunk = header[12:16] + if chunk == b"VP8 " and len(header) >= 30: + width, height = struct.unpack("= 25: + bits = struct.unpack("> 14) & 0x3FFF) + 1 + return width, height + if chunk == b"VP8X" and len(header) >= 30: + width = 1 + int.from_bytes(header[24:27], "little") + height = 1 + int.from_bytes(header[27:30], "little") + return width, height + return None, None + + @staticmethod + def _avif_dimensions(path: Path) -> tuple[int | None, int | None]: + with path.open("rb") as handle: + data = handle.read(256 * 1024) + if b"ftypavif" not in data and b"ftypavis" not in data: + return None, None + index = data.find(b"ispe") + if index == -1 or index + 20 > len(data): + return None, None + width = int.from_bytes(data[index + 12:index + 16], "big") + height = int.from_bytes(data[index + 16:index + 20], "big") + if width <= 0 or height <= 0: + return None, None + return width, height diff --git a/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc index f6d0058c329d71183fb7b63fc35580dbc57768d1..c9668dace75d8a70825fa2679884f64da27febb9 100644 GIT binary patch delta 5036 zcmb7HeQXrh5x>3LJD=~)pJ!wH&e(T0#w`9I#SpN;CbqG`!8YIp3gqK*+^y|(?n`gi z#y&_cq*2wDekCuJ_@gQ6R~iB|&_hYus#O!IshTEgqo@+veu%14N&2Nwk|?54+nL$3 zeKsJPv-R8Ad2i;;%$s>{_RnX94_+3EuNM`$EbtTll2tDrda}4$xbkH4bKPFqD_6*s za+O>y*T}y10#+^i>n7CkdGbg+m3Eoj@QgYx6|i#D=0&JLs02uh z?I(_vgQx;IAP41w++3&JAUla)C^yQ5M7coQD%q8r;g*|Z zH}O3{d4S4@tO&>=ViwSC!mA*j zcbjjd?6@ltWcs4U4s&w#_mQ#+jy6&SMc-Wt|_6GUvOV1gtVMNw5t#lmwTO`nS?Y%R*RAhaT&VXPg% zaKP%}xWYETi{vm(-9!Uga+*aH!yQ#LT}^~_HJLEwHluJSKUv;6Or9OW_rnOJ7R_f{ z5xNn25ViqGRm=|$qmn6FGB%@xlvx!uPnf><@=|XgGk`N3W=z1EC+G z7Qk?u=9?;ZB6|n{i{2>1D2*od!Q^xzI>1wa_>?%^<;i>xF*q^Vh12Q7OQQ6oLp+ ziy^S*g@7DH-^feN4iq_P`ZfixH~AJ-Vv4SOGke-UY8O=g&wzi4hv}Aj-VywK>2eqN z8^N_>lrqhz)e}i3bSwpZE~T(pv|tV)jc^R%IDoX=X%@$!#k6>s?`+rt74}5Ky2OHq z%!89~8900ww%c%yCs{~|s=B$YkHEN5kYdV=svKpszZkOI{!#!85yuSgvfV}Iud~6% z4+K{UNZ8=Uxqfdp(6mX&?6=7-3?e+;_>?`Jo>dm9Xq3z`3ZZh9h@NC~hDckt;I2`q z%tAagtUI#Io;;dRm|>q_$?23~KdMIcNy9Ozs1uXAv?t+Y!Peyr2S6U~p;UM}~Y+Voa=<7Kb^)XulOP1kCQN?q?+ ziV9t$1T%r~Nk3 z>sjCA&_G+djqeK9^UtlH?EDnkRWPZ9!6nVi4>VOOy;D@U)Y0I;{9=#sy&ZMkBS=y_ z$~2uR;W(W|Ar?+dDEX(X;UQMYTryXB{9K3Mho-PI05_eh{7)S{!hPAmhHnc(Q}&sz zkkxm~^Z?m(BhR$>O7+R<_>n|djWP0f4f@-=$b1fF!qtq6<@sEEN1uPOIXFm;o<&L1 zd!)#3cb?y8me&R?GhY^1744ma!WXjBoz3$u-FDCFJ=8s#p0uJT8Da$R+<_aEp*QSB z0O^h%z$Bw8MB<_R6E_5PP8TA(g=g$#T;PWQIS1-kAT;qU+xmmw1CP&GBF32O=+u^N61%wP3={Pea4;UE0fZH*xD z{oWe>-)(OSKK|xCjY2cOc8?^qaer@(gIc#oc~@_faE(9IJ3^DwFj4CB2VTe6zKQTF z1Z)8`I4mt@sk7&Kw6Def5pb^ar~8@~L^t-=-%0c(uJ^AKKIY%)mrSvY5A;f+;T;<3 z-7ygA-#xl-V02$--=RGNGP{EvKLJr=^|FiaHi=|`AhEYWfHeU`&>Q;kANR()ayFFh z**^HmEO>=KzFo44R(^i_nsQVO4eKFzj|kYs zN>;WK9?*Kgaiv2?n$X!C+=SF2{GU2*)ucOiqSiY8*p51@)5fK^3CB-4L&n` z1+5Sn&S&co?m`$st|QM3YB=*;g>emA9`jN#TeDsK*bd1WwDMVm^qRGP(EvJ#?I`_o1dKIQ$oTMxu$5mL4z55( z+Hqt~SF~?c2=DXJk-MaeNd5qUu09t~rk9AOr{{rZH1PCrgV2?IedG}#gQqWTrFni+hB;V0 za!U{b2*n6NfOPdv`0{|3YfMQcH5IAA@xW*@p+L|_aTc8%*e;sXM@SU{z#);Z0k&;MU*P1FUE{%6%`zXj#3pTn59V+ z#f2y;LZYY=iQ0^Mi9(MIk#@slevgt$DgkQqZ&toL*Bvg%GY1-HH4P>69cH4DD#|uW zN?a>lqEDw{%6)1=Zw&xEAo=-Id+*LPqD37D2N9+aK8x@)!fAvo!jBMsits9c;lZ|9 zjX)nfnT(oWuaDy3^9U~?;6ch>L7=kVg%nmiI}cz~hTxMOH1O`RlDfR=!wFUMfZvMR zclp%Vsug8+*ZZrt*j*o{g*G4`6zT!{_&3JJ-CM8QEROQ`tb%pCt z?WkRuBmMIE`_A{B?|IJg?d#;-XG!Tt%a*wW_;mc`1+_%JP+CiFUTAw-^2xPwUAK+a z%Jp)?8ba%8g%&|>tQX|v8*I2<5NQ2mlhmB?kgKGA&e44$H96-5R3_m|Dv4iCE5}kS zU_Y3iBZBa4;fO$qEKr+Fs9mlD!}faNgyRIE4!J>gAWfZeqwM6AC@+^qP8G>bas#&XzyXM+KeC(gD6Qq{K-3jm!HBwQkB#lTaY8IWp=;oaBwDVm=K@`urZL5I#}(aBlTky}l9pZ%DsN!L-rhAllKuGo00K9Q z?$XT&y$F2>TL2^<^|4dl2C|KP-Rtk)0W4FrqNOq}+KzBP!cGJc;XZ^~0Ml(bY9(d> z*@Ntsw=q48Fj(}IXRqptg2gd=cShYwtm|f=9I2Q!0x9T5gtT1h;Rtu zPI;=NJ`^2B;8`kC^~ePD8ihV-Sa%Y0V%Ef!gkmTWWh!ONMxfkE%+Tm8wK9XMMF>1Q z5u|e2apq-*2a9p?h;=7WJXkBv2}T<0$;C`#W5`Jqb~4aV5aWfw)8$J8U?VN7#&{nD z!ItTXX-Nb7*_cf!)WD6V5oQohBb)({mWE7K6y-W#lD*Qh1;&H~SBDn^p&nd>+ek&Z zkj&xz3dyanS&1#vF|Jsxq&)@3U7cxL*>X z9f6m16}u2n~u9A%j#wj{Op9HwmNoH2D8$DI*YR?LBABOr}9%^Vq2O)vXzwe9K?h^6)n+MvZMaij$ffrT`CM;ch{yQ@)U<*}iWkzY8&ru-@zZaK!6!cB=mjd$RAxpB;d9)y#y>^kMfUowUPmLWHe~|9bsP{9kD#M=X=858GZbLx6lEHb*Fa#O*;_=kt^nT zz#l7lQG#5@yEA`3dj$#6<7_MUB&>ut-zS7^bb>WuP zZv$D-NVu=*TXLgBlG^Ers%uX6mGCgk-0R_2ZOu7f`RU_Cqd!H!-C$6!j_oBL_TRBU z4Kn7$W3z^$Z!<|JeF7Ie#x}_9TfPM(o@7)_!M{`jE_8lG{1Ex}DK%-j4nz~v${tA-*FRde zI9(slkuD&=z&<@V!RVpe9zK<*5t161e)zpTyte!~^)lM|Il`|HUPE{Tfp3sE)4(%} mx5B$?I-zW*B7s*lu=LC9xrffOT}PVA28&6@hXNu$1^x&Bqr+$b diff --git a/webui/backend/app/services/file_ops_service.py b/webui/backend/app/services/file_ops_service.py index b211e2a..03bf372 100644 --- a/webui/backend/app/services/file_ops_service.py +++ b/webui/backend/app/services/file_ops_service.py @@ -32,6 +32,15 @@ THUMBNAIL_CONTENT_TYPES = { ".png": "image/png", ".webp": "image/webp", } +IMAGE_CONTENT_TYPES = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + ".gif": "image/gif", + ".bmp": "image/bmp", + ".avif": "image/avif", +} VIDEO_CONTENT_TYPES = { ".mp4": "video/mp4", ".mkv": "video/x-matroska", @@ -270,6 +279,8 @@ class FileOpsService: content_type=metadata["content_type"], owner=metadata["owner"], group=metadata["group"], + width=metadata["width"], + height=metadata["height"], ) def save(self, path: str, content: str, expected_modified: str) -> SaveResponse: @@ -413,6 +424,39 @@ class FileOpsService: "content": self._filesystem.stream_file(resolved_target.absolute), } + def prepare_image_stream(self, path: str) -> dict: + resolved_target = self._path_guard.resolve_existing_path(path) + + if resolved_target.absolute.is_dir(): + raise AppError( + code="type_conflict", + message="Source must be a file", + status_code=409, + details={"path": resolved_target.relative}, + ) + if not resolved_target.absolute.is_file(): + raise AppError( + code="type_conflict", + message="Unsupported path type for image", + status_code=409, + details={"path": resolved_target.relative}, + ) + + content_type = self._image_content_type_for(resolved_target.absolute) + if content_type is None: + raise AppError( + code="unsupported_type", + message="File type is not supported for image viewing", + status_code=409, + details={"path": resolved_target.relative}, + ) + + return { + "headers": {"Content-Length": str(int(resolved_target.absolute.stat().st_size))}, + "content_type": content_type, + "content": self._filesystem.stream_file(resolved_target.absolute), + } + def prepare_pdf_stream(self, path: str) -> dict: resolved_target = self._path_guard.resolve_existing_path(path) @@ -465,6 +509,10 @@ class FileOpsService: def _thumbnail_content_type_for(path: Path) -> str | None: return THUMBNAIL_CONTENT_TYPES.get(path.suffix.lower()) + @staticmethod + def _image_content_type_for(path: Path) -> str | None: + return IMAGE_CONTENT_TYPES.get(path.suffix.lower()) + @staticmethod def _pdf_content_type_for(path: Path) -> str | None: return PDF_CONTENT_TYPES.get(path.suffix.lower()) diff --git a/webui/backend/tests/golden/__pycache__/test_api_image_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_image_golden.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4e873f1b41c3525370fc0b923da39fba0150ee12 GIT binary patch literal 7191 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 literal 0 HcmV?d00001 diff --git a/webui/backend/tests/golden/__pycache__/test_api_info_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_info_golden.cpython-313.pyc index 5cf52aded80bf306fb92f8eae2422ccdb4f50201..d190463b5e9f066da65b11cfaff5635a9ae85624 100644 GIT binary patch delta 1854 zcmah|O>7%Q6rNe{ukBsias2Q6kfJ6|NK==z3Z*0@X`0q1Y3xnXAKb2-dTYCN>~wb1 zA4{l!1c#~|ssYght$N_l0tZ@)3nzqx5GODSYUPS@09Eu>sYYCxS+^k(2uAYfZ{EE3 z_RV|W+#3CTT6rSNc0krU4{OD*y;qbVynLm1IL;(_#H&EHC542D#BM-1la!E@Vbkr@ zfAdNQaNX_ofsC^UWJL0lcfyuOx&x<#Fq7`24p;6+Uv?)v$YbT>lWhqv@>*O-wkLeZ zXK^vdf%z5%M7~J_tomN2XJi zcOM2I+XetR86|;Ha)&&VXJQJz3|Eg=GsVS;gXweIlhgUiI9apmLSbqlPw5neiOKN; zm#623t0UAhGclgpn;2So?JV7mPnGt~pH^2>%afBBGX9I_ zNdrCr8GxV)5Tin5Qx%m7gHtSG3A0&@Q(Y=+F`n2siwT7B783~*EG7{qTFg$EWHAR} zc8fU)BW)m=Wz~t4yc}~kpby^`e9WV8?PKOU20n+&+(Q_tE$~$cx7L0TK4joPtw5Ry z`|)+h7`$G4;5f*z3n1;nXJnt(&yo6{wYnRu+XI{Jc@PWYxbmr|!JRAS%tC`N=*4`& zjEVSx@~W%h%o>J{%mc=3rL5n=U9R)|4wBf|QM=}H!q}SFdrx%U6+?9~bhYif zW-r9QI7WomS&{$%+}XCptuoIVTxLjvB^>wopq;)HeAJ^rnLZu9Y_pEYOz?FN@8Whc zRsvwnM543u@$|~-znokYtP}C?7&0$uwkoX@&+DaaE9HE(%`ht{o7dw*>ozn=(pl|# zL|?JhGjxEks#IJiu0tg5X%8EYbEs(Q+R_D6HyXm+?6ej?9A9thzKva7AQ=pL`);yO zgpkJbB+AsRq*}3jvZ58ThGywV%a(ImOG=I1M9IOv|92!W9vJW8FC$Uzqx_n<72l6+ z#gAP67X$TB>}GsT9JIz2tc$^`fg8bksQ>2pnz(JFv9li9{yEXxDAgP8^54O%Z!EA` z9=Dj5G7jORz9TS);~{VDTVF4X<;nL^f!-^yk~IsOiLz&PWMoTPPB--vrk+EmsLN>_ z2x#z>6?iESg^mifwxaRvV32?6^?fi4S8DOl9SEKHw{UN2UBM_pBlc6EbBT#9K`&9m zWhHhG_Zm_5I3r5V=Dt?i=k{d*h!n62`$TBa>${(B?_eEcUE_^aN)pdk=9H)?@ zP@-^#!YYL-{wBJ8jJ^7 z=%F}IgfmGTkW2&Kpf+|nTP&lu>AsJGr7&^NawWG~(nrzz#75<8TqE$<1|j?z^!y5x z`#{E@bj}Ps=7Ga=*B<`S9=^veJ+Uzk=1rk#Hz2fM-G4VaSdR`i0o^?=VYw^%7lBf} AasU7T delta 998 zcmZ9LOK1~87=Y*Bcau$;=G8Sx+oVzJg3_WEYtX8QSQBhD8^uR(yJ}jaeZXw0wihqf zgSRlg@E~|BQbD%|=|Q{-UV0EAP&_E2=)vNOA|9OCc+rLZ_S>2N|7ZW**^dVo27})M z0Ux5r^*c|i^-w*i!Dzi_dyFqgc^S)TWFUhpC|D`;#V}csBM>8NY9xlr{%%y3>8bVu z%SWh~Bv0f7S4@$IozeRHQO}h^EFZ^l&sz%Zc@1l6 zq@iwP_!7wQm&M{%S~IeaZ?1q7<)oqJP9S835u&pL($0D1E(aT&-@bl;^<=BMha2WdrZeX( zs!>iDM|zyZS|4$`OjcU=f=<$*tI}rLgRMFbLMrgDl^(LJC1HuI#dea%amDEi*MKFe zHItfUOC3)Ed}O?_PL#cco%FKH;PiK*tPu@{W<|QF+{qkrDZcUmu!_3hp&%(`564}}qHpWo(3{kjmDCm&vAeHBUgT)hlL;Dc#_kt3*jS zZx5nzqA57e2T__RCYq^eQ&y&9w#qz;5~wI8U4uvbB2rBM{smjvt)$}tMNMXLM9rs@ zxq#7Jb0!r$g^LkI%?1o_%{KCRHKJ&d@sbuWTK@C%6Qye4sRc!><6W1lxP^2kk|`!t z957d#=jza++1*~Y)rD6&x7a8=ZTG?SK^MFcT=3EFf@QzUT=T!C{oBCR&_*4L~Ojlz{s5PnH@f=CxJZFCBnMWY@ zej`R#UkEWU7V4m{%M29yg1c4Dsw%E(u}AEnHJN!B3nDu)3v!h=?fc2-Y5Q|NcrmMqglZEUU<52N2Dd z17=^)7ufukFNA6GS3#A@CPoki5m34YN-qG?dx7#GLnMU+z_cxdW=x!XPDoiWDU{ii z31l+^g93v-LvZqBFX48^l+Blf8<;j966FKFc}9XlWTwQJHF=_$GIMq)+vM}=ip)8o>>!#kce0|nGIL%i=j41dWkxQr zTtO%|SgvsLMssDxqRCgybvcWJOM*)+nezB1KQvclEep$cS`16k1)vWyjz zF9<2Hgz^Vhf*3-f0>M>?AV_D>tloUy;w2lS>g0*elAG%sHgU0c1N~Qgm1DEH_dVv# v`Tk2;If@xs&A(?cyKI&T^=1@vU}1D+oWXHjLi3`8<`)Jq_k-Ky+AtXafm4m| diff --git a/webui/backend/tests/golden/test_api_image_golden.py b/webui/backend/tests/golden/test_api_image_golden.py new file mode 100644 index 0000000..905390e --- /dev/null +++ b/webui/backend/tests/golden/test_api_image_golden.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import asyncio +import sys +import tempfile +import unittest +from pathlib import Path + +import httpx + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from backend.app.dependencies import get_file_ops_service +from backend.app.fs.filesystem_adapter import FilesystemAdapter +from backend.app.main import app +from backend.app.security.path_guard import PathGuard +from backend.app.services.file_ops_service import FileOpsService + + +PNG_1X1 = ( + b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR" + b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00" + b"\x90wS\xde" + b"\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0\x00\x00\x03\x01\x01\x00" + b"\xc9\xfe\x92\xef" + b"\x00\x00\x00\x00IEND\xaeB`\x82" +) + + +class ImageApiGoldenTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) / "root" + self.root.mkdir(parents=True, exist_ok=True) + path_guard = PathGuard({"storage1": str(self.root)}) + service = FileOpsService(path_guard=path_guard, filesystem=FilesystemAdapter()) + + async def _override_file_ops_service() -> FileOpsService: + return service + + app.dependency_overrides[get_file_ops_service] = _override_file_ops_service + + def tearDown(self) -> None: + app.dependency_overrides.clear() + self.temp_dir.cleanup() + + def _request(self, path: str) -> httpx.Response: + async def _run() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + return await client.get("/api/files/image", params={"path": path}) + + return asyncio.run(_run()) + + def test_image_endpoint_success(self) -> None: + (self.root / "sample.png").write_bytes(PNG_1X1) + + response = self._request("storage1/sample.png") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["content-type"], "image/png") + self.assertEqual(response.headers["content-length"], str(len(PNG_1X1))) + self.assertEqual(response.content, PNG_1X1) + + def test_image_directory_type_conflict(self) -> None: + (self.root / "images").mkdir() + + response = self._request("storage1/images") + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "type_conflict") + + def test_image_path_not_found(self) -> None: + response = self._request("storage1/missing.png") + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["error"]["code"], "path_not_found") + + def test_image_traversal_blocked(self) -> None: + response = self._request("storage1/../etc/passwd") + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"]["code"], "path_traversal_detected") + + def test_image_invalid_root_alias(self) -> None: + response = self._request("unknown/sample.png") + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"]["code"], "invalid_root_alias") + + def test_image_non_image_blocked(self) -> None: + (self.root / "notes.txt").write_text("hello", encoding="utf-8") + + response = self._request("storage1/notes.txt") + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "unsupported_type") + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/backend/tests/golden/test_api_info_golden.py b/webui/backend/tests/golden/test_api_info_golden.py index 9071e19..fb9e39b 100644 --- a/webui/backend/tests/golden/test_api_info_golden.py +++ b/webui/backend/tests/golden/test_api_info_golden.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import base64 import sys import tempfile import unittest @@ -17,6 +18,11 @@ from backend.app.security.path_guard import PathGuard from backend.app.services.file_ops_service import FileOpsService +PNG_1X1 = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC" +) + + class FileInfoApiGoldenTest(unittest.TestCase): def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() @@ -59,6 +65,8 @@ class FileInfoApiGoldenTest(unittest.TestCase): self.assertIn("modified", payload) self.assertIn("owner", payload) self.assertIn("group", payload) + self.assertIsNone(payload["width"]) + self.assertIsNone(payload["height"]) def test_directory_info_success(self) -> None: directory = self.root / "Media" @@ -74,6 +82,20 @@ class FileInfoApiGoldenTest(unittest.TestCase): self.assertIsNone(payload["size"]) self.assertEqual(payload["root"], "storage1") self.assertIsNone(payload["extension"]) + self.assertIsNone(payload["width"]) + self.assertIsNone(payload["height"]) + + def test_image_info_has_width_and_height(self) -> None: + file_path = self.root / "pixel.png" + file_path.write_bytes(PNG_1X1) + + response = self._request("storage1/pixel.png") + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["width"], 1) + self.assertEqual(payload["height"], 1) + self.assertEqual(payload["content_type"], "image/png") def test_info_path_not_found(self) -> None: response = self._request("storage1/missing.txt") @@ -113,6 +135,8 @@ class FileInfoApiGoldenTest(unittest.TestCase): "content_type", "owner", "group", + "width", + "height", }, ) diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 8773702..bd7f840 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -62,6 +62,11 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="viewer-modal"', body) self.assertIn('id="video-modal"', body) self.assertIn('id="pdf-modal"', body) + self.assertIn('id="image-modal"', body) + self.assertIn('id="image-viewer-img"', body) + self.assertIn('id="image-zoom-in-btn"', body) + self.assertIn('id="image-zoom-out-btn"', body) + self.assertIn('id="image-reset-btn"', body) self.assertIn('id="pdf-frame"', body) self.assertIn('id="pdf-close-btn"', body) self.assertIn('id="video-player"', body) @@ -192,6 +197,12 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function openSearch()', app_js) self.assertIn('async function submitSearch()', app_js) self.assertIn('async function openInfo()', app_js) + self.assertIn('function imageElements()', app_js) + self.assertIn('function isImageSelection(item)', app_js) + self.assertIn('async function openImageViewer()', app_js) + self.assertIn('function isImageOpen()', app_js) + self.assertIn("`/api/files/image?", app_js) + self.assertIn('if (isImageSelection(selected)) {', app_js) self.assertIn('document.getElementById("info-modal")', app_js) self.assertIn("`/api/files/info?", app_js) self.assertIn('document.getElementById("search-input")', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index 8bcd5f5..1fc8e6a 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -51,6 +51,12 @@ let batchMoveState = { destinationBase: "", count: 0, }; +let imageViewerState = { + scale: 1, + fitScale: 1, + path: null, + resizeHandler: null, +}; let settingsState = { activeTab: "general", logsLoaded: false, @@ -203,6 +209,22 @@ function pdfElements() { }; } +function imageElements() { + return { + overlay: document.getElementById("image-modal"), + title: document.getElementById("image-title"), + fileName: document.getElementById("image-file-name"), + filePath: document.getElementById("image-file-path"), + error: document.getElementById("image-error"), + viewport: document.getElementById("image-viewport"), + image: document.getElementById("image-viewer-img"), + closeButton: document.getElementById("image-close-btn"), + zoomInButton: document.getElementById("image-zoom-in-btn"), + zoomOutButton: document.getElementById("image-zoom-out-btn"), + resetButton: document.getElementById("image-reset-btn"), + }; +} + function moveElements() { return { overlay: document.getElementById("move-popup"), @@ -655,6 +677,32 @@ function isPdfSelection(item) { return (item.name || "").toLowerCase().endsWith(".pdf"); } +function isImageSelection(item) { + if (!item || item.kind !== "file") { + return false; + } + const lower = (item.name || "").toLowerCase(); + return [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".avif"].some((suffix) => lower.endsWith(suffix)); +} + +function currentImageScale() { + return Number.isFinite(imageViewerState.scale) ? imageViewerState.scale : 1; +} + +function applyImageScale() { + const image = imageElements().image; + image.style.transform = `scale(${currentImageScale()})`; +} + +function resetImageViewerState() { + imageViewerState = { + scale: 1, + fitScale: 1, + path: null, + resizeHandler: null, + }; +} + function currentParentPath(path) { const normalized = (path || "").trim(); if (!normalized) { @@ -1771,6 +1819,55 @@ function closePdfViewer() { pdf.frame.removeAttribute("src"); } +function isImageOpen() { + return !imageElements().overlay.classList.contains("hidden"); +} + +function fitImageToViewport() { + const image = imageElements().image; + const viewport = imageElements().viewport; + if (!image.naturalWidth || !image.naturalHeight) { + return; + } + const widthScale = viewport.clientWidth / image.naturalWidth; + const heightScale = viewport.clientHeight / image.naturalHeight; + imageViewerState.fitScale = Math.min(widthScale, heightScale, 1); + imageViewerState.scale = imageViewerState.fitScale; + applyImageScale(); +} + +function adjustImageZoom(multiplier) { + if (!isImageOpen()) { + return; + } + const minScale = Math.max(imageViewerState.fitScale * 0.5, 0.1); + const maxScale = Math.max(imageViewerState.fitScale * 6, 1.5); + imageViewerState.scale = Math.min(maxScale, Math.max(minScale, currentImageScale() * multiplier)); + applyImageScale(); +} + +function resetImageZoom() { + if (!isImageOpen()) { + return; + } + imageViewerState.scale = imageViewerState.fitScale; + applyImageScale(); +} + +function closeImageViewer() { + const image = imageElements(); + image.overlay.classList.add("hidden"); + image.error.textContent = ""; + image.image.removeAttribute("src"); + image.image.removeAttribute("alt"); + image.image.onload = null; + image.image.onerror = null; + if (imageViewerState.resizeHandler) { + window.removeEventListener("resize", imageViewerState.resizeHandler); + } + resetImageViewerState(); +} + function isInfoOpen() { return !infoElements().overlay.classList.contains("hidden"); } @@ -1815,6 +1912,8 @@ async function openInfo() { renderInfoField("Content type", data.content_type); renderInfoField("Owner", data.owner); renderInfoField("Group", data.group); + renderInfoField("Width", data.width); + renderInfoField("Height", data.height); } catch (err) { elements.error.textContent = err.message; } @@ -2116,6 +2215,36 @@ async function openPdfViewer() { pdf.frame.src = pdfUrl; } +async function openImageViewer() { + const selectedItems = activePaneState().selectedItems; + if (selectedItems.length !== 1 || !isImageSelection(selectedItems[0])) { + return; + } + const selected = selectedItems[0]; + const image = imageElements(); + const imageUrl = `/api/files/image?${new URLSearchParams({ path: selected.path }).toString()}`; + + closeImageViewer(); + image.overlay.classList.remove("hidden"); + image.title.textContent = "Image"; + image.fileName.textContent = selected.name; + image.filePath.textContent = selected.path; + image.error.textContent = ""; + image.image.alt = selected.name; + image.image.onload = () => { + fitImageToViewport(); + image.image.onload = null; + }; + image.image.onerror = () => { + image.error.textContent = "Image could not be displayed in this browser."; + image.image.onerror = null; + }; + imageViewerState.path = selected.path; + imageViewerState.resizeHandler = () => fitImageToViewport(); + window.addEventListener("resize", imageViewerState.resizeHandler); + image.image.src = imageUrl; +} + function videoPlaybackMessage(item) { const lower = (item.name || "").toLowerCase(); if (lower.endsWith(".mkv")) { @@ -2179,6 +2308,10 @@ function openViewer() { return; } const selected = selectedItems[0]; + if (isImageSelection(selected)) { + openImageViewer(); + return; + } if (isVideoSelection(selected)) { openVideoViewer(); return; @@ -2369,6 +2502,13 @@ function handleKeyboardShortcuts(event) { } return; } + if (isImageOpen()) { + if (event.key === "Escape") { + event.preventDefault(); + closeImageViewer(); + } + return; + } if (isVideoOpen()) { if (event.key === "Escape") { event.preventDefault(); @@ -2553,6 +2693,12 @@ function setupEvents() { } }; + const image = imageElements(); + image.closeButton.onclick = closeImageViewer; + image.zoomInButton.onclick = () => adjustImageZoom(1.2); + image.zoomOutButton.onclick = () => adjustImageZoom(1 / 1.2); + image.resetButton.onclick = resetImageZoom; + const search = searchElements(); search.closeButton.onclick = closeSearch; search.overlay.onclick = (event) => { diff --git a/webui/html/base.css b/webui/html/base.css index b2ec52d..b35ea41 100644 --- a/webui/html/base.css +++ b/webui/html/base.css @@ -626,6 +626,36 @@ button:disabled { border: 1px solid var(--color-border); } +.image-card { + width: min(1100px, calc(100vw - 28px)); +} + +.image-toolbar { + display: flex; + gap: 8px; + margin: 8px 0 8px 0; +} + +.image-viewport { + display: flex; + align-items: center; + justify-content: center; + min-height: 420px; + height: calc(100vh - 240px); + overflow: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--color-surface) 88%, black 12%); +} + +.image-viewer-img { + max-width: none; + max-height: none; + transform-origin: center center; + transition: transform 120ms ease; + user-select: none; +} + .search-card { width: min(680px, calc(100vw - 32px)); } diff --git a/webui/html/index.html b/webui/html/index.html index 2517529..b507507 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -249,6 +249,24 @@ + +