Files
ds-galo/frontend/src/routes/albums/[id]/members/+page.svelte
T
iu 2b2b342492 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.
2026-10-10 12:50:33 +03:00

159 lines
4.6 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { page } from '$app/stores';
import { api } from '$lib/api';
import { auth } from '$lib/stores/auth.svelte';
import type { Album, AlbumMember } from '$lib/types';
const albumId = $derived($page.params.id);
let members = $state<AlbumMember[]>([]);
let role = $state('');
let inviteRole = $state('member');
let inviteUses = $state(1);
let inviteExpires = $state('168h');
let inviteUrl = $state('');
let error = $state('');
const assignableRoles = $derived(
role === 'owner'
? ['admin', 'editor', 'member', 'viewer']
: role === 'admin'
? ['editor', 'member', 'viewer']
: []
);
const inviteRoles = $derived(role === 'owner' ? ['admin', 'editor', 'member', 'viewer'] : ['member', 'viewer']);
async function load() {
try {
const [membersData, albumData] = await Promise.all([
api<{ members: AlbumMember[] }>(`/albums/${albumId}/members`),
api<{ album: Album; role: string }>(`/albums/${albumId}`)
]);
members = membersData.members;
role = albumData.role;
} catch (e) {
error = (e as Error).message;
}
}
$effect(() => {
load();
});
async function invite(ev: Event) {
ev.preventDefault();
error = '';
try {
const data = await api<{ invite: { token: string } }>(`/albums/${albumId}/invites`, {
method: 'POST',
body: { role: inviteRole, expires_in: inviteExpires, max_uses: inviteUses }
});
inviteUrl = `${location.origin}/invites/${data.invite.token}`;
} catch (e) {
error = (e as Error).message;
}
}
async function changeRole(userId: string, newRole: string) {
try {
await api(`/albums/${albumId}/members/${userId}`, {
method: 'PATCH',
body: { role: newRole }
});
await load();
} catch (e) {
error = (e as Error).message;
}
}
async function removeMember(userId: string) {
try {
await api(`/albums/${albumId}/members/${userId}`, { method: 'DELETE' });
await load();
} 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}
<ul class="mb-8 divide-y divide-border border-border rounded-xl border">
<li class="bg-bg-muted flex items-center justify-between px-4 py-3">
<span class="font-medium">Владелец</span>
</li>
{#each members as member (member.user_id)}
<li class="flex items-center justify-between gap-4 px-4 py-3">
<span class="text-text"
>{member.user.name} <span class="text-text-muted text-sm">({member.user.email})</span></span
>
<div class="flex items-center gap-2">
{#if assignableRoles.length > 0}
<select
value={member.role}
onchange={(e) => changeRole(member.user_id, e.currentTarget.value)}
class="border-border bg-bg-elevated text-text rounded-lg border px-2 py-1 text-sm"
>
{#each Array.from(new Set([member.role, ...assignableRoles])) as r}
<option value={r}>{r}</option>
{/each}
</select>
<button
type="button"
class="text-danger text-sm"
onclick={() => removeMember(member.user_id)}>Удалить</button
>
{:else}
<span class="text-text-muted text-sm">{member.role}</span>
{/if}
</div>
</li>
{/each}
</ul>
{#if assignableRoles.length > 0}
<form class="border-border bg-bg-elevated space-y-3 rounded-xl border p-4" onsubmit={invite}>
<h2 class="font-semibold">Пригласить</h2>
<div class="flex flex-wrap gap-3">
<label class="text-sm">
<span class="text-text-muted mb-1 block">Роль</span>
<select
bind:value={inviteRole}
class="border-border bg-bg text-text rounded-lg border px-2 py-1.5"
>
{#each inviteRoles as r}
<option value={r}>{r}</option>
{/each}
</select>
</label>
<label class="text-sm">
<span class="text-text-muted mb-1 block">Использований</span>
<input
type="number"
min="1"
bind:value={inviteUses}
class="border-border bg-bg text-text w-24 rounded-lg border px-2 py-1.5"
/>
</label>
<label class="text-sm">
<span class="text-text-muted mb-1 block">Срок</span>
<input
bind:value={inviteExpires}
class="border-border bg-bg text-text w-32 rounded-lg border px-2 py-1.5"
/>
</label>
</div>
<button class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm" type="submit"
>Создать ссылку</button
>
{#if inviteUrl}
<p class="text-text-muted break-all text-sm">Ссылка: <a class="text-primary" href={inviteUrl}>{inviteUrl}</a></p>
{/if}
</form>
{/if}