image file info toegevoegd bij CMD+ENTER
This commit is contained in:
@@ -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
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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")
|
@router.get("/thumbnail")
|
||||||
async def thumbnail(
|
async def thumbnail(
|
||||||
path: str,
|
path: str,
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ class FileInfoResponse(BaseModel):
|
|||||||
content_type: str | None = None
|
content_type: str | None = None
|
||||||
owner: str | None = None
|
owner: str | None = None
|
||||||
group: str | None = None
|
group: str | None = None
|
||||||
|
width: int | None = None
|
||||||
|
height: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class SettingsResponse(BaseModel):
|
class SettingsResponse(BaseModel):
|
||||||
|
|||||||
Binary file not shown.
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import struct
|
||||||
import grp
|
import grp
|
||||||
import pwd
|
import pwd
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -23,6 +24,7 @@ class FilesystemAdapter:
|
|||||||
group = None
|
group = None
|
||||||
|
|
||||||
content_type, _ = mimetypes.guess_type(path.name)
|
content_type, _ = mimetypes.guess_type(path.name)
|
||||||
|
width, height = self._image_dimensions(path) if path.is_file() else (None, None)
|
||||||
return {
|
return {
|
||||||
"name": path.name,
|
"name": path.name,
|
||||||
"size": int(stat.st_size) if path.is_file() else None,
|
"size": int(stat.st_size) if path.is_file() else None,
|
||||||
@@ -31,6 +33,8 @@ class FilesystemAdapter:
|
|||||||
"group": group,
|
"group": group,
|
||||||
"content_type": content_type,
|
"content_type": content_type,
|
||||||
"extension": path.suffix.lower() or None,
|
"extension": path.suffix.lower() or None,
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
}
|
}
|
||||||
|
|
||||||
def list_directory(self, directory: Path, show_hidden: bool) -> tuple[list[dict], list[dict]]:
|
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:
|
def modified_iso(path: Path) -> str:
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
return datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
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("<HH", header[6:10])
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _bmp_dimensions(path: Path) -> 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("<ii", header[18:26])
|
||||||
|
return abs(width), abs(height)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _webp_dimensions(path: Path) -> 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("<HH", header[26:30])
|
||||||
|
return width & 0x3FFF, height & 0x3FFF
|
||||||
|
if chunk == b"VP8L" and len(header) >= 25:
|
||||||
|
bits = struct.unpack("<I", header[21:25])[0]
|
||||||
|
width = (bits & 0x3FFF) + 1
|
||||||
|
height = ((bits >> 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
|
||||||
|
|||||||
Binary file not shown.
@@ -32,6 +32,15 @@ THUMBNAIL_CONTENT_TYPES = {
|
|||||||
".png": "image/png",
|
".png": "image/png",
|
||||||
".webp": "image/webp",
|
".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 = {
|
VIDEO_CONTENT_TYPES = {
|
||||||
".mp4": "video/mp4",
|
".mp4": "video/mp4",
|
||||||
".mkv": "video/x-matroska",
|
".mkv": "video/x-matroska",
|
||||||
@@ -270,6 +279,8 @@ class FileOpsService:
|
|||||||
content_type=metadata["content_type"],
|
content_type=metadata["content_type"],
|
||||||
owner=metadata["owner"],
|
owner=metadata["owner"],
|
||||||
group=metadata["group"],
|
group=metadata["group"],
|
||||||
|
width=metadata["width"],
|
||||||
|
height=metadata["height"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def save(self, path: str, content: str, expected_modified: str) -> SaveResponse:
|
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),
|
"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:
|
def prepare_pdf_stream(self, path: str) -> dict:
|
||||||
resolved_target = self._path_guard.resolve_existing_path(path)
|
resolved_target = self._path_guard.resolve_existing_path(path)
|
||||||
|
|
||||||
@@ -465,6 +509,10 @@ class FileOpsService:
|
|||||||
def _thumbnail_content_type_for(path: Path) -> str | None:
|
def _thumbnail_content_type_for(path: Path) -> str | None:
|
||||||
return THUMBNAIL_CONTENT_TYPES.get(path.suffix.lower())
|
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
|
@staticmethod
|
||||||
def _pdf_content_type_for(path: Path) -> str | None:
|
def _pdf_content_type_for(path: Path) -> str | None:
|
||||||
return PDF_CONTENT_TYPES.get(path.suffix.lower())
|
return PDF_CONTENT_TYPES.get(path.suffix.lower())
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
@@ -17,6 +18,11 @@ from backend.app.security.path_guard import PathGuard
|
|||||||
from backend.app.services.file_ops_service import FileOpsService
|
from backend.app.services.file_ops_service import FileOpsService
|
||||||
|
|
||||||
|
|
||||||
|
PNG_1X1 = base64.b64decode(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FileInfoApiGoldenTest(unittest.TestCase):
|
class FileInfoApiGoldenTest(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.temp_dir = tempfile.TemporaryDirectory()
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
@@ -59,6 +65,8 @@ class FileInfoApiGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn("modified", payload)
|
self.assertIn("modified", payload)
|
||||||
self.assertIn("owner", payload)
|
self.assertIn("owner", payload)
|
||||||
self.assertIn("group", payload)
|
self.assertIn("group", payload)
|
||||||
|
self.assertIsNone(payload["width"])
|
||||||
|
self.assertIsNone(payload["height"])
|
||||||
|
|
||||||
def test_directory_info_success(self) -> None:
|
def test_directory_info_success(self) -> None:
|
||||||
directory = self.root / "Media"
|
directory = self.root / "Media"
|
||||||
@@ -74,6 +82,20 @@ class FileInfoApiGoldenTest(unittest.TestCase):
|
|||||||
self.assertIsNone(payload["size"])
|
self.assertIsNone(payload["size"])
|
||||||
self.assertEqual(payload["root"], "storage1")
|
self.assertEqual(payload["root"], "storage1")
|
||||||
self.assertIsNone(payload["extension"])
|
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:
|
def test_info_path_not_found(self) -> None:
|
||||||
response = self._request("storage1/missing.txt")
|
response = self._request("storage1/missing.txt")
|
||||||
@@ -113,6 +135,8 @@ class FileInfoApiGoldenTest(unittest.TestCase):
|
|||||||
"content_type",
|
"content_type",
|
||||||
"owner",
|
"owner",
|
||||||
"group",
|
"group",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn('id="viewer-modal"', body)
|
self.assertIn('id="viewer-modal"', body)
|
||||||
self.assertIn('id="video-modal"', body)
|
self.assertIn('id="video-modal"', body)
|
||||||
self.assertIn('id="pdf-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-frame"', body)
|
||||||
self.assertIn('id="pdf-close-btn"', body)
|
self.assertIn('id="pdf-close-btn"', body)
|
||||||
self.assertIn('id="video-player"', body)
|
self.assertIn('id="video-player"', body)
|
||||||
@@ -192,6 +197,12 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn('function openSearch()', app_js)
|
self.assertIn('function openSearch()', app_js)
|
||||||
self.assertIn('async function submitSearch()', app_js)
|
self.assertIn('async function submitSearch()', app_js)
|
||||||
self.assertIn('async function openInfo()', 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('document.getElementById("info-modal")', app_js)
|
||||||
self.assertIn("`/api/files/info?", app_js)
|
self.assertIn("`/api/files/info?", app_js)
|
||||||
self.assertIn('document.getElementById("search-input")', app_js)
|
self.assertIn('document.getElementById("search-input")', app_js)
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ let batchMoveState = {
|
|||||||
destinationBase: "",
|
destinationBase: "",
|
||||||
count: 0,
|
count: 0,
|
||||||
};
|
};
|
||||||
|
let imageViewerState = {
|
||||||
|
scale: 1,
|
||||||
|
fitScale: 1,
|
||||||
|
path: null,
|
||||||
|
resizeHandler: null,
|
||||||
|
};
|
||||||
let settingsState = {
|
let settingsState = {
|
||||||
activeTab: "general",
|
activeTab: "general",
|
||||||
logsLoaded: false,
|
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() {
|
function moveElements() {
|
||||||
return {
|
return {
|
||||||
overlay: document.getElementById("move-popup"),
|
overlay: document.getElementById("move-popup"),
|
||||||
@@ -655,6 +677,32 @@ function isPdfSelection(item) {
|
|||||||
return (item.name || "").toLowerCase().endsWith(".pdf");
|
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) {
|
function currentParentPath(path) {
|
||||||
const normalized = (path || "").trim();
|
const normalized = (path || "").trim();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -1771,6 +1819,55 @@ function closePdfViewer() {
|
|||||||
pdf.frame.removeAttribute("src");
|
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() {
|
function isInfoOpen() {
|
||||||
return !infoElements().overlay.classList.contains("hidden");
|
return !infoElements().overlay.classList.contains("hidden");
|
||||||
}
|
}
|
||||||
@@ -1815,6 +1912,8 @@ async function openInfo() {
|
|||||||
renderInfoField("Content type", data.content_type);
|
renderInfoField("Content type", data.content_type);
|
||||||
renderInfoField("Owner", data.owner);
|
renderInfoField("Owner", data.owner);
|
||||||
renderInfoField("Group", data.group);
|
renderInfoField("Group", data.group);
|
||||||
|
renderInfoField("Width", data.width);
|
||||||
|
renderInfoField("Height", data.height);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
elements.error.textContent = err.message;
|
elements.error.textContent = err.message;
|
||||||
}
|
}
|
||||||
@@ -2116,6 +2215,36 @@ async function openPdfViewer() {
|
|||||||
pdf.frame.src = pdfUrl;
|
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) {
|
function videoPlaybackMessage(item) {
|
||||||
const lower = (item.name || "").toLowerCase();
|
const lower = (item.name || "").toLowerCase();
|
||||||
if (lower.endsWith(".mkv")) {
|
if (lower.endsWith(".mkv")) {
|
||||||
@@ -2179,6 +2308,10 @@ function openViewer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selected = selectedItems[0];
|
const selected = selectedItems[0];
|
||||||
|
if (isImageSelection(selected)) {
|
||||||
|
openImageViewer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isVideoSelection(selected)) {
|
if (isVideoSelection(selected)) {
|
||||||
openVideoViewer();
|
openVideoViewer();
|
||||||
return;
|
return;
|
||||||
@@ -2369,6 +2502,13 @@ function handleKeyboardShortcuts(event) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isImageOpen()) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
closeImageViewer();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isVideoOpen()) {
|
if (isVideoOpen()) {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault();
|
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();
|
const search = searchElements();
|
||||||
search.closeButton.onclick = closeSearch;
|
search.closeButton.onclick = closeSearch;
|
||||||
search.overlay.onclick = (event) => {
|
search.overlay.onclick = (event) => {
|
||||||
|
|||||||
@@ -626,6 +626,36 @@ button:disabled {
|
|||||||
border: 1px solid var(--color-border);
|
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 {
|
.search-card {
|
||||||
width: min(680px, calc(100vw - 32px));
|
width: min(680px, calc(100vw - 32px));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -249,6 +249,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="image-modal" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="image-title">
|
||||||
|
<div class="popup-card viewer-card image-card">
|
||||||
|
<button id="image-close-btn" class="viewer-close" type="button" aria-label="Close image">X</button>
|
||||||
|
<h3 id="image-title">Image</h3>
|
||||||
|
<div id="image-file-name" class="popup-meta"></div>
|
||||||
|
<div id="image-file-path" class="popup-meta"></div>
|
||||||
|
<div class="image-toolbar">
|
||||||
|
<button id="image-zoom-out-btn" type="button">Zoom out</button>
|
||||||
|
<button id="image-reset-btn" type="button">Reset</button>
|
||||||
|
<button id="image-zoom-in-btn" type="button">Zoom in</button>
|
||||||
|
</div>
|
||||||
|
<div id="image-error" class="error"></div>
|
||||||
|
<div id="image-viewport" class="image-viewport">
|
||||||
|
<img id="image-viewer-img" class="image-viewer-img" alt="">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="editor-modal" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="editor-title">
|
<div id="editor-modal" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="editor-title">
|
||||||
<div class="popup-card viewer-card editor-card">
|
<div class="popup-card viewer-card editor-card">
|
||||||
<button id="editor-close-btn" class="viewer-close" type="button" aria-label="Close editor">X</button>
|
<button id="editor-close-btn" class="viewer-close" type="button" aria-label="Close editor">X</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user