diff --git a/src/app/(auth)/auth-actions.ts b/src/app/(auth)/auth-actions.ts index 3d75e8b..89c8455 100644 --- a/src/app/(auth)/auth-actions.ts +++ b/src/app/(auth)/auth-actions.ts @@ -16,10 +16,12 @@ export async function loginAction( _prev: FormState, data: FormData, ): Promise { - const email = String(data.get("email") || ""); + // The form sends a single `identifier` field — value may be either an email + // or a username, distinguished server-side by the presence of "@". + const identifier = String(data.get("identifier") || data.get("email") || ""); const password = String(data.get("password") || ""); const next = safeNext(data.get("next")); - const res = await loginUser(email, password); + const res = await loginUser(identifier, password); if (!res.ok) return { error: res.error }; redirect(next); } @@ -31,13 +33,19 @@ export async function registerAction( const email = String(data.get("email") || ""); const password = String(data.get("password") || ""); const name = String(data.get("name") || ""); + const usernameRaw = String(data.get("username") || "").trim(); + const username = usernameRaw || undefined; const next = safeNext(data.get("next")); - const res = await registerUser(email, password, name); + const res = await registerUser(email, password, name, username); if (!res.ok) return { error: res.error }; redirect(next); } +// Intentionally does NOT call redirect(). The client invokes this from a +// transition and then navigates with the router — calling redirect() inside +// a useTransition-scoped server action makes React surface the redirect +// "error" as an uncaught rejection in some browsers, which is the symptom +// of the logout button doing nothing visible. export async function logoutAction(): Promise { await logoutImpl(); - redirect("/login"); } diff --git a/src/app/(auth)/auth-form.tsx b/src/app/(auth)/auth-form.tsx index fc53815..1e8057e 100644 --- a/src/app/(auth)/auth-form.tsx +++ b/src/app/(auth)/auth-form.tsx @@ -75,32 +75,64 @@ export function AuthForm({ {!isLogin && ( - + <> + + + + + + + + + + )} + + {isLogin ? ( + + + ) : ( + + + )} - - - - - ; +}) { + const { id } = await params; + const collectionId = Number(id); + if (!Number.isFinite(collectionId) || collectionId <= 0) notFound(); + + const uid = await getCurrentUserId(); + if (!uid) { + redirect(`/login?next=${encodeURIComponent(`/collections/${collectionId}`)}`); + } + + const [me, users, places, collections, publicPlaces] = await Promise.all([ + getCurrentUser(), + getAllUsers(), + getPlacesForUser(uid), + getCollectionsForUser(uid), + getPublicPlaces(uid), + ]); + if (!me) redirect("/login"); + if (!collections.some((c) => c.id === collectionId)) notFound(); + + return ( + + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 193a917..a01cb10 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -655,3 +655,776 @@ input, textarea { font-family: inherit; } outline: 2px solid var(--ring); outline-offset: 2px; } + +/* ═════════════════════════════════════════════════════════ + DESKTOP (≥ 1024px) — 3-pane shell: sidebar + list + detail + Activated by JS-mounted wrapping content + in a `.dt-shell` container. Mobile styles above continue to + apply where they don't conflict (badges, avatars, etc.). + Spec: design `Places Desktop.html` (see chat handoff). + ═════════════════════════════════════════════════════════ */ + +.dt-shell { + display: grid; + grid-template-columns: 268px 420px 1fr; + height: 100vh; + height: 100dvh; + min-height: 0; + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans); + font-size: 15px; + line-height: 1.5; + overflow: hidden; +} + +/* ── Sidebar ────────────────────────────────────────────── */ +.dt-sidebar { + background: var(--background-soft); + border-right: 0.5px solid var(--border); + display: flex; + flex-direction: column; + min-height: 0; + padding: 18px 14px 14px; + gap: 14px; + overflow-y: auto; +} + +.dt-brand { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 8px 6px; +} +.dt-brand-logo { + width: 34px; + height: 34px; + border-radius: 10px; + background: var(--primary); + color: var(--primary-foreground); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 1px 2px rgba(28, 22, 14, 0.1), + 0 6px 14px color-mix(in oklch, var(--primary) 28%, transparent); +} +.dt-brand-name { + font-family: var(--font-display); + font-weight: 600; + font-size: 20px; + letter-spacing: -0.02em; +} +.dt-brand-sub { + font-size: 11px; + color: var(--subtle-foreground); + letter-spacing: 0.06em; + text-transform: uppercase; + margin-top: 1px; + font-weight: 500; +} + +.dt-add-btn { + appearance: none; + display: flex; + align-items: center; + gap: 10px; + width: 100%; + height: 42px; + padding: 0 14px; + border-radius: var(--radius-md); + border: 0; + background: var(--primary); + color: var(--primary-foreground); + font-weight: 600; + font-size: 14px; + letter-spacing: -0.005em; + cursor: pointer; + box-shadow: 0 1px 2px rgba(28, 22, 14, 0.1), + 0 4px 10px color-mix(in oklch, var(--primary) 22%, transparent); + transition: transform 0.08s ease, opacity 0.15s ease; +} +.dt-add-btn:active { transform: scale(0.98); } +.dt-add-btn .kbd { + margin-left: auto; + opacity: 0.75; + font-size: 11.5px; + font-weight: 600; + letter-spacing: 0.04em; +} + +.dt-nav-section { display: flex; flex-direction: column; gap: 1px; } +.dt-nav-label { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--subtle-foreground); +} +.dt-nav-label button { + background: transparent; + border: 0; + color: inherit; + width: 22px; + height: 22px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} +.dt-nav-label button:hover { + background: var(--muted); + color: var(--foreground); +} + +.dt-nav-item { + appearance: none; + width: 100%; + display: flex; + align-items: center; + gap: 10px; + height: 36px; + padding: 0 10px; + border-radius: 8px; + border: 0; + background: transparent; + color: var(--foreground); + font-size: 14px; + font-weight: 500; + letter-spacing: -0.005em; + text-align: left; + transition: background 0.12s ease, color 0.12s ease; + cursor: pointer; +} +.dt-nav-item:hover { background: var(--muted); } +.dt-nav-item[data-active="true"] { + background: color-mix(in oklch, var(--primary) 14%, transparent); + color: var(--primary); + font-weight: 600; +} +.dt-nav-item[data-active="true"] .dt-nav-count { + background: color-mix(in oklch, var(--primary) 22%, transparent); + color: var(--primary); +} +.dt-nav-item .dt-nav-count { + margin-left: auto; + font-size: 11.5px; + font-weight: 600; + padding: 1px 7px; + border-radius: 9999px; + background: var(--muted); + color: var(--muted-foreground); +} + +.dt-coll-row { + appearance: none; + width: 100%; + display: flex; + align-items: center; + gap: 10px; + height: 34px; + padding: 0 10px; + border-radius: 8px; + border: 0; + background: transparent; + color: var(--foreground); + font-size: 13.5px; + font-weight: 500; + text-align: left; + cursor: pointer; +} +.dt-coll-row:hover { background: var(--muted); } +.dt-coll-row[data-active="true"] { + background: color-mix(in oklch, var(--primary) 14%, transparent); + color: var(--primary); +} +.dt-coll-row .dt-coll-thumb { + width: 22px; + height: 22px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + color: var(--muted-foreground); + background: var(--muted); + flex-shrink: 0; +} +.dt-coll-row[data-type="trip"] .dt-coll-thumb { + background: color-mix(in oklch, var(--primary) 16%, transparent); + color: var(--primary); +} +.dt-coll-row .dt-coll-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.dt-coll-row .dt-coll-meta { + font-size: 11.5px; + color: var(--subtle-foreground); + flex-shrink: 0; +} + +.dt-sidebar-spacer { flex: 1; min-height: 12px; } + +.dt-me { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + border-radius: 10px; + background: var(--card); + border: 0.5px solid var(--border); + cursor: pointer; + text-align: left; + appearance: none; + color: inherit; + transition: background 0.12s ease; +} +.dt-me:hover { background: var(--muted); } +.dt-me-name { + font-size: 14px; + font-weight: 600; + letter-spacing: -0.005em; +} +.dt-me-status { + font-size: 11.5px; + color: var(--subtle-foreground); + display: flex; + align-items: center; + gap: 5px; +} +.dt-me-status::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 9999px; + background: var(--success); + box-shadow: 0 0 0 2px color-mix(in oklch, var(--success) 25%, transparent); +} + +.dt-avatar-wrap { position: relative; } +.dt-presence { + position: absolute; + right: -1px; + bottom: -1px; + width: 10px; + height: 10px; + border-radius: 9999px; + background: var(--success); + box-shadow: 0 0 0 2px var(--card); +} + +/* ── Middle list pane ───────────────────────────────────── */ +.dt-list-pane { + border-right: 0.5px solid var(--border); + background: var(--background); + display: flex; + flex-direction: column; + min-height: 0; +} +.dt-list-head { + padding: 16px 18px 12px; + display: flex; + flex-direction: column; + gap: 12px; + border-bottom: 0.5px solid var(--border); +} +.dt-list-title-row { + display: flex; + align-items: center; + justify-content: space-between; +} +.dt-list-title { + font-family: var(--font-display); + font-weight: 600; + font-size: 22px; + letter-spacing: -0.02em; +} +.dt-list-sub { + font-size: 12.5px; + color: var(--muted-foreground); + margin-top: 2px; +} + +.dt-search { + display: flex; + align-items: center; + gap: 9px; + height: 38px; + padding: 0 12px; + background: var(--input); + border: 0.5px solid var(--border); + border-radius: var(--radius-md); + color: var(--foreground); + transition: border-color 0.15s ease, background 0.15s ease; +} +.dt-search:focus-within { + border-color: var(--primary); + background: var(--card); +} +.dt-search input { + flex: 1; + border: 0; + background: transparent; + outline: none; + font: inherit; + font-size: 14px; + min-width: 0; + color: inherit; +} +.dt-search .dt-kbd { + font-size: 11px; + font-weight: 600; + color: var(--subtle-foreground); + background: var(--card); + border: 0.5px solid var(--border); + border-radius: 5px; + padding: 1px 6px; +} + +.dt-filter-rail { + display: flex; + gap: 6px; + overflow-x: auto; + overflow-y: hidden; + margin: 0 -2px; + padding: 0 2px 2px; +} +.dt-filter-rail::-webkit-scrollbar { display: none; } +.dt-filter-rail { scrollbar-width: none; } + +.dt-pill { + appearance: none; + display: inline-flex; + align-items: center; + gap: 5px; + height: 30px; + padding: 0 12px; + border-radius: 9999px; + background: var(--muted); + border: 0; + color: var(--muted-foreground); + font-size: 13px; + font-weight: 500; + white-space: nowrap; + flex-shrink: 0; + cursor: pointer; + transition: all 0.12s ease; +} +.dt-pill:hover { + color: var(--foreground); + background: color-mix(in oklch, var(--muted) 70%, var(--border-strong)); +} +.dt-pill[data-active="true"] { + background: var(--foreground); + color: var(--background); +} + +.dt-sort-btn { + appearance: none; + display: inline-flex; + align-items: center; + gap: 5px; + height: 30px; + padding: 0 10px; + border-radius: 8px; + background: transparent; + border: 0; + color: var(--muted-foreground); + font-size: 13px; + font-weight: 500; + cursor: pointer; +} +.dt-sort-btn:hover { color: var(--foreground); background: var(--muted); } + +.dt-list-scroll { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 8px 10px 18px; +} +.dt-list-scroll::-webkit-scrollbar { width: 10px; } +.dt-list-scroll::-webkit-scrollbar-thumb { + background: color-mix(in oklch, var(--border-strong) 70%, transparent); + border-radius: 9999px; + border: 2px solid transparent; + background-clip: padding-box; +} +.dt-list-scroll::-webkit-scrollbar-thumb:hover { + background: var(--border-strong); + background-clip: padding-box; +} + +/* ── Place row (desktop) ───────────────────────────────── */ +.dt-row { + appearance: none; + display: grid; + grid-template-columns: 64px 1fr auto; + gap: 12px; + align-items: center; + width: 100%; + padding: 10px 10px; + border-radius: var(--radius-md); + background: transparent; + border: 0; + text-align: left; + color: inherit; + cursor: pointer; + transition: background 0.1s ease; + position: relative; +} +.dt-row:hover { background: var(--muted); } +.dt-row[data-active="true"] { + background: color-mix(in oklch, var(--primary) 10%, transparent); +} +.dt-row[data-active="true"]::before { + content: ""; + position: absolute; + left: 0; + top: 8px; + bottom: 8px; + width: 3px; + border-radius: 9999px; + background: var(--primary); +} +.dt-row-cover { + width: 64px; + height: 64px; + border-radius: 10px; + overflow: hidden; + background: var(--muted); + flex-shrink: 0; + position: relative; +} +.dt-row-name { + font-size: 15px; + font-weight: 600; + letter-spacing: -0.01em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: flex; + align-items: center; + gap: 6px; +} +.dt-row-name .dt-visited-dot { + display: inline-flex; + align-items: center; + color: var(--success); +} +.dt-row-meta { + margin-top: 2px; + font-size: 12.5px; + color: var(--muted-foreground); + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.dt-row-meta-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} +.dt-row-tags { + margin-top: 6px; + display: flex; + flex-wrap: nowrap; + gap: 5px; + overflow: hidden; +} +.dt-row-tags .badge { + height: 18px; + font-size: 11px; + padding: 0 7px; + flex-shrink: 0; +} +.dt-row-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + flex-shrink: 0; +} +.dt-row-rating { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 12.5px; + font-weight: 600; +} +.dt-row-rating .lucide-star { color: var(--star); } + +/* ── Detail pane (right) ───────────────────────────────── */ +.dt-detail-pane { + min-height: 0; + background: var(--background); + display: flex; + flex-direction: column; + overflow: hidden; +} +.dt-detail-topbar { + height: 56px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 16px 0 20px; + border-bottom: 0.5px solid var(--border); + background: color-mix(in oklch, var(--background) 92%, transparent); + -webkit-backdrop-filter: blur(20px) saturate(180%); + backdrop-filter: blur(20px) saturate(180%); + flex-shrink: 0; +} +.dt-detail-crumb { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--muted-foreground); + min-width: 0; +} +.dt-detail-crumb b { + color: var(--foreground); + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dt-detail-scroll { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} +.dt-detail-scroll::-webkit-scrollbar { width: 12px; } +.dt-detail-scroll::-webkit-scrollbar-thumb { + background: color-mix(in oklch, var(--border-strong) 60%, transparent); + border-radius: 9999px; + border: 3px solid transparent; + background-clip: padding-box; +} + +.dt-icon-btn { + appearance: none; + width: 36px; + height: 36px; + border-radius: 8px; + border: 0; + background: transparent; + color: var(--muted-foreground); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; +} +.dt-icon-btn:hover { background: var(--muted); color: var(--foreground); } + +.dt-btn { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + height: 36px; + padding: 0 14px; + border-radius: 8px; + border: 0; + background: var(--primary); + color: var(--primary-foreground); + font-size: 13.5px; + font-weight: 600; + letter-spacing: -0.005em; + cursor: pointer; + transition: opacity 0.15s ease, transform 0.08s ease; +} +.dt-btn:active { transform: scale(0.985); } +.dt-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.dt-btn--outline { + background: transparent; + border: 0.5px solid var(--border-strong); + color: var(--foreground); +} +.dt-btn--ghost { background: var(--muted); color: var(--foreground); } +.dt-btn--danger { background: var(--danger); color: white; } +.dt-btn--lg { height: 42px; padding: 0 18px; font-size: 14.5px; } + +/* ── Hero strip ─────────────────────────────────────────── */ +.dt-hero { + position: relative; + aspect-ratio: 21 / 9; + background: var(--muted); + overflow: hidden; +} +.dt-hero-overlay { + position: absolute; + inset: 0; + background: linear-gradient(180deg, + rgba(0, 0, 0, 0.15) 0%, + rgba(0, 0, 0, 0) 25%, + rgba(0, 0, 0, 0) 50%, + rgba(0, 0, 0, 0.65) 100%); +} +.dt-hero-tag { + display: inline-flex; + align-items: center; + gap: 6px; + background: rgba(20, 16, 10, 0.55); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); + color: #fff; + padding: 5px 12px; + border-radius: 9999px; + font-size: 12px; + font-weight: 600; +} + +/* ── Generic card on detail ─────────────────────────── */ +.dt-card { + background: var(--card); + border: 0.5px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} +.dt-card-row { + padding: 16px 20px; + display: flex; + align-items: center; + gap: 14px; +} +.dt-card-row + .dt-card-row { border-top: 0.5px solid var(--border); } + +.dt-sec-label { + font-size: 11.5px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--subtle-foreground); + display: inline-flex; + align-items: center; + gap: 6px; +} + +/* ── Map placeholder ──────────────────────────────────── */ +.dt-map { + position: relative; + background: + radial-gradient(ellipse at 30% 40%, + color-mix(in oklch, var(--primary) 15%, transparent), + transparent 55%), + radial-gradient(ellipse at 70% 70%, + color-mix(in oklch, var(--success) 12%, transparent), + transparent 55%), + repeating-linear-gradient(90deg, + color-mix(in oklch, var(--border) 80%, transparent) 0 1px, + transparent 1px 64px), + repeating-linear-gradient(0deg, + color-mix(in oklch, var(--border) 80%, transparent) 0 1px, + transparent 1px 64px), + var(--background-soft); + border-radius: var(--radius-lg); + border: 0.5px solid var(--border); + overflow: hidden; +} +.dt-map-pin { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -100%); + color: var(--primary); + filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.18)); +} +.dt-map-pulse { + position: absolute; + left: 50%; + top: 50%; + width: 16px; + height: 16px; + margin-left: -8px; + margin-top: -8px; + border-radius: 9999px; + background: color-mix(in oklch, var(--primary) 30%, transparent); + animation: dt-pulse 2s ease-out infinite; +} +@keyframes dt-pulse { + 0% { transform: scale(0.6); opacity: 0.9; } + 100% { transform: scale(2.4); opacity: 0; } +} + +/* ── Collection grid (inside detail pane) ──────────── */ +.dt-coll-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 14px; +} + +.dt-place-card { + appearance: none; + background: var(--card); + border: 0.5px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + text-align: left; + color: inherit; + cursor: pointer; + padding: 0; + display: flex; + flex-direction: column; + transition: transform 0.15s ease, box-shadow 0.18s ease; +} +.dt-place-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} +.dt-place-card-cover { + aspect-ratio: 16 / 10; + background: var(--muted); + position: relative; + overflow: hidden; +} + +/* ── Stats card ─────────────────────────────────────── */ +.dt-stat { + padding: 16px 18px; + background: var(--card); + border: 0.5px solid var(--border); + border-radius: var(--radius-lg); +} +.dt-stat-val { + font-family: var(--font-display); + font-size: 32px; + font-weight: 600; + letter-spacing: -0.02em; + line-height: 1; +} +.dt-stat-label { + margin-top: 6px; + font-size: 12.5px; + color: var(--muted-foreground); +} + +/* ── Offline banner (desktop) ─────────────────────── */ +.dt-offline { + display: flex; + align-items: center; + gap: 8px; + height: 32px; + padding: 0 16px; + background: var(--warning-soft); + color: oklch(38% 0.12 75); + font-size: 13px; + font-weight: 500; + border-bottom: 0.5px solid + color-mix(in oklch, var(--warning) 30%, transparent); +} +[data-theme="dark"] .dt-offline { color: var(--warning); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 79b321d..fa5372b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,31 +1,42 @@ import { redirect } from "next/navigation"; import { PlacesApp } from "./places-app"; +import { PublicHome } from "./public-home"; import { getCurrentUserId } from "@/lib/db/auth"; import { getAllUsers, getCollectionsForUser, getCurrentUser, getPlacesForUser, + getPublicPlaces, } from "@/lib/db/queries"; export const dynamic = "force-dynamic"; export default async function Page() { const uid = await getCurrentUserId(); - if (!uid) redirect("/login"); - const [me, users, places, collections] = await Promise.all([ + // Anonymous: serve the read-only public feed instead of redirecting to + // /login. Login is still available via the CTA in the header. + if (!uid) { + const places = await getPublicPlaces(); + return ; + } + + const [me, users, places, collections, publicPlaces] = await Promise.all([ getCurrentUser(), getAllUsers(), getPlacesForUser(uid), getCollectionsForUser(uid), + // Exclude places the user already has access to so the same row doesn't + // appear in both "Của tôi" and "Khám phá". + getPublicPlaces(uid), ]); if (!me) redirect("/login"); return ( ); } diff --git a/src/app/places-app.tsx b/src/app/places-app.tsx index 62ba4fb..48cc491 100644 --- a/src/app/places-app.tsx +++ b/src/app/places-app.tsx @@ -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 ( -
- {renderScreen(top.screen)} - dispatch({ type: "OPEN_ADD" })} - showFab={top.screen !== "profile"} - /> - + {isDesktop ? ( + + ) : ( +
+ {renderScreen(top.screen)} + dispatch({ type: "OPEN_ADD" })} + showFab={top.screen !== "profile"} + /> +
+ )} + <> {m?.type === "add" && ( dispatch({ type: "CLOSE_MODAL" })} @@ -207,6 +256,12 @@ export function PlacesApp({ /> ) : null; })()} + {m?.type === "editProfile" && ( + dispatch({ type: "CLOSE_MODAL" })} + dispatch={dispatch} + /> + )} {m?.type === "invite" && ( )} -
+
); } diff --git a/src/app/places/[id]/page.tsx b/src/app/places/[id]/page.tsx new file mode 100644 index 0000000..c3e4166 --- /dev/null +++ b/src/app/places/[id]/page.tsx @@ -0,0 +1,56 @@ +import { notFound, redirect } from "next/navigation"; +import { PlacesApp } from "@/app/places-app"; +import { getCurrentUserId } from "@/lib/db/auth"; +import { + getAllUsers, + getCollectionsForUser, + getCurrentUser, + getPlaceById, + getPlacesForUser, + getPublicPlaces, +} from "@/lib/db/queries"; + +export const dynamic = "force-dynamic"; + +// Shared place link target. The full app shell is rendered so the visitor +// has the same navigation, FAB, and offline cache as the home page — we +// just boot the stack already pointing at this place's detail. +// +// Privacy: getPlaceById already enforces the visibility rule (owner OR +// member of a collection that contains it), so 404 here means either the +// place doesn't exist OR the viewer isn't allowed to see it — both should +// look the same to the caller. +export default async function PlaceDeepLinkPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const placeId = Number(id); + if (!Number.isFinite(placeId) || placeId <= 0) notFound(); + + const uid = await getCurrentUserId(); + if (!uid) { + redirect(`/login?next=${encodeURIComponent(`/places/${placeId}`)}`); + } + + const place = await getPlaceById(placeId, uid); + if (!place) notFound(); + + const [me, users, places, collections, publicPlaces] = await Promise.all([ + getCurrentUser(), + getAllUsers(), + getPlacesForUser(uid), + getCollectionsForUser(uid), + getPublicPlaces(uid), + ]); + if (!me) redirect("/login"); + + return ( + + ); +} diff --git a/src/app/public-home.tsx b/src/app/public-home.tsx new file mode 100644 index 0000000..140ddef --- /dev/null +++ b/src/app/public-home.tsx @@ -0,0 +1,221 @@ +// Public landing page for anonymous (logged-out) visitors. Renders only +// places that live in a collection whose owner has flipped public_token, +// plus a sign-in CTA. Kept as a server component because there's no +// per-user state — every visitor sees the same data. + +import Link from "next/link"; +import type { Place } from "@/lib/types"; +import { CATEGORIES } from "@/lib/ui-config"; +import { Icons } from "@/components/icons"; +import { CoverImage } from "@/components/cover-image"; + +export function PublicHome({ places }: { places: Place[] }) { + return ( +
+
+
+
+
+

