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 ChipGroup from '$lib/components/ui/primitives/ChipGroup.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';
|
||||
|
||||
interface Props {
|
||||
@@ -65,6 +66,8 @@
|
||||
let publishValue = $state('true');
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
let coauthorTrackId = $state('');
|
||||
let coauthorsOpen = $state(false);
|
||||
let hiddenIds = $state<string[]>([]);
|
||||
let overrides = $state<Record<string, boolean>>({});
|
||||
let deleteError = $state('');
|
||||
@@ -162,6 +165,7 @@
|
||||
};
|
||||
};
|
||||
|
||||
const coauthorTrack = $derived(items.find((track) => track.id === coauthorTrackId));
|
||||
const hasFilters = $derived(query !== '' || filter !== 'all');
|
||||
const counters = $derived(filterCounts(counts));
|
||||
const filterOptions = $derived(
|
||||
@@ -206,6 +210,11 @@
|
||||
else publish([track]);
|
||||
}
|
||||
|
||||
function openCoauthors(track: LibraryTrack) {
|
||||
coauthorTrackId = track.id;
|
||||
coauthorsOpen = true;
|
||||
}
|
||||
|
||||
function unpublishSelected() {
|
||||
pendingUnpublish = selected.filter((track) => track.published);
|
||||
}
|
||||
@@ -274,6 +283,7 @@
|
||||
bind:selectedIds
|
||||
onPlay={togglePlay}
|
||||
onPublish={requestToggle}
|
||||
onCoauthors={openCoauthors}
|
||||
onDelete={(track) => (pendingDelete = [track])}
|
||||
/>
|
||||
<InfiniteScroll
|
||||
@@ -306,6 +316,14 @@
|
||||
</EmptyState>
|
||||
{/if}
|
||||
|
||||
{#if coauthorTrack}
|
||||
<CoauthorsDialog
|
||||
open={coauthorsOpen}
|
||||
onClose={() => (coauthorsOpen = false)}
|
||||
track={coauthorTrack}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingDelete.length > 0}
|
||||
onClose={() => {
|
||||
|
||||
@@ -18,12 +18,14 @@
|
||||
class: `${TEXT} tabular-nums`
|
||||
},
|
||||
{ id: 'publish', width: COL.publish },
|
||||
{ id: 'coauthors', width: COL.action, mobile: COL.actionMobile },
|
||||
{ id: 'delete', width: COL.action, mobile: COL.actionMobile }
|
||||
];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Users from '@lucide/svelte/icons/users';
|
||||
import type { LibraryTrack } from '$lib/types';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { genreLabels } from '$lib/data/genres';
|
||||
@@ -41,6 +43,7 @@
|
||||
selectedIds: string[];
|
||||
onPlay: (index: number) => void;
|
||||
onPublish: (track: LibraryTrack) => void;
|
||||
onCoauthors: (track: LibraryTrack) => void;
|
||||
onDelete: (track: LibraryTrack) => void;
|
||||
class?: string;
|
||||
}
|
||||
@@ -50,6 +53,7 @@
|
||||
selectedIds = $bindable(),
|
||||
onPlay,
|
||||
onPublish,
|
||||
onCoauthors,
|
||||
onDelete,
|
||||
class: klass = ''
|
||||
}: Props = $props();
|
||||
@@ -106,6 +110,15 @@
|
||||
{track.published ? 'Despublicar' : 'Publicar'}
|
||||
</Button>
|
||||
</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}
|
||||
<IconButton label="Borrar canción" size="xs" revealOnHover onclick={() => onDelete(track)}>
|
||||
<X class="size-icon-sm" />
|
||||
|
||||
@@ -22,3 +22,6 @@ export const EXPLORE_USERS_LIMIT = 12;
|
||||
export const FOLLOW_LIST_PAGE_SIZE = 30;
|
||||
export const MENU_WIDTH = 240;
|
||||
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 { loadLibraryTracks, loadStatusCounts } from '$lib/server/libraryTracks';
|
||||
import { deleteTracks, setTracksPublished } from '$lib/server/tracks';
|
||||
import { inviteCoauthors, removeCoauthor } from '$lib/server/coauthors';
|
||||
import { parseTrackFilter } from '$lib/data/libraryTracks';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
@@ -44,5 +45,18 @@ export const actions: Actions = {
|
||||
const result = await setTracksPublished(api, trackIds, isPublished);
|
||||
if (isActionFailure(result)) return result;
|
||||
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();
|
||||
|
||||
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(
|
||||
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