From f2b7b8e5718608978fb88ac5b577c99f68692c3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Mint=C4=9Bl?= Date: Wed, 15 Jul 2026 15:27:10 +0200 Subject: [PATCH] feat: add ability to reorder album items --- migrations/20260605000000_edit_tokens.ts | 26 +++ .../20260715153000_add_album_images_order.ts | 14 ++ src/lib/server/routes/albums.ts | 66 ++++++- src/routes/album/[id]/+page.svelte | 180 ++++++++++++++++-- src/types/database.ts | 12 ++ 5 files changed, 276 insertions(+), 22 deletions(-) create mode 100644 migrations/20260605000000_edit_tokens.ts create mode 100644 migrations/20260715153000_add_album_images_order.ts diff --git a/migrations/20260605000000_edit_tokens.ts b/migrations/20260605000000_edit_tokens.ts new file mode 100644 index 0000000..8038070 --- /dev/null +++ b/migrations/20260605000000_edit_tokens.ts @@ -0,0 +1,26 @@ +/*eslint-disable @typescript-eslint/no-explicit-any*/ + +import { Kysely, sql } from 'kysely'; + +export const up = async (conn: Kysely) => { + await conn.schema + .createTable('edit_tokens') + .addColumn('id', 'integer', (col) => col.primaryKey().autoIncrement()) + .addColumn('file_id', 'varchar(36)', (col) => + col.references('files.id').onDelete('cascade').notNull() + ) + .addColumn('created_by', 'integer', (col) => + col.references('users.id').onDelete('cascade').notNull() + ) + .addColumn('token_hash', 'varchar(255)', (col) => col.notNull().unique()) + .addColumn('created_at', 'datetime', (col) => + col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) + ) + .addColumn('expires_at', 'datetime', (col) => col.notNull()) + .addColumn('consumed_at', 'datetime') + .execute(); +}; + +export const down = async (conn: Kysely) => { + await conn.schema.dropTable('edit_tokens').execute(); +}; diff --git a/migrations/20260715153000_add_album_images_order.ts b/migrations/20260715153000_add_album_images_order.ts new file mode 100644 index 0000000..6ff49ca --- /dev/null +++ b/migrations/20260715153000_add_album_images_order.ts @@ -0,0 +1,14 @@ +/*eslint-disable @typescript-eslint/no-explicit-any*/ + +import { Kysely } from 'kysely'; + +export const up = async (conn: Kysely) => { + await conn.schema + .alterTable('album_images') + .addColumn('display_order', 'integer', (col) => col.notNull().defaultTo(0)) + .execute(); +}; + +export const down = async (conn: Kysely) => { + await conn.schema.alterTable('album_images').dropColumn('display_order').execute(); +}; diff --git a/src/lib/server/routes/albums.ts b/src/lib/server/routes/albums.ts index 7cb4dbb..8b58043 100644 --- a/src/lib/server/routes/albums.ts +++ b/src/lib/server/routes/albums.ts @@ -55,7 +55,13 @@ export const albumsRouter = { // Add images to album await conn .insertInto('album_images') - .values(input.fileIds.map((fileId) => ({ album_id: albumId, file_id: fileId }))) + .values( + input.fileIds.map((fileId, index) => ({ + album_id: albumId, + file_id: fileId, + display_order: index + })) + ) .execute(); return { @@ -109,11 +115,24 @@ export const albumsRouter = { } satisfies ErrorApiResponse; } - for (const file of files) { + const maxOrderResult = await conn + .selectFrom('album_images') + .select(({ fn }) => fn.max('display_order').as('max_order')) + .where('album_id', '=', input.albumId) + .executeTakeFirst(); + + const maxOrder = Number(maxOrderResult?.max_order ?? -1); + + for (let i = 0; i < files.length; i++) { + const file = files[i]; await conn .insertInto('album_images') .ignore() - .values({ album_id: input.albumId, file_id: file.id }) + .values({ + album_id: input.albumId, + file_id: file.id, + display_order: maxOrder + i + 1 + }) .execute(); } @@ -138,8 +157,14 @@ export const albumsRouter = { const images = await conn .selectFrom('album_images') .innerJoin('files', 'files.id', 'album_images.file_id') - .select(['files.id', 'files.original_name', 'files.mime_type']) + .select([ + 'files.id', + 'files.original_name', + 'files.mime_type', + 'album_images.display_order' + ]) .where('album_images.album_id', '=', input.id) + .orderBy('album_images.display_order', 'asc') .execute(); return { @@ -210,5 +235,38 @@ export const albumsRouter = { return { status: true } as const; + }), + reorder: authProcedure.POST.input( + z.object({ + albumId: z.string(), + fileIds: z.array(z.string()) + }) + ).query(async ({ input, ctx }) => { + const album = await conn + .selectFrom('albums') + .select(['id', 'created_by']) + .where('id', '=', input.albumId) + .executeTakeFirst(); + + if (!album || album.created_by !== ctx.id) { + return { + status: false, + code: 403, + message: 'Album not found or unauthorized' + } satisfies ErrorApiResponse; + } + + await conn.transaction().execute(async (trx) => { + for (let i = 0; i < input.fileIds.length; i++) { + await trx + .updateTable('album_images') + .set({ display_order: i }) + .where('album_id', '=', input.albumId) + .where('file_id', '=', input.fileIds[i]) + .execute(); + } + }); + + return { status: true } as const; }) }; diff --git a/src/routes/album/[id]/+page.svelte b/src/routes/album/[id]/+page.svelte index fa37083..31e189d 100644 --- a/src/routes/album/[id]/+page.svelte +++ b/src/routes/album/[id]/+page.svelte @@ -2,12 +2,35 @@ import { resolve } from '$app/paths'; import * as Card from '$lib/components/ui/card/index.js'; import CirclePlay from '@lucide/svelte/icons/circle-play'; + import Pencil from '@lucide/svelte/icons/pencil'; + import Check from '@lucide/svelte/icons/check'; + import X from '@lucide/svelte/icons/x'; + import GripVertical from '@lucide/svelte/icons/grip-vertical'; + import { Button } from '$lib/components/ui/button/index.js'; + import { API } from '$/lib/api'; + import { toast } from 'svelte-sonner'; + import { invalidateAll } from '$app/navigation'; + import { flip } from 'svelte/animate'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); const album = $derived(data.album); - const images = $derived(data.images); + + let isEditing = $state(false); + let orderedImages = $state([]); + let isSaving = $state(false); + + $effect(() => { + if (!isEditing) { + orderedImages = [...data.images]; + } + }); + + const isOwner = $derived(data.user?.id === album.created_by); + + let draggedIndex = $state(null); + let dragOverIndex = $state(null); function getExt(filename: string) { return filename.substring(filename.lastIndexOf('.')); @@ -20,6 +43,64 @@ day: 'numeric' }); } + + function handleDragStart(e: DragEvent, index: number) { + if (!isEditing) return; + draggedIndex = index; + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', index.toString()); + } + } + + function handleDragOver(e: DragEvent, index: number) { + if (!isEditing || draggedIndex === null) return; + e.preventDefault(); + if (draggedIndex === index) return; + dragOverIndex = index; + } + + function handleDrop(e: DragEvent, index: number) { + if (!isEditing || draggedIndex === null) return; + e.preventDefault(); + + if (draggedIndex !== index) { + const items = [...orderedImages]; + const [draggedItem] = items.splice(draggedIndex, 1); + items.splice(index, 0, draggedItem); + orderedImages = items; + } + + draggedIndex = null; + dragOverIndex = null; + } + + function handleDragEnd() { + draggedIndex = null; + dragOverIndex = null; + } + + async function saveOrder() { + isSaving = true; + const res = await API.albums.reorder({ + albumId: album.id, + fileIds: orderedImages.map((i) => i.id) + }); + isSaving = false; + + if (res.status) { + toast.success('Album order saved successfully'); + isEditing = false; + await invalidateAll(); + } else { + toast.error(res.message || 'Failed to save album order'); + } + } + + function cancelEdit() { + isEditing = false; + orderedImages = [...data.images]; + } @@ -27,16 +108,46 @@
-
- {#if album.name} -

{album.name}

+
+
+ {#if album.name} +

{album.name}

+ {/if} +

+ Created on {formatDate(album.created_at)} +

+
+ {#if isOwner} +
+ {#if isEditing} + + + {:else} + + {/if} +
{/if} -

- Created on {formatDate(album.created_at)} -

- {#if images.length === 0} + {#if isEditing} +
+ + Drag and drop the cards below to reorder items in the album. +
+ {/if} + + {#if orderedImages.length === 0} No images in album @@ -47,22 +158,32 @@ {:else} {/if} diff --git a/src/types/database.ts b/src/types/database.ts index c3c3fc9..688d6c7 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -12,6 +12,7 @@ export type Generated = export interface AlbumImages { album_id: string; + display_order: Generated; file_id: string; } @@ -30,6 +31,16 @@ export interface ApiKeys { user_id: number; } +export interface EditTokens { + consumed_at: Generated; + created_at: Generated; + created_by: number; + expires_at: Date; + file_id: string; + id: Generated; + token_hash: string; +} + export interface Files { id: string; mime_type: string; @@ -62,6 +73,7 @@ export interface DB { album_images: AlbumImages; albums: Albums; api_keys: ApiKeys; + edit_tokens: EditTokens; files: Files; folder_files: FolderFiles; folders: Folders;