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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 || "";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
{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>{c.projectCount}</span>
|
||||
<span aria-hidden="true">{c.projectCount}</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
{views.map((v) => (
|
||||
);
|
||||
})}
|
||||
{liveViews.map((v) => (
|
||||
<Link
|
||||
key={v.id}
|
||||
to={`/portfolio/${v.slug}`}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 doesn’t 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>
|
||||
);
|
||||
}
|
||||
@@ -131,9 +131,12 @@ export function PortfolioPage() {
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<option value="">All categories</option>
|
||||
{categories.map((c) => (
|
||||
{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>
|
||||
@@ -149,16 +152,27 @@ export function PortfolioPage() {
|
||||
</div>
|
||||
|
||||
<div className="portfolio__quick">
|
||||
{categories.map((c) => (
|
||||
{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" ? ` (${c.projectCount})` : ""}
|
||||
{typeof c.projectCount === "number" ? (
|
||||
<span aria-hidden="true">{` (${c.projectCount})`}</span>
|
||||
) : null}
|
||||
</Link>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{loading && <p className="portfolio__status">Loading…</p>}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 AGI’s 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);
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 || "")
|
||||
const tags = normalizeFlickrTags(
|
||||
(photo.tags || "")
|
||||
.split(/\s+/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 20);
|
||||
).slice(0, 12);
|
||||
|
||||
const visibility = options.publish ? Visibility.published : Visibility.draft;
|
||||
const slug = await uniqueSlug(title);
|
||||
|
||||
Reference in New Issue
Block a user