Skip to content

Commit 3e9d24a

Browse files
authored
feat(sessions): location/member/month/participant-count filter with URL sync (#38)
1 parent 91a9c05 commit 3e9d24a

3 files changed

Lines changed: 330 additions & 13 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import Link from "next/link";
2+
3+
import { Button } from "@/components/ui/button";
4+
import { Input } from "@/components/ui/input";
5+
import { Label } from "@/components/ui/label";
6+
7+
type Member = {
8+
id: string;
9+
name: string;
10+
};
11+
12+
type Props = {
13+
q: string;
14+
memberId: string;
15+
month: string;
16+
pmin: string;
17+
pmax: string;
18+
members: Member[];
19+
hasActiveFilters: boolean;
20+
};
21+
22+
/**
23+
* /sessions 상단 필터. GET form 으로 제출 → URL ?q=&member=&month=&pmin=&pmax= 갱신.
24+
* Server-rendered, 별도 client component 없음. 필터 변경 시 cursor 는 자동으로 리셋
25+
* (form 이 cursor 를 hidden 으로 안 들고 가니까).
26+
*/
27+
export function FilterBar({
28+
q,
29+
memberId,
30+
month,
31+
pmin,
32+
pmax,
33+
members,
34+
hasActiveFilters,
35+
}: Props) {
36+
return (
37+
<form
38+
method="get"
39+
className="mb-6 flex flex-col gap-3 rounded-md border bg-muted/30 p-4"
40+
>
41+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
42+
<div className="flex flex-col gap-1">
43+
<Label htmlFor="f-q" className="text-xs">
44+
장소
45+
</Label>
46+
<Input
47+
id="f-q"
48+
name="q"
49+
type="search"
50+
defaultValue={q}
51+
placeholder="예: 서울숲"
52+
maxLength={120}
53+
/>
54+
</div>
55+
56+
<div className="flex flex-col gap-1">
57+
<Label htmlFor="f-member" className="text-xs">
58+
참여 멤버
59+
</Label>
60+
<select
61+
id="f-member"
62+
name="member"
63+
defaultValue={memberId}
64+
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
65+
>
66+
<option value="">전체</option>
67+
{members.map((m) => (
68+
<option key={m.id} value={m.id}>
69+
{m.name}
70+
</option>
71+
))}
72+
</select>
73+
</div>
74+
75+
<div className="flex flex-col gap-1">
76+
<Label htmlFor="f-month" className="text-xs">
77+
78+
</Label>
79+
<Input
80+
id="f-month"
81+
name="month"
82+
type="month"
83+
defaultValue={month}
84+
/>
85+
</div>
86+
87+
<div className="flex flex-col gap-1">
88+
<Label className="text-xs">참여 인원</Label>
89+
<div className="flex items-center gap-2">
90+
<Input
91+
name="pmin"
92+
type="number"
93+
min="0"
94+
max="999"
95+
defaultValue={pmin}
96+
placeholder="최소"
97+
className="w-full"
98+
aria-label="참여 인원 최소"
99+
/>
100+
<span className="text-muted-foreground">~</span>
101+
<Input
102+
name="pmax"
103+
type="number"
104+
min="0"
105+
max="999"
106+
defaultValue={pmax}
107+
placeholder="최대"
108+
className="w-full"
109+
aria-label="참여 인원 최대"
110+
/>
111+
</div>
112+
</div>
113+
</div>
114+
115+
<div className="flex items-center justify-end gap-2">
116+
{hasActiveFilters && (
117+
<Link
118+
href="/sessions"
119+
className="text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground"
120+
>
121+
필터 초기화
122+
</Link>
123+
)}
124+
<Button type="submit" size="sm">
125+
필터 적용
126+
</Button>
127+
</div>
128+
</form>
129+
);
130+
}

app/sessions/page.tsx

Lines changed: 94 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,61 @@
11
import Link from "next/link";
22

3-
import { requireApproved } from "@/lib/guard";
4-
import { listSessions } from "@/lib/sessions";
53
import { Button } from "@/components/ui/button";
4+
import { requireApproved } from "@/lib/guard";
5+
import {
6+
listApprovedMembers,
7+
listSessions,
8+
type SessionFilters,
9+
} from "@/lib/sessions";
610

711
import { EmptyState } from "./_components/EmptyState";
12+
import { FilterBar } from "./_components/FilterBar";
813
import { SessionCard } from "./_components/SessionCard";
914

1015
export const dynamic = "force-dynamic";
1116

17+
type SearchParams = {
18+
cursor?: string;
19+
q?: string;
20+
member?: string;
21+
month?: string;
22+
pmin?: string;
23+
pmax?: string;
24+
};
25+
1226
type PageProps = {
13-
searchParams: Promise<{ cursor?: string }>;
27+
searchParams: Promise<SearchParams>;
1428
};
1529

1630
export default async function SessionsArchivePage({ searchParams }: PageProps) {
1731
await requireApproved();
1832

19-
const { cursor } = await searchParams;
20-
const cursorId = parseCursor(cursor);
33+
const sp = await searchParams;
34+
const cursorId = parseCursor(sp.cursor);
35+
const filters: SessionFilters = {
36+
q: sp.q?.trim() || undefined,
37+
memberId: sp.member?.trim() || undefined,
38+
month: sp.month?.trim() || undefined,
39+
pmin: parsePositiveInt(sp.pmin),
40+
pmax: parsePositiveInt(sp.pmax),
41+
};
42+
const hasActiveFilters = Boolean(
43+
filters.q ||
44+
filters.memberId ||
45+
filters.month ||
46+
filters.pmin != null ||
47+
filters.pmax != null,
48+
);
49+
50+
const [page, members] = await Promise.all([
51+
listSessions({ cursorId, filters }),
52+
listApprovedMembers(),
53+
]);
2154

22-
const page = await listSessions({ cursorId });
55+
const nextHref =
56+
page.nextCursorId != null
57+
? buildHref({ ...sp, cursor: String(page.nextCursorId) })
58+
: null;
2359

2460
return (
2561
<main className="container mx-auto max-w-2xl p-6">
@@ -36,8 +72,22 @@ export default async function SessionsArchivePage({ searchParams }: PageProps) {
3672
</Link>
3773
</header>
3874

75+
<FilterBar
76+
q={sp.q ?? ""}
77+
memberId={sp.member ?? ""}
78+
month={sp.month ?? ""}
79+
pmin={sp.pmin ?? ""}
80+
pmax={sp.pmax ?? ""}
81+
members={members}
82+
hasActiveFilters={hasActiveFilters}
83+
/>
84+
3985
{page.items.length === 0 ? (
40-
<EmptyState />
86+
hasActiveFilters ? (
87+
<EmptyFilterResult />
88+
) : (
89+
<EmptyState />
90+
)
4191
) : (
4292
<>
4393
<ul className="flex flex-col gap-4">
@@ -48,9 +98,9 @@ export default async function SessionsArchivePage({ searchParams }: PageProps) {
4898
))}
4999
</ul>
50100

51-
{page.nextCursorId != null && (
101+
{nextHref && (
52102
<div className="mt-6 flex justify-center">
53-
<Link href={`/sessions?cursor=${page.nextCursorId}`}>
103+
<Link href={nextHref}>
54104
<Button variant="outline">더 보기</Button>
55105
</Link>
56106
</div>
@@ -61,8 +111,43 @@ export default async function SessionsArchivePage({ searchParams }: PageProps) {
61111
);
62112
}
63113

114+
function EmptyFilterResult() {
115+
return (
116+
<section className="flex flex-col items-center gap-3 rounded-md border border-dashed p-10 text-center">
117+
<p className="text-sm text-muted-foreground">
118+
조건에 맞는 세션이 없어요.
119+
</p>
120+
<Link
121+
href="/sessions"
122+
className="text-xs underline underline-offset-4"
123+
>
124+
필터 초기화
125+
</Link>
126+
</section>
127+
);
128+
}
129+
64130
function parseCursor(raw: string | undefined): number | undefined {
65131
if (!raw) return undefined;
66132
const n = Number.parseInt(raw, 10);
67133
return Number.isFinite(n) && n > 0 ? n : undefined;
68134
}
135+
136+
function parsePositiveInt(raw: string | undefined): number | undefined {
137+
if (raw == null || raw === "") return undefined;
138+
const n = Number.parseInt(raw, 10);
139+
if (!Number.isFinite(n) || n < 0) return undefined;
140+
return n;
141+
}
142+
143+
/**
144+
* 기존 searchParams 를 유지하면서 cursor 만 갈아끼운 URL.
145+
*/
146+
function buildHref(params: Record<string, string | undefined>): string {
147+
const q = new URLSearchParams();
148+
for (const [k, v] of Object.entries(params)) {
149+
if (v != null && v !== "") q.set(k, v);
150+
}
151+
const s = q.toString();
152+
return s ? `/sessions?${s}` : "/sessions";
153+
}

0 commit comments

Comments
 (0)