Add History API routing so browser/Android back navigates screens
- ui-store syncs screens to history (push on go/goGame, hash URLs like #/profile), back() = history.back(), initHistory anchors a home base + restores deep links - page.tsx listens to popstate; resolveScreen() guards transient screens (game/room/chat/matchmaking fall back to home when their state is gone) - ScreenHeader + ChatScreen back buttons use history back; chat cleans up on unmount Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+27
-1
@@ -14,7 +14,25 @@ import { ChatScreen } from "@/components/screens/ChatScreen";
|
|||||||
import { AuthScreen } from "@/components/screens/AuthScreen";
|
import { AuthScreen } from "@/components/screens/AuthScreen";
|
||||||
import { DailyRewardModal } from "@/components/online/DailyRewardModal";
|
import { DailyRewardModal } from "@/components/online/DailyRewardModal";
|
||||||
import { useSessionStore } from "@/lib/session-store";
|
import { useSessionStore } from "@/lib/session-store";
|
||||||
import { useUIStore } from "@/lib/ui-store";
|
import { useGameStore } from "@/lib/game-store";
|
||||||
|
import { useOnlineStore } from "@/lib/online-store";
|
||||||
|
import { screenFromHash, useUIStore, type Screen } from "@/lib/ui-store";
|
||||||
|
|
||||||
|
/** Transient screens can't be restored without their state — fall back to home. */
|
||||||
|
function resolveScreen(s: Screen): Screen {
|
||||||
|
switch (s) {
|
||||||
|
case "game":
|
||||||
|
return useGameStore.getState().started ? s : "home";
|
||||||
|
case "room":
|
||||||
|
return useOnlineStore.getState().room ? s : "home";
|
||||||
|
case "chat":
|
||||||
|
return useOnlineStore.getState().activeChatFriend ? s : "home";
|
||||||
|
case "matchmaking":
|
||||||
|
return useOnlineStore.getState().matchmaking.phase !== "idle" ? s : "home";
|
||||||
|
default:
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const screen = useUIStore((s) => s.screen);
|
const screen = useUIStore((s) => s.screen);
|
||||||
@@ -23,6 +41,14 @@ export default function Page() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
init();
|
init();
|
||||||
|
useUIStore.getState().initHistory();
|
||||||
|
|
||||||
|
const onPop = (e: PopStateEvent) => {
|
||||||
|
const raw = ((e.state?.screen as Screen) ?? screenFromHash());
|
||||||
|
useUIStore.getState().syncFromPop(resolveScreen(raw));
|
||||||
|
};
|
||||||
|
window.addEventListener("popstate", onPop);
|
||||||
|
return () => window.removeEventListener("popstate", onPop);
|
||||||
}, [init]);
|
}, [init]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ export function ScreenHeader({
|
|||||||
back?: Screen;
|
back?: Screen;
|
||||||
right?: React.ReactNode;
|
right?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const go = useUIStore((s) => s.go);
|
const navBack = useUIStore((s) => s.back);
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
const Chevron = locale === "fa" ? ChevronRight : ChevronLeft;
|
const Chevron = locale === "fa" ? ChevronRight : ChevronLeft;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-3 mb-5">
|
<div className="flex items-center justify-between gap-3 mb-5">
|
||||||
<button
|
<button
|
||||||
onClick={() => go(back)}
|
onClick={() => navBack(back)}
|
||||||
className="glass rounded-full p-2.5 hover:bg-navy-800/80 transition"
|
className="glass rounded-full p-2.5 hover:bg-navy-800/80 transition"
|
||||||
>
|
>
|
||||||
<Chevron className="size-5 text-cream/80" />
|
<Chevron className="size-5 text-cream/80" />
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function ChatScreen() {
|
|||||||
const messages = useOnlineStore((s) => s.chatMessages);
|
const messages = useOnlineStore((s) => s.chatMessages);
|
||||||
const sendChat = useOnlineStore((s) => s.sendChat);
|
const sendChat = useOnlineStore((s) => s.sendChat);
|
||||||
const closeChat = useOnlineStore((s) => s.closeChat);
|
const closeChat = useOnlineStore((s) => s.closeChat);
|
||||||
const go = useUIStore((s) => s.go);
|
const navBack = useUIStore((s) => s.back);
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const endRef = useRef<HTMLDivElement>(null);
|
const endRef = useRef<HTMLDivElement>(null);
|
||||||
const prevLen = useRef(0);
|
const prevLen = useRef(0);
|
||||||
@@ -30,14 +30,14 @@ export function ChatScreen() {
|
|||||||
prevLen.current = messages.length;
|
prevLen.current = messages.length;
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
|
// Clean up the chat subscription whenever we leave (header back OR hardware back).
|
||||||
|
useEffect(() => () => closeChat(), [closeChat]);
|
||||||
|
|
||||||
if (!friend) {
|
if (!friend) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const back = () => {
|
const back = () => navBack("friends");
|
||||||
closeChat();
|
|
||||||
go("friends");
|
|
||||||
};
|
|
||||||
|
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
const v = text.trim();
|
const v = text.trim();
|
||||||
|
|||||||
+72
-3
@@ -15,23 +15,92 @@ export type Screen =
|
|||||||
| "chat"
|
| "chat"
|
||||||
| "game"; // the table (used for both ai + online)
|
| "game"; // the table (used for both ai + online)
|
||||||
|
|
||||||
|
const ALL_SCREENS: Screen[] = [
|
||||||
|
"home", "auth", "profile", "friends", "online",
|
||||||
|
"room", "matchmaking", "leaderboard", "shop", "chat", "game",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Screens safe to restore from a URL on a cold load (no transient state needed). */
|
||||||
|
export const STATIC_SCREENS: Screen[] = [
|
||||||
|
"home", "auth", "profile", "friends", "online", "leaderboard", "shop",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function screenFromHash(): Screen {
|
||||||
|
if (typeof window === "undefined") return "home";
|
||||||
|
const h = window.location.hash.replace(/^#\/?/, "");
|
||||||
|
return (ALL_SCREENS.includes(h as Screen) ? (h as Screen) : "home");
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlFor(screen: Screen): string {
|
||||||
|
if (typeof window === "undefined") return "/";
|
||||||
|
const base = window.location.pathname + window.location.search;
|
||||||
|
return screen === "home" ? base : `${base}#/${screen}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushHistory(screen: Screen, replace = false) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const state = { screen };
|
||||||
|
if (replace) window.history.replaceState(state, "", urlFor(screen));
|
||||||
|
else window.history.pushState(state, "", urlFor(screen));
|
||||||
|
}
|
||||||
|
|
||||||
interface UIStore {
|
interface UIStore {
|
||||||
screen: Screen;
|
screen: Screen;
|
||||||
/** screen to return to from the game table */
|
/** screen to return to from the game table */
|
||||||
returnTo: Screen;
|
returnTo: Screen;
|
||||||
dailyModalOpen: boolean;
|
dailyModalOpen: boolean;
|
||||||
|
|
||||||
go: (screen: Screen) => void;
|
go: (screen: Screen) => void;
|
||||||
goGame: (returnTo?: Screen) => void;
|
goGame: (returnTo?: Screen) => void;
|
||||||
|
/** Browser/hardware back. */
|
||||||
|
back: (fallback?: Screen) => void;
|
||||||
|
/** Sync from a popstate event (does not touch history). */
|
||||||
|
syncFromPop: (screen: Screen) => void;
|
||||||
|
/** Establish a home base entry + restore a deep-linked screen on load. */
|
||||||
|
initHistory: () => void;
|
||||||
|
|
||||||
openDaily: () => void;
|
openDaily: () => void;
|
||||||
closeDaily: () => void;
|
closeDaily: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUIStore = create<UIStore>((set) => ({
|
export const useUIStore = create<UIStore>((set, get) => ({
|
||||||
screen: "home",
|
screen: "home",
|
||||||
returnTo: "home",
|
returnTo: "home",
|
||||||
dailyModalOpen: false,
|
dailyModalOpen: false,
|
||||||
go: (screen) => set({ screen }),
|
|
||||||
goGame: (returnTo = "home") => set({ screen: "game", returnTo }),
|
go: (screen) => {
|
||||||
|
if (get().screen === screen) return;
|
||||||
|
pushHistory(screen);
|
||||||
|
set({ screen });
|
||||||
|
},
|
||||||
|
|
||||||
|
goGame: (returnTo = "home") => {
|
||||||
|
pushHistory("game");
|
||||||
|
set({ screen: "game", returnTo });
|
||||||
|
},
|
||||||
|
|
||||||
|
back: (fallback) => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.history.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fallback) set({ screen: fallback });
|
||||||
|
},
|
||||||
|
|
||||||
|
syncFromPop: (screen) => set({ screen }),
|
||||||
|
|
||||||
|
initHistory: () => {
|
||||||
|
// Always anchor a "home" base entry so back() has somewhere to land.
|
||||||
|
pushHistory("home", true);
|
||||||
|
const target = screenFromHash();
|
||||||
|
if (target !== "home" && STATIC_SCREENS.includes(target)) {
|
||||||
|
pushHistory(target);
|
||||||
|
set({ screen: target });
|
||||||
|
} else {
|
||||||
|
set({ screen: "home" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
openDaily: () => set({ dailyModalOpen: true }),
|
openDaily: () => set({ dailyModalOpen: true }),
|
||||||
closeDaily: () => set({ dailyModalOpen: false }),
|
closeDaily: () => set({ dailyModalOpen: false }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user