feat:friends
This commit is contained in:
1
drizzle/0005_rapid_warpath.sql
Normal file
1
drizzle/0005_rapid_warpath.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user" ADD COLUMN "profile_picture_url" text;
|
||||
443
drizzle/meta/0005_snapshot.json
Normal file
443
drizzle/meta/0005_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
125
src/lib/components/FindsFilter.svelte
Normal file
125
src/lib/components/FindsFilter.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
175
src/routes/api/friends/+server.ts
Normal file
175
src/routes/api/friends/+server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
129
src/routes/api/friends/[friendshipId]/+server.ts
Normal file
129
src/routes/api/friends/[friendshipId]/+server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
97
src/routes/api/users/+server.ts
Normal file
97
src/routes/api/users/+server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
36
src/routes/friends/+page.server.ts
Normal file
36
src/routes/friends/+page.server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
429
src/routes/friends/+page.svelte
Normal file
429
src/routes/friends/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user