diff --git a/.env.example b/.env.example index fc6df8e..c57f6a6 100644 --- a/.env.example +++ b/.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 and stored in places.cover_url +R2_ENDPOINT= +R2_BUCKET= +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +R2_PUBLIC_URL= diff --git a/docker-compose.yml b/docker-compose.yml index c4e0762..11b0852 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/next.config.ts b/next.config.ts index 8ecd85d..781b30e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -7,6 +7,7 @@ const nextConfig: NextConfig = { images: { remotePatterns: [ { protocol: "https", hostname: "images.unsplash.com" }, + { protocol: "https", hostname: "cdn.renolation.com" }, ], }, }; diff --git a/package.json b/package.json index 640d9ef..24d52b5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/places-app.tsx b/src/app/places-app.tsx index 64431ea..62ba4fb 100644 --- a/src/app/places-app.tsx +++ b/src/app/places-app.tsx @@ -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 ( ); @@ -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 ( - +
{renderScreen(top.screen)} )} {m?.type === "editCollection" && (() => { - const c = data.collections.find((x) => x.id === m.collectionId); + const c = bootData.data.collections.find((x) => x.id === m.collectionId); return c ? ( { + if (state.kind === "none") return null; + if (state.kind === "url") return state.url; + const ext = state.blob.type.split("/")[1] || "bin"; + const file = new File([state.blob], `cover.${ext}`, { type: state.blob.type }); + const fd = new FormData(); + fd.append("file", file); + const { url } = await uploadImage(fd); + return url; +} + +export function CoverPicker({ + value, + onChange, + onError, + disabled = false, +}: { + value: CoverState; + onChange: (next: CoverState) => void; + onError?: (msg: string) => void; + disabled?: boolean; +}) { + const inputRef = useRef(null); + const [busy, setBusy] = useState(false); + + // Revoke a discarded local preview whenever it leaves the tree. + useEffect(() => { + if (value.kind !== "local") return; + const url = value.previewUrl; + return () => URL.revokeObjectURL(url); + }, [value]); + + const handleFile = async (file: File) => { + setBusy(true); + try { + const { blob } = await resizeImage(file); + const previewUrl = URL.createObjectURL(blob); + // Release the previous local preview (if any) before swapping in the new + // one — otherwise the old object URL would leak. + disposeCover(value); + onChange({ kind: "local", blob, previewUrl }); + } catch (e) { + onError?.((e as Error).message || "Đọc ảnh thất bại"); + } finally { + setBusy(false); + if (inputRef.current) inputRef.current.value = ""; + } + }; + + const openPicker = () => inputRef.current?.click(); + const clear = (e: React.MouseEvent) => { + e.stopPropagation(); + disposeCover(value); + onChange(COVER_NONE); + }; + + const previewSrc = + value.kind === "url" ? value.url + : value.kind === "local" ? value.previewUrl + : null; + + return ( + <> + { + const f = e.target.files?.[0]; + if (f) void handleFile(f); + }} + /> + + {value.kind === "local" && ( + + Sẽ tải lên khi lưu + + )} + + ) : ( + <> + + {busy ? "Đang xử lý..." : "Thêm ảnh"} + + )} + + + ); +} diff --git a/src/components/reviews-section.tsx b/src/components/reviews-section.tsx new file mode 100644 index 0000000..b7da3e0 --- /dev/null +++ b/src/components/reviews-section.tsx @@ -0,0 +1,352 @@ +"use client"; + +import { useEffect, useState, useTransition } from "react"; +import { useCollections, useMe } from "@/lib/app-context"; +import { fmtDate } from "@/lib/format"; +import type { Dispatch } from "@/lib/app-state"; +import { + deleteReview, + getReviews, + upsertReview, + type ReviewRow, +} from "@/lib/db/actions"; +import { Icons } from "./icons"; +import { RatingStars } from "./rating-stars"; + +// Place reviews — visible to members of the collection. Editor+ can write +// (one review per (place, collection, user)), viewer can only read. +// Per CLAUDE.md: empty state = "Chưa có review nào. Chia sẻ trải nghiệm của bạn!" +export function ReviewsSection({ + placeId, + collectionId, + dispatch, +}: { + placeId: number; + collectionId: number; + dispatch: Dispatch; +}) { + const collections = useCollections(); + const me = useMe(); + const c = collections.find((x) => x.id === collectionId); + const canWrite = c?.my_role === "owner" || c?.my_role === "editor"; + + const [reviews, setReviews] = useState(null); + const [body, setBody] = useState(""); + const [rating, setRating] = useState(0); + const [saving, setSaving] = useState(false); + const [, startTransition] = useTransition(); + + const myReview = reviews?.find((r) => r.is_mine); + const others = reviews?.filter((r) => !r.is_mine) ?? []; + + useEffect(() => { + let cancelled = false; + getReviews(placeId, collectionId) + .then((rows) => { + if (!cancelled) setReviews(rows); + }) + .catch(() => { + if (!cancelled) setReviews([]); + }); + return () => { + cancelled = true; + }; + }, [placeId, collectionId]); + + // Hydrate the form with the user's existing review when reviews load. + useEffect(() => { + if (myReview && body === "" && rating === 0) { + setBody(myReview.body); + setRating(myReview.rating ?? 0); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [myReview?.id]); + + const submit = () => { + if (!body.trim() || saving) return; + setSaving(true); + startTransition(() => { + upsertReview(placeId, collectionId, body, rating || null) + .then((row) => { + setReviews((prev) => { + if (!prev) return [row]; + const without = prev.filter((r) => !r.is_mine); + return [row, ...without]; + }); + dispatch({ type: "TOAST", value: "Đã lưu review" }); + }) + .catch((e: Error) => + dispatch({ + type: "TOAST", + value: e.message || "Lưu review thất bại", + }), + ) + .finally(() => setSaving(false)); + }); + }; + + const remove = (id: number) => { + const snapshot = reviews; + setReviews((prev) => prev?.filter((r) => r.id !== id) ?? null); + if (myReview?.id === id) { + setBody(""); + setRating(0); + } + startTransition(() => { + deleteReview(id).catch(() => { + setReviews(snapshot); + dispatch({ type: "TOAST", value: "Xóa review thất bại" }); + }); + }); + }; + + if (reviews === null) { + return ( +
+ Đang tải reviews... +
+ ); + } + + return ( +
+
+ + Review trong "{c?.name}" +
+ + {canWrite && ( +
+