aa
This commit is contained in:
12
.env.example
12
.env.example
@@ -15,3 +15,15 @@ INVITE_FROM_EMAIL=
|
|||||||
# Optional override for the origin used in invite URLs. Falls back to the
|
# 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.
|
# request's host/protocol if unset. Server-only — never sent to the browser.
|
||||||
APP_URL=
|
APP_URL=
|
||||||
|
|
||||||
|
# Cloudflare R2 (S3-compatible object storage) for image uploads.
|
||||||
|
# R2_ENDPOINT base API endpoint for the account
|
||||||
|
# R2_BUCKET bucket name
|
||||||
|
# R2_ACCESS_KEY_ID + R2_SECRET_ACCESS_KEY — from R2 → API Tokens
|
||||||
|
# R2_PUBLIC_URL custom-domain origin attached to the bucket; this is
|
||||||
|
# what gets put into <img src> and stored in places.cover_url
|
||||||
|
R2_ENDPOINT=
|
||||||
|
R2_BUCKET=
|
||||||
|
R2_ACCESS_KEY_ID=
|
||||||
|
R2_SECRET_ACCESS_KEY=
|
||||||
|
R2_PUBLIC_URL=
|
||||||
|
|||||||
@@ -1,27 +1,25 @@
|
|||||||
# Run the prod-built app in a container, pulling env vars from .env.local
|
# Pulls the published image from Docker Hub — no local build path here.
|
||||||
# (if present) or from the shell. No build-time secrets — every env value is
|
# Use `./build.sh` to (re)publish the image; this file only consumes it.
|
||||||
# read at container start by Next's standalone server.
|
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# docker compose up --build
|
# docker compose pull # fetch latest from Hub
|
||||||
# APP_PORT=8080 docker compose up (override host port)
|
# docker compose up -d # start
|
||||||
|
# APP_PORT=8080 docker compose up -d # override host port
|
||||||
|
# docker compose down # stop + remove
|
||||||
#
|
#
|
||||||
# Compose v2.24+ (required for `env_file: path/required` syntax).
|
# Compose v2.24+ (required for `env_file: path/required` syntax).
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
image: renolation/places:latest
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
image: places:latest
|
|
||||||
container_name: places
|
container_name: places
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
pull_policy: always
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-3000}:3000"
|
- "${APP_PORT:-3000}:3000"
|
||||||
|
|
||||||
# Env vars are loaded from .env.local at container start.
|
# Env vars are loaded from .env.local at container start (if present).
|
||||||
# `required: false` lets compose run even if the file is missing — useful
|
# Useful for ad-hoc overrides; baseline values are in `environment:` below.
|
||||||
# when vars are injected by an external system (CI, K8s secrets, etc).
|
|
||||||
env_file:
|
env_file:
|
||||||
- path: .env.local
|
- path: .env.local
|
||||||
required: false
|
required: false
|
||||||
@@ -37,6 +35,13 @@ services:
|
|||||||
PGDATABASE: places_db
|
PGDATABASE: places_db
|
||||||
PGPORT: "5432"
|
PGPORT: "5432"
|
||||||
|
|
||||||
|
# Cloudflare R2 — image storage
|
||||||
|
R2_ENDPOINT: https://a825ab8ed865a9e0bfbf99feca3694e8.r2.cloudflarestorage.com
|
||||||
|
R2_BUCKET: places
|
||||||
|
R2_ACCESS_KEY_ID: 456ee3bb8c537fc6441f87e1e62c5631
|
||||||
|
R2_SECRET_ACCESS_KEY: 7c9081e46301ecd0196cbe24f4fc05c0841d0720d3e33d198b1f8659f87757aa
|
||||||
|
R2_PUBLIC_URL: https://cdn.renolation.com
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
- "CMD"
|
- "CMD"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const nextConfig: NextConfig = {
|
|||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{ protocol: "https", hostname: "images.unsplash.com" },
|
{ protocol: "https", hostname: "images.unsplash.com" },
|
||||||
|
{ protocol: "https", hostname: "cdn.renolation.com" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"db:seed": "node --env-file=.env.local db/seed-user.mjs"
|
"db:seed": "node --env-file=.env.local db/seed-user.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1050.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"next": "^16.2.6",
|
"next": "^16.2.6",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useReducer, useTransition } from "react";
|
import { useEffect, useReducer, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
makeInitialState,
|
makeInitialState,
|
||||||
reducer,
|
reducer,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
import { AppDataProvider, type AppData } from "@/lib/app-context";
|
import { AppDataProvider, type AppData } from "@/lib/app-context";
|
||||||
import type { Place } from "@/lib/types";
|
import type { Place } from "@/lib/types";
|
||||||
import { deleteCollection, deletePlace } from "@/lib/db/actions";
|
import { deleteCollection, deletePlace } from "@/lib/db/actions";
|
||||||
|
import { loadSnapshot, saveSnapshot } from "@/lib/offline-cache";
|
||||||
import { TabBar } from "@/components/ui-primitives";
|
import { TabBar } from "@/components/ui-primitives";
|
||||||
import { Icons } from "@/components/icons";
|
import { Icons } from "@/components/icons";
|
||||||
import { PlacesListScreen } from "@/screens/places-list-screen";
|
import { PlacesListScreen } from "@/screens/places-list-screen";
|
||||||
@@ -31,8 +33,59 @@ export function PlacesApp({
|
|||||||
initialPlaces: Place[];
|
initialPlaces: Place[];
|
||||||
data: AppData;
|
data: AppData;
|
||||||
}) {
|
}) {
|
||||||
const [state, dispatch] = useReducer(reducer, initialPlaces, makeInitialState);
|
// If we boot up offline, prefer the cached snapshot so the UI has *some*
|
||||||
|
// data to show. When network returns, the visibility/focus effect refreshes.
|
||||||
|
const bootData: { places: Place[]; data: AppData } = (() => {
|
||||||
|
if (typeof window === "undefined") return { places: initialPlaces, data };
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
const snap = loadSnapshot(data.me.id);
|
||||||
|
if (snap) {
|
||||||
|
return {
|
||||||
|
places: snap.places,
|
||||||
|
data: { me: snap.me, users: snap.users, collections: snap.collections },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { places: initialPlaces, data };
|
||||||
|
})();
|
||||||
|
|
||||||
|
const [state, dispatch] = useReducer(reducer, bootData.places, makeInitialState);
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Mirror the latest server snapshot to localStorage so it's available next
|
||||||
|
// time the user boots up offline. Only writes when we trust the data
|
||||||
|
// (i.e. currently online).
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (!navigator.onLine) return;
|
||||||
|
saveSnapshot({
|
||||||
|
v: 1,
|
||||||
|
savedAt: Date.now(),
|
||||||
|
me: data.me,
|
||||||
|
users: data.users,
|
||||||
|
collections: data.collections,
|
||||||
|
places: state.places,
|
||||||
|
});
|
||||||
|
}, [data, state.places]);
|
||||||
|
|
||||||
|
// Realtime-lite sync: re-fetch server data when the tab regains focus or
|
||||||
|
// network comes back. Cheap proxy for true realtime — catches changes other
|
||||||
|
// members made while this tab was inactive. CLAUDE.md Phase 2.
|
||||||
|
useEffect(() => {
|
||||||
|
const refresh = () => router.refresh();
|
||||||
|
const onVisible = () => {
|
||||||
|
if (document.visibilityState === "visible") refresh();
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onVisible);
|
||||||
|
window.addEventListener("focus", refresh);
|
||||||
|
window.addEventListener("online", refresh);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", onVisible);
|
||||||
|
window.removeEventListener("focus", refresh);
|
||||||
|
window.removeEventListener("online", refresh);
|
||||||
|
};
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state.toast) return;
|
if (!state.toast) return;
|
||||||
@@ -78,7 +131,7 @@ export function PlacesApp({
|
|||||||
if (screen === "place")
|
if (screen === "place")
|
||||||
return (
|
return (
|
||||||
<PlaceDetailScreen
|
<PlaceDetailScreen
|
||||||
state={{ ...state, placeId: top.placeId }}
|
state={{ ...state, placeId: top.placeId, collectionId: top.collectionId }}
|
||||||
dispatch={dispatch}
|
dispatch={dispatch}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -99,11 +152,11 @@ export function PlacesApp({
|
|||||||
: null;
|
: null;
|
||||||
const collectionForDelete =
|
const collectionForDelete =
|
||||||
m?.type === "confirmDeleteCollection"
|
m?.type === "confirmDeleteCollection"
|
||||||
? data.collections.find((c) => c.id === m.collectionId)
|
? bootData.data.collections.find((c) => c.id === m.collectionId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppDataProvider value={data}>
|
<AppDataProvider value={bootData.data}>
|
||||||
<div className="app-frame">
|
<div className="app-frame">
|
||||||
{renderScreen(top.screen)}
|
{renderScreen(top.screen)}
|
||||||
<TabBar
|
<TabBar
|
||||||
@@ -144,7 +197,7 @@ export function PlacesApp({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{m?.type === "editCollection" && (() => {
|
{m?.type === "editCollection" && (() => {
|
||||||
const c = data.collections.find((x) => x.id === m.collectionId);
|
const c = bootData.data.collections.find((x) => x.id === m.collectionId);
|
||||||
return c ? (
|
return c ? (
|
||||||
<CollectionFormSheet
|
<CollectionFormSheet
|
||||||
mode="edit"
|
mode="edit"
|
||||||
|
|||||||
188
src/components/cover-picker.tsx
Normal file
188
src/components/cover-picker.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Icons } from "./icons";
|
||||||
|
import { uploadImage } from "@/lib/db/actions";
|
||||||
|
import { ACCEPTED_TYPES, resizeImage } from "@/lib/image-resize";
|
||||||
|
|
||||||
|
// Tri-state cover model used by both add + edit sheets.
|
||||||
|
// none — no cover set
|
||||||
|
// url — already uploaded; URL is final (R2 public URL or external)
|
||||||
|
// local — freshly picked + resized; blob lives in memory only, not on R2
|
||||||
|
// yet. Parent calls commitCover() on submit to flush to R2.
|
||||||
|
export type CoverState =
|
||||||
|
| { kind: "none" }
|
||||||
|
| { kind: "url"; url: string }
|
||||||
|
| { kind: "local"; blob: Blob; previewUrl: string };
|
||||||
|
|
||||||
|
export const COVER_NONE: CoverState = { kind: "none" };
|
||||||
|
|
||||||
|
export function coverStateFromUrl(url: string | null | undefined): CoverState {
|
||||||
|
return url ? { kind: "url", url } : COVER_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free the object URL backing a local preview, if any. Safe to call on any
|
||||||
|
// CoverState. Should be called whenever a CoverState is replaced or discarded.
|
||||||
|
export function disposeCover(state: CoverState): void {
|
||||||
|
if (state.kind === "local") URL.revokeObjectURL(state.previewUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a CoverState to a final URL ready to persist. Uploads the local
|
||||||
|
// blob to R2 if needed; reuses the existing URL or returns null otherwise.
|
||||||
|
export async function commitCover(state: CoverState): Promise<string | null> {
|
||||||
|
if (state.kind === "none") return null;
|
||||||
|
if (state.kind === "url") return state.url;
|
||||||
|
const ext = state.blob.type.split("/")[1] || "bin";
|
||||||
|
const file = new File([state.blob], `cover.${ext}`, { type: state.blob.type });
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const { url } = await uploadImage(fd);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CoverPicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onError,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
value: CoverState;
|
||||||
|
onChange: (next: CoverState) => void;
|
||||||
|
onError?: (msg: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// Revoke a discarded local preview whenever it leaves the tree.
|
||||||
|
useEffect(() => {
|
||||||
|
if (value.kind !== "local") return;
|
||||||
|
const url = value.previewUrl;
|
||||||
|
return () => URL.revokeObjectURL(url);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleFile = async (file: File) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const { blob } = await resizeImage(file);
|
||||||
|
const previewUrl = URL.createObjectURL(blob);
|
||||||
|
// Release the previous local preview (if any) before swapping in the new
|
||||||
|
// one — otherwise the old object URL would leak.
|
||||||
|
disposeCover(value);
|
||||||
|
onChange({ kind: "local", blob, previewUrl });
|
||||||
|
} catch (e) {
|
||||||
|
onError?.((e as Error).message || "Đọc ảnh thất bại");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPicker = () => inputRef.current?.click();
|
||||||
|
const clear = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
disposeCover(value);
|
||||||
|
onChange(COVER_NONE);
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewSrc =
|
||||||
|
value.kind === "url" ? value.url
|
||||||
|
: value.kind === "local" ? value.previewUrl
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPTED_TYPES.join(",")}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void handleFile(f);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openPicker}
|
||||||
|
disabled={disabled || busy}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
aspectRatio: "16 / 9",
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
background: previewSrc ? "transparent" : "var(--muted)",
|
||||||
|
border: previewSrc ? "0" : "1.5px dashed var(--border-strong)",
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
position: "relative",
|
||||||
|
padding: 0,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: busy ? "wait" : "pointer",
|
||||||
|
opacity: disabled ? 0.6 : busy ? 0.85 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{previewSrc ? (
|
||||||
|
<>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={previewSrc}
|
||||||
|
alt=""
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clear}
|
||||||
|
disabled={disabled || busy}
|
||||||
|
aria-label="Xoá ảnh"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 9999,
|
||||||
|
border: 0,
|
||||||
|
background: "rgba(20,16,10,0.55)",
|
||||||
|
color: "#fff",
|
||||||
|
backdropFilter: "blur(12px)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icons.X size={16} stroke={2} />
|
||||||
|
</button>
|
||||||
|
{value.kind === "local" && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 8,
|
||||||
|
left: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: "rgba(20,16,10,0.55)",
|
||||||
|
color: "#fff",
|
||||||
|
backdropFilter: "blur(12px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sẽ tải lên khi lưu
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icons.Camera size={20} stroke={1.75} />
|
||||||
|
{busy ? "Đang xử lý..." : "Thêm ảnh"}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
352
src/components/reviews-section.tsx
Normal file
352
src/components/reviews-section.tsx
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useTransition } from "react";
|
||||||
|
import { useCollections, useMe } from "@/lib/app-context";
|
||||||
|
import { fmtDate } from "@/lib/format";
|
||||||
|
import type { Dispatch } from "@/lib/app-state";
|
||||||
|
import {
|
||||||
|
deleteReview,
|
||||||
|
getReviews,
|
||||||
|
upsertReview,
|
||||||
|
type ReviewRow,
|
||||||
|
} from "@/lib/db/actions";
|
||||||
|
import { Icons } from "./icons";
|
||||||
|
import { RatingStars } from "./rating-stars";
|
||||||
|
|
||||||
|
// Place reviews — visible to members of the collection. Editor+ can write
|
||||||
|
// (one review per (place, collection, user)), viewer can only read.
|
||||||
|
// Per CLAUDE.md: empty state = "Chưa có review nào. Chia sẻ trải nghiệm của bạn!"
|
||||||
|
export function ReviewsSection({
|
||||||
|
placeId,
|
||||||
|
collectionId,
|
||||||
|
dispatch,
|
||||||
|
}: {
|
||||||
|
placeId: number;
|
||||||
|
collectionId: number;
|
||||||
|
dispatch: Dispatch;
|
||||||
|
}) {
|
||||||
|
const collections = useCollections();
|
||||||
|
const me = useMe();
|
||||||
|
const c = collections.find((x) => x.id === collectionId);
|
||||||
|
const canWrite = c?.my_role === "owner" || c?.my_role === "editor";
|
||||||
|
|
||||||
|
const [reviews, setReviews] = useState<ReviewRow[] | null>(null);
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const [rating, setRating] = useState(0);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const myReview = reviews?.find((r) => r.is_mine);
|
||||||
|
const others = reviews?.filter((r) => !r.is_mine) ?? [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
getReviews(placeId, collectionId)
|
||||||
|
.then((rows) => {
|
||||||
|
if (!cancelled) setReviews(rows);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setReviews([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [placeId, collectionId]);
|
||||||
|
|
||||||
|
// Hydrate the form with the user's existing review when reviews load.
|
||||||
|
useEffect(() => {
|
||||||
|
if (myReview && body === "" && rating === 0) {
|
||||||
|
setBody(myReview.body);
|
||||||
|
setRating(myReview.rating ?? 0);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [myReview?.id]);
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!body.trim() || saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
startTransition(() => {
|
||||||
|
upsertReview(placeId, collectionId, body, rating || null)
|
||||||
|
.then((row) => {
|
||||||
|
setReviews((prev) => {
|
||||||
|
if (!prev) return [row];
|
||||||
|
const without = prev.filter((r) => !r.is_mine);
|
||||||
|
return [row, ...without];
|
||||||
|
});
|
||||||
|
dispatch({ type: "TOAST", value: "Đã lưu review" });
|
||||||
|
})
|
||||||
|
.catch((e: Error) =>
|
||||||
|
dispatch({
|
||||||
|
type: "TOAST",
|
||||||
|
value: e.message || "Lưu review thất bại",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (id: number) => {
|
||||||
|
const snapshot = reviews;
|
||||||
|
setReviews((prev) => prev?.filter((r) => r.id !== id) ?? null);
|
||||||
|
if (myReview?.id === id) {
|
||||||
|
setBody("");
|
||||||
|
setRating(0);
|
||||||
|
}
|
||||||
|
startTransition(() => {
|
||||||
|
deleteReview(id).catch(() => {
|
||||||
|
setReviews(snapshot);
|
||||||
|
dispatch({ type: "TOAST", value: "Xóa review thất bại" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (reviews === null) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
padding: "8px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Đang tải reviews...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
marginBottom: 8,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icons.Users size={13} stroke={2} />
|
||||||
|
Review trong "{c?.name}"
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canWrite && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--card)",
|
||||||
|
border: "0.5px solid var(--border)",
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
placeholder="Chia sẻ trải nghiệm của bạn..."
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
minHeight: 64,
|
||||||
|
padding: 4,
|
||||||
|
resize: "vertical",
|
||||||
|
border: 0,
|
||||||
|
outline: "none",
|
||||||
|
background: "transparent",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
fontSize: 14,
|
||||||
|
color: "var(--foreground)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 8,
|
||||||
|
paddingTop: 8,
|
||||||
|
borderTop: "0.5px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RatingStars
|
||||||
|
value={rating}
|
||||||
|
readOnly={false}
|
||||||
|
size={20}
|
||||||
|
onChange={setRating}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
disabled={!body.trim() || saving}
|
||||||
|
onClick={submit}
|
||||||
|
style={{ height: 36, padding: "0 14px", fontSize: 13 }}
|
||||||
|
>
|
||||||
|
{saving
|
||||||
|
? "Đang lưu..."
|
||||||
|
: myReview
|
||||||
|
? "Cập nhật"
|
||||||
|
: "Đăng review"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reviews.length === 0 && !canWrite && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
padding: "12px 0",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Chưa có review nào.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{reviews.length === 0 && canWrite && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
padding: "4px 0 12px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Chưa có review nào. Chia sẻ trải nghiệm của bạn!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{others.map((r) => (
|
||||||
|
<ReviewCard
|
||||||
|
key={r.id}
|
||||||
|
review={r}
|
||||||
|
canDelete={c?.my_role === "owner"}
|
||||||
|
onDelete={() => remove(r.id)}
|
||||||
|
currentUserId={me.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{myReview && (
|
||||||
|
<ReviewCard
|
||||||
|
review={myReview}
|
||||||
|
canDelete
|
||||||
|
onDelete={() => remove(myReview.id)}
|
||||||
|
currentUserId={me.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewCard({
|
||||||
|
review,
|
||||||
|
canDelete,
|
||||||
|
onDelete,
|
||||||
|
currentUserId,
|
||||||
|
}: {
|
||||||
|
review: ReviewRow;
|
||||||
|
canDelete: boolean;
|
||||||
|
onDelete: () => void;
|
||||||
|
currentUserId: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 12,
|
||||||
|
background: "var(--card)",
|
||||||
|
border: "0.5px solid var(--border)",
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="avatar"
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
fontSize: 13,
|
||||||
|
background: review.user_color || "var(--muted)",
|
||||||
|
color: review.user_color ? "rgba(255,255,255,0.95)" : "var(--muted-foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{review.user_initials}
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600 }}>
|
||||||
|
{review.user_name}
|
||||||
|
{review.user_id === currentUserId && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
fontWeight: 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
· bạn
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--subtle-foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmtDate(review.updated_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{review.rating != null && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 3,
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--foreground)",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icons.StarFilled size={14} style={{ color: "var(--star)" }} />
|
||||||
|
{review.rating}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDelete}
|
||||||
|
aria-label="Xóa review"
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: 0,
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
padding: 4,
|
||||||
|
marginLeft: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icons.Trash size={16} stroke={1.75} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "var(--foreground)",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{review.body}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ export type Action =
|
|||||||
| { type: "SET_RATING"; placeId: number; value: number }
|
| { type: "SET_RATING"; placeId: number; value: number }
|
||||||
| { type: "SET_NOTES"; placeId: number; value: string }
|
| { type: "SET_NOTES"; placeId: number; value: string }
|
||||||
| { type: "ADD_PLACE"; place: Place }
|
| { type: "ADD_PLACE"; place: Place }
|
||||||
|
| { type: "PATCH_PLACE"; placeId: number; patch: Partial<Place> }
|
||||||
| { type: "DELETE_PLACE"; placeId: number }
|
| { type: "DELETE_PLACE"; placeId: number }
|
||||||
| { type: "TOAST"; value: string }
|
| { type: "TOAST"; value: string }
|
||||||
| { type: "CLEAR_TOAST"; key: number }
|
| { type: "CLEAR_TOAST"; key: number }
|
||||||
@@ -122,6 +123,12 @@ export function reducer(state: AppState, action: Action): AppState {
|
|||||||
}
|
}
|
||||||
case "ADD_PLACE":
|
case "ADD_PLACE":
|
||||||
return { ...state, places: [action.place, ...state.places] };
|
return { ...state, places: [action.place, ...state.places] };
|
||||||
|
case "PATCH_PLACE": {
|
||||||
|
const places = state.places.map((p) =>
|
||||||
|
p.id === action.placeId ? { ...p, ...action.patch } : p,
|
||||||
|
);
|
||||||
|
return { ...state, places };
|
||||||
|
}
|
||||||
case "DELETE_PLACE":
|
case "DELETE_PLACE":
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ import {
|
|||||||
collectionPlaces,
|
collectionPlaces,
|
||||||
collections,
|
collections,
|
||||||
invitations,
|
invitations,
|
||||||
|
placeReviews,
|
||||||
places,
|
places,
|
||||||
userPlaceData,
|
userPlaceData,
|
||||||
users,
|
users,
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
import { makeImageKey, uploadObject } from "@/lib/r2";
|
||||||
import type { CategoryId, CollectionType, Place, Role } from "@/lib/types";
|
import type { CategoryId, CollectionType, Place, Role } from "@/lib/types";
|
||||||
import { buildInviteEmail, sendEmail } from "@/lib/email";
|
import { buildInviteEmail, sendEmail } from "@/lib/email";
|
||||||
|
|
||||||
@@ -512,6 +514,257 @@ export async function sendEmailInvite(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Images ─────────────────────────────────────────────
|
||||||
|
// Server-side enforcement: ≤1MB, allowed mime types only. Client already
|
||||||
|
// resizes via canvas (see lib/image-resize.ts), but never trust the client.
|
||||||
|
const IMAGE_MAX_BYTES = 1024 * 1024;
|
||||||
|
const IMAGE_ALLOWED_MIMES = new Set([
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/webp",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export async function uploadImage(formData: FormData): Promise<{ url: string }> {
|
||||||
|
// Note: uid resolution kept inside the action so unauthenticated callers
|
||||||
|
// get the standard 'not authenticated' error before any storage hit.
|
||||||
|
await requireUserId();
|
||||||
|
const file = formData.get("file");
|
||||||
|
if (!(file instanceof File)) throw new Error("Thiếu file ảnh");
|
||||||
|
if (!IMAGE_ALLOWED_MIMES.has(file.type)) {
|
||||||
|
throw new Error("Định dạng ảnh không hỗ trợ");
|
||||||
|
}
|
||||||
|
if (file.size > IMAGE_MAX_BYTES) {
|
||||||
|
throw new Error("Ảnh quá lớn (> 1MB)");
|
||||||
|
}
|
||||||
|
const buf = Buffer.from(await file.arrayBuffer());
|
||||||
|
const key = makeImageKey(file.type);
|
||||||
|
const url = await uploadObject(key, buf, file.type);
|
||||||
|
return { url };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Reviews (place_reviews) ─────────────────────────────
|
||||||
|
// Per CLAUDE.md: a review lives in the context of one collection. Members of
|
||||||
|
// that collection see all reviews. The author must be at least 'editor'; the
|
||||||
|
// place must be in the collection; viewers can only read.
|
||||||
|
export type ReviewRow = {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
user_name: string;
|
||||||
|
user_initials: string;
|
||||||
|
user_color: string | null;
|
||||||
|
body: string;
|
||||||
|
rating: number | null;
|
||||||
|
is_mine: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function assertCanReadCollection(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");
|
||||||
|
return row.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertPlaceInCollection(placeId: number, collectionId: number) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ ok: collectionPlaces.placeId })
|
||||||
|
.from(collectionPlaces)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(collectionPlaces.collectionId, collectionId),
|
||||||
|
eq(collectionPlaces.placeId, placeId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!row) throw new Error("Địa điểm không thuộc bộ sưu tập này");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReviews(
|
||||||
|
placeId: number,
|
||||||
|
collectionId: number,
|
||||||
|
): Promise<ReviewRow[]> {
|
||||||
|
const uid = await requireUserId();
|
||||||
|
await assertCanReadCollection(collectionId, uid);
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: placeReviews.id,
|
||||||
|
userId: placeReviews.userId,
|
||||||
|
body: placeReviews.body,
|
||||||
|
rating: placeReviews.rating,
|
||||||
|
createdAt: placeReviews.createdAt,
|
||||||
|
updatedAt: placeReviews.updatedAt,
|
||||||
|
userName: users.name,
|
||||||
|
userInitials: users.initials,
|
||||||
|
userColor: users.color,
|
||||||
|
})
|
||||||
|
.from(placeReviews)
|
||||||
|
.innerJoin(users, eq(users.id, placeReviews.userId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(placeReviews.placeId, placeId),
|
||||||
|
eq(placeReviews.collectionId, collectionId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(sql`${placeReviews.createdAt} DESC`);
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
user_id: r.userId,
|
||||||
|
user_name: r.userName,
|
||||||
|
user_initials: r.userInitials,
|
||||||
|
user_color: r.userColor,
|
||||||
|
body: r.body,
|
||||||
|
rating: r.rating,
|
||||||
|
is_mine: r.userId === uid,
|
||||||
|
created_at: r.createdAt.toISOString(),
|
||||||
|
updated_at: r.updatedAt.toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertReview(
|
||||||
|
placeId: number,
|
||||||
|
collectionId: number,
|
||||||
|
body: string,
|
||||||
|
rating: number | null,
|
||||||
|
): Promise<ReviewRow> {
|
||||||
|
const uid = await requireUserId();
|
||||||
|
const role = await assertCanReadCollection(collectionId, uid);
|
||||||
|
if (role === "viewer") {
|
||||||
|
throw new Error("Viewer không được viết review");
|
||||||
|
}
|
||||||
|
await assertPlaceInCollection(placeId, collectionId);
|
||||||
|
const trimmed = body.trim();
|
||||||
|
if (!trimmed) throw new Error("Vui lòng nhập nội dung review");
|
||||||
|
if (rating !== null && (rating < 1 || rating > 5)) {
|
||||||
|
throw new Error("Rating phải từ 1 đến 5");
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
const [row] = await db
|
||||||
|
.insert(placeReviews)
|
||||||
|
.values({
|
||||||
|
placeId,
|
||||||
|
collectionId,
|
||||||
|
userId: uid,
|
||||||
|
body: trimmed,
|
||||||
|
rating,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [placeReviews.placeId, placeReviews.collectionId, placeReviews.userId],
|
||||||
|
set: { body: trimmed, rating, updatedAt: now },
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const [author] = await db
|
||||||
|
.select({
|
||||||
|
name: users.name,
|
||||||
|
initials: users.initials,
|
||||||
|
color: users.color,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, uid))
|
||||||
|
.limit(1);
|
||||||
|
revalidatePath("/");
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
user_id: uid,
|
||||||
|
user_name: author?.name ?? "",
|
||||||
|
user_initials: author?.initials ?? "?",
|
||||||
|
user_color: author?.color ?? null,
|
||||||
|
body: row.body,
|
||||||
|
rating: row.rating,
|
||||||
|
is_mine: true,
|
||||||
|
created_at: row.createdAt.toISOString(),
|
||||||
|
updated_at: row.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteReview(reviewId: number): Promise<void> {
|
||||||
|
const uid = await requireUserId();
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
userId: placeReviews.userId,
|
||||||
|
collectionId: placeReviews.collectionId,
|
||||||
|
ownerId: collections.ownerId,
|
||||||
|
})
|
||||||
|
.from(placeReviews)
|
||||||
|
.innerJoin(collections, eq(collections.id, placeReviews.collectionId))
|
||||||
|
.where(eq(placeReviews.id, reviewId))
|
||||||
|
.limit(1);
|
||||||
|
if (!row) return;
|
||||||
|
// Author can delete own; collection owner can delete any review in their col.
|
||||||
|
const canDelete = row.userId === uid || row.ownerId === uid;
|
||||||
|
if (!canDelete) throw new Error("not authorized");
|
||||||
|
await db.delete(placeReviews).where(eq(placeReviews.id, reviewId));
|
||||||
|
revalidatePath("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Member management ──────────────────────────────────
|
||||||
|
export async function changeMemberRole(
|
||||||
|
collectionId: number,
|
||||||
|
memberUserId: number,
|
||||||
|
newRole: Role,
|
||||||
|
): Promise<void> {
|
||||||
|
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("Bộ sưu tập không tồn tại");
|
||||||
|
if (col.ownerId !== uid) throw new Error("Chỉ chủ sở hữu được đổi vai trò");
|
||||||
|
if (memberUserId === col.ownerId) {
|
||||||
|
throw new Error("Không thể đổi vai trò chủ sở hữu");
|
||||||
|
}
|
||||||
|
if (newRole !== "editor" && newRole !== "viewer") {
|
||||||
|
throw new Error("Vai trò không hợp lệ");
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.update(collectionMembers)
|
||||||
|
.set({ role: newRole })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(collectionMembers.collectionId, collectionId),
|
||||||
|
eq(collectionMembers.userId, memberUserId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
revalidatePath("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeMember(
|
||||||
|
collectionId: number,
|
||||||
|
memberUserId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
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("Bộ sưu tập không tồn tại");
|
||||||
|
if (col.ownerId !== uid) throw new Error("Chỉ chủ sở hữu được xóa thành viên");
|
||||||
|
if (memberUserId === col.ownerId) {
|
||||||
|
throw new Error("Không thể xóa chủ sở hữu");
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.delete(collectionMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(collectionMembers.collectionId, collectionId),
|
||||||
|
eq(collectionMembers.userId, memberUserId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
revalidatePath("/");
|
||||||
|
}
|
||||||
|
|
||||||
export async function revokeInvitation(invitationId: number): Promise<void> {
|
export async function revokeInvitation(invitationId: number): Promise<void> {
|
||||||
const uid = await requireUserId();
|
const uid = await requireUserId();
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ import "server-only";
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { and, eq, gt } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "./client";
|
import { db } from "./client";
|
||||||
import { sessions, users } from "./schema";
|
import { sessions, users } from "./schema";
|
||||||
import type { User } from "@/lib/types";
|
import type { User } from "@/lib/types";
|
||||||
|
|
||||||
export const SESSION_COOKIE = "places_session";
|
export const SESSION_COOKIE = "places_session";
|
||||||
const SESSION_DAYS = 30;
|
// Best-effort persistence — Chrome/Safari now cap cookie lifetime to ~400d
|
||||||
|
// regardless of what we set. The DB row has no expiry (expires_at = NULL),
|
||||||
|
// so re-login isn't required even if the cookie eventually gets pruned.
|
||||||
|
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 * 10; // 10 years
|
||||||
|
|
||||||
function newToken(): string {
|
function newToken(): string {
|
||||||
return randomBytes(32).toString("base64url");
|
return randomBytes(32).toString("base64url");
|
||||||
@@ -119,15 +122,14 @@ export async function loginUser(
|
|||||||
|
|
||||||
async function createSessionCookie(userId: number): Promise<void> {
|
async function createSessionCookie(userId: number): Promise<void> {
|
||||||
const token = newToken();
|
const token = newToken();
|
||||||
const expiresAt = new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
|
await db.insert(sessions).values({ id: token, userId, expiresAt: null });
|
||||||
await db.insert(sessions).values({ id: token, userId, expiresAt });
|
|
||||||
const c = await cookies();
|
const c = await cookies();
|
||||||
c.set(SESSION_COOKIE, token, {
|
c.set(SESSION_COOKIE, token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
path: "/",
|
path: "/",
|
||||||
expires: expiresAt,
|
maxAge: COOKIE_MAX_AGE_SECONDS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,10 +140,12 @@ export async function getCurrentUserId(): Promise<number | null> {
|
|||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({ userId: sessions.userId, expiresAt: sessions.expiresAt })
|
.select({ userId: sessions.userId, expiresAt: sessions.expiresAt })
|
||||||
.from(sessions)
|
.from(sessions)
|
||||||
.where(and(eq(sessions.id, token), gt(sessions.expiresAt, new Date())))
|
.where(eq(sessions.id, token))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!row) {
|
if (!row) return null;
|
||||||
// Clean up an expired/invalid token if present.
|
// expiresAt = NULL means the session never expires. Legacy rows with a
|
||||||
|
// timestamp value still respect that expiry.
|
||||||
|
if (row.expiresAt && row.expiresAt.getTime() < Date.now()) {
|
||||||
await db.delete(sessions).where(eq(sessions.id, token));
|
await db.delete(sessions).where(eq(sessions.id, token));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,13 @@ export async function getPlacesForUser(userId?: number): Promise<Place[]> {
|
|||||||
coverUrl: places.coverUrl,
|
coverUrl: places.coverUrl,
|
||||||
createdBy: places.createdBy,
|
createdBy: places.createdBy,
|
||||||
createdAt: places.createdAt,
|
createdAt: places.createdAt,
|
||||||
avgRating: places.avgRating,
|
// Computed from per-user ratings — replaces the (unused) manual
|
||||||
|
// places.avg_rating column for display. Returns null when no one rated.
|
||||||
|
avgRating: sql<string | null>`(
|
||||||
|
SELECT to_char(avg(rating), 'FM999990.00')
|
||||||
|
FROM ${userPlaceData} upd2
|
||||||
|
WHERE upd2.place_id = ${places.id} AND upd2.rating IS NOT NULL
|
||||||
|
)`,
|
||||||
city: places.city,
|
city: places.city,
|
||||||
myRating: userPlaceData.rating,
|
myRating: userPlaceData.rating,
|
||||||
myNotes: userPlaceData.notes,
|
myNotes: userPlaceData.notes,
|
||||||
@@ -123,7 +129,13 @@ export async function getPlaceById(
|
|||||||
coverUrl: places.coverUrl,
|
coverUrl: places.coverUrl,
|
||||||
createdBy: places.createdBy,
|
createdBy: places.createdBy,
|
||||||
createdAt: places.createdAt,
|
createdAt: places.createdAt,
|
||||||
avgRating: places.avgRating,
|
// Computed from per-user ratings — replaces the (unused) manual
|
||||||
|
// places.avg_rating column for display. Returns null when no one rated.
|
||||||
|
avgRating: sql<string | null>`(
|
||||||
|
SELECT to_char(avg(rating), 'FM999990.00')
|
||||||
|
FROM ${userPlaceData} upd2
|
||||||
|
WHERE upd2.place_id = ${places.id} AND upd2.rating IS NOT NULL
|
||||||
|
)`,
|
||||||
city: places.city,
|
city: places.city,
|
||||||
myRating: userPlaceData.rating,
|
myRating: userPlaceData.rating,
|
||||||
myNotes: userPlaceData.notes,
|
myNotes: userPlaceData.notes,
|
||||||
@@ -156,6 +168,7 @@ type CollectionRow = {
|
|||||||
cover_place_ids: number[];
|
cover_place_ids: number[];
|
||||||
place_ids: number[];
|
place_ids: number[];
|
||||||
members: number[];
|
members: number[];
|
||||||
|
member_roles: Record<string, Collection["my_role"]> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getCollectionsForUser(
|
export async function getCollectionsForUser(
|
||||||
@@ -194,7 +207,12 @@ export async function getCollectionsForUser(
|
|||||||
SELECT array_agg(user_id ORDER BY (role = 'owner') DESC, joined_at)
|
SELECT array_agg(user_id ORDER BY (role = 'owner') DESC, joined_at)
|
||||||
FROM ${collectionMembers}
|
FROM ${collectionMembers}
|
||||||
WHERE collection_id = c.id
|
WHERE collection_id = c.id
|
||||||
), ARRAY[]::int[]) AS members
|
), ARRAY[]::int[]) AS members,
|
||||||
|
COALESCE((
|
||||||
|
SELECT jsonb_object_agg(user_id::text, role)
|
||||||
|
FROM ${collectionMembers}
|
||||||
|
WHERE collection_id = c.id
|
||||||
|
), '{}'::jsonb) AS member_roles
|
||||||
FROM ${collections} c
|
FROM ${collections} c
|
||||||
WHERE c.id IN (
|
WHERE c.id IN (
|
||||||
SELECT collection_id FROM ${collectionMembers} WHERE user_id = ${uid}
|
SELECT collection_id FROM ${collectionMembers} WHERE user_id = ${uid}
|
||||||
@@ -214,6 +232,7 @@ export async function getCollectionsForUser(
|
|||||||
cover_place_ids: r.cover_place_ids ?? [],
|
cover_place_ids: r.cover_place_ids ?? [],
|
||||||
place_ids: r.place_ids ?? [],
|
place_ids: r.place_ids ?? [],
|
||||||
members: r.members ?? [],
|
members: r.members ?? [],
|
||||||
|
member_roles: r.member_roles ?? {},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ export const sessions = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
// NULL = never expires (preferred for new sessions). Older rows with
|
||||||
|
// a timestamp value are still honoured for backward compatibility.
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||||
userAgent: text("user_agent"),
|
userAgent: text("user_agent"),
|
||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
|
|||||||
68
src/lib/image-resize.ts
Normal file
68
src/lib/image-resize.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// Client-side image normalisation. Resizes to MAX_EDGE px on the long axis
|
||||||
|
// and re-encodes to WebP (smaller than JPEG at equivalent quality). Output
|
||||||
|
// is typically <200KB even from a 12MP iPhone photo.
|
||||||
|
//
|
||||||
|
// CLAUDE.md: "resize about max 1200px and compress below 1MB before upload,
|
||||||
|
// use canvas API at client".
|
||||||
|
|
||||||
|
export const MAX_EDGE = 1200;
|
||||||
|
export const OUTPUT_MIME = "image/webp";
|
||||||
|
export const OUTPUT_QUALITY = 0.85;
|
||||||
|
export const HARD_LIMIT_BYTES = 1024 * 1024; // 1MB after re-encode
|
||||||
|
|
||||||
|
export const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
||||||
|
|
||||||
|
export type ResizeResult = {
|
||||||
|
blob: Blob;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
bytes: number;
|
||||||
|
mimeType: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function resizeImage(file: File): Promise<ResizeResult> {
|
||||||
|
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||||
|
throw new Error(
|
||||||
|
"Định dạng không hỗ trợ. Hãy chọn JPG, PNG hoặc WebP (HEIC không xử lý được trực tiếp).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = await loadImage(file);
|
||||||
|
const scale = Math.min(1, MAX_EDGE / Math.max(img.width, img.height));
|
||||||
|
const w = Math.max(1, Math.round(img.width * scale));
|
||||||
|
const h = Math.max(1, Math.round(img.height * scale));
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) throw new Error("Trình duyệt không hỗ trợ canvas.");
|
||||||
|
ctx.imageSmoothingQuality = "high";
|
||||||
|
ctx.drawImage(img, 0, 0, w, h);
|
||||||
|
URL.revokeObjectURL(img.src);
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) =>
|
||||||
|
canvas.toBlob(resolve, OUTPUT_MIME, OUTPUT_QUALITY),
|
||||||
|
);
|
||||||
|
if (!blob) throw new Error("Không re-encode được ảnh.");
|
||||||
|
if (blob.size > HARD_LIMIT_BYTES) {
|
||||||
|
throw new Error("Ảnh vẫn quá lớn sau khi nén (> 1MB). Hãy chọn ảnh khác.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
blob,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
bytes: blob.size,
|
||||||
|
mimeType: OUTPUT_MIME,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(file: File): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = () => reject(new Error("Không đọc được ảnh."));
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
56
src/lib/offline-cache.ts
Normal file
56
src/lib/offline-cache.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// Offline read cache — mirrors the server-rendered AppData + places into
|
||||||
|
// localStorage so the app stays usable when the browser is offline. Per
|
||||||
|
// CLAUDE.md "Offline" section: "snapshot data vào localStorage key
|
||||||
|
// places_cache_*". Write actions are blocked while offline (handled in the
|
||||||
|
// component layer via state.offline).
|
||||||
|
|
||||||
|
import type { Collection, Place, User } from "./types";
|
||||||
|
|
||||||
|
export type CacheSnapshot = {
|
||||||
|
v: 1;
|
||||||
|
savedAt: number;
|
||||||
|
me: User;
|
||||||
|
users: Record<number, User>;
|
||||||
|
collections: Collection[];
|
||||||
|
places: Place[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEY_PREFIX = "places_cache";
|
||||||
|
|
||||||
|
function userKey(userId: number): string {
|
||||||
|
return `${KEY_PREFIX}_${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSnapshot(snapshot: CacheSnapshot): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
userKey(snapshot.me.id),
|
||||||
|
JSON.stringify(snapshot),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Quota exceeded or storage disabled — silently degrade.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSnapshot(userId: number): CacheSnapshot | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(userKey(userId));
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as CacheSnapshot;
|
||||||
|
if (parsed.v !== 1) return null;
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSnapshot(userId: number): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(userKey(userId));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/lib/r2.ts
Normal file
73
src/lib/r2.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||||
|
|
||||||
|
// Cloudflare R2 — S3-compatible object storage. We talk to it via the AWS
|
||||||
|
// SDK; R2 ignores the region but the SDK requires it, hence "auto".
|
||||||
|
//
|
||||||
|
// Env:
|
||||||
|
// R2_ENDPOINT e.g. https://<account>.r2.cloudflarestorage.com
|
||||||
|
// R2_BUCKET bucket name (e.g. "places")
|
||||||
|
// R2_ACCESS_KEY_ID API token's access key
|
||||||
|
// R2_SECRET_ACCESS_KEY API token's secret
|
||||||
|
// R2_PUBLIC_URL the bucket's public custom-domain origin
|
||||||
|
// (e.g. https://cdn.renolation.com) — used to build
|
||||||
|
// the URL handed back to the browser.
|
||||||
|
|
||||||
|
function env(name: string): string {
|
||||||
|
const v = process.env[name];
|
||||||
|
if (!v) throw new Error(`Missing env var: ${name}`);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var __r2_client: S3Client | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeClient(): S3Client {
|
||||||
|
return new S3Client({
|
||||||
|
region: "auto",
|
||||||
|
endpoint: env("R2_ENDPOINT"),
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: env("R2_ACCESS_KEY_ID"),
|
||||||
|
secretAccessKey: env("R2_SECRET_ACCESS_KEY"),
|
||||||
|
},
|
||||||
|
// R2 expects path-style addressing.
|
||||||
|
forcePathStyle: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const r2 = globalThis.__r2_client ?? makeClient();
|
||||||
|
if (process.env.NODE_ENV !== "production") globalThis.__r2_client = r2;
|
||||||
|
|
||||||
|
// Generate a content-addressed key partitioned by month. The random suffix
|
||||||
|
// is 12 bytes (96 bits) base64url — collision-free for the lifetime of the app.
|
||||||
|
// places/2026/05/aB3xY1zQpWeR.webp
|
||||||
|
const KEY_PREFIX = "images";
|
||||||
|
export function makeImageKey(mimeType: string): string {
|
||||||
|
const ext = mimeType.split("/")[1] || "bin";
|
||||||
|
const now = new Date();
|
||||||
|
const yyyy = now.getUTCFullYear();
|
||||||
|
const mm = String(now.getUTCMonth() + 1).padStart(2, "0");
|
||||||
|
const rand = randomBytes(12).toString("base64url");
|
||||||
|
return `${KEY_PREFIX}/${yyyy}/${mm}/${rand}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadObject(
|
||||||
|
key: string,
|
||||||
|
body: Buffer,
|
||||||
|
mimeType: string,
|
||||||
|
): Promise<string> {
|
||||||
|
await r2.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: env("R2_BUCKET"),
|
||||||
|
Key: key,
|
||||||
|
Body: body,
|
||||||
|
ContentType: mimeType,
|
||||||
|
// Long cache — keys are immutable.
|
||||||
|
CacheControl: "public, max-age=31536000, immutable",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return `${env("R2_PUBLIC_URL").replace(/\/$/, "")}/${key}`;
|
||||||
|
}
|
||||||
@@ -43,7 +43,12 @@ export type Collection = {
|
|||||||
my_role: Role;
|
my_role: Role;
|
||||||
cover_place_ids: number[];
|
cover_place_ids: number[];
|
||||||
place_ids: number[];
|
place_ids: number[];
|
||||||
|
// Member user-ids ordered owner-first then by join time.
|
||||||
members: number[];
|
members: number[];
|
||||||
|
// Map of user-id → role for fast role lookup in member-management UI.
|
||||||
|
// Keys come out as string from JSON, callers should coerce or look up
|
||||||
|
// via `member_roles[String(userId)]`.
|
||||||
|
member_roles: Record<string, Role>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CategoryMeta = {
|
export type CategoryMeta = {
|
||||||
|
|||||||
@@ -290,7 +290,12 @@ export function CollectionDetailScreen({
|
|||||||
key={p.id}
|
key={p.id}
|
||||||
place={p}
|
place={p}
|
||||||
onTap={() =>
|
onTap={() =>
|
||||||
dispatch({ type: "NAV", screen: "place", placeId: p.id })
|
dispatch({
|
||||||
|
type: "NAV",
|
||||||
|
screen: "place",
|
||||||
|
placeId: p.id,
|
||||||
|
collectionId: c.id,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
trailing={
|
trailing={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -12,13 +12,14 @@ import { IconBtn, Checkbox, MenuItem } from "@/components/ui-primitives";
|
|||||||
import { CoverImage } from "@/components/cover-image";
|
import { CoverImage } from "@/components/cover-image";
|
||||||
import { RatingStars } from "@/components/rating-stars";
|
import { RatingStars } from "@/components/rating-stars";
|
||||||
import { Avatar } from "@/components/avatar";
|
import { Avatar } from "@/components/avatar";
|
||||||
|
import { ReviewsSection } from "@/components/reviews-section";
|
||||||
import { Icons } from "@/components/icons";
|
import { Icons } from "@/components/icons";
|
||||||
|
|
||||||
export function PlaceDetailScreen({
|
export function PlaceDetailScreen({
|
||||||
state,
|
state,
|
||||||
dispatch,
|
dispatch,
|
||||||
}: {
|
}: {
|
||||||
state: AppState & { placeId?: number };
|
state: AppState & { placeId?: number; collectionId?: number };
|
||||||
dispatch: Dispatch;
|
dispatch: Dispatch;
|
||||||
}) {
|
}) {
|
||||||
const place = state.places.find((p) => p.id === state.placeId);
|
const place = state.places.find((p) => p.id === state.placeId);
|
||||||
@@ -368,6 +369,16 @@ export function PlaceDetailScreen({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Reviews — only shown when viewing the place inside a collection
|
||||||
|
context. Per CLAUDE.md, reviews are scoped per-collection. */}
|
||||||
|
{state.collectionId != null && (
|
||||||
|
<ReviewsSection
|
||||||
|
placeId={place.id}
|
||||||
|
collectionId={state.collectionId}
|
||||||
|
dispatch={dispatch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Collections */}
|
{/* Collections */}
|
||||||
{collectionsContaining.length > 0 && (
|
{collectionsContaining.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ import {
|
|||||||
} from "@/lib/nominatim";
|
} from "@/lib/nominatim";
|
||||||
import { FieldLabel } from "@/components/ui-primitives";
|
import { FieldLabel } from "@/components/ui-primitives";
|
||||||
import { RatingStars } from "@/components/rating-stars";
|
import { RatingStars } from "@/components/rating-stars";
|
||||||
|
import {
|
||||||
|
COVER_NONE,
|
||||||
|
CoverPicker,
|
||||||
|
commitCover,
|
||||||
|
disposeCover,
|
||||||
|
type CoverState,
|
||||||
|
} from "@/components/cover-picker";
|
||||||
import { Icons } from "@/components/icons";
|
import { Icons } from "@/components/icons";
|
||||||
|
|
||||||
export function AddPlaceSheet({
|
export function AddPlaceSheet({
|
||||||
@@ -34,6 +41,7 @@ export function AddPlaceSheet({
|
|||||||
const [tagInput, setTagInput] = useState("");
|
const [tagInput, setTagInput] = useState("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [rating, setRating] = useState(0);
|
const [rating, setRating] = useState(0);
|
||||||
|
const [cover, setCover] = useState<CoverState>(COVER_NONE);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
@@ -117,27 +125,33 @@ export function AddPlaceSheet({
|
|||||||
city: a.split(",").pop()?.trim() || "",
|
city: a.split(",").pop()?.trim() || "",
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
startTransition(() => {
|
startTransition(async () => {
|
||||||
addPlace({
|
try {
|
||||||
|
// Upload to R2 only now — before this, the cover is just a local blob.
|
||||||
|
const coverUrl = await commitCover(cover);
|
||||||
|
const place = await addPlace({
|
||||||
name: trimmedName,
|
name: trimmedName,
|
||||||
address: fields.address,
|
address: fields.address,
|
||||||
short_address: fields.short_address,
|
short_address: fields.short_address,
|
||||||
city: fields.city,
|
city: fields.city,
|
||||||
category,
|
category,
|
||||||
tags,
|
tags,
|
||||||
cover_url: null,
|
cover_url: coverUrl,
|
||||||
rating: rating || undefined,
|
rating: rating || undefined,
|
||||||
notes: notes || undefined,
|
notes: notes || undefined,
|
||||||
})
|
});
|
||||||
.then((place) => {
|
// Cover is now persisted on R2 + the place row; free the local blob URL.
|
||||||
|
disposeCover(cover);
|
||||||
dispatch({ type: "ADD_PLACE", place });
|
dispatch({ type: "ADD_PLACE", place });
|
||||||
onClose();
|
onClose();
|
||||||
dispatch({ type: "TOAST", value: `Đã lưu "${trimmedName}"` });
|
dispatch({ type: "TOAST", value: `Đã lưu "${trimmedName}"` });
|
||||||
})
|
} catch (e) {
|
||||||
.catch(() => {
|
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
dispatch({ type: "TOAST", value: "Lưu thất bại" });
|
dispatch({
|
||||||
|
type: "TOAST",
|
||||||
|
value: (e as Error).message || "Lưu thất bại",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -172,6 +186,14 @@ export function AddPlaceSheet({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, overflowY: "auto", padding: "4px 16px" }}>
|
<div style={{ flex: 1, overflowY: "auto", padding: "4px 16px" }}>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<CoverPicker
|
||||||
|
value={cover}
|
||||||
|
onChange={setCover}
|
||||||
|
onError={(msg) => dispatch({ type: "TOAST", value: msg })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FieldLabel required>Tên địa điểm</FieldLabel>
|
<FieldLabel required>Tên địa điểm</FieldLabel>
|
||||||
<div className="input">
|
<div className="input">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import { CATEGORIES } from "@/lib/ui-config";
|
|||||||
import type { CategoryId, Place } from "@/lib/types";
|
import type { CategoryId, Place } from "@/lib/types";
|
||||||
import type { Dispatch } from "@/lib/app-state";
|
import type { Dispatch } from "@/lib/app-state";
|
||||||
import { FieldLabel } from "@/components/ui-primitives";
|
import { FieldLabel } from "@/components/ui-primitives";
|
||||||
|
import {
|
||||||
|
CoverPicker,
|
||||||
|
commitCover,
|
||||||
|
coverStateFromUrl,
|
||||||
|
disposeCover,
|
||||||
|
type CoverState,
|
||||||
|
} from "@/components/cover-picker";
|
||||||
import { Icons } from "@/components/icons";
|
import { Icons } from "@/components/icons";
|
||||||
import { editPlace } from "@/lib/db/actions";
|
import { editPlace } from "@/lib/db/actions";
|
||||||
|
|
||||||
@@ -22,6 +29,7 @@ export function EditPlaceSheet({
|
|||||||
const [category, setCategory] = useState<CategoryId>(place.category);
|
const [category, setCategory] = useState<CategoryId>(place.category);
|
||||||
const [tags, setTags] = useState<string[]>(place.tags);
|
const [tags, setTags] = useState<string[]>(place.tags);
|
||||||
const [tagInput, setTagInput] = useState("");
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
const [cover, setCover] = useState<CoverState>(() => coverStateFromUrl(place.cover_url));
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
@@ -37,24 +45,32 @@ export function EditPlaceSheet({
|
|||||||
if (!isValid) return;
|
if (!isValid) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const trimmedAddress = address.trim();
|
const trimmedAddress = address.trim();
|
||||||
startTransition(() => {
|
startTransition(async () => {
|
||||||
editPlace(place.id, {
|
try {
|
||||||
|
// Upload to R2 only now — local blob (if any) is converted to a real
|
||||||
|
// URL. An existing URL is returned unchanged.
|
||||||
|
const coverUrl = await commitCover(cover);
|
||||||
|
const patch = {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
address: trimmedAddress,
|
address: trimmedAddress,
|
||||||
short_address: trimmedAddress.split(",").slice(0, 2).join(" · "),
|
short_address: trimmedAddress.split(",").slice(0, 2).join(" · "),
|
||||||
city: trimmedAddress.split(",").pop()?.trim() || "",
|
city: trimmedAddress.split(",").pop()?.trim() || "",
|
||||||
category,
|
category,
|
||||||
tags,
|
tags,
|
||||||
cover_url: place.cover_url ?? null,
|
cover_url: coverUrl,
|
||||||
})
|
};
|
||||||
.then(() => {
|
await editPlace(place.id, patch);
|
||||||
|
disposeCover(cover);
|
||||||
|
dispatch({ type: "PATCH_PLACE", placeId: place.id, patch });
|
||||||
onClose();
|
onClose();
|
||||||
dispatch({ type: "TOAST", value: "Đã lưu thay đổi" });
|
dispatch({ type: "TOAST", value: "Đã lưu thay đổi" });
|
||||||
})
|
} catch (e) {
|
||||||
.catch(() => {
|
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
dispatch({ type: "TOAST", value: "Lưu thất bại" });
|
dispatch({
|
||||||
|
type: "TOAST",
|
||||||
|
value: (e as Error).message || "Lưu thất bại",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,6 +105,14 @@ export function EditPlaceSheet({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, overflowY: "auto", padding: "4px 16px" }}>
|
<div style={{ flex: 1, overflowY: "auto", padding: "4px 16px" }}>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<CoverPicker
|
||||||
|
value={cover}
|
||||||
|
onChange={setCover}
|
||||||
|
onError={(msg) => dispatch({ type: "TOAST", value: msg })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FieldLabel required>Tên địa điểm</FieldLabel>
|
<FieldLabel required>Tên địa điểm</FieldLabel>
|
||||||
<div className="input">
|
<div className="input">
|
||||||
<input value={name} onChange={(e) => setName(e.target.value)} />
|
<input value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
import { useCollections, useMe, useUsers } from "@/lib/app-context";
|
import { useCollections, useMe, useUsers } from "@/lib/app-context";
|
||||||
import type { Dispatch } from "@/lib/app-state";
|
import type { Dispatch } from "@/lib/app-state";
|
||||||
|
import type { Role } from "@/lib/types";
|
||||||
import { Avatar } from "@/components/avatar";
|
import { Avatar } from "@/components/avatar";
|
||||||
import { Icons } from "@/components/icons";
|
import { Icons } from "@/components/icons";
|
||||||
|
import { changeMemberRole, removeMember } from "@/lib/db/actions";
|
||||||
|
|
||||||
|
function roleLabel(role: Role): string {
|
||||||
|
if (role === "owner") return "Chủ sở hữu";
|
||||||
|
if (role === "editor") return "Sửa được";
|
||||||
|
return "Chỉ xem";
|
||||||
|
}
|
||||||
|
|
||||||
export function MembersSheet({
|
export function MembersSheet({
|
||||||
collectionId,
|
collectionId,
|
||||||
@@ -17,8 +26,48 @@ export function MembersSheet({
|
|||||||
const c = useCollections().find((x) => x.id === collectionId);
|
const c = useCollections().find((x) => x.id === collectionId);
|
||||||
const users = useUsers();
|
const users = useUsers();
|
||||||
const me = useMe();
|
const me = useMe();
|
||||||
|
const [actionFor, setActionFor] = useState<number | null>(null);
|
||||||
|
const [confirmRemove, setConfirmRemove] = useState<number | null>(null);
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
if (!c) return null;
|
if (!c) return null;
|
||||||
const owner = c.owner_id;
|
const owner = c.owner_id;
|
||||||
|
const isOwner = c.my_role === "owner";
|
||||||
|
|
||||||
|
const handleChangeRole = (userId: number, newRole: Role) => {
|
||||||
|
setActionFor(null);
|
||||||
|
startTransition(() => {
|
||||||
|
changeMemberRole(c.id, userId, newRole)
|
||||||
|
.then(() =>
|
||||||
|
dispatch({
|
||||||
|
type: "TOAST",
|
||||||
|
value: `Đã đặt vai trò "${roleLabel(newRole)}"`,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch((e: Error) =>
|
||||||
|
dispatch({
|
||||||
|
type: "TOAST",
|
||||||
|
value: e.message || "Đổi vai trò thất bại",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (userId: number) => {
|
||||||
|
setConfirmRemove(null);
|
||||||
|
setActionFor(null);
|
||||||
|
startTransition(() => {
|
||||||
|
removeMember(c.id, userId)
|
||||||
|
.then(() => dispatch({ type: "TOAST", value: "Đã xóa thành viên" }))
|
||||||
|
.catch((e: Error) =>
|
||||||
|
dispatch({
|
||||||
|
type: "TOAST",
|
||||||
|
value: e.message || "Xóa thành viên thất bại",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="overlay" onClick={onClose} />
|
<div className="overlay" onClick={onClose} />
|
||||||
@@ -51,7 +100,7 @@ export function MembersSheet({
|
|||||||
<div style={{ width: 48 }} />
|
<div style={{ width: 48 }} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ overflowY: "auto", padding: "0 16px 16px" }}>
|
<div style={{ overflowY: "auto", padding: "0 16px 16px" }}>
|
||||||
{c.my_role !== "viewer" && (
|
{isOwner && (
|
||||||
<button
|
<button
|
||||||
className="btn btn--ghost btn--block"
|
className="btn btn--ghost btn--block"
|
||||||
style={{
|
style={{
|
||||||
@@ -83,8 +132,10 @@ export function MembersSheet({
|
|||||||
)}
|
)}
|
||||||
{c.members.map((id) => {
|
{c.members.map((id) => {
|
||||||
const u = users[id];
|
const u = users[id];
|
||||||
const role = id === owner ? "owner" : "editor";
|
const role: Role =
|
||||||
|
id === owner ? "owner" : (c.member_roles[String(id)] ?? "editor");
|
||||||
const isMe = id === me.id;
|
const isMe = id === me.id;
|
||||||
|
const canManage = isOwner && role !== "owner";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={id}
|
key={id}
|
||||||
@@ -118,11 +169,16 @@ export function MembersSheet({
|
|||||||
color: "var(--muted-foreground)",
|
color: "var(--muted-foreground)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{role === "owner" ? "Chủ sở hữu" : "Sửa được"}
|
{roleLabel(role)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{role !== "owner" && c.my_role === "owner" && (
|
{canManage && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Tuỳ chọn thành viên"
|
||||||
|
onClick={() =>
|
||||||
|
setActionFor(actionFor === id ? null : id)
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
border: 0,
|
border: 0,
|
||||||
@@ -137,6 +193,76 @@ export function MembersSheet({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{actionFor != null && isOwner && (() => {
|
||||||
|
const targetRole: Role =
|
||||||
|
actionFor === owner
|
||||||
|
? "owner"
|
||||||
|
: (c.member_roles[String(actionFor)] ?? "editor");
|
||||||
|
const next: Role = targetRole === "editor" ? "viewer" : "editor";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="overlay"
|
||||||
|
style={{ background: "rgba(20,14,8,0.25)" }}
|
||||||
|
onClick={() => setActionFor(null)}
|
||||||
|
/>
|
||||||
|
<div className="sheet" style={{ maxHeight: "40%" }}>
|
||||||
|
<div className="sheet-handle" />
|
||||||
|
<div style={{ padding: "8px 0 16px" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleChangeRole(actionFor, next)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 14,
|
||||||
|
padding: "14px 20px",
|
||||||
|
background: "transparent",
|
||||||
|
border: 0,
|
||||||
|
color: "var(--foreground)",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 500,
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icons.Edit size={20} stroke={1.75} />
|
||||||
|
Đổi vai trò sang "{roleLabel(next)}"
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmRemove(actionFor)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 14,
|
||||||
|
padding: "14px 20px",
|
||||||
|
background: "transparent",
|
||||||
|
border: 0,
|
||||||
|
color: "var(--danger)",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 500,
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icons.Trash size={20} stroke={1.75} />
|
||||||
|
Xóa khỏi bộ sưu tập
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{confirmRemove != null && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Xóa thành viên?"
|
||||||
|
body={`${users[confirmRemove]?.name ?? "Thành viên"} sẽ mất quyền xem bộ sưu tập này.`}
|
||||||
|
confirmLabel="Xóa"
|
||||||
|
onConfirm={() => handleRemove(confirmRemove)}
|
||||||
|
onClose={() => setConfirmRemove(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user