添加前端支持

This commit is contained in:
2026-03-13 15:03:36 +08:00
parent c2b9c5d4c0
commit 8612b2dbde
12 changed files with 897 additions and 43 deletions

View File

@@ -784,6 +784,18 @@
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="motorola" />
<option name="codename" value="fogorow" />
<option name="id" value="fogorow" />
<option name="labId" value="google" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="moto g24" />
<option name="screenDensity" value="280" />
<option name="screenX" value="720" />
<option name="screenY" value="1612" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="motorola" />

View File

@@ -18,7 +18,8 @@ Response jsonResponse(dynamic data) {
);
}
Router createRouter() {
/// 创建 API v1 版本的路由
Router createApiV1Router() {
final router = Router()
..get('/', _rootHandler)
..get("/album", _listAlbumHandler)
@@ -31,6 +32,16 @@ Router createRouter() {
return router;
}
/// 创建主路由器,将 API v1 挂载到 /api/v1 路径下
Router createRouter() {
final router = Router();
// 将 API v1 路由挂载到 /api/v1 路径下
router.mount('/api/v1', createApiV1Router().call);
return router;
}
Response _rootHandler(Request req) {
return Response.ok('Hello, World!\n');
}
@@ -113,8 +124,26 @@ Future<Response> _getPhotoFileHandler(Request req) async {
return Response.notFound('File not found');
}
// ✅ 修复:使用 StreamController 包装,确保在客户端断开时关闭文件流
final streamController = StreamController<List<int>>();
final fileStream = file.openRead();
// 订阅文件流,并将数据转发到响应流
final subscription = fileStream.listen(
streamController.add,
onError: streamController.addError,
onDone: streamController.close,
cancelOnError: true,
);
// 当客户端取消订阅时(断开连接),取消文件流订阅并关闭文件
streamController.onCancel = () {
subscription.cancel();
return Future.value();
};
return Response.ok(
file.openRead(),
streamController.stream,
headers: {
'Content-Type': photo.mimeType,
'Content-Length': photo.fileSize.toString(),
@@ -122,8 +151,6 @@ Future<Response> _getPhotoFileHandler(Request req) async {
);
}
Future<Response> _getPhotosOfAlbumHander(Request req) async {
final idParam = req.params['id'];
if (idParam == null) {

View File

@@ -28,30 +28,30 @@ void main() {
});
group('Root endpoint', () {
test('GET / returns Hello World', () async {
final response = await http.get(Uri.parse('$host/'));
test('GET /api/v1/ returns Hello World', () async {
final response = await http.get(Uri.parse('$host/api/v1/'));
expect(response.statusCode, 200);
expect(response.body, 'Hello, World!\n');
});
});
group('Echo endpoint', () {
test('GET /echo/<message> returns the message', () async {
final response = await http.get(Uri.parse('$host/echo/hello'));
test('GET /api/v1/echo/<message> returns the message', () async {
final response = await http.get(Uri.parse('$host/api/v1/echo/hello'));
expect(response.statusCode, 200);
expect(response.body, 'hello\n');
});
test('GET /echo/<message> with special characters', () async {
final response = await http.get(Uri.parse('$host/echo/test%20message'));
test('GET /api/v1/echo/<message> with special characters', () async {
final response = await http.get(Uri.parse('$host/api/v1/echo/test%20message'));
expect(response.statusCode, 200);
expect(response.body, contains('test'));
});
});
group('Album endpoints', () {
test('GET /album returns list of albums', () async {
final response = await http.get(Uri.parse('$host/album'));
test('GET /api/v1/album returns list of albums', () async {
final response = await http.get(Uri.parse('$host/api/v1/album'));
expect(response.statusCode, 200);
expect(response.headers['content-type'], contains('application/json'));
final albums = jsonDecode(response.body) as List;
@@ -62,27 +62,27 @@ void main() {
}
});
test('GET /album/<id> with invalid id returns 400', () async {
final response = await http.get(Uri.parse('$host/album/invalid_id'));
test('GET /api/v1/album/<id> with invalid id returns 400', () async {
final response = await http.get(Uri.parse('$host/api/v1/album/invalid_id'));
expect(response.statusCode, 400);
expect(response.body, contains('Invalid album id'));
});
test('GET /album/<id> with non-existent id returns 404', () async {
final response = await http.get(Uri.parse('$host/album/999999'));
test('GET /api/v1/album/<id> with non-existent id returns 404', () async {
final response = await http.get(Uri.parse('$host/api/v1/album/999999'));
expect(response.statusCode, 404);
expect(response.body, contains('Album not found'));
});
test('GET /album/<id>/photo with invalid id returns 400', () async {
final response = await http.get(Uri.parse('$host/album/invalid_id/photo'));
test('GET /api/v1/album/<id>/photo with invalid id returns 400', () async {
final response = await http.get(Uri.parse('$host/api/v1/album/invalid_id/photo'));
expect(response.statusCode, 400);
expect(response.body, contains('Invalid album id'));
});
test('GET /album/<id>/photo with valid id returns photos', () async {
test('GET /api/v1/album/<id>/photo with valid id returns photos', () async {
// First get the list to find a valid album id
final listResponse = await http.get(Uri.parse('$host/album'));
final listResponse = await http.get(Uri.parse('$host/api/v1/album'));
expect(listResponse.statusCode, 200);
final albums = jsonDecode(listResponse.body) as List;
@@ -94,7 +94,7 @@ void main() {
final album = albums.first as Map<String, dynamic>;
final albumId = album['id'];
final response = await http.get(Uri.parse('$host/album/$albumId/photo'));
final response = await http.get(Uri.parse('$host/api/v1/album/$albumId/photo'));
expect(response.statusCode, 200);
expect(response.headers['content-type'], contains('application/json'));
final photos = jsonDecode(response.body) as List;
@@ -103,35 +103,35 @@ void main() {
});
group('Photo endpoints', () {
test('GET /photo/<id> with invalid id returns 400', () async {
final response = await http.get(Uri.parse('$host/photo/abc'));
test('GET /api/v1/photo/<id> with invalid id returns 400', () async {
final response = await http.get(Uri.parse('$host/api/v1/photo/abc'));
expect(response.statusCode, 400);
expect(response.body, contains('Invalid photo id'));
});
test('GET /photo/<id> with non-existent id returns 404', () async {
test('GET /api/v1/photo/<id> with non-existent id returns 404', () async {
// Use a hash that won't match any file
final response = await http.get(Uri.parse('$host/photo/12345'));
final response = await http.get(Uri.parse('$host/api/v1/photo/12345'));
expect(response.statusCode, 404);
expect(response.body, contains('Photo not found'));
});
test('GET /photo/<id>/file with invalid id returns 400', () async {
final response = await http.get(Uri.parse('$host/photo/abc/file'));
test('GET /api/v1/photo/<id>/file with invalid id returns 400', () async {
final response = await http.get(Uri.parse('$host/api/v1/photo/abc/file'));
expect(response.statusCode, 400);
expect(response.body, contains('Invalid photo id'));
});
test('GET /photo/<id>/file with non-existent id returns 404', () async {
test('GET /api/v1/photo/<id>/file with non-existent id returns 404', () async {
// Use a hash that won't match any file
final response = await http.get(Uri.parse('$host/photo/12345/file'));
final response = await http.get(Uri.parse('$host/api/v1/photo/12345/file'));
expect(response.statusCode, 404);
expect(response.body, contains('Photo not found'));
});
test('GET /photo/<id>/file with valid id returns file', () async {
test('GET /api/v1/photo/<id>/file with valid id returns file', () async {
// First get a valid photo id from an album
final albumResponse = await http.get(Uri.parse('$host/album'));
final albumResponse = await http.get(Uri.parse('$host/api/v1/album'));
expect(albumResponse.statusCode, 200);
final albums = jsonDecode(albumResponse.body) as List;
@@ -144,7 +144,7 @@ void main() {
final albumId = album['id'];
final photosResponse = await http.get(
Uri.parse('$host/album/$albumId/photo'),
Uri.parse('$host/api/v1/album/$albumId/photo'),
);
expect(photosResponse.statusCode, 200);
final photos = jsonDecode(photosResponse.body) as List;
@@ -158,7 +158,7 @@ void main() {
final photoId = photo['id'];
final response = await http.get(
Uri.parse('$host/photo/$photoId/file'),
Uri.parse('$host/api/v1/photo/$photoId/file'),
);
expect(response.statusCode, 200);
expect(response.contentLength, greaterThan(0));
@@ -172,7 +172,7 @@ void main() {
});
test('Unknown nested route returns 404', () async {
final response = await http.get(Uri.parse('$host/api/v1/unknown'));
final response = await http.get(Uri.parse('$host/api/v2/unknown'));
expect(response.statusCode, 404);
});
});

View File

@@ -1,6 +1,14 @@
# sv
# Loongyan Photo Album Web
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
网页相册前端,基于 SvelteKit 构建,支持中英文国际化。
## 功能特性
- 📁 相册列表展示
- 🖼️ 照片浏览
- 🌍 中英文国际化支持
- 📱 响应式设计
- ⌨️ 键盘导航支持左右键切换照片ESC 关闭)
## Creating a project
@@ -18,17 +26,24 @@ To recreate this project with the same configuration:
pnpm dlx sv@0.12.6 create --template minimal --types jsdoc --add prettier vitest="usages:unit,component" eslint sveltekit-adapter="adapter:auto" devtools-json better-auth="demo:password" mdsvex paraglide="languageTags:en, zh+demo:yes" drizzle="database:sqlite+sqlite:better-sqlite3" --install pnpm web/
```
## Developing
## 开发
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
安装依赖并启动开发服务器:
```sh
pnpm install
npm run dev
# or start the server and open the app in a new browser tab
# 或自动打开浏览器
npm run dev -- --open
```
## API 配置
本项目需要配合后端 API 使用。默认情况下API 请求会发送到同源服务器。
如果需要配置 API 地址,请修改 `src/lib/api/client.js` 中的 `API_BASE` 常量。
## Building
To create a production version of your app:

View File

@@ -1,4 +1,12 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"hello_world": "Hello, {name} from en!"
"hello_world": "Hello, {name} from en!",
"albums": "Albums",
"loading": "Loading...",
"no_albums": "No albums yet",
"error": "Error",
"back": "Back",
"photo_count": "{count} photos",
"view_photo": "View Photo",
"load_more": "All loaded"
}

View File

@@ -1,4 +1,12 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"hello_world": "Hello, {name} from zh!"
"hello_world": "Hello, {name} from zh!",
"albums": "相册",
"loading": "加载中...",
"no_albums": "暂无相册",
"error": "错误",
"back": "返回",
"photo_count": "{count} 张照片",
"view_photo": "查看照片",
"load_more": "已全部加载"
}

60
web/src/lib/api/client.js Normal file
View File

@@ -0,0 +1,60 @@
const API_BASE = '/api/v1';
/**
* @param {string} endpoint
* @returns {Promise<any>}
*/
async function fetchApi(endpoint) {
const response = await fetch(`${API_BASE}${endpoint}`);
if (!response.ok) {
if (response.status === 404) {
return null;
}
throw new Error(`API Error: ${response.status}`);
}
return response.json();
}
/**
* Get all albums
* @returns {Promise<import('./types').Album[]>}
*/
export async function getAlbums() {
return fetchApi('/album');
}
/**
* Get album by id
* @param {number} id
* @returns {Promise<import('./types').Album|null>}
*/
export async function getAlbum(id) {
return fetchApi(`/album/${id}`);
}
/**
* Get photos of an album
* @param {number} albumId
* @returns {Promise<import('./types').Photo[]>}
*/
export async function getAlbumPhotos(albumId) {
return fetchApi(`/album/${albumId}/photo`);
}
/**
* Get photo by id
* @param {number} id
* @returns {Promise<import('./types').Photo|null>}
*/
export async function getPhoto(id) {
return fetchApi(`/photo/${id}`);
}
/**
* Get photo file URL
* @param {number} id
* @returns {string}
*/
export function getPhotoFileUrl(id) {
return `${API_BASE}/photo/${id}/file`;
}

22
web/src/lib/api/types.js Normal file
View File

@@ -0,0 +1,22 @@
/**
* @typedef {Object} Album
* @property {number} id
* @property {string} name
* @property {string} createdAt
* @property {string} updatedAt
*/
/**
* @typedef {Object} Photo
* @property {number} id
* @property {number} albumId
* @property {string} filePath
* @property {string} fileName
* @property {number} fileSize
* @property {string} mimeType
* @property {number|null} width
* @property {number|null} height
* @property {string} createdAt
*/
export const types = {};

View File

@@ -1,2 +1,124 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
<script>
import { onMount } from 'svelte';
import { localizeHref } from '$lib/paraglide/runtime';
import { getAlbums } from '$lib/api/client';
import { albums, loading, no_albums } from '$lib/paraglide/messages';
/** @type {import('$lib/api/types').Album[]} */
let albums_list = $state([]);
let loading_state = $state(true);
let error = $state(/** @type {string|null} */ (null));
onMount(async () => {
try {
albums_list = await getAlbums();
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
} finally {
loading_state = false;
}
});
</script>
<svelte:head>
<title>{albums()}</title>
</svelte:head>
<div class="container">
<header class="header">
<h1>{albums()}</h1>
</header>
{#if loading_state}
<div class="loading">{loading()}</div>
{:else if error}
<div class="error">{error}</div>
{:else if albums_list.length === 0}
<div class="empty">{no_albums()}</div>
{:else}
<div class="album-grid">
{#each albums_list as album (album.id)}
<a href="/album/{album.id}" class="album-card">
<div class="album-icon">📁</div>
<h3 class="album-name">{album.name}</h3>
<p class="album-date">
{new Date(album.createdAt).toLocaleDateString()}
</p>
</a>
{/each}
</div>
{/if}
</div>
<style>
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.header {
margin-bottom: 2rem;
}
.header h1 {
font-size: 2rem;
font-weight: 700;
color: #1a1a1a;
}
.loading,
.empty,
.error {
text-align: center;
padding: 4rem 2rem;
color: #666;
}
.error {
color: #dc2626;
}
.album-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1.5rem;
}
.album-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
background: #f9fafb;
border-radius: 12px;
text-decoration: none;
color: inherit;
transition:
transform 0.2s,
box-shadow 0.2s;
}
.album-card:hover {
transform: translateY(-4px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
.album-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.album-name {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 0.5rem 0;
color: #1a1a1a;
}
.album-date {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
}
</style>

View File

@@ -0,0 +1,302 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/state';
import { getAlbum, getAlbumPhotos, getPhotoFileUrl } from '$lib/api/client';
import { albums, loading, no_albums, back, photo_count } from '$lib/paraglide/messages';
/** @type {import('$lib/api/types').Album|null} */
let album = $state(null);
/** @type {import('$lib/api/types').Photo[]} */
let photos = $state([]);
let loading_state = $state(true);
let error = $state(/** @type {string|null} */ (null));
// 分批加载配置
const BATCH_SIZE = 100;
let displayedCount = $state(BATCH_SIZE);
/** @type {HTMLDivElement|null} */
let scrollContainer = $state(null);
/** @type {IntersectionObserver|null} */
let observer = $state(null);
/** @type {HTMLDivElement|null} */
let loadMoreTrigger = $state(null);
onMount(async () => {
const albumId = parseInt(page.params.id ?? '');
if (isNaN(albumId)) {
error = 'Invalid album id';
loading_state = false;
return;
}
try {
[album, photos] = await Promise.all([getAlbum(albumId), getAlbumPhotos(albumId)]);
if (!album) {
error = 'Album not found';
}
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
} finally {
loading_state = false;
}
});
onMount(() => {
// 设置 Intersection Observer 监听加载更多
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && displayedCount < photos.length) {
loadMore();
}
},
{
root: scrollContainer,
rootMargin: '200px',
threshold: 0
}
);
return () => {
if (observer) {
observer.disconnect();
}
};
});
onMount(() => {
if (loadMoreTrigger && observer) {
observer.observe(loadMoreTrigger);
}
});
function loadMore() {
if (displayedCount >= photos.length) return;
displayedCount = Math.min(displayedCount + BATCH_SIZE, photos.length);
}
function getVisiblePhotos() {
return photos.slice(0, displayedCount);
}
</script>
<svelte:head>
<title>{album ? `${album.name} - ${albums()}` : loading()}</title>
</svelte:head>
<div class="container">
<header class="header">
<a href="/" class="back-link">{back()}</a>
{#if album}
<h1>{album.name}</h1>
<p class="photo-count">
{photo_count({ count: photos.length })}
</p>
{/if}
</header>
{#if loading_state}
<div class="loading">{loading()}</div>
{:else if error}
<div class="error">{error}</div>
{:else if photos.length === 0}
<div class="empty">{no_albums()}</div>
{:else}
<div class="photo-scroll-container" bind:this={scrollContainer}>
<div class="photo-grid">
{#each getVisiblePhotos() as photo (photo.id)}
<a href="/photo/{photo.id}" class="photo-card">
<div class="photo-wrapper">
{#if photo.mimeType.startsWith('video/')}
<div class="video-indicator">🎬</div>
<div class="photo-placeholder">
<span>{photo.fileName}</span>
</div>
{:else}
<img
src={getPhotoFileUrl(photo.id)}
alt={photo.fileName}
loading="lazy"
decoding="async"
/>
{/if}
</div>
<p class="photo-name">{photo.fileName}</p>
</a>
{/each}
</div>
{#if displayedCount < photos.length}
<div class="load-more-trigger" bind:this={loadMoreTrigger}>
<div class="loading-more">{loading()}</div>
</div>
{:else}
<div class="load-complete">
✓ 已加载全部 {photos.length} 张照片
</div>
{/if}
</div>
{/if}
</div>
<style>
.container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
height: calc(100vh - 4rem);
display: flex;
flex-direction: column;
}
.header {
margin-bottom: 1rem;
flex-shrink: 0;
}
.back-link {
display: inline-block;
margin-bottom: 1rem;
color: #3b82f6;
text-decoration: none;
font-size: 1rem;
}
.back-link:hover {
text-decoration: underline;
}
.header h1 {
font-size: 2rem;
font-weight: 700;
color: #1a1a1a;
margin: 0 0 0.5rem 0;
}
.photo-count {
font-size: 1rem;
color: #6b7280;
margin: 0;
}
.loading,
.empty,
.error {
text-align: center;
padding: 4rem 2rem;
color: #666;
}
.error {
color: #dc2626;
}
.photo-scroll-container {
flex: 1;
overflow-y: auto;
scroll-behavior: smooth;
}
.photo-scroll-container::-webkit-scrollbar {
width: 8px;
}
.photo-scroll-container::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.photo-scroll-container::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
.photo-scroll-container::-webkit-scrollbar-thumb:hover {
background: #a1a1a1;
}
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
padding-bottom: 2rem;
}
.photo-card {
display: block;
text-decoration: none;
color: inherit;
}
.photo-wrapper {
position: relative;
aspect-ratio: 1;
background: #f3f4f6;
border-radius: 8px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.photo-wrapper img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
background: #e5e7eb;
}
.photo-card:hover .photo-wrapper img {
transform: scale(1.05);
}
.photo-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #6b7280;
font-size: 0.875rem;
padding: 1rem;
text-align: center;
}
.video-indicator {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
z-index: 1;
}
.photo-name {
font-size: 0.875rem;
color: #374151;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.load-more-trigger {
text-align: center;
padding: 2rem;
}
.loading-more {
display: inline-block;
color: #6b7280;
}
.load-complete {
text-align: center;
padding: 2rem;
color: #10b981;
font-size: 0.875rem;
}
</style>

View 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>

View File

@@ -10,6 +10,11 @@ export default defineConfig({
devtoolsJson(),
paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide' })
],
server: {
proxy: {
'/api': 'http://localhost:8080'
}
},
test: {
expect: { requireAssertions: true },
projects: [