3 Commits

Author SHA1 Message Date
d7fe9091ce fix:new child subscription
this fix makes sure that when the client initializes a new child that
needs to subscribe for updates, it first checks whether this item
already has a currrent state and uses this, so that a new child always
starts in sync with the global state.
2025-10-29 00:14:26 +01:00
6620cc6078 feat:implement a sync-service
this new feature make sure that serverside actions are all in sync with
client. when a client is multiple times subscribed to the same server
side action it needs to be synced on all children. this implementation
gives access to one client synced status that will handle all api
actions in a queue.
2025-10-29 00:09:01 +01:00
3b3ebc2873 fix:continuously watch location 2025-10-27 15:07:24 +01:00
9 changed files with 860 additions and 66 deletions

View File

@@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/button'; import { Button } from '$lib/components/button';
import { Heart } from '@lucide/svelte'; import { Heart } from '@lucide/svelte';
import { toast } from 'svelte-sonner'; import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { writable } from 'svelte/store';
interface Props { interface Props {
findId: string; findId: string;
@@ -19,59 +21,75 @@
class: className = '' class: className = ''
}: Props = $props(); }: Props = $props();
// Track the source of truth - server state // Local state stores for this like button - start with props but will be overridden by global state
let serverIsLiked = $state(isLiked); const likeState = writable({
let serverLikeCount = $state(likeCount); isLiked: isLiked,
likeCount: likeCount,
isLoading: false
});
// Track optimistic state during loading let apiSync: any = null;
let isLoading = $state(false);
let optimisticIsLiked = $state(isLiked);
let optimisticLikeCount = $state(likeCount);
// Derived state for display // Initialize API sync and subscribe to global state
let displayIsLiked = $derived(isLoading ? optimisticIsLiked : serverIsLiked); onMount(async () => {
let displayLikeCount = $derived(isLoading ? optimisticLikeCount : serverLikeCount); if (browser) {
try {
// Dynamically import the API sync
const module = await import('$lib/stores/api-sync');
apiSync = module.apiSync;
// Check if global state already exists for this find
const existingState = apiSync.getEntityState('find', findId);
if (existingState) {
// Use existing global state - it's more current than props
console.log(`Using existing global state for find ${findId}`, existingState);
} else {
// Initialize with minimal data only if no global state exists
console.log(`Initializing new minimal state for find ${findId}`);
apiSync.setEntityState('find', findId, {
id: findId,
isLikedByUser: isLiked,
likeCount: likeCount,
// Minimal data needed for like functionality
title: '',
latitude: '',
longitude: '',
isPublic: true,
createdAt: new Date(),
userId: '',
username: '',
isFromFriend: false
});
}
// Subscribe to global state for this find
const globalLikeState = apiSync.subscribeFindLikes(findId);
globalLikeState.subscribe((state: any) => {
likeState.set({
isLiked: state.isLiked,
likeCount: state.likeCount,
isLoading: state.isLoading
});
});
} catch (error) {
console.error('Failed to initialize API sync:', error);
}
}
});
async function toggleLike() { async function toggleLike() {
if (isLoading) return; if (!apiSync || !browser) return;
// Set optimistic state const currentState = likeState;
optimisticIsLiked = !serverIsLiked; if (currentState && (currentState as any).isLoading) return;
optimisticLikeCount = serverLikeCount + (optimisticIsLiked ? 1 : -1);
isLoading = true;
try { try {
const method = optimisticIsLiked ? 'POST' : 'DELETE'; await apiSync.toggleLike(findId);
const response = await fetch(`/api/finds/${findId}/like`, { } catch (error) {
method, console.error('Failed to toggle like:', error);
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP ${response.status}`);
}
const result = await response.json();
// Update server state with response
serverIsLiked = result.isLiked;
serverLikeCount = result.likeCount;
} catch (error: unknown) {
console.error('Error updating like:', error);
toast.error('Failed to update like. Please try again.');
} finally {
isLoading = false;
} }
} }
// Update server state when props change (from parent component)
$effect(() => {
serverIsLiked = isLiked;
serverLikeCount = likeCount;
});
</script> </script>
<Button <Button
@@ -79,22 +97,22 @@
{size} {size}
class="group gap-1.5 {className}" class="group gap-1.5 {className}"
onclick={toggleLike} onclick={toggleLike}
disabled={isLoading} disabled={$likeState.isLoading}
> >
<Heart <Heart
class="h-4 w-4 transition-all duration-200 {displayIsLiked class="h-4 w-4 transition-all duration-200 {$likeState.isLiked
? 'scale-110 fill-red-500 text-red-500' ? 'scale-110 fill-red-500 text-red-500'
: 'text-gray-500 group-hover:scale-105 group-hover:text-red-400'} {isLoading : 'text-gray-500 group-hover:scale-105 group-hover:text-red-400'} {$likeState.isLoading
? 'animate-pulse' ? 'animate-pulse'
: ''}" : ''}"
/> />
{#if displayLikeCount > 0} {#if $likeState.likeCount > 0}
<span <span
class="text-sm font-medium transition-colors {displayIsLiked class="text-sm font-medium transition-colors {$likeState.isLiked
? 'text-red-500' ? 'text-red-500'
: 'text-gray-500 group-hover:text-red-400'}" : 'text-gray-500 group-hover:text-red-400'}"
> >
{displayLikeCount} {$likeState.likeCount}
</span> </span>
{/if} {/if}
</Button> </Button>

View File

@@ -4,7 +4,8 @@
locationActions, locationActions,
locationStatus, locationStatus,
locationError, locationError,
isLocationLoading isLocationLoading,
isWatching
} from '$lib/stores/location'; } from '$lib/stores/location';
import { Skeleton } from './skeleton'; import { Skeleton } from './skeleton';
@@ -22,26 +23,45 @@
showLabel = true showLabel = true
}: Props = $props(); }: Props = $props();
// Track if location watching is active from the derived store
async function handleLocationClick() { 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({ const result = await locationActions.getCurrentLocation({
enableHighAccuracy: true, enableHighAccuracy: true,
timeout: 15000, timeout: 15000,
maximumAge: 300000 maximumAge: 300000
}); });
if (!result && $locationError) { 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); toast.error($locationError.message);
} }
} }
}
const buttonText = $derived(() => { const buttonText = $derived(() => {
if ($isLocationLoading) return 'Finding location...'; if ($isLocationLoading) return 'Finding location...';
if ($locationStatus === 'success') return 'Update location'; if ($isWatching) return 'Stop watching location';
if ($locationStatus === 'success') return 'Watch location';
return 'Find my location'; return 'Find my location';
}); });
const iconClass = $derived(() => { const iconClass = $derived(() => {
if ($isLocationLoading) return 'loading'; if ($isLocationLoading) return 'loading';
if ($isWatching) return 'watching';
if ($locationStatus === 'success') return 'success'; if ($locationStatus === 'success') return 'success';
if ($locationStatus === 'error') return 'error'; if ($locationStatus === 'error') return 'error';
return 'default'; return 'default';
@@ -59,6 +79,22 @@
<div class="loading-skeleton"> <div class="loading-skeleton">
<Skeleton class="h-5 w-5 rounded-full" /> <Skeleton class="h-5 w-5 rounded-full" />
</div> </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'} {:else if $locationStatus === 'success'}
<svg viewBox="0 0 24 24"> <svg viewBox="0 0 24 24">
<path <path
@@ -201,6 +237,11 @@
fill: #10b981; fill: #10b981;
} }
.icon.watching svg {
color: #f59e0b;
fill: #f59e0b;
}
.icon.error svg { .icon.error svg {
color: #ef4444; color: #ef4444;
fill: #ef4444; fill: #ef4444;
@@ -219,6 +260,25 @@
} }
} }
.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 { .label {
white-space: nowrap; white-space: nowrap;
} }

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { locationActions, isWatching, coordinates } from '$lib/stores/location';
interface Props {
autoStart?: boolean;
enableHighAccuracy?: boolean;
timeout?: number;
maximumAge?: number;
}
let {
autoStart = true,
enableHighAccuracy = true,
timeout = 15000,
maximumAge = 60000
}: Props = $props();
// Location watching options
const watchOptions = {
enableHighAccuracy,
timeout,
maximumAge
};
onMount(() => {
if (!browser || !autoStart) return;
// Check if geolocation is supported
if (!navigator.geolocation) {
console.warn('Geolocation is not supported by this browser');
return;
}
// Check if we already have coordinates and aren't watching
if ($coordinates && !$isWatching) {
// Start watching immediately if we have previous coordinates
startLocationWatching();
return;
}
// If no coordinates, try to get current location first
if (!$coordinates) {
getCurrentLocationThenWatch();
}
});
async function getCurrentLocationThenWatch() {
try {
const result = await locationActions.getCurrentLocation(watchOptions);
if (result) {
// Successfully got location, now start watching
startLocationWatching();
}
} catch {
// If we can't get location due to permissions, don't auto-start watching
console.log('Could not get initial location, location watching not started automatically');
}
}
function startLocationWatching() {
if (!$isWatching) {
locationActions.startWatching(watchOptions);
}
}
// Cleanup function to stop watching when component is destroyed
function cleanup() {
if ($isWatching) {
locationActions.stopWatching();
}
}
// Stop watching when the component is destroyed
onMount(() => {
return cleanup;
});
</script>
<!-- This component doesn't render anything, it just manages location watching -->

View File

@@ -6,7 +6,8 @@
getMapCenter, getMapCenter,
getMapZoom, getMapZoom,
shouldZoomToLocation, shouldZoomToLocation,
locationActions locationActions,
isWatching
} from '$lib/stores/location'; } from '$lib/stores/location';
import LocationButton from './LocationButton.svelte'; import LocationButton from './LocationButton.svelte';
import { Skeleton } from '$lib/components/skeleton'; import { Skeleton } from '$lib/components/skeleton';
@@ -139,11 +140,14 @@
> >
{#if $coordinates} {#if $coordinates}
<Marker lngLat={[$coordinates.longitude, $coordinates.latitude]}> <Marker lngLat={[$coordinates.longitude, $coordinates.latitude]}>
<div class="location-marker"> <div class="location-marker" class:watching={$isWatching}>
<div class="marker-pulse"></div> <div class="marker-pulse" class:watching={$isWatching}></div>
<div class="marker-outer"> <div class="marker-outer" class:watching={$isWatching}>
<div class="marker-inner"></div> <div class="marker-inner" class:watching={$isWatching}></div>
</div> </div>
{#if $isWatching}
<div class="watching-ring"></div>
{/if}
</div> </div>
</Marker> </Marker>
{/if} {/if}
@@ -243,6 +247,13 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.3s ease;
}
:global(.marker-outer.watching) {
background: rgba(245, 158, 11, 0.2);
border-color: #f59e0b;
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.1);
} }
:global(.marker-inner) { :global(.marker-inner) {
@@ -250,6 +261,12 @@
height: 8px; height: 8px;
background: #2563eb; background: #2563eb;
border-radius: 50%; border-radius: 50%;
transition: all 0.3s ease;
}
:global(.marker-inner.watching) {
background: #f59e0b;
animation: pulse-glow 2s infinite;
} }
:global(.marker-pulse) { :global(.marker-pulse) {
@@ -263,6 +280,22 @@
animation: pulse 2s infinite; animation: pulse 2s infinite;
} }
:global(.marker-pulse.watching) {
border-color: rgba(245, 158, 11, 0.6);
animation: pulse-watching 1.5s infinite;
}
:global(.watching-ring) {
position: absolute;
top: -8px;
left: -8px;
width: 36px;
height: 36px;
border: 2px solid rgba(245, 158, 11, 0.4);
border-radius: 50%;
animation: expand-ring 3s infinite;
}
@keyframes pulse { @keyframes pulse {
0% { 0% {
transform: scale(1); transform: scale(1);
@@ -274,6 +307,48 @@
} }
} }
@keyframes pulse-watching {
0% {
transform: scale(1);
opacity: 0.8;
}
50% {
transform: scale(1.5);
opacity: 0.4;
}
100% {
transform: scale(2);
opacity: 0;
}
}
@keyframes pulse-glow {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.2);
}
}
@keyframes expand-ring {
0% {
transform: scale(1);
opacity: 0.6;
}
50% {
transform: scale(1.3);
opacity: 0.3;
}
100% {
transform: scale(1.6);
opacity: 0;
}
}
/* Find marker styles */ /* Find marker styles */
:global(.find-marker) { :global(.find-marker) {
width: 40px; width: 40px;

View File

@@ -8,6 +8,7 @@ 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.svelte';
export { default as LocationButton } from './components/LocationButton.svelte'; export { default as LocationButton } from './components/LocationButton.svelte';
export { default as LocationManager } from './components/LocationManager.svelte';
export { default as FindCard } from './components/FindCard.svelte'; export { default as FindCard } from './components/FindCard.svelte';
export { default as FindsList } from './components/FindsList.svelte'; export { default as FindsList } from './components/FindsList.svelte';
@@ -24,6 +25,7 @@ export {
locationError, locationError,
isLocationLoading, isLocationLoading,
hasLocationAccess, hasLocationAccess,
isWatching,
getMapCenter, getMapCenter,
getMapZoom getMapZoom
} from './stores/location'; } from './stores/location';

493
src/lib/stores/api-sync.ts Normal file
View File

@@ -0,0 +1,493 @@
import { writable, derived, type Readable, type Writable } from 'svelte/store';
import { toast } from 'svelte-sonner';
// Core types for the API sync system
export interface EntityState<T = unknown> {
data: T;
isLoading: boolean;
error: string | null;
lastUpdated: Date;
}
export interface QueuedOperation {
id: string;
entityType: string;
entityId: string;
operation: 'create' | 'update' | 'delete';
action?: string;
data?: unknown;
retry: number;
maxRetries: number;
timestamp: Date;
}
export interface APIResponse<T = unknown> {
success: boolean;
data?: T;
error?: string;
[key: string]: unknown;
}
// Specific entity state types
export interface FindLikeState {
isLiked: boolean;
likeCount: number;
isLoading: boolean;
error: string | null;
}
export interface FindState {
id: string;
title: string;
description?: string;
latitude: string;
longitude: string;
locationName?: string;
category?: string;
isPublic: boolean;
createdAt: Date;
userId: string;
username: string;
profilePictureUrl?: string;
media?: Array<{
id: string;
findId: string;
type: string;
url: string;
thumbnailUrl: string | null;
orderIndex: number | null;
}>;
isLikedByUser: boolean;
likeCount: number;
isFromFriend: boolean;
}
// Generate unique operation IDs
function generateOperationId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// Create operation key for deduplication
function createOperationKey(
entityType: string,
entityId: string,
operation: string,
action?: string
): string {
return `${entityType}:${entityId}:${operation}${action ? `:${action}` : ''}`;
}
class APISync {
// Entity stores - each entity type has its own store
private entityStores = new Map<string, Writable<Map<string, EntityState>>>();
// Operation queue for API calls
private operationQueue = new Map<string, QueuedOperation>();
private processingQueue = false;
// Cleanup tracking for memory management
private subscriptions = new Map<string, Set<() => void>>();
constructor() {
// Initialize core entity stores
this.initializeEntityStore('find');
this.initializeEntityStore('user');
this.initializeEntityStore('friendship');
// Start processing queue
this.startQueueProcessor();
}
private initializeEntityStore(entityType: string): void {
if (!this.entityStores.has(entityType)) {
this.entityStores.set(entityType, writable(new Map<string, EntityState>()));
}
}
private getEntityStore(entityType: string): Writable<Map<string, EntityState>> {
this.initializeEntityStore(entityType);
return this.entityStores.get(entityType)!;
}
/**
* Subscribe to a specific entity's state
*/
subscribe<T>(entityType: string, entityId: string): Readable<EntityState<T>> {
const store = this.getEntityStore(entityType);
return derived(store, ($entities) => {
const entity = $entities.get(entityId);
if (!entity) {
// Return default state if entity doesn't exist
return {
data: null as T,
isLoading: false,
error: null,
lastUpdated: new Date()
};
}
return entity as EntityState<T>;
});
}
/**
* Subscribe specifically to find like state
*/
subscribeFindLikes(findId: string): Readable<FindLikeState> {
const store = this.getEntityStore('find');
return derived(store, ($entities) => {
const entity = $entities.get(findId);
if (!entity || !entity.data) {
return {
isLiked: false,
likeCount: 0,
isLoading: false,
error: null
};
}
const findData = entity.data as FindState;
return {
isLiked: findData.isLikedByUser,
likeCount: findData.likeCount,
isLoading: entity.isLoading,
error: entity.error
};
});
}
/**
* Check if entity state exists
*/
hasEntityState(entityType: string, entityId: string): boolean {
const store = this.getEntityStore(entityType);
let exists = false;
const unsubscribe = store.subscribe(($entities) => {
exists = $entities.has(entityId);
});
unsubscribe();
return exists;
}
/**
* Get current entity state
*/
getEntityState<T>(entityType: string, entityId: string): T | null {
const store = this.getEntityStore(entityType);
let currentState: T | null = null;
const unsubscribe = store.subscribe(($entities) => {
const entity = $entities.get(entityId);
if (entity?.data) {
currentState = entity.data as T;
}
});
unsubscribe();
return currentState;
}
/**
* Initialize entity state with server data (only if no existing state)
*/
setEntityState<T>(entityType: string, entityId: string, data: T, isLoading = false): void {
const store = this.getEntityStore(entityType);
store.update(($entities) => {
const newEntities = new Map($entities);
newEntities.set(entityId, {
data,
isLoading,
error: null,
lastUpdated: new Date()
});
return newEntities;
});
}
/**
* Initialize entity state only if it doesn't exist yet
*/
initializeEntityState<T>(entityType: string, entityId: string, data: T, isLoading = false): void {
if (!this.hasEntityState(entityType, entityId)) {
this.setEntityState(entityType, entityId, data, isLoading);
}
}
/**
* Update entity loading state
*/
private setEntityLoading(entityType: string, entityId: string, isLoading: boolean): void {
const store = this.getEntityStore(entityType);
store.update(($entities) => {
const newEntities = new Map($entities);
const existing = newEntities.get(entityId);
if (existing) {
newEntities.set(entityId, {
...existing,
isLoading
});
}
return newEntities;
});
}
/**
* Update entity error state
*/
private setEntityError(entityType: string, entityId: string, error: string): void {
const store = this.getEntityStore(entityType);
store.update(($entities) => {
const newEntities = new Map($entities);
const existing = newEntities.get(entityId);
if (existing) {
newEntities.set(entityId, {
...existing,
isLoading: false,
error
});
}
return newEntities;
});
}
/**
* Queue an operation for processing
*/
async queueOperation(
entityType: string,
entityId: string,
operation: 'create' | 'update' | 'delete',
action?: string,
data?: Record<string, unknown>
): Promise<void> {
const operationKey = createOperationKey(entityType, entityId, operation, action);
// Check if same operation is already queued
if (this.operationQueue.has(operationKey)) {
console.log(`Operation ${operationKey} already queued, skipping duplicate`);
return;
}
const queuedOperation: QueuedOperation = {
id: generateOperationId(),
entityType,
entityId,
operation,
action,
data,
retry: 0,
maxRetries: 3,
timestamp: new Date()
};
this.operationQueue.set(operationKey, queuedOperation);
// Set entity to loading state
this.setEntityLoading(entityType, entityId, true);
// Process queue if not already processing
if (!this.processingQueue) {
this.processQueue();
}
}
/**
* Process the operation queue
*/
private async processQueue(): Promise<void> {
if (this.processingQueue || this.operationQueue.size === 0) {
return;
}
this.processingQueue = true;
const operations = Array.from(this.operationQueue.entries());
for (const [operationKey, operation] of operations) {
try {
await this.executeOperation(operation);
this.operationQueue.delete(operationKey);
} catch (error) {
console.error(`Operation ${operationKey} failed:`, error);
if (operation.retry < operation.maxRetries) {
operation.retry++;
console.log(
`Retrying operation ${operationKey} (attempt ${operation.retry}/${operation.maxRetries})`
);
} else {
console.error(`Operation ${operationKey} failed after ${operation.maxRetries} retries`);
this.operationQueue.delete(operationKey);
this.setEntityError(
operation.entityType,
operation.entityId,
'Operation failed after multiple retries'
);
toast.error('Failed to sync changes. Please try again.');
}
}
}
this.processingQueue = false;
// If more operations were added while processing, process again
if (this.operationQueue.size > 0) {
setTimeout(() => this.processQueue(), 1000); // Wait 1s before retry
}
}
/**
* Execute a specific operation
*/
private async executeOperation(operation: QueuedOperation): Promise<void> {
const { entityType, entityId, operation: op, action, data } = operation;
let response: Response;
if (entityType === 'find' && action === 'like') {
// Handle like operations
const method = (data as { isLiked?: boolean })?.isLiked ? 'POST' : 'DELETE';
response = await fetch(`/api/finds/${entityId}/like`, {
method,
headers: {
'Content-Type': 'application/json'
}
});
} else {
throw new Error(`Unsupported operation: ${entityType}:${op}:${action}`);
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP ${response.status}`);
}
const result = await response.json();
// Update entity state with successful result
if (entityType === 'find' && action === 'like') {
this.updateFindLikeState(entityId, result.isLiked, result.likeCount);
}
}
/**
* Update find like state after successful API call
*/
private updateFindLikeState(findId: string, isLiked: boolean, likeCount: number): void {
const store = this.getEntityStore('find');
store.update(($entities) => {
const newEntities = new Map($entities);
const existing = newEntities.get(findId);
if (existing && existing.data) {
const findData = existing.data as FindState;
newEntities.set(findId, {
...existing,
data: {
...findData,
isLikedByUser: isLiked,
likeCount: likeCount
},
isLoading: false,
error: null,
lastUpdated: new Date()
});
}
return newEntities;
});
}
/**
* Start the queue processor
*/
private startQueueProcessor(): void {
// Process queue every 100ms
setInterval(() => {
if (this.operationQueue.size > 0 && !this.processingQueue) {
this.processQueue();
}
}, 100);
}
/**
* Toggle like for a find
*/
async toggleLike(findId: string): Promise<void> {
// Get current state for optimistic update
const store = this.getEntityStore('find');
let currentState: FindState | null = null;
const unsubscribe = store.subscribe(($entities) => {
const entity = $entities.get(findId);
if (entity?.data) {
currentState = entity.data as FindState;
}
});
unsubscribe();
if (!currentState) {
console.warn(`Cannot toggle like for find ${findId}: find state not found`);
return;
}
// Optimistic update
const findState = currentState as FindState;
const newIsLiked = !findState.isLikedByUser;
const newLikeCount = findState.likeCount + (newIsLiked ? 1 : -1);
// Update state optimistically
store.update(($entities) => {
const newEntities = new Map($entities);
const existing = newEntities.get(findId);
if (existing && existing.data) {
const findData = existing.data as FindState;
newEntities.set(findId, {
...existing,
data: {
...findData,
isLikedByUser: newIsLiked,
likeCount: newLikeCount
}
});
}
return newEntities;
});
// Queue the operation
await this.queueOperation('find', findId, 'update', 'like', { isLiked: newIsLiked });
}
/**
* Initialize find data from server (only if no existing state)
*/
initializeFindData(finds: FindState[]): void {
for (const find of finds) {
this.initializeEntityState('find', find.id, find);
}
}
/**
* Cleanup unused subscriptions (call this when components unmount)
*/
cleanup(entityType: string, entityId: string): void {
const key = `${entityType}:${entityId}`;
const subscriptions = this.subscriptions.get(key);
if (subscriptions) {
subscriptions.forEach((unsubscribe) => unsubscribe());
this.subscriptions.delete(key);
}
}
}
// Create singleton instance
export const apiSync = new APISync();

View File

@@ -43,6 +43,7 @@ export const shouldZoomToLocation = derived(
locationStore, locationStore,
($location) => $location.shouldZoomToLocation ($location) => $location.shouldZoomToLocation
); );
export const isWatching = derived(locationStore, ($location) => $location.isWatching);
// Location actions // Location actions
export const locationActions = { export const locationActions = {

View File

@@ -5,6 +5,7 @@
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';
let { children, data } = $props(); let { children, data } = $props();
let isLoginRoute = $derived(page.url.pathname.startsWith('/login')); let isLoginRoute = $derived(page.url.pathname.startsWith('/login'));
@@ -32,6 +33,11 @@
<Toaster /> <Toaster />
<!-- Auto-start location watching for authenticated users -->
{#if data?.user && !isLoginRoute}
<LocationManager autoStart={true} />
{/if}
{#if showHeader && data.user} {#if showHeader && data.user}
<Header user={data.user} /> <Header user={data.user} />
{:else if isLoading} {:else if isLoading}

View File

@@ -7,6 +7,8 @@
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';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
// Server response type // Server response type
interface ServerFind { interface ServerFind {
@@ -92,6 +94,62 @@
let selectedFind: FindPreviewData | null = $state(null); let selectedFind: FindPreviewData | null = $state(null);
let currentFilter = $state('all'); let currentFilter = $state('all');
// Initialize API sync with server data on mount
onMount(async () => {
if (browser && data.finds && data.finds.length > 0) {
// Dynamically import the API sync to avoid SSR issues
const { apiSync } = await import('$lib/stores/api-sync');
// Define the FindState interface locally
interface FindState {
id: string;
title: string;
description?: string;
latitude: string;
longitude: string;
locationName?: string;
category?: string;
isPublic: boolean;
createdAt: Date;
userId: string;
username: string;
profilePictureUrl?: string;
media: Array<{
id: string;
findId: string;
type: string;
url: string;
thumbnailUrl: string | null;
orderIndex: number | null;
}>;
isLikedByUser: boolean;
likeCount: number;
isFromFriend: boolean;
}
const findStates: FindState[] = data.finds.map((serverFind: ServerFind) => ({
id: serverFind.id,
title: serverFind.title,
description: serverFind.description,
latitude: serverFind.latitude,
longitude: serverFind.longitude,
locationName: serverFind.locationName,
category: serverFind.category,
isPublic: Boolean(serverFind.isPublic),
createdAt: new Date(serverFind.createdAt),
userId: serverFind.userId,
username: serverFind.username,
profilePictureUrl: serverFind.profilePictureUrl || undefined,
media: serverFind.media,
isLikedByUser: Boolean(serverFind.isLikedByUser),
likeCount: serverFind.likeCount || 0,
isFromFriend: Boolean(serverFind.isFromFriend)
}));
apiSync.initializeFindData(findStates);
}
});
// All finds - convert server format to component format // All finds - convert server format to component format
let allFinds = $derived( let allFinds = $derived(
(data.finds || ([] as ServerFind[])).map((serverFind: ServerFind) => ({ (data.finds || ([] as ServerFind[])).map((serverFind: ServerFind) => ({