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:
@@ -0,0 +1,39 @@
|
|||||||
|
# Server
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PUBLIC_URL=https://jmartgraphix.com
|
||||||
|
|
||||||
|
# Postgres
|
||||||
|
DB_HOST=postgres
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=jmartgraphix-com
|
||||||
|
DB_USER=jmartgraphix-com
|
||||||
|
DB_PASS=changeme
|
||||||
|
DB_TYPE=postgres
|
||||||
|
DATABASE_URL=postgresql://jmartgraphix-com:changeme@postgres:5432/jmartgraphix-com
|
||||||
|
|
||||||
|
# Typesense
|
||||||
|
TYPESENSE_HOST=typesense
|
||||||
|
TYPESENSE_PORT=8108
|
||||||
|
TYPESENSE_PROTOCOL=http
|
||||||
|
TYPESENSE_API_KEY=typesense_api_key_change_me
|
||||||
|
|
||||||
|
# Imgproxy (optional)
|
||||||
|
IMGPROXY_HOST=http://imgproxy:8080
|
||||||
|
IMGPROXY_KEY=
|
||||||
|
IMGPROXY_SALT=
|
||||||
|
|
||||||
|
# Auth — Authelia Remote-User trust
|
||||||
|
# Comma-separated groups allowed to access admin (empty = any authenticated user)
|
||||||
|
ADMIN_GROUPS=lldap_admin,admin,portfolio_admin
|
||||||
|
# When true, require Remote-User header for /api/v1/admin/* (disable only for local dev)
|
||||||
|
REQUIRE_REMOTE_USER=true
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
UPLOAD_DIR=/app/data/uploads
|
||||||
|
MAX_UPLOAD_MB=50
|
||||||
|
|
||||||
|
# Resume PDF
|
||||||
|
SITE_NAME=jmartgraphix
|
||||||
|
SITE_TAGLINE=Creative Professional Portfolio
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
coverage/
|
||||||
|
.vite/
|
||||||
|
uploads/
|
||||||
|
server/public/uploads/
|
||||||
|
client/dist/
|
||||||
|
server/dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.prisma/
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ── Dependencies ──────────────────────────────────────────────
|
||||||
|
FROM node:22-bookworm-slim AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
COPY client/package.json ./client/
|
||||||
|
COPY server/package.json ./server/
|
||||||
|
RUN npm ci 2>/dev/null || npm install
|
||||||
|
|
||||||
|
# ── Build client ──────────────────────────────────────────────
|
||||||
|
FROM deps AS client-build
|
||||||
|
COPY client ./client
|
||||||
|
RUN npm run build -w client
|
||||||
|
|
||||||
|
# ── Build server ──────────────────────────────────────────────
|
||||||
|
FROM deps AS server-build
|
||||||
|
COPY server ./server
|
||||||
|
WORKDIR /app/server
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Runtime ───────────────────────────────────────────────────
|
||||||
|
FROM node:22-bookworm-slim AS runtime
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
chromium \
|
||||||
|
ca-certificates \
|
||||||
|
fonts-liberation \
|
||||||
|
fonts-dejavu-core \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
PORT=3000 \
|
||||||
|
HOST=0.0.0.0 \
|
||||||
|
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \
|
||||||
|
PUPPETEER_SKIP_DOWNLOAD=true \
|
||||||
|
CLIENT_DIST=/app/public \
|
||||||
|
PUBLIC_DIR=/app/public \
|
||||||
|
UPLOAD_DIR=/app/data/uploads
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
COPY server/package.json ./server/
|
||||||
|
COPY client/package.json ./client/
|
||||||
|
RUN npm ci --omit=dev 2>/dev/null || npm install --omit=dev
|
||||||
|
|
||||||
|
COPY --from=server-build /app/server/dist ./server/dist
|
||||||
|
COPY --from=server-build /app/server/prisma ./server/prisma
|
||||||
|
COPY --from=server-build /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=server-build /app/node_modules/@prisma ./node_modules/@prisma
|
||||||
|
COPY --from=client-build /app/client/dist ./public
|
||||||
|
COPY public/.well-known ./public/.well-known
|
||||||
|
|
||||||
|
# ArtStation import tooling (tsx available via optional reinstall of dev deps on demand)
|
||||||
|
COPY server/scripts ./server/scripts
|
||||||
|
COPY server/src ./server/src
|
||||||
|
COPY server/tsconfig.json ./server/tsconfig.json
|
||||||
|
COPY server/package.json ./server/package.json
|
||||||
|
|
||||||
|
RUN mkdir -p /app/data/uploads/originals /app/data/uploads/thumbs /app/data/uploads/videos \
|
||||||
|
&& cd /app/server && npx prisma generate
|
||||||
|
|
||||||
|
WORKDIR /app/server
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://127.0.0.1:3000/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# jmartgraphix.com — Portfolio CMS
|
||||||
|
|
||||||
|
Single-container portfolio website: **Fastify API** + **Vite/React SPA**, backed by **PostgreSQL** and **Typesense 30.2**. Admin is protected by **Authelia** (`Remote-User` / `Remote-Groups` headers).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Traefik (TLS) → jmartgraphix-com:3000
|
||||||
|
├── static SPA (public + /admin)
|
||||||
|
├── /api/v1/* (OpenAPI at /api/docs)
|
||||||
|
├── /uploads/*
|
||||||
|
└── /.well-known/* (matrix, webfinger)
|
||||||
|
|
||||||
|
Sidecars: Typesense
|
||||||
|
Shared: Postgres (catalog), optional imgproxy
|
||||||
|
```
|
||||||
|
|
||||||
|
| Path | Auth |
|
||||||
|
|------|------|
|
||||||
|
| `/`, `/portfolio/*`, `/resume`, `/api/v1/*` (public) | None |
|
||||||
|
| `/admin`, `/api/v1/admin/*` | Authelia forwardAuth → `Remote-User` |
|
||||||
|
|
||||||
|
## Source of truth
|
||||||
|
|
||||||
|
- Gitea: `https://git.jmartgraphix.com/jmartin/jmartgraphix.com.git`
|
||||||
|
- Local clone: `/docker/jmartgraphix/hub/`
|
||||||
|
- Host workspace `/docker/jmartgraphix` is **not** the git root (uploads, typesense data, prompt live there)
|
||||||
|
|
||||||
|
## Deploy loop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /docker/jmartgraphix/hub
|
||||||
|
git add -A && git commit -m "…" && git push origin master
|
||||||
|
|
||||||
|
cd /docker/komodo/periphery/stacks/jmartgraphix-com
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://jmartgraphix.com/api/v1/health
|
||||||
|
docker logs jmartgraphix-com --tail 80
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for: Database migrations applied, Typesense index, Server listening.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
See `.env.example`. Stack secrets live in
|
||||||
|
`/docker/komodo/periphery/stacks/jmartgraphix-com/.env`.
|
||||||
|
|
||||||
|
## ArtStation import
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it jmartgraphix-com \
|
||||||
|
npx tsx scripts/import-artstation.ts --user jmartgraphix
|
||||||
|
|
||||||
|
# dry run
|
||||||
|
docker exec -it jmartgraphix-com \
|
||||||
|
npx tsx scripts/import-artstation.ts --user jmartgraphix --dry-run
|
||||||
|
|
||||||
|
# single artwork
|
||||||
|
docker exec -it jmartgraphix-com \
|
||||||
|
npx tsx scripts/import-artstation.ts --url https://www.artstation.com/artwork/XXXX
|
||||||
|
```
|
||||||
|
|
||||||
|
Imported projects are **drafts** — review and publish in `/admin`.
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /docker/jmartgraphix/hub
|
||||||
|
cp .env.example .env # set DATABASE_URL, REQUIRE_REMOTE_USER=false
|
||||||
|
npm install
|
||||||
|
npm run db:migrate -w server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Multi-category projects with media gallery, YouTube/Vimeo/self-hosted video
|
||||||
|
- Portfolio views (`/portfolio/game-dev`, `/portfolio/engineering`, …)
|
||||||
|
- Typesense search, filters, featured, sort by priority/date/title
|
||||||
|
- Editable resume + PDF download
|
||||||
|
- OpenAPI docs at `/api/docs`
|
||||||
|
- Sitemap `/sitemap.xml`, RSS `/rss.xml`
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#0a0a0b" />
|
||||||
|
<meta name="description" content="jmartgraphix — Creative professional portfolio: 3D, CAD, animation, design." />
|
||||||
|
<meta property="og:site_name" content="jmartgraphix" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet" />
|
||||||
|
<title>jmartgraphix</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.4.0",
|
||||||
|
"yet-another-react-lightbox": "^3.21.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.12",
|
||||||
|
"@types/react-dom": "^19.0.4",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"sass": "^1.86.0",
|
||||||
|
"typescript": "^5.8.2",
|
||||||
|
"vite": "^6.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import { Layout } from "./components/Layout";
|
||||||
|
import { HomePage } from "./pages/HomePage";
|
||||||
|
import { PortfolioPage } from "./pages/PortfolioPage";
|
||||||
|
import { ProjectPage } from "./pages/ProjectPage";
|
||||||
|
import { ResumePage } from "./pages/ResumePage";
|
||||||
|
import { AboutPage } from "./pages/AboutPage";
|
||||||
|
import { AdminLayout } from "./pages/admin/AdminLayout";
|
||||||
|
import { AdminDashboard } from "./pages/admin/AdminDashboard";
|
||||||
|
import { AdminProjects } from "./pages/admin/AdminProjects";
|
||||||
|
import { AdminProjectEdit } from "./pages/admin/AdminProjectEdit";
|
||||||
|
import { AdminCategories } from "./pages/admin/AdminCategories";
|
||||||
|
import { AdminViews } from "./pages/admin/AdminViews";
|
||||||
|
import { AdminResume } from "./pages/admin/AdminResume";
|
||||||
|
import { AdminSettings } from "./pages/admin/AdminSettings";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route element={<Layout />}>
|
||||||
|
<Route index element={<HomePage />} />
|
||||||
|
<Route path="portfolio" element={<PortfolioPage />} />
|
||||||
|
<Route path="portfolio/:viewSlug" element={<PortfolioPage />} />
|
||||||
|
<Route path="project/:slug" element={<ProjectPage />} />
|
||||||
|
<Route path="resume" element={<ResumePage />} />
|
||||||
|
<Route path="about" element={<AboutPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="admin" element={<AdminLayout />}>
|
||||||
|
<Route index element={<AdminDashboard />} />
|
||||||
|
<Route path="projects" element={<AdminProjects />} />
|
||||||
|
<Route path="projects/new" element={<AdminProjectEdit />} />
|
||||||
|
<Route path="projects/:id" element={<AdminProjectEdit />} />
|
||||||
|
<Route path="categories" element={<AdminCategories />} />
|
||||||
|
<Route path="views" element={<AdminViews />} />
|
||||||
|
<Route path="resume" element={<AdminResume />} />
|
||||||
|
<Route path="settings" element={<AdminSettings />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
export type Visibility = "published" | "draft" | "private";
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description?: string | null;
|
||||||
|
sortOrder?: number;
|
||||||
|
projectCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
projectCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Media {
|
||||||
|
id: string;
|
||||||
|
projectId?: string | null;
|
||||||
|
type: "image" | "video" | "external";
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl?: string | null;
|
||||||
|
filename?: string | null;
|
||||||
|
mimeType?: string | null;
|
||||||
|
width?: number | null;
|
||||||
|
height?: number | null;
|
||||||
|
alt?: string | null;
|
||||||
|
caption?: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
videoSource?: "youtube" | "vimeo" | "self_hosted" | "external" | null;
|
||||||
|
videoId?: string | null;
|
||||||
|
externalUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalLink {
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
shortDescription?: string | null;
|
||||||
|
date?: string | null;
|
||||||
|
software: string[];
|
||||||
|
externalLinks: ExternalLink[];
|
||||||
|
featured: boolean;
|
||||||
|
displayPriority: number;
|
||||||
|
visibility: Visibility;
|
||||||
|
thumbnailId?: string | null;
|
||||||
|
thumbnail?: Media | null;
|
||||||
|
media?: Media[];
|
||||||
|
categories: Category[];
|
||||||
|
tags: Tag[];
|
||||||
|
sourceUrl?: string | null;
|
||||||
|
sourcePlatform?: string | null;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortfolioView {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description?: string | null;
|
||||||
|
defaultSort?: string;
|
||||||
|
showOthers?: boolean;
|
||||||
|
isActive?: boolean;
|
||||||
|
categoryPriorities?: {
|
||||||
|
priority: number;
|
||||||
|
category: Category;
|
||||||
|
categoryId?: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Resume {
|
||||||
|
id: string;
|
||||||
|
fullName: string;
|
||||||
|
title: string;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
website?: string | null;
|
||||||
|
summary: string;
|
||||||
|
sections: unknown[];
|
||||||
|
htmlContent?: string | null;
|
||||||
|
theme?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteSettings {
|
||||||
|
name?: string;
|
||||||
|
tagline?: string;
|
||||||
|
about?: string;
|
||||||
|
social?: Record<string, string>;
|
||||||
|
heroTitle?: string;
|
||||||
|
heroSubtitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListMeta {
|
||||||
|
page: number;
|
||||||
|
perPage: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
view?: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
categories?: Category[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...(init?.body instanceof FormData ? {} : { "Content-Type": "application/json" }),
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = res.statusText;
|
||||||
|
try {
|
||||||
|
const j = await res.json();
|
||||||
|
msg = j.message || j.error || msg;
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
throw new Error(msg || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
site: () => request<SiteSettings>("/api/v1/site"),
|
||||||
|
categories: () => request<Category[]>("/api/v1/categories"),
|
||||||
|
tags: () => request<Tag[]>("/api/v1/tags"),
|
||||||
|
projects: (params: Record<string, string | number | undefined> = {}) => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
|
if (v !== undefined && v !== "") q.set(k, String(v));
|
||||||
|
});
|
||||||
|
return request<{ data: Project[]; meta: ListMeta }>(`/api/v1/projects?${q}`);
|
||||||
|
},
|
||||||
|
project: (slug: string) => request<Project>(`/api/v1/projects/${slug}`),
|
||||||
|
featured: () => request<Project[]>("/api/v1/featured"),
|
||||||
|
views: () => request<PortfolioView[]>("/api/v1/views"),
|
||||||
|
view: (slug: string) => request<PortfolioView>(`/api/v1/views/${slug}`),
|
||||||
|
resume: () => request<Resume>("/api/v1/resume"),
|
||||||
|
health: () => request<{ status: string }>("/api/v1/health"),
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
me: () => request<{ username: string; name?: string; groups: string[] }>("/api/v1/admin/me"),
|
||||||
|
adminProjects: (params: Record<string, string | number | undefined> = {}) => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
|
if (v !== undefined && v !== "") q.set(k, String(v));
|
||||||
|
});
|
||||||
|
return request<{ data: Project[]; meta: ListMeta }>(`/api/v1/admin/projects?${q}`);
|
||||||
|
},
|
||||||
|
adminProject: (id: string) => request<Project>(`/api/v1/admin/projects/${id}`),
|
||||||
|
createProject: (body: unknown) =>
|
||||||
|
request<Project>("/api/v1/admin/projects", { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
updateProject: (id: string, body: unknown) =>
|
||||||
|
request<Project>(`/api/v1/admin/projects/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
deleteProject: (id: string) =>
|
||||||
|
request<{ ok: boolean }>(`/api/v1/admin/projects/${id}`, { method: "DELETE" }),
|
||||||
|
reorderProjects: (ids: string[]) =>
|
||||||
|
request<{ ok: boolean }>("/api/v1/admin/projects/reorder", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
}),
|
||||||
|
uploadMedia: (file: File, projectId?: string) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
if (projectId) fd.append("projectId", projectId);
|
||||||
|
return request<Media>("/api/v1/admin/media/upload", { method: "POST", body: fd });
|
||||||
|
},
|
||||||
|
addVideoLink: (url: string, projectId?: string) =>
|
||||||
|
request<Media>("/api/v1/admin/media/video-link", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ url, projectId }),
|
||||||
|
}),
|
||||||
|
deleteMedia: (id: string) =>
|
||||||
|
request<{ ok: boolean }>(`/api/v1/admin/media/${id}`, { method: "DELETE" }),
|
||||||
|
setThumbnail: (projectId: string, mediaId: string) =>
|
||||||
|
request<Project>(`/api/v1/admin/projects/${projectId}/thumbnail`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ mediaId }),
|
||||||
|
}),
|
||||||
|
adminCategories: () => request<Category[]>("/api/v1/admin/categories"),
|
||||||
|
createCategory: (body: unknown) =>
|
||||||
|
request<Category>("/api/v1/admin/categories", { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
updateCategory: (id: string, body: unknown) =>
|
||||||
|
request<Category>(`/api/v1/admin/categories/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
deleteCategory: (id: string) =>
|
||||||
|
request<{ ok: boolean }>(`/api/v1/admin/categories/${id}`, { method: "DELETE" }),
|
||||||
|
adminTags: () => request<Tag[]>("/api/v1/admin/tags"),
|
||||||
|
deleteTag: (id: string) =>
|
||||||
|
request<{ ok: boolean }>(`/api/v1/admin/tags/${id}`, { method: "DELETE" }),
|
||||||
|
adminViews: () => request<PortfolioView[]>("/api/v1/admin/views"),
|
||||||
|
createView: (body: unknown) =>
|
||||||
|
request<PortfolioView>("/api/v1/admin/views", { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
updateView: (id: string, body: unknown) =>
|
||||||
|
request<PortfolioView>(`/api/v1/admin/views/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
deleteView: (id: string) =>
|
||||||
|
request<{ ok: boolean }>(`/api/v1/admin/views/${id}`, { method: "DELETE" }),
|
||||||
|
adminResume: () => request<Resume | null>("/api/v1/admin/resume"),
|
||||||
|
saveResume: (body: unknown) =>
|
||||||
|
request<Resume>("/api/v1/admin/resume", { method: "PUT", body: JSON.stringify(body) }),
|
||||||
|
adminSettings: () => request<Record<string, unknown>>("/api/v1/admin/settings"),
|
||||||
|
saveSiteSettings: (body: unknown) =>
|
||||||
|
request<unknown>("/api/v1/admin/settings/site", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
reindex: () =>
|
||||||
|
request<{ ok: boolean; indexed: number }>("/api/v1/admin/reindex", { method: "POST" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mediaSrc(url?: string | null): string {
|
||||||
|
if (!url) return "";
|
||||||
|
if (url.startsWith("http") || url.startsWith("//")) return url;
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function thumbOf(p: Project): string {
|
||||||
|
return (
|
||||||
|
mediaSrc(p.thumbnail?.thumbnailUrl || p.thumbnail?.url) ||
|
||||||
|
mediaSrc(p.media?.find((m) => m.type === "image")?.thumbnailUrl) ||
|
||||||
|
mediaSrc(p.media?.find((m) => m.type === "image")?.url) ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles/global.scss";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
.about {
|
||||||
|
padding: 3.5rem 0 4rem;
|
||||||
|
max-width: 820px;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: clamp(2.2rem, 5vw, 3.2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__eyebrow {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__lead {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 2.5rem;
|
||||||
|
max-width: 36rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 2rem;
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 0.85rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
|
||||||
|
li {
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__links a:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { api, type SiteSettings } from "../lib/api";
|
||||||
|
import "./AboutPage.scss";
|
||||||
|
|
||||||
|
export function AboutPage() {
|
||||||
|
const [site, setSite] = useState<SiteSettings | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.site().then(setSite).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="about page-enter container">
|
||||||
|
<p className="about__eyebrow">About</p>
|
||||||
|
<h1>{site?.name || "jmartgraphix"}</h1>
|
||||||
|
<p className="about__lead">
|
||||||
|
{site?.about ||
|
||||||
|
"Creative professional specializing in 3D art, technical design, animation, and visual media."}
|
||||||
|
</p>
|
||||||
|
<div className="about__grid">
|
||||||
|
<div>
|
||||||
|
<h2>Disciplines</h2>
|
||||||
|
<ul>
|
||||||
|
<li>3D Modeling & Sculpting</li>
|
||||||
|
<li>CAD & Product Visualization</li>
|
||||||
|
<li>Animation & Motion</li>
|
||||||
|
<li>Graphic Design & Illustration</li>
|
||||||
|
<li>Photography & Video</li>
|
||||||
|
<li>AI-assisted creative pipelines</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Connect</h2>
|
||||||
|
<ul className="about__links">
|
||||||
|
{site?.social?.artstation && (
|
||||||
|
<li>
|
||||||
|
<a href={site.social.artstation} target="_blank" rel="noreferrer">
|
||||||
|
ArtStation
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{site?.social?.youtube && (
|
||||||
|
<li>
|
||||||
|
<a href={site.social.youtube} target="_blank" rel="noreferrer">
|
||||||
|
YouTube
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{site?.social?.vimeo && (
|
||||||
|
<li>
|
||||||
|
<a href={site.social.vimeo} target="_blank" rel="noreferrer">
|
||||||
|
Vimeo
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
<li>
|
||||||
|
<Link to="/resume">Resume</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/portfolio">Portfolio</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
.home-hero {
|
||||||
|
padding: 5rem 0 3.5rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -20% 20% auto -10%;
|
||||||
|
height: 70%;
|
||||||
|
background: radial-gradient(ellipse, rgba(201, 168, 124, 0.12), transparent 65%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: clamp(2.8rem, 8vw, 5.2rem);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__serif {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-style: italic;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__eyebrow {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
max-width: 32rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__about {
|
||||||
|
max-width: 36rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin: 0 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-cats {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
|
||||||
|
&__list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.45rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--view {
|
||||||
|
border-color: rgba(201, 168, 124, 0.35);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-section {
|
||||||
|
margin-bottom: 4rem;
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
&:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 2rem;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { api, type Project, type SiteSettings, type Category } from "../lib/api";
|
||||||
|
import { ProjectCard } from "../components/ProjectCard";
|
||||||
|
import "./HomePage.scss";
|
||||||
|
|
||||||
|
export function HomePage() {
|
||||||
|
const [site, setSite] = useState<SiteSettings | null>(null);
|
||||||
|
const [featured, setFeatured] = useState<Project[]>([]);
|
||||||
|
const [recent, setRecent] = useState<Project[]>([]);
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
api.site(),
|
||||||
|
api.featured().catch(() => [] as Project[]),
|
||||||
|
api.projects({ sort: "date", perPage: 6 }),
|
||||||
|
api.categories(),
|
||||||
|
]).then(([s, f, r, c]) => {
|
||||||
|
setSite(s);
|
||||||
|
setFeatured(f);
|
||||||
|
setRecent(r.data);
|
||||||
|
setCategories(c);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const heroTitle = site?.heroTitle || site?.name || "jmartgraphix";
|
||||||
|
const heroSub = site?.heroSubtitle || "3D · Design · Motion · Craft";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="home page-enter">
|
||||||
|
<section className="home-hero">
|
||||||
|
<div className="container">
|
||||||
|
<p className="home-hero__eyebrow">Portfolio</p>
|
||||||
|
<h1>
|
||||||
|
<span className="home-hero__serif">{heroTitle}</span>
|
||||||
|
</h1>
|
||||||
|
<p className="home-hero__sub">{heroSub}</p>
|
||||||
|
<p className="home-hero__about">
|
||||||
|
{site?.about ||
|
||||||
|
"A curated collection of 3D modeling, CAD, animation, and visual design."}
|
||||||
|
</p>
|
||||||
|
<div className="home-hero__actions">
|
||||||
|
<Link to="/portfolio" className="btn btn--primary">
|
||||||
|
View portfolio
|
||||||
|
</Link>
|
||||||
|
<Link to="/resume" className="btn btn--ghost">
|
||||||
|
Resume
|
||||||
|
</Link>
|
||||||
|
</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>
|
||||||
|
))}
|
||||||
|
<Link to="/portfolio/game-dev" className="home-cats__chip home-cats__chip--view">
|
||||||
|
Game Dev view
|
||||||
|
</Link>
|
||||||
|
<Link to="/portfolio/engineering" className="home-cats__chip home-cats__chip--view">
|
||||||
|
Engineering view
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{featured.length > 0 && (
|
||||||
|
<section className="container home-section">
|
||||||
|
<div className="home-section__head">
|
||||||
|
<h2>Featured</h2>
|
||||||
|
<Link to="/portfolio">All work →</Link>
|
||||||
|
</div>
|
||||||
|
<div className="project-grid">
|
||||||
|
{featured.map((p, i) => (
|
||||||
|
<ProjectCard key={p.id} project={p} index={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="container home-section">
|
||||||
|
<div className="home-section__head">
|
||||||
|
<h2>Recent</h2>
|
||||||
|
</div>
|
||||||
|
{recent.length === 0 ? (
|
||||||
|
<p className="home-empty">
|
||||||
|
Projects will appear here once published. Use the{" "}
|
||||||
|
<Link to="/admin">admin panel</Link> to add work, or import from ArtStation.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="project-grid">
|
||||||
|
{recent.map((p, i) => (
|
||||||
|
<ProjectCard key={p.id} project={p} index={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
.portfolio {
|
||||||
|
padding: 3rem 0 4rem;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: clamp(2rem, 5vw, 3rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__eyebrow {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__desc {
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 40rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__view-note {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
|
||||||
|
code {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
|
||||||
|
@media (min-width: 720px) {
|
||||||
|
grid-template-columns: 1.5fr 1fr 0.8fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__quick {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.is-active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 2rem 0;
|
||||||
|
|
||||||
|
&--err {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__pager {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useSearchParams, Link } from "react-router-dom";
|
||||||
|
import { api, type Project, type Category, type ListMeta } from "../lib/api";
|
||||||
|
import { ProjectCard } from "../components/ProjectCard";
|
||||||
|
import "./PortfolioPage.scss";
|
||||||
|
|
||||||
|
export function PortfolioPage() {
|
||||||
|
const { viewSlug } = useParams();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [meta, setMeta] = useState<ListMeta | null>(null);
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const q = searchParams.get("q") || "";
|
||||||
|
const category = searchParams.get("category") || "";
|
||||||
|
const tag = searchParams.get("tag") || "";
|
||||||
|
const sort = searchParams.get("sort") || "priority";
|
||||||
|
const page = parseInt(searchParams.get("page") || "1", 10) || 1;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.categories().then(setCategories).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
api
|
||||||
|
.projects({
|
||||||
|
q: q || undefined,
|
||||||
|
category: category || undefined,
|
||||||
|
tag: tag || undefined,
|
||||||
|
sort,
|
||||||
|
page,
|
||||||
|
perPage: 24,
|
||||||
|
view: viewSlug || undefined,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
setProjects(res.data);
|
||||||
|
setMeta(res.meta);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [q, category, tag, sort, page, viewSlug]);
|
||||||
|
|
||||||
|
function updateParam(key: string, value: string) {
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
if (value) next.set(key, value);
|
||||||
|
else next.delete(key);
|
||||||
|
if (key !== "page") next.delete("page");
|
||||||
|
setSearchParams(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = meta?.view?.name || (category ? categories.find((c) => c.slug === category)?.name : null) || "Portfolio";
|
||||||
|
const description =
|
||||||
|
meta?.view?.description ||
|
||||||
|
"Browse projects by category, tag, or search. Share tailored views with clients and collaborators.";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="portfolio page-enter container">
|
||||||
|
<header className="portfolio__header">
|
||||||
|
<div>
|
||||||
|
<p className="portfolio__eyebrow">Work</p>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<p className="portfolio__desc">{description}</p>
|
||||||
|
{viewSlug && (
|
||||||
|
<p className="portfolio__view-note">
|
||||||
|
Viewing curated list <code>/portfolio/{viewSlug}</code>
|
||||||
|
{" · "}
|
||||||
|
<Link to="/portfolio">clear view</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="portfolio__filters">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search projects…"
|
||||||
|
defaultValue={q}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") updateParam("q", (e.target as HTMLInputElement).value);
|
||||||
|
}}
|
||||||
|
onBlur={(e) => updateParam("q", e.target.value)}
|
||||||
|
aria-label="Search projects"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => updateParam("category", e.target.value)}
|
||||||
|
aria-label="Filter by category"
|
||||||
|
disabled={!!viewSlug}
|
||||||
|
>
|
||||||
|
<option value="">All categories</option>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c.id} value={c.slug}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={sort}
|
||||||
|
onChange={(e) => updateParam("sort", e.target.value)}
|
||||||
|
aria-label="Sort"
|
||||||
|
>
|
||||||
|
<option value="priority">Priority</option>
|
||||||
|
<option value="date">Date</option>
|
||||||
|
<option value="title">Alphabetical</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="portfolio__quick">
|
||||||
|
{categories.map((c) => (
|
||||||
|
<Link
|
||||||
|
key={c.id}
|
||||||
|
to={`/portfolio/${c.slug}`}
|
||||||
|
className={viewSlug === c.slug ? "is-active" : ""}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <p className="portfolio__status">Loading…</p>}
|
||||||
|
{error && <p className="portfolio__status portfolio__status--err">{error}</p>}
|
||||||
|
|
||||||
|
{!loading && !error && projects.length === 0 && (
|
||||||
|
<p className="portfolio__status">No published projects match these filters.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="project-grid">
|
||||||
|
{projects.map((p, i) => (
|
||||||
|
<ProjectCard key={p.id} project={p} index={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{meta && meta.totalPages > 1 && (
|
||||||
|
<div className="portfolio__pager">
|
||||||
|
<button
|
||||||
|
className="btn btn--ghost btn--sm"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => updateParam("page", String(page - 1))}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
Page {meta.page} of {meta.totalPages} · {meta.total} projects
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="btn btn--ghost btn--sm"
|
||||||
|
disabled={page >= meta.totalPages}
|
||||||
|
onClick={() => updateParam("page", String(page + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
.project-page {
|
||||||
|
padding: 2.5rem 0 4rem;
|
||||||
|
|
||||||
|
&__back {
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
&:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
font-size: clamp(1.8rem, 4vw, 2.8rem);
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__cats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__desc {
|
||||||
|
max-width: 42rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 0.85rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__videos {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__video {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background: #000;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
|
||||||
|
iframe,
|
||||||
|
video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__gallery {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
@media (min-width: 720px) {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__shot {
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-card);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.5rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__loading,
|
||||||
|
&__err {
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__err {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import Lightbox from "yet-another-react-lightbox";
|
||||||
|
import "yet-another-react-lightbox/styles.css";
|
||||||
|
import { api, type Project, mediaSrc } from "../lib/api";
|
||||||
|
import "./ProjectPage.scss";
|
||||||
|
|
||||||
|
export function ProjectPage() {
|
||||||
|
const { slug } = useParams();
|
||||||
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [lbIndex, setLbIndex] = useState(-1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slug) return;
|
||||||
|
api
|
||||||
|
.project(slug)
|
||||||
|
.then((p) => {
|
||||||
|
setProject(p);
|
||||||
|
document.title = `${p.title} · jmartgraphix`;
|
||||||
|
// Open Graph-ish meta updates
|
||||||
|
const setMeta = (prop: string, content: string) => {
|
||||||
|
let el = document.querySelector(`meta[property="${prop}"]`);
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement("meta");
|
||||||
|
el.setAttribute("property", prop);
|
||||||
|
document.head.appendChild(el);
|
||||||
|
}
|
||||||
|
el.setAttribute("content", content);
|
||||||
|
};
|
||||||
|
setMeta("og:title", p.title);
|
||||||
|
setMeta("og:description", p.shortDescription || p.description.slice(0, 160));
|
||||||
|
const img = p.thumbnail?.url || p.media?.find((m) => m.type === "image")?.url;
|
||||||
|
if (img) setMeta("og:image", mediaSrc(img));
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e.message));
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="container project-page">
|
||||||
|
<p className="project-page__err">{error}</p>
|
||||||
|
<Link to="/portfolio">← Back to portfolio</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return (
|
||||||
|
<div className="container project-page">
|
||||||
|
<p className="project-page__loading">Loading…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const images = (project.media || []).filter((m) => m.type === "image");
|
||||||
|
const videos = (project.media || []).filter((m) => m.type === "video");
|
||||||
|
const slides = images.map((m) => ({ src: mediaSrc(m.url) }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="project-page page-enter">
|
||||||
|
<div className="container">
|
||||||
|
<Link to="/portfolio" className="project-page__back">
|
||||||
|
← Portfolio
|
||||||
|
</Link>
|
||||||
|
<header className="project-page__header">
|
||||||
|
<div className="project-page__cats">
|
||||||
|
{project.categories.map((c) => (
|
||||||
|
<Link key={c.id} to={`/portfolio/${c.slug}`} className="badge">
|
||||||
|
{c.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<h1>{project.title}</h1>
|
||||||
|
<div className="project-page__meta">
|
||||||
|
{project.date && (
|
||||||
|
<span>{new Date(project.date).toLocaleDateString(undefined, { year: "numeric", month: "long" })}</span>
|
||||||
|
)}
|
||||||
|
{project.software.length > 0 && (
|
||||||
|
<span>{project.software.join(" · ")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{project.description && (
|
||||||
|
<div className="project-page__desc">
|
||||||
|
{project.description.split("\n").map((para, i) => (
|
||||||
|
<p key={i}>{para}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{videos.length > 0 && (
|
||||||
|
<div className="project-page__videos">
|
||||||
|
{videos.map((v) => (
|
||||||
|
<div key={v.id} className="project-page__video">
|
||||||
|
{v.videoSource === "youtube" || v.videoSource === "vimeo" ? (
|
||||||
|
<iframe
|
||||||
|
src={mediaSrc(v.url)}
|
||||||
|
title={v.caption || project.title}
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
) : v.videoSource === "self_hosted" ? (
|
||||||
|
<video src={mediaSrc(v.url)} controls playsInline />
|
||||||
|
) : (
|
||||||
|
<a href={v.externalUrl || v.url} target="_blank" rel="noreferrer" className="btn btn--ghost">
|
||||||
|
Watch video ↗
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{v.caption && <p>{v.caption}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="project-page__gallery">
|
||||||
|
{images.map((m, i) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
type="button"
|
||||||
|
className="project-page__shot"
|
||||||
|
onClick={() => setLbIndex(i)}
|
||||||
|
aria-label={`Open image ${i + 1}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={mediaSrc(m.thumbnailUrl || m.url)}
|
||||||
|
alt={m.alt || project.title}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(project.tags.length > 0 || project.externalLinks.length > 0) && (
|
||||||
|
<footer className="project-page__footer">
|
||||||
|
{project.tags.length > 0 && (
|
||||||
|
<div className="project-page__tags">
|
||||||
|
{project.tags.map((t) => (
|
||||||
|
<Link key={t.id} to={`/portfolio?tag=${encodeURIComponent(t.name)}`}>
|
||||||
|
#{t.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{project.externalLinks.length > 0 && (
|
||||||
|
<div className="project-page__links">
|
||||||
|
{project.externalLinks.map((l) => (
|
||||||
|
<a key={l.url} href={l.url} target="_blank" rel="noreferrer" className="btn btn--ghost btn--sm">
|
||||||
|
{l.label} ↗
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Lightbox
|
||||||
|
open={lbIndex >= 0}
|
||||||
|
index={lbIndex}
|
||||||
|
close={() => setLbIndex(-1)}
|
||||||
|
slides={slides}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
.resume-page {
|
||||||
|
padding: 2.5rem 0 4rem;
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__err {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.resume-doc {
|
||||||
|
max-width: 780px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
|
||||||
|
@media (min-width: 720px) {
|
||||||
|
padding: 3rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 2.2rem;
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__contact {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__summary {
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
padding-left: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section {
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0.4rem 0 0 1.1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, type Resume } from "../lib/api";
|
||||||
|
import "./ResumePage.scss";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type Sec = any;
|
||||||
|
|
||||||
|
export function ResumePage() {
|
||||||
|
const [resume, setResume] = useState<Resume | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.resume()
|
||||||
|
.then((r) => {
|
||||||
|
setResume(r);
|
||||||
|
document.title = `${r.fullName} — Resume · jmartgraphix`;
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="container resume-page">
|
||||||
|
<p className="resume-page__err">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resume) {
|
||||||
|
return (
|
||||||
|
<div className="container resume-page">
|
||||||
|
<p>Loading resume…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = (resume.sections || []) as Sec[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="resume-page page-enter container">
|
||||||
|
<div className="resume-page__actions">
|
||||||
|
<a className="btn btn--primary" href="/api/v1/resume/pdf">
|
||||||
|
Download PDF
|
||||||
|
</a>
|
||||||
|
<a className="btn btn--ghost" href="/api/v1/resume/html" target="_blank" rel="noreferrer">
|
||||||
|
Printable HTML
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
className="btn btn--ghost"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard?.writeText(window.location.href);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy share link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article className="resume-doc">
|
||||||
|
{resume.htmlContent ? (
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: resume.htmlContent }} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<header className="resume-doc__header">
|
||||||
|
<h1>{resume.fullName}</h1>
|
||||||
|
{resume.title && <p className="resume-doc__title">{resume.title}</p>}
|
||||||
|
<p className="resume-doc__contact">
|
||||||
|
{[resume.email, resume.phone, resume.location, resume.website]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
{resume.summary && <p className="resume-doc__summary">{resume.summary}</p>}
|
||||||
|
{sections.map((sec, i) => (
|
||||||
|
<section key={i} className="resume-doc__section">
|
||||||
|
<h2>{sec.title || sec.type}</h2>
|
||||||
|
{sec.type === "skills" &&
|
||||||
|
(sec.items || []).map((g: Sec, j: number) => (
|
||||||
|
<p key={j}>
|
||||||
|
<strong>{g.group}</strong> — {(g.skills || []).join(", ")}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{sec.type === "links" && (
|
||||||
|
<ul>
|
||||||
|
{(sec.items || []).map((l: Sec, j: number) => (
|
||||||
|
<li key={j}>
|
||||||
|
<a href={l.url} target="_blank" rel="noreferrer">
|
||||||
|
{l.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{sec.type !== "skills" &&
|
||||||
|
sec.type !== "links" &&
|
||||||
|
(sec.items || []).map((item: Sec, j: number) => (
|
||||||
|
<div key={j} className="resume-doc__item">
|
||||||
|
<h3>
|
||||||
|
{item.title}
|
||||||
|
{item.organization ? ` · ${item.organization}` : ""}
|
||||||
|
</h3>
|
||||||
|
<div className="resume-doc__meta">
|
||||||
|
{[item.location, [item.startDate, item.endDate].filter(Boolean).join(" – ")]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
</div>
|
||||||
|
{item.description && <p>{item.description}</p>}
|
||||||
|
{item.highlights && (
|
||||||
|
<ul>
|
||||||
|
{item.highlights.map((h: string, k: number) => (
|
||||||
|
<li key={k}>{h}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
.admin-auth-error {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__detail {
|
||||||
|
color: var(--danger);
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240px 1fr;
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
|
||||||
|
&__brand {
|
||||||
|
a {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
span {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
|
||||||
|
a {
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__user {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-content {
|
||||||
|
padding: 1.75rem 2rem 3rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page {
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.75rem 0.65rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover td {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
width: 48px;
|
||||||
|
height: 36px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #111;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.15rem;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form {
|
||||||
|
max-width: 720px;
|
||||||
|
|
||||||
|
&--wide {
|
||||||
|
max-width: 960px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-check-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-media-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
|
||||||
|
figure {
|
||||||
|
margin: 0;
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
background: #111;
|
||||||
|
|
||||||
|
img,
|
||||||
|
video {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
figcaption {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.35rem;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-badge {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
|
||||||
|
&--published {
|
||||||
|
background: rgba(74, 222, 128, 0.12);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
&--draft {
|
||||||
|
background: rgba(161, 161, 170, 0.15);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
&--private {
|
||||||
|
background: rgba(248, 113, 113, 0.12);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-msg {
|
||||||
|
padding: 0.65rem 0.9rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
background: rgba(74, 222, 128, 0.1);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
&--err {
|
||||||
|
background: rgba(248, 113, 113, 0.1);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, type Category, type Tag } from "../../lib/api";
|
||||||
|
|
||||||
|
export function AdminCategories() {
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
api.adminCategories().then(setCategories);
|
||||||
|
api.adminTags().then(setTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function addCategory(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim()) return;
|
||||||
|
await api.createCategory({ name: name.trim() });
|
||||||
|
setName("");
|
||||||
|
setMsg("Category created");
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeCategory(id: string, n: string) {
|
||||||
|
if (!confirm(`Delete category “${n}”?`)) return;
|
||||||
|
await api.deleteCategory(id);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTag(id: string, n: string) {
|
||||||
|
if (!confirm(`Delete tag “${n}”?`)) return;
|
||||||
|
await api.deleteTag(id);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<h1>Categories & Tags</h1>
|
||||||
|
<p className="admin-page__sub">
|
||||||
|
Categories are fixed taxonomy; tags are free-form labels on projects.
|
||||||
|
</p>
|
||||||
|
{msg && <div className="admin-msg admin-msg--ok">{msg}</div>}
|
||||||
|
|
||||||
|
<form className="admin-page__toolbar" onSubmit={addCategory}>
|
||||||
|
<input
|
||||||
|
placeholder="New category name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-elevated)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "0.55rem 0.8rem",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn--primary btn--sm">
|
||||||
|
Add category
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: "1rem" }}>Categories</h2>
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Slug</th>
|
||||||
|
<th>Order</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td>{c.name}</td>
|
||||||
|
<td>
|
||||||
|
<code>{c.slug}</code>
|
||||||
|
</td>
|
||||||
|
<td>{c.sortOrder ?? 0}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--danger btn--sm"
|
||||||
|
onClick={() => removeCategory(c.id, c.name)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: "1rem", marginTop: "2rem" }}>Tags</h2>
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Slug</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tags.map((t) => (
|
||||||
|
<tr key={t.id}>
|
||||||
|
<td>{t.name}</td>
|
||||||
|
<td>
|
||||||
|
<code>{t.slug}</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--danger btn--sm"
|
||||||
|
onClick={() => removeTag(t.id, t.name)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
|
||||||
|
export function AdminDashboard() {
|
||||||
|
const [stats, setStats] = useState({
|
||||||
|
projects: 0,
|
||||||
|
published: 0,
|
||||||
|
drafts: 0,
|
||||||
|
categories: 0,
|
||||||
|
views: 0,
|
||||||
|
});
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
api.adminProjects({ perPage: 1 }),
|
||||||
|
api.adminProjects({ visibility: "published", perPage: 1 }),
|
||||||
|
api.adminProjects({ visibility: "draft", perPage: 1 }),
|
||||||
|
api.adminCategories(),
|
||||||
|
api.adminViews(),
|
||||||
|
]).then(([all, pub, draft, cats, views]) => {
|
||||||
|
setStats({
|
||||||
|
projects: all.meta.total,
|
||||||
|
published: pub.meta.total,
|
||||||
|
drafts: draft.meta.total,
|
||||||
|
categories: cats.length,
|
||||||
|
views: views.length,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function reindex() {
|
||||||
|
try {
|
||||||
|
const r = await api.reindex();
|
||||||
|
setMsg(`Typesense reindexed ${r.indexed} projects`);
|
||||||
|
} catch (e) {
|
||||||
|
setMsg((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<p className="admin-page__sub">Manage portfolio content protected by Authelia SSO.</p>
|
||||||
|
{msg && <div className="admin-msg admin-msg--ok">{msg}</div>}
|
||||||
|
<div className="admin-cards">
|
||||||
|
<div className="admin-stat">
|
||||||
|
<strong>{stats.projects}</strong>
|
||||||
|
<span>Total projects</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-stat">
|
||||||
|
<strong>{stats.published}</strong>
|
||||||
|
<span>Published</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-stat">
|
||||||
|
<strong>{stats.drafts}</strong>
|
||||||
|
<span>Drafts</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-stat">
|
||||||
|
<strong>{stats.categories}</strong>
|
||||||
|
<span>Categories</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-stat">
|
||||||
|
<strong>{stats.views}</strong>
|
||||||
|
<span>Portfolio views</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-page__toolbar">
|
||||||
|
<Link to="/admin/projects/new" className="btn btn--primary">
|
||||||
|
New project
|
||||||
|
</Link>
|
||||||
|
<Link to="/admin/resume" className="btn btn--ghost">
|
||||||
|
Edit resume
|
||||||
|
</Link>
|
||||||
|
<button type="button" className="btn btn--ghost" onClick={reindex}>
|
||||||
|
Reindex search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<section>
|
||||||
|
<h2 style={{ fontSize: "1rem", color: "var(--text-muted)" }}>Quick links for audiences</h2>
|
||||||
|
<ul style={{ color: "var(--text-dim)", lineHeight: 1.9 }}>
|
||||||
|
<li>
|
||||||
|
<code>/portfolio/game-dev</code> — animation first, then modeling
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>/portfolio/engineering</code> — CAD & product visualization first
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>/portfolio/3d</code>, <code>/portfolio/cad</code>, <code>/portfolio/ai</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>/resume</code> — shareable resume URL
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import "./Admin.scss";
|
||||||
|
|
||||||
|
export function AdminLayout() {
|
||||||
|
const [user, setUser] = useState<{ username: string; name?: string } | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.me()
|
||||||
|
.then(setUser)
|
||||||
|
.catch((e) => setError(e.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="admin-auth-error">
|
||||||
|
<h1>Admin access required</h1>
|
||||||
|
<p>
|
||||||
|
This area is protected by Authelia SSO via the <code>Remote-User</code> header.
|
||||||
|
</p>
|
||||||
|
<p className="admin-auth-error__detail">{error}</p>
|
||||||
|
<p>
|
||||||
|
Sign in at{" "}
|
||||||
|
<a href="https://auth.jmartgraphix.com">auth.jmartgraphix.com</a>, then return here.
|
||||||
|
</p>
|
||||||
|
<Link to="/" className="btn btn--ghost">
|
||||||
|
Back to site
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="admin-auth-error">
|
||||||
|
<p>Checking authentication…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-shell">
|
||||||
|
<aside className="admin-nav">
|
||||||
|
<div className="admin-nav__brand">
|
||||||
|
<Link to="/admin">jmartgraphix</Link>
|
||||||
|
<span>Admin</span>
|
||||||
|
</div>
|
||||||
|
<nav>
|
||||||
|
<NavLink to="/admin" end>
|
||||||
|
Dashboard
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/admin/projects">Projects</NavLink>
|
||||||
|
<NavLink to="/admin/categories">Categories & Tags</NavLink>
|
||||||
|
<NavLink to="/admin/views">Portfolio Views</NavLink>
|
||||||
|
<NavLink to="/admin/resume">Resume</NavLink>
|
||||||
|
<NavLink to="/admin/settings">Settings</NavLink>
|
||||||
|
</nav>
|
||||||
|
<div className="admin-nav__user">
|
||||||
|
<span>{user.name || user.username}</span>
|
||||||
|
<button type="button" className="btn btn--ghost btn--sm" onClick={() => navigate("/")}>
|
||||||
|
View site
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div className="admin-content">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type Category,
|
||||||
|
type Media,
|
||||||
|
type Project,
|
||||||
|
type Visibility,
|
||||||
|
mediaSrc,
|
||||||
|
} from "../../lib/api";
|
||||||
|
|
||||||
|
const empty = {
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
shortDescription: "",
|
||||||
|
date: "",
|
||||||
|
software: "",
|
||||||
|
externalLinksText: "",
|
||||||
|
featured: false,
|
||||||
|
displayPriority: 0,
|
||||||
|
visibility: "draft" as Visibility,
|
||||||
|
categoryIds: [] as string[],
|
||||||
|
tagNames: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdminProjectEdit() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const isNew = !id || id === "new";
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [form, setForm] = useState(empty);
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [media, setMedia] = useState<Media[]>([]);
|
||||||
|
const [thumbnailId, setThumbnailId] = useState<string | null>(null);
|
||||||
|
const [videoUrl, setVideoUrl] = useState("");
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.adminCategories().then(setCategories);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNew) return;
|
||||||
|
api.adminProject(id!).then((p: Project) => {
|
||||||
|
setForm({
|
||||||
|
title: p.title,
|
||||||
|
description: p.description,
|
||||||
|
shortDescription: p.shortDescription || "",
|
||||||
|
date: p.date ? p.date.slice(0, 10) : "",
|
||||||
|
software: (p.software || []).join(", "),
|
||||||
|
externalLinksText: (p.externalLinks || [])
|
||||||
|
.map((l) => `${l.label}|${l.url}`)
|
||||||
|
.join("\n"),
|
||||||
|
featured: p.featured,
|
||||||
|
displayPriority: p.displayPriority,
|
||||||
|
visibility: p.visibility,
|
||||||
|
categoryIds: p.categories.map((c) => c.id),
|
||||||
|
tagNames: p.tags.map((t) => t.name).join(", "),
|
||||||
|
});
|
||||||
|
setMedia(p.media || []);
|
||||||
|
setThumbnailId(p.thumbnailId || p.thumbnail?.id || null);
|
||||||
|
});
|
||||||
|
}, [id, isNew]);
|
||||||
|
|
||||||
|
function parseLinks() {
|
||||||
|
return form.externalLinksText
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => {
|
||||||
|
const [label, ...rest] = line.split("|");
|
||||||
|
return { label: label.trim(), url: rest.join("|").trim() };
|
||||||
|
})
|
||||||
|
.filter((l) => l.label && l.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setErr(null);
|
||||||
|
setMsg(null);
|
||||||
|
const body = {
|
||||||
|
title: form.title,
|
||||||
|
description: form.description,
|
||||||
|
shortDescription: form.shortDescription || null,
|
||||||
|
date: form.date || null,
|
||||||
|
software: form.software
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
externalLinks: parseLinks(),
|
||||||
|
featured: form.featured,
|
||||||
|
displayPriority: Number(form.displayPriority) || 0,
|
||||||
|
visibility: form.visibility,
|
||||||
|
categoryIds: form.categoryIds,
|
||||||
|
tagNames: form.tagNames
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
thumbnailId,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (isNew) {
|
||||||
|
const p = await api.createProject(body);
|
||||||
|
setMsg("Created");
|
||||||
|
navigate(`/admin/projects/${p.id}`, { replace: true });
|
||||||
|
} else {
|
||||||
|
const p = await api.updateProject(id!, body);
|
||||||
|
setMedia(p.media || []);
|
||||||
|
setMsg("Saved");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUpload(files: FileList | null) {
|
||||||
|
if (!files?.length || isNew) {
|
||||||
|
setErr("Save the project first, then upload media.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
const m = await api.uploadMedia(file, id);
|
||||||
|
setMedia((prev) => [...prev, m]);
|
||||||
|
if (!thumbnailId && m.type === "image") {
|
||||||
|
await api.setThumbnail(id!, m.id);
|
||||||
|
setThumbnailId(m.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMsg("Upload complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addVideo() {
|
||||||
|
if (!videoUrl || isNew) return;
|
||||||
|
const m = await api.addVideoLink(videoUrl, id);
|
||||||
|
setMedia((prev) => [...prev, m]);
|
||||||
|
setVideoUrl("");
|
||||||
|
setMsg("Video link added");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMedia(mid: string) {
|
||||||
|
await api.deleteMedia(mid);
|
||||||
|
setMedia((prev) => prev.filter((m) => m.id !== mid));
|
||||||
|
if (thumbnailId === mid) setThumbnailId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeThumb(mid: string) {
|
||||||
|
if (isNew) return;
|
||||||
|
await api.setThumbnail(id!, mid);
|
||||||
|
setThumbnailId(mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<h1>{isNew ? "New project" : "Edit project"}</h1>
|
||||||
|
<p className="admin-page__sub">
|
||||||
|
<Link to="/admin/projects">← Projects</Link>
|
||||||
|
</p>
|
||||||
|
{msg && <div className="admin-msg admin-msg--ok">{msg}</div>}
|
||||||
|
{err && <div className="admin-msg admin-msg--err">{err}</div>}
|
||||||
|
|
||||||
|
<form className="admin-form admin-form--wide" onSubmit={save}>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="title">Title</label>
|
||||||
|
<input
|
||||||
|
id="title"
|
||||||
|
required
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="short">Short description</label>
|
||||||
|
<input
|
||||||
|
id="short"
|
||||||
|
value={form.shortDescription}
|
||||||
|
onChange={(e) => setForm({ ...form, shortDescription: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="desc">Description</label>
|
||||||
|
<textarea
|
||||||
|
id="desc"
|
||||||
|
rows={8}
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "1rem" }}>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="date">Date</label>
|
||||||
|
<input
|
||||||
|
id="date"
|
||||||
|
type="date"
|
||||||
|
value={form.date}
|
||||||
|
onChange={(e) => setForm({ ...form, date: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="pri">Display priority</label>
|
||||||
|
<input
|
||||||
|
id="pri"
|
||||||
|
type="number"
|
||||||
|
value={form.displayPriority}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, displayPriority: parseInt(e.target.value, 10) || 0 })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="vis">Visibility</label>
|
||||||
|
<select
|
||||||
|
id="vis"
|
||||||
|
value={form.visibility}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, visibility: e.target.value as Visibility })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="published">Published</option>
|
||||||
|
<option value="private">Private</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.featured}
|
||||||
|
onChange={(e) => setForm({ ...form, featured: e.target.checked })}
|
||||||
|
/>{" "}
|
||||||
|
Featured
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Categories</label>
|
||||||
|
<div className="admin-check-grid">
|
||||||
|
{categories.map((c) => (
|
||||||
|
<label key={c.id}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.categoryIds.includes(c.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
categoryIds: e.target.checked
|
||||||
|
? [...form.categoryIds, c.id]
|
||||||
|
: form.categoryIds.filter((x) => x !== c.id),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{c.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="tags">Tags (comma-separated)</label>
|
||||||
|
<input
|
||||||
|
id="tags"
|
||||||
|
value={form.tagNames}
|
||||||
|
onChange={(e) => setForm({ ...form, tagNames: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="soft">Software / tools (comma-separated)</label>
|
||||||
|
<input
|
||||||
|
id="soft"
|
||||||
|
value={form.software}
|
||||||
|
onChange={(e) => setForm({ ...form, software: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="links">External links (one per line: Label|https://…)</label>
|
||||||
|
<textarea
|
||||||
|
id="links"
|
||||||
|
rows={3}
|
||||||
|
value={form.externalLinksText}
|
||||||
|
onChange={(e) => setForm({ ...form, externalLinksText: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-page__toolbar">
|
||||||
|
<button type="submit" className="btn btn--primary" disabled={saving}>
|
||||||
|
{saving ? "Saving…" : "Save project"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{!isNew && (
|
||||||
|
<section style={{ marginTop: "2rem" }}>
|
||||||
|
<h2>Media</h2>
|
||||||
|
<div className="admin-page__toolbar">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*,video/*"
|
||||||
|
multiple
|
||||||
|
onChange={(e) => onUpload(e.target.files)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="YouTube / Vimeo / video URL"
|
||||||
|
value={videoUrl}
|
||||||
|
onChange={(e) => setVideoUrl(e.target.value)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 200,
|
||||||
|
background: "var(--bg-elevated)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "0.55rem 0.8rem",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn--ghost btn--sm" onClick={addVideo}>
|
||||||
|
Add video link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="admin-media-grid">
|
||||||
|
{media.map((m) => (
|
||||||
|
<figure key={m.id}>
|
||||||
|
{m.type === "image" ? (
|
||||||
|
<img src={mediaSrc(m.thumbnailUrl || m.url)} alt="" />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
aspectRatio: 1,
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
color: "var(--text-dim)",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.videoSource || "video"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<figcaption>
|
||||||
|
{m.type === "image" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--ghost btn--sm"
|
||||||
|
onClick={() => makeThumb(m.id)}
|
||||||
|
style={{
|
||||||
|
opacity: thumbnailId === m.id ? 1 : 0.7,
|
||||||
|
color: thumbnailId === m.id ? "var(--accent)" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Thumb
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--danger btn--sm"
|
||||||
|
onClick={() => removeMedia(m.id)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { api, type Project, thumbOf, mediaSrc } from "../../lib/api";
|
||||||
|
|
||||||
|
export function AdminProjects() {
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [visibility, setVisibility] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
setLoading(true);
|
||||||
|
api
|
||||||
|
.adminProjects({ q: q || undefined, visibility: visibility || undefined, perPage: 100 })
|
||||||
|
.then((r) => setProjects(r.data))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [visibility]);
|
||||||
|
|
||||||
|
async function remove(id: string, title: string) {
|
||||||
|
if (!confirm(`Delete “${title}”? This cannot be undone.`)) return;
|
||||||
|
await api.deleteProject(id);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<h1>Projects</h1>
|
||||||
|
<p className="admin-page__sub">Create, edit, publish, and reorder portfolio entries.</p>
|
||||||
|
<div className="admin-page__toolbar">
|
||||||
|
<Link to="/admin/projects/new" className="btn btn--primary">
|
||||||
|
New project
|
||||||
|
</Link>
|
||||||
|
<input
|
||||||
|
placeholder="Search…"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && load()}
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-elevated)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "0.55rem 0.8rem",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={visibility}
|
||||||
|
onChange={(e) => setVisibility(e.target.value)}
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-elevated)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "0.55rem 0.8rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All visibility</option>
|
||||||
|
<option value="published">Published</option>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="private">Private</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" className="btn btn--ghost btn--sm" onClick={load}>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p>Loading…</p>
|
||||||
|
) : (
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Priority</th>
|
||||||
|
<th>Categories</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<tr key={p.id}>
|
||||||
|
<td>
|
||||||
|
{thumbOf(p) ? (
|
||||||
|
<img className="thumb" src={mediaSrc(thumbOf(p))} alt="" />
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Link to={`/admin/projects/${p.id}`}>{p.title}</Link>
|
||||||
|
{p.featured && (
|
||||||
|
<span className="badge" style={{ marginLeft: 8 }}>
|
||||||
|
Featured
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`vis-badge vis-badge--${p.visibility}`}>{p.visibility}</span>
|
||||||
|
</td>
|
||||||
|
<td>{p.displayPriority}</td>
|
||||||
|
<td style={{ color: "var(--text-dim)", fontSize: "0.85rem" }}>
|
||||||
|
{p.categories.map((c) => c.name).join(", ") || "—"}
|
||||||
|
</td>
|
||||||
|
<td style={{ whiteSpace: "nowrap" }}>
|
||||||
|
<Link to={`/admin/projects/${p.id}`} className="btn btn--ghost btn--sm">
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
{p.visibility === "published" && (
|
||||||
|
<a
|
||||||
|
href={`/project/${p.slug}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="btn btn--ghost btn--sm"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--danger btn--sm"
|
||||||
|
onClick={() => remove(p.id, p.title)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, type Resume } from "../../lib/api";
|
||||||
|
|
||||||
|
export function AdminResume() {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
fullName: "",
|
||||||
|
title: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
location: "",
|
||||||
|
website: "",
|
||||||
|
summary: "",
|
||||||
|
sectionsJson: "[]",
|
||||||
|
htmlContent: "",
|
||||||
|
});
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.adminResume().then((r) => {
|
||||||
|
if (!r) return;
|
||||||
|
setForm({
|
||||||
|
fullName: r.fullName,
|
||||||
|
title: r.title || "",
|
||||||
|
email: r.email || "",
|
||||||
|
phone: r.phone || "",
|
||||||
|
location: r.location || "",
|
||||||
|
website: r.website || "",
|
||||||
|
summary: r.summary || "",
|
||||||
|
sectionsJson: JSON.stringify(r.sections ?? [], null, 2),
|
||||||
|
htmlContent: r.htmlContent || "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function save(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setErr(null);
|
||||||
|
setMsg(null);
|
||||||
|
let sections: unknown = [];
|
||||||
|
try {
|
||||||
|
sections = JSON.parse(form.sectionsJson || "[]");
|
||||||
|
} catch {
|
||||||
|
setErr("Sections JSON is invalid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.saveResume({
|
||||||
|
fullName: form.fullName,
|
||||||
|
title: form.title,
|
||||||
|
email: form.email || null,
|
||||||
|
phone: form.phone || null,
|
||||||
|
location: form.location || null,
|
||||||
|
website: form.website || null,
|
||||||
|
summary: form.summary,
|
||||||
|
sections,
|
||||||
|
htmlContent: form.htmlContent || null,
|
||||||
|
});
|
||||||
|
setMsg("Resume saved");
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<h1>Resume</h1>
|
||||||
|
<p className="admin-page__sub">
|
||||||
|
Public at <a href="/resume">/resume</a> · PDF at{" "}
|
||||||
|
<a href="/api/v1/resume/pdf">/api/v1/resume/pdf</a>
|
||||||
|
</p>
|
||||||
|
{msg && <div className="admin-msg admin-msg--ok">{msg}</div>}
|
||||||
|
{err && <div className="admin-msg admin-msg--err">{err}</div>}
|
||||||
|
|
||||||
|
<form className="admin-form admin-form--wide" onSubmit={save}>
|
||||||
|
<div className="field">
|
||||||
|
<label>Full name</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={form.fullName}
|
||||||
|
onChange={(e) => setForm({ ...form, fullName: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Title</label>
|
||||||
|
<input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem" }}>
|
||||||
|
<div className="field">
|
||||||
|
<label>Email</label>
|
||||||
|
<input
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Phone</label>
|
||||||
|
<input
|
||||||
|
value={form.phone}
|
||||||
|
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Location</label>
|
||||||
|
<input
|
||||||
|
value={form.location}
|
||||||
|
onChange={(e) => setForm({ ...form, location: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Website</label>
|
||||||
|
<input
|
||||||
|
value={form.website}
|
||||||
|
onChange={(e) => setForm({ ...form, website: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Summary</label>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={form.summary}
|
||||||
|
onChange={(e) => setForm({ ...form, summary: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Sections (JSON)</label>
|
||||||
|
<textarea
|
||||||
|
rows={16}
|
||||||
|
style={{ fontFamily: "ui-monospace, monospace", fontSize: 13 }}
|
||||||
|
value={form.sectionsJson}
|
||||||
|
onChange={(e) => setForm({ ...form, sectionsJson: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Optional HTML override (replaces structured layout when set)</label>
|
||||||
|
<textarea
|
||||||
|
rows={6}
|
||||||
|
value={form.htmlContent}
|
||||||
|
onChange={(e) => setForm({ ...form, htmlContent: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn--primary">
|
||||||
|
Save resume
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
|
||||||
|
export function AdminSettings() {
|
||||||
|
const [json, setJson] = useState("{}");
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.adminSettings().then((s) => {
|
||||||
|
setJson(JSON.stringify((s as { site?: unknown }).site ?? s.site ?? s, null, 2));
|
||||||
|
}).catch(async () => {
|
||||||
|
const site = await api.site();
|
||||||
|
setJson(JSON.stringify(site, null, 2));
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function save(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setErr(null);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(json);
|
||||||
|
await api.saveSiteSettings(body);
|
||||||
|
setMsg("Settings saved");
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<h1>Site settings</h1>
|
||||||
|
<p className="admin-page__sub">Hero text, about blurb, and social links.</p>
|
||||||
|
{msg && <div className="admin-msg admin-msg--ok">{msg}</div>}
|
||||||
|
{err && <div className="admin-msg admin-msg--err">{err}</div>}
|
||||||
|
<form className="admin-form admin-form--wide" onSubmit={save}>
|
||||||
|
<div className="field">
|
||||||
|
<label>Site JSON</label>
|
||||||
|
<textarea
|
||||||
|
rows={20}
|
||||||
|
style={{ fontFamily: "ui-monospace, monospace", fontSize: 13 }}
|
||||||
|
value={json}
|
||||||
|
onChange={(e) => setJson(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn--primary">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, type Category, type PortfolioView } from "../../lib/api";
|
||||||
|
|
||||||
|
export function AdminViews() {
|
||||||
|
const [views, setViews] = useState<PortfolioView[]>([]);
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [slug, setSlug] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
api.adminViews().then(setViews);
|
||||||
|
api.adminCategories().then(setCategories);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function create(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
await api.createView({
|
||||||
|
name,
|
||||||
|
slug: slug || undefined,
|
||||||
|
description,
|
||||||
|
showOthers: true,
|
||||||
|
categories: selected.map((categoryId, priority) => ({ categoryId, priority })),
|
||||||
|
});
|
||||||
|
setName("");
|
||||||
|
setSlug("");
|
||||||
|
setDescription("");
|
||||||
|
setSelected([]);
|
||||||
|
setMsg("View created");
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string, n: string) {
|
||||||
|
if (!confirm(`Delete view “${n}”?`)) return;
|
||||||
|
await api.deleteView(id);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCat(id: string) {
|
||||||
|
setSelected((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(id: string, dir: -1 | 1) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const i = prev.indexOf(id);
|
||||||
|
if (i < 0) return prev;
|
||||||
|
const j = i + dir;
|
||||||
|
if (j < 0 || j >= prev.length) return prev;
|
||||||
|
const next = [...prev];
|
||||||
|
[next[i], next[j]] = [next[j], next[i]];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<h1>Portfolio views</h1>
|
||||||
|
<p className="admin-page__sub">
|
||||||
|
Human-readable URLs like <code>/portfolio/game-dev</code> that prioritize categories for
|
||||||
|
different audiences.
|
||||||
|
</p>
|
||||||
|
{msg && <div className="admin-msg admin-msg--ok">{msg}</div>}
|
||||||
|
|
||||||
|
<form className="admin-form" onSubmit={create}>
|
||||||
|
<div className="field">
|
||||||
|
<label>Name</label>
|
||||||
|
<input required value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Slug (URL path)</label>
|
||||||
|
<input
|
||||||
|
placeholder="game-dev"
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => setSlug(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Description</label>
|
||||||
|
<textarea value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Category priority order (select, then reorder)</label>
|
||||||
|
<div className="admin-check-grid">
|
||||||
|
{categories.map((c) => (
|
||||||
|
<label key={c.id}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.includes(c.id)}
|
||||||
|
onChange={() => toggleCat(c.id)}
|
||||||
|
/>
|
||||||
|
{c.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<ol style={{ color: "var(--text-muted)" }}>
|
||||||
|
{selected.map((id) => {
|
||||||
|
const c = categories.find((x) => x.id === id);
|
||||||
|
return (
|
||||||
|
<li key={id} style={{ marginBottom: 6 }}>
|
||||||
|
{c?.name}{" "}
|
||||||
|
<button type="button" className="btn btn--ghost btn--sm" onClick={() => move(id, -1)}>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn--ghost btn--sm" onClick={() => move(id, 1)}>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn--primary">
|
||||||
|
Create view
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: "1rem", marginTop: "2.5rem" }}>Existing views</h2>
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>URL</th>
|
||||||
|
<th>Category order</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{views.map((v) => (
|
||||||
|
<tr key={v.id}>
|
||||||
|
<td>{v.name}</td>
|
||||||
|
<td>
|
||||||
|
<a href={`/portfolio/${v.slug}`} target="_blank" rel="noreferrer">
|
||||||
|
/portfolio/{v.slug}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: "0.85rem", color: "var(--text-dim)" }}>
|
||||||
|
{(v.categoryPriorities || [])
|
||||||
|
.map((cp) => cp.category?.name)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" → ") || "—"}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--danger btn--sm"
|
||||||
|
onClick={() => remove(v.id, v.name)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0a0a0b;
|
||||||
|
--bg-elevated: #121214;
|
||||||
|
--bg-card: #161618;
|
||||||
|
--bg-hover: #1c1c1f;
|
||||||
|
--border: rgba(255, 255, 255, 0.08);
|
||||||
|
--border-strong: rgba(255, 255, 255, 0.14);
|
||||||
|
--text: #f4f4f5;
|
||||||
|
--text-muted: #a1a1aa;
|
||||||
|
--text-dim: #71717a;
|
||||||
|
--accent: #c9a87c;
|
||||||
|
--accent-soft: rgba(201, 168, 124, 0.15);
|
||||||
|
--accent-hover: #dbb98e;
|
||||||
|
--danger: #f87171;
|
||||||
|
--success: #4ade80;
|
||||||
|
--radius: 12px;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
|
||||||
|
--font-serif: "Instrument Serif", Georgia, serif;
|
||||||
|
--shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
|
||||||
|
--header-h: 72px;
|
||||||
|
--max: 1280px;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
video {
|
||||||
|
max-width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4 {
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: min(100% - 2.5rem, var(--max));
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.7rem 1.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.2s, color 0.2s, border-color 0.2s, transform 0.15s;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #0a0a0b;
|
||||||
|
&:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ghost {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
color: var(--text);
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--text-dim);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--danger {
|
||||||
|
background: rgba(248, 113, 113, 0.15);
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: rgba(248, 113, 113, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--sm {
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forms
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 120px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid rgba(201, 168, 124, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-enter {
|
||||||
|
animation: fadeUp 0.45s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus visible for a11y
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrollbar
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #2a2a2e;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid var(--bg);
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: { "@": path.resolve(__dirname, "src") },
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:3000",
|
||||||
|
"/uploads": "http://localhost:3000",
|
||||||
|
"/.well-known": "http://localhost:3000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
emptyOutDir: true,
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+6572
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "jmartgraphix-com",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "jmartgraphix portfolio CMS — Fastify API + Vite SPA",
|
||||||
|
"workspaces": [
|
||||||
|
"server",
|
||||||
|
"client"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -n server,client -c blue,green \"npm run dev -w server\" \"npm run dev -w client\"",
|
||||||
|
"build": "npm run build -w client && npm run build -w server",
|
||||||
|
"start": "npm run start -w server",
|
||||||
|
"db:generate": "npm run db:generate -w server",
|
||||||
|
"db:migrate": "npm run db:migrate -w server",
|
||||||
|
"db:seed": "npm run db:seed -w server",
|
||||||
|
"import:artstation": "npm run import:artstation -w server"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"m.homeserver": {
|
||||||
|
"base_url": "https://synapse.jmartgraphix.com"
|
||||||
|
},
|
||||||
|
"m.identity_server": {
|
||||||
|
"base_url": "https://vector.im"
|
||||||
|
},
|
||||||
|
"org.matrix.msc4143.rtc_foci": [
|
||||||
|
{
|
||||||
|
"livekit_service_url": "https://synapse.jmartgraphix.com",
|
||||||
|
"type": "livekit"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"m.server": "synapse.jmartgraphix.com:443"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"subject": "acct:jmartgraphix.com",
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"rel": "http://openid.net/specs/connect/1.0/issuer",
|
||||||
|
"href": "https://auth.jmartgraphix.com"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc && cp -r src/openapi dist/openapi 2>/dev/null || true",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:migrate": "prisma migrate deploy && prisma db seed",
|
||||||
|
"db:migrate:dev": "prisma migrate dev",
|
||||||
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"import:artstation": "tsx scripts/import-artstation.ts"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^10.0.2",
|
||||||
|
"@fastify/multipart": "^9.0.3",
|
||||||
|
"@fastify/static": "^8.1.0",
|
||||||
|
"@fastify/swagger": "^9.4.2",
|
||||||
|
"@fastify/swagger-ui": "^5.2.2",
|
||||||
|
"@prisma/client": "^6.5.0",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"fastify": "^5.2.1",
|
||||||
|
"fastify-plugin": "^5.0.1",
|
||||||
|
"fastify-type-provider-zod": "^4.0.2",
|
||||||
|
"puppeteer": "^24.4.0",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
|
"slugify": "^1.6.6",
|
||||||
|
"typesense": "^2.0.3",
|
||||||
|
"zod": "^3.24.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.13.10",
|
||||||
|
"prisma": "^6.5.0",
|
||||||
|
"tsx": "^4.19.3",
|
||||||
|
"typescript": "^5.8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Visibility" AS ENUM ('published', 'draft', 'private');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "MediaType" AS ENUM ('image', 'video', 'external');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "VideoSource" AS ENUM ('youtube', 'vimeo', 'self_hosted', 'external');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "categories" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "categories_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "tags" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "tags_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "projects" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL DEFAULT '',
|
||||||
|
"short_description" TEXT,
|
||||||
|
"date" DATE,
|
||||||
|
"software" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"external_links" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"featured" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"display_priority" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"visibility" "Visibility" NOT NULL DEFAULT 'draft',
|
||||||
|
"thumbnail_id" TEXT,
|
||||||
|
"source_url" TEXT,
|
||||||
|
"source_platform" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"published_at" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "projects_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "project_categories" (
|
||||||
|
"project_id" TEXT NOT NULL,
|
||||||
|
"category_id" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "project_categories_pkey" PRIMARY KEY ("project_id","category_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "project_tags" (
|
||||||
|
"project_id" TEXT NOT NULL,
|
||||||
|
"tag_id" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "project_tags_pkey" PRIMARY KEY ("project_id","tag_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "media" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"project_id" TEXT,
|
||||||
|
"type" "MediaType" NOT NULL DEFAULT 'image',
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"thumbnail_url" TEXT,
|
||||||
|
"filename" TEXT,
|
||||||
|
"mime_type" TEXT,
|
||||||
|
"width" INTEGER,
|
||||||
|
"height" INTEGER,
|
||||||
|
"size_bytes" INTEGER,
|
||||||
|
"alt" TEXT,
|
||||||
|
"caption" TEXT,
|
||||||
|
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"video_source" "VideoSource",
|
||||||
|
"video_id" TEXT,
|
||||||
|
"external_url" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "media_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "portfolio_views" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"default_sort" TEXT NOT NULL DEFAULT 'priority',
|
||||||
|
"show_others" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "portfolio_views_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "portfolio_view_categories" (
|
||||||
|
"view_id" TEXT NOT NULL,
|
||||||
|
"category_id" TEXT NOT NULL,
|
||||||
|
"priority" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "portfolio_view_categories_pkey" PRIMARY KEY ("view_id","category_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "resumes" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"full_name" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL DEFAULT '',
|
||||||
|
"email" TEXT,
|
||||||
|
"phone" TEXT,
|
||||||
|
"location" TEXT,
|
||||||
|
"website" TEXT,
|
||||||
|
"summary" TEXT NOT NULL DEFAULT '',
|
||||||
|
"sections" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"html_content" TEXT,
|
||||||
|
"theme" TEXT NOT NULL DEFAULT 'dark',
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "resumes_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "site_settings" (
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"value" JSONB NOT NULL,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "site_settings_pkey" PRIMARY KEY ("key")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "categories_name_key" ON "categories"("name");
|
||||||
|
CREATE UNIQUE INDEX "categories_slug_key" ON "categories"("slug");
|
||||||
|
CREATE UNIQUE INDEX "tags_name_key" ON "tags"("name");
|
||||||
|
CREATE UNIQUE INDEX "tags_slug_key" ON "tags"("slug");
|
||||||
|
CREATE UNIQUE INDEX "projects_slug_key" ON "projects"("slug");
|
||||||
|
CREATE INDEX "projects_visibility_display_priority_idx" ON "projects"("visibility", "display_priority");
|
||||||
|
CREATE INDEX "projects_featured_idx" ON "projects"("featured");
|
||||||
|
CREATE INDEX "projects_date_idx" ON "projects"("date");
|
||||||
|
CREATE INDEX "media_project_id_sort_order_idx" ON "media"("project_id", "sort_order");
|
||||||
|
CREATE UNIQUE INDEX "portfolio_views_slug_key" ON "portfolio_views"("slug");
|
||||||
|
|
||||||
|
-- AddForeignKeys
|
||||||
|
ALTER TABLE "project_categories" ADD CONSTRAINT "project_categories_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "project_categories" ADD CONSTRAINT "project_categories_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "project_tags" ADD CONSTRAINT "project_tags_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "project_tags" ADD CONSTRAINT "project_tags_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "media" ADD CONSTRAINT "media_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "projects" ADD CONSTRAINT "projects_thumbnail_id_fkey" FOREIGN KEY ("thumbnail_id") REFERENCES "media"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "portfolio_view_categories" ADD CONSTRAINT "portfolio_view_categories_view_id_fkey" FOREIGN KEY ("view_id") REFERENCES "portfolio_views"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "portfolio_view_categories" ADD CONSTRAINT "portfolio_view_categories_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Visibility {
|
||||||
|
published
|
||||||
|
draft
|
||||||
|
private
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MediaType {
|
||||||
|
image
|
||||||
|
video
|
||||||
|
external
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VideoSource {
|
||||||
|
youtube
|
||||||
|
vimeo
|
||||||
|
self_hosted
|
||||||
|
external
|
||||||
|
}
|
||||||
|
|
||||||
|
model Category {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
slug String @unique
|
||||||
|
description String?
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
projects ProjectCategory[]
|
||||||
|
viewPriorities PortfolioViewCategory[]
|
||||||
|
|
||||||
|
@@map("categories")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Tag {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
slug String @unique
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
projects ProjectTag[]
|
||||||
|
|
||||||
|
@@map("tags")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Project {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
slug String @unique
|
||||||
|
description String @default("")
|
||||||
|
shortDescription String? @map("short_description")
|
||||||
|
date DateTime? @db.Date
|
||||||
|
software String[] @default([])
|
||||||
|
externalLinks Json @default("[]") @map("external_links")
|
||||||
|
featured Boolean @default(false)
|
||||||
|
displayPriority Int @default(0) @map("display_priority")
|
||||||
|
visibility Visibility @default(draft)
|
||||||
|
thumbnailId String? @map("thumbnail_id")
|
||||||
|
sourceUrl String? @map("source_url")
|
||||||
|
sourcePlatform String? @map("source_platform")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
publishedAt DateTime? @map("published_at")
|
||||||
|
|
||||||
|
thumbnail Media? @relation("ProjectThumbnail", fields: [thumbnailId], references: [id], onDelete: SetNull)
|
||||||
|
media Media[] @relation("ProjectMedia")
|
||||||
|
categories ProjectCategory[]
|
||||||
|
tags ProjectTag[]
|
||||||
|
|
||||||
|
@@index([visibility, displayPriority])
|
||||||
|
@@index([featured])
|
||||||
|
@@index([date])
|
||||||
|
@@map("projects")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProjectCategory {
|
||||||
|
projectId String @map("project_id")
|
||||||
|
categoryId String @map("category_id")
|
||||||
|
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([projectId, categoryId])
|
||||||
|
@@map("project_categories")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProjectTag {
|
||||||
|
projectId String @map("project_id")
|
||||||
|
tagId String @map("tag_id")
|
||||||
|
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([projectId, tagId])
|
||||||
|
@@map("project_tags")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Media {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String? @map("project_id")
|
||||||
|
type MediaType @default(image)
|
||||||
|
url String
|
||||||
|
thumbnailUrl String? @map("thumbnail_url")
|
||||||
|
filename String?
|
||||||
|
mimeType String? @map("mime_type")
|
||||||
|
width Int?
|
||||||
|
height Int?
|
||||||
|
sizeBytes Int? @map("size_bytes")
|
||||||
|
alt String?
|
||||||
|
caption String?
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
videoSource VideoSource? @map("video_source")
|
||||||
|
videoId String? @map("video_id")
|
||||||
|
externalUrl String? @map("external_url")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
project Project? @relation("ProjectMedia", fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
thumbnailFor Project[] @relation("ProjectThumbnail")
|
||||||
|
|
||||||
|
@@index([projectId, sortOrder])
|
||||||
|
@@map("media")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Named portfolio landing experiences e.g. game-dev, engineering
|
||||||
|
model PortfolioView {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
slug String @unique
|
||||||
|
description String?
|
||||||
|
/// Default sort when no category match: priority | date | title
|
||||||
|
defaultSort String @default("priority") @map("default_sort")
|
||||||
|
/// Show projects outside configured categories after prioritized ones
|
||||||
|
showOthers Boolean @default(true) @map("show_others")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
categoryPriorities PortfolioViewCategory[]
|
||||||
|
|
||||||
|
@@map("portfolio_views")
|
||||||
|
}
|
||||||
|
|
||||||
|
model PortfolioViewCategory {
|
||||||
|
viewId String @map("view_id")
|
||||||
|
categoryId String @map("category_id")
|
||||||
|
priority Int @default(0)
|
||||||
|
|
||||||
|
view PortfolioView @relation(fields: [viewId], references: [id], onDelete: Cascade)
|
||||||
|
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([viewId, categoryId])
|
||||||
|
@@map("portfolio_view_categories")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Resume {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
/// Single active resume — only one should be isActive=true
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
fullName String @map("full_name")
|
||||||
|
title String @default("")
|
||||||
|
email String?
|
||||||
|
phone String?
|
||||||
|
location String?
|
||||||
|
website String?
|
||||||
|
summary String @default("")
|
||||||
|
/// Structured sections as JSON: experience, education, skills, etc.
|
||||||
|
sections Json @default("[]")
|
||||||
|
/// Optional raw HTML override for public render
|
||||||
|
htmlContent String? @map("html_content") @db.Text
|
||||||
|
theme String @default("dark")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@map("resumes")
|
||||||
|
}
|
||||||
|
|
||||||
|
model SiteSetting {
|
||||||
|
key String @id
|
||||||
|
value Json
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("site_settings")
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import slugify from "slugify";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
"3D Modeling",
|
||||||
|
"3D Animation",
|
||||||
|
"CAD",
|
||||||
|
"3D Sculpting",
|
||||||
|
"AI Generated Content",
|
||||||
|
"Video",
|
||||||
|
"Photography",
|
||||||
|
"Graphic Design",
|
||||||
|
"Illustration",
|
||||||
|
];
|
||||||
|
|
||||||
|
function slug(s: string) {
|
||||||
|
return slugify(s, { lower: true, strict: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("Seeding categories…");
|
||||||
|
for (let i = 0; i < CATEGORIES.length; i++) {
|
||||||
|
const name = CATEGORIES[i];
|
||||||
|
await prisma.category.upsert({
|
||||||
|
where: { slug: slug(name) },
|
||||||
|
create: { name, slug: slug(name), sortOrder: i },
|
||||||
|
update: { name, sortOrder: i },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience slugs for URL filters (/portfolio/3d, /portfolio/ai, etc.)
|
||||||
|
const aliases: Record<string, string> = {
|
||||||
|
"3d-modeling": "3d",
|
||||||
|
"3d-animation": "animation",
|
||||||
|
"ai-generated-content": "ai",
|
||||||
|
"graphic-design": "design",
|
||||||
|
};
|
||||||
|
// We keep canonical slugs; PortfolioView can use short names
|
||||||
|
|
||||||
|
console.log("Seeding default portfolio views…");
|
||||||
|
const cats = await prisma.category.findMany();
|
||||||
|
const bySlug = Object.fromEntries(cats.map((c) => [c.slug, c]));
|
||||||
|
|
||||||
|
async function upsertView(
|
||||||
|
name: string,
|
||||||
|
viewSlug: string,
|
||||||
|
description: string,
|
||||||
|
orderedSlugs: string[]
|
||||||
|
) {
|
||||||
|
const view = await prisma.portfolioView.upsert({
|
||||||
|
where: { slug: viewSlug },
|
||||||
|
create: { name, slug: viewSlug, description, showOthers: true },
|
||||||
|
update: { name, description },
|
||||||
|
});
|
||||||
|
await prisma.portfolioViewCategory.deleteMany({ where: { viewId: view.id } });
|
||||||
|
for (let i = 0; i < orderedSlugs.length; i++) {
|
||||||
|
const cat = bySlug[orderedSlugs[i]];
|
||||||
|
if (!cat) continue;
|
||||||
|
await prisma.portfolioViewCategory.create({
|
||||||
|
data: { viewId: view.id, categoryId: cat.id, priority: i },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await upsertView(
|
||||||
|
"Game Development",
|
||||||
|
"game-dev",
|
||||||
|
"Work prioritized for game studios and interactive media.",
|
||||||
|
["3d-animation", "3d-modeling", "3d-sculpting", "ai-generated-content", "cad"]
|
||||||
|
);
|
||||||
|
await upsertView(
|
||||||
|
"Engineering",
|
||||||
|
"engineering",
|
||||||
|
"Work prioritized for engineering and product teams.",
|
||||||
|
["cad", "3d-modeling", "illustration", "graphic-design"]
|
||||||
|
);
|
||||||
|
await upsertView(
|
||||||
|
"3D",
|
||||||
|
"3d",
|
||||||
|
"All 3D work front and center.",
|
||||||
|
["3d-modeling", "3d-animation", "3d-sculpting", "cad"]
|
||||||
|
);
|
||||||
|
await upsertView("Animation", "animation", "Animation-focused portfolio.", [
|
||||||
|
"3d-animation",
|
||||||
|
"video",
|
||||||
|
]);
|
||||||
|
await upsertView("CAD", "cad", "CAD and technical design.", ["cad", "3d-modeling"]);
|
||||||
|
await upsertView("AI", "ai", "AI-generated creative work.", [
|
||||||
|
"ai-generated-content",
|
||||||
|
"illustration",
|
||||||
|
"graphic-design",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Shorter category filter aliases as views that just prioritize that category
|
||||||
|
for (const [canonical, short] of Object.entries(aliases)) {
|
||||||
|
if (short === "3d" || short === "animation" || short === "ai") continue;
|
||||||
|
await upsertView(bySlug[canonical]?.name ?? short, short, `Filter: ${short}`, [
|
||||||
|
canonical,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingResume = await prisma.resume.findFirst({ where: { isActive: true } });
|
||||||
|
if (!existingResume) {
|
||||||
|
console.log("Seeding sample resume…");
|
||||||
|
await prisma.resume.create({
|
||||||
|
data: {
|
||||||
|
isActive: true,
|
||||||
|
fullName: "J. Martin",
|
||||||
|
title: "3D Artist · Designer · Creative Technologist",
|
||||||
|
email: "contact@jmartgraphix.com",
|
||||||
|
website: "https://jmartgraphix.com",
|
||||||
|
location: "United States",
|
||||||
|
summary:
|
||||||
|
"Creative professional specializing in 3D modeling, CAD, animation, and visual design. Building polished digital experiences and production-ready assets for games, product visualization, and media.",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
type: "experience",
|
||||||
|
title: "Experience",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Independent Creative / jmartgraphix",
|
||||||
|
organization: "Freelance",
|
||||||
|
location: "Remote",
|
||||||
|
startDate: "2018",
|
||||||
|
endDate: "Present",
|
||||||
|
description:
|
||||||
|
"Delivered 3D models, animations, CAD designs, and graphic work for clients across game development, product design, and marketing.",
|
||||||
|
highlights: [
|
||||||
|
"End-to-end 3D pipeline: modeling, sculpting, texturing, rendering",
|
||||||
|
"CAD and product visualization for engineering collaborators",
|
||||||
|
"Motion graphics and short-form video production",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "skills",
|
||||||
|
title: "Software & Tools",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
group: "3D & CAD",
|
||||||
|
skills: [
|
||||||
|
"Blender",
|
||||||
|
"Maya",
|
||||||
|
"ZBrush",
|
||||||
|
"SolidWorks",
|
||||||
|
"Fusion 360",
|
||||||
|
"Substance Painter",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Design & Video",
|
||||||
|
skills: [
|
||||||
|
"Photoshop",
|
||||||
|
"Illustrator",
|
||||||
|
"After Effects",
|
||||||
|
"Premiere Pro",
|
||||||
|
"DaVinci Resolve",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Other",
|
||||||
|
skills: ["AI image/video tools", "Git", "Web (HTML/CSS)"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "links",
|
||||||
|
title: "Links",
|
||||||
|
items: [
|
||||||
|
{ label: "Portfolio", url: "https://jmartgraphix.com" },
|
||||||
|
{ label: "ArtStation", url: "https://jmartgraphix.artstation.com/" },
|
||||||
|
{ label: "YouTube", url: "https://www.youtube.com/@samuraijkm" },
|
||||||
|
{ label: "Vimeo", url: "https://vimeo.com/jmartgraphix" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.siteSetting.upsert({
|
||||||
|
where: { key: "site" },
|
||||||
|
create: {
|
||||||
|
key: "site",
|
||||||
|
value: {
|
||||||
|
name: "jmartgraphix",
|
||||||
|
tagline: "Creative Professional Portfolio",
|
||||||
|
about:
|
||||||
|
"I create 3D art, technical design, animation, and visual media — from concept through polished delivery.",
|
||||||
|
social: {
|
||||||
|
artstation: "https://jmartgraphix.artstation.com/",
|
||||||
|
youtube: "https://www.youtube.com/@samuraijkm",
|
||||||
|
vimeo: "https://vimeo.com/jmartgraphix",
|
||||||
|
},
|
||||||
|
heroTitle: "jmartgraphix",
|
||||||
|
heroSubtitle: "3D · Design · Motion · Craft",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Seed complete.");
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
/**
|
||||||
|
* ArtStation project importer for jmartgraphix portfolio.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npm run import:artstation -- --user jmartgraphix
|
||||||
|
* npm run import:artstation -- --user jmartgraphix --dry-run
|
||||||
|
* npm run import:artstation -- --url https://www.artstation.com/artwork/XXXX
|
||||||
|
*
|
||||||
|
* Fetches public ArtStation JSON endpoints, downloads images, and creates
|
||||||
|
* draft projects so you can edit/publish from the admin panel afterward.
|
||||||
|
*/
|
||||||
|
import "dotenv/config";
|
||||||
|
import { PrismaClient, Visibility } from "@prisma/client";
|
||||||
|
import slugify from "slugify";
|
||||||
|
import { downloadRemoteImage } from "../src/lib/media.js";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const USER_AGENT =
|
||||||
|
"Mozilla/5.0 (compatible; jmartgraphix-importer/1.0; +https://jmartgraphix.com)";
|
||||||
|
|
||||||
|
function arg(name: string, fallback?: string): string | undefined {
|
||||||
|
const idx = process.argv.indexOf(`--${name}`);
|
||||||
|
if (idx >= 0 && process.argv[idx + 1]) return process.argv[idx + 1];
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFlag(name: string): boolean {
|
||||||
|
return process.argv.includes(`--${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { "User-Agent": USER_AGENT, Accept: "application/json" },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AsProjectListItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
hash_id?: string;
|
||||||
|
permalink?: string;
|
||||||
|
cover?: { url?: string; small_square_url?: string };
|
||||||
|
mediums?: { name: string }[];
|
||||||
|
categories?: { name: string }[];
|
||||||
|
published_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AsProjectDetail {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
hash_id?: string;
|
||||||
|
permalink?: string;
|
||||||
|
published_at?: string;
|
||||||
|
tags?: string[];
|
||||||
|
mediums?: { name: string }[];
|
||||||
|
categories?: { name: string }[];
|
||||||
|
software_items?: { name: string }[];
|
||||||
|
assets?: {
|
||||||
|
id: number;
|
||||||
|
title?: string;
|
||||||
|
asset_type?: string;
|
||||||
|
image_url?: string;
|
||||||
|
has_image?: boolean;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}[];
|
||||||
|
cover?: { url?: string };
|
||||||
|
user?: { full_name?: string; username?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map ArtStation medium/category names to our category slugs
|
||||||
|
const CATEGORY_MAP: Record<string, string> = {
|
||||||
|
"3d": "3d-modeling",
|
||||||
|
"3d modeling": "3d-modeling",
|
||||||
|
modeling: "3d-modeling",
|
||||||
|
characters: "3d-modeling",
|
||||||
|
environments: "3d-modeling",
|
||||||
|
animation: "3d-animation",
|
||||||
|
"3d animation": "3d-animation",
|
||||||
|
cad: "cad",
|
||||||
|
product: "cad",
|
||||||
|
industrial: "cad",
|
||||||
|
sculpting: "3d-sculpting",
|
||||||
|
"digital sculpting": "3d-sculpting",
|
||||||
|
ai: "ai-generated-content",
|
||||||
|
"ai art": "ai-generated-content",
|
||||||
|
video: "video",
|
||||||
|
cinematography: "video",
|
||||||
|
photography: "photography",
|
||||||
|
"graphic design": "graphic-design",
|
||||||
|
design: "graphic-design",
|
||||||
|
illustration: "illustration",
|
||||||
|
concept: "illustration",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function resolveCategoryIds(names: string[]): Promise<string[]> {
|
||||||
|
const all = await prisma.category.findMany();
|
||||||
|
const bySlug = Object.fromEntries(all.map((c) => [c.slug, c.id]));
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const n of names) {
|
||||||
|
const key = n.toLowerCase().trim();
|
||||||
|
const mapped = CATEGORY_MAP[key];
|
||||||
|
if (mapped && bySlug[mapped]) ids.add(bySlug[mapped]);
|
||||||
|
const direct = all.find((c) => c.name.toLowerCase() === key || c.slug === slugify(key, { lower: true, strict: true }));
|
||||||
|
if (direct) ids.add(direct.id);
|
||||||
|
}
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uniqueSlug(title: string): Promise<string> {
|
||||||
|
const base = slugify(title, { lower: true, strict: true }) || "artwork";
|
||||||
|
let slug = base;
|
||||||
|
let n = 2;
|
||||||
|
while (await prisma.project.findUnique({ where: { slug } })) {
|
||||||
|
slug = `${base}-${n++}`;
|
||||||
|
}
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importProject(hashOrUrl: string, dryRun: boolean): Promise<void> {
|
||||||
|
let hash = hashOrUrl;
|
||||||
|
const m = hashOrUrl.match(/artstation\.com\/artwork\/([A-Za-z0-9]+)/);
|
||||||
|
if (m) hash = m[1];
|
||||||
|
|
||||||
|
const detail = await fetchJson<AsProjectDetail>(
|
||||||
|
`https://www.artstation.com/projects/${hash}.json`
|
||||||
|
);
|
||||||
|
|
||||||
|
const sourceUrl =
|
||||||
|
detail.permalink || `https://www.artstation.com/artwork/${detail.hash_id || hash}`;
|
||||||
|
const existing = await prisma.project.findFirst({
|
||||||
|
where: { sourceUrl },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
console.log(` skip (exists): ${detail.title}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediums = (detail.mediums || []).map((m) => m.name);
|
||||||
|
const cats = (detail.categories || []).map((c) => c.name);
|
||||||
|
const software = (detail.software_items || []).map((s) => s.name);
|
||||||
|
const tags = detail.tags || [];
|
||||||
|
const categoryIds = await resolveCategoryIds([...mediums, ...cats]);
|
||||||
|
|
||||||
|
console.log(` import: ${detail.title} (${(detail.assets || []).length} assets)`);
|
||||||
|
if (dryRun) return;
|
||||||
|
|
||||||
|
const slug = await uniqueSlug(detail.title);
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
title: detail.title,
|
||||||
|
slug,
|
||||||
|
description: stripHtml(detail.description || ""),
|
||||||
|
shortDescription: stripHtml(detail.description || "").slice(0, 280) || null,
|
||||||
|
date: detail.published_at ? new Date(detail.published_at) : null,
|
||||||
|
software,
|
||||||
|
externalLinks: [{ label: "ArtStation", url: sourceUrl }],
|
||||||
|
featured: false,
|
||||||
|
displayPriority: 100,
|
||||||
|
visibility: Visibility.draft,
|
||||||
|
sourceUrl,
|
||||||
|
sourcePlatform: "artstation",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const cid of categoryIds) {
|
||||||
|
await prisma.projectCategory.create({
|
||||||
|
data: { projectId: project.id, categoryId: cid },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const t of tags) {
|
||||||
|
const tslug = slugify(t, { lower: true, strict: true });
|
||||||
|
if (!tslug) continue;
|
||||||
|
const tag = await prisma.tag.upsert({
|
||||||
|
where: { slug: tslug },
|
||||||
|
create: { name: t, slug: tslug },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
await prisma.projectTag.upsert({
|
||||||
|
where: { projectId_tagId: { projectId: project.id, tagId: tag.id } },
|
||||||
|
create: { projectId: project.id, tagId: tag.id },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let sortOrder = 0;
|
||||||
|
let thumbnailId: string | null = null;
|
||||||
|
const assets = detail.assets || [];
|
||||||
|
for (const asset of assets) {
|
||||||
|
if (asset.asset_type && asset.asset_type !== "image" && !asset.has_image) continue;
|
||||||
|
const imageUrl = asset.image_url || detail.cover?.url;
|
||||||
|
if (!imageUrl) continue;
|
||||||
|
const saved = await downloadRemoteImage(imageUrl);
|
||||||
|
if (!saved) {
|
||||||
|
console.warn(` failed download: ${imageUrl}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const media = await prisma.media.create({
|
||||||
|
data: {
|
||||||
|
projectId: project.id,
|
||||||
|
type: "image",
|
||||||
|
url: saved.url,
|
||||||
|
thumbnailUrl: saved.thumbnailUrl,
|
||||||
|
filename: saved.filename,
|
||||||
|
mimeType: saved.mimeType,
|
||||||
|
width: saved.width || asset.width,
|
||||||
|
height: saved.height || asset.height,
|
||||||
|
sizeBytes: saved.sizeBytes,
|
||||||
|
alt: asset.title || detail.title,
|
||||||
|
sortOrder: sortOrder++,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!thumbnailId) thumbnailId = media.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thumbnailId) {
|
||||||
|
await prisma.project.update({
|
||||||
|
where: { id: project.id },
|
||||||
|
data: { thumbnailId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` created draft project ${project.slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtml(html: string): string {
|
||||||
|
return html
|
||||||
|
.replace(/<br\s*\/?>/gi, "\n")
|
||||||
|
.replace(/<\/p>/gi, "\n\n")
|
||||||
|
.replace(/<[^>]+>/g, "")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listUserProjects(username: string): Promise<AsProjectListItem[]> {
|
||||||
|
const all: AsProjectListItem[] = [];
|
||||||
|
let page = 1;
|
||||||
|
for (;;) {
|
||||||
|
// Public profile projects endpoint
|
||||||
|
const url = `https://www.artstation.com/users/${username}/projects.json?page=${page}`;
|
||||||
|
try {
|
||||||
|
const data = await fetchJson<{ data?: AsProjectListItem[] } | AsProjectListItem[]>(url);
|
||||||
|
const batch = Array.isArray(data) ? data : data.data || [];
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
all.push(...batch);
|
||||||
|
if (batch.length < 50) break;
|
||||||
|
page++;
|
||||||
|
if (page > 20) break;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to list page ${page}:`, err);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const dryRun = hasFlag("dry-run");
|
||||||
|
const user = arg("user", "jmartgraphix")!;
|
||||||
|
const singleUrl = arg("url");
|
||||||
|
|
||||||
|
console.log(`ArtStation import (user=${user}, dryRun=${dryRun})`);
|
||||||
|
|
||||||
|
if (singleUrl) {
|
||||||
|
await importProject(singleUrl, dryRun);
|
||||||
|
} else {
|
||||||
|
const list = await listUserProjects(user);
|
||||||
|
console.log(`Found ${list.length} projects`);
|
||||||
|
for (const item of list) {
|
||||||
|
const hash = item.hash_id || String(item.id);
|
||||||
|
try {
|
||||||
|
await importProject(hash, dryRun);
|
||||||
|
await new Promise((r) => setTimeout(r, 500)); // be polite
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` error importing ${item.title}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Done. Review drafts in the admin panel and publish when ready.");
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
function env(key: string, fallback = ""): string {
|
||||||
|
return process.env[key] ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function envBool(key: string, fallback: boolean): boolean {
|
||||||
|
const v = process.env[key];
|
||||||
|
if (v === undefined) return fallback;
|
||||||
|
return ["1", "true", "yes", "on"].includes(v.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function envInt(key: string, fallback: number): number {
|
||||||
|
const v = process.env[key];
|
||||||
|
if (!v) return fallback;
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
return Number.isFinite(n) ? n : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbHost = env("DB_HOST", "localhost");
|
||||||
|
const dbPort = env("DB_PORT", "5432");
|
||||||
|
const dbName = env("DB_NAME", "jmartgraphix-com");
|
||||||
|
const dbUser = env("DB_USER", "jmartgraphix-com");
|
||||||
|
const dbPass = env("DB_PASS", "");
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
nodeEnv: env("NODE_ENV", "development"),
|
||||||
|
isProd: env("NODE_ENV", "development") === "production",
|
||||||
|
host: env("HOST", "0.0.0.0"),
|
||||||
|
port: envInt("PORT", 3000),
|
||||||
|
publicUrl: env("PUBLIC_URL", "http://localhost:3000").replace(/\/$/, ""),
|
||||||
|
databaseUrl:
|
||||||
|
env("DATABASE_URL") ||
|
||||||
|
`postgresql://${encodeURIComponent(dbUser)}:${encodeURIComponent(dbPass)}@${dbHost}:${dbPort}/${dbName}`,
|
||||||
|
typesense: {
|
||||||
|
host: env("TYPESENSE_HOST", "localhost"),
|
||||||
|
port: envInt("TYPESENSE_PORT", 8108),
|
||||||
|
protocol: env("TYPESENSE_PROTOCOL", "http") as "http" | "https",
|
||||||
|
apiKey: env("TYPESENSE_API_KEY", "typesense_dev_key"),
|
||||||
|
},
|
||||||
|
imgproxy: {
|
||||||
|
host: env("IMGPROXY_HOST", ""),
|
||||||
|
key: env("IMGPROXY_KEY", ""),
|
||||||
|
salt: env("IMGPROXY_SALT", ""),
|
||||||
|
},
|
||||||
|
adminGroups: env("ADMIN_GROUPS", "lldap_admin,admin,portfolio_admin")
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
requireRemoteUser: envBool("REQUIRE_REMOTE_USER", true),
|
||||||
|
uploadDir: env("UPLOAD_DIR", path.resolve(process.cwd(), "data/uploads")),
|
||||||
|
maxUploadMb: envInt("MAX_UPLOAD_MB", 50),
|
||||||
|
siteName: env("SITE_NAME", "jmartgraphix"),
|
||||||
|
siteTagline: env("SITE_TAGLINE", "Creative Professional Portfolio"),
|
||||||
|
publicDir: env("PUBLIC_DIR", path.resolve(process.cwd(), "public")),
|
||||||
|
clientDist: env("CLIENT_DIST", path.resolve(process.cwd(), "public")),
|
||||||
|
};
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import cors from "@fastify/cors";
|
||||||
|
import multipart from "@fastify/multipart";
|
||||||
|
import fastifyStatic from "@fastify/static";
|
||||||
|
import swagger from "@fastify/swagger";
|
||||||
|
import swaggerUi from "@fastify/swagger-ui";
|
||||||
|
import { config } from "./config.js";
|
||||||
|
import { prisma } from "./lib/prisma.js";
|
||||||
|
import { ensureUploadDirs } from "./lib/media.js";
|
||||||
|
import { reindexAllProjects } from "./lib/typesense.js";
|
||||||
|
import { healthRoutes } from "./routes/health.js";
|
||||||
|
import { publicRoutes } from "./routes/public.js";
|
||||||
|
import { adminProjectRoutes } from "./routes/admin-projects.js";
|
||||||
|
import { adminMetaRoutes } from "./routes/admin-meta.js";
|
||||||
|
import { resumePdfRoutes } from "./routes/resume-pdf.js";
|
||||||
|
|
||||||
|
async function waitForDb(retries = 30): Promise<void> {
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Database not ready (${i + 1}/${retries})…`);
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Database connection failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMigrations(): Promise<void> {
|
||||||
|
// Prefer prisma migrate deploy when migrations exist
|
||||||
|
const { execSync } = await import("node:child_process");
|
||||||
|
try {
|
||||||
|
execSync("npx prisma migrate deploy", {
|
||||||
|
stdio: "inherit",
|
||||||
|
env: { ...process.env, DATABASE_URL: config.databaseUrl },
|
||||||
|
cwd: path.resolve(import.meta.dirname, ".."),
|
||||||
|
});
|
||||||
|
console.log("Database migrations applied");
|
||||||
|
} catch {
|
||||||
|
console.warn("migrate deploy failed, falling back to db push");
|
||||||
|
execSync("npx prisma db push --skip-generate", {
|
||||||
|
stdio: "inherit",
|
||||||
|
env: { ...process.env, DATABASE_URL: config.databaseUrl },
|
||||||
|
cwd: path.resolve(import.meta.dirname, ".."),
|
||||||
|
});
|
||||||
|
console.log("Database schema pushed");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
execSync("npx tsx prisma/seed.ts", {
|
||||||
|
stdio: "inherit",
|
||||||
|
env: { ...process.env, DATABASE_URL: config.databaseUrl },
|
||||||
|
cwd: path.resolve(import.meta.dirname, ".."),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Seed skipped or failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
process.env.DATABASE_URL = config.databaseUrl;
|
||||||
|
|
||||||
|
await ensureUploadDirs();
|
||||||
|
await waitForDb();
|
||||||
|
await runMigrations();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const n = await reindexAllProjects();
|
||||||
|
console.log(`Typesense index ready (${n} projects)`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Typesense index failed (search may be degraded):", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = Fastify({
|
||||||
|
logger: true,
|
||||||
|
trustProxy: true,
|
||||||
|
bodyLimit: config.maxUploadMb * 1024 * 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(cors, { origin: true, credentials: true });
|
||||||
|
await app.register(multipart, {
|
||||||
|
limits: { fileSize: config.maxUploadMb * 1024 * 1024 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(swagger, {
|
||||||
|
openapi: {
|
||||||
|
info: {
|
||||||
|
title: "jmartgraphix Portfolio API",
|
||||||
|
description: "Public and admin API for the jmartgraphix portfolio CMS",
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
servers: [{ url: config.publicUrl }],
|
||||||
|
tags: [
|
||||||
|
{ name: "public", description: "Public read endpoints" },
|
||||||
|
{ name: "admin", description: "Admin endpoints (Authelia Remote-User)" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.register(swaggerUi, {
|
||||||
|
routePrefix: "/api/docs",
|
||||||
|
uiConfig: { docExpansion: "list" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await healthRoutes(app);
|
||||||
|
await publicRoutes(app);
|
||||||
|
await adminProjectRoutes(app);
|
||||||
|
await adminMetaRoutes(app);
|
||||||
|
await resumePdfRoutes(app);
|
||||||
|
|
||||||
|
// Uploads
|
||||||
|
await app.register(fastifyStatic, {
|
||||||
|
root: config.uploadDir,
|
||||||
|
prefix: "/uploads/",
|
||||||
|
decorateReply: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// .well-known (matrix, webfinger) + built SPA
|
||||||
|
const publicRoot = config.clientDist;
|
||||||
|
if (fs.existsSync(publicRoot)) {
|
||||||
|
// Explicit .well-known with correct content types
|
||||||
|
app.get("/.well-known/*", async (req, reply) => {
|
||||||
|
const sub = (req.params as { "*": string })["*"];
|
||||||
|
const filePath = path.join(publicRoot, ".well-known", sub);
|
||||||
|
if (!filePath.startsWith(path.join(publicRoot, ".well-known"))) {
|
||||||
|
return reply.code(403).send("Forbidden");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await fs.promises.readFile(filePath);
|
||||||
|
// matrix & webfinger are JSON without extension often
|
||||||
|
const isJson =
|
||||||
|
sub.includes("webfinger") ||
|
||||||
|
sub.includes("matrix") ||
|
||||||
|
filePath.endsWith(".json");
|
||||||
|
return reply
|
||||||
|
.type(isJson ? "application/json" : "application/octet-stream")
|
||||||
|
.header("Access-Control-Allow-Origin", "*")
|
||||||
|
.send(data);
|
||||||
|
} catch {
|
||||||
|
return reply.code(404).send({ error: "Not found" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(fastifyStatic, {
|
||||||
|
root: publicRoot,
|
||||||
|
prefix: "/",
|
||||||
|
wildcard: false,
|
||||||
|
decorateReply: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// SPA fallback for client routes (not API, not uploads, not well-known)
|
||||||
|
app.setNotFoundHandler(async (req, reply) => {
|
||||||
|
if (
|
||||||
|
req.url.startsWith("/api/") ||
|
||||||
|
req.url.startsWith("/uploads/") ||
|
||||||
|
req.url.startsWith("/.well-known/")
|
||||||
|
) {
|
||||||
|
return reply.code(404).send({ error: "Not found" });
|
||||||
|
}
|
||||||
|
const indexPath = path.join(publicRoot, "index.html");
|
||||||
|
if (fs.existsSync(indexPath)) {
|
||||||
|
return reply.type("text/html").send(await fs.promises.readFile(indexPath, "utf8"));
|
||||||
|
}
|
||||||
|
return reply.code(404).send("Not found");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.listen({ host: config.host, port: config.port });
|
||||||
|
console.log(`Server listening on http://${config.host}:${config.port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { FastifyRequest, FastifyReply } from "fastify";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
username: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
groups: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function header(req: FastifyRequest, name: string): string | undefined {
|
||||||
|
const v = req.headers[name.toLowerCase()];
|
||||||
|
if (Array.isArray(v)) return v[0];
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRemoteUser(req: FastifyRequest): AuthUser | null {
|
||||||
|
const username = header(req, "remote-user") || header(req, "Remote-User");
|
||||||
|
if (!username) return null;
|
||||||
|
const groupsRaw =
|
||||||
|
header(req, "remote-groups") || header(req, "Remote-Groups") || "";
|
||||||
|
const groups = groupsRaw
|
||||||
|
.split(",")
|
||||||
|
.map((g) => g.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return {
|
||||||
|
username,
|
||||||
|
name: header(req, "remote-name") || header(req, "Remote-Name"),
|
||||||
|
email: header(req, "remote-email") || header(req, "Remote-Email"),
|
||||||
|
groups,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAdminUser(user: AuthUser): boolean {
|
||||||
|
if (config.adminGroups.length === 0) return true;
|
||||||
|
return user.groups.some((g) => config.adminGroups.includes(g));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dev bypass when REQUIRE_REMOTE_USER=false */
|
||||||
|
export function requireAdmin(req: FastifyRequest, reply: FastifyReply): AuthUser | null {
|
||||||
|
const user = getRemoteUser(req);
|
||||||
|
if (user) {
|
||||||
|
if (!isAdminUser(user)) {
|
||||||
|
reply.code(403).send({ error: "Forbidden", message: "Insufficient group membership" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
if (!config.requireRemoteUser) {
|
||||||
|
return {
|
||||||
|
username: "dev-admin",
|
||||||
|
name: "Dev Admin",
|
||||||
|
groups: config.adminGroups.length ? [config.adminGroups[0]] : ["admin"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
reply.code(401).send({
|
||||||
|
error: "Unauthorized",
|
||||||
|
message: "Missing Remote-User header. Authenticate via Authelia SSO.",
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
|
||||||
|
export async function ensureUploadDirs(): Promise<void> {
|
||||||
|
await fs.mkdir(path.join(config.uploadDir, "originals"), { recursive: true });
|
||||||
|
await fs.mkdir(path.join(config.uploadDir, "thumbs"), { recursive: true });
|
||||||
|
await fs.mkdir(path.join(config.uploadDir, "videos"), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function extFromMime(mime: string, originalName?: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
"image/gif": ".gif",
|
||||||
|
"image/avif": ".avif",
|
||||||
|
"video/mp4": ".mp4",
|
||||||
|
"video/webm": ".webm",
|
||||||
|
"video/quicktime": ".mov",
|
||||||
|
};
|
||||||
|
if (map[mime]) return map[mime];
|
||||||
|
if (originalName) {
|
||||||
|
const e = path.extname(originalName).toLowerCase();
|
||||||
|
if (e) return e;
|
||||||
|
}
|
||||||
|
return ".bin";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveImageUpload(
|
||||||
|
buffer: Buffer,
|
||||||
|
mimeType: string,
|
||||||
|
originalName?: string
|
||||||
|
): Promise<{
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
sizeBytes: number;
|
||||||
|
}> {
|
||||||
|
await ensureUploadDirs();
|
||||||
|
const id = crypto.randomBytes(16).toString("hex");
|
||||||
|
const ext = extFromMime(mimeType, originalName);
|
||||||
|
const filename = `${id}${ext}`;
|
||||||
|
const originalPath = path.join(config.uploadDir, "originals", filename);
|
||||||
|
const thumbName = `${id}.webp`;
|
||||||
|
const thumbPath = path.join(config.uploadDir, "thumbs", thumbName);
|
||||||
|
|
||||||
|
const image = sharp(buffer, { failOn: "none" });
|
||||||
|
const meta = await image.metadata();
|
||||||
|
await fs.writeFile(originalPath, buffer);
|
||||||
|
|
||||||
|
await sharp(buffer, { failOn: "none" })
|
||||||
|
.rotate()
|
||||||
|
.resize(800, 800, { fit: "inside", withoutEnlargement: true })
|
||||||
|
.webp({ quality: 82 })
|
||||||
|
.toFile(thumbPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: `/uploads/originals/${filename}`,
|
||||||
|
thumbnailUrl: `/uploads/thumbs/${thumbName}`,
|
||||||
|
filename: originalName || filename,
|
||||||
|
mimeType,
|
||||||
|
width: meta.width ?? 0,
|
||||||
|
height: meta.height ?? 0,
|
||||||
|
sizeBytes: buffer.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveVideoUpload(
|
||||||
|
buffer: Buffer,
|
||||||
|
mimeType: string,
|
||||||
|
originalName?: string
|
||||||
|
): Promise<{
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
}> {
|
||||||
|
await ensureUploadDirs();
|
||||||
|
const id = crypto.randomBytes(16).toString("hex");
|
||||||
|
const ext = extFromMime(mimeType, originalName);
|
||||||
|
const filename = `${id}${ext}`;
|
||||||
|
const videoPath = path.join(config.uploadDir, "videos", filename);
|
||||||
|
await fs.writeFile(videoPath, buffer);
|
||||||
|
return {
|
||||||
|
url: `/uploads/videos/${filename}`,
|
||||||
|
filename: originalName || filename,
|
||||||
|
mimeType,
|
||||||
|
sizeBytes: buffer.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadRemoteImage(url: string): Promise<{
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
sizeBytes: number;
|
||||||
|
} | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { "User-Agent": "jmartgraphix-importer/1.0" },
|
||||||
|
signal: AbortSignal.timeout(60_000),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const mime = res.headers.get("content-type")?.split(";")[0] || "image/jpeg";
|
||||||
|
if (!mime.startsWith("image/")) return null;
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
|
const name = path.basename(new URL(url).pathname) || "import.jpg";
|
||||||
|
return saveImageUpload(buf, mime, name);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("downloadRemoteImage failed:", url, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse YouTube / Vimeo URLs into embed metadata */
|
||||||
|
export function parseVideoUrl(input: string): {
|
||||||
|
videoSource: "youtube" | "vimeo" | "external";
|
||||||
|
videoId: string | null;
|
||||||
|
externalUrl: string;
|
||||||
|
embedUrl: string | null;
|
||||||
|
} {
|
||||||
|
const url = input.trim();
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
// YouTube
|
||||||
|
if (u.hostname.includes("youtube.com") || u.hostname === "youtu.be") {
|
||||||
|
let id: string | null = null;
|
||||||
|
if (u.hostname === "youtu.be") id = u.pathname.slice(1).split("/")[0];
|
||||||
|
else if (u.pathname.startsWith("/embed/")) id = u.pathname.split("/")[2];
|
||||||
|
else if (u.pathname.startsWith("/shorts/")) id = u.pathname.split("/")[2];
|
||||||
|
else id = u.searchParams.get("v");
|
||||||
|
return {
|
||||||
|
videoSource: "youtube",
|
||||||
|
videoId: id,
|
||||||
|
externalUrl: url,
|
||||||
|
embedUrl: id ? `https://www.youtube.com/embed/${id}` : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Vimeo
|
||||||
|
if (u.hostname.includes("vimeo.com")) {
|
||||||
|
const parts = u.pathname.split("/").filter(Boolean);
|
||||||
|
const id = parts[parts.length - 1]?.match(/^\d+$/) ? parts[parts.length - 1] : null;
|
||||||
|
return {
|
||||||
|
videoSource: "vimeo",
|
||||||
|
videoId: id,
|
||||||
|
externalUrl: url,
|
||||||
|
embedUrl: id ? `https://player.vimeo.com/video/${id}` : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fallthrough */
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
videoSource: "external",
|
||||||
|
videoId: null,
|
||||||
|
externalUrl: url,
|
||||||
|
embedUrl: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
export const prisma = new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export const projectPublicInclude = {
|
||||||
|
categories: { include: { category: true } },
|
||||||
|
tags: { include: { tag: true } },
|
||||||
|
thumbnail: true,
|
||||||
|
media: { orderBy: { sortOrder: "asc" as const } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function serializeProject<
|
||||||
|
T extends {
|
||||||
|
categories: { category: unknown }[];
|
||||||
|
tags: { tag: unknown }[];
|
||||||
|
externalLinks: unknown;
|
||||||
|
},
|
||||||
|
>(p: T) {
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
categories: p.categories.map((c) => c.category),
|
||||||
|
tags: p.tags.map((t) => t.tag),
|
||||||
|
externalLinks: p.externalLinks ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import slugify from "slugify";
|
||||||
|
import { prisma } from "./prisma.js";
|
||||||
|
|
||||||
|
export function makeSlug(input: string): string {
|
||||||
|
return slugify(input, { lower: true, strict: true, trim: true }) || "item";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uniqueProjectSlug(title: string, excludeId?: string): Promise<string> {
|
||||||
|
const base = makeSlug(title);
|
||||||
|
let slug = base;
|
||||||
|
let n = 2;
|
||||||
|
for (;;) {
|
||||||
|
const existing = await prisma.project.findUnique({ where: { slug } });
|
||||||
|
if (!existing || existing.id === excludeId) return slug;
|
||||||
|
slug = `${base}-${n++}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uniqueCategorySlug(name: string, excludeId?: string): Promise<string> {
|
||||||
|
const base = makeSlug(name);
|
||||||
|
let slug = base;
|
||||||
|
let n = 2;
|
||||||
|
for (;;) {
|
||||||
|
const existing = await prisma.category.findUnique({ where: { slug } });
|
||||||
|
if (!existing || existing.id === excludeId) return slug;
|
||||||
|
slug = `${base}-${n++}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uniqueTagSlug(name: string, excludeId?: string): Promise<string> {
|
||||||
|
const base = makeSlug(name);
|
||||||
|
let slug = base;
|
||||||
|
let n = 2;
|
||||||
|
for (;;) {
|
||||||
|
const existing = await prisma.tag.findUnique({ where: { slug } });
|
||||||
|
if (!existing || existing.id === excludeId) return slug;
|
||||||
|
slug = `${base}-${n++}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import Typesense from "typesense";
|
||||||
|
import type { Client } from "typesense";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { prisma } from "./prisma.js";
|
||||||
|
|
||||||
|
const COLLECTION = "projects";
|
||||||
|
|
||||||
|
let client: Client | null = null;
|
||||||
|
|
||||||
|
export function getTypesense(): Client {
|
||||||
|
if (!client) {
|
||||||
|
client = new Typesense.Client({
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
host: config.typesense.host,
|
||||||
|
port: config.typesense.port,
|
||||||
|
protocol: config.typesense.protocol,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
apiKey: config.typesense.apiKey,
|
||||||
|
connectionTimeoutSeconds: 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = {
|
||||||
|
name: COLLECTION,
|
||||||
|
fields: [
|
||||||
|
{ name: "id", type: "string" as const },
|
||||||
|
{ name: "title", type: "string" as const },
|
||||||
|
{ name: "slug", type: "string" as const },
|
||||||
|
{ name: "description", type: "string" as const },
|
||||||
|
{ name: "shortDescription", type: "string" as const, optional: true },
|
||||||
|
{ name: "categories", type: "string[]" as const, facet: true },
|
||||||
|
{ name: "categorySlugs", type: "string[]" as const, facet: true },
|
||||||
|
{ name: "tags", type: "string[]" as const, facet: true },
|
||||||
|
{ name: "software", type: "string[]" as const, facet: true },
|
||||||
|
{ name: "featured", type: "bool" as const, facet: true },
|
||||||
|
{ name: "visibility", type: "string" as const, facet: true },
|
||||||
|
{ name: "displayPriority", type: "int32" as const },
|
||||||
|
{ name: "date", type: "int64" as const, optional: true },
|
||||||
|
],
|
||||||
|
default_sorting_field: "displayPriority",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function ensureTypesenseCollection(): Promise<void> {
|
||||||
|
const ts = getTypesense();
|
||||||
|
try {
|
||||||
|
await ts.collections(COLLECTION).retrieve();
|
||||||
|
} catch {
|
||||||
|
await ts.collections().create(schema);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDoc(p: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
shortDescription: string | null;
|
||||||
|
software: string[];
|
||||||
|
featured: boolean;
|
||||||
|
visibility: string;
|
||||||
|
displayPriority: number;
|
||||||
|
date: Date | null;
|
||||||
|
categories: { category: { name: string; slug: string } }[];
|
||||||
|
tags: { tag: { name: string; slug: string } }[];
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
title: p.title,
|
||||||
|
slug: p.slug,
|
||||||
|
description: p.description,
|
||||||
|
shortDescription: p.shortDescription ?? "",
|
||||||
|
categories: p.categories.map((c) => c.category.name),
|
||||||
|
categorySlugs: p.categories.map((c) => c.category.slug),
|
||||||
|
tags: p.tags.map((t) => t.tag.name),
|
||||||
|
software: p.software,
|
||||||
|
featured: p.featured,
|
||||||
|
visibility: p.visibility,
|
||||||
|
displayPriority: p.displayPriority,
|
||||||
|
date: p.date ? Math.floor(p.date.getTime() / 1000) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reindexAllProjects(): Promise<number> {
|
||||||
|
await ensureTypesenseCollection();
|
||||||
|
const ts = getTypesense();
|
||||||
|
try {
|
||||||
|
await ts.collections(COLLECTION).delete();
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
|
await ts.collections().create(schema);
|
||||||
|
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
include: {
|
||||||
|
categories: { include: { category: true } },
|
||||||
|
tags: { include: { tag: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (projects.length === 0) return 0;
|
||||||
|
|
||||||
|
const docs = projects.map(toDoc);
|
||||||
|
await ts.collections(COLLECTION).documents().import(docs, { action: "upsert" });
|
||||||
|
return docs.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function indexProject(projectId: string): Promise<void> {
|
||||||
|
const p = await prisma.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
include: {
|
||||||
|
categories: { include: { category: true } },
|
||||||
|
tags: { include: { tag: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!p) {
|
||||||
|
await removeProjectFromIndex(projectId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await ensureTypesenseCollection();
|
||||||
|
await getTypesense().collections(COLLECTION).documents().upsert(toDoc(p));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Typesense indexProject failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeProjectFromIndex(projectId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await getTypesense().collections(COLLECTION).documents(projectId).delete();
|
||||||
|
} catch {
|
||||||
|
/* not found */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchProjects(opts: {
|
||||||
|
q?: string;
|
||||||
|
category?: string;
|
||||||
|
tag?: string;
|
||||||
|
featured?: boolean;
|
||||||
|
visibility?: string;
|
||||||
|
page?: number;
|
||||||
|
perPage?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
}) {
|
||||||
|
await ensureTypesenseCollection();
|
||||||
|
const filters: string[] = [];
|
||||||
|
if (opts.visibility) filters.push(`visibility:=${opts.visibility}`);
|
||||||
|
else filters.push("visibility:=published");
|
||||||
|
if (opts.category) filters.push(`categorySlugs:=[${opts.category}]`);
|
||||||
|
if (opts.tag) filters.push(`tags:=[${opts.tag}]`);
|
||||||
|
if (opts.featured !== undefined) filters.push(`featured:=${opts.featured}`);
|
||||||
|
|
||||||
|
let sortBy = "displayPriority:asc,date:desc";
|
||||||
|
if (opts.sortBy === "date") sortBy = "date:desc";
|
||||||
|
else if (opts.sortBy === "title") sortBy = "title:asc";
|
||||||
|
else if (opts.sortBy === "priority") sortBy = "displayPriority:asc,date:desc";
|
||||||
|
|
||||||
|
const result = await getTypesense()
|
||||||
|
.collections(COLLECTION)
|
||||||
|
.documents()
|
||||||
|
.search({
|
||||||
|
q: opts.q?.trim() || "*",
|
||||||
|
query_by: "title,description,shortDescription,tags,categories,software",
|
||||||
|
filter_by: filters.join(" && "),
|
||||||
|
sort_by: sortBy,
|
||||||
|
page: opts.page ?? 1,
|
||||||
|
per_page: opts.perPage ?? 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
import { requireAdmin } from "../lib/auth.js";
|
||||||
|
import { uniqueCategorySlug, uniqueTagSlug, makeSlug } from "../lib/slug.js";
|
||||||
|
import { reindexAllProjects } from "../lib/typesense.js";
|
||||||
|
|
||||||
|
export async function adminMetaRoutes(app: FastifyInstance) {
|
||||||
|
// Categories
|
||||||
|
app.get("/api/v1/admin/categories", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
return prisma.category.findMany({
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
include: { _count: { select: { projects: true } } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/admin/categories", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
const body = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
description: z.string().optional(),
|
||||||
|
sortOrder: z.number().int().optional(),
|
||||||
|
slug: z.string().optional(),
|
||||||
|
})
|
||||||
|
.parse(req.body);
|
||||||
|
const slug = body.slug || (await uniqueCategorySlug(body.name));
|
||||||
|
const cat = await prisma.category.create({
|
||||||
|
data: {
|
||||||
|
name: body.name,
|
||||||
|
slug,
|
||||||
|
description: body.description,
|
||||||
|
sortOrder: body.sortOrder ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return reply.code(201).send(cat);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string } }>("/api/v1/admin/categories/:id", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
const body = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
sortOrder: z.number().int().optional(),
|
||||||
|
slug: z.string().optional(),
|
||||||
|
})
|
||||||
|
.parse(req.body);
|
||||||
|
const existing = await prisma.category.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!existing) return reply.code(404).send({ error: "Not found" });
|
||||||
|
let slug = existing.slug;
|
||||||
|
if (body.slug) slug = body.slug;
|
||||||
|
else if (body.name) slug = await uniqueCategorySlug(body.name, existing.id);
|
||||||
|
return prisma.category.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
...(body.name ? { name: body.name } : {}),
|
||||||
|
slug,
|
||||||
|
...(body.description !== undefined ? { description: body.description } : {}),
|
||||||
|
...(body.sortOrder !== undefined ? { sortOrder: body.sortOrder } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>("/api/v1/admin/categories/:id", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
await prisma.category.delete({ where: { id: req.params.id } });
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
app.get("/api/v1/admin/tags", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
return prisma.tag.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
include: { _count: { select: { projects: true } } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/admin/tags", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
const body = z.object({ name: z.string().min(1) }).parse(req.body);
|
||||||
|
const slug = await uniqueTagSlug(body.name);
|
||||||
|
const tag = await prisma.tag.create({ data: { name: body.name, slug } });
|
||||||
|
return reply.code(201).send(tag);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>("/api/v1/admin/tags/:id", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
await prisma.tag.delete({ where: { id: req.params.id } });
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Portfolio views
|
||||||
|
app.get("/api/v1/admin/views", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
return prisma.portfolioView.findMany({
|
||||||
|
include: {
|
||||||
|
categoryPriorities: {
|
||||||
|
orderBy: { priority: "asc" },
|
||||||
|
include: { category: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/admin/views", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
const body = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
slug: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
defaultSort: z.string().optional(),
|
||||||
|
showOthers: z.boolean().optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
categories: z
|
||||||
|
.array(z.object({ categoryId: z.string(), priority: z.number().int() }))
|
||||||
|
.optional()
|
||||||
|
.default([]),
|
||||||
|
})
|
||||||
|
.parse(req.body);
|
||||||
|
const slug = body.slug || makeSlug(body.name);
|
||||||
|
const view = await prisma.portfolioView.create({
|
||||||
|
data: {
|
||||||
|
name: body.name,
|
||||||
|
slug,
|
||||||
|
description: body.description,
|
||||||
|
defaultSort: body.defaultSort ?? "priority",
|
||||||
|
showOthers: body.showOthers ?? true,
|
||||||
|
isActive: body.isActive ?? true,
|
||||||
|
categoryPriorities: {
|
||||||
|
create: body.categories.map((c) => ({
|
||||||
|
categoryId: c.categoryId,
|
||||||
|
priority: c.priority,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
categoryPriorities: { include: { category: true }, orderBy: { priority: "asc" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return reply.code(201).send(view);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string } }>("/api/v1/admin/views/:id", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
const body = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
slug: z.string().optional(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
defaultSort: z.string().optional(),
|
||||||
|
showOthers: z.boolean().optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
categories: z
|
||||||
|
.array(z.object({ categoryId: z.string(), priority: z.number().int() }))
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.parse(req.body);
|
||||||
|
const existing = await prisma.portfolioView.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!existing) return reply.code(404).send({ error: "Not found" });
|
||||||
|
|
||||||
|
if (body.categories) {
|
||||||
|
await prisma.portfolioViewCategory.deleteMany({ where: { viewId: existing.id } });
|
||||||
|
await prisma.portfolioViewCategory.createMany({
|
||||||
|
data: body.categories.map((c) => ({
|
||||||
|
viewId: existing.id,
|
||||||
|
categoryId: c.categoryId,
|
||||||
|
priority: c.priority,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.portfolioView.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
...(body.name ? { name: body.name } : {}),
|
||||||
|
...(body.slug ? { slug: body.slug } : {}),
|
||||||
|
...(body.description !== undefined ? { description: body.description } : {}),
|
||||||
|
...(body.defaultSort ? { defaultSort: body.defaultSort } : {}),
|
||||||
|
...(body.showOthers !== undefined ? { showOthers: body.showOthers } : {}),
|
||||||
|
...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
categoryPriorities: { include: { category: true }, orderBy: { priority: "asc" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>("/api/v1/admin/views/:id", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
await prisma.portfolioView.delete({ where: { id: req.params.id } });
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resume
|
||||||
|
app.get("/api/v1/admin/resume", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
return (
|
||||||
|
(await prisma.resume.findFirst({ where: { isActive: true } })) ||
|
||||||
|
(await prisma.resume.findFirst({ orderBy: { updatedAt: "desc" } }))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/api/v1/admin/resume", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
const body = z
|
||||||
|
.object({
|
||||||
|
fullName: z.string().min(1),
|
||||||
|
title: z.string().optional().default(""),
|
||||||
|
email: z.string().optional().nullable(),
|
||||||
|
phone: z.string().optional().nullable(),
|
||||||
|
location: z.string().optional().nullable(),
|
||||||
|
website: z.string().optional().nullable(),
|
||||||
|
summary: z.string().optional().default(""),
|
||||||
|
sections: z.any().optional().default([]),
|
||||||
|
htmlContent: z.string().optional().nullable(),
|
||||||
|
theme: z.string().optional(),
|
||||||
|
})
|
||||||
|
.parse(req.body);
|
||||||
|
|
||||||
|
const existing = await prisma.resume.findFirst({ where: { isActive: true } });
|
||||||
|
if (existing) {
|
||||||
|
return prisma.resume.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
fullName: body.fullName,
|
||||||
|
title: body.title,
|
||||||
|
email: body.email,
|
||||||
|
phone: body.phone,
|
||||||
|
location: body.location,
|
||||||
|
website: body.website,
|
||||||
|
summary: body.summary,
|
||||||
|
sections: body.sections,
|
||||||
|
htmlContent: body.htmlContent,
|
||||||
|
theme: body.theme ?? existing.theme,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return prisma.resume.create({
|
||||||
|
data: {
|
||||||
|
isActive: true,
|
||||||
|
fullName: body.fullName,
|
||||||
|
title: body.title,
|
||||||
|
email: body.email,
|
||||||
|
phone: body.phone,
|
||||||
|
location: body.location,
|
||||||
|
website: body.website,
|
||||||
|
summary: body.summary,
|
||||||
|
sections: body.sections,
|
||||||
|
htmlContent: body.htmlContent,
|
||||||
|
theme: body.theme ?? "dark",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Site settings
|
||||||
|
app.get("/api/v1/admin/settings", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
const rows = await prisma.siteSetting.findMany();
|
||||||
|
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/api/v1/admin/settings/site", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
const value = req.body as object;
|
||||||
|
return prisma.siteSetting.upsert({
|
||||||
|
where: { key: "site" },
|
||||||
|
create: { key: "site", value },
|
||||||
|
update: { value },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/admin/reindex", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
const count = await reindexAllProjects();
|
||||||
|
return { ok: true, indexed: count };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { Visibility } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
import { requireAdmin } from "../lib/auth.js";
|
||||||
|
import { uniqueProjectSlug } from "../lib/slug.js";
|
||||||
|
import { projectPublicInclude, serializeProject } from "../lib/project-include.js";
|
||||||
|
import { indexProject, removeProjectFromIndex } from "../lib/typesense.js";
|
||||||
|
import { parseVideoUrl, saveImageUpload, saveVideoUpload } from "../lib/media.js";
|
||||||
|
|
||||||
|
const projectBody = z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
description: z.string().optional().default(""),
|
||||||
|
shortDescription: z.string().optional().nullable(),
|
||||||
|
date: z.string().optional().nullable(),
|
||||||
|
software: z.array(z.string()).optional().default([]),
|
||||||
|
externalLinks: z
|
||||||
|
.array(z.object({ label: z.string(), url: z.string().url() }))
|
||||||
|
.optional()
|
||||||
|
.default([]),
|
||||||
|
featured: z.boolean().optional().default(false),
|
||||||
|
displayPriority: z.number().int().optional().default(0),
|
||||||
|
visibility: z.enum(["published", "draft", "private"]).optional().default("draft"),
|
||||||
|
categoryIds: z.array(z.string()).optional().default([]),
|
||||||
|
tagNames: z.array(z.string()).optional().default([]),
|
||||||
|
thumbnailId: z.string().optional().nullable(),
|
||||||
|
slug: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function syncTags(projectId: string, tagNames: string[]) {
|
||||||
|
await prisma.projectTag.deleteMany({ where: { projectId } });
|
||||||
|
for (const raw of tagNames) {
|
||||||
|
const name = raw.trim();
|
||||||
|
if (!name) continue;
|
||||||
|
const { makeSlug } = await import("../lib/slug.js");
|
||||||
|
const slug = makeSlug(name);
|
||||||
|
const tag = await prisma.tag.upsert({
|
||||||
|
where: { slug },
|
||||||
|
create: { name, slug },
|
||||||
|
update: { name },
|
||||||
|
});
|
||||||
|
await prisma.projectTag.create({ data: { projectId, tagId: tag.id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncCategories(projectId: string, categoryIds: string[]) {
|
||||||
|
await prisma.projectCategory.deleteMany({ where: { projectId } });
|
||||||
|
for (const categoryId of categoryIds) {
|
||||||
|
await prisma.projectCategory.create({ data: { projectId, categoryId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminProjectRoutes(app: FastifyInstance) {
|
||||||
|
app.get("/api/v1/admin/me", async (req, reply) => {
|
||||||
|
const user = requireAdmin(req, reply);
|
||||||
|
if (!user) return;
|
||||||
|
return user;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{
|
||||||
|
Querystring: { q?: string; visibility?: string; page?: string; perPage?: string };
|
||||||
|
}>("/api/v1/admin/projects", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
const page = Math.max(1, parseInt(req.query.page || "1", 10) || 1);
|
||||||
|
const perPage = Math.min(100, parseInt(req.query.perPage || "50", 10) || 50);
|
||||||
|
const where = {
|
||||||
|
...(req.query.visibility
|
||||||
|
? { visibility: req.query.visibility as Visibility }
|
||||||
|
: {}),
|
||||||
|
...(req.query.q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ title: { contains: req.query.q, mode: "insensitive" as const } },
|
||||||
|
{ description: { contains: req.query.q, mode: "insensitive" as const } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
const [total, projects] = await Promise.all([
|
||||||
|
prisma.project.count({ where }),
|
||||||
|
prisma.project.findMany({
|
||||||
|
where,
|
||||||
|
include: projectPublicInclude,
|
||||||
|
orderBy: [{ displayPriority: "asc" }, { updatedAt: "desc" }],
|
||||||
|
skip: (page - 1) * perPage,
|
||||||
|
take: perPage,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
data: projects.map(serializeProject),
|
||||||
|
meta: { page, perPage, total, totalPages: Math.ceil(total / perPage) },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>("/api/v1/admin/projects/:id", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
const project = await prisma.project.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
include: projectPublicInclude,
|
||||||
|
});
|
||||||
|
if (!project) return reply.code(404).send({ error: "Not found" });
|
||||||
|
return serializeProject(project);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/admin/projects", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
const body = projectBody.parse(req.body);
|
||||||
|
const slug = body.slug || (await uniqueProjectSlug(body.title));
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
title: body.title,
|
||||||
|
slug,
|
||||||
|
description: body.description,
|
||||||
|
shortDescription: body.shortDescription,
|
||||||
|
date: body.date ? new Date(body.date) : null,
|
||||||
|
software: body.software,
|
||||||
|
externalLinks: body.externalLinks,
|
||||||
|
featured: body.featured,
|
||||||
|
displayPriority: body.displayPriority,
|
||||||
|
visibility: body.visibility as Visibility,
|
||||||
|
thumbnailId: body.thumbnailId,
|
||||||
|
publishedAt: body.visibility === "published" ? new Date() : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await syncCategories(project.id, body.categoryIds);
|
||||||
|
await syncTags(project.id, body.tagNames);
|
||||||
|
await indexProject(project.id);
|
||||||
|
const full = await prisma.project.findUnique({
|
||||||
|
where: { id: project.id },
|
||||||
|
include: projectPublicInclude,
|
||||||
|
});
|
||||||
|
return reply.code(201).send(serializeProject(full!));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string } }>("/api/v1/admin/projects/:id", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
const existing = await prisma.project.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!existing) return reply.code(404).send({ error: "Not found" });
|
||||||
|
const body = projectBody.partial().extend({ title: z.string().min(1).optional() }).parse(req.body);
|
||||||
|
|
||||||
|
let slug = existing.slug;
|
||||||
|
if (body.slug) slug = body.slug;
|
||||||
|
else if (body.title && body.title !== existing.title) {
|
||||||
|
slug = await uniqueProjectSlug(body.title, existing.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibility = (body.visibility as Visibility | undefined) ?? existing.visibility;
|
||||||
|
await prisma.project.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
...(body.title !== undefined ? { title: body.title } : {}),
|
||||||
|
slug,
|
||||||
|
...(body.description !== undefined ? { description: body.description } : {}),
|
||||||
|
...(body.shortDescription !== undefined
|
||||||
|
? { shortDescription: body.shortDescription }
|
||||||
|
: {}),
|
||||||
|
...(body.date !== undefined
|
||||||
|
? { date: body.date ? new Date(body.date) : null }
|
||||||
|
: {}),
|
||||||
|
...(body.software !== undefined ? { software: body.software } : {}),
|
||||||
|
...(body.externalLinks !== undefined ? { externalLinks: body.externalLinks } : {}),
|
||||||
|
...(body.featured !== undefined ? { featured: body.featured } : {}),
|
||||||
|
...(body.displayPriority !== undefined
|
||||||
|
? { displayPriority: body.displayPriority }
|
||||||
|
: {}),
|
||||||
|
visibility,
|
||||||
|
...(body.thumbnailId !== undefined ? { thumbnailId: body.thumbnailId } : {}),
|
||||||
|
...(visibility === "published" && !existing.publishedAt
|
||||||
|
? { publishedAt: new Date() }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (body.categoryIds) await syncCategories(existing.id, body.categoryIds);
|
||||||
|
if (body.tagNames) await syncTags(existing.id, body.tagNames);
|
||||||
|
await indexProject(existing.id);
|
||||||
|
const full = await prisma.project.findUnique({
|
||||||
|
where: { id: existing.id },
|
||||||
|
include: projectPublicInclude,
|
||||||
|
});
|
||||||
|
return serializeProject(full!);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>("/api/v1/admin/projects/:id", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
const existing = await prisma.project.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!existing) return reply.code(404).send({ error: "Not found" });
|
||||||
|
await prisma.project.delete({ where: { id: existing.id } });
|
||||||
|
await removeProjectFromIndex(existing.id);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/admin/projects/reorder", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
const body = z.object({ ids: z.array(z.string()) }).parse(req.body);
|
||||||
|
await prisma.$transaction(
|
||||||
|
body.ids.map((id, i) =>
|
||||||
|
prisma.project.update({ where: { id }, data: { displayPriority: i } })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
for (const id of body.ids) await indexProject(id);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Media upload
|
||||||
|
app.post("/api/v1/admin/media/upload", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
const file = await req.file();
|
||||||
|
if (!file) return reply.code(400).send({ error: "No file" });
|
||||||
|
const buffer = await file.toBuffer();
|
||||||
|
const projectId = (file.fields as Record<string, { value?: string }>)?.projectId?.value;
|
||||||
|
const isVideo = file.mimetype.startsWith("video/");
|
||||||
|
|
||||||
|
if (isVideo) {
|
||||||
|
const saved = await saveVideoUpload(buffer, file.mimetype, file.filename);
|
||||||
|
const media = await prisma.media.create({
|
||||||
|
data: {
|
||||||
|
projectId: projectId || null,
|
||||||
|
type: "video",
|
||||||
|
url: saved.url,
|
||||||
|
filename: saved.filename,
|
||||||
|
mimeType: saved.mimeType,
|
||||||
|
sizeBytes: saved.sizeBytes,
|
||||||
|
videoSource: "self_hosted",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return reply.code(201).send(media);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.mimetype.startsWith("image/")) {
|
||||||
|
return reply.code(400).send({ error: "Unsupported file type" });
|
||||||
|
}
|
||||||
|
const saved = await saveImageUpload(buffer, file.mimetype, file.filename);
|
||||||
|
const media = await prisma.media.create({
|
||||||
|
data: {
|
||||||
|
projectId: projectId || null,
|
||||||
|
type: "image",
|
||||||
|
url: saved.url,
|
||||||
|
thumbnailUrl: saved.thumbnailUrl,
|
||||||
|
filename: saved.filename,
|
||||||
|
mimeType: saved.mimeType,
|
||||||
|
width: saved.width,
|
||||||
|
height: saved.height,
|
||||||
|
sizeBytes: saved.sizeBytes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return reply.code(201).send(media);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/admin/media/video-link", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
const body = z
|
||||||
|
.object({
|
||||||
|
url: z.string().url(),
|
||||||
|
projectId: z.string().optional(),
|
||||||
|
caption: z.string().optional(),
|
||||||
|
})
|
||||||
|
.parse(req.body);
|
||||||
|
const parsed = parseVideoUrl(body.url);
|
||||||
|
const media = await prisma.media.create({
|
||||||
|
data: {
|
||||||
|
projectId: body.projectId || null,
|
||||||
|
type: "video",
|
||||||
|
url: parsed.embedUrl || body.url,
|
||||||
|
externalUrl: parsed.externalUrl,
|
||||||
|
videoSource: parsed.videoSource,
|
||||||
|
videoId: parsed.videoId,
|
||||||
|
caption: body.caption,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return reply.code(201).send(media);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch<{ Params: { id: string } }>("/api/v1/admin/media/:id", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
const body = z
|
||||||
|
.object({
|
||||||
|
projectId: z.string().optional().nullable(),
|
||||||
|
alt: z.string().optional().nullable(),
|
||||||
|
caption: z.string().optional().nullable(),
|
||||||
|
sortOrder: z.number().int().optional(),
|
||||||
|
})
|
||||||
|
.parse(req.body);
|
||||||
|
const media = await prisma.media.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
return media;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>("/api/v1/admin/media/:id", async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
await prisma.media.delete({ where: { id: req.params.id } });
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
"/api/v1/admin/projects/:id/thumbnail",
|
||||||
|
async (req, reply) => {
|
||||||
|
if (!requireAdmin(req, reply)) return;
|
||||||
|
const body = z.object({ mediaId: z.string() }).parse(req.body);
|
||||||
|
const media = await prisma.media.findUnique({ where: { id: body.mediaId } });
|
||||||
|
if (!media) return reply.code(404).send({ error: "Media not found" });
|
||||||
|
await prisma.media.update({
|
||||||
|
where: { id: media.id },
|
||||||
|
data: { projectId: req.params.id },
|
||||||
|
});
|
||||||
|
const project = await prisma.project.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: { thumbnailId: media.id },
|
||||||
|
include: projectPublicInclude,
|
||||||
|
});
|
||||||
|
await indexProject(project.id);
|
||||||
|
return serializeProject(project);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
import { getTypesense } from "../lib/typesense.js";
|
||||||
|
|
||||||
|
export async function healthRoutes(app: FastifyInstance) {
|
||||||
|
app.get("/api/v1/health", async () => {
|
||||||
|
let db = "ok";
|
||||||
|
let typesense = "ok";
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
} catch {
|
||||||
|
db = "error";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await getTypesense().health.retrieve();
|
||||||
|
} catch {
|
||||||
|
typesense = "error";
|
||||||
|
}
|
||||||
|
const status = db === "ok" ? "ok" : "degraded";
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
service: "jmartgraphix-com",
|
||||||
|
db,
|
||||||
|
typesense,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { Visibility } from "@prisma/client";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
import { projectPublicInclude, serializeProject } from "../lib/project-include.js";
|
||||||
|
import { searchProjects } from "../lib/typesense.js";
|
||||||
|
|
||||||
|
export async function publicRoutes(app: FastifyInstance) {
|
||||||
|
app.get("/api/v1/site", async () => {
|
||||||
|
const setting = await prisma.siteSetting.findUnique({ where: { key: "site" } });
|
||||||
|
return setting?.value ?? {};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/categories", async () => {
|
||||||
|
const categories = await prisma.category.findMany({
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
projects: {
|
||||||
|
where: { project: { visibility: Visibility.published } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return categories.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
slug: c.slug,
|
||||||
|
description: c.description,
|
||||||
|
sortOrder: c.sortOrder,
|
||||||
|
projectCount: c._count.projects,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/tags", async () => {
|
||||||
|
const tags = await prisma.tag.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
projects: {
|
||||||
|
where: { project: { visibility: Visibility.published } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return tags.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
slug: t.slug,
|
||||||
|
projectCount: t._count.projects,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{
|
||||||
|
Querystring: {
|
||||||
|
q?: string;
|
||||||
|
category?: string;
|
||||||
|
tag?: string;
|
||||||
|
featured?: string;
|
||||||
|
sort?: string;
|
||||||
|
page?: string;
|
||||||
|
perPage?: string;
|
||||||
|
view?: string;
|
||||||
|
};
|
||||||
|
}>("/api/v1/projects", async (req) => {
|
||||||
|
const page = Math.max(1, parseInt(req.query.page || "1", 10) || 1);
|
||||||
|
const perPage = Math.min(100, Math.max(1, parseInt(req.query.perPage || "24", 10) || 24));
|
||||||
|
const sort = req.query.sort || "priority";
|
||||||
|
const category = req.query.category;
|
||||||
|
const tag = req.query.tag;
|
||||||
|
const featured =
|
||||||
|
req.query.featured === "true" ? true : req.query.featured === "false" ? false : undefined;
|
||||||
|
const q = req.query.q;
|
||||||
|
const viewSlug = req.query.view;
|
||||||
|
|
||||||
|
// Portfolio view prioritization
|
||||||
|
if (viewSlug && !category) {
|
||||||
|
const view = await prisma.portfolioView.findFirst({
|
||||||
|
where: { slug: viewSlug, isActive: true },
|
||||||
|
include: {
|
||||||
|
categoryPriorities: {
|
||||||
|
orderBy: { priority: "asc" },
|
||||||
|
include: { category: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (view) {
|
||||||
|
const orderedCatIds = view.categoryPriorities.map((cp) => cp.categoryId);
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
visibility: Visibility.published,
|
||||||
|
...(featured !== undefined ? { featured } : {}),
|
||||||
|
...(tag
|
||||||
|
? { tags: { some: { tag: { OR: [{ slug: tag }, { name: tag }] } } } }
|
||||||
|
: {}),
|
||||||
|
...(q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ title: { contains: q, mode: "insensitive" } },
|
||||||
|
{ description: { contains: q, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
include: projectPublicInclude,
|
||||||
|
orderBy: [{ displayPriority: "asc" }, { date: "desc" }, { title: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const score = (p: (typeof projects)[0]) => {
|
||||||
|
const catIds = p.categories.map((c) => c.categoryId);
|
||||||
|
let best = 999;
|
||||||
|
for (const cid of catIds) {
|
||||||
|
const idx = orderedCatIds.indexOf(cid);
|
||||||
|
if (idx >= 0 && idx < best) best = idx;
|
||||||
|
}
|
||||||
|
if (best === 999 && !view.showOthers) return null;
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scored = projects
|
||||||
|
.map((p) => ({ p, s: score(p) }))
|
||||||
|
.filter((x): x is { p: (typeof projects)[0]; s: number } => x.s !== null)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.s !== b.s) return a.s - b.s;
|
||||||
|
if (a.p.displayPriority !== b.p.displayPriority)
|
||||||
|
return a.p.displayPriority - b.p.displayPriority;
|
||||||
|
const da = a.p.date?.getTime() ?? 0;
|
||||||
|
const db = b.p.date?.getTime() ?? 0;
|
||||||
|
return db - da;
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = scored.length;
|
||||||
|
const slice = scored.slice((page - 1) * perPage, page * perPage);
|
||||||
|
return {
|
||||||
|
data: slice.map((x) => serializeProject(x.p)),
|
||||||
|
meta: {
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / perPage),
|
||||||
|
view: {
|
||||||
|
slug: view.slug,
|
||||||
|
name: view.name,
|
||||||
|
description: view.description,
|
||||||
|
categories: view.categoryPriorities.map((cp) => ({
|
||||||
|
...cp.category,
|
||||||
|
priority: cp.priority,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typesense search when query present
|
||||||
|
if (q?.trim()) {
|
||||||
|
try {
|
||||||
|
const result = await searchProjects({
|
||||||
|
q,
|
||||||
|
category,
|
||||||
|
tag,
|
||||||
|
featured,
|
||||||
|
visibility: "published",
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
sortBy: sort,
|
||||||
|
});
|
||||||
|
const ids = (result.hits ?? []).map((h) => (h.document as { id: string }).id);
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: { id: { in: ids }, visibility: Visibility.published },
|
||||||
|
include: projectPublicInclude,
|
||||||
|
});
|
||||||
|
const byId = Object.fromEntries(projects.map((p) => [p.id, p]));
|
||||||
|
const ordered = ids.map((id) => byId[id]).filter(Boolean);
|
||||||
|
return {
|
||||||
|
data: ordered.map(serializeProject),
|
||||||
|
meta: {
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
total: result.found ?? ordered.length,
|
||||||
|
totalPages: Math.ceil((result.found ?? ordered.length) / perPage),
|
||||||
|
search: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Typesense search fallback to SQL:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
visibility: Visibility.published,
|
||||||
|
...(featured !== undefined ? { featured } : {}),
|
||||||
|
...(category
|
||||||
|
? {
|
||||||
|
categories: {
|
||||||
|
some: { category: { OR: [{ slug: category }, { name: category }] } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(tag
|
||||||
|
? { tags: { some: { tag: { OR: [{ slug: tag }, { name: tag }] } } } }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let orderBy:
|
||||||
|
| { displayPriority: "asc" | "desc" }[]
|
||||||
|
| { date: "asc" | "desc" }[]
|
||||||
|
| { title: "asc" | "desc" }[]
|
||||||
|
| object[] = [{ displayPriority: "asc" }, { date: "desc" }];
|
||||||
|
if (sort === "date") orderBy = [{ date: "desc" }, { displayPriority: "asc" }];
|
||||||
|
else if (sort === "title" || sort === "alphabetical")
|
||||||
|
orderBy = [{ title: "asc" }, { displayPriority: "asc" }];
|
||||||
|
else if (sort === "priority") orderBy = [{ displayPriority: "asc" }, { date: "desc" }];
|
||||||
|
|
||||||
|
const [total, projects] = await Promise.all([
|
||||||
|
prisma.project.count({ where }),
|
||||||
|
prisma.project.findMany({
|
||||||
|
where,
|
||||||
|
include: projectPublicInclude,
|
||||||
|
orderBy: orderBy as never,
|
||||||
|
skip: (page - 1) * perPage,
|
||||||
|
take: perPage,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: projects.map(serializeProject),
|
||||||
|
meta: {
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / perPage),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { slug: string } }>("/api/v1/projects/:slug", async (req, reply) => {
|
||||||
|
const project = await prisma.project.findFirst({
|
||||||
|
where: { slug: req.params.slug, visibility: Visibility.published },
|
||||||
|
include: projectPublicInclude,
|
||||||
|
});
|
||||||
|
if (!project) {
|
||||||
|
return reply.code(404).send({ error: "Not found" });
|
||||||
|
}
|
||||||
|
return serializeProject(project);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/featured", async () => {
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: { visibility: Visibility.published, featured: true },
|
||||||
|
include: projectPublicInclude,
|
||||||
|
orderBy: [{ displayPriority: "asc" }, { date: "desc" }],
|
||||||
|
take: 12,
|
||||||
|
});
|
||||||
|
return projects.map(serializeProject);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/views", async () => {
|
||||||
|
return prisma.portfolioView.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
include: {
|
||||||
|
categoryPriorities: {
|
||||||
|
orderBy: { priority: "asc" },
|
||||||
|
include: { category: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { slug: string } }>("/api/v1/views/:slug", async (req, reply) => {
|
||||||
|
const view = await prisma.portfolioView.findFirst({
|
||||||
|
where: { slug: req.params.slug, isActive: true },
|
||||||
|
include: {
|
||||||
|
categoryPriorities: {
|
||||||
|
orderBy: { priority: "asc" },
|
||||||
|
include: { category: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!view) return reply.code(404).send({ error: "Not found" });
|
||||||
|
return view;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/resume", async (_req, reply) => {
|
||||||
|
const resume = await prisma.resume.findFirst({ where: { isActive: true } });
|
||||||
|
if (!resume) return reply.code(404).send({ error: "No resume published" });
|
||||||
|
return resume;
|
||||||
|
});
|
||||||
|
|
||||||
|
// SEO: sitemap
|
||||||
|
app.get("/sitemap.xml", async (_req, reply) => {
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: { visibility: Visibility.published },
|
||||||
|
select: { slug: true, updatedAt: true },
|
||||||
|
});
|
||||||
|
const base = process.env.PUBLIC_URL?.replace(/\/$/, "") || "https://jmartgraphix.com";
|
||||||
|
const urls = [
|
||||||
|
"",
|
||||||
|
"/portfolio",
|
||||||
|
"/resume",
|
||||||
|
"/about",
|
||||||
|
...projects.map((p) => `/project/${p.slug}`),
|
||||||
|
];
|
||||||
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
${urls
|
||||||
|
.map(
|
||||||
|
(u) => ` <url><loc>${base}${u}</loc><changefreq>weekly</changefreq></url>`
|
||||||
|
)
|
||||||
|
.join("\n")}
|
||||||
|
</urlset>`;
|
||||||
|
return reply.type("application/xml").send(xml);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/rss.xml", async (_req, reply) => {
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: { visibility: Visibility.published },
|
||||||
|
orderBy: { date: "desc" },
|
||||||
|
take: 30,
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
slug: true,
|
||||||
|
shortDescription: true,
|
||||||
|
description: true,
|
||||||
|
date: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const base = process.env.PUBLIC_URL?.replace(/\/$/, "") || "https://jmartgraphix.com";
|
||||||
|
const items = projects
|
||||||
|
.map(
|
||||||
|
(p) => ` <item>
|
||||||
|
<title><![CDATA[${p.title}]]></title>
|
||||||
|
<link>${base}/project/${p.slug}</link>
|
||||||
|
<guid>${base}/project/${p.slug}</guid>
|
||||||
|
<description><![CDATA[${p.shortDescription || p.description.slice(0, 300)}]]></description>
|
||||||
|
<pubDate>${(p.date || p.updatedAt).toUTCString()}</pubDate>
|
||||||
|
</item>`
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>jmartgraphix Portfolio</title>
|
||||||
|
<link>${base}</link>
|
||||||
|
<description>Creative professional portfolio updates</description>
|
||||||
|
${items}
|
||||||
|
</channel>
|
||||||
|
</rss>`;
|
||||||
|
return reply.type("application/rss+xml").send(xml);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function renderResumeHtml(resume: any): string {
|
||||||
|
if (resume.htmlContent) {
|
||||||
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${escapeHtml(
|
||||||
|
resume.fullName
|
||||||
|
)} — Resume</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:Georgia,serif;max-width:800px;margin:40px auto;padding:0 24px;color:#111;line-height:1.5}
|
||||||
|
@media print{body{margin:0}}
|
||||||
|
</style></head><body>${resume.htmlContent}</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = Array.isArray(resume.sections) ? resume.sections : [];
|
||||||
|
let sectionsHtml = "";
|
||||||
|
for (const sec of sections) {
|
||||||
|
sectionsHtml += `<section class="sec"><h2>${escapeHtml(sec.title || sec.type || "")}</h2>`;
|
||||||
|
if (sec.type === "skills" && Array.isArray(sec.items)) {
|
||||||
|
for (const g of sec.items) {
|
||||||
|
sectionsHtml += `<p><strong>${escapeHtml(g.group || "")}</strong> — ${(g.skills || [])
|
||||||
|
.map((s: string) => escapeHtml(s))
|
||||||
|
.join(", ")}</p>`;
|
||||||
|
}
|
||||||
|
} else if (sec.type === "links" && Array.isArray(sec.items)) {
|
||||||
|
sectionsHtml += `<ul>${sec.items
|
||||||
|
.map(
|
||||||
|
(i: { label: string; url: string }) =>
|
||||||
|
`<li><a href="${escapeHtml(i.url)}">${escapeHtml(i.label)}</a></li>`
|
||||||
|
)
|
||||||
|
.join("")}</ul>`;
|
||||||
|
} else if (Array.isArray(sec.items)) {
|
||||||
|
for (const item of sec.items) {
|
||||||
|
sectionsHtml += `<div class="item">
|
||||||
|
<h3>${escapeHtml(item.title || "")}${
|
||||||
|
item.organization ? ` · ${escapeHtml(item.organization)}` : ""
|
||||||
|
}</h3>
|
||||||
|
<div class="meta">${[item.location, [item.startDate, item.endDate].filter(Boolean).join(" – ")]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(escapeHtml)
|
||||||
|
.join(" · ")}</div>
|
||||||
|
${item.description ? `<p>${escapeHtml(item.description)}</p>` : ""}
|
||||||
|
${
|
||||||
|
item.highlights
|
||||||
|
? `<ul>${item.highlights.map((h: string) => `<li>${escapeHtml(h)}</li>`).join("")}</ul>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sectionsHtml += `</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<title>${escapeHtml(resume.fullName)} — Resume</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{font-family:"Segoe UI",system-ui,sans-serif;max-width:820px;margin:0 auto;padding:48px 32px;color:#0f0f0f;line-height:1.55;background:#fff}
|
||||||
|
h1{font-size:2rem;margin:0 0 4px;letter-spacing:-0.02em}
|
||||||
|
.title{color:#444;font-size:1.1rem;margin-bottom:8px}
|
||||||
|
.contact{color:#555;font-size:0.9rem;margin-bottom:24px}
|
||||||
|
.summary{font-size:1rem;margin-bottom:28px;border-left:3px solid #111;padding-left:16px}
|
||||||
|
h2{font-size:0.85rem;text-transform:uppercase;letter-spacing:0.12em;border-bottom:1px solid #ddd;padding-bottom:6px;margin:28px 0 14px;color:#222}
|
||||||
|
h3{font-size:1.05rem;margin:0 0 4px}
|
||||||
|
.meta{color:#666;font-size:0.85rem;margin-bottom:8px}
|
||||||
|
.item{margin-bottom:18px}
|
||||||
|
ul{margin:6px 0 0 18px;padding:0}
|
||||||
|
li{margin-bottom:4px}
|
||||||
|
a{color:#111}
|
||||||
|
@media print{body{padding:24px}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>${escapeHtml(resume.fullName)}</h1>
|
||||||
|
${resume.title ? `<div class="title">${escapeHtml(resume.title)}</div>` : ""}
|
||||||
|
<div class="contact">
|
||||||
|
${[
|
||||||
|
resume.email,
|
||||||
|
resume.phone,
|
||||||
|
resume.location,
|
||||||
|
resume.website,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(escapeHtml)
|
||||||
|
.join(" · ")}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
${resume.summary ? `<p class="summary">${escapeHtml(resume.summary)}</p>` : ""}
|
||||||
|
${sectionsHtml}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resumePdfRoutes(app: FastifyInstance) {
|
||||||
|
app.get("/api/v1/resume/html", async (_req, reply) => {
|
||||||
|
const resume = await prisma.resume.findFirst({ where: { isActive: true } });
|
||||||
|
if (!resume) return reply.code(404).send({ error: "No resume" });
|
||||||
|
return reply.type("text/html").send(renderResumeHtml(resume));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/resume/pdf", async (_req, reply) => {
|
||||||
|
const resume = await prisma.resume.findFirst({ where: { isActive: true } });
|
||||||
|
if (!resume) return reply.code(404).send({ error: "No resume" });
|
||||||
|
|
||||||
|
const html = renderResumeHtml(resume);
|
||||||
|
try {
|
||||||
|
// Dynamic import so local dev without chromium still works for HTML
|
||||||
|
const puppeteer = await import("puppeteer");
|
||||||
|
const browser = await puppeteer.default.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
|
||||||
|
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setContent(html, { waitUntil: "load" });
|
||||||
|
const pdf = await page.pdf({
|
||||||
|
format: "A4",
|
||||||
|
printBackground: true,
|
||||||
|
margin: { top: "16mm", bottom: "16mm", left: "14mm", right: "14mm" },
|
||||||
|
});
|
||||||
|
const filename = `${(resume.fullName || "resume").replace(/\s+/g, "_")}_Resume.pdf`;
|
||||||
|
return reply
|
||||||
|
.header("Content-Type", "application/pdf")
|
||||||
|
.header("Content-Disposition", `attachment; filename="${filename}"`)
|
||||||
|
.send(Buffer.from(pdf));
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("PDF generation failed:", err);
|
||||||
|
// Fallback: return HTML with print hint
|
||||||
|
return reply
|
||||||
|
.code(503)
|
||||||
|
.type("application/json")
|
||||||
|
.send({
|
||||||
|
error: "PDF generation unavailable",
|
||||||
|
htmlUrl: `${config.publicUrl}/api/v1/resume/html`,
|
||||||
|
message: "Open the HTML version and use Print → Save as PDF",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { renderResumeHtml };
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user