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 {
+5 -1
View File
@@ -74,7 +74,11 @@ async function main() {
"3d-animation",
"video",
]);
await createView("CAD", "cad", "CAD and technical design.", ["cad", "3d-modeling"]);
// Slug must not collide with category "cad" (path resolution prefers categories)
await createView("CAD & Technical", "technical-cad", "CAD and technical design.", [
"cad",
"3d-modeling",
]);
await createView("AI", "ai", "AI-generated creative work.", [
"ai-generated-content",
"illustration",
+167
View File
@@ -0,0 +1,167 @@
/**
* One-shot content hygiene from dogfood QA (grammar, Flickr residue, junk tags, etc.)
*
* npx tsx scripts/content-cleanup.ts
*/
import { prisma } from "../src/lib/prisma.js";
import { reindexAllProjects } from "../src/lib/typesense.js";
function cleanDescription(text: string): string {
let t = text || "";
t = t.replace(/\bcompanies sensors\b/gi, "company's sensors");
t = t.replace(/\b3d model\b/g, "3D model");
t = t.replace(/\b3d models\b/g, "3D models");
t = t.replace(/\bgaurded\b/gi, "guarded");
t = t.replace(/\bView\s+On\s+Black\b/gi, "");
t = t.replace(/[ \t]+\n/g, "\n");
t = t.replace(/\n{3,}/g, "\n\n");
return t.trim();
}
function makeShort(description: string, existing: string | null): string | null {
const cleaned = cleanDescription(existing || description || "");
if (!cleaned) return null;
// Prefer first sentence if reasonably short
const sentence = cleaned.match(/^[^.!?]+[.!?]/)?.[0];
if (sentence && sentence.length >= 40 && sentence.length <= 180) return sentence.trim();
if (cleaned.length <= 180) return cleaned;
return cleaned.slice(0, 177).replace(/\s+\S*$/, "") + "…";
}
function isJunkTag(name: string): boolean {
const t = name.trim();
if (!t) return true;
if (/^\d+$/.test(t)) return true;
if (/^\d+mm$/i.test(t)) return true;
if (/nikon|canon|nikkor|sony|sigma|tamron|exif|\bd90\b|\bd700|\bd800|e5000/i.test(t))
return true;
if (/^after$/i.test(t) || /^effects$/i.test(t)) return true;
if (/viewonblack|view.?on.?black/i.test(t)) return true;
if (t.length >= 14 && !/\s/.test(t) && /[0-9]/.test(t)) return true;
if (/369rollinghill|frenchcreekstatepark/i.test(t)) return true;
return false;
}
const DESCRIPTION_OVERRIDES: Record<string, string> = {
ankle:
"3D animated anatomical study of a human ankle, modeled and rendered for motion work and produced with Modo and After Effects.",
"agi-engineering-v-process-diagram":
"Motion graphics process diagram explaining AGIs Engineering V methodology for industrial automation projects—used in technical and marketing communications.",
"wired-for-sound-animated-tv-spot":
"Animated television spot for Wired for Sound, combining motion graphics and 2D animation for broadcast.",
"aikido-kokikai-federation-akf-logo":
"Video identity sequence for the Aikido Kokikai Federation (AKF) logo—brand motion for federation communications.",
};
async function main() {
console.log("Content cleanup starting…");
const projects = await prisma.project.findMany({
select: {
id: true,
slug: true,
title: true,
description: true,
shortDescription: true,
},
});
let textFixed = 0;
for (const p of projects) {
let description = cleanDescription(p.description || "");
let shortDescription = p.shortDescription
? cleanDescription(p.shortDescription)
: null;
if (DESCRIPTION_OVERRIDES[p.slug]) {
description = DESCRIPTION_OVERRIDES[p.slug];
}
// Eagle: shortDescription was truncated mid-word
if (p.slug === "eagle-in-flight" && shortDescription && /tru$/.test(shortDescription)) {
shortDescription = makeShort(description, null);
}
// When short is empty, a full dump, or identical to full desc — derive a blurb
if (
!shortDescription ||
shortDescription === description ||
(description && shortDescription.length > 200 && shortDescription.startsWith(description.slice(0, 40)))
) {
shortDescription = makeShort(description, null);
}
if (
description !== (p.description || "") ||
shortDescription !== (p.shortDescription || null)
) {
await prisma.project.update({
where: { id: p.id },
data: { description, shortDescription },
});
textFixed++;
console.log(` text: ${p.slug}`);
}
}
console.log(`Updated text on ${textFixed} projects`);
// Rename CAD view to avoid /portfolio/cad collision with empty CAD category
const cadView = await prisma.portfolioView.findFirst({ where: { slug: "cad" } });
if (cadView) {
await prisma.portfolioView.update({
where: { id: cadView.id },
data: { slug: "technical-cad", name: "CAD & Technical" },
});
console.log("Renamed portfolio view cad → technical-cad");
}
// Feature a few strong recent published projects if none featured
const featuredCount = await prisma.project.count({
where: { featured: true, visibility: "published" },
});
if (featuredCount === 0) {
const candidates = await prisma.project.findMany({
where: { visibility: "published" },
orderBy: [{ date: { sort: "desc", nulls: "last" } }, { displayPriority: "asc" }],
take: 6,
select: { id: true, title: true, slug: true },
});
for (const c of candidates) {
await prisma.project.update({ where: { id: c.id }, data: { featured: true } });
console.log(` featured: ${c.slug}`);
}
console.log(`Featured ${candidates.length} projects`);
} else {
console.log(`Leaving ${featuredCount} existing featured projects`);
}
// Junk tags
const tags = await prisma.tag.findMany({ select: { id: true, name: true, slug: true } });
let tagsRemoved = 0;
for (const tag of tags) {
if (!isJunkTag(tag.name) && !isJunkTag(tag.slug)) continue;
await prisma.projectTag.deleteMany({ where: { tagId: tag.id } });
await prisma.tag.delete({ where: { id: tag.id } });
tagsRemoved++;
console.log(` removed tag: ${tag.name}`);
}
console.log(`Removed ${tagsRemoved} junk tags`);
// Merge After Effects if both tags somehow remain (handled by delete)
try {
const n = await reindexAllProjects();
console.log(`Typesense reindexed (${n} projects)`);
} catch (err) {
console.warn("Typesense reindex failed:", err);
}
console.log("Content cleanup done.");
await prisma.$disconnect();
}
main().catch(async (err) => {
console.error(err);
await prisma.$disconnect();
process.exit(1);
});
+8
View File
@@ -84,6 +84,14 @@ async function main() {
limits: { fileSize: config.maxUploadMb * 1024 * 1024 },
});
// Baseline security headers on all responses (edge may add HSTS/CSP)
app.addHook("onSend", async (_req, reply) => {
reply.header("X-Content-Type-Options", "nosniff");
reply.header("X-Frame-Options", "SAMEORIGIN");
reply.header("Referrer-Policy", "strict-origin-when-cross-origin");
reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
});
await app.register(swagger, {
openapi: {
info: {
+46 -8
View File
@@ -160,9 +160,46 @@ function bestPhotoUrl(p: FlickrPhoto): string | null {
function photoDescription(p: FlickrPhoto): string {
const d = p.description;
if (!d) return "";
if (typeof d === "string") return stripHtml(d);
return stripHtml(d._content || "");
let text = "";
if (!d) text = "";
else if (typeof d === "string") text = stripHtml(d);
else text = stripHtml(d._content || "");
// Strip Flickr UI residue often left in descriptions
return text
.replace(/\bView\s+On\s+Black\b/gi, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
/** Drop EXIF/camera junk and meaningless Flickr tags. */
function isUsefulFlickrTag(raw: string): boolean {
const t = raw.trim();
if (!t || t.length < 2) return false;
if (/^\d+$/.test(t)) return false;
if (/^\d+mm$/i.test(t)) return false;
if (/nikon|canon|nikkor|sony|sigma|tamron|exif|d90|d700|d800|e5000/i.test(t)) return false;
if (/^af[s]?$/i.test(t)) return false;
if (/viewonblack|view.?on.?black/i.test(t)) return false;
// Long concatenated garbage without spaces (lens model dumps, addresses)
if (t.length >= 14 && !/\s/.test(t) && /[0-9]/.test(t)) return false;
if (/rollinghill|frenchcreekstatepark/i.test(t) && !/\s/.test(t)) return false;
return true;
}
/** Merge common split software tokens from Flickr (After + Effects). */
function normalizeFlickrTags(tags: string[]): string[] {
const out: string[] = [];
for (let i = 0; i < tags.length; i++) {
const cur = tags[i];
const next = tags[i + 1];
if (/^after$/i.test(cur) && next && /^effects$/i.test(next)) {
out.push("After Effects");
i++;
continue;
}
if (isUsefulFlickrTag(cur)) out.push(cur);
}
return out;
}
async function listPublicPhotos(
@@ -277,11 +314,12 @@ export async function runFlickrImport(
}
const desc = photoDescription(photo);
const tags = (photo.tags || "")
.split(/\s+/)
.map((t) => t.trim())
.filter(Boolean)
.slice(0, 20);
const tags = normalizeFlickrTags(
(photo.tags || "")
.split(/\s+/)
.map((t) => t.trim())
.filter(Boolean)
).slice(0, 12);
const visibility = options.publish ? Visibility.published : Visibility.draft;
const slug = await uniqueSlug(title);