Files
places/src/app/places-app.tsx
2026-05-20 18:08:37 +07:00

270 lines
9.1 KiB
TypeScript

"use client";
import { useEffect, useReducer, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
makeInitialState,
reducer,
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 { 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";
export function PlacesApp({
initialPlaces,
data,
}: {
initialPlaces: Place[];
data: AppData;
}) {
// 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 },
};
}
}
return { places: initialPlaces, data };
})();
const [state, dispatch] = useReducer(reducer, bootData.places, makeInitialState);
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,
});
}, [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;
return (
<AppDataProvider value={bootData.data}>
<div className="app-frame">
{renderScreen(top.screen)}
<TabBar
active={activeTab}
onTab={onTab}
onFab={() => dispatch({ type: "OPEN_ADD" })}
showFab={top.screen !== "profile"}
/>
{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 === "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>
)}
</div>
</AppDataProvider>
);
}