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
|
||||
# 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=
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -7,6 +7,7 @@ const nextConfig: NextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ 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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1050.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"next": "^16.2.6",
|
||||
|
||||
@@ -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"
|
||||
|
||||
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_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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ?? {},
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
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;
|
||||
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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user