Hard-filter /portfolio/:slug when it matches a category

Category chips (e.g. /portfolio/photography) now show only that
category — empty if none. Audience views like /portfolio/game-dev
still use prioritization.
This commit is contained in:
2026-07-24 11:19:47 -04:00
parent 81e80c7e2f
commit f29efb7924
3 changed files with 93 additions and 13 deletions
+4
View File
@@ -105,12 +105,16 @@ export interface ListMeta {
perPage: number; perPage: number;
total: number; total: number;
totalPages: number; totalPages: number;
/** all | category (hard filter) | view (audience prioritization) */
mode?: "all" | "category" | "view";
category?: Category;
view?: { view?: {
slug: string; slug: string;
name: string; name: string;
description?: string | null; description?: string | null;
categories?: Category[]; categories?: Category[];
}; };
search?: boolean;
} }
async function request<T>(path: string, init?: RequestInit): Promise<T> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
+45 -11
View File
@@ -53,13 +53,21 @@ export function PortfolioPage() {
setSearchParams(next); setSearchParams(next);
} }
const title = meta?.view?.name || (category ? categories.find((c) => c.slug === category)?.name : null) || "Portfolio"; const mode = meta?.mode;
const title =
meta?.view?.name ||
meta?.category?.name ||
(category ? categories.find((c) => c.slug === category)?.name : null) ||
"Portfolio";
const description = const description =
meta?.view?.description || meta?.view?.description ||
"Browse projects by category, tag, or search. Share tailored views with clients and collaborators."; meta?.category?.description ||
(mode === "category"
? `Only projects tagged ${title}.`
: "Browse projects by category, tag, or search. Share tailored views with clients and collaborators.");
const tabTitle = viewSlug const tabTitle = viewSlug
? meta?.view?.name || viewSlug ? meta?.view?.name || meta?.category?.name || viewSlug
: category : category
? categories.find((c) => c.slug === category)?.name || category ? categories.find((c) => c.slug === category)?.name || category
: q : q
@@ -67,6 +75,10 @@ export function PortfolioPage() {
: "Portfolio"; : "Portfolio";
usePageTitle(tabTitle); usePageTitle(tabTitle);
const activeSlug = viewSlug || category;
const isCategoryMode = mode === "category" || (!!viewSlug && categories.some((c) => c.slug === viewSlug));
const isViewMode = mode === "view";
return ( return (
<div className="portfolio page-enter container"> <div className="portfolio page-enter container">
<header className="portfolio__header"> <header className="portfolio__header">
@@ -74,11 +86,19 @@ export function PortfolioPage() {
<p className="portfolio__eyebrow">Work</p> <p className="portfolio__eyebrow">Work</p>
<h1>{title}</h1> <h1>{title}</h1>
<p className="portfolio__desc">{description}</p> <p className="portfolio__desc">{description}</p>
{viewSlug && ( {activeSlug && (
<p className="portfolio__view-note"> <p className="portfolio__view-note">
Viewing curated list <code>/portfolio/{viewSlug}</code> {isCategoryMode && !isViewMode ? (
<>
Filtered by category <code>/portfolio/{activeSlug}</code>
</>
) : (
<>
Viewing curated list <code>/portfolio/{activeSlug}</code>
</>
)}
{" · "} {" · "}
<Link to="/portfolio">clear view</Link> <Link to="/portfolio">show all</Link>
</p> </p>
)} )}
</div> </div>
@@ -96,10 +116,19 @@ export function PortfolioPage() {
aria-label="Search projects" aria-label="Search projects"
/> />
<select <select
value={category} value={viewSlug && isCategoryMode ? viewSlug : category}
onChange={(e) => updateParam("category", e.target.value)} onChange={(e) => {
const slug = e.target.value;
if (!slug) {
// clear path filter → all portfolio
window.location.href = "/portfolio";
return;
}
// Category hard-filter via path
window.location.href = `/portfolio/${slug}`;
}}
aria-label="Filter by category" aria-label="Filter by category"
disabled={!!viewSlug} disabled={isViewMode}
> >
<option value="">All categories</option> <option value="">All categories</option>
{categories.map((c) => ( {categories.map((c) => (
@@ -124,9 +153,10 @@ export function PortfolioPage() {
<Link <Link
key={c.id} key={c.id}
to={`/portfolio/${c.slug}`} to={`/portfolio/${c.slug}`}
className={viewSlug === c.slug ? "is-active" : ""} className={viewSlug === c.slug || category === c.slug ? "is-active" : ""}
> >
{c.name} {c.name}
{typeof c.projectCount === "number" ? ` (${c.projectCount})` : ""}
</Link> </Link>
))} ))}
</div> </div>
@@ -135,7 +165,11 @@ export function PortfolioPage() {
{error && <p className="portfolio__status portfolio__status--err">{error}</p>} {error && <p className="portfolio__status portfolio__status--err">{error}</p>}
{!loading && !error && projects.length === 0 && ( {!loading && !error && projects.length === 0 && (
<p className="portfolio__status">No published projects match these filters.</p> <p className="portfolio__status">
{isCategoryMode
? `No published projects in ${title} yet.`
: "No published projects match these filters."}
</p>
)} )}
<div className="project-grid"> <div className="project-grid">
+44 -2
View File
@@ -69,15 +69,41 @@ export async function publicRoutes(app: FastifyInstance) {
const page = Math.max(1, parseInt(req.query.page || "1", 10) || 1); const page = Math.max(1, parseInt(req.query.page || "1", 10) || 1);
const perPage = Math.min(100, Math.max(1, parseInt(req.query.perPage || "24", 10) || 24)); const perPage = Math.min(100, Math.max(1, parseInt(req.query.perPage || "24", 10) || 24));
const sort = req.query.sort || "priority"; const sort = req.query.sort || "priority";
const category = req.query.category; let category = req.query.category;
const tag = req.query.tag; const tag = req.query.tag;
const featured = const featured =
req.query.featured === "true" ? true : req.query.featured === "false" ? false : undefined; req.query.featured === "true" ? true : req.query.featured === "false" ? false : undefined;
const q = req.query.q; const q = req.query.q;
const viewSlug = req.query.view; const viewSlug = req.query.view;
// Portfolio view prioritization /**
* /portfolio/:slug resolution:
* 1) If slug matches a **category** → hard filter to only that category
* (empty categories return zero results — used by category chips)
* 2) Else if slug matches an audience **portfolio view** (game-dev, etc.)
* → prioritize ordered categories; optionally include the rest
*/
let matchedCategory:
| { id: string; name: string; slug: string; description: string | null }
| null = null;
if (viewSlug && !category) { if (viewSlug && !category) {
matchedCategory = await prisma.category.findFirst({
where: {
OR: [
{ slug: viewSlug },
{ name: { equals: viewSlug, mode: "insensitive" } },
],
},
select: { id: true, name: true, slug: true, description: true },
});
if (matchedCategory) {
category = matchedCategory.slug;
}
}
// Audience portfolio view prioritization (only when not a category slug)
if (viewSlug && !matchedCategory && !req.query.category) {
const view = await prisma.portfolioView.findFirst({ const view = await prisma.portfolioView.findFirst({
where: { slug: viewSlug, isActive: true }, where: { slug: viewSlug, isActive: true },
include: { include: {
@@ -141,6 +167,7 @@ export async function publicRoutes(app: FastifyInstance) {
perPage, perPage,
total, total,
totalPages: Math.ceil(total / perPage), totalPages: Math.ceil(total / perPage),
mode: "view" as const,
view: { view: {
slug: view.slug, slug: view.slug,
name: view.name, name: view.name,
@@ -233,6 +260,21 @@ export async function publicRoutes(app: FastifyInstance) {
perPage, perPage,
total, total,
totalPages: Math.ceil(total / perPage), totalPages: Math.ceil(total / perPage),
...(matchedCategory
? {
mode: "category" as const,
category: matchedCategory,
view: {
slug: matchedCategory.slug,
name: matchedCategory.name,
description:
matchedCategory.description ||
`Projects in ${matchedCategory.name}.`,
},
}
: category
? { mode: "category" as const }
: { mode: "all" as const }),
}, },
}; };
}); });