diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bc3b177 --- /dev/null +++ b/.dockerignore @@ -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/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fc6df8e --- /dev/null +++ b/.env.example @@ -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= diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cea5883 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..1163a84 --- /dev/null +++ b/build.sh @@ -0,0 +1,5 @@ +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + -t renolation/places:latest \ + --push \ + . \ No newline at end of file diff --git a/db/seed-user.mjs b/db/seed-user.mjs new file mode 100644 index 0000000..27b81b5 --- /dev/null +++ b/db/seed-user.mjs @@ -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(); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c4e0762 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..7f59b59 --- /dev/null +++ b/drizzle.config.ts @@ -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; diff --git a/next.config.ts b/next.config.ts index 561d804..8ecd85d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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" }, diff --git a/package.json b/package.json index 9f7d6ec..640d9ef 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/public/.gitkeep b/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/app/(auth)/auth-actions.ts b/src/app/(auth)/auth-actions.ts new file mode 100644 index 0000000..3d75e8b --- /dev/null +++ b/src/app/(auth)/auth-actions.ts @@ -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 { + 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 { + 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 { + await logoutImpl(); + redirect("/login"); +} diff --git a/src/app/(auth)/auth-form.tsx b/src/app/(auth)/auth-form.tsx new file mode 100644 index 0000000..fc53815 --- /dev/null +++ b/src/app/(auth)/auth-form.tsx @@ -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; + +export function AuthForm({ + mode, + action, + next = "/", +}: { + mode: "login" | "register"; + action: Action; + next?: string; +}) { + const [state, formAction, pending] = useActionState( + 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 ( +
+ +
+

+ {title} +

+

+ {subtitle} +

+
+ + {!isLogin && ( + + + + + )} + + + + + + + + + + + + {state.error && ( +
+ {state.error} +
+ )} + + + +
+ {switchPrompt}{" "} + + {switchLabel} + +
+
+ ); +} + +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..b590111 --- /dev/null +++ b/src/app/(auth)/layout.tsx @@ -0,0 +1,78 @@ +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+
+
+ + + + +
+
+ Places +
+
+ {children} +

+ Lưu địa điểm. Cùng nhóm nhỏ. +

