Add coauthor invitations tab with accept and reject
This commit is contained in:
@@ -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;
|
href: string;
|
||||||
label: string;
|
label: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -51,6 +52,16 @@
|
|||||||
: 'text-fg-2 hover:text-fg'}"
|
: 'text-fg-2 hover:text-fg'}"
|
||||||
>
|
>
|
||||||
{tab.label}
|
{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>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</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 PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
||||||
import Tabs from '$lib/components/ui/primitives/Tabs.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 pathname = $derived(page.url.pathname.replace(/\/$/, ''));
|
||||||
const current = $derived(
|
const current = $derived(
|
||||||
@@ -35,7 +35,12 @@
|
|||||||
) ?? SECTIONS[0]
|
) ?? SECTIONS[0]
|
||||||
);
|
);
|
||||||
const tabs = $derived(
|
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>
|
</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} />
|
||||||
Reference in New Issue
Block a user