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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user