diff --git a/next.config.ts b/next.config.ts
index 0910bf7..b78d338 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -10,6 +10,10 @@ const nextConfig = {
protocol: "https",
hostname: "i.pravatar.cc",
},
+ {
+ protocol: "https",
+ hostname: "imgs.search.brave.com",
+ },
],
qualities: [75, 85],
},
diff --git a/src/app/favorites/page.tsx b/src/app/favorites/page.tsx
index 3a33fa7..ea96acd 100644
--- a/src/app/favorites/page.tsx
+++ b/src/app/favorites/page.tsx
@@ -1,7 +1,8 @@
"use client";
-import { useState } from "react";
+import { useState, useDeferredValue, useMemo } from "react";
import Link from "next/link";
+import dynamic from "next/dynamic";
import { useFavorites } from "@/lib/hooks/useFavorites";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { api } from "@/lib/api";
@@ -9,6 +10,21 @@ import { fromEventSessionDetail } from "@/lib/utils/sessionMappers";
import { sortScheduleSessions } from "@/lib/utils/sortSessions";
import { ScheduleTable } from "@/components/sessions/ScheduleTable";
import { TablePagination } from "@/components/ui/TablePagination";
+import { Select } from "@/components/ui/Select";
+import { Search, X } from "lucide-react";
+
+const DateRangePicker = dynamic(() =>
+ import("@/components/ui/DateRangePicker").then((m) => ({ default: m.DateRangePicker })),
+);
+
+type FavStatus = "all" | "live" | "upcoming" | "ended";
+
+const STATUS_OPTIONS = [
+ { value: "all" as const, label: "All statuses" },
+ { value: "live" as const, label: "Live now" },
+ { value: "upcoming" as const, label: "Upcoming" },
+ { value: "ended" as const, label: "Ended" },
+];
const PAGE_SIZE = 5;
@@ -29,17 +45,63 @@ export default function FavoritesPage() {
placeholderData: keepPreviousData,
});
+ const [favSearch, setFavSearch] = useState("");
+ const deferredFavSearch = useDeferredValue(favSearch);
+ const [favStatus, setFavStatus] = useState("all");
+ const [dateFrom, setDateFrom] = useState("");
+ const [dateTo, setDateTo] = useState("");
+
const sorted = sortScheduleSessions(
(sessions ?? []).filter(Boolean).map(fromEventSessionDetail),
"asc",
);
+ const filtered = useMemo(() => {
+ const now = new Date();
+ let result = sorted;
+
+ if (deferredFavSearch) {
+ result = result.filter((s) =>
+ s.title.toLowerCase().includes(deferredFavSearch.toLowerCase()),
+ );
+ }
+
+ if (favStatus !== "all") {
+ result = result.filter((s) => {
+ if (favStatus === "live") return s.isLive;
+ if (favStatus === "upcoming") return s.startTime > now;
+ if (favStatus === "ended") return s.endTime < now;
+ return true;
+ });
+ }
- const totalFavPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
+ if (dateFrom) {
+ const from = new Date(dateFrom);
+ result = result.filter((s) => s.endTime >= from);
+ }
+ if (dateTo) {
+ const to = new Date(dateTo);
+ to.setHours(23, 59, 59, 999);
+ result = result.filter((s) => s.startTime <= to);
+ }
+
+ return result;
+ }, [sorted, deferredFavSearch, favStatus, dateFrom, dateTo]);
+
+ const totalFavPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safeFavPage = Math.min(favPage, totalFavPages);
- const paginatedSessions = sorted.slice(
+ const paginatedSessions = filtered.slice(
(safeFavPage - 1) * PAGE_SIZE,
safeFavPage * PAGE_SIZE,
);
+ const hasActiveFilters = favStatus !== "all" || !!favSearch || !!dateFrom || !!dateTo;
+
+ const clearAll = () => {
+ setFavSearch("");
+ setFavStatus("all");
+ setDateFrom("");
+ setDateTo("");
+ setFavPage(1);
+ };
return (
@@ -97,12 +159,43 @@ export default function FavoritesPage() {
) : (
+ <>
+
+
+
+ { setFavSearch(e.target.value); setFavPage(1); }}
+ placeholder="Search favorites"
+ className="w-full h-9 pl-8 pr-3 text-sm font-medium text-ivory placeholder-ivory/40 focus:outline-none transition-colors rounded-lg"
+ style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)" }}
+ />
+
+
+
+
+ >
)}
+ import("@/components/ui/DateRangePicker").then((m) => ({ default: m.DateRangePicker })),
+);
+
+type SessStatus = "all" | "live" | "upcoming" | "ended";
+
+const STATUS_OPTIONS = [
+ { value: "all" as const, label: "All statuses" },
+ { value: "live" as const, label: "Live now" },
+ { value: "upcoming" as const, label: "Upcoming" },
+ { value: "ended" as const, label: "Ended" },
+];
+
export default function SpeakerProfilePage() {
const { speakerId } = useParams<{ speakerId: string }>();
@@ -26,6 +41,13 @@ export default function SpeakerProfilePage() {
const [sessPage, setSessPage] = useState(1);
const [questionPage, setQuestionPage] = useState(1);
+ const [sessSearch, setSessSearch] = useState("");
+ const deferredSessSearch = useDeferredValue(sessSearch);
+ const [sessStatus, setSessStatus] = useState("all");
+ const [sessDateFrom, setSessDateFrom] = useState("");
+ const [sessDateTo, setSessDateTo] = useState("");
+ const [questionSearch, setQuestionSearch] = useState("");
+ const deferredQuestionSearch = useDeferredValue(questionSearch);
const sessionIds = speaker?.eventSessions.map((s) => s.id) ?? [];
@@ -56,18 +78,63 @@ export default function SpeakerProfilePage() {
).sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
+ const searchedQuestions = deferredQuestionSearch
+ ? questionsWithSession.filter((qq) =>
+ qq.content.toLowerCase().includes(deferredQuestionSearch.toLowerCase()),
+ )
+ : questionsWithSession;
const QUESTION_PAGE_SIZE = 5;
- const totalQuestionPages = Math.max(1, Math.ceil(questionsWithSession.length / QUESTION_PAGE_SIZE));
+ const totalQuestionPages = Math.max(1, Math.ceil(searchedQuestions.length / QUESTION_PAGE_SIZE));
const safeQuestionPage = Math.min(questionPage, totalQuestionPages);
- const paginatedQuestions = questionsWithSession.slice((safeQuestionPage - 1) * QUESTION_PAGE_SIZE, safeQuestionPage * QUESTION_PAGE_SIZE);
+ const paginatedQuestions = searchedQuestions.slice((safeQuestionPage - 1) * QUESTION_PAGE_SIZE, safeQuestionPage * QUESTION_PAGE_SIZE);
const sortedSessions = speaker
? sortScheduleSessions(speaker.eventSessions.map(fromSpeakerEventSession), "asc")
: [];
+ const filteredSessions = useMemo(() => {
+ const now = new Date();
+ let result = sortedSessions;
+
+ if (deferredSessSearch) {
+ result = result.filter((s) =>
+ s.title.toLowerCase().includes(deferredSessSearch.toLowerCase()),
+ );
+ }
+
+ if (sessStatus !== "all") {
+ result = result.filter((s) => {
+ if (sessStatus === "live") return s.isLive;
+ if (sessStatus === "upcoming") return s.startTime > now;
+ if (sessStatus === "ended") return s.endTime < now;
+ return true;
+ });
+ }
+
+ if (sessDateFrom) {
+ const from = new Date(sessDateFrom);
+ result = result.filter((s) => s.endTime >= from);
+ }
+ if (sessDateTo) {
+ const to = new Date(sessDateTo);
+ to.setHours(23, 59, 59, 999);
+ result = result.filter((s) => s.startTime <= to);
+ }
+
+ return result;
+ }, [sortedSessions, deferredSessSearch, sessStatus, sessDateFrom, sessDateTo]);
const SESSION_PAGE_SIZE = 5;
- const totalSessPages = Math.max(1, Math.ceil(sortedSessions.length / SESSION_PAGE_SIZE));
+ const totalSessPages = Math.max(1, Math.ceil(filteredSessions.length / SESSION_PAGE_SIZE));
const safeSessPage = Math.min(sessPage, totalSessPages);
- const paginatedSessions = sortedSessions.slice((safeSessPage - 1) * SESSION_PAGE_SIZE, safeSessPage * SESSION_PAGE_SIZE);
+ const paginatedSessions = filteredSessions.slice((safeSessPage - 1) * SESSION_PAGE_SIZE, safeSessPage * SESSION_PAGE_SIZE);
+ const hasActiveSessionFilters = sessSearch !== "" || sessStatus !== "all" || sessDateFrom !== "" || sessDateTo !== "";
+
+ const clearSessionFilters = () => {
+ setSessSearch("");
+ setSessStatus("all");
+ setSessDateFrom("");
+ setSessDateTo("");
+ setSessPage(1);
+ };
return (
@@ -136,10 +203,23 @@ export default function SpeakerProfilePage() {
QUESTIONS FROM THEIR SESSIONS
-
{questionsWithSession.length}
+
{searchedQuestions.length}
+
+
- {!questionsBySession ? (
+ {!questionsBySession && sessionIds.length > 0 ? (
{Array.from({ length: 3 }).map((_, i) => (
@@ -148,7 +228,7 @@ export default function SpeakerProfilePage() {
))}
- ) : questionsWithSession.length > 0 ? (
+ ) : searchedQuestions.length > 0 ? (
{paginatedQuestions.map((q) => (
@@ -207,7 +287,36 @@ export default function SpeakerProfilePage() {
SESSIONS
-
{sortedSessions.length} TOTAL
+
{filteredSessions.length} TOTAL
+
+
+
+
+ { setSessSearch(e.target.value); setSessPage(1); }}
+ placeholder="Search a session"
+ className="w-full h-9 pl-8 pr-3 text-sm font-medium text-ivory placeholder-ivory/40 focus:outline-none transition-colors rounded-lg"
+ style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)" }}
+ />
+
+
+
{!speaker ? (
@@ -225,7 +334,7 @@ export default function SpeakerProfilePage() {
))}
- ) : sortedSessions.length > 0 ? (
+ ) : filteredSessions.length > 0 ? (
<>
;
+ searchParams: Promise<{ page?: string; q?: string }>;
}) {
const resolvedParams = await searchParams;
const page = Math.max(1, Number(resolvedParams.page) || 1);
+ const q = resolvedParams.q?.trim() || "";
+ const qFilter = q
+ ? {
+ OR: [
+ { name: { contains: q, mode: "insensitive" as const } },
+ { bio: { contains: q, mode: "insensitive" as const } },
+ ],
+ }
+ : {};
const [speakers, total] = await Promise.all([
prisma.speaker.findMany({
@@ -69,10 +79,11 @@ export default async function SpeakersPage({
select: { sessions: true },
},
},
+ where: qFilter,
take: LIMIT,
skip: (page - 1) * LIMIT,
}),
- prisma.speaker.count(),
+ prisma.speaker.count({ where: qFilter }),
]);
const mappedSpeakers: SpeakerSummaryDto[] = speakers.map((s) => ({
@@ -103,6 +114,13 @@ export default async function SpeakersPage({
+
+
+
+
{mappedSpeakers.length > 0 ? (
<>
diff --git a/src/app/speakers/speakers-search.tsx b/src/app/speakers/speakers-search.tsx
new file mode 100644
index 0000000..866adf7
--- /dev/null
+++ b/src/app/speakers/speakers-search.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { useRouter, useSearchParams } from "next/navigation";
+import { Search, X } from "lucide-react";
+import { useEffect, useState } from "react";
+
+export function SpeakersSearch() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [value, setValue] = useState(searchParams.get("q") || "");
+
+ useEffect(() => {
+ const trimmed = value.trim();
+ const params = new URLSearchParams();
+ if (trimmed) params.set("q", trimmed);
+ params.set("page", "1");
+ const qs = params.toString();
+ router.replace(`/speakers${qs ? `?${qs}` : ""}`, { scroll: false });
+ }, [value, router]);
+
+ const hasSearch = value.trim() !== "";
+ const clear = () => setValue("");
+
+ return (
+ <>
+
+
+ setValue(e.target.value)}
+ placeholder="Search a speaker"
+ className="w-full h-9 pl-8 pr-3 text-sm font-medium text-ivory placeholder-ivory/40 focus:outline-none transition-colors rounded-lg"
+ style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)" }}
+ />
+
+ {hasSearch && (
+
+ )}
+ >
+ );
+}