Add follow button next to each user in search results

Extract FollowButton and shared follow/unfollow actions, reused by the user profile page.
This commit is contained in:
2026-09-23 22:34:56 +02:00
parent 84408b842b
commit 72cbc18a48
6 changed files with 110 additions and 59 deletions
@@ -1,6 +1,7 @@
import type { PageServerLoad, Actions } from './$types';
import { createApiClient, unwrapOrError } from '$lib/server/api';
import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions';
import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
import {
EXPLORE_ALBUMS_PAGE_SIZE,
EXPLORE_PAGE_SIZE as PAGE_SIZE,
@@ -45,16 +46,38 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.');
const userItems = usersRes?.data?.items ?? [];
const followedIds = user
? await Promise.all(
userItems
.filter((u) => u.id !== user.sub)
.map((u) =>
api
.GET('/users/{id}/is-following', { params: { path: { id: u.id } } })
.then((res) => (res.data ? u.id : null))
.catch(() => null)
)
)
: [];
const followed = new Set(followedIds);
return {
query,
tracks,
albums: (albumsRes?.data?.items ?? []).map((item) => item.album),
users: usersRes?.data?.items ?? [],
users: userItems.map((u) => ({
...u,
isSelf: user?.sub === u.id,
isFollowing: followed.has(u.id)
})),
canFollow: !!user,
playlists: playlistsRes?.data?.items ?? []
};
};
export const actions: Actions = {
addTrack: addTrackAction,
addAlbumToPlaylist: addAlbumToPlaylistAction
addAlbumToPlaylist: addAlbumToPlaylistAction,
follow: followUserAction,
unfollow: unfollowUserAction
};
@@ -10,6 +10,7 @@
import InfiniteScroll from '$lib/components/ui/primitives/InfiniteScroll.svelte';
import Artwork from '$lib/components/ui/media/Artwork.svelte';
import Avatar from '$lib/components/ui/media/Avatar.svelte';
import FollowButton from '$lib/components/ui/media/FollowButton.svelte';
import PlayButton from '$lib/components/ui/media/PlayButton.svelte';
import EqBars from '$lib/components/ui/media/EqBars.svelte';
import ListRow from '$lib/components/ui/media/ListRow.svelte';
@@ -400,12 +401,15 @@
<SectionHeading title="Usuarios" count={counts.Usuarios} />
<ul class="flex flex-col gap-1">
{#each capped(sfilter, users) as u (u.id)}
<li>
<ListRow title={u.name} href="/user/{u.id}" size="lg">
<li class="flex items-center gap-3">
<ListRow title={u.name} href="/user/{u.id}" size="lg" class="min-w-0 flex-1">
{#snippet art()}
<Avatar name={u.name} src={u.profilePictureUrl} size="md" />
{/snippet}
</ListRow>
{#if data.canFollow && !u.isSelf}
<FollowButton following={u.isFollowing} userId={u.id} class="shrink-0" />
{/if}
</li>
{/each}
</ul>
@@ -1,10 +1,6 @@
import type { PageServerLoad, Actions } from './$types';
import {
createApiClient,
optionalUser,
requireAccessTokenAction,
unwrapOrError
} from '$lib/server/api';
import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api';
import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
const PROFILE_PLAYLISTS_PAGE_SIZE = 50;
@@ -55,21 +51,6 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
};
export const actions: Actions = {
follow: async ({ params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
await api.POST('/users/{id}/follow', { params: { path: { id: params.id } } });
return { following: true };
},
unfollow: async ({ params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
await api.DELETE('/users/{id}/follow', { params: { path: { id: params.id } } });
return { following: false };
}
follow: followUserAction,
unfollow: unfollowUserAction
};
@@ -1,29 +1,19 @@
<script lang="ts">
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import ListMusic from '@lucide/svelte/icons/list-music';
import Page from '$lib/components/ui/layout/Page.svelte';
import MediaCard from '$lib/components/ui/media/MediaCard.svelte';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte';
import FollowButton from '$lib/components/ui/media/FollowButton.svelte';
import Avatar from '$lib/components/ui/media/Avatar.svelte';
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
import { plural, playlistMeta } from '$lib/utils/format';
let { data, form } = $props();
let { data } = $props();
const profile = $derived(data.profile);
const playlists = $derived(data.playlists);
const canFollow = $derived(!data.isOwnProfile && !data.isAnonymous);
let following = $state(untrack(() => data.isFollowing));
let submitting = $state(false);
$effect(() => {
if (form && 'following' in form && typeof form.following === 'boolean')
following = form.following;
});
</script>
<svelte:head>
@@ -32,26 +22,7 @@
</svelte:head>
{#snippet followAction()}
<form
method="POST"
action={following ? '?/unfollow' : '?/follow'}
use:enhance={() => {
submitting = true;
return async ({ update }) => {
await update();
submitting = false;
};
}}
>
<Button
type="submit"
size="sm"
variant={following ? 'secondary' : 'primary'}
disabled={submitting}
>
{following ? 'Dejar de seguir' : 'Seguir'}
</Button>
</form>
<FollowButton following={data.isFollowing} invalidateAll />
{/snippet}
<Page>