This commit is contained in:
2026-05-20 23:24:22 +07:00
parent 290d36e8cb
commit 9a228bc574
25 changed files with 4775 additions and 172 deletions

View File

@@ -1,10 +1,11 @@
"use client";
import { useEffect, useReducer, useTransition } from "react";
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";
@@ -21,17 +22,35 @@ 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.
@@ -42,14 +61,21 @@ export function PlacesApp({
if (snap) {
return {
places: snap.places,
data: { me: snap.me, users: snap.users, collections: snap.collections },
data: {
me: snap.me,
users: snap.users,
collections: snap.collections,
publicPlaces: snap.publicPlaces ?? [],
},
};
}
}
return { places: initialPlaces, data };
})();
const [state, dispatch] = useReducer(reducer, bootData.places, makeInitialState);
const [state, dispatch] = useReducer(reducer, bootData.places, (p) =>
makeInitialState(p, initialNav),
);
const [, startTransition] = useTransition();
const router = useRouter();
@@ -66,6 +92,7 @@ export function PlacesApp({
users: data.users,
collections: data.collections,
places: state.places,
publicPlaces: data.publicPlaces,
});
}, [data, state.places]);
@@ -155,17 +182,39 @@ export function PlacesApp({
? 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}>
<div className="app-frame">
{renderScreen(top.screen)}
<TabBar
active={activeTab}
onTab={onTab}
onFab={() => dispatch({ type: "OPEN_ADD" })}
showFab={top.screen !== "profile"}
/>
{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" })}
@@ -207,6 +256,12 @@ export function PlacesApp({
/>
) : null;
})()}
{m?.type === "editProfile" && (
<EditProfileSheet
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
dispatch={dispatch}
/>
)}
{m?.type === "invite" && (
<InviteDialog
collectionId={m.collectionId}
@@ -263,7 +318,7 @@ export function PlacesApp({
{state.toast}
</div>
)}
</div>
</>
</AppDataProvider>
);
}