Fix dogfood QA: 404, content hygiene, grid UX, a11y, CTAs

- Add branded NotFound catch-all (no more blank unknown routes)
- Portfolio cards: shimmer skeleton, eager first-row thumbs, real alts
- Hide empty categories; rename CAD view slug to avoid collision
- Resume summary preserves paragraphs/bullets
- Home/About/footer hire contact mailto CTA
- Sticky header scroll-padding; chip aria-labels
- Security headers; Flickr import strip View On Black + junk tags
- Content cleanup script for grammar, residue, tags, featured
This commit is contained in:
2026-08-04 16:14:24 -04:00
parent ba5325196c
commit a2cec1baf1
18 changed files with 479 additions and 42 deletions
+4
View File
@@ -5,6 +5,7 @@ import { PortfolioPage } from "./pages/PortfolioPage";
import { ProjectPage } from "./pages/ProjectPage";
import { ResumePage } from "./pages/ResumePage";
import { AboutPage } from "./pages/AboutPage";
import { NotFoundPage } from "./pages/NotFoundPage";
import { AdminLayout } from "./pages/admin/AdminLayout";
import { AdminDashboard } from "./pages/admin/AdminDashboard";
import { AdminProjects } from "./pages/admin/AdminProjects";
@@ -24,6 +25,7 @@ export default function App() {
<Route path="project/:slug" element={<ProjectPage />} />
<Route path="resume" element={<ResumePage />} />
<Route path="about" element={<AboutPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
<Route path="admin" element={<AdminLayout />}>
<Route index element={<AdminDashboard />} />
@@ -34,7 +36,9 @@ export default function App() {
<Route path="views" element={<AdminViews />} />
<Route path="resume" element={<AdminResume />} />
<Route path="settings" element={<AdminSettings />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
);
}
+3
View File
@@ -72,6 +72,9 @@ export function Layout() {
</div>
</div>
<div className="site-footer__links">
<a href={`mailto:${site?.social?.email || "jmartin@jmartgraphix.com"}`}>
Contact
</a>
{site?.social?.youtube && (
<a href={site.social.youtube} target="_blank" rel="noreferrer">
YouTube
+33 -1
View File
@@ -27,11 +27,34 @@
overflow: hidden;
background: #0e0e10;
&.is-loading::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
110deg,
#141416 0%,
#1c1c20 40%,
#141416 60%,
#0e0e10 100%
);
background-size: 200% 100%;
animation: project-card-shimmer 1.25s ease-in-out infinite;
z-index: 0;
}
img {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.55s ease;
opacity: 0;
transition: transform 0.55s ease, opacity 0.35s ease;
&.is-loaded {
opacity: 1;
}
}
}
@@ -93,3 +116,12 @@
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.35rem;
}
@keyframes project-card-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
+17 -6
View File
@@ -1,12 +1,17 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import type { Project } from "../lib/api";
import { thumbOf } from "../lib/api";
import { thumbOf, thumbAltOf } from "../lib/api";
import { formatProjectYear } from "../lib/dates";
import "./ProjectCard.scss";
export function ProjectCard({ project, index = 0 }: { project: Project; index?: number }) {
const thumb = thumbOf(project);
const alt = thumbAltOf(project);
const year = formatProjectYear(project.date);
const [loaded, setLoaded] = useState(false);
// First row-ish: eager load so portfolio doesn't look empty pre-scroll
const eager = index < 8;
return (
<article
@@ -14,9 +19,17 @@ export function ProjectCard({ project, index = 0 }: { project: Project; index?:
style={{ animationDelay: `${Math.min(index, 12) * 40}ms` }}
>
<Link to={`/project/${project.slug}`} className="project-card__link">
<div className="project-card__media">
<div className={`project-card__media${thumb && !loaded ? " is-loading" : ""}`}>
{thumb ? (
<img src={thumb} alt="" loading="lazy" />
<img
src={thumb}
alt={alt}
loading={eager ? "eager" : "lazy"}
decoding="async"
fetchPriority={eager ? "high" : "auto"}
onLoad={() => setLoaded(true)}
className={loaded ? "is-loaded" : undefined}
/>
) : (
<div className="project-card__placeholder" aria-hidden>
{project.title.slice(0, 1)}
@@ -32,9 +45,7 @@ export function ProjectCard({ project, index = 0 }: { project: Project; index?:
{year && <span className="project-card__year">{year}</span>}
</div>
<h3>{project.title}</h3>
{project.shortDescription && (
<p>{project.shortDescription}</p>
)}
{project.shortDescription && <p>{project.shortDescription}</p>}
</div>
</Link>
</article>
+9
View File
@@ -316,3 +316,12 @@ export function thumbOf(p: Project): string {
""
);
}
/** Prefer media alt text; fall back to project title for a11y. */
export function thumbAltOf(p: Project): string {
const fromThumb = p.thumbnail?.alt?.trim();
if (fromThumb) return fromThumb;
const fromMedia = p.media?.find((m) => m.type === "image")?.alt?.trim();
if (fromMedia) return fromMedia;
return p.title || "";
}
+8 -1
View File
@@ -21,10 +21,17 @@
&__lead {
font-size: 1.15rem;
color: var(--text-muted);
margin: 0 0 2.5rem;
margin: 0 0 1.5rem;
max-width: 36rem;
}
&__cta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin: 0 0 2.5rem;
}
&__grid {
display: grid;
gap: 2rem;
+16
View File
@@ -13,6 +13,8 @@ export function AboutPage() {
api.site().then(setSite).catch(() => {});
}, []);
const contactEmail = site?.social?.email || "jmartin@jmartgraphix.com";
return (
<div className="about page-enter container">
<p className="about__eyebrow">About</p>
@@ -21,6 +23,17 @@ export function AboutPage() {
{site?.about ||
"Creative professional specializing in 3D art, technical design, animation, and visual media."}
</p>
<div className="about__cta">
<a className="btn btn--primary" href={`mailto:${contactEmail}`}>
Hire / contact
</a>
<Link to="/portfolio" className="btn btn--ghost">
View portfolio
</Link>
<Link to="/resume" className="btn btn--ghost">
Resume
</Link>
</div>
<div className="about__grid">
<div>
<h2>Disciplines</h2>
@@ -36,6 +49,9 @@ export function AboutPage() {
<div>
<h2>Connect</h2>
<ul className="about__links">
<li>
<a href={`mailto:${contactEmail}`}>{contactEmail}</a>
</li>
{site?.social?.artstation && (
<li>
<a href={site.social.artstation} target="_blank" rel="noreferrer">
+31 -9
View File
@@ -37,6 +37,14 @@ export function HomePage() {
}, []);
const heroSub = site?.heroSubtitle || "3D · Design · Motion · Craft";
// Hide empty categories (avoid dead-end chips); keep curated views
const liveCategories = categories.filter(
(c) => typeof c.projectCount !== "number" || c.projectCount > 0
);
// Avoid advertising a view that collides with a category slug (e.g. cad)
const categorySlugs = new Set(categories.map((c) => c.slug));
const liveViews = views.filter((v) => !categorySlugs.has(v.slug));
const contactEmail = site?.social?.email || "jmartin@jmartgraphix.com";
return (
<div className="home page-enter">
@@ -63,21 +71,35 @@ export function HomePage() {
<Link to="/resume" className="btn btn--ghost">
Resume
</Link>
<a className="btn btn--ghost" href={`mailto:${contactEmail}`}>
Hire / contact
</a>
</div>
</div>
</section>
<section className="home-cats container">
<div className="home-cats__list">
{categories.map((c) => (
<Link key={c.id} to={`/portfolio/${c.slug}`} className="home-cats__chip">
{c.name}
{typeof c.projectCount === "number" && (
<span>{c.projectCount}</span>
)}
</Link>
))}
{views.map((v) => (
{liveCategories.map((c) => {
const countLabel =
typeof c.projectCount === "number"
? `${c.projectCount} project${c.projectCount === 1 ? "" : "s"}`
: undefined;
return (
<Link
key={c.id}
to={`/portfolio/${c.slug}`}
className="home-cats__chip"
aria-label={countLabel ? `${c.name}, ${countLabel}` : c.name}
>
{c.name}
{typeof c.projectCount === "number" && (
<span aria-hidden="true">{c.projectCount}</span>
)}
</Link>
);
})}
{liveViews.map((v) => (
<Link
key={v.id}
to={`/portfolio/${v.slug}`}
+35
View File
@@ -0,0 +1,35 @@
.not-found {
padding: 5rem 0 6rem;
max-width: 560px;
text-align: center;
margin: 0 auto;
&__code {
margin: 0 0 0.5rem;
font-size: 0.78rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent);
}
h1 {
margin: 0 0 0.85rem;
font-family: var(--font-serif);
font-style: italic;
font-weight: 400;
font-size: clamp(2rem, 5vw, 2.8rem);
}
&__lead {
margin: 0 0 2rem;
color: var(--text-muted);
line-height: 1.55;
}
&__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
}
}
+28
View File
@@ -0,0 +1,28 @@
import { Link } from "react-router-dom";
import { usePageTitle } from "../lib/pageTitle";
import "./NotFoundPage.scss";
export function NotFoundPage() {
usePageTitle("Page not found");
return (
<div className="not-found page-enter container">
<p className="not-found__code">404</p>
<h1>Page not found</h1>
<p className="not-found__lead">
That URL doesnt match anything on this site. It may have been moved or never existed.
</p>
<div className="not-found__actions">
<Link to="/" className="btn btn--primary">
Home
</Link>
<Link to="/portfolio" className="btn btn--ghost">
Portfolio
</Link>
<Link to="/about" className="btn btn--ghost">
About
</Link>
</div>
</div>
);
}
+29 -15
View File
@@ -131,11 +131,14 @@ export function PortfolioPage() {
disabled={isViewMode}
>
<option value="">All categories</option>
{categories.map((c) => (
<option key={c.id} value={c.slug}>
{c.name}
</option>
))}
{categories
.filter((c) => typeof c.projectCount !== "number" || c.projectCount > 0)
.map((c) => (
<option key={c.id} value={c.slug}>
{c.name}
{typeof c.projectCount === "number" ? ` (${c.projectCount})` : ""}
</option>
))}
</select>
<select
value={sort}
@@ -149,16 +152,27 @@ export function PortfolioPage() {
</div>
<div className="portfolio__quick">
{categories.map((c) => (
<Link
key={c.id}
to={`/portfolio/${c.slug}`}
className={viewSlug === c.slug || category === c.slug ? "is-active" : ""}
>
{c.name}
{typeof c.projectCount === "number" ? ` (${c.projectCount})` : ""}
</Link>
))}
{categories
.filter((c) => typeof c.projectCount !== "number" || c.projectCount > 0)
.map((c) => {
const countLabel =
typeof c.projectCount === "number"
? `${c.projectCount} project${c.projectCount === 1 ? "" : "s"}`
: undefined;
return (
<Link
key={c.id}
to={`/portfolio/${c.slug}`}
className={viewSlug === c.slug || category === c.slug ? "is-active" : ""}
aria-label={countLabel ? `${c.name}, ${countLabel}` : c.name}
>
{c.name}
{typeof c.projectCount === "number" ? (
<span aria-hidden="true">{` (${c.projectCount})`}</span>
) : null}
</Link>
);
})}
</div>
{loading && <p className="portfolio__status">Loading</p>}
+12
View File
@@ -55,6 +55,18 @@
padding-left: 1rem;
color: var(--text-muted);
margin: 0 0 2rem;
p {
margin: 0 0 0.65rem;
&:last-child {
margin-bottom: 0;
}
}
}
&__summary-bullet {
padding-left: 0.15rem;
}
&__section {
+26 -1
View File
@@ -6,6 +6,29 @@ import "./ResumePage.scss";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Sec = any;
/** Preserve paragraphs and bullet lines from resume summary text. */
function renderSummary(summary: string) {
let text = summary.replace(/\r\n/g, "\n").trim();
// If bullets are inline without newlines, split them out
if (!text.includes("\n") && /[•]/.test(text)) {
text = text.replace(/\s*•\s*/g, "\n• ");
}
return text
.split(/\n+/)
.map((line) => line.trim())
.filter(Boolean)
.map((line, i) => {
if (/^[•\-\*]\s*/.test(line)) {
return (
<p key={i} className="resume-doc__summary-bullet">
{line.replace(/^[•\-\*]\s*/, "• ")}
</p>
);
}
return <p key={i}>{line}</p>;
});
}
export function ResumePage() {
const [resume, setResume] = useState<Resume | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -75,7 +98,9 @@ export function ResumePage() {
.join(" · ")}
</p>
</header>
{resume.summary && <p className="resume-doc__summary">{resume.summary}</p>}
{resume.summary && (
<div className="resume-doc__summary">{renderSummary(resume.summary)}</div>
)}
{sections.map((sec, i) => (
<section key={i} className="resume-doc__section">
<h2>{sec.title || sec.type}</h2>
+2
View File
@@ -31,6 +31,8 @@
html {
scroll-behavior: smooth;
/* Keep anchored content clear of sticky site header */
scroll-padding-top: calc(var(--header-h) + 0.75rem);
}
body {