a
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
49
src/app/collections/[id]/page.tsx
Normal file
49
src/app/collections/[id]/page.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
56
src/app/places/[id]/page.tsx
Normal file
56
src/app/places/[id]/page.tsx
Normal 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
221
src/app/public-home.tsx
Normal 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 có đị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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
2296
src/desktop/desktop-shell.tsx
Normal file
2296
src/desktop/desktop-shell.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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ó 3–30 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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (3–30 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(),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -8,6 +8,7 @@ export type User = {
|
||||
id: number;
|
||||
name: string;
|
||||
email?: string;
|
||||
username?: string | null;
|
||||
avatar_url?: string | null;
|
||||
initials: string;
|
||||
color?: string;
|
||||
|
||||
@@ -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))"
|
||||
|
||||
@@ -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ả
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 có 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 tư
|
||||
</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>
|
||||
|
||||
|
||||
366
src/sheets/edit-profile-sheet.tsx
Normal file
366
src/sheets/edit-profile-sheet.tsx
Normal 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 (3–30, 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ó 3–30 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ồ sơ</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user