This commit is contained in:
2026-05-20 18:08:37 +07:00
parent dd3fd889a3
commit 290d36e8cb
21 changed files with 1359 additions and 72 deletions

View File

@@ -15,3 +15,15 @@ INVITE_FROM_EMAIL=
# Optional override for the origin used in invite URLs. Falls back to the
# request's host/protocol if unset. Server-only — never sent to the browser.
APP_URL=
# 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=

View File

@@ -1,27 +1,25 @@
# Run the prod-built app in a container, pulling env vars from .env.local
# (if present) or from the shell. No build-time secrets — every env value is
# read at container start by Next's standalone server.
# Pulls the published image from Docker Hub — no local build path here.
# Use `./build.sh` to (re)publish the image; this file only consumes it.
#
# Usage:
# docker compose up --build
# APP_PORT=8080 docker compose up (override host port)
# docker compose pull # fetch latest from Hub
# 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).
services:
app:
build:
context: .
dockerfile: Dockerfile
image: places:latest
image: renolation/places:latest
container_name: places
restart: unless-stopped
pull_policy: always
ports:
- "${APP_PORT:-3000}:3000"
# Env vars are loaded from .env.local at container start.
# `required: false` lets compose run even if the file is missing — useful
# when vars are injected by an external system (CI, K8s secrets, etc).
# Env vars are loaded from .env.local at container start (if present).
# Useful for ad-hoc overrides; baseline values are in `environment:` below.
env_file:
- path: .env.local
required: false
@@ -37,6 +35,13 @@ services:
PGDATABASE: places_db
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:
test:
- "CMD"

View File

@@ -7,6 +7,7 @@ const nextConfig: NextConfig = {
images: {
remotePatterns: [
{ protocol: "https", hostname: "images.unsplash.com" },
{ protocol: "https", hostname: "cdn.renolation.com" },
],
},
};

View File

