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
+77
View File
@@ -0,0 +1,77 @@
export interface SSEEvent {
type: string;
data: unknown;
}
export interface SSEClientOptions {
path: string;
onEvent: (event: SSEEvent) => void;
onReconnect?: () => void;
}
// Wrapper над EventSource с экспоненциальным backoff и догоном через REST.
export class SSEClient {
private es: EventSource | null = null;
private stopped = false;
private attempt = 0;
private readonly knownEvents = [
'photo.created',
'photo.deleted',
'comment.created',
'like.added',
'like.removed',
'member.joined',
'member.left',
'member.updated',
'album.updated',
'album.deleted',
'notification'
];
constructor(private opts: SSEClientOptions) {}
connect(token: string) {
this.stopped = false;
this.open(token);
}
private open(token: string) {
if (this.stopped) return;
const url = `${this.opts.path}?token=${encodeURIComponent(token)}`;
this.es = new EventSource(url);
this.es.onopen = () => {
if (this.attempt > 0) {
this.opts.onReconnect?.();
}
this.attempt = 0;
};
for (const name of this.knownEvents) {
this.es.addEventListener(name, (ev) => {
let data: unknown = null;
try {
data = JSON.parse((ev as MessageEvent).data);
} catch {
data = (ev as MessageEvent).data;
}
this.opts.onEvent({ type: name, data });
});
}
this.es.onerror = () => {
this.es?.close();
this.es = null;
if (this.stopped) return;
const delay = Math.min(1000 * 2 ** this.attempt, 30000);
this.attempt += 1;
setTimeout(() => this.open(token), delay);
};
}
close() {
this.stopped = true;
this.es?.close();
this.es = null;
}
}