From a2cec1baf19dbf30060290b82064990d834bc611 Mon Sep 17 00:00:00 2001 From: jmartin Date: Tue, 4 Aug 2026 16:14:24 -0400 Subject: [PATCH] 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 --- client/src/App.tsx | 4 + client/src/components/Layout.tsx | 3 + client/src/components/ProjectCard.scss | 34 ++++- client/src/components/ProjectCard.tsx | 23 +++- client/src/lib/api.ts | 9 ++ client/src/pages/AboutPage.scss | 9 +- client/src/pages/AboutPage.tsx | 16 +++ client/src/pages/HomePage.tsx | 40 ++++-- client/src/pages/NotFoundPage.scss | 35 ++++++ client/src/pages/NotFoundPage.tsx | 28 +++++ client/src/pages/PortfolioPage.tsx | 44 ++++--- client/src/pages/ResumePage.scss | 12 ++ client/src/pages/ResumePage.tsx | 27 +++- client/src/styles/global.scss | 2 + server/prisma/seed.ts | 6 +- server/scripts/content-cleanup.ts | 167 +++++++++++++++++++++++++ server/src/index.ts | 8 ++ server/src/services/flickr-import.ts | 54 ++++++-- 18 files changed, 479 insertions(+), 42 deletions(-) create mode 100644 client/src/pages/NotFoundPage.scss create mode 100644 client/src/pages/NotFoundPage.tsx create mode 100644 server/scripts/content-cleanup.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 9baea2d..9ed7872 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { } /> } /> } /> + } /> }> } /> @@ -34,7 +36,9 @@ export default function App() { } /> } /> } /> + } /> + } /> ); } diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index 96fc12a..8a8eb47 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -72,6 +72,9 @@ export function Layout() {
+ + Contact + {site?.social?.youtube && ( YouTube diff --git a/client/src/components/ProjectCard.scss b/client/src/components/ProjectCard.scss index 69a9694..3e36e65 100644 --- a/client/src/components/ProjectCard.scss +++ b/client/src/components/ProjectCard.scss @@ -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; + } +} diff --git a/client/src/components/ProjectCard.tsx b/client/src/components/ProjectCard.tsx index d274658..91e5ded 100644 --- a/client/src/components/ProjectCard.tsx +++ b/client/src/components/ProjectCard.tsx @@ -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 (
-
+
{thumb ? ( - + {alt} setLoaded(true)} + className={loaded ? "is-loaded" : undefined} + /> ) : (
{project.title.slice(0, 1)} @@ -32,9 +45,7 @@ export function ProjectCard({ project, index = 0 }: { project: Project; index?: {year && {year}}

{project.title}

- {project.shortDescription && ( -

{project.shortDescription}

- )} + {project.shortDescription &&

{project.shortDescription}

}
diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index ad2b1bd..8252bb4 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -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 || ""; +} diff --git a/client/src/pages/AboutPage.scss b/client/src/pages/AboutPage.scss index c9997d5..9749dd1 100644 --- a/client/src/pages/AboutPage.scss +++ b/client/src/pages/AboutPage.scss @@ -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; diff --git a/client/src/pages/AboutPage.tsx b/client/src/pages/AboutPage.tsx index 0c06e74..ec3817a 100644 --- a/client/src/pages/AboutPage.tsx +++ b/client/src/pages/AboutPage.tsx @@ -13,6 +13,8 @@ export function AboutPage() { api.site().then(setSite).catch(() => {}); }, []); + const contactEmail = site?.social?.email || "jmartin@jmartgraphix.com"; + return (

About

@@ -21,6 +23,17 @@ export function AboutPage() { {site?.about || "Creative professional specializing in 3D art, technical design, animation, and visual media."}

+
+ + Hire / contact + + + View portfolio + + + Resume + +

Disciplines

@@ -36,6 +49,9 @@ export function AboutPage() {

Connect

- {categories.map((c) => ( - - {c.name} - {typeof c.projectCount === "number" && ( - {c.projectCount} - )} - - ))} - {views.map((v) => ( + {liveCategories.map((c) => { + const countLabel = + typeof c.projectCount === "number" + ? `${c.projectCount} project${c.projectCount === 1 ? "" : "s"}` + : undefined; + return ( + + {c.name} + {typeof c.projectCount === "number" && ( + + )} + + ); + })} + {liveViews.map((v) => ( +

404

+

Page not found

+

+ That URL doesn’t match anything on this site. It may have been moved or never existed. +

+
+ + Home + + + Portfolio + + + About + +
+
+ ); +} diff --git a/client/src/pages/PortfolioPage.tsx b/client/src/pages/PortfolioPage.tsx index 1362205..57d59c5 100644 --- a/client/src/pages/PortfolioPage.tsx +++ b/client/src/pages/PortfolioPage.tsx @@ -131,11 +131,14 @@ export function PortfolioPage() { disabled={isViewMode} > - {categories.map((c) => ( - - ))} + {categories + .filter((c) => typeof c.projectCount !== "number" || c.projectCount > 0) + .map((c) => ( + + ))}