+ Khám phá +

+
+ {places.length > 0 + ? `${places.length} địa điểm được chia sẻ công khai` + : "Bộ sưu tập công khai sẽ xuất hiện ở đây"} +
+
+ + Đăng nhập + +
+
+ +
+
+ {places.length === 0 ? ( +
+ +
+ Chưa có địa điểm công khai nào. +
+ + Đăng nhập + {" "} + để lưu địa điểm của riêng bạn. +
+
+ ) : ( + places.map((p) => ) + )} +
+
+
+
+ ); +} + +// Same shape as the logged-in PlaceCard but wrapped in a so anon +// taps go through the (login → deep-link → detail) flow. We can't reuse +// PlaceCard directly because it's a + {value.kind === "local" && ( + - - - {value.kind === "local" && ( - - Sẽ tải lên khi lưu - - )} - - ) : ( - <> - - {busy ? "Đang xử lý..." : "Thêm ảnh"} - - )} - + Sẽ tải lên khi lưu + + )} + + ) : ( + + )} ); } diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 77e0f18..cc008d9 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -289,6 +289,32 @@ export const Icons = { ), + Filter: (p: IconProps) => ( + + + + ), + LayoutGrid: (p: IconProps) => ( + + + + + + + ), + SlidersHorizontal: (p: IconProps) => ( + + + + + + + + + + + + ), }; export type IconName = keyof typeof Icons; diff --git a/src/desktop/desktop-shell.tsx b/src/desktop/desktop-shell.tsx new file mode 100644 index 0000000..3e01e53 --- /dev/null +++ b/src/desktop/desktop-shell.tsx @@ -0,0 +1,2296 @@ +"use client"; + +// Desktop 3-pane shell — sidebar + list + detail. Activated at ≥1024px +// by PlacesApp. Reuses the same reducer/state/dispatch as the mobile +// shell, plus the desktop-only `selectedPlaceId` / `selectedCollectionId` +// keys on AppState. +// +// Spec: design/Places Desktop.html (handoff bundle). + +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { CATEGORIES, FILTERS } from "@/lib/ui-config"; +import type { AppState, Dispatch, Tab } from "@/lib/app-state"; +import type { Collection, Place } from "@/lib/types"; +import { + useCollections, + useMe, + useUsers, + usePublicPlaces, +} from "@/lib/app-context"; +import { + setNotes as saveNotes, + setRating, + toggleVisited, +} from "@/lib/db/actions"; +import { logoutAction } from "@/app/(auth)/auth-actions"; +import { copyToClipboard } from "@/lib/clipboard"; +import { fmtDate, fmtShortDate, tripDays } from "@/lib/format"; +import { Icons, type IconName } from "@/components/icons"; +import { Avatar, AvatarStack } from "@/components/avatar"; +import { CoverImage } from "@/components/cover-image"; +import { RatingStars } from "@/components/rating-stars"; +import { Checkbox, EmptyState } from "@/components/ui-primitives"; + +// ─── Top-level shell ────────────────────────────────────────────── + +export function DesktopShell({ + state, + dispatch, +}: { + state: AppState; + dispatch: Dispatch; +}) { + const collections = useCollections(); + const publicPlaces = usePublicPlaces(); + + // ⌘N / Ctrl-N → open add. Esc → close any modal. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const meta = e.metaKey || e.ctrlKey; + if (meta && (e.key === "n" || e.key === "N")) { + e.preventDefault(); + dispatch({ type: "OPEN_ADD" }); + } else if (e.key === "Escape" && state.modal) { + dispatch({ type: "CLOSE_MODAL" }); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [state.modal, dispatch]); + + // Lazy-seed a selected collection when the user lands on the Collections + // tab without one. Runs in DesktopShell (not PlacesApp) because the + // SELECT_COLLECTION action also forces tab=collections — we only want + // that side effect when the user has actually navigated there. + useEffect(() => { + if ( + state.tab === "collections" && + state.selectedCollectionId == null && + collections.length > 0 + ) { + dispatch({ + type: "SELECT_COLLECTION", + collectionId: collections[0].id, + }); + } + }, [state.tab, state.selectedCollectionId, collections, dispatch]); + + // Middle pane: collections list vs places list, depending on tab. + const middlePane = + state.tab === "collections" ? ( + + ) : ( + + ); + + // Right pane: depends on tab + selection. + let rightPane: React.ReactNode; + if (state.tab === "profile") { + rightPane = ; + } else if (state.tab === "collections") { + rightPane = ; + } else { + // Look in both `state.places` (the user's own + member-visible places) + // and the public feed, since DesktopPlaceRow surfaces both lists. + const place = + state.places.find((p) => p.id === state.selectedPlaceId) ?? + publicPlaces.find((p) => p.id === state.selectedPlaceId); + rightPane = place ? ( + + ) : ( + + ); + } + + return ( +
+ +
+ {state.offline && ( +
+ + Đang xem bản offline. Một số thao tác bị tạm khoá. +
+ )} + {middlePane} +
+ {rightPane} +
+ ); +} + +// ─── Sidebar ────────────────────────────────────────────────────── + +function DesktopSidebar({ + state, + dispatch, +}: { + state: AppState; + dispatch: Dispatch; +}) { + const me = useMe(); + const collections = useCollections(); + const trips = collections.filter((c) => c.type === "trip"); + const folders = collections.filter((c) => c.type === "folder"); + const visitedCount = state.places.filter((p) => p.visited).length; + + // "Đã đến" virtual tab: tab=places + filter=visited, no separate Tab value. + const onVisited = () => { + dispatch({ type: "TAB", tab: "places" }); + dispatch({ type: "SET_FILTER", value: "visited" }); + }; + const isVisitedActive = + state.tab === "places" && state.filter === "visited"; + + const onPlaces = () => { + dispatch({ type: "TAB", tab: "places" }); + if (state.filter === "visited" || state.filter === "unvisited") { + dispatch({ type: "SET_FILTER", value: "all" }); + } + }; + const isPlacesActive = state.tab === "places" && !isVisitedActive; + + return ( + + ); +} + +function NavItem({ + icon, + label, + count, + active, + onClick, +}: { + icon: IconName; + label: string; + count?: number; + active?: boolean; + onClick: () => void; +}) { + const I = Icons[icon]; + return ( + + ); +} + +function SidebarCollRow({ + c, + active, + onClick, +}: { + c: Collection; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +// ─── Middle list pane: places ───────────────────────────────────── + +function DesktopPlacesList({ + state, + dispatch, +}: { + state: AppState; + dispatch: Dispatch; +}) { + const { filter, search, places } = state; + const publicPlaces = usePublicPlaces(); + + const filtered = useMemo(() => { + let out = places; + if (filter !== "all") { + if (filter === "visited") out = out.filter((p) => p.visited); + else if (filter === "unvisited") out = out.filter((p) => !p.visited); + else out = out.filter((p) => p.category === filter); + } + if (search.trim()) { + const s = search.toLowerCase(); + out = out.filter( + (p) => + p.name.toLowerCase().includes(s) || + p.address.toLowerCase().includes(s) || + p.tags.some((t) => t.toLowerCase().includes(s)), + ); + } + return out; + }, [filter, search, places]); + + // Public feed only when not filtering by visited/unvisited (those rely on + // per-user data that public rows don't carry). + const filteredPublic = useMemo(() => { + if (publicPlaces.length === 0) return []; + if (filter === "visited" || filter === "unvisited") return []; + let out = publicPlaces; + if (filter !== "all") out = out.filter((p) => p.category === filter); + if (search.trim()) { + const s = search.toLowerCase(); + out = out.filter( + (p) => + p.name.toLowerCase().includes(s) || + p.address.toLowerCase().includes(s) || + p.tags.some((t) => t.toLowerCase().includes(s)), + ); + } + return out; + }, [filter, search, publicPlaces]); + + const visitedCount = places.filter((p) => p.visited).length; + const showingVisited = filter === "visited"; + const totalMatches = filtered.length + filteredPublic.length; + + return ( +
+
+
+
+
+ {showingVisited ? "Đã đến" : "Địa điểm"} +
+
+ {places.length} chỗ đã lưu · {visitedCount} đã đến +
+
+
+ + +
+
+ +
+ + + dispatch({ type: "SET_SEARCH", value: e.target.value }) + } + /> + {search ? ( + + ) : ( + ⌘K + )} +
+ +
+ {FILTERS.map((f) => ( + + ))} +
+
+ +
+ {totalMatches === 0 ? ( + + ) : ( +
+ {filtered.map((p) => ( + + dispatch({ type: "SELECT_PLACE", placeId: p.id }) + } + /> + ))} + {filteredPublic.length > 0 && ( + <> +
+ Khám phá +
+ {filteredPublic.map((p) => ( + + dispatch({ type: "SELECT_PLACE", placeId: p.id }) + } + /> + ))} + + )} +
+ )} +
+
+ ); +} + +function DesktopPlaceRow({ + place, + active, + onClick, +}: { + place: Place; + active: boolean; + onClick: () => void; +}) { + const cat = CATEGORIES[place.category] || CATEGORIES.other; + const CatIcon = Icons[cat.icon as IconName]; + return ( + + ); +} + +// ─── Middle list pane: collections ──────────────────────────────── + +function DesktopCollectionsList({ + state, + dispatch, +}: { + state: AppState; + dispatch: Dispatch; +}) { + const collections = useCollections(); + const [tab, setTab] = useState<"all" | "trips" | "folders">("all"); + const filtered = collections.filter((c) => { + if (tab === "all") return true; + if (tab === "trips") return c.type === "trip"; + return c.type === "folder"; + }); + const sharedCount = collections.filter((c) => c.my_role !== "owner").length; + + return ( +
+
+
+
+
Bộ sưu tập
+
+ {collections.length} bộ · {sharedCount} được chia sẻ +
+
+ +
+
+ + + +
+
+
+
+ {filtered.map((c) => ( + + dispatch({ type: "SELECT_COLLECTION", collectionId: c.id }) + } + /> + ))} +
+
+
+ ); +} + +function DesktopCollectionRow({ + c, + places, + active, + onClick, +}: { + c: Collection; + places: Place[]; + active: boolean; + onClick: () => void; +}) { + const isTrip = c.type === "trip"; + const cover = c.cover_place_ids + .map((id) => places.find((p) => p.id === id)) + .filter((p): p is Place => Boolean(p)); + return ( + + ); +} + +// ─── Right pane: empty state ────────────────────────────────────── + +function DesktopEmptyDetail({ tab }: { tab: Tab }) { + const isCol = tab === "collections"; + return ( +
+
+
+ {isCol ? "Bộ sưu tập" : "Địa điểm"} + + Chưa chọn +
+
+
+
+ +
+
+ Chọn {isCol ? "một bộ sưu tập" : "một địa điểm"} +
+
+ {isCol + ? "Chọn bộ sưu tập bên trái để xem chi tiết, hoặc tạo mới." + : "Chọn một địa điểm từ danh sách bên trái để xem chi tiết, đánh giá và ghi chú."} +
+
+
+ ); +} + +// ─── Right pane: place detail ───────────────────────────────────── + +function DesktopPlaceDetail({ + place, + state, + dispatch, +}: { + place: Place; + state: AppState; + dispatch: Dispatch; +}) { + const cat = CATEGORIES[place.category]; + const CatIcon = Icons[cat.icon as IconName]; + const collections = useCollections(); + const users = useUsers(); + const collectionsContaining = collections.filter((c) => + c.place_ids.includes(place.id), + ); + const creator = users[place.created_by]; + const [notes, setNotesText] = useState(place.my_notes ?? ""); + useEffect(() => { + setNotesText(place.my_notes ?? ""); + }, [place.id, place.my_notes]); + + const doToggleVisited = () => { + dispatch({ type: "TOGGLE_VISITED", placeId: place.id }); + toggleVisited(place.id).catch(() => { + dispatch({ type: "TOGGLE_VISITED", placeId: place.id }); + dispatch({ type: "TOAST", value: "Lưu thất bại" }); + }); + }; + + const doShare = async () => { + const url = `${window.location.origin}/places/${place.id}`; + const ok = await copyToClipboard(url); + dispatch({ + type: "TOAST", + value: ok ? "Đã sao chép liên kết" : "Không sao chép được", + }); + }; + + return ( +
+
+
+ Địa điểm + + {place.name} +
+
+ + + +
+ +
+ +
+ {/* Hero */} +
+ +
+
+
+ + + {cat.label} + + {place.visited && place.visited_at && ( + + + Đã đến · {fmtDate(place.visited_at)} + + )} + {place.avg_rating != null && ( + + + Nhóm {place.avg_rating.toFixed(1)} + + )} +
+

+ {place.name} +

+
+
+ + {/* Body 2-col */} +
+ {/* LEFT */} +
+ {place.tags.length > 0 && ( +
+ {place.tags.map((t) => ( + + {t} + + ))} +
+ )} + + + +
+
+
+
+ Đã đến đây +
+
+ {place.visited && place.visited_at + ? `Đánh dấu vào ${fmtDate(place.visited_at)}` + : "Bấm khi bạn đã ghé qua"} +
+
+ +
+
+
+
+ Đánh giá của bạn +
+
+ {place.my_rating ? ( + <> + Bạn{" "} + + ★{place.my_rating} + + + ) : ( + "Chưa đánh giá — tap để xếp sao" + )} + {place.avg_rating != null && ( + <> + + · + + Nhóm{" "} + + ★{place.avg_rating.toFixed(1)} + + + )} +
+
+ { + dispatch({ + type: "SET_RATING", + placeId: place.id, + value: v, + }); + setRating(place.id, v).catch(() => { + dispatch({ + type: "TOAST", + value: "Lưu thất bại", + }); + }); + }} + /> +
+
+ +
+
+ + Ghi chú riêng tư +
+
+