Initial portfolio CMS: Fastify API + Vite SPA

Single-container app with Postgres catalog, Typesense search,
Authelia Remote-User admin, portfolio views, resume/PDF, media
uploads, and ArtStation import tooling.
This commit is contained in:
2026-07-23 17:01:34 -04:00
commit 43928f7a49
61 changed files with 13218 additions and 0 deletions
+152
View File
@@ -0,0 +1,152 @@
.site-header {
position: sticky;
top: 0;
z-index: 50;
height: var(--header-h);
border-bottom: 1px solid var(--border);
background: rgba(10, 10, 11, 0.82);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
&__inner {
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
&__logo {
display: inline-flex;
align-items: center;
gap: 0.55rem;
font-weight: 600;
letter-spacing: -0.02em;
font-size: 1.05rem;
}
&__mark {
color: var(--accent);
font-size: 0.85rem;
}
&__nav {
display: flex;
align-items: center;
gap: 1.75rem;
a {
font-size: 0.9rem;
color: var(--text-muted);
transition: color 0.15s;
&:hover,
&.active {
color: var(--text);
}
&.active {
color: var(--accent);
}
}
}
&__ext {
opacity: 0.85;
}
&__burger {
display: none;
width: 40px;
height: 40px;
place-items: center;
flex-direction: column;
gap: 6px;
span {
display: block;
width: 20px;
height: 1.5px;
background: var(--text);
}
}
}
@media (max-width: 720px) {
.site-header {
&__burger {
display: flex;
}
&__nav {
position: absolute;
top: var(--header-h);
left: 0;
right: 0;
flex-direction: column;
align-items: stretch;
gap: 0;
padding: 0.5rem 0 1rem;
background: var(--bg-elevated);
border-bottom: 1px solid var(--border);
display: none;
&.is-open {
display: flex;
}
a {
padding: 0.85rem 1.25rem;
}
}
}
}
.site-main {
flex: 1;
}
.site-footer {
margin-top: 5rem;
border-top: 1px solid var(--border);
padding: 3rem 0 2.5rem;
color: var(--text-muted);
font-size: 0.9rem;
&__inner {
display: grid;
gap: 1.5rem;
strong {
color: var(--text);
display: block;
margin-bottom: 0.35rem;
}
p {
margin: 0;
}
}
&__links {
display: flex;
flex-wrap: wrap;
gap: 1.25rem;
a:hover {
color: var(--accent);
}
}
&__copy {
font-size: 0.8rem;
color: var(--text-dim);
}
}
@media (min-width: 768px) {
.site-footer__inner {
grid-template-columns: 1.5fr 1fr auto;
align-items: end;
}
}
+85
View File
@@ -0,0 +1,85 @@
import { useEffect, useState } from "react";
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
import { api, type SiteSettings } from "../lib/api";
import "./Layout.scss";
export function Layout() {
const [site, setSite] = useState<SiteSettings | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const location = useLocation();
useEffect(() => {
api.site().then(setSite).catch(() => setSite({ name: "jmartgraphix" }));
}, []);
useEffect(() => {
setMenuOpen(false);
}, [location.pathname]);
const name = site?.name || "jmartgraphix";
return (
<>
<header className="site-header">
<div className="container site-header__inner">
<Link to="/" className="site-header__logo" aria-label={`${name} home`}>
<span className="site-header__mark"></span>
<span>{name}</span>
</Link>
<button
className="site-header__burger"
aria-label="Toggle menu"
aria-expanded={menuOpen}
onClick={() => setMenuOpen((v) => !v)}
>
<span />
<span />
</button>
<nav className={`site-header__nav ${menuOpen ? "is-open" : ""}`} aria-label="Main">
<NavLink to="/portfolio">Portfolio</NavLink>
<NavLink to="/resume">Resume</NavLink>
<NavLink to="/about">About</NavLink>
<a
href="https://jmartgraphix.artstation.com/"
target="_blank"
rel="noreferrer"
className="site-header__ext"
>
ArtStation
</a>
</nav>
</div>
</header>
<main className="site-main">
<Outlet context={{ site }} />
</main>
<footer className="site-footer">
<div className="container site-footer__inner">
<div>
<strong>{name}</strong>
<p>{site?.tagline || "Creative professional portfolio"}</p>
</div>
<div className="site-footer__links">
{site?.social?.youtube && (
<a href={site.social.youtube} target="_blank" rel="noreferrer">
YouTube
</a>
)}
{site?.social?.vimeo && (
<a href={site.social.vimeo} target="_blank" rel="noreferrer">
Vimeo
</a>
)}
{site?.social?.artstation && (
<a href={site.social.artstation} target="_blank" rel="noreferrer">
ArtStation
</a>
)}
<Link to="/resume">Resume</Link>
</div>
<p className="site-footer__copy">© {new Date().getFullYear()} {name}</p>
</div>
</footer>
</>
);
}
+95
View File
@@ -0,0 +1,95 @@
.project-card {
animation: fadeUp 0.5s ease both;
&__link {
display: block;
height: 100%;
border-radius: var(--radius);
overflow: hidden;
background: var(--bg-card);
border: 1px solid var(--border);
transition: border-color 0.2s, transform 0.25s, box-shadow 0.25s;
&:hover {
border-color: var(--border-strong);
transform: translateY(-3px);
box-shadow: var(--shadow);
.project-card__media img {
transform: scale(1.04);
}
}
}
&__media {
position: relative;
aspect-ratio: 4 / 3;
overflow: hidden;
background: #0e0e10;
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.55s ease;
}
}
&__placeholder {
width: 100%;
height: 100%;
display: grid;
place-items: center;
font-family: var(--font-serif);
font-size: 3rem;
color: var(--text-dim);
background: linear-gradient(145deg, #141416, #0c0c0e);
}
&__featured {
position: absolute;
top: 0.75rem;
left: 0.75rem;
}
&__body {
padding: 1.1rem 1.15rem 1.25rem;
h3 {
margin: 0 0 0.35rem;
font-size: 1.05rem;
}
p {
margin: 0;
font-size: 0.88rem;
color: var(--text-muted);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.45rem;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
}
&__year {
color: var(--text-dim);
margin-left: auto;
}
}
.project-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.35rem;
}
+41
View File
@@ -0,0 +1,41 @@
import { Link } from "react-router-dom";
import type { Project } from "../lib/api";
import { thumbOf } from "../lib/api";
import "./ProjectCard.scss";
export function ProjectCard({ project, index = 0 }: { project: Project; index?: number }) {
const thumb = thumbOf(project);
const year = project.date ? new Date(project.date).getFullYear() : null;
return (
<article
className="project-card"
style={{ animationDelay: `${Math.min(index, 12) * 40}ms` }}
>
<Link to={`/project/${project.slug}`} className="project-card__link">
<div className="project-card__media">
{thumb ? (
<img src={thumb} alt="" loading="lazy" />
) : (
<div className="project-card__placeholder" aria-hidden>
{project.title.slice(0, 1)}
</div>
)}
{project.featured && <span className="badge project-card__featured">Featured</span>}
</div>
<div className="project-card__body">
<div className="project-card__meta">
{project.categories.slice(0, 2).map((c) => (
<span key={c.id}>{c.name}</span>
))}
{year && <span className="project-card__year">{year}</span>}
</div>
<h3>{project.title}</h3>
{project.shortDescription && (
<p>{project.shortDescription}</p>
)}
</div>
</Link>
</article>
);
}