8 Commits

Author SHA1 Message Date
73eeaf0c74 feat:better sharing of finds 2025-11-22 20:10:13 +01:00
2ac826cbf9 ui:use the default styling on homepage and find detail page 2025-11-22 20:07:06 +01:00
5285a15335 feat:big update to public finds 2025-11-22 20:04:25 +01:00
9f608067fc fix:dont autozoom when watching 2025-11-22 19:47:59 +01:00
4c73b6f919 fix:sidebar toggle 2025-11-22 14:11:38 +01:00
42d7246cff ui:update findpreview and commentlist 2025-11-22 11:39:18 +01:00
63b3e5112b ui:big ui overhaul
Improved dev experience by foldering components per feature. Improved
the ui of the notification mangager to be more consistent with overall
ui.
2025-11-21 14:37:09 +01:00
84f3d0bdb9 update:logboek.md 2025-11-18 14:51:51 +01:00
41 changed files with 1995 additions and 733 deletions

View File

@@ -1,5 +1,113 @@
# Logboek - Serengo Project
## November 2025
### 17 November 2025 - 3 uren
**Werk uitgevoerd:**
- **UI/UX Grote Overhaul - Fullscreen Map & Sidebar Design**
- Fullscreen map layout met side-sheet voor finds geïmplementeerd
- Sidebar toggle functionaliteit toegevoegd
- Local media proxy geïmplementeerd voor caching en CSP fixes
- Mobile en desktop UI verbeteringen voor CreateFind en FindPreview
- Overscroll behavior fixes
- FindList overflow problemen opgelost
**Commits:**
- 1c31e2c - add:sidebar toggle and fix overscroll behavior
- d8cab06 - fix:overflow of findlist
- d4d23ed - ui:find preview better ui
- ab8b0ee - ui:create find better ui
- dabc732 - fix:styling for mobile createfind
- 1f0e814 - ui:remove mobile + button and use same as desktop
- 96a173b - feat:use local proxy for media
- 08f7e77 - ui:big ui update
**Details:**
- Complete UI refresh met fullscreen map en overlay side-sheet voor finds
- Sidebar toggle voor betere map ervaring
- Local media proxy (/api/media/[...path]) voor caching en snellere laadtijden
- CSP issues opgelost door local proxy te gebruiken in plaats van directe R2 requests
- LocationButton component verwijderd (430 lines cleanup)
- Verbeterde mobile en desktop styling voor CreateFindModal
- FindPreview UI verbeteringen voor betere gebruikerservaring
- Overscroll behavior gefixed voor soepelere scrolling
- Mobile + button verwijderd, unified desktop/mobile interface
---
### 8 November 2025 - 5 uren
**Werk uitgevoerd:**
- **Web Push Notifications Systeem**
- Complete Web Push notification systeem geïmplementeerd
- NotificationManager, NotificationPrompt, en NotificationSettings componenten
- Notification preferences API endpoints
- Push notification triggers voor likes, comments, en friend requests
- Service worker push event handling
- VAPID keys generation script
**Commits:**
- ae339d6 - chore:linting,formatting,type fixing, ....
- 0754d62 - fix:push notification UI, settings and API
- e27b249 - fix:notifications
- 4d28834 - fix:notificationmanager
- d7f803c - fix:add NotificationManager and enable in layout
- df67564 - feat:add Web Push notification system
**Details:**
- Complete Web Push notification infrastructuur met VAPID keys
- Database schema uitbreiding voor notification subscriptions en preferences
- NotificationManager component voor real-time notification handling
- NotificationPrompt voor gebruikers toestemming
- NotificationSettings en NotificationSettingsSheet voor preference management
- Push notifications bij likes, comments, en friend requests
- Service worker integratie voor background push events
- API endpoints voor subscription management en preferences
- CSP updates voor FCM/GCM endpoints
- Lucide-svelte dependency toegevoegd voor icons
- Code linting, formatting, en type fixes
---
### 6 November 2025 - 4 uren
**Werk uitgevoerd:**
- **Comments Feature Implementatie**
- Complete comment systeem voor finds geïmplementeerd
- Comment creation, viewing, en deletion functionaliteit
- API-sync layer voor real-time comment synchronisatie
- Scrollable comments met limit functionaliteit
- Comment form en list UI componenten
**Commits:**
- 2efd496 - add:enhance comments list with scroll, limit, and styling
- b8c88d7 - feat:comments
- af49ed6 - logs:update logs
**Details:**
- Comment database schema en migraties
- API endpoints voor comment CRUD operaties (/api/finds/[findId]/comments)
- API-sync store uitbreiding voor comment state management
- CommentForm component met real-time posting
- CommentsList component met scrolling en limit ("+N more comments")
- Comment component met delete functionaliteit
- Integration in FindCard en FindPreview componenten
- Responsive styling voor mobile en desktop
- User authentication en authorization voor comments
- Real-time updates via API-sync layer
---
## Oktober 2025
### 4 November 2025 - 1 uren
@@ -381,9 +489,9 @@
## Totaal Overzicht
**Totale geschatte uren:** 80 uren
**Werkdagen:** 14 dagen
**Gemiddelde uren per dag:** 5.8 uur
**Totale geschatte uren:** 93 uren
**Werkdagen:** 17 dagen
**Gemiddelde uren per dag:** 5.5 uur
### Project Milestones:
@@ -401,6 +509,9 @@
12. **21 Okt**: UI refinement en bug fixes
13. **27-29 Okt**: Google Places Integration & Sync Service
14. **4 Nov**: UI Consistency & Media Layout Improvements
15. **6 Nov**: Comments Feature Implementatie
16. **8 Nov**: Web Push Notifications Systeem
17. **17 Nov**: UI/UX Grote Overhaul - Fullscreen Map & Sidebar Design
### Hoofdfunctionaliteiten geïmplementeerd:
@@ -431,3 +542,12 @@
- [x] Sync-service voor API data synchronisatie
- [x] Continuous location watching voor nauwkeurige tracking
- [x] CSP security verbeteringen
- [x] Comments systeem met real-time synchronisatie
- [x] Scrollable comments met limit functionaliteit
- [x] Web Push notifications systeem
- [x] Notification preferences management
- [x] Push notifications voor likes, comments, en friend requests
- [x] Service worker push event handling
- [x] Local media proxy voor caching en performance
- [x] Fullscreen map UI met sidebar toggle
- [x] Unified mobile/desktop interface

View File

