diff --git a/.codex-remote-attachments/01a11089-f830-7442-857d-9e4559b01bc2/5e9df3a4-4f87-4069-b63e-6e5570c635da/1-1728.jpg b/.codex-remote-attachments/01a11089-f830-7442-857d-9e4559b01bc2/5e9df3a4-4f87-4069-b63e-6e5570c635da/1-1728.jpg new file mode 100644 index 0000000..afcd265 Binary files /dev/null and b/.codex-remote-attachments/01a11089-f830-7442-857d-9e4559b01bc2/5e9df3a4-4f87-4069-b63e-6e5570c635da/1-1728.jpg differ diff --git a/app/account.css b/app/account.css index c9b4672..92de7ce 100644 --- a/app/account.css +++ b/app/account.css @@ -284,3 +284,10 @@ gap: 8px; } } +.legacy-password-dialog { width: min(540px, calc(100vw - 24px)); } +.legacy-password-dialog > svg { color: var(--green); } +.legacy-password-dialog h2 { margin-top: 16px; } +.legacy-password-dialog .field input { width: 100%; font: 14px monospace; padding: 12px; background: var(--bg); color: var(--text); border: 1px solid var(--line); } +.legacy-password-dialog > .text-link { margin-top: 18px; } +.password-saved { display: flex; gap: 10px; align-items: flex-start; margin: 20px 0; } +.password-saved input { flex-shrink: 0; margin-top: 5px; accent-color: var(--green); } diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts index 0b264e6..ab77b64 100644 --- a/app/auth/callback/route.ts +++ b/app/auth/callback/route.ts @@ -11,11 +11,13 @@ export async function GET(request: NextRequest) { const legacyToken = request.nextUrl.searchParams.get("legacy_recovery_token"); const type = request.nextUrl.searchParams.get("type"); const next = authDestination(request.nextUrl.searchParams.get("next")); + if (type === "magiclink") + return NextResponse.redirect(new URL("/log-in?auth=failed", origin)); if ( code || legacyToken || (tokenHash && - (type === "email" || type === "recovery" || type === "magiclink")) + (type === "email" || type === "signup" || type === "recovery")) ) { const client = await authClient(); let { data, error } = code @@ -24,7 +26,7 @@ export async function GET(request: NextRequest) { ? await client.auth.exchangeCodeForSession(legacyToken) : await client.auth.verifyOtp({ token_hash: tokenHash!, - type: type as "email" | "recovery" | "magiclink", + type: type as "email" | "signup" | "recovery", }); // Older email templates could place a recovery token hash in /code=... // instead of a PKCE code. Only Supabase can decide whether it is valid. diff --git a/app/layout.tsx b/app/layout.tsx index e7289db..c419231 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -12,8 +12,11 @@ import { SiteCursor } from "@/components/SiteCursor"; import { I18nProvider } from "@/components/I18nProvider"; import { getI18n } from "@/lib/i18n"; import "./polish.css"; +import "./mobile-game.css"; import { SiteSettingsProvider } from "@/components/SiteSettingsProvider"; import { initialSiteSettings } from "@/lib/site-settings"; +import { user } from "@/lib/supabase"; +import { LegacyPasswordNotice } from "@/components/LegacyPasswordNotice"; export async function generateMetadata(): Promise { const { t } = await getI18n(); return { @@ -31,10 +34,8 @@ export default async function RootLayout({ }: { children: React.ReactNode; }) { - const [{ locale, preference, detectedLocale }, settings] = await Promise.all([ - getI18n(), - initialSiteSettings(), - ]); + const [{ locale, preference, detectedLocale }, settings, account] = + await Promise.all([getI18n(), initialSiteSettings(), user()]); return ( @@ -46,6 +47,7 @@ export default async function RootLayout({ {children} + diff --git a/app/mobile-game.css b/app/mobile-game.css new file mode 100644 index 0000000..bb548bf --- /dev/null +++ b/app/mobile-game.css @@ -0,0 +1,192 @@ +.mobile-map-toggle, +.report-icon { + display: none; +} +@media (max-width: 700px) { + .game-page { + min-height: 0; + } + .game-header { + display: grid; + grid-template-columns: minmax(0, 1fr) 44px; + gap: 8px; + padding: max(10px, env(safe-area-inset-top)) + max(10px, env(safe-area-inset-right)) 12px + max(10px, env(safe-area-inset-left)); + } + .game-header .brand { + font-size: 17px; + white-space: nowrap; + gap: 6px; + } + .game-header .brand-cube { + flex-shrink: 0; + } + .game-header .report-trigger { + width: 44px; + height: 44px; + padding: 0; + } + .game-header .report-trigger > span { + display: none; + } + .game-header .report-icon { + display: block; + } + .game-info { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + width: 100%; + gap: 8px; + padding: 8px; + border-radius: 10px; + } + .game-info > div { + min-width: 0; + } + .game-info small { + white-space: nowrap; + font-size: 8px; + letter-spacing: 0; + } + .game-info strong { + white-space: nowrap; + font-size: 17px; + line-height: 1.4; + } + .panorama-controls { + top: calc(max(10px, env(safe-area-inset-top)) + 112px); + left: max(10px, env(safe-area-inset-left)); + gap: 6px; + } + .panorama-controls button { + width: 44px; + height: 44px; + padding: 0; + } + .game-page .game-map, + .game-page .game-map.expanded { + right: max(8px, env(safe-area-inset-right)); + left: max(8px, env(safe-area-inset-left)); + bottom: max(10px, env(safe-area-inset-bottom)); + width: auto; + max-width: none; + box-shadow: 0 8px 24px #0006; + } + .game-map:not(.expanded) .game-map-content { + position: absolute; + bottom: 100%; + left: 0; + right: 0; + visibility: hidden; + pointer-events: none; + } + .mobile-map-toggle { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + width: 100%; + min-height: 56px; + padding: 12px; + border: 0; + color: #fff1d0; + background: #241e18; + font: 14px var(--font-pixel, monospace); + } + .mobile-map-toggle small { + font: 11px monospace; + } + .game-map.expanded .mobile-map-toggle { + display: none; + } + .game-map .map-topline { + min-height: 44px; + padding: 0 8px 0 12px; + } + .game-map .map-topline button { + display: grid; + place-items: center; + width: 44px; + height: 44px; + } + .game-page .game-map .map-surface { + height: min(46dvh, 360px); + border-width: 12px; + border-image-width: 12px; + } + .game-map .map-actions { + padding: 8px 10px 10px; + } + .game-map .map-actions .button { + min-height: 48px; + padding: 10px 12px; + font-size: 13px; + white-space: nowrap; + } + .game-map .map-actions small { + margin-bottom: 8px; + } + .game-map .leaflet-bar a { + width: 40px; + height: 40px; + line-height: 40px; + } + .look-help { + display: none; + } + .round-result { + max-width: calc(100% - 24px); + max-height: calc(100dvh - 140px); + overflow-y: auto; + padding: 20px; + } + html[lang] .round-result h2 { + font-size: 21px; + } + .round-result .score { + font-size: 42px; + } +} +@media (max-height: 500px) and (max-width: 950px) { + .game-page { + min-height: 0; + } + .game-header { + display: flex; + padding: 8px 12px; + gap: 10px; + } + .game-header .brand { + font-size: 16px; + } + .game-header .report-trigger { + order: 3; + } + .game-info { + width: auto; + flex: 1; + max-width: 300px; + margin-left: auto; + gap: 16px; + padding: 6px 10px; + } + .panorama-controls { + top: 72px; + left: 12px; + } + .game-page .game-map, + .game-page .game-map.expanded { + left: auto; + width: min(440px, calc(100vw - 80px)); + right: 8px; + bottom: max(8px, env(safe-area-inset-bottom)); + } + .game-page .game-map .map-surface { + height: max(100px, calc(100dvh - 190px)); + } + .round-result { + max-height: calc(100dvh - 80px); + } +} diff --git a/components/AccountSettings.tsx b/components/AccountSettings.tsx index 3d723bc..a9f9dc7 100644 --- a/components/AccountSettings.tsx +++ b/components/AccountSettings.tsx @@ -134,7 +134,7 @@ export function AccountSettings({ )}
-

{t("Signed up with an email link?")}

+

{t("Forgot your password?")}

{t( "Set your first password, or recover a forgotten one, using a link sent to your account email.", diff --git a/components/Game.tsx b/components/Game.tsx index 30e3bee..22d11f0 100644 --- a/components/Game.tsx +++ b/components/Game.tsx @@ -9,6 +9,7 @@ import { MousePointer2, Move, MapPin, + Map as MapIcon, } from "lucide-react"; import { Brand } from "./Brand"; import { Panorama } from "./Panorama"; @@ -174,6 +175,7 @@ export function Game({ id }: { id: string }) { setSession(null); setResult(null); setPin(null); + setExpanded(false); setLoaded({ panorama: false, map: false }); timingOut.current = false; setError(""); @@ -228,40 +230,56 @@ export function Game({ id }: { id: string }) {

{session && (
-
- {t("↑ N · PLACE YOUR GUESS")} - -
- -
- - {pin - ? `X ${Math.round(pin.x)} · Z ${Math.round(pin.z)}` - : t("Click the map to drop a pin")} - - + +
+
+ {t("↑ N · PLACE YOUR GUESS")} + +
+ +
+ + {pin + ? `X ${Math.round(pin.x)} · Z ${Math.round(pin.z)}` + : t("Click the map to drop a pin")} + + +
)} diff --git a/components/GuessMap.tsx b/components/GuessMap.tsx index f04e14c..b9523e4 100644 --- a/components/GuessMap.tsx +++ b/components/GuessMap.tsx @@ -59,7 +59,11 @@ export function GuessMap({ zoomSnap: 0, maxBounds: b.pad(0.15), attributionControl: false, + zoomControl: false, }); + L.control + .zoom({ zoomInTitle: t("Zoom in"), zoomOutTitle: t("Zoom out") }) + .addTo(m); // Install cleanup immediately, including when later initialization fails. let observer: ResizeObserver | undefined; cleanup = () => { diff --git a/components/LegacyPasswordNotice.tsx b/components/LegacyPasswordNotice.tsx new file mode 100644 index 0000000..b3be33e --- /dev/null +++ b/components/LegacyPasswordNotice.tsx @@ -0,0 +1,147 @@ +"use client"; +import { useEffect, useRef, useState } from "react"; +import { usePathname } from "next/navigation"; +import { Copy, KeyRound } from "lucide-react"; +import { useI18n } from "./I18nProvider"; +import { api } from "@/lib/client-api"; +import { generateInitialPassword } from "@/shared/initial-password"; + +export function LegacyPasswordNotice({ enabled }: { enabled: boolean }) { + const { t } = useI18n(); + const pathname = usePathname(); + const dialog = useRef(null); + const [needed, setNeeded] = useState(false); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [saved, setSaved] = useState(false); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(""); + useEffect(() => { + if (!enabled || pathname?.startsWith("/auth/") || password) return; + let active = true; + api<{ needed: boolean }>("auth/initial-password") + .then((data) => { + if (active) setNeeded(data.needed); + }) + .catch(() => {}); + return () => { + active = false; + }; + }, [enabled, pathname, password]); + useEffect(() => { + if (!needed) return; + const element = dialog.current; + element?.showModal(); + return () => { + element?.close(); + }; + }, [needed]); + async function generate() { + if (busy) return; + setBusy(true); + setError(""); + const generated = generateInitialPassword(); + try { + await api("auth/initial-password", { password: generated }); + // Only keep the confirmed password in this mounted dialog's memory. + setPassword(generated); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + } + function dismiss() { + setNeeded(false); + setPassword(""); + setSaved(false); + setCopied(false); + setError(""); + } + async function copy() { + try { + await navigator.clipboard.writeText(password); + setCopied(true); + } catch { + setError("Select the password and copy it manually."); + } + } + if (!needed) return null; + return ( + { + if (password || busy) event.preventDefault(); + else dismiss(); + }} + > + + ); +} diff --git a/components/Panorama.tsx b/components/Panorama.tsx index 11c34b8..2b4a411 100644 --- a/components/Panorama.tsx +++ b/components/Panorama.tsx @@ -39,21 +39,21 @@ export function Panorama({ const element = mount.current!; view.current = { yaw: 0, pitch: 0, fov: 80 }; const down = (e: PointerEvent) => { - if (e.button !== 0) return; + if (e.button !== 0 || drag) return; element.setPointerCapture(e.pointerId); - drag = { x: e.clientX, y: e.clientY }; + drag = { x: e.clientX, y: e.clientY, pointerId: e.pointerId }; }, move = (e: PointerEvent) => { - if (!drag) return; + if (!drag || drag.pointerId !== e.pointerId) return; view.current.yaw -= (e.clientX - drag.x) * 0.003; view.current.pitch = Math.max( -1.5, Math.min(1.5, view.current.pitch + (e.clientY - drag.y) * 0.003), ); - drag = { x: e.clientX, y: e.clientY }; + drag = { x: e.clientX, y: e.clientY, pointerId: e.pointerId }; }, - up = () => { - drag = null; + up = (e: PointerEvent) => { + if (drag?.pointerId === e.pointerId) drag = null; }, wheel = (e: WheelEvent) => { if (!zoom) return; @@ -70,7 +70,7 @@ export function Panorama({ "The panorama graphics context was lost. Reconnect to restore the view.", ); }; - let drag: { x: number; y: number } | null = null; + let drag: { x: number; y: number; pointerId: number } | null = null; if (urls.length !== 6) { callbacks.current.onError( "This panorama is missing one or more of its six faces.", @@ -133,6 +133,7 @@ export function Panorama({ element.addEventListener("pointermove", move); element.addEventListener("pointerup", up); element.addEventListener("pointercancel", up); + element.addEventListener("lostpointercapture", up); element.addEventListener("wheel", wheel, { passive: false }); callbacks.current.onReady(); }) @@ -150,6 +151,7 @@ export function Panorama({ element.removeEventListener("pointermove", move); element.removeEventListener("pointerup", up); element.removeEventListener("pointercancel", up); + element.removeEventListener("lostpointercapture", up); element.removeEventListener("wheel", wheel); geometry?.dispose(); material?.dispose(); diff --git a/components/ReportContent.tsx b/components/ReportContent.tsx index 55d9594..1be4324 100644 --- a/components/ReportContent.tsx +++ b/components/ReportContent.tsx @@ -4,6 +4,7 @@ import { useI18n } from "@/components/I18nProvider"; import { useEffect, useRef, useState } from "react"; import { api } from "./StartGame"; import { reportCategories } from "@/shared/moderation"; +import { Flag } from "lucide-react"; export function ReportContent({ datasetId, @@ -66,14 +67,16 @@ export function ReportContent({ return ( <> {message && (

diff --git a/components/SignIn.tsx b/components/SignIn.tsx index 20996f4..160e5a6 100644 --- a/components/SignIn.tsx +++ b/components/SignIn.tsx @@ -7,7 +7,7 @@ import { ArrowRight, Compass, Mail, ShieldCheck } from "lucide-react"; import { api } from "./StartGame"; import { authActionSchema, authDestination } from "@/shared/account"; -type Mode = "login" | "signup" | "recover" | "magic"; +type Mode = "login" | "signup" | "recover"; export function SignIn({ next = "/dashboard", initialError = "", @@ -41,9 +41,7 @@ export function SignIn({ ? { action: mode, email, password, displayName: name, next } : mode === "login" ? { action: mode, email, password, next } - : mode === "magic" - ? { action: mode, email, next } - : { action: mode, email }; + : { action: mode, email }; const valid = authActionSchema.safeParse(body); if (!valid.success) { setError(valid.error.issues[0].message); @@ -75,7 +73,6 @@ export function SignIn({ login: "Welcome back.", signup: "Make yourself at home.", recover: "Find your way back.", - magic: "An email, a way in.", }[mode]; return (

@@ -132,11 +129,7 @@ export function SignIn({ ? t("Choose the name people will see on your maps.") : mode === "recover" ? t("We’ll email you a link to set a new password.") - : mode === "magic" - ? t( - "For existing accounts. Open the link on this device to continue.", - ) - : t("Log in with your email and password.")} + : t("Log in with your email and password.")}

{mode === "signup" && ( @@ -230,7 +223,7 @@ export function SignIn({

)}
- {mode === "login" && ( -
- {t("Already use email links?")} - -
- )} - {(mode === "recover" || mode === "magic") && ( + {mode === "recover" && (