Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions migrations/20260605000000_edit_tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*eslint-disable @typescript-eslint/no-explicit-any*/

import { Kysely, sql } from 'kysely';

export const up = async (conn: Kysely<any>) => {
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<any>) => {
await conn.schema.dropTable('edit_tokens').execute();
};
14 changes: 14 additions & 0 deletions migrations/20260715153000_add_album_images_order.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/*eslint-disable @typescript-eslint/no-explicit-any*/

import { Kysely } from 'kysely';

export const up = async (conn: Kysely<any>) => {
await conn.schema
.alterTable('album_images')
.addColumn('display_order', 'integer', (col) => col.notNull().defaultTo(0))
.execute();
};

export const down = async (conn: Kysely<any>) => {
await conn.schema.alterTable('album_images').dropColumn('display_order').execute();
};
66 changes: 62 additions & 4 deletions src/lib/server/routes/albums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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;
})
};
180 changes: 162 additions & 18 deletions src/routes/album/[id]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof data.images>([]);
let isSaving = $state(false);

$effect(() => {
if (!isEditing) {
orderedImages = [...data.images];
}
});

const isOwner = $derived(data.user?.id === album.created_by);

let draggedIndex = $state<number | null>(null);
let dragOverIndex = $state<number | null>(null);

function getExt(filename: string) {
return filename.substring(filename.lastIndexOf('.'));
Expand All @@ -20,23 +43,111 @@
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];
}
</script>

<svelte:head>
<title>{album.name || 'Album'} | Uploader</title>
</svelte:head>

<div class="container mx-auto max-w-7xl p-4">
<div class="mb-6">
{#if album.name}
<h1 class="text-3xl font-bold">{album.name}</h1>
<div class="mb-6 flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div>
{#if album.name}
<h1 class="text-3xl font-bold">{album.name}</h1>
{/if}
<p class="text-muted-foreground">
Created on {formatDate(album.created_at)}
</p>
</div>
{#if isOwner}
<div class="flex items-center gap-2">
{#if isEditing}
<Button variant="outline" size="sm" onclick={cancelEdit} disabled={isSaving}>
<X class="mr-2 h-4 w-4" />
Cancel
</Button>
<Button variant="default" size="sm" onclick={saveOrder} disabled={isSaving}>
<Check class="mr-2 h-4 w-4" />
{isSaving ? 'Saving...' : 'Save Order'}
</Button>
{:else}
<Button variant="outline" size="sm" onclick={() => (isEditing = true)}>
<Pencil class="mr-2 h-4 w-4" />
Edit Order
</Button>
{/if}
</div>
{/if}
<p class="text-muted-foreground">
Created on {formatDate(album.created_at)}
</p>
</div>

{#if images.length === 0}
{#if isEditing}
<div
class="mb-4 flex items-center gap-2 rounded-lg border border-primary/20 bg-primary/10 p-3 text-sm text-primary"
>
<GripVertical class="h-4 w-4 shrink-0" />
<span>Drag and drop the cards below to reorder items in the album.</span>
</div>
{/if}

{#if orderedImages.length === 0}
<Card.Root class="border-destructive/50 bg-destructive/10">
<Card.Header>
<Card.Title class="text-destructive">No images in album</Card.Title>
Expand All @@ -47,35 +158,68 @@
</Card.Root>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each images as image (image.id)}
{#each orderedImages as image, index (image.id)}
{@const isVideo = image.mime_type.startsWith('video/')}
<a
href={resolve(
`/raw/${isVideo ? 'videos' : 'images'}/${image.id}${getExt(image.original_name)}`
)}
target="_blank"
rel="noopener noreferrer"
class="group relative aspect-square overflow-hidden rounded-lg bg-muted transition-transform hover:scale-105"
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
animate:flip={{ duration: 250 }}
draggable={isEditing}
ondragstart={(e) => handleDragStart(e, index)}
ondragover={(e) => handleDragOver(e, index)}
ondrop={(e) => handleDrop(e, index)}
ondragend={handleDragEnd}
class="group relative aspect-square overflow-hidden rounded-lg bg-muted transition-all duration-200
{isEditing
? 'cursor-grab border-2 border-transparent active:cursor-grabbing'
: 'hover:scale-105'}
{isEditing && draggedIndex === index ? 'scale-95 opacity-40' : ''}
{isEditing && dragOverIndex === index && draggedIndex !== index
? 'scale-105 border-dashed border-primary shadow-lg'
: ''}"
>
<img
src="/raw/images/{image.id}{getExt(image.original_name)}?width=400"
alt={image.original_name}
class="h-full w-full object-cover"
class="pointer-events-none h-full w-full object-cover select-none"
loading="lazy"
/>

{#if isVideo}
<div
class="absolute inset-0 flex items-center justify-center bg-black/20 transition-colors group-hover:bg-black/40"
>
<CirclePlay class="h-12 w-12 text-white/80" />
</div>
{/if}

{#if isEditing}
<!-- Premium drag handle overlay -->
<div
class="absolute top-2 left-2 flex h-8 w-8 items-center justify-center rounded-full bg-black/60 text-white shadow-md transition-opacity"
>
<GripVertical class="h-4 w-4" />
</div>
{/if}

{#if !isEditing}
<a
href={resolve(
`/raw/${isVideo ? 'videos' : 'images'}/${image.id}${getExt(image.original_name)}`
)}
target="_blank"
rel="noopener noreferrer"
class="absolute inset-0 z-10"
>
<span class="sr-only">View full media</span>
</a>
{/if}

<div
class="absolute right-0 bottom-0 left-0 bg-black/60 p-2 text-sm text-white opacity-0 transition-opacity group-hover:opacity-100"
>
<p class="truncate" title={image.original_name}>{image.original_name}</p>
</div>
</a>
</div>
{/each}
</div>
{/if}
Expand Down
Loading
Loading