feat:friends

This commit is contained in:
2025-10-16 18:53:21 +02:00
parent fdbd495bdd
commit a01d183072
14 changed files with 1550 additions and 9 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "profile_picture_url" text;

View File

@@ -0,0 +1,443 @@
{
"id": "e0be0091-df6b-48be-9d64-8b4108d91651",
"prevId": "eaa0fec3-527f-4569-9c01-a4802700b646",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.find": {
"name": "find",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"latitude": {
"name": "latitude",
"type": "text",
"primaryKey": false,
"notNull": true
},
"longitude": {
"name": "longitude",
"type": "text",
"primaryKey": false,
"notNull": true
},
"location_name": {
"name": "location_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false
},
"is_public": {
"name": "is_public",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 1
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_user_id_user_id_fk": {
"name": "find_user_id_user_id_fk",
"tableFrom": "find",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.find_like": {
"name": "find_like",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"find_id": {
"name": "find_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_like_find_id_find_id_fk": {
"name": "find_like_find_id_find_id_fk",
"tableFrom": "find_like",
"tableTo": "find",
"columnsFrom": [
"find_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"find_like_user_id_user_id_fk": {
"name": "find_like_user_id_user_id_fk",
"tableFrom": "find_like",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.find_media": {
"name": "find_media",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"find_id": {
"name": "find_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"thumbnail_url": {
"name": "thumbnail_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fallback_url": {
"name": "fallback_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fallback_thumbnail_url": {
"name": "fallback_thumbnail_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"order_index": {
"name": "order_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_media_find_id_find_id_fk": {
"name": "find_media_find_id_find_id_fk",
"tableFrom": "find_media",
"tableTo": "find",
"columnsFrom": [
"find_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.friendship": {
"name": "friendship",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"friend_id": {
"name": "friend_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"friendship_user_id_user_id_fk": {
"name": "friendship_user_id_user_id_fk",
"tableFrom": "friendship",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"friendship_friend_id_user_id_fk": {
"name": "friendship_friend_id_user_id_fk",
"tableFrom": "friendship",
"tableTo": "user",
"columnsFrom": [
"friend_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"age": {
"name": "age",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"google_id": {
"name": "google_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"profile_picture_url": {
"name": "profile_picture_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_username_unique": {
"name": "user_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
},
"user_google_id_unique": {
"name": "user_google_id_unique",
"nullsNotDistinct": false,
"columns": [
"google_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -36,6 +36,13 @@
"when": 1760456880877,
"tag": "0004_large_doctor_strange",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1760631798851,
"tag": "0005_rapid_warpath",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,125 @@
<script lang="ts">
import { Button } from '$lib/components/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '$lib/components/dropdown-menu';
import { Badge } from '$lib/components/badge';
interface Props {
currentFilter: string;
onFilterChange: (filter: string) => void;
}
let { currentFilter, onFilterChange }: Props = $props();
const filterOptions = [
{ value: 'all', label: 'All Finds', description: 'Public, friends, and your finds' },
{ value: 'public', label: 'Public Only', description: 'Publicly visible finds' },
{ value: 'friends', label: 'Friends Only', description: 'Finds from your friends' },
{ value: 'mine', label: 'My Finds', description: 'Only your finds' }
];
const currentOption = $derived(
filterOptions.find((option) => option.value === currentFilter) || filterOptions[0]
);
</script>
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="outline" class="filter-trigger">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
<path
d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v2.586a1 1 0 0 1-.293.707l-6.414 6.414a1 1 0 0 0-.293.707V17l-4 4v-6.586a1 1 0 0 0-.293-.707L3.293 7.293A1 1 0 0 1 3 6.586V4z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{currentOption.label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="filter-dropdown">
{#each filterOptions as option (option.value)}
<DropdownMenuItem
class="filter-option"
onclick={() => onFilterChange(option.value)}
data-selected={currentFilter === option.value}
>
<div class="option-content">
<div class="option-header">
<span class="option-label">{option.label}</span>
{#if currentFilter === option.value}
<Badge variant="default" class="selected-badge">Selected</Badge>
{/if}
</div>
<span class="option-description">{option.description}</span>
</div>
</DropdownMenuItem>
{/each}
</DropdownMenuContent>
</DropdownMenu>
<style>
:global(.filter-trigger) {
gap: 8px;
min-width: 120px;
justify-content: flex-start;
}
:global(.filter-dropdown) {
min-width: 250px;
padding: 4px;
}
:global(.filter-option) {
padding: 8px 12px;
cursor: pointer;
border-radius: 6px;
margin-bottom: 2px;
}
:global(.filter-option:hover) {
background: #f5f5f5;
}
:global(.filter-option[data-selected='true']) {
background: #f0f9ff;
border: 1px solid #e0f2fe;
}
.option-content {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.option-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.option-label {
font-weight: 500;
color: #1a1a1a;
font-size: 14px;
}
.option-description {
font-size: 12px;
color: #6b7280;
line-height: 1.4;
}
:global(.selected-badge) {
font-size: 10px;
padding: 2px 6px;
height: auto;
}
</style>

View File

@@ -12,7 +12,7 @@
<header class="app-header">
<div class="header-content">
<h1 class="app-title">Serengo</h1>
<h1 class="app-title"><a href="/">Serengo</a></h1>
<div class="profile-container">
<ProfilePanel
username={user.username}

View File

@@ -84,6 +84,10 @@
Profile Picture
</DropdownMenuItem>
<DropdownMenuItem class="friends-item">
<a href="/friends" class="friends-link">Friends</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
<div class="user-info-item">
@@ -196,6 +200,25 @@
background: #f5f5f5;
}
:global(.friends-item) {
cursor: pointer;
font-weight: 500;
color: #333;
padding: 0;
}
:global(.friends-item:hover) {
background: #f5f5f5;
}
.friends-link {
display: block;
width: 100%;
padding: 6px 8px;
text-decoration: none;
color: inherit;
}
:global(.logout-item) {
padding: 0;
margin: 4px;

View File

@@ -18,6 +18,7 @@ export const load: PageServerLoad = async ({ locals, url, fetch, request }) => {
if (lng) apiUrl.searchParams.set('lng', lng);
apiUrl.searchParams.set('radius', radius);
apiUrl.searchParams.set('includePrivate', 'true'); // Include user's private finds
apiUrl.searchParams.set('includeFriends', 'true'); // Include friends' finds
apiUrl.searchParams.set('order', 'desc'); // Newest first
try {

View File

@@ -3,6 +3,7 @@
import FindsList from '$lib/components/FindsList.svelte';
import CreateFindModal from '$lib/components/CreateFindModal.svelte';
import FindPreview from '$lib/components/FindPreview.svelte';
import FindsFilter from '$lib/components/FindsFilter.svelte';
import type { PageData } from './$types';
import { coordinates } from '$lib/stores/location';
import { Button } from '$lib/components/button';
@@ -87,9 +88,10 @@
let showCreateModal = $state(false);
let selectedFind: FindPreviewData | null = $state(null);
let currentFilter = $state('all');
// Reactive finds list - convert server format to component format
let finds = $derived(
// All finds - convert server format to component format
let allFinds = $derived(
(data.finds || ([] as ServerFind[])).map((serverFind: ServerFind) => ({
...serverFind,
createdAt: new Date(serverFind.createdAt), // Convert string to Date
@@ -110,6 +112,27 @@
})) as MapFind[]
);
// Filtered finds based on current filter
let finds = $derived.by(() => {
if (!data.user) return allFinds;
switch (currentFilter) {
case 'public':
return allFinds.filter((find) => find.isPublic === 1);
case 'friends':
return allFinds.filter((find) => find.isPublic === 0 && find.userId !== data.user!.id);
case 'mine':
return allFinds.filter((find) => find.userId === data.user!.id);
case 'all':
default:
return allFinds;
}
});
function handleFilterChange(filter: string) {
currentFilter = filter;
}
function handleFindCreated(event: CustomEvent) {
// For now, just close modal and refresh page as in original implementation
showCreateModal = false;
@@ -195,6 +218,9 @@
<div class="finds-section">
<div class="finds-header">
<div class="finds-title-section">
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
</div>
<FindsList {finds} onFindExplore={handleFindExplore} />
<Button onclick={openCreateModal} class="create-find-button">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
@@ -268,6 +294,13 @@
position: relative;
}
.finds-title-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
:global(.create-find-button) {
position: absolute;
top: 0;
@@ -309,6 +342,12 @@
padding: 16px;
}
.finds-title-section {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
:global(.create-find-button) {
display: none;
}

View File

@@ -1,8 +1,8 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { find, findMedia, user, findLike } from '$lib/server/db/schema';
import { eq, and, sql, desc } from 'drizzle-orm';
import { find, findMedia, user, findLike, friendship } from '$lib/server/db/schema';
import { eq, and, sql, desc, or } from 'drizzle-orm';
import { encodeBase64url } from '@oslojs/encoding';
import { getSignedR2Url } from '$lib/server/r2';
@@ -22,11 +22,47 @@ export const GET: RequestHandler = async ({ url, locals }) => {
const includePrivate = url.searchParams.get('includePrivate') === 'true';
const order = url.searchParams.get('order') || 'desc';
const includeFriends = url.searchParams.get('includeFriends') === 'true';
try {
// Build where conditions
const baseCondition = includePrivate
? sql`(${find.isPublic} = 1 OR ${find.userId} = ${locals.user.id})`
: sql`${find.isPublic} = 1`;
// Get user's friends if needed
let friendIds: string[] = [];
if (includeFriends || includePrivate) {
const friendships = await db
.select({
userId: friendship.userId,
friendId: friendship.friendId
})
.from(friendship)
.where(
and(
eq(friendship.status, 'accepted'),
or(eq(friendship.userId, locals.user!.id), eq(friendship.friendId, locals.user!.id))
)
);
friendIds = friendships.map((f) => (f.userId === locals.user!.id ? f.friendId : f.userId));
}
// Build privacy conditions
const conditions = [sql`${find.isPublic} = 1`]; // Always include public finds
if (includePrivate) {
// Include user's own finds (both public and private)
conditions.push(sql`${find.userId} = ${locals.user!.id}`);
}
if (includeFriends && friendIds.length > 0) {
// Include friends' finds (both public and private)
conditions.push(
sql`${find.userId} IN (${sql.join(
friendIds.map((id) => sql`${id}`),
sql`, `
)})`
);
}
const baseCondition = sql`(${sql.join(conditions, sql` OR `)})`;
let whereConditions = baseCondition;

View File

@@ -0,0 +1,175 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { friendship, user } from '$lib/server/db/schema';
import { eq, and, or } from 'drizzle-orm';
import { encodeBase64url } from '@oslojs/encoding';
import { getSignedR2Url } from '$lib/server/r2';
function generateFriendshipId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(15));
return encodeBase64url(bytes);
}
export const GET: RequestHandler = async ({ url, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const status = url.searchParams.get('status') || 'accepted';
const type = url.searchParams.get('type') || 'friends'; // 'friends', 'sent', 'received'
try {
let friendships;
if (type === 'friends') {
// Get accepted friendships where user is either sender or receiver
friendships = await db
.select({
id: friendship.id,
userId: friendship.userId,
friendId: friendship.friendId,
status: friendship.status,
createdAt: friendship.createdAt,
friendUsername: user.username,
friendProfilePictureUrl: user.profilePictureUrl
})
.from(friendship)
.innerJoin(
user,
or(
and(eq(friendship.friendId, user.id), eq(friendship.userId, locals.user.id)),
and(eq(friendship.userId, user.id), eq(friendship.friendId, locals.user.id))
)
)
.where(
and(
eq(friendship.status, status),
or(eq(friendship.userId, locals.user.id), eq(friendship.friendId, locals.user.id))
)
);
} else if (type === 'sent') {
// Get friend requests sent by current user
friendships = await db
.select({
id: friendship.id,
userId: friendship.userId,
friendId: friendship.friendId,
status: friendship.status,
createdAt: friendship.createdAt,
friendUsername: user.username,
friendProfilePictureUrl: user.profilePictureUrl
})
.from(friendship)
.innerJoin(user, eq(friendship.friendId, user.id))
.where(and(eq(friendship.userId, locals.user.id), eq(friendship.status, status)));
} else if (type === 'received') {
// Get friend requests received by current user
friendships = await db
.select({
id: friendship.id,
userId: friendship.userId,
friendId: friendship.friendId,
status: friendship.status,
createdAt: friendship.createdAt,
friendUsername: user.username,
friendProfilePictureUrl: user.profilePictureUrl
})
.from(friendship)
.innerJoin(user, eq(friendship.userId, user.id))
.where(and(eq(friendship.friendId, locals.user.id), eq(friendship.status, status)));
}
// Generate signed URLs for profile pictures
const friendshipsWithSignedUrls = await Promise.all(
(friendships || []).map(async (friendship) => {
let profilePictureUrl = friendship.friendProfilePictureUrl;
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
try {
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60);
} catch (error) {
console.error('Failed to generate signed URL for profile picture:', error);
profilePictureUrl = null;
}
}
return {
...friendship,
friendProfilePictureUrl: profilePictureUrl
};
})
);
return json(friendshipsWithSignedUrls);
} catch (err) {
console.error('Error loading friendships:', err);
throw error(500, 'Failed to load friendships');
}
};
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const data = await request.json();
const { friendId } = data;
if (!friendId) {
throw error(400, 'Friend ID is required');
}
if (friendId === locals.user.id) {
throw error(400, 'Cannot send friend request to yourself');
}
try {
// Check if friend exists
const friendExists = await db.select().from(user).where(eq(user.id, friendId)).limit(1);
if (friendExists.length === 0) {
throw error(404, 'User not found');
}
// Check if friendship already exists
const existingFriendship = await db
.select()
.from(friendship)
.where(
or(
and(eq(friendship.userId, locals.user.id), eq(friendship.friendId, friendId)),
and(eq(friendship.userId, friendId), eq(friendship.friendId, locals.user.id))
)
)
.limit(1);
if (existingFriendship.length > 0) {
const status = existingFriendship[0].status;
if (status === 'accepted') {
throw error(400, 'Already friends');
} else if (status === 'pending') {
throw error(400, 'Friend request already sent');
} else if (status === 'blocked') {
throw error(400, 'Cannot send friend request');
}
}
// Create new friend request
const newFriendship = await db
.insert(friendship)
.values({
id: generateFriendshipId(),
userId: locals.user.id,
friendId,
status: 'pending'
})
.returning();
return json({ success: true, friendship: newFriendship[0] });
} catch (err) {
console.error('Error sending friend request:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to send friend request');
}
};

View File

@@ -0,0 +1,129 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { friendship } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
export const PUT: RequestHandler = async ({ params, request, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const { friendshipId } = params;
const data = await request.json();
const { action } = data; // 'accept', 'decline', 'block'
if (!friendshipId) {
throw error(400, 'Friendship ID is required');
}
if (!action || !['accept', 'decline', 'block'].includes(action)) {
throw error(400, 'Valid action is required (accept, decline, block)');
}
try {
// Find the friendship
const existingFriendship = await db
.select()
.from(friendship)
.where(eq(friendship.id, friendshipId))
.limit(1);
if (existingFriendship.length === 0) {
throw error(404, 'Friendship not found');
}
const friendshipRecord = existingFriendship[0];
// Check if user is authorized to modify this friendship
// User can only accept/decline requests sent TO them, or cancel requests they sent
const isReceiver = friendshipRecord.friendId === locals.user.id;
const isSender = friendshipRecord.userId === locals.user.id;
if (!isReceiver && !isSender) {
throw error(403, 'Not authorized to modify this friendship');
}
// Only receivers can accept or decline pending requests
if ((action === 'accept' || action === 'decline') && !isReceiver) {
throw error(403, 'Only the recipient can accept or decline friend requests');
}
// Only accept pending requests
if (action === 'accept' && friendshipRecord.status !== 'pending') {
throw error(400, 'Can only accept pending friend requests');
}
let newStatus: string;
if (action === 'accept') {
newStatus = 'accepted';
} else if (action === 'decline') {
newStatus = 'declined';
} else if (action === 'block') {
newStatus = 'blocked';
} else {
throw error(400, 'Invalid action');
}
// Update the friendship status
const updatedFriendship = await db
.update(friendship)
.set({ status: newStatus })
.where(eq(friendship.id, friendshipId))
.returning();
return json({ success: true, friendship: updatedFriendship[0] });
} catch (err) {
console.error('Error updating friendship:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to update friendship');
}
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const { friendshipId } = params;
if (!friendshipId) {
throw error(400, 'Friendship ID is required');
}
try {
// Find the friendship
const existingFriendship = await db
.select()
.from(friendship)
.where(eq(friendship.id, friendshipId))
.limit(1);
if (existingFriendship.length === 0) {
throw error(404, 'Friendship not found');
}
const friendshipRecord = existingFriendship[0];
// Check if user is part of this friendship
if (
friendshipRecord.userId !== locals.user.id &&
friendshipRecord.friendId !== locals.user.id
) {
throw error(403, 'Not authorized to delete this friendship');
}
// Delete the friendship
await db.delete(friendship).where(eq(friendship.id, friendshipId));
return json({ success: true });
} catch (err) {
console.error('Error deleting friendship:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to delete friendship');
}
};

View File

@@ -0,0 +1,97 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { user, friendship } from '$lib/server/db/schema';
import { eq, and, or, ilike, ne } from 'drizzle-orm';
import { getSignedR2Url } from '$lib/server/r2';
export const GET: RequestHandler = async ({ url, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const query = url.searchParams.get('q');
const limit = parseInt(url.searchParams.get('limit') || '20');
if (!query || query.trim().length < 2) {
throw error(400, 'Search query must be at least 2 characters');
}
try {
// Search for users by username, excluding current user
const users = await db
.select({
id: user.id,
username: user.username,
profilePictureUrl: user.profilePictureUrl
})
.from(user)
.where(and(ilike(user.username, `%${query.trim()}%`), ne(user.id, locals.user.id)))
.limit(Math.min(limit, 50));
// Get existing friendships to determine relationship status
const userIds = users.map((u) => u.id);
let existingFriendships: Array<{
userId: string;
friendId: string;
status: string;
}> = [];
if (userIds.length > 0) {
existingFriendships = await db
.select({
userId: friendship.userId,
friendId: friendship.friendId,
status: friendship.status
})
.from(friendship)
.where(
or(
and(
eq(friendship.userId, locals.user.id),
or(...userIds.map((id) => eq(friendship.friendId, id)))
),
and(
eq(friendship.friendId, locals.user.id),
or(...userIds.map((id) => eq(friendship.userId, id)))
)
)
);
}
// Create a map of friendship statuses
const friendshipStatusMap = new Map<string, string>();
existingFriendships.forEach((f) => {
const otherUserId = f.userId === locals.user!.id ? f.friendId : f.userId;
friendshipStatusMap.set(otherUserId, f.status);
});
// Generate signed URLs and add friendship status
const usersWithSignedUrls = await Promise.all(
users.map(async (userItem) => {
let profilePictureUrl = userItem.profilePictureUrl;
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
try {
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60);
} catch (error) {
console.error('Failed to generate signed URL for profile picture:', error);
profilePictureUrl = null;
}
}
const friendshipStatus = friendshipStatusMap.get(userItem.id) || 'none';
return {
...userItem,
profilePictureUrl,
friendshipStatus
};
})
);
return json(usersWithSignedUrls);
} catch (err) {
console.error('Error searching users:', err);
throw error(500, 'Failed to search users');
}
};

View File

@@ -0,0 +1,36 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
try {
// Fetch friends, sent requests, and received requests in parallel
const [friendsResponse, sentResponse, receivedResponse] = await Promise.all([
fetch('/api/friends?type=friends&status=accepted'),
fetch('/api/friends?type=sent&status=pending'),
fetch('/api/friends?type=received&status=pending')
]);
if (!friendsResponse.ok || !sentResponse.ok || !receivedResponse.ok) {
throw error(500, 'Failed to load friends data');
}
const [friends, sentRequests, receivedRequests] = await Promise.all([
friendsResponse.json(),
sentResponse.json(),
receivedResponse.json()
]);
return {
friends,
sentRequests,
receivedRequests
};
} catch (err) {
console.error('Error loading friends page:', err);
throw error(500, 'Failed to load friends');
}
};

View File

@@ -0,0 +1,429 @@
<script lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/card';
import { Button } from '$lib/components/button';
import { Input } from '$lib/components/input';
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/avatar';
import { Badge } from '$lib/components/badge';
import { toast } from 'svelte-sonner';
import type { PageData } from './$types';
type Friendship = {
id: string;
userId: string;
friendId: string;
status: string;
createdAt: string;
friendUsername: string;
friendProfilePictureUrl: string | null;
};
type SearchUser = {
id: string;
username: string;
profilePictureUrl: string | null;
friendshipStatus: string;
};
let { data }: { data: PageData } = $props();
let searchQuery = $state('');
let searchResults = $state<SearchUser[]>([]);
let isSearching = $state(false);
let activeTab = $state('friends');
let friends = $state<Friendship[]>(data.friends);
let sentRequests = $state<Friendship[]>(data.sentRequests);
let receivedRequests = $state<Friendship[]>(data.receivedRequests);
async function searchUsers() {
if (searchQuery.trim().length < 2) {
searchResults = [];
return;
}
isSearching = true;
try {
const response = await fetch(`/api/users?q=${encodeURIComponent(searchQuery.trim())}`);
if (!response.ok) {
throw new Error('Failed to search users');
}
searchResults = await response.json();
} catch (error) {
console.error('Error searching users:', error);
toast.error('Failed to search users');
} finally {
isSearching = false;
}
}
async function sendFriendRequest(friendId: string) {
try {
const response = await fetch('/api/friends', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ friendId })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to send friend request');
}
toast.success('Friend request sent!');
// Update the search results to reflect the new status
searchResults = searchResults.map((user) =>
user.id === friendId ? { ...user, friendshipStatus: 'pending' } : user
);
} catch (error) {
console.error('Error sending friend request:', error);
toast.error(error instanceof Error ? error.message : 'Failed to send friend request');
}
}
async function respondToFriendRequest(friendshipId: string, action: 'accept' | 'decline') {
try {
const response = await fetch(`/api/friends/${friendshipId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ action })
});
if (!response.ok) {
throw new Error(`Failed to ${action} friend request`);
}
toast.success(`Friend request ${action}ed!`);
// Remove from received requests
receivedRequests = receivedRequests.filter((req: Friendship) => req.id !== friendshipId);
// If accepted, refetch friends list
if (action === 'accept') {
const friendsResponse = await fetch('/api/friends?type=friends&status=accepted');
if (friendsResponse.ok) {
friends = await friendsResponse.json();
}
}
} catch (error) {
console.error(`Error ${action}ing friend request:`, error);
toast.error(`Failed to ${action} friend request`);
}
}
async function removeFriend(friendshipId: string) {
try {
const response = await fetch(`/api/friends/${friendshipId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to remove friend');
}
toast.success('Friend removed');
friends = friends.filter((friend: Friendship) => friend.id !== friendshipId);
} catch (error) {
console.error('Error removing friend:', error);
toast.error('Failed to remove friend');
}
}
// Search users when query changes with debounce
let searchTimeout: ReturnType<typeof setTimeout>;
$effect(() => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(searchUsers, 300);
});
</script>
<svelte:head>
<title>Friends - Serengo</title>
</svelte:head>
<div class="friends-page">
<div class="friends-container">
<!-- Tab Navigation -->
<div class="tabs">
<Button
variant={activeTab === 'friends' ? 'default' : 'outline'}
onclick={() => (activeTab = 'friends')}
>
Friends ({friends.length})
</Button>
<Button
variant={activeTab === 'requests' ? 'default' : 'outline'}
onclick={() => (activeTab = 'requests')}
>
Requests ({receivedRequests.length})
</Button>
<Button
variant={activeTab === 'search' ? 'default' : 'outline'}
onclick={() => (activeTab = 'search')}
>
Find Friends
</Button>
</div>
<!-- Friends List -->
{#if activeTab === 'friends'}
<Card>
<CardHeader>
<CardTitle>Your Friends</CardTitle>
</CardHeader>
<CardContent>
{#if friends.length === 0}
<p class="empty-state">
No friends yet. Use the "Find Friends" tab to search for people!
</p>
{:else}
<div class="user-grid">
{#each friends as friend (friend.id)}
<div class="user-card">
<Avatar>
<AvatarImage src={friend.friendProfilePictureUrl} alt={friend.friendUsername} />
<AvatarFallback>{friend.friendUsername.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<div class="user-info">
<span class="username">{friend.friendUsername}</span>
<Badge variant="secondary">Friend</Badge>
</div>
<Button variant="destructive" size="sm" onclick={() => removeFriend(friend.id)}>
Remove
</Button>
</div>
{/each}
</div>
{/if}
</CardContent>
</Card>
{/if}
<!-- Friend Requests -->
{#if activeTab === 'requests'}
<div class="requests-section">
<!-- Received Requests -->
<Card>
<CardHeader>
<CardTitle>Friend Requests ({receivedRequests.length})</CardTitle>
</CardHeader>
<CardContent>
{#if receivedRequests.length === 0}
<p class="empty-state">No pending friend requests</p>
{:else}
<div class="user-grid">
{#each receivedRequests as request (request.id)}
<div class="user-card">
<Avatar>
<AvatarImage
src={request.friendProfilePictureUrl}
alt={request.friendUsername}
/>
<AvatarFallback
>{request.friendUsername.charAt(0).toUpperCase()}</AvatarFallback
>
</Avatar>
<div class="user-info">
<span class="username">{request.friendUsername}</span>
<Badge variant="outline">Pending</Badge>
</div>
<div class="request-actions">
<Button
variant="default"
size="sm"
onclick={() => respondToFriendRequest(request.id, 'accept')}
>
Accept
</Button>
<Button
variant="outline"
size="sm"
onclick={() => respondToFriendRequest(request.id, 'decline')}
>
Decline
</Button>
</div>
</div>
{/each}
</div>
{/if}
</CardContent>
</Card>
<!-- Sent Requests -->
{#if sentRequests.length > 0}
<Card>
<CardHeader>
<CardTitle>Sent Requests ({sentRequests.length})</CardTitle>
</CardHeader>
<CardContent>
<div class="user-grid">
{#each sentRequests as request (request.id)}
<div class="user-card">
<Avatar>
<AvatarImage
src={request.friendProfilePictureUrl}
alt={request.friendUsername}
/>
<AvatarFallback
>{request.friendUsername.charAt(0).toUpperCase()}</AvatarFallback
>
</Avatar>
<div class="user-info">
<span class="username">{request.friendUsername}</span>
<Badge variant="secondary">Sent</Badge>
</div>
</div>
{/each}
</div>
</CardContent>
</Card>
{/if}
</div>
{/if}
<!-- Search Users -->
{#if activeTab === 'search'}
<Card>
<CardHeader>
<CardTitle>Find Friends</CardTitle>
</CardHeader>
<CardContent>
<div class="search-section">
<Input bind:value={searchQuery} placeholder="Search for users by username..." />
{#if isSearching}
<p class="loading">Searching...</p>
{:else if searchQuery.trim().length >= 2}
{#if searchResults.length === 0}
<p class="empty-state">No users found matching "{searchQuery}"</p>
{:else}
<div class="user-grid">
{#each searchResults as user (user.id)}
<div class="user-card">
<Avatar>
<AvatarImage src={user.profilePictureUrl} alt={user.username} />
<AvatarFallback>{user.username.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<div class="user-info">
<span class="username">{user.username}</span>
{#if user.friendshipStatus === 'accepted'}
<Badge variant="secondary">Friend</Badge>
{:else if user.friendshipStatus === 'pending'}
<Badge variant="outline">Request Sent</Badge>
{:else if user.friendshipStatus === 'blocked'}
<Badge variant="destructive">Blocked</Badge>
{/if}
</div>
{#if user.friendshipStatus === 'none'}
<Button onclick={() => sendFriendRequest(user.id)} size="sm">
Add Friend
</Button>
{/if}
</div>
{/each}
</div>
{/if}
{:else if searchQuery.trim().length > 0}
<p class="hint">Please enter at least 2 characters to search</p>
{/if}
</div>
</CardContent>
</Card>
{/if}
</div>
</div>
<style>
.friends-page {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.friends-container {
display: flex;
flex-direction: column;
gap: 24px;
}
.tabs {
display: flex;
gap: 8px;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 16px;
}
.user-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
.user-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fafafa;
}
.user-info {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.username {
font-weight: 500;
color: #1a1a1a;
}
.request-actions {
display: flex;
gap: 8px;
}
.requests-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.search-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.empty-state,
.loading,
.hint {
padding: 40px 20px;
text-align: center;
color: #6b7280;
}
@media (max-width: 768px) {
.friends-page {
padding: 12px;
}
.user-grid {
grid-template-columns: 1fr;
}
.tabs {
flex-wrap: wrap;
}
.request-actions {
flex-direction: column;
}
}
</style>