{renderScreen(top.screen)}
)}
{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 ? (
{
+ 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(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 (
+ <>
+ {
+ const f = e.target.files?.[0];
+ if (f) void handleFile(f);
+ }}
+ />
+
+ >
+ );
+}
diff --git a/src/components/reviews-section.tsx b/src/components/reviews-section.tsx
new file mode 100644
index 0000000..b7da3e0
--- /dev/null
+++ b/src/components/reviews-section.tsx
@@ -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(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 (
+
+ Đang tải reviews...
+
+ );
+ }
+
+ return (
+
+
+
+ Review trong "{c?.name}"
+
+
+ {canWrite && (
+
+ )}
+
+ {reviews.length === 0 && !canWrite && (
+
+ Chưa có review nào.
+
+ )}
+ {reviews.length === 0 && canWrite && (
+
+ Chưa có review nào. Chia sẻ trải nghiệm của bạn!
+
+ )}
+
+
+ {others.map((r) => (
+ remove(r.id)}
+ currentUserId={me.id}
+ />
+ ))}
+ {myReview && (
+ remove(myReview.id)}
+ currentUserId={me.id}
+ />
+ )}
+
+
+ );
+}
+
+function ReviewCard({
+ review,
+ canDelete,
+ onDelete,
+ currentUserId,
+}: {
+ review: ReviewRow;
+ canDelete: boolean;
+ onDelete: () => void;
+ currentUserId: number;
+}) {
+ return (
+
+
+
+ {review.user_initials}
+
+
+
+ {review.user_name}
+ {review.user_id === currentUserId && (
+
+ {" "}
+ · bạn
+
+ )}
+
+
+ {fmtDate(review.updated_at)}
+
+
+ {review.rating != null && (
+
+
+ {review.rating}
+
+ )}
+ {canDelete && (
+
+
+
+ )}
+
+
+ {review.body}
+
+
+ );
+}
diff --git a/src/lib/app-state.ts b/src/lib/app-state.ts
index 74f1525..b22c2d2 100644
--- a/src/lib/app-state.ts
+++ b/src/lib/app-state.ts
@@ -44,6 +44,7 @@ export type Action =
| { type: "SET_RATING"; placeId: number; value: number }
| { type: "SET_NOTES"; placeId: number; value: string }
| { type: "ADD_PLACE"; place: Place }
+ | { type: "PATCH_PLACE"; placeId: number; patch: Partial }
| { type: "DELETE_PLACE"; placeId: number }
| { type: "TOAST"; value: string }
| { type: "CLEAR_TOAST"; key: number }
@@ -122,6 +123,12 @@ export function reducer(state: AppState, action: Action): AppState {
}
case "ADD_PLACE":
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":
return {
...state,
diff --git a/src/lib/db/actions.ts b/src/lib/db/actions.ts
index fbae0d6..346e409 100644
--- a/src/lib/db/actions.ts
+++ b/src/lib/db/actions.ts
@@ -11,10 +11,12 @@ import {
collectionPlaces,
collections,
invitations,
+ placeReviews,
places,
userPlaceData,
users,
} from "./schema";
+import { makeImageKey, uploadObject } from "@/lib/r2";
import type { CategoryId, CollectionType, Place, Role } from "@/lib/types";
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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
const uid = await requireUserId();
const [row] = await db
diff --git a/src/lib/db/auth.ts b/src/lib/db/auth.ts
index efce400..0f09784 100644
--- a/src/lib/db/auth.ts
+++ b/src/lib/db/auth.ts
@@ -2,13 +2,16 @@ 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 { eq } 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;
+// 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 {
return randomBytes(32).toString("base64url");
@@ -119,15 +122,14 @@ export async function loginUser(
async function createSessionCookie(userId: number): Promise {
const token = newToken();
- const expiresAt = new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
- await db.insert(sessions).values({ id: token, userId, expiresAt });
+ await db.insert(sessions).values({ id: token, userId, expiresAt: null });
const c = await cookies();
c.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
- expires: expiresAt,
+ maxAge: COOKIE_MAX_AGE_SECONDS,
});
}
@@ -138,10 +140,12 @@ export async function getCurrentUserId(): Promise {
const [row] = await db
.select({ userId: sessions.userId, expiresAt: sessions.expiresAt })
.from(sessions)
- .where(and(eq(sessions.id, token), gt(sessions.expiresAt, new Date())))
+ .where(eq(sessions.id, token))
.limit(1);
- if (!row) {
- // Clean up an expired/invalid token if present.
+ if (!row) return null;
+ // 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));
return null;
}
diff --git a/src/lib/db/queries.ts b/src/lib/db/queries.ts
index 4737e15..df0a447 100644
--- a/src/lib/db/queries.ts
+++ b/src/lib/db/queries.ts
@@ -87,7 +87,13 @@ export async function getPlacesForUser(userId?: number): Promise {
coverUrl: places.coverUrl,
createdBy: places.createdBy,
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`(
+ 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,
myRating: userPlaceData.rating,
myNotes: userPlaceData.notes,
@@ -123,7 +129,13 @@ export async function getPlaceById(
coverUrl: places.coverUrl,
createdBy: places.createdBy,
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`(
+ 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,
myRating: userPlaceData.rating,
myNotes: userPlaceData.notes,
@@ -156,6 +168,7 @@ type CollectionRow = {
cover_place_ids: number[];
place_ids: number[];
members: number[];
+ member_roles: Record | null;
};
export async function getCollectionsForUser(
@@ -194,7 +207,12 @@ export async function getCollectionsForUser(
SELECT array_agg(user_id ORDER BY (role = 'owner') DESC, joined_at)
FROM ${collectionMembers}
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
WHERE c.id IN (
SELECT collection_id FROM ${collectionMembers} WHERE user_id = ${uid}
@@ -214,6 +232,7 @@ export async function getCollectionsForUser(
cover_place_ids: r.cover_place_ids ?? [],
place_ids: r.place_ids ?? [],
members: r.members ?? [],
+ member_roles: r.member_roles ?? {},
}));
}
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index ce1a99d..6c7858a 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -46,7 +46,9 @@ export const sessions = pgTable(
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
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"),
},
(t) => [
diff --git a/src/lib/image-resize.ts b/src/lib/image-resize.ts
new file mode 100644
index 0000000..eee20b6
--- /dev/null
+++ b/src/lib/image-resize.ts
@@ -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 {
+ 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((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 {
+ 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);
+ });
+}
diff --git a/src/lib/offline-cache.ts b/src/lib/offline-cache.ts
new file mode 100644
index 0000000..0d304a2
--- /dev/null
+++ b/src/lib/offline-cache.ts
@@ -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;
+ 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
+ }
+}
diff --git a/src/lib/r2.ts b/src/lib/r2.ts
new file mode 100644
index 0000000..dd39426
--- /dev/null
+++ b/src/lib/r2.ts
@@ -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://.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 {
+ 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}`;
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index cd4dff3..f10860e 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -43,7 +43,12 @@ export type Collection = {
my_role: Role;
cover_place_ids: number[];
place_ids: number[];
+ // Member user-ids ordered owner-first then by join time.
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;
};
export type CategoryMeta = {
diff --git a/src/screens/collection-detail-screen.tsx b/src/screens/collection-detail-screen.tsx
index a1136fa..3e99a11 100644
--- a/src/screens/collection-detail-screen.tsx
+++ b/src/screens/collection-detail-screen.tsx
@@ -290,7 +290,12 @@ export function CollectionDetailScreen({
key={p.id}
place={p}
onTap={() =>
- dispatch({ type: "NAV", screen: "place", placeId: p.id })
+ dispatch({
+ type: "NAV",
+ screen: "place",
+ placeId: p.id,
+ collectionId: c.id,
+ })
}
trailing={
p.id === state.placeId);
@@ -368,6 +369,16 @@ export function PlaceDetailScreen({
+ {/* Reviews — only shown when viewing the place inside a collection
+ context. Per CLAUDE.md, reviews are scoped per-collection. */}
+ {state.collectionId != null && (
+
+ )}
+
{/* Collections */}
{collectionsContaining.length > 0 && (
diff --git a/src/sheets/add-place-sheet.tsx b/src/sheets/add-place-sheet.tsx
index d1b342c..58b69ee 100644
--- a/src/sheets/add-place-sheet.tsx
+++ b/src/sheets/add-place-sheet.tsx
@@ -13,6 +13,13 @@ import {
} from "@/lib/nominatim";
import { FieldLabel } from "@/components/ui-primitives";
import { RatingStars } from "@/components/rating-stars";
+import {
+ COVER_NONE,
+ CoverPicker,
+ commitCover,
+ disposeCover,
+ type CoverState,
+} from "@/components/cover-picker";
import { Icons } from "@/components/icons";
export function AddPlaceSheet({
@@ -34,6 +41,7 @@ export function AddPlaceSheet({
const [tagInput, setTagInput] = useState("");
const [notes, setNotes] = useState("");
const [rating, setRating] = useState(0);
+ const [cover, setCover] = useState(COVER_NONE);
const [submitting, setSubmitting] = useState(false);
const [, startTransition] = useTransition();
@@ -117,27 +125,33 @@ export function AddPlaceSheet({
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" });
+ startTransition(async () => {
+ 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,
+ address: fields.address,
+ short_address: fields.short_address,
+ city: fields.city,
+ category,
+ tags,
+ cover_url: coverUrl,
+ rating: rating || undefined,
+ notes: notes || undefined,
});
+ // Cover is now persisted on R2 + the place row; free the local blob URL.
+ disposeCover(cover);
+ dispatch({ type: "ADD_PLACE", place });
+ onClose();
+ dispatch({ type: "TOAST", value: `Đã lưu "${trimmedName}"` });
+ } catch (e) {
+ setSubmitting(false);
+ dispatch({
+ type: "TOAST",
+ value: (e as Error).message || "Lưu thất bại",
+ });
+ }
});
};
@@ -172,6 +186,14 @@ export function AddPlaceSheet({
+
+ dispatch({ type: "TOAST", value: msg })}
+ />
+
+
Tên địa điểm
(place.category);
const [tags, setTags] = useState(place.tags);
const [tagInput, setTagInput] = useState("");
+ const [cover, setCover] = useState(() => coverStateFromUrl(place.cover_url));
const [saving, setSaving] = useState(false);
const [, startTransition] = useTransition();
@@ -37,24 +45,32 @@ export function EditPlaceSheet({
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" });
+ startTransition(async () => {
+ 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(),
+ address: trimmedAddress,
+ short_address: trimmedAddress.split(",").slice(0, 2).join(" · "),
+ city: trimmedAddress.split(",").pop()?.trim() || "",
+ category,
+ tags,
+ cover_url: coverUrl,
+ };
+ await editPlace(place.id, patch);
+ disposeCover(cover);
+ dispatch({ type: "PATCH_PLACE", placeId: place.id, patch });
+ onClose();
+ dispatch({ type: "TOAST", value: "Đã lưu thay đổi" });
+ } catch (e) {
+ setSaving(false);
+ dispatch({
+ type: "TOAST",
+ value: (e as Error).message || "Lưu thất bại",
});
+ }
});
};
@@ -89,6 +105,14 @@ export function EditPlaceSheet({
+
+ dispatch({ type: "TOAST", value: msg })}
+ />
+
+
Tên địa điểm
setName(e.target.value)} />
diff --git a/src/sheets/members-sheet.tsx b/src/sheets/members-sheet.tsx
index 6029b52..5ec4433 100644
--- a/src/sheets/members-sheet.tsx
+++ b/src/sheets/members-sheet.tsx
@@ -1,9 +1,18 @@
"use client";
+import { useState, useTransition } from "react";
import { useCollections, useMe, useUsers } from "@/lib/app-context";
import type { Dispatch } from "@/lib/app-state";
+import type { Role } from "@/lib/types";
import { Avatar } from "@/components/avatar";
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({
collectionId,
@@ -17,8 +26,48 @@ export function MembersSheet({
const c = useCollections().find((x) => x.id === collectionId);
const users = useUsers();
const me = useMe();
+ const [actionFor, setActionFor] = useState
(null);
+ const [confirmRemove, setConfirmRemove] = useState(null);
+ const [, startTransition] = useTransition();
+
if (!c) return null;
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 (
<>
@@ -51,7 +100,7 @@ export function MembersSheet({
- {c.my_role !== "viewer" && (
+ {isOwner && (
{
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 canManage = isOwner && role !== "owner";
return (
- {role === "owner" ? "Chủ sở hữu" : "Sửa được"}
+ {roleLabel(role)}
- {role !== "owner" && c.my_role === "owner" && (
+ {canManage && (
+ setActionFor(actionFor === id ? null : id)
+ }
style={{
background: "transparent",
border: 0,
@@ -137,6 +193,76 @@ export function MembersSheet({
);
})}
+
+ {actionFor != null && isOwner && (() => {
+ const targetRole: Role =
+ actionFor === owner
+ ? "owner"
+ : (c.member_roles[String(actionFor)] ?? "editor");
+ const next: Role = targetRole === "editor" ? "viewer" : "editor";
+ return (
+ <>
+
setActionFor(null)}
+ />
+
+
+
+ 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",
+ }}
+ >
+
+ Đổi vai trò sang "{roleLabel(next)}"
+
+ 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",
+ }}
+ >
+
+ Xóa khỏi bộ sưu tập
+
+
+
+ >
+ );
+ })()}
+
+ {confirmRemove != null && (
+
handleRemove(confirmRemove)}
+ onClose={() => setConfirmRemove(null)}
+ />
+ )}
>
);