ui:big ui overhaul
Improved dev experience by foldering components per feature. Improved the ui of the notification mangager to be more consistent with overall ui.
This commit is contained in:
@@ -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';
|
||||||
|
|
||||||
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';
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
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 {
|
||||||
@@ -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 {
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
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';
|
||||||
|
|
||||||
interface Find {
|
interface Find {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -274,10 +274,10 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
top: 80px;
|
top: 80px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
width: 40%;
|
width: fit-content;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
min-width: 500px;
|
min-width: 400px;
|
||||||
height: calc(100vh - 100px);
|
max-height: calc(100vh - 100px);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
@@ -424,63 +424,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 0;
|
|
||||||
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) {
|
|
||||||
.modal-body {
|
|
||||||
height: calc(100vh - 180px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-container {
|
|
||||||
flex: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-section {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 160px;
|
|
||||||
max-height: 300px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile media viewer - maximize available space */
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.modal-body {
|
|
||||||
height: calc(90vh - 180px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-container {
|
|
||||||
flex: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-section {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 140px;
|
|
||||||
max-height: 250px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-image {
|
.media-image {
|
||||||
@@ -551,7 +516,6 @@
|
|||||||
|
|
||||||
.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);
|
background: rgba(255, 255, 255, 0.5);
|
||||||
@@ -619,28 +583,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.comments-section {
|
.comments-section {
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: rgba(255, 255, 255, 0.5);
|
background: rgba(255, 255, 255, 0.5);
|
||||||
}
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
/* Desktop comments section */
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.comments-section {
|
|
||||||
height: calc(100vh - 500px);
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile comments section */
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.comments-section {
|
|
||||||
height: calc(90vh - 450px);
|
|
||||||
min-height: 150px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile specific adjustments */
|
/* Mobile specific adjustments */
|
||||||
@@ -665,18 +613,5 @@
|
|||||||
.content-section {
|
.content-section {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comments-section {
|
|
||||||
height: calc(90vh - 480px);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</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';
|
||||||
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';
|
||||||
@@ -2,19 +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 LocationManager } from './components/LocationManager.svelte';
|
export { default as LocationManager } from './components/map/LocationManager.svelte';
|
||||||
export { default as NotificationManager } from './components/NotificationManager.svelte';
|
export { default as NotificationManager } from './components/notifications/NotificationManager.svelte';
|
||||||
export { default as NotificationPrompt } from './components/NotificationPrompt.svelte';
|
export { default as NotificationPrompt } from './components/notifications/NotificationPrompt.svelte';
|
||||||
export { default as NotificationSettings } from './components/NotificationSettings.svelte';
|
export { default as NotificationSettings } from './components/notifications/NotificationSettings.svelte';
|
||||||
export { default as NotificationSettingsSheet } from './components/NotificationSettingsSheet.svelte';
|
export { default as FindCard } from './components/finds/FindCard.svelte';
|
||||||
export { default as FindCard } from './components/FindCard.svelte';
|
export { default as FindsList } from './components/finds/FindsList.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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -178,9 +178,10 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
findMedia.map(async (mediaItem) => {
|
findMedia.map(async (mediaItem) => {
|
||||||
// URLs in database are now paths, generate local proxy URLs
|
// URLs in database are now paths, generate local proxy URLs
|
||||||
const localUrl = getLocalR2Url(mediaItem.url);
|
const localUrl = getLocalR2Url(mediaItem.url);
|
||||||
const localThumbnailUrl = mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
|
const localThumbnailUrl =
|
||||||
? getLocalR2Url(mediaItem.thumbnailUrl)
|
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
|
||||||
: mediaItem.thumbnailUrl; // Keep static placeholder paths as-is
|
? getLocalR2Url(mediaItem.thumbnailUrl)
|
||||||
|
: mediaItem.thumbnailUrl; // Keep static placeholder paths as-is
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...mediaItem,
|
...mediaItem,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -131,7 +131,11 @@ self.addEventListener('fetch', (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle R2 resources and local media proxy 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') || url.pathname.startsWith('/api/media/')) {
|
if (
|
||||||
|
url.hostname.includes('.r2.dev') ||
|
||||||
|
url.hostname.includes('.r2.cloudflarestorage.com') ||
|
||||||
|
url.pathname.startsWith('/api/media/')
|
||||||
|
) {
|
||||||
const cachedResponse = await r2Cache.match(event.request);
|
const cachedResponse = await r2Cache.match(event.request);
|
||||||
if (cachedResponse) {
|
if (cachedResponse) {
|
||||||
return cachedResponse;
|
return cachedResponse;
|
||||||
|
|||||||
Reference in New Issue
Block a user