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

FeatureDescription
Public ProfilesShare your profile at /u/username
Privacy ControlsGranular visibility settings
Follow SystemBuild your beer community
Activity FeedSee what you’ve been up to
StatisticsCollection and rating insights

Profile Visibility

Loading diagram...
Profile Access Flow

Visibility Levels

Loading diagram...
What's visible at each privacy level

Users control what information is visible on their public profile through granular settings:

SettingDefaultDescription
is_publicfalseWhether profile is publicly accessible
show_collectionfalseShow collection count on profile
show_ratingsfalseShow ratings count on profile
show_statstrueShow detailed statistics
discoverabletrueAppear 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:

Text
https://brewhoard.com/u/{username}

Server-Side Loading

JavaScript
// 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

Svelte
<!-- 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:

JavaScript
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):

JavaScript
import { getUserProfile } from '$lib/social/profile.js';

const profile = await getUserProfile(userId, currentUserId);

// Includes recentActivity array

getUserRecentActivity

Gets a user’s recent ratings and collection additions:

JavaScript
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:

JavaScript
import { getUserFavoriteBeers } from '$lib/social/profile.js';

const favorites = await getUserFavoriteBeers(userId, 10);

// Returns beers with avgRating >= 4

getUserRatingStats

Gets detailed rating statistics:

JavaScript
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

JavaScript
import { followUser } from '$lib/social/profile.js';

await followUser(followerId, followingId);

// Creates activity event
// Sends real-time notification to followed user

Unfollowing

JavaScript
import { unfollowUser } from '$lib/social/profile.js';

await unfollowUser(followerId, followingId);

Getting Followers/Following

JavaScript
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

Svelte
<!-- 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

Svelte
<!-- 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

JavaScript
// 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

SQL
-- 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 true

Follow Relationship

SQL
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:

JavaScript
// 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:

JavaScript
// 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:

JavaScript
// When someone follows a user
notifyUser(followingId, 'follow', {
  fromUserId: followerId,
  fromUsername: follower.username
});

Next Steps