Compare commits
3 Commits
search
...
syncservic
| Author | SHA1 | Date | |
|---|---|---|---|
|
d7fe9091ce
|
|||
|
6620cc6078
|
|||
|
3b3ebc2873
|
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/button';
|
||||
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 {
|
||||
findId: string;
|
||||
@@ -19,59 +21,75 @@
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
// Track the source of truth - server state
|
||||
let serverIsLiked = $state(isLiked);
|
||||
let serverLikeCount = $state(likeCount);
|
||||
// Local state stores for this like button - start with props but will be overridden by global state
|
||||
const likeState = writable({
|
||||
isLiked: isLiked,
|
||||
likeCount: likeCount,
|
||||
isLoading: false
|
||||
});
|
||||
|
||||
// Track optimistic state during loading
|
||||
let isLoading = $state(false);
|
||||
let optimisticIsLiked = $state(isLiked);
|
||||
let optimisticLikeCount = $state(likeCount);
|
||||
let apiSync: any = null;
|
||||
|
||||
// Derived state for display
|
||||
let displayIsLiked = $derived(isLoading ? optimisticIsLiked : serverIsLiked);
|
||||
let displayLikeCount = $derived(isLoading ? optimisticLikeCount : serverLikeCount);
|
||||
// Initialize API sync and subscribe to global state
|
||||
onMount(async () => {
|
||||
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() {
|
||||
if (isLoading) return;
|
||||
if (!apiSync || !browser) return;
|
||||
|
||||
// Set optimistic state
|
||||
optimisticIsLiked = !serverIsLiked;
|
||||
optimisticLikeCount = serverLikeCount + (optimisticIsLiked ? 1 : -1);
|
||||
isLoading = true;
|
||||
const currentState = likeState;
|
||||
if (currentState && (currentState as any).isLoading) return;
|
||||
|
||||
try {
|
||||
const method = optimisticIsLiked ? 'POST' : 'DELETE';
|
||||
const response = await fetch(`/api/finds/${findId}/like`, {
|
||||
method,
|
||||
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;
|
||||
await apiSync.toggleLike(findId);
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle like:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update server state when props change (from parent component)
|
||||
$effect(() => {
|
||||
serverIsLiked = isLiked;
|
||||
serverLikeCount = likeCount;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Button
|
||||
@@ -79,22 +97,22 @@
|
||||
{size}
|
||||
class="group gap-1.5 {className}"
|
||||
onclick={toggleLike}
|
||||
disabled={isLoading}
|
||||
disabled={$likeState.isLoading}
|
||||
>
|
||||
<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'
|
||||
: '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'
|
||||
: ''}"
|
||||
/>
|
||||
{#if displayLikeCount > 0}
|
||||
{#if $likeState.likeCount > 0}
|
||||
<span
|
||||
class="text-sm font-medium transition-colors {displayIsLiked
|
||||
class="text-sm font-medium transition-colors {$likeState.isLiked
|
||||
? 'text-red-500'
|
||||
: 'text-gray-500 group-hover:text-red-400'}"
|
||||
>
|
||||
{displayLikeCount}
|
||||
{$likeState.likeCount}
|
||||
</span>
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
locationActions,
|
||||
locationStatus,
|
||||
locationError,
|
||||
isLocationLoading
|
||||
isLocationLoading,
|
||||
isWatching
|
||||
} from '$lib/stores/location';
|
||||
import { Skeleton } from './skeleton';
|
||||
|
||||
@@ -22,26 +23,45 @@
|
||||
showLabel = true
|
||||
}: Props = $props();
|
||||
|
||||
async function handleLocationClick() {
|
||||
const result = await locationActions.getCurrentLocation({
|
||||
enableHighAccuracy: true,
|
||||
timeout: 15000,
|
||||
maximumAge: 300000
|
||||
});
|
||||
// Track if location watching is active from the derived store
|
||||
|
||||
if (!result && $locationError) {
|
||||
toast.error($locationError.message);
|
||||
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 ($locationStatus === 'success') return 'Update 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';
|
||||
@@ -59,6 +79,22 @@
|
||||
<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
|
||||
@@ -201,6 +237,11 @@
|
||||
fill: #10b981;
|
||||
}
|
||||
|
||||
.icon.watching svg {
|
||||
color: #f59e0b;
|
||||
fill: #f59e0b;
|
||||
}
|
||||
|
||||
.icon.error svg {
|
||||
color: #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 {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
81
src/lib/components/LocationManager.svelte
Normal file
81
src/lib/components/LocationManager.svelte
Normal 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 -->
|
||||
@@ -6,7 +6,8 @@
|
||||
getMapCenter,
|
||||
getMapZoom,
|
||||
shouldZoomToLocation,
|
||||
locationActions
|
||||
locationActions,
|
||||
isWatching
|
||||
} from '$lib/stores/location';
|
||||
import LocationButton from './LocationButton.svelte';
|
||||
import { Skeleton } from '$lib/components/skeleton';
|
||||
@@ -139,11 +140,14 @@
|
||||
>
|
||||
{#if $coordinates}
|
||||
<Marker lngLat={[$coordinates.longitude, $coordinates.latitude]}>
|
||||
<div class="location-marker">
|
||||
<div class="marker-pulse"></div>
|
||||
<div class="marker-outer">
|
||||
<div class="marker-inner"></div>
|
||||
<div class="location-marker" class:watching={$isWatching}>
|
||||
<div class="marker-pulse" class:watching={$isWatching}></div>
|
||||
<div class="marker-outer" class:watching={$isWatching}>
|
||||
<div class="marker-inner" class:watching={$isWatching}></div>
|
||||
</div>
|
||||
{#if $isWatching}
|
||||
<div class="watching-ring"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</Marker>
|
||||
{/if}
|
||||
@@ -243,6 +247,13 @@
|
||||
display: flex;
|
||||
align-items: 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) {
|
||||
@@ -250,6 +261,12 @@
|
||||
height: 8px;
|
||||
background: #2563eb;
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
:global(.marker-inner.watching) {
|
||||
background: #f59e0b;
|
||||
animation: pulse-glow 2s infinite;
|
||||
}
|
||||
|
||||
:global(.marker-pulse) {
|
||||
@@ -263,6 +280,22 @@
|
||||
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 {
|
||||
0% {
|
||||
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 */
|
||||
:global(.find-marker) {
|
||||
width: 40px;
|
||||
|
||||
@@ -8,6 +8,7 @@ export { default as Header } from './components/Header.svelte';
|
||||
export { default as Modal } from './components/Modal.svelte';
|
||||
export { default as Map } from './components/Map.svelte';
|
||||
export { default as LocationButton } from './components/LocationButton.svelte';
|
||||
export { default as LocationManager } from './components/LocationManager.svelte';
|
||||
export { default as FindCard } from './components/FindCard.svelte';
|
||||
export { default as FindsList } from './components/FindsList.svelte';
|
||||
|
||||
@@ -24,6 +25,7 @@ export {
|
||||
locationError,
|
||||
isLocationLoading,
|
||||
hasLocationAccess,
|
||||
isWatching,
|
||||
getMapCenter,
|
||||
getMapZoom
|
||||
} from './stores/location';
|
||||
|
||||
493
src/lib/stores/api-sync.ts
Normal file
493
src/lib/stores/api-sync.ts
Normal 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();
|
||||
@@ -43,6 +43,7 @@ export const shouldZoomToLocation = derived(
|
||||
locationStore,
|
||||
($location) => $location.shouldZoomToLocation
|
||||
);
|
||||
export const isWatching = derived(locationStore, ($location) => $location.isWatching);
|
||||
|
||||
// Location actions
|
||||
export const locationActions = {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { page } from '$app/state';
|
||||
import { Toaster } from '$lib/components/sonner/index.js';
|
||||
import { Skeleton } from '$lib/components/skeleton';
|
||||
import LocationManager from '$lib/components/LocationManager.svelte';
|
||||
|
||||
let { children, data } = $props();
|
||||
let isLoginRoute = $derived(page.url.pathname.startsWith('/login'));
|
||||
@@ -32,6 +33,11 @@
|
||||
|
||||
<Toaster />
|
||||
|
||||
<!-- Auto-start location watching for authenticated users -->
|
||||
{#if data?.user && !isLoginRoute}
|
||||
<LocationManager autoStart={true} />
|
||||
{/if}
|
||||
|
||||
{#if showHeader && data.user}
|
||||
<Header user={data.user} />
|
||||
{:else if isLoading}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
import type { PageData } from './$types';
|
||||
import { coordinates } from '$lib/stores/location';
|
||||
import { Button } from '$lib/components/button';
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// Server response type
|
||||
interface ServerFind {
|
||||
@@ -92,6 +94,62 @@
|
||||
let selectedFind: FindPreviewData | null = $state(null);
|
||||
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
|
||||
let allFinds = $derived(
|
||||
(data.finds || ([] as ServerFind[])).map((serverFind: ServerFind) => ({
|
||||
|
||||
Reference in New Issue
Block a user