+
+ {album.name}
+
{formatRelative(album.created_at, new Date(), {
locale: sv,
weekStartsOn: 1,
diff --git a/app/components/dynamic-breadcrum.tsx b/app/components/dynamic-breadcrum.tsx
index ce3fc70..5278f4f 100644
--- a/app/components/dynamic-breadcrum.tsx
+++ b/app/components/dynamic-breadcrum.tsx
@@ -11,6 +11,7 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from './ui/breadcrumb';
+import { Fragment } from 'react/jsx-runtime';
export type CrumbHandle = {
breadcrumb: (
@@ -55,12 +56,12 @@ export function DynamicBreadcrum() {
match.handle.breadcrumb(match, match.pathname === pathname)
)
.map((match, i) => (
- <>
+
{i !== 0 && }
-
+
- >
+
))}
diff --git a/app/lib/session.server.ts b/app/lib/session.server.ts
index d5ba9be..8ec5893 100644
--- a/app/lib/session.server.ts
+++ b/app/lib/session.server.ts
@@ -4,7 +4,7 @@ import {
createFileSessionStorage,
} from '@remix-run/node';
import { join } from 'path';
-import { storagePath } from './storage.server';
+import { storagePath } from './storage/paths';
// export the whole sessionStorage object
/*export const sessionStorage = createCookieSessionStorage({
diff --git a/app/lib/storage.server.ts b/app/lib/storage.server.ts
deleted file mode 100644
index dae1f03..0000000
--- a/app/lib/storage.server.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { extension } from 'mime-types';
-import { join } from 'path';
-import { mkdir, rename } from 'fs/promises';
-
-export const storagePath = process.env.STORAGE_PATH ?? './storage';
-export const imagePath = join(storagePath, 'images');
-export const uploadsPath = join(storagePath, 'uploads');
-
-export function getImagePath(image: {
- id: number;
- album_id: number;
- mimetype?: string | null;
-}): string {
- const ext = image.mimetype ? extension(image.mimetype) || '' : '';
- const filename = `${image.id}.${ext}`;
- const path = join(imagePath, image.album_id.toString(), filename);
- return path;
-}
-
-export async function commitUpload(
- stagePath: string,
- image: { id: number; album_id: number; mimetype?: string | null }
-) {
- const ext = image.mimetype ? extension(image.mimetype) || '' : '';
- const filename = `${image.id}.${ext}`;
- const dest = join(imagePath, image.album_id.toString(), filename);
- await mkdir(join(imagePath, image.album_id.toString()), {
- recursive: true,
- });
- await rename(stagePath, dest);
-}
diff --git a/app/lib/storage/image.ts b/app/lib/storage/image.ts
new file mode 100644
index 0000000..781ef15
--- /dev/null
+++ b/app/lib/storage/image.ts
@@ -0,0 +1,49 @@
+import {
+ createReadableStreamFromReadable,
+ writeReadableStreamToWritable,
+} from '@remix-run/node';
+import { createReadStream, createWriteStream } from 'node:fs';
+import { dirname } from 'node:path';
+import { ImageError, type ImageRecord, type ImageStream } from './types';
+import { safeStat } from './utils';
+import { getLegacyImageStream } from './legacy';
+import { stat, mkdir, rename } from 'node:fs/promises';
+import { createOptimized } from './optimizer';
+import { getImagePath, getPreviewPath, getThumbnailPath } from './paths';
+
+export async function ensureImage(image: ImageRecord) {
+ const path = getImagePath(image);
+ let stats = await safeStat(path);
+ if (stats != null) return stats;
+ const imageStream = await getLegacyImageStream(image);
+ await mkdir(dirname(path), { recursive: true });
+ await writeReadableStreamToWritable(
+ imageStream.stream,
+ createWriteStream(path)
+ );
+ stats = await safeStat(path);
+ if (stats == null) throw new ImageError('image', image.id);
+ return stats;
+}
+
+export async function getImageStream(
+ image: ImageRecord,
+ ensure = false
+): Promise {
+ const path = getImagePath(image);
+ const stats = ensure ? await ensureImage(image) : await stat(path);
+ const stream = createReadableStreamFromReadable(createReadStream(path));
+ return {
+ id: image.id,
+ stream,
+ mimetype: image.mimetype ?? 'application/octet-stream',
+ size: stats.size,
+ };
+}
+
+export async function commitUpload(stagePath: string, image: ImageRecord) {
+ const path = getImagePath(image);
+ await mkdir(dirname(path), { recursive: true });
+ await rename(stagePath, path);
+ createOptimized(path, getThumbnailPath(image), getPreviewPath(image));
+}
diff --git a/app/lib/storage/legacy.ts b/app/lib/storage/legacy.ts
new file mode 100644
index 0000000..4cf7254
--- /dev/null
+++ b/app/lib/storage/legacy.ts
@@ -0,0 +1,18 @@
+import { ImageError, type ImageRecord, type ImageStream } from './types';
+
+export async function getLegacyImageStream(
+ image: ImageRecord
+): Promise {
+ const id = image.legacy_id;
+ if (!id) throw new ImageError('legacy', 'missing');
+ const res = await fetch(
+ `https://dfoto.se/v1/image/${image.legacy_id}/fullSize`
+ );
+ if (!res.ok || !res.body) throw new ImageError('legacy', id);
+ return {
+ id: image.id,
+ stream: res.body,
+ mimetype: image.mimetype ?? 'application/octet-stream',
+ size: null,
+ };
+}
diff --git a/app/lib/storage/optimizer.ts b/app/lib/storage/optimizer.ts
new file mode 100644
index 0000000..e0e80f0
--- /dev/null
+++ b/app/lib/storage/optimizer.ts
@@ -0,0 +1,31 @@
+import sharp from 'sharp';
+
+export async function createThumbnail(input: string, output: string) {
+ await sharp(input)
+ .resize(300, 200, { fit: 'cover', position: sharp.strategy.entropy })
+ .rotate()
+ .webp()
+ .toFile(output);
+}
+
+export async function createPreview(input: string, output: string) {
+ await sharp(input)
+ .resize(1200, 800, { fit: 'inside' })
+ .rotate()
+ .webp()
+ .toFile(output);
+}
+
+export async function createOptimized(
+ input: string,
+ thumbnailOutput: string,
+ previewOutput: string
+) {
+ const pipeline = sharp(input).rotate().webp().clone();
+ await Promise.all([
+ pipeline
+ .resize(300, 200, { fit: 'cover', position: sharp.strategy.entropy })
+ .toFile(thumbnailOutput),
+ pipeline.resize(null, 800).toFile(previewOutput),
+ ]);
+}
diff --git a/app/lib/storage/paths.ts b/app/lib/storage/paths.ts
new file mode 100644
index 0000000..edb1cda
--- /dev/null
+++ b/app/lib/storage/paths.ts
@@ -0,0 +1,31 @@
+import { extension } from 'mime-types';
+import { join } from 'node:path';
+
+export const storagePath = process.env.STORAGE_PATH ?? './storage';
+export const imagePath = join(storagePath, 'images');
+export const thumbnailPath = join(storagePath, 'thumbnails');
+export const previewPath = join(storagePath, 'previews');
+export const uploadsPath = join(storagePath, 'uploads');
+
+export function getImagePath(image: {
+ id: number;
+ album_id: number;
+ mimetype?: string | null;
+}): string {
+ const ext = image.mimetype ? extension(image.mimetype) || '' : '';
+ return join(imagePath, image.album_id.toString(), `${image.id}.${ext}`);
+}
+
+export function getThumbnailPath(image: {
+ id: number;
+ album_id: number;
+}): string {
+ return join(thumbnailPath, image.album_id.toString(), `${image.id}.webp`);
+}
+
+export function getPreviewPath(image: {
+ id: number;
+ album_id: number;
+}): string {
+ return join(previewPath, image.album_id.toString(), `${image.id}.webp`);
+}
diff --git a/app/lib/storage/preview.ts b/app/lib/storage/preview.ts
new file mode 100644
index 0000000..0869f9d
--- /dev/null
+++ b/app/lib/storage/preview.ts
@@ -0,0 +1,37 @@
+import { createReadStream } from 'node:fs';
+import { ImageError, type ImageRecord, type ImageStream } from './types';
+import { safeStat } from './utils';
+import { createReadableStreamFromReadable } from '@remix-run/node';
+import { createPreview } from './optimizer';
+import { stat, mkdir } from 'node:fs/promises';
+import { getImagePath, getPreviewPath } from './paths';
+import { ensureImage } from './image';
+import { dirname } from 'node:path';
+
+async function ensurePreview(image: ImageRecord) {
+ const path = getPreviewPath(image);
+ let stats = await safeStat(path);
+ if (stats != null) return stats;
+ await ensureImage(image);
+ const imagePath = getImagePath(image);
+ mkdir(dirname(path), { recursive: true });
+ await createPreview(imagePath, path);
+ stats = await safeStat(path);
+ if (stats == null) throw new ImageError('image', image.id);
+ return stats;
+}
+
+export async function getPreviewStream(
+ image: ImageRecord,
+ ensure = false
+): Promise {
+ const path = getPreviewPath(image);
+ const stats = ensure ? await ensurePreview(image) : await stat(path);
+ const stream = createReadableStreamFromReadable(createReadStream(path));
+ return {
+ id: image.id,
+ stream,
+ mimetype: 'image/webp',
+ size: stats.size,
+ };
+}
diff --git a/app/lib/storage/thumbnail.ts b/app/lib/storage/thumbnail.ts
new file mode 100644
index 0000000..ae1652b
--- /dev/null
+++ b/app/lib/storage/thumbnail.ts
@@ -0,0 +1,37 @@
+import { createReadStream } from 'node:fs';
+import { ImageError, type ImageRecord, type ImageStream } from './types';
+import { safeStat } from './utils';
+import { createReadableStreamFromReadable } from '@remix-run/node';
+import { ensureImage } from './image';
+import { createThumbnail } from './optimizer';
+import { stat, mkdir } from 'node:fs/promises';
+import { getImagePath, getThumbnailPath } from './paths';
+import { dirname } from 'node:path';
+
+async function ensureThumbnail(image: ImageRecord) {
+ const path = getThumbnailPath(image);
+ let stats = await safeStat(path);
+ if (stats != null) return stats;
+ await ensureImage(image);
+ const imagePath = getImagePath(image);
+ mkdir(dirname(path), { recursive: true });
+ await createThumbnail(imagePath, path);
+ stats = await safeStat(path);
+ if (stats == null) throw new ImageError('image', image.id);
+ return stats;
+}
+
+export async function getThumbnailStream(
+ image: ImageRecord,
+ ensure = false
+): Promise {
+ const path = getThumbnailPath(image);
+ const stats = ensure ? await ensureThumbnail(image) : await stat(path);
+ const stream = createReadableStreamFromReadable(createReadStream(path));
+ return {
+ id: image.id,
+ stream,
+ mimetype: 'image/webp',
+ size: stats.size,
+ };
+}
diff --git a/app/lib/storage/types.ts b/app/lib/storage/types.ts
new file mode 100644
index 0000000..c0c914a
--- /dev/null
+++ b/app/lib/storage/types.ts
@@ -0,0 +1,24 @@
+export class ImageError extends Error {
+ type: 'legacy' | 'image';
+ id: string | number;
+ constructor(type: 'legacy' | 'image', id: string | number) {
+ super(`Failed to fetch ${type} ${id}`);
+ this.name = 'ImageError';
+ this.id = id;
+ this.type = type;
+ }
+}
+
+export interface ImageStream {
+ id: number;
+ stream: ReadableStream;
+ mimetype: string | null;
+ size: number | null;
+}
+
+export interface ImageRecord {
+ id: number;
+ album_id: number;
+ mimetype?: string | null;
+ legacy_id?: string | null;
+}
diff --git a/app/lib/storage/utils.ts b/app/lib/storage/utils.ts
new file mode 100644
index 0000000..7882349
--- /dev/null
+++ b/app/lib/storage/utils.ts
@@ -0,0 +1,9 @@
+import { stat } from 'node:fs/promises';
+
+export async function safeStat(path: string) {
+ try {
+ return await stat(path);
+ } catch (e) {
+ return null;
+ }
+}
diff --git a/app/routes/_main._index.tsx b/app/routes/_main._index.tsx
index a7846f7..f1272ad 100644
--- a/app/routes/_main._index.tsx
+++ b/app/routes/_main._index.tsx
@@ -8,20 +8,23 @@ import { Input } from '~/components/ui/input';
import { getAlbums, getPagesCount } from '~/lib/data.server';
export const loader = async ({ request }: LoaderFunctionArgs) => {
- const queryParams = (new URL (request.url)).searchParams;
- if (queryParams.get("page") === '1') throw redirect('/');
- const page = Math.max(queryParams.get("page") ? parseInt(queryParams.get("page")!) : 1, 1);
- const albums = getAlbums(page - 1, 28, queryParams.get("q")!);
- const totalPages = await getPagesCount(28, queryParams.get("q")!);
+ const queryParams = new URL(request.url).searchParams;
+ if (queryParams.get('page') === '1') throw redirect('/');
+ const page = Math.max(
+ queryParams.get('page') ? parseInt(queryParams.get('page')!) : 1,
+ 1
+ );
+ const albums = getAlbums(page - 1, 28, queryParams.get('q')!);
+ const totalPages = await getPagesCount(28, queryParams.get('q')!);
return {
page,
totalPages,
- albums
+ albums,
};
};
export default function Page() {
- const { totalPages, albums } = useLoaderData();
+ const { totalPages, albums, page } = useLoaderData();
return (
<>
@@ -41,7 +44,7 @@ export default function Page() {
-
+
>
);
}
diff --git a/app/routes/_main.admin.$id/columns.tsx b/app/routes/_main.admin.$id/columns.tsx
index 94cd946..39b5fa7 100644
--- a/app/routes/_main.admin.$id/columns.tsx
+++ b/app/routes/_main.admin.$id/columns.tsx
@@ -1,5 +1,3 @@
-'use client';
-
import { SortButton } from '~/components/data-table';
import { Button } from '~/components/ui/button';
import { Checkbox } from '~/components/ui/checkbox';
@@ -16,7 +14,6 @@ import type { ColumnDef } from '@tanstack/react-table';
import { createColumnHelper } from '@tanstack/react-table';
import { format } from 'date-fns';
import { Check, Link as LinkIcon, MoreHorizontal, Trash2 } from 'lucide-react';
-import { extension } from 'mime-types';
import { Link } from '@remix-run/react';
type ItemType = ImageType & { thumbnail: boolean };
@@ -87,22 +84,19 @@ export function createColumns() {
id: 'picture',
header: 'Picture',
cell: (info) => (
-
-
-
+
),
enableSorting: false,
}),
cb.accessor('mimetype', {
header: (info) => Filtyp,
- cell: (info) => extension(info.getValue()),
}),
cb.accessor('taken_at', {
header: (info) => Tagen vid,
diff --git a/app/routes/_main.album.$id.$imageId.tsx b/app/routes/_main.album.$id.$imageId.tsx
index a9cf123..e2d49be 100644
--- a/app/routes/_main.album.$id.$imageId.tsx
+++ b/app/routes/_main.album.$id.$imageId.tsx
@@ -145,7 +145,7 @@ function ImageCarousel({
className='overflow-hidden rounded-lg'
>
{albumName}