feat: implement shared-album multi-user photo album

Backend (Go/Gin/pgx): env config, pgx pool with retries, Garage S3 client
(path-style, region garage), embedded golang-migrate migrations, JWT auth with
refresh rotation, albums CRUD, members/roles/invites with no-escalation matrix,
photo upload + async imaging/goexif WebP variants, cursor pagination, presigned
URLs, likes/comments, SSE hub (album + personal), notifications, share links.

Frontend (SvelteKit 2 + Svelte 5 + Tailwind v4): API wrapper with 401 refresh,
auth store, all screens, Uploader/PhotoGrid/Lightbox/Comments, SSE client with
reconnect + REST catch-up, three themes + system via data-theme.

Deploy without Docker: Makefile builds the frontend into backend/static and
embeds it (go:embed) into a single statically linked binary; systemd unit and
nginx SSE example included.

Use gen2brain/webp (-tags nodynamic) instead of cgo kolesa-team/go-webp to keep
CGO_ENABLED=0 static builds; design.md/proposal.md updated accordingly.
This commit is contained in:
iu
2026-10-10 12:50:33 +03:00
commit 2b2b342492
105 changed files with 12494 additions and 0 deletions
@@ -0,0 +1,143 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import type { Album, ShareLink } from '$lib/types';
const albumId = $derived($page.params.id);
let album = $state<Album | null>(null);
let shareLinks = $state<ShareLink[]>([]);
let title = $state('');
let description = $state('');
let expiresIn = $state('168h');
let error = $state('');
let saved = $state(false);
async function load() {
try {
const [albumData, linksData] = await Promise.all([
api<{ album: Album }>(`/albums/${albumId}`),
api<{ share_links: ShareLink[] }>(`/albums/${albumId}/share`)
]);
album = albumData.album;
title = album.title;
description = album.description;
shareLinks = linksData.share_links;
} catch (e) {
error = (e as Error).message;
}
}
$effect(() => {
load();
});
async function save(ev: Event) {
ev.preventDefault();
error = '';
saved = false;
try {
const data = await api<{ album: Album }>(`/albums/${albumId}`, {
method: 'PATCH',
body: { title, description }
});
album = data.album;
saved = true;
} catch (e) {
error = (e as Error).message;
}
}
async function createLink(ev: Event) {
ev.preventDefault();
try {
await api(`/albums/${albumId}/share`, { method: 'POST', body: { expires_in: expiresIn } });
await load();
} catch (e) {
error = (e as Error).message;
}
}
async function revoke(id: string) {
try {
await api(`/albums/${albumId}/share/${id}`, { method: 'DELETE' });
shareLinks = shareLinks.filter((l) => l.id !== id);
} catch (e) {
error = (e as Error).message;
}
}
async function removeAlbum() {
if (!confirm('Удалить альбом вместе с фото?')) return;
try {
await api(`/albums/${albumId}`, { method: 'DELETE' });
goto('/');
} catch (e) {
error = (e as Error).message;
}
}
</script>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">Настройки</h1>
<a href="/albums/{albumId}" class="text-primary text-sm">← К альбому</a>
</div>
{#if error}<p class="text-danger mb-4 text-sm">{error}</p>{/if}
<form class="border-border bg-bg-elevated mb-6 space-y-3 rounded-xl border p-4" onsubmit={save}>
<h2 class="font-semibold">Альбом</h2>
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Название</span>
<input
bind:value={title}
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
/>
</label>
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Описание</span>
<textarea
bind:value={description}
rows="3"
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
></textarea>
</label>
<button class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm" type="submit">Сохранить</button>
{#if saved}<span class="text-success ml-3 text-sm">Сохранено</span>{/if}
</form>
<div class="border-border bg-bg-elevated mb-6 space-y-3 rounded-xl border p-4">
<h2 class="font-semibold">Ссылки для просмотра</h2>
<form class="flex items-end gap-2" onsubmit={createLink}>
<label class="text-sm">
<span class="text-text-muted mb-1 block">Срок действия</span>
<input
bind:value={expiresIn}
class="border-border bg-bg text-text w-32 rounded-lg border px-2 py-1.5"
/>
</label>
<button class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm" type="submit"
>Создать ссылку</button
>
</form>
<ul class="divide-border divide-y">
{#each shareLinks as link (link.id)}
<li class="flex items-center justify-between gap-2 py-2 text-sm">
<span class="text-text-muted truncate">
{link.revoked_at ? 'отозвана' : 'до ' + new Date(link.expires_at).toLocaleString()}
· <a class="text-primary" href="/share/{link.token}">/share/{link.token}</a>
</span>
{#if !link.revoked_at}
<button type="button" class="text-danger" onclick={() => revoke(link.id)}>Отозвать</button>
{/if}
</li>
{/each}
</ul>
</div>
<button
type="button"
class="border-danger text-danger rounded-lg border px-4 py-2 text-sm"
onclick={removeAlbum}>Удалить альбом</button
>