Compare commits
16 Commits
notificati
...
public-fin
| Author | SHA1 | Date | |
|---|---|---|---|
|
73eeaf0c74
|
|||
|
2ac826cbf9
|
|||
|
5285a15335
|
|||
|
9f608067fc
|
|||
|
4c73b6f919
|
|||
|
42d7246cff
|
|||
|
63b3e5112b
|
|||
|
84f3d0bdb9
|
|||
|
1c31e2cdda
|
|||
|
d8cab06e90
|
|||
|
d4d23ed46d
|
|||
|
ab8b0ee982
|
|||
|
dabc732f4b
|
|||
|
1f0e8141be
|
|||
|
96a173b73b
|
|||
|
08f7e77a86
|
126
logs/logboek.md
126
logs/logboek.md
@@ -1,5 +1,113 @@
|
|||||||
# Logboek - Serengo Project
|
# 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
|
## Oktober 2025
|
||||||
|
|
||||||
### 4 November 2025 - 1 uren
|
### 4 November 2025 - 1 uren
|
||||||
@@ -381,9 +489,9 @@
|
|||||||
|
|
||||||
## Totaal Overzicht
|
## Totaal Overzicht
|
||||||
|
|
||||||
**Totale geschatte uren:** 80 uren
|
**Totale geschatte uren:** 93 uren
|
||||||
**Werkdagen:** 14 dagen
|
**Werkdagen:** 17 dagen
|
||||||
**Gemiddelde uren per dag:** 5.8 uur
|
**Gemiddelde uren per dag:** 5.5 uur
|
||||||
|
|
||||||
### Project Milestones:
|
### Project Milestones:
|
||||||
|
|
||||||
@@ -401,6 +509,9 @@
|
|||||||
12. **21 Okt**: UI refinement en bug fixes
|
12. **21 Okt**: UI refinement en bug fixes
|
||||||
13. **27-29 Okt**: Google Places Integration & Sync Service
|
13. **27-29 Okt**: Google Places Integration & Sync Service
|
||||||
14. **4 Nov**: UI Consistency & Media Layout Improvements
|
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:
|
### Hoofdfunctionaliteiten geïmplementeerd:
|
||||||
|
|
||||||
@@ -431,3 +542,12 @@
|
|||||||
- [x] Sync-service voor API data synchronisatie
|
- [x] Sync-service voor API data synchronisatie
|
||||||
- [x] Continuous location watching voor nauwkeurige tracking
|
- [x] Continuous location watching voor nauwkeurige tracking
|
||||||
- [x] CSP security verbeteringen
|
- [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
|
||||||
|
|||||||
@@ -88,6 +88,7 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
overscroll-behavior: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -28,8 +28,7 @@
|
|||||||
.app-header {
|
.app-header {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 100;
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content {
|
.header-content {
|
||||||
@@ -38,7 +37,6 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
max-width: 1200px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-container {
|
.profile-container {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -163,6 +163,7 @@
|
|||||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||||
background: white;
|
background: white;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
z-index: 9999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal.dropdown {
|
.modal.dropdown {
|
||||||
@@ -171,11 +172,12 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
width: 320px;
|
width: 320px;
|
||||||
z-index: 1000;
|
z-index: 10000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal::backdrop {
|
.modal::backdrop {
|
||||||
background: rgba(0, 0, 0, 0.1);
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 9998;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
1
src/lib/components/auth/index.ts
Normal file
1
src/lib/components/auth/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as LoginForm } from './login-form.svelte';
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
import { cn } from '$lib/utils.js';
|
import { cn } from '$lib/utils.js';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import type { ActionData } from '../../routes/login/$types.js';
|
import type { ActionData } from '../../../routes/login/$types.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
class: className,
|
class: className,
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/button';
|
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 { Trash2 } from '@lucide/svelte';
|
||||||
import type { CommentState } from '$lib/stores/api-sync';
|
import type { CommentState } from '$lib/stores/api-sync';
|
||||||
|
|
||||||
@@ -75,7 +75,6 @@
|
|||||||
<style>
|
<style>
|
||||||
.comment-form {
|
.comment-form {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-top: 1px solid hsl(var(--border));
|
|
||||||
background: hsl(var(--background));
|
background: hsl(var(--background));
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
23
src/lib/components/finds/Comments.svelte
Normal file
23
src/lib/components/finds/Comments.svelte
Normal 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} />
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Comment from '$lib/components/Comment.svelte';
|
import Comment from './Comment.svelte';
|
||||||
import CommentForm from '$lib/components/CommentForm.svelte';
|
import CommentForm from './CommentForm.svelte';
|
||||||
import { Skeleton } from '$lib/components/skeleton';
|
import { Skeleton } from '$lib/components/skeleton';
|
||||||
import { apiSync } from '$lib/stores/api-sync';
|
import { apiSync } from '$lib/stores/api-sync';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
@@ -92,6 +92,12 @@
|
|||||||
|
|
||||||
{#if isExpanded}
|
{#if isExpanded}
|
||||||
<div class="comments-container">
|
<div class="comments-container">
|
||||||
|
{#if showCommentForm}
|
||||||
|
<div class="comment-form-container">
|
||||||
|
<CommentForm onSubmit={handleAddComment} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if $commentsState.isLoading && !hasLoadedComments}
|
{#if $commentsState.isLoading && !hasLoadedComments}
|
||||||
{@render loadingSkeleton()}
|
{@render loadingSkeleton()}
|
||||||
{:else if $commentsState.error}
|
{:else if $commentsState.error}
|
||||||
@@ -119,10 +125,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showCommentForm}
|
|
||||||
<CommentForm onSubmit={handleAddComment} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -148,14 +150,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.comments-container {
|
.comments-container {
|
||||||
border-top: 1px solid hsl(var(--border));
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
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 {
|
.comments {
|
||||||
padding: 0.75rem 0;
|
padding: 0.75rem 0;
|
||||||
}
|
}
|
||||||
@@ -165,6 +174,7 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
max-height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.see-more {
|
.see-more {
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
|
|
||||||
import { Input } from '$lib/components/input';
|
import { Input } from '$lib/components/input';
|
||||||
import { Label } from '$lib/components/label';
|
import { Label } from '$lib/components/label';
|
||||||
import { Button } from '$lib/components/button';
|
import { Button } from '$lib/components/button';
|
||||||
import { coordinates } from '$lib/stores/location';
|
import { coordinates } from '$lib/stores/location';
|
||||||
import POISearch from './POISearch.svelte';
|
import POISearch from '../map/POISearch.svelte';
|
||||||
import type { PlaceResult } from '$lib/utils/places';
|
import type { PlaceResult } from '$lib/utils/places';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -37,7 +36,6 @@
|
|||||||
{ value: 'other', label: 'Other' }
|
{ value: 'other', label: 'Other' }
|
||||||
];
|
];
|
||||||
|
|
||||||
let showModal = $state(true);
|
|
||||||
let isMobile = $state(false);
|
let isMobile = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -54,13 +52,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!showModal) {
|
if (isOpen && $coordinates) {
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (showModal && $coordinates) {
|
|
||||||
latitude = $coordinates.latitude.toString();
|
latitude = $coordinates.latitude.toString();
|
||||||
longitude = $coordinates.longitude.toString();
|
longitude = $coordinates.longitude.toString();
|
||||||
}
|
}
|
||||||
@@ -129,8 +121,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
resetForm();
|
resetForm();
|
||||||
showModal = false;
|
|
||||||
onFindCreated(new CustomEvent('findCreated', { detail: { reload: true } }));
|
onFindCreated(new CustomEvent('findCreated', { detail: { reload: true } }));
|
||||||
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating find:', error);
|
console.error('Error creating find:', error);
|
||||||
alert('Failed to create find. Please try again.');
|
alert('Failed to create find. Please try again.');
|
||||||
@@ -173,16 +165,27 @@
|
|||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
resetForm();
|
resetForm();
|
||||||
showModal = false;
|
onClose();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
|
<div class="modal-container" class:mobile={isMobile}>
|
||||||
<SheetContent side={isMobile ? 'bottom' : 'right'} class="create-find-sheet">
|
<div class="modal-content">
|
||||||
<SheetHeader>
|
<div class="modal-header">
|
||||||
<SheetTitle>Create Find</SheetTitle>
|
<h2 class="modal-title">Create Find</h2>
|
||||||
</SheetHeader>
|
<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
|
<form
|
||||||
onsubmit={(e) => {
|
onsubmit={(e) => {
|
||||||
@@ -191,7 +194,7 @@
|
|||||||
}}
|
}}
|
||||||
class="form"
|
class="form"
|
||||||
>
|
>
|
||||||
<div class="form-content">
|
<div class="modal-body">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<Label for="title">What did you find?</Label>
|
<Label for="title">What did you find?</Label>
|
||||||
<Input name="title" placeholder="Amazing coffee shop..." required bind:value={title} />
|
<Input name="title" placeholder="Amazing coffee shop..." required bind:value={title} />
|
||||||
@@ -334,7 +337,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="modal-footer">
|
||||||
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
|
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -343,24 +346,106 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</SheetContent>
|
</div>
|
||||||
</Sheet>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:global(.create-find-sheet) {
|
.modal-container {
|
||||||
padding: 0 !important;
|
position: fixed;
|
||||||
width: 100%;
|
top: 80px;
|
||||||
max-width: 500px;
|
right: 20px;
|
||||||
height: 100vh;
|
width: 40%;
|
||||||
border-radius: 0;
|
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) {
|
@keyframes slideIn {
|
||||||
:global(.create-find-sheet) {
|
from {
|
||||||
height: 90vh;
|
opacity: 0;
|
||||||
border-radius: 12px 12px 0 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 {
|
.form {
|
||||||
@@ -369,13 +454,14 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-content {
|
.modal-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
@@ -390,14 +476,8 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.field-group {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
min-height: 80px;
|
min-height: 100px;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid hsl(var(--border));
|
border: 1px solid hsl(var(--border));
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -407,6 +487,7 @@
|
|||||||
resize: vertical;
|
resize: vertical;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s ease;
|
transition: border-color 0.2s ease;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea:focus {
|
textarea:focus {
|
||||||
@@ -448,13 +529,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.privacy-toggle:hover {
|
.privacy-toggle:hover {
|
||||||
background: hsl(var(--muted));
|
background: hsl(var(--muted) / 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.privacy-toggle input[type='checkbox'] {
|
.privacy-toggle input[type='checkbox'] {
|
||||||
width: 16px;
|
width: 18px;
|
||||||
height: 16px;
|
height: 18px;
|
||||||
accent-color: hsl(var(--primary));
|
accent-color: hsl(var(--primary));
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-upload {
|
.file-upload {
|
||||||
@@ -485,7 +567,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 2rem;
|
padding: 2rem 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
@@ -493,6 +575,7 @@
|
|||||||
|
|
||||||
.file-content span {
|
.file-content span {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-selected {
|
.file-selected {
|
||||||
@@ -500,7 +583,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: hsl(var(--muted));
|
background: hsl(var(--muted) / 0.5);
|
||||||
border: 1px solid hsl(var(--border));
|
border: 1px solid hsl(var(--border));
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@@ -516,24 +599,26 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.modal-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-top: 1px solid hsl(var(--border));
|
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
background: hsl(var(--background));
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions :global(button) {
|
.modal-footer :global(button) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-section {
|
.location-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-header {
|
.location-header {
|
||||||
@@ -544,18 +629,19 @@
|
|||||||
|
|
||||||
.toggle-button {
|
.toggle-button {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.375rem 0.75rem;
|
||||||
height: auto;
|
height: auto;
|
||||||
background: transparent;
|
background: hsl(var(--secondary));
|
||||||
border: 1px solid hsl(var(--border));
|
border: 1px solid hsl(var(--border));
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--secondary-foreground));
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-button:hover {
|
.toggle-button:hover {
|
||||||
background: hsl(var(--muted));
|
background: hsl(var(--secondary) / 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.coordinates-display {
|
.coordinates-display {
|
||||||
@@ -567,7 +653,7 @@
|
|||||||
.coordinates-info {
|
.coordinates-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: hsl(var(--muted) / 0.5);
|
background: hsl(var(--muted) / 0.5);
|
||||||
border: 1px solid hsl(var(--border));
|
border: 1px solid hsl(var(--border));
|
||||||
@@ -578,38 +664,53 @@
|
|||||||
.coordinate {
|
.coordinate {
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-coords-button {
|
.edit-coords-button {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.375rem 0.75rem;
|
||||||
height: auto;
|
height: auto;
|
||||||
background: transparent;
|
background: hsl(var(--secondary));
|
||||||
border: 1px solid hsl(var(--border));
|
border: 1px solid hsl(var(--border));
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--secondary-foreground));
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-coords-button:hover {
|
.edit-coords-button:hover {
|
||||||
background: hsl(var(--muted));
|
background: hsl(var(--secondary) / 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
/* Mobile specific adjustments */
|
||||||
.form-content {
|
@media (max-width: 767px) {
|
||||||
|
.modal-header {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
gap: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.modal-title {
|
||||||
padding: 1rem;
|
font-size: 1.25rem;
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions :global(button) {
|
.modal-body {
|
||||||
flex: none;
|
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>
|
</style>
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/button';
|
import { Button } from '$lib/components/button';
|
||||||
import { Badge } from '$lib/components/badge';
|
import { Badge } from '$lib/components/badge';
|
||||||
import LikeButton from '$lib/components/LikeButton.svelte';
|
import LikeButton from './LikeButton.svelte';
|
||||||
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
|
import VideoPlayer from '../media/VideoPlayer.svelte';
|
||||||
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
|
import ProfilePicture from '../profile/ProfilePicture.svelte';
|
||||||
import CommentsList from '$lib/components/CommentsList.svelte';
|
import CommentsList from './CommentsList.svelte';
|
||||||
import { Ellipsis, MessageCircle, Share } from '@lucide/svelte';
|
import { Ellipsis, MessageCircle, Share } from '@lucide/svelte';
|
||||||
|
|
||||||
interface FindCardProps {
|
interface FindCardProps {
|
||||||
@@ -53,6 +53,35 @@
|
|||||||
function toggleComments() {
|
function toggleComments() {
|
||||||
showComments = !showComments;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="find-card">
|
<div class="find-card">
|
||||||
@@ -135,7 +164,7 @@
|
|||||||
<MessageCircle size={16} />
|
<MessageCircle size={16} />
|
||||||
<span>{commentCount || 'comment'}</span>
|
<span>{commentCount || 'comment'}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" class="action-button">
|
<Button variant="ghost" size="sm" class="action-button" onclick={handleShare}>
|
||||||
<Share size={16} />
|
<Share size={16} />
|
||||||
<span>share</span>
|
<span>share</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -151,7 +180,7 @@
|
|||||||
<CommentsList
|
<CommentsList
|
||||||
findId={id}
|
findId={id}
|
||||||
{currentUserId}
|
{currentUserId}
|
||||||
collapsed={false}
|
collapsed={true}
|
||||||
maxComments={5}
|
maxComments={5}
|
||||||
showCommentForm={true}
|
showCommentForm={true}
|
||||||
/>
|
/>
|
||||||
@@ -161,16 +190,8 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.find-card {
|
.find-card {
|
||||||
background: white;
|
backdrop-filter: blur(10px);
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 1rem;
|
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 */
|
/* Post Header */
|
||||||
@@ -294,12 +315,16 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.action-button) {
|
:global(.action-button) {
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.action-button:hover) {
|
:global(.action-button:hover) {
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '$lib/components/sheet';
|
import LikeButton from './LikeButton.svelte';
|
||||||
import LikeButton from '$lib/components/LikeButton.svelte';
|
import VideoPlayer from '../media/VideoPlayer.svelte';
|
||||||
import VideoPlayer from '$lib/components/VideoPlayer.svelte';
|
import ProfilePicture from '../profile/ProfilePicture.svelte';
|
||||||
import ProfilePicture from '$lib/components/ProfilePicture.svelte';
|
import CommentsList from './CommentsList.svelte';
|
||||||
import CommentsList from '$lib/components/CommentsList.svelte';
|
|
||||||
|
|
||||||
interface Find {
|
interface Find {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -36,7 +35,6 @@
|
|||||||
|
|
||||||
let { find, onClose, currentUserId }: Props = $props();
|
let { find, onClose, currentUserId }: Props = $props();
|
||||||
|
|
||||||
let showModal = $state(true);
|
|
||||||
let currentMediaIndex = $state(0);
|
let currentMediaIndex = $state(0);
|
||||||
let isMobile = $state(false);
|
let isMobile = $state(false);
|
||||||
|
|
||||||
@@ -54,13 +52,6 @@
|
|||||||
return () => window.removeEventListener('resize', checkIsMobile);
|
return () => window.removeEventListener('resize', checkIsMobile);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close modal when showModal changes to false
|
|
||||||
$effect(() => {
|
|
||||||
if (!showModal) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function nextMedia() {
|
function nextMedia() {
|
||||||
if (!find?.media) return;
|
if (!find?.media) return;
|
||||||
currentMediaIndex = (currentMediaIndex + 1) % find.media.length;
|
currentMediaIndex = (currentMediaIndex + 1) % find.media.length;
|
||||||
@@ -97,22 +88,36 @@
|
|||||||
const url = `${window.location.origin}/finds/${find.id}`;
|
const url = `${window.location.origin}/finds/${find.id}`;
|
||||||
|
|
||||||
if (navigator.share) {
|
if (navigator.share) {
|
||||||
navigator.share({
|
navigator
|
||||||
title: find.title,
|
.share({
|
||||||
text: find.description || `Check out this find: ${find.title}`,
|
title: find.title,
|
||||||
url: url
|
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 {
|
} else {
|
||||||
navigator.clipboard.writeText(url);
|
// Fallback: Copy to clipboard
|
||||||
alert('Find URL copied to clipboard!');
|
navigator.clipboard
|
||||||
|
.writeText(url)
|
||||||
|
.then(() => {
|
||||||
|
alert('Find URL copied to clipboard!');
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error copying to clipboard:', error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if find}
|
{#if find}
|
||||||
<Sheet open={showModal} onOpenChange={(open) => (showModal = open)}>
|
<div class="modal-container" class:mobile={isMobile}>
|
||||||
<SheetContent side={isMobile ? 'bottom' : 'right'} class="sheet-content">
|
<div class="modal-content">
|
||||||
<SheetHeader class="sheet-header">
|
<div class="modal-header">
|
||||||
<div class="user-section">
|
<div class="user-section">
|
||||||
<ProfilePicture
|
<ProfilePicture
|
||||||
username={find.user.username}
|
username={find.user.username}
|
||||||
@@ -120,7 +125,7 @@
|
|||||||
class="user-avatar"
|
class="user-avatar"
|
||||||
/>
|
/>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<SheetTitle class="find-title">{find.title}</SheetTitle>
|
<h2 class="find-title">{find.title}</h2>
|
||||||
<div class="find-meta">
|
<div class="find-meta">
|
||||||
<span class="username">@{find.user.username}</span>
|
<span class="username">@{find.user.username}</span>
|
||||||
<span class="separator">•</span>
|
<span class="separator">•</span>
|
||||||
@@ -132,9 +137,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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}
|
{#if find.media && find.media.length > 0}
|
||||||
<div class="media-container">
|
<div class="media-container">
|
||||||
<div class="media-viewer">
|
<div class="media-viewer">
|
||||||
@@ -258,42 +274,85 @@
|
|||||||
<CommentsList
|
<CommentsList
|
||||||
findId={find.id}
|
findId={find.id}
|
||||||
{currentUserId}
|
{currentUserId}
|
||||||
|
collapsed={false}
|
||||||
isScrollable={true}
|
isScrollable={true}
|
||||||
showCommentForm={true}
|
showCommentForm={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</div>
|
||||||
</Sheet>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Base styles for sheet content */
|
.modal-container {
|
||||||
:global(.sheet-content) {
|
position: fixed;
|
||||||
padding: 0 !important;
|
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) */
|
@keyframes slideIn {
|
||||||
@media (min-width: 768px) {
|
from {
|
||||||
:global(.sheet-content) {
|
opacity: 0;
|
||||||
width: 80vw !important;
|
transform: translateX(20px);
|
||||||
max-width: 600px !important;
|
}
|
||||||
height: 100vh !important;
|
to {
|
||||||
border-radius: 0 !important;
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile styles (bottom sheet) */
|
.modal-container.mobile {
|
||||||
@media (max-width: 767px) {
|
top: auto;
|
||||||
:global(.sheet-content) {
|
bottom: 0;
|
||||||
height: 50vh !important;
|
left: 0;
|
||||||
border-radius: 16px 16px 0 0 !important;
|
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) {
|
.modal-content {
|
||||||
padding: 1rem 1.5rem;
|
display: flex;
|
||||||
border-bottom: 1px solid hsl(var(--border));
|
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;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +360,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
width: 100%;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.user-avatar) {
|
:global(.user-avatar) {
|
||||||
@@ -322,13 +382,13 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.find-title) {
|
.find-title {
|
||||||
font-family: 'Washington', serif !important;
|
font-family: 'Washington', serif;
|
||||||
font-size: 1.25rem !important;
|
font-size: 1.25rem;
|
||||||
font-weight: 600 !important;
|
font-weight: 600;
|
||||||
margin: 0 !important;
|
margin: 0;
|
||||||
color: hsl(var(--foreground)) !important;
|
color: hsl(var(--foreground));
|
||||||
line-height: 1.3 !important;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.find-meta {
|
.find-meta {
|
||||||
@@ -359,64 +419,49 @@
|
|||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet-body {
|
.close-button {
|
||||||
flex: 1;
|
display: flex;
|
||||||
overflow: hidden;
|
align-items: center;
|
||||||
padding: 0;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
overflow: auto;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-container {
|
.media-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-viewer {
|
.media-viewer {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-height: 400px;
|
||||||
background: hsl(var(--muted));
|
background: hsl(var(--muted));
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex: 1;
|
display: flex;
|
||||||
min-height: 0;
|
align-items: center;
|
||||||
}
|
justify-content: center;
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-image {
|
.media-image {
|
||||||
@@ -487,9 +532,9 @@
|
|||||||
|
|
||||||
.content-section {
|
.content-section {
|
||||||
padding: 1rem 1.5rem 1.5rem;
|
padding: 1rem 1.5rem 1.5rem;
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
@@ -505,7 +550,6 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
@@ -554,32 +598,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.comments-section {
|
.comments-section {
|
||||||
flex: 1;
|
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
min-height: 0;
|
|
||||||
border-top: 1px solid hsl(var(--border));
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
max-height: 400px;
|
||||||
/* Desktop comments section */
|
overflow: hidden;
|
||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile specific adjustments */
|
/* Mobile specific adjustments */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
:global(.sheet-header) {
|
.modal-header {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,25 +621,12 @@
|
|||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.find-title) {
|
.find-title {
|
||||||
font-size: 1.125rem !important;
|
font-size: 1.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-section {
|
.content-section {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comments-section {
|
|
||||||
height: calc(80vh - 380px);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
10
src/lib/components/finds/index.ts
Normal file
10
src/lib/components/finds/index.ts
Normal 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';
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
locationActions,
|
locationActions,
|
||||||
isWatching
|
isWatching
|
||||||
} from '$lib/stores/location';
|
} from '$lib/stores/location';
|
||||||
import LocationButton from './LocationButton.svelte';
|
|
||||||
import { Skeleton } from '$lib/components/skeleton';
|
import { Skeleton } from '$lib/components/skeleton';
|
||||||
|
|
||||||
interface Find {
|
interface Find {
|
||||||
@@ -39,7 +38,6 @@
|
|||||||
center?: [number, number];
|
center?: [number, number];
|
||||||
zoom?: number;
|
zoom?: number;
|
||||||
class?: string;
|
class?: string;
|
||||||
showLocationButton?: boolean;
|
|
||||||
autoCenter?: boolean;
|
autoCenter?: boolean;
|
||||||
finds?: Find[];
|
finds?: Find[];
|
||||||
onFindClick?: (find: Find) => void;
|
onFindClick?: (find: Find) => void;
|
||||||
@@ -67,7 +65,6 @@
|
|||||||
center,
|
center,
|
||||||
zoom,
|
zoom,
|
||||||
class: className = '',
|
class: className = '',
|
||||||
showLocationButton = true,
|
|
||||||
autoCenter = true,
|
autoCenter = true,
|
||||||
finds = [],
|
finds = [],
|
||||||
onFindClick
|
onFindClick
|
||||||
@@ -90,8 +87,10 @@
|
|||||||
const mapReady = $derived(mapLoaded && styleLoaded && isIdle);
|
const mapReady = $derived(mapLoaded && styleLoaded && isIdle);
|
||||||
|
|
||||||
// Reactive center and zoom based on location or props
|
// 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(
|
const mapCenter = $derived(
|
||||||
$coordinates && (autoCenter || $shouldZoomToLocation)
|
$coordinates && $shouldZoomToLocation
|
||||||
? ([$coordinates.longitude, $coordinates.latitude] as [number, number])
|
? ([$coordinates.longitude, $coordinates.latitude] as [number, number])
|
||||||
: center || $getMapCenter
|
: center || $getMapCenter
|
||||||
);
|
);
|
||||||
@@ -101,9 +100,7 @@
|
|||||||
// Force zoom to calculated level when location button is clicked
|
// Force zoom to calculated level when location button is clicked
|
||||||
return $getMapZoom;
|
return $getMapZoom;
|
||||||
}
|
}
|
||||||
if ($coordinates && autoCenter) {
|
// Don't auto-zoom on coordinate updates, keep current zoom
|
||||||
return $getMapZoom;
|
|
||||||
}
|
|
||||||
return zoom || 13;
|
return zoom || 13;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,12 +176,6 @@
|
|||||||
</Marker>
|
</Marker>
|
||||||
{/each}
|
{/each}
|
||||||
</MapLibre>
|
</MapLibre>
|
||||||
|
|
||||||
{#if showLocationButton}
|
|
||||||
<div class="location-controls">
|
|
||||||
<LocationButton variant="icon" size="medium" showLabel={false} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -219,17 +210,9 @@
|
|||||||
|
|
||||||
.map-container :global(.maplibregl-map) {
|
.map-container :global(.maplibregl-map) {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-controls {
|
|
||||||
position: absolute;
|
|
||||||
top: 12px;
|
|
||||||
right: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Location marker styles */
|
/* Location marker styles */
|
||||||
:global(.location-marker) {
|
:global(.location-marker) {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
3
src/lib/components/map/index.ts
Normal file
3
src/lib/components/map/index.ts
Normal 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';
|
||||||
1
src/lib/components/media/index.ts
Normal file
1
src/lib/components/media/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as VideoPlayer } from './VideoPlayer.svelte';
|
||||||
613
src/lib/components/notifications/NotificationSettings.svelte
Normal file
613
src/lib/components/notifications/NotificationSettings.svelte
Normal 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>
|
||||||
3
src/lib/components/notifications/index.ts
Normal file
3
src/lib/components/notifications/index.ts
Normal 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';
|
||||||
@@ -7,11 +7,11 @@
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from './dropdown-menu';
|
} from '../dropdown-menu';
|
||||||
import { Skeleton } from './skeleton';
|
import { Skeleton } from '../skeleton';
|
||||||
import ProfilePicture from './ProfilePicture.svelte';
|
import ProfilePicture from './ProfilePicture.svelte';
|
||||||
import ProfilePictureSheet from './ProfilePictureSheet.svelte';
|
import ProfilePictureSheet from './ProfilePictureSheet.svelte';
|
||||||
import NotificationSettingsSheet from './NotificationSettingsSheet.svelte';
|
import NotificationSettings from '../notifications/NotificationSettings.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
let { username, id, profilePictureUrl, loading = false }: Props = $props();
|
let { username, id, profilePictureUrl, loading = false }: Props = $props();
|
||||||
|
|
||||||
let showProfilePictureSheet = $state(false);
|
let showProfilePictureSheet = $state(false);
|
||||||
let showNotificationSettingsSheet = $state(false);
|
let showNotificationSettings = $state(false);
|
||||||
|
|
||||||
function openProfilePictureSheet() {
|
function openProfilePictureSheet() {
|
||||||
showProfilePictureSheet = true;
|
showProfilePictureSheet = true;
|
||||||
@@ -33,12 +33,12 @@
|
|||||||
showProfilePictureSheet = false;
|
showProfilePictureSheet = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNotificationSettingsSheet() {
|
function openNotificationSettings() {
|
||||||
showNotificationSettingsSheet = true;
|
showNotificationSettings = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeNotificationSettingsSheet() {
|
function closeNotificationSettings() {
|
||||||
showNotificationSettingsSheet = false;
|
showNotificationSettings = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
<a href={resolveRoute('/friends')} class="friends-link">Friends</a>
|
<a href={resolveRoute('/friends')} class="friends-link">Friends</a>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem class="notification-settings-item" onclick={openNotificationSettingsSheet}>
|
<DropdownMenuItem class="notification-settings-item" onclick={openNotificationSettings}>
|
||||||
Notifications
|
Notifications
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
@@ -121,8 +121,8 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showNotificationSettingsSheet}
|
{#if showNotificationSettings}
|
||||||
<NotificationSettingsSheet onClose={closeNotificationSettingsSheet} />
|
<NotificationSettings onClose={closeNotificationSettings} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from './avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '../avatar';
|
||||||
import { cn } from '$lib/utils';
|
import { cn } from '$lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './sheet';
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '../sheet';
|
||||||
import { Button } from './button';
|
import { Button } from '../button';
|
||||||
import ProfilePicture from './ProfilePicture.svelte';
|
import ProfilePicture from './ProfilePicture.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
3
src/lib/components/profile/index.ts
Normal file
3
src/lib/components/profile/index.ts
Normal 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';
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
import { tv, type VariantProps } from 'tailwind-variants';
|
import { tv, type VariantProps } from 'tailwind-variants';
|
||||||
export const sheetVariants = tv({
|
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: {
|
variants: {
|
||||||
side: {
|
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',
|
top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
bind:ref
|
bind:ref
|
||||||
data-slot="sheet-overlay"
|
data-slot="sheet-overlay"
|
||||||
class={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -2,20 +2,18 @@
|
|||||||
export { default as Input } from './components/Input.svelte';
|
export { default as Input } from './components/Input.svelte';
|
||||||
export { default as Button } from './components/Button.svelte';
|
export { default as Button } from './components/Button.svelte';
|
||||||
export { default as ErrorMessage } from './components/ErrorMessage.svelte';
|
export { default as ErrorMessage } from './components/ErrorMessage.svelte';
|
||||||
export { default as ProfilePanel } from './components/ProfilePanel.svelte';
|
export { default as ProfilePanel } from './components/profile/ProfilePanel.svelte';
|
||||||
export { default as ProfilePicture } from './components/ProfilePicture.svelte';
|
export { default as ProfilePicture } from './components/profile/ProfilePicture.svelte';
|
||||||
export { default as ProfilePictureSheet } from './components/ProfilePictureSheet.svelte';
|
export { default as ProfilePictureSheet } from './components/profile/ProfilePictureSheet.svelte';
|
||||||
export { default as Header } from './components/Header.svelte';
|
export { default as Header } from './components/Header.svelte';
|
||||||
export { default as Modal } from './components/Modal.svelte';
|
export { default as Modal } from './components/Modal.svelte';
|
||||||
export { default as Map } from './components/Map.svelte';
|
export { default as Map } from './components/map/Map.svelte';
|
||||||
export { default as LocationButton } from './components/LocationButton.svelte';
|
export { default as LocationManager } from './components/map/LocationManager.svelte';
|
||||||
export { default as LocationManager } from './components/LocationManager.svelte';
|
export { default as NotificationManager } from './components/notifications/NotificationManager.svelte';
|
||||||
export { default as NotificationManager } from './components/NotificationManager.svelte';
|
export { default as NotificationPrompt } from './components/notifications/NotificationPrompt.svelte';
|
||||||
export { default as NotificationPrompt } from './components/NotificationPrompt.svelte';
|
export { default as NotificationSettings } from './components/notifications/NotificationSettings.svelte';
|
||||||
export { default as NotificationSettings } from './components/NotificationSettings.svelte';
|
export { default as FindCard } from './components/finds/FindCard.svelte';
|
||||||
export { default as NotificationSettingsSheet } from './components/NotificationSettingsSheet.svelte';
|
export { default as FindsList } from './components/finds/FindsList.svelte';
|
||||||
export { default as FindCard } from './components/FindCard.svelte';
|
|
||||||
export { default as FindsList } from './components/FindsList.svelte';
|
|
||||||
|
|
||||||
// Skeleton Loading Components
|
// Skeleton Loading Components
|
||||||
export { Skeleton, SkeletonVariants } from './components/skeleton';
|
export { Skeleton, SkeletonVariants } from './components/skeleton';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { sha256 } from '@oslojs/crypto/sha2';
|
|||||||
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
|
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import * as table from '$lib/server/db/schema';
|
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;
|
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));
|
.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;
|
let profilePictureUrl = user.profilePictureUrl;
|
||||||
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||||
// It's a path, generate signed URL
|
// It's a path, generate local proxy URL
|
||||||
try {
|
profilePictureUrl = getLocalR2Url(profilePictureUrl);
|
||||||
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60); // 24 hours
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to generate signed URL for profile picture:', error);
|
|
||||||
profilePictureUrl = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -72,3 +72,7 @@ export async function getSignedR2Url(path: string, expiresIn = 3600): Promise<st
|
|||||||
|
|
||||||
return await getSignedUrl(r2Client, command, { expiresIn });
|
return await getSignedUrl(r2Client, command, { expiresIn });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLocalR2Url(path: string): string {
|
||||||
|
return `/api/media/${path}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { Toaster } from '$lib/components/sonner/index.js';
|
import { Toaster } from '$lib/components/sonner/index.js';
|
||||||
import { Skeleton } from '$lib/components/skeleton';
|
import { Skeleton } from '$lib/components/skeleton';
|
||||||
import LocationManager from '$lib/components/LocationManager.svelte';
|
import LocationManager from '$lib/components/map/LocationManager.svelte';
|
||||||
import NotificationManager from '$lib/components/NotificationManager.svelte';
|
import NotificationManager from '$lib/components/notifications/NotificationManager.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
@@ -64,12 +64,12 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.header-skeleton {
|
.header-skeleton {
|
||||||
border-bottom: 1px solid #e5e7eb;
|
|
||||||
background: white;
|
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content {
|
.header-content {
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, url, fetch, request }) => {
|
export const load: PageServerLoad = async ({ locals, url, fetch, request }) => {
|
||||||
if (!locals.user) {
|
|
||||||
return redirect(302, '/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build API URL with query parameters
|
// Build API URL with query parameters
|
||||||
const apiUrl = new URL('/api/finds', url.origin);
|
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 (lat) apiUrl.searchParams.set('lat', lat);
|
||||||
if (lng) apiUrl.searchParams.set('lng', lng);
|
if (lng) apiUrl.searchParams.set('lng', lng);
|
||||||
apiUrl.searchParams.set('radius', radius);
|
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
|
apiUrl.searchParams.set('order', 'desc'); // Newest first
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Map } from '$lib';
|
import { Map } from '$lib';
|
||||||
import FindsList from '$lib/components/FindsList.svelte';
|
import FindsList from '$lib/components/finds/FindsList.svelte';
|
||||||
import CreateFindModal from '$lib/components/CreateFindModal.svelte';
|
import CreateFindModal from '$lib/components/finds/CreateFindModal.svelte';
|
||||||
import FindPreview from '$lib/components/FindPreview.svelte';
|
import FindPreview from '$lib/components/finds/FindPreview.svelte';
|
||||||
import FindsFilter from '$lib/components/FindsFilter.svelte';
|
import FindsFilter from '$lib/components/finds/FindsFilter.svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { coordinates } from '$lib/stores/location';
|
import { coordinates } from '$lib/stores/location';
|
||||||
import { Button } from '$lib/components/button';
|
import { Button } from '$lib/components/button';
|
||||||
@@ -94,6 +94,7 @@
|
|||||||
let showCreateModal = $state(false);
|
let showCreateModal = $state(false);
|
||||||
let selectedFind: FindPreviewData | null = $state(null);
|
let selectedFind: FindPreviewData | null = $state(null);
|
||||||
let currentFilter = $state('all');
|
let currentFilter = $state('all');
|
||||||
|
let isSidebarVisible = $state(true);
|
||||||
|
|
||||||
// Initialize API sync with server data on mount
|
// Initialize API sync with server data on mount
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@@ -215,6 +216,10 @@
|
|||||||
function closeCreateModal() {
|
function closeCreateModal() {
|
||||||
showCreateModal = false;
|
showCreateModal = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
isSidebarVisible = !isSidebarVisible;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -238,24 +243,23 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="home-container">
|
<div class="home-container">
|
||||||
<main class="main-content">
|
<!-- Fullscreen map -->
|
||||||
<div class="map-section">
|
<div class="map-section">
|
||||||
<Map
|
<Map
|
||||||
showLocationButton={true}
|
autoCenter={true}
|
||||||
autoCenter={true}
|
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
|
||||||
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
|
{finds}
|
||||||
{finds}
|
onFindClick={handleFindClick}
|
||||||
onFindClick={handleFindClick}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="finds-section">
|
<!-- Sidebar container -->
|
||||||
<div class="finds-sticky-header">
|
<div class="sidebar-container">
|
||||||
<div class="finds-header-content">
|
<!-- Left sidebar with finds list -->
|
||||||
<div class="finds-title-section">
|
<div class="finds-sidebar" class:hidden={!isSidebarVisible}>
|
||||||
<h2 class="finds-title">Finds</h2>
|
<div class="finds-header">
|
||||||
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
|
{#if data.user}
|
||||||
</div>
|
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
|
||||||
<Button onclick={openCreateModal} class="create-find-button">
|
<Button onclick={openCreateModal} class="create-find-button">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
|
||||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
|
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
|
||||||
@@ -263,19 +267,34 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Create Find
|
Create Find
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
<FindsList {finds} onFindExplore={handleFindExplore} hideTitle={true} />
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
<!-- Toggle button -->
|
||||||
|
<button
|
||||||
<!-- Floating action button for mobile -->
|
class="sidebar-toggle"
|
||||||
<button class="fab" onclick={openCreateModal} aria-label="Create new find">
|
class:collapsed={!isSidebarVisible}
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
onclick={toggleSidebar}
|
||||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
|
aria-label="Toggle finds list"
|
||||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
|
>
|
||||||
</svg>
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
</button>
|
{#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>
|
</div>
|
||||||
|
|
||||||
<!-- Modals -->
|
<!-- Modals -->
|
||||||
@@ -293,145 +312,193 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.home-container {
|
.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;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finds-sticky-header {
|
.map-section {
|
||||||
position: sticky;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 50;
|
left: 0;
|
||||||
background: white;
|
width: 100vw;
|
||||||
border-bottom: 1px solid hsl(var(--border));
|
height: 100vh;
|
||||||
padding: 24px 24px 16px 24px;
|
z-index: 0;
|
||||||
border-radius: 12px 12px 0 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;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
padding: 20px;
|
||||||
gap: 1rem;
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finds-title-section {
|
.login-prompt {
|
||||||
display: flex;
|
width: 100%;
|
||||||
align-items: center;
|
text-align: center;
|
||||||
gap: 1rem;
|
padding: 1rem;
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.finds-title {
|
.login-prompt h3 {
|
||||||
font-family: 'Washington', serif;
|
font-family: 'Washington', serif;
|
||||||
font-size: 1.875rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 700;
|
margin: 0 0 0.5rem 0;
|
||||||
margin: 0;
|
|
||||||
color: hsl(var(--foreground));
|
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) {
|
:global(.create-find-button) {
|
||||||
flex-shrink: 0;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.main-content {
|
.sidebar-container {
|
||||||
padding: 16px;
|
position: fixed;
|
||||||
gap: 16px;
|
bottom: 0;
|
||||||
}
|
left: 0;
|
||||||
|
right: 0;
|
||||||
.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 {
|
|
||||||
display: flex;
|
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) {
|
.map-section :global(.map-container) {
|
||||||
height: 300px;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.main-content {
|
.finds-sidebar {
|
||||||
padding: 12px;
|
height: 60vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finds-sticky-header {
|
.finds-header {
|
||||||
padding: 12px 12px 8px 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { db } from '$lib/server/db';
|
|||||||
import { find, findMedia, user, findLike, friendship } from '$lib/server/db/schema';
|
import { find, findMedia, user, findLike, friendship } from '$lib/server/db/schema';
|
||||||
import { eq, and, sql, desc, or } from 'drizzle-orm';
|
import { eq, and, sql, desc, or } from 'drizzle-orm';
|
||||||
import { encodeBase64url } from '@oslojs/encoding';
|
import { encodeBase64url } from '@oslojs/encoding';
|
||||||
import { getSignedR2Url } from '$lib/server/r2';
|
import { getLocalR2Url } from '$lib/server/r2';
|
||||||
|
|
||||||
function generateFindId(): string {
|
function generateFindId(): string {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
||||||
@@ -12,10 +12,6 @@ function generateFindId(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||||
if (!locals.user) {
|
|
||||||
throw error(401, 'Unauthorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
const lat = url.searchParams.get('lat');
|
const lat = url.searchParams.get('lat');
|
||||||
const lng = url.searchParams.get('lng');
|
const lng = url.searchParams.get('lng');
|
||||||
const radius = url.searchParams.get('radius') || '50';
|
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';
|
const includeFriends = url.searchParams.get('includeFriends') === 'true';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get user's friends if needed
|
// Get user's friends if needed and user is logged in
|
||||||
let friendIds: string[] = [];
|
let friendIds: string[] = [];
|
||||||
if (includeFriends || includePrivate) {
|
if (locals.user && (includeFriends || includePrivate)) {
|
||||||
const friendships = await db
|
const friendships = await db
|
||||||
.select({
|
.select({
|
||||||
userId: friendship.userId,
|
userId: friendship.userId,
|
||||||
@@ -37,7 +33,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(friendship.status, 'accepted'),
|
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
|
// Build privacy conditions
|
||||||
const conditions = [sql`${find.isPublic} = 1`]; // Always include public finds
|
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)
|
// 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)
|
// Include friends' finds (both public and private)
|
||||||
conditions.push(
|
conditions.push(
|
||||||
sql`${find.userId} IN (${sql.join(
|
sql`${find.userId} IN (${sql.join(
|
||||||
@@ -103,19 +99,23 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
profilePictureUrl: user.profilePictureUrl,
|
profilePictureUrl: user.profilePictureUrl,
|
||||||
likeCount: sql<number>`COALESCE(COUNT(DISTINCT ${findLike.id}), 0)`,
|
likeCount: sql<number>`COALESCE(COUNT(DISTINCT ${findLike.id}), 0)`,
|
||||||
isLikedByUser: sql<boolean>`CASE WHEN EXISTS(
|
isLikedByUser: locals.user
|
||||||
SELECT 1 FROM ${findLike}
|
? sql<boolean>`CASE WHEN EXISTS(
|
||||||
WHERE ${findLike.findId} = ${find.id}
|
SELECT 1 FROM ${findLike}
|
||||||
AND ${findLike.userId} = ${locals.user.id}
|
WHERE ${findLike.findId} = ${find.id}
|
||||||
) THEN 1 ELSE 0 END`,
|
AND ${findLike.userId} = ${locals.user.id}
|
||||||
isFromFriend: sql<boolean>`CASE WHEN ${
|
) THEN 1 ELSE 0 END`
|
||||||
friendIds.length > 0
|
: sql<boolean>`0`,
|
||||||
? sql`${find.userId} IN (${sql.join(
|
isFromFriend: locals.user
|
||||||
friendIds.map((id) => sql`${id}`),
|
? sql<boolean>`CASE WHEN ${
|
||||||
sql`, `
|
friendIds.length > 0
|
||||||
)})`
|
? sql`${find.userId} IN (${sql.join(
|
||||||
: sql`FALSE`
|
friendIds.map((id) => sql`${id}`),
|
||||||
} THEN 1 ELSE 0 END`
|
sql`, `
|
||||||
|
)})`
|
||||||
|
: sql`FALSE`
|
||||||
|
} THEN 1 ELSE 0 END`
|
||||||
|
: sql<boolean>`0`
|
||||||
})
|
})
|
||||||
.from(find)
|
.from(find)
|
||||||
.innerJoin(user, eq(find.userId, user.id))
|
.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
|
// Generate signed URLs for all media items
|
||||||
const mediaWithSignedUrls = await Promise.all(
|
const mediaWithSignedUrls = await Promise.all(
|
||||||
findMedia.map(async (mediaItem) => {
|
findMedia.map(async (mediaItem) => {
|
||||||
// URLs in database are now paths, generate signed URLs directly
|
// URLs in database are now paths, generate local proxy URLs
|
||||||
const [signedUrl, signedThumbnailUrl] = await Promise.all([
|
const localUrl = getLocalR2Url(mediaItem.url);
|
||||||
getSignedR2Url(mediaItem.url, 24 * 60 * 60), // 24 hours
|
const localThumbnailUrl =
|
||||||
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
|
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
|
||||||
? getSignedR2Url(mediaItem.thumbnailUrl, 24 * 60 * 60)
|
? getLocalR2Url(mediaItem.thumbnailUrl)
|
||||||
: Promise.resolve(mediaItem.thumbnailUrl) // Keep static placeholder paths as-is
|
: mediaItem.thumbnailUrl; // Keep static placeholder paths as-is
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...mediaItem,
|
...mediaItem,
|
||||||
url: signedUrl,
|
url: localUrl,
|
||||||
thumbnailUrl: signedThumbnailUrl
|
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;
|
let userProfilePictureUrl = findItem.profilePictureUrl;
|
||||||
if (userProfilePictureUrl && !userProfilePictureUrl.startsWith('http')) {
|
if (userProfilePictureUrl && !userProfilePictureUrl.startsWith('http')) {
|
||||||
try {
|
userProfilePictureUrl = getLocalR2Url(userProfilePictureUrl);
|
||||||
userProfilePictureUrl = await getSignedR2Url(userProfilePictureUrl, 24 * 60 * 60);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to generate signed URL for user profile picture:', error);
|
|
||||||
userProfilePictureUrl = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
115
src/routes/api/finds/[findId]/+server.ts
Normal file
115
src/routes/api/finds/[findId]/+server.ts
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -4,7 +4,7 @@ import { db } from '$lib/server/db';
|
|||||||
import { friendship, user } from '$lib/server/db/schema';
|
import { friendship, user } from '$lib/server/db/schema';
|
||||||
import { eq, and, or } from 'drizzle-orm';
|
import { eq, and, or } from 'drizzle-orm';
|
||||||
import { encodeBase64url } from '@oslojs/encoding';
|
import { encodeBase64url } from '@oslojs/encoding';
|
||||||
import { getSignedR2Url } from '$lib/server/r2';
|
import { getLocalR2Url } from '$lib/server/r2';
|
||||||
import { notificationService } from '$lib/server/notifications';
|
import { notificationService } from '$lib/server/notifications';
|
||||||
import { pushService } from '$lib/server/push';
|
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)));
|
.where(and(eq(friendship.friendId, locals.user.id), eq(friendship.status, status)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate signed URLs for profile pictures
|
// Generate local proxy URLs for profile pictures
|
||||||
const friendshipsWithSignedUrls = await Promise.all(
|
const friendshipsWithSignedUrls = (friendships || []).map((friendship) => {
|
||||||
(friendships || []).map(async (friendship) => {
|
let profilePictureUrl = friendship.friendProfilePictureUrl;
|
||||||
let profilePictureUrl = friendship.friendProfilePictureUrl;
|
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||||
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
profilePictureUrl = getLocalR2Url(profilePictureUrl);
|
||||||
try {
|
}
|
||||||
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to generate signed URL for profile picture:', error);
|
|
||||||
profilePictureUrl = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...friendship,
|
...friendship,
|
||||||
friendProfilePictureUrl: profilePictureUrl
|
friendProfilePictureUrl: profilePictureUrl
|
||||||
};
|
};
|
||||||
})
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return json(friendshipsWithSignedUrls);
|
return json(friendshipsWithSignedUrls);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
63
src/routes/api/media/[...path]/+server.ts
Normal file
63
src/routes/api/media/[...path]/+server.ts
Normal 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 });
|
||||||
|
};
|
||||||
@@ -3,7 +3,7 @@ import type { RequestHandler } from './$types';
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { user, friendship } from '$lib/server/db/schema';
|
import { user, friendship } from '$lib/server/db/schema';
|
||||||
import { eq, and, or, ilike, ne } from 'drizzle-orm';
|
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 }) => {
|
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||||
if (!locals.user) {
|
if (!locals.user) {
|
||||||
@@ -63,28 +63,21 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
friendshipStatusMap.set(otherUserId, f.status);
|
friendshipStatusMap.set(otherUserId, f.status);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate signed URLs and add friendship status
|
// Generate local proxy URLs and add friendship status
|
||||||
const usersWithSignedUrls = await Promise.all(
|
const usersWithSignedUrls = users.map((userItem) => {
|
||||||
users.map(async (userItem) => {
|
let profilePictureUrl = userItem.profilePictureUrl;
|
||||||
let profilePictureUrl = userItem.profilePictureUrl;
|
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
||||||
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
|
profilePictureUrl = getLocalR2Url(profilePictureUrl);
|
||||||
try {
|
}
|
||||||
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to generate signed URL for profile picture:', error);
|
|
||||||
profilePictureUrl = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const friendshipStatus = friendshipStatusMap.get(userItem.id) || 'none';
|
const friendshipStatus = friendshipStatusMap.get(userItem.id) || 'none';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...userItem,
|
...userItem,
|
||||||
profilePictureUrl,
|
profilePictureUrl,
|
||||||
friendshipStatus
|
friendshipStatus
|
||||||
};
|
};
|
||||||
})
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return json(usersWithSignedUrls);
|
return json(usersWithSignedUrls);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
43
src/routes/finds/[findId]/+page.server.ts
Normal file
43
src/routes/finds/[findId]/+page.server.ts
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
780
src/routes/finds/[findId]/+page.svelte
Normal file
780
src/routes/finds/[findId]/+page.svelte
Normal 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>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/card';
|
||||||
import { Button } from '$lib/components/button';
|
import { Button } from '$lib/components/button';
|
||||||
import { Input } from '$lib/components/input';
|
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 { Badge } from '$lib/components/badge';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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';
|
import type { ActionData } from './$types';
|
||||||
|
|
||||||
let { form }: { form: ActionData } = $props();
|
let { form }: { form: ActionData } = $props();
|
||||||
|
|||||||
@@ -130,8 +130,12 @@ self.addEventListener('fetch', (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle R2 resources with cache-first strategy
|
// Handle R2 resources and local media proxy with cache-first strategy
|
||||||
if (url.hostname.includes('.r2.dev') || url.hostname.includes('.r2.cloudflarestorage.com')) {
|
if (
|
||||||
|
url.hostname.includes('.r2.dev') ||
|
||||||
|
url.hostname.includes('.r2.cloudflarestorage.com') ||
|
||||||
|
url.pathname.startsWith('/api/media/')
|
||||||
|
) {
|
||||||
const cachedResponse = await r2Cache.match(event.request);
|
const cachedResponse = await r2Cache.match(event.request);
|
||||||
if (cachedResponse) {
|
if (cachedResponse) {
|
||||||
return cachedResponse;
|
return cachedResponse;
|
||||||
@@ -146,7 +150,7 @@ self.addEventListener('fetch', (event) => {
|
|||||||
return response;
|
return response;
|
||||||
} catch {
|
} catch {
|
||||||
// Return cached version if available, or fall through to other cache checks
|
// 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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user