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 e15c344..a3b0caa 100644 Binary files a/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc and b/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc differ diff --git a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc index a3c9719..c3eb33d 100644 Binary files a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc and b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc differ 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 e5ed6bc..eb48fd5 100644 Binary files a/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc and b/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc differ 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 f6d0058..c9668da 100644 Binary files a/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc and b/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc differ 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 0000000..4e873f1 Binary files /dev/null and b/webui/backend/tests/golden/__pycache__/test_api_image_golden.cpython-313.pyc differ 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 5cf52ad..d190463 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_api_info_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_api_info_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index 41012b3..387bf88 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc differ 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 @@ + +