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

@@ -16,10 +16,12 @@ export async function loginAction(
_prev: FormState,
data: FormData,
): Promise<FormState> {
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<void> {
await logoutImpl();
redirect("/login");
}

View File

@@ -75,32 +75,64 @@ export function AuthForm({
</div>
{!isLogin && (
<Field label="Tên hiển thị">
<>
<Field label="Tên hiển thị">
<Icons.User size={18} stroke={1.75} style={{ color: "var(--muted-foreground)" }} />
<input
name="name"
type="text"
required
autoComplete="name"
placeholder="Tên của bạn"
disabled={pending}
/>
</Field>
<Field label="Username (tuỳ chọn)">
<Icons.User size={18} stroke={1.75} style={{ color: "var(--muted-foreground)" }} />
<input
name="username"
type="text"
autoComplete="username"
pattern="[a-z0-9](?:[a-z0-9._]{1,28})[a-z0-9]"
minLength={3}
maxLength={30}
placeholder="vd: minh.nguyen — dùng để đăng nhập"
disabled={pending}
style={{ textTransform: "lowercase" }}
/>
</Field>
</>
)}
{isLogin ? (
<Field label="Email hoặc username">
<Icons.User size={18} stroke={1.75} style={{ color: "var(--muted-foreground)" }} />
<input
name="name"
name="identifier"
type="text"
required
autoComplete="name"
placeholder="Tên của bạn"
autoComplete="username"
autoCapitalize="off"
spellCheck={false}
placeholder="ten@email.com hoặc minh.nguyen"
disabled={pending}
/>
</Field>
) : (
<Field label="Email">
<Icons.Mail size={18} stroke={1.75} style={{ color: "var(--muted-foreground)" }} />
<input
name="email"
type="email"
required
autoComplete="email"
inputMode="email"
placeholder="ten@email.com"
disabled={pending}
/>
</Field>
)}
<Field label="Email">
<Icons.Mail size={18} stroke={1.75} style={{ color: "var(--muted-foreground)" }} />
<input
name="email"
type="email"
required
autoComplete="email"
inputMode="email"
placeholder="ten@email.com"
disabled={pending}
/>
</Field>
<Field label="Mật khẩu">
<Icons.Lock size={18} stroke={1.75} style={{ color: "var(--muted-foreground)" }} />
<input

View File

@@ -0,0 +1,49 @@
import { notFound, redirect } from "next/navigation";
import { PlacesApp } from "@/app/places-app";
import { getCurrentUserId } from "@/lib/db/auth";
import {
getAllUsers,
getCollectionsForUser,
getCurrentUser,
getPlacesForUser,
getPublicPlaces,
} from "@/lib/db/queries";
export const dynamic = "force-dynamic";
// Shared collection link target. Privacy is enforced by the membership filter
// in getCollectionsForUser — collections the visitor isn't a member of simply
// don't appear in the result. 404 covers both "doesn't exist" and "not
// allowed" without leaking which one it is.
export default async function CollectionDeepLinkPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
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 (
<PlacesApp
initialPlaces={places}
data={{ me, users, collections, publicPlaces }}
initialNav={{ kind: "collection", collectionId }}
/>
);
}

View File

@@ -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 <DesktopShell /> 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); }

View File

@@ -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 <PublicHome places={places} />;
}
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 (
<PlacesApp
initialPlaces={places}
data={{ me, users, collections }}
data={{ me, users, collections, publicPlaces }}
/>
);
}

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>
);
}

View File

@@ -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 (
<PlacesApp
initialPlaces={places}
data={{ me, users, collections, publicPlaces }}
initialNav={{ kind: "place", placeId }}
/>
);
}

221
src/app/public-home.tsx Normal file
View File

