Social Features

Connect with fellow beer enthusiasts - follow collectors, view activity feeds, discover users, and engage with the community.

Social Features

BrewHoard includes a complete social platform for beer enthusiasts. Follow other collectors, see their activity in your feed, discover new users, and engage with the community through likes and comments.

Feature Overview

FeatureDescriptionLocation
Activity FeedSee activity from people you follow/feed
User DiscoveryFind and follow other collectors/discover
User ProfilesPublic profile pages with stats/u/[username]
FollowingFollow/unfollow other usersProfile & Discover
Likes & CommentsEngage with ratings and contentThroughout app

Social Architecture

Loading diagram...

Activity Feed

The activity feed shows recent activity from users you follow. Activities are displayed in chronological order with real-time updates via WebSocket.

Activity Types

Loading diagram...
Event TypeDescription
collection_addUser added a beer to their collection
ratingUser rated/reviewed a beer
listing_createUser listed a beer for sale
listing_soldUser sold a beer on the marketplace
followUser followed another user
consumeUser consumed a beer from their collection
likeUser liked content
commentUser commented on content

Feed Page Component

Svelte
<!-- src/routes/(app)/feed/+page.svelte -->
<script>
  import { m } from '$lib/paraglide/messages.js';
  import ActivityFeed from '$components/social/ActivityFeed.svelte';
  import { Button } from '$lib/components/ui/button';
  import { Users, UserPlus } from 'lucide-svelte';

  let { data } = $props();
</script>

