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
130 changes: 130 additions & 0 deletions app/sessions/_components/FilterBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import Link from "next/link";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

type Member = {
id: string;
name: string;
};

type Props = {
q: string;
memberId: string;
month: string;
pmin: string;
pmax: string;
members: Member[];
hasActiveFilters: boolean;
};

/**
* /sessions 상단 필터. GET form 으로 제출 → URL ?q=&member=&month=&pmin=&pmax= 갱신.
* Server-rendered, 별도 client component 없음. 필터 변경 시 cursor 는 자동으로 리셋
* (form 이 cursor 를 hidden 으로 안 들고 가니까).
*/
export function FilterBar({
q,
memberId,
month,
pmin,
pmax,
members,
hasActiveFilters,
}: Props) {
return (
<form
method="get"
className="mb-6 flex flex-col gap-3 rounded-md border bg-muted/30 p-4"
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1">
<Label htmlFor="f-q" className="text-xs">
장소
</Label>
<Input
id="f-q"
name="q"
type="search"
defaultValue={q}
placeholder="예: 서울숲"
maxLength={120}
/>
</div>

<div className="flex flex-col gap-1">
<Label htmlFor="f-member" className="text-xs">
참여 멤버
</Label>
<select
id="f-member"
name="member"
defaultValue={memberId}
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"
>
<option value="">전체</option>
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</div>

<div className="flex flex-col gap-1">
<Label htmlFor="f-month" className="text-xs">
</Label>
<Input
id="f-month"
name="month"
type="month"
defaultValue={month}
/>
</div>

<div className="flex flex-col gap-1">
<Label className="text-xs">참여 인원</Label>
<div className="flex items-center gap-2">
<Input
name="pmin"
type="number"
min="0"
max="999"
defaultValue={pmin}
placeholder="최소"
className="w-full"
aria-label="참여 인원 최소"
/>
<span className="text-muted-foreground">~</span>
<Input
name="pmax"
type="number"
min="0"
max="999"
defaultValue={pmax}
placeholder="최대"
className="w-full"
aria-label="참여 인원 최대"
/>
</div>
</div>
</div>

<div className="flex items-center justify-end gap-2">
{hasActiveFilters && (
<Link
href="/sessions"
className="text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
필터 초기화
</Link>
)}
<Button type="submit" size="sm">
필터 적용
</Button>
</div>
</form>
);
}
103 changes: 94 additions & 9 deletions app/sessions/page.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,61 @@
import Link from "next/link";

import { requireApproved } from "@/lib/guard";
import { listSessions } from "@/lib/sessions";
import { Button } from "@/components/ui/button";
import { requireApproved } from "@/lib/guard";
import {
listApprovedMembers,
listSessions,
type SessionFilters,
} from "@/lib/sessions";

import { EmptyState } from "./_components/EmptyState";
import { FilterBar } from "./_components/FilterBar";
import { SessionCard } from "./_components/SessionCard";

export const dynamic = "force-dynamic";

type SearchParams = {
cursor?: string;
q?: string;
member?: string;
month?: string;
pmin?: string;
pmax?: string;
};

type PageProps = {
searchParams: Promise<{ cursor?: string }>;
searchParams: Promise<SearchParams>;
};

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

const { cursor } = await searchParams;
const cursorId = parseCursor(cursor);
const sp = await searchParams;
const cursorId = parseCursor(sp.cursor);
const filters: SessionFilters = {
q: sp.q?.trim() || undefined,
memberId: sp.member?.trim() || undefined,
month: sp.month?.trim() || undefined,
pmin: parsePositiveInt(sp.pmin),
pmax: parsePositiveInt(sp.pmax),
};
const hasActiveFilters = Boolean(
filters.q ||
filters.memberId ||
filters.month ||
filters.pmin != null ||
filters.pmax != null,
);

const [page, members] = await Promise.all([
listSessions({ cursorId, filters }),
listApprovedMembers(),
]);

const page = await listSessions({ cursorId });
const nextHref =
page.nextCursorId != null
? buildHref({ ...sp, cursor: String(page.nextCursorId) })
: null;

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

<FilterBar
q={sp.q ?? ""}
memberId={sp.member ?? ""}
month={sp.month ?? ""}
pmin={sp.pmin ?? ""}
pmax={sp.pmax ?? ""}
members={members}
hasActiveFilters={hasActiveFilters}
/>

{page.items.length === 0 ? (
<EmptyState />
hasActiveFilters ? (
<EmptyFilterResult />
) : (
<EmptyState />
)
) : (
<>
<ul className="flex flex-col gap-4">
Expand All @@ -48,9 +98,9 @@ export default async function SessionsArchivePage({ searchParams }: PageProps) {
))}
</ul>

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

function EmptyFilterResult() {
return (
<section className="flex flex-col items-center gap-3 rounded-md border border-dashed p-10 text-center">
<p className="text-sm text-muted-foreground">
조건에 맞는 세션이 없어요.
</p>
<Link
href="/sessions"
className="text-xs underline underline-offset-4"
>
필터 초기화
</Link>
</section>
);
}

function parseCursor(raw: string | undefined): number | undefined {
if (!raw) return undefined;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : undefined;
}

function parsePositiveInt(raw: string | undefined): number | undefined {
if (raw == null || raw === "") return undefined;
const n = Number.parseInt(raw, 10);
if (!Number.isFinite(n) || n < 0) return undefined;
return n;
}

/**
* 기존 searchParams 를 유지하면서 cursor 만 갈아끼운 URL.
*/
function buildHref(params: Record<string, string | undefined>): string {
const q = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v != null && v !== "") q.set(k, v);
}
const s = q.toString();
return s ? `/sessions?${s}` : "/sessions";
}
Loading
Loading