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
| Feature | Description | Location |
|---|---|---|
| Activity Feed | See activity from people you follow | /feed |
| User Discovery | Find and follow other collectors | /discover |
| User Profiles | Public profile pages with stats | /u/[username] |
| Following | Follow/unfollow other users | Profile & Discover |
| Likes & Comments | Engage with ratings and content | Throughout app |
Social Architecture
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
| Event Type | Description |
|---|---|
collection_add | User added a beer to their collection |
rating | User rated/reviewed a beer |
listing_create | User listed a beer for sale |
listing_sold | User sold a beer on the marketplace |
follow | User followed another user |
consume | User consumed a beer from their collection |
like | User liked content |
comment | User commented on content |
Feed Page Component
<!-- 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
// 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
<!-- 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:
// 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
<!-- 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
// 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
// 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
/**
* 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
// 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:
| Setting | Description | Default |
|---|---|---|
is_public | Profile visible to non-followers | true |
feature_social | Social features enabled | true |
discoverable | Appear in discover page | true |
show_collection | Show collection stats publicly | true |
show_ratings | Show rating stats publicly | true |
show_stats | Show follower/following counts | true |
Next Steps
- User Profiles - Public profile customization
- Settings - Privacy and notification settings
- Social API Reference - API endpoints for social features