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:
@@ -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> {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user