This commit is contained in:
2026-05-20 15:40:17 +07:00
parent 230eb9010c
commit dd3fd889a3
48 changed files with 3374 additions and 737 deletions

21
.dockerignore Normal file
View File

@@ -0,0 +1,21 @@
**/node_modules
**/.next
**/.git
**/.env
**/.env.*
!.env.example
**/dist
**/build
**/.DS_Store
**/.vscode
**/.idea
**/coverage
**/.turbo
Dockerfile
docker-compose.yml
docker-compose.*.yml
.dockerignore
*.log
README.md
plans/
db/migrations/

17
.env.example Normal file
View File

@@ -0,0 +1,17 @@
# Postgres
PGHOST=
PGUSER=
PGPASSWORD=
PGDATABASE=
PGPORT=5432
# Email (optional — without these, sendEmailInvite logs to console and the
# invitation row still gets persisted so the owner can copy the URL).
# Resend.com — RESEND_API_KEY is the secret API key; INVITE_FROM_EMAIL must be
# a verified sender domain in your Resend account.
RESEND_API_KEY=
INVITE_FROM_EMAIL=
# Optional override for the origin used in invite URLs. Falls back to the
# request's host/protocol if unset. Server-only — never sent to the browser.
APP_URL=

46
Dockerfile Normal file
View File

@@ -0,0 +1,46 @@
# syntax=docker/dockerfile:1.7
# ─── Stage 1: install deps (cacheable) ──────────────────────────────────────
FROM node:22-alpine AS deps
WORKDIR /app
# libc6-compat helps some native deps (bcryptjs is pure JS so not strictly
# needed, but cheap insurance for pg / future native deps).
RUN apk add --no-cache libc6-compat
COPY package.json package-lock.json ./
RUN npm ci
# ─── Stage 2: build ──────────────────────────────────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# next.config.ts has `output: "standalone"`, which writes a minimal Node
# server bundle to .next/standalone — used by the runtime stage below.
RUN npm run build
# ─── Stage 3: runtime ────────────────────────────────────────────────────────
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME=0.0.0.0 \
NEXT_TELEMETRY_DISABLED=1
# Non-root user for the app process.
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 --ingroup nodejs nextjs
# Standalone output bundles only the files needed to run the server, plus a
# trimmed node_modules. Public assets and .next/static are still external.
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
# server.js is generated by Next's standalone output; it reads PORT/HOSTNAME
# from the environment at startup, so docker compose can inject them.
CMD ["node", "server.js"]

5
build.sh Normal file
View File

@@ -0,0 +1,5 @@
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t renolation/places:latest \
--push \
.

48
db/seed-user.mjs Normal file
View File

@@ -0,0 +1,48 @@
// Seed a single user (no places/collections). Use after `npm run db:push`.
// Defaults to vodanh.2901@gmail.com / renolation. Pass args to override.
//
// node --env-file=.env.local db/seed-user.mjs
// node --env-file=.env.local db/seed-user.mjs other@email.com pw123456 "Other Name"
import bcrypt from "bcryptjs";
import pg from "pg";
const EMAIL = process.argv[2] || "vodanh.2901@gmail.com";
const PASSWORD = process.argv[3] || "renolation";
const NAME = process.argv[4] || "Vô Danh";
function deriveInitials(name) {
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();
}
const conn = process.env.DATABASE_URL
? { connectionString: process.env.DATABASE_URL }
: {
host: process.env.PGHOST,
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
database: process.env.PGDATABASE,
};
const client = new pg.Client(conn);
await client.connect();
const hash = await bcrypt.hash(PASSWORD, 10);
const initials = deriveInitials(NAME);
const res = await client.query(
`INSERT INTO users (email, password_hash, name, initials)
VALUES ($1, $2, $3, $4)
ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash
RETURNING id`,
[EMAIL, hash, NAME, initials],
);
console.log(`[user] ${EMAIL} → id=${res.rows[0].id}`);
console.log(`[login] ${EMAIL} / ${PASSWORD}`);
await client.end();

49
docker-compose.yml Normal file
View File

@@ -0,0 +1,49 @@
# Run the prod-built app in a container, pulling env vars from .env.local
# (if present) or from the shell. No build-time secrets — every env value is
# read at container start by Next's standalone server.
#
# Usage:
# docker compose up --build
# APP_PORT=8080 docker compose up (override host port)
#
# Compose v2.24+ (required for `env_file: path/required` syntax).
services:
app:
build:
context: .
dockerfile: Dockerfile
image: places:latest
container_name: places
restart: unless-stopped
ports:
- "${APP_PORT:-3000}:3000"
# Env vars are loaded from .env.local at container start.
# `required: false` lets compose run even if the file is missing — useful
# when vars are injected by an external system (CI, K8s secrets, etc).
env_file:
- path: .env.local
required: false
# Explicit values that always apply (these win over .env.local).
environment:
NODE_ENV: production
PORT: "3000"
HOSTNAME: "0.0.0.0"
PGHOST: 103.188.82.191
PGUSER: renolation
PGPASSWORD: renolation
PGDATABASE: places_db
PGPORT: "5432"
healthcheck:
test:
- "CMD"
- "node"
- "-e"
- "fetch('http://localhost:3000/login').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
interval: 30s
timeout: 5s
retries: 3
start_period: 15s

15
drizzle.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { Config } from "drizzle-kit";
export default {
schema: "./src/lib/db/schema.ts",
out: "./db/migrations",
dialect: "postgresql",
dbCredentials: {
host: process.env.PGHOST ?? "",
user: process.env.PGUSER ?? "",
password: process.env.PGPASSWORD ?? "",
database: process.env.PGDATABASE ?? "",
port: process.env.PGPORT ? Number(process.env.PGPORT) : 5432,
ssl: false,
},
} satisfies Config;

View File

@@ -1,6 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Produces .next/standalone with a minimal Node server bundle — the Docker
// image stays small (no node_modules, no source code copied at runtime).
output: "standalone",
images: {
remotePatterns: [
{ protocol: "https", hostname: "images.unsplash.com" },

View File

@@ -6,18 +6,28 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"db:push": "dotenv -e .env.local -- drizzle-kit push",
"db:studio": "dotenv -e .env.local -- drizzle-kit studio",
"db:seed": "node --env-file=.env.local db/seed-user.mjs"
},
"dependencies": {
"next": "15.0.3",
"react": "19.0.0-rc-66855b96-20241106",
"react-dom": "19.0.0-rc-66855b96-20241106"
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.45.2",
"next": "^16.2.6",
"pg": "^8.21.0",
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv-cli": "^11.0.0",
"drizzle-kit": "^0.31.10",
"postcss": "^8",
"tailwindcss": "^4.0.0",
"typescript": "^5"

0
public/.gitkeep Normal file
View File

View File

@@ -0,0 +1,43 @@
"use server";
import { redirect } from "next/navigation";
import { loginUser, logout as logoutImpl, registerUser } from "@/lib/db/auth";
export type FormState = { error?: string };
function safeNext(value: unknown): string {
if (typeof value !== "string") return "/";
// Only allow same-origin relative paths to avoid open-redirect.
if (!value.startsWith("/") || value.startsWith("//")) return "/";
return value;
}
export async function loginAction(
_prev: FormState,
data: FormData,
): Promise<FormState> {
const email = String(data.get("email") || "");
const password = String(data.get("password") || "");
const next = safeNext(data.get("next"));
const res = await loginUser(email, password);
if (!res.ok) return { error: res.error };
redirect(next);
}
export async function registerAction(
_prev: FormState,
data: FormData,
): Promise<FormState> {
const email = String(data.get("email") || "");
const password = String(data.get("password") || "");
const name = String(data.get("name") || "");
const next = safeNext(data.get("next"));
const res = await registerUser(email, password, name);
if (!res.ok) return { error: res.error };
redirect(next);
}
export async function logoutAction(): Promise<void> {
await logoutImpl();
redirect("/login");
}

View File

@@ -0,0 +1,185 @@
"use client";
import { useActionState } from "react";
import Link from "next/link";
import { Icons } from "@/components/icons";
import type { FormState } from "./auth-actions";
type Action = (prev: FormState, data: FormData) => Promise<FormState>;
export function AuthForm({
mode,
action,
next = "/",
}: {
mode: "login" | "register";
action: Action;
next?: string;
}) {
const [state, formAction, pending] = useActionState<FormState, FormData>(
action,
{},
);
const isLogin = mode === "login";
const title = isLogin ? "Đăng nhập" : "Tạo tài khoản";
const subtitle = isLogin
? "Tiếp tục với địa điểm đã lưu của bạn"
: "Bắt đầu lưu địa điểm cùng nhóm nhỏ";
const cta = isLogin ? "Đăng nhập" : "Đăng ký";
const switchPrompt = isLogin ? "Chưa có tài khoản?" : "Đã có tài khoản?";
const switchBase = isLogin ? "/register" : "/login";
const switchHref =
next && next !== "/"
? `${switchBase}?next=${encodeURIComponent(next)}`
: switchBase;
const switchLabel = isLogin ? "Đăng ký" : "Đăng nhập";
return (
<form
action={formAction}
style={{
display: "flex",
flexDirection: "column",
gap: 14,
padding: 24,
background: "var(--card)",
border: "0.5px solid var(--border)",
borderRadius: "var(--radius-xl)",
boxShadow: "var(--shadow-md)",
}}
>
<input type="hidden" name="next" value={next} />
<div style={{ marginBottom: 4 }}>
<h1
className="display"
style={{
margin: 0,
fontSize: 28,
fontWeight: 700,
letterSpacing: "-0.02em",
lineHeight: 1.1,
}}
>
{title}
</h1>
<p
style={{
margin: "4px 0 0",
fontSize: 14,
color: "var(--muted-foreground)",
}}
>
{subtitle}
</p>
</div>
{!isLogin && (
<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="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
name="password"
type="password"
required
minLength={8}
autoComplete={isLogin ? "current-password" : "new-password"}
placeholder={isLogin ? "••••••••" : "Tối thiểu 8 ký tự"}
disabled={pending}
/>
</Field>
{state.error && (
<div
role="alert"
style={{
padding: "10px 12px",
background: "var(--danger-soft)",
color: "var(--danger)",
borderRadius: "var(--radius-md)",
fontSize: 13,
fontWeight: 500,
}}
>
{state.error}
</div>
)}
<button
type="submit"
className="btn btn--block btn--lg"
disabled={pending}
style={{ marginTop: 4 }}
>
{pending ? "Đang xử lý..." : cta}
</button>
<div
style={{
fontSize: 13,
color: "var(--muted-foreground)",
textAlign: "center",
paddingTop: 4,
}}
>
{switchPrompt}{" "}
<Link
href={switchHref}
style={{ color: "var(--primary)", fontWeight: 600, textDecoration: "none" }}
>
{switchLabel}
</Link>
</div>
</form>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--muted-foreground)",
textTransform: "uppercase",
letterSpacing: "0.04em",
}}
>
{label}
</span>
<span className="input">{children}</span>
</label>
);
}

78
src/app/(auth)/layout.tsx Normal file
View File

@@ -0,0 +1,78 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<main
style={{
minHeight: "100dvh",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
padding: "32px 20px",
background: "var(--background)",
}}
>
<div
style={{
width: "100%",
maxWidth: 420,
display: "flex",
flexDirection: "column",
gap: 24,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
justifyContent: "center",
}}
>
<div
style={{
width: 36,
height: 36,
borderRadius: 10,
background:
"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",
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
</div>
<div
className="display"
style={{
fontSize: 22,
fontWeight: 600,
letterSpacing: "-0.015em",
}}
>
Places
</div>
</div>
{children}
<p
style={{
textAlign: "center",
fontSize: 12,
color: "var(--subtle-foreground)",
margin: 0,
}}
>
Lưu đa điểm. Cùng nhóm nhỏ.
</p>
</div>
</main>
);
}

View File

@@ -0,0 +1,22 @@
import { redirect } from "next/navigation";
import { getCurrentUserId } from "@/lib/db/auth";
import { AuthForm } from "../auth-form";
import { loginAction } from "../auth-actions";
export const dynamic = "force-dynamic";
function safeNext(v: string | undefined): string {
if (!v || !v.startsWith("/") || v.startsWith("//")) return "/";
return v;
}
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ next?: string }>;
}) {
const sp = await searchParams;
const next = safeNext(sp.next);
if (await getCurrentUserId()) redirect(next);
return <AuthForm mode="login" action={loginAction} next={next} />;
}

View File

@@ -0,0 +1,22 @@
import { redirect } from "next/navigation";
import { getCurrentUserId } from "@/lib/db/auth";
import { AuthForm } from "../auth-form";
import { registerAction } from "../auth-actions";
export const dynamic = "force-dynamic";
function safeNext(v: string | undefined): string {
if (!v || !v.startsWith("/") || v.startsWith("//")) return "/";
return v;
}
export default async function RegisterPage({
searchParams,
}: {
searchParams: Promise<{ next?: string }>;
}) {
const sp = await searchParams;
const next = safeNext(sp.next);
if (await getCurrentUserId()) redirect(next);
return <AuthForm mode="register" action={registerAction} next={next} />;
}

View File

@@ -0,0 +1,77 @@
import { redirect } from "next/navigation";
import { acceptInviteCore } from "@/lib/db/invites";
import { getCurrentUserId } from "@/lib/db/auth";
export const dynamic = "force-dynamic";
export default async function AcceptInvitePage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const { token } = await params;
const uid = await getCurrentUserId();
if (!uid) {
redirect(`/login?next=${encodeURIComponent(`/invite/${token}`)}`);
}
try {
await acceptInviteCore(token, uid);
} catch (e) {
return (
<main
style={{
minHeight: "100dvh",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
padding: "32px 20px",
background: "var(--background)",
}}
>
<div
style={{
maxWidth: 380,
width: "100%",
padding: 24,
background: "var(--card)",
border: "0.5px solid var(--border)",
borderRadius: "var(--radius-xl)",
textAlign: "center",
}}
>
<h1
style={{
margin: 0,
fontSize: 20,
fontWeight: 700,
letterSpacing: "-0.01em",
}}
>
Lời mời không hợp lệ
</h1>
<p
style={{
marginTop: 8,
fontSize: 14,
color: "var(--muted-foreground)",
lineHeight: 1.5,
}}
>
{(e as Error).message || "Liên kết đã hết hạn hoặc đã bị vô hiệu."}
</p>
<a
href="/"
className="btn btn--block"
style={{ marginTop: 16, textDecoration: "none" }}
>
Về trang chủ
</a>
</div>
</main>
);
}
redirect("/");
}

View File

@@ -1,5 +1,31 @@
import { redirect } from "next/navigation";
import { PlacesApp } from "./places-app";
import { getCurrentUserId } from "@/lib/db/auth";
import {
getAllUsers,
getCollectionsForUser,
getCurrentUser,
getPlacesForUser,
} from "@/lib/db/queries";
export default function Page() {
return <PlacesApp />;
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([
getCurrentUser(),
getAllUsers(),
getPlacesForUser(uid),
getCollectionsForUser(uid),
]);
if (!me) redirect("/login");
return (
<PlacesApp
initialPlaces={places}
data={{ me, users, collections }}
/>
);
}

View File

