Skip to content

Commit d8e1e57

Browse files
authored
Add Image archive (#921)
* add gallery and albums * add album uploading, fix styling, remove unneeded * change swedish translation for gallery * make album responsive * add support for album metadata * do not kill the browser * remove unused code * remove unused code * fix syntax error
1 parent a6a84a0 commit d8e1e57

File tree

14 files changed

+436
-0
lines changed

14 files changed

+436
-0
lines changed

.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ PUBLIC_MINIO_USE_SSL=true
3737
PUBLIC_BUCKETS_DOCUMENTS="documents"
3838
PUBLIC_BUCKETS_FILES="files"
3939
PUBLIC_BUCKETS_MEMBERS="members"
40+
PUBLIC_BUCKETS_ALBUMS="albums"
4041

4142
# YRKA
4243
# Used to send emails for "yrkanden".
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<div class="layout-container">
2+
<slot />
3+
</div>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { PUBLIC_BUCKETS_ALBUMS } from "$env/static/public";
2+
import { fileHandler } from "$lib/files";
3+
import type { FileData } from "$lib/files/fileHandler";
4+
import type { PageServerLoad } from "./$types";
5+
6+
export const load: PageServerLoad = async ({ locals }) => {
7+
const { user } = locals;
8+
9+
const files: FileData[] = await fileHandler
10+
.getInBucket(user, PUBLIC_BUCKETS_ALBUMS, "public/", true)
11+
.catch((err) => {
12+
console.error("Error fetching files", err);
13+
return [];
14+
});
15+
16+
const filesGroupedByAlbum = files.reduce<Record<string, FileData[]>>(
17+
(acc, file) => {
18+
const fileParts = file.id.split("/");
19+
const album = fileParts[fileParts.length - 2] ?? "unknown";
20+
if (!acc[album]) acc[album] = [];
21+
acc[album]!.push(file);
22+
return acc;
23+
},
24+
{},
25+
);
26+
27+
const albumEntries = Object.entries(filesGroupedByAlbum);
28+
return {
29+
albums: albumEntries,
30+
};
31+
};
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<script lang="ts">
2+
import * as m from "$paraglide/messages";
3+
4+
import SetPageTitle from "$lib/components/nav/SetPageTitle.svelte";
5+
import type { PageProps } from "./$types";
6+
import { isAuthorized } from "$lib/utils/authorization";
7+
import { PUBLIC_BUCKETS_ALBUMS } from "$env/static/public";
8+
import apiNames from "$lib/utils/apiNames";
9+
import AlbumCard from "./AlbumCard.svelte";
10+
let { data }: PageProps = $props();
11+
12+
let canCreate = $state(
13+
isAuthorized(
14+
apiNames.FILES.BUCKET(PUBLIC_BUCKETS_ALBUMS).CREATE,
15+
data.user,
16+
),
17+
);
18+
</script>
19+
20+
<SetPageTitle title={m.gallery()} />
21+
22+
<h1 class="text-2xl font-bold">{m.gallery()}</h1>
23+
<div class="flex flex-row flex-wrap justify-between">
24+
{#if canCreate}
25+
<div class="my-4 flex flex-row gap-1">
26+
<a class="btn btn-primary btn-sm" href="/gallery/upload"
27+
>+ {m.gallery_create_album()}</a
28+
>
29+
</div>
30+
{/if}
31+
</div>
32+
33+
<div class="flex flex-col gap-4">
34+
<div
35+
class="grid grid-cols-1 items-stretch justify-items-stretch gap-4 md:grid-cols-2 lg:grid-cols-3"
36+
>
37+
{#each data.albums as album (album)}
38+
<AlbumCard {album} />
39+
{/each}
40+
</div>
41+
</div>
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<script lang="ts">
2+
import * as m from "$paraglide/messages";
3+
let { album } = $props();
4+
</script>
5+
6+
<a href={"./gallery/album/" + album[0]}
7+
><div class="card bg-base-300 shadow-sm">
8+
<figure class="relative">
9+
<div
10+
class="absolute inset-0 flex flex-col items-center justify-center bg-base-300 opacity-0 transition-all hover:bg-opacity-70 hover:opacity-100 group-hover:opacity-100 md:opacity-0"
11+
>
12+
<span class="link text-lg text-neutral-content hover:opacity-100"
13+
>{m.gallery_show_pictures()}</span
14+
>
15+
</div>
16+
<img src={album[1][0]?.thumbnailUrl} alt="Album display" />
17+
</figure>
18+
<div class="card-body pt-5">
19+
<h2 class="card-title text-2xl">{album[0].split(/ (.*)/s)[1]}</h2>
20+
<div class="space-between flex flex-row">
21+
<p>{album[0].split(" ")[0]}</p>
22+
<p class="text-right">
23+
{album[1].length}
24+
{album[1].length > 1 ? m.gallery_pictures() : m.gallery_picture()}
25+
</p>
26+
</div>
27+
</div>
28+
</div>
29+
</a>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { PUBLIC_BUCKETS_ALBUMS } from "$env/static/public";
2+
import { fileHandler } from "$lib/files";
3+
import type { FileData } from "$lib/files/fileHandler";
4+
import type { PageServerLoad } from "./$types";
5+
6+
export const load: PageServerLoad = async ({ locals, params }) => {
7+
const { user } = locals;
8+
const album = params.slug;
9+
10+
let pictures: FileData[] = await fileHandler
11+
.getInBucket(
12+
user,
13+
PUBLIC_BUCKETS_ALBUMS,
14+
"public/" + album.split("-")[0] + "/" + album,
15+
true,
16+
)
17+
.catch((err) => {
18+
console.error("Error fetching files", err);
19+
return [];
20+
});
21+
22+
type Metadata = {
23+
photographer: string;
24+
editor: string;
25+
};
26+
27+
const metadataUrl = pictures.filter((e) => e.name == "album.json")[0]
28+
?.thumbnailUrl;
29+
let metadata: Metadata | undefined;
30+
if (metadataUrl) {
31+
metadata = await fetch(metadataUrl)
32+
.then((res) => res.text())
33+
.then((text) => JSON.parse(text));
34+
}
35+
36+
pictures = pictures.filter((e) => e.name != "album.json");
37+
38+
return {
39+
album: album,
40+
pictures: pictures,
41+
metadata: metadata,
42+
};
43+
};
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<script lang="ts">
2+
import * as m from "$paraglide/messages";
3+
import type { PageProps } from "./$types";
4+
import SetPageTitle from "$lib/components/nav/SetPageTitle.svelte";
5+
6+
let { data }: PageProps = $props();
7+
8+
const albumName = data.album.split(/ (.*)/s)[1];
9+
const albumDate = data.album.split(" ")[0];
10+
11+
let pictureModal: HTMLDialogElement | undefined = $state();
12+
let selectedModalPicture = $state(0);
13+
$effect(() => {
14+
if (selectedModalPicture < 0) {
15+
selectedModalPicture = 0;
16+
}
17+
if (selectedModalPicture > pictures.length - 1 && pictures.length > 0) {
18+
selectedModalPicture = pictures.length - 1;
19+
}
20+
selectedModalUrl = pictures[selectedModalPicture]?.thumbnailUrl;
21+
});
22+
let selectedModalUrl: string | undefined = $state();
23+
24+
let pictures = data.pictures;
25+
26+
let modalPrev: HTMLButtonElement | undefined = $state();
27+
let modalNext: HTMLButtonElement | undefined = $state();
28+
29+
function onKeyDown(e: { key: string }) {
30+
switch (e.key) {
31+
case "ArrowLeft":
32+
modalPrev?.click();
33+
break;
34+
case "ArrowRight":
35+
modalNext?.click();
36+
break;
37+
}
38+
}
39+
</script>
40+
41+
<svelte:window on:keydown={onKeyDown} />
42+
43+
<SetPageTitle title={m.gallery_album() + " - " + albumName} />
44+
45+
<a href="/gallery" class="btn btn-outline btn-sm m-2">{m.gallery_back()}</a>
46+
<div class="rounded-xl bg-base-300 p-7">
47+
<div class="flex flex-col">
48+
<div class="flex flex-row items-center justify-between p-3">
49+
<h1 class="text-2xl font-bold">{albumName}</h1>
50+
<span class="">{albumDate}</span>
51+
</div>
52+
<div class="flex flex-col px-3">
53+
{#if data.metadata}
54+
<p>{m.gallery_photographer() + ": " + data.metadata.photographer}</p>
55+
<p>{m.gallery_editor() + ": " + data.metadata.editor}</p>
56+
{/if}
57+
</div>
58+
</div>
59+
<div
60+
class="flex flex-col items-center gap-4 sm:block sm:columns-2 md:columns-3"
61+
>
62+
{#each pictures as picture, index (picture)}
63+
<button
64+
type="button"
65+
onclick={() => {
66+
selectedModalPicture = index;
67+
pictureModal?.showModal();
68+
}}
69+
class="block text-left leading-[unset]"
70+
><img
71+
class="relative my-4 block object-contain"
72+
src={picture.thumbnailUrl}
73+
alt="display"
74+
/>
75+
</button>
76+
{/each}
77+
</div>
78+
<dialog bind:this={pictureModal} class="modal">
79+
<div class="modal-box flex max-w-[50vw] flex-col justify-center">
80+
<img
81+
src={selectedModalUrl}
82+
class="max-h-[70vh] object-contain"
83+
alt="display"
84+
/>
85+
<div class="m-1 flex flex-row items-center justify-center">
86+
<button
87+
bind:this={modalPrev}
88+
class="btn"
89+
onclick={() => selectedModalPicture--}
90+
><span class="i-mdi-arrow-left"></span>{m.gallery_previous()}</button
91+
>
92+
<button
93+
bind:this={modalNext}
94+
class="btn"
95+
onclick={() => selectedModalPicture++}
96+
><span class="i-mdi-arrow-right"></span>{m.gallery_next()}</button
97+
>
98+
</div>
99+
</div>
100+
<form method="dialog" class="modal-backdrop">
101+
<button>close</button>
102+
</form>
103+
</dialog>
104+
</div>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { fail } from "@sveltejs/kit";
2+
import { zod } from "sveltekit-superforms/adapters";
3+
import { message, superValidate, withFiles } from "sveltekit-superforms/server";
4+
import type { Actions, PageServerLoad } from "./$types";
5+
import { uploadSchema } from "./types";
6+
import { uploadAlbumFiles } from "./uploadFiles";
7+
import { redirect } from "$lib/utils/redirect";
8+
9+
export const load: PageServerLoad = async () => {
10+
const form = await superValidate(zod(uploadSchema));
11+
return { form };
12+
};
13+
14+
export const actions: Actions = {
15+
default: async ({ request, locals }) => {
16+
const { user } = locals;
17+
18+
const form = await superValidate(request, zod(uploadSchema), {
19+
allowFiles: true,
20+
});
21+
22+
if (!form.valid) return fail(400, withFiles({ form }));
23+
try {
24+
await uploadAlbumFiles(user, form.data);
25+
} catch (e) {
26+
return message(
27+
form,
28+
{
29+
message: e instanceof Error ? e.message : `${e}`,
30+
type: "error",
31+
},
32+
{ status: 500 },
33+
);
34+
}
35+
redirect(303, "/gallery/album/" + form.data.date + " " + form.data.name);
36+
},
37+
};
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<script lang="ts">
2+
import Labeled from "$lib/components/Labeled.svelte";
3+
import { type SuperForm } from "sveltekit-superforms/client";
4+
import { superForm } from "$lib/utils/client/superForms";
5+
import type { PageProps } from "./$types";
6+
import * as m from "$paraglide/messages";
7+
import SetPageTitle from "$lib/components/nav/SetPageTitle.svelte";
8+
import { zodClient } from "sveltekit-superforms/adapters";
9+
import { uploadSchema, type UploadSchema } from "./types";
10+
11+
let { data }: PageProps = $props();
12+
13+
const { form, constraints, errors, enhance } = superForm(data.form, {
14+
resetForm: false,
15+
validators: zodClient(uploadSchema),
16+
}) as SuperForm<UploadSchema>;
17+
let fileInput: HTMLInputElement;
18+
19+
let fileErrors = $derived($errors.files as string | string[] | undefined);
20+
</script>
21+
22+
<SetPageTitle title={m.gallery_uploadAlbum()} />
23+
<a href="/gallery" class="btn btn-outline btn-sm my-2">{m.gallery_back()}</a>
24+
<h1 class="text-2xl font-bold">{m.gallery_uploadAlbum()}</h1>
25+
<form
26+
id="upload-album"
27+
class="form-control items-stretch gap-4"
28+
method="POST"
29+
enctype="multipart/form-data"
30+
use:enhance
31+
>
32+
<Labeled error={$errors.date}>
33+
<label class="mb-1 text-lg font-medium" for="date"
34+
>{m.gallery_upload_date()}</label
35+
>
36+
<input
37+
id="date"
38+
name="date"
39+
class="input input-bordered"
40+
bind:value={$form.date}
41+
type="text"
42+
placeholder="2025-01-01"
43+
{...$constraints.date}
44+
/>
45+
</Labeled>
46+
47+
<Labeled error={$errors.name}>
48+
<label class="mb-1 text-lg font-medium" for="name"
49+
>{m.gallery_upload_name()}</label
50+
>
51+
<input
52+
id="name"
53+
name="name"
54+
class="input input-bordered"
55+
bind:value={$form.name}
56+
type="text"
57+
placeholder="Nollefredagen"
58+
{...$constraints.name}
59+
/>
60+
</Labeled>
61+
62+
<Labeled error={fileErrors}>
63+
<label class="mb-1 text-lg font-medium" for="files">
64+
{m.gallery_upload_files()}
65+
</label>
66+
<input
67+
bind:this={fileInput}
68+
id="files"
69+
type="file"
70+
multiple
71+
name="files"
72+
class="file-input file-input-bordered"
73+
{...$constraints.files}
74+
/>
75+
</Labeled>
76+
77+
<button type="submit" form="upload-album" class="btn btn-primary">
78+
{m.gallery_upload_upload()}
79+
</button>
80+
</form>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import * as m from "$paraglide/messages";
2+
import type { Infer } from "sveltekit-superforms";
3+
import { z } from "zod";
4+
5+
export const uploadSchema = z.object({
6+
name: z.string().default(""),
7+
date: z.string().default(""),
8+
files: z.array(
9+
z.instanceof(File, { message: m.documents_errors_erroneousFile() }),
10+
),
11+
});
12+
export type UploadSchema = Infer<typeof uploadSchema>;

0 commit comments

Comments
 (0)