"use client"; import { useEffect, useReducer, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { makeInitialState, reducer, type InitialNav, type Screen, type Tab, } from "@/lib/app-state"; import { AppDataProvider, type AppData } from "@/lib/app-context"; import type { Place } from "@/lib/types"; import { deleteCollection, deletePlace } from "@/lib/db/actions"; import { loadSnapshot, saveSnapshot } from "@/lib/offline-cache"; import { TabBar } from "@/components/ui-primitives"; import { Icons } from "@/components/icons"; import { PlacesListScreen } from "@/screens/places-list-screen"; import { PlaceDetailScreen } from "@/screens/place-detail-screen"; import { CollectionsListScreen } from "@/screens/collections-list-screen"; import { CollectionDetailScreen } from "@/screens/collection-detail-screen"; import { ProfileScreen } from "@/screens/profile-screen"; import { AddPlaceSheet } from "@/sheets/add-place-sheet"; import { EditPlaceSheet } from "@/sheets/edit-place-sheet"; import { EditProfileSheet } from "@/sheets/edit-profile-sheet"; import { SaveToCollectionSheet } from "@/sheets/save-to-collection-sheet"; import { CollectionFormSheet } from "@/sheets/collection-form-sheet"; import { InviteDialog } from "@/sheets/invite-dialog"; import { MembersSheet, ConfirmDialog } from "@/sheets/members-sheet"; import { DesktopShell } from "@/desktop/desktop-shell"; // Tracks a CSS media query reactively. SSR-safe: returns `false` until the // first effect tick on the client, then matches the real viewport. function useMediaQuery(query: string): boolean { const [matches, setMatches] = useState(false); useEffect(() => { const mq = window.matchMedia(query); setMatches(mq.matches); const onChange = (e: MediaQueryListEvent) => setMatches(e.matches); mq.addEventListener("change", onChange); return () => mq.removeEventListener("change", onChange); }, [query]); return matches; } export function PlacesApp({ initialPlaces, data, initialNav, }: { initialPlaces: Place[]; data: AppData; initialNav?: InitialNav; }) { // If we boot up offline, prefer the cached snapshot so the UI has *some* // data to show. When network returns, the visibility/focus effect refreshes. const bootData: { places: Place[]; data: AppData } = (() => { if (typeof window === "undefined") return { places: initialPlaces, data }; if (!navigator.onLine) { const snap = loadSnapshot(data.me.id); if (snap) { return { places: snap.places, data: { me: snap.me, users: snap.users, collections: snap.collections, publicPlaces: snap.publicPlaces ?? [], }, }; } } return { places: initialPlaces, data }; })(); const [state, dispatch] = useReducer(reducer, bootData.places, (p) => makeInitialState(p, initialNav), ); const [, startTransition] = useTransition(); const router = useRouter(); // Mirror the latest server snapshot to localStorage so it's available next // time the user boots up offline. Only writes when we trust the data // (i.e. currently online). useEffect(() => { if (typeof window === "undefined") return; if (!navigator.onLine) return; saveSnapshot({ v: 1, savedAt: Date.now(), me: data.me, users: data.users, collections: data.collections, places: state.places, publicPlaces: data.publicPlaces, }); }, [data, state.places]); // Realtime-lite sync: re-fetch server data when the tab regains focus or // network comes back. Cheap proxy for true realtime — catches changes other // members made while this tab was inactive. CLAUDE.md Phase 2. useEffect(() => { const refresh = () => router.refresh(); const onVisible = () => { if (document.visibilityState === "visible") refresh(); }; document.addEventListener("visibilitychange", onVisible); window.addEventListener("focus", refresh); window.addEventListener("online", refresh); return () => { document.removeEventListener("visibilitychange", onVisible); window.removeEventListener("focus", refresh); window.removeEventListener("online", refresh); }; }, [router]); useEffect(() => { if (!state.toast) return; const key = state.toastKey; const id = setTimeout( () => dispatch({ type: "CLEAR_TOAST", key }), 2200, ); return () => clearTimeout(id); }, [state.toast, state.toastKey]); useEffect(() => { const sync = () => dispatch({ type: "SET_OFFLINE", value: !navigator.onLine }); sync(); window.addEventListener("online", sync); window.addEventListener("offline", sync); return () => { window.removeEventListener("online", sync); window.removeEventListener("offline", sync); }; }, []); const top = state.stack[state.stack.length - 1]; const activeTab: Tab = top.screen === "place" || top.screen === "collection" ? state.tab : (top.screen as Tab); const onTab = (id: string) => { if (id === "profile" || id === "collections" || id === "places") { dispatch({ type: "TAB", tab: id }); } }; const renderScreen = (screen: Screen) => { if (screen === "places") return ; if (screen === "collections") return ; if (screen === "profile") return ; if (screen === "place") return ( ); if (screen === "collection") return ( ); return null; }; const m = state.modal; const placeForDelete = m?.type === "confirmDeletePlace" ? state.places.find((p) => p.id === m.placeId) : null; const collectionForDelete = m?.type === "confirmDeleteCollection" ? bootData.data.collections.find((c) => c.id === m.collectionId) : null; // ≥1024px: switch to the 3-pane desktop shell. Modals and toast are still // rendered below so both layouts share the same sheets/dialogs. const isDesktop = useMediaQuery("(min-width: 1024px)"); // On first desktop mount, seed selectedPlaceId so the right pane has // something to show. Collection seeding is deferred to DesktopShell (it // happens lazily when the user clicks the Collections tab) because the // SELECT_COLLECTION action also flips the active tab — running it here // would yank a freshly-loaded user into Collections. useEffect(() => { if (!isDesktop) return; if (state.selectedPlaceId == null && state.places.length > 0) { dispatch({ type: "SELECT_PLACE", placeId: state.places[0].id }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isDesktop]); return ( {isDesktop ? ( ) : (
{renderScreen(top.screen)} dispatch({ type: "OPEN_ADD" })} showFab={top.screen !== "profile"} />
)} <> {m?.type === "add" && ( dispatch({ type: "CLOSE_MODAL" })} dispatch={dispatch} /> )} {m?.type === "editPlace" && (() => { const p = state.places.find((x) => x.id === m.placeId); return p ? ( dispatch({ type: "CLOSE_MODAL" })} dispatch={dispatch} /> ) : null; })()} {m?.type === "saveToCollection" && ( dispatch({ type: "CLOSE_MODAL" })} dispatch={dispatch} /> )} {m?.type === "createCollection" && ( dispatch({ type: "CLOSE_MODAL" })} dispatch={dispatch} /> )} {m?.type === "editCollection" && (() => { const c = bootData.data.collections.find((x) => x.id === m.collectionId); return c ? ( dispatch({ type: "CLOSE_MODAL" })} dispatch={dispatch} /> ) : null; })()} {m?.type === "editProfile" && ( dispatch({ type: "CLOSE_MODAL" })} dispatch={dispatch} /> )} {m?.type === "invite" && ( dispatch({ type: "CLOSE_MODAL" })} dispatch={dispatch} /> )} {m?.type === "members" && ( dispatch({ type: "CLOSE_MODAL" })} dispatch={dispatch} /> )} {m?.type === "confirmDeletePlace" && placeForDelete && ( { const id = m.placeId; dispatch({ type: "DELETE_PLACE", placeId: id }); startTransition(() => { deletePlace(id).catch(() => dispatch({ type: "TOAST", value: "Xóa thất bại" }), ); }); }} onClose={() => dispatch({ type: "CLOSE_MODAL" })} /> )} {m?.type === "confirmDeleteCollection" && collectionForDelete && ( { const id = m.collectionId; dispatch({ type: "CLOSE_MODAL" }); dispatch({ type: "BACK" }); startTransition(() => { deleteCollection(id) .then(() => dispatch({ type: "TOAST", value: "Đã xóa" })) .catch(() => dispatch({ type: "TOAST", value: "Xóa thất bại" })); }); }} onClose={() => dispatch({ type: "CLOSE_MODAL" })} /> )} {state.toast && (
{state.toast}
)}
); }