This commit is contained in:
Andras Schmelczer 2026-06-25 22:29:52 +01:00
parent 2efa4d9f47
commit 5e73287eaf
99 changed files with 6392 additions and 1462 deletions

View file

@ -0,0 +1,82 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import pb from '../lib/pocketbase';
/** Per-user record of opened listings, stored in PocketBase so visited listings can be drawn
* in a distinct colour on the map and persist across devices. One row per (user, url). */
const CLICKED_LISTINGS_COLLECTION = 'clicked_listings';
function currentUserId(): string | null {
return pb.authStore.isValid && pb.authStore.record ? pb.authStore.record.id : null;
}
/** Tracks which listings the signed-in user has opened.
*
* Reads the user from the shared PocketBase authStore so it stays self-contained no need to
* thread a user id down through the map layer tree. Returns a Set for O(1) membership checks in
* the map's colour accessor; a fresh Set instance is produced on every change so it can double
* as a deck.gl `updateTrigger` (identity-compared).
*
* Writes are optimistic: `markClicked` updates local state synchronously so the pin recolours
* the instant it is clicked, independent of the PocketBase round-trip or the next listings fetch.
*/
export function useClickedListings() {
const [clickedUrls, setClickedUrls] = useState<Set<string>>(() => new Set());
const [userId, setUserId] = useState<string | null>(currentUserId);
const userIdRef = useRef(userId);
userIdRef.current = userId;
// Mirrors the latest committed set so markClicked can decide (synchronously) whether a url is
// new — without depending on the async state updater having run yet.
const clickedUrlsRef = useRef(clickedUrls);
clickedUrlsRef.current = clickedUrls;
// Follow login/logout via the shared authStore (also covers cross-tab auth changes).
useEffect(() => {
const unsubscribe = pb.authStore.onChange(() => setUserId(currentUserId()));
return unsubscribe;
}, []);
// Load the user's previously opened listings whenever the signed-in user changes. Signed-out
// users keep an in-memory set for the current session only (nothing to persist to).
useEffect(() => {
if (!userId) {
setClickedUrls(new Set());
return;
}
let cancelled = false;
pb.collection(CLICKED_LISTINGS_COLLECTION)
.getFullList({ filter: `user = "${userId}"`, fields: 'url' })
.then((records) => {
if (cancelled) return;
const urls = records
.map((record) => (record as { url?: unknown }).url)
.filter((url): url is string => typeof url === 'string');
setClickedUrls(new Set(urls));
})
.catch(() => {
// Visited history is a convenience; a failed load just starts the session empty.
});
return () => {
cancelled = true;
};
}, [userId]);
const markClicked = useCallback((url: string | undefined | null) => {
if (!url || clickedUrlsRef.current.has(url)) return; // unknown or already visited
// Optimistic, synchronous local update → the map pin recolours on click.
setClickedUrls((prev) => (prev.has(url) ? prev : new Set(prev).add(url)));
const uid = userIdRef.current;
if (!uid) return; // anonymous: recolour for the session, nothing to persist
pb.collection(CLICKED_LISTINGS_COLLECTION)
.create({ user: uid, url })
.catch(() => {
// Ignore duplicate-index conflicts and transient failures — local state already
// reflects the click, and a missed write only means it won't persist to the next visit.
});
}, []);
return { clickedUrls, markClicked };
}