User Profiles
Complete guide to BrewHoard user profiles - public profiles, visibility settings, followers, and profile customization.
User profiles in BrewHoard allow collectors to share their beer journey with the community. This guide covers profile setup, visibility controls, and the public profile system.
Overview
| Feature | Description |
|---|---|
| Public Profiles | Share your profile at /u/username |
| Privacy Controls | Granular visibility settings |
| Follow System | Build your beer community |
| Activity Feed | See what you’ve been up to |
| Statistics | Collection and rating insights |
Profile Visibility
Visibility Levels
Users control what information is visible on their public profile through granular settings:
| Setting | Default | Description |
|---|---|---|
is_public | false | Whether profile is publicly accessible |
show_collection | false | Show collection count on profile |
show_ratings | false | Show ratings count on profile |
show_stats | true | Show detailed statistics |
discoverable | true | Appear in user discovery/search |
Privacy Scenarios
Private Profile (default)
- Only username and avatar visible
- Cannot be followed
- Not discoverable in search
Public Profile
- Full bio, location, member since date
- Can be followed by others
- Optionally shows collection/rating counts
Accessing Profiles
Public Profile URL
Every user has a unique profile URL:
https://brewhoard.com/u/{username}Server-Side Loading
// src/routes/(app)/u/[username]/+page.server.js
import { getPublicProfileByUsername } from '$lib/social/profile.js';
export async function load({ params, locals }) {
const profile = await getPublicProfileByUsername(
params.username,
locals.user?.id
);
if (!profile) {
throw error(404, 'User not found');
}
return { profile };
}Profile Component
<!-- src/routes/(app)/u/[username]/+page.svelte -->
<script>
import { Button } from '$lib/components/ui/button';
import FollowButton from '$lib/components/social/FollowButton.svelte';
import ActivityFeed from '$lib/components/social/ActivityFeed.svelte';
let { data } = $props();
const { profile } = data;
const { user, stats, social, visibility, isPrivate } = profile;
</script>
<div class="max-w-4xl mx-auto p-6">
<!-- Profile Header -->
<header class="flex items-start gap-6 mb-8">
<img
src={user.avatarUrl || '/placeholder-avatar.png'}
alt={user.displayName}
class="w-24 h-24 rounded-full object-cover"
/>
<div class="flex-1">
<div class="flex items-center gap-4">
<h1 class="text-2xl font-bold">{user.displayName}</h1>
<span class="text-muted-foreground">@{user.username}</span>
</div>
{#if !isPrivate}
{#if user.bio}
<p class="mt-2 text-muted-foreground">{user.bio}</p>
{/if}
{#if user.location}
<p class="mt-1 text-sm text-muted-foreground">
{user.location}
</p>
{/if}
{#if user.memberSince}
<p class="mt-1 text-sm text-muted-foreground">
Member since {new Date(user.memberSince).toLocaleDateString()}
</p>
{/if}
{:else}
<p class="mt-2 text-muted-foreground italic">
This profile is private
</p>
{/if}
</div>
<!-- Actions -->
<div>
{#if social.isOwnProfile}
<Button href="/settings/profile" variant="outline">
Edit Profile
</Button>
{:else if !isPrivate}
<FollowButton
userId={user.id}
isFollowing={social.isFollowing}
/>
{/if}
</div>
</header>
{#if !isPrivate && stats}
<!-- Stats -->
<div class="grid grid-cols-4 gap-4 mb-8">
<div class="text-center p-4 bg-muted rounded-lg">
<div class="text-2xl font-bold">{stats.followers}</div>
<div class="text-sm text-muted-foreground">Followers</div>
</div>
<div class="text-center p-4 bg-muted rounded-lg">
<div class="text-2xl font-bold">{stats.following}</div>
<div class="text-sm text-muted-foreground">Following</div>
</div>
{#if visibility?.showCollection && stats.collection !== null}
<div class="text-center p-4 bg-muted rounded-lg">
<div class="text-2xl font-bold">{stats.collection}</div>
<div class="text-sm text-muted-foreground">Beers</div>
</div>
{/if}
{#if visibility?.showRatings && stats.ratings !== null}
<div class="text-center p-4 bg-muted rounded-lg">
<div class="text-2xl font-bold">{stats.ratings}</div>
<div class="text-sm text-muted-foreground">Ratings</div>
</div>
{/if}
</div>
<!-- Activity Feed -->
<section>
<h2 class="text-xl font-semibold mb-4">Recent Activity</h2>
<ActivityFeed userId={user.id} />
</section>
{/if}
</div>Profile Service API
The profile service ($lib/social/profile.js) provides these functions:
getPublicProfileByUsername
Fetches a public profile by username:
import { getPublicProfileByUsername } from '$lib/social/profile.js';
const profile = await getPublicProfileByUsername('beermaster', currentUserId);
// Returns:
// {
// user: { id, username, displayName, avatarUrl, bio, location, memberSince },
// isPrivate: false,
// visibility: { showCollection, showRatings, showStats },
// stats: { followers, following, collection, ratings },
// social: { isFollowing, isOwnProfile }
// }getUserProfile
Fetches a full user profile (for authenticated users viewing their own profile):
import { getUserProfile } from '$lib/social/profile.js';
const profile = await getUserProfile(userId, currentUserId);
// Includes recentActivity arraygetUserRecentActivity
Gets a user’s recent ratings and collection additions:
import { getUserRecentActivity } from '$lib/social/profile.js';
const activity = await getUserRecentActivity(userId, 10);
// Returns array of:
// { id, type: 'rating' | 'collection', data, timestamp }getUserFavoriteBeers
Gets a user’s highest-rated beers:
import { getUserFavoriteBeers } from '$lib/social/profile.js';
const favorites = await getUserFavoriteBeers(userId, 10);
// Returns beers with avgRating >= 4getUserRatingStats
Gets detailed rating statistics:
import { getUserRatingStats } from '$lib/social/profile.js';
const stats = await getUserRatingStats(userId);
// Returns:
// {
// totalRatings, averageRating, highestRating, lowestRating,
// wouldBuyAgainCount, wouldBuyAgainPercentage,
// highRatingsCount, lowRatingsCount,
// distribution: [{ rating, count }]
// }Follow System
Following a User
import { followUser } from '$lib/social/profile.js';
await followUser(followerId, followingId);
// Creates activity event
// Sends real-time notification to followed userUnfollowing
import { unfollowUser } from '$lib/social/profile.js';
await unfollowUser(followerId, followingId);Getting Followers/Following
import { getUserFollowers, getUserFollowing } from '$lib/social/profile.js';
// Get followers with pagination
const { followers, pagination } = await getUserFollowers(userId, 20, 0);
// Get users this person follows
const { following, pagination } = await getUserFollowing(userId, 20, 0);Follow Button Component
<!-- src/lib/components/social/FollowButton.svelte -->
<script>
import { Button } from '$lib/components/ui/button';
import { invalidateAll } from '$app/navigation';
let { userId, isFollowing = false } = $props();
let loading = $state(false);
let following = $state(isFollowing);
async function toggleFollow() {
loading = true;
try {
const response = await fetch(`/api/v1/social/follow/${userId}`, {
method: following ? 'DELETE' : 'POST'
});
if (response.ok) {
following = !following;
await invalidateAll();
}
} finally {
loading = false;
}
}
</script>
<Button
onclick={toggleFollow}
disabled={loading}
variant={following ? 'outline' : 'default'}
>
{loading ? '...' : following ? 'Following' : 'Follow'}
</Button>Profile Settings
Users can customize their profile through the settings page:
Profile Settings Form
<!-- src/routes/(app)/settings/profile/+page.svelte -->
<script>
import { enhance } from '$app/forms';
import { Input } from '$lib/components/ui/input';
import { Textarea } from '$lib/components/ui/textarea';
import { Switch } from '$lib/components/ui/switch';
import { Button } from '$lib/components/ui/button';
let { data } = $props();
let username = $state(data.user.username);
let bio = $state(data.user.bio || '');
let location = $state(data.user.location || '');
let isPublic = $state(data.user.is_public);
let showCollection = $state(data.user.show_collection);
let showRatings = $state(data.user.show_ratings);
let showStats = $state(data.user.show_stats);
</script>
<form method="POST" use:enhance>
<div class="space-y-8">
<!-- Basic Info -->
<section>
<h2 class="text-lg font-semibold mb-4">Profile Information</h2>
<div class="space-y-4">
<div>
<label for="username">Username</label>
<Input
name="username"
bind:value={username}
pattern="^[a-zA-Z0-9_]+$"
minlength="3"
maxlength="30"
/>
<p class="text-sm text-muted-foreground mt-1">
Your profile URL: brewhoard.com/u/{username}
</p>
</div>
<div>
<label for="bio">Bio</label>
<Textarea
name="bio"
bind:value={bio}
maxlength="500"
placeholder="Tell others about yourself..."
/>
</div>
<div>
<label for="location">Location</label>
<Input
name="location"
bind:value={location}
placeholder="e.g., Portland, OR"
/>
</div>
</div>
</section>
<!-- Privacy Settings -->
<section>
<h2 class="text-lg font-semibold mb-4">Privacy Settings</h2>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="font-medium">Public Profile</p>
<p class="text-sm text-muted-foreground">
Allow others to view your profile
</p>
</div>
<Switch name="is_public" bind:checked={isPublic} />
</div>
{#if isPublic}
<div class="ml-4 space-y-4 border-l-2 pl-4">
<div class="flex items-center justify-between">
<div>
<p class="font-medium">Show Collection Count</p>
<p class="text-sm text-muted-foreground">
Display how many beers are in your collection
</p>
</div>
<Switch name="show_collection" bind:checked={showCollection} />
</div>
<div class="flex items-center justify-between">
<div>
<p class="font-medium">Show Ratings Count</p>
<p class="text-sm text-muted-foreground">
Display how many beers you've rated
</p>
</div>
<Switch name="show_ratings" bind:checked={showRatings} />
</div>
<div class="flex items-center justify-between">
<div>
<p class="font-medium">Show Statistics</p>
<p class="text-sm text-muted-foreground">
Display detailed collection statistics
</p>
</div>
<Switch name="show_stats" bind:checked={showStats} />
</div>
</div>
{/if}
</div>
</section>
<Button type="submit">Save Changes</Button>
</div>
</form>Server Action
// src/routes/(app)/settings/profile/+page.server.js
import { fail } from '@sveltejs/kit';
import { sql } from '$lib/server/db.js';
export const actions = {
default: async ({ request, locals }) => {
const data = await request.formData();
const username = data.get('username');
const bio = data.get('bio');
const location = data.get('location');
const isPublic = data.get('is_public') === 'on';
const showCollection = data.get('show_collection') === 'on';
const showRatings = data.get('show_ratings') === 'on';
const showStats = data.get('show_stats') === 'on';
// Validate username
if (!/^[a-zA-Z0-9_]{3,30}$/.test(username)) {
return fail(400, {
error: 'Username must be 3-30 characters (letters, numbers, underscores)'
});
}
// Check username availability
const [existing] = await sql`
SELECT id FROM users
WHERE username = ${username} AND id != ${locals.user.id}
`;
if (existing) {
return fail(400, { error: 'Username is already taken' });
}
// Update profile
await sql`
UPDATE users SET
username = ${username},
bio = ${bio},
location = ${location},
is_public = ${isPublic},
show_collection = ${showCollection},
show_ratings = ${showRatings},
show_stats = ${showStats},
updated_at = NOW()
WHERE id = ${locals.user.id}
`;
return { success: true };
}
};Database Schema
User Profile Columns
-- Core profile fields
username VARCHAR(30) UNIQUE
first_name VARCHAR(100)
last_name VARCHAR(100)
bio TEXT
location VARCHAR(100)
avatar_url TEXT
-- Visibility settings
is_public BOOLEAN DEFAULT false
show_collection BOOLEAN DEFAULT false
show_ratings BOOLEAN DEFAULT false
show_stats BOOLEAN DEFAULT true
discoverable BOOLEAN DEFAULT trueFollow Relationship
CREATE TABLE user_follows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
follower_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
following_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(follower_id, following_id),
CHECK (follower_id != following_id)
);Best Practices
Privacy by Default
Profiles are private by default. Users must explicitly opt-in to public visibility:
// Good: Check visibility before exposing data
if (user.is_public || currentUserId === user.id) {
// Show full profile
} else {
// Show limited info only
}Efficient Queries
Use single queries with JOINs for profile data:
// Good: Single query for profile + stats
const [profile] = await sql`
SELECT
u.*,
COUNT(DISTINCT f1.follower_id) as followers,
COUNT(DISTINCT f2.following_id) as following
FROM users u
LEFT JOIN user_follows f1 ON u.id = f1.following_id
LEFT JOIN user_follows f2 ON u.id = f2.follower_id
WHERE u.username = ${username}
GROUP BY u.id
`;Real-time Follow Updates
Use Socket.IO to notify users of new followers:
// When someone follows a user
notifyUser(followingId, 'follow', {
fromUserId: followerId,
fromUsername: follower.username
});Next Steps
- Social Features - Activity feed and interactions
- Settings - All user preferences
- Social API - REST endpoints for profiles