Add coauthor invitations tab with accept and reject

This commit is contained in:
2026-09-25 02:22:46 +02:00
parent 058a902a81
commit dddb57c98b
6 changed files with 151 additions and 2 deletions
@@ -0,0 +1,92 @@
<script lang="ts">
import type { SubmitFunction } from '@sveltejs/kit';
import { flip } from 'svelte/animate';
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import Mail from '@lucide/svelte/icons/mail';
import type { CoauthorInvitation } from '$lib/types';
import { fmtDate } from '$lib/utils/format';
import { collapse } from '$lib/utils/transitions';
import Alert from '$lib/components/ui/primitives/Alert.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import ListRow from '$lib/components/ui/media/ListRow.svelte';
interface Props {
invitations: CoauthorInvitation[];
}
let { invitations }: Props = $props();
let hiddenIds = $state<string[]>([]);
let error = $state('');
const visible = $derived(invitations.filter((invitation) => !hiddenIds.includes(invitation.id)));
const respondSubmit: SubmitFunction = ({ formData }) => {
const id = String(formData.get('invitationId'));
hiddenIds = [...hiddenIds, id];
error = '';
return async ({ result, update }) => {
if (result.type === 'success') {
await update({ reset: false });
hiddenIds = hiddenIds.filter((item) => item !== id);
return;
}
hiddenIds = hiddenIds.filter((item) => item !== id);
error =
result.type === 'failure' && typeof result.data?.message === 'string'
? result.data.message
: 'No se pudo responder a la invitación.';
await invalidateAll();
};
};
</script>
{#if error}
<Alert tone="danger" class="mt-4">{error}</Alert>
{/if}
{#if visible.length > 0}
<ul class="mt-4 flex flex-col">
{#each visible as invitation, i (invitation.id)}
<li
animate:flip={{ duration: 200 }}
out:collapse
class="animate-enter"
style="--i:{Math.min(i, 9)}"
>
<ListRow
trackId={invitation.trackId}
title={invitation.trackTitle}
subtitle={invitation.artist}
subtitleHref={invitation.ownerUserId}
size="lg"
class="flex-wrap sm:flex-nowrap"
>
{#snippet trailing()}
<span class="ml-auto hidden text-xs text-fg-2 sm:block">
{fmtDate(invitation.invitedAt)}
</span>
<div class="flex shrink-0 items-center gap-2">
<form method="POST" action="?/reject" use:enhance={respondSubmit}>
<input type="hidden" name="invitationId" value={invitation.id} />
<Button type="submit" size="sm" variant="secondary">Rechazar</Button>
</form>
<form method="POST" action="?/accept" use:enhance={respondSubmit}>
<input type="hidden" name="invitationId" value={invitation.id} />
<Button type="submit" size="sm">Aceptar</Button>
</form>
</div>
{/snippet}
</ListRow>
</li>
{/each}
</ul>
{:else}
<EmptyState
icon={Mail}
title="No tienes invitaciones"
description="Cuando otro artista te invite a ser coautor de una canción, la verás aquí para aceptarla o rechazarla."
/>
{/if}
@@ -3,6 +3,7 @@
href: string;
label: string;
active: boolean;
count?: number;
}
interface Props {
@@ -51,6 +52,16 @@
: 'text-fg-2 hover:text-fg'}"
>
{tab.label}
{#if tab.count}
<span
class="ml-1.5 inline-grid min-w-5 place-items-center rounded-full px-1.5 text-xs tabular-nums {tab.active
? 'bg-ink/15 text-ink'
: 'bg-accent-tint text-accent-soft'}"
aria-label="{tab.count} pendientes"
>
{tab.count}
</span>
{/if}
</a>
{/each}
</nav>
@@ -0,0 +1,9 @@
import type { LayoutServerLoad } from './$types';
import { apiFor } from '$lib/server/api';
import { requireUser } from '$lib/server/guards';
import { loadInvitationCount } from '$lib/server/coauthors';
export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
requireUser(locals, url);
return { invitationCount: await loadInvitationCount(apiFor({ fetch, locals })) };
};
@@ -24,7 +24,7 @@
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
import Tabs from '$lib/components/ui/primitives/Tabs.svelte';
let { children } = $props();
let { data, children } = $props();
const pathname = $derived(page.url.pathname.replace(/\/$/, ''));
const current = $derived(
@@ -35,7 +35,12 @@
) ?? SECTIONS[0]
);
const tabs = $derived(
SECTIONS.map(({ href, label }) => ({ href, label, active: href === current.href }))
SECTIONS.map(({ href, label }) => ({
href,
label,
active: href === current.href,
count: href === '/library/invitations' ? data.invitationCount : undefined
}))
);
</script>
@@ -0,0 +1,25 @@
import type { PageServerLoad, Actions } from './$types';
import { fail } from '@sveltejs/kit';
import { apiFor } from '$lib/server/api';
import { requireUser } from '$lib/server/guards';
import { formString } from '$lib/server/forms/fields';
import { tagged } from '$lib/server/actions/tagged';
import { loadInvitations, respondToInvitation } from '$lib/server/coauthors';
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
requireUser(locals, url);
return { invitations: await loadInvitations(apiFor({ fetch, locals })) };
};
function respond(decision: 'accept' | 'reject') {
return tagged(decision, async ({ api, form }) => {
const invitationId = formString(form, 'invitationId');
if (!invitationId) return fail(400, { message: 'Falta la invitación.' });
return respondToInvitation(api, invitationId, decision);
});
}
export const actions: Actions = {
accept: respond('accept'),
reject: respond('reject')
};
@@ -0,0 +1,7 @@
<script lang="ts">
import LibraryInvitationsSection from '$lib/components/music/library/LibraryInvitationsSection.svelte';
let { data } = $props();
</script>
<LibraryInvitationsSection invitations={data.invitations.items} />