+
+
+ ); +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..c1c7832 --- /dev/null +++ b/src/app/(auth)/login/page.tsx @@ -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 ; +} diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx new file mode 100644 index 0000000..d4b47ae --- /dev/null +++ b/src/app/(auth)/register/page.tsx @@ -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 ; +} diff --git a/src/app/invite/[token]/page.tsx b/src/app/invite/[token]/page.tsx new file mode 100644 index 0000000..ea4a823 --- /dev/null +++ b/src/app/invite/[token]/page.tsx @@ -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 ( +
+
+

+ Lời mời không hợp lệ +

+

+ {(e as Error).message || "Liên kết đã hết hạn hoặc đã bị vô hiệu."} +

+ + Về trang chủ + +
+
+ ); + } + + redirect("/"); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index a58a906..79b321d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ; +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 ( + + ); } diff --git a/src/app/places-app.tsx b/src/app/places-app.tsx index a393040..64431ea 100644 --- a/src/app/places-app.tsx +++ b/src/app/places-app.tsx @@ -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 ( -
- {renderScreen(top.screen)} - dispatch({ type: "OPEN_ADD" })} - showFab={top.screen !== "profile"} - /> + +
+ {renderScreen(top.screen)} + dispatch({ type: "OPEN_ADD" })} + showFab={top.screen !== "profile"} + /> - {m?.type === "add" && ( - dispatch({ type: "CLOSE_MODAL" })} - dispatch={dispatch} - /> - )} - {m?.type === "invite" && ( - dispatch({ type: "CLOSE_MODAL" })} - dispatch={dispatch} - /> - )} - {m?.type === "members" && ( - dispatch({ type: "CLOSE_MODAL" })} - dispatch={dispatch} - /> - )} - {m?.type === "confirmDeletePlace" && placeForDelete && ( - - dispatch({ type: "DELETE_PLACE", placeId: m.placeId }) - } - onClose={() => dispatch({ type: "CLOSE_MODAL" })} - /> - )} - {m?.type === "confirmDeleteCollection" && collectionForDelete && ( - { - dispatch({ type: "CLOSE_MODAL" }); - dispatch({ type: "BACK" }); - dispatch({ type: "TOAST", value: "Đã xóa" }); - }} - onClose={() => dispatch({ type: "CLOSE_MODAL" })} - /> - )} + {m?.type === "add" && ( + dispatch({ type: "CLOSE_MODAL" })} + dispatch={dispatch} + /> + )} + {m?.type === "editPlace" && (() => { + const p = state.places.find((x) => x.id === m.placeId); + return p ? ( + dispatch({ type: "CLOSE_MODAL" })} + dispatch={dispatch} + /> + ) : null; + })()} + {m?.type === "saveToCollection" && ( + dispatch({ type: "CLOSE_MODAL" })} + dispatch={dispatch} + /> + )} + {m?.type === "createCollection" && ( + dispatch({ type: "CLOSE_MODAL" })} + dispatch={dispatch} + /> + )} + {m?.type === "editCollection" && (() => { + const c = data.collections.find((x) => x.id === m.collectionId); + return c ? ( + dispatch({ type: "CLOSE_MODAL" })} + dispatch={dispatch} + /> + ) : null; + })()} + {m?.type === "invite" && ( + dispatch({ type: "CLOSE_MODAL" })} + dispatch={dispatch} + /> + )} + {m?.type === "members" && ( + dispatch({ type: "CLOSE_MODAL" })} + dispatch={dispatch} + /> + )} + {m?.type === "confirmDeletePlace" && placeForDelete && ( + { + 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 && ( + { + 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 && ( -
- - {state.toast} -
- )} -
+ {state.toast && ( +
+ + {state.toast} +
+ )} +
+ ); } diff --git a/src/components/avatar.tsx b/src/components/avatar.tsx index 8e05db3..e00980d 100644 --- a/src/components/avatar.tsx +++ b/src/components/avatar.tsx @@ -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 ( {list.map((id) => ( - + ))} {more > 0 && ( ; + collections: Collection[]; +}; + +const AppDataContext = createContext(null); + +export function AppDataProvider({ + value, + children, +}: { + value: AppData; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useAppData(): AppData { + const v = useContext(AppDataContext); + if (!v) throw new Error("useAppData must be used inside "); + return v; +} + +export function useUsers(): Record { + return useAppData().users; +} + +export function useCollections(): Collection[] { + return useAppData().collections; +} + +export function useMe(): User { + return useAppData().me; +} diff --git a/src/lib/app-state.ts b/src/lib/app-state.ts index a55339f..74f1525 100644 --- a/src/lib/app-state.ts +++ b/src/lib/app-state.ts @@ -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": diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts new file mode 100644 index 0000000..0dfdb4e --- /dev/null +++ b/src/lib/clipboard.ts @@ -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 { + 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; + } +} diff --git a/src/lib/db/actions.ts b/src/lib/db/actions.ts new file mode 100644 index 0000000..fbae0d6 --- /dev/null +++ b/src/lib/db/actions.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const uid = await requireUserId(); + await assertCanWriteCollection(collectionId, uid); + await assertCanAccessPlace(placeId, uid); + const [next] = await db + .select({ max: sql`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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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("/"); +} + diff --git a/src/lib/db/auth.ts b/src/lib/db/auth.ts new file mode 100644 index 0000000..efce400 --- /dev/null +++ b/src/lib/db/auth.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const id = await getCurrentUserId(); + if (!id) throw new Error("not authenticated"); + return id; +} diff --git a/src/lib/db/client.ts b/src/lib/db/client.ts new file mode 100644 index 0000000..7ef0d03 --- /dev/null +++ b/src/lib/db/client.ts @@ -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 | 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 = + globalThis.__drizzle ?? drizzle(pool, { schema }); + +if (process.env.NODE_ENV !== "production") { + globalThis.__pg_pool = pool; + globalThis.__drizzle = db; +} diff --git a/src/lib/db/invites.ts b/src/lib/db/invites.ts new file mode 100644 index 0000000..a369204 --- /dev/null +++ b/src/lib/db/invites.ts @@ -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), + ), + ); +} diff --git a/src/lib/db/queries.ts b/src/lib/db/queries.ts new file mode 100644 index 0000000..4737e15 --- /dev/null +++ b/src/lib/db/queries.ts @@ -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 { + 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 { + 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 { + 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(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> { + 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 { + 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, + }; +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts new file mode 100644 index 0000000..ce1a99d --- /dev/null +++ b/src/lib/db/schema.ts @@ -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), + ], +); diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..9333cd0 --- /dev/null +++ b/src/lib/email.ts @@ -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 { + 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 = ` + +

Bạn vừa được mời

+

+ ${escapeHtml(args.inviterName)} mời bạn tham gia bộ sưu tập + "${escapeHtml(args.collectionName)}" trên Places với quyền ${roleLabel}. +

+ + Chấp nhận lời mời + +

+ 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. +

+`.trim(); + return { to: "", subject, html, text }; +} + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => + c === "&" + ? "&" + : c === "<" + ? "<" + : c === ">" + ? ">" + : c === '"' + ? """ + : "'", + ); +} diff --git a/src/lib/maps.ts b/src/lib/maps.ts new file mode 100644 index 0000000..0c9e14b --- /dev/null +++ b/src/lib/maps.ts @@ -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"); +} diff --git a/src/lib/mock-data.ts b/src/lib/mock-data.ts deleted file mode 100644 index b290ef7..0000000 --- a/src/lib/mock-data.ts +++ /dev/null @@ -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 = { - 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 = { - 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" }, -]; diff --git a/src/lib/nominatim.ts b/src/lib/nominatim.ts new file mode 100644 index 0000000..f1b1ca0 --- /dev/null +++ b/src/lib/nominatim.ts @@ -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; +}; + +const BASE = "https://nominatim.openstreetmap.org"; +const HEADERS: HeadersInit = { "Accept-Language": "vi" }; + +export async function searchAddress( + query: string, + signal?: AbortSignal, +): Promise { + 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 { + 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), + }; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 11901c2..cd4dff3 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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 = { diff --git a/src/lib/ui-config.ts b/src/lib/ui-config.ts new file mode 100644 index 0000000..1b660d2 --- /dev/null +++ b/src/lib/ui-config.ts @@ -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 = { + 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" }, +]; diff --git a/src/screens/collection-detail-screen.tsx b/src/screens/collection-detail-screen.tsx index d7a7363..a1136fa 100644 --- a/src/screens/collection-detail-screen.tsx +++ b/src/screens/collection-detail-screen.tsx @@ -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={ - 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({
- {!isViewer && ( + {c.my_role === "owner" && ( { setMenuOpen(false); - dispatch({ type: "TOAST", value: "Sửa (demo)" }); + dispatch({ type: "OPEN_EDIT_COLLECTION", collectionId: c.id }); }} /> )} @@ -339,9 +349,14 @@ export function CollectionDetailScreen({ { + 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" && ( diff --git a/src/screens/collections-list-screen.tsx b/src/screens/collections-list-screen.tsx index 8e31cb1..24c02a7 100644 --- a/src/screens/collections-list-screen.tsx +++ b/src/screens/collections-list-screen.tsx @@ -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({
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={ - 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({ 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 => Boolean(p)); const isTrip = c.type === "trip"; return ( diff --git a/src/screens/place-detail-screen.tsx b/src/screens/place-detail-screen.tsx index f48c419..0f19adb 100644 --- a/src/screens/place-detail-screen.tsx +++ b/src/screens/place-detail-screen.tsx @@ -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 (
@@ -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", + }); + }} /> - 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({
- 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" }); + }); + }); + }} />
@@ -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" }), + ); + }); + }} />
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 }} />
@@ -419,34 +446,41 @@ export function PlaceDetailScreen({
- { - setMenuOpen(false); - dispatch({ type: "TOAST", value: "Tính năng sửa (demo)" }); - }} - /> + {place.created_by === me.id && ( + { + setMenuOpen(false); + dispatch({ type: "OPEN_EDIT_PLACE", placeId: place.id }); + }} + /> + )} { setMenuOpen(false); dispatch({ - type: "TOAST", - value: 'Đã thêm vào "Hà Nội phải đi"', + type: "OPEN_SAVE_TO_COLLECTION", + placeId: place.id, }); }} /> { + 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 && ( 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}
- {ME.name} + {me.name}
- {ME.email} + {me.email}
@@ -124,7 +129,14 @@ export function ProfileScreen({ state }: { state: AppState; dispatch: Dispatch } - + startSignOut(() => logoutAction())} + disabled={signingOut} + />
void; + disabled?: boolean; }) { const I = Icons[icon]; return ( <>
- {/* Cover upload */} - - - ) : ( - <> - - Thêm ảnh - - )} - - - {/* Name */} Tên địa điểm
- {/* Address */} Địa chỉ
{ setAddress(e.target.value); + setPicked(null); setShowSugg(true); }} onFocus={() => setShowSugg(true)} placeholder="Số nhà, đường, quận, tỉnh" />
- {showSugg && address && ( + {showSugg && address.length >= 3 && !picked && (
+ {searching && suggestions.length === 0 && ( +
+ Đang tìm... +
+ )} + {!searching && suggestions.length === 0 && ( +
+ Không có gợi ý +
+ )} {suggestions.slice(0, 4).map((s, i) => ( @@ -290,7 +297,6 @@ export function AddPlaceSheet({
)} - {/* Category */} Danh mục
{(Object.entries(CATEGORIES) as [CategoryId, typeof CATEGORIES[CategoryId]][]).map( @@ -310,12 +316,9 @@ export function AddPlaceSheet({ )}
- {/* Tags */} Thẻ{" "} - + · {tags.length}/10 @@ -376,7 +379,6 @@ export function AddPlaceSheet({ />
- {/* Notes */} Ghi chú riêng tư @@ -390,12 +392,9 @@ export function AddPlaceSheet({ />
- {/* Rating */} Đánh giá{" "} - + · tuỳ chọn @@ -411,7 +410,6 @@ export function AddPlaceSheet({
- {/* Footer */}
void; + dispatch: Dispatch; +}) { + const [name, setName] = useState(collection?.name ?? ""); + const [type, setType] = useState(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 ( + <> +
+
+
+
+ +
+ {mode === "create" ? "Tạo bộ sưu tập" : "Sửa bộ sưu tập"} +
+
+
+ +
+ Tên +
+ setName(e.target.value)} + placeholder="VD: Sài Gòn tháng 6" + autoFocus + /> +
+ + Loại +
+ {([ + { 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 ( + + ); + })} +
+ + {type === "trip" && ( + <> + Bắt đầu +
+ + setStart(e.target.value)} + /> +
+ Kết thúc +
+ + setEnd(e.target.value)} + /> +
+ + )} +
+ +
+ +
+
+ + ); +} diff --git a/src/sheets/edit-place-sheet.tsx b/src/sheets/edit-place-sheet.tsx new file mode 100644 index 0000000..7dcdfc6 --- /dev/null +++ b/src/sheets/edit-place-sheet.tsx @@ -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(place.category); + const [tags, setTags] = useState(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 ( + <> +
+
+
+
+ +
Sửa địa điểm
+
+
+ +
+ Tên địa điểm +
+ setName(e.target.value)} /> +
+ + Địa chỉ +
+ + setAddress(e.target.value)} /> +
+ + Danh mục +
+ {(Object.entries(CATEGORIES) as [CategoryId, typeof CATEGORIES[CategoryId]][]).map( + ([k, c]) => { + const I = Icons[c.icon as keyof typeof Icons]; + return ( + + ); + }, + )} +
+ + + Thẻ · {tags.length}/10 + +
+ {tags.map((t) => ( + + {t} + + + ))} + { + 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 }} + /> +
+ +
+
+ +
+ +
+
+ + ); +} diff --git a/src/sheets/invite-dialog.tsx b/src/sheets/invite-dialog.tsx index 1aa7cc3..14a78de 100644 --- a/src/sheets/invite-dialog.tsx +++ b/src/sheets/invite-dialog.tsx @@ -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; 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 ( -
+
{roles.map((r) => ( -
- Vai trò - +
+ @@ -236,33 +308,35 @@ export function InviteDialog({ onChange={(e) => setEmail(e.target.value)} placeholder="ten@email.com" type="email" + autoComplete="email" + disabled={emailBusy} />
Vai trò - + - {pending.length > 0 && ( + {pendingLoaded && pending.length > 0 && ( <> Lời mời đang chờ · {pending.length} -
- {pending.map((p, i) => ( +
+ {pending.map((p) => (
{p.email}
-
+
{p.role === "editor" ? "Sửa được" : "Chỉ xem"}
diff --git a/src/sheets/members-sheet.tsx b/src/sheets/members-sheet.tsx index b093802..6029b52 100644 --- a/src/sheets/members-sheet.tsx +++ b/src/sheets/members-sheet.tsx @@ -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({ )} {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 (
void; + dispatch: Dispatch; +}) { + const collections = useCollections(); + // Initial membership state derived from server data. + const [membership, setMembership] = useState>(() => + 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 ( + <> +
+
+
+
+ +
Lưu vào bộ sưu tập
+
+
+
+ {editable.length === 0 ? ( +
+ Bạn chưa có bộ sưu tập nào có quyền chỉnh sửa. Tạo bộ sưu tập trước từ tab "Bộ sưu tập". +
+ ) : ( + editable.map((c) => { + const inIt = !!membership[c.id]; + const Icon = c.type === "trip" ? Icons.Plane : Icons.Folder; + return ( + + ); + }) + )} +
+
+ + ); +} diff --git a/tsconfig.json b/tsconfig.json index 6978b6c..7bbb10f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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" + ] }