添加前端支持
This commit is contained in:
273
web/src/routes/photo/[id]/+page.svelte
Normal file
273
web/src/routes/photo/[id]/+page.svelte
Normal file
@@ -0,0 +1,273 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { getPhoto, getPhotoFileUrl, getAlbumPhotos } from '$lib/api/client';
|
||||
import { m, loading } from '$lib/paraglide/messages';
|
||||
|
||||
/** @type {import('$lib/api/types').Photo|null} */
|
||||
let photo = $state(null);
|
||||
/** @type {import('$lib/api/types').Photo[]} */
|
||||
let albumPhotos = $state([]);
|
||||
let currentIndex = $state(0);
|
||||
let loading_state = $state(true);
|
||||
let error = $state(/** @type {string|null} */ (null));
|
||||
|
||||
onMount(async () => {
|
||||
const photoId = parseInt(page.params.id ?? '');
|
||||
if (isNaN(photoId)) {
|
||||
error = 'Invalid photo id';
|
||||
loading_state = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
photo = await getPhoto(photoId);
|
||||
|
||||
if (!photo) {
|
||||
error = 'Photo not found';
|
||||
} else {
|
||||
albumPhotos = await getAlbumPhotos(photo.albumId);
|
||||
currentIndex = albumPhotos.findIndex((p) => p.id === photoId);
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Unknown error';
|
||||
} finally {
|
||||
loading_state = false;
|
||||
}
|
||||
});
|
||||
|
||||
function goToPrevious() {
|
||||
if (currentIndex > 0) {
|
||||
const prevPhoto = albumPhotos[currentIndex - 1];
|
||||
history.pushState({}, '', `/photo/${prevPhoto.id}`);
|
||||
photo = albumPhotos[currentIndex - 1];
|
||||
currentIndex--;
|
||||
}
|
||||
}
|
||||
|
||||
function goToNext() {
|
||||
if (currentIndex < albumPhotos.length - 1) {
|
||||
const nextPhoto = albumPhotos[currentIndex + 1];
|
||||
history.pushState({}, '', `/photo/${nextPhoto.id}`);
|
||||
photo = albumPhotos[currentIndex + 1];
|
||||
currentIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (photo) {
|
||||
history.back();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
/** @param {Event} event */
|
||||
function handleKeydown(event) {
|
||||
const keyboardEvent = /** @type {KeyboardEvent} */ (event);
|
||||
if (keyboardEvent.key === 'ArrowLeft') {
|
||||
goToPrevious();
|
||||
} else if (keyboardEvent.key === 'ArrowRight') {
|
||||
goToNext();
|
||||
} else if (keyboardEvent.key === 'Escape') {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
|
||||
const container = /** @type {HTMLElement|null} */ (document.querySelector('.viewer-container'));
|
||||
if (container) {
|
||||
container.addEventListener('keydown', handleKeydown);
|
||||
container.focus();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (container) {
|
||||
container.removeEventListener('keydown', handleKeydown);
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{photo ? photo.fileName : loading()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="viewer-container" role="application" tabindex="0">
|
||||
{#if loading_state}
|
||||
<div class="loading">{m.loading()}</div>
|
||||
{:else if error}
|
||||
<div class="error">{m.error()}</div>
|
||||
{:else if photo}
|
||||
<div class="viewer-header">
|
||||
<a href="/album/{photo.albumId}" class="back-link">← {m.back()}</a>
|
||||
<span class="photo-index">
|
||||
{currentIndex + 1} / {albumPhotos.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="viewer-content">
|
||||
<button
|
||||
class="nav-button prev"
|
||||
onclick={goToPrevious}
|
||||
disabled={currentIndex === 0}
|
||||
aria-label="Previous photo"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
|
||||
<div class="image-wrapper">
|
||||
{#if photo.mimeType.startsWith('video/')}
|
||||
<video controls>
|
||||
<source src={getPhotoFileUrl(photo.id)} type={photo.mimeType} />
|
||||
</video>
|
||||
{:else}
|
||||
<img src={getPhotoFileUrl(photo.id)} alt={photo.fileName} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="nav-button next"
|
||||
onclick={goToNext}
|
||||
disabled={currentIndex === albumPhotos.length - 1}
|
||||
aria-label="Next photo"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="photo-info">
|
||||
<h2>{photo.fileName}</h2>
|
||||
<p class="meta">
|
||||
{Math.round(photo.fileSize / 1024)} KB •
|
||||
{photo.width && photo.height ? `${photo.width}×${photo.height}` : ''} •
|
||||
{new Date(photo.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.viewer-container {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #000;
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1000;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.viewer-container:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.viewer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.photo-index {
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.viewer-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-wrapper {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.image-wrapper img,
|
||||
.image-wrapper video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 3rem;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.nav-button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.nav-button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nav-button.prev {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.nav-button.next {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.photo-info {
|
||||
padding: 1rem 1.5rem;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.photo-info h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.photo-info .meta {
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user