Invite and remove track coauthors from the library songs list

This commit is contained in:
2026-09-25 02:21:37 +02:00
parent 153c33bf51
commit 058a902a81
11 changed files with 601 additions and 1 deletions
@@ -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" />
+3
View File
@@ -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;
+27
View File
@@ -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']);
});
});
+30
View File
@@ -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));
}
+143
View File
@@ -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 }
]);
});
});
+119
View File
@@ -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));
};