325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
"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 <PlacesListScreen state={state} dispatch={dispatch} />;
|
|
if (screen === "collections")
|
|
return <CollectionsListScreen state={state} dispatch={dispatch} />;
|
|
if (screen === "profile")
|
|
return <ProfileScreen state={state} dispatch={dispatch} />;
|
|
if (screen === "place")
|
|
return (
|
|
<PlaceDetailScreen
|
|
state={{ ...state, placeId: top.placeId, collectionId: top.collectionId }}
|
|
dispatch={dispatch}
|
|
/>
|
|
);
|
|
if (screen === "collection")
|
|
return (
|
|
<CollectionDetailScreen
|
|
state={{ ...state, collectionId: top.collectionId }}
|
|
dispatch={dispatch}
|
|
/>
|
|
);
|
|
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 (
|
|
<AppDataProvider value={bootData.data}>
|
|
{isDesktop ? (
|
|
<DesktopShell state={state} dispatch={dispatch} />
|
|
) : (
|
|
<div className="app-frame">
|
|
{renderScreen(top.screen)}
|
|
<TabBar
|
|
active={activeTab}
|
|
onTab={onTab}
|
|
onFab={() => dispatch({ type: "OPEN_ADD" })}
|
|
showFab={top.screen !== "profile"}
|
|
/>
|
|
</div>
|
|
)}
|
|
<>
|
|
{m?.type === "add" && (
|
|
<AddPlaceSheet
|
|
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
|
|
dispatch={dispatch}
|
|
/>
|
|
)}
|
|
{m?.type === "editPlace" && (() => {
|
|
const p = state.places.find((x) => x.id === m.placeId);
|
|
return p ? (
|
|
<EditPlaceSheet
|
|
place={p}
|
|
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
|
|
dispatch={dispatch}
|
|
/>
|
|
) : null;
|
|
})()}
|
|
{m?.type === "saveToCollection" && (
|
|
<SaveToCollectionSheet
|
|
placeId={m.placeId}
|
|
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
|
|
dispatch={dispatch}
|
|
/>
|
|
)}
|
|
{m?.type === "createCollection" && (
|
|
<CollectionFormSheet
|
|
mode="create"
|
|
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
|
|
dispatch={dispatch}
|
|
/>
|
|
)}
|
|
{m?.type === "editCollection" && (() => {
|
|
const c = bootData.data.collections.find((x) => x.id === m.collectionId);
|
|
return c ? (
|
|
<CollectionFormSheet
|
|
mode="edit"
|
|
collection={c}
|
|
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
|
|
dispatch={dispatch}
|
|
/>
|
|
) : null;
|
|
})()}
|
|
{m?.type === "editProfile" && (
|
|
<EditProfileSheet
|
|
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
|
|
dispatch={dispatch}
|
|
/>
|
|
)}
|
|
{m?.type === "invite" && (
|
|
<InviteDialog
|
|
collectionId={m.collectionId}
|
|
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
|
|
dispatch={dispatch}
|
|
/>
|
|
)}
|
|
{m?.type === "members" && (
|
|
<MembersSheet
|
|
collectionId={m.collectionId}
|
|
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
|
|
dispatch={dispatch}
|
|
/>
|
|
)}
|
|
{m?.type === "confirmDeletePlace" && placeForDelete && (
|
|
<ConfirmDialog
|
|
title="Xóa địa điểm?"
|
|
body={`"${placeForDelete.name}" sẽ bị xóa khỏi tất cả bộ sưu tập. Không thể hoàn tác.`}
|
|
confirmLabel="Xóa"
|
|
onConfirm={() => {
|
|
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 && (
|
|
<ConfirmDialog
|
|
title="Xóa bộ sưu tập?"
|
|
body={`"${collectionForDelete.name}" sẽ bị xóa. Các địa điểm bên trong vẫn còn ở "Địa điểm".`}
|
|
confirmLabel="Xóa"
|
|
onConfirm={() => {
|
|
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 && (
|
|
<div className="toast" key={state.toastKey}>
|
|
<Icons.Check size={14} stroke={2.5} />
|
|
{state.toast}
|
|
</div>
|
|
)}
|
|
</>
|
|
</AppDataProvider>
|
|
);
|
|
}
|