添加前端支持
This commit is contained in:
12
.idea/caches/deviceStreaming.xml
generated
12
.idea/caches/deviceStreaming.xml
generated
@@ -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" />
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
60
web/src/lib/api/client.js
Normal 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
22
web/src/lib/api/types.js
Normal 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 = {};
|
||||
@@ -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>
|
||||
|
||||
302
web/src/routes/album/[id]/+page.svelte
Normal file
302
web/src/routes/album/[id]/+page.svelte
Normal 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>
|
||||
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>
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user