Files
loongyan/web/src/routes/photo/[id]/+page.svelte
2026-03-20 22:18:00 +08:00

1341 lines
31 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { m } from '$lib/paraglide/messages';
import { resolve } from '$app/paths';
import { getPhotoFileUrl } from '$lib/api/client';
import { goto } from '$app/navigation';
let { data } = $props();
/** @type {import('$lib/api/types').Photo|null} */
let photo = $derived(data.photo);
let currentIndex = $derived(data.currentIndex ?? 0);
let totalPhotos = $derived(data.totalPhotos ?? 0);
let hasPrev = $derived(data.hasPrev ?? false);
let hasNext = $derived(data.hasNext ?? false);
let prevPhotoId = $derived(data.prevPhotoId);
let nextPhotoId = $derived(data.nextPhotoId);
// 导航加载状态
let isNavigating = $state(false);
let loadError = $state(false);
// 渐进式加载状态
/** @type {'thumbnail' | 'preview' | 'full'} */
let loadStage = $state('thumbnail');
let isFullQualityLoaded = $state(false);
let loadProgress = $state(0);
let isFirstLoad = true;
// 图片尺寸信息
let imageNaturalWidth = $state(0);
let imageNaturalHeight = $state(0);
let containerWidth = $state(0);
let containerHeight = $state(0);
// 触摸滑动支持
let touchStartX = 0;
let touchEndX = 0;
const SWIPE_THRESHOLD = 50;
// ========== 缩放相关状态 ==========
let scale = $state(1);
let minScale = $state(1);
let maxScale = 4;
let translateX = $state(0);
let translateY = $state(0);
let isDragging = $state(false);
let dragStartX = 0;
let dragStartY = 0;
let isPinching = $state(false);
let pinchStartDistance = 0;
let pinchStartScale = 0;
// 双击缩放
let lastClickTime = 0;
let lastClickX = 0;
let lastClickY = 0;
// 动画帧 ID
let animationFrameId = null;
let initialScale = 1;
// 监听容器尺寸变化
function updateContainerSize() {
const container = document.querySelector('.viewer-content');
if (container) {
containerWidth = container.clientWidth;
containerHeight = container.clientHeight;
updateMinScale();
}
}
$effect(() => {
updateContainerSize();
window.addEventListener('resize', updateContainerSize);
return () => window.removeEventListener('resize', updateContainerSize);
});
// 更新最小缩放比例
function updateMinScale() {
if (!imageNaturalWidth || !imageNaturalHeight || !containerWidth || !containerHeight) {
minScale = 0.25;
initialScale = 1;
return;
}
const fitWidth = containerWidth / imageNaturalWidth;
const fitHeight = containerHeight / imageNaturalHeight;
// 最小缩放允许缩小到25%
minScale = 0.25;
// 只在首次加载时设置初始缩放
if (isFirstLoad) {
isFirstLoad = false;
// 初始缩放适应容器但不超过100%(不放大)
initialScale = Math.min(Math.max(fitWidth, fitHeight), 1);
scale = initialScale;
targetScaleRef = initialScale;
}
}
// 平滑缩放的动画循环
function startAnimation() {
if (animationFrameId) return;
// 使用局部变量跟踪当前值,避免响应式更新延迟问题
let currentScale = scale;
let currentTranslateX = translateX;
let currentTranslateY = translateY;
function animate() {
const ease = 0.2;
let needsContinue = false;
if (Math.abs(currentScale - targetScaleRef) > 0.001) {
currentScale = currentScale + (targetScaleRef - currentScale) * ease;
scale = currentScale;
needsContinue = true;
} else {
// 动画完成,精确设置为目标值
scale = targetScaleRef;
}
if (Math.abs(currentTranslateX - targetTranslateXRef) > 0.5) {
currentTranslateX = currentTranslateX + (targetTranslateXRef - currentTranslateX) * ease;
translateX = currentTranslateX;
needsContinue = true;
} else {
translateX = targetTranslateXRef;
}
if (Math.abs(currentTranslateY - targetTranslateYRef) > 0.5) {
currentTranslateY = currentTranslateY + (targetTranslateYRef - currentTranslateY) * ease;
translateY = currentTranslateY;
needsContinue = true;
} else {
translateY = targetTranslateYRef;
}
clampTranslate();
if (needsContinue) {
animationFrameId = requestAnimationFrame(animate);
} else {
animationFrameId = null;
}
}
animationFrameId = requestAnimationFrame(animate);
}
// 使用普通变量存储目标值(避免 $state 的复杂响应式)
let targetScaleRef = 1;
let targetTranslateXRef = 0;
let targetTranslateYRef = 0;
function goToPrevious() {
if (!hasPrev || isNavigating) return;
navigateToPhoto(prevPhotoId);
}
function goToNext() {
if (!hasNext || isNavigating) return;
navigateToPhoto(nextPhotoId);
}
async function navigateToPhoto(photoId) {
if (isNavigating) return;
isNavigating = true;
loadError = false;
loadStage = 'thumbnail';
isFullQualityLoaded = false;
loadProgress = 0;
imageNaturalWidth = 0;
imageNaturalHeight = 0;
isFirstLoad = true;
try {
await goto(resolve(`/photo/${photoId}`), { keepFocus: true });
} catch (error) {
console.error('Failed to navigate:', error);
loadError = true;
} finally {
isNavigating = false;
}
}
function handleClose() {
if (photo?.albumId) {
goto(resolve(`/album/${photo.albumId}`));
} else {
history.back();
}
}
function handleTouchStart(event) {
if (event.touches.length === 1) {
touchStartX = event.touches[0].clientX;
dragStartX = event.touches[0].clientX - translateX;
dragStartY = event.touches[0].clientY - translateY;
} else if (event.touches.length === 2) {
isPinching = true;
pinchStartDistance = getPinchDistance(event.touches);
pinchStartScale = scale;
}
}
function handleTouchMove(event) {
if (event.touches.length === 1 && !isPinching && scale > minScale + 0.1) {
event.preventDefault();
touchEndX = event.touches[0].clientX;
translateX = event.touches[0].clientX - dragStartX;
translateY = event.touches[0].clientY - dragStartY;
targetTranslateXRef = translateX;
targetTranslateYRef = translateY;
} else if (event.touches.length === 2 && isPinching) {
event.preventDefault();
const currentDistance = getPinchDistance(event.touches);
const newScale = pinchStartScale * (currentDistance / pinchStartDistance);
targetScaleRef = Math.max(minScale, Math.min(newScale, maxScale));
startAnimation();
}
}
function handleTouchEnd() {
isPinching = false;
if (touchStartX && touchEndX && scale <= minScale + 0.1) {
const diff = touchStartX - touchEndX;
if (Math.abs(diff) > SWIPE_THRESHOLD) {
if (diff > 0) {
goToNext();
} else {
goToPrevious();
}
}
}
touchStartX = 0;
touchEndX = 0;
}
function getPinchDistance(touches) {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.sqrt(dx * dx + dy * dy);
}
// 键盘导航
$effect(() => {
function handleKeydown(event) {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return;
}
switch (event.key) {
case 'ArrowLeft':
if (scale <= minScale + 0.1) {
event.preventDefault();
goToPrevious();
}
break;
case 'ArrowRight':
if (scale <= minScale + 0.1) {
event.preventDefault();
goToNext();
}
break;
case 'Escape':
event.preventDefault();
if (scale > minScale) {
resetZoom();
} else {
handleClose();
}
break;
case ' ':
event.preventDefault();
if (loadStage !== 'full' && photo?.id) {
upgradeToFullQuality();
}
break;
case '+':
case '=':
event.preventDefault();
zoomIn();
break;
case '-':
event.preventDefault();
zoomOut();
break;
case '0':
event.preventDefault();
resetZoom();
break;
}
}
const container = document.querySelector('.viewer-container');
if (container) {
container.addEventListener('keydown', handleKeydown);
container.focus();
}
return () => {
if (container) {
container.removeEventListener('keydown', handleKeydown);
}
};
});
// 智能预加载相邻照片
$effect(() => {
let cancelled = false;
let preloadTimeout;
preloadTimeout = setTimeout(() => {
if (cancelled) return;
if (hasNext && nextPhotoId) {
const nextImg = new Image();
nextImg.src = resolve(`/api/v1/photo/${nextPhotoId}/preview?w=800`);
nextImg.fetchPriority = 'low';
}
if (hasPrev && prevPhotoId) {
const prevImg = new Image();
prevImg.src = resolve(`/api/v1/photo/${prevPhotoId}/preview?w=800`);
prevImg.fetchPriority = 'low';
}
}, 300);
return () => {
cancelled = true;
clearTimeout(preloadTimeout);
};
});
// 渐进式加载
$effect(() => {
if (!photo || photo.mimeType?.startsWith('video/')) return;
let cancelled = false;
const previewTimeout = setTimeout(() => {
if (cancelled || loadStage !== 'thumbnail') return;
loadStage = 'preview';
}, 100);
const fullTimeout = setTimeout(() => {
if (cancelled || !isFullQualityLoaded) return;
upgradeToFullQuality();
}, 800);
return () => {
cancelled = true;
clearTimeout(previewTimeout);
clearTimeout(fullTimeout);
};
});
function upgradeToFullQuality() {
if (!photo || loadStage === 'full' || photo.mimeType?.startsWith('video/')) return;
loadStage = 'full';
isFullQualityLoaded = true;
const fullResImg = new Image();
fullResImg.src = resolve(getPhotoFileUrl(photo.id));
fullResImg.fetchPriority = 'high';
fullResImg.onprogress = (event) => {
if (event.lengthComputable) {
loadProgress = Math.round((event.loaded / event.total) * 100);
}
};
fullResImg.onload = () => {
loadProgress = 100;
const imgElement = document.querySelector('.viewer-content img');
if (imgElement) {
imgElement.src = resolve(getPhotoFileUrl(photo.id));
imgElement.srcset = '';
imgElement.sizes = '100vw';
}
setTimeout(updateMinScale, 50);
};
fullResImg.onerror = () => {
console.error('Failed to load full resolution image');
loadProgress = 0;
loadStage = 'preview';
};
}
function handleImageLoad(event) {
isNavigating = false;
const img = event.target;
imageNaturalWidth = img.naturalWidth;
imageNaturalHeight = img.naturalHeight;
setTimeout(() => {
updateContainerSize();
// 只在首次加载时重置缩放
if (isFirstLoad) {
resetZoom();
}
}, 10);
}
function handleImageError() {
isNavigating = false;
loadError = true;
}
function getImageSrc() {
if (!photo || photo.mimeType?.startsWith('video/')) return '';
if (loadStage === 'full') {
return resolve(getPhotoFileUrl(photo.id));
}
const previewSrc = resolve(`/api/v1/photo/${photo.id}/preview`);
if (loadStage === 'preview') {
return `${previewSrc}?w=1200`;
}
return `${previewSrc}?w=400`;
}
function getImageSrcSet() {
if (!photo || loadStage === 'full' || photo.mimeType?.startsWith('video/')) return '';
const previewSrc = resolve(`/api/v1/photo/${photo.id}/preview`);
if (loadStage === 'preview') {
return `${previewSrc}?w=800 800w, ${previewSrc}?w=1200 1200w, ${previewSrc}?w=1600 1600w`;
}
return `${previewSrc}?w=400 400w, ${previewSrc}?w=800 800w`;
}
function getImageSizes() {
if (!photo || photo.mimeType?.startsWith('video/')) return '';
return '100vw';
}
// ========== 缩放控制函数 ==========
function zoomIn() {
const currentPercent = Math.round(targetScaleRef * 100);
// 找到下一个更高的规整百分比
const steps = [25, 50, 75, 100, 125, 150, 175, 200, 250, 300, 400];
let nextPercent = currentPercent * 1.5;
for (const step of steps) {
if (step > currentPercent + 5) {
nextPercent = step;
break;
}
}
targetScaleRef = Math.min(nextPercent / 100, maxScale);
startAnimation();
}
function zoomOut() {
const currentPercent = Math.round(targetScaleRef * 100);
// 找到下一个更低的规整百分比
const steps = [400, 300, 250, 200, 175, 150, 125, 100, 75, 50, 25];
let nextPercent = currentPercent / 1.5;
for (const step of steps) {
if (step < currentPercent - 5) {
nextPercent = step;
break;
}
}
targetScaleRef = Math.max(nextPercent / 100, minScale);
startAnimation();
}
function resetZoom() {
isFirstLoad = false;
targetScaleRef = initialScale;
targetTranslateXRef = 0;
targetTranslateYRef = 0;
startAnimation();
}
// 双击缩放
function handleImageClick(event) {
const now = Date.now();
// 使用容器作为参考系计算坐标
const container = document.querySelector('.viewer-content');
if (!container) return;
const rect = container.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
if (
now - lastClickTime < 300 &&
Math.abs(event.clientX - lastClickX) < 50 &&
Math.abs(event.clientY - lastClickY) < 50
) {
if (targetScaleRef > initialScale + 0.01) {
resetZoom();
} else {
const zoomLevel = Math.min(maxScale, 2.5);
zoomToPoint(x, y, zoomLevel);
}
} else {
lastClickTime = now;
lastClickX = event.clientX;
lastClickY = event.clientY;
}
}
function zoomToPoint(pointX, pointY, zoomLevel) {
const currentScale = targetScaleRef;
const currentTranslateX = targetTranslateXRef;
const currentTranslateY = targetTranslateYRef;
// 点击点相对于容器中心的偏移
const dx = pointX - containerWidth / 2;
const dy = pointY - containerHeight / 2;
// 缩放比例变化
const scaleRatio = zoomLevel / currentScale;
// 计算新的平移,使点击点保持在容器中的相同位置
targetTranslateXRef = dx * (1 - scaleRatio) + currentTranslateX * scaleRatio;
targetTranslateYRef = dy * (1 - scaleRatio) + currentTranslateY * scaleRatio;
targetScaleRef = zoomLevel;
startAnimation();
}
// 鼠标滚轮缩放
function handleWheel(event) {
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
const delta = event.deltaY > 0 ? 0.9 : 1.1;
const currentScale = targetScaleRef;
const newScale = Math.max(minScale, Math.min(currentScale * delta, maxScale));
if (Math.abs(newScale - currentScale) > 0.001) {
// 使用容器作为参考系计算坐标
const container = document.querySelector('.viewer-content');
if (container) {
const rect = container.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
zoomToPoint(x, y, newScale);
}
}
}
}
// 鼠标拖拽
function handleMouseDown(event) {
if (scale > minScale + 0.1 && event.button === 0) {
isDragging = true;
dragStartX = event.clientX - translateX;
dragStartY = event.clientY - translateY;
}
}
function handleMouseMove(event) {
if (isDragging) {
translateX = event.clientX - dragStartX;
translateY = event.clientY - dragStartY;
targetTranslateXRef = translateX;
targetTranslateYRef = translateY;
}
}
function handleMouseUp() {
isDragging = false;
}
function handleMouseLeave() {
isDragging = false;
}
// 限制平移范围
function clampTranslate() {
if (!imageNaturalWidth || !imageNaturalHeight) return;
const scaledWidth = imageNaturalWidth * targetScaleRef;
const scaledHeight = imageNaturalHeight * targetScaleRef;
const maxX = Math.max(0, (scaledWidth - containerWidth) / 2);
const maxY = Math.max(0, (scaledHeight - containerHeight) / 2);
targetTranslateXRef = Math.max(-maxX, Math.min(maxX, targetTranslateXRef));
targetTranslateYRef = Math.max(-maxY, Math.min(maxY, targetTranslateYRef));
}
// 获取当前缩放百分比显示
function getZoomPercent() {
return Math.round(scale * 100);
}
// 图片样式
function getImageStyle() {
const styles = {
transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`,
transformOrigin: 'center center',
transition: isDragging || isPinching ? 'none' : 'transform 0.1s ease-out',
cursor: scale > minScale + 0.1 ? 'grab' : 'default'
};
return Object.entries(styles)
.map(([key, value]) => `${key}: ${value}`)
.join('; ');
}
function getImageWrapperStyle() {
const styles = {
cursor: isDragging ? 'grabbing' : scale > minScale + 0.1 ? 'grab' : 'default',
overflow: 'hidden'
};
return Object.entries(styles)
.map(([key, value]) => `${key}: ${value}`)
.join('; ');
}
</script>
<svelte:head>
<title>{photo ? photo.fileName : m.loading()}</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
/>
</svelte:head>
<div
class="viewer-container"
role="application"
tabindex="0"
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
>
{#if !photo}
<div class="loading">
<div class="loading-spinner"></div>
<p>{m.loading()}</p>
</div>
{:else}
{#if isNavigating}
<div class="loading-indicator">
<div class="loading-spinner small"></div>
</div>
{/if}
{#if loadError}
<div class="error-toast">
<span>{m.load_failed()}</span>
<button
onclick={() => {
loadError = false;
loadStage = 'thumbnail';
}}>{m.retry()}</button
>
</div>
{/if}
{#if loadStage === 'full' && loadProgress < 100}
<div class="progress-indicator">
<div class="progress-bar">
<div class="progress-fill" style="width: {loadProgress}%"></div>
</div>
<span class="progress-text">{loadProgress}%</span>
</div>
{/if}
{#if loadStage !== 'full' && !isNavigating && photo && !photo.mimeType?.startsWith('video/')}
<button class="quality-upgrade-btn" onclick={upgradeToFullQuality} type="button">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
<path d="M11 8v6" />
<path d="M8 11h6" />
</svg>
<span>{m.view_original()}</span>
</button>
{/if}
<div class="viewer-header">
<a href={resolve(`/album/${photo.albumId}`)} class="back-link">
<span class="back-icon"></span>
<span class="back-text">{m.back()}</span>
</a>
<span class="photo-index">
{currentIndex + 1} / {totalPhotos}
</span>
{#if photo && !photo.mimeType?.startsWith('video/')}
<span class="quality-indicator">
{#if loadStage === 'full'}
{m.original()}
{:else if loadStage === 'preview'}
{m.high_quality()}
{:else}
{m.thumbnail()}
{/if}
</span>
{/if}
</div>
<div class="viewer-content">
<button
class="nav-button prev"
onclick={goToPrevious}
disabled={!hasPrev || isNavigating}
aria-label={m.previous_photo()}
type="button"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
<div
class="image-wrapper"
style={getImageWrapperStyle()}
onmousedown={handleMouseDown}
onmousemove={handleMouseMove}
onmouseup={handleMouseUp}
onmouseleave={handleMouseLeave}
>
{#if photo.mimeType?.startsWith('video/')}
<video
controls
playsinline
class="fill-container"
onloadeddata={handleImageLoad}
onerror={handleImageError}
>
<source src={resolve(getPhotoFileUrl(photo.id))} type={photo.mimeType} />
</video>
{:else}
<img
src={getImageSrc()}
srcset={getImageSrcSet()}
sizes={getImageSizes()}
alt={photo.fileName}
style={getImageStyle()}
onload={handleImageLoad}
onerror={handleImageError}
decoding="async"
fetchpriority={loadStage === 'full' ? 'high' : 'auto'}
onclick={handleImageClick}
onwheel={handleWheel}
class="zoomable-image"
/>
{/if}
{#if targetScaleRef > initialScale + 0.01 || targetScaleRef < initialScale - 0.01}
<button class="reset-zoom-btn" onclick={resetZoom} type="button" aria-label="重置缩放">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
</button>
{/if}
</div>
<button
class="nav-button next"
onclick={goToNext}
disabled={!hasNext || isNavigating}
aria-label={m.next_photo()}
type="button"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</div>
<!-- 缩放控制栏 -->
{#if photo && !photo.mimeType?.startsWith('video/')}
<div class="zoom-controls">
<button
onclick={zoomOut}
disabled={targetScaleRef <= minScale + 0.01}
type="button"
aria-label={m.zoom_out()}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
<path d="M8 11h6" />
</svg>
</button>
<span class="zoom-level">{getZoomPercent()}%</span>
<button
onclick={zoomIn}
disabled={targetScaleRef >= maxScale - 0.01}
type="button"
aria-label={m.zoom_in()}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
<path d="M11 8v6" />
<path d="M8 11h6" />
</svg>
</button>
<button onclick={resetZoom} type="button" aria-label={m.reset()}>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="20"
height="20"
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
</button>
</div>
{/if}
<div class="photo-info">
<div class="photo-details">
<h2>{photo.fileName}</h2>
<p class="meta">
{#if photo.fileSize}
<span>{Math.round(photo.fileSize / 1024)} KB</span>
{/if}
{#if photo.width && photo.height}
<span>{photo.width}×{photo.height}</span>
{/if}
{#if photo.createdAt}
<span>{new Date(photo.createdAt).toLocaleDateString()}</span>
{/if}
</p>
</div>
</div>
<!-- 快捷键提示 -->
<div class="shortcut-hints">
<span>{m.double_click_zoom()}</span>
<span>{m.pinch_zoom()}</span>
<span>{m.keyboard_zoom()}</span>
<span>{m.reset_zoom()}</span>
</div>
{/if}
</div>
<style>
.viewer-container {
position: fixed;
inset: 0;
background: #000;
color: #fff;
display: flex;
flex-direction: column;
z-index: var(--z-modal);
outline: none;
overflow: hidden;
touch-action: pan-y;
}
.viewer-container:focus {
outline: none;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: var(--space-md);
}
.loading-spinner {
width: 48px;
height: 48px;
border: 3px solid var(--color-overlay-light);
border-top-color: var(--color-text);
border-radius: var(--radius-full);
animation: spin 1s linear infinite;
}
.loading-spinner.small {
width: 24px;
height: 24px;
border-width: 2px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading p {
color: var(--color-text-tertiary);
font-size: var(--font-size-base);
margin: 0;
}
.loading-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 100;
pointer-events: none;
}
.error-toast {
position: absolute;
top: 80px;
left: 50%;
transform: translateX(-50%);
background: var(--color-error);
color: var(--color-text-inverse);
padding: var(--space-md) var(--space-lg);
border-radius: var(--radius-md);
display: flex;
align-items: center;
gap: var(--space-md);
z-index: 101;
box-shadow: var(--shadow-lg);
}
.error-toast button {
background: var(--color-overlay-light);
border: none;
color: var(--color-text-inverse);
padding: var(--space-xs) var(--space-md);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--font-size-sm);
}
.error-toast button:hover {
background: var(--color-overlay);
}
.progress-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--color-overlay-dark);
padding: var(--space-md) var(--space-lg);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-md);
z-index: 102;
min-width: 200px;
}
.progress-bar {
width: 100%;
height: 6px;
background: var(--color-overlay-light);
border-radius: var(--radius-sm);
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--color-primary), #8b5cf6);
transition: width var(--transition-normal);
border-radius: var(--radius-sm);
}
.progress-text {
color: var(--color-text-inverse);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
}
.quality-upgrade-btn {
position: absolute;
bottom: 120px;
left: 50%;
transform: translateX(-50%);
background: var(--color-primary);
color: var(--color-text-inverse);
border: none;
padding: var(--space-sm) var(--space-lg);
border-radius: var(--radius-md);
display: flex;
align-items: center;
gap: var(--space-sm);
cursor: pointer;
transition: all var(--transition-normal);
z-index: 101;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
box-shadow: var(--shadow-lg);
}
.quality-upgrade-btn:hover {
background: var(--color-primary-hover);
transform: translateX(-50%) scale(1.05);
}
.quality-upgrade-btn:active {
transform: translateX(-50%) scale(0.98);
}
.quality-upgrade-btn svg {
flex-shrink: 0;
}
.viewer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-md) var(--space-md);
background: linear-gradient(to bottom, var(--color-overlay-dark), transparent);
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
}
.back-link {
display: flex;
align-items: center;
gap: var(--space-sm);
color: var(--color-text-inverse);
text-decoration: none;
font-size: var(--font-size-base);
padding: var(--space-sm) var(--space-md);
background: var(--color-overlay-light);
border-radius: var(--radius-md);
transition: background var(--transition-normal);
}
.back-link:hover {
background: var(--color-overlay);
}
.back-icon {
font-size: var(--font-size-xl);
line-height: 1;
}
@media (max-width: 640px) {
.back-text {
display: none;
}
}
.photo-index {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
background: var(--color-overlay-light);
padding: 0.35rem var(--space-md);
border-radius: var(--radius-md);
}
.quality-indicator {
color: var(--color-primary);
font-size: var(--font-size-xs);
background: var(--color-primary-light);
padding: var(--space-xs) 0.625rem;
border-radius: var(--radius-sm);
font-weight: var(--font-weight-semibold);
}
.viewer-content {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.image-wrapper {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.image-wrapper img,
.image-wrapper video {
max-width: 100%;
max-height: 100%;
transition: opacity var(--transition-slow);
user-select: none;
-webkit-user-drag: none;
}
.zoomable-image {
will-change: transform;
}
.fill-container {
width: 100%;
height: 100%;
object-fit: contain;
}
.reset-zoom-btn {
position: absolute;
top: var(--space-md);
right: var(--space-md);
background: var(--color-overlay-dark);
color: var(--color-text-inverse);
border: none;
padding: var(--space-sm);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-normal);
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
}
.reset-zoom-btn:hover {
background: var(--color-primary);
transform: scale(1.1);
}
.reset-zoom-btn:active {
transform: scale(0.95);
}
.nav-button {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: var(--color-overlay-light);
color: var(--color-text-inverse);
border: none;
padding: var(--space-md);
cursor: pointer;
transition: all var(--transition-normal);
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
}
.nav-button svg {
width: 24px;
height: 24px;
}
.nav-button:hover:not(:disabled) {
background: var(--color-overlay);
transform: translateY(-50%) scale(1.1);
}
.nav-button:active:not(:disabled) {
transform: translateY(-50%) scale(0.95);
}
.nav-button:disabled {
opacity: 0.2;
cursor: not-allowed;
}
.nav-button.prev {
left: var(--space-md);
}
.nav-button.next {
right: var(--space-md);
}
.zoom-controls {
position: absolute;
bottom: 60px;
left: 50%;
transform: translateX(-50%);
background: var(--color-overlay-dark);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-lg);
display: flex;
align-items: center;
gap: var(--space-sm);
z-index: 101;
box-shadow: var(--shadow-lg);
}
.zoom-controls button {
background: var(--color-overlay-light);
color: var(--color-text-inverse);
border: none;
padding: var(--space-sm);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-normal);
display: flex;
align-items: center;
justify-content: center;
}
.zoom-controls button:hover:not(:disabled) {
background: var(--color-primary);
transform: scale(1.1);
}
.zoom-controls button:active:not(:disabled) {
transform: scale(0.95);
}
.zoom-controls button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.zoom-level {
min-width: 50px;
text-align: center;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-inverse);
}
.shortcut-hints {
position: absolute;
bottom: var(--space-md);
left: 50%;
transform: translateX(-50%);
background: var(--color-overlay-dark);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
display: flex;
gap: var(--space-md);
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
z-index: 10;
flex-wrap: wrap;
justify-content: center;
}
@media (max-width: 768px) {
.nav-button {
padding: var(--space-sm);
}
.nav-button svg {
width: 20px;
height: 20px;
}
.nav-button.prev {
left: var(--space-sm);
}
.nav-button.next {
right: var(--space-sm);
}
.shortcut-hints {
display: none;
}
.zoom-controls {
bottom: 140px;
}
}
.photo-info {
padding: var(--space-md);
background: linear-gradient(to top, var(--color-overlay-dark), transparent);
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 10;
}
.photo-details {
max-width: 800px;
margin: 0 auto;
}
.photo-info h2 {
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
margin: 0 0 var(--space-sm) 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-inverse);
}
.photo-info .meta {
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
display: flex;
flex-wrap: wrap;
gap: var(--space-md);
}
.photo-info .meta span {
display: inline-flex;
align-items: center;
}
</style>