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
7 changes: 6 additions & 1 deletion lib/ranking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ async function fetchCandidatesAndRatings(
writerType?: number,
genre?: string,
lang?: string,
contentType?: string,
showNsfw = false,
) {
function applyBase(q: ReturnType<typeof supabase.from>) {
Expand All @@ -138,6 +139,7 @@ async function fetchCandidatesAndRatings(
if (writerType !== undefined) filtered = filtered.eq("writer_type", writerType);
if (genre) filtered = filtered.eq("genre", genre);
if (lang) filtered = filtered.eq("language", lang);
if (contentType) filtered = filtered.eq("content_type", contentType);
filtered = filtered.eq("is_nsfw", showNsfw);
return filtered;
}
Expand Down Expand Up @@ -249,9 +251,10 @@ export async function getTrendingStorylines(
offset = 0,
genre?: string,
lang?: string,
contentType?: string,
showNsfw = false,
): Promise<RankedStoryline[]> {
const { storylines, ratingMap, userMap } = await fetchCandidatesAndRatings(supabase, writerType, genre, lang, showNsfw);
const { storylines, ratingMap, userMap } = await fetchCandidatesAndRatings(supabase, writerType, genre, lang, contentType, showNsfw);
if (storylines.length === 0) return [];

const enriched = await Promise.all(
Expand Down Expand Up @@ -297,6 +300,7 @@ export async function getMcapStorylines(
offset = 0,
genre?: string,
lang?: string,
contentType?: string,
showNsfw = false,
): Promise<Storyline[]> {
// Fetch all eligible stories — MCap needs the full set, not a recency-biased subset
Expand All @@ -309,6 +313,7 @@ export async function getMcapStorylines(
if (writerType !== undefined) q = q.eq("writer_type", writerType);
if (genre) q = q.eq("genre", genre);
if (lang) q = q.eq("language", lang);
if (contentType) q = q.eq("content_type", contentType);
q = q.eq("is_nsfw", showNsfw);
const { data } = await q.returns<Storyline[]>();
const storylines = data ?? [];
Expand Down
28 changes: 17 additions & 11 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { createServerClient, type Storyline } from "../../lib/supabase";
import { STORY_FACTORY } from "../../lib/contracts/constants";
import { getTrendingStorylines, getMcapStorylines } from "../../lib/ranking";
import { StoryGrid } from "../components/StoryGrid";
import { FilterBar, type WriterFilterValue } from "../components/FilterBar";
import { GENRES, LANGUAGES } from "../../lib/genres";
import { FilterBar, type WriterFilterValue, type ContentTypeFilterValue } from "../components/FilterBar";
import { GENRES, LANGUAGES, CONTENT_TYPES } from "../../lib/genres";
import Link from "next/link";

const TABS = ["new", "trending", "mcap"] as const;
Expand All @@ -13,14 +13,14 @@ const WRITER_VALUES: WriterFilterValue[] = ["all", "human", "agent"];

const PAGE_SIZE = 24;

type SearchParams = Promise<{ tab?: string; writer?: string; page?: string; genre?: string; lang?: string; nsfw?: string }>;
type SearchParams = Promise<{ tab?: string; writer?: string; page?: string; genre?: string; lang?: string; type?: string; nsfw?: string }>;

export default async function Home({
searchParams,
}: {
searchParams: SearchParams;
}) {
const { tab: rawTab, writer: rawWriter, page: rawPage, genre: rawGenre, lang: rawLang, nsfw: rawNsfw } = await searchParams;
const { tab: rawTab, writer: rawWriter, page: rawPage, genre: rawGenre, lang: rawLang, type: rawType, nsfw: rawNsfw } = await searchParams;
const tab: Tab = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : "trending";
const writer: WriterFilterValue = WRITER_VALUES.includes(
rawWriter as WriterFilterValue,
Expand All @@ -30,13 +30,14 @@ export default async function Home({
const page = Math.max(1, parseInt(rawPage ?? "1", 10) || 1);
const genre = rawGenre && (GENRES as readonly string[]).includes(rawGenre) ? rawGenre : "all";
const lang = rawLang && (LANGUAGES as readonly string[]).includes(rawLang) ? rawLang : "all";
const contentType: ContentTypeFilterValue = rawType && (CONTENT_TYPES as readonly string[]).includes(rawType) ? (rawType as ContentTypeFilterValue) : "all";
const showNsfw = rawNsfw === "1";

const supabase = createServerClient();

let storylines: Storyline[] = [];
if (supabase) {
storylines = await queryTab(supabase, tab, writer, page, genre, lang, showNsfw);
storylines = await queryTab(supabase, tab, writer, page, genre, lang, contentType, showNsfw);
}

return (
Expand All @@ -51,7 +52,7 @@ export default async function Home({
</p>
</header>

<FilterBar writer={writer} genre={genre} lang={lang} tab={tab} totalCount={storylines.length} showNsfw={showNsfw} />
<FilterBar writer={writer} genre={genre} lang={lang} contentType={contentType} tab={tab} totalCount={storylines.length} showNsfw={showNsfw} />

{/* Section label */}
<h2 className="mt-4 mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted">
Expand All @@ -66,7 +67,7 @@ export default async function Home({
<div className="mt-8 flex items-center justify-center gap-4">
{page > 1 && (
<Link
href={buildPageHref(tab, writer, page - 1, genre, lang, showNsfw)}
href={buildPageHref(tab, writer, page - 1, genre, lang, contentType, showNsfw)}
className="border-border text-muted hover:text-foreground rounded border px-4 py-2 text-xs transition-colors"
>
&larr; Previous
Expand All @@ -75,7 +76,7 @@ export default async function Home({
<span className="text-muted text-xs">Page {page}</span>
{storylines.length === PAGE_SIZE && (
<Link
href={buildPageHref(tab, writer, page + 1, genre, lang, showNsfw)}
href={buildPageHref(tab, writer, page + 1, genre, lang, contentType, showNsfw)}
className="border-border text-muted hover:text-foreground rounded border px-4 py-2 text-xs transition-colors"
>
Next &rarr;
Expand Down Expand Up @@ -106,11 +107,12 @@ export default async function Home({
);
}

function buildPageHref(tab: string, writer: string, page: number, genre: string, lang: string, showNsfw?: boolean): string {
function buildPageHref(tab: string, writer: string, page: number, genre: string, lang: string, contentType: string, showNsfw?: boolean): string {
const params = new URLSearchParams({ tab });
if (writer !== "all") params.set("writer", writer);
if (genre !== "all") params.set("genre", genre);
if (lang !== "all") params.set("lang", lang);
if (contentType !== "all") params.set("type", contentType);
if (showNsfw) params.set("nsfw", "1");
if (page > 1) params.set("page", String(page));
return `/?${params.toString()}`;
Expand All @@ -123,6 +125,7 @@ async function queryTab(
page: number,
genre: string,
lang: string,
contentType: string,
showNsfw: boolean,
): Promise<Storyline[]> {
const from = (page - 1) * PAGE_SIZE;
Expand All @@ -134,6 +137,7 @@ async function queryTab(
if (writer === "agent") filtered = filtered.eq("writer_type", 1);
if (genre !== "all") filtered = filtered.eq("genre", genre);
if (lang !== "all") filtered = filtered.eq("language", lang);
if (contentType !== "all") filtered = filtered.eq("content_type", contentType);
filtered = filtered.eq("is_nsfw", showNsfw);
return filtered;
}
Expand All @@ -158,14 +162,16 @@ async function queryTab(
const wt = writer === "human" ? 0 : writer === "agent" ? 1 : undefined;
const g = genre !== "all" ? genre : undefined;
const l = lang !== "all" ? lang : undefined;
return getTrendingStorylines(supabase, PAGE_SIZE, wt, from, g, l, showNsfw);
const ct = contentType !== "all" ? contentType : undefined;
return getTrendingStorylines(supabase, PAGE_SIZE, wt, from, g, l, ct, showNsfw);
}

case "mcap": {
const wt = writer === "human" ? 0 : writer === "agent" ? 1 : undefined;
const g = genre !== "all" ? genre : undefined;
const l = lang !== "all" ? lang : undefined;
return getMcapStorylines(supabase, PAGE_SIZE, wt, from, g, l, showNsfw);
const ct = contentType !== "all" ? contentType : undefined;
return getMcapStorylines(supabase, PAGE_SIZE, wt, from, g, l, ct, showNsfw);
}
}
}
97 changes: 76 additions & 21 deletions src/components/FilterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { GENRES, LANGUAGES } from "../../lib/genres";
import { GENRES, LANGUAGES, CONTENT_TYPES } from "../../lib/genres";

const SORT_OPTIONS = [
{ value: "new", label: "New" },
Expand All @@ -18,20 +18,30 @@ const WRITER_OPTIONS = [

export type WriterFilterValue = "all" | "human" | "agent";

const CONTENT_TYPE_OPTIONS = [
{ value: "all", label: "All" },
{ value: "fiction", label: "Fiction" },
{ value: "cartoon", label: "Cartoon" },
] as const;

export type ContentTypeFilterValue = "all" | "fiction" | "cartoon";

interface FilterBarProps {
writer: string;
genre: string;
lang: string;
contentType: string;
tab: string;
totalCount?: number;
showNsfw?: boolean;
}

function buildHref(params: { tab: string; writer: string; genre: string; lang: string; nsfw?: boolean }) {
function buildHref(params: { tab: string; writer: string; genre: string; lang: string; contentType: string; nsfw?: boolean }) {
const sp = new URLSearchParams({ tab: params.tab });
if (params.writer !== "all") sp.set("writer", params.writer);
if (params.genre !== "all") sp.set("genre", params.genre);
if (params.lang !== "all") sp.set("lang", params.lang);
if (params.contentType !== "all") sp.set("type", params.contentType);
if (params.nsfw) sp.set("nsfw", "1");
return `/?${sp.toString()}`;
}
Expand All @@ -41,19 +51,22 @@ function FilterSheetContent({
writer,
genre,
lang,
contentType,
nsfw,
onApply,
}: {
onClose: () => void;
writer: string;
genre: string;
lang: string;
contentType: string;
nsfw: boolean;
onApply: (w: string, g: string, l: string, nsfw: boolean) => void;
onApply: (w: string, g: string, l: string, ct: string, nsfw: boolean) => void;
}) {
const [localWriter, setLocalWriter] = useState(writer);
const [localGenre, setLocalGenre] = useState(genre);
const [localLang, setLocalLang] = useState(lang);
const [localContentType, setLocalContentType] = useState(contentType);
const [localNsfw, setLocalNsfw] = useState(nsfw);

useEffect(() => {
Expand Down Expand Up @@ -89,6 +102,26 @@ function FilterSheetContent({
</div>
</div>

{/* Content Type */}
<div>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-[var(--muted)]">Type</div>
<div className="flex gap-1.5">
{CONTENT_TYPE_OPTIONS.map(({ value, label }) => (
<button
key={value}
onClick={() => setLocalContentType(value)}
className={`rounded-full border px-3.5 py-1.5 text-xs font-medium transition-colors ${
localContentType === value
? "border-[var(--accent)] text-[var(--accent)] bg-[var(--accent-bg)]"
: "border-[var(--border)] text-[var(--muted)]"
}`}
>
{label}
</button>
))}
</div>
</div>

{/* Genre */}
<div>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-[var(--muted)]">Genre</div>
Expand Down Expand Up @@ -134,7 +167,7 @@ function FilterSheetContent({

{/* Apply */}
<button
onClick={() => { onApply(localWriter, localGenre, localLang, localNsfw); onClose(); }}
onClick={() => { onApply(localWriter, localGenre, localLang, localContentType, localNsfw); onClose(); }}
className="w-full rounded-lg bg-[var(--accent)] py-3 text-sm font-semibold text-white transition-opacity hover:opacity-90"
>
Apply Filters
Expand All @@ -145,46 +178,50 @@ function FilterSheetContent({
);
}

function FilterSheet({ open, ...props }: { open: boolean; onClose: () => void; writer: string; genre: string; lang: string; nsfw: boolean; onApply: (w: string, g: string, l: string, nsfw: boolean) => void }) {
function FilterSheet({ open, ...props }: { open: boolean; onClose: () => void; writer: string; genre: string; lang: string; contentType: string; nsfw: boolean; onApply: (w: string, g: string, l: string, ct: string, nsfw: boolean) => void }) {
if (!open) return null;
return <FilterSheetContent {...props} />;
}

export function FilterBar({ writer, genre, lang, tab, totalCount, showNsfw = false }: FilterBarProps) {
export function FilterBar({ writer, genre, lang, contentType, tab, totalCount, showNsfw = false }: FilterBarProps) {
const router = useRouter();
const [sheetOpen, setSheetOpen] = useState(false);
const nsfw = showNsfw;

useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has("lang") || urlParams.has("nsfw")) return;
if (urlParams.has("lang") || urlParams.has("nsfw") || urlParams.has("type")) return;
try {
const savedLang = localStorage.getItem("plotlink_lang");
const savedContentType = localStorage.getItem("plotlink_content_type") || "all";
const validContentType = savedContentType !== "all" && (CONTENT_TYPES as readonly string[]).includes(savedContentType) ? savedContentType : "all";
const savedNsfw = localStorage.getItem("plotlink_nsfw") === "1";
if ((savedLang && savedLang !== "all" && (LANGUAGES as readonly string[]).includes(savedLang)) || savedNsfw) {
router.replace(buildHref({ tab, writer, genre, lang: savedLang && savedLang !== "all" ? savedLang : lang, nsfw: savedNsfw }));
if ((savedLang && savedLang !== "all" && (LANGUAGES as readonly string[]).includes(savedLang)) || savedNsfw || validContentType !== "all") {
router.replace(buildHref({ tab, writer, genre, lang: savedLang && savedLang !== "all" ? savedLang : lang, contentType: validContentType !== "all" ? validContentType : contentType, nsfw: savedNsfw }));
}
} catch {}
}, []); // eslint-disable-line react-hooks/exhaustive-deps

function navigate(params: { tab: string; writer: string; genre: string; lang: string; nsfw?: boolean }) {
function navigate(params: { tab: string; writer: string; genre: string; lang: string; contentType: string; nsfw?: boolean }) {
try {
localStorage.setItem("plotlink_lang", params.lang);
localStorage.setItem("plotlink_content_type", params.contentType);
localStorage.setItem("plotlink_nsfw", params.nsfw ? "1" : "0");
} catch {}
router.push(buildHref(params));
}

const handleApplyFilters = useCallback((w: string, g: string, l: string, n: boolean) => {
navigate({ tab, writer: w, genre: g, lang: l, nsfw: n });
const handleApplyFilters = useCallback((w: string, g: string, l: string, ct: string, n: boolean) => {
navigate({ tab, writer: w, genre: g, lang: l, contentType: ct, nsfw: n });
}, [tab]); // eslint-disable-line react-hooks/exhaustive-deps

const activeFilterCount = [writer !== "all", genre !== "all", lang !== "all", nsfw].filter(Boolean).length;
const activeFilterCount = [writer !== "all", genre !== "all", lang !== "all", contentType !== "all", nsfw].filter(Boolean).length;
const activeChips: { label: string; clear: () => void }[] = [];
if (writer !== "all") activeChips.push({ label: `Writer: ${writer}`, clear: () => navigate({ tab, writer: "all", genre, lang, nsfw }) });
if (genre !== "all") activeChips.push({ label: genre, clear: () => navigate({ tab, writer, genre: "all", lang, nsfw }) });
if (lang !== "all") activeChips.push({ label: lang, clear: () => navigate({ tab, writer, genre, lang: "all", nsfw }) });
if (nsfw) activeChips.push({ label: "19+", clear: () => navigate({ tab, writer, genre, lang, nsfw: false }) });
if (writer !== "all") activeChips.push({ label: `Writer: ${writer}`, clear: () => navigate({ tab, writer: "all", genre, lang, contentType, nsfw }) });
if (contentType !== "all") activeChips.push({ label: contentType === "cartoon" ? "Cartoon" : "Fiction", clear: () => navigate({ tab, writer, genre, lang, contentType: "all", nsfw }) });
if (genre !== "all") activeChips.push({ label: genre, clear: () => navigate({ tab, writer, genre: "all", lang, contentType, nsfw }) });
if (lang !== "all") activeChips.push({ label: lang, clear: () => navigate({ tab, writer, genre, lang: "all", contentType, nsfw }) });
if (nsfw) activeChips.push({ label: "19+", clear: () => navigate({ tab, writer, genre, lang, contentType, nsfw: false }) });

return (
<>
Expand All @@ -195,7 +232,7 @@ export function FilterBar({ writer, genre, lang, tab, totalCount, showNsfw = fal
{SORT_OPTIONS.map(({ value, label }) => (
<button
key={value}
onClick={() => navigate({ tab: value, writer, genre, lang, nsfw })}
onClick={() => navigate({ tab: value, writer, genre, lang, contentType, nsfw })}
className={`relative px-3 py-2 text-[13px] font-medium transition-colors sm:text-sm ${
tab === value
? "font-semibold text-[var(--fg)]"
Expand Down Expand Up @@ -224,7 +261,7 @@ export function FilterBar({ writer, genre, lang, tab, totalCount, showNsfw = fal
{WRITER_OPTIONS.map(({ value, label }) => (
<button
key={value}
onClick={() => navigate({ tab, writer: value, genre, lang, nsfw })}
onClick={() => navigate({ tab, writer: value, genre, lang, contentType, nsfw })}
className={`rounded-full px-2.5 py-1 text-[12px] font-medium transition-colors ${
writer === value
? "bg-[var(--accent)] text-white"
Expand All @@ -236,11 +273,28 @@ export function FilterBar({ writer, genre, lang, tab, totalCount, showNsfw = fal
))}
</div>

{/* Content type pills */}
<div className="flex items-center rounded-full border border-[var(--border)] p-0.5">
{CONTENT_TYPE_OPTIONS.map(({ value, label }) => (
<button
key={value}
onClick={() => navigate({ tab, writer, genre, lang, contentType: value, nsfw })}
className={`rounded-full px-2.5 py-1 text-[12px] font-medium transition-colors ${
contentType === value
? "bg-[var(--accent)] text-white"
: "text-[var(--muted)] hover:text-[var(--fg)]"
}`}
>
{label}
</button>
))}
</div>

{/* Genre pill */}
<div className="relative">
<select
value={genre}
onChange={(e) => navigate({ tab, writer, genre: e.target.value, lang, nsfw })}
onChange={(e) => navigate({ tab, writer, genre: e.target.value, lang, contentType, nsfw })}
className={`rounded-full border px-3 py-1.5 text-[12px] font-medium transition-colors focus:border-[var(--accent)] focus:outline-none ${
genre !== "all"
? "border-[var(--accent)] bg-[var(--accent-bg)] text-[var(--accent)]"
Expand All @@ -256,7 +310,7 @@ export function FilterBar({ writer, genre, lang, tab, totalCount, showNsfw = fal
<div className="relative">
<select
value={lang}
onChange={(e) => navigate({ tab, writer, genre, lang: e.target.value, nsfw })}
onChange={(e) => navigate({ tab, writer, genre, lang: e.target.value, contentType, nsfw })}
className={`rounded-full border px-3 py-1.5 text-[12px] font-medium transition-colors focus:border-[var(--accent)] focus:outline-none ${
lang !== "all"
? "border-[var(--accent)] bg-[var(--accent-bg)] text-[var(--accent)]"
Expand Down Expand Up @@ -322,6 +376,7 @@ export function FilterBar({ writer, genre, lang, tab, totalCount, showNsfw = fal
writer={writer}
genre={genre}
lang={lang}
contentType={contentType}
nsfw={nsfw}
onApply={handleApplyFilters}
/>
Expand Down
Loading
Loading