<div class="container mx-auto max-w-2xl px-4 py-8">
  <div class="mb-8">
    <h1 class="text-3xl font-bold text-zinc-100">{m.feed_title()}</h1>
    <p class="mt-2 text-zinc-400">{m.feed_description()}</p>
  </div>

  {#if data.activities.length === 0}
    <div class="rounded-xl border border-zinc-800 bg-card p-12 text-center">
      <div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-zinc-800">
        <Users class="h-8 w-8 text-zinc-400" />
      </div>
      <h2 class="mb-2 text-xl font-semibold text-zinc-200">{m.feed_empty_title()}</h2>
      <p class="mb-6 text-zinc-400">{m.feed_empty_description()}</p>
      <Button href="/discover" class="gap-2">
        <UserPlus class="h-4 w-4" />
        {m.feed_discover_users()}
      </Button>
    </div>
  {:else}
    <ActivityFeed initialActivities={data.activities} userId={data.user.id} />

    {#if data.pagination.hasMore}
      <div class="mt-6 text-center">
        <Button variant="outline" href="/feed?page={data.pagination.page + 1}">
          {m.feed_load_more()}
        </Button>
      </div>
    {/if}
  {/if}
</div>

Server-Side Feed Loading

JavaScript
// src/routes/(app)/feed/+page.server.js
import { redirect } from '@sveltejs/kit';
import { getActivityFeed } from '$lib/social/activity.js';

export async function load({ locals, url }) {
  if (!locals.user) {
    throw redirect(302, '/login');
  }

  const page = parseInt(url.searchParams.get('page') || '1');
  const limit = 20;
  const offset = (page - 1) * limit;

  const result = await getActivityFeed(locals.user.id, { limit, offset });

  return {
    activities: result.activities || [],
    user: {
      id: locals.user.id,
      username: locals.user.username,
    },
    pagination: {
      page,
      limit,
      hasMore: (result.activities?.length || 0) === limit,
    },
  };
}

User Discovery

The discover page helps users find other collectors to follow. Users are sorted by collection size and follower count.

Discovery Page

Svelte
<!-- src/routes/(app)/discover/+page.svelte -->
<script>
  import { m } from '$lib/paraglide/messages.js';
  import * as Card from '$lib/components/ui/card';
  import { Beer, Users, Search } from 'lucide-svelte';
  import FollowButton from '$components/social/FollowButton.svelte';

  let { data } = $props();
  let searchQuery = $state('');

  function getDisplayName(user) {
    if (user.first_name || user.last_name) {
      return [user.first_name, user.last_name].filter(Boolean).join(' ');
    }
    return user.username || '';
  }

  const filteredCollectors = $derived(
    searchQuery
      ? data.collectors.filter(c => {
          const displayName = getDisplayName(c);
          return (
            c.username?.toLowerCase().includes(searchQuery.toLowerCase()) ||
            displayName.toLowerCase().includes(searchQuery.toLowerCase())
          );
        })
      : data.collectors
  );
</script>

<div class="container mx-auto max-w-4xl px-4 py-8">
  <div class="mb-8">
    <h1 class="text-3xl font-bold text-zinc-100">{m.discover_title()}</h1>
    <p class="mt-2 text-zinc-400">{m.discover_description()}</p>
  </div>

  <!-- Search -->
  <div class="mb-6">
    <div class="relative">
      <Search class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-zinc-500" />
      <input
        type="text"
        placeholder={m.discover_search_placeholder()}
        bind:value={searchQuery}
        class="w-full rounded-lg border border-zinc-700 bg-card py-2 pr-4 pl-10"
      />
    </div>
  </div>

  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
    {#each filteredCollectors as collector}
      <Card.Root class="border-zinc-800">
        <Card.Content class="p-4">
          <div class="flex items-start gap-3">
            <a href="/u/{collector.username}">
              <div class="flex h-12 w-12 items-center justify-center rounded-full bg-zinc-800">
                {#if collector.avatar_url}
                  <img src={collector.avatar_url} alt="" class="h-full w-full rounded-full" />
                {:else}
                  <span class="text-lg font-medium text-zinc-400">
                    {(getDisplayName(collector) || collector.username || 'U').charAt(0).toUpperCase()}
                  </span>
                {/if}
              </div>
            </a>

            <div class="min-w-0 flex-1">
              <a href="/u/{collector.username}">
                <h3 class="truncate font-medium text-zinc-100 hover:text-amber-400">
                  {getDisplayName(collector) || collector.username}
                </h3>
                <p class="text-sm text-zinc-500">@{collector.username}</p>
              </a>

              <div class="mt-2 flex items-center gap-3 text-xs text-zinc-500">
                <span class="flex items-center gap-1">
                  <Beer class="h-3 w-3" />
                  {collector.beer_count}
                </span>
                <span class="flex items-center gap-1">
                  <Users class="h-3 w-3" />
                  {collector.follower_count}
                </span>
              </div>
            </div>
          </div>

          <div class="mt-3">
            <FollowButton
              userId={collector.id}
              isFollowing={collector.is_following}
              size="sm"
              class="w-full"
            />
          </div>
        </Card.Content>
      </Card.Root>
    {/each}
  </div>
</div>

Discovery Query

The discover page queries users with public profiles who have opted into being discoverable:

JavaScript
// src/routes/(app)/discover/+page.server.js
const collectors = await sql`
  SELECT 
    u.id, u.username, u.first_name, u.last_name,
    u.avatar_url, u.bio, u.is_public,
    COALESCE(collection_stats.beer_count, 0) as beer_count,
    COALESCE(follower_stats.follower_count, 0) as follower_count,
    EXISTS(
      SELECT 1 FROM user_follows uf 
      WHERE uf.follower_id = ${user.userId} AND uf.following_id = u.id
    ) as is_following
  FROM users u
  LEFT JOIN LATERAL (
    SELECT COUNT(DISTINCT c.id) as beer_count
    FROM collections c WHERE c.user_id = u.id
  ) collection_stats ON true
  LEFT JOIN LATERAL (
    SELECT COUNT(*) as follower_count
    FROM user_follows uf WHERE uf.following_id = u.id
  ) follower_stats ON true
  WHERE u.id != ${user.userId}
    AND u.is_public = true
    AND u.feature_social = true
    AND u.discoverable = true
  ORDER BY 
    collection_stats.beer_count DESC,
    follower_stats.follower_count DESC
  LIMIT 50
`;

Follow System

The follow system connects users and powers the activity feed.

FollowButton Component

Svelte
<!-- src/components/social/FollowButton.svelte -->
<script>
  import { Button } from '$lib/components/ui/button';
  import { UserPlus, UserMinus, Loader2 } from 'lucide-svelte';

  let {
    userId,
    isFollowing = false,
    size = 'sm',
    variant = 'default',
    showIcon = true,
    showText = true,
    onFollowChange,
  } = $props();

  let loading = $state(false);
  let following = $state(isFollowing);

  // Sync with prop changes
  $effect(() => {
    following = isFollowing;
  });

  async function toggleFollow() {
    if (loading) return;
    loading = true;

    try {
      const method = following ? 'DELETE' : 'POST';
      const response = await fetch('/api/v1/social', {
        method,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ followingId: userId }),
      });

      if (response.ok) {
        following = !following;
        onFollowChange?.(following);
      }
    } catch (error) {
      console.error('Failed to toggle follow:', error);
    } finally {
      loading = false;
    }
  }
</script>

<Button
  {size}
  variant={following ? 'outline' : variant}
  onclick={toggleFollow}
  disabled={loading}
>
  {#if loading}
    <Loader2 class="h-4 w-4 animate-spin" />
  {:else if showIcon}
    {#if following}
      <UserMinus class={showText ? 'mr-2 h-4 w-4' : 'h-4 w-4'} />
    {:else}
      <UserPlus class={showText ? 'mr-2 h-4 w-4' : 'h-4 w-4'} />
    {/if}
  {/if}
  {#if showText}
    {following ? 'Unfollow' : 'Follow'}
  {/if}
</Button>

Follow Service Functions

JavaScript
// src/lib/social/profile.js

/**
 * Follow a user
 * @param {string} followerId - Follower user ID
 * @param {string} followingId - User to follow ID
 */
export async function followUser(followerId, followingId) {
  if (followerId === followingId) {
    throw new ValidationError('You cannot follow yourself');
  }

  // Check if already following
  const [existing] = await sql`
    SELECT COUNT(*) as count
    FROM user_follows
    WHERE follower_id = ${followerId} AND following_id = ${followingId}
  `;

  if (existing.count > 0) {
    throw new ValidationError('You are already following this user');
  }

  // Create follow relationship
  const [follow] = await sql`
    INSERT INTO user_follows (follower_id, following_id)
    VALUES (${followerId}, ${followingId})
    RETURNING follower_id, following_id, created_at
  `;

  // Create activity event
  await createActivity({
    userId: followerId,
    eventType: ActivityTypes.FOLLOW,
    targetType: 'user',
    targetId: followingId,
  });

  // Notify the followed user in real-time
  notifyUser(followingId, 'follow', {
    fromUserId: followerId,
  });

  return follow;
}

/**
 * Unfollow a user
 */
export async function unfollowUser(followerId, followingId) {
  const result = await sql`
    DELETE FROM user_follows
    WHERE follower_id = ${followerId} AND following_id = ${followingId}
  `;

  return result.count > 0;
}

Likes & Comments

Users can like and comment on ratings, collection items, and listings.

Toggle Like Function

JavaScript
// src/lib/social/activity.js

/**
 * Like or unlike content
 * @param {Object} params
 * @param {string} params.userId - User doing the liking
 * @param {string} params.targetType - Type of content (rating, collection_item, listing)
 * @param {string} params.targetId - ID of the content
 */
export async function toggleLike({ userId, targetType, targetId }) {
  // Check if already liked
  const [existing] = await sql`
    SELECT id FROM likes 
    WHERE user_id = ${userId} 
      AND target_type = ${targetType} 
      AND target_id = ${targetId}
  `;

  if (existing) {
    // Unlike
    await sql`DELETE FROM likes WHERE id = ${existing.id}`;
    return { success: true, liked: false };
  } else {
    // Like
    await sql`
      INSERT INTO likes (user_id, target_type, target_id)
      VALUES (${userId}, ${targetType}, ${targetId})
    `;

    // Notify content owner
    // ... notification logic

    return { success: true, liked: true };
  }
}

Add Comment Function

JavaScript
/**
 * Add a comment to content
 * @param {Object} params
 * @param {string} params.userId
 * @param {string} params.targetType
 * @param {string} params.targetId
 * @param {string} params.content
 * @param {string} [params.parentId] - For threaded replies
 */
export async function addComment({ userId, targetType, targetId, content, parentId }) {
  const [comment] = await sql`
    INSERT INTO comments (user_id, target_type, target_id, content, parent_id)
    VALUES (${userId}, ${targetType}, ${targetId}, ${content}, ${parentId || null})
    RETURNING *
  `;

  // Get commenter info for enriched response
  const [user] = await sql`
    SELECT id, username, avatar_url, first_name, last_name 
    FROM users WHERE id = ${userId}
  `;

  return {
    success: true,
    comment: {
      ...comment,
      user: {
        id: user.id,
        username: user.username,
        displayName: user.first_name
          ? `${user.first_name} ${user.last_name || ''}`.trim()
          : user.username,
      },
    },
  };
}

Real-Time Updates

Social features use Socket.IO for real-time activity updates.

Activity Emission

JavaScript
// src/lib/social/activity.js
import { emitActivity, emitToUser } from '$lib/server/socketio.js';

/**
 * Create an activity event and emit it in real-time
 */
export async function createActivity({
  userId,
  eventType,
  targetType,
  targetId,
  metadata = {},
  isPublic = true,
}) {
  const [event] = await sql`
    INSERT INTO activity_events (user_id, event_type, target_type, target_id, metadata, is_public)
    VALUES (${userId}, ${eventType}, ${targetType}, ${targetId}, ${JSON.stringify(metadata)}, ${isPublic})
    RETURNING *
  `;

  // Emit real-time event to followers
  if (isPublic) {
    emitActivity(userId, 'activity:new', enrichedEvent);
  }

  return { success: true, event: enrichedEvent };
}

/**
 * Notify a user of an action (e.g., someone followed them)
 */
export function notifyUser(userId, eventType, data) {
  emitToUser(userId, `notification:${eventType}`, data);
}

Privacy Controls

Users can control their social visibility through settings:

SettingDescriptionDefault
is_publicProfile visible to non-followerstrue
feature_socialSocial features enabledtrue
discoverableAppear in discover pagetrue
show_collectionShow collection stats publiclytrue
show_ratingsShow rating stats publiclytrue
show_statsShow follower/following countstrue

Next Steps