aaa
This commit is contained in:
21
.dockerignore
Normal file
21
.dockerignore
Normal file
@@ -0,0 +1,21 @@
|
||||
**/node_modules
|
||||
**/.next
|
||||
**/.git
|
||||
**/.env
|
||||
**/.env.*
|
||||
!.env.example
|
||||
**/dist
|
||||
**/build
|
||||
**/.DS_Store
|
||||
**/.vscode
|
||||
**/.idea
|
||||
**/coverage
|
||||
**/.turbo
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
docker-compose.*.yml
|
||||
.dockerignore
|
||||
*.log
|
||||
README.md
|
||||
plans/
|
||||
db/migrations/
|
||||
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# Postgres
|
||||
PGHOST=
|
||||
PGUSER=
|
||||
PGPASSWORD=
|
||||
PGDATABASE=
|
||||
PGPORT=5432
|
||||
|
||||
# Email (optional — without these, sendEmailInvite logs to console and the
|
||||
# invitation row still gets persisted so the owner can copy the URL).
|
||||
# Resend.com — RESEND_API_KEY is the secret API key; INVITE_FROM_EMAIL must be
|
||||
# a verified sender domain in your Resend account.
|
||||
RESEND_API_KEY=
|
||||
INVITE_FROM_EMAIL=
|
||||
|
||||
# Optional override for the origin used in invite URLs. Falls back to the
|
||||
# request's host/protocol if unset. Server-only — never sent to the browser.
|
||||
APP_URL=
|
||||
46
Dockerfile
Normal file
46
Dockerfile
Normal file
@@ -0,0 +1,46 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# ─── Stage 1: install deps (cacheable) ──────────────────────────────────────
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
# libc6-compat helps some native deps (bcryptjs is pure JS so not strictly
|
||||
# needed, but cheap insurance for pg / future native deps).
|
||||
RUN apk add --no-cache libc6-compat
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# ─── Stage 2: build ──────────────────────────────────────────────────────────
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# next.config.ts has `output: "standalone"`, which writes a minimal Node
|
||||
# server bundle to .next/standalone — used by the runtime stage below.
|
||||
RUN npm run build
|
||||
|
||||
# ─── Stage 3: runtime ────────────────────────────────────────────────────────
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
HOSTNAME=0.0.0.0 \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Non-root user for the app process.
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 --ingroup nodejs nextjs
|
||||
|
||||
# Standalone output bundles only the files needed to run the server, plus a
|
||||
# trimmed node_modules. Public assets and .next/static are still external.
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
|
||||
# server.js is generated by Next's standalone output; it reads PORT/HOSTNAME
|
||||
# from the environment at startup, so docker compose can inject them.
|
||||
CMD ["node", "server.js"]
|
||||
5
build.sh
Normal file
5
build.sh
Normal file
@@ -0,0 +1,5 @@
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t renolation/places:latest \
|
||||
--push \
|
||||
.
|
||||
48
db/seed-user.mjs
Normal file
48
db/seed-user.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
// Seed a single user (no places/collections). Use after `npm run db:push`.
|
||||
// Defaults to vodanh.2901@gmail.com / renolation. Pass args to override.
|
||||
//
|
||||
// node --env-file=.env.local db/seed-user.mjs
|
||||
// node --env-file=.env.local db/seed-user.mjs other@email.com pw123456 "Other Name"
|
||||
|
||||
import bcrypt from "bcryptjs";
|
||||
import pg from "pg";
|
||||
|
||||
const EMAIL = process.argv[2] || "vodanh.2901@gmail.com";
|
||||
const PASSWORD = process.argv[3] || "renolation";
|
||||
const NAME = process.argv[4] || "Vô Danh";
|
||||
|
||||
function deriveInitials(name) {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
return parts[0].slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
const conn = process.env.DATABASE_URL
|
||||
? { connectionString: process.env.DATABASE_URL }
|
||||
: {
|
||||
host: process.env.PGHOST,
|
||||
user: process.env.PGUSER,
|
||||
password: process.env.PGPASSWORD,
|
||||
database: process.env.PGDATABASE,
|
||||
};
|
||||
|
||||
const client = new pg.Client(conn);
|
||||
await client.connect();
|
||||
|
||||
const hash = await bcrypt.hash(PASSWORD, 10);
|
||||
const initials = deriveInitials(NAME);
|
||||
|
||||
const res = await client.query(
|
||||
`INSERT INTO users (email, password_hash, name, initials)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash
|
||||
RETURNING id`,
|
||||
[EMAIL, hash, NAME, initials],
|
||||
);
|
||||
|
||||
console.log(`[user] ${EMAIL} → id=${res.rows[0].id}`);
|
||||
console.log(`[login] ${EMAIL} / ${PASSWORD}`);
|
||||
|
||||
await client.end();
|
||||
49
docker-compose.yml
Normal file
49
docker-compose.yml
Normal file
@@ -0,0 +1,49 @@
|
||||
# Run the prod-built app in a container, pulling env vars from .env.local
|
||||
# (if present) or from the shell. No build-time secrets — every env value is
|
||||
# read at container start by Next's standalone server.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose up --build
|
||||
# APP_PORT=8080 docker compose up (override host port)
|
||||
#
|
||||
# Compose v2.24+ (required for `env_file: path/required` syntax).
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: places:latest
|
||||
container_name: places
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${APP_PORT:-3000}:3000"
|
||||
|
||||
# Env vars are loaded from .env.local at container start.
|
||||
# `required: false` lets compose run even if the file is missing — useful
|
||||
# when vars are injected by an external system (CI, K8s secrets, etc).
|
||||
env_file:
|
||||
- path: .env.local
|
||||
required: false
|
||||
|
||||
# Explicit values that always apply (these win over .env.local).
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3000"
|
||||
HOSTNAME: "0.0.0.0"
|
||||
PGHOST: 103.188.82.191
|
||||
PGUSER: renolation
|
||||
PGPASSWORD: renolation
|
||||
PGDATABASE: places_db
|
||||
PGPORT: "5432"
|
||||
|
||||
healthcheck:
|
||||
test:
|
||||
- "CMD"
|
||||
- "node"
|
||||
- "-e"
|
||||
- "fetch('http://localhost:3000/login').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
15
drizzle.config.ts
Normal file
15
drizzle.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Config } from "drizzle-kit";
|
||||
|
||||
export default {
|
||||
schema: "./src/lib/db/schema.ts",
|
||||
out: "./db/migrations",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
host: process.env.PGHOST ?? "",
|
||||
user: process.env.PGUSER ?? "",
|
||||
password: process.env.PGPASSWORD ?? "",
|
||||
database: process.env.PGDATABASE ?? "",
|
||||
port: process.env.PGPORT ? Number(process.env.PGPORT) : 5432,
|
||||
ssl: false,
|
||||
},
|
||||
} satisfies Config;
|
||||
@@ -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" },
|
||||
|
||||
18
package.json
18
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"
|
||||
|
||||
0
public/.gitkeep
Normal file
0
public/.gitkeep
Normal file
43
src/app/(auth)/auth-actions.ts
Normal file
43
src/app/(auth)/auth-actions.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { loginUser, logout as logoutImpl, registerUser } from "@/lib/db/auth";
|
||||
|
||||
export type FormState = { error?: string };
|
||||
|
||||
function safeNext(value: unknown): string {
|
||||
if (typeof value !== "string") return "/";
|
||||
// Only allow same-origin relative paths to avoid open-redirect.
|
||||
if (!value.startsWith("/") || value.startsWith("//")) return "/";
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function loginAction(
|
||||
_prev: FormState,
|
||||
data: FormData,
|
||||
): Promise<FormState> {
|
||||
const email = String(data.get("email") || "");
|
||||
const password = String(data.get("password") || "");
|
||||
const next = safeNext(data.get("next"));
|
||||
const res = await loginUser(email, password);
|
||||
if (!res.ok) return { error: res.error };
|
||||
redirect(next);
|
||||
}
|
||||
|
||||
export async function registerAction(
|
||||
_prev: FormState,
|
||||
data: FormData,
|
||||
): Promise<FormState> {
|
||||
const email = String(data.get("email") || "");
|
||||
const password = String(data.get("password") || "");
|
||||
const name = String(data.get("name") || "");
|
||||
const next = safeNext(data.get("next"));
|
||||
const res = await registerUser(email, password, name);
|
||||
if (!res.ok) return { error: res.error };
|
||||
redirect(next);
|
||||
}
|
||||
|
||||
export async function logoutAction(): Promise<void> {
|
||||
await logoutImpl();
|
||||
redirect("/login");
|
||||
}
|
||||
185
src/app/(auth)/auth-form.tsx
Normal file
185
src/app/(auth)/auth-form.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Icons } from "@/components/icons";
|
||||
import type { FormState } from "./auth-actions";
|
||||
|
||||
type Action = (prev: FormState, data: FormData) => Promise<FormState>;
|
||||
|
||||
export function AuthForm({
|
||||
mode,
|
||||
action,
|
||||
next = "/",
|
||||
}: {
|
||||
mode: "login" | "register";
|
||||
action: Action;
|
||||
next?: string;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState<FormState, FormData>(
|
||||
action,
|
||||
{},
|
||||
);
|
||||
|
||||
const isLogin = mode === "login";
|
||||
const title = isLogin ? "Đăng nhập" : "Tạo tài khoản";
|
||||
const subtitle = isLogin
|
||||
? "Tiếp tục với địa điểm đã lưu của bạn"
|
||||
: "Bắt đầu lưu địa điểm cùng nhóm nhỏ";
|
||||
const cta = isLogin ? "Đăng nhập" : "Đăng ký";
|
||||
const switchPrompt = isLogin ? "Chưa có tài khoản?" : "Đã có tài khoản?";
|
||||
const switchBase = isLogin ? "/register" : "/login";
|
||||
const switchHref =
|
||||
next && next !== "/"
|
||||
? `${switchBase}?next=${encodeURIComponent(next)}`
|
||||
: switchBase;
|
||||
const switchLabel = isLogin ? "Đăng ký" : "Đăng nhập";
|
||||
|
||||
return (
|
||||
<form
|
||||
action={formAction}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 14,
|
||||
padding: 24,
|
||||
background: "var(--card)",
|
||||
border: "0.5px solid var(--border)",
|
||||
borderRadius: "var(--radius-xl)",
|
||||
boxShadow: "var(--shadow-md)",
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="next" value={next} />
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<h1
|
||||
className="display"
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "-0.02em",
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
margin: "4px 0 0",
|
||||
fontSize: 14,
|
||||
color: "var(--muted-foreground)",
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isLogin && (
|
||||
<Field label="Tên hiển thị">
|
||||
<Icons.User size={18} stroke={1.75} style={{ color: "var(--muted-foreground)" }} />
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="name"
|
||||
placeholder="Tên của bạn"
|
||||
disabled={pending}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Email">
|
||||
<Icons.Mail size={18} stroke={1.75} style={{ color: "var(--muted-foreground)" }} />
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
placeholder="ten@email.com"
|
||||
disabled={pending}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Mật khẩu">
|
||||
<Icons.Lock size={18} stroke={1.75} style={{ color: "var(--muted-foreground)" }} />
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete={isLogin ? "current-password" : "new-password"}
|
||||
placeholder={isLogin ? "••••••••" : "Tối thiểu 8 ký tự"}
|
||||
disabled={pending}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{state.error && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
background: "var(--danger-soft)",
|
||||
color: "var(--danger)",
|
||||
borderRadius: "var(--radius-md)",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn--block btn--lg"
|
||||
disabled={pending}
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
{pending ? "Đang xử lý..." : cta}
|
||||
</button>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--muted-foreground)",
|
||||
textAlign: "center",
|
||||
paddingTop: 4,
|
||||
}}
|
||||
>
|
||||
{switchPrompt}{" "}
|
||||
<Link
|
||||
href={switchHref}
|
||||
style={{ color: "var(--primary)", fontWeight: 600, textDecoration: "none" }}
|
||||
>
|
||||
{switchLabel}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--muted-foreground)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.04em",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="input">{children}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
78
src/app/(auth)/layout.tsx
Normal file
78
src/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
minHeight: "100dvh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: "32px 20px",
|
||||
background: "var(--background)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 420,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 24,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
background:
|
||||
"linear-gradient(135deg, var(--primary), color-mix(in oklch, var(--primary) 60%, oklch(60% 0.12 320)))",
|
||||
color: "var(--primary-foreground)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
className="display"
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "-0.015em",
|
||||
}}
|
||||
>
|
||||
Places
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
<p
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: 12,
|
||||
color: "var(--subtle-foreground)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Lưu địa điểm. Cùng nhóm nhỏ.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
22
src/app/(auth)/login/page.tsx
Normal file
22
src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getCurrentUserId } from "@/lib/db/auth";
|
||||
import { AuthForm } from "../auth-form";
|
||||
import { loginAction } from "../auth-actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function safeNext(v: string | undefined): string {
|
||||
if (!v || !v.startsWith("/") || v.startsWith("//")) return "/";
|
||||
return v;
|
||||
}
|
||||
|
||||
export default async function LoginPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ next?: string }>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const next = safeNext(sp.next);
|
||||
if (await getCurrentUserId()) redirect(next);
|
||||
return <AuthForm mode="login" action={loginAction} next={next} />;
|
||||
}
|
||||
22
src/app/(auth)/register/page.tsx
Normal file
22
src/app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getCurrentUserId } from "@/lib/db/auth";
|
||||
import { AuthForm } from "../auth-form";
|
||||
import { registerAction } from "../auth-actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function safeNext(v: string | undefined): string {
|
||||
if (!v || !v.startsWith("/") || v.startsWith("//")) return "/";
|
||||
return v;
|
||||
}
|
||||
|
||||
export default async function RegisterPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ next?: string }>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const next = safeNext(sp.next);
|
||||
if (await getCurrentUserId()) redirect(next);
|
||||
return <AuthForm mode="register" action={registerAction} next={next} />;
|
||||
}
|
||||
77
src/app/invite/[token]/page.tsx
Normal file
77
src/app/invite/[token]/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { acceptInviteCore } from "@/lib/db/invites";
|
||||
import { getCurrentUserId } from "@/lib/db/auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AcceptInvitePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ token: string }>;
|
||||
}) {
|
||||
const { token } = await params;
|
||||
const uid = await getCurrentUserId();
|
||||
if (!uid) {
|
||||
redirect(`/login?next=${encodeURIComponent(`/invite/${token}`)}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await acceptInviteCore(token, uid);
|
||||
} catch (e) {
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
minHeight: "100dvh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: "32px 20px",
|
||||
background: "var(--background)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 380,
|
||||
width: "100%",
|
||||
padding: 24,
|
||||
background: "var(--card)",
|
||||
border: "0.5px solid var(--border)",
|
||||
borderRadius: "var(--radius-xl)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
Lời mời không hợp lệ
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 8,
|
||||
fontSize: 14,
|
||||
color: "var(--muted-foreground)",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{(e as Error).message || "Liên kết đã hết hạn hoặc đã bị vô hiệu."}
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
className="btn btn--block"
|
||||
style={{ marginTop: 16, textDecoration: "none" }}
|
||||
>
|
||||
Về trang chủ
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
redirect("/");
|
||||
}
|
||||
@@ -1,5 +1,31 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { PlacesApp } from "./places-app";
|
||||
import { getCurrentUserId } from "@/lib/db/auth";
|
||||
import {
|
||||
getAllUsers,
|
||||
getCollectionsForUser,
|
||||
getCurrentUser,
|
||||
getPlacesForUser,
|
||||
} from "@/lib/db/queries";
|
||||
|
||||
export default function Page() {
|
||||
return <PlacesApp />;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Page() {
|
||||
const uid = await getCurrentUserId();
|
||||
if (!uid) redirect("/login");
|
||||
|
||||
const [me, users, places, collections] = await Promise.all([
|
||||
getCurrentUser(),
|
||||
getAllUsers(),
|
||||
getPlacesForUser(uid),
|
||||
getCollectionsForUser(uid),
|
||||
]);
|
||||
if (!me) redirect("/login");
|
||||
|
||||
return (
|
||||
<PlacesApp
|
||||
initialPlaces={places}
|
||||
data={{ me, users, collections }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useReducer } from "react";
|
||||
import { COLLECTIONS } from "@/lib/mock-data";
|
||||
import { useEffect, useReducer, useTransition } from "react";
|
||||
import {
|
||||
INITIAL_STATE,
|
||||
makeInitialState,
|
||||
reducer,
|
||||
type Screen,
|
||||
type Tab,
|
||||
} from "@/lib/app-state";
|
||||
import { AppDataProvider, type AppData } from "@/lib/app-context";
|
||||
import type { Place } from "@/lib/types";
|
||||
import { deleteCollection, deletePlace } from "@/lib/db/actions";
|
||||
import { TabBar } from "@/components/ui-primitives";
|
||||
import { Icons } from "@/components/icons";
|
||||
import { PlacesListScreen } from "@/screens/places-list-screen";
|
||||
@@ -16,11 +18,21 @@ import { CollectionsListScreen } from "@/screens/collections-list-screen";
|
||||
import { CollectionDetailScreen } from "@/screens/collection-detail-screen";
|
||||
import { ProfileScreen } from "@/screens/profile-screen";
|
||||
import { AddPlaceSheet } from "@/sheets/add-place-sheet";
|
||||
import { EditPlaceSheet } from "@/sheets/edit-place-sheet";
|
||||
import { SaveToCollectionSheet } from "@/sheets/save-to-collection-sheet";
|
||||
import { CollectionFormSheet } from "@/sheets/collection-form-sheet";
|
||||
import { InviteDialog } from "@/sheets/invite-dialog";
|
||||
import { MembersSheet, ConfirmDialog } from "@/sheets/members-sheet";
|
||||
|
||||
export function PlacesApp() {
|
||||
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
|
||||
export function PlacesApp({
|
||||
initialPlaces,
|
||||
data,
|
||||
}: {
|
||||
initialPlaces: Place[];
|
||||
data: AppData;
|
||||
}) {
|
||||
const [state, dispatch] = useReducer(reducer, initialPlaces, makeInitialState);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.toast) return;
|
||||
@@ -32,7 +44,6 @@ export function PlacesApp() {
|
||||
return () => clearTimeout(id);
|
||||
}, [state.toast, state.toastKey]);
|
||||
|
||||
// Online/offline detection
|
||||
useEffect(() => {
|
||||
const sync = () =>
|
||||
dispatch({ type: "SET_OFFLINE", value: !navigator.onLine });
|
||||
@@ -88,70 +99,118 @@ export function PlacesApp() {
|
||||
: null;
|
||||
const collectionForDelete =
|
||||
m?.type === "confirmDeleteCollection"
|
||||
? COLLECTIONS.find((c) => c.id === m.collectionId)
|
||||
? data.collections.find((c) => c.id === m.collectionId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="app-frame">
|
||||
{renderScreen(top.screen)}
|
||||
<TabBar
|
||||
active={activeTab}
|
||||
onTab={onTab}
|
||||
onFab={() => dispatch({ type: "OPEN_ADD" })}
|
||||
showFab={top.screen !== "profile"}
|
||||
/>
|
||||
<AppDataProvider value={data}>
|
||||
<div className="app-frame">
|
||||
{renderScreen(top.screen)}
|
||||
<TabBar
|
||||
active={activeTab}
|
||||
onTab={onTab}
|
||||
onFab={() => dispatch({ type: "OPEN_ADD" })}
|
||||
showFab={top.screen !== "profile"}
|
||||
/>
|
||||
|
||||
{m?.type === "add" && (
|
||||
<AddPlaceSheet
|
||||
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
)}
|
||||
{m?.type === "invite" && (
|
||||
<InviteDialog
|
||||
collectionId={m.collectionId}
|
||||
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
)}
|
||||
{m?.type === "members" && (
|
||||
<MembersSheet
|
||||
collectionId={m.collectionId}
|
||||
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
)}
|
||||
{m?.type === "confirmDeletePlace" && placeForDelete && (
|
||||
<ConfirmDialog
|
||||
title="Xóa địa điểm?"
|
||||
body={`"${placeForDelete.name}" sẽ bị xóa khỏi tất cả bộ sưu tập. Không thể hoàn tác.`}
|
||||
confirmLabel="Xóa"
|
||||
onConfirm={() =>
|
||||
dispatch({ type: "DELETE_PLACE", placeId: m.placeId })
|
||||
}
|
||||
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
|
||||
/>
|
||||
)}
|
||||
{m?.type === "confirmDeleteCollection" && collectionForDelete && (
|
||||
<ConfirmDialog
|
||||
title="Xóa bộ sưu tập?"
|
||||
body={`"${collectionForDelete.name}" sẽ bị xóa. Các địa điểm bên trong vẫn còn ở "Địa điểm".`}
|
||||
confirmLabel="Xóa"
|
||||
onConfirm={() => {
|
||||
dispatch({ type: "CLOSE_MODAL" });
|
||||
dispatch({ type: "BACK" });
|
||||
dispatch({ type: "TOAST", value: "Đã xóa" });
|
||||
}}
|
||||
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
|
||||
/>
|
||||
)}
|
||||
{m?.type === "add" && (
|
||||
<AddPlaceSheet
|
||||
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
)}
|
||||
{m?.type === "editPlace" && (() => {
|
||||
const p = state.places.find((x) => x.id === m.placeId);
|
||||
return p ? (
|
||||
<EditPlaceSheet
|
||||
place={p}
|
||||
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
{m?.type === "saveToCollection" && (
|
||||
<SaveToCollectionSheet
|
||||
placeId={m.placeId}
|
||||
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
)}
|
||||
{m?.type === "createCollection" && (
|
||||
<CollectionFormSheet
|
||||
mode="create"
|
||||
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
)}
|
||||
{m?.type === "editCollection" && (() => {
|
||||
const c = data.collections.find((x) => x.id === m.collectionId);
|
||||
return c ? (
|
||||
<CollectionFormSheet
|
||||
mode="edit"
|
||||
collection={c}
|
||||
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
{m?.type === "invite" && (
|
||||
<InviteDialog
|
||||
collectionId={m.collectionId}
|
||||
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
)}
|
||||
{m?.type === "members" && (
|
||||
<MembersSheet
|
||||
collectionId={m.collectionId}
|
||||
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
)}
|
||||
{m?.type === "confirmDeletePlace" && placeForDelete && (
|
||||
<ConfirmDialog
|
||||
title="Xóa địa điểm?"
|
||||
body={`"${placeForDelete.name}" sẽ bị xóa khỏi tất cả bộ sưu tập. Không thể hoàn tác.`}
|
||||
confirmLabel="Xóa"
|
||||
onConfirm={() => {
|
||||
const id = m.placeId;
|
||||
dispatch({ type: "DELETE_PLACE", placeId: id });
|
||||
startTransition(() => {
|
||||
deletePlace(id).catch(() =>
|
||||
dispatch({ type: "TOAST", value: "Xóa thất bại" }),
|
||||
);
|
||||
});
|
||||
}}
|
||||
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
|
||||
/>
|
||||
)}
|
||||
{m?.type === "confirmDeleteCollection" && collectionForDelete && (
|
||||
<ConfirmDialog
|
||||
title="Xóa bộ sưu tập?"
|
||||
body={`"${collectionForDelete.name}" sẽ bị xóa. Các địa điểm bên trong vẫn còn ở "Địa điểm".`}
|
||||
confirmLabel="Xóa"
|
||||
onConfirm={() => {
|
||||
const id = m.collectionId;
|
||||
dispatch({ type: "CLOSE_MODAL" });
|
||||
dispatch({ type: "BACK" });
|
||||
startTransition(() => {
|
||||
deleteCollection(id)
|
||||
.then(() => dispatch({ type: "TOAST", value: "Đã xóa" }))
|
||||
.catch(() => dispatch({ type: "TOAST", value: "Xóa thất bại" }));
|
||||
});
|
||||
}}
|
||||
onClose={() => dispatch({ type: "CLOSE_MODAL" })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.toast && (
|
||||
<div className="toast" key={state.toastKey}>
|
||||
<Icons.Check size={14} stroke={2.5} />
|
||||
{state.toast}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{state.toast && (
|
||||
<div className="toast" key={state.toastKey}>
|
||||
<Icons.Check size={14} stroke={2.5} />
|
||||
{state.toast}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppDataProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import type { User } from "@/lib/types";
|
||||
import { USERS } from "@/lib/mock-data";
|
||||
import { useUsers } from "@/lib/app-context";
|
||||
|
||||
export function Avatar({
|
||||
user,
|
||||
@@ -35,17 +37,18 @@ export function AvatarStack({
|
||||
size = 28,
|
||||
extra = 0,
|
||||
}: {
|
||||
userIds: string[];
|
||||
userIds: number[];
|
||||
max?: number;
|
||||
size?: number;
|
||||
extra?: number;
|
||||
}) {
|
||||
const users = useUsers();
|
||||
const list = userIds.slice(0, max);
|
||||
const more = userIds.length + extra - list.length;
|
||||
return (
|
||||
<span className="avatar-stack">
|
||||
{list.map((id) => (
|
||||
<Avatar key={id} user={USERS[id]} size={size} />
|
||||
<Avatar key={id} user={users[id]} size={size} />
|
||||
))}
|
||||
{more > 0 && (
|
||||
<span
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import { CATEGORIES } from "@/lib/mock-data";
|
||||
import { CATEGORIES } from "@/lib/ui-config";
|
||||
import type { CategoryId } from "@/lib/types";
|
||||
import { Icons } from "./icons";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { CATEGORIES } from "@/lib/mock-data";
|
||||
import { CATEGORIES } from "@/lib/ui-config";
|
||||
import type { Place } from "@/lib/types";
|
||||
import { Icons } from "./icons";
|
||||
import { CoverImage } from "./cover-image";
|
||||
|
||||
44
src/lib/app-context.tsx
Normal file
44
src/lib/app-context.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { Collection, User } from "./types";
|
||||
|
||||
export type AppData = {
|
||||
me: User;
|
||||
users: Record<number, User>;
|
||||
collections: Collection[];
|
||||
};
|
||||
|
||||
const AppDataContext = createContext<AppData | null>(null);
|
||||
|
||||
export function AppDataProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: AppData;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AppDataContext.Provider value={value}>
|
||||
{children}
|
||||
</AppDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAppData(): AppData {
|
||||
const v = useContext(AppDataContext);
|
||||
if (!v) throw new Error("useAppData must be used inside <AppDataProvider>");
|
||||
return v;
|
||||
}
|
||||
|
||||
export function useUsers(): Record<number, User> {
|
||||
return useAppData().users;
|
||||
}
|
||||
|
||||
export function useCollections(): Collection[] {
|
||||
return useAppData().collections;
|
||||
}
|
||||
|
||||
export function useMe(): User {
|
||||
return useAppData().me;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Place } from "./types";
|
||||
import { PLACES } from "./mock-data";
|
||||
|
||||
export type Screen = "places" | "collections" | "profile" | "place" | "collection";
|
||||
|
||||
@@ -7,16 +6,20 @@ export type Tab = "places" | "collections" | "profile";
|
||||
|
||||
export type StackFrame = {
|
||||
screen: Screen;
|
||||
placeId?: string;
|
||||
collectionId?: string;
|
||||
placeId?: number;
|
||||
collectionId?: number;
|
||||
};
|
||||
|
||||
export type Modal =
|
||||
| { type: "add" }
|
||||
| { type: "invite"; collectionId: string }
|
||||
| { type: "members"; collectionId: string }
|
||||
| { type: "confirmDeletePlace"; placeId: string }
|
||||
| { type: "confirmDeleteCollection"; collectionId: string }
|
||||
| { type: "editPlace"; placeId: number }
|
||||
| { type: "saveToCollection"; placeId: number }
|
||||
| { type: "createCollection" }
|
||||
| { type: "editCollection"; collectionId: number }
|
||||
| { type: "invite"; collectionId: number }
|
||||
| { type: "members"; collectionId: number }
|
||||
| { type: "confirmDeletePlace"; placeId: number }
|
||||
| { type: "confirmDeleteCollection"; collectionId: number }
|
||||
| null;
|
||||
|
||||
export type AppState = {
|
||||
@@ -32,37 +35,43 @@ export type AppState = {
|
||||
};
|
||||
|
||||
export type Action =
|
||||
| { type: "NAV"; screen: Screen; placeId?: string; collectionId?: string }
|
||||
| { type: "NAV"; screen: Screen; placeId?: number; collectionId?: number }
|
||||
| { type: "BACK" }
|
||||
| { type: "TAB"; tab: Tab }
|
||||
| { type: "SET_FILTER"; value: string }
|
||||
| { type: "SET_SEARCH"; value: string }
|
||||
| { type: "TOGGLE_VISITED"; placeId: string }
|
||||
| { type: "SET_RATING"; placeId: string; value: number }
|
||||
| { type: "SET_NOTES"; placeId: string; value: string }
|
||||
| { type: "TOGGLE_VISITED"; placeId: number }
|
||||
| { type: "SET_RATING"; placeId: number; value: number }
|
||||
| { type: "SET_NOTES"; placeId: number; value: string }
|
||||
| { type: "ADD_PLACE"; place: Place }
|
||||
| { type: "DELETE_PLACE"; placeId: string }
|
||||
| { type: "DELETE_PLACE"; placeId: number }
|
||||
| { type: "TOAST"; value: string }
|
||||
| { type: "CLEAR_TOAST"; key: number }
|
||||
| { type: "OPEN_ADD" }
|
||||
| { type: "OPEN_INVITE"; collectionId: string }
|
||||
| { type: "OPEN_MEMBERS"; collectionId: string }
|
||||
| { type: "CONFIRM_DELETE_PLACE"; placeId: string }
|
||||
| { type: "CONFIRM_DELETE_COLLECTION"; collectionId: string }
|
||||
| { type: "OPEN_EDIT_PLACE"; placeId: number }
|
||||
| { type: "OPEN_SAVE_TO_COLLECTION"; placeId: number }
|
||||
| { type: "OPEN_CREATE_COLLECTION" }
|
||||
| { type: "OPEN_EDIT_COLLECTION"; collectionId: number }
|
||||
| { type: "OPEN_INVITE"; collectionId: number }
|
||||
| { type: "OPEN_MEMBERS"; collectionId: number }
|
||||
| { type: "CONFIRM_DELETE_PLACE"; placeId: number }
|
||||
| { type: "CONFIRM_DELETE_COLLECTION"; collectionId: number }
|
||||
| { type: "CLOSE_MODAL" }
|
||||
| { type: "SET_OFFLINE"; value: boolean };
|
||||
|
||||
export const INITIAL_STATE: AppState = {
|
||||
tab: "places",
|
||||
stack: [{ screen: "places" }],
|
||||
filter: "all",
|
||||
search: "",
|
||||
places: PLACES,
|
||||
modal: null,
|
||||
toast: null,
|
||||
toastKey: 0,
|
||||
offline: false,
|
||||
};
|
||||
export function makeInitialState(places: Place[]): AppState {
|
||||
return {
|
||||
tab: "places",
|
||||
stack: [{ screen: "places" }],
|
||||
filter: "all",
|
||||
search: "",
|
||||
places,
|
||||
modal: null,
|
||||
toast: null,
|
||||
toastKey: 0,
|
||||
offline: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function reducer(state: AppState, action: Action): AppState {
|
||||
switch (action.type) {
|
||||
@@ -77,9 +86,8 @@ export function reducer(state: AppState, action: Action): AppState {
|
||||
if (state.stack.length <= 1) return state;
|
||||
return { ...state, stack: state.stack.slice(0, -1) };
|
||||
}
|
||||
case "TAB": {
|
||||
case "TAB":
|
||||
return { ...state, tab: action.tab, stack: [{ screen: action.tab }] };
|
||||
}
|
||||
case "SET_FILTER":
|
||||
return { ...state, filter: action.value };
|
||||
case "SET_SEARCH":
|
||||
@@ -112,23 +120,29 @@ export function reducer(state: AppState, action: Action): AppState {
|
||||
);
|
||||
return { ...state, places };
|
||||
}
|
||||
case "ADD_PLACE": {
|
||||
case "ADD_PLACE":
|
||||
return { ...state, places: [action.place, ...state.places] };
|
||||
}
|
||||
case "DELETE_PLACE": {
|
||||
case "DELETE_PLACE":
|
||||
return {
|
||||
...state,
|
||||
places: state.places.filter((p) => p.id !== action.placeId),
|
||||
stack: state.stack.length > 1 ? state.stack.slice(0, -1) : state.stack,
|
||||
modal: null,
|
||||
};
|
||||
}
|
||||
case "TOAST":
|
||||
return { ...state, toast: action.value, toastKey: state.toastKey + 1 };
|
||||
case "CLEAR_TOAST":
|
||||
return state.toastKey === action.key ? { ...state, toast: null } : state;
|
||||
case "OPEN_ADD":
|
||||
return { ...state, modal: { type: "add" } };
|
||||
case "OPEN_EDIT_PLACE":
|
||||
return { ...state, modal: { type: "editPlace", placeId: action.placeId } };
|
||||
case "OPEN_SAVE_TO_COLLECTION":
|
||||
return { ...state, modal: { type: "saveToCollection", placeId: action.placeId } };
|
||||
case "OPEN_CREATE_COLLECTION":
|
||||
return { ...state, modal: { type: "createCollection" } };
|
||||
case "OPEN_EDIT_COLLECTION":
|
||||
return { ...state, modal: { type: "editCollection", collectionId: action.collectionId } };
|
||||
case "OPEN_INVITE":
|
||||
return { ...state, modal: { type: "invite", collectionId: action.collectionId } };
|
||||
case "OPEN_MEMBERS":
|
||||
|
||||
25
src/lib/clipboard.ts
Normal file
25
src/lib/clipboard.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copy a string to the system clipboard. Best-effort fallback when the async
|
||||
// API isn't available (older Safari on http://, etc.).
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// fall through to fallback
|
||||
}
|
||||
try {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.left = "-9999px";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
532
src/lib/db/actions.ts
Normal file
532
src/lib/db/actions.ts
Normal file
@@ -0,0 +1,532 @@
|
||||
"use server";
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { headers } from "next/headers";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { db } from "./client";
|
||||
import { requireUserId } from "./auth";
|
||||
import {
|
||||
collectionMembers,
|
||||
collectionPlaces,
|
||||
collections,
|
||||
invitations,
|
||||
places,
|
||||
userPlaceData,
|
||||
users,
|
||||
} from "./schema";
|
||||
import type { CategoryId, CollectionType, Place, Role } from "@/lib/types";
|
||||
import { buildInviteEmail, sendEmail } from "@/lib/email";
|
||||
|
||||
function makeInviteToken(): string {
|
||||
return randomBytes(16).toString("base64url");
|
||||
}
|
||||
|
||||
// Privacy guard: user must own the place or be a member of a collection
|
||||
// containing it. Mirrors the spec in CLAUDE.md / RLS policy.
|
||||
async function assertCanAccessPlace(placeId: number, userId: number) {
|
||||
const [row] = await db.execute<{ ok: boolean }>(sql`
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM ${places} p
|
||||
WHERE p.id = ${placeId}
|
||||
AND (
|
||||
p.created_by = ${userId}
|
||||
OR p.id IN (
|
||||
SELECT cp.place_id FROM ${collectionPlaces} cp
|
||||
JOIN ${collectionMembers} cm
|
||||
ON cm.collection_id = cp.collection_id AND cm.user_id = ${userId}
|
||||
)
|
||||
)
|
||||
) AS ok
|
||||
`).then((r) => r.rows);
|
||||
if (!row?.ok) throw new Error("forbidden: cannot access place");
|
||||
}
|
||||
|
||||
export async function toggleVisited(
|
||||
placeId: number,
|
||||
): Promise<{ visited: boolean; visited_at: string | null }> {
|
||||
const uid = await requireUserId();
|
||||
await assertCanAccessPlace(placeId, uid);
|
||||
const [current] = await db
|
||||
.select({ visited: userPlaceData.visited })
|
||||
.from(userPlaceData)
|
||||
.where(
|
||||
and(eq(userPlaceData.userId, uid), eq(userPlaceData.placeId, placeId)),
|
||||
)
|
||||
.limit(1);
|
||||
const next = !current?.visited;
|
||||
const visitedAt = next ? new Date() : null;
|
||||
|
||||
await db
|
||||
.insert(userPlaceData)
|
||||
.values({ userId: uid, placeId, visited: next, visitedAt })
|
||||
.onConflictDoUpdate({
|
||||
target: [userPlaceData.userId, userPlaceData.placeId],
|
||||
set: { visited: next, visitedAt },
|
||||
});
|
||||
|
||||
revalidatePath("/");
|
||||
return {
|
||||
visited: next,
|
||||
visited_at: visitedAt ? visitedAt.toISOString().slice(0, 10) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function setRating(placeId: number, rating: number): Promise<void> {
|
||||
if (rating < 0 || rating > 5) throw new Error("rating out of range");
|
||||
const uid = await requireUserId();
|
||||
await assertCanAccessPlace(placeId, uid);
|
||||
if (rating === 0) {
|
||||
await db
|
||||
.update(userPlaceData)
|
||||
.set({ rating: null })
|
||||
.where(
|
||||
and(eq(userPlaceData.userId, uid), eq(userPlaceData.placeId, placeId)),
|
||||
);
|
||||
} else {
|
||||
await db
|
||||
.insert(userPlaceData)
|
||||
.values({ userId: uid, placeId, rating })
|
||||
.onConflictDoUpdate({
|
||||
target: [userPlaceData.userId, userPlaceData.placeId],
|
||||
set: { rating },
|
||||
});
|
||||
}
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function setNotes(placeId: number, notes: string): Promise<void> {
|
||||
const uid = await requireUserId();
|
||||
await assertCanAccessPlace(placeId, uid);
|
||||
const trimmed = notes.trim();
|
||||
if (!trimmed) {
|
||||
await db
|
||||
.update(userPlaceData)
|
||||
.set({ notes: null })
|
||||
.where(
|
||||
and(eq(userPlaceData.userId, uid), eq(userPlaceData.placeId, placeId)),
|
||||
);
|
||||
} else {
|
||||
await db
|
||||
.insert(userPlaceData)
|
||||
.values({ userId: uid, placeId, notes: trimmed })
|
||||
.onConflictDoUpdate({
|
||||
target: [userPlaceData.userId, userPlaceData.placeId],
|
||||
set: { notes: trimmed },
|
||||
});
|
||||
}
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export type NewPlaceInput = {
|
||||
name: string;
|
||||
address: string;
|
||||
short_address: string;
|
||||
city: string;
|
||||
category: CategoryId;
|
||||
tags: string[];
|
||||
cover_url: string | null;
|
||||
rating?: number;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export async function addPlace(input: NewPlaceInput): Promise<Place> {
|
||||
const uid = await requireUserId();
|
||||
const [row] = await db
|
||||
.insert(places)
|
||||
.values({
|
||||
createdBy: uid,
|
||||
name: input.name,
|
||||
address: input.address,
|
||||
shortAddress: input.short_address,
|
||||
city: input.city,
|
||||
category: input.category,
|
||||
tags: input.tags,
|
||||
coverUrl: input.cover_url,
|
||||
})
|
||||
.returning();
|
||||
if (input.rating || input.notes) {
|
||||
await db.insert(userPlaceData).values({
|
||||
userId: uid,
|
||||
placeId: row.id,
|
||||
rating: input.rating ?? null,
|
||||
notes: input.notes ?? null,
|
||||
});
|
||||
}
|
||||
revalidatePath("/");
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
address: row.address,
|
||||
short_address: row.shortAddress,
|
||||
city: row.city,
|
||||
category: row.category,
|
||||
tags: row.tags,
|
||||
cover_url: row.coverUrl,
|
||||
created_by: uid,
|
||||
created_at: row.createdAt.toISOString().slice(0, 10),
|
||||
my_rating: input.rating,
|
||||
my_notes: input.notes,
|
||||
visited: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deletePlace(placeId: number): Promise<void> {
|
||||
const uid = await requireUserId();
|
||||
const [row] = await db
|
||||
.select({ createdBy: places.createdBy })
|
||||
.from(places)
|
||||
.where(eq(places.id, placeId))
|
||||
.limit(1);
|
||||
if (!row) return;
|
||||
if (row.createdBy !== uid) throw new Error("not authorized");
|
||||
await db.delete(places).where(eq(places.id, placeId));
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export type EditPlaceInput = {
|
||||
name: string;
|
||||
address: string;
|
||||
short_address: string;
|
||||
city: string;
|
||||
category: CategoryId;
|
||||
tags: string[];
|
||||
cover_url: string | null;
|
||||
};
|
||||
|
||||
export async function editPlace(
|
||||
placeId: number,
|
||||
input: EditPlaceInput,
|
||||
): Promise<void> {
|
||||
const uid = await requireUserId();
|
||||
const [row] = await db
|
||||
.select({ createdBy: places.createdBy })
|
||||
.from(places)
|
||||
.where(eq(places.id, placeId))
|
||||
.limit(1);
|
||||
if (!row) throw new Error("not found");
|
||||
if (row.createdBy !== uid) throw new Error("not authorized");
|
||||
|
||||
await db
|
||||
.update(places)
|
||||
.set({
|
||||
name: input.name,
|
||||
address: input.address,
|
||||
shortAddress: input.short_address,
|
||||
city: input.city,
|
||||
category: input.category,
|
||||
tags: input.tags,
|
||||
coverUrl: input.cover_url,
|
||||
})
|
||||
.where(eq(places.id, placeId));
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export type CollectionInput = {
|
||||
name: string;
|
||||
type: CollectionType;
|
||||
trip_start?: string;
|
||||
trip_end?: string;
|
||||
};
|
||||
|
||||
export async function createCollection(
|
||||
input: CollectionInput,
|
||||
): Promise<{ id: number }> {
|
||||
const uid = await requireUserId();
|
||||
const name = input.name.trim();
|
||||
if (!name) throw new Error("name required");
|
||||
const [row] = await db
|
||||
.insert(collections)
|
||||
.values({
|
||||
ownerId: uid,
|
||||
name,
|
||||
type: input.type,
|
||||
tripStart: input.type === "trip" ? input.trip_start ?? null : null,
|
||||
tripEnd: input.type === "trip" ? input.trip_end ?? null : null,
|
||||
})
|
||||
.returning({ id: collections.id });
|
||||
await db
|
||||
.insert(collectionMembers)
|
||||
.values({ collectionId: row.id, userId: uid, role: "owner" });
|
||||
revalidatePath("/");
|
||||
return { id: row.id };
|
||||
}
|
||||
|
||||
export async function editCollection(
|
||||
collectionId: number,
|
||||
input: CollectionInput,
|
||||
): Promise<void> {
|
||||
const uid = await requireUserId();
|
||||
const [row] = await db
|
||||
.select({ ownerId: collections.ownerId })
|
||||
.from(collections)
|
||||
.where(eq(collections.id, collectionId))
|
||||
.limit(1);
|
||||
if (!row) throw new Error("not found");
|
||||
if (row.ownerId !== uid) throw new Error("not authorized");
|
||||
await db
|
||||
.update(collections)
|
||||
.set({
|
||||
name: input.name.trim(),
|
||||
type: input.type,
|
||||
tripStart: input.type === "trip" ? input.trip_start ?? null : null,
|
||||
tripEnd: input.type === "trip" ? input.trip_end ?? null : null,
|
||||
})
|
||||
.where(eq(collections.id, collectionId));
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function deleteCollection(collectionId: number): Promise<void> {
|
||||
const uid = await requireUserId();
|
||||
const [row] = await db
|
||||
.select({ ownerId: collections.ownerId })
|
||||
.from(collections)
|
||||
.where(eq(collections.id, collectionId))
|
||||
.limit(1);
|
||||
if (!row) return;
|
||||
if (row.ownerId !== uid) throw new Error("not authorized");
|
||||
await db.delete(collections).where(eq(collections.id, collectionId));
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
async function assertCanWriteCollection(
|
||||
collectionId: number,
|
||||
userId: number,
|
||||
) {
|
||||
const [row] = await db
|
||||
.select({ role: collectionMembers.role })
|
||||
.from(collectionMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(collectionMembers.collectionId, collectionId),
|
||||
eq(collectionMembers.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!row) throw new Error("forbidden: not a member");
|
||||
if (row.role === "viewer") throw new Error("forbidden: viewer cannot write");
|
||||
}
|
||||
|
||||
export async function addPlaceToCollection(
|
||||
collectionId: number,
|
||||
placeId: number,
|
||||
): Promise<void> {
|
||||
const uid = await requireUserId();
|
||||
await assertCanWriteCollection(collectionId, uid);
|
||||
await assertCanAccessPlace(placeId, uid);
|
||||
const [next] = await db
|
||||
.select({ max: sql<number | null>`max(${collectionPlaces.sortOrder})` })
|
||||
.from(collectionPlaces)
|
||||
.where(eq(collectionPlaces.collectionId, collectionId));
|
||||
await db
|
||||
.insert(collectionPlaces)
|
||||
.values({
|
||||
collectionId,
|
||||
placeId,
|
||||
addedBy: uid,
|
||||
sortOrder: (next?.max ?? -1) + 1,
|
||||
})
|
||||
.onConflictDoNothing({
|
||||
target: [collectionPlaces.collectionId, collectionPlaces.placeId],
|
||||
});
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function removePlaceFromCollection(
|
||||
collectionId: number,
|
||||
placeId: number,
|
||||
): Promise<void> {
|
||||
const uid = await requireUserId();
|
||||
await assertCanWriteCollection(collectionId, uid);
|
||||
await db
|
||||
.delete(collectionPlaces)
|
||||
.where(
|
||||
and(
|
||||
eq(collectionPlaces.collectionId, collectionId),
|
||||
eq(collectionPlaces.placeId, placeId),
|
||||
),
|
||||
);
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function createInviteLink(
|
||||
collectionId: number,
|
||||
): Promise<{ token: string; expires_at: string }> {
|
||||
const uid = await requireUserId();
|
||||
const [row] = await db
|
||||
.select({ ownerId: collections.ownerId })
|
||||
.from(collections)
|
||||
.where(eq(collections.id, collectionId))
|
||||
.limit(1);
|
||||
if (!row) throw new Error("not found");
|
||||
if (row.ownerId !== uid) throw new Error("not authorized");
|
||||
|
||||
const token = makeInviteToken();
|
||||
const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
await db
|
||||
.update(collections)
|
||||
.set({ inviteToken: token, tokenExpiresAt: expires })
|
||||
.where(eq(collections.id, collectionId));
|
||||
revalidatePath("/");
|
||||
return { token, expires_at: expires.toISOString() };
|
||||
}
|
||||
|
||||
export async function revokeInviteLink(collectionId: number): Promise<void> {
|
||||
const uid = await requireUserId();
|
||||
const [row] = await db
|
||||
.select({ ownerId: collections.ownerId })
|
||||
.from(collections)
|
||||
.where(eq(collections.id, collectionId))
|
||||
.limit(1);
|
||||
if (!row) throw new Error("not found");
|
||||
if (row.ownerId !== uid) throw new Error("not authorized");
|
||||
await db
|
||||
.update(collections)
|
||||
.set({ inviteToken: null, tokenExpiresAt: null })
|
||||
.where(eq(collections.id, collectionId));
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function acceptInvite(
|
||||
token: string,
|
||||
): Promise<{ collectionId: number }> {
|
||||
const uid = await requireUserId();
|
||||
const { acceptInviteCore } = await import("./invites");
|
||||
const result = await acceptInviteCore(token, uid);
|
||||
revalidatePath("/");
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Email invitations ──────────────────────────────────
|
||||
async function resolveOrigin(): Promise<string> {
|
||||
if (process.env.APP_URL) return process.env.APP_URL;
|
||||
const h = await headers();
|
||||
const host = h.get("host") ?? "localhost:3000";
|
||||
const proto =
|
||||
h.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
export type PendingInvitation = {
|
||||
id: number;
|
||||
email: string;
|
||||
role: Role;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export async function fetchPendingInvitations(
|
||||
collectionId: number,
|
||||
): Promise<PendingInvitation[]> {
|
||||
const uid = await requireUserId();
|
||||
const [col] = await db
|
||||
.select({ ownerId: collections.ownerId })
|
||||
.from(collections)
|
||||
.where(eq(collections.id, collectionId))
|
||||
.limit(1);
|
||||
if (!col) throw new Error("not found");
|
||||
if (col.ownerId !== uid) throw new Error("not authorized");
|
||||
|
||||
const { listPendingInvitations } = await import("./invites");
|
||||
const rows = await listPendingInvitations(collectionId);
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
email: r.email,
|
||||
role: r.role,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
expiresAt: r.expiresAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function sendEmailInvite(
|
||||
collectionId: number,
|
||||
emailRaw: string,
|
||||
role: Role,
|
||||
): Promise<PendingInvitation> {
|
||||
const uid = await requireUserId();
|
||||
const email = emailRaw.trim().toLowerCase();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
throw new Error("Email không hợp lệ");
|
||||
}
|
||||
if (role !== "editor" && role !== "viewer") {
|
||||
throw new Error("Vai trò không hợp lệ");
|
||||
}
|
||||
|
||||
const [col] = await db
|
||||
.select({ id: collections.id, name: collections.name, ownerId: collections.ownerId })
|
||||
.from(collections)
|
||||
.where(eq(collections.id, collectionId))
|
||||
.limit(1);
|
||||
if (!col) throw new Error("Bộ sưu tập không tồn tại");
|
||||
if (col.ownerId !== uid) throw new Error("Chỉ chủ sở hữu mới được mời");
|
||||
|
||||
const [inviter] = await db
|
||||
.select({ name: users.name })
|
||||
.from(users)
|
||||
.where(eq(users.id, uid))
|
||||
.limit(1);
|
||||
|
||||
const token = randomBytes(16).toString("base64url");
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Upsert by (collection_id, email) — re-sending replaces the old token.
|
||||
const [row] = await db
|
||||
.insert(invitations)
|
||||
.values({
|
||||
collectionId,
|
||||
email,
|
||||
role,
|
||||
token,
|
||||
expiresAt,
|
||||
invitedBy: uid,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [invitations.collectionId, invitations.email],
|
||||
set: { token, expiresAt, role, invitedBy: uid, acceptedAt: null },
|
||||
})
|
||||
.returning();
|
||||
|
||||
const origin = await resolveOrigin();
|
||||
const inviteUrl = `${origin}/invite/${token}`;
|
||||
const msg = buildInviteEmail({
|
||||
collectionName: col.name,
|
||||
inviterName: inviter?.name ?? "Thành viên Places",
|
||||
inviteUrl,
|
||||
role,
|
||||
});
|
||||
// Don't fail the action if email delivery fails — the row is persisted and
|
||||
// the owner can copy the URL from the pending list if needed.
|
||||
try {
|
||||
await sendEmail({ ...msg, to: email });
|
||||
} catch (e) {
|
||||
console.error("[email] send failed:", (e as Error).message);
|
||||
}
|
||||
|
||||
revalidatePath("/");
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
expiresAt: row.expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function revokeInvitation(invitationId: number): Promise<void> {
|
||||
const uid = await requireUserId();
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: invitations.id,
|
||||
collectionId: invitations.collectionId,
|
||||
ownerId: collections.ownerId,
|
||||
})
|
||||
.from(invitations)
|
||||
.innerJoin(collections, eq(collections.id, invitations.collectionId))
|
||||
.where(eq(invitations.id, invitationId))
|
||||
.limit(1);
|
||||
if (!row) return;
|
||||
if (row.ownerId !== uid) throw new Error("not authorized");
|
||||
await db.delete(invitations).where(eq(invitations.id, invitationId));
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
164
src/lib/db/auth.ts
Normal file
164
src/lib/db/auth.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import "server-only";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { cookies } from "next/headers";
|
||||
import { and, eq, gt } from "drizzle-orm";
|
||||
import { db } from "./client";
|
||||
import { sessions, users } from "./schema";
|
||||
import type { User } from "@/lib/types";
|
||||
|
||||
export const SESSION_COOKIE = "places_session";
|
||||
const SESSION_DAYS = 30;
|
||||
|
||||
function newToken(): string {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
function deriveInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
return parts[0].slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export type AuthResult =
|
||||
| { ok: true; user: User }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export async function registerUser(
|
||||
email: string,
|
||||
password: string,
|
||||
name: string,
|
||||
): Promise<AuthResult> {
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
const cleanName = name.trim();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(cleanEmail)) {
|
||||
return { ok: false, error: "Email không hợp lệ" };
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return { ok: false, error: "Mật khẩu cần ít nhất 8 ký tự" };
|
||||
}
|
||||
if (cleanName.length < 1) {
|
||||
return { ok: false, error: "Vui lòng nhập tên" };
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.email, cleanEmail))
|
||||
.limit(1);
|
||||
if (existing.length) {
|
||||
return { ok: false, error: "Email đã được đăng ký" };
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
const initials = deriveInitials(cleanName);
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
email: cleanEmail,
|
||||
passwordHash: hash,
|
||||
name: cleanName,
|
||||
initials,
|
||||
})
|
||||
.returning({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
initials: users.initials,
|
||||
avatarUrl: users.avatarUrl,
|
||||
color: users.color,
|
||||
});
|
||||
|
||||
await createSessionCookie(inserted.id);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
user: {
|
||||
id: inserted.id,
|
||||
email: inserted.email,
|
||||
name: inserted.name,
|
||||
initials: inserted.initials,
|
||||
avatar_url: inserted.avatarUrl,
|
||||
color: inserted.color ?? undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function loginUser(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<AuthResult> {
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, cleanEmail))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return { ok: false, error: "Email hoặc mật khẩu không đúng" };
|
||||
const ok = await bcrypt.compare(password, row.passwordHash);
|
||||
if (!ok) return { ok: false, error: "Email hoặc mật khẩu không đúng" };
|
||||
|
||||
await createSessionCookie(row.id);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
user: {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
initials: row.initials,
|
||||
avatar_url: row.avatarUrl,
|
||||
color: row.color ?? undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createSessionCookie(userId: number): Promise<void> {
|
||||
const token = newToken();
|
||||
const expiresAt = new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
|
||||
await db.insert(sessions).values({ id: token, userId, expiresAt });
|
||||
const c = await cookies();
|
||||
c.set(SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
expires: expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCurrentUserId(): Promise<number | null> {
|
||||
const c = await cookies();
|
||||
const token = c.get(SESSION_COOKIE)?.value;
|
||||
if (!token) return null;
|
||||
const [row] = await db
|
||||
.select({ userId: sessions.userId, expiresAt: sessions.expiresAt })
|
||||
.from(sessions)
|
||||
.where(and(eq(sessions.id, token), gt(sessions.expiresAt, new Date())))
|
||||
.limit(1);
|
||||
if (!row) {
|
||||
// Clean up an expired/invalid token if present.
|
||||
await db.delete(sessions).where(eq(sessions.id, token));
|
||||
return null;
|
||||
}
|
||||
return row.userId;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
const c = await cookies();
|
||||
const token = c.get(SESSION_COOKIE)?.value;
|
||||
if (token) {
|
||||
await db.delete(sessions).where(eq(sessions.id, token));
|
||||
}
|
||||
c.delete(SESSION_COOKIE);
|
||||
}
|
||||
|
||||
export async function requireUserId(): Promise<number> {
|
||||
const id = await getCurrentUserId();
|
||||
if (!id) throw new Error("not authenticated");
|
||||
return id;
|
||||
}
|
||||
34
src/lib/db/client.ts
Normal file
34
src/lib/db/client.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import "server-only";
|
||||
import { Pool } from "pg";
|
||||
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import * as schema from "./schema";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __pg_pool: Pool | undefined;
|
||||
// eslint-disable-next-line no-var
|
||||
var __drizzle: NodePgDatabase<typeof schema> | undefined;
|
||||
}
|
||||
|
||||
function makePool() {
|
||||
if (process.env.DATABASE_URL) {
|
||||
return new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });
|
||||
}
|
||||
return new Pool({
|
||||
host: process.env.PGHOST,
|
||||
user: process.env.PGUSER,
|
||||
password: process.env.PGPASSWORD,
|
||||
database: process.env.PGDATABASE,
|
||||
port: process.env.PGPORT ? Number(process.env.PGPORT) : 5432,
|
||||
max: 5,
|
||||
});
|
||||
}
|
||||
|
||||
const pool = globalThis.__pg_pool ?? makePool();
|
||||
export const db: NodePgDatabase<typeof schema> =
|
||||
globalThis.__drizzle ?? drizzle(pool, { schema });
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalThis.__pg_pool = pool;
|
||||
globalThis.__drizzle = db;
|
||||
}
|
||||
86
src/lib/db/invites.ts
Normal file
86
src/lib/db/invites.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import "server-only";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { db } from "./client";
|
||||
import { collectionMembers, collections, invitations } from "./schema";
|
||||
|
||||
// Pure DB op — no revalidatePath. Callable from Server Components
|
||||
// during render. Caller is responsible for cache invalidation.
|
||||
//
|
||||
// Two token shapes supported:
|
||||
// 1) `collections.invite_token` — anyone-with-link, joins as 'editor'.
|
||||
// 2) `invitations.token` — email-targeted, joins at the role
|
||||
// the inviter specified.
|
||||
export async function acceptInviteCore(
|
||||
token: string,
|
||||
userId: number,
|
||||
): Promise<{ collectionId: number }> {
|
||||
// Try a collection-level link first.
|
||||
const [linkRow] = await db
|
||||
.select({ id: collections.id, expiresAt: collections.tokenExpiresAt })
|
||||
.from(collections)
|
||||
.where(eq(collections.inviteToken, token))
|
||||
.limit(1);
|
||||
if (linkRow) {
|
||||
if (linkRow.expiresAt && linkRow.expiresAt.getTime() < Date.now()) {
|
||||
throw new Error("Liên kết đã hết hạn");
|
||||
}
|
||||
await db
|
||||
.insert(collectionMembers)
|
||||
.values({ collectionId: linkRow.id, userId, role: "editor" })
|
||||
.onConflictDoNothing({
|
||||
target: [collectionMembers.collectionId, collectionMembers.userId],
|
||||
});
|
||||
return { collectionId: linkRow.id };
|
||||
}
|
||||
|
||||
// Fall back to per-email invitation token.
|
||||
const [inv] = await db
|
||||
.select({
|
||||
id: invitations.id,
|
||||
collectionId: invitations.collectionId,
|
||||
role: invitations.role,
|
||||
expiresAt: invitations.expiresAt,
|
||||
acceptedAt: invitations.acceptedAt,
|
||||
})
|
||||
.from(invitations)
|
||||
.where(eq(invitations.token, token))
|
||||
.limit(1);
|
||||
if (!inv) throw new Error("Liên kết không hợp lệ");
|
||||
if (inv.acceptedAt) throw new Error("Lời mời đã được dùng");
|
||||
if (inv.expiresAt.getTime() < Date.now()) {
|
||||
throw new Error("Liên kết đã hết hạn");
|
||||
}
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.insert(collectionMembers)
|
||||
.values({ collectionId: inv.collectionId, userId, role: inv.role })
|
||||
.onConflictDoNothing({
|
||||
target: [collectionMembers.collectionId, collectionMembers.userId],
|
||||
});
|
||||
await tx
|
||||
.update(invitations)
|
||||
.set({ acceptedAt: new Date() })
|
||||
.where(eq(invitations.id, inv.id));
|
||||
});
|
||||
return { collectionId: inv.collectionId };
|
||||
}
|
||||
|
||||
// Pending email invitations for a collection (not yet redeemed).
|
||||
// Used to render the list in the invite dialog.
|
||||
export async function listPendingInvitations(collectionId: number) {
|
||||
return db
|
||||
.select({
|
||||
id: invitations.id,
|
||||
email: invitations.email,
|
||||
role: invitations.role,
|
||||
createdAt: invitations.createdAt,
|
||||
expiresAt: invitations.expiresAt,
|
||||
})
|
||||
.from(invitations)
|
||||
.where(
|
||||
and(
|
||||
eq(invitations.collectionId, collectionId),
|
||||
isNull(invitations.acceptedAt),
|
||||
),
|
||||
);
|
||||
}
|
||||
270
src/lib/db/queries.ts
Normal file
270
src/lib/db/queries.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import "server-only";
|
||||
import { and, desc, eq, inArray, or, sql } from "drizzle-orm";
|
||||
import { db } from "./client";
|
||||
import {
|
||||
collectionMembers,
|
||||
collectionPlaces,
|
||||
collections,
|
||||
places,
|
||||
userPlaceData,
|
||||
users,
|
||||
} from "./schema";
|
||||
import { getCurrentUserId, requireUserId } from "./auth";
|
||||
import type { Collection, Place, User } from "@/lib/types";
|
||||
|
||||
function dateToStr(d: Date | string | null | undefined): string | undefined {
|
||||
if (!d) return undefined;
|
||||
return (typeof d === "string" ? new Date(d) : d).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function toPlace(r: {
|
||||
id: number;
|
||||
name: string;
|
||||
address: string;
|
||||
shortAddress: string;
|
||||
category: Place["category"];
|
||||
tags: string[];
|
||||
coverUrl: string | null;
|
||||
createdBy: number;
|
||||
createdAt: Date | string;
|
||||
avgRating: string | null;
|
||||
city: string;
|
||||
myRating: number | null;
|
||||
myNotes: string | null;
|
||||
visited: boolean | null;
|
||||
visitedAt: Date | string | null;
|
||||
}): Place {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
address: r.address,
|
||||
short_address: r.shortAddress,
|
||||
category: r.category,
|
||||
tags: r.tags ?? [],
|
||||
cover_url: r.coverUrl,
|
||||
created_by: r.createdBy,
|
||||
created_at: dateToStr(r.createdAt) ?? "",
|
||||
avg_rating: r.avgRating != null ? Number(r.avgRating) : undefined,
|
||||
city: r.city,
|
||||
my_rating: r.myRating ?? undefined,
|
||||
my_notes: r.myNotes ?? undefined,
|
||||
visited: !!r.visited,
|
||||
visited_at: dateToStr(r.visitedAt),
|
||||
};
|
||||
}
|
||||
|
||||
// Privacy filter: user sees place if (a) they created it, or
|
||||
// (b) it lives in a collection where they're a member. CLAUDE.md spec.
|
||||
function placeVisibilityFilter(userId: number) {
|
||||
return or(
|
||||
eq(places.createdBy, userId),
|
||||
inArray(
|
||||
places.id,
|
||||
db
|
||||
.select({ id: collectionPlaces.placeId })
|
||||
.from(collectionPlaces)
|
||||
.innerJoin(
|
||||
collectionMembers,
|
||||
and(
|
||||
eq(collectionMembers.collectionId, collectionPlaces.collectionId),
|
||||
eq(collectionMembers.userId, userId),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPlacesForUser(userId?: number): Promise<Place[]> {
|
||||
const uid = userId ?? (await requireUserId());
|
||||
const rows = await db
|
||||
.select({
|
||||
id: places.id,
|
||||
name: places.name,
|
||||
address: places.address,
|
||||
shortAddress: places.shortAddress,
|
||||
category: places.category,
|
||||
tags: places.tags,
|
||||
coverUrl: places.coverUrl,
|
||||
createdBy: places.createdBy,
|
||||
createdAt: places.createdAt,
|
||||
avgRating: places.avgRating,
|
||||
city: places.city,
|
||||
myRating: userPlaceData.rating,
|
||||
myNotes: userPlaceData.notes,
|
||||
visited: userPlaceData.visited,
|
||||
visitedAt: userPlaceData.visitedAt,
|
||||
})
|
||||
.from(places)
|
||||
.leftJoin(
|
||||
userPlaceData,
|
||||
and(
|
||||
eq(userPlaceData.placeId, places.id),
|
||||
eq(userPlaceData.userId, uid),
|
||||
),
|
||||
)
|
||||
.where(placeVisibilityFilter(uid))
|
||||
.orderBy(desc(places.createdAt));
|
||||
return rows.map(toPlace);
|
||||
}
|
||||
|
||||
export async function getPlaceById(
|
||||
placeId: number,
|
||||
userId?: number,
|
||||
): Promise<Place | null> {
|
||||
const uid = userId ?? (await requireUserId());
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: places.id,
|
||||
name: places.name,
|
||||
address: places.address,
|
||||
shortAddress: places.shortAddress,
|
||||
category: places.category,
|
||||
tags: places.tags,
|
||||
coverUrl: places.coverUrl,
|
||||
createdBy: places.createdBy,
|
||||
createdAt: places.createdAt,
|
||||
avgRating: places.avgRating,
|
||||
city: places.city,
|
||||
myRating: userPlaceData.rating,
|
||||
myNotes: userPlaceData.notes,
|
||||
visited: userPlaceData.visited,
|
||||
visitedAt: userPlaceData.visitedAt,
|
||||
})
|
||||
.from(places)
|
||||
.leftJoin(
|
||||
userPlaceData,
|
||||
and(
|
||||
eq(userPlaceData.placeId, places.id),
|
||||
eq(userPlaceData.userId, uid),
|
||||
),
|
||||
)
|
||||
.where(and(eq(places.id, placeId), placeVisibilityFilter(uid)))
|
||||
.limit(1);
|
||||
return row ? toPlace(row) : null;
|
||||
}
|
||||
|
||||
type CollectionRow = {
|
||||
id: number;
|
||||
owner_id: number;
|
||||
name: string;
|
||||
type: Collection["type"];
|
||||
trip_start: Date | string | null;
|
||||
trip_end: Date | string | null;
|
||||
member_count: number;
|
||||
place_count: number;
|
||||
my_role: Collection["my_role"] | null;
|
||||
cover_place_ids: number[];
|
||||
place_ids: number[];
|
||||
members: number[];
|
||||
};
|
||||
|
||||
export async function getCollectionsForUser(
|
||||
userId?: number,
|
||||
): Promise<Collection[]> {
|
||||
const uid = userId ?? (await requireUserId());
|
||||
// Aggregates (member_count, place_ids, members…) are cheaper as correlated
|
||||
// subqueries than as N+1 round-trips. Drizzle's sql template keeps it readable.
|
||||
const result = await db.execute<CollectionRow>(sql`
|
||||
SELECT
|
||||
c.id,
|
||||
c.owner_id,
|
||||
c.name,
|
||||
c.type,
|
||||
c.trip_start,
|
||||
c.trip_end,
|
||||
(SELECT count(*)::int FROM ${collectionMembers} WHERE collection_id = c.id) AS member_count,
|
||||
(SELECT count(*)::int FROM ${collectionPlaces} WHERE collection_id = c.id) AS place_count,
|
||||
(SELECT role FROM ${collectionMembers} WHERE collection_id = c.id AND user_id = ${uid}) AS my_role,
|
||||
COALESCE((
|
||||
SELECT array_agg(place_id ORDER BY sort_order)
|
||||
FROM (
|
||||
SELECT place_id, sort_order
|
||||
FROM ${collectionPlaces}
|
||||
WHERE collection_id = c.id
|
||||
ORDER BY sort_order
|
||||
LIMIT 3
|
||||
) cover
|
||||
), ARRAY[]::int[]) AS cover_place_ids,
|
||||
COALESCE((
|
||||
SELECT array_agg(place_id ORDER BY sort_order)
|
||||
FROM ${collectionPlaces}
|
||||
WHERE collection_id = c.id
|
||||
), ARRAY[]::int[]) AS place_ids,
|
||||
COALESCE((
|
||||
SELECT array_agg(user_id ORDER BY (role = 'owner') DESC, joined_at)
|
||||
FROM ${collectionMembers}
|
||||
WHERE collection_id = c.id
|
||||
), ARRAY[]::int[]) AS members
|
||||
FROM ${collections} c
|
||||
WHERE c.id IN (
|
||||
SELECT collection_id FROM ${collectionMembers} WHERE user_id = ${uid}
|
||||
) OR c.owner_id = ${uid}
|
||||
ORDER BY c.created_at DESC
|
||||
`);
|
||||
return result.rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
type: r.type,
|
||||
owner_id: r.owner_id,
|
||||
trip_start: dateToStr(r.trip_start),
|
||||
trip_end: dateToStr(r.trip_end),
|
||||
member_count: Number(r.member_count),
|
||||
place_count: Number(r.place_count),
|
||||
my_role: r.my_role ?? "viewer",
|
||||
cover_place_ids: r.cover_place_ids ?? [],
|
||||
place_ids: r.place_ids ?? [],
|
||||
members: r.members ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getAllUsers(): Promise<Record<number, User>> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
initials: users.initials,
|
||||
avatarUrl: users.avatarUrl,
|
||||
color: users.color,
|
||||
})
|
||||
.from(users);
|
||||
return Object.fromEntries(
|
||||
rows.map((u) => [
|
||||
u.id,
|
||||
{
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
initials: u.initials,
|
||||
avatar_url: u.avatarUrl,
|
||||
color: u.color ?? undefined,
|
||||
} as User,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCurrentUser(): Promise<User | null> {
|
||||
const uid = await getCurrentUserId();
|
||||
if (!uid) return null;
|
||||
const [u] = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
initials: users.initials,
|
||||
avatarUrl: users.avatarUrl,
|
||||
color: users.color,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, uid))
|
||||
.limit(1);
|
||||
if (!u) return null;
|
||||
return {
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
initials: u.initials,
|
||||
avatar_url: u.avatarUrl,
|
||||
color: u.color ?? undefined,
|
||||
};
|
||||
}
|
||||
202
src/lib/db/schema.ts
Normal file
202
src/lib/db/schema.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
boolean,
|
||||
date,
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
numeric,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
serial,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const categoryEnum = pgEnum("category", [
|
||||
"food",
|
||||
"cafe",
|
||||
"shopping",
|
||||
"entertainment",
|
||||
"other",
|
||||
]);
|
||||
export const collectionTypeEnum = pgEnum("collection_type", ["folder", "trip"]);
|
||||
export const memberRoleEnum = pgEnum("member_role", ["owner", "editor", "viewer"]);
|
||||
export const priceRangeEnum = pgEnum("price_range", ["$", "$$", "$$$"]);
|
||||
|
||||
export const users = pgTable("users", {
|
||||
id: serial("id").primaryKey(),
|
||||
email: text("email").notNull().unique(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
name: text("name").notNull(),
|
||||
initials: text("initials").notNull(),
|
||||
avatarUrl: text("avatar_url"),
|
||||
color: text("color"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// Session token is a cryptographic secret — must stay random text.
|
||||
// Sequential ints would be guessable and enable account takeover.
|
||||
export const sessions = pgTable(
|
||||
"sessions",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
userAgent: text("user_agent"),
|
||||
},
|
||||
(t) => [
|
||||
index("sessions_user_id_idx").on(t.userId),
|
||||
index("sessions_expires_at_idx").on(t.expiresAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const places = pgTable(
|
||||
"places",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
createdBy: integer("created_by")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
name: text("name").notNull(),
|
||||
address: text("address").notNull(),
|
||||
shortAddress: text("short_address").notNull(),
|
||||
city: text("city").notNull(),
|
||||
lat: doublePrecision("lat"),
|
||||
lng: doublePrecision("lng"),
|
||||
category: categoryEnum("category").notNull(),
|
||||
tags: text("tags").array().notNull().default([]),
|
||||
coverUrl: text("cover_url"),
|
||||
phone: text("phone"),
|
||||
website: text("website"),
|
||||
priceRange: priceRangeEnum("price_range"),
|
||||
openingHours: text("opening_hours"),
|
||||
permanentlyClosed: boolean("permanently_closed").notNull().default(false),
|
||||
avgRating: numeric("avg_rating", { precision: 3, scale: 2 }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index("places_created_by_idx").on(t.createdBy),
|
||||
index("places_category_idx").on(t.category),
|
||||
],
|
||||
);
|
||||
|
||||
export const userPlaceData = pgTable(
|
||||
"user_place_data",
|
||||
{
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
placeId: integer("place_id")
|
||||
.notNull()
|
||||
.references(() => places.id, { onDelete: "cascade" }),
|
||||
notes: text("notes"),
|
||||
rating: integer("rating"),
|
||||
visited: boolean("visited").notNull().default(false),
|
||||
visitedAt: timestamp("visited_at", { withTimezone: true }),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.userId, t.placeId] })],
|
||||
);
|
||||
|
||||
export const collections = pgTable("collections", {
|
||||
id: serial("id").primaryKey(),
|
||||
ownerId: integer("owner_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
name: text("name").notNull(),
|
||||
type: collectionTypeEnum("type").notNull(),
|
||||
tripStart: date("trip_start"),
|
||||
tripEnd: date("trip_end"),
|
||||
inviteToken: text("invite_token").unique(),
|
||||
tokenExpiresAt: timestamp("token_expires_at", { withTimezone: true }),
|
||||
publicToken: text("public_token").unique(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const collectionMembers = pgTable(
|
||||
"collection_members",
|
||||
{
|
||||
collectionId: integer("collection_id")
|
||||
.notNull()
|
||||
.references(() => collections.id, { onDelete: "cascade" }),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
role: memberRoleEnum("role").notNull(),
|
||||
joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.collectionId, t.userId] })],
|
||||
);
|
||||
|
||||
export const collectionPlaces = pgTable(
|
||||
"collection_places",
|
||||
{
|
||||
collectionId: integer("collection_id")
|
||||
.notNull()
|
||||
.references(() => collections.id, { onDelete: "cascade" }),
|
||||
placeId: integer("place_id")
|
||||
.notNull()
|
||||
.references(() => places.id, { onDelete: "cascade" }),
|
||||
addedBy: integer("added_by")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
addedAt: timestamp("added_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.collectionId, t.placeId] }),
|
||||
index("collection_places_place_idx").on(t.placeId),
|
||||
],
|
||||
);
|
||||
|
||||
// Email-targeted invitation — one per (collection, email). Token is the
|
||||
// shareable secret; once accepted, the user joins the collection at `role`.
|
||||
export const invitations = pgTable(
|
||||
"invitations",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
collectionId: integer("collection_id")
|
||||
.notNull()
|
||||
.references(() => collections.id, { onDelete: "cascade" }),
|
||||
email: text("email").notNull(),
|
||||
role: memberRoleEnum("role").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
invitedBy: integer("invited_by")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
acceptedAt: timestamp("accepted_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("invitations_collection_email_idx").on(t.collectionId, t.email),
|
||||
index("invitations_token_idx").on(t.token),
|
||||
],
|
||||
);
|
||||
|
||||
export const placeReviews = pgTable(
|
||||
"place_reviews",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
placeId: integer("place_id")
|
||||
.notNull()
|
||||
.references(() => places.id, { onDelete: "cascade" }),
|
||||
collectionId: integer("collection_id")
|
||||
.notNull()
|
||||
.references(() => collections.id, { onDelete: "cascade" }),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
body: text("body").notNull(),
|
||||
rating: integer("rating"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("place_reviews_unique").on(t.placeId, t.collectionId, t.userId),
|
||||
],
|
||||
);
|
||||
92
src/lib/email.ts
Normal file
92
src/lib/email.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import "server-only";
|
||||
|
||||
// Send email via Resend REST API. No npm package required — Resend has a
|
||||
// stable REST surface. If RESEND_API_KEY is missing, we log to console
|
||||
// (so dev environments still see the link the user would have received).
|
||||
//
|
||||
// Env:
|
||||
// RESEND_API_KEY — required to actually send
|
||||
// INVITE_FROM_EMAIL — required to actually send (e.g. "no-reply@places.app")
|
||||
|
||||
export type EmailMessage = {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export async function sendEmail(msg: EmailMessage): Promise<void> {
|
||||
const key = process.env.RESEND_API_KEY;
|
||||
const from = process.env.INVITE_FROM_EMAIL;
|
||||
if (!key || !from) {
|
||||
// Dev fallback: print the would-be email so the link is still copyable.
|
||||
console.log(
|
||||
`\n[email:dry-run] (set RESEND_API_KEY + INVITE_FROM_EMAIL to send)\n` +
|
||||
` to: ${msg.to}\n` +
|
||||
` subject: ${msg.subject}\n` +
|
||||
` text: ${msg.text}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const res = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from,
|
||||
to: msg.to,
|
||||
subject: msg.subject,
|
||||
html: msg.html,
|
||||
text: msg.text,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`resend send failed: ${res.status} ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildInviteEmail(args: {
|
||||
collectionName: string;
|
||||
inviterName: string;
|
||||
inviteUrl: string;
|
||||
role: "editor" | "viewer";
|
||||
}): EmailMessage {
|
||||
const roleLabel = args.role === "editor" ? "Sửa được" : "Chỉ xem";
|
||||
const subject = `${args.inviterName} mời bạn vào "${args.collectionName}" trên Places`;
|
||||
const text =
|
||||
`${args.inviterName} mời bạn tham gia bộ sưu tập "${args.collectionName}" với quyền ${roleLabel}.\n\n` +
|
||||
`Mở liên kết để chấp nhận: ${args.inviteUrl}\n\n` +
|
||||
`Liên kết hết hạn sau 7 ngày.`;
|
||||
const html = `
|
||||
<!doctype html><html><body style="font-family: -apple-system, system-ui, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px; color: #1f1a14;">
|
||||
<h1 style="font-size: 22px; letter-spacing: -0.02em; margin: 0 0 8px;">Bạn vừa được mời</h1>
|
||||
<p style="font-size: 15px; line-height: 1.5; color: #5c4d3d; margin: 0 0 20px;">
|
||||
<b>${escapeHtml(args.inviterName)}</b> mời bạn tham gia bộ sưu tập
|
||||
<b>"${escapeHtml(args.collectionName)}"</b> trên Places với quyền <b>${roleLabel}</b>.
|
||||
</p>
|
||||
<a href="${args.inviteUrl}" style="display: inline-block; padding: 12px 20px; background: #b85a3a; color: #fff; text-decoration: none; border-radius: 10px; font-weight: 600;">
|
||||
Chấp nhận lời mời
|
||||
</a>
|
||||
<p style="font-size: 12px; color: #998472; margin-top: 24px;">
|
||||
Liên kết sẽ hết hạn sau 7 ngày. Nếu bạn không mong đợi lời mời này, bỏ qua email này.
|
||||
</p>
|
||||
</body></html>`.trim();
|
||||
return { to: "", subject, html, text };
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (c) =>
|
||||
c === "&"
|
||||
? "&"
|
||||
: c === "<"
|
||||
? "<"
|
||||
: c === ">"
|
||||
? ">"
|
||||
: c === '"'
|
||||
? """
|
||||
: "'",
|
||||
);
|
||||
}
|
||||
21
src/lib/maps.ts
Normal file
21
src/lib/maps.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Open Google Maps with lat/lng (or name + coords).
|
||||
// Following CLAUDE.md: never uses Google Maps API (not available in VN),
|
||||
// only deep-links to the public web/app surface.
|
||||
export function openGoogleMaps(args: {
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
query?: string;
|
||||
name?: string;
|
||||
}): void {
|
||||
let url: string;
|
||||
if (args.lat != null && args.lng != null) {
|
||||
url = args.name
|
||||
? `https://maps.google.com/maps?q=${encodeURIComponent(args.name)}&ll=${args.lat},${args.lng}`
|
||||
: `https://maps.google.com/?q=${args.lat},${args.lng}`;
|
||||
} else if (args.query) {
|
||||
url = `https://maps.google.com/maps?q=${encodeURIComponent(args.query)}`;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
window.open(url, "_blank", "noopener");
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
import type { CategoryId, CategoryMeta, Collection, Place, User } from "./types";
|
||||
|
||||
export const ME: User = {
|
||||
id: "u_me",
|
||||
name: "Minh Anh",
|
||||
email: "minhanh@places.app",
|
||||
avatar_url: null,
|
||||
initials: "MA",
|
||||
};
|
||||
|
||||
export const USERS: Record<string, User> = {
|
||||
u_me: ME,
|
||||
u_tung: { id: "u_tung", name: "Tùng Lâm", initials: "TL", avatar_url: null, color: "oklch(70% 0.12 145)" },
|
||||
u_linh: { id: "u_linh", name: "Linh Đan", initials: "LD", avatar_url: null, color: "oklch(70% 0.13 320)" },
|
||||
u_hung: { id: "u_hung", name: "Hùng Phạm", initials: "HP", avatar_url: null, color: "oklch(68% 0.12 245)" },
|
||||
u_thao: { id: "u_thao", name: "Thảo Vy", initials: "TV", avatar_url: null, color: "oklch(72% 0.11 35)" },
|
||||
};
|
||||
|
||||
export const PLACES: Place[] = [
|
||||
{
|
||||
id: "p_pho_gia_truyen",
|
||||
name: "Phở Gia Truyền Bát Đàn",
|
||||
address: "49 Bát Đàn, Hoàn Kiếm, Hà Nội",
|
||||
short_address: "49 Bát Đàn · Hoàn Kiếm",
|
||||
category: "food",
|
||||
tags: ["phở bò", "sáng", "cổ truyền"],
|
||||
cover_url: null,
|
||||
created_by: "u_me",
|
||||
created_at: "2026-04-12",
|
||||
my_rating: 5,
|
||||
my_notes: "Đi sớm trước 8h kẻo hết. Tái nạm gầu là chân ái.",
|
||||
visited: true,
|
||||
visited_at: "2026-04-14",
|
||||
avg_rating: 4.6,
|
||||
city: "Hà Nội",
|
||||
},
|
||||
{
|
||||
id: "p_bun_cha_huong_lien",
|
||||
name: "Bún chả Hương Liên",
|
||||
address: "24 Lê Văn Hưu, Hai Bà Trưng, Hà Nội",
|
||||
short_address: "24 Lê Văn Hưu · Hai Bà Trưng",
|
||||
category: "food",
|
||||
tags: ["bún chả", "combo Obama"],
|
||||
cover_url: "https://images.unsplash.com/photo-1606851094291-6efae152bb87?w=600&q=80",
|
||||
created_by: "u_tung",
|
||||
created_at: "2026-04-08",
|
||||
my_rating: 4,
|
||||
my_notes: "",
|
||||
visited: true,
|
||||
visited_at: "2026-04-20",
|
||||
avg_rating: 4.2,
|
||||
city: "Hà Nội",
|
||||
},
|
||||
{
|
||||
id: "p_cafe_giang",
|
||||
name: "Cà phê Giảng",
|
||||
address: "39 Nguyễn Hữu Huân, Hoàn Kiếm, Hà Nội",
|
||||
short_address: "39 Nguyễn Hữu Huân · Hoàn Kiếm",
|
||||
category: "cafe",
|
||||
tags: ["cà phê trứng", "cổ điển"],
|
||||
cover_url: "https://images.unsplash.com/photo-1442975631115-c4f7b05b8a2c?w=600&q=80",
|
||||
created_by: "u_me",
|
||||
created_at: "2026-04-02",
|
||||
my_rating: 5,
|
||||
my_notes: "Tầng 2 yên hơn. Trứng đánh bông kiểu cũ.",
|
||||
visited: true,
|
||||
visited_at: "2026-04-05",
|
||||
avg_rating: 4.7,
|
||||
city: "Hà Nội",
|
||||
},
|
||||
{
|
||||
id: "p_the_note",
|
||||
name: "The Note Coffee",
|
||||
address: "64 Lương Văn Can, Hoàn Kiếm, Hà Nội",
|
||||
short_address: "64 Lương Văn Can · Hoàn Kiếm",
|
||||
category: "cafe",
|
||||
tags: ["view hồ Gươm", "sticky notes"],
|
||||
cover_url: "https://images.unsplash.com/photo-1521017432531-fbd92d768814?w=600&q=80",
|
||||
created_by: "u_linh",
|
||||
created_at: "2026-03-28",
|
||||
my_rating: 4,
|
||||
visited: false,
|
||||
avg_rating: 4.1,
|
||||
city: "Hà Nội",
|
||||
},
|
||||
{
|
||||
id: "p_cong_nha_tho",
|
||||
name: "Cộng Cà Phê — Nhà Thờ",
|
||||
address: "27 Nhà Thờ, Hoàn Kiếm, Hà Nội",
|
||||
short_address: "27 Nhà Thờ · Hoàn Kiếm",
|
||||
category: "cafe",
|
||||
tags: ["cốt dừa", "concept bao cấp"],
|
||||
cover_url: "https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?w=600&q=80",
|
||||
created_by: "u_me",
|
||||
created_at: "2026-03-15",
|
||||
my_rating: 4,
|
||||
visited: true,
|
||||
visited_at: "2026-03-15",
|
||||
avg_rating: 4.0,
|
||||
city: "Hà Nội",
|
||||
},
|
||||
{
|
||||
id: "p_ta_hien",
|
||||
name: "Bia hơi Tạ Hiện",
|
||||
address: "Tạ Hiện, Hoàn Kiếm, Hà Nội",
|
||||
short_address: "Tạ Hiện · Hoàn Kiếm",
|
||||
category: "entertainment",
|
||||
tags: ["phố tây", "tối"],
|
||||
cover_url: "https://images.unsplash.com/photo-1514933651103-005eec06c04b?w=600&q=80",
|
||||
created_by: "u_hung",
|
||||
created_at: "2026-03-10",
|
||||
visited: false,
|
||||
avg_rating: 3.8,
|
||||
city: "Hà Nội",
|
||||
},
|
||||
{
|
||||
id: "p_trang_tien",
|
||||
name: "Kem Tràng Tiền",
|
||||
address: "35 Tràng Tiền, Hoàn Kiếm, Hà Nội",
|
||||
short_address: "35 Tràng Tiền · Hoàn Kiếm",
|
||||
category: "food",
|
||||
tags: ["kem", "tráng miệng"],
|
||||
cover_url: "https://images.unsplash.com/photo-1497034825429-c343d7c6a68f?w=600&q=80",
|
||||
created_by: "u_thao",
|
||||
created_at: "2026-03-05",
|
||||
my_rating: 3,
|
||||
visited: true,
|
||||
visited_at: "2026-03-06",
|
||||
avg_rating: 3.6,
|
||||
city: "Hà Nội",
|
||||
},
|
||||
{
|
||||
id: "p_banh_mi_huynh_hoa",
|
||||
name: "Bánh mì Huỳnh Hoa",
|
||||
address: "26 Lê Thị Riêng, Quận 1, TP. Hồ Chí Minh",
|
||||
short_address: "26 Lê Thị Riêng · Q1",
|
||||
category: "food",
|
||||
tags: ["bánh mì", "pate"],
|
||||
cover_url: null,
|
||||
created_by: "u_me",
|
||||
created_at: "2026-02-20",
|
||||
my_rating: 5,
|
||||
my_notes: "Béo, ngậy, đi 2 người ăn 1 ổ là vừa.",
|
||||
visited: true,
|
||||
visited_at: "2026-02-21",
|
||||
avg_rating: 4.5,
|
||||
city: "TP. HCM",
|
||||
},
|
||||
{
|
||||
id: "p_workshop",
|
||||
name: "The Workshop Coffee",
|
||||
address: "27 Ngô Đức Kế, Quận 1, TP. Hồ Chí Minh",
|
||||
short_address: "27 Ngô Đức Kế · Q1",
|
||||
category: "cafe",
|
||||
tags: ["specialty", "không gian rộng"],
|
||||
cover_url: "https://images.unsplash.com/photo-1453614512568-c4024d13c247?w=600&q=80",
|
||||
created_by: "u_linh",
|
||||
created_at: "2026-02-18",
|
||||
my_rating: 4,
|
||||
visited: false,
|
||||
avg_rating: 4.3,
|
||||
city: "TP. HCM",
|
||||
},
|
||||
{
|
||||
id: "p_pho_le",
|
||||
name: "Phở Lệ",
|
||||
address: "413 Nguyễn Trãi, Quận 5, TP. Hồ Chí Minh",
|
||||
short_address: "413 Nguyễn Trãi · Q5",
|
||||
category: "food",
|
||||
tags: ["phở Nam", "mở khuya"],
|
||||
cover_url: "https://images.unsplash.com/photo-1591814468924-caf88d1232e1?w=600&q=80",
|
||||
created_by: "u_tung",
|
||||
created_at: "2026-02-10",
|
||||
visited: false,
|
||||
avg_rating: 4.4,
|
||||
city: "TP. HCM",
|
||||
},
|
||||
{
|
||||
id: "p_ben_thanh",
|
||||
name: "Chợ Bến Thành",
|
||||
address: "Lê Lợi, Quận 1, TP. Hồ Chí Minh",
|
||||
short_address: "Lê Lợi · Q1",
|
||||
category: "shopping",
|
||||
tags: ["chợ", "đặc sản"],
|
||||
cover_url: "https://images.unsplash.com/photo-1555529669-e69e7aa0ba9a?w=600&q=80",
|
||||
created_by: "u_me",
|
||||
created_at: "2026-02-01",
|
||||
visited: true,
|
||||
visited_at: "2026-02-02",
|
||||
my_rating: 3,
|
||||
avg_rating: 3.5,
|
||||
city: "TP. HCM",
|
||||
},
|
||||
{
|
||||
id: "p_banh_mi_phuong",
|
||||
name: "Bánh mì Phượng",
|
||||
address: "2B Phan Châu Trinh, Hội An, Quảng Nam",
|
||||
short_address: "2B Phan Châu Trinh · Hội An",
|
||||
category: "food",
|
||||
tags: ["bánh mì", "huyền thoại"],
|
||||
cover_url: "https://images.unsplash.com/photo-1558030006-450675393462?w=600&q=80",
|
||||
created_by: "u_me",
|
||||
created_at: "2026-05-01",
|
||||
visited: false,
|
||||
avg_rating: 4.7,
|
||||
city: "Hội An",
|
||||
},
|
||||
{
|
||||
id: "p_reaching_out",
|
||||
name: "Reaching Out Tea House",
|
||||
address: "131 Trần Phú, Hội An, Quảng Nam",
|
||||
short_address: "131 Trần Phú · Hội An",
|
||||
category: "cafe",
|
||||
tags: ["trà", "yên tĩnh"],
|
||||
cover_url: "https://images.unsplash.com/photo-1556679343-c7306c1976bc?w=600&q=80",
|
||||
created_by: "u_linh",
|
||||
created_at: "2026-05-02",
|
||||
visited: false,
|
||||
avg_rating: 4.8,
|
||||
city: "Hội An",
|
||||
},
|
||||
{
|
||||
id: "p_my_quang",
|
||||
name: "Mỳ Quảng Bà Mua",
|
||||
address: "95 Nguyễn Tri Phương, Đà Nẵng",
|
||||
short_address: "95 Nguyễn Tri Phương · Đà Nẵng",
|
||||
category: "food",
|
||||
tags: ["mỳ Quảng", "đặc sản"],
|
||||
cover_url: "https://images.unsplash.com/photo-1569718212165-3a8278d5f624?w=600&q=80",
|
||||
created_by: "u_tung",
|
||||
created_at: "2026-05-03",
|
||||
visited: false,
|
||||
avg_rating: 4.3,
|
||||
city: "Đà Nẵng",
|
||||
},
|
||||
{
|
||||
id: "p_hoi_an_old_town",
|
||||
name: "Phố cổ Hội An",
|
||||
address: "Phố cổ, Hội An, Quảng Nam",
|
||||
short_address: "Phố cổ · Hội An",
|
||||
category: "entertainment",
|
||||
tags: ["di sản", "đèn lồng", "tối"],
|
||||
cover_url: "https://images.unsplash.com/photo-1540541338287-41700207dee6?w=600&q=80",
|
||||
created_by: "u_me",
|
||||
created_at: "2026-05-04",
|
||||
visited: false,
|
||||
avg_rating: 4.9,
|
||||
city: "Hội An",
|
||||
},
|
||||
];
|
||||
|
||||
export const COLLECTIONS: Collection[] = [
|
||||
{
|
||||
id: "c_ha_noi",
|
||||
name: "Hà Nội phải đi",
|
||||
type: "folder",
|
||||
owner_id: "u_me",
|
||||
member_count: 3,
|
||||
place_count: 7,
|
||||
my_role: "owner",
|
||||
cover_place_ids: ["p_pho_gia_truyen", "p_cafe_giang", "p_ta_hien"],
|
||||
place_ids: ["p_pho_gia_truyen", "p_bun_cha_huong_lien", "p_cafe_giang", "p_the_note", "p_cong_nha_tho", "p_ta_hien", "p_trang_tien"],
|
||||
members: ["u_me", "u_tung", "u_linh"],
|
||||
},
|
||||
{
|
||||
id: "c_hoi_an",
|
||||
name: "Hội An tháng 6",
|
||||
type: "trip",
|
||||
owner_id: "u_me",
|
||||
trip_start: "2026-06-12",
|
||||
trip_end: "2026-06-18",
|
||||
member_count: 4,
|
||||
place_count: 5,
|
||||
my_role: "owner",
|
||||
cover_place_ids: ["p_hoi_an_old_town", "p_banh_mi_phuong", "p_reaching_out"],
|
||||
place_ids: ["p_banh_mi_phuong", "p_reaching_out", "p_my_quang", "p_hoi_an_old_town", "p_cong_nha_tho"],
|
||||
members: ["u_me", "u_tung", "u_linh", "u_thao"],
|
||||
},
|
||||
{
|
||||
id: "c_cafe",
|
||||
name: "Quán cà phê cuối tuần",
|
||||
type: "folder",
|
||||
owner_id: "u_me",
|
||||
member_count: 1,
|
||||
place_count: 4,
|
||||
my_role: "owner",
|
||||
cover_place_ids: ["p_cafe_giang", "p_workshop", "p_the_note"],
|
||||
place_ids: ["p_cafe_giang", "p_workshop", "p_the_note", "p_cong_nha_tho"],
|
||||
members: ["u_me"],
|
||||
},
|
||||
{
|
||||
id: "c_sg_short",
|
||||
name: "Sài Gòn 2 ngày",
|
||||
type: "trip",
|
||||
owner_id: "u_hung",
|
||||
trip_start: "2026-05-23",
|
||||
trip_end: "2026-05-25",
|
||||
member_count: 5,
|
||||
place_count: 6,
|
||||
my_role: "viewer",
|
||||
cover_place_ids: ["p_banh_mi_huynh_hoa", "p_pho_le", "p_ben_thanh"],
|
||||
place_ids: ["p_banh_mi_huynh_hoa", "p_workshop", "p_pho_le", "p_ben_thanh"],
|
||||
members: ["u_hung", "u_me", "u_tung", "u_linh", "u_thao"],
|
||||
},
|
||||
];
|
||||
|
||||
export const CATEGORIES: Record<CategoryId, CategoryMeta> = {
|
||||
food: { label: "Ăn uống", icon: "Utensils", color: "var(--cat-food)" },
|
||||
cafe: { label: "Cà phê", icon: "Coffee", color: "var(--cat-cafe)" },
|
||||
shopping: { label: "Mua sắm", icon: "ShoppingBag", color: "var(--cat-shopping)" },
|
||||
entertainment: { label: "Giải trí", icon: "Sparkles", color: "var(--cat-entertainment)" },
|
||||
other: { label: "Khác", icon: "MapPin", color: "var(--cat-other)" },
|
||||
};
|
||||
|
||||
export const FILTERS: { id: string; label: string }[] = [
|
||||
{ id: "all", label: "Tất cả" },
|
||||
{ id: "food", label: "Ăn uống" },
|
||||
{ id: "cafe", label: "Cà phê" },
|
||||
{ id: "shopping", label: "Mua sắm" },
|
||||
{ id: "entertainment", label: "Giải trí" },
|
||||
{ id: "visited", label: "Đã đến" },
|
||||
{ id: "unvisited", label: "Chưa đến" },
|
||||
];
|
||||
59
src/lib/nominatim.ts
Normal file
59
src/lib/nominatim.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// Nominatim (OpenStreetMap) geocoding helpers.
|
||||
// Per CLAUDE.md: countrycodes=vn, Accept-Language: vi, rate-limit 1 req/s
|
||||
// (callers MUST debounce — see useDebouncedNominatim below).
|
||||
|
||||
export type NominatimResult = {
|
||||
place_id: number;
|
||||
display_name: string;
|
||||
lat: string;
|
||||
lon: string;
|
||||
address?: Record<string, string>;
|
||||
};
|
||||
|
||||
const BASE = "https://nominatim.openstreetmap.org";
|
||||
const HEADERS: HeadersInit = { "Accept-Language": "vi" };
|
||||
|
||||
export async function searchAddress(
|
||||
query: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NominatimResult[]> {
|
||||
const q = query.trim();
|
||||
if (!q) return [];
|
||||
const url = `${BASE}/search?q=${encodeURIComponent(q)}&format=json&limit=5&countrycodes=vn&addressdetails=1`;
|
||||
const res = await fetch(url, { headers: HEADERS, signal });
|
||||
if (!res.ok) return [];
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function reverseGeocode(
|
||||
lat: number,
|
||||
lng: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NominatimResult | null> {
|
||||
const url = `${BASE}/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`;
|
||||
const res = await fetch(url, { headers: HEADERS, signal });
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Build a Place-friendly representation from a Nominatim hit.
|
||||
export function toPlaceFields(r: NominatimResult): {
|
||||
address: string;
|
||||
short_address: string;
|
||||
city: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
} {
|
||||
const a = r.address || {};
|
||||
const houseLine = [a.house_number, a.road].filter(Boolean).join(" ");
|
||||
const district = a.suburb || a.city_district || a.district || a.county;
|
||||
const city = a.city || a.town || a.village || a.state || "";
|
||||
const short = [houseLine, district].filter(Boolean).join(" · ") || r.display_name.split(",")[0];
|
||||
return {
|
||||
address: r.display_name,
|
||||
short_address: short,
|
||||
city,
|
||||
lat: Number(r.lat),
|
||||
lng: Number(r.lon),
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export type CollectionType = "folder" | "trip";
|
||||
export type Role = "owner" | "editor" | "viewer";
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
id: number;
|
||||
name: string;
|
||||
email?: string;
|
||||
avatar_url?: string | null;
|
||||
@@ -14,14 +14,14 @@ export type User = {
|
||||
};
|
||||
|
||||
export type Place = {
|
||||
id: string;
|
||||
id: number;
|
||||
name: string;
|
||||
address: string;
|
||||
short_address: string;
|
||||
category: CategoryId;
|
||||
tags: string[];
|
||||
cover_url?: string | null;
|
||||
created_by: string;
|
||||
created_by: number;
|
||||
created_at: string;
|
||||
my_rating?: number;
|
||||
my_notes?: string;
|
||||
@@ -32,18 +32,18 @@ export type Place = {
|
||||
};
|
||||
|
||||
export type Collection = {
|
||||
id: string;
|
||||
id: number;
|
||||
name: string;
|
||||
type: CollectionType;
|
||||
owner_id: string;
|
||||
owner_id: number;
|
||||
trip_start?: string;
|
||||
trip_end?: string;
|
||||
member_count: number;
|
||||
place_count: number;
|
||||
my_role: Role;
|
||||
cover_place_ids: string[];
|
||||
place_ids: string[];
|
||||
members: string[];
|
||||
cover_place_ids: number[];
|
||||
place_ids: number[];
|
||||
members: number[];
|
||||
};
|
||||
|
||||
export type CategoryMeta = {
|
||||
|
||||
22
src/lib/ui-config.ts
Normal file
22
src/lib/ui-config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Static UI config (not user data). Categories + filter chip labels are
|
||||
// shared between screens; they're not stored in the DB.
|
||||
|
||||
import type { CategoryId, CategoryMeta } from "./types";
|
||||
|
||||
export const CATEGORIES: Record<CategoryId, CategoryMeta> = {
|
||||
food: { label: "Ăn uống", icon: "Utensils", color: "var(--cat-food)" },
|
||||
cafe: { label: "Cà phê", icon: "Coffee", color: "var(--cat-cafe)" },
|
||||
shopping: { label: "Mua sắm", icon: "ShoppingBag", color: "var(--cat-shopping)" },
|
||||
entertainment: { label: "Giải trí", icon: "Sparkles", color: "var(--cat-entertainment)" },
|
||||
other: { label: "Khác", icon: "MapPin", color: "var(--cat-other)" },
|
||||
};
|
||||
|
||||
export const FILTERS: { id: string; label: string }[] = [
|
||||
{ id: "all", label: "Tất cả" },
|
||||
{ id: "food", label: "Ăn uống" },
|
||||
{ id: "cafe", label: "Cà phê" },
|
||||
{ id: "shopping", label: "Mua sắm" },
|
||||
{ id: "entertainment", label: "Giải trí" },
|
||||
{ id: "visited", label: "Đã đến" },
|
||||
{ id: "unvisited", label: "Chưa đến" },
|
||||
];
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { COLLECTIONS } from "@/lib/mock-data";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useCollections } from "@/lib/app-context";
|
||||
import { fmtShortDate, tripDays } from "@/lib/format";
|
||||
import { toggleVisited } from "@/lib/db/actions";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import type { AppState, Dispatch } from "@/lib/app-state";
|
||||
import {
|
||||
Header,
|
||||
@@ -19,14 +21,16 @@ export function CollectionDetailScreen({
|
||||
state,
|
||||
dispatch,
|
||||
}: {
|
||||
state: AppState & { collectionId?: string };
|
||||
state: AppState & { collectionId?: number };
|
||||
dispatch: Dispatch;
|
||||
}) {
|
||||
const c = COLLECTIONS.find((x) => x.id === state.collectionId);
|
||||
const collections = useCollections();
|
||||
const c = collections.find((x) => x.id === state.collectionId);
|
||||
const [vfilter, setVfilter] = useState<"all" | "visited" | "unvisited">(
|
||||
"all",
|
||||
);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [, startTransition] = useTransition();
|
||||
if (!c) return null;
|
||||
const places = c.place_ids
|
||||
.map((id) => state.places.find((p) => p.id === id))
|
||||
@@ -291,9 +295,15 @@ export function CollectionDetailScreen({
|
||||
trailing={
|
||||
<Checkbox
|
||||
checked={p.visited}
|
||||
onClick={() =>
|
||||
dispatch({ type: "TOGGLE_VISITED", placeId: p.id })
|
||||
}
|
||||
onClick={() => {
|
||||
dispatch({ type: "TOGGLE_VISITED", placeId: p.id });
|
||||
startTransition(() => {
|
||||
toggleVisited(p.id).catch(() => {
|
||||
dispatch({ type: "TOGGLE_VISITED", placeId: p.id });
|
||||
dispatch({ type: "TOAST", value: "Lưu thất bại" });
|
||||
});
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -308,13 +318,13 @@ export function CollectionDetailScreen({
|
||||
<div className="sheet" style={{ maxHeight: "60%" }}>
|
||||
<div className="sheet-handle" />
|
||||
<div style={{ padding: "8px 0 16px" }}>
|
||||
{!isViewer && (
|
||||
{c.my_role === "owner" && (
|
||||
<MenuItem
|
||||
icon="Edit"
|
||||
label="Sửa thông tin"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
dispatch({ type: "TOAST", value: "Sửa (demo)" });
|
||||
dispatch({ type: "OPEN_EDIT_COLLECTION", collectionId: c.id });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -339,9 +349,14 @@ export function CollectionDetailScreen({
|
||||
<MenuItem
|
||||
icon="Share"
|
||||
label="Chia sẻ bộ sưu tập"
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
setMenuOpen(false);
|
||||
dispatch({ type: "TOAST", value: "Đã sao chép liên kết" });
|
||||
const url = `${window.location.origin}/collections/${c.id}`;
|
||||
const ok = await copyToClipboard(url);
|
||||
dispatch({
|
||||
type: "TOAST",
|
||||
value: ok ? "Đã sao chép liên kết" : "Không sao chép được",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{c.my_role === "owner" && (
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { type CSSProperties, useState } from "react";
|
||||
import { COLLECTIONS, PLACES } from "@/lib/mock-data";
|
||||
import { useCollections } from "@/lib/app-context";
|
||||
import { fmtShortDate } from "@/lib/format";
|
||||
import type { AppState, Dispatch } from "@/lib/app-state";
|
||||
import type { Collection } from "@/lib/types";
|
||||
import type { Collection, Place } from "@/lib/types";
|
||||
import { Header, IconBtn } from "@/components/ui-primitives";
|
||||
import { CoverImage } from "@/components/cover-image";
|
||||
import { AvatarStack } from "@/components/avatar";
|
||||
import { Icons } from "@/components/icons";
|
||||
|
||||
export function CollectionsListScreen({
|
||||
state,
|
||||
dispatch,
|
||||
}: {
|
||||
state: AppState;
|
||||
dispatch: Dispatch;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"all" | "trips" | "folders">("all");
|
||||
const filtered = COLLECTIONS.filter((c) => {
|
||||
const allCollections = useCollections();
|
||||
const filtered = allCollections.filter((c) => {
|
||||
if (tab === "all") return true;
|
||||
if (tab === "trips") return c.type === "trip";
|
||||
return c.type === "folder";
|
||||
@@ -28,15 +30,13 @@ export function CollectionsListScreen({
|
||||
<Header
|
||||
big
|
||||
title="Bộ sưu tập"
|
||||
subtitle={`${COLLECTIONS.length} bộ sưu tập · ${COLLECTIONS.filter((c) => c.my_role !== "owner").length} được chia sẻ`}
|
||||
subtitle={`${allCollections.length} bộ sưu tập · ${allCollections.filter((c) => c.my_role !== "owner").length} được chia sẻ`}
|
||||
right={
|
||||
<IconBtn
|
||||
icon="Plus"
|
||||
label="Tạo mới"
|
||||
variant="muted"
|
||||
onClick={() =>
|
||||
dispatch({ type: "TOAST", value: "Tạo bộ sưu tập mới (demo)" })
|
||||
}
|
||||
onClick={() => dispatch({ type: "OPEN_CREATE_COLLECTION" })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -73,6 +73,7 @@ export function CollectionsListScreen({
|
||||
<CollectionCard
|
||||
key={c.id}
|
||||
c={c}
|
||||
places={state.places}
|
||||
onTap={() =>
|
||||
dispatch({
|
||||
type: "NAV",
|
||||
@@ -91,15 +92,17 @@ export function CollectionsListScreen({
|
||||
|
||||
function CollectionCard({
|
||||
c,
|
||||
places: allPlaces,
|
||||
onTap,
|
||||
style,
|
||||
}: {
|
||||
c: Collection;
|
||||
places: Place[];
|
||||
onTap: () => void;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
const places = c.cover_place_ids
|
||||
.map((id) => PLACES.find((p) => p.id === id))
|
||||
.map((id) => allPlaces.find((p) => p.id === id))
|
||||
.filter((p): p is NonNullable<typeof p> => Boolean(p));
|
||||
const isTrip = c.type === "trip";
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CATEGORIES, COLLECTIONS, USERS } from "@/lib/mock-data";
|
||||
import { useState, useTransition } from "react";
|
||||
import { CATEGORIES } from "@/lib/ui-config";
|
||||
import { useCollections, useMe, useUsers } from "@/lib/app-context";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import { setNotes as saveNotes, setRating, toggleVisited } from "@/lib/db/actions";
|
||||
import { openGoogleMaps } from "@/lib/maps";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import type { AppState, Dispatch } from "@/lib/app-state";
|
||||
import { IconBtn, Checkbox, MenuItem } from "@/components/ui-primitives";
|
||||
import { CoverImage } from "@/components/cover-image";
|
||||
@@ -14,19 +18,23 @@ export function PlaceDetailScreen({
|
||||
state,
|
||||
dispatch,
|
||||
}: {
|
||||
state: AppState & { placeId?: string };
|
||||
state: AppState & { placeId?: number };
|
||||
dispatch: Dispatch;
|
||||
}) {
|
||||
const place = state.places.find((p) => p.id === state.placeId);
|
||||
const [notes, setNotes] = useState(place?.my_notes || "");
|
||||
const [notes, setNotesLocal] = useState(place?.my_notes || "");
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [, startTransition] = useTransition();
|
||||
const collections = useCollections();
|
||||
const users = useUsers();
|
||||
const me = useMe();
|
||||
if (!place) return null;
|
||||
const cat = CATEGORIES[place.category];
|
||||
const CatIcon = Icons[cat.icon as keyof typeof Icons];
|
||||
const collectionsContaining = COLLECTIONS.filter((c) =>
|
||||
const collectionsContaining = collections.filter((c) =>
|
||||
c.place_ids.includes(place.id),
|
||||
);
|
||||
const creator = USERS[place.created_by];
|
||||
const creator = users[place.created_by];
|
||||
|
||||
return (
|
||||
<div className="app-surface" style={{ background: "var(--background)" }}>
|
||||
@@ -74,9 +82,14 @@ export function PlaceDetailScreen({
|
||||
icon="Share"
|
||||
label="Chia sẻ"
|
||||
variant="glass"
|
||||
onClick={() =>
|
||||
dispatch({ type: "TOAST", value: "Đã sao chép liên kết" })
|
||||
}
|
||||
onClick={async () => {
|
||||
const url = `${window.location.origin}/places/${place.id}`;
|
||||
const ok = await copyToClipboard(url);
|
||||
dispatch({
|
||||
type: "TOAST",
|
||||
value: ok ? "Đã sao chép liên kết" : "Không sao chép được",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<IconBtn
|
||||
icon="MoreHorizontal"
|
||||
@@ -160,9 +173,7 @@ export function PlaceDetailScreen({
|
||||
|
||||
{/* Address row */}
|
||||
<button
|
||||
onClick={() =>
|
||||
dispatch({ type: "TOAST", value: "Đang mở Google Maps..." })
|
||||
}
|
||||
onClick={() => openGoogleMaps({ query: place.address, name: place.name })}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -243,9 +254,15 @@ export function PlaceDetailScreen({
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={place.visited}
|
||||
onClick={() =>
|
||||
dispatch({ type: "TOGGLE_VISITED", placeId: place.id })
|
||||
}
|
||||
onClick={() => {
|
||||
dispatch({ type: "TOGGLE_VISITED", placeId: place.id });
|
||||
startTransition(() => {
|
||||
toggleVisited(place.id).catch(() => {
|
||||
dispatch({ type: "TOGGLE_VISITED", placeId: place.id });
|
||||
dispatch({ type: "TOAST", value: "Lưu thất bại" });
|
||||
});
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="divider" />
|
||||
@@ -264,13 +281,18 @@ export function PlaceDetailScreen({
|
||||
value={place.my_rating || 0}
|
||||
readOnly={false}
|
||||
size={22}
|
||||
onChange={(v) =>
|
||||
onChange={(v) => {
|
||||
dispatch({
|
||||
type: "SET_RATING",
|
||||
placeId: place.id,
|
||||
value: v,
|
||||
})
|
||||
}
|
||||
});
|
||||
startTransition(() => {
|
||||
setRating(place.id, v).catch(() =>
|
||||
dispatch({ type: "TOAST", value: "Lưu thất bại" }),
|
||||
);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -328,14 +350,19 @@ export function PlaceDetailScreen({
|
||||
<textarea
|
||||
value={notes}
|
||||
placeholder="Thêm ghi chú riêng — chỉ mình bạn thấy..."
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
onBlur={() =>
|
||||
onChange={(e) => setNotesLocal(e.target.value)}
|
||||
onBlur={() => {
|
||||
dispatch({
|
||||
type: "SET_NOTES",
|
||||
placeId: place.id,
|
||||
value: notes,
|
||||
})
|
||||
}
|
||||
});
|
||||
startTransition(() => {
|
||||
saveNotes(place.id, notes).catch(() =>
|
||||
dispatch({ type: "TOAST", value: "Lưu thất bại" }),
|
||||
);
|
||||
});
|
||||
}}
|
||||
style={{ resize: "none", minHeight: 72 }}
|
||||
/>
|
||||
</div>
|
||||
@@ -419,34 +446,41 @@ export function PlaceDetailScreen({
|
||||
<div className="sheet" style={{ maxHeight: "50%" }}>
|
||||
<div className="sheet-handle" />
|
||||
<div style={{ padding: "8px 0 16px" }}>
|
||||
<MenuItem
|
||||
icon="Edit"
|
||||
label="Sửa địa điểm"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
dispatch({ type: "TOAST", value: "Tính năng sửa (demo)" });
|
||||
}}
|
||||
/>
|
||||
{place.created_by === me.id && (
|
||||
<MenuItem
|
||||
icon="Edit"
|
||||
label="Sửa địa điểm"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
dispatch({ type: "OPEN_EDIT_PLACE", placeId: place.id });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
icon="Bookmark"
|
||||
label="Lưu vào bộ sưu tập"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
dispatch({
|
||||
type: "TOAST",
|
||||
value: 'Đã thêm vào "Hà Nội phải đi"',
|
||||
type: "OPEN_SAVE_TO_COLLECTION",
|
||||
placeId: place.id,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
icon="Share"
|
||||
label="Chia sẻ"
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
setMenuOpen(false);
|
||||
dispatch({ type: "TOAST", value: "Đã sao chép liên kết" });
|
||||
const url = `${window.location.origin}/places/${place.id}`;
|
||||
const ok = await copyToClipboard(url);
|
||||
dispatch({
|
||||
type: "TOAST",
|
||||
value: ok ? "Đã sao chép liên kết" : "Không sao chép được",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{place.created_by === "u_me" && (
|
||||
{place.created_by === me.id && (
|
||||
<MenuItem
|
||||
icon="Trash"
|
||||
label="Xóa địa điểm"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { FILTERS } from "@/lib/mock-data";
|
||||
import { FILTERS } from "@/lib/ui-config";
|
||||
import type { AppState, Dispatch } from "@/lib/app-state";
|
||||
import { Header, IconBtn, OfflineBanner, EmptyState } from "@/components/ui-primitives";
|
||||
import { PlaceCard } from "@/components/place-card";
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { COLLECTIONS, ME } from "@/lib/mock-data";
|
||||
import { useTransition } from "react";
|
||||
import { useCollections, useMe } from "@/lib/app-context";
|
||||
import type { AppState, Dispatch } from "@/lib/app-state";
|
||||
import { Header, IconBtn } from "@/components/ui-primitives";
|
||||
import { Icons, type IconName } from "@/components/icons";
|
||||
import { logoutAction } from "@/app/(auth)/auth-actions";
|
||||
|
||||
export function ProfileScreen({ state }: { state: AppState; dispatch: Dispatch }) {
|
||||
const me = useMe();
|
||||
const collections = useCollections();
|
||||
const [signingOut, startSignOut] = useTransition();
|
||||
const stats = [
|
||||
{ label: "Địa điểm", value: state.places.length },
|
||||
{ label: "Đã đến", value: state.places.filter((p) => p.visited).length },
|
||||
{ label: "Bộ sưu tập", value: COLLECTIONS.length },
|
||||
{ label: "Bộ sưu tập", value: collections.length },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -51,7 +56,7 @@ export function ProfileScreen({ state }: { state: AppState; dispatch: Dispatch }
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{ME.initials}
|
||||
{me.initials}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
@@ -61,12 +66,12 @@ export function ProfileScreen({ state }: { state: AppState; dispatch: Dispatch }
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
{ME.name}
|
||||
{me.name}
|
||||
</div>
|
||||
<div
|
||||
style={{ fontSize: 13, color: "var(--muted-foreground)" }}
|
||||
>
|
||||
{ME.email}
|
||||
{me.email}
|
||||
</div>
|
||||
</div>
|
||||
<IconBtn icon="Edit2" label="Sửa" />
|
||||
@@ -124,7 +129,14 @@ export function ProfileScreen({ state }: { state: AppState; dispatch: Dispatch }
|
||||
<SettingRow icon="Bell" label="Thông báo" detail="Bật" />
|
||||
<SettingRow icon="Globe" label="Ngôn ngữ" detail="Tiếng Việt" />
|
||||
<SettingRow icon="Settings" label="Cài đặt" />
|
||||
<SettingRow icon="LogOut" label="Đăng xuất" danger last />
|
||||
<SettingRow
|
||||
icon="LogOut"
|
||||
label={signingOut ? "Đang đăng xuất..." : "Đăng xuất"}
|
||||
danger
|
||||
last
|
||||
onClick={() => startSignOut(() => logoutAction())}
|
||||
disabled={signingOut}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -149,17 +161,24 @@ function SettingRow({
|
||||
detail,
|
||||
last,
|
||||
danger,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
icon: IconName;
|
||||
label: string;
|
||||
detail?: string;
|
||||
last?: boolean;
|
||||
danger?: boolean;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const I = Icons[icon];
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
@@ -170,6 +189,7 @@ function SettingRow({
|
||||
border: 0,
|
||||
textAlign: "left",
|
||||
color: danger ? "var(--danger)" : "var(--foreground)",
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<I size={20} stroke={1.75} />
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CATEGORIES } from "@/lib/mock-data";
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { CATEGORIES } from "@/lib/ui-config";
|
||||
import type { CategoryId } from "@/lib/types";
|
||||
import type { Dispatch } from "@/lib/app-state";
|
||||
import { addPlace } from "@/lib/db/actions";
|
||||
import {
|
||||
type NominatimResult,
|
||||
reverseGeocode,
|
||||
searchAddress,
|
||||
toPlaceFields,
|
||||
} from "@/lib/nominatim";
|
||||
import { FieldLabel } from "@/components/ui-primitives";
|
||||
import { RatingStars } from "@/components/rating-stars";
|
||||
import { Icons } from "@/components/icons";
|
||||
|
||||
const ADDRESS_SUGG = [
|
||||
{ addr: "12 Nhà Thờ, Hoàn Kiếm, Hà Nội", sub: "Hà Nội · 0.8 km" },
|
||||
{ addr: "49 Bát Đàn, Hoàn Kiếm, Hà Nội", sub: "Hà Nội · 1.2 km" },
|
||||
{ addr: "128 Mã Mây, Hoàn Kiếm, Hà Nội", sub: "Hà Nội · 1.5 km" },
|
||||
{ addr: "24 Lý Quốc Sư, Hoàn Kiếm, Hà Nội", sub: "Hà Nội · 1.8 km" },
|
||||
];
|
||||
|
||||
export function AddPlaceSheet({
|
||||
onClose,
|
||||
dispatch,
|
||||
@@ -24,26 +24,79 @@ export function AddPlaceSheet({
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [address, setAddress] = useState("");
|
||||
const [picked, setPicked] = useState<NominatimResult | null>(null);
|
||||
const [suggestions, setSuggestions] = useState<NominatimResult[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [showSugg, setShowSugg] = useState(false);
|
||||
const [geoLoading, setGeoLoading] = useState(false);
|
||||
const [category, setCategory] = useState<CategoryId>("food");
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [rating, setRating] = useState(0);
|
||||
const [cover, setCover] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const suggestions = address
|
||||
? ADDRESS_SUGG.filter((s) =>
|
||||
s.addr.toLowerCase().includes(address.toLowerCase()),
|
||||
)
|
||||
: ADDRESS_SUGG;
|
||||
// Debounced Nominatim search — CLAUDE.md: rate-limit 1 req/s + cancel in-flight.
|
||||
const lastReqRef = useRef<AbortController | null>(null);
|
||||
useEffect(() => {
|
||||
const q = address.trim();
|
||||
if (!q || q.length < 3 || picked) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
const ac = new AbortController();
|
||||
lastReqRef.current?.abort();
|
||||
lastReqRef.current = ac;
|
||||
const id = setTimeout(async () => {
|
||||
setSearching(true);
|
||||
try {
|
||||
const rows = await searchAddress(q, ac.signal);
|
||||
if (!ac.signal.aborted) setSuggestions(rows);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
if (!ac.signal.aborted) setSearching(false);
|
||||
}
|
||||
}, 500);
|
||||
return () => {
|
||||
clearTimeout(id);
|
||||
ac.abort();
|
||||
};
|
||||
}, [address, picked]);
|
||||
|
||||
const useCurrentLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
dispatch({ type: "TOAST", value: "Trình duyệt không hỗ trợ định vị" });
|
||||
return;
|
||||
}
|
||||
setGeoLoading(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (pos) => {
|
||||
try {
|
||||
const r = await reverseGeocode(pos.coords.latitude, pos.coords.longitude);
|
||||
if (r) {
|
||||
setAddress(r.display_name);
|
||||
setPicked(r);
|
||||
setShowSugg(false);
|
||||
} else {
|
||||
dispatch({ type: "TOAST", value: "Không tra được địa chỉ" });
|
||||
}
|
||||
} finally {
|
||||
setGeoLoading(false);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
setGeoLoading(false);
|
||||
dispatch({ type: "TOAST", value: "Không lấy được vị trí" });
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 8000 },
|
||||
);
|
||||
};
|
||||
|
||||
const addTag = (raw: string) => {
|
||||
const t = raw.trim().replace(/,$/, "");
|
||||
if (t && !tags.includes(t) && tags.length < 10) {
|
||||
setTags([...tags, t]);
|
||||
}
|
||||
if (t && !tags.includes(t) && tags.length < 10) setTags([...tags, t]);
|
||||
setTagInput("");
|
||||
};
|
||||
const removeTag = (t: string) => setTags(tags.filter((x) => x !== t));
|
||||
@@ -53,29 +106,39 @@ export function AddPlaceSheet({
|
||||
const submit = () => {
|
||||
if (!isValid) return;
|
||||
setSubmitting(true);
|
||||
setTimeout(() => {
|
||||
dispatch({
|
||||
type: "ADD_PLACE",
|
||||
place: {
|
||||
id: "p_new_" + Date.now(),
|
||||
name: name.trim(),
|
||||
address: address.trim(),
|
||||
short_address: address.trim().split(",").slice(0, 2).join(" · "),
|
||||
category,
|
||||
tags,
|
||||
cover_url: cover,
|
||||
created_by: "u_me",
|
||||
created_at: new Date().toISOString().slice(0, 10),
|
||||
my_rating: rating || undefined,
|
||||
my_notes: notes || undefined,
|
||||
visited: false,
|
||||
avg_rating: undefined,
|
||||
city: address.split(",").pop()?.trim() || "",
|
||||
},
|
||||
});
|
||||
onClose();
|
||||
dispatch({ type: "TOAST", value: `Đã lưu "${name}"` });
|
||||
}, 500);
|
||||
const trimmedName = name.trim();
|
||||
const fields = picked
|
||||
? toPlaceFields(picked)
|
||||
: (() => {
|
||||
const a = address.trim();
|
||||
return {
|
||||
address: a,
|
||||
short_address: a.split(",").slice(0, 2).join(" · "),
|
||||
city: a.split(",").pop()?.trim() || "",
|
||||
};
|
||||
})();
|
||||
startTransition(() => {
|
||||
addPlace({
|
||||
name: trimmedName,
|
||||
address: fields.address,
|
||||
short_address: fields.short_address,
|
||||
city: fields.city,
|
||||
category,
|
||||
tags,
|
||||
cover_url: null,
|
||||
rating: rating || undefined,
|
||||
notes: notes || undefined,
|
||||
})
|
||||
.then((place) => {
|
||||
dispatch({ type: "ADD_PLACE", place });
|
||||
onClose();
|
||||
dispatch({ type: "TOAST", value: `Đã lưu "${trimmedName}"` });
|
||||
})
|
||||
.catch(() => {
|
||||
setSubmitting(false);
|
||||
dispatch({ type: "TOAST", value: "Lưu thất bại" });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -109,76 +172,6 @@ export function AddPlaceSheet({
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "4px 16px" }}>
|
||||
{/* Cover upload */}
|
||||
<button
|
||||
onClick={() =>
|
||||
setCover(
|
||||
"https://images.unsplash.com/photo-1559925393-8be0ec4767c8?w=600&q=80",
|
||||
)
|
||||
}
|
||||
style={{
|
||||
width: "100%",
|
||||
aspectRatio: "16 / 9",
|
||||
borderRadius: "var(--radius-lg)",
|
||||
background: cover ? "transparent" : "var(--muted)",
|
||||
border: cover ? "0" : "1.5px dashed var(--border-strong)",
|
||||
color: "var(--muted-foreground)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
padding: 0,
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{cover ? (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={cover}
|
||||
alt=""
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCover(null);
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 9999,
|
||||
border: 0,
|
||||
background: "rgba(20,16,10,0.55)",
|
||||
color: "#fff",
|
||||
backdropFilter: "blur(12px)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Icons.X size={16} stroke={2} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icons.Camera size={20} stroke={1.75} />
|
||||
Thêm ảnh
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Name */}
|
||||
<FieldLabel required>Tên địa điểm</FieldLabel>
|
||||
<div className="input">
|
||||
<input
|
||||
@@ -188,7 +181,6 @@ export function AddPlaceSheet({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<FieldLabel required>Địa chỉ</FieldLabel>
|
||||
<div className="input">
|
||||
<Icons.MapPin
|
||||
@@ -200,16 +192,15 @@ export function AddPlaceSheet({
|
||||
value={address}
|
||||
onChange={(e) => {
|
||||
setAddress(e.target.value);
|
||||
setPicked(null);
|
||||
setShowSugg(true);
|
||||
}}
|
||||
onFocus={() => setShowSugg(true)}
|
||||
placeholder="Số nhà, đường, quận, tỉnh"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
setAddress("Vị trí hiện tại · 39 Nguyễn Hữu Huân, Hoàn Kiếm");
|
||||
setShowSugg(false);
|
||||
}}
|
||||
onClick={useCurrentLocation}
|
||||
disabled={geoLoading}
|
||||
style={{
|
||||
background: "var(--muted)",
|
||||
border: 0,
|
||||
@@ -220,13 +211,14 @@ export function AddPlaceSheet({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--primary)",
|
||||
opacity: geoLoading ? 0.6 : 1,
|
||||
}}
|
||||
aria-label="Lấy vị trí hiện tại"
|
||||
>
|
||||
<Icons.Crosshair size={16} stroke={2} />
|
||||
</button>
|
||||
</div>
|
||||
{showSugg && address && (
|
||||
{showSugg && address.length >= 3 && !picked && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 6,
|
||||
@@ -237,11 +229,34 @@ export function AddPlaceSheet({
|
||||
boxShadow: "var(--shadow-md)",
|
||||
}}
|
||||
>
|
||||
{searching && suggestions.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
fontSize: 13,
|
||||
color: "var(--muted-foreground)",
|
||||
}}
|
||||
>
|
||||
Đang tìm...
|
||||
</div>
|
||||
)}
|
||||
{!searching && suggestions.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
fontSize: 13,
|
||||
color: "var(--muted-foreground)",
|
||||
}}
|
||||
>
|
||||
Không có gợi ý
|
||||
</div>
|
||||
)}
|
||||
{suggestions.slice(0, 4).map((s, i) => (
|
||||
<button
|
||||
key={i}
|
||||
key={s.place_id}
|
||||
onClick={() => {
|
||||
setAddress(s.addr);
|
||||
setAddress(s.display_name);
|
||||
setPicked(s);
|
||||
setShowSugg(false);
|
||||
}}
|
||||
style={{
|
||||
@@ -253,16 +268,16 @@ export function AddPlaceSheet({
|
||||
background: "transparent",
|
||||
border: 0,
|
||||
textAlign: "left",
|
||||
borderBottom: i < 3 ? "0.5px solid var(--border)" : 0,
|
||||
borderBottom:
|
||||
i < Math.min(3, suggestions.length - 1)
|
||||
? "0.5px solid var(--border)"
|
||||
: 0,
|
||||
}}
|
||||
>
|
||||
<Icons.MapPin
|
||||
size={16}
|
||||
stroke={1.75}
|
||||
style={{
|
||||
color: "var(--muted-foreground)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
style={{ color: "var(--muted-foreground)", flexShrink: 0 }}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
@@ -274,15 +289,7 @@ export function AddPlaceSheet({
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{s.addr}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--muted-foreground)",
|
||||
}}
|
||||
>
|
||||
{s.sub}
|
||||
{s.display_name}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -290,7 +297,6 @@ export function AddPlaceSheet({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category */}
|
||||
<FieldLabel required>Danh mục</FieldLabel>
|
||||
<div className="toggle-group">
|
||||
{(Object.entries(CATEGORIES) as [CategoryId, typeof CATEGORIES[CategoryId]][]).map(
|
||||
@@ -310,12 +316,9 @@ export function AddPlaceSheet({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<FieldLabel>
|
||||
Thẻ{" "}
|
||||
<span
|
||||
style={{ color: "var(--subtle-foreground)", fontWeight: 400 }}
|
||||
>
|
||||
<span style={{ color: "var(--subtle-foreground)", fontWeight: 400 }}>
|
||||
· {tags.length}/10
|
||||
</span>
|
||||
</FieldLabel>
|
||||
@@ -376,7 +379,6 @@ export function AddPlaceSheet({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<FieldLabel>
|
||||
<Icons.Lock size={12} stroke={2.5} style={{ marginRight: 4 }} />
|
||||
Ghi chú riêng tư
|
||||
@@ -390,12 +392,9 @@ export function AddPlaceSheet({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rating */}
|
||||
<FieldLabel>
|
||||
Đánh giá{" "}
|
||||
<span
|
||||
style={{ color: "var(--subtle-foreground)", fontWeight: 400 }}
|
||||
>
|
||||
<span style={{ color: "var(--subtle-foreground)", fontWeight: 400 }}>
|
||||
· tuỳ chọn
|
||||
</span>
|
||||
</FieldLabel>
|
||||
@@ -411,7 +410,6 @@ export function AddPlaceSheet({
|
||||
<div style={{ height: 8 }} />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px 4px",
|
||||
|
||||
184
src/sheets/collection-form-sheet.tsx
Normal file
184
src/sheets/collection-form-sheet.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import type { Collection, CollectionType } from "@/lib/types";
|
||||
import type { Dispatch } from "@/lib/app-state";
|
||||
import { FieldLabel } from "@/components/ui-primitives";
|
||||
import { Icons } from "@/components/icons";
|
||||
import { createCollection, editCollection } from "@/lib/db/actions";
|
||||
|
||||
export function CollectionFormSheet({
|
||||
mode,
|
||||
collection,
|
||||
onClose,
|
||||
dispatch,
|
||||
}: {
|
||||
mode: "create" | "edit";
|
||||
collection?: Collection;
|
||||
onClose: () => void;
|
||||
dispatch: Dispatch;
|
||||
}) {
|
||||
const [name, setName] = useState(collection?.name ?? "");
|
||||
const [type, setType] = useState<CollectionType>(collection?.type ?? "folder");
|
||||
const [start, setStart] = useState(collection?.trip_start ?? "");
|
||||
const [end, setEnd] = useState(collection?.trip_end ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const isValid = name.trim().length > 0 &&
|
||||
(type === "folder" || (start && end));
|
||||
|
||||
const submit = () => {
|
||||
if (!isValid) return;
|
||||
setSaving(true);
|
||||
const input = {
|
||||
name: name.trim(),
|
||||
type,
|
||||
trip_start: type === "trip" ? start : undefined,
|
||||
trip_end: type === "trip" ? end : undefined,
|
||||
};
|
||||
startTransition(() => {
|
||||
const op = mode === "create"
|
||||
? createCollection(input).then(() => undefined)
|
||||
: editCollection(collection!.id, input);
|
||||
op
|
||||
.then(() => {
|
||||
onClose();
|
||||
dispatch({
|
||||
type: "TOAST",
|
||||
value: mode === "create" ? "Đã tạo bộ sưu tập" : "Đã lưu thay đổi",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setSaving(false);
|
||||
dispatch({ type: "TOAST", value: "Lưu thất bại" });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overlay" onClick={onClose} />
|
||||
<div className="sheet" style={{ maxHeight: "75%" }}>
|
||||
<div className="sheet-handle" />
|
||||
<div
|
||||
style={{
|
||||
padding: "6px 12px 8px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: 0,
|
||||
color: "var(--muted-foreground)",
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
padding: "8px 12px",
|
||||
}}
|
||||
>
|
||||
Hủy
|
||||
</button>
|
||||
<div style={{ fontSize: 16, fontWeight: 600 }}>
|
||||
{mode === "create" ? "Tạo bộ sưu tập" : "Sửa bộ sưu tập"}
|
||||
</div>
|
||||
<div style={{ width: 70 }} />
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "4px 16px 16px" }}>
|
||||
<FieldLabel required>Tên</FieldLabel>
|
||||
<div className="input">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="VD: Sài Gòn tháng 6"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FieldLabel required>Loại</FieldLabel>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{([
|
||||
{ id: "folder", label: "Thư mục", desc: "Không có ngày" },
|
||||
{ id: "trip", label: "Chuyến đi", desc: "Có khoảng ngày" },
|
||||
] as const).map((opt) => {
|
||||
const Icon = opt.id === "trip" ? Icons.Plane : Icons.Folder;
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => setType(opt.id)}
|
||||
style={{
|
||||
padding: 14,
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: type === opt.id
|
||||
? "1.5px solid var(--primary)"
|
||||
: "1px solid var(--border)",
|
||||
background: type === opt.id ? "var(--primary-soft)" : "var(--card)",
|
||||
color: type === opt.id ? "var(--primary)" : "var(--foreground)",
|
||||
textAlign: "left",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<Icon size={20} stroke={1.75} />
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{opt.label}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--muted-foreground)" }}>
|
||||
{opt.desc}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{type === "trip" && (
|
||||
<>
|
||||
<FieldLabel required>Bắt đầu</FieldLabel>
|
||||
<div className="input">
|
||||
<Icons.Calendar size={18} stroke={1.75} style={{ color: "var(--muted-foreground)" }} />
|
||||
<input
|
||||
type="date"
|
||||
value={start}
|
||||
onChange={(e) => setStart(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<FieldLabel required>Kết thúc</FieldLabel>
|
||||
<div className="input">
|
||||
<Icons.Calendar size={18} stroke={1.75} style={{ color: "var(--muted-foreground)" }} />
|
||||
<input
|
||||
type="date"
|
||||
value={end}
|
||||
min={start || undefined}
|
||||
onChange={(e) => setEnd(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px 4px",
|
||||
borderTop: "0.5px solid var(--border)",
|
||||
background: "color-mix(in oklch, var(--card) 90%, transparent)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="btn btn--block btn--lg"
|
||||
disabled={!isValid || saving}
|
||||
onClick={submit}
|
||||
>
|
||||
{saving
|
||||
? "Đang lưu..."
|
||||
: mode === "create"
|
||||
? "Tạo"
|
||||
: "Lưu thay đổi"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
185
src/sheets/edit-place-sheet.tsx
Normal file
185
src/sheets/edit-place-sheet.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { CATEGORIES } from "@/lib/ui-config";
|
||||
import type { CategoryId, Place } from "@/lib/types";
|
||||
import type { Dispatch } from "@/lib/app-state";
|
||||
import { FieldLabel } from "@/components/ui-primitives";
|
||||
import { Icons } from "@/components/icons";
|
||||
import { editPlace } from "@/lib/db/actions";
|
||||
|
||||
export function EditPlaceSheet({
|
||||
place,
|
||||
onClose,
|
||||
dispatch,
|
||||
}: {
|
||||
place: Place;
|
||||
onClose: () => void;
|
||||
dispatch: Dispatch;
|
||||
}) {
|
||||
const [name, setName] = useState(place.name);
|
||||
const [address, setAddress] = useState(place.address);
|
||||
const [category, setCategory] = useState<CategoryId>(place.category);
|
||||
const [tags, setTags] = useState<string[]>(place.tags);
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const isValid = name.trim() && address.trim();
|
||||
|
||||
const addTag = (raw: string) => {
|
||||
const t = raw.trim().replace(/,$/, "");
|
||||
if (t && !tags.includes(t) && tags.length < 10) setTags([...tags, t]);
|
||||
setTagInput("");
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (!isValid) return;
|
||||
setSaving(true);
|
||||
const trimmedAddress = address.trim();
|
||||
startTransition(() => {
|
||||
editPlace(place.id, {
|
||||
name: name.trim(),
|
||||
address: trimmedAddress,
|
||||
short_address: trimmedAddress.split(",").slice(0, 2).join(" · "),
|
||||
city: trimmedAddress.split(",").pop()?.trim() || "",
|
||||
category,
|
||||
tags,
|
||||
cover_url: place.cover_url ?? null,
|
||||
})
|
||||
.then(() => {
|
||||
onClose();
|
||||
dispatch({ type: "TOAST", value: "Đã lưu thay đổi" });
|
||||
})
|
||||
.catch(() => {
|
||||
setSaving(false);
|
||||
dispatch({ type: "TOAST", value: "Lưu thất bại" });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overlay" onClick={onClose} />
|
||||
<div className="sheet" style={{ height: "85%" }}>
|
||||
<div className="sheet-handle" />
|
||||
<div
|
||||
style={{
|
||||
padding: "6px 12px 8px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: 0,
|
||||
color: "var(--muted-foreground)",
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
padding: "8px 12px",
|
||||
}}
|
||||
>
|
||||
Hủy
|
||||
</button>
|
||||
<div style={{ fontSize: 16, fontWeight: 600 }}>Sửa địa điểm</div>
|
||||
<div style={{ width: 70 }} />
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "4px 16px" }}>
|
||||
<FieldLabel required>Tên địa điểm</FieldLabel>
|
||||
<div className="input">
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<FieldLabel required>Địa chỉ</FieldLabel>
|
||||
<div className="input">
|
||||
<Icons.MapPin size={18} stroke={1.75} style={{ color: "var(--muted-foreground)" }} />
|
||||
<input value={address} onChange={(e) => setAddress(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<FieldLabel required>Danh mục</FieldLabel>
|
||||
<div className="toggle-group">
|
||||
{(Object.entries(CATEGORIES) as [CategoryId, typeof CATEGORIES[CategoryId]][]).map(
|
||||
([k, c]) => {
|
||||
const I = Icons[c.icon as keyof typeof Icons];
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
data-active={category === k}
|
||||
onClick={() => setCategory(k)}
|
||||
>
|
||||
<I size={20} stroke={1.75} />
|
||||
<span>{c.label}</span>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FieldLabel>
|
||||
Thẻ <span style={{ color: "var(--subtle-foreground)", fontWeight: 400 }}>· {tags.length}/10</span>
|
||||
</FieldLabel>
|
||||
<div
|
||||
className="input"
|
||||
style={{ flexWrap: "wrap", height: "auto", minHeight: 48, padding: "6px 10px", gap: 6 }}
|
||||
>
|
||||
{tags.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="badge"
|
||||
style={{ height: 26, background: "var(--primary-soft)", color: "var(--primary)" }}
|
||||
>
|
||||
{t}
|
||||
<button
|
||||
onClick={() => setTags(tags.filter((x) => x !== t))}
|
||||
style={{ background: "transparent", border: 0, color: "inherit", padding: 2, marginLeft: 2 }}
|
||||
>
|
||||
<Icons.X size={12} stroke={2.5} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
value={tagInput}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v.endsWith(",")) addTag(v);
|
||||
else setTagInput(v);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addTag(tagInput);
|
||||
} else if (e.key === "Backspace" && !tagInput && tags.length) {
|
||||
setTags(tags.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
placeholder={tags.length ? "" : "Enter để thêm thẻ"}
|
||||
style={{ flex: 1, minWidth: 80, height: 32 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 8 }} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px 4px",
|
||||
borderTop: "0.5px solid var(--border)",
|
||||
background: "color-mix(in oklch, var(--card) 90%, transparent)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="btn btn--block btn--lg"
|
||||
disabled={!isValid || saving}
|
||||
onClick={submit}
|
||||
>
|
||||
{saving ? "Đang lưu..." : "Lưu thay đổi"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { COLLECTIONS } from "@/lib/mock-data";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useCollections } from "@/lib/app-context";
|
||||
import type { Dispatch } from "@/lib/app-state";
|
||||
import type { Role } from "@/lib/types";
|
||||
import { FieldLabel, IconBtn } from "@/components/ui-primitives";
|
||||
import { Icons } from "@/components/icons";
|
||||
import {
|
||||
createInviteLink,
|
||||
fetchPendingInvitations,
|
||||
revokeInvitation,
|
||||
revokeInviteLink,
|
||||
sendEmailInvite,
|
||||
type PendingInvitation,
|
||||
} from "@/lib/db/actions";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
|
||||
type RoleId = "viewer" | "editor";
|
||||
type EmailRole = Extract<Role, "editor" | "viewer">;
|
||||
|
||||
function RolePicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: RoleId;
|
||||
onChange: (v: RoleId) => void;
|
||||
value: EmailRole;
|
||||
onChange: (v: EmailRole) => void;
|
||||
}) {
|
||||
const roles: { id: RoleId; label: string; desc: string }[] = [
|
||||
const roles: { id: EmailRole; label: string; desc: string }[] = [
|
||||
{ id: "viewer", label: "Chỉ xem", desc: "Chỉ xem được" },
|
||||
{ id: "editor", label: "Sửa được", desc: "Thêm và sửa" },
|
||||
];
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{roles.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
@@ -61,30 +65,106 @@ export function InviteDialog({
|
||||
onClose,
|
||||
dispatch,
|
||||
}: {
|
||||
collectionId: string;
|
||||
collectionId: number;
|
||||
onClose: () => void;
|
||||
dispatch: Dispatch;
|
||||
}) {
|
||||
const c = COLLECTIONS.find((x) => x.id === collectionId);
|
||||
const c = useCollections().find((x) => x.id === collectionId);
|
||||
const [tab, setTab] = useState<"link" | "email">("link");
|
||||
const [role, setRole] = useState<RoleId>("editor");
|
||||
const [email, setEmail] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [pending, setPending] = useState<{ email: string; role: RoleId }[]>([
|
||||
{ email: "bao.tran@gmail.com", role: "editor" },
|
||||
]);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const link = `places.app/invite/${c?.id || "demo"}-7k2x9`;
|
||||
const copy = () => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
// Link tab
|
||||
const [linkToken, setLinkToken] = useState<string | null>(null);
|
||||
const [linkBusy, setLinkBusy] = useState(false);
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!c || linkToken || linkBusy) return;
|
||||
setLinkBusy(true);
|
||||
startTransition(() => {
|
||||
createInviteLink(c.id)
|
||||
.then((r) => setLinkToken(r.token))
|
||||
.catch(() => dispatch({ type: "TOAST", value: "Không tạo được link" }))
|
||||
.finally(() => setLinkBusy(false));
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [c?.id]);
|
||||
|
||||
const link = linkToken
|
||||
? `${typeof window !== "undefined" ? window.location.origin : ""}/invite/${linkToken}`
|
||||
: "";
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
if (!link) return;
|
||||
const ok = await copyToClipboard(link);
|
||||
if (ok) {
|
||||
setLinkCopied(true);
|
||||
setTimeout(() => setLinkCopied(false), 1500);
|
||||
} else {
|
||||
dispatch({ type: "TOAST", value: "Không sao chép được" });
|
||||
}
|
||||
};
|
||||
|
||||
const sendInvite = () => {
|
||||
if (!email.trim() || !email.includes("@")) return;
|
||||
setPending([...pending, { email: email.trim(), role }]);
|
||||
setEmail("");
|
||||
dispatch({ type: "TOAST", value: "Đã gửi lời mời" });
|
||||
const handleRotateLink = () => {
|
||||
if (!c) return;
|
||||
setLinkBusy(true);
|
||||
startTransition(() => {
|
||||
revokeInviteLink(c.id)
|
||||
.then(() => createInviteLink(c.id))
|
||||
.then((r) => {
|
||||
setLinkToken(r.token);
|
||||
dispatch({ type: "TOAST", value: "Đã tạo link mới" });
|
||||
})
|
||||
.catch(() => dispatch({ type: "TOAST", value: "Không tạo được link" }))
|
||||
.finally(() => setLinkBusy(false));
|
||||
});
|
||||
};
|
||||
|
||||
// Email tab
|
||||
const [email, setEmail] = useState("");
|
||||
const [emailRole, setEmailRole] = useState<EmailRole>("editor");
|
||||
const [emailBusy, setEmailBusy] = useState(false);
|
||||
const [pending, setPending] = useState<PendingInvitation[]>([]);
|
||||
const [pendingLoaded, setPendingLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!c) return;
|
||||
fetchPendingInvitations(c.id)
|
||||
.then((list) => setPending(list))
|
||||
.catch(() => setPending([]))
|
||||
.finally(() => setPendingLoaded(true));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [c?.id]);
|
||||
|
||||
const handleSendEmail = () => {
|
||||
if (!c || !email.includes("@") || emailBusy) return;
|
||||
setEmailBusy(true);
|
||||
startTransition(() => {
|
||||
sendEmailInvite(c.id, email, emailRole)
|
||||
.then((row) => {
|
||||
setPending((prev) => {
|
||||
const without = prev.filter((p) => p.email !== row.email);
|
||||
return [row, ...without];
|
||||
});
|
||||
setEmail("");
|
||||
dispatch({ type: "TOAST", value: "Đã gửi lời mời" });
|
||||
})
|
||||
.catch((e: Error) =>
|
||||
dispatch({ type: "TOAST", value: e.message || "Gửi thất bại" }),
|
||||
)
|
||||
.finally(() => setEmailBusy(false));
|
||||
});
|
||||
};
|
||||
|
||||
const handleRevokeInvitation = (id: number) => {
|
||||
const snapshot = pending;
|
||||
setPending(snapshot.filter((p) => p.id !== id));
|
||||
startTransition(() => {
|
||||
revokeInvitation(id).catch(() => {
|
||||
setPending(snapshot);
|
||||
dispatch({ type: "TOAST", value: "Không xóa được lời mời" });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -100,13 +180,7 @@ export function InviteDialog({
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: "-0.01em" }}>
|
||||
Mời vào bộ sưu tập
|
||||
</div>
|
||||
<div
|
||||
@@ -124,10 +198,7 @@ export function InviteDialog({
|
||||
|
||||
<div style={{ padding: "14px 20px 0" }}>
|
||||
<div className="tabs">
|
||||
<button
|
||||
data-active={tab === "link"}
|
||||
onClick={() => setTab("link")}
|
||||
>
|
||||
<button data-active={tab === "link"} onClick={() => setTab("link")}>
|
||||
<Icons.Link
|
||||
size={14}
|
||||
stroke={2}
|
||||
@@ -135,10 +206,7 @@ export function InviteDialog({
|
||||
/>
|
||||
Bằng link
|
||||
</button>
|
||||
<button
|
||||
data-active={tab === "email"}
|
||||
onClick={() => setTab("email")}
|
||||
>
|
||||
<button data-active={tab === "email"} onClick={() => setTab("email")}>
|
||||
<Icons.Mail
|
||||
size={14}
|
||||
stroke={2}
|
||||
@@ -160,18 +228,23 @@ export function InviteDialog({
|
||||
stroke={1.75}
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
/>
|
||||
<input readOnly value={link} style={{ fontSize: 13 }} />
|
||||
<input
|
||||
readOnly
|
||||
value={linkBusy ? "Đang tạo..." : link}
|
||||
style={{ fontSize: 13 }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={copy}
|
||||
className={copied ? "btn" : "btn btn--ghost"}
|
||||
onClick={handleCopyLink}
|
||||
disabled={!linkToken || linkBusy}
|
||||
className={linkCopied ? "btn" : "btn btn--ghost"}
|
||||
style={{
|
||||
width: 88,
|
||||
width: 96,
|
||||
height: 48,
|
||||
borderRadius: "var(--radius-md)",
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
{linkCopied ? (
|
||||
<>
|
||||
<Icons.Check size={16} stroke={2.5} />
|
||||
Đã copy
|
||||
@@ -184,8 +257,7 @@ export function InviteDialog({
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<FieldLabel>Vai trò</FieldLabel>
|
||||
<RolePicker value={role} onChange={setRole} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
@@ -212,12 +284,12 @@ export function InviteDialog({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn--outline btn--block"
|
||||
style={{ marginTop: 14, height: 44 }}
|
||||
onClick={() =>
|
||||
dispatch({ type: "TOAST", value: "Đã tạo link mới" })
|
||||
}
|
||||
onClick={handleRotateLink}
|
||||
disabled={linkBusy}
|
||||
>
|
||||
Tạo link mới
|
||||
</button>
|
||||
@@ -236,33 +308,35 @@ export function InviteDialog({
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="ten@email.com"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
disabled={emailBusy}
|
||||
/>
|
||||
</div>
|
||||
<FieldLabel>Vai trò</FieldLabel>
|
||||
<RolePicker value={role} onChange={setRole} />
|
||||
<RolePicker value={emailRole} onChange={setEmailRole} />
|
||||
<button
|
||||
className="btn btn--block"
|
||||
style={{ marginTop: 14, height: 44 }}
|
||||
disabled={!email.includes("@")}
|
||||
onClick={sendInvite}
|
||||
disabled={!email.includes("@") || emailBusy}
|
||||
onClick={handleSendEmail}
|
||||
>
|
||||
<Icons.Send size={16} stroke={2} />
|
||||
Gửi lời mời
|
||||
{emailBusy ? (
|
||||
"Đang gửi..."
|
||||
) : (
|
||||
<>
|
||||
<Icons.Send size={16} stroke={2} />
|
||||
Gửi lời mời
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{pending.length > 0 && (
|
||||
{pendingLoaded && pending.length > 0 && (
|
||||
<>
|
||||
<FieldLabel>Lời mời đang chờ · {pending.length}</FieldLabel>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{pending.map((p, i) => (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{pending.map((p) => (
|
||||
<div
|
||||
key={i}
|
||||
key={p.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -299,26 +373,19 @@ export function InviteDialog({
|
||||
>
|
||||
{p.email}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--muted-foreground)",
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, color: "var(--muted-foreground)" }}>
|
||||
{p.role === "editor" ? "Sửa được" : "Chỉ xem"}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
setPending(pending.filter((_, j) => j !== i))
|
||||
}
|
||||
onClick={() => handleRevokeInvitation(p.id)}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: 0,
|
||||
color: "var(--muted-foreground)",
|
||||
padding: 6,
|
||||
}}
|
||||
aria-label="Hủy lời mời"
|
||||
>
|
||||
<Icons.X size={16} stroke={2} />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { COLLECTIONS, USERS } from "@/lib/mock-data";
|
||||
import { useCollections, useMe, useUsers } from "@/lib/app-context";
|
||||
import type { Dispatch } from "@/lib/app-state";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
import { Icons } from "@/components/icons";
|
||||
@@ -10,11 +10,13 @@ export function MembersSheet({
|
||||
onClose,
|
||||
dispatch,
|
||||
}: {
|
||||
collectionId: string;
|
||||
collectionId: number;
|
||||
onClose: () => void;
|
||||
dispatch: Dispatch;
|
||||
}) {
|
||||
const c = COLLECTIONS.find((x) => x.id === collectionId);
|
||||
const c = useCollections().find((x) => x.id === collectionId);
|
||||
const users = useUsers();
|
||||
const me = useMe();
|
||||
if (!c) return null;
|
||||
const owner = c.owner_id;
|
||||
return (
|
||||
@@ -80,9 +82,9 @@ export function MembersSheet({
|
||||
</button>
|
||||
)}
|
||||
{c.members.map((id) => {
|
||||
const u = USERS[id];
|
||||
const u = users[id];
|
||||
const role = id === owner ? "owner" : "editor";
|
||||
const isMe = id === "u_me";
|
||||
const isMe = id === me.id;
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
|
||||
140
src/sheets/save-to-collection-sheet.tsx
Normal file
140
src/sheets/save-to-collection-sheet.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useCollections } from "@/lib/app-context";
|
||||
import type { Dispatch } from "@/lib/app-state";
|
||||
import { Icons } from "@/components/icons";
|
||||
import {
|
||||
addPlaceToCollection,
|
||||
removePlaceFromCollection,
|
||||
} from "@/lib/db/actions";
|
||||
|
||||
export function SaveToCollectionSheet({
|
||||
placeId,
|
||||
onClose,
|
||||
dispatch,
|
||||
}: {
|
||||
placeId: number;
|
||||
onClose: () => void;
|
||||
dispatch: Dispatch;
|
||||
}) {
|
||||
const collections = useCollections();
|
||||
// Initial membership state derived from server data.
|
||||
const [membership, setMembership] = useState<Record<string, boolean>>(() =>
|
||||
Object.fromEntries(collections.map((c) => [c.id, c.place_ids.includes(placeId)])),
|
||||
);
|
||||
const [, startTransition] = useTransition();
|
||||
const editable = collections.filter((c) => c.my_role !== "viewer");
|
||||
|
||||
const toggle = (cid: number) => {
|
||||
const wasIn = !!membership[cid];
|
||||
setMembership({ ...membership, [cid]: !wasIn });
|
||||
startTransition(() => {
|
||||
const op = wasIn
|
||||
? removePlaceFromCollection(cid, placeId)
|
||||
: addPlaceToCollection(cid, placeId);
|
||||
op.catch(() => {
|
||||
setMembership({ ...membership, [cid]: wasIn });
|
||||
dispatch({ type: "TOAST", value: "Lưu thất bại" });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overlay" onClick={onClose} />
|
||||
<div className="sheet" style={{ maxHeight: "70%" }}>
|
||||
<div className="sheet-handle" />
|
||||
<div
|
||||
style={{
|
||||
padding: "6px 16px 12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: 0,
|
||||
color: "var(--muted-foreground)",
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
Xong
|
||||
</button>
|
||||
<div style={{ fontSize: 16, fontWeight: 600 }}>Lưu vào bộ sưu tập</div>
|
||||
<div style={{ width: 48 }} />
|
||||
</div>
|
||||
<div style={{ overflowY: "auto", padding: "0 16px 16px" }}>
|
||||
{editable.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "32px 16px",
|
||||
textAlign: "center",
|
||||
color: "var(--muted-foreground)",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
Bạn chưa 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".
|
||||
</div>
|
||||
) : (
|
||||
editable.map((c) => {
|
||||
const inIt = !!membership[c.id];
|
||||
const Icon = c.type === "trip" ? Icons.Plane : Icons.Folder;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => toggle(c.id)}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
padding: "12px 4px",
|
||||
background: "transparent",
|
||||
border: 0,
|
||||
borderBottom: "0.5px solid var(--border)",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
background: c.type === "trip" ? "var(--primary-soft)" : "var(--muted)",
|
||||
color: c.type === "trip" ? "var(--primary)" : "var(--muted-foreground)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Icon size={18} stroke={1.75} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 500 }}>{c.name}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--muted-foreground)" }}>
|
||||
{c.place_count} địa điểm
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="checkbox"
|
||||
data-checked={inIt}
|
||||
style={{ pointerEvents: "none" }}
|
||||
>
|
||||
<Icons.Check size={14} stroke={3} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user