diff --git a/app/components/album.tsx b/app/components/album.tsx index a14193b..c8188f9 100644 --- a/app/components/album.tsx +++ b/app/components/album.tsx @@ -1,6 +1,6 @@ -import { Link } from "@remix-run/react"; -import { formatRelative } from "date-fns"; -import { sv } from "date-fns/locale"; +import { Link } from '@remix-run/react'; +import { formatRelative } from 'date-fns'; +import { sv } from 'date-fns/locale'; type Props = { album: { @@ -14,19 +14,21 @@ type Props = { export function Album({ album }: Props) { return ( - -
+ +
{album.name}
-
- {album.name} - +
+ {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' > fotografi{albumName}
- + {/* */}
fotografi
-
+
Info diff --git a/app/routes/_main.album.$id._index.tsx b/app/routes/_main.album.$id._index.tsx index 633937e..380653a 100644 --- a/app/routes/_main.album.$id._index.tsx +++ b/app/routes/_main.album.$id._index.tsx @@ -1,4 +1,4 @@ -import { LoaderFunctionArgs } from '@remix-run/node'; +import type { LoaderFunctionArgs } from '@remix-run/node'; import { Link, useLoaderData } from '@remix-run/react'; import { AutoGrid } from '~/components/autogrid'; import { getAlbum } from '~/lib/data.server'; @@ -30,10 +30,12 @@ export default function Page() { className='overflow-hidden rounded-lg' > diff --git a/app/routes/api.image.$id.tsx b/app/routes/api.image.$id.tsx index 06d4800..06a96fb 100644 --- a/app/routes/api.image.$id.tsx +++ b/app/routes/api.image.$id.tsx @@ -1,13 +1,14 @@ -import { - createReadableStreamFromReadable, - type LoaderFunctionArgs, -} from '@remix-run/node'; +import { type LoaderFunctionArgs } from '@remix-run/node'; import { getImage } from '~/lib/data.server'; -import { getImagePath } from '~/lib/storage.server'; -import { createReadStream } from 'node:fs'; -import { stat } from 'node:fs/promises'; import { assertResponse } from '~/lib/utils'; import { checkRole } from '~/lib/auth.server'; +import { getThumbnailStream } from '~/lib/storage/thumbnail'; +import { getPreviewStream } from '~/lib/storage/preview'; +import { getImageStream } from '~/lib/storage/image'; +import type { ImageStream } from '~/lib/storage/types'; +import { extension } from 'mime-types'; + +const ensure = true; export const loader = async ({ params, request }: LoaderFunctionArgs) => { const { passed } = await checkRole(['read:album'])(request); @@ -18,18 +19,29 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { const data = await getImage(id, passed); assertResponse(data, 'Image not found', { status: 404 }); - const imagePath = getImagePath(data.image); - const fileStat = await stat(imagePath); - - return new Response( - createReadableStreamFromReadable(createReadStream(imagePath)), - { - headers: { - 'Content-Type': data.image.mimetype ?? '', - 'Content-Length': fileStat.size.toString(), - 'Content-Disposition': `inline; filename="${data.image.id}"`, - 'Cache-Control': 'public, max-age=604800, immutable', - }, - } - ); + const query = new URL(request.url).searchParams; + const thumbnail = query.get('thumbnail') != null; + const preview = query.get('preview') != null; + + assertResponse(!(thumbnail && preview), 'Invalid query parameters'); + + let imageStream: ImageStream; + if (thumbnail) { + imageStream = await getThumbnailStream(data.image, ensure); + } else if (preview) { + imageStream = await getPreviewStream(data.image, ensure); + } else { + imageStream = await getImageStream(data.image, ensure); + } + + const ext = data.image.mimetype ? extension(data.image.mimetype) || '' : ''; + + return new Response(imageStream.stream, { + headers: { + 'Content-Type': imageStream.mimetype ?? 'application/octet-stream', + 'Content-Length': imageStream.size?.toString() ?? '', + 'Content-Disposition': `inline; filename="${imageStream.id}.${ext}"`, + 'Cache-Control': 'public, max-age=604800, immutable', + }, + }); }; diff --git a/app/routes/api.upload.tsx b/app/routes/api.upload.tsx index 05842ce..45fa2eb 100644 --- a/app/routes/api.upload.tsx +++ b/app/routes/api.upload.tsx @@ -9,13 +9,14 @@ import { json, } from '@remix-run/node'; import { z } from 'zod'; -import { commitUpload, uploadsPath } from '~/lib/storage.server'; import sharp from 'sharp'; import type { InferInsertModel } from 'drizzle-orm'; import { image } from '~/lib/schema.server'; import exif from 'exif-reader'; import { db } from '~/lib/db.server'; import { ensureRole } from '~/lib/auth.server'; +import { uploadsPath } from '~/lib/storage/paths'; +import { commitUpload } from '~/lib/storage/image'; const imageTypes = ['image/jpeg', 'image/png'];