16 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
1c31e2cdda add:sidebar toggle and fix overscroll behavior 2025-11-17 11:57:09 +01:00
d8cab06e90 fix:overflow of findlist 2025-11-17 11:53:09 +01:00
d4d23ed46d ui:find preview better ui 2025-11-17 11:12:26 +01:00
ab8b0ee982 ui:create find better ui 2025-11-17 11:09:21 +01:00
dabc732f4b fix:styling for mobile createfind 2025-11-17 10:57:47 +01:00
1f0e8141be ui:remove mobile + button and use same as desktop 2025-11-17 10:53:30 +01:00
96a173b73b feat:use local proxy for media
use local proxy for media so that media doesnt need to be requested from
r2 everytime but can be cached locally. this also fixes some csp issues
ive been having.
2025-11-17 10:48:40 +01:00
08f7e77a86 ui:big ui update
refreshed the ui by making the map full screen and adding the finds as a
side-sheet on top of the map.
2025-11-17 10:46:17 +01:00
52 changed files with 2488 additions and 1329 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

@@ -88,6 +88,7 @@
box-sizing: border-box;
margin: 0;
padding: 0;
overscroll-behavior: none;
}
body {

View File

@@ -28,8 +28,7 @@
.app-header {
width: 100%;
position: relative;
z-index: 10;
backdrop-filter: blur(10px);
z-index: 100;
}
.header-content {
@@ -38,7 +37,6 @@
justify-content: space-between;
padding: 16px 20px;
margin: 0 auto;
max-width: 1200px;
}
.profile-container {

View File

@@ -1,297 +0,0 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import {
locationActions,
locationStatus,
locationError,
isLocationLoading,
isWatching
} from '$lib/stores/location';
import { Skeleton } from './skeleton';
interface Props {
class?: string;
variant?: 'primary' | 'secondary' | 'icon';
size?: 'small' | 'medium' | 'large';
showLabel?: boolean;
}
let {
class: className = '',
variant = 'primary',
size = 'medium',
showLabel = true
}: Props = $props();
// Track if location watching is active from the derived store
async function handleLocationClick() {
if ($isWatching) {
// Stop watching if currently active
locationActions.stopWatching();
toast.success('Location watching stopped');
} else {
// Try to get current location first, then start watching
const result = await locationActions.getCurrentLocation({
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 300000
});
if (result) {
// Start watching for continuous updates
locationActions.startWatching({
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 60000 // Update every minute
});
toast.success('Location watching started');
} else if ($locationError) {
toast.error($locationError.message);
}
}
}
const buttonText = $derived(() => {
if ($isLocationLoading) return 'Finding location...';
if ($isWatching) return 'Stop watching location';
if ($locationStatus === 'success') return 'Watch location';
return 'Find my location';
});
const iconClass = $derived(() => {
if ($isLocationLoading) return 'loading';
if ($isWatching) return 'watching';
if ($locationStatus === 'success') return 'success';
if ($locationStatus === 'error') return 'error';
return 'default';
});
</script>
<button
class="location-button {variant} {size} {className}"
onclick={handleLocationClick}
disabled={$isLocationLoading}
title={buttonText()}
>
<span class="icon {iconClass()}">
{#if $isLocationLoading}
<div class="loading-skeleton">
<Skeleton class="h-5 w-5 rounded-full" />
</div>
{:else if $isWatching}
<svg viewBox="0 0 24 24" class="watching-icon">
<path
d="M12,11.5A2.5,2.5 0 0,1 9.5,9A2.5,2.5 0 0,1 12,6.5A2.5,2.5 0 0,1 14.5,9A2.5,2.5 0 0,1 12,11.5M12,2A7,7 0 0,0 5,9C5,14.25 12,22 12,22C12,22 19,14.25 19,9A7,7 0 0,0 12,2Z"
/>
<circle
cx="12"
cy="9"
r="15"
stroke="currentColor"
stroke-width="1"
fill="none"
opacity="0.3"
class="pulse-ring"
/>
</svg>
{:else if $locationStatus === 'success'}
<svg viewBox="0 0 24 24">
<path
d="M12,11.5A2.5,2.5 0 0,1 9.5,9A2.5,2.5 0 0,1 12,6.5A2.5,2.5 0 0,1 14.5,9A2.5,2.5 0 0,1 12,11.5M12,2A7,7 0 0,0 5,9C5,14.25 12,22 12,22C12,22 19,14.25 19,9A7,7 0 0,0 12,2Z"
/>
</svg>
{:else if $locationStatus === 'error'}
<svg viewBox="0 0 24 24">
<path
d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"
/>
</svg>
{:else}
<svg viewBox="0 0 24 24">
<path
d="M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M3.05,13H1V11H3.05C3.5,6.83 6.83,3.5 11,3.05V1H13V3.05C17.17,3.5 20.5,6.83 20.95,11H23V13H20.95C20.5,17.17 17.17,20.5 13,20.95V23H11V20.95C6.83,20.5 3.5,17.17 3.05,13M12,5A7,7 0 0,0 5,12A7,7 0 0,0 12,19A7,7 0 0,0 19,12A7,7 0 0,0 12,5Z"
/>
</svg>
{/if}
</span>
{#if showLabel && variant !== 'icon'}
<span class="label">{buttonText()}</span>
{/if}
</button>
<style>
.location-button {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
border-radius: 8px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
transition: all 0.2s ease;
position: relative;
}
.location-button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
/* Variants */
.primary {
background: #2563eb;
color: white;
box-shadow: 0 2px 4px rgba(37, 99, 235, 0.2);
}
.primary:hover:not(:disabled) {
background: #1d4ed8;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(37, 99, 235, 0.3);
}
.secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.secondary:hover:not(:disabled) {
background: #f9fafb;
border-color: #9ca3af;
transform: translateY(-1px);
}
.icon {
background: transparent;
color: #6b7280;
padding: 8px;
border-radius: 50%;
border: 1px solid #d1d5db;
}
.icon:hover:not(:disabled) {
background: #f3f4f6;
color: #374151;
}
/* Sizes */
.small {
padding: 6px 12px;
font-size: 14px;
}
.small.icon {
padding: 6px;
}
.medium {
padding: 8px 16px;
font-size: 16px;
}
.medium.icon {
padding: 8px;
}
.large {
padding: 12px 20px;
font-size: 18px;
}
.large.icon {
padding: 12px;
}
/* Icon styles */
.icon svg {
width: 20px;
height: 20px;
fill: currentColor;
}
.small .icon svg {
width: 16px;
height: 16px;
}
.large .icon svg {
width: 24px;
height: 24px;
}
.loading-skeleton {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
}
.icon.success svg {
color: #10b981;
fill: #10b981;
}
.icon.watching svg {
color: #f59e0b;
fill: #f59e0b;
}
.icon.error svg {
color: #ef4444;
fill: #ef4444;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.watching-icon .pulse-ring {
animation: pulse-ring 2s infinite;
}
@keyframes pulse-ring {
0% {
transform: scale(0.5);
opacity: 0.5;
}
50% {
transform: scale(1);
opacity: 0.3;
}
100% {
transform: scale(1.5);
opacity: 0;
}
}
.label {
white-space: nowrap;
}
/* Responsive adjustments */
@media (max-width: 480px) {
.location-button .label {
display: none;
}
.location-button {
padding: 8px;
border-radius: 50%;
}
}
</style>

View File

@@ -163,6 +163,7 @@
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
background: white;
overflow: hidden;
z-index: 9999;
}
.modal.dropdown {
@@ -171,11 +172,12 @@
right: 0;
max-width: 320px;
width: 320px;
z-index: 1000;
z-index: 10000;
}
.modal::backdrop {
background: rgba(0, 0, 0, 0.1);
z-index: 9998;
}
.modal-content {

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

@@ -1,10 +1,9 @@
<script lang="ts">
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
import { Input } from '$lib/components/input';
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 {
@@ -37,7 +36,6 @@
{ value: 'other', label: 'Other' }
];
let showModal = $state(true);
let isMobile = $state(false);
$effect(() => {
@@ -54,13 +52,7 @@
});
$effect(() => {
if (!showModal) {
onClose();
}
});
$effect(() => {
if (showModal && $coordinates) {
if (isOpen && $coordinates) {
latitude = $coordinates.latitude.toString();
longitude = $coordinates.longitude.toString();
}
@@ -129,8 +121,8 @@
}
resetForm();
showModal = false;
onFindCreated(new CustomEvent('findCreated', { detail: { reload: true } }));
onClose();
} catch (error) {
console.error('Error creating find:', error);
alert('Failed to create find. Please try again.');
@@ -173,16 +165,27 @@
function closeModal() {
resetForm();
showModal = false;
onClose();
}
</script>
{#if isOpen}
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
<SheetContent side={isMobile ? 'bottom' : 'right'} class="create-find-sheet">
<SheetHeader>
<SheetTitle>Create Find</SheetTitle>
</SheetHeader>
<div class="modal-container" class:mobile={isMobile}>
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">Create Find</h2>
<button type="button" class="close-button" onclick={closeModal} 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>
<form
onsubmit={(e) => {
@@ -191,7 +194,7 @@
}}
class="form"
>
<div class="form-content">
<div class="modal-body">
<div class="field">
<Label for="title">What did you find?</Label>
<Input name="title" placeholder="Amazing coffee shop..." required bind:value={title} />
@@ -334,7 +337,7 @@
{/if}
</div>
<div class="actions">
<div class="modal-footer">
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
Cancel
</Button>
@@ -343,24 +346,106 @@
</Button>
</div>
</form>
</SheetContent>
</Sheet>
</div>
</div>
{/if}
<style>
:global(.create-find-sheet) {
padding: 0 !important;
width: 100%;
max-width: 500px;
height: 100vh;
border-radius: 0;
.modal-container {
position: fixed;
top: 80px;
right: 20px;
width: 40%;
max-width: 600px;
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;
animation: slideIn 0.3s ease-out;
}
@media (max-width: 767px) {
:global(.create-find-sheet) {
height: 90vh;
border-radius: 12px 12px 0 0;
@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: 100%;
background: rgba(255, 255, 255, 0.6);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.modal-title {
font-family: 'Washington', serif;
font-size: 1.5rem;
font-weight: 600;
margin: 0;
color: hsl(var(--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;
}
.close-button:hover {
background: hsl(var(--muted) / 0.5);
color: hsl(var(--foreground));
}
.form {
@@ -369,13 +454,14 @@
flex-direction: column;
}
.form-content {
.modal-body {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
min-height: 0;
}
.field {
@@ -390,14 +476,8 @@
gap: 1rem;
}
@media (max-width: 640px) {
.field-group {
grid-template-columns: 1fr;
}
}
textarea {
min-height: 80px;
min-height: 100px;
padding: 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 8px;
@@ -407,6 +487,7 @@
resize: vertical;
outline: none;
transition: border-color 0.2s ease;
line-height: 1.5;
}
textarea:focus {
@@ -448,13 +529,14 @@
}
.privacy-toggle:hover {
background: hsl(var(--muted));
background: hsl(var(--muted) / 0.5);
}
.privacy-toggle input[type='checkbox'] {
width: 16px;
height: 16px;
width: 18px;
height: 18px;
accent-color: hsl(var(--primary));
cursor: pointer;
}
.file-upload {
@@ -485,7 +567,7 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
padding: 2rem 1rem;
text-align: center;
gap: 0.5rem;
color: hsl(var(--muted-foreground));
@@ -493,6 +575,7 @@
.file-content span {
font-size: 0.875rem;
font-weight: 500;
}
.file-selected {
@@ -500,7 +583,7 @@
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: hsl(var(--muted));
background: hsl(var(--muted) / 0.5);
border: 1px solid hsl(var(--border));
border-radius: 8px;
font-size: 0.875rem;
@@ -516,24 +599,26 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: hsl(var(--foreground));
}
.actions {
.modal-footer {
display: flex;
gap: 1rem;
gap: 0.75rem;
padding: 1.5rem;
border-top: 1px solid hsl(var(--border));
background: hsl(var(--background));
border-top: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.actions :global(button) {
.modal-footer :global(button) {
flex: 1;
}
.location-section {
display: flex;
flex-direction: column;
gap: 1rem;
gap: 0.75rem;
}
.location-header {
@@ -544,18 +629,19 @@
.toggle-button {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
padding: 0.375rem 0.75rem;
height: auto;
background: transparent;
background: hsl(var(--secondary));
border: 1px solid hsl(var(--border));
border-radius: 6px;
color: hsl(var(--foreground));
color: hsl(var(--secondary-foreground));
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.toggle-button:hover {
background: hsl(var(--muted));
background: hsl(var(--secondary) / 0.8);
}
.coordinates-display {
@@ -567,7 +653,7 @@
.coordinates-info {
display: flex;
align-items: center;
gap: 1rem;
gap: 0.75rem;
padding: 0.75rem;
background: hsl(var(--muted) / 0.5);
border: 1px solid hsl(var(--border));
@@ -578,38 +664,53 @@
.coordinate {
color: hsl(var(--muted-foreground));
font-family: monospace;
font-size: 0.8125rem;
}
.edit-coords-button {
margin-left: auto;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
padding: 0.375rem 0.75rem;
height: auto;
background: transparent;
background: hsl(var(--secondary));
border: 1px solid hsl(var(--border));
border-radius: 6px;
color: hsl(var(--foreground));
color: hsl(var(--secondary-foreground));
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.edit-coords-button:hover {
background: hsl(var(--muted));
background: hsl(var(--secondary) / 0.8);
}
@media (max-width: 640px) {
.form-content {
/* Mobile specific adjustments */
@media (max-width: 767px) {
.modal-header {
padding: 1rem;
gap: 1rem;
}
.actions {
padding: 1rem;
flex-direction: column;
.modal-title {
font-size: 1.25rem;
}
.actions :global(button) {
flex: none;
.modal-body {
padding: 1rem;
gap: 1.25rem;
}
.modal-footer {
padding: 1rem;
gap: 0.5rem;
}
.file-content {
padding: 1.5rem 1rem;
}
.field-group {
grid-template-columns: 1fr;
}
}
</style>

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}
/>
@@ -161,16 +190,8 @@
<style>
.find-card {
background: white;
border: 1px solid hsl(var(--border));
border-radius: 12px;
overflow: hidden;
backdrop-filter: blur(10px);
margin-bottom: 1rem;
transition: box-shadow 0.2s ease;
}
.find-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* Post Header */
@@ -294,12 +315,16 @@
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
min-width: 0;
}
:global(.action-button) {
gap: 0.375rem;
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
flex-shrink: 1;
min-width: 0;
}
:global(.action-button:hover) {

View File

@@ -1,9 +1,8 @@
<script lang="ts">
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
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;
@@ -36,7 +35,6 @@
let { find, onClose, currentUserId }: Props = $props();
let showModal = $state(true);
let currentMediaIndex = $state(0);
let isMobile = $state(false);
@@ -54,13 +52,6 @@
return () => window.removeEventListener('resize', checkIsMobile);
});
// Close modal when showModal changes to false
$effect(() => {
if (!showModal) {
onClose();
}
});
function nextMedia() {
if (!find?.media) return;
currentMediaIndex = (currentMediaIndex + 1) % find.media.length;
@@ -97,22 +88,36 @@
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>
{#if find}
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
<SheetContent side={isMobile ? 'bottom' : 'right'} class="sheet-content">
<SheetHeader class="sheet-header">
<div class="modal-container" class:mobile={isMobile}>
<div class="modal-content">
<div class="modal-header">
<div class="user-section">
<ProfilePicture
username={find.user.username}
@@ -120,7 +125,7 @@
class="user-avatar"
/>
<div class="user-info">
<SheetTitle class="find-title">{find.title}</SheetTitle>
<h2 class="find-title">{find.title}</h2>
<div class="find-meta">
<span class="username">@{find.user.username}</span>
<span class="separator"></span>
@@ -132,9 +137,20 @@
</div>
</div>
</div>
</SheetHeader>
<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="sheet-body">
<div class="modal-body">
{#if find.media && find.media.length > 0}
<div class="media-container">
<div class="media-viewer">
@@ -258,42 +274,85 @@
<CommentsList
findId={find.id}
{currentUserId}
collapsed={false}
isScrollable={true}
showCommentForm={true}
/>
</div>
</div>
</SheetContent>
</Sheet>
</div>
</div>
{/if}
<style>
/* Base styles for sheet content */
:global(.sheet-content) {
padding: 0 !important;
.modal-container {
position: fixed;
top: 80px;
right: 20px;
width: fit-content;
max-width: 600px;
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);
z-index: 50;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideIn 0.3s ease-out;
}
/* Desktop styles (side sheet) */
@media (min-width: 768px) {
:global(.sheet-content) {
width: 80vw !important;
max-width: 600px !important;
height: 100vh !important;
border-radius: 0 !important;
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Mobile styles (bottom sheet) */
@media (max-width: 767px) {
:global(.sheet-content) {
height: 50vh !important;
border-radius: 16px 16px 0 0 !important;
.modal-container.mobile {
top: auto;
bottom: 0;
left: 0;
right: 0;
width: 100%;
min-width: 0;
max-width: none;
height: auto;
max-height: calc(90vh - 20px);
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);
}
}
:global(.sheet-header) {
padding: 1rem 1.5rem;
border-bottom: 1px solid hsl(var(--border));
.modal-content {
display: flex;
flex-direction: column;
height: 100%;
}
.modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
@@ -301,7 +360,8 @@
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
flex: 1;
min-width: 0;
}
:global(.user-avatar) {
@@ -322,13 +382,13 @@
min-width: 0;
}
:global(.find-title) {
font-family: 'Washington', serif !important;
font-size: 1.25rem !important;
font-weight: 600 !important;
margin: 0 !important;
color: hsl(var(--foreground)) !important;
line-height: 1.3 !important;
.find-title {
font-family: 'Washington', serif;
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: hsl(var(--foreground));
line-height: 1.3;
}
.find-meta {
@@ -359,64 +419,49 @@
text-transform: capitalize;
}
.sheet-body {
flex: 1;
overflow: hidden;
padding: 0;
.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 {
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) {
.sheet-body {
height: calc(100vh - 140px);
}
.media-container {
flex: 2;
}
.content-section {
flex: 1;
min-height: 160px;
max-height: 300px;
}
}
/* Mobile media viewer - maximize available space */
@media (max-width: 767px) {
.sheet-body {
height: calc(80vh - 140px);
}
.media-container {
flex: 2;
}
.content-section {
flex: 1;
min-height: 140px;
max-height: 250px;
}
display: flex;
align-items: center;
justify-content: center;
}
.media-image {
@@ -487,9 +532,9 @@
.content-section {
padding: 1rem 1.5rem 1.5rem;
overflow-y: auto;
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.5);
}
.description {
@@ -505,7 +550,6 @@
gap: 0.5rem;
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
margin-bottom: 1.5rem;
}
.actions {
@@ -554,32 +598,17 @@
}
.comments-section {
flex: 1;
min-height: 0;
border-top: 1px solid hsl(var(--border));
border-top: 1px solid rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
}
/* Desktop comments section */
@media (min-width: 768px) {
.comments-section {
height: calc(100vh - 400px);
min-height: 200px;
}
}
/* Mobile comments section */
@media (max-width: 767px) {
.comments-section {
height: calc(80vh - 350px);
min-height: 150px;
}
background: rgba(255, 255, 255, 0.5);
max-height: 400px;
overflow: hidden;
}
/* Mobile specific adjustments */
@media (max-width: 640px) {
:global(.sheet-header) {
.modal-header {
padding: 1rem;
}
@@ -592,25 +621,12 @@
height: 40px;
}
:global(.find-title) {
font-size: 1.125rem !important;
.find-title {
font-size: 1.125rem;
}
.content-section {
padding: 1rem;
}
.actions {
flex-direction: column;
gap: 0.5rem;
}
.action-button {
width: 100%;
}
.comments-section {
height: calc(80vh - 380px);
}
}
</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

@@ -9,7 +9,6 @@
locationActions,
isWatching
} from '$lib/stores/location';
import LocationButton from './LocationButton.svelte';
import { Skeleton } from '$lib/components/skeleton';
interface Find {
@@ -39,7 +38,6 @@
center?: [number, number];
zoom?: number;
class?: string;
showLocationButton?: boolean;
autoCenter?: boolean;
finds?: Find[];
onFindClick?: (find: Find) => void;
@@ -67,7 +65,6 @@
center,
zoom,
class: className = '',
showLocationButton = true,
autoCenter = true,
finds = [],
onFindClick
@@ -90,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
);
@@ -101,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;
});
@@ -179,12 +176,6 @@
</Marker>
{/each}
</MapLibre>
{#if showLocationButton}
<div class="location-controls">
<LocationButton variant="icon" size="medium" showLabel={false} />
</div>
{/if}
</div>
</div>
@@ -219,17 +210,9 @@
.map-container :global(.maplibregl-map) {
margin: 0 auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border-radius: 12px;
overflow: hidden;
}
.location-controls {
position: absolute;
top: 12px;
right: 12px;
}
/* Location marker styles */
:global(.location-marker) {
width: 24px;

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

@@ -1,7 +1,7 @@
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const sheetVariants = tv({
base: 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
base: 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-[9999] flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
variants: {
side: {
top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',

View File

@@ -13,7 +13,7 @@
bind:ref
data-slot="sheet-overlay"
class={cn(
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
'fixed inset-0 z-[9998] bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
className
)}
{...restProps}

View File

@@ -2,20 +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 LocationButton } from './components/LocationButton.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

@@ -4,7 +4,7 @@ import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
import { db } from '$lib/server/db';
import * as table from '$lib/server/db/schema';
import { getSignedR2Url } from '$lib/server/r2';
import { getLocalR2Url } from '$lib/server/r2';
const DAY_IN_MS = 1000 * 60 * 60 * 24;
@@ -63,16 +63,11 @@ export async function validateSessionToken(token: string) {
.where(eq(table.session.id, session.id));
}
// Generate signed URL for profile picture if it exists
// Generate local proxy URL for profile picture if it exists
let profilePictureUrl = user.profilePictureUrl;
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
// It's a path, generate signed URL
try {
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60); // 24 hours
} catch (error) {
console.error('Failed to generate signed URL for profile picture:', error);
profilePictureUrl = null;
}
// It's a path, generate local proxy URL
profilePictureUrl = getLocalR2Url(profilePictureUrl);
}
return {

View File

@@ -72,3 +72,7 @@ export async function getSignedR2Url(path: string, expiresIn = 3600): Promise<st
return await getSignedUrl(r2Client, command, { expiresIn });
}
export function getLocalR2Url(path: string): string {
return `/api/media/${path}`;
}

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';
@@ -64,12 +64,12 @@
<style>
.header-skeleton {
border-bottom: 1px solid #e5e7eb;
background: white;
padding: 0 20px;
height: 64px;
display: flex;
align-items: center;
position: relative;
z-index: 100;
}
.header-content {

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';
@@ -94,6 +94,7 @@
let showCreateModal = $state(false);
let selectedFind: FindPreviewData | null = $state(null);
let currentFilter = $state('all');
let isSidebarVisible = $state(true);
// Initialize API sync with server data on mount
onMount(async () => {
@@ -215,6 +216,10 @@
function closeCreateModal() {
showCreateModal = false;
}
function toggleSidebar() {
isSidebarVisible = !isSidebarVisible;
}
</script>
<svelte:head>
@@ -238,24 +243,23 @@
</svelte:head>
<div class="home-container">
<main class="main-content">
<div class="map-section">
<Map
showLocationButton={true}
autoCenter={true}
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
{finds}
onFindClick={handleFindClick}
/>
</div>
<!-- Fullscreen map -->
<div class="map-section">
<Map
autoCenter={true}
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
{finds}
onFindClick={handleFindClick}
/>
</div>
<div class="finds-section">
<div class="finds-sticky-header">
<div class="finds-header-content">
<div class="finds-title-section">
<h2 class="finds-title">Finds</h2>
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
</div>
<!-- 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" />
@@ -263,19 +267,34 @@
</svg>
Create Find
</Button>
</div>
{: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>
<FindsList {finds} onFindExplore={handleFindExplore} hideTitle={true} />
</div>
</main>
<!-- Floating action button for mobile -->
<button class="fab" onclick={openCreateModal} aria-label="Create new find">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<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>
</button>
<!-- 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>
<!-- Modals -->
@@ -293,145 +312,193 @@
<style>
.home-container {
background-color: #f8f8f8;
min-height: 100vh;
}
.main-content {
padding: 24px 20px;
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 24px;
}
.map-section {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.map-section :global(.map-container) {
height: 500px;
border-radius: 0;
}
.finds-section {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: relative;
}
.finds-sticky-header {
position: sticky;
.map-section {
position: fixed;
top: 0;
z-index: 50;
background: white;
border-bottom: 1px solid hsl(var(--border));
padding: 24px 24px 16px 24px;
border-radius: 12px 12px 0 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 0;
overflow: hidden;
}
.finds-header-content {
.map-section :global(.map-container) {
height: 100vh;
border-radius: 0;
}
.map-section :global(.maplibregl-map) {
border-radius: 0 !important;
box-shadow: none !important;
}
.sidebar-container {
display: flex;
flex-direction: row;
margin-left: 20px;
gap: 12px;
}
.sidebar-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;
}
.sidebar-toggle:hover {
background: rgba(255, 255, 255, 1);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.2);
}
.sidebar-toggle svg {
color: #333;
}
.finds-sidebar {
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;
}
.finds-sidebar.hidden {
display: none;
transform: translateX(-100%);
opacity: 0;
pointer-events: none;
}
.finds-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 20px;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.finds-title-section {
display: flex;
align-items: center;
gap: 1rem;
flex: 1;
.login-prompt {
width: 100%;
text-align: center;
padding: 1rem;
}
.finds-title {
.login-prompt h3 {
font-family: 'Washington', serif;
font-size: 1.875rem;
font-weight: 700;
margin: 0;
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;
overflow-x: hidden;
}
:global(.create-find-button) {
flex-shrink: 0;
}
.fab {
position: fixed;
bottom: 2rem;
right: 2rem;
width: 56px;
height: 56px;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
border-radius: 50%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
transition: all 0.2s;
z-index: 100;
}
.fab:hover {
transform: scale(1.1);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
@media (max-width: 768px) {
.main-content {
padding: 16px;
gap: 16px;
}
.finds-sticky-header {
padding: 16px 16px 12px 16px;
}
.finds-header-content {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.finds-title-section {
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.finds-title {
font-size: 1.5rem;
}
:global(.create-find-button) {
display: none;
}
.fab {
.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;
border-radius: 20px 20px 0 0;
box-sizing: border-box;
}
.finds-sidebar.hidden {
display: none;
transform: translateX(-100%);
opacity: 0;
pointer-events: none;
}
.finds-header {
padding: 16px;
}
.map-section :global(.map-container) {
height: 300px;
height: 100vh;
}
}
@media (max-width: 480px) {
.main-content {
padding: 12px;
.finds-sidebar {
height: 60vh;
}
.finds-sticky-header {
padding: 12px 12px 8px 12px;
.finds-header {
padding: 12px;
}
}
</style>

View File

@@ -4,7 +4,7 @@ import { db } from '$lib/server/db';
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';
import { getLocalR2Url } from '$lib/server/r2';
function generateFindId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(15));
@@ -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))
@@ -176,31 +176,25 @@ export const GET: RequestHandler = async ({ url, locals }) => {
// Generate signed URLs for all media items
const mediaWithSignedUrls = await Promise.all(
findMedia.map(async (mediaItem) => {
// URLs in database are now paths, generate signed URLs directly
const [signedUrl, signedThumbnailUrl] = await Promise.all([
getSignedR2Url(mediaItem.url, 24 * 60 * 60), // 24 hours
// URLs in database are now paths, generate local proxy URLs
const localUrl = getLocalR2Url(mediaItem.url);
const localThumbnailUrl =
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
? getSignedR2Url(mediaItem.thumbnailUrl, 24 * 60 * 60)
: Promise.resolve(mediaItem.thumbnailUrl) // Keep static placeholder paths as-is
]);
? getLocalR2Url(mediaItem.thumbnailUrl)
: mediaItem.thumbnailUrl; // Keep static placeholder paths as-is
return {
...mediaItem,
url: signedUrl,
thumbnailUrl: signedThumbnailUrl
url: localUrl,
thumbnailUrl: localThumbnailUrl
};
})
);
// Generate signed URL for user profile picture if it exists
// Generate local proxy URL for user profile picture if it exists
let userProfilePictureUrl = findItem.profilePictureUrl;
if (userProfilePictureUrl && !userProfilePictureUrl.startsWith('http')) {
try {
userProfilePictureUrl = await getSignedR2Url(userProfilePictureUrl, 24 * 60 * 60);
} catch (error) {
console.error('Failed to generate signed URL for user profile picture:', error);
userProfilePictureUrl = null;
}
userProfilePictureUrl = getLocalR2Url(userProfilePictureUrl);
}
return {

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

@@ -4,7 +4,7 @@ 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';
import { getLocalR2Url } from '$lib/server/r2';
import { notificationService } from '$lib/server/notifications';
import { pushService } from '$lib/server/push';
@@ -98,25 +98,18 @@ export const GET: RequestHandler = async ({ url, locals }) => {
.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;
}
}
// Generate local proxy URLs for profile pictures
const friendshipsWithSignedUrls = (friendships || []).map((friendship) => {
let profilePictureUrl = friendship.friendProfilePictureUrl;
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
profilePictureUrl = getLocalR2Url(profilePictureUrl);
}
return {
...friendship,
friendProfilePictureUrl: profilePictureUrl
};
})
);
return {
...friendship,
friendProfilePictureUrl: profilePictureUrl
};
});
return json(friendshipsWithSignedUrls);
} catch (err) {

View File

@@ -0,0 +1,63 @@
import type { RequestHandler } from './$types';
import { error } from '@sveltejs/kit';
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { r2Client } from '$lib/server/r2';
import { env } from '$env/dynamic/private';
const R2_BUCKET_NAME = env.R2_BUCKET_NAME;
export const GET: RequestHandler = async ({ params, setHeaders }) => {
const path = params.path;
if (!path) {
throw error(400, 'Path is required');
}
if (!R2_BUCKET_NAME) {
throw error(500, 'R2_BUCKET_NAME environment variable is required');
}
try {
const command = new GetObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: path
});
const response = await r2Client.send(command);
if (!response.Body) {
throw error(404, 'File not found');
}
const chunks: Uint8Array[] = [];
const body = response.Body as AsyncIterable<Uint8Array>;
for await (const chunk of body) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
setHeaders({
'Content-Type': response.ContentType || 'application/octet-stream',
'Cache-Control': response.CacheControl || 'public, max-age=31536000, immutable',
'Content-Length': buffer.length.toString(),
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
});
return new Response(buffer);
} catch (err) {
console.error('Error fetching file from R2:', err);
throw error(500, 'Failed to fetch file');
}
};
export const OPTIONS: RequestHandler = async ({ setHeaders }) => {
setHeaders({
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
});
return new Response(null, { status: 204 });
};

View File

@@ -3,7 +3,7 @@ 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';
import { getLocalR2Url } from '$lib/server/r2';
export const GET: RequestHandler = async ({ url, locals }) => {
if (!locals.user) {
@@ -63,28 +63,21 @@ export const GET: RequestHandler = async ({ url, locals }) => {
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;
}
}
// Generate local proxy URLs and add friendship status
const usersWithSignedUrls = users.map((userItem) => {
let profilePictureUrl = userItem.profilePictureUrl;
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
profilePictureUrl = getLocalR2Url(profilePictureUrl);
}
const friendshipStatus = friendshipStatusMap.get(userItem.id) || 'none';
const friendshipStatus = friendshipStatusMap.get(userItem.id) || 'none';
return {
...userItem,
profilePictureUrl,
friendshipStatus
};
})
);
return {
...userItem,
profilePictureUrl,
friendshipStatus
};
});
return json(usersWithSignedUrls);
} catch (err) {

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

@@ -130,8 +130,12 @@ self.addEventListener('fetch', (event) => {
}
}
// Handle R2 resources with cache-first strategy
if (url.hostname.includes('.r2.dev') || url.hostname.includes('.r2.cloudflarestorage.com')) {
// 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/')
) {
const cachedResponse = await r2Cache.match(event.request);
if (cachedResponse) {
return cachedResponse;
@@ -146,7 +150,7 @@ self.addEventListener('fetch', (event) => {
return response;
} catch {
// Return cached version if available, or fall through to other cache checks
return cachedResponse || new Response('R2 resource not available', { status: 404 });
return cachedResponse || new Response('Media resource not available', { status: 404 });
}
}