@@ -1,13 +1,15 @@
"use client";
import { useEffect, useReducer } from "react";
import { COLLECTIONS } from "@/lib/mock-data";
import { useEffect, useReducer, useTransition } from "react";
import {
INITIAL_STATE,
makeInitialState,
reducer,
type Screen,
type Tab,
} from "@/lib/app-state";
import { AppDataProvider, type AppData } from "@/lib/app-context";
import type { Place } from "@/lib/types";
import { deleteCollection, deletePlace } from "@/lib/db/actions";
import { TabBar } from "@/components/ui-primitives";
import { Icons } from "@/components/icons";
import { PlacesListScreen } from "@/screens/places-list-screen";
@@ -16,11 +18,21 @@ import { CollectionsListScreen } from "@/screens/collections-list-screen";
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 { 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";
export function PlacesApp() {
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
export function PlacesApp({
initialPlaces,
data,
}: {
initialPlaces: Place[];
data: AppData;
}) {
const [state, dispatch] = useReducer(reducer, initialPlaces, makeInitialState);
const [, startTransition] = useTransition();
useEffect(() => {
if (!state.toast) return;
@@ -32,7 +44,6 @@ export function PlacesApp() {
return () => clearTimeout(id);
}, [state.toast, state.toastKey]);
// Online/offline detection
useEffect(() => {
const sync = () =>
dispatch({ type: "SET_OFFLINE", value: !navigator.onLine });
@@ -88,70 +99,118 @@ export function PlacesApp() {
: null;
const collectionForDelete =
m?.type === "confirmDeleteCollection"
? COLLECTIONS.find((c) => c.id === m.collectionId)
? data.collections.find((c) => c.id === m.collectionId)
: null;
return (
<div className="app-frame">
{renderScreen(top.screen)}
<TabBar
active={activeTab}
onTab={onTab}
onFab={() => dispatch({ type: "OPEN_ADD" })}
showFab={top.screen !== "profile"}
/>
<AppDataProvider value={data}>
<div className="app-frame">
{renderScreen(top.screen)}
<TabBar
active={activeTab}
onTab={onTab}
onFab={() => dispatch({ type: "OPEN_ADD" })}
showFab={top.screen !== "profile"}
/>
{m?.type === "add" && (
<AddPlaceSheet
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
dispatch={dispatch}
/>
)}
{m?.type === "invite" && (
<InviteDialog
collectionId={m.collectionId}
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
dispatch={dispatch}
/>
)}
{m?.type === "members" && (
<MembersSheet
collectionId={m.collectionId}
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
dispatch={dispatch}
/>
)}
{m?.type === "confirmDeletePlace" && placeForDelete && (
<ConfirmDialog
title="Xóa địa điểm?"
body={`"${placeForDelete.name}" sẽ bị xóa khỏi tất cả bộ sưu tập. Không thể hoàn tác.`}
confirmLabel="Xóa"
onConfirm={() =>
dispatch({ type: "DELETE_PLACE", placeId: m.placeId })
}
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
/>
)}
{m?.type === "confirmDeleteCollection" && collectionForDelete && (
<ConfirmDialog
title="Xóa bộ sưu tập?"
body={`"${collectionForDelete.name}" sẽ bị xóa. Các địa điểm bên trong vẫn còn ở "Địa điểm".`}
confirmLabel="Xóa"
onConfirm={() => {
dispatch({ type: "CLOSE_MODAL" });
dispatch({ type: "BACK" });
dispatch({ type: "TOAST", value: "Đã xóa" });
}}
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
/>
)}
{m?.type === "add" && (
<AddPlaceSheet
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
dispatch={dispatch}
/>
)}
{m?.type === "editPlace" && (() => {
const p = state.places.find((x) => x.id === m.placeId);
return p ? (
<EditPlaceSheet
place={p}
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
dispatch={dispatch}
/>
) : null;
})()}
{m?.type === "saveToCollection" && (
<SaveToCollectionSheet
placeId={m.placeId}
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
dispatch={dispatch}
/>
)}
{m?.type === "createCollection" && (
<CollectionFormSheet
mode="create"
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
dispatch={dispatch}
/>
)}
{m?.type === "editCollection" && (() => {
const c = data.collections.find((x) => x.id === m.collectionId);
return c ? (
<CollectionFormSheet
mode="edit"
collection={c}
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
dispatch={dispatch}
/>
) : null;
})()}
{m?.type === "invite" && (
<InviteDialog
collectionId={m.collectionId}
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
dispatch={dispatch}
/>
)}
{m?.type === "members" && (
<MembersSheet
collectionId={m.collectionId}
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
dispatch={dispatch}
/>
)}
{m?.type === "confirmDeletePlace" && placeForDelete && (
<ConfirmDialog
title="Xóa địa điểm?"
body={`"${placeForDelete.name}" sẽ bị xóa khỏi tất cả bộ sưu tập. Không thể hoàn tác.`}
confirmLabel="Xóa"
onConfirm={() => {
const id = m.placeId;
dispatch({ type: "DELETE_PLACE", placeId: id });
startTransition(() => {
deletePlace(id).catch(() =>
dispatch({ type: "TOAST", value: "Xóa thất bại" }),
);
});
}}
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
/>
)}
{m?.type === "confirmDeleteCollection" && collectionForDelete && (
<ConfirmDialog
title="Xóa bộ sưu tập?"
body={`"${collectionForDelete.name}" sẽ bị xóa. Các địa điểm bên trong vẫn còn ở "Địa điểm".`}
confirmLabel="Xóa"
onConfirm={() => {
const id = m.collectionId;
dispatch({ type: "CLOSE_MODAL" });
dispatch({ type: "BACK" });
startTransition(() => {
deleteCollection(id)
.then(() => dispatch({ type: "TOAST", value: "Đã xóa" }))
.catch(() => dispatch({ type: "TOAST", value: "Xóa thất bại" }));
});
}}
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
/>
)}
{state.toast && (
<div className="toast" key={state.toastKey}>
<Icons.Check size={14} stroke={2.5} />
{state.toast}
</div>
)}
</div>
{state.toast && (
<div className="toast" key={state.toastKey}>
<Icons.Check size={14} stroke={2.5} />
{state.toast}
</div>
)}
</div>
</AppDataProvider>
);
}

View File

@@ -1,6 +1,8 @@
"use client";
import type { CSSProperties } from "react";
import type { User } from "@/lib/types";
import { USERS } from "@/lib/mock-data";
import { useUsers } from "@/lib/app-context";
export function Avatar({
user,
@@ -35,17 +37,18 @@ export function AvatarStack({
size = 28,
extra = 0,
}: {
userIds: string[];
userIds: number[];
max?: number;
size?: number;
extra?: number;
}) {
const users = useUsers();
const list = userIds.slice(0, max);
const more = userIds.length + extra - list.length;
return (
<span className="avatar-stack">
{list.map((id) => (
<Avatar key={id} user={USERS[id]} size={size} />
<Avatar key={id} user={users[id]} size={size} />
))}
{more > 0 && (
<span

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, type CSSProperties } from "react";
import { CATEGORIES } from "@/lib/mock-data";
import { CATEGORIES } from "@/lib/ui-config";
import type { CategoryId } from "@/lib/types";
import { Icons } from "./icons";

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { CATEGORIES } from "@/lib/mock-data";
import { CATEGORIES } from "@/lib/ui-config";
import type { Place } from "@/lib/types";
import { Icons } from "./icons";
import { CoverImage } from "./cover-image";

44
src/lib/app-context.tsx Normal file
View File

@@ -0,0 +1,44 @@
"use client";
import { createContext, useContext, type ReactNode } from "react";
import type { Collection, User } from "./types";
export type AppData = {
me: User;
users: Record<number, User>;
collections: Collection[];
};
const AppDataContext = createContext<AppData | null>(null);
export function AppDataProvider({
value,
children,
}: {
value: AppData;
children: ReactNode;
}) {
return (
<AppDataContext.Provider value={value}>
{children}
</AppDataContext.Provider>
);
}
export function useAppData(): AppData {
const v = useContext(AppDataContext);
if (!v) throw new Error("useAppData must be used inside <AppDataProvider>");
return v;
}
export function useUsers(): Record<number, User> {
return useAppData().users;
}
export function useCollections(): Collection[] {
return useAppData().collections;
}
export function useMe(): User {
return useAppData().me;
}

View File

@@ -1,5 +1,4 @@
import type { Place } from "./types";
import { PLACES } from "./mock-data";
export type Screen = "places" | "collections" | "profile" | "place" | "collection";
@@ -7,16 +6,20 @@ export type Tab = "places" | "collections" | "profile";
export type StackFrame = {
screen: Screen;
placeId?: string;
collectionId?: string;
placeId?: number;
collectionId?: number;
};
export type Modal =
| { type: "add" }
| { type: "invite"; collectionId: string }
| { type: "members"; collectionId: string }
| { type: "confirmDeletePlace"; placeId: string }
| { type: "confirmDeleteCollection"; collectionId: string }
| { type: "editPlace"; placeId: number }
| { type: "saveToCollection"; placeId: number }
| { type: "createCollection" }
| { type: "editCollection"; collectionId: number }
| { type: "invite"; collectionId: number }
| { type: "members"; collectionId: number }
| { type: "confirmDeletePlace"; placeId: number }
| { type: "confirmDeleteCollection"; collectionId: number }
| null;
export type AppState = {
@@ -32,37 +35,43 @@ export type AppState = {
};
export type Action =
| { type: "NAV"; screen: Screen; placeId?: string; collectionId?: string }
| { type: "NAV"; screen: Screen; placeId?: number; collectionId?: number }
| { type: "BACK" }
| { type: "TAB"; tab: Tab }
| { type: "SET_FILTER"; value: string }
| { type: "SET_SEARCH"; value: string }
| { type: "TOGGLE_VISITED"; placeId: string }
| { type: "SET_RATING"; placeId: string; value: number }
| { type: "SET_NOTES"; placeId: string; value: string }
| { type: "TOGGLE_VISITED"; placeId: number }
| { type: "SET_RATING"; placeId: number; value: number }
| { type: "SET_NOTES"; placeId: number; value: string }
| { type: "ADD_PLACE"; place: Place }
| { type: "DELETE_PLACE"; placeId: string }
| { type: "DELETE_PLACE"; placeId: number }
| { type: "TOAST"; value: string }
| { type: "CLEAR_TOAST"; key: number }
| { type: "OPEN_ADD" }
| { type: "OPEN_INVITE"; collectionId: string }
| { type: "OPEN_MEMBERS"; collectionId: string }
| { type: "CONFIRM_DELETE_PLACE"; placeId: string }
| { type: "CONFIRM_DELETE_COLLECTION"; collectionId: string }
| { type: "OPEN_EDIT_PLACE"; placeId: number }
| { type: "OPEN_SAVE_TO_COLLECTION"; placeId: number }
| { type: "OPEN_CREATE_COLLECTION" }
| { type: "OPEN_EDIT_COLLECTION"; collectionId: number }
| { type: "OPEN_INVITE"; collectionId: number }
| { type: "OPEN_MEMBERS"; collectionId: number }
| { type: "CONFIRM_DELETE_PLACE"; placeId: number }
| { type: "CONFIRM_DELETE_COLLECTION"; collectionId: number }
| { type: "CLOSE_MODAL" }
| { type: "SET_OFFLINE"; value: boolean };
export const INITIAL_STATE: AppState = {
tab: "places",
stack: [{ screen: "places" }],
filter: "all",
search: "",
places: PLACES,
modal: null,
toast: null,
toastKey: 0,
offline: false,
};
export function makeInitialState(places: Place[]): AppState {
return {
tab: "places",
stack: [{ screen: "places" }],
filter: "all",
search: "",
places,
modal: null,
toast: null,
toastKey: 0,
offline: false,
};
}
export function reducer(state: AppState, action: Action): AppState {
switch (action.type) {
@@ -77,9 +86,8 @@ export function reducer(state: AppState, action: Action): AppState {
if (state.stack.length <= 1) return state;
return { ...state, stack: state.stack.slice(0, -1) };
}
case "TAB": {
case "TAB":
return { ...state, tab: action.tab, stack: [{ screen: action.tab }] };
}
case "SET_FILTER":
return { ...state, filter: action.value };
case "SET_SEARCH":
@@ -112,23 +120,29 @@ export function reducer(state: AppState, action: Action): AppState {
);
return { ...state, places };
}
case "ADD_PLACE": {
case "ADD_PLACE":
return { ...state, places: [action.place, ...state.places] };
}
case "DELETE_PLACE": {
case "DELETE_PLACE":
return {
...state,
places: state.places.filter((p) => p.id !== action.placeId),
stack: state.stack.length > 1 ? state.stack.slice(0, -1) : state.stack,
modal: null,
};
}
case "TOAST":
return { ...state, toast: action.value, toastKey: state.toastKey + 1 };
case "CLEAR_TOAST":
return state.toastKey === action.key ? { ...state, toast: null } : state;
case "OPEN_ADD":
return { ...state, modal: { type: "add" } };
case "OPEN_EDIT_PLACE":
return { ...state, modal: { type: "editPlace", placeId: action.placeId } };
case "OPEN_SAVE_TO_COLLECTION":
return { ...state, modal: { type: "saveToCollection", placeId: action.placeId } };
case "OPEN_CREATE_COLLECTION":
return { ...state, modal: { type: "createCollection" } };
case "OPEN_EDIT_COLLECTION":
return { ...state, modal: { type: "editCollection", collectionId: action.collectionId } };
case "OPEN_INVITE":
return { ...state, modal: { type: "invite", collectionId: action.collectionId } };
case "OPEN_MEMBERS":

25
src/lib/clipboard.ts Normal file
View File

@@ -0,0 +1,25 @@
// Copy a string to the system clipboard. Best-effort fallback when the async
// API isn't available (older Safari on http://, etc.).
export async function copyToClipboard(text: string): Promise<boolean> {
try {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// fall through to fallback
}
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand("copy");
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}

532
src/lib/db/actions.ts Normal file
View File

@@ -0,0 +1,532 @@
"use server";
import { randomBytes } from "node:crypto";
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 {
collectionMembers,
collectionPlaces,
collections,
invitations,
places,
userPlaceData,
users,
} from "./schema";
import type { CategoryId, CollectionType, Place, Role } from "@/lib/types";
import { buildInviteEmail, sendEmail } from "@/lib/email";
function makeInviteToken(): string {
return randomBytes(16).toString("base64url");
}
// 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) {
const [row] = await db.execute<{ ok: boolean }>(sql`
SELECT EXISTS(
SELECT 1 FROM ${places} p
WHERE p.id = ${placeId}
AND (
p.created_by = ${userId}
OR p.id IN (
SELECT cp.place_id FROM ${collectionPlaces} cp
JOIN ${collectionMembers} cm
ON cm.collection_id = cp.collection_id AND cm.user_id = ${userId}
)
)
) AS ok
`).then((r) => r.rows);
if (!row?.ok) throw new Error("forbidden: cannot access place");
}
export async function toggleVisited(
placeId: number,
): Promise<{ visited: boolean; visited_at: string | null }> {
const uid = await requireUserId();
await assertCanAccessPlace(placeId, uid);
const [current] = await db
.select({ visited: userPlaceData.visited })
.from(userPlaceData)
.where(
and(eq(userPlaceData.userId, uid), eq(userPlaceData.placeId, placeId)),
)
.limit(1);
const next = !current?.visited;
const visitedAt = next ? new Date() : null;
await db
.insert(userPlaceData)
.values({ userId: uid, placeId, visited: next, visitedAt })
.onConflictDoUpdate({
target: [userPlaceData.userId, userPlaceData.placeId],
set: { visited: next, visitedAt },
});
revalidatePath("/");
return {
visited: next,
visited_at: visitedAt ? visitedAt.toISOString().slice(0, 10) : null,
};
}
export async function setRating(placeId: number, rating: number): Promise<void> {
if (rating < 0 || rating > 5) throw new Error("rating out of range");
const uid = await requireUserId();
await assertCanAccessPlace(placeId, uid);
if (rating === 0) {
await db
.update(userPlaceData)
.set({ rating: null })
.where(
and(eq(userPlaceData.userId, uid), eq(userPlaceData.placeId, placeId)),
);
} else {
await db
.insert(userPlaceData)
.values({ userId: uid, placeId, rating })
.onConflictDoUpdate({
target: [userPlaceData.userId, userPlaceData.placeId],
set: { rating },
});
}
revalidatePath("/");
}
export async function setNotes(placeId: number, notes: string): Promise<void> {
const uid = await requireUserId();
await assertCanAccessPlace(placeId, uid);
const trimmed = notes.trim();
if (!trimmed) {
await db
.update(userPlaceData)
.set({ notes: null })
.where(
and(eq(userPlaceData.userId, uid), eq(userPlaceData.placeId, placeId)),
);
} else {
await db
.insert(userPlaceData)
.values({ userId: uid, placeId, notes: trimmed })
.onConflictDoUpdate({
target: [userPlaceData.userId, userPlaceData.placeId],
set: { notes: trimmed },
});
}
revalidatePath("/");
}
export type NewPlaceInput = {
name: string;
address: string;
short_address: string;
city: string;
category: CategoryId;
tags: string[];
cover_url: string | null;
rating?: number;
notes?: string;
};
export async function addPlace(input: NewPlaceInput): Promise<Place> {
const uid = await requireUserId();
const [row] = await db
.insert(places)
.values({
createdBy: uid,
name: input.name,
address: input.address,
shortAddress: input.short_address,
city: input.city,
category: input.category,
tags: input.tags,
coverUrl: input.cover_url,
})
.returning();
if (input.rating || input.notes) {
await db.insert(userPlaceData).values({
userId: uid,
placeId: row.id,
rating: input.rating ?? null,
notes: input.notes ?? null,
});
}
revalidatePath("/");
return {
id: row.id,
name: row.name,
address: row.address,
short_address: row.shortAddress,
city: row.city,
category: row.category,
tags: row.tags,
cover_url: row.coverUrl,
created_by: uid,
created_at: row.createdAt.toISOString().slice(0, 10),
my_rating: input.rating,
my_notes: input.notes,
visited: false,
};
}
export async function deletePlace(placeId: number): Promise<void> {
const uid = await requireUserId();
const [row] = await db
.select({ createdBy: places.createdBy })
.from(places)
.where(eq(places.id, placeId))
.limit(1);
if (!row) return;
if (row.createdBy !== uid) throw new Error("not authorized");
await db.delete(places).where(eq(places.id, placeId));
revalidatePath("/");
}
export type EditPlaceInput = {
name: string;
address: string;
short_address: string;
city: string;
category: CategoryId;
tags: string[];
cover_url: string | null;
};
export async function editPlace(
placeId: number,
input: EditPlaceInput,
): Promise<void> {
const uid = await requireUserId();
const [row] = await db
.select({ createdBy: places.createdBy })
.from(places)
.where(eq(places.id, placeId))
.limit(1);
if (!row) throw new Error("not found");
if (row.createdBy !== uid) throw new Error("not authorized");
await db
.update(places)
.set({
name: input.name,
address: input.address,
shortAddress: input.short_address,
city: input.city,
category: input.category,
tags: input.tags,
coverUrl: input.cover_url,
})
.where(eq(places.id, placeId));
revalidatePath("/");
}
export type CollectionInput = {
name: string;
type: CollectionType;
trip_start?: string;
trip_end?: string;
};
export async function createCollection(
input: CollectionInput,
): Promise<{ id: number }> {
const uid = await requireUserId();
const name = input.name.trim();
if (!name) throw new Error("name required");
const [row] = await db
.insert(collections)
.values({
ownerId: uid,
name,
type: input.type,
tripStart: input.type === "trip" ? input.trip_start ?? null : null,
tripEnd: input.type === "trip" ? input.trip_end ?? null : null,
})
.returning({ id: collections.id });
await db
.insert(collectionMembers)
.values({ collectionId: row.id, userId: uid, role: "owner" });
revalidatePath("/");
return { id: row.id };
}
export async function editCollection(
collectionId: number,
input: CollectionInput,
): Promise<void> {
const uid = await requireUserId();
const [row] = await db
.select({ ownerId: collections.ownerId })
.from(collections)
.where(eq(collections.id, collectionId))
.limit(1);
if (!row) throw new Error("not found");
if (row.ownerId !== uid) throw new Error("not authorized");
await db
.update(collections)
.set({
name: input.name.trim(),
type: input.type,
tripStart: input.type === "trip" ? input.trip_start ?? null : null,
tripEnd: input.type === "trip" ? input.trip_end ?? null : null,
})
.where(eq(collections.id, collectionId));
revalidatePath("/");
}
export async function deleteCollection(collectionId: number): Promise<void> {
const uid = await requireUserId();
const [row] = await db
.select({ ownerId: collections.ownerId })
.from(collections)
.where(eq(collections.id, collectionId))
.limit(1);
if (!row) return;
if (row.ownerId !== uid) throw new Error("not authorized");
await db.delete(collections).where(eq(collections.id, collectionId));
revalidatePath("/");
}
async function assertCanWriteCollection(
collectionId: number,
userId: number,
) {
const [row] = await db
.select({ role: collectionMembers.role })
.from(collectionMembers)
.where(
and(
eq(collectionMembers.collectionId, collectionId),
eq(collectionMembers.userId, userId),
),
)
.limit(1);
if (!row) throw new Error("forbidden: not a member");
if (row.role === "viewer") throw new Error("forbidden: viewer cannot write");
}
export async function addPlaceToCollection(
collectionId: number,
placeId: number,
): Promise<void> {
const uid = await requireUserId();
await assertCanWriteCollection(collectionId, uid);
await assertCanAccessPlace(placeId, uid);
const [next] = await db
.select({ max: sql<number | null>`max(${collectionPlaces.sortOrder})` })
.from(collectionPlaces)
.where(eq(collectionPlaces.collectionId, collectionId));
await db
.insert(collectionPlaces)
.values({
collectionId,
placeId,
addedBy: uid,
sortOrder: (next?.max ?? -1) + 1,
})
.onConflictDoNothing({
target: [collectionPlaces.collectionId, collectionPlaces.placeId],
});
revalidatePath("/");
}
export async function removePlaceFromCollection(
collectionId: number,
placeId: number,
): Promise<void> {
const uid = await requireUserId();
await assertCanWriteCollection(collectionId, uid);
await db
.delete(collectionPlaces)
.where(
and(
eq(collectionPlaces.collectionId, collectionId),
eq(collectionPlaces.placeId, placeId),
),
);
revalidatePath("/");
}
export async function createInviteLink(
collectionId: number,
): Promise<{ token: string; expires_at: string }> {
const uid = await requireUserId();
const [row] = await db
.select({ ownerId: collections.ownerId })
.from(collections)
.where(eq(collections.id, collectionId))
.limit(1);
if (!row) throw new Error("not found");
if (row.ownerId !== uid) throw new Error("not authorized");
const token = makeInviteToken();
const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await db
.update(collections)
.set({ inviteToken: token, tokenExpiresAt: expires })
.where(eq(collections.id, collectionId));
revalidatePath("/");
return { token, expires_at: expires.toISOString() };
}
export async function revokeInviteLink(collectionId: number): Promise<void> {
const uid = await requireUserId();
const [row] = await db
.select({ ownerId: collections.ownerId })
.from(collections)
.where(eq(collections.id, collectionId))
.limit(1);
if (!row) throw new Error("not found");
if (row.ownerId !== uid) throw new Error("not authorized");
await db
.update(collections)
.set({ inviteToken: null, tokenExpiresAt: null })
.where(eq(collections.id, collectionId));
revalidatePath("/");
}
export async function acceptInvite(
token: string,
): Promise<{ collectionId: number }> {
const uid = await requireUserId();
const { acceptInviteCore } = await import("./invites");
const result = await acceptInviteCore(token, uid);
revalidatePath("/");
return result;
}
// ─── Email invitations ──────────────────────────────────
async function resolveOrigin(): Promise<string> {
if (process.env.APP_URL) return process.env.APP_URL;
const h = await headers();
const host = h.get("host") ?? "localhost:3000";
const proto =
h.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
return `${proto}://${host}`;
}
export type PendingInvitation = {
id: number;
email: string;
role: Role;
createdAt: string;
expiresAt: string;
};
export async function fetchPendingInvitations(
collectionId: number,
): Promise<PendingInvitation[]> {
const uid = await requireUserId();
const [col] = await db
.select({ ownerId: collections.ownerId })
.from(collections)
.where(eq(collections.id, collectionId))
.limit(1);
if (!col) throw new Error("not found");
if (col.ownerId !== uid) throw new Error("not authorized");
const { listPendingInvitations } = await import("./invites");
const rows = await listPendingInvitations(collectionId);
return rows.map((r) => ({
id: r.id,
email: r.email,
role: r.role,
createdAt: r.createdAt.toISOString(),
expiresAt: r.expiresAt.toISOString(),
}));
}
export async function sendEmailInvite(
collectionId: number,
emailRaw: string,
role: Role,
): Promise<PendingInvitation> {
const uid = await requireUserId();
const email = emailRaw.trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw new Error("Email không hợp lệ");
}
if (role !== "editor" && role !== "viewer") {
throw new Error("Vai trò không hợp lệ");
}
const [col] = await db
.select({ id: collections.id, name: collections.name, ownerId: collections.ownerId })
.from(collections)
.where(eq(collections.id, collectionId))
.limit(1);
if (!col) throw new Error("Bộ sưu tập không tồn tại");
if (col.ownerId !== uid) throw new Error("Chỉ chủ sở hữu mới được mời");
const [inviter] = await db
.select({ name: users.name })
.from(users)
.where(eq(users.id, uid))
.limit(1);
const token = randomBytes(16).toString("base64url");
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
// Upsert by (collection_id, email) — re-sending replaces the old token.
const [row] = await db
.insert(invitations)
.values({
collectionId,
email,
role,
token,
expiresAt,
invitedBy: uid,
})
.onConflictDoUpdate({
target: [invitations.collectionId, invitations.email],
set: { token, expiresAt, role, invitedBy: uid, acceptedAt: null },
})
.returning();
const origin = await resolveOrigin();
const inviteUrl = `${origin}/invite/${token}`;
const msg = buildInviteEmail({
collectionName: col.name,
inviterName: inviter?.name ?? "Thành viên Places",
inviteUrl,
role,
});
// Don't fail the action if email delivery fails — the row is persisted and
// the owner can copy the URL from the pending list if needed.
try {
await sendEmail({ ...msg, to: email });
} catch (e) {
console.error("[email] send failed:", (e as Error).message);
}
revalidatePath("/");
return {
id: row.id,
email: row.email,
role: row.role,
createdAt: row.createdAt.toISOString(),
expiresAt: row.expiresAt.toISOString(),
};
}
export async function revokeInvitation(invitationId: number): Promise<void> {
const uid = await requireUserId();
const [row] = await db
.select({
id: invitations.id,
collectionId: invitations.collectionId,
ownerId: collections.ownerId,
})
.from(invitations)
.innerJoin(collections, eq(collections.id, invitations.collectionId))
.where(eq(invitations.id, invitationId))
.limit(1);
if (!row) return;
if (row.ownerId !== uid) throw new Error("not authorized");
await db.delete(invitations).where(eq(invitations.id, invitationId));
revalidatePath("/");
}

164
src/lib/db/auth.ts Normal file
View File

@@ -0,0 +1,164 @@
import "server-only";
import { randomBytes } from "node:crypto";
import bcrypt from "bcryptjs";
import { cookies } from "next/headers";
import { and, eq, gt } from "drizzle-orm";
import { db } from "./client";
import { sessions, users } from "./schema";
import type { User } from "@/lib/types";
export const SESSION_COOKIE = "places_session";
const SESSION_DAYS = 30;
function newToken(): string {
return randomBytes(32).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 AuthResult =
| { ok: true; user: User }
| { ok: false; error: string };
export async function registerUser(
email: string,
password: string,
name: string,
): Promise<AuthResult> {
const cleanEmail = email.trim().toLowerCase();
const cleanName = name.trim();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(cleanEmail)) {
return { ok: false, error: "Email không hợp lệ" };
}
if (password.length < 8) {
return { ok: false, error: "Mật khẩu cần ít nhất 8 ký tự" };
}
if (cleanName.length < 1) {
return { ok: false, error: "Vui lòng nhập tên" };
}
const existing = await db
.select({ id: users.id })
.from(users)
.where(eq(users.email, cleanEmail))
.limit(1);
if (existing.length) {
return { ok: false, error: "Email đã được đăng ký" };
}
const hash = await bcrypt.hash(password, 10);
const initials = deriveInitials(cleanName);
const [inserted] = await db
.insert(users)
.values({
email: cleanEmail,
passwordHash: hash,
name: cleanName,
initials,
})
.returning({
id: users.id,
email: users.email,
name: users.name,
initials: users.initials,
avatarUrl: users.avatarUrl,
color: users.color,
});
await createSessionCookie(inserted.id);
return {
ok: true,
user: {
id: inserted.id,
email: inserted.email,
name: inserted.name,
initials: inserted.initials,
avatar_url: inserted.avatarUrl,
color: inserted.color ?? undefined,
},
};
}
export async function loginUser(
email: string,
password: string,
): Promise<AuthResult> {
const cleanEmail = email.trim().toLowerCase();
const [row] = await db
.select()
.from(users)
.where(eq(users.email, cleanEmail))
.limit(1);
if (!row) return { ok: false, error: "Email hoặc mật khẩu không đúng" };
const ok = await bcrypt.compare(password, row.passwordHash);
if (!ok) return { ok: false, error: "Email hoặc mật khẩu không đúng" };
await createSessionCookie(row.id);
return {
ok: true,
user: {
id: row.id,
email: row.email,
name: row.name,
initials: row.initials,
avatar_url: row.avatarUrl,
color: row.color ?? undefined,
},
};
}
async function createSessionCookie(userId: number): Promise<void> {
const token = newToken();
const expiresAt = new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
await db.insert(sessions).values({ id: token, userId, expiresAt });
const c = await cookies();
c.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
expires: expiresAt,
});
}
export async function getCurrentUserId(): Promise<number | null> {
const c = await cookies();
const token = c.get(SESSION_COOKIE)?.value;
if (!token) return null;
const [row] = await db
.select({ userId: sessions.userId, expiresAt: sessions.expiresAt })
.from(sessions)
.where(and(eq(sessions.id, token), gt(sessions.expiresAt, new Date())))
.limit(1);
if (!row) {
// Clean up an expired/invalid token if present.
await db.delete(sessions).where(eq(sessions.id, token));
return null;
}
return row.userId;
}
export async function logout(): Promise<void> {
const c = await cookies();
const token = c.get(SESSION_COOKIE)?.value;
if (token) {
await db.delete(sessions).where(eq(sessions.id, token));
}
c.delete(SESSION_COOKIE);
}
export async function requireUserId(): Promise<number> {
const id = await getCurrentUserId();
if (!id) throw new Error("not authenticated");
return id;
}

34
src/lib/db/client.ts Normal file
View File

@@ -0,0 +1,34 @@
import "server-only";
import { Pool } from "pg";
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import * as schema from "./schema";
declare global {
// eslint-disable-next-line no-var
var __pg_pool: Pool | undefined;
// eslint-disable-next-line no-var
var __drizzle: NodePgDatabase<typeof schema> | undefined;
}
function makePool() {
if (process.env.DATABASE_URL) {
return new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });
}
return new Pool({
host: process.env.PGHOST,
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
database: process.env.PGDATABASE,
port: process.env.PGPORT ? Number(process.env.PGPORT) : 5432,
max: 5,
});
}
const pool = globalThis.__pg_pool ?? makePool();
export const db: NodePgDatabase<typeof schema> =
globalThis.__drizzle ?? drizzle(pool, { schema });
if (process.env.NODE_ENV !== "production") {
globalThis.__pg_pool = pool;
globalThis.__drizzle = db;
}

86
src/lib/db/invites.ts Normal file
View File

@@ -0,0 +1,86 @@
import "server-only";
import { and, eq, isNull } from "drizzle-orm";
import { db } from "./client";
import { collectionMembers, collections, invitations } from "./schema";
// Pure DB op — no revalidatePath. Callable from Server Components
// during render. Caller is responsible for cache invalidation.
//
// Two token shapes supported:
// 1) `collections.invite_token` — anyone-with-link, joins as 'editor'.
// 2) `invitations.token` — email-targeted, joins at the role
// the inviter specified.
export async function acceptInviteCore(
token: string,
userId: number,
): Promise<{ collectionId: number }> {
// Try a collection-level link first.
const [linkRow] = await db
.select({ id: collections.id, expiresAt: collections.tokenExpiresAt })
.from(collections)
.where(eq(collections.inviteToken, token))
.limit(1);
if (linkRow) {
if (linkRow.expiresAt && linkRow.expiresAt.getTime() < Date.now()) {
throw new Error("Liên kết đã hết hạn");
}
await db
.insert(collectionMembers)
.values({ collectionId: linkRow.id, userId, role: "editor" })
.onConflictDoNothing({
target: [collectionMembers.collectionId, collectionMembers.userId],
});
return { collectionId: linkRow.id };
}
// Fall back to per-email invitation token.
const [inv] = await db
.select({
id: invitations.id,
collectionId: invitations.collectionId,
role: invitations.role,
expiresAt: invitations.expiresAt,
acceptedAt: invitations.acceptedAt,
})
.from(invitations)
.where(eq(invitations.token, token))
.limit(1);
if (!inv) throw new Error("Liên kết không hợp lệ");
if (inv.acceptedAt) throw new Error("Lời mời đã được dùng");
if (inv.expiresAt.getTime() < Date.now()) {
throw new Error("Liên kết đã hết hạn");
}
await db.transaction(async (tx) => {
await tx
.insert(collectionMembers)
.values({ collectionId: inv.collectionId, userId, role: inv.role })
.onConflictDoNothing({
target: [collectionMembers.collectionId, collectionMembers.userId],
});
await tx
.update(invitations)
.set({ acceptedAt: new Date() })
.where(eq(invitations.id, inv.id));
});
return { collectionId: inv.collectionId };
}
// Pending email invitations for a collection (not yet redeemed).
// Used to render the list in the invite dialog.
export async function listPendingInvitations(collectionId: number) {
return db
.select({
id: invitations.id,
email: invitations.email,
role: invitations.role,
createdAt: invitations.createdAt,
expiresAt: invitations.expiresAt,
})
.from(invitations)
.where(
and(
eq(invitations.collectionId, collectionId),
isNull(invitations.acceptedAt),
),
);
}

270
src/lib/db/queries.ts Normal file
View File

@@ -0,0 +1,270 @@
import "server-only";
import { and, desc, eq, inArray, or, sql } from "drizzle-orm";
import { db } from "./client";
import {
collectionMembers,
collectionPlaces,
collections,
places,
userPlaceData,
users,
} from "./schema";
import { getCurrentUserId, requireUserId } from "./auth";
import type { Collection, Place, User } from "@/lib/types";
function dateToStr(d: Date | string | null | undefined): string | undefined {
if (!d) return undefined;
return (typeof d === "string" ? new Date(d) : d).toISOString().slice(0, 10);
}
function toPlace(r: {
id: number;
name: string;
address: string;
shortAddress: string;
category: Place["category"];
tags: string[];
coverUrl: string | null;
createdBy: number;
createdAt: Date | string;
avgRating: string | null;
city: string;
myRating: number | null;
myNotes: string | null;
visited: boolean | null;
visitedAt: Date | string | null;
}): Place {
return {
id: r.id,
name: r.name,
address: r.address,
short_address: r.shortAddress,
category: r.category,
tags: r.tags ?? [],
cover_url: r.coverUrl,
created_by: r.createdBy,
created_at: dateToStr(r.createdAt) ?? "",
avg_rating: r.avgRating != null ? Number(r.avgRating) : undefined,
city: r.city,
my_rating: r.myRating ?? undefined,
my_notes: r.myNotes ?? undefined,
visited: !!r.visited,
visited_at: dateToStr(r.visitedAt),
};
}
// Privacy filter: user sees place if (a) they created it, or
// (b) it lives in a collection where they're a member. CLAUDE.md spec.
function placeVisibilityFilter(userId: number) {
return or(
eq(places.createdBy, userId),
inArray(
places.id,
db
.select({ id: collectionPlaces.placeId })
.from(collectionPlaces)
.innerJoin(
collectionMembers,
and(
eq(collectionMembers.collectionId, collectionPlaces.collectionId),
eq(collectionMembers.userId, userId),
),
),
),
);
}
export async function getPlacesForUser(userId?: number): Promise<Place[]> {
const uid = userId ?? (await requireUserId());
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: places.avgRating,
city: places.city,
myRating: userPlaceData.rating,
myNotes: userPlaceData.notes,
visited: userPlaceData.visited,
visitedAt: userPlaceData.visitedAt,
})
.from(places)
.leftJoin(
userPlaceData,
and(
eq(userPlaceData.placeId, places.id),
eq(userPlaceData.userId, uid),
),
)
.where(placeVisibilityFilter(uid))
.orderBy(desc(places.createdAt));
return rows.map(toPlace);
}
export async function getPlaceById(
placeId: number,
userId?: number,
): Promise<Place | null> {
const uid = userId ?? (await requireUserId());
const [row] = 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: places.avgRating,
city: places.city,
myRating: userPlaceData.rating,
myNotes: userPlaceData.notes,
visited: userPlaceData.visited,
visitedAt: userPlaceData.visitedAt,
})
.from(places)
.leftJoin(
userPlaceData,
and(
eq(userPlaceData.placeId, places.id),
eq(userPlaceData.userId, uid),
),
)
.where(and(eq(places.id, placeId), placeVisibilityFilter(uid)))
.limit(1);
return row ? toPlace(row) : null;
}
type CollectionRow = {
id: number;
owner_id: number;
name: string;
type: Collection["type"];
trip_start: Date | string | null;
trip_end: Date | string | null;
member_count: number;
place_count: number;
my_role: Collection["my_role"] | null;
cover_place_ids: number[];
place_ids: number[];
members: number[];
};
export async function getCollectionsForUser(
userId?: number,
): Promise<Collection[]> {
const uid = userId ?? (await requireUserId());
// Aggregates (member_count, place_ids, members…) are cheaper as correlated
// subqueries than as N+1 round-trips. Drizzle's sql template keeps it readable.
const result = await db.execute<CollectionRow>(sql`
SELECT
c.id,
c.owner_id,
c.name,
c.type,
c.trip_start,
c.trip_end,
(SELECT count(*)::int FROM ${collectionMembers} WHERE collection_id = c.id) AS member_count,
(SELECT count(*)::int FROM ${collectionPlaces} WHERE collection_id = c.id) AS place_count,
(SELECT role FROM ${collectionMembers} WHERE collection_id = c.id AND user_id = ${uid}) AS my_role,
COALESCE((
SELECT array_agg(place_id ORDER BY sort_order)
FROM (
SELECT place_id, sort_order
FROM ${collectionPlaces}
WHERE collection_id = c.id
ORDER BY sort_order
LIMIT 3
) cover
), ARRAY[]::int[]) AS cover_place_ids,
COALESCE((
SELECT array_agg(place_id ORDER BY sort_order)
FROM ${collectionPlaces}
WHERE collection_id = c.id
), ARRAY[]::int[]) AS place_ids,
COALESCE((
SELECT array_agg(user_id ORDER BY (role = 'owner') DESC, joined_at)
FROM ${collectionMembers}
WHERE collection_id = c.id
), ARRAY[]::int[]) AS members
FROM ${collections} c
WHERE c.id IN (
SELECT collection_id FROM ${collectionMembers} WHERE user_id = ${uid}
) OR c.owner_id = ${uid}
ORDER BY c.created_at DESC
`);
return result.rows.map((r) => ({
id: r.id,
name: r.name,
type: r.type,
owner_id: r.owner_id,
trip_start: dateToStr(r.trip_start),
trip_end: dateToStr(r.trip_end),
member_count: Number(r.member_count),
place_count: Number(r.place_count),
my_role: r.my_role ?? "viewer",
cover_place_ids: r.cover_place_ids ?? [],
place_ids: r.place_ids ?? [],
members: r.members ?? [],
}));
}
export async function getAllUsers(): Promise<Record<number, User>> {
const rows = await db
.select({
id: users.id,
name: users.name,
email: users.email,
initials: users.initials,
avatarUrl: users.avatarUrl,
color: users.color,
})
.from(users);
return Object.fromEntries(
rows.map((u) => [
u.id,
{
id: u.id,
name: u.name,
email: u.email,
initials: u.initials,
avatar_url: u.avatarUrl,
color: u.color ?? undefined,
} as User,
]),
);
}
export async function getCurrentUser(): Promise<User | null> {
const uid = await getCurrentUserId();
if (!uid) return null;
const [u] = await db
.select({
id: users.id,
name: users.name,
email: users.email,
initials: users.initials,
avatarUrl: users.avatarUrl,
color: users.color,
})
.from(users)
.where(eq(users.id, uid))
.limit(1);
if (!u) return null;
return {
id: u.id,
name: u.name,
email: u.email,
initials: u.initials,
avatar_url: u.avatarUrl,
color: u.color ?? undefined,
};
}

202
src/lib/db/schema.ts Normal file
View File

@@ -0,0 +1,202 @@
import {
boolean,
date,
doublePrecision,
index,
integer,
numeric,
pgEnum,
pgTable,
primaryKey,
serial,
text,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core";
export const categoryEnum = pgEnum("category", [
"food",
"cafe",
"shopping",
"entertainment",
"other",
]);
export const collectionTypeEnum = pgEnum("collection_type", ["folder", "trip"]);
export const memberRoleEnum = pgEnum("member_role", ["owner", "editor", "viewer"]);
export const priceRangeEnum = pgEnum("price_range", ["$", "$$", "$$$"]);
export const users = pgTable("users", {
id: serial("id").primaryKey(),
email: text("email").notNull().unique(),
passwordHash: text("password_hash").notNull(),
name: text("name").notNull(),
initials: text("initials").notNull(),
avatarUrl: text("avatar_url"),
color: text("color"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
// Session token is a cryptographic secret — must stay random text.
// Sequential ints would be guessable and enable account takeover.
export const sessions = pgTable(
"sessions",
{
id: text("id").primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
userAgent: text("user_agent"),
},
(t) => [
index("sessions_user_id_idx").on(t.userId),
index("sessions_expires_at_idx").on(t.expiresAt),
],
);
export const places = pgTable(
"places",
{
id: serial("id").primaryKey(),
createdBy: integer("created_by")
.notNull()
.references(() => users.id),
name: text("name").notNull(),
address: text("address").notNull(),
shortAddress: text("short_address").notNull(),
city: text("city").notNull(),
lat: doublePrecision("lat"),
lng: doublePrecision("lng"),
category: categoryEnum("category").notNull(),
tags: text("tags").array().notNull().default([]),
coverUrl: text("cover_url"),
phone: text("phone"),
website: text("website"),
priceRange: priceRangeEnum("price_range"),
openingHours: text("opening_hours"),
permanentlyClosed: boolean("permanently_closed").notNull().default(false),
avgRating: numeric("avg_rating", { precision: 3, scale: 2 }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index("places_created_by_idx").on(t.createdBy),
index("places_category_idx").on(t.category),
],
);
export const userPlaceData = pgTable(
"user_place_data",
{
userId: integer("user_id")
.notNull()
.references(() => users.id),
placeId: integer("place_id")
.notNull()
.references(() => places.id, { onDelete: "cascade" }),
notes: text("notes"),
rating: integer("rating"),
visited: boolean("visited").notNull().default(false),
visitedAt: timestamp("visited_at", { withTimezone: true }),
},
(t) => [primaryKey({ columns: [t.userId, t.placeId] })],
);
export const collections = pgTable("collections", {
id: serial("id").primaryKey(),
ownerId: integer("owner_id")
.notNull()
.references(() => users.id),
name: text("name").notNull(),
type: collectionTypeEnum("type").notNull(),
tripStart: date("trip_start"),
tripEnd: date("trip_end"),
inviteToken: text("invite_token").unique(),
tokenExpiresAt: timestamp("token_expires_at", { withTimezone: true }),
publicToken: text("public_token").unique(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const collectionMembers = pgTable(
"collection_members",
{
collectionId: integer("collection_id")
.notNull()
.references(() => collections.id, { onDelete: "cascade" }),
userId: integer("user_id")
.notNull()
.references(() => users.id),
role: memberRoleEnum("role").notNull(),
joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [primaryKey({ columns: [t.collectionId, t.userId] })],
);
export const collectionPlaces = pgTable(
"collection_places",
{
collectionId: integer("collection_id")
.notNull()
.references(() => collections.id, { onDelete: "cascade" }),
placeId: integer("place_id")
.notNull()
.references(() => places.id, { onDelete: "cascade" }),
addedBy: integer("added_by")
.notNull()
.references(() => users.id),
sortOrder: integer("sort_order").notNull().default(0),
addedAt: timestamp("added_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
primaryKey({ columns: [t.collectionId, t.placeId] }),
index("collection_places_place_idx").on(t.placeId),
],
);
// Email-targeted invitation — one per (collection, email). Token is the
// shareable secret; once accepted, the user joins the collection at `role`.
export const invitations = pgTable(
"invitations",
{
id: serial("id").primaryKey(),
collectionId: integer("collection_id")
.notNull()
.references(() => collections.id, { onDelete: "cascade" }),
email: text("email").notNull(),
role: memberRoleEnum("role").notNull(),
token: text("token").notNull().unique(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
invitedBy: integer("invited_by")
.notNull()
.references(() => users.id),
acceptedAt: timestamp("accepted_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex("invitations_collection_email_idx").on(t.collectionId, t.email),
index("invitations_token_idx").on(t.token),
],
);
export const placeReviews = pgTable(
"place_reviews",
{
id: serial("id").primaryKey(),
placeId: integer("place_id")
.notNull()
.references(() => places.id, { onDelete: "cascade" }),
collectionId: integer("collection_id")
.notNull()
.references(() => collections.id, { onDelete: "cascade" }),
userId: integer("user_id")
.notNull()
.references(() => users.id),
body: text("body").notNull(),
rating: integer("rating"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex("place_reviews_unique").on(t.placeId, t.collectionId, t.userId),
],
);

92
src/lib/email.ts Normal file
View File

@@ -0,0 +1,92 @@
import "server-only";
// Send email via Resend REST API. No npm package required — Resend has a
// stable REST surface. If RESEND_API_KEY is missing, we log to console
// (so dev environments still see the link the user would have received).
//
// Env:
// RESEND_API_KEY — required to actually send
// INVITE_FROM_EMAIL — required to actually send (e.g. "no-reply@places.app")
export type EmailMessage = {
to: string;
subject: string;
html: string;
text: string;
};
export async function sendEmail(msg: EmailMessage): Promise<void> {
const key = process.env.RESEND_API_KEY;
const from = process.env.INVITE_FROM_EMAIL;
if (!key || !from) {
// Dev fallback: print the would-be email so the link is still copyable.
console.log(
`\n[email:dry-run] (set RESEND_API_KEY + INVITE_FROM_EMAIL to send)\n` +
` to: ${msg.to}\n` +
` subject: ${msg.subject}\n` +
` text: ${msg.text}\n`,
);
return;
}
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from,
to: msg.to,
subject: msg.subject,
html: msg.html,
text: msg.text,
}),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`resend send failed: ${res.status} ${body}`);
}
}
export function buildInviteEmail(args: {
collectionName: string;
inviterName: string;
inviteUrl: string;
role: "editor" | "viewer";
}): EmailMessage {
const roleLabel = args.role === "editor" ? "Sửa được" : "Chỉ xem";
const subject = `${args.inviterName} mời bạn vào "${args.collectionName}" trên Places`;
const text =
`${args.inviterName} mời bạn tham gia bộ sưu tập "${args.collectionName}" với quyền ${roleLabel}.\n\n` +
`Mở liên kết để chấp nhận: ${args.inviteUrl}\n\n` +
`Liên kết hết hạn sau 7 ngày.`;
const html = `
<!doctype html><html><body style="font-family: -apple-system, system-ui, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px; color: #1f1a14;">
<h1 style="font-size: 22px; letter-spacing: -0.02em; margin: 0 0 8px;">Bạn vừa được mời</h1>
<p style="font-size: 15px; line-height: 1.5; color: #5c4d3d; margin: 0 0 20px;">
<b>${escapeHtml(args.inviterName)}</b> mời bạn tham gia bộ sưu tập
<b>"${escapeHtml(args.collectionName)}"</b> trên Places với quyền <b>${roleLabel}</b>.
</p>
<a href="${args.inviteUrl}" style="display: inline-block; padding: 12px 20px; background: #b85a3a; color: #fff; text-decoration: none; border-radius: 10px; font-weight: 600;">
Chấp nhận lời mời
</a>
<p style="font-size: 12px; color: #998472; margin-top: 24px;">
Liên kết sẽ hết hạn sau 7 ngày. Nếu bạn không mong đợi lời mời này, bỏ qua email này.
</p>
</body></html>`.trim();
return { to: "", subject, html, text };
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) =>
c === "&"
? "&amp;"
: c === "<"
? "&lt;"
: c === ">"
? "&gt;"
: c === '"'
? "&quot;"
: "&#39;",
);
}

21
src/lib/maps.ts Normal file
View File

@@ -0,0 +1,21 @@
// Open Google Maps with lat/lng (or name + coords).
// Following CLAUDE.md: never uses Google Maps API (not available in VN),
// only deep-links to the public web/app surface.
export function openGoogleMaps(args: {
lat?: number;
lng?: number;
query?: string;
name?: string;
}): void {
let url: string;
if (args.lat != null && args.lng != null) {
url = args.name
? `https://maps.google.com/maps?q=${encodeURIComponent(args.name)}&ll=${args.lat},${args.lng}`
: `https://maps.google.com/?q=${args.lat},${args.lng}`;
} else if (args.query) {
url = `https://maps.google.com/maps?q=${encodeURIComponent(args.query)}`;
} else {
return;
}
window.open(url, "_blank", "noopener");
}

View File

@@ -1,323 +0,0 @@
import type { CategoryId, CategoryMeta, Collection, Place, User } from "./types";
export const ME: User = {
id: "u_me",
name: "Minh Anh",
email: "minhanh@places.app",
avatar_url: null,
initials: "MA",
};
export const USERS: Record<string, User> = {
u_me: ME,
u_tung: { id: "u_tung", name: "Tùng Lâm", initials: "TL", avatar_url: null, color: "oklch(70% 0.12 145)" },
u_linh: { id: "u_linh", name: "Linh Đan", initials: "LD", avatar_url: null, color: "oklch(70% 0.13 320)" },
u_hung: { id: "u_hung", name: "Hùng Phạm", initials: "HP", avatar_url: null, color: "oklch(68% 0.12 245)" },
u_thao: { id: "u_thao", name: "Thảo Vy", initials: "TV", avatar_url: null, color: "oklch(72% 0.11 35)" },
};
export const PLACES: Place[] = [
{
id: "p_pho_gia_truyen",
name: "Phở Gia Truyền Bát Đàn",
address: "49 Bát Đàn, Hoàn Kiếm, Hà Nội",
short_address: "49 Bát Đàn · Hoàn Kiếm",
category: "food",
tags: ["phở bò", "sáng", "cổ truyền"],
cover_url: null,
created_by: "u_me",
created_at: "2026-04-12",
my_rating: 5,
my_notes: "Đi sớm trước 8h kẻo hết. Tái nạm gầu là chân ái.",
visited: true,
visited_at: "2026-04-14",
avg_rating: 4.6,
city: "Hà Nội",
},
{
id: "p_bun_cha_huong_lien",
name: "Bún chả Hương Liên",
address: "24 Lê Văn Hưu, Hai Bà Trưng, Hà Nội",
short_address: "24 Lê Văn Hưu · Hai Bà Trưng",
category: "food",
tags: ["bún chả", "combo Obama"],
cover_url: "https://images.unsplash.com/photo-1606851094291-6efae152bb87?w=600&q=80",
created_by: "u_tung",
created_at: "2026-04-08",
my_rating: 4,
my_notes: "",
visited: true,
visited_at: "2026-04-20",
avg_rating: 4.2,
city: "Hà Nội",
},
{
id: "p_cafe_giang",
name: "Cà phê Giảng",
address: "39 Nguyễn Hữu Huân, Hoàn Kiếm, Hà Nội",
short_address: "39 Nguyễn Hữu Huân · Hoàn Kiếm",
category: "cafe",
tags: ["cà phê trứng", "cổ điển"],
cover_url: "https://images.unsplash.com/photo-1442975631115-c4f7b05b8a2c?w=600&q=80",
created_by: "u_me",
created_at: "2026-04-02",
my_rating: 5,
my_notes: "Tầng 2 yên hơn. Trứng đánh bông kiểu cũ.",
visited: true,
visited_at: "2026-04-05",
avg_rating: 4.7,
city: "Hà Nội",
},
{
id: "p_the_note",
name: "The Note Coffee",
address: "64 Lương Văn Can, Hoàn Kiếm, Hà Nội",
short_address: "64 Lương Văn Can · Hoàn Kiếm",
category: "cafe",
tags: ["view hồ Gươm", "sticky notes"],
cover_url: "https://images.unsplash.com/photo-1521017432531-fbd92d768814?w=600&q=80",
created_by: "u_linh",
created_at: "2026-03-28",
my_rating: 4,
visited: false,
avg_rating: 4.1,
city: "Hà Nội",
},
{
id: "p_cong_nha_tho",
name: "Cộng Cà Phê — Nhà Thờ",
address: "27 Nhà Thờ, Hoàn Kiếm, Hà Nội",
short_address: "27 Nhà Thờ · Hoàn Kiếm",
category: "cafe",
tags: ["cốt dừa", "concept bao cấp"],
cover_url: "https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?w=600&q=80",
created_by: "u_me",
created_at: "2026-03-15",
my_rating: 4,
visited: true,
visited_at: "2026-03-15",
avg_rating: 4.0,
city: "Hà Nội",
},
{
id: "p_ta_hien",
name: "Bia hơi Tạ Hiện",
address: "Tạ Hiện, Hoàn Kiếm, Hà Nội",
short_address: "Tạ Hiện · Hoàn Kiếm",
category: "entertainment",
tags: ["phố tây", "tối"],
cover_url: "https://images.unsplash.com/photo-1514933651103-005eec06c04b?w=600&q=80",
created_by: "u_hung",
created_at: "2026-03-10",
visited: false,
avg_rating: 3.8,
city: "Hà Nội",
},
{
id: "p_trang_tien",
name: "Kem Tràng Tiền",
address: "35 Tràng Tiền, Hoàn Kiếm, Hà Nội",
short_address: "35 Tràng Tiền · Hoàn Kiếm",
category: "food",
tags: ["kem", "tráng miệng"],
cover_url: "https://images.unsplash.com/photo-1497034825429-c343d7c6a68f?w=600&q=80",
created_by: "u_thao",
created_at: "2026-03-05",
my_rating: 3,
visited: true,
visited_at: "2026-03-06",
avg_rating: 3.6,
city: "Hà Nội",
},
{
id: "p_banh_mi_huynh_hoa",
name: "Bánh mì Huỳnh Hoa",
address: "26 Lê Thị Riêng, Quận 1, TP. Hồ Chí Minh",
short_address: "26 Lê Thị Riêng · Q1",
category: "food",
tags: ["bánh mì", "pate"],
cover_url: null,
created_by: "u_me",
created_at: "2026-02-20",
my_rating: 5,
my_notes: "Béo, ngậy, đi 2 người ăn 1 ổ là vừa.",
visited: true,
visited_at: "2026-02-21",
avg_rating: 4.5,
city: "TP. HCM",
},
{
id: "p_workshop",
name: "The Workshop Coffee",
address: "27 Ngô Đức Kế, Quận 1, TP. Hồ Chí Minh",
short_address: "27 Ngô Đức Kế · Q1",
category: "cafe",
tags: ["specialty", "không gian rộng"],
cover_url: "https://images.unsplash.com/photo-1453614512568-c4024d13c247?w=600&q=80",
created_by: "u_linh",
created_at: "2026-02-18",
my_rating: 4,
visited: false,
avg_rating: 4.3,
city: "TP. HCM",
},
{
id: "p_pho_le",
name: "Phở Lệ",
address: "413 Nguyễn Trãi, Quận 5, TP. Hồ Chí Minh",
short_address: "413 Nguyễn Trãi · Q5",
category: "food",
tags: ["phở Nam", "mở khuya"],
cover_url: "https://images.unsplash.com/photo-1591814468924-caf88d1232e1?w=600&q=80",
created_by: "u_tung",
created_at: "2026-02-10",
visited: false,
avg_rating: 4.4,
city: "TP. HCM",
},
{
id: "p_ben_thanh",
name: "Chợ Bến Thành",
address: "Lê Lợi, Quận 1, TP. Hồ Chí Minh",
short_address: "Lê Lợi · Q1",
category: "shopping",
tags: ["chợ", "đặc sản"],
cover_url: "https://images.unsplash.com/photo-1555529669-e69e7aa0ba9a?w=600&q=80",
created_by: "u_me",
created_at: "2026-02-01",
visited: true,
visited_at: "2026-02-02",
my_rating: 3,
avg_rating: 3.5,
city: "TP. HCM",
},
{
id: "p_banh_mi_phuong",
name: "Bánh mì Phượng",
address: "2B Phan Châu Trinh, Hội An, Quảng Nam",
short_address: "2B Phan Châu Trinh · Hội An",
category: "food",
tags: ["bánh mì", "huyền thoại"],
cover_url: "https://images.unsplash.com/photo-1558030006-450675393462?w=600&q=80",
created_by: "u_me",
created_at: "2026-05-01",
visited: false,
avg_rating: 4.7,
city: "Hội An",
},
{
id: "p_reaching_out",
name: "Reaching Out Tea House",
address: "131 Trần Phú, Hội An, Quảng Nam",
short_address: "131 Trần Phú · Hội An",
category: "cafe",
tags: ["trà", "yên tĩnh"],
cover_url: "https://images.unsplash.com/photo-1556679343-c7306c1976bc?w=600&q=80",
created_by: "u_linh",
created_at: "2026-05-02",
visited: false,
avg_rating: 4.8,
city: "Hội An",
},
{
id: "p_my_quang",
name: "Mỳ Quảng Bà Mua",
address: "95 Nguyễn Tri Phương, Đà Nẵng",
short_address: "95 Nguyễn Tri Phương · Đà Nẵng",
category: "food",
tags: ["mỳ Quảng", "đặc sản"],
cover_url: "https://images.unsplash.com/photo-1569718212165-3a8278d5f624?w=600&q=80",
created_by: "u_tung",
created_at: "2026-05-03",
visited: false,
avg_rating: 4.3,
city: "Đà Nẵng",
},
{
id: "p_hoi_an_old_town",
name: "Phố cổ Hội An",
address: "Phố cổ, Hội An, Quảng Nam",
short_address: "Phố cổ · Hội An",
category: "entertainment",
tags: ["di sản", "đèn lồng", "tối"],
cover_url: "https://images.unsplash.com/photo-1540541338287-41700207dee6?w=600&q=80",
created_by: "u_me",
created_at: "2026-05-04",
visited: false,
avg_rating: 4.9,
city: "Hội An",
},
];
export const COLLECTIONS: Collection[] = [
{
id: "c_ha_noi",
name: "Hà Nội phải đi",
type: "folder",
owner_id: "u_me",
member_count: 3,
place_count: 7,
my_role: "owner",
cover_place_ids: ["p_pho_gia_truyen", "p_cafe_giang", "p_ta_hien"],
place_ids: ["p_pho_gia_truyen", "p_bun_cha_huong_lien", "p_cafe_giang", "p_the_note", "p_cong_nha_tho", "p_ta_hien", "p_trang_tien"],
members: ["u_me", "u_tung", "u_linh"],
},
{
id: "c_hoi_an",
name: "Hội An tháng 6",
type: "trip",
owner_id: "u_me",
trip_start: "2026-06-12",
trip_end: "2026-06-18",
member_count: 4,
place_count: 5,
my_role: "owner",
cover_place_ids: ["p_hoi_an_old_town", "p_banh_mi_phuong", "p_reaching_out"],
place_ids: ["p_banh_mi_phuong", "p_reaching_out", "p_my_quang", "p_hoi_an_old_town", "p_cong_nha_tho"],
members: ["u_me", "u_tung", "u_linh", "u_thao"],
},
{
id: "c_cafe",
name: "Quán cà phê cuối tuần",
type: "folder",
owner_id: "u_me",
member_count: 1,
place_count: 4,
my_role: "owner",
cover_place_ids: ["p_cafe_giang", "p_workshop", "p_the_note"],
place_ids: ["p_cafe_giang", "p_workshop", "p_the_note", "p_cong_nha_tho"],
members: ["u_me"],
},
{
id: "c_sg_short",
name: "Sài Gòn 2 ngày",
type: "trip",
owner_id: "u_hung",
trip_start: "2026-05-23",
trip_end: "2026-05-25",
member_count: 5,
place_count: 6,
my_role: "viewer",
cover_place_ids: ["p_banh_mi_huynh_hoa", "p_pho_le", "p_ben_thanh"],
place_ids: ["p_banh_mi_huynh_hoa", "p_workshop", "p_pho_le", "p_ben_thanh"],
members: ["u_hung", "u_me", "u_tung", "u_linh", "u_thao"],
},
];
export const CATEGORIES: Record<CategoryId, CategoryMeta> = {
food: { label: "Ăn uống", icon: "Utensils", color: "var(--cat-food)" },
cafe: { label: "Cà phê", icon: "Coffee", color: "var(--cat-cafe)" },
shopping: { label: "Mua sắm", icon: "ShoppingBag", color: "var(--cat-shopping)" },
entertainment: { label: "Giải trí", icon: "Sparkles", color: "var(--cat-entertainment)" },
other: { label: "Khác", icon: "MapPin", color: "var(--cat-other)" },
};
export const FILTERS: { id: string; label: string }[] = [
{ id: "all", label: "Tất cả" },
{ id: "food", label: "Ăn uống" },
{ id: "cafe", label: "Cà phê" },
{ id: "shopping", label: "Mua sắm" },
{ id: "entertainment", label: "Giải trí" },
{ id: "visited", label: "Đã đến" },
{ id: "unvisited", label: "Chưa đến" },
];

59
src/lib/nominatim.ts Normal file
View File

@@ -0,0 +1,59 @@
// Nominatim (OpenStreetMap) geocoding helpers.
// Per CLAUDE.md: countrycodes=vn, Accept-Language: vi, rate-limit 1 req/s
// (callers MUST debounce — see useDebouncedNominatim below).
export type NominatimResult = {
place_id: number;
display_name: string;
lat: string;
lon: string;
address?: Record<string, string>;
};
const BASE = "https://nominatim.openstreetmap.org";
const HEADERS: HeadersInit = { "Accept-Language": "vi" };
export async function searchAddress(
query: string,
signal?: AbortSignal,
): Promise<NominatimResult[]> {
const q = query.trim();
if (!q) return [];
const url = `${BASE}/search?q=${encodeURIComponent(q)}&format=json&limit=5&countrycodes=vn&addressdetails=1`;
const res = await fetch(url, { headers: HEADERS, signal });
if (!res.ok) return [];
return res.json();
}
export async function reverseGeocode(
lat: number,
lng: number,
signal?: AbortSignal,
): Promise<NominatimResult | null> {
const url = `${BASE}/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`;
const res = await fetch(url, { headers: HEADERS, signal });
if (!res.ok) return null;
return res.json();
}
// Build a Place-friendly representation from a Nominatim hit.
export function toPlaceFields(r: NominatimResult): {
address: string;
short_address: string;
city: string;
lat: number;
lng: number;
} {
const a = r.address || {};
const houseLine = [a.house_number, a.road].filter(Boolean).join(" ");
const district = a.suburb || a.city_district || a.district || a.county;
const city = a.city || a.town || a.village || a.state || "";
const short = [houseLine, district].filter(Boolean).join(" · ") || r.display_name.split(",")[0];
return {
address: r.display_name,
short_address: short,
city,
lat: Number(r.lat),
lng: Number(r.lon),
};
}

View File

@@ -5,7 +5,7 @@ export type CollectionType = "folder" | "trip";
export type Role = "owner" | "editor" | "viewer";
export type User = {
id: string;
id: number;
name: string;
email?: string;
avatar_url?: string | null;
@@ -14,14 +14,14 @@ export type User = {
};
export type Place = {
id: string;
id: number;
name: string;
address: string;
short_address: string;
category: CategoryId;
tags: string[];
cover_url?: string | null;
created_by: string;
created_by: number;
created_at: string;
my_rating?: number;
my_notes?: string;
@@ -32,18 +32,18 @@ export type Place = {
};
export type Collection = {
id: string;
id: number;
name: string;
type: CollectionType;
owner_id: string;
owner_id: number;
trip_start?: string;
trip_end?: string;
member_count: number;
place_count: number;
my_role: Role;
cover_place_ids: string[];
place_ids: string[];
members: string[];
cover_place_ids: number[];
place_ids: number[];
members: number[];
};
export type CategoryMeta = {

22
src/lib/ui-config.ts Normal file
View File

@@ -0,0 +1,22 @@
// Static UI config (not user data). Categories + filter chip labels are
// shared between screens; they're not stored in the DB.
import type { CategoryId, CategoryMeta } from "./types";
export const CATEGORIES: Record<CategoryId, CategoryMeta> = {
food: { label: "Ăn uống", icon: "Utensils", color: "var(--cat-food)" },
cafe: { label: "Cà phê", icon: "Coffee", color: "var(--cat-cafe)" },
shopping: { label: "Mua sắm", icon: "ShoppingBag", color: "var(--cat-shopping)" },
entertainment: { label: "Giải trí", icon: "Sparkles", color: "var(--cat-entertainment)" },
other: { label: "Khác", icon: "MapPin", color: "var(--cat-other)" },
};
export const FILTERS: { id: string; label: string }[] = [
{ id: "all", label: "Tất cả" },
{ id: "food", label: "Ăn uống" },
{ id: "cafe", label: "Cà phê" },
{ id: "shopping", label: "Mua sắm" },
{ id: "entertainment", label: "Giải trí" },
{ id: "visited", label: "Đã đến" },
{ id: "unvisited", label: "Chưa đến" },
];

View File

@@ -1,8 +1,10 @@
"use client";
import { useState } from "react";
import { COLLECTIONS } from "@/lib/mock-data";
import { useState, useTransition } from "react";
import { useCollections } from "@/lib/app-context";
import { fmtShortDate, tripDays } from "@/lib/format";
import { toggleVisited } from "@/lib/db/actions";
import { copyToClipboard } from "@/lib/clipboard";
import type { AppState, Dispatch } from "@/lib/app-state";
import {
Header,
@@ -19,14 +21,16 @@ export function CollectionDetailScreen({
state,
dispatch,
}: {
state: AppState & { collectionId?: string };
state: AppState & { collectionId?: number };
dispatch: Dispatch;
}) {
const c = COLLECTIONS.find((x) => x.id === state.collectionId);
const collections = useCollections();
const c = collections.find((x) => x.id === state.collectionId);
const [vfilter, setVfilter] = useState<"all" | "visited" | "unvisited">(
"all",
);
const [menuOpen, setMenuOpen] = useState(false);
const [, startTransition] = useTransition();
if (!c) return null;
const places = c.place_ids
.map((id) => state.places.find((p) => p.id === id))
@@ -291,9 +295,15 @@ export function CollectionDetailScreen({
trailing={
<Checkbox
checked={p.visited}
onClick={() =>
dispatch({ type: "TOGGLE_VISITED", placeId: p.id })
}
onClick={() => {
dispatch({ type: "TOGGLE_VISITED", placeId: p.id });
startTransition(() => {
toggleVisited(p.id).catch(() => {
dispatch({ type: "TOGGLE_VISITED", placeId: p.id });
dispatch({ type: "TOAST", value: "Lưu thất bại" });
});
});
}}
/>
}
/>
@@ -308,13 +318,13 @@ export function CollectionDetailScreen({
<div className="sheet" style={{ maxHeight: "60%" }}>
<div className="sheet-handle" />
<div style={{ padding: "8px 0 16px" }}>
{!isViewer && (
{c.my_role === "owner" && (
<MenuItem
icon="Edit"
label="Sửa thông tin"
onClick={() => {
setMenuOpen(false);
dispatch({ type: "TOAST", value: "Sửa (demo)" });
dispatch({ type: "OPEN_EDIT_COLLECTION", collectionId: c.id });
}}
/>
)}
@@ -339,9 +349,14 @@ export function CollectionDetailScreen({
<MenuItem
icon="Share"
label="Chia sẻ bộ sưu tập"
onClick={() => {
onClick={async () => {
setMenuOpen(false);
dispatch({ type: "TOAST", value: "Đã sao chép liên kết" });
const url = `${window.location.origin}/collections/${c.id}`;
const ok = await copyToClipboard(url);
dispatch({
type: "TOAST",
value: ok ? "Đã sao chép liên kết" : "Không sao chép được",
});
}}
/>
{c.my_role === "owner" && (

View File

@@ -1,23 +1,25 @@
"use client";
import { type CSSProperties, useState } from "react";
import { COLLECTIONS, PLACES } from "@/lib/mock-data";
import { useCollections } from "@/lib/app-context";
import { fmtShortDate } from "@/lib/format";
import type { AppState, Dispatch } from "@/lib/app-state";
import type { Collection } from "@/lib/types";
import type { Collection, Place } from "@/lib/types";
import { Header, IconBtn } from "@/components/ui-primitives";
import { CoverImage } from "@/components/cover-image";
import { AvatarStack } from "@/components/avatar";
import { Icons } from "@/components/icons";
export function CollectionsListScreen({
state,
dispatch,
}: {
state: AppState;
dispatch: Dispatch;
}) {
const [tab, setTab] = useState<"all" | "trips" | "folders">("all");
const filtered = COLLECTIONS.filter((c) => {
const allCollections = useCollections();
const filtered = allCollections.filter((c) => {
if (tab === "all") return true;
if (tab === "trips") return c.type === "trip";
return c.type === "folder";
@@ -28,15 +30,13 @@ export function CollectionsListScreen({
<Header
big
title="Bộ sưu tập"
subtitle={`${COLLECTIONS.length} bộ sưu tập · ${COLLECTIONS.filter((c) => c.my_role !== "owner").length} được chia sẻ`}
subtitle={`${allCollections.length} bộ sưu tập · ${allCollections.filter((c) => c.my_role !== "owner").length} được chia sẻ`}
right={
<IconBtn
icon="Plus"
label="Tạo mới"
variant="muted"
onClick={() =>
dispatch({ type: "TOAST", value: "Tạo bộ sưu tập mới (demo)" })
}
onClick={() => dispatch({ type: "OPEN_CREATE_COLLECTION" })}
/>
}
/>
@@ -73,6 +73,7 @@ export function CollectionsListScreen({
<CollectionCard
key={c.id}
c={c}
places={state.places}
onTap={() =>
dispatch({
type: "NAV",
@@ -91,15 +92,17 @@ export function CollectionsListScreen({
function CollectionCard({
c,
places: allPlaces,
onTap,
style,
}: {
c: Collection;
places: Place[];
onTap: () => void;
style?: CSSProperties;
}) {
const places = c.cover_place_ids
.map((id) => PLACES.find((p) => p.id === id))
.map((id) => allPlaces.find((p) => p.id === id))
.filter((p): p is NonNullable<typeof p> => Boolean(p));
const isTrip = c.type === "trip";
return (

View File

@@ -1,8 +1,12 @@
"use client";
import { useState } from "react";
import { CATEGORIES, COLLECTIONS, USERS } from "@/lib/mock-data";
import { useState, useTransition } from "react";
import { CATEGORIES } from "@/lib/ui-config";
import { useCollections, useMe, useUsers } from "@/lib/app-context";
import { fmtDate } from "@/lib/format";
import { setNotes as saveNotes, setRating, toggleVisited } from "@/lib/db/actions";
import { openGoogleMaps } from "@/lib/maps";
import { copyToClipboard } from "@/lib/clipboard";
import type { AppState, Dispatch } from "@/lib/app-state";
import { IconBtn, Checkbox, MenuItem } from "@/components/ui-primitives";
import { CoverImage } from "@/components/cover-image";
@@ -14,19 +18,23 @@ export function PlaceDetailScreen({
state,
dispatch,
}: {
state: AppState & { placeId?: string };
state: AppState & { placeId?: number };
dispatch: Dispatch;
}) {
const place = state.places.find((p) => p.id === state.placeId);
const [notes, setNotes] = useState(place?.my_notes || "");
const [notes, setNotesLocal] = useState(place?.my_notes || "");
const [menuOpen, setMenuOpen] = useState(false);
const [, startTransition] = useTransition();
const collections = useCollections();
const users = useUsers();
const me = useMe();
if (!place) return null;
const cat = CATEGORIES[place.category];
const CatIcon = Icons[cat.icon as keyof typeof Icons];
const collectionsContaining = COLLECTIONS.filter((c) =>
const collectionsContaining = collections.filter((c) =>
c.place_ids.includes(place.id),
);
const creator = USERS[place.created_by];
const creator = users[place.created_by];
return (
<div className="app-surface" style={{ background: "var(--background)" }}>
@@ -74,9 +82,14 @@ export function PlaceDetailScreen({
icon="Share"
label="Chia sẻ"
variant="glass"
onClick={() =>
dispatch({ type: "TOAST", value: "Đã sao chép liên kết" })
}
onClick={async () => {
const url = `${window.location.origin}/places/${place.id}`;
const ok = await copyToClipboard(url);
dispatch({
type: "TOAST",
value: ok ? "Đã sao chép liên kết" : "Không sao chép được",
});
}}
/>
<IconBtn
icon="MoreHorizontal"
@@ -160,9 +173,7 @@ export function PlaceDetailScreen({
{/* Address row */}
<button
onClick={() =>
dispatch({ type: "TOAST", value: "Đang mở Google Maps..." })
}
onClick={() => openGoogleMaps({ query: place.address, name: place.name })}
style={{
display: "flex",
alignItems: "center",
@@ -243,9 +254,15 @@ export function PlaceDetailScreen({
</div>
<Checkbox
checked={place.visited}
onClick={() =>
dispatch({ type: "TOGGLE_VISITED", placeId: place.id })
}
onClick={() => {
dispatch({ type: "TOGGLE_VISITED", placeId: place.id });
startTransition(() => {
toggleVisited(place.id).catch(() => {
dispatch({ type: "TOGGLE_VISITED", placeId: place.id });
dispatch({ type: "TOAST", value: "Lưu thất bại" });
});
});
}}
/>
</div>
<div className="divider" />
@@ -264,13 +281,18 @@ export function PlaceDetailScreen({
value={place.my_rating || 0}
readOnly={false}
size={22}
onChange={(v) =>
onChange={(v) => {
dispatch({
type: "SET_RATING",
placeId: place.id,
value: v,
})
}
});
startTransition(() => {
setRating(place.id, v).catch(() =>
dispatch({ type: "TOAST", value: "Lưu thất bại" }),
);
});
}}
/>
</div>
<div
@@ -328,14 +350,19 @@ export function PlaceDetailScreen({
<textarea
value={notes}
placeholder="Thêm ghi chú riêng — chỉ mình bạn thấy..."
onChange={(e) => setNotes(e.target.value)}
onBlur={() =>
onChange={(e) => setNotesLocal(e.target.value)}
onBlur={() => {
dispatch({
type: "SET_NOTES",
placeId: place.id,
value: notes,
})
}
});
startTransition(() => {
saveNotes(place.id, notes).catch(() =>
dispatch({ type: "TOAST", value: "Lưu thất bại" }),
);
});
}}
style={{ resize: "none", minHeight: 72 }}
/>
</div>
@@ -419,34 +446,41 @@ export function PlaceDetailScreen({
<div className="sheet" style={{ maxHeight: "50%" }}>
<div className="sheet-handle" />
<div style={{ padding: "8px 0 16px" }}>
<MenuItem
icon="Edit"
label="Sửa địa điểm"
onClick={() => {
setMenuOpen(false);
dispatch({ type: "TOAST", value: "Tính năng sửa (demo)" });
}}
/>
{place.created_by === me.id && (
<MenuItem
icon="Edit"
label="Sửa địa điểm"
onClick={() => {
setMenuOpen(false);
dispatch({ type: "OPEN_EDIT_PLACE", placeId: place.id });
}}
/>
)}
<MenuItem
icon="Bookmark"
label="Lưu vào bộ sưu tập"
onClick={() => {
setMenuOpen(false);
dispatch({
type: "TOAST",
value: 'Đã thêm vào "Hà Nội phải đi"',
type: "OPEN_SAVE_TO_COLLECTION",
placeId: place.id,
});
}}
/>
<MenuItem
icon="Share"
label="Chia sẻ"
onClick={() => {
onClick={async () => {
setMenuOpen(false);
dispatch({ type: "TOAST", value: "Đã sao chép liên kết" });
const url = `${window.location.origin}/places/${place.id}`;
const ok = await copyToClipboard(url);
dispatch({
type: "TOAST",
value: ok ? "Đã sao chép liên kết" : "Không sao chép được",
});
}}
/>
{place.created_by === "u_me" && (
{place.created_by === me.id && (
<MenuItem
icon="Trash"
label="Xóa địa điểm"

View File

@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { FILTERS } from "@/lib/mock-data";
import { FILTERS } from "@/lib/ui-config";
import type { AppState, Dispatch } from "@/lib/app-state";
import { Header, IconBtn, OfflineBanner, EmptyState } from "@/components/ui-primitives";
import { PlaceCard } from "@/components/place-card";

View File

@@ -1,15 +1,20 @@
"use client";
import { COLLECTIONS, ME } from "@/lib/mock-data";
import { useTransition } from "react";
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 }) {
const me = useMe();
const collections = useCollections();
const [signingOut, startSignOut] = useTransition();
const stats = [
{ label: "Địa điểm", value: state.places.length },
{ label: "Đã đến", value: state.places.filter((p) => p.visited).length },
{ label: "Bộ sưu tập", value: COLLECTIONS.length },
{ label: "Bộ sưu tập", value: collections.length },
];
return (
@@ -51,7 +56,7 @@ export function ProfileScreen({ state }: { state: AppState; dispatch: Dispatch }
fontWeight: 700,
}}
>
{ME.initials}
{me.initials}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
@@ -61,12 +66,12 @@ export function ProfileScreen({ state }: { state: AppState; dispatch: Dispatch }
letterSpacing: "-0.01em",
}}
>
{ME.name}
{me.name}
</div>
<div
style={{ fontSize: 13, color: "var(--muted-foreground)" }}
>
{ME.email}
{me.email}
</div>
</div>
<IconBtn icon="Edit2" label="Sửa" />
@@ -124,7 +129,14 @@ export function ProfileScreen({ state }: { state: AppState; dispatch: Dispatch }
<SettingRow icon="Bell" label="Thông báo" detail="Bật" />
<SettingRow icon="Globe" label="Ngôn ngữ" detail="Tiếng Việt" />
<SettingRow icon="Settings" label="Cài đặt" />
<SettingRow icon="LogOut" label="Đăng xuất" danger last />
<SettingRow
icon="LogOut"
label={signingOut ? "Đang đăng xuất..." : "Đăng xuất"}
danger
last
onClick={() => startSignOut(() => logoutAction())}
disabled={signingOut}
/>
</div>
<div
@@ -149,17 +161,24 @@ function SettingRow({
detail,
last,
danger,
onClick,
disabled,
}: {
icon: IconName;
label: string;
detail?: string;
last?: boolean;
danger?: boolean;
onClick?: () => void;
disabled?: boolean;
}) {
const I = Icons[icon];
return (
<>
<button
type="button"
onClick={onClick}
disabled={disabled}
style={{
width: "100%",
display: "flex",
@@ -170,6 +189,7 @@ function SettingRow({
border: 0,
textAlign: "left",
color: danger ? "var(--danger)" : "var(--foreground)",
opacity: disabled ? 0.6 : 1,
}}
>
<I size={20} stroke={1.75} />

View File

@@ -1,20 +1,20 @@
"use client";
import { useState } from "react";
import { CATEGORIES } from "@/lib/mock-data";
import { useEffect, useRef, useState, useTransition } from "react";
import { CATEGORIES } from "@/lib/ui-config";
import type { CategoryId } from "@/lib/types";
import type { Dispatch } from "@/lib/app-state";
import { addPlace } from "@/lib/db/actions";
import {
type NominatimResult,
reverseGeocode,
searchAddress,
toPlaceFields,
} from "@/lib/nominatim";
import { FieldLabel } from "@/components/ui-primitives";
import { RatingStars } from "@/components/rating-stars";
import { Icons } from "@/components/icons";
const ADDRESS_SUGG = [
{ addr: "12 Nhà Thờ, Hoàn Kiếm, Hà Nội", sub: "Hà Nội · 0.8 km" },
{ addr: "49 Bát Đàn, Hoàn Kiếm, Hà Nội", sub: "Hà Nội · 1.2 km" },
{ addr: "128 Mã Mây, Hoàn Kiếm, Hà Nội", sub: "Hà Nội · 1.5 km" },
{ addr: "24 Lý Quốc Sư, Hoàn Kiếm, Hà Nội", sub: "Hà Nội · 1.8 km" },
];
export function AddPlaceSheet({
onClose,
dispatch,
@@ -24,26 +24,79 @@ export function AddPlaceSheet({
}) {
const [name, setName] = useState("");
const [address, setAddress] = useState("");
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>("food");
const [tags, setTags] = useState<string[]>([]);
const [tagInput, setTagInput] = useState("");
const [notes, setNotes] = useState("");
const [rating, setRating] = useState(0);
const [cover, setCover] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [, startTransition] = useTransition();
const suggestions = address
? ADDRESS_SUGG.filter((s) =>
s.addr.toLowerCase().includes(address.toLowerCase()),
)
: ADDRESS_SUGG;
// Debounced Nominatim search — CLAUDE.md: rate-limit 1 req/s + cancel in-flight.
const lastReqRef = useRef<AbortController | null>(null);
useEffect(() => {
const q = address.trim();
if (!q || q.length < 3 || picked) {
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]);
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 addTag = (raw: string) => {
const t = raw.trim().replace(/,$/, "");
if (t && !tags.includes(t) && tags.length < 10) {
setTags([...tags, t]);
}
if (t && !tags.includes(t) && tags.length < 10) setTags([...tags, t]);
setTagInput("");
};
const removeTag = (t: string) => setTags(tags.filter((x) => x !== t));
@@ -53,29 +106,39 @@ export function AddPlaceSheet({
const submit = () => {
if (!isValid) return;
setSubmitting(true);
setTimeout(() => {
dispatch({
type: "ADD_PLACE",
place: {
id: "p_new_" + Date.now(),
name: name.trim(),
address: address.trim(),
short_address: address.trim().split(",").slice(0, 2).join(" · "),
category,
tags,
cover_url: cover,
created_by: "u_me",
created_at: new Date().toISOString().slice(0, 10),
my_rating: rating || undefined,
my_notes: notes || undefined,
visited: false,
avg_rating: undefined,
city: address.split(",").pop()?.trim() || "",
},
});
onClose();
dispatch({ type: "TOAST", value: `Đã lưu "${name}"` });
}, 500);
const trimmedName = name.trim();
const fields = picked
? toPlaceFields(picked)
: (() => {
const a = address.trim();
return {
address: a,
short_address: a.split(",").slice(0, 2).join(" · "),
city: a.split(",").pop()?.trim() || "",
};
})();
startTransition(() => {
addPlace({
name: trimmedName,
address: fields.address,
short_address: fields.short_address,
city: fields.city,
category,
tags,
cover_url: null,
rating: rating || undefined,
notes: notes || undefined,
})
.then((place) => {
dispatch({ type: "ADD_PLACE", place });
onClose();
dispatch({ type: "TOAST", value: `Đã lưu "${trimmedName}"` });
})
.catch(() => {
setSubmitting(false);
dispatch({ type: "TOAST", value: "Lưu thất bại" });
});
});
};
return (
@@ -109,76 +172,6 @@ export function AddPlaceSheet({
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "4px 16px" }}>
{/* Cover upload */}
<button
onClick={() =>
setCover(
"https://images.unsplash.com/photo-1559925393-8be0ec4767c8?w=600&q=80",
)
}
style={{
width: "100%",
aspectRatio: "16 / 9",
borderRadius: "var(--radius-lg)",
background: cover ? "transparent" : "var(--muted)",
border: cover ? "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,
}}
>
{cover ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={cover}
alt=""
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
<button
onClick={(e) => {
e.stopPropagation();
setCover(null);
}}
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>
</>
) : (
<>
<Icons.Camera size={20} stroke={1.75} />
Thêm nh
</>
)}
</button>
{/* Name */}
<FieldLabel required>Tên đa điểm</FieldLabel>
<div className="input">
<input
@@ -188,7 +181,6 @@ export function AddPlaceSheet({
/>
</div>
{/* Address */}
<FieldLabel required>Đa chỉ</FieldLabel>
<div className="input">
<Icons.MapPin
@@ -200,16 +192,15 @@ export function AddPlaceSheet({
value={address}
onChange={(e) => {
setAddress(e.target.value);
setPicked(null);
setShowSugg(true);
}}
onFocus={() => setShowSugg(true)}
placeholder="Số nhà, đường, quận, tỉnh"
/>
<button
onClick={() => {
setAddress("Vị trí hiện tại · 39 Nguyễn Hữu Huân, Hoàn Kiếm");
setShowSugg(false);
}}
onClick={useCurrentLocation}
disabled={geoLoading}
style={{
background: "var(--muted)",
border: 0,
@@ -220,13 +211,14 @@ export function AddPlaceSheet({
alignItems: "center",
justifyContent: "center",
color: "var(--primary)",
opacity: geoLoading ? 0.6 : 1,
}}
aria-label="Lấy vị trí hiện tại"
>
<Icons.Crosshair size={16} stroke={2} />
</button>
</div>
{showSugg && address && (
{showSugg && address.length >= 3 && !picked && (
<div
style={{
marginTop: 6,
@@ -237,11 +229,34 @@ export function AddPlaceSheet({
boxShadow: "var(--shadow-md)",
}}
>
{searching && suggestions.length === 0 && (
<div
style={{
padding: "10px 12px",
fontSize: 13,
color: "var(--muted-foreground)",
}}
>
Đang tìm...
</div>
)}
{!searching && suggestions.length === 0 && (
<div
style={{
padding: "10px 12px",
fontSize: 13,
color: "var(--muted-foreground)",
}}
>
Không gợi ý
</div>
)}
{suggestions.slice(0, 4).map((s, i) => (
<button
key={i}
key={s.place_id}
onClick={() => {
setAddress(s.addr);
setAddress(s.display_name);
setPicked(s);
setShowSugg(false);
}}
style={{
@@ -253,16 +268,16 @@ export function AddPlaceSheet({
background: "transparent",
border: 0,
textAlign: "left",
borderBottom: i < 3 ? "0.5px solid var(--border)" : 0,
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,
}}
style={{ color: "var(--muted-foreground)", flexShrink: 0 }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
@@ -274,15 +289,7 @@ export function AddPlaceSheet({
whiteSpace: "nowrap",
}}
>
{s.addr}
</div>
<div
style={{
fontSize: 12,
color: "var(--muted-foreground)",
}}
>
{s.sub}
{s.display_name}
</div>
</div>
</button>
@@ -290,7 +297,6 @@ export function AddPlaceSheet({
</div>
)}
{/* Category */}
<FieldLabel required>Danh mục</FieldLabel>
<div className="toggle-group">
{(Object.entries(CATEGORIES) as [CategoryId, typeof CATEGORIES[CategoryId]][]).map(
@@ -310,12 +316,9 @@ export function AddPlaceSheet({
)}
</div>
{/* Tags */}
<FieldLabel>
Thẻ{" "}
<span
style={{ color: "var(--subtle-foreground)", fontWeight: 400 }}
>
<span style={{ color: "var(--subtle-foreground)", fontWeight: 400 }}>
· {tags.length}/10
</span>
</FieldLabel>
@@ -376,7 +379,6 @@ export function AddPlaceSheet({
/>
</div>
{/* Notes */}
<FieldLabel>
<Icons.Lock size={12} stroke={2.5} style={{ marginRight: 4 }} />
Ghi chú riêng
@@ -390,12 +392,9 @@ export function AddPlaceSheet({
/>
</div>
{/* Rating */}
<FieldLabel>
Đánh giá{" "}
<span
style={{ color: "var(--subtle-foreground)", fontWeight: 400 }}
>
<span style={{ color: "var(--subtle-foreground)", fontWeight: 400 }}>
· tuỳ chọn
</span>
</FieldLabel>
@@ -411,7 +410,6 @@ export function AddPlaceSheet({
<div style={{ height: 8 }} />
</div>
{/* Footer */}
<div
style={{
padding: "12px 16px 4px",

View File

@@ -0,0 +1,184 @@
"use client";
import { useState, useTransition } from "react";
import type { Collection, CollectionType } from "@/lib/types";
import type { Dispatch } from "@/lib/app-state";
import { FieldLabel } from "@/components/ui-primitives";
import { Icons } from "@/components/icons";
import { createCollection, editCollection } from "@/lib/db/actions";
export function CollectionFormSheet({
mode,
collection,
onClose,
dispatch,
}: {
mode: "create" | "edit";
collection?: Collection;
onClose: () => void;
dispatch: Dispatch;
}) {
const [name, setName] = useState(collection?.name ?? "");
const [type, setType] = useState<CollectionType>(collection?.type ?? "folder");
const [start, setStart] = useState(collection?.trip_start ?? "");
const [end, setEnd] = useState(collection?.trip_end ?? "");
const [saving, setSaving] = useState(false);
const [, startTransition] = useTransition();
const isValid = name.trim().length > 0 &&
(type === "folder" || (start && end));
const submit = () => {
if (!isValid) return;
setSaving(true);
const input = {
name: name.trim(),
type,
trip_start: type === "trip" ? start : undefined,
trip_end: type === "trip" ? end : undefined,
};
startTransition(() => {
const op = mode === "create"
? createCollection(input).then(() => undefined)
: editCollection(collection!.id, input);
op
.then(() => {
onClose();
dispatch({
type: "TOAST",
value: mode === "create" ? "Đã tạo bộ sưu tập" : "Đã lưu thay đổi",
});
})
.catch(() => {
setSaving(false);
dispatch({ type: "TOAST", value: "Lưu thất bại" });
});
});
};
return (
<>
<div className="overlay" onClick={onClose} />
<div className="sheet" style={{ maxHeight: "75%" }}>
<div className="sheet-handle" />
<div
style={{
padding: "6px 12px 8px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<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 }}>
{mode === "create" ? "Tạo bộ sưu tập" : "Sửa bộ sưu tập"}
</div>
<div style={{ width: 70 }} />
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "4px 16px 16px" }}>
<FieldLabel required>Tên</FieldLabel>
<div className="input">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="VD: Sài Gòn tháng 6"
autoFocus
/>
</div>
<FieldLabel required>Loại</FieldLabel>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{([
{ id: "folder", label: "Thư mục", desc: "Không có ngày" },
{ id: "trip", label: "Chuyến đi", desc: "Có khoảng ngày" },
] as const).map((opt) => {
const Icon = opt.id === "trip" ? Icons.Plane : Icons.Folder;
return (
<button
key={opt.id}
onClick={() => setType(opt.id)}
style={{
padding: 14,
borderRadius: "var(--radius-md)",
border: type === opt.id
? "1.5px solid var(--primary)"
: "1px solid var(--border)",
background: type === opt.id ? "var(--primary-soft)" : "var(--card)",
color: type === opt.id ? "var(--primary)" : "var(--foreground)",
textAlign: "left",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<Icon size={20} stroke={1.75} />
<div style={{ fontSize: 14, fontWeight: 600 }}>{opt.label}</div>
<div style={{ fontSize: 12, color: "var(--muted-foreground)" }}>
{opt.desc}
</div>
</button>
);
})}
</div>
{type === "trip" && (
<>
<FieldLabel required>Bắt đu</FieldLabel>
<div className="input">
<Icons.Calendar size={18} stroke={1.75} style={{ color: "var(--muted-foreground)" }} />
<input
type="date"
value={start}
onChange={(e) => setStart(e.target.value)}
/>
</div>
<FieldLabel required>Kết thúc</FieldLabel>
<div className="input">
<Icons.Calendar size={18} stroke={1.75} style={{ color: "var(--muted-foreground)" }} />
<input
type="date"
value={end}
min={start || undefined}
onChange={(e) => setEnd(e.target.value)}
/>
</div>
</>
)}
</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}
onClick={submit}
>
{saving
? "Đang lưu..."
: mode === "create"
? "Tạo"
: "Lưu thay đổi"}
</button>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,185 @@
"use client";
import { useState, useTransition } from "react";
import { CATEGORIES } from "@/lib/ui-config";
import type { CategoryId, Place } from "@/lib/types";
import type { Dispatch } from "@/lib/app-state";
import { FieldLabel } from "@/components/ui-primitives";
import { Icons } from "@/components/icons";
import { editPlace } from "@/lib/db/actions";
export function EditPlaceSheet({
place,
onClose,
dispatch,
}: {
place: Place;
onClose: () => void;
dispatch: Dispatch;
}) {
const [name, setName] = useState(place.name);
const [address, setAddress] = useState(place.address);
const [category, setCategory] = useState<CategoryId>(place.category);
const [tags, setTags] = useState<string[]>(place.tags);
const [tagInput, setTagInput] = useState("");
const [saving, setSaving] = useState(false);
const [, startTransition] = useTransition();
const isValid = name.trim() && address.trim();
const addTag = (raw: string) => {
const t = raw.trim().replace(/,$/, "");
if (t && !tags.includes(t) && tags.length < 10) setTags([...tags, t]);
setTagInput("");
};
const submit = () => {
if (!isValid) return;
setSaving(true);
const trimmedAddress = address.trim();
startTransition(() => {
editPlace(place.id, {
name: name.trim(),
address: trimmedAddress,
short_address: trimmedAddress.split(",").slice(0, 2).join(" · "),
city: trimmedAddress.split(",").pop()?.trim() || "",
category,
tags,
cover_url: place.cover_url ?? null,
})
.then(() => {
onClose();
dispatch({ type: "TOAST", value: "Đã lưu thay đổi" });
})
.catch(() => {
setSaving(false);
dispatch({ type: "TOAST", value: "Lưu thất bại" });
});
});
};
return (
<>
<div className="overlay" onClick={onClose} />
<div className="sheet" style={{ height: "85%" }}>
<div className="sheet-handle" />
<div
style={{
padding: "6px 12px 8px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<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 đa điểm</div>
<div style={{ width: 70 }} />
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "4px 16px" }}>
<FieldLabel required>Tên đa điểm</FieldLabel>
<div className="input">
<input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<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)} />
</div>
<FieldLabel required>Danh mục</FieldLabel>
<div className="toggle-group">
{(Object.entries(CATEGORIES) as [CategoryId, typeof CATEGORIES[CategoryId]][]).map(
([k, c]) => {
const I = Icons[c.icon as keyof typeof Icons];
return (
<button
key={k}
data-active={category === k}
onClick={() => setCategory(k)}
>
<I size={20} stroke={1.75} />
<span>{c.label}</span>
</button>
);
},
)}
</div>
<FieldLabel>
Thẻ <span style={{ color: "var(--subtle-foreground)", fontWeight: 400 }}>· {tags.length}/10</span>
</FieldLabel>
<div
className="input"
style={{ flexWrap: "wrap", height: "auto", minHeight: 48, padding: "6px 10px", gap: 6 }}
>
{tags.map((t) => (
<span
key={t}
className="badge"
style={{ height: 26, background: "var(--primary-soft)", color: "var(--primary)" }}
>
{t}
<button
onClick={() => setTags(tags.filter((x) => x !== t))}
style={{ background: "transparent", border: 0, color: "inherit", padding: 2, marginLeft: 2 }}
>
<Icons.X size={12} stroke={2.5} />
</button>
</span>
))}
<input
value={tagInput}
onChange={(e) => {
const v = e.target.value;
if (v.endsWith(",")) addTag(v);
else setTagInput(v);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addTag(tagInput);
} else if (e.key === "Backspace" && !tagInput && tags.length) {
setTags(tags.slice(0, -1));
}
}}
placeholder={tags.length ? "" : "Enter để thêm thẻ"}
style={{ flex: 1, minWidth: 80, height: 32 }}
/>
</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}
onClick={submit}
>
{saving ? "Đang lưu..." : "Lưu thay đổi"}
</button>
</div>
</div>
</>
);
}

View File

@@ -1,32 +1,36 @@
"use client";
import { useState } from "react";
import { COLLECTIONS } from "@/lib/mock-data";
import { useEffect, useState, useTransition } from "react";
import { useCollections } from "@/lib/app-context";
import type { Dispatch } from "@/lib/app-state";
import type { Role } from "@/lib/types";
import { FieldLabel, IconBtn } from "@/components/ui-primitives";
import { Icons } from "@/components/icons";
import {
createInviteLink,
fetchPendingInvitations,
revokeInvitation,
revokeInviteLink,
sendEmailInvite,
type PendingInvitation,
} from "@/lib/db/actions";
import { copyToClipboard } from "@/lib/clipboard";
type RoleId = "viewer" | "editor";
type EmailRole = Extract<Role, "editor" | "viewer">;
function RolePicker({
value,
onChange,
}: {
value: RoleId;
onChange: (v: RoleId) => void;
value: EmailRole;
onChange: (v: EmailRole) => void;
}) {
const roles: { id: RoleId; label: string; desc: string }[] = [
const roles: { id: EmailRole; label: string; desc: string }[] = [
{ id: "viewer", label: "Chỉ xem", desc: "Chỉ xem được" },
{ id: "editor", label: "Sửa được", desc: "Thêm và sửa" },
];
return (
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 8,
}}
>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{roles.map((r) => (
<button
key={r.id}
@@ -61,30 +65,106 @@ export function InviteDialog({
onClose,
dispatch,
}: {
collectionId: string;
collectionId: number;
onClose: () => void;
dispatch: Dispatch;
}) {
const c = COLLECTIONS.find((x) => x.id === collectionId);
const c = useCollections().find((x) => x.id === collectionId);
const [tab, setTab] = useState<"link" | "email">("link");
const [role, setRole] = useState<RoleId>("editor");
const [email, setEmail] = useState("");
const [copied, setCopied] = useState(false);
const [pending, setPending] = useState<{ email: string; role: RoleId }[]>([
{ email: "bao.tran@gmail.com", role: "editor" },
]);
const [, startTransition] = useTransition();
const link = `places.app/invite/${c?.id || "demo"}-7k2x9`;
const copy = () => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
// Link tab
const [linkToken, setLinkToken] = useState<string | null>(null);
const [linkBusy, setLinkBusy] = useState(false);
const [linkCopied, setLinkCopied] = useState(false);
useEffect(() => {
if (!c || linkToken || linkBusy) return;
setLinkBusy(true);
startTransition(() => {
createInviteLink(c.id)
.then((r) => setLinkToken(r.token))
.catch(() => dispatch({ type: "TOAST", value: "Không tạo được link" }))
.finally(() => setLinkBusy(false));
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [c?.id]);
const link = linkToken
? `${typeof window !== "undefined" ? window.location.origin : ""}/invite/${linkToken}`
: "";
const handleCopyLink = async () => {
if (!link) return;
const ok = await copyToClipboard(link);
if (ok) {
setLinkCopied(true);
setTimeout(() => setLinkCopied(false), 1500);
} else {
dispatch({ type: "TOAST", value: "Không sao chép được" });
}
};
const sendInvite = () => {
if (!email.trim() || !email.includes("@")) return;
setPending([...pending, { email: email.trim(), role }]);
setEmail("");
dispatch({ type: "TOAST", value: "Đã gửi lời mời" });
const handleRotateLink = () => {
if (!c) return;
setLinkBusy(true);
startTransition(() => {
revokeInviteLink(c.id)
.then(() => createInviteLink(c.id))
.then((r) => {
setLinkToken(r.token);
dispatch({ type: "TOAST", value: "Đã tạo link mới" });
})
.catch(() => dispatch({ type: "TOAST", value: "Không tạo được link" }))
.finally(() => setLinkBusy(false));
});
};
// Email tab
const [email, setEmail] = useState("");
const [emailRole, setEmailRole] = useState<EmailRole>("editor");
const [emailBusy, setEmailBusy] = useState(false);
const [pending, setPending] = useState<PendingInvitation[]>([]);
const [pendingLoaded, setPendingLoaded] = useState(false);
useEffect(() => {
if (!c) return;
fetchPendingInvitations(c.id)
.then((list) => setPending(list))
.catch(() => setPending([]))
.finally(() => setPendingLoaded(true));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [c?.id]);
const handleSendEmail = () => {
if (!c || !email.includes("@") || emailBusy) return;
setEmailBusy(true);
startTransition(() => {
sendEmailInvite(c.id, email, emailRole)
.then((row) => {
setPending((prev) => {
const without = prev.filter((p) => p.email !== row.email);
return [row, ...without];
});
setEmail("");
dispatch({ type: "TOAST", value: "Đã gửi lời mời" });
})
.catch((e: Error) =>
dispatch({ type: "TOAST", value: e.message || "Gửi thất bại" }),
)
.finally(() => setEmailBusy(false));
});
};
const handleRevokeInvitation = (id: number) => {
const snapshot = pending;
setPending(snapshot.filter((p) => p.id !== id));
startTransition(() => {
revokeInvitation(id).catch(() => {
setPending(snapshot);
dispatch({ type: "TOAST", value: "Không xóa được lời mời" });
});
});
};
return (
@@ -100,13 +180,7 @@ export function InviteDialog({
}}
>
<div>
<div
style={{
fontSize: 18,
fontWeight: 700,
letterSpacing: "-0.01em",
}}
>
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: "-0.01em" }}>
Mời vào bộ sưu tập
</div>
<div
@@ -124,10 +198,7 @@ export function InviteDialog({
<div style={{ padding: "14px 20px 0" }}>
<div className="tabs">
<button
data-active={tab === "link"}
onClick={() => setTab("link")}
>
<button data-active={tab === "link"} onClick={() => setTab("link")}>
<Icons.Link
size={14}
stroke={2}
@@ -135,10 +206,7 @@ export function InviteDialog({
/>
Bằng link
</button>
<button
data-active={tab === "email"}
onClick={() => setTab("email")}
>
<button data-active={tab === "email"} onClick={() => setTab("email")}>
<Icons.Mail
size={14}
stroke={2}
@@ -160,18 +228,23 @@ export function InviteDialog({
stroke={1.75}
style={{ color: "var(--muted-foreground)" }}
/>
<input readOnly value={link} style={{ fontSize: 13 }} />
<input
readOnly
value={linkBusy ? "Đang tạo..." : link}
style={{ fontSize: 13 }}
/>
</div>
<button
onClick={copy}
className={copied ? "btn" : "btn btn--ghost"}
onClick={handleCopyLink}
disabled={!linkToken || linkBusy}
className={linkCopied ? "btn" : "btn btn--ghost"}
style={{
width: 88,
width: 96,
height: 48,
borderRadius: "var(--radius-md)",
}}
>
{copied ? (
{linkCopied ? (
<>
<Icons.Check size={16} stroke={2.5} />
Đã copy
@@ -184,8 +257,7 @@ export function InviteDialog({
)}
</button>
</div>
<FieldLabel>Vai trò</FieldLabel>
<RolePicker value={role} onChange={setRole} />
<div
style={{
marginTop: 14,
@@ -212,12 +284,12 @@ export function InviteDialog({
</div>
</div>
</div>
<button
className="btn btn--outline btn--block"
style={{ marginTop: 14, height: 44 }}
onClick={() =>
dispatch({ type: "TOAST", value: "Đã tạo link mới" })
}
onClick={handleRotateLink}
disabled={linkBusy}
>
Tạo link mới
</button>
@@ -236,33 +308,35 @@ export function InviteDialog({
onChange={(e) => setEmail(e.target.value)}
placeholder="ten@email.com"
type="email"
autoComplete="email"
disabled={emailBusy}
/>
</div>
<FieldLabel>Vai trò</FieldLabel>
<RolePicker value={role} onChange={setRole} />
<RolePicker value={emailRole} onChange={setEmailRole} />
<button
className="btn btn--block"
style={{ marginTop: 14, height: 44 }}
disabled={!email.includes("@")}
onClick={sendInvite}
disabled={!email.includes("@") || emailBusy}
onClick={handleSendEmail}
>
<Icons.Send size={16} stroke={2} />
Gửi lời mời
{emailBusy ? (
"Đang gửi..."
) : (
<>
<Icons.Send size={16} stroke={2} />
Gửi lời mời
</>
)}
</button>
{pending.length > 0 && (
{pendingLoaded && pending.length > 0 && (
<>
<FieldLabel>Lời mời đang chờ · {pending.length}</FieldLabel>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
{pending.map((p, i) => (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{pending.map((p) => (
<div
key={i}
key={p.id}
style={{
display: "flex",
alignItems: "center",
@@ -299,26 +373,19 @@ export function InviteDialog({
>
{p.email}
</div>
<div
style={{
fontSize: 11,
color: "var(--muted-foreground)",
textTransform: "capitalize",
}}
>
<div style={{ fontSize: 11, color: "var(--muted-foreground)" }}>
{p.role === "editor" ? "Sửa được" : "Chỉ xem"}
</div>
</div>
<button
onClick={() =>
setPending(pending.filter((_, j) => j !== i))
}
onClick={() => handleRevokeInvitation(p.id)}
style={{
background: "transparent",
border: 0,
color: "var(--muted-foreground)",
padding: 6,
}}
aria-label="Hủy lời mời"
>
<Icons.X size={16} stroke={2} />
</button>

View File

@@ -1,6 +1,6 @@
"use client";
import { COLLECTIONS, USERS } from "@/lib/mock-data";
import { useCollections, useMe, useUsers } from "@/lib/app-context";
import type { Dispatch } from "@/lib/app-state";
import { Avatar } from "@/components/avatar";
import { Icons } from "@/components/icons";
@@ -10,11 +10,13 @@ export function MembersSheet({
onClose,
dispatch,
}: {
collectionId: string;
collectionId: number;
onClose: () => void;
dispatch: Dispatch;
}) {
const c = COLLECTIONS.find((x) => x.id === collectionId);
const c = useCollections().find((x) => x.id === collectionId);
const users = useUsers();
const me = useMe();
if (!c) return null;
const owner = c.owner_id;
return (
@@ -80,9 +82,9 @@ export function MembersSheet({
</button>
)}
{c.members.map((id) => {
const u = USERS[id];
const u = users[id];
const role = id === owner ? "owner" : "editor";
const isMe = id === "u_me";
const isMe = id === me.id;
return (
<div
key={id}

View File

@@ -0,0 +1,140 @@
"use client";
import { useState, useTransition } from "react";
import { useCollections } from "@/lib/app-context";
import type { Dispatch } from "@/lib/app-state";
import { Icons } from "@/components/icons";
import {
addPlaceToCollection,
removePlaceFromCollection,
} from "@/lib/db/actions";
export function SaveToCollectionSheet({
placeId,
onClose,
dispatch,
}: {
placeId: number;
onClose: () => void;
dispatch: Dispatch;
}) {
const collections = useCollections();
// Initial membership state derived from server data.
const [membership, setMembership] = useState<Record<string, boolean>>(() =>
Object.fromEntries(collections.map((c) => [c.id, c.place_ids.includes(placeId)])),
);
const [, startTransition] = useTransition();
const editable = collections.filter((c) => c.my_role !== "viewer");
const toggle = (cid: number) => {
const wasIn = !!membership[cid];
setMembership({ ...membership, [cid]: !wasIn });
startTransition(() => {
const op = wasIn
? removePlaceFromCollection(cid, placeId)
: addPlaceToCollection(cid, placeId);
op.catch(() => {
setMembership({ ...membership, [cid]: wasIn });
dispatch({ type: "TOAST", value: "Lưu thất bại" });
});
});
};
return (
<>
<div className="overlay" onClick={onClose} />
<div className="sheet" style={{ maxHeight: "70%" }}>
<div className="sheet-handle" />
<div
style={{
padding: "6px 16px 12px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<button
onClick={onClose}
style={{
background: "transparent",
border: 0,
color: "var(--muted-foreground)",
fontSize: 15,
fontWeight: 500,
padding: "8px 0",
}}
>
Xong
</button>
<div style={{ fontSize: 16, fontWeight: 600 }}>Lưu vào bộ sưu tập</div>
<div style={{ width: 48 }} />
</div>
<div style={{ overflowY: "auto", padding: "0 16px 16px" }}>
{editable.length === 0 ? (
<div
style={{
padding: "32px 16px",
textAlign: "center",
color: "var(--muted-foreground)",
fontSize: 14,
}}
>
Bạn chưa bộ sưu tập nào quyền chỉnh sửa. Tạo bộ sưu tập trước từ tab "Bộ sưu tập".
</div>
) : (
editable.map((c) => {
const inIt = !!membership[c.id];
const Icon = c.type === "trip" ? Icons.Plane : Icons.Folder;
return (
<button
key={c.id}
onClick={() => toggle(c.id)}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 14,
padding: "12px 4px",
background: "transparent",
border: 0,
borderBottom: "0.5px solid var(--border)",
textAlign: "left",
}}
>
<div
style={{
width: 36,
height: 36,
borderRadius: 10,
background: c.type === "trip" ? "var(--primary-soft)" : "var(--muted)",
color: c.type === "trip" ? "var(--primary)" : "var(--muted-foreground)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<Icon size={18} stroke={1.75} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 500 }}>{c.name}</div>
<div style={{ fontSize: 12, color: "var(--muted-foreground)" }}>
{c.place_count} đa điểm
</div>
</div>
<div
className="checkbox"
data-checked={inIt}
style={{ pointerEvents: "none" }}
>
<Icons.Check size={14} stroke={3} />
</div>
</button>
);
})
)}
</div>
</div>
</>
);
}

View File

@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,13 +15,27 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}