Landscape: whole-app landscape-first + Home 2-column landscape layout
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 47s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m7s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m0s

- Move orientation lock + RotatePrompt to app root → whole app is landscape-
  first now (UNO-style), not just the game. Generalized rotate copy.
- Home: portrait unchanged; in landscape it becomes a 2-column app layout
  (col A = branding + play actions, col B = tiles + footer) that fits the
  short height with no scroll (landscape: Tailwind variants, overflow-hidden).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-11 00:33:21 +03:30
parent e8b3172197
commit 3e37085d18
4 changed files with 35 additions and 28 deletions
+10
View File
@@ -22,6 +22,7 @@ import { ResumeGameBar } from "@/components/online/ResumeGameBar";
import { CelebrationOverlay } from "@/components/online/CelebrationOverlay"; import { CelebrationOverlay } from "@/components/online/CelebrationOverlay";
import { ErrorBoundary } from "@/components/ErrorBoundary"; import { ErrorBoundary } from "@/components/ErrorBoundary";
import { PublicProfileModal } from "@/components/online/PublicProfileModal"; import { PublicProfileModal } from "@/components/online/PublicProfileModal";
import { RotatePrompt } from "@/components/online/RotatePrompt";
import { CapacitorBack } from "@/components/CapacitorBack"; import { CapacitorBack } from "@/components/CapacitorBack";
import { useSessionStore } from "@/lib/session-store"; import { useSessionStore } from "@/lib/session-store";
import { useGameStore } from "@/lib/game-store"; import { useGameStore } from "@/lib/game-store";
@@ -191,6 +192,14 @@ export default function Page() {
}; };
}, [init]); }, [init]);
// Landscape-first app (UNO-style): best-effort lock the whole app to landscape
// on Android / installed PWA. iOS & desktop reject it harmlessly; the
// RotatePrompt covers the portrait case there.
useEffect(() => {
const o = (screen as unknown as { orientation?: { lock?: (m: string) => Promise<void> } }).orientation;
o?.lock?.("landscape").catch(() => {});
}, []);
return ( return (
// reducedMotion="user" makes every Framer Motion animation honor the OS // reducedMotion="user" makes every Framer Motion animation honor the OS
// "reduce motion" accessibility setting (coin rain, confetti, count-ups…). // "reduce motion" accessibility setting (coin rain, confetti, count-ups…).
@@ -202,6 +211,7 @@ export default function Page() {
<ResumeGameBar /> <ResumeGameBar />
<CelebrationOverlay /> <CelebrationOverlay />
<PublicProfileModal /> <PublicProfileModal />
<RotatePrompt />
</ErrorBoundary> </ErrorBoundary>
<CapacitorBack /> <CapacitorBack />
{loading && null} {loading && null}
+21 -6
View File
@@ -73,18 +73,24 @@ export function HomeScreen() {
const Chevron = locale === "fa" ? ChevronLeft : ChevronRight; const Chevron = locale === "fa" ? ChevronLeft : ChevronRight;
return ( return (
<main className="persian-pattern relative h-dvh w-full overflow-y-auto overscroll-contain safe-top safe-x safe-bottom"> <main className="persian-pattern relative h-dvh w-full overflow-y-auto landscape:overflow-hidden overscroll-contain safe-top safe-x safe-bottom">
<FloatingSuits /> <FloatingSuits />
<div className="relative z-10 mx-auto w-full max-w-md p-4 sm:p-6 flex flex-col min-h-dvh"> <div className="relative z-10 mx-auto w-full max-w-md landscape:max-w-5xl p-4 sm:p-6 flex flex-col min-h-dvh landscape:min-h-0 landscape:h-dvh">
<div className="pt-1"> <div className="pt-1">
<TopBar /> <TopBar />
</div> </div>
{/* content: single column (portrait) → two columns (landscape) */}
<div className="flex-1 flex flex-col min-h-0 landscape:flex-row landscape:items-stretch landscape:justify-center landscape:gap-6">
{/* ===== column A: branding + play actions ===== */}
<div className="flex flex-col landscape:flex-1 landscape:max-w-md landscape:justify-center landscape:min-h-0">
{/* logo */} {/* logo */}
<motion.div <motion.div
initial={{ opacity: 0, y: 16 }} initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center mt-4 mb-5 sm:mt-6 sm:mb-7" className="flex flex-col items-center mt-4 mb-5 sm:mt-6 sm:mb-7 landscape:mt-0 landscape:mb-3"
> >
{/* logo + title on one row (no overflow); subtitle beneath the title */} {/* logo + title on one row (no overflow); subtitle beneath the title */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -159,19 +165,24 @@ export function HomeScreen() {
</button> </button>
</div> </div>
</div> </div>
{/* ===== end column A ===== */}
</div>
{/* ===== column B: tiles + footer ===== */}
<div className="flex flex-col landscape:flex-1 landscape:max-w-sm landscape:justify-center landscape:min-h-0">
{/* tiles */} {/* tiles */}
<div className="grid grid-cols-4 gap-2.5 mt-3 sm:mt-4"> <div className="grid grid-cols-4 gap-2.5 mt-3 sm:mt-4 landscape:mt-0">
<Tile icon={<User className="size-5" />} label={t("menu.profile")} tint="teal" onClick={() => nav("profile")} /> <Tile icon={<User className="size-5" />} label={t("menu.profile")} tint="teal" onClick={() => nav("profile")} />
<Tile icon={<Users className="size-5" />} label={t("menu.friends")} tint="sky" onClick={() => nav(isAuthed ? "friends" : "auth")} /> <Tile icon={<Users className="size-5" />} label={t("menu.friends")} tint="sky" onClick={() => nav(isAuthed ? "friends" : "auth")} />
<Tile icon={<Trophy className="size-5" />} label={t("menu.leaderboard")} tint="gold" onClick={() => nav("leaderboard")} /> <Tile icon={<Trophy className="size-5" />} label={t("menu.leaderboard")} tint="gold" onClick={() => nav("leaderboard")} />
<Tile icon={<ShoppingBag className="size-5" />} label={t("menu.shop")} tint="rose" onClick={() => nav("shop")} /> <Tile icon={<ShoppingBag className="size-5" />} label={t("menu.shop")} tint="rose" onClick={() => nav("shop")} />
</div> </div>
<div className="flex-1" /> <div className="flex-1 landscape:hidden" />
{/* footer */} {/* footer */}
<div className="flex items-center justify-between gap-2 pt-4 sm:pt-6 pb-2"> <div className="flex items-center justify-between gap-2 pt-4 sm:pt-6 pb-2 landscape:pt-0 landscape:mt-4">
{isAuthed ? ( {isAuthed ? (
<button <button
onClick={signOut} onClick={signOut}
@@ -197,6 +208,10 @@ export function HomeScreen() {
{t("home.lang")} {t("home.lang")}
</button> </button>
</div> </div>
{/* ===== end column B ===== */}
</div>
{/* ===== end content columns ===== */}
</div>
</div> </div>
</main> </main>
); );
-18
View File
@@ -5,7 +5,6 @@ import { useEffect, useRef, useState } from "react";
import { GameTable } from "@/components/GameTable"; import { GameTable } from "@/components/GameTable";
import { PostMatchRewardsModal } from "@/components/online/PostMatchRewardsModal"; import { PostMatchRewardsModal } from "@/components/online/PostMatchRewardsModal";
import { MatchIntroOverlay } from "@/components/online/MatchIntroOverlay"; import { MatchIntroOverlay } from "@/components/online/MatchIntroOverlay";
import { RotatePrompt } from "@/components/online/RotatePrompt";
import { useGameStore } from "@/lib/game-store"; import { useGameStore } from "@/lib/game-store";
import { useSessionStore } from "@/lib/session-store"; import { useSessionStore } from "@/lib/session-store";
import { useUIStore } from "@/lib/ui-store"; import { useUIStore } from "@/lib/ui-store";
@@ -59,20 +58,6 @@ export function GameScreen() {
}; };
}, []); }, []);
// Landscape-first table: best-effort lock to landscape on Android/PWA (iOS &
// desktop reject it — harmless). Restored to portrait/auto when leaving.
useEffect(() => {
const o = (screen as unknown as { orientation?: { lock?: (m: string) => Promise<void>; unlock?: () => void } }).orientation;
o?.lock?.("landscape").catch(() => {});
return () => {
try {
o?.unlock?.();
} catch {
/* unsupported — ignore */
}
};
}, []);
const notifyAchievements = (r: RewardResult) => { const notifyAchievements = (r: RewardResult) => {
for (const a of r.newAchievements) for (const a of r.newAchievements)
pushNotification({ pushNotification({
@@ -157,9 +142,6 @@ export function GameScreen() {
onForfeit={canForfeit ? () => useGameStore.getState().forfeit() : undefined} onForfeit={canForfeit ? () => useGameStore.getState().forfeit() : undefined}
/> />
{/* Landscape-first: nudge to rotate when held in portrait on a phone */}
<RotatePrompt />
{/* UNO-style "players joining the table" intro (online matches, once) */} {/* UNO-style "players joining the table" intro (online matches, once) */}
<AnimatePresence> <AnimatePresence>
{introPending && mode === "online" && ( {introPending && mode === "online" && (
+4 -4
View File
@@ -328,8 +328,8 @@ const fa: Dict = {
"settings.sound": "افکت صدا", "settings.sound": "افکت صدا",
"settings.music": "موسیقی پس‌زمینه", "settings.music": "موسیقی پس‌زمینه",
"rotate.title": "گوشی را بچرخانید", "rotate.title": "گوشی را بچرخانید",
"rotate.desc": "برای تجربهٔ بهترِ میز بازی، گوشی را افقی (چرخانده) نگه دارید.", "rotate.desc": "برگ وسط در حالت افقی بهترین تجربه را دارد. گوشی را چرخانده (افقی) نگه دارید.",
"rotate.anyway": "همین‌طور عمودی بازی می‌کنم", "rotate.anyway": "ادامه در حالت عمودی",
"settings.musicStyle": "سبک موسیقی", "settings.musicStyle": "سبک موسیقی",
"settings.trackSantoor": "سنتی (سنتور)", "settings.trackSantoor": "سنتی (سنتور)",
"settings.trackPlayful": "شاد", "settings.trackPlayful": "شاد",
@@ -667,8 +667,8 @@ const en: Dict = {
"settings.sound": "Sound effects", "settings.sound": "Sound effects",
"settings.music": "Background music", "settings.music": "Background music",
"rotate.title": "Rotate your phone", "rotate.title": "Rotate your phone",
"rotate.desc": "The table plays best in landscape — turn your phone sideways for the full experience.", "rotate.desc": "Barg-e Vasat is best in landscape — turn your phone sideways for the full experience.",
"rotate.anyway": "Keep playing in portrait", "rotate.anyway": "Continue in portrait",
"settings.musicStyle": "Music style", "settings.musicStyle": "Music style",
"settings.trackSantoor": "Traditional (Santoor)", "settings.trackSantoor": "Traditional (Santoor)",
"settings.trackPlayful": "Playful", "settings.trackPlayful": "Playful",