Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 66 additions & 20 deletions app/api/postcards/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,48 +3,94 @@ import { pool } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
import { ensureImageHashColumn, normalizeImageHash } from "@/lib/postcard-image-hash";
import { ensurePostcardMetadataColumns } from "@/lib/schema";
import type { PostcardCounts } from "@/lib/types";

// GET /api/postcards -> all postcards (newest first)
// GET /api/postcards?status=available
// GET /api/postcards?mine=1 -> postcards claimed by the current user
Comment on lines 8 to 10
function positiveInt(value: string | null, fallback: number) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
}

export async function GET(request: Request) {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "未登录" }, { status: 401 });

const { searchParams } = new URL(request.url);
const status = searchParams.get("status");
const mine = searchParams.get("mine");
const page = positiveInt(searchParams.get("page"), 1);
const pageSize = Math.min(100, positiveInt(searchParams.get("pageSize"), 21));
const offset = (page - 1) * pageSize;
Comment on lines +23 to +25

const conditions: string[] = [];
const params: unknown[] = [];
const baseConditions: string[] = [];
const baseParams: unknown[] = [];

if (mine === "1") {
params.push(user.id);
conditions.push(`p.claimer_id = $${params.length}`);
baseParams.push(user.id);
baseConditions.push(`p.claimer_id = $${baseParams.length}`);
}

const conditions = [...baseConditions];
const params = [...baseParams];
if (status && ["available", "claimed", "received"].includes(status)) {
params.push(status);
conditions.push(`p.status = $${params.length}`);
}

const where = conditions.length ? `where ${conditions.join(" and ")}` : "";
const baseWhere = baseConditions.length ? `where ${baseConditions.join(" and ")}` : "";

params.push(pageSize, offset);
const limitParam = params.length - 1;
const offsetParam = params.length;

const [result, countResult] = await Promise.all([
pool.query(
`
select
p.*,
up.nickname as uploader_nickname,
cl.nickname as claimer_nickname
from postcards p
left join users up on p.uploader_id = up.id
left join users cl on p.claimer_id = cl.id
${where}
order by p.created_at desc
limit $${limitParam} offset $${offsetParam}
`,
params
),
pool.query(
`
select p.status, count(*)::int as count
from postcards p
${baseWhere}
group by p.status
`,
baseParams
),
]);

const counts: PostcardCounts = { all: 0, available: 0, claimed: 0, received: 0 };
for (const row of countResult.rows) {
if (row.status in counts) {
counts[row.status as keyof PostcardCounts] = row.count;
counts.all += row.count;
}
}
const total =
status && ["available", "claimed", "received"].includes(status)
? counts[status as keyof PostcardCounts]
: counts.all;

const result = await pool.query(
`
select
p.*,
up.nickname as uploader_nickname,
cl.nickname as claimer_nickname
from postcards p
left join users up on p.uploader_id = up.id
left join users cl on p.claimer_id = cl.id
${where}
order by p.created_at desc
`,
params
);

return NextResponse.json({ postcards: result.rows, currentUserId: user.id });
return NextResponse.json({
postcards: result.rows,
counts,
pagination: { page, pageSize, total },
currentUserId: user.id,
});
}

// POST /api/postcards { imageUrl, recipientName, pickupLocation?, note?, sentAt?, arrivedAt?, imageHash? }
Expand Down
188 changes: 158 additions & 30 deletions app/home-client.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,126 @@
"use client";

import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import PostcardCard from "@/components/postcard-card";
import PostcardDetail from "@/components/postcard-detail";
import type { Postcard, PostcardStatus, PostcardUpdateInput } from "@/lib/types";
import type {
Postcard,
PostcardCounts,
PostcardFilter,
PostcardUpdateInput,
} from "@/lib/types";

type Filter = "all" | PostcardStatus;

