Invite and remove track coauthors from the library songs list
This commit is contained in:
@@ -0,0 +1,214 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import type { ActionResult, SubmitFunction } from '@sveltejs/kit';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
import type { CoauthorInvite, LibraryTrack } from '$lib/types';
|
||||||
|
import { COAUTHOR_INVITES_MAX, SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
|
||||||
|
import { COAUTHOR_STATUS, inviteCandidates, type UserOption } from '$lib/data/coauthors';
|
||||||
|
import Modal from '$lib/components/ui/overlay/Modal.svelte';
|
||||||
|
import TagInput from '$lib/components/ui/forms/TagInput.svelte';
|
||||||
|
import Alert from '$lib/components/ui/primitives/Alert.svelte';
|
||||||
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
|
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||||
|
import Avatar from '$lib/components/ui/media/Avatar.svelte';
|
||||||
|
import StatusBadge from '$lib/components/ui/media/StatusBadge.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
track: LibraryTrack;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { open, onClose, track }: Props = $props();
|
||||||
|
|
||||||
|
const listId = $props.id();
|
||||||
|
|
||||||
|
let invites = $state<CoauthorInvite[]>([]);
|
||||||
|
let selected = $state<UserOption[]>([]);
|
||||||
|
let search = $state('');
|
||||||
|
let results = $state<UserOption[]>([]);
|
||||||
|
let error = $state('');
|
||||||
|
let sending = $state(false);
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let searchToken = 0;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
track.id;
|
||||||
|
untrack(() => {
|
||||||
|
invites = track.coauthorInvites;
|
||||||
|
selected = [];
|
||||||
|
search = '';
|
||||||
|
results = [];
|
||||||
|
error = '';
|
||||||
|
sending = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const candidates = $derived(
|
||||||
|
inviteCandidates(results, {
|
||||||
|
ownerId: track.ownerUserId,
|
||||||
|
selectedIds: selected.map((user) => user.id),
|
||||||
|
invites
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const full = $derived(selected.length >= COAUTHOR_INVITES_MAX);
|
||||||
|
|
||||||
|
function onSearchInput() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
const query = search.trim();
|
||||||
|
const token = ++searchToken;
|
||||||
|
if (query.length < SEARCH_MIN_LENGTH) {
|
||||||
|
results = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/search?${new URLSearchParams({ q: query })}`);
|
||||||
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
|
const found = (await res.json()) as UserOption[];
|
||||||
|
if (token === searchToken) results = found;
|
||||||
|
} catch {
|
||||||
|
if (token === searchToken) {
|
||||||
|
results = [];
|
||||||
|
error = 'No se pudo buscar usuarios.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(user: UserOption) {
|
||||||
|
if (full || selected.some((item) => item.id === user.id)) return;
|
||||||
|
selected = [...selected, user];
|
||||||
|
search = '';
|
||||||
|
results = [];
|
||||||
|
searchToken++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deselect(id: string) {
|
||||||
|
selected = selected.filter((user) => user.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function failureMessage(result: ActionResult, fallback: string): string {
|
||||||
|
return result.type === 'failure' && typeof result.data?.message === 'string'
|
||||||
|
? result.data.message
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inviteSubmit: SubmitFunction = () => {
|
||||||
|
sending = true;
|
||||||
|
error = '';
|
||||||
|
return async ({ result }) => {
|
||||||
|
sending = false;
|
||||||
|
if (result.type === 'success') {
|
||||||
|
invites = (result.data as { invites: CoauthorInvite[] }).invites;
|
||||||
|
selected = [];
|
||||||
|
await invalidateAll();
|
||||||
|
} else {
|
||||||
|
error = failureMessage(result, 'No se pudieron enviar las invitaciones.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSubmit: SubmitFunction = ({ formData }) => {
|
||||||
|
const userId = String(formData.get('userId'));
|
||||||
|
const previous = invites;
|
||||||
|
invites = invites.filter((invite) => invite.userId !== userId);
|
||||||
|
error = '';
|
||||||
|
return async ({ result }) => {
|
||||||
|
if (result.type === 'success') {
|
||||||
|
await invalidateAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
invites = previous;
|
||||||
|
error = failureMessage(result, 'No se pudo quitar al coautor.');
|
||||||
|
};
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal {open} {onClose} eyebrow={track.title} title="Coautores" maxWidth="max-w-lg">
|
||||||
|
<p class="mt-2 text-sm text-fg-2">
|
||||||
|
Los usuarios que invites aparecerán como coautores cuando acepten la invitación.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<Alert tone="danger" class="mt-4">{error}</Alert>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="mt-5" aria-label="Invitaciones enviadas">
|
||||||
|
{#if invites.length > 0}
|
||||||
|
<ul class="flex flex-col gap-1">
|
||||||
|
{#each invites as invite (invite.userId)}
|
||||||
|
<li class="flex items-center gap-3 rounded-control py-1.5 pl-2 hover:bg-hover">
|
||||||
|
<Avatar name={invite.name} size="sm" />
|
||||||
|
<a
|
||||||
|
href="/user/{invite.userId}"
|
||||||
|
class="min-w-0 flex-1 truncate text-sm text-fg hover:underline"
|
||||||
|
>
|
||||||
|
{invite.name}
|
||||||
|
</a>
|
||||||
|
<StatusBadge
|
||||||
|
label={COAUTHOR_STATUS[invite.status].label}
|
||||||
|
tone={COAUTHOR_STATUS[invite.status].tone}
|
||||||
|
/>
|
||||||
|
<form method="POST" action="?/removeCoauthor" use:enhance={removeSubmit}>
|
||||||
|
<input type="hidden" name="trackId" value={track.id} />
|
||||||
|
<input type="hidden" name="userId" value={invite.userId} />
|
||||||
|
<IconButton label="Quitar a {invite.name}" type="submit" size="xs">
|
||||||
|
<X class="size-icon-sm" />
|
||||||
|
</IconButton>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-fg-2">Todavía no has invitado a nadie.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form method="POST" action="?/inviteCoauthors" use:enhance={inviteSubmit} class="mt-6">
|
||||||
|
<input type="hidden" name="trackId" value={track.id} />
|
||||||
|
{#each selected as user (user.id)}
|
||||||
|
<input type="hidden" name="userIds" value={user.id} />
|
||||||
|
{/each}
|
||||||
|
<label for="{listId}-search" class="mb-2 block text-sm font-medium text-fg-2">
|
||||||
|
Invitar usuarios
|
||||||
|
</label>
|
||||||
|
<TagInput
|
||||||
|
id="{listId}-search"
|
||||||
|
label="Buscar usuarios"
|
||||||
|
placeholder={full ? `Máximo ${COAUTHOR_INVITES_MAX} usuarios` : 'Buscar por nombre'}
|
||||||
|
disabled={full || sending}
|
||||||
|
controls="{listId}-results"
|
||||||
|
tags={selected.map((user) => ({ id: user.id, label: user.name }))}
|
||||||
|
bind:value={search}
|
||||||
|
oninput={onSearchInput}
|
||||||
|
onadd={() => {
|
||||||
|
if (candidates.length > 0) select(candidates[0]);
|
||||||
|
}}
|
||||||
|
onremove={deselect}
|
||||||
|
/>
|
||||||
|
<ul id="{listId}-results" aria-label="Resultados" class="mt-2 flex flex-col gap-0.5">
|
||||||
|
{#each candidates as user (user.id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => select(user)}
|
||||||
|
class="flex w-full items-center gap-3 rounded-control p-1.5 text-left text-sm text-fg focus-ring transition-colors hover:bg-hover"
|
||||||
|
>
|
||||||
|
<Avatar name={user.name} src={user.profilePictureUrl} size="sm" />
|
||||||
|
<span class="min-w-0 flex-1 truncate">{user.name}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<div class="mt-5 flex justify-end gap-2.5">
|
||||||
|
<Button variant="secondary" onclick={onClose}>Cerrar</Button>
|
||||||
|
<Button type="submit" disabled={selected.length === 0 || sending}>
|
||||||
|
{selected.length > 1 ? `Invitar a ${selected.length} usuarios` : 'Enviar invitación'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
import SearchInput from '$lib/components/ui/primitives/SearchInput.svelte';
|
import SearchInput from '$lib/components/ui/primitives/SearchInput.svelte';
|
||||||
import ChipGroup from '$lib/components/ui/primitives/ChipGroup.svelte';
|
import ChipGroup from '$lib/components/ui/primitives/ChipGroup.svelte';
|
||||||
import ConfirmDialog from '$lib/components/ui/overlay/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ui/overlay/ConfirmDialog.svelte';
|
||||||
|
import CoauthorsDialog from '$lib/components/music/library/CoauthorsDialog.svelte';
|
||||||
import ManageTrackList from '$lib/components/music/library/ManageTrackList.svelte';
|
import ManageTrackList from '$lib/components/music/library/ManageTrackList.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -65,6 +66,8 @@
|
|||||||
let publishValue = $state('true');
|
let publishValue = $state('true');
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
let coauthorTrackId = $state('');
|
||||||
|
let coauthorsOpen = $state(false);
|
||||||
let hiddenIds = $state<string[]>([]);
|
let hiddenIds = $state<string[]>([]);
|
||||||
let overrides = $state<Record<string, boolean>>({});
|
let overrides = $state<Record<string, boolean>>({});
|
||||||
let deleteError = $state('');
|
let deleteError = $state('');
|
||||||
@@ -162,6 +165,7 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const coauthorTrack = $derived(items.find((track) => track.id === coauthorTrackId));
|
||||||
const hasFilters = $derived(query !== '' || filter !== 'all');
|
const hasFilters = $derived(query !== '' || filter !== 'all');
|
||||||
const counters = $derived(filterCounts(counts));
|
const counters = $derived(filterCounts(counts));
|
||||||
const filterOptions = $derived(
|
const filterOptions = $derived(
|
||||||
@@ -206,6 +210,11 @@
|
|||||||
else publish([track]);
|
else publish([track]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCoauthors(track: LibraryTrack) {
|
||||||
|
coauthorTrackId = track.id;
|
||||||
|
coauthorsOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
function unpublishSelected() {
|
function unpublishSelected() {
|
||||||
pendingUnpublish = selected.filter((track) => track.published);
|
pendingUnpublish = selected.filter((track) => track.published);
|
||||||
}
|
}
|
||||||
@@ -274,6 +283,7 @@
|
|||||||
bind:selectedIds
|
bind:selectedIds
|
||||||
onPlay={togglePlay}
|
onPlay={togglePlay}
|
||||||
onPublish={requestToggle}
|
onPublish={requestToggle}
|
||||||
|
onCoauthors={openCoauthors}
|
||||||
onDelete={(track) => (pendingDelete = [track])}
|
onDelete={(track) => (pendingDelete = [track])}
|
||||||
/>
|
/>
|
||||||
<InfiniteScroll
|
<InfiniteScroll
|
||||||
@@ -306,6 +316,14 @@
|
|||||||
</EmptyState>
|
</EmptyState>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if coauthorTrack}
|
||||||
|
<CoauthorsDialog
|
||||||
|
open={coauthorsOpen}
|
||||||
|
onClose={() => (coauthorsOpen = false)}
|
||||||
|
track={coauthorTrack}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={pendingDelete.length > 0}
|
open={pendingDelete.length > 0}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
@@ -18,12 +18,14 @@
|
|||||||
class: `${TEXT} tabular-nums`
|
class: `${TEXT} tabular-nums`
|
||||||
},
|
},
|
||||||
{ id: 'publish', width: COL.publish },
|
{ id: 'publish', width: COL.publish },
|
||||||
|
{ id: 'coauthors', width: COL.action, mobile: COL.actionMobile },
|
||||||
{ id: 'delete', width: COL.action, mobile: COL.actionMobile }
|
{ id: 'delete', width: COL.action, mobile: COL.actionMobile }
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
import Users from '@lucide/svelte/icons/users';
|
||||||
import type { LibraryTrack } from '$lib/types';
|
import type { LibraryTrack } from '$lib/types';
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
import { genreLabels } from '$lib/data/genres';
|
import { genreLabels } from '$lib/data/genres';
|
||||||
@@ -41,6 +43,7 @@
|
|||||||
selectedIds: string[];
|
selectedIds: string[];
|
||||||
onPlay: (index: number) => void;
|
onPlay: (index: number) => void;
|
||||||
onPublish: (track: LibraryTrack) => void;
|
onPublish: (track: LibraryTrack) => void;
|
||||||
|
onCoauthors: (track: LibraryTrack) => void;
|
||||||
onDelete: (track: LibraryTrack) => void;
|
onDelete: (track: LibraryTrack) => void;
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
@@ -50,6 +53,7 @@
|
|||||||
selectedIds = $bindable(),
|
selectedIds = $bindable(),
|
||||||
onPlay,
|
onPlay,
|
||||||
onPublish,
|
onPublish,
|
||||||
|
onCoauthors,
|
||||||
onDelete,
|
onDelete,
|
||||||
class: klass = ''
|
class: klass = ''
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -106,6 +110,15 @@
|
|||||||
{track.published ? 'Despublicar' : 'Publicar'}
|
{track.published ? 'Despublicar' : 'Publicar'}
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
|
{:else if column.id === 'coauthors'}
|
||||||
|
<IconButton
|
||||||
|
label="Coautores"
|
||||||
|
size="xs"
|
||||||
|
pressed={track.coauthorInvites.length > 0}
|
||||||
|
onclick={() => onCoauthors(track)}
|
||||||
|
>
|
||||||
|
<Users class="size-icon-sm" />
|
||||||
|
</IconButton>
|
||||||
{:else}
|
{:else}
|
||||||
<IconButton label="Borrar canción" size="xs" revealOnHover onclick={() => onDelete(track)}>
|
<IconButton label="Borrar canción" size="xs" revealOnHover onclick={() => onDelete(track)}>
|
||||||
<X class="size-icon-sm" />
|
<X class="size-icon-sm" />
|
||||||
|
|||||||
@@ -22,3 +22,6 @@ export const EXPLORE_USERS_LIMIT = 12;
|
|||||||
export const FOLLOW_LIST_PAGE_SIZE = 30;
|
export const FOLLOW_LIST_PAGE_SIZE = 30;
|
||||||
export const MENU_WIDTH = 240;
|
export const MENU_WIDTH = 240;
|
||||||
export const SUBMENU_WIDTH = 256;
|
export const SUBMENU_WIDTH = 256;
|
||||||
|
export const COAUTHOR_SEARCH_LIMIT = 8;
|
||||||
|
export const COAUTHOR_INVITES_MAX = 20;
|
||||||
|
export const COAUTHOR_INVITATIONS_PAGE_SIZE = 50;
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { inviteCandidates } from '$lib/data/coauthors';
|
||||||
|
|
||||||
|
const user = (id: string) => ({ id, name: `User ${id}`, profilePictureUrl: null });
|
||||||
|
|
||||||
|
describe('inviteCandidates', () => {
|
||||||
|
it('hides the owner, selected users and users with an active invitation', () => {
|
||||||
|
const result = inviteCandidates([user('owner'), user('a'), user('b'), user('c'), user('d')], {
|
||||||
|
ownerId: 'owner',
|
||||||
|
selectedIds: ['a'],
|
||||||
|
invites: [
|
||||||
|
{ userId: 'b', name: 'B', status: 'Pending' },
|
||||||
|
{ userId: 'c', name: 'C', status: 'Accepted' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(result.map((item) => item.id)).toEqual(['d']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps users who rejected so they can be invited again', () => {
|
||||||
|
const result = inviteCandidates([user('a')], {
|
||||||
|
ownerId: null,
|
||||||
|
selectedIds: [],
|
||||||
|
invites: [{ userId: 'a', name: 'A', status: 'Rejected' }]
|
||||||
|
});
|
||||||
|
expect(result.map((item) => item.id)).toEqual(['a']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { CoauthorInvite, CoauthorStatus } from '$lib/types';
|
||||||
|
import type { TrackTone } from '$lib/data/libraryTracks';
|
||||||
|
|
||||||
|
export interface UserOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
profilePictureUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COAUTHOR_STATUS: Record<CoauthorStatus, { label: string; tone: TrackTone }> = {
|
||||||
|
Pending: { label: 'Pendiente', tone: 'neutral' },
|
||||||
|
Accepted: { label: 'Aceptado', tone: 'success' },
|
||||||
|
Rejected: { label: 'Rechazado', tone: 'danger' }
|
||||||
|
};
|
||||||
|
|
||||||
|
export function inviteCandidates(
|
||||||
|
results: UserOption[],
|
||||||
|
{
|
||||||
|
ownerId,
|
||||||
|
selectedIds,
|
||||||
|
invites
|
||||||
|
}: { ownerId: string | null; selectedIds: string[]; invites: CoauthorInvite[] }
|
||||||
|
): UserOption[] {
|
||||||
|
const blocked = new Set([
|
||||||
|
...selectedIds,
|
||||||
|
...invites.filter((invite) => invite.status !== 'Rejected').map((invite) => invite.userId)
|
||||||
|
]);
|
||||||
|
if (ownerId) blocked.add(ownerId);
|
||||||
|
return results.filter((user) => !blocked.has(user.id));
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { isActionFailure } from '@sveltejs/kit';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { ApiClient } from '$lib/server/api';
|
||||||
|
import {
|
||||||
|
inviteCoauthors,
|
||||||
|
loadInvitationCount,
|
||||||
|
parseUserIds,
|
||||||
|
removeCoauthor,
|
||||||
|
respondToInvitation,
|
||||||
|
searchUsers
|
||||||
|
} from '$lib/server/coauthors';
|
||||||
|
|
||||||
|
const asApi = (fake: unknown) => fake as ApiClient;
|
||||||
|
|
||||||
|
function failed(status: number) {
|
||||||
|
return { error: { status }, response: { status } };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseUserIds', () => {
|
||||||
|
it('trims, drops blanks and removes duplicates', () => {
|
||||||
|
expect(parseUserIds([' a ', '', 'b', 'a'])).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inviteCoauthors', () => {
|
||||||
|
it('rejects an empty selection without calling the API', async () => {
|
||||||
|
const POST = vi.fn();
|
||||||
|
const result = await inviteCoauthors(asApi({ POST }), 't1', ['', ' ']);
|
||||||
|
expect(isActionFailure(result)).toBe(true);
|
||||||
|
expect(POST).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects more users than the API accepts', async () => {
|
||||||
|
const POST = vi.fn();
|
||||||
|
const ids = Array.from({ length: 21 }, (_, i) => String(i));
|
||||||
|
const result = await inviteCoauthors(asApi({ POST }), 't1', ids);
|
||||||
|
expect(isActionFailure(result)).toBe(true);
|
||||||
|
expect(POST).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the full invite list of the track', async () => {
|
||||||
|
const POST = vi.fn(async () => ({
|
||||||
|
data: [
|
||||||
|
{ userId: 'u1', name: 'Ana', status: 'Pending' },
|
||||||
|
{ userId: 'u2', name: 'Leo', status: 'Accepted' }
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
const result = await inviteCoauthors(asApi({ POST }), 't1', ['u1', 'u1']);
|
||||||
|
expect(POST).toHaveBeenCalledWith('/tracks/{id}/coauthors', {
|
||||||
|
params: { path: { id: 't1' } },
|
||||||
|
body: { userIds: ['u1'] }
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
invites: [
|
||||||
|
{ userId: 'u1', name: 'Ana', status: 'Pending' },
|
||||||
|
{ userId: 'u2', name: 'Leo', status: 'Accepted' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[400, 'ti mismo'],
|
||||||
|
[403, 'autor'],
|
||||||
|
[409, 'ya tiene']
|
||||||
|
])('explains a %i response', async (status, text) => {
|
||||||
|
const POST = vi.fn(async () => failed(status));
|
||||||
|
const result = await inviteCoauthors(asApi({ POST }), 't1', ['u1']);
|
||||||
|
expect(isActionFailure(result)).toBe(true);
|
||||||
|
if (!isActionFailure(result)) return;
|
||||||
|
expect(result.status).toBe(status);
|
||||||
|
expect(result.data).toMatchObject({ message: expect.stringContaining(text) });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeCoauthor', () => {
|
||||||
|
it('returns the removed user', async () => {
|
||||||
|
const DELETE = vi.fn(async () => ({}));
|
||||||
|
expect(await removeCoauthor(asApi({ DELETE }), 't1', 'u1')).toEqual({ removed: 'u1' });
|
||||||
|
expect(DELETE).toHaveBeenCalledWith('/tracks/{id}/coauthors/{userId}', {
|
||||||
|
params: { path: { id: 't1', userId: 'u1' } }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails when the API refuses', async () => {
|
||||||
|
const DELETE = vi.fn(async () => failed(403));
|
||||||
|
expect(isActionFailure(await removeCoauthor(asApi({ DELETE }), 't1', 'u1'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('respondToInvitation', () => {
|
||||||
|
it('calls the endpoint matching the decision', async () => {
|
||||||
|
const POST = vi.fn(async () => ({}));
|
||||||
|
await respondToInvitation(asApi({ POST }), 'i1', 'accept');
|
||||||
|
await respondToInvitation(asApi({ POST }), 'i1', 'reject');
|
||||||
|
expect(POST).toHaveBeenNthCalledWith(1, '/coauthor-invitations/{id}/accept', {
|
||||||
|
params: { path: { id: 'i1' } }
|
||||||
|
});
|
||||||
|
expect(POST).toHaveBeenNthCalledWith(2, '/coauthor-invitations/{id}/reject', {
|
||||||
|
params: { path: { id: 'i1' } }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an already answered invitation', async () => {
|
||||||
|
const POST = vi.fn(async () => failed(409));
|
||||||
|
const result = await respondToInvitation(asApi({ POST }), 'i1', 'accept');
|
||||||
|
expect(isActionFailure(result)).toBe(true);
|
||||||
|
if (isActionFailure(result)) expect(result.status).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadInvitationCount', () => {
|
||||||
|
it('reads the total of pending invitations', async () => {
|
||||||
|
const GET = vi.fn(async () => ({ data: { items: [], totalItemCount: '3' } }));
|
||||||
|
expect(await loadInvitationCount(asApi({ GET }))).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to zero when the request fails', async () => {
|
||||||
|
const GET = vi.fn(async () => failed(500));
|
||||||
|
expect(await loadInvitationCount(asApi({ GET }))).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('searchUsers', () => {
|
||||||
|
it('keeps only what the invite UI needs', async () => {
|
||||||
|
const GET = vi.fn(async () => ({
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'u1',
|
||||||
|
name: 'Ana',
|
||||||
|
firstName: 'A',
|
||||||
|
secondName: null,
|
||||||
|
profilePictureUrl: null,
|
||||||
|
isFollowedByViewer: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
expect(await searchUsers(asApi({ GET }), 'an')).toEqual([
|
||||||
|
{ id: 'u1', name: 'Ana', profilePictureUrl: null }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import { apiErrorDetail, unwrapOrError, type ApiClient } from '$lib/server/api';
|
||||||
|
import { toCoauthorInvitation, toCoauthorInvite, toCount, toPage } from '$lib/server/mappers';
|
||||||
|
import {
|
||||||
|
COAUTHOR_INVITATIONS_PAGE_SIZE,
|
||||||
|
COAUTHOR_INVITES_MAX,
|
||||||
|
COAUTHOR_SEARCH_LIMIT
|
||||||
|
} from '$lib/config';
|
||||||
|
|
||||||
|
const INVITE_ERRORS: Record<number, string> = {
|
||||||
|
400: 'No puedes invitarte a ti mismo.',
|
||||||
|
403: 'Solo el autor de la canción puede invitar coautores.',
|
||||||
|
404: 'No se encontró la canción o alguno de los usuarios.',
|
||||||
|
409: 'Alguno de los usuarios ya tiene una invitación para esta canción.'
|
||||||
|
};
|
||||||
|
|
||||||
|
const REMOVE_ERRORS: Record<number, string> = {
|
||||||
|
403: 'No puedes quitar a este coautor.',
|
||||||
|
404: 'La invitación ya no existe.'
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESPOND_ERRORS: Record<number, string> = {
|
||||||
|
403: 'Esta invitación no es tuya.',
|
||||||
|
404: 'La invitación ya no existe.',
|
||||||
|
409: 'Esta invitación ya se respondió.'
|
||||||
|
};
|
||||||
|
|
||||||
|
function failure(
|
||||||
|
result: { error?: unknown; response?: { status: number } },
|
||||||
|
messages: Record<number, string>,
|
||||||
|
fallback: string
|
||||||
|
) {
|
||||||
|
const status = result.response?.status ?? 502;
|
||||||
|
return fail(status >= 400 && status < 600 ? status : 502, {
|
||||||
|
message: messages[status] ?? fallback,
|
||||||
|
detail: apiErrorDetail(result.error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUserIds(ids: string[]): string[] {
|
||||||
|
return [...new Set(ids.map((id) => id.trim()).filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inviteCoauthors(api: ApiClient, trackId: string, userIds: string[]) {
|
||||||
|
const unique = parseUserIds(userIds);
|
||||||
|
if (unique.length === 0) return fail(400, { message: 'Elige al menos un usuario.' });
|
||||||
|
if (unique.length > COAUTHOR_INVITES_MAX) {
|
||||||
|
return fail(400, {
|
||||||
|
message: `Puedes invitar a ${COAUTHOR_INVITES_MAX} usuarios como máximo cada vez.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.POST('/tracks/{id}/coauthors', {
|
||||||
|
params: { path: { id: trackId } },
|
||||||
|
body: { userIds: unique }
|
||||||
|
});
|
||||||
|
if (result.error || !result.data) {
|
||||||
|
return failure(result, INVITE_ERRORS, 'No se pudieron enviar las invitaciones.');
|
||||||
|
}
|
||||||
|
return { invites: result.data.map(toCoauthorInvite) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeCoauthor(api: ApiClient, trackId: string, userId: string) {
|
||||||
|
const result = await api.DELETE('/tracks/{id}/coauthors/{userId}', {
|
||||||
|
params: { path: { id: trackId, userId } }
|
||||||
|
});
|
||||||
|
if (result.error) return failure(result, REMOVE_ERRORS, 'No se pudo quitar al coautor.');
|
||||||
|
return { removed: userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function respondToInvitation(
|
||||||
|
api: ApiClient,
|
||||||
|
invitationId: string,
|
||||||
|
decision: 'accept' | 'reject'
|
||||||
|
) {
|
||||||
|
const params = { path: { id: invitationId } };
|
||||||
|
const result =
|
||||||
|
decision === 'accept'
|
||||||
|
? await api.POST('/coauthor-invitations/{id}/accept', { params })
|
||||||
|
: await api.POST('/coauthor-invitations/{id}/reject', { params });
|
||||||
|
if (result.error) {
|
||||||
|
return failure(
|
||||||
|
result,
|
||||||
|
RESPOND_ERRORS,
|
||||||
|
decision === 'accept' ? 'No se pudo aceptar la invitación.' : 'No se pudo rechazar.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { invitationId, decision };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadInvitations(api: ApiClient) {
|
||||||
|
const result = await api.GET('/users/me/coauthor-invitations', {
|
||||||
|
params: { query: { pageNumber: 1, pageSize: COAUTHOR_INVITATIONS_PAGE_SIZE } }
|
||||||
|
});
|
||||||
|
return toPage(
|
||||||
|
unwrapOrError(result, 'No se pudieron cargar las invitaciones.'),
|
||||||
|
toCoauthorInvitation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadInvitationCount(api: ApiClient): Promise<number> {
|
||||||
|
const result = await api.GET('/users/me/coauthor-invitations', {
|
||||||
|
params: { query: { pageNumber: 1, pageSize: 1 } }
|
||||||
|
});
|
||||||
|
return toCount(result.data?.totalItemCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchUsers(api: ApiClient, query: string) {
|
||||||
|
const result = await api.GET('/users', {
|
||||||
|
params: {
|
||||||
|
query: { usernameSearch: query, pageNumber: 1, pageSize: COAUTHOR_SEARCH_LIMIT }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return (result.data?.items ?? []).map(({ id, name, profilePictureUrl }) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
profilePictureUrl
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { formIds, formString } from '$lib/server/forms/fields';
|
|||||||
import { tagged } from '$lib/server/actions/tagged';
|
import { tagged } from '$lib/server/actions/tagged';
|
||||||
import { loadLibraryTracks, loadStatusCounts } from '$lib/server/libraryTracks';
|
import { loadLibraryTracks, loadStatusCounts } from '$lib/server/libraryTracks';
|
||||||
import { deleteTracks, setTracksPublished } from '$lib/server/tracks';
|
import { deleteTracks, setTracksPublished } from '$lib/server/tracks';
|
||||||
|
import { inviteCoauthors, removeCoauthor } from '$lib/server/coauthors';
|
||||||
import { parseTrackFilter } from '$lib/data/libraryTracks';
|
import { parseTrackFilter } from '$lib/data/libraryTracks';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||||
@@ -44,5 +45,18 @@ export const actions: Actions = {
|
|||||||
const result = await setTracksPublished(api, trackIds, isPublished);
|
const result = await setTracksPublished(api, trackIds, isPublished);
|
||||||
if (isActionFailure(result)) return result;
|
if (isActionFailure(result)) return result;
|
||||||
return { ...result, isPublished };
|
return { ...result, isPublished };
|
||||||
|
}),
|
||||||
|
|
||||||
|
inviteCoauthors: tagged('inviteCoauthors', async ({ api, form }) => {
|
||||||
|
const trackId = formString(form, 'trackId');
|
||||||
|
if (!trackId) return fail(400, { message: 'Falta la canción.' });
|
||||||
|
return inviteCoauthors(api, trackId, formIds(form, 'userIds'));
|
||||||
|
}),
|
||||||
|
|
||||||
|
removeCoauthor: tagged('removeCoauthor', async ({ api, form }) => {
|
||||||
|
const trackId = formString(form, 'trackId');
|
||||||
|
const userId = formString(form, 'userId');
|
||||||
|
if (!trackId || !userId) return fail(400, { message: 'Falta el coautor.' });
|
||||||
|
return removeCoauthor(api, trackId, userId);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,13 @@
|
|||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
const errorMessage = $derived(
|
const errorMessage = $derived(
|
||||||
form && 'message' in form && form.action !== 'saveAlbum' ? form.message : undefined
|
form &&
|
||||||
|
'message' in form &&
|
||||||
|
form.action !== 'saveAlbum' &&
|
||||||
|
form.action !== 'inviteCoauthors' &&
|
||||||
|
form.action !== 'removeCoauthor'
|
||||||
|
? form.message
|
||||||
|
: undefined
|
||||||
);
|
);
|
||||||
const published = $derived(
|
const published = $derived(
|
||||||
form?.action === 'setPublished' ? (form as { skipped?: number; isPublished?: boolean }) : null
|
form?.action === 'setPublished' ? (form as { skipped?: number; isPublished?: boolean }) : null
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { apiFor } from '$lib/server/api';
|
||||||
|
import { requireUser } from '$lib/server/guards';
|
||||||
|
import { searchUsers } from '$lib/server/coauthors';
|
||||||
|
import { SEARCH_MIN_LENGTH } from '$lib/config';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, locals, fetch }) => {
|
||||||
|
requireUser(locals, url);
|
||||||
|
const query = url.searchParams.get('q')?.trim() ?? '';
|
||||||
|
if (query.length < SEARCH_MIN_LENGTH) return json([]);
|
||||||
|
return json(await searchUsers(apiFor({ fetch, locals }), query));
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user