summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--index.html2
-rw-r--r--src/App.svelte10
-rw-r--r--src/app.css5
-rw-r--r--src/lib/components/ImageList.svelte11
-rw-r--r--src/lib/components/ImagePreview.svelte191
5 files changed, 204 insertions, 15 deletions
diff --git a/index.html b/index.html
index bc1e3b5..ac9ac68 100644
--- a/index.html
+++ b/index.html
@@ -2,7 +2,7 @@
<html lang="en" data-theme="cmyk">
<head>
<meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>SpeedSync</title>
</head>
<body>
diff --git a/src/App.svelte b/src/App.svelte
index b9050dc..a9a2506 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -62,7 +62,7 @@
</div>
<!-- Right: Image list -->
- <div class="w-72 lg:w-80 shrink-0 border-l border-base-300 flex flex-col">
+<div class="w-36 lg:w-40 shrink-0 border-l border-base-300 flex flex-col">
<div class="px-3 py-2 bg-base-100 border-b border-base-300 shrink-0">
<span class="text-sm font-semibold">Photos ({images.length})</span>
</div>
@@ -74,16 +74,16 @@
<!-- FAB Flower: bottom-right -->
<div class="fab fab-flower">
- <div tabindex="0" class="btn btn-lg btn-circle btn-neutral">
+ <div tabindex="0" class="btn btn-lg btn-circle btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</div>
<div class="fab-close">
- <span class="btn btn-circle btn-lg btn-neutral">✕</span>
+ <span class="btn btn-circle btn-lg btn-secondary">✕</span>
</div>
<!-- Dark mode toggle -->
- <button class="btn btn-lg btn-circle btn-neutral" onclick={() => (isDark = !isDark)}>
+ <button class="btn btn-lg btn-circle btn-secondary" onclick={() => (isDark = !isDark)}>
{#if isDark}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
@@ -95,7 +95,7 @@
{/if}
</button>
<!-- Refresh -->
- <button class="btn btn-lg btn-circle btn-neutral" onclick={() => loadAllImages()}>
+ <button class="btn btn-lg btn-circle btn-secondary" onclick={() => loadAllImages()}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12a7.5 7.5 0 0 1 12.57-5.55L19.5 8.87" />
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 4.5v4.37h-4.37" />
diff --git a/src/app.css b/src/app.css
index 147bcf3..9a009fc 100644
--- a/src/app.css
+++ b/src/app.css
@@ -2,3 +2,8 @@
@plugin "daisyui" {
themes: cmyk --default, black;
}
+
+html, body {
+ touch-action: pan-x pan-y;
+ overscroll-behavior: none;
+}
diff --git a/src/lib/components/ImageList.svelte b/src/lib/components/ImageList.svelte
index 7e6f836..f338670 100644
--- a/src/lib/components/ImageList.svelte
+++ b/src/lib/components/ImageList.svelte
@@ -23,7 +23,7 @@
{@const isSelected = file.path === selectedPath}
<li>
<button
- class="flex items-center gap-2 w-full rounded-lg p-1.5 text-left transition-colors {isSelected
+ class="flex flex-col w-full rounded-lg p-1.5 text-left transition-colors {isSelected
? 'bg-primary text-primary-content'
: 'hover:bg-base-200'}"
onclick={() => onSelect(file)}
@@ -32,17 +32,18 @@
<img
src={thumbUrl}
alt={file.filename}
- class="w-12 h-12 rounded object-cover shrink-0"
+ class="w-full aspect-square rounded object-cover"
loading="lazy"
/>
{:else}
- <div class="w-12 h-12 rounded bg-base-300 flex items-center justify-center shrink-0">
+ <div class="w-full aspect-square rounded bg-base-300 flex items-center justify-center">
<span class="text-lg">🖼️</span>
</div>
{/if}
- <div class="min-w-0 flex-1">
+ <div class="min-w-0 w-full mt-1">
<p class="text-xs font-medium truncate">{file.filename}</p>
- <p class="text-xs opacity-60">{file.date.toLocaleString()}</p>
+ <p class="text-xs opacity-60">{file.date.toLocaleDateString()}</p>
+ <p class="text-xs opacity-60">{file.date.toLocaleTimeString()}</p>
</div>
</button>
</li>
diff --git a/src/lib/components/ImagePreview.svelte b/src/lib/components/ImagePreview.svelte
index 8ef9395..2f41ea3 100644
--- a/src/lib/components/ImagePreview.svelte
+++ b/src/lib/components/ImagePreview.svelte
@@ -26,6 +26,173 @@
let rawObjectUrl: string | undefined;
let rawThumbnailUrl: string | undefined;
+ // --- Zoom & pan state ---
+ let zoomLevel = $state(1); // user zoom: 1 = fit, >1 = zoomed in
+ let panX = $state(0);
+ let panY = $state(0);
+ let containerEl: HTMLDivElement | undefined;
+ let containerW = $state(0);
+ let containerH = $state(0);
+
+ // Touch tracking for pinch-to-zoom and pan
+ let lastTouchDist = 0;
+ let lastTouchMidX = 0;
+ let lastTouchMidY = 0;
+ let isPinching = false;
+ let isPanning = false;
+ let lastPanX = 0;
+ let lastPanY = 0;
+
+ const MIN_ZOOM = 1;
+ const MAX_ZOOM = 10;
+
+ function resetZoom() {
+ zoomLevel = 1;
+ panX = 0;
+ panY = 0;
+ }
+
+ function clampPan() {
+ if (zoomLevel <= 1) {
+ panX = 0;
+ panY = 0;
+ return;
+ }
+ if (containerW === 0 || containerH === 0) return;
+ const maxPanX = (containerW * (zoomLevel - 1)) / 2;
+ const maxPanY = (containerH * (zoomLevel - 1)) / 2;
+ panX = Math.max(-maxPanX, Math.min(maxPanX, panX));
+ panY = Math.max(-maxPanY, Math.min(maxPanY, panY));
+ }
+
+ function handleWheel(e: WheelEvent) {
+ e.preventDefault();
+ const delta = e.deltaY > 0 ? 0.9 : 1.1;
+ const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoomLevel * delta));
+
+ if (containerEl !== undefined) {
+ const rect = containerEl.getBoundingClientRect();
+ const cursorX = e.clientX - rect.left - rect.width / 2;
+ const cursorY = e.clientY - rect.top - rect.height / 2;
+ const factor = newZoom / zoomLevel;
+ panX = cursorX - factor * (cursorX - panX);
+ panY = cursorY - factor * (cursorY - panY);
+ }
+
+ zoomLevel = newZoom;
+ clampPan();
+ }
+
+ function touchDist(t1: Touch, t2: Touch): number {
+ const dx = t1.clientX - t2.clientX;
+ const dy = t1.clientY - t2.clientY;
+ return Math.sqrt(dx * dx + dy * dy);
+ }
+
+ function handleTouchStart(e: TouchEvent) {
+ if (e.touches.length === 2) {
+ e.preventDefault();
+ isPinching = true;
+ isPanning = false;
+ const t0 = e.touches[0] as Touch;
+ const t1 = e.touches[1] as Touch;
+ lastTouchDist = touchDist(t0, t1);
+ lastTouchMidX = (t0.clientX + t1.clientX) / 2;
+ lastTouchMidY = (t0.clientY + t1.clientY) / 2;
+ } else if (e.touches.length === 1 && zoomLevel > 1) {
+ isPanning = true;
+ isPinching = false;
+ const t = e.touches[0] as Touch;
+ lastPanX = t.clientX;
+ lastPanY = t.clientY;
+ }
+ }
+
+ function handleTouchMove(e: TouchEvent) {
+ if (isPinching && e.touches.length === 2) {
+ e.preventDefault();
+ const t0 = e.touches[0] as Touch;
+ const t1 = e.touches[1] as Touch;
+ const dist = touchDist(t0, t1);
+ const midX = (t0.clientX + t1.clientX) / 2;
+ const midY = (t0.clientY + t1.clientY) / 2;
+
+ const factor = dist / lastTouchDist;
+ const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoomLevel * factor));
+
+ if (containerEl !== undefined) {
+ const rect = containerEl.getBoundingClientRect();
+ const cx = midX - rect.left - rect.width / 2;
+ const cy = midY - rect.top - rect.height / 2;
+ const sf = newZoom / zoomLevel;
+ panX = cx - sf * (cx - panX) + (midX - lastTouchMidX);
+ panY = cy - sf * (cy - panY) + (midY - lastTouchMidY);
+ }
+
+ zoomLevel = newZoom;
+ clampPan();
+
+ lastTouchDist = dist;
+ lastTouchMidX = midX;
+ lastTouchMidY = midY;
+ } else if (isPanning && e.touches.length === 1 && zoomLevel > 1) {
+ e.preventDefault();
+ const t = e.touches[0] as Touch;
+ panX += t.clientX - lastPanX;
+ panY += t.clientY - lastPanY;
+ clampPan();
+ lastPanX = t.clientX;
+ lastPanY = t.clientY;
+ }
+ }
+
+ function handleTouchEnd(e: TouchEvent) {
+ if (e.touches.length < 2) {
+ isPinching = false;
+ }
+ if (e.touches.length === 0) {
+ isPanning = false;
+ }
+ if (e.touches.length === 1 && zoomLevel > 1) {
+ isPanning = true;
+ const t = e.touches[0] as Touch;
+ lastPanX = t.clientX;
+ lastPanY = t.clientY;
+ }
+ }
+
+ // Track container size via ResizeObserver
+ $effect(() => {
+ const el = containerEl;
+ if (el === undefined) return;
+
+ const ro = new ResizeObserver((entries) => {
+ const entry = entries[0];
+ if (entry !== undefined) {
+ containerW = entry.contentRect.width;
+ containerH = entry.contentRect.height;
+ }
+ });
+ ro.observe(el);
+ return () => ro.disconnect();
+ });
+
+ // Bind touch listeners with { passive: false } so we can preventDefault
+ $effect(() => {
+ const el = containerEl;
+ if (el === undefined) return;
+
+ el.addEventListener('touchstart', handleTouchStart, { passive: false });
+ el.addEventListener('touchmove', handleTouchMove, { passive: false });
+ el.addEventListener('touchend', handleTouchEnd, { passive: true });
+
+ return () => {
+ el.removeEventListener('touchstart', handleTouchStart);
+ el.removeEventListener('touchmove', handleTouchMove);
+ el.removeEventListener('touchend', handleTouchEnd);
+ };
+ });
+
$effect(() => {
const currentFile = file;
if (currentFile === undefined) {
@@ -33,6 +200,9 @@
return;
}
+ // Reset zoom on file change
+ resetZoom();
+
// Clear stale state synchronously before async loads
if (rawThumbnailUrl !== undefined) {
URL.revokeObjectURL(rawThumbnailUrl);
@@ -67,6 +237,7 @@
progress = 0;
downloading = false;
loadError = undefined;
+ resetZoom();
}
async function loadThumbnail(entry: FlashAirFileEntry) {
@@ -157,9 +328,17 @@
let progressPercent = $derived(Math.round(progress * 100));
let showThumbnail = $derived(fullObjectUrl === undefined && thumbnailBlobUrl !== undefined);
+ let imageTransform = $derived(
+ `translate(${String(panX)}px, ${String(panY)}px) scale(${String(zoomLevel)})`
+ );
</script>
-<div class="h-full flex items-center justify-center bg-base-300 relative">
+<!-- svelte-ignore a11y_no_static_element_interactions -->
+<div
+ class="h-full flex items-center justify-center bg-base-300 relative overflow-hidden touch-none"
+ bind:this={containerEl}
+ onwheel={handleWheel}
+>
{#if file === undefined}
<div class="text-base-content/40 text-center p-8">
<p class="text-lg">Select a photo to preview</p>
@@ -171,8 +350,8 @@
</div>
{:else}
{#if downloading && progress < 1}
- <div class="absolute inset-x-0 top-0 z-10 p-3">
- <div class="max-w-xs mx-auto">
+ <div class="absolute inset-0 z-10 flex items-center justify-center bg-black/50">
+ <div class="bg-base-100 rounded-box p-6 shadow-xl max-w-xs w-full mx-4">
<div class="flex items-center gap-2">
<progress class="progress progress-primary flex-1" value={progressPercent} max="100"></progress>
<span class="text-xs font-mono text-base-content/70 w-10 text-right">{progressPercent}%</span>
@@ -185,7 +364,10 @@
<img
src={fullObjectUrl}
alt={file.filename}
- class="max-w-full max-h-full object-contain"
+ class="max-w-full max-h-full object-contain will-change-transform"
+ style:transform={imageTransform}
+ style:transform-origin="center center"
+ draggable="false"
/>
{/key}
{:else if showThumbnail}
@@ -197,6 +379,7 @@
src={thumbnailBlobUrl}
alt={file.filename}
class="w-full h-full object-cover blur-lg"
+ draggable="false"
/>
</div>
{:else}