@@ -12,6 +12,7 @@
"db:seed": "node --env-file=.env.local db/seed-user.mjs"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1050.0",
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.45.2",
"next": "^16.2.6",

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useReducer, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
makeInitialState,
reducer,
@@ -10,6 +11,7 @@ import {
import { AppDataProvider, type AppData } from "@/lib/app-context";
import type { Place } from "@/lib/types";
import { deleteCollection, deletePlace } from "@/lib/db/actions";
import { loadSnapshot, saveSnapshot } from "@/lib/offline-cache";
import { TabBar } from "@/components/ui-primitives";
import { Icons } from "@/components/icons";
import { PlacesListScreen } from "@/screens/places-list-screen";
@@ -31,8 +33,59 @@ export function PlacesApp({
initialPlaces: Place[];
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 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(() => {
if (!state.toast) return;
@@ -78,7 +131,7 @@ export function PlacesApp({
if (screen === "place")
return (
<PlaceDetailScreen
state={{ ...state, placeId: top.placeId }}
state={{ ...state, placeId: top.placeId, collectionId: top.collectionId }}
dispatch={dispatch}
/>
);
@@ -99,11 +152,11 @@ export function PlacesApp({
: null;
const collectionForDelete =
m?.type === "confirmDeleteCollection"
? data.collections.find((c) => c.id === m.collectionId)
? bootData.data.collections.find((c) => c.id === m.collectionId)
: null;
return (
<AppDataProvider value={data}>
<AppDataProvider value={bootData.data}>
<div className="app-frame">
{renderScreen(top.screen)}
<TabBar
@@ -144,7 +197,7 @@ export function PlacesApp({
/>
)}
{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 ? (
<CollectionFormSheet
mode="edit"

View 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>
</>
);
}

View 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 review nào.
</div>
)}
{reviews.length === 0 && canWrite && (
<div
style={{
fontSize: 13,
color: "var(--muted-foreground)",
padding: "4px 0 12px",
textAlign: "center",
}}
>
Chưa 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>
);
}

View File

@@ -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<Place> }
| { 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,

View File

@@ -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<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> {
const uid = await requireUserId();
const [row] = await db

View File

@@ -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<void> {
const token = newToken();
const expiresAt = new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
await db.insert(sessions).values({ id: token, userId, expiresAt });
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<number | null> {
const [row] = await db
.select({ userId: sessions.userId, expiresAt: sessions.expiresAt })
.from(sessions)
.where(and(eq(sessions.id, token), gt(sessions.expiresAt, new Date())))
.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;
}

View File

@@ -87,7 +87,13 @@ export async function getPlacesForUser(userId?: number): Promise<Place[]> {
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<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,
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<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,
myRating: userPlaceData.rating,
myNotes: userPlaceData.notes,
@@ -156,6 +168,7 @@ type CollectionRow = {
cover_place_ids: number[];
place_ids: number[];
members: number[];
member_roles: Record<string, Collection["my_role"]> | 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 ?? {},
}));
}

View File

@@ -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) => [

68
src/lib/image-resize.ts Normal file
View 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
View 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
View 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}`;
}

View File

@@ -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<string, Role>;
};
export type CategoryMeta = {

View File

@@ -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={
<Checkbox

View File

@@ -12,13 +12,14 @@ import { IconBtn, Checkbox, MenuItem } from "@/components/ui-primitives";
import { CoverImage } from "@/components/cover-image";
import { RatingStars } from "@/components/rating-stars";
import { Avatar } from "@/components/avatar";
import { ReviewsSection } from "@/components/reviews-section";
import { Icons } from "@/components/icons";
export function PlaceDetailScreen({
state,
dispatch,
}: {
state: AppState & { placeId?: number };
state: AppState & { placeId?: number; collectionId?: number };
dispatch: Dispatch;
}) {
const place = state.places.find((p) => p.id === state.placeId);
@@ -368,6 +369,16 @@ export function PlaceDetailScreen({
</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 */}
{collectionsContaining.length > 0 && (
<div>

View File

@@ -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<CoverState>(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({
</div>
<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>
<div className="input">
<input

View File

@@ -5,6 +5,13 @@ import { CATEGORIES } from "@/lib/ui-config";
import type { CategoryId, Place } from "@/lib/types";
import type { Dispatch } from "@/lib/app-state";
import { FieldLabel } from "@/components/ui-primitives";
import {
CoverPicker,
commitCover,
coverStateFromUrl,
disposeCover,
type CoverState,
} from "@/components/cover-picker";
import { Icons } from "@/components/icons";
import { editPlace } from "@/lib/db/actions";
@@ -22,6 +29,7 @@ export function EditPlaceSheet({
const [category, setCategory] = useState<CategoryId>(place.category);
const [tags, setTags] = useState<string[]>(place.tags);
const [tagInput, setTagInput] = useState("");
const [cover, setCover] = useState<CoverState>(() => 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({
</div>
<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>
<div className="input">
<input value={name} onChange={(e) => setName(e.target.value)} />

View File

@@ -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<number | null>(null);
const [confirmRemove, setConfirmRemove] = useState<number | null>(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 (
<>
<div className="overlay" onClick={onClose} />
@@ -51,7 +100,7 @@ export function MembersSheet({
<div style={{ width: 48 }} />
</div>
<div style={{ overflowY: "auto", padding: "0 16px 16px" }}>
{c.my_role !== "viewer" && (
{isOwner && (
<button
className="btn btn--ghost btn--block"
style={{
@@ -83,8 +132,10 @@ export function MembersSheet({
)}
{c.members.map((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 canManage = isOwner && role !== "owner";
return (
<div
key={id}
@@ -118,11 +169,16 @@ export function MembersSheet({
color: "var(--muted-foreground)",
}}
>
{role === "owner" ? "Chủ sở hữu" : "Sửa được"}
{roleLabel(role)}
</div>
</div>
{role !== "owner" && c.my_role === "owner" && (
{canManage && (
<button
type="button"
aria-label="Tuỳ chọn thành viên"
onClick={() =>
setActionFor(actionFor === id ? null : id)
}
style={{
background: "transparent",
border: 0,
@@ -137,6 +193,76 @@ export function MembersSheet({
);
})}
</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>
</>
);