diff --git a/drizzle/0005_rapid_warpath.sql b/drizzle/0005_rapid_warpath.sql new file mode 100644 index 0000000..fdcf1f1 --- /dev/null +++ b/drizzle/0005_rapid_warpath.sql @@ -0,0 +1 @@ +ALTER TABLE "user" ADD COLUMN "profile_picture_url" text; \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..e246615 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 4259354..fe58885 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/src/lib/components/FindsFilter.svelte b/src/lib/components/FindsFilter.svelte new file mode 100644 index 0000000..568b9b9 --- /dev/null +++ b/src/lib/components/FindsFilter.svelte @@ -0,0 +1,125 @@ + + + + + + + + + {currentOption.label} + + + + + {#each filterOptions as option (option.value)} + onFilterChange(option.value)} + data-selected={currentFilter === option.value} + > + + + {option.label} + {#if currentFilter === option.value} + Selected + {/if} + + {option.description} + + + {/each} + + + + diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte index c159d21..3d60bf1 100644 --- a/src/lib/components/Header.svelte +++ b/src/lib/components/Header.svelte @@ -12,7 +12,7 @@ - Serengo + Serengo + + Friends + + @@ -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; diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index e8a95da..df81ae7 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -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 { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index e9822e3..ee2ddb6 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -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 @@ + + + @@ -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; } diff --git a/src/routes/api/finds/+server.ts b/src/routes/api/finds/+server.ts index acfb94a..0ed0064 100644 --- a/src/routes/api/finds/+server.ts +++ b/src/routes/api/finds/+server.ts @@ -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; diff --git a/src/routes/api/friends/+server.ts b/src/routes/api/friends/+server.ts new file mode 100644 index 0000000..e8ff518 --- /dev/null +++ b/src/routes/api/friends/+server.ts @@ -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'); + } +}; diff --git a/src/routes/api/friends/[friendshipId]/+server.ts b/src/routes/api/friends/[friendshipId]/+server.ts new file mode 100644 index 0000000..df1f6bb --- /dev/null +++ b/src/routes/api/friends/[friendshipId]/+server.ts @@ -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'); + } +}; diff --git a/src/routes/api/users/+server.ts b/src/routes/api/users/+server.ts new file mode 100644 index 0000000..0f0e5b5 --- /dev/null +++ b/src/routes/api/users/+server.ts @@ -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(); + 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'); + } +}; diff --git a/src/routes/friends/+page.server.ts b/src/routes/friends/+page.server.ts new file mode 100644 index 0000000..ac83080 --- /dev/null +++ b/src/routes/friends/+page.server.ts @@ -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'); + } +}; diff --git a/src/routes/friends/+page.svelte b/src/routes/friends/+page.svelte new file mode 100644 index 0000000..03f4d83 --- /dev/null +++ b/src/routes/friends/+page.svelte @@ -0,0 +1,429 @@ + + + + Friends - Serengo + + + + + + + (activeTab = 'friends')} + > + Friends ({friends.length}) + + (activeTab = 'requests')} + > + Requests ({receivedRequests.length}) + + (activeTab = 'search')} + > + Find Friends + + + + + {#if activeTab === 'friends'} + + + Your Friends + + + {#if friends.length === 0} + + No friends yet. Use the "Find Friends" tab to search for people! + + {:else} + + {#each friends as friend (friend.id)} + + + + {friend.friendUsername.charAt(0).toUpperCase()} + + + {friend.friendUsername} + Friend + + removeFriend(friend.id)}> + Remove + + + {/each} + + {/if} + + + {/if} + + + {#if activeTab === 'requests'} + + + + + Friend Requests ({receivedRequests.length}) + + + {#if receivedRequests.length === 0} + No pending friend requests + {:else} + + {#each receivedRequests as request (request.id)} + + + + {request.friendUsername.charAt(0).toUpperCase()} + + + {request.friendUsername} + Pending + + + respondToFriendRequest(request.id, 'accept')} + > + Accept + + respondToFriendRequest(request.id, 'decline')} + > + Decline + + + + {/each} + + {/if} + + + + + {#if sentRequests.length > 0} + + + Sent Requests ({sentRequests.length}) + + + + {#each sentRequests as request (request.id)} + + + + {request.friendUsername.charAt(0).toUpperCase()} + + + {request.friendUsername} + Sent + + + {/each} + + + + {/if} + + {/if} + + + {#if activeTab === 'search'} + + + Find Friends + + + + + + {#if isSearching} + Searching... + {:else if searchQuery.trim().length >= 2} + {#if searchResults.length === 0} + No users found matching "{searchQuery}" + {:else} + + {#each searchResults as user (user.id)} + + + + {user.username.charAt(0).toUpperCase()} + + + {user.username} + {#if user.friendshipStatus === 'accepted'} + Friend + {:else if user.friendshipStatus === 'pending'} + Request Sent + {:else if user.friendshipStatus === 'blocked'} + Blocked + {/if} + + {#if user.friendshipStatus === 'none'} + sendFriendRequest(user.id)} size="sm"> + Add Friend + + {/if} + + {/each} + + {/if} + {:else if searchQuery.trim().length > 0} + Please enter at least 2 characters to search + {/if} + + + + {/if} + + + +
+ No friends yet. Use the "Find Friends" tab to search for people! +
No pending friend requests
Searching...
No users found matching "{searchQuery}"
Please enter at least 2 characters to search