@@ -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 (
<div className="app-frame">
<div className="app-surface">
<div className="app-header">
<div
style={{
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
gap: 12,
padding: "12px 16px 14px",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<h1
style={{
margin: 0,
fontSize: 30,
fontWeight: 700,
letterSpacing: "-0.02em",
lineHeight: 1.1,
}}
>
Khám phá
</h1>
<div
style={{
marginTop: 4,
fontSize: 14,
color: "var(--muted-foreground)",
}}
>
{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"}
</div>
</div>
<Link
href="/login"
className="btn"
style={{
height: 36,
padding: "0 14px",
fontSize: 13,
textDecoration: "none",
whiteSpace: "nowrap",
}}
>
Đăng nhập
</Link>
</div>
</div>
<div className="app-scroll">
<div
style={{
padding: "16px 16px 24px",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
{places.length === 0 ? (
<div
style={{
padding: "48px 16px",
textAlign: "center",
color: "var(--muted-foreground)",
fontSize: 14,
lineHeight: 1.6,
}}
>
<Icons.MapPin
size={32}
stroke={1.5}
style={{
color: "var(--subtle-foreground)",
marginBottom: 12,
}}
/>
<div>
Chưa đa điểm công khai nào.
<br />
<Link
href="/login"
style={{
color: "var(--primary)",
textDecoration: "none",
fontWeight: 600,
}}
>
Đăng nhập
</Link>{" "}
đ lưu đa điểm của riêng bạn.
</div>
</div>
) : (
places.map((p) => <PublicPlaceCard key={p.id} place={p} />)
)}
</div>
</div>
</div>
</div>
);
}
// Same shape as the logged-in PlaceCard but wrapped in a <Link> so anon
// taps go through the (login → deep-link → detail) flow. We can't reuse
// PlaceCard directly because it's a <button> and we need href routing.
function PublicPlaceCard({ place }: { place: Place }) {
const cat = CATEGORIES[place.category] || CATEGORIES.other;
const CatIcon = Icons[cat.icon as keyof typeof Icons];
return (
<Link
href={`/places/${place.id}`}
className="place-card"
style={{ textDecoration: "none", color: "inherit" }}
>
<div
style={{
width: 72,
height: 72,
borderRadius: 12,
overflow: "hidden",
flexShrink: 0,
background: "var(--muted)",
}}
>
<CoverImage
src={place.cover_url}
alt={place.name}
category={place.category}
style={{ width: "100%", height: "100%" }}
/>
</div>
<div
style={{
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
<span
style={{
fontSize: 16,
fontWeight: 600,
color: "var(--foreground)",
letterSpacing: "-0.01em",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{place.name}
</span>
<div
style={{
fontSize: 13,
color: "var(--muted-foreground)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<span
style={{
color: cat.color,
display: "inline-flex",
alignItems: "center",
}}
>
<CatIcon size={13} stroke={2} />
</span>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{place.city ? `${place.city} · ` : ""}
{place.short_address}
</span>
</div>
{place.avg_rating != null && (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 3,
fontSize: 12,
color: "var(--muted-foreground)",
fontWeight: 500,
marginTop: 2,
}}
>
<Icons.StarFilled size={12} style={{ color: "var(--star)" }} />
{place.avg_rating.toFixed(1)}
</span>
)}
</div>
</Link>
);
}

View File

@@ -78,7 +78,10 @@ export function CoverPicker({
}
};
const openPicker = () => inputRef.current?.click();
const openPicker = () => {
if (disabled || busy) return;
inputRef.current?.click();
};
const clear = (e: React.MouseEvent) => {
e.stopPropagation();
disposeCover(value);
@@ -90,6 +93,26 @@ export function CoverPicker({
: value.kind === "local" ? value.previewUrl
: null;
const containerStyle: React.CSSProperties = {
width: "100%",
aspectRatio: "16 / 9",
borderRadius: "var(--radius-lg)",
background: previewSrc ? "transparent" : "var(--muted)",
border: previewSrc ? "0" : "1.5px dashed var(--border-strong)",
color: "var(--muted-foreground)",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
overflow: "hidden",
position: "relative",
padding: 0,
fontSize: 14,
fontWeight: 500,
cursor: busy ? "wait" : "pointer",
opacity: disabled ? 0.6 : busy ? 0.85 : 1,
};
return (
<>
<input
@@ -102,87 +125,79 @@ export function CoverPicker({
if (f) void handleFile(f);
}}
/>
<button
type="button"
onClick={openPicker}
disabled={disabled || busy}
style={{
width: "100%",
aspectRatio: "16 / 9",
borderRadius: "var(--radius-lg)",
background: previewSrc ? "transparent" : "var(--muted)",
border: previewSrc ? "0" : "1.5px dashed var(--border-strong)",
color: "var(--muted-foreground)",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
overflow: "hidden",
position: "relative",
padding: 0,
fontSize: 14,
fontWeight: 500,
cursor: busy ? "wait" : "pointer",
opacity: disabled ? 0.6 : busy ? 0.85 : 1,
}}
>
{previewSrc ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewSrc}
alt=""
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
<button
type="button"
onClick={clear}
disabled={disabled || busy}
aria-label="Xoá ảnh"
{previewSrc ? (
<div
role="button"
tabIndex={disabled || busy ? -1 : 0}
aria-disabled={disabled || busy}
onClick={openPicker}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openPicker();
}
}}
style={containerStyle}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewSrc}
alt=""
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
<button
type="button"
onClick={clear}
disabled={disabled || busy}
aria-label="Xoá ảnh"
style={{
position: "absolute",
top: 8,
right: 8,
width: 32,
height: 32,
borderRadius: 9999,
border: 0,
background: "rgba(20,16,10,0.55)",
color: "#fff",
backdropFilter: "blur(12px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Icons.X size={16} stroke={2} />
</button>
{value.kind === "local" && (
<span
style={{
position: "absolute",
top: 8,
right: 8,
width: 32,
height: 32,
bottom: 8,
left: 8,
fontSize: 11,
fontWeight: 600,
padding: "4px 8px",
borderRadius: 9999,
border: 0,
background: "rgba(20,16,10,0.55)",
color: "#fff",
backdropFilter: "blur(12px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Icons.X size={16} stroke={2} />
</button>
{value.kind === "local" && (
<span
style={{
position: "absolute",
bottom: 8,
left: 8,
fontSize: 11,
fontWeight: 600,
padding: "4px 8px",
borderRadius: 9999,
background: "rgba(20,16,10,0.55)",
color: "#fff",
backdropFilter: "blur(12px)",
}}
>
Sẽ tải lên khi lưu
</span>
)}
</>
) : (
<>
<Icons.Camera size={20} stroke={1.75} />
{busy ? "Đang xử lý..." : "Thêm ảnh"}
</>
)}
</button>
Sẽ tải lên khi lưu
</span>
)}
</div>
) : (
<button
type="button"
onClick={openPicker}
disabled={disabled || busy}
style={containerStyle}
>
<Icons.Camera size={20} stroke={1.75} />
{busy ? "Đang xử lý..." : "Thêm ảnh"}
</button>
)}
</>
);
}

View File

@@ -289,6 +289,32 @@ export const Icons = {
<path d="m9 12 2 2 4-4" />
</Icon>
),
Filter: (p: IconProps) => (
<Icon {...p}>
<path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z" />
</Icon>
),
LayoutGrid: (p: IconProps) => (
<Icon {...p}>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</Icon>
),
SlidersHorizontal: (p: IconProps) => (
<Icon {...p}>
<line x1="21" y1="4" x2="14" y2="4" />
<line x1="10" y1="4" x2="3" y2="4" />
<line x1="21" y1="12" x2="12" y2="12" />
<line x1="8" y1="12" x2="3" y2="12" />
<line x1="21" y1="20" x2="16" y2="20" />
<line x1="12" y1="20" x2="3" y2="20" />
<circle cx="12" cy="4" r="2" />
<circle cx="10" cy="12" r="2" />
<circle cx="14" cy="20" r="2" />
</Icon>
),
};
export type IconName = keyof typeof Icons;

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,15 @@
"use client";
import { createContext, useContext, type ReactNode } from "react";
import type { Collection, User } from "./types";
import type { Collection, Place, User } from "./types";
export type AppData = {
me: User;
users: Record<number, User>;
collections: Collection[];
// Places shared publicly (collections with public_token set) that the
// logged user doesn't already have. Empty array when nothing's public.
publicPlaces: Place[];
};
const AppDataContext = createContext<AppData | null>(null);
@@ -42,3 +45,7 @@ export function useCollections(): Collection[] {
export function useMe(): User {
return useAppData().me;
}
export function usePublicPlaces(): Place[] {
return useAppData().publicPlaces;
}

View File

@@ -20,6 +20,7 @@ export type Modal =
| { type: "members"; collectionId: number }
| { type: "confirmDeletePlace"; placeId: number }
| { type: "confirmDeleteCollection"; collectionId: number }
| { type: "editProfile" }
| null;
export type AppState = {
@@ -32,6 +33,11 @@ export type AppState = {
toast: string | null;
toastKey: number;
offline: boolean;
// Desktop-only — drives the 3-pane shell's right detail. Mobile ignores
// these and routes via `stack` instead. When both are set, the active
// tab determines which one's surfaced.
selectedPlaceId?: number;
selectedCollectionId?: number;
};
export type Action =
@@ -57,10 +63,54 @@ export type Action =
| { type: "OPEN_MEMBERS"; collectionId: number }
| { type: "CONFIRM_DELETE_PLACE"; placeId: number }
| { type: "CONFIRM_DELETE_COLLECTION"; collectionId: number }
| { type: "OPEN_EDIT_PROFILE" }
| { type: "CLOSE_MODAL" }
| { type: "SET_OFFLINE"; value: boolean };
| { type: "SET_OFFLINE"; value: boolean }
| { type: "SELECT_PLACE"; placeId: number }
| { type: "SELECT_COLLECTION"; collectionId: number };
export function makeInitialState(places: Place[]): AppState {
export type InitialNav =
| { kind: "place"; placeId: number }
| { kind: "collection"; collectionId: number };
export function makeInitialState(
places: Place[],
initialNav?: InitialNav,
): AppState {
// Deep-link target lives ON TOP of the appropriate tab's root, so Back
// returns the user to a sane list view instead of an empty stack.
if (initialNav?.kind === "place") {
return {
tab: "places",
stack: [
{ screen: "places" },
{ screen: "place", placeId: initialNav.placeId },
],
filter: "all",
search: "",
places,
modal: null,
toast: null,
toastKey: 0,
offline: false,
};
}
if (initialNav?.kind === "collection") {
return {
tab: "collections",
stack: [
{ screen: "collections" },
{ screen: "collection", collectionId: initialNav.collectionId },
],
filter: "all",
search: "",
places,
modal: null,
toast: null,
toastKey: 0,
offline: false,
};
}
return {
tab: "places",
stack: [{ screen: "places" }],
@@ -158,10 +208,26 @@ export function reducer(state: AppState, action: Action): AppState {
return { ...state, modal: { type: "confirmDeletePlace", placeId: action.placeId } };
case "CONFIRM_DELETE_COLLECTION":
return { ...state, modal: { type: "confirmDeleteCollection", collectionId: action.collectionId } };
case "OPEN_EDIT_PROFILE":
return { ...state, modal: { type: "editProfile" } };
case "CLOSE_MODAL":
return { ...state, modal: null };
case "SET_OFFLINE":
return { ...state, offline: action.value };
case "SELECT_PLACE":
// Desktop: just swap the right pane. Keep tab on "places" so the
// middle pane stays consistent (we may have been on "collections").
return {
...state,
tab: state.tab === "collections" ? state.tab : "places",
selectedPlaceId: action.placeId,
};
case "SELECT_COLLECTION":
return {
...state,
tab: "collections",
selectedCollectionId: action.collectionId,
};
default:
return state;
}

View File

@@ -5,7 +5,7 @@ import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { and, eq, sql } from "drizzle-orm";
import { db } from "./client";
import { requireUserId } from "./auth";
import { requireUserId, validateUsername } from "./auth";
import {
collectionMembers,
collectionPlaces,
@@ -24,6 +24,79 @@ function makeInviteToken(): string {
return randomBytes(16).toString("base64url");
}
function deriveInitials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return parts[0].slice(0, 2).toUpperCase();
}
export type UpdateProfileInput = {
name: string;
avatar_url: string | null;
username: string | null;
};
export async function updateProfile(
input: UpdateProfileInput,
): Promise<{
id: number;
name: string;
initials: string;
avatar_url: string | null;
username: string | null;
}> {
const uid = await requireUserId();
const cleanName = input.name.trim();
if (!cleanName) throw new Error("Vui lòng nhập tên");
if (cleanName.length > 60) throw new Error("Tên quá dài (tối đa 60 ký tự)");
const initials = deriveInitials(cleanName);
// Username: validate format, then check uniqueness (excluding current user).
// null = clear it; empty string in input is treated as null upstream.
const cleanUsername = input.username
? input.username.trim().toLowerCase()
: null;
if (cleanUsername) {
const err = validateUsername(cleanUsername);
if (err) throw new Error(err);
const conflict = await db
.select({ id: users.id })
.from(users)
.where(eq(users.username, cleanUsername))
.limit(1);
if (conflict.length && conflict[0].id !== uid) {
throw new Error("Username đã được sử dụng");
}
}
const [row] = await db
.update(users)
.set({
name: cleanName,
initials,
avatarUrl: input.avatar_url,
username: cleanUsername,
})
.where(eq(users.id, uid))
.returning({
id: users.id,
name: users.name,
username: users.username,
initials: users.initials,
avatarUrl: users.avatarUrl,
});
revalidatePath("/");
return {
id: row.id,
name: row.name,
username: row.username,
initials: row.initials,
avatar_url: row.avatarUrl,
};
}
// Privacy guard: user must own the place or be a member of a collection
// containing it. Mirrors the spec in CLAUDE.md / RLS policy.
async function assertCanAccessPlace(placeId: number, userId: number) {

View File

@@ -7,6 +7,20 @@ import { db } from "./client";
import { sessions, users } from "./schema";
import type { User } from "@/lib/types";
// Public so the profile editor can re-use the same rules.
export const USERNAME_RE = /^[a-z0-9](?:[a-z0-9._]{1,28})[a-z0-9]$/;
export function validateUsername(raw: string): string | null {
const v = raw.trim().toLowerCase();
if (v.length < 3 || v.length > 30) {
return "Username phải có 330 ký tự";
}
if (!USERNAME_RE.test(v)) {
return "Username chỉ chứa chữ thường, số, dấu chấm hoặc gạch dưới";
}
return null;
}
export const SESSION_COOKIE = "places_session";
// Best-effort persistence — Chrome/Safari now cap cookie lifetime to ~400d
// regardless of what we set. The DB row has no expiry (expires_at = NULL),
@@ -33,9 +47,11 @@ export async function registerUser(
email: string,
password: string,
name: string,
username?: string,
): Promise<AuthResult> {
const cleanEmail = email.trim().toLowerCase();
const cleanName = name.trim();
const cleanUsername = username?.trim().toLowerCase() || null;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(cleanEmail)) {
return { ok: false, error: "Email không hợp lệ" };
}
@@ -45,6 +61,10 @@ export async function registerUser(
if (cleanName.length < 1) {
return { ok: false, error: "Vui lòng nhập tên" };
}
if (cleanUsername) {
const err = validateUsername(cleanUsername);
if (err) return { ok: false, error: err };
}
const existing = await db
.select({ id: users.id })
@@ -54,6 +74,16 @@ export async function registerUser(
if (existing.length) {
return { ok: false, error: "Email đã được đăng ký" };
}
if (cleanUsername) {
const taken = await db
.select({ id: users.id })
.from(users)
.where(eq(users.username, cleanUsername))
.limit(1);
if (taken.length) {
return { ok: false, error: "Username đã được sử dụng" };
}
}
const hash = await bcrypt.hash(password, 10);
const initials = deriveInitials(cleanName);
@@ -62,6 +92,7 @@ export async function registerUser(
.insert(users)
.values({
email: cleanEmail,
username: cleanUsername,
passwordHash: hash,
name: cleanName,
initials,
@@ -69,6 +100,7 @@ export async function registerUser(
.returning({
id: users.id,
email: users.email,
username: users.username,
name: users.name,
initials: users.initials,
avatarUrl: users.avatarUrl,
@@ -82,6 +114,7 @@ export async function registerUser(
user: {
id: inserted.id,
email: inserted.email,
username: inserted.username,
name: inserted.name,
initials: inserted.initials,
avatar_url: inserted.avatarUrl,
@@ -90,20 +123,31 @@ export async function registerUser(
};
}
// `identifier` accepts either an email or a username. The "@" character is
// the discriminator: anything containing it is treated as email (and
// lower-cased), everything else is treated as a username.
export async function loginUser(
email: string,
identifier: string,
password: string,
): Promise<AuthResult> {
const cleanEmail = email.trim().toLowerCase();
const raw = identifier.trim();
if (!raw) return { ok: false, error: "Vui lòng nhập email hoặc username" };
const isEmail = raw.includes("@");
const cleaned = raw.toLowerCase();
const [row] = await db
.select()
.from(users)
.where(eq(users.email, cleanEmail))
.where(isEmail ? eq(users.email, cleaned) : eq(users.username, cleaned))
.limit(1);
if (!row) return { ok: false, error: "Email hoặc mật khẩu không đúng" };
// Same error for both branches so the form doesn't leak which one missed.
const genericError = isEmail
? "Email hoặc mật khẩu không đúng"
: "Username hoặc mật khẩu không đúng";
if (!row) return { ok: false, error: genericError };
const ok = await bcrypt.compare(password, row.passwordHash);
if (!ok) return { ok: false, error: "Email hoặc mật khẩu không đúng" };
if (!ok) return { ok: false, error: genericError };
await createSessionCookie(row.id);
@@ -112,6 +156,7 @@ export async function loginUser(
user: {
id: row.id,
email: row.email,
username: row.username,
name: row.name,
initials: row.initials,
avatar_url: row.avatarUrl,
@@ -158,7 +203,18 @@ export async function logout(): Promise<void> {
if (token) {
await db.delete(sessions).where(eq(sessions.id, token));
}
c.delete(SESSION_COOKIE);
// Belt-and-suspenders: c.delete() alone has occasionally failed to emit a
// Set-Cookie header in Next 15+ when the action also triggers navigation.
// Explicitly overwriting with maxAge:0 + an expired Date guarantees the
// browser drops it on the next response.
c.set(SESSION_COOKIE, "", {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 0,
expires: new Date(0),
});
}
export async function requireUserId(): Promise<number> {

View File

@@ -74,6 +74,75 @@ function placeVisibilityFilter(userId: number) {
);
}
// Places that live in at least one collection whose public_token is set
// (i.e. the owner has flipped the collection to read-only-public). Used by
// the home page community feed for both anonymous visitors and the logged
// "Khám phá" section.
//
// excludeUserId: omit places where this user already has access (owned or
// via membership) so a logged-in user doesn't see the same row in both
// "Của tôi" and "Khám phá". When undefined (anonymous), no exclusion is
// applied.
export async function getPublicPlaces(
excludeUserId?: number,
): Promise<Place[]> {
// Subquery: place ids visible publicly via at least one collection with a
// public_token. DISTINCT because a place can live in multiple collections.
const publicPlaceIds = db
.selectDistinct({ id: collectionPlaces.placeId })
.from(collectionPlaces)
.innerJoin(
collections,
and(
eq(collections.id, collectionPlaces.collectionId),
sql`${collections.publicToken} IS NOT NULL`,
),
);
const whereExpr = excludeUserId
? and(
inArray(places.id, publicPlaceIds),
// Exclude places already visible to this user (owner OR via any
// collection they're a member of). Same logic as
// placeVisibilityFilter, just inverted with NOT.
sql`NOT (${eq(places.createdBy, excludeUserId)} OR ${places.id} IN (
SELECT cp.place_id FROM ${collectionPlaces} cp
JOIN ${collectionMembers} cm
ON cm.collection_id = cp.collection_id AND cm.user_id = ${excludeUserId}
))`,
)
: inArray(places.id, publicPlaceIds);
const rows = await db
.select({
id: places.id,
name: places.name,
address: places.address,
shortAddress: places.shortAddress,
category: places.category,
tags: places.tags,
coverUrl: places.coverUrl,
createdBy: places.createdBy,
createdAt: places.createdAt,
avgRating: sql<string | null>`(
SELECT to_char(avg(rating), 'FM999990.00')
FROM ${userPlaceData} upd2
WHERE upd2.place_id = ${places.id} AND upd2.rating IS NOT NULL
)`,
city: places.city,
// No per-user data for anonymous/public view — these are someone else's
// ratings/notes/visited.
myRating: sql<number | null>`NULL`,
myNotes: sql<string | null>`NULL`,
visited: sql<boolean | null>`NULL`,
visitedAt: sql<Date | null>`NULL`,
})
.from(places)
.where(whereExpr)
.orderBy(desc(places.createdAt));
return rows.map(toPlace);
}
export async function getPlacesForUser(userId?: number): Promise<Place[]> {
const uid = userId ?? (await requireUserId());
const rows = await db
@@ -242,6 +311,7 @@ export async function getAllUsers(): Promise<Record<number, User>> {
id: users.id,
name: users.name,
email: users.email,
username: users.username,
initials: users.initials,
avatarUrl: users.avatarUrl,
color: users.color,
@@ -254,6 +324,7 @@ export async function getAllUsers(): Promise<Record<number, User>> {
id: u.id,
name: u.name,
email: u.email,
username: u.username,
initials: u.initials,
avatar_url: u.avatarUrl,
color: u.color ?? undefined,
@@ -270,6 +341,7 @@ export async function getCurrentUser(): Promise<User | null> {
id: users.id,
name: users.name,
email: users.email,
username: users.username,
initials: users.initials,
avatarUrl: users.avatarUrl,
color: users.color,
@@ -282,6 +354,7 @@ export async function getCurrentUser(): Promise<User | null> {
id: u.id,
name: u.name,
email: u.email,
username: u.username,
initials: u.initials,
avatar_url: u.avatarUrl,
color: u.color ?? undefined,

View File

@@ -28,6 +28,9 @@ export const priceRangeEnum = pgEnum("price_range", ["$", "$$", "$$$"]);
export const users = pgTable("users", {
id: serial("id").primaryKey(),
email: text("email").notNull().unique(),
// Optional handle (330 lowercase alphanum/_/.). Nullable so existing rows
// remain valid after the migration; login still accepts email for those.
username: text("username").unique(),
passwordHash: text("password_hash").notNull(),
name: text("name").notNull(),
initials: text("initials").notNull(),

View File

@@ -13,6 +13,8 @@ export type CacheSnapshot = {
users: Record<number, User>;
collections: Collection[];
places: Place[];
// Optional so older v1 snapshots still load; we default to [] on read.
publicPlaces?: Place[];
};
const KEY_PREFIX = "places_cache";

View File

@@ -8,6 +8,7 @@ export type User = {
id: number;
name: string;
email?: string;
username?: string | null;
avatar_url?: string | null;
initials: string;
color?: string;

View File

@@ -69,7 +69,7 @@ export function CollectionDetailScreen({
{/* Hero */}
<div
style={{
margin: "8px 16px 0",
margin: "16px 16px 0",
padding: "20px",
background: isTrip
? "linear-gradient(135deg, color-mix(in oklch, var(--primary) 14%, var(--card)), var(--card))"

View File

@@ -40,7 +40,7 @@ export function CollectionsListScreen({
/>
}
/>
<div style={{ padding: "0 16px 12px" }}>
<div style={{ padding: "12px 16px" }}>
<div className="tabs">
<button data-active={tab === "all"} onClick={() => setTab("all")}>
Tất cả

View File

@@ -3,10 +3,35 @@
import { useMemo, useState } from "react";
import { FILTERS } from "@/lib/ui-config";
import type { AppState, Dispatch } from "@/lib/app-state";
import type { Place } from "@/lib/types";
import { usePublicPlaces } from "@/lib/app-context";
import { Header, IconBtn, OfflineBanner, EmptyState } from "@/components/ui-primitives";
import { PlaceCard } from "@/components/place-card";
import { Icons } from "@/components/icons";
function applyFilters(
list: Place[],
filter: string,
search: string,
): Place[] {
let out = list;
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;
}
export function PlacesListScreen({
state,
dispatch,
@@ -15,26 +40,22 @@ export function PlacesListScreen({
dispatch: Dispatch;
}) {
const { filter, search, places, offline } = state;
const publicPlaces = usePublicPlaces();
const [searchOpen, setSearchOpen] = useState(false);
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]);
const filteredMine = useMemo(
() => applyFilters(places, filter, search),
[filter, search, places],
);
// Public places never carry per-user visited/rating so the visited/unvisited
// filter would zero them out — skip those buckets for "Khám phá".
const filteredPublic = useMemo(() => {
if (filter === "visited" || filter === "unvisited") return [];
return applyFilters(publicPlaces, filter, search);
}, [filter, search, publicPlaces]);
const showPublic = publicPlaces.length > 0;
const totalMatches = filteredMine.length + filteredPublic.length;
return (
<div className="app-surface">
@@ -53,7 +74,7 @@ export function PlacesListScreen({
/>
{offline && <OfflineBanner />}
{searchOpen && (
<div style={{ padding: "0 16px 12px" }}>
<div style={{ padding: "12px 16px 0" }}>
<div className="input" style={{ height: 44 }}>
<Icons.Search
size={18}
@@ -90,7 +111,7 @@ export function PlacesListScreen({
style={{
display: "flex",
gap: 8,
padding: "0 16px 12px",
padding: "12px 16px",
overflowX: "auto",
overflowY: "hidden",
scrollSnapType: "x proximity",
@@ -109,7 +130,7 @@ export function PlacesListScreen({
</div>
<div className="app-scroll">
{filtered.length === 0 ? (
{totalMatches === 0 ? (
<EmptyState
icon={search ? "Search" : "MapPin"}
title={search ? "Không tìm thấy gì" : "Chưa có địa điểm nào"}
@@ -124,27 +145,145 @@ export function PlacesListScreen({
style={{
display: "flex",
flexDirection: "column",
gap: 10,
padding: "4px 16px 24px",
gap: 20,
padding: "4px 0 24px",
}}
>
{filtered.map((p, i) => (
<div
key={p.id}
className="page-enter"
style={{ animationDelay: `${i * 18}ms` }}
>
<PlaceCard
place={p}
onTap={() =>
dispatch({ type: "NAV", screen: "place", placeId: p.id })
}
/>
{/* "Của tôi" — only labelled when there's also a public section
to disambiguate, otherwise just the bare list. */}
{filteredMine.length > 0 && (
<div>
{showPublic && (
<SectionHeader
title="Của tôi"
count={filteredMine.length}
/>
)}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 10,
padding: "0 16px",
}}
>
{filteredMine.map((p, i) => (
<div
key={p.id}
className="page-enter"
style={{ animationDelay: `${i * 18}ms` }}
>
<PlaceCard
place={p}
onTap={() =>
dispatch({
type: "NAV",
screen: "place",
placeId: p.id,
})
}
/>
</div>
))}
</div>
</div>
))}
)}
{filteredPublic.length > 0 && (
<div>
<SectionHeader
title="Khám phá"
subtitle="Bộ sưu tập công khai từ cộng đồng"
count={filteredPublic.length}
/>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 10,
padding: "0 16px",
}}
>
{filteredPublic.map((p, i) => (
<div
key={`pub-${p.id}`}
className="page-enter"
style={{ animationDelay: `${i * 18}ms` }}
>
<PlaceCard
place={p}
showCity
onTap={() =>
dispatch({
type: "NAV",
screen: "place",
placeId: p.id,
})
}
/>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}
function SectionHeader({
title,
subtitle,
count,
}: {
title: string;
subtitle?: string;
count?: number;
}) {
return (
<div
style={{
padding: "0 16px 8px",
display: "flex",
alignItems: "baseline",
gap: 8,
}}
>
<span
style={{
fontSize: 13,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--muted-foreground)",
}}
>
{title}
</span>
{typeof count === "number" && (
<span
style={{
fontSize: 12,
color: "var(--subtle-foreground)",
fontWeight: 500,
}}
>
{count}
</span>
)}
{subtitle && (
<span
style={{
fontSize: 12,
color: "var(--subtle-foreground)",
marginLeft: "auto",
}}
>
{subtitle}
</span>
)}
</div>
);
}

View File

@@ -1,16 +1,37 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { useCollections, useMe } from "@/lib/app-context";
import type { AppState, Dispatch } from "@/lib/app-state";
import { Header, IconBtn } from "@/components/ui-primitives";
import { Icons, type IconName } from "@/components/icons";
import { logoutAction } from "@/app/(auth)/auth-actions";
export function ProfileScreen({ state }: { state: AppState; dispatch: Dispatch }) {
export function ProfileScreen({ state, dispatch }: { state: AppState; dispatch: Dispatch }) {
const me = useMe();
const collections = useCollections();
const router = useRouter();
const [signingOut, startSignOut] = useTransition();
const handleLogout = () => {
if (signingOut) return;
startSignOut(async () => {
try {
await logoutAction();
} catch (e) {
dispatch({
type: "TOAST",
value: (e as Error).message || "Đăng xuất thất bại",
});
return;
}
// The cookie has been cleared server-side; hard-replace to /login so
// the in-memory client state (places, collections, me) is dropped.
router.replace("/login");
router.refresh();
});
};
const stats = [
{ label: "Địa điểm", value: state.places.length },
{ label: "Đã đến", value: state.places.filter((p) => p.visited).length },
@@ -23,7 +44,7 @@ export function ProfileScreen({ state }: { state: AppState; dispatch: Dispatch }
<div className="app-scroll">
<div
style={{
padding: "4px 16px 24px",
padding: "16px 16px 24px",
display: "flex",
flexDirection: "column",
gap: 18,
@@ -46,17 +67,33 @@ export function ProfileScreen({ state }: { state: AppState; dispatch: Dispatch }
width: 64,
height: 64,
borderRadius: 9999,
background:
"linear-gradient(135deg, var(--primary), color-mix(in oklch, var(--primary) 60%, oklch(60% 0.12 320)))",
overflow: "hidden",
background: me.avatar_url
? "var(--muted)"
: "linear-gradient(135deg, var(--primary), color-mix(in oklch, var(--primary) 60%, oklch(60% 0.12 320)))",
color: "var(--primary-foreground)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 24,
fontWeight: 700,
flexShrink: 0,
}}
>
{me.initials}
{me.avatar_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={me.avatar_url}
alt={me.name}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
) : (
me.initials
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
@@ -74,7 +111,11 @@ export function ProfileScreen({ state }: { state: AppState; dispatch: Dispatch }
{me.email}
</div>
</div>
<IconBtn icon="Edit2" label="Sửa" />
<IconBtn
icon="Edit2"
label="Sửa"
onClick={() => dispatch({ type: "OPEN_EDIT_PROFILE" })}
/>
</div>
{/* Stats */}
@@ -134,7 +175,7 @@ export function ProfileScreen({ state }: { state: AppState; dispatch: Dispatch }
label={signingOut ? "Đang đăng xuất..." : "Đăng xuất"}
danger
last
onClick={() => startSignOut(() => logoutAction())}
onClick={handleLogout}
disabled={signingOut}
/>
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useTransition } from "react";
import { useEffect, useRef, useState, useTransition } from "react";
import { CATEGORIES } from "@/lib/ui-config";
import type { CategoryId, Place } from "@/lib/types";
import type { Dispatch } from "@/lib/app-state";
@@ -12,8 +12,15 @@ import {
disposeCover,
type CoverState,
} from "@/components/cover-picker";
import { RatingStars } from "@/components/rating-stars";
import { Icons } from "@/components/icons";
import { editPlace } from "@/lib/db/actions";
import { editPlace, setNotes as saveNotes, setRating } from "@/lib/db/actions";
import {
type NominatimResult,
reverseGeocode,
searchAddress,
toPlaceFields,
} from "@/lib/nominatim";
export function EditPlaceSheet({
place,
@@ -26,13 +33,81 @@ export function EditPlaceSheet({
}) {
const [name, setName] = useState(place.name);
const [address, setAddress] = useState(place.address);
const [picked, setPicked] = useState<NominatimResult | null>(null);
const [suggestions, setSuggestions] = useState<NominatimResult[]>([]);
const [searching, setSearching] = useState(false);
const [showSugg, setShowSugg] = useState(false);
const [geoLoading, setGeoLoading] = useState(false);
const [category, setCategory] = useState<CategoryId>(place.category);
const [tags, setTags] = useState<string[]>(place.tags);
const [tagInput, setTagInput] = useState("");
const [cover, setCover] = useState<CoverState>(() => coverStateFromUrl(place.cover_url));
const [notes, setNotesValue] = useState(place.my_notes ?? "");
const [rating, setRatingValue] = useState(place.my_rating ?? 0);
const [saving, setSaving] = useState(false);
const [, startTransition] = useTransition();
// Debounced Nominatim autocomplete — mirrors add-place-sheet:
// rate-limit 1 req/s + cancel in-flight, only when user has actively typed.
// The showSugg gate is critical here (unlike add-place-sheet): the address
// input starts pre-populated, so without it we'd fire a Nominatim request
// on every sheet open just to display the existing value.
const lastReqRef = useRef<AbortController | null>(null);
useEffect(() => {
const q = address.trim();
if (!q || q.length < 3 || picked || !showSugg) {
setSuggestions([]);
return;
}
const ac = new AbortController();
lastReqRef.current?.abort();
lastReqRef.current = ac;
const id = setTimeout(async () => {
setSearching(true);
try {
const rows = await searchAddress(q, ac.signal);
if (!ac.signal.aborted) setSuggestions(rows);
} catch {
// ignore
} finally {
if (!ac.signal.aborted) setSearching(false);
}
}, 500);
return () => {
clearTimeout(id);
ac.abort();
};
}, [address, picked, showSugg]);
const useCurrentLocation = () => {
if (!navigator.geolocation) {
dispatch({ type: "TOAST", value: "Trình duyệt không hỗ trợ định vị" });
return;
}
setGeoLoading(true);
navigator.geolocation.getCurrentPosition(
async (pos) => {
try {
const r = await reverseGeocode(pos.coords.latitude, pos.coords.longitude);
if (r) {
setAddress(r.display_name);
setPicked(r);
setShowSugg(false);
} else {
dispatch({ type: "TOAST", value: "Không tra được địa chỉ" });
}
} finally {
setGeoLoading(false);
}
},
() => {
setGeoLoading(false);
dispatch({ type: "TOAST", value: "Không lấy được vị trí" });
},
{ enableHighAccuracy: true, timeout: 8000 },
);
};
const isValid = name.trim() && address.trim();
const addTag = (raw: string) => {
@@ -45,6 +120,18 @@ export function EditPlaceSheet({
if (!isValid) return;
setSaving(true);
const trimmedAddress = address.trim();
const trimmedNotes = notes.trim();
const prevRating = place.my_rating ?? 0;
const prevNotes = place.my_notes ?? "";
// Prefer the structured fields from a Nominatim pick (short_address, city
// already normalized). Fall back to naive split() of the raw text.
const addressFields = picked
? toPlaceFields(picked)
: {
address: trimmedAddress,
short_address: trimmedAddress.split(",").slice(0, 2).join(" · "),
city: trimmedAddress.split(",").pop()?.trim() || "",
};
startTransition(async () => {
try {
// Upload to R2 only now — local blob (if any) is converted to a real
@@ -52,16 +139,26 @@ export function EditPlaceSheet({
const coverUrl = await commitCover(cover);
const patch = {
name: name.trim(),
address: trimmedAddress,
short_address: trimmedAddress.split(",").slice(0, 2).join(" · "),
city: trimmedAddress.split(",").pop()?.trim() || "",
address: addressFields.address,
short_address: addressFields.short_address,
city: addressFields.city,
category,
tags,
cover_url: coverUrl,
};
await editPlace(place.id, patch);
// Persist per-user fields only when they changed — these live in
// user_place_data, not places, and have their own server actions.
if (rating !== prevRating) await setRating(place.id, rating);
if (trimmedNotes !== prevNotes) await saveNotes(place.id, trimmedNotes);
disposeCover(cover);
dispatch({ type: "PATCH_PLACE", placeId: place.id, patch });
if (rating !== prevRating) {
dispatch({ type: "SET_RATING", placeId: place.id, value: rating });
}
if (trimmedNotes !== prevNotes) {
dispatch({ type: "SET_NOTES", placeId: place.id, value: trimmedNotes });
}
onClose();
dispatch({ type: "TOAST", value: "Đã lưu thay đổi" });
} catch (e) {
@@ -121,8 +218,117 @@ export function EditPlaceSheet({
<FieldLabel required>Đa chỉ</FieldLabel>
<div className="input">
<Icons.MapPin size={18} stroke={1.75} style={{ color: "var(--muted-foreground)" }} />
<input value={address} onChange={(e) => setAddress(e.target.value)} />
<input
value={address}
onChange={(e) => {
setAddress(e.target.value);
setPicked(null);
setShowSugg(true);
}}
onFocus={() => setShowSugg(true)}
placeholder="Số nhà, đường, quận, tỉnh"
/>
<button
type="button"
onClick={useCurrentLocation}
disabled={geoLoading}
style={{
background: "var(--muted)",
border: 0,
width: 32,
height: 32,
borderRadius: 9999,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--primary)",
opacity: geoLoading ? 0.6 : 1,
flexShrink: 0,
}}
aria-label="Lấy vị trí hiện tại"
>
<Icons.Crosshair size={16} stroke={2} />
</button>
</div>
{showSugg && address.length >= 3 && !picked && (
<div
style={{
marginTop: 6,
background: "var(--card)",
border: "0.5px solid var(--border)",
borderRadius: "var(--radius-md)",
overflow: "hidden",
boxShadow: "var(--shadow-md)",
}}
>
{searching && suggestions.length === 0 && (
<div
style={{
padding: "10px 12px",
fontSize: 13,
color: "var(--muted-foreground)",
}}
>
Đang tìm...
</div>
)}
{!searching && suggestions.length === 0 && (
<div
style={{
padding: "10px 12px",
fontSize: 13,
color: "var(--muted-foreground)",
}}
>
Không gợi ý
</div>
)}
{suggestions.slice(0, 4).map((s, i) => (
<button
key={s.place_id}
type="button"
onClick={() => {
setAddress(s.display_name);
setPicked(s);
setShowSugg(false);
}}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 12px",
background: "transparent",
border: 0,
textAlign: "left",
borderBottom:
i < Math.min(3, suggestions.length - 1)
? "0.5px solid var(--border)"
: 0,
}}
>
<Icons.MapPin
size={16}
stroke={1.75}
style={{ color: "var(--muted-foreground)", flexShrink: 0 }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 14,
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{s.display_name}
</div>
</div>
</button>
))}
</div>
)}
<FieldLabel required>Danh mục</FieldLabel>
<div className="toggle-group">
@@ -185,6 +391,34 @@ export function EditPlaceSheet({
/>
</div>
<FieldLabel>
<Icons.Lock size={12} stroke={2.5} style={{ marginRight: 4 }} />
Ghi chú riêng
</FieldLabel>
<div className="input input--multi">
<textarea
value={notes}
onChange={(e) => setNotesValue(e.target.value)}
placeholder="Chỉ mình bạn thấy..."
style={{ resize: "none", minHeight: 72 }}
/>
</div>
<FieldLabel>
Đánh giá{" "}
<span style={{ color: "var(--subtle-foreground)", fontWeight: 400 }}>
· tuỳ chọn
</span>
</FieldLabel>
<div style={{ padding: "8px 0" }}>
<RatingStars
value={rating}
readOnly={false}
size={28}
onChange={(v) => setRatingValue(v)}
/>
</div>
<div style={{ height: 8 }} />
</div>

View File

@@ -0,0 +1,366 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { useMe } from "@/lib/app-context";
import { FieldLabel } from "@/components/ui-primitives";
import { Icons } from "@/components/icons";
import { updateProfile, uploadImage } from "@/lib/db/actions";
import { ACCEPTED_TYPES, resizeImage } from "@/lib/image-resize";
import type { Dispatch } from "@/lib/app-state";
// Avatar editor: a circular preview + hidden file input. Local blob lives
// in memory until the user hits "Save", at which point we upload to R2 and
// then call updateProfile. Mirrors the deferred-upload pattern in CoverPicker.
type AvatarState =
| { kind: "url"; url: string | null }
| { kind: "local"; blob: Blob; previewUrl: string };
export function EditProfileSheet({
onClose,
dispatch,
}: {
onClose: () => void;
dispatch: Dispatch;
}) {
const me = useMe();
const router = useRouter();
const [name, setName] = useState(me.name);
const [username, setUsername] = useState(me.username ?? "");
const [avatar, setAvatar] = useState<AvatarState>({
kind: "url",
url: me.avatar_url ?? null,
});
const [busy, setBusy] = useState(false);
const [saving, setSaving] = useState(false);
const [, startTransition] = useTransition();
const inputRef = useRef<HTMLInputElement>(null);
// Username is optional. If filled, validate against the same rules the
// server uses (330, lowercase alphanum + . + _) so we surface errors
// immediately rather than on submit.
const trimmedUsername = username.trim().toLowerCase();
const usernameError = (() => {
if (!trimmedUsername) return null;
if (trimmedUsername.length < 3 || trimmedUsername.length > 30) {
return "Username phải có 330 ký tự";
}
if (!/^[a-z0-9](?:[a-z0-9._]{1,28})[a-z0-9]$/.test(trimmedUsername)) {
return "Chỉ chữ thường, số, dấu chấm hoặc gạch dưới";
}
return null;
})();
const isValid = name.trim().length > 0 && !usernameError;
// Free the object URL when the local preview is replaced or unmounted.
useEffect(() => {
if (avatar.kind !== "local") return;
const url = avatar.previewUrl;
return () => URL.revokeObjectURL(url);
}, [avatar]);
const onPickFile = async (file: File) => {
setBusy(true);
try {
const { blob } = await resizeImage(file);
const previewUrl = URL.createObjectURL(blob);
if (avatar.kind === "local") URL.revokeObjectURL(avatar.previewUrl);
setAvatar({ kind: "local", blob, previewUrl });
} catch (e) {
dispatch({
type: "TOAST",
value: (e as Error).message || "Đọc ảnh thất bại",
});
} finally {
setBusy(false);
if (inputRef.current) inputRef.current.value = "";
}
};
const clearAvatar = () => {
if (avatar.kind === "local") URL.revokeObjectURL(avatar.previewUrl);
setAvatar({ kind: "url", url: null });
};
const submit = () => {
if (!isValid || saving) return;
setSaving(true);
startTransition(async () => {
try {
let nextUrl: string | null = null;
if (avatar.kind === "local") {
const ext = avatar.blob.type.split("/")[1] || "bin";
const file = new File([avatar.blob], `avatar.${ext}`, {
type: avatar.blob.type,
});
const fd = new FormData();
fd.append("file", file);
const { url } = await uploadImage(fd);
nextUrl = url;
} else {
nextUrl = avatar.url;
}
await updateProfile({
name: name.trim(),
avatar_url: nextUrl,
username: trimmedUsername || null,
});
// The `me` object lives in server-fetched AppData — refresh to pick up
// the new name/initials/avatar. router.refresh() re-runs the page RSC.
router.refresh();
onClose();
dispatch({ type: "TOAST", value: "Đã cập nhật hồ sơ" });
} catch (e) {
setSaving(false);
dispatch({
type: "TOAST",
value: (e as Error).message || "Lưu thất bại",
});
}
});
};
const previewSrc =
avatar.kind === "local"
? avatar.previewUrl
: avatar.kind === "url"
? avatar.url
: null;
return (
<>
<div className="overlay" onClick={onClose} />
<div className="sheet" style={{ height: "70%" }}>
<div className="sheet-handle" />
<div
style={{
padding: "6px 12px 8px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<button
type="button"
onClick={onClose}
style={{
background: "transparent",
border: 0,
color: "var(--muted-foreground)",
fontSize: 15,
fontWeight: 500,
padding: "8px 12px",
}}
>
Hủy
</button>
<div style={{ fontSize: 16, fontWeight: 600 }}>Sửa hồ </div>
<div style={{ width: 70 }} />
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "4px 16px" }}>
<input
ref={inputRef}
type="file"
accept={ACCEPTED_TYPES.join(",")}
style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void onPickFile(f);
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 12,
padding: "16px 0 8px",
}}
>
<div
style={{
position: "relative",
width: 96,
height: 96,
}}
>
<div
style={{
width: 96,
height: 96,
borderRadius: 9999,
overflow: "hidden",
background: previewSrc
? "var(--muted)"
: "linear-gradient(135deg, var(--primary), color-mix(in oklch, var(--primary) 60%, oklch(60% 0.12 320)))",
color: "var(--primary-foreground)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 32,
fontWeight: 700,
border: "0.5px solid var(--border)",
}}
>
{previewSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewSrc}
alt=""
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
) : (
me.initials
)}
</div>
{previewSrc && (
<button
type="button"
onClick={clearAvatar}
disabled={busy || saving}
aria-label="Xoá ảnh đại diện"
style={{
position: "absolute",
top: -2,
right: -2,
width: 30,
height: 30,
borderRadius: 9999,
border: "2px solid var(--card)",
background: "var(--card)",
color: "var(--muted-foreground)",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "var(--shadow-sm)",
}}
>
<Icons.X size={14} stroke={2.25} />
</button>
)}
</div>
<button
type="button"
onClick={() => !busy && !saving && inputRef.current?.click()}
disabled={busy || saving}
style={{
appearance: "none",
background: "transparent",
border: 0,
color: "var(--primary)",
fontSize: 14,
fontWeight: 600,
padding: "6px 10px",
cursor: busy ? "wait" : "pointer",
}}
>
{busy
? "Đang xử lý..."
: previewSrc
? "Đổi ảnh đại diện"
: "Thêm ảnh đại diện"}
</button>
</div>
<FieldLabel required>Tên hiển thị</FieldLabel>
<div className="input">
<input
value={name}
maxLength={60}
onChange={(e) => setName(e.target.value)}
placeholder="Tên của bạn"
/>
</div>
<FieldLabel>
Username{" "}
<span style={{ color: "var(--subtle-foreground)", fontWeight: 400 }}>
· tuỳ chọn
</span>
</FieldLabel>
<div
className="input"
style={{
borderColor: usernameError ? "var(--danger)" : undefined,
}}
>
<Icons.User
size={18}
stroke={1.75}
style={{ color: "var(--muted-foreground)" }}
/>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
maxLength={30}
placeholder="vd: minh.nguyen"
style={{ textTransform: "lowercase" }}
/>
</div>
<div
style={{
fontSize: 12,
color: usernameError ? "var(--danger)" : "var(--subtle-foreground)",
marginTop: 4,
marginBottom: 8,
}}
>
{usernameError ||
"Dùng thay email khi đăng nhập. Chữ thường, số, dấu chấm hoặc gạch dưới."}
</div>
<FieldLabel>Email</FieldLabel>
<div
className="input"
style={{ background: "var(--muted)", color: "var(--muted-foreground)" }}
>
<Icons.Mail
size={18}
stroke={1.75}
style={{ color: "var(--muted-foreground)" }}
/>
<input value={me.email ?? ""} readOnly disabled />
</div>
<div
style={{
fontSize: 12,
color: "var(--subtle-foreground)",
marginTop: 4,
marginBottom: 8,
}}
>
Email dùng đ đăng nhập không thể đi.
</div>
<div style={{ height: 8 }} />
</div>
<div
style={{
padding: "12px 16px 4px",
borderTop: "0.5px solid var(--border)",
background: "color-mix(in oklch, var(--card) 90%, transparent)",
}}
>
<button
className="btn btn--block btn--lg"
disabled={!isValid || saving || busy}
onClick={submit}
>
{saving ? "Đang lưu..." : "Lưu thay đổi"}
</button>
</div>
</div>
</>
);
}