const FILTERS: { key: Filter; label: string }[] = [
const FILTERS: { key: PostcardFilter; label: string }[] = [
{ key: "all", label: "全部" },
{ key: "available", label: "待认领" },
{ key: "claimed", label: "已认领" },
{ key: "received", label: "已收到" },
];

type PostcardsPageResponse = {
postcards?: Postcard[];
counts?: PostcardCounts;
pagination?: {
page?: number;
pageSize?: number;
total?: number;
};
error?: string;
};

export default function HomeClient({
initialPostcards,
initialCounts,
initialPageSize,
currentUserId,
}: {
initialPostcards: Postcard[];
initialCounts: PostcardCounts;
initialPageSize: number;
currentUserId: string;
}) {
const [postcards, setPostcards] = useState<Postcard[]>(initialPostcards);
const [filter, setFilter] = useState<Filter>("all");
const [counts, setCounts] = useState<PostcardCounts>(initialCounts);
const [filter, setFilter] = useState<PostcardFilter>("all");
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [detailId, setDetailId] = useState<string | null>(null);
const [pageSize, setPageSize] = useState(initialPageSize);
const [page, setPage] = useState(1);
const [loadingPage, setLoadingPage] = useState(false);
Comment on lines +48 to +50

const visible = useMemo(
() => (filter === "all" ? postcards : postcards.filter((p) => p.status === filter)),
[postcards, filter]
);
const total = counts[filter];
const pageCount = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(page, pageCount);
const pageStart = (currentPage - 1) * pageSize;
const pageEnd = Math.min(pageStart + postcards.length, total);

const counts = useMemo(
() => ({
all: postcards.length,
available: postcards.filter((p) => p.status === "available").length,
claimed: postcards.filter((p) => p.status === "claimed").length,
received: postcards.filter((p) => p.status === "received").length,
}),
[postcards]
);
useEffect(() => {
setPage((current) => Math.min(current, pageCount));
}, [pageCount]);

const detail = useMemo(
() => postcards.find((p) => p.id === detailId) ?? null,
[postcards, detailId]
[postcards, detailId],
);

const loadPage = async ({
nextFilter = filter,
nextPage = currentPage,
nextPageSize = pageSize,
}: {
nextFilter?: PostcardFilter;
nextPage?: number;
nextPageSize?: number;
} = {}) => {
setLoadingPage(true);
setError(null);
try {
Comment on lines +76 to +78
const params = new URLSearchParams({
page: String(nextPage),
pageSize: String(nextPageSize),
});
if (nextFilter !== "all") params.set("status", nextFilter);

const res = await fetch(`/api/postcards?${params.toString()}`);
const data = (await res
.json()
.catch(() => ({}))) as PostcardsPageResponse;
if (!res.ok) {
setError(data?.error || "加载失败");
return;
}

const nextCounts = data.counts || counts;
const nextTotal = nextCounts[nextFilter];
const nextPageCount = Math.max(1, Math.ceil(nextTotal / nextPageSize));
const safePage = Math.min(nextPage, nextPageCount);
if (safePage !== nextPage) {
await loadPage({ nextFilter, nextPage: safePage, nextPageSize });
return;
}

setFilter(nextFilter);
setPageSize(nextPageSize);
setPage(safePage);
setCounts(nextCounts);
setPostcards(data.postcards || []);
Comment on lines +102 to +107
} catch {
setError("网络错误,请重试");
} finally {
setLoadingPage(false);
}
Comment on lines +110 to +112
};

const act = async (id: string, path: string) => {
await actWithMethod(id, path, "POST");
};

const actWithMethod = async (id: string, path: string, method: "POST" | "DELETE") => {
const actWithMethod = async (
id: string,
path: string,
method: "POST" | "DELETE",
) => {
setBusyId(id);
setError(null);
try {
Expand All @@ -61,9 +130,8 @@ export default function HomeClient({
setError(data?.error || "操作失败");
return;
}
setPostcards((prev) =>
prev.map((p) => (p.id === id ? { ...p, ...data.postcard } : p))
);
setDetailId(null);
await loadPage();
} catch {
setError("网络错误,请重试");
} finally {
Expand All @@ -81,8 +149,8 @@ export default function HomeClient({
setError(data?.error || "删除失败");
return;
}
setPostcards((prev) => prev.filter((p) => p.id !== id));
setDetailId(null);
await loadPage();
} catch {
setError("网络错误,请重试");
} finally {
Expand All @@ -105,7 +173,7 @@ export default function HomeClient({
return false;
}
setPostcards((prev) =>
prev.map((p) => (p.id === id ? { ...p, ...data.postcard } : p))
prev.map((p) => (p.id === id ? { ...p, ...data.postcard } : p)),
);
return true;
} catch {
Expand All @@ -122,7 +190,9 @@ export default function HomeClient({
{FILTERS.map((f) => (
<button
key={f.key}
onClick={() => setFilter(f.key)}
onClick={() => {
loadPage({ nextFilter: f.key, nextPage: 1 });
}}
className={`rounded-full px-3.5 py-1.5 text-sm transition ${
filter === f.key
? "bg-foreground text-background"
Expand All @@ -140,13 +210,17 @@ export default function HomeClient({
</p>
)}

{visible.length === 0 ? (
{total === 0 ? (
<div className="rounded-xl border border-dashed border-border py-16 text-center text-sm text-muted-foreground">
这里还没有明信片。<a href="/upload" className="text-primary underline">上传第一张</a>吧!
这里还没有明信片。
<a href="/upload" className="text-primary underline">
上传第一张
</a>
吧!
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{visible.map((p) => (
{postcards.map((p) => (
<PostcardCard
key={p.id}
postcard={p}
Expand Down Expand Up @@ -176,6 +250,60 @@ export default function HomeClient({
onClose={() => setDetailId(null)}
/>
)}
<div className="mt-6 flex flex-col gap-3 rounded-lg border border-border bg-card px-4 py-3 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<div className="text-center sm:text-left">
显示第 {total === 0 ? 0 : pageStart + 1}-{pageEnd} 张,共 {total} 张
{loadingPage ? ",加载中…" : ""}
</div>
<div className="flex flex-wrap items-center justify-center gap-3">
<div className="flex items-center gap-2">
<button
type="button"
aria-label="上一页"
onClick={() =>
loadPage({ nextPage: Math.max(1, currentPage - 1) })
}
disabled={currentPage <= 1 || loadingPage}
className="grid h-8 w-8 place-items-center rounded-full border border-border text-lg leading-none text-foreground transition hover:border-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-35"
>
</button>
<span className="min-w-16 text-center font-medium text-foreground">
{currentPage}/{pageCount}
</span>
<button
type="button"
aria-label="下一页"
onClick={() =>
loadPage({ nextPage: Math.min(pageCount, currentPage + 1) })
}
disabled={currentPage >= pageCount || loadingPage}
className="grid h-8 w-8 place-items-center rounded-full border border-border text-lg leading-none text-foreground transition hover:border-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-35"
>
</button>
</div>
<label className="flex items-center gap-2">
<span>每页</span>
<input
type="number"
min={1}
max={100}
value={pageSize}
onChange={(e) => {
const next = Number(e.target.value);
if (!Number.isFinite(next)) return;
loadPage({
nextPage: 1,
nextPageSize: Math.min(100, Math.max(1, Math.floor(next))),
});
}}
Comment on lines +293 to +300
className="w-20 rounded-lg border border-input bg-background px-2 py-1.5 text-base text-foreground outline-none ring-ring focus:ring-2 sm:text-sm"
/>
<span>张</span>
</label>
</div>
</div>
</div>
);
}
Loading