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
+117
View File
@@ -0,0 +1,117 @@
<script lang="ts">
import { auth } from '$lib/stores/auth.svelte';
import type { Photo } from '$lib/types';
interface Props {
albumId: string;
onUploaded?: (photos: Photo[]) => void;
}
let { albumId, onUploaded }: Props = $props();
let dragging = $state(false);
let progress = $state(0);
let busy = $state(false);
let error = $state('');
function pick() {
fileInput?.click();
}
let fileInput: HTMLInputElement | undefined = $state();
function onDrop(ev: DragEvent) {
ev.preventDefault();
dragging = false;
if (ev.dataTransfer?.files?.length) {
upload(ev.dataTransfer.files);
}
}
function onChange(ev: Event) {
const target = ev.target as HTMLInputElement;
if (target.files?.length) upload(target.files);
target.value = '';
}
function upload(files: FileList) {
error = '';
busy = true;
progress = 0;
const form = new FormData();
for (const file of Array.from(files)) {
form.append('files', file);
}
const xhr = new XMLHttpRequest();
xhr.open('POST', `/api/albums/${albumId}/photos`);
if (auth.access) xhr.setRequestHeader('Authorization', `Bearer ${auth.access}`);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) progress = Math.round((e.loaded / e.total) * 100);
};
xhr.onload = () => {
busy = false;
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText);
onUploaded?.(data.photos ?? []);
} catch {
onUploaded?.([]);
}
} else {
try {
error = JSON.parse(xhr.responseText)?.error?.message ?? 'Ошибка загрузки';
} catch {
error = 'Ошибка загрузки';
}
}
};
xhr.onerror = () => {
busy = false;
error = 'Сетевая ошибка';
};
xhr.send(form);
}
</script>
<div
class="border-border rounded-xl border-2 border-dashed p-6 text-center transition-colors {dragging
? 'bg-bg-muted border-primary'
: 'bg-bg-elevated'}"
ondragover={(e) => {
e.preventDefault();
dragging = true;
}}
ondragleave={() => (dragging = false)}
ondrop={onDrop}
ondragenter={(e) => e.preventDefault()}
role="region"
aria-label="Загрузка фотографий"
>
<input
bind:this={fileInput}
type="file"
accept="image/*"
multiple
class="hidden"
onchange={onChange}
/>
<p class="text-text mb-3 text-sm">Перетащите фото сюда или</p>
<button
type="button"
onclick={pick}
disabled={busy}
class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{busy ? 'Загрузка…' : 'Выбрать файлы'}
</button>
{#if busy}
<div class="bg-bg-muted mt-4 h-2 w-full overflow-hidden rounded">
<div class="bg-primary h-full transition-all" style="width: {progress}%"></div>
</div>
{/if}
{#if error}
<p class="text-danger mt-3 text-sm">{error}</p>
{/if}
</div>