@@ -1,469 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { toast } from 'svelte-sonner';
interface NotificationPreferences {
friendRequests: boolean;
friendAccepted: boolean;
findLiked: boolean;
findCommented: boolean;
pushEnabled: boolean;
}
let preferences = $state<NotificationPreferences>({
friendRequests: true,
friendAccepted: true,
findLiked: true,
findCommented: true,
pushEnabled: true
});
let isLoading = $state<boolean>(true);
let isSaving = $state<boolean>(false);
let isSubscribing = $state<boolean>(false);
let browserPermission = $state<NotificationPermission>('default');
onMount(() => {
if (!browser) return;
loadPreferences();
checkBrowserPermission();
});
function checkBrowserPermission() {
if (!browser || !('Notification' in window)) {
browserPermission = 'denied';
return;
}
browserPermission = Notification.permission;
}
async function requestBrowserPermission() {
if (!browser || !('Notification' in window)) {
toast.error('Notifications are not supported in this browser');
return;
}
try {
isSubscribing = true;
const permission = await Notification.requestPermission();
browserPermission = permission;
if (permission === 'granted') {
// Subscribe to push notifications
await subscribeToPush();
toast.success('Notifications enabled successfully');
} else if (permission === 'denied') {
toast.error('Notification permission denied');
}
} catch (error) {
console.error('Error requesting notification permission:', error);
toast.error('Failed to enable notifications');
} finally {
isSubscribing = false;
}
}
async function subscribeToPush() {
try {
const registration = await navigator.serviceWorker.ready;
const vapidPublicKey = await fetch('/api/notifications/subscribe').then((r) => r.text());
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
});
await fetch('/api/notifications/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
} catch (error) {
console.error('Error subscribing to push:', error);
throw error;
}
}
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
async function loadPreferences() {
try {
isLoading = true;
const response = await fetch('/api/notifications/preferences');
if (!response.ok) {
throw new Error('Failed to load notification preferences');
}
const data = await response.json();
preferences = data;
} catch (error) {
console.error('Error loading notification preferences:', error);
toast.error('Failed to load notification preferences');
} finally {
isLoading = false;
}
}
async function savePreferences() {
try {
isSaving = true;
const response = await fetch('/api/notifications/preferences', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(preferences)
});
if (!response.ok) {
throw new Error('Failed to save notification preferences');
}
toast.success('Notification preferences updated');
} catch (error) {
console.error('Error saving notification preferences:', error);
toast.error('Failed to save notification preferences');
} finally {
isSaving = false;
}
}
function handleToggle(key: keyof NotificationPreferences) {
preferences[key] = !preferences[key];
savePreferences();
}
const canTogglePreferences = $derived(browserPermission === 'granted' && preferences.pushEnabled);
</script>
<div class="notification-settings">
{#if isLoading}
<div class="loading">Loading preferences...</div>
{:else}
<!-- Browser Permission Banner -->
{#if browserPermission !== 'granted'}
<div class="permission-banner {browserPermission === 'denied' ? 'denied' : 'default'}">
<div class="permission-info">
{#if browserPermission === 'denied'}
<strong>Browser notifications blocked</strong>
<p>
Please enable notifications in your browser settings to receive push notifications
</p>
{:else}
<strong>Browser permission required</strong>
<p>Enable browser notifications to receive push notifications</p>
{/if}
</div>
{#if browserPermission === 'default'}
<button class="enable-button" onclick={requestBrowserPermission} disabled={isSubscribing}>
{isSubscribing ? 'Enabling...' : 'Enable'}
</button>
{/if}
</div>
{/if}
<div class="settings-list">
<!-- Push Notifications Toggle -->
<div class="setting-item">
<div class="setting-info">
<h3>Push Notifications</h3>
<p>Enable or disable all push notifications</p>
</div>
<label class="toggle">
<input
type="checkbox"
checked={preferences.pushEnabled}
onchange={() => handleToggle('pushEnabled')}
disabled={isSaving || browserPermission !== 'granted'}
/>
<span class="toggle-slider"></span>
</label>
</div>
<div class="divider"></div>
<!-- Friend Requests -->
<div class="setting-item" class:disabled={!canTogglePreferences}>
<div class="setting-info">
<h3>Friend Requests</h3>
<p>Get notified when someone sends you a friend request</p>
</div>
<label class="toggle">
<input
type="checkbox"
checked={preferences.friendRequests}
onchange={() => handleToggle('friendRequests')}
disabled={isSaving || !canTogglePreferences}
/>
<span class="toggle-slider"></span>
</label>
</div>
<!-- Friend Accepted -->
<div class="setting-item" class:disabled={!canTogglePreferences}>
<div class="setting-info">
<h3>Friend Request Accepted</h3>
<p>Get notified when someone accepts your friend request</p>
</div>
<label class="toggle">
<input
type="checkbox"
checked={preferences.friendAccepted}
onchange={() => handleToggle('friendAccepted')}
disabled={isSaving || !canTogglePreferences}
/>
<span class="toggle-slider"></span>
</label>
</div>
<!-- Find Liked -->
<div class="setting-item" class:disabled={!canTogglePreferences}>
<div class="setting-info">
<h3>Find Likes</h3>
<p>Get notified when someone likes your find</p>
</div>
<label class="toggle">
<input
type="checkbox"
checked={preferences.findLiked}
onchange={() => handleToggle('findLiked')}
disabled={isSaving || !canTogglePreferences}
/>
<span class="toggle-slider"></span>
</label>
</div>
<!-- Find Commented -->
<div class="setting-item" class:disabled={!canTogglePreferences}>
<div class="setting-info">
<h3>Find Comments</h3>
<p>Get notified when someone comments on your find</p>
</div>
<label class="toggle">
<input
type="checkbox"
checked={preferences.findCommented}
onchange={() => handleToggle('findCommented')}
disabled={isSaving || !canTogglePreferences}
/>
<span class="toggle-slider"></span>
</label>
</div>
</div>
{/if}
</div>
<style>
.notification-settings {
max-width: 600px;
margin: 0 auto;
padding: 20px;
font-family:
system-ui,
-apple-system,
sans-serif;
}
.loading {
text-align: center;
padding: 40px;
color: #6b7280;
}
.permission-banner {
background: #fef3c7;
border: 1px solid #fbbf24;
border-radius: 12px;
padding: 16px;
margin-bottom: 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.permission-banner.denied {
background: #fee2e2;
border-color: #ef4444;
}
.permission-info {
flex: 1;
}
.permission-info strong {
display: block;
font-size: 14px;
font-weight: 600;
color: #111827;
margin-bottom: 4px;
}
.permission-info p {
margin: 0;
font-size: 13px;
color: #6b7280;
}
.enable-button {
background: #3b82f6;
color: white;
border: none;
border-radius: 8px;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: background 0.2s;
}
.enable-button:hover:not(:disabled) {
background: #2563eb;
}
.enable-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.settings-list {
background: white;
border-radius: 12px;
border: 1px solid #e5e7eb;
overflow: hidden;
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
gap: 16px;
transition: opacity 0.2s;
}
.setting-item.disabled {
opacity: 0.5;
}
.setting-info {
flex: 1;
min-width: 0;
}
.setting-info h3 {
margin: 0 0 4px 0;
font-size: 16px;
font-family: inherit;
font-weight: 500;
color: #111827;
}
.setting-info p {
margin: 0;
font-size: 14px;
color: #6b7280;
line-height: 1.4;
}
.divider {
height: 1px;
background: #e5e7eb;
margin: 0 20px;
}
/* Toggle Switch */
.toggle {
position: relative;
display: inline-block;
width: 48px;
height: 28px;
flex-shrink: 0;
cursor: pointer;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #d1d5db;
border-radius: 28px;
transition: background-color 0.2s;
}
.toggle-slider:before {
position: absolute;
content: '';
height: 20px;
width: 20px;
left: 4px;
bottom: 4px;
background-color: white;
border-radius: 50%;
transition: transform 0.2s;
}
.toggle input:checked + .toggle-slider {
background-color: #3b82f6;
}
.toggle input:checked + .toggle-slider:before {
transform: translateX(20px);
}
.toggle input:disabled + .toggle-slider {
cursor: not-allowed;
opacity: 0.6;
}
@media (max-width: 768px) {
.notification-settings {
padding: 16px;
}
.settings-header h2 {
font-size: 20px;
}
.setting-item {
padding: 16px;
}
.setting-info h3 {
font-size: 15px;
}
.setting-info p {
font-size: 13px;
}
.permission-banner {
flex-direction: column;
align-items: flex-start;
}
.enable-button {
width: 100%;
}
}
</style>

View File

@@ -1,36 +0,0 @@
<script lang="ts">
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from './sheet/index.js';
import NotificationSettings from './NotificationSettings.svelte';
interface Props {
onClose: () => void;
}
let { onClose }: Props = $props();
</script>
<Sheet open={true} {onClose}>
<SheetContent class="notification-settings-sheet">
<SheetHeader>
<SheetTitle>Notifications</SheetTitle>
<SheetDescription>Manage your notification preferences</SheetDescription>
</SheetHeader>
<div class="notification-settings-content">
<NotificationSettings />
</div>
</SheetContent>
</Sheet>
<style>
:global(.notification-settings-sheet) {
max-width: 500px;
font-family:
system-ui,
-apple-system,
sans-serif;
}
.notification-settings-content {
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1 @@
export { default as LoginForm } from './login-form.svelte';

View File

@@ -9,7 +9,7 @@
import { cn } from '$lib/utils.js';
import { toast } from 'svelte-sonner';
import type { HTMLAttributes } from 'svelte/elements';
import type { ActionData } from '../../routes/login/$types.js';
import type { ActionData } from '../../../routes/login/$types.js';
let {
class: className,

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Button } from '$lib/components/button';
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
import ProfilePicture from '../profile/ProfilePicture.svelte';
import { Trash2 } from '@lucide/svelte';
import type { CommentState } from '$lib/stores/api-sync';

View File

@@ -75,7 +75,6 @@
<style>
.comment-form {
padding: 0.75rem 1rem;
border-top: 1px solid hsl(var(--border));
background: hsl(var(--background));
flex-shrink: 0;
}

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import CommentsList from './CommentsList.svelte';
interface CommentsProps {
findId: string;
currentUserId?: string;
collapsed?: boolean;
maxComments?: number;
showCommentForm?: boolean;
isScrollable?: boolean;
}
let {
findId,
currentUserId,
collapsed = true,
maxComments,
showCommentForm = true,
isScrollable = false
}: CommentsProps = $props();
</script>
<CommentsList {findId} {currentUserId} {collapsed} {maxComments} {showCommentForm} {isScrollable} />

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import Comment from '$lib/components/Comment.svelte';
import CommentForm from '$lib/components/CommentForm.svelte';
import Comment from './Comment.svelte';
import CommentForm from './CommentForm.svelte';
import { Skeleton } from '$lib/components/skeleton';
import { apiSync } from '$lib/stores/api-sync';
import { onMount } from 'svelte';
@@ -92,6 +92,12 @@
{#if isExpanded}
<div class="comments-container">
{#if showCommentForm}
<div class="comment-form-container">
<CommentForm onSubmit={handleAddComment} />
</div>
{/if}
{#if $commentsState.isLoading && !hasLoadedComments}
{@render loadingSkeleton()}
{:else if $commentsState.error}
@@ -119,10 +125,6 @@
{/if}
</div>
{/if}
{#if showCommentForm}
<CommentForm onSubmit={handleAddComment} />
{/if}
</div>
{/if}
</div>
@@ -148,14 +150,21 @@
}
.comments-container {
border-top: 1px solid hsl(var(--border));
margin-top: 0.5rem;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.comment-form-container {
flex-shrink: 0;
border-bottom: 1px solid hsl(var(--border));
position: sticky;
top: 0;
background: hsl(var(--background));
z-index: 1;
}
.comments {
padding: 0.75rem 0;
}
@@ -165,6 +174,7 @@
overflow-y: auto;
padding: 0.75rem 1rem;
min-height: 0;
max-height: 300px;
}
.see-more {

View File

@@ -3,7 +3,7 @@
import { Label } from '$lib/components/label';
import { Button } from '$lib/components/button';
import { coordinates } from '$lib/stores/location';
import POISearch from './POISearch.svelte';
import POISearch from '../map/POISearch.svelte';
import type { PlaceResult } from '$lib/utils/places';
interface Props {

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { Button } from '$lib/components/button';
import { Badge } from '$lib/components/badge';
import LikeButton from '$lib/components/LikeButton.svelte';
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
import CommentsList from '$lib/components/CommentsList.svelte';
import LikeButton from './LikeButton.svelte';
import VideoPlayer from '../media/VideoPlayer.svelte';
import ProfilePicture from '../profile/ProfilePicture.svelte';
import CommentsList from './CommentsList.svelte';
import { Ellipsis, MessageCircle, Share } from '@lucide/svelte';
interface FindCardProps {
@@ -53,6 +53,35 @@
function toggleComments() {
showComments = !showComments;
}
function handleShare() {
const url = `${window.location.origin}/finds/${id}`;
if (navigator.share) {
navigator
.share({
title: title,
text: description || `Check out this find: ${title}`,
url: url
})
.catch((error) => {
// User cancelled or error occurred
if (error.name !== 'AbortError') {
console.error('Error sharing:', error);
}
});
} else {
// Fallback: Copy to clipboard
navigator.clipboard
.writeText(url)
.then(() => {
alert('Find URL copied to clipboard!');
})
.catch((error) => {
console.error('Error copying to clipboard:', error);
});
}
}
</script>
<div class="find-card">
@@ -135,7 +164,7 @@
<MessageCircle size={16} />
<span>{commentCount || 'comment'}</span>
</Button>
<Button variant="ghost" size="sm" class="action-button">
<Button variant="ghost" size="sm" class="action-button" onclick={handleShare}>
<Share size={16} />
<span>share</span>
</Button>
@@ -151,7 +180,7 @@
<CommentsList
findId={id}
{currentUserId}
collapsed={false}
collapsed={true}
maxComments={5}
showCommentForm={true}
/>

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import LikeButton from '$lib/components/LikeButton.svelte';
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
import CommentsList from '$lib/components/CommentsList.svelte';
import LikeButton from './LikeButton.svelte';
import VideoPlayer from '../media/VideoPlayer.svelte';
import ProfilePicture from '../profile/ProfilePicture.svelte';
import CommentsList from './CommentsList.svelte';
interface Find {
id: string;
@@ -88,14 +88,28 @@
const url = `${window.location.origin}/finds/${find.id}`;
if (navigator.share) {
navigator.share({
title: find.title,
text: find.description || `Check out this find: ${find.title}`,
url: url
});
navigator
.share({
title: find.title,
text: find.description || `Check out this find: ${find.title}`,
url: url
})
.catch((error) => {
// User cancelled or error occurred
if (error.name !== 'AbortError') {
console.error('Error sharing:', error);
}
});
} else {
navigator.clipboard.writeText(url);
alert('Find URL copied to clipboard!');
// Fallback: Copy to clipboard
navigator.clipboard
.writeText(url)
.then(() => {
alert('Find URL copied to clipboard!');
})
.catch((error) => {
console.error('Error copying to clipboard:', error);
});
}
}
</script>
@@ -260,6 +274,7 @@
<CommentsList
findId={find.id}
{currentUserId}
collapsed={false}
isScrollable={true}
showCommentForm={true}
/>
@@ -274,10 +289,10 @@
position: fixed;
top: 80px;
right: 20px;
width: 40%;
width: fit-content;
max-width: 600px;
min-width: 500px;
height: calc(100vh - 100px);
min-width: 400px;
max-height: calc(100vh - 100px);
backdrop-filter: blur(10px);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
@@ -307,7 +322,8 @@
width: 100%;
min-width: 0;
max-width: none;
height: 90vh;
height: auto;
max-height: calc(90vh - 20px);
border-radius: 16px 16px 0 0;
animation: slideUp 0.3s ease-out;
}
@@ -424,63 +440,28 @@
}
.modal-body {
flex: 1;
overflow: hidden;
padding: 0;
display: flex;
flex-direction: column;
min-height: 0;
overflow: auto;
padding: 0;
}
.media-container {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
width: 100%;
}
.media-viewer {
position: relative;
width: 100%;
max-height: 400px;
background: hsl(var(--muted));
overflow: hidden;
flex: 1;
min-height: 0;
}
/* Desktop media viewer - maximize available space */
@media (min-width: 768px) {
.modal-body {
height: calc(100vh - 180px);
}
.media-container {
flex: 2;
}
.content-section {
flex: 1;
min-height: 160px;
max-height: 300px;
}
}
/* Mobile media viewer - maximize available space */
@media (max-width: 767px) {
.modal-body {
height: calc(90vh - 180px);
}
.media-container {
flex: 2;
}
.content-section {
flex: 1;
min-height: 140px;
max-height: 250px;
}
display: flex;
align-items: center;
justify-content: center;
}
.media-image {
@@ -551,7 +532,6 @@
.content-section {
padding: 1rem 1.5rem 1.5rem;
overflow-y: auto;
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.5);
@@ -570,7 +550,6 @@
gap: 0.5rem;
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
margin-bottom: 1.5rem;
}
.actions {
@@ -619,28 +598,12 @@
}
.comments-section {
flex: 1;
min-height: 0;
border-top: 1px solid rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.5);
}
/* Desktop comments section */
@media (min-width: 768px) {
.comments-section {
height: calc(100vh - 500px);
min-height: 200px;
}
}
/* Mobile comments section */
@media (max-width: 767px) {
.comments-section {
height: calc(90vh - 450px);
min-height: 150px;
}
max-height: 400px;
overflow: hidden;
}
/* Mobile specific adjustments */
@@ -665,18 +628,5 @@
.content-section {
padding: 1rem;
}
.actions {
flex-direction: column;
gap: 0.5rem;
}
.action-button {
width: 100%;
}
.comments-section {
height: calc(90vh - 480px);
}
}
</style>

View File

@@ -0,0 +1,10 @@
export { default as Comment } from './Comment.svelte';
export { default as CommentForm } from './CommentForm.svelte';
export { default as CommentsList } from './CommentsList.svelte';
export { default as Comments } from './Comments.svelte';
export { default as CreateFindModal } from './CreateFindModal.svelte';
export { default as FindCard } from './FindCard.svelte';
export { default as FindPreview } from './FindPreview.svelte';
export { default as FindsFilter } from './FindsFilter.svelte';
export { default as FindsList } from './FindsList.svelte';
export { default as LikeButton } from './LikeButton.svelte';

View File

@@ -87,8 +87,10 @@
const mapReady = $derived(mapLoaded && styleLoaded && isIdle);
// Reactive center and zoom based on location or props
// Only recenter when shouldZoomToLocation is true (user clicked location button)
// or when autoCenter is true AND coordinates are first loaded
const mapCenter = $derived(
$coordinates && (autoCenter || $shouldZoomToLocation)
$coordinates && $shouldZoomToLocation
? ([$coordinates.longitude, $coordinates.latitude] as [number, number])
: center || $getMapCenter
);
@@ -98,9 +100,7 @@
// Force zoom to calculated level when location button is clicked
return $getMapZoom;
}
if ($coordinates && autoCenter) {
return $getMapZoom;
}
// Don't auto-zoom on coordinate updates, keep current zoom
return zoom || 13;
});

View File

@@ -0,0 +1,3 @@
export { default as LocationManager } from './LocationManager.svelte';
export { default as Map } from './Map.svelte';
export { default as POISearch } from './POISearch.svelte';

View File

@@ -0,0 +1 @@
export { default as VideoPlayer } from './VideoPlayer.svelte';

View File

@@ -0,0 +1,613 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { toast } from 'svelte-sonner';
interface NotificationPreferences {
friendRequests: boolean;
friendAccepted: boolean;
findLiked: boolean;
findCommented: boolean;
pushEnabled: boolean;
}
interface Props {
onClose: () => void;
}
let { onClose }: Props = $props();
let preferences = $state<NotificationPreferences>({
friendRequests: true,
friendAccepted: true,
findLiked: true,
findCommented: true,
pushEnabled: true
});
let isLoading = $state<boolean>(true);
let isSaving = $state<boolean>(false);
let isSubscribing = $state<boolean>(false);
let browserPermission = $state<NotificationPermission>('default');
let isMobile = $state(false);
// Detect screen size
$effect(() => {
if (typeof window === 'undefined') return;
const checkIsMobile = () => {
isMobile = window.innerWidth < 768;
};
checkIsMobile();
window.addEventListener('resize', checkIsMobile);
return () => window.removeEventListener('resize', checkIsMobile);
});
onMount(() => {
if (!browser) return;
loadPreferences();
checkBrowserPermission();
});
function checkBrowserPermission() {
if (!browser || !('Notification' in window)) {
browserPermission = 'denied';
return;
}
browserPermission = Notification.permission;
}
async function requestBrowserPermission() {
if (!browser || !('Notification' in window)) {
toast.error('Notifications are not supported in this browser');
return;
}
try {
isSubscribing = true;
const permission = await Notification.requestPermission();
browserPermission = permission;
if (permission === 'granted') {
// Subscribe to push notifications
await subscribeToPush();
toast.success('Notifications enabled successfully');
} else if (permission === 'denied') {
toast.error('Notification permission denied');
}
} catch (error) {
console.error('Error requesting notification permission:', error);
toast.error('Failed to enable notifications');
} finally {
isSubscribing = false;
}
}
async function subscribeToPush() {
try {
const registration = await navigator.serviceWorker.ready;
const vapidPublicKey = await fetch('/api/notifications/subscribe').then((r) => r.text());
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
});
await fetch('/api/notifications/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
} catch (error) {
console.error('Error subscribing to push:', error);
throw error;
}
}
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
async function loadPreferences() {
try {
isLoading = true;
const response = await fetch('/api/notifications/preferences');
if (!response.ok) {
throw new Error('Failed to load notification preferences');
}
const data = await response.json();
preferences = data;
} catch (error) {
console.error('Error loading notification preferences:', error);
toast.error('Failed to load notification preferences');
} finally {
isLoading = false;
}
}
async function savePreferences() {
try {
isSaving = true;
const response = await fetch('/api/notifications/preferences', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(preferences)
});
if (!response.ok) {
throw new Error('Failed to save notification preferences');
}
toast.success('Notification preferences updated');
} catch (error) {
console.error('Error saving notification preferences:', error);
toast.error('Failed to save notification preferences');
} finally {
isSaving = false;
}
}
function handleToggle(key: keyof NotificationPreferences) {
preferences[key] = !preferences[key];
savePreferences();
}
const canTogglePreferences = $derived(browserPermission === 'granted' && preferences.pushEnabled);
</script>
<div class="modal-container" class:mobile={isMobile}>
<div class="modal-content">
<div class="modal-header">
<div class="header-content">
<h2 class="modal-title">Notification Settings</h2>
<p class="modal-subtitle">Manage your notification preferences</p>
</div>
<button type="button" class="close-button" onclick={onClose} aria-label="Close">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
d="M18 6L6 18M6 6L18 18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<div class="modal-body">
{#if isLoading}
<div class="loading">Loading preferences...</div>
{:else}
<!-- Browser Permission Banner -->
{#if browserPermission !== 'granted'}
<div class="permission-banner {browserPermission === 'denied' ? 'denied' : 'default'}">
<div class="permission-info">
{#if browserPermission === 'denied'}
<strong>Browser notifications blocked</strong>
<p>
Please enable notifications in your browser settings to receive push notifications
</p>
{:else}
<strong>Browser permission required</strong>
<p>Enable browser notifications to receive push notifications</p>
{/if}
</div>
{#if browserPermission === 'default'}
<button
class="enable-button"
onclick={requestBrowserPermission}
disabled={isSubscribing}
>
{isSubscribing ? 'Enabling...' : 'Enable'}
</button>
{/if}
</div>
{/if}
<div class="settings-list">
<!-- Push Notifications Toggle -->
<div class="setting-item">
<div class="setting-info">
<h3>Push Notifications</h3>
<p>Enable or disable all push notifications</p>
</div>
<label class="toggle">
<input
type="checkbox"
checked={preferences.pushEnabled}
onchange={() => handleToggle('pushEnabled')}
disabled={isSaving || browserPermission !== 'granted'}
/>
<span class="toggle-slider"></span>
</label>
</div>
<div class="divider"></div>
<!-- Friend Requests -->
<div class="setting-item" class:disabled={!canTogglePreferences}>
<div class="setting-info">
<h3>Friend Requests</h3>
<p>Get notified when someone sends you a friend request</p>
</div>
<label class="toggle">
<input
type="checkbox"
checked={preferences.friendRequests}
onchange={() => handleToggle('friendRequests')}
disabled={isSaving || !canTogglePreferences}
/>
<span class="toggle-slider"></span>
</label>
</div>
<!-- Friend Accepted -->
<div class="setting-item" class:disabled={!canTogglePreferences}>
<div class="setting-info">
<h3>Friend Request Accepted</h3>
<p>Get notified when someone accepts your friend request</p>
</div>
<label class="toggle">
<input
type="checkbox"
checked={preferences.friendAccepted}
onchange={() => handleToggle('friendAccepted')}
disabled={isSaving || !canTogglePreferences}
/>
<span class="toggle-slider"></span>
</label>
</div>
<!-- Find Liked -->
<div class="setting-item" class:disabled={!canTogglePreferences}>
<div class="setting-info">
<h3>Find Likes</h3>
<p>Get notified when someone likes your find</p>
</div>
<label class="toggle">
<input
type="checkbox"
checked={preferences.findLiked}
onchange={() => handleToggle('findLiked')}
disabled={isSaving || !canTogglePreferences}
/>
<span class="toggle-slider"></span>
</label>
</div>
<!-- Find Commented -->
<div class="setting-item" class:disabled={!canTogglePreferences}>
<div class="setting-info">
<h3>Find Comments</h3>
<p>Get notified when someone comments on your find</p>
</div>
<label class="toggle">
<input
type="checkbox"
checked={preferences.findCommented}
onchange={() => handleToggle('findCommented')}
disabled={isSaving || !canTogglePreferences}
/>
<span class="toggle-slider"></span>
</label>
</div>
</div>
{/if}
</div>
</div>
</div>
<style>
.modal-container {
position: fixed;
top: 80px;
right: 20px;
width: auto;
max-width: 500px;
min-width: 380px;
max-height: calc(100vh - 100px);
backdrop-filter: blur(10px);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 50;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.modal-container.mobile {
top: auto;
bottom: 0;
left: 0;
right: 0;
width: 100%;
min-width: 0;
max-width: none;
height: 90vh;
border-radius: 16px 16px 0 0;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-content {
display: flex;
flex-direction: column;
height: auto;
max-height: 100%;
}
.modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.header-content {
flex: 1;
min-width: 0;
}
.modal-title {
font-family: 'Washington', serif;
font-size: 1.125rem;
font-weight: 600;
margin: 0;
color: hsl(var(--foreground));
line-height: 1.3;
}
.modal-subtitle {
margin: 0.25rem 0 0 0;
font-size: 0.8125rem;
color: hsl(var(--muted-foreground));
}
.close-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: hsl(var(--muted-foreground));
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.close-button:hover {
background: hsl(var(--muted) / 0.5);
color: hsl(var(--foreground));
}
.modal-body {
flex: 0 1 auto;
overflow-y: auto;
padding: 1.25rem;
background: transparent;
min-height: 0;
}
.loading {
text-align: center;
padding: 40px;
color: hsl(var(--muted-foreground));
}
.permission-banner {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 10px;
padding: 0.875rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.permission-banner.denied {
background: hsl(var(--destructive) / 0.1);
border-color: hsl(var(--destructive));
}
.permission-info {
flex: 1;
}
.permission-info strong {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 0.25rem;
}
.permission-info p {
margin: 0;
font-size: 0.8125rem;
color: hsl(var(--muted-foreground));
line-height: 1.5;
}
.enable-button {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
border-radius: 8px;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: background 0.2s;
}
.enable-button:hover:not(:disabled) {
background: hsl(var(--primary) / 0.9);
}
.enable-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.settings-list {
background: hsl(var(--card));
border-radius: 12px;
border: 1px solid hsl(var(--border));
overflow: hidden;
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem;
gap: 1rem;
transition: opacity 0.2s;
}
.setting-item.disabled {
opacity: 0.5;
}
.setting-info {
flex: 1;
min-width: 0;
}
.setting-info h3 {
margin: 0 0 0.25rem 0;
font-size: 1rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.setting-info p {
margin: 0;
font-size: 0.8125rem;
color: hsl(var(--muted-foreground));
line-height: 1.4;
}
.divider {
height: 1px;
background: hsl(var(--border));
margin: 0 1.25rem;
}
/* Toggle Switch */
.toggle {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
flex-shrink: 0;
cursor: pointer;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.6);
border: 1px solid hsl(var(--border));
border-radius: 24px;
transition: all 0.2s;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
}
.toggle-slider:before {
position: absolute;
content: '';
height: 22px;
width: 22px;
left: 3px;
bottom: 2px;
background-color: #ffffff;
border-radius: 50%;
transition: transform 0.25s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.toggle input:checked + .toggle-slider {
background-color: rgba(0, 0, 0, 0.8);
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15);
}
.toggle input:checked + .toggle-slider:before {
transform: translateX(22px);
}
.toggle input:disabled + .toggle-slider {
cursor: not-allowed;
opacity: 0.6;
}
/* Mobile specificadjust ments */
@media (max-width: 767px) {
.modal-container {
top: auto;
bottom: 0;
left: 0;
right: 0;
width: 100%;
min-width: 0;
max-width: none;
height: 90vh;
border-radius: 16px 16px 0 0;
}
}
</style>

View File

@@ -0,0 +1,3 @@
export { default as NotificationManager } from './NotificationManager.svelte';
export { default as NotificationPrompt } from './NotificationPrompt.svelte';
export { default as NotificationSettings } from './NotificationSettings.svelte';

View File

@@ -7,11 +7,11 @@
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from './dropdown-menu';
import { Skeleton } from './skeleton';
} from '../dropdown-menu';
import { Skeleton } from '../skeleton';
import ProfilePicture from './ProfilePicture.svelte';
import ProfilePictureSheet from './ProfilePictureSheet.svelte';
import NotificationSettingsSheet from './NotificationSettingsSheet.svelte';
import NotificationSettings from '../notifications/NotificationSettings.svelte';
interface Props {
username: string;
@@ -23,7 +23,7 @@
let { username, id, profilePictureUrl, loading = false }: Props = $props();
let showProfilePictureSheet = $state(false);
let showNotificationSettingsSheet = $state(false);
let showNotificationSettings = $state(false);
function openProfilePictureSheet() {
showProfilePictureSheet = true;
@@ -33,12 +33,12 @@
showProfilePictureSheet = false;
}
function openNotificationSettingsSheet() {
showNotificationSettingsSheet = true;
function openNotificationSettings() {
showNotificationSettings = true;
}
function closeNotificationSettingsSheet() {
showNotificationSettingsSheet = false;
function closeNotificationSettings() {
showNotificationSettings = false;
}
</script>
@@ -85,7 +85,7 @@
<a href={resolveRoute('/friends')} class="friends-link">Friends</a>
</DropdownMenuItem>
<DropdownMenuItem class="notification-settings-item" onclick={openNotificationSettingsSheet}>
<DropdownMenuItem class="notification-settings-item" onclick={openNotificationSettings}>
Notifications
</DropdownMenuItem>
@@ -121,8 +121,8 @@
/>
{/if}
{#if showNotificationSettingsSheet}
<NotificationSettingsSheet onClose={closeNotificationSettingsSheet} />
{#if showNotificationSettings}
<NotificationSettings onClose={closeNotificationSettings} />
{/if}
<style>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Avatar, AvatarFallback, AvatarImage } from './avatar';
import { Avatar, AvatarFallback, AvatarImage } from '../avatar';
import { cn } from '$lib/utils';
interface Props {

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './sheet';
import { Button } from './button';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '../sheet';
import { Button } from '../button';
import ProfilePicture from './ProfilePicture.svelte';
interface Props {

View File

@@ -0,0 +1,3 @@
export { default as ProfilePanel } from './ProfilePanel.svelte';
export { default as ProfilePicture } from './ProfilePicture.svelte';
export { default as ProfilePictureSheet } from './ProfilePictureSheet.svelte';

View File

@@ -2,19 +2,18 @@
export { default as Input } from './components/Input.svelte';
export { default as Button } from './components/Button.svelte';
export { default as ErrorMessage } from './components/ErrorMessage.svelte';
export { default as ProfilePanel } from './components/ProfilePanel.svelte';
export { default as ProfilePicture } from './components/ProfilePicture.svelte';
export { default as ProfilePictureSheet } from './components/ProfilePictureSheet.svelte';
export { default as ProfilePanel } from './components/profile/ProfilePanel.svelte';
export { default as ProfilePicture } from './components/profile/ProfilePicture.svelte';
export { default as ProfilePictureSheet } from './components/profile/ProfilePictureSheet.svelte';
export { default as Header } from './components/Header.svelte';
export { default as Modal } from './components/Modal.svelte';
export { default as Map } from './components/Map.svelte';
export { default as LocationManager } from './components/LocationManager.svelte';
export { default as NotificationManager } from './components/NotificationManager.svelte';
export { default as NotificationPrompt } from './components/NotificationPrompt.svelte';
export { default as NotificationSettings } from './components/NotificationSettings.svelte';
export { default as NotificationSettingsSheet } from './components/NotificationSettingsSheet.svelte';
export { default as FindCard } from './components/FindCard.svelte';
export { default as FindsList } from './components/FindsList.svelte';
export { default as Map } from './components/map/Map.svelte';
export { default as LocationManager } from './components/map/LocationManager.svelte';
export { default as NotificationManager } from './components/notifications/NotificationManager.svelte';
export { default as NotificationPrompt } from './components/notifications/NotificationPrompt.svelte';
export { default as NotificationSettings } from './components/notifications/NotificationSettings.svelte';
export { default as FindCard } from './components/finds/FindCard.svelte';
export { default as FindsList } from './components/finds/FindsList.svelte';
// Skeleton Loading Components
export { Skeleton, SkeletonVariants } from './components/skeleton';

View File

@@ -5,8 +5,8 @@
import { page } from '$app/state';
import { Toaster } from '$lib/components/sonner/index.js';
import { Skeleton } from '$lib/components/skeleton';
import LocationManager from '$lib/components/LocationManager.svelte';
import NotificationManager from '$lib/components/NotificationManager.svelte';
import LocationManager from '$lib/components/map/LocationManager.svelte';
import NotificationManager from '$lib/components/notifications/NotificationManager.svelte';
import { onMount } from 'svelte';
import { browser } from '$app/environment';

View File

@@ -1,11 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, url, fetch, request }) => {
if (!locals.user) {
return redirect(302, '/login');
}
// Build API URL with query parameters
const apiUrl = new URL('/api/finds', url.origin);
@@ -17,8 +12,13 @@ export const load: PageServerLoad = async ({ locals, url, fetch, request }) => {
if (lat) apiUrl.searchParams.set('lat', lat);
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
// Only include private and friends' finds if user is logged in
if (locals.user) {
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

@@ -1,9 +1,9 @@
<script lang="ts">
import { Map } from '$lib';
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 FindsList from '$lib/components/finds/FindsList.svelte';
import CreateFindModal from '$lib/components/finds/CreateFindModal.svelte';
import FindPreview from '$lib/components/finds/FindPreview.svelte';
import FindsFilter from '$lib/components/finds/FindsFilter.svelte';
import type { PageData } from './$types';
import { coordinates } from '$lib/stores/location';
import { Button } from '$lib/components/button';
@@ -253,32 +253,47 @@
/>
</div>
<!-- Toggle button -->
<button class="sidebar-toggle" onclick={toggleSidebar} aria-label="Toggle finds list">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
{#if isSidebarVisible}
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
{:else}
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
{/if}
</svg>
</button>
<!-- Left sidebar with finds list -->
<div class="finds-sidebar" class:hidden={!isSidebarVisible}>
<div class="finds-header">
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
<Button onclick={openCreateModal} class="create-find-button">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
</svg>
Create Find
</Button>
</div>
<div class="finds-list-container">
<FindsList {finds} onFindExplore={handleFindExplore} hideTitle={true} />
<!-- Sidebar container -->
<div class="sidebar-container">
<!-- Left sidebar with finds list -->
<div class="finds-sidebar" class:hidden={!isSidebarVisible}>
<div class="finds-header">
{#if data.user}
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
<Button onclick={openCreateModal} class="create-find-button">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
</svg>
Create Find
</Button>
{:else}
<div class="login-prompt">
<h3>Welcome to Serengo</h3>
<p>Login to create finds and view your friends' discoveries</p>
<a href="/login" class="login-button">Login</a>
</div>
{/if}
</div>
<div class="finds-list-container">
<FindsList {finds} onFindExplore={handleFindExplore} hideTitle={true} />
</div>
</div>
<!-- Toggle button -->
<button
class="sidebar-toggle"
class:collapsed={!isSidebarVisible}
onclick={toggleSidebar}
aria-label="Toggle finds list"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
{#if isSidebarVisible}
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
{:else}
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
{/if}
</svg>
</button>
</div>
</div>
@@ -320,10 +335,14 @@
box-shadow: none !important;
}
.sidebar-container {
display: flex;
flex-direction: row;
margin-left: 20px;
gap: 12px;
}
.sidebar-toggle {
position: fixed;
top: 90px;
left: 20px;
width: 48px;
height: 48px;
background: rgba(255, 255, 255, 0.95);
@@ -349,9 +368,6 @@
}
.finds-sidebar {
position: fixed;
top: 80px;
left: 80px;
width: 40%;
max-width: 1000px;
min-width: 500px;
@@ -370,6 +386,7 @@
}
.finds-sidebar.hidden {
display: none;
transform: translateX(-100%);
opacity: 0;
pointer-events: none;
@@ -384,6 +401,43 @@
flex-shrink: 0;
}
.login-prompt {
width: 100%;
text-align: center;
padding: 1rem;
}
.login-prompt h3 {
font-family: 'Washington', serif;
font-size: 1.25rem;
margin: 0 0 0.5rem 0;
color: hsl(var(--foreground));
}
.login-prompt p {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0 0 1rem 0;
}
.login-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1.5rem;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
transition: all 0.2s ease;
}
.login-button:hover {
background: hsl(var(--primary) / 0.9);
}
.finds-list-container {
flex: 1;
overflow-y: auto;
@@ -395,32 +449,38 @@
}
@media (max-width: 768px) {
.sidebar-toggle {
top: auto;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
}
.sidebar-toggle svg {
transform: rotate(90deg);
}
.finds-sidebar {
top: auto;
.sidebar-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
flex-direction: column-reverse;
align-items: center;
margin: 0;
}
.sidebar-toggle.collapsed {
margin: 12px auto;
}
.sidebar-toggle svg {
transform: rotate(-90deg);
}
.finds-sidebar {
width: 100%;
max-width: 100vw;
min-width: 0;
height: 50vh;
border-radius: 20px 20px 0 0;
box-sizing: border-box;
}
.finds-sidebar.hidden {
transform: translateY(100%);
display: none;
transform: translateX(-100%);
opacity: 0;
pointer-events: none;
}
.finds-header {

View File

@@ -12,10 +12,6 @@ function generateFindId(): string {
}
export const GET: RequestHandler = async ({ url, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const lat = url.searchParams.get('lat');
const lng = url.searchParams.get('lng');
const radius = url.searchParams.get('radius') || '50';
@@ -25,9 +21,9 @@ export const GET: RequestHandler = async ({ url, locals }) => {
const includeFriends = url.searchParams.get('includeFriends') === 'true';
try {
// Get user's friends if needed
// Get user's friends if needed and user is logged in
let friendIds: string[] = [];
if (includeFriends || includePrivate) {
if (locals.user && (includeFriends || includePrivate)) {
const friendships = await db
.select({
userId: friendship.userId,
@@ -37,7 +33,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
.where(
and(
eq(friendship.status, 'accepted'),
or(eq(friendship.userId, locals.user!.id), eq(friendship.friendId, locals.user!.id))
or(eq(friendship.userId, locals.user.id), eq(friendship.friendId, locals.user.id))
)
);
@@ -47,12 +43,12 @@ export const GET: RequestHandler = async ({ url, locals }) => {
// Build privacy conditions
const conditions = [sql`${find.isPublic} = 1`]; // Always include public finds
if (includePrivate) {
if (locals.user && includePrivate) {
// Include user's own finds (both public and private)
conditions.push(sql`${find.userId} = ${locals.user!.id}`);
conditions.push(sql`${find.userId} = ${locals.user.id}`);
}
if (includeFriends && friendIds.length > 0) {
if (locals.user && includeFriends && friendIds.length > 0) {
// Include friends' finds (both public and private)
conditions.push(
sql`${find.userId} IN (${sql.join(
@@ -103,19 +99,23 @@ export const GET: RequestHandler = async ({ url, locals }) => {
username: user.username,
profilePictureUrl: user.profilePictureUrl,
likeCount: sql<number>`COALESCE(COUNT(DISTINCT ${findLike.id}), 0)`,
isLikedByUser: sql<boolean>`CASE WHEN EXISTS(
SELECT 1 FROM ${findLike}
WHERE ${findLike.findId} = ${find.id}
AND ${findLike.userId} = ${locals.user.id}
) THEN 1 ELSE 0 END`,
isFromFriend: sql<boolean>`CASE WHEN ${
friendIds.length > 0
? sql`${find.userId} IN (${sql.join(
friendIds.map((id) => sql`${id}`),
sql`, `
)})`
: sql`FALSE`
} THEN 1 ELSE 0 END`
isLikedByUser: locals.user
? sql<boolean>`CASE WHEN EXISTS(
SELECT 1 FROM ${findLike}
WHERE ${findLike.findId} = ${find.id}
AND ${findLike.userId} = ${locals.user.id}
) THEN 1 ELSE 0 END`
: sql<boolean>`0`,
isFromFriend: locals.user
? sql<boolean>`CASE WHEN ${
friendIds.length > 0
? sql`${find.userId} IN (${sql.join(
friendIds.map((id) => sql`${id}`),
sql`, `
)})`
: sql`FALSE`
} THEN 1 ELSE 0 END`
: sql<boolean>`0`
})
.from(find)
.innerJoin(user, eq(find.userId, user.id))
@@ -178,9 +178,10 @@ export const GET: RequestHandler = async ({ url, locals }) => {
findMedia.map(async (mediaItem) => {
// URLs in database are now paths, generate local proxy URLs
const localUrl = getLocalR2Url(mediaItem.url);
const localThumbnailUrl = mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
? getLocalR2Url(mediaItem.thumbnailUrl)
: mediaItem.thumbnailUrl; // Keep static placeholder paths as-is
const localThumbnailUrl =
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
? getLocalR2Url(mediaItem.thumbnailUrl)
: mediaItem.thumbnailUrl; // Keep static placeholder paths as-is
return {
...mediaItem,

View File

@@ -0,0 +1,115 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { find, findMedia, user, findLike, findComment } from '$lib/server/db/schema';
import { eq, sql } from 'drizzle-orm';
import { getLocalR2Url } from '$lib/server/r2';
export const GET: RequestHandler = async ({ params, locals }) => {
const findId = params.findId;
if (!findId) {
throw error(400, 'Find ID is required');
}
try {
// Get the find with user info and like count
const findResult = await db
.select({
id: find.id,
title: find.title,
description: find.description,
latitude: find.latitude,
longitude: find.longitude,
locationName: find.locationName,
category: find.category,
isPublic: find.isPublic,
createdAt: find.createdAt,
userId: find.userId,
username: user.username,
profilePictureUrl: user.profilePictureUrl,
likeCount: sql<number>`COALESCE(COUNT(DISTINCT ${findLike.id}), 0)`,
commentCount: sql<number>`COALESCE((
SELECT COUNT(*) FROM ${findComment}
WHERE ${findComment.findId} = ${find.id}
), 0)`,
isLikedByUser: locals.user
? sql<boolean>`CASE WHEN EXISTS(
SELECT 1 FROM ${findLike}
WHERE ${findLike.findId} = ${find.id}
AND ${findLike.userId} = ${locals.user.id}
) THEN 1 ELSE 0 END`
: sql<boolean>`0`
})
.from(find)
.innerJoin(user, eq(find.userId, user.id))
.leftJoin(findLike, eq(find.id, findLike.findId))
.where(eq(find.id, findId))
.groupBy(find.id, user.username, user.profilePictureUrl)
.limit(1);
if (findResult.length === 0) {
throw error(404, 'Find not found');
}
const findData = findResult[0];
// Check if the find is public or if user has access
const isOwner = locals.user && findData.userId === locals.user.id;
const isPublic = findData.isPublic === 1;
if (!isPublic && !isOwner) {
throw error(403, 'This find is private');
}
// Get media for the find
const media = await db
.select({
id: findMedia.id,
findId: findMedia.findId,
type: findMedia.type,
url: findMedia.url,
thumbnailUrl: findMedia.thumbnailUrl,
orderIndex: findMedia.orderIndex
})
.from(findMedia)
.where(eq(findMedia.findId, findId))
.orderBy(findMedia.orderIndex);
// Generate signed URLs for media
const mediaWithSignedUrls = await Promise.all(
media.map(async (mediaItem) => {
const localUrl = getLocalR2Url(mediaItem.url);
const localThumbnailUrl =
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
? getLocalR2Url(mediaItem.thumbnailUrl)
: mediaItem.thumbnailUrl;
return {
...mediaItem,
url: localUrl,
thumbnailUrl: localThumbnailUrl
};
})
);
// Generate local proxy URL for user profile picture
let userProfilePictureUrl = findData.profilePictureUrl;
if (userProfilePictureUrl && !userProfilePictureUrl.startsWith('http')) {
userProfilePictureUrl = getLocalR2Url(userProfilePictureUrl);
}
return json({
...findData,
profilePictureUrl: userProfilePictureUrl,
media: mediaWithSignedUrls,
isLikedByUser: Boolean(findData.isLikedByUser)
});
} catch (err) {
console.error('Error loading find:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to load find');
}
};

View File

@@ -0,0 +1,43 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, fetch, url, request }) => {
const findId = params.findId;
if (!findId) {
throw error(400, 'Find ID is required');
}
try {
// Build API URL
const apiUrl = new URL(`/api/finds/${findId}`, url.origin);
// Fetch the find data - no auth required for public finds
const response = await fetch(apiUrl.toString(), {
headers: {
Cookie: request.headers.get('Cookie') || ''
}
});
if (!response.ok) {
if (response.status === 404) {
throw error(404, 'Find not found');
} else if (response.status === 403) {
throw error(403, 'This find is private');
}
throw error(response.status, 'Failed to load find');
}
const find = await response.json();
return {
find
};
} catch (err) {
console.error('Error loading find:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to load find');
}
};

View File

@@ -0,0 +1,780 @@
<script lang="ts">
import { Map } from '$lib';
import LikeButton from '$lib/components/finds/LikeButton.svelte';
import VideoPlayer from '$lib/components/media/VideoPlayer.svelte';
import ProfilePicture from '$lib/components/profile/ProfilePicture.svelte';
import CommentsList from '$lib/components/finds/CommentsList.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let currentMediaIndex = $state(0);
let isPanelVisible = $state(true);
function nextMedia() {
if (!data.find?.media) return;
currentMediaIndex = (currentMediaIndex + 1) % data.find.media.length;
}
function prevMedia() {
if (!data.find?.media) return;
currentMediaIndex =
currentMediaIndex === 0 ? data.find.media.length - 1 : currentMediaIndex - 1;
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function getDirections() {
if (!data.find) return;
const lat = parseFloat(data.find.latitude);
const lng = parseFloat(data.find.longitude);
const url = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
window.open(url, '_blank');
}
function shareFindUrl() {
if (!data.find) return;
const url = `${window.location.origin}/finds/${data.find.id}`;
if (navigator.share) {
navigator
.share({
title: data.find.title,
text: data.find.description || `Check out this find: ${data.find.title}`,
url: url
})
.catch((error) => {
// User cancelled or error occurred
if (error.name !== 'AbortError') {
console.error('Error sharing:', error);
}
});
} else {
// Fallback: Copy to clipboard
navigator.clipboard
.writeText(url)
.then(() => {
alert('Find URL copied to clipboard!');
})
.catch((error) => {
console.error('Error copying to clipboard:', error);
});
}
}
function togglePanel() {
isPanelVisible = !isPanelVisible;
}
// Create the map find format
let mapFinds = $derived(
data.find
? [
{
id: data.find.id,
title: data.find.title,
description: data.find.description,
latitude: data.find.latitude,
longitude: data.find.longitude,
locationName: data.find.locationName,
category: data.find.category,
isPublic: data.find.isPublic,
createdAt: new Date(data.find.createdAt),
userId: data.find.userId,
user: {
id: data.find.userId,
username: data.find.username,
profilePictureUrl: data.find.profilePictureUrl
},
likeCount: data.find.likeCount,
isLiked: data.find.isLikedByUser,
media: data.find.media?.map(
(m: { type: string; url: string; thumbnailUrl: string | null }) => ({
type: m.type,
url: m.url,
thumbnailUrl: m.thumbnailUrl || m.url
})
)
}
]
: []
);
// Get first media for OG image
let ogImage = $derived(data.find?.media?.[0]?.url || '');
</script>
<svelte:head>
<title>{data.find ? `${data.find.title} - Serengo` : 'Find - Serengo'}</title>
<meta
name="description"
content={data.find?.description ||
`Check out this find on Serengo: ${data.find?.title || 'Unknown'}`}
/>
<meta
property="og:title"
content={data.find ? `${data.find.title} - Serengo` : 'Find - Serengo'}
/>
<meta
property="og:description"
content={data.find?.description ||
`Check out this find on Serengo: ${data.find?.title || 'Unknown'}`}
/>
<meta property="og:type" content="article" />
{#if ogImage}
<meta property="og:image" content={ogImage} />
{/if}
<meta name="twitter:card" content="summary_large_image" />
<meta
name="twitter:title"
content={data.find ? `${data.find.title} - Serengo` : 'Find - Serengo'}
/>
<meta
name="twitter:description"
content={data.find?.description ||
`Check out this find on Serengo: ${data.find?.title || 'Unknown'}`}
/>
{#if ogImage}
<meta name="twitter:image" content={ogImage} />
{/if}
</svelte:head>
<div class="public-find-page">
<!-- Fullscreen map -->
<div class="map-section">
<Map
autoCenter={true}
center={[parseFloat(data.find?.longitude || '0'), parseFloat(data.find?.latitude || '0')]}
finds={mapFinds}
onFindClick={() => {}}
/>
</div>
<!-- Details panel container -->
<div class="panel-container">
<!-- Details panel -->
<div class="find-details-panel" class:hidden={!isPanelVisible}>
{#if data.find}
<div class="panel-content">
<div class="panel-header">
<div class="user-section">
<ProfilePicture
username={data.find.username}
profilePictureUrl={data.find.profilePictureUrl}
class="user-avatar"
/>
<div class="user-info">
<h1 class="find-title">{data.find.title}</h1>
<div class="find-meta">
<span class="username">@{data.find.username}</span>
<span class="separator">•</span>
<span class="date">{formatDate(data.find.createdAt)}</span>
{#if data.find.category}
<span class="separator">•</span>
<span class="category">{data.find.category}</span>
{/if}
</div>
</div>
</div>
</div>
<div class="panel-body">
{#if data.find.media && data.find.media.length > 0}
<div class="media-container">
<div class="media-viewer">
{#if data.find.media[currentMediaIndex].type === 'photo'}
<img
src={data.find.media[currentMediaIndex].url}
alt={data.find.title}
class="media-image"
/>
{:else}
<VideoPlayer
src={data.find.media[currentMediaIndex].url}
poster={data.find.media[currentMediaIndex].thumbnailUrl}
class="media-video"
/>
{/if}
{#if data.find.media.length > 1}
<button class="media-nav prev" onclick={prevMedia} aria-label="Previous media">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M15 18L9 12L15 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<button class="media-nav next" onclick={nextMedia} aria-label="Next media">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M9 18L15 12L9 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{/if}
</div>
{#if data.find.media.length > 1}
<div class="media-indicators">
{#each data.find.media as _, index (index)}
<button
class="indicator"
class:active={index === currentMediaIndex}
onclick={() => (currentMediaIndex = index)}
aria-label={`View media ${index + 1}`}
></button>
{/each}
</div>
{/if}
</div>
{/if}
<div class="content-section">
{#if data.find.description}
<p class="description">{data.find.description}</p>
{/if}
{#if data.find.locationName}
<div class="location-info">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M21 10C21 17 12 23 12 23S3 17 3 10A9 9 0 0 1 12 1A9 9 0 0 1 21 10Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
</svg>
<span>{data.find.locationName}</span>
</div>
{/if}
<div class="actions">
<LikeButton
findId={data.find.id}
isLiked={data.find.isLikedByUser || false}
likeCount={data.find.likeCount || 0}
size="default"
class="like-action"
/>
<button class="action-button primary" onclick={getDirections}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M3 11L22 2L13 21L11 13L3 11Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Directions
</button>
<button class="action-button secondary" onclick={shareFindUrl}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M4 12V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="16,6 12,2 8,6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Share
</button>
</div>
</div>
<div class="comments-section">
<CommentsList
findId={data.find.id}
currentUserId={data.user?.id}
collapsed={false}
isScrollable={true}
showCommentForm={data.user ? true : false}
/>
</div>
</div>
</div>
{:else}
<div class="error-state">
<h1>Find not found</h1>
<p>This find does not exist or is private.</p>
<a href="/" class="back-button">Back to Home</a>
</div>
{/if}
</div>
<!-- Toggle button -->
<button
class="panel-toggle"
class:collapsed={!isPanelVisible}
onclick={togglePanel}
aria-label="Toggle find details"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
{#if isPanelVisible}
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
{:else}
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
{/if}
</svg>
</button>
</div>
</div>
<style>
.public-find-page {
position: relative;
}
.map-section {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 0;
overflow: hidden;
}
.map-section :global(.map-container) {
height: 100vh;
border-radius: 0;
}
.map-section :global(.maplibregl-map) {
border-radius: 0 !important;
box-shadow: none !important;
}
.panel-container {
display: flex;
flex-direction: row;
margin-left: 20px;
gap: 12px;
}
.panel-toggle {
width: 48px;
height: 48px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: none;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
}
.panel-toggle:hover {
background: rgba(255, 255, 255, 1);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.2);
}
.panel-toggle svg {
color: #333;
}
.find-details-panel {
width: 40%;
max-width: 1000px;
min-width: 500px;
height: calc(100vh - 100px);
backdrop-filter: blur(10px);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 50;
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
transition:
transform 0.3s ease,
opacity 0.3s ease;
}
.find-details-panel.hidden {
display: none;
transform: translateX(-100%);
opacity: 0;
pointer-events: none;
}
.panel-content {
display: flex;
flex-direction: column;
height: 100%;
}
.panel-header {
padding: 1.5rem 2rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.user-section {
display: flex;
align-items: center;
gap: 1rem;
}
:global(.user-avatar) {
width: 56px;
height: 56px;
flex-shrink: 0;
}
:global(.avatar-fallback) {
background: hsl(var(--primary));
color: white;
font-size: 1rem;
font-weight: 600;
}
.user-info {
flex: 1;
min-width: 0;
}
.find-title {
font-family: 'Washington', serif;
font-size: 1.5rem;
font-weight: 600;
margin: 0;
color: hsl(var(--foreground));
line-height: 1.3;
}
.find-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
flex-wrap: wrap;
}
.username {
font-weight: 500;
color: hsl(var(--primary));
}
.separator {
color: hsl(var(--muted-foreground));
opacity: 0.5;
}
.category {
background: hsl(var(--muted));
padding: 0.125rem 0.5rem;
border-radius: 8px;
font-size: 0.75rem;
text-transform: capitalize;
}
.panel-body {
display: flex;
flex-direction: column;
overflow: auto;
flex: 1;
}
.media-container {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
}
.media-viewer {
position: relative;
width: 100%;
max-height: 400px;
background: hsl(var(--muted));
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.media-image {
width: 100%;
height: 100%;
object-fit: contain;
}
:global(.media-video) {
width: 100%;
height: 100%;
}
.media-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.6);
color: white;
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.2s ease;
z-index: 10;
}
.media-nav:hover {
background: rgba(0, 0, 0, 0.8);
}
.media-nav.prev {
left: 1rem;
}
.media-nav.next {
right: 1rem;
}
.media-indicators {
display: flex;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem 0;
flex-shrink: 0;
}
.indicator {
width: 8px;
height: 8px;
border-radius: 50%;
border: none;
background: hsl(var(--muted-foreground));
opacity: 0.3;
cursor: pointer;
transition: all 0.2s ease;
}
.indicator.active {
background: hsl(var(--primary));
opacity: 1;
transform: scale(1.2);
}
.content-section {
padding: 1.5rem 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
background: rgba(255, 255, 255, 0.5);
}
.description {
font-size: 1rem;
line-height: 1.6;
color: hsl(var(--foreground));
margin: 0;
}
.location-info {
display: flex;
align-items: center;
gap: 0.5rem;
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
}
.actions {
display: flex;
gap: 0.75rem;
align-items: center;
padding-top: 0.5rem;
}
:global(.like-action) {
flex-shrink: 0;
}
.action-button {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
justify-content: center;
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
}
.action-button.primary {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.action-button.primary:hover {
background: hsl(var(--primary) / 0.9);
}
.action-button.secondary {
background: hsl(var(--secondary));
color: hsl(var(--secondary-foreground));
}
.action-button.secondary:hover {
background: hsl(var(--secondary) / 0.8);
}
.comments-section {
border-top: 1px solid rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
background: rgba(255, 255, 255, 0.5);
}
.error-state {
padding: 2rem;
text-align: center;
background: rgba(255, 255, 255, 0.5);
}
.error-state h1 {
font-family: 'Washington', serif;
font-size: 2rem;
margin-bottom: 1rem;
color: hsl(var(--foreground));
}
.error-state p {
color: hsl(var(--muted-foreground));
margin-bottom: 1.5rem;
}
.back-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1.5rem;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
transition: all 0.2s ease;
}
.back-button:hover {
background: hsl(var(--primary) / 0.9);
}
@media (max-width: 768px) {
.panel-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
flex-direction: column-reverse;
align-items: center;
margin: 0;
}
.panel-toggle.collapsed {
margin: 12px auto;
}
.panel-toggle svg {
transform: rotate(-90deg);
}
.find-details-panel {
width: 100%;
max-width: 100vw;
min-width: 0;
border-radius: 20px 20px 0 0;
box-sizing: border-box;
}
.find-details-panel.hidden {
display: none;
transform: translateX(-100%);
opacity: 0;
pointer-events: none;
}
.panel-header {
padding: 1rem 1.25rem;
}
.find-title {
font-size: 1.25rem;
}
:global(.user-avatar) {
width: 48px;
height: 48px;
}
.content-section {
padding: 1rem 1.25rem;
}
.map-section :global(.map-container) {
height: 100vh;
}
}
@media (max-width: 480px) {
.find-details-panel {
height: 60vh;
}
.panel-header,
.content-section {
padding: 1rem;
}
}
</style>

View File

@@ -2,7 +2,7 @@
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/card';
import { Button } from '$lib/components/button';
import { Input } from '$lib/components/input';
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
import ProfilePicture from '$lib/components/profile/ProfilePicture.svelte';
import { Badge } from '$lib/components/badge';
import { toast } from 'svelte-sonner';
import type { PageData } from './$types';

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import LoginForm from '$lib/components/login-form.svelte';
import LoginForm from '$lib/components/auth/login-form.svelte';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();

View File

@@ -131,7 +131,11 @@ self.addEventListener('fetch', (event) => {
}
// Handle R2 resources and local media proxy with cache-first strategy
if (url.hostname.includes('.r2.dev') || url.hostname.includes('.r2.cloudflarestorage.com') || url.pathname.startsWith('/api/media/')) {
if (
url.hostname.includes('.r2.dev') ||
url.hostname.includes('.r2.cloudflarestorage.com') ||
url.pathname.startsWith('/api/media/')
) {
const cachedResponse = await r2Cache.match(event.request);
if (cachedResponse) {
return cachedResponse;