From 43928f7a49892ec74e278db417a574179f0590ed Mon Sep 17 00:00:00 2001 From: jmartin Date: Thu, 23 Jul 2026 17:01:34 -0400 Subject: [PATCH] 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. --- .env.example | 39 + .gitignore | 15 + Dockerfile | 71 + README.md | 87 + client/index.html | 19 + client/package.json | 25 + client/src/App.tsx | 40 + client/src/components/Layout.scss | 152 + client/src/components/Layout.tsx | 85 + client/src/components/ProjectCard.scss | 95 + client/src/components/ProjectCard.tsx | 41 + client/src/lib/api.ts | 247 + client/src/main.tsx | 13 + client/src/pages/AboutPage.scss | 60 + client/src/pages/AboutPage.tsx | 68 + client/src/pages/HomePage.scss | 131 + client/src/pages/HomePage.tsx | 106 + client/src/pages/PortfolioPage.scss | 109 + client/src/pages/PortfolioPage.tsx | 160 + client/src/pages/ProjectPage.scss | 145 + client/src/pages/ProjectPage.tsx | 167 + client/src/pages/ResumePage.scss | 98 + client/src/pages/ResumePage.tsx | 125 + client/src/pages/admin/Admin.scss | 264 + client/src/pages/admin/AdminCategories.tsx | 128 + client/src/pages/admin/AdminDashboard.tsx | 99 + client/src/pages/admin/AdminLayout.tsx | 74 + client/src/pages/admin/AdminProjectEdit.tsx | 367 + client/src/pages/admin/AdminProjects.tsx | 138 + client/src/pages/admin/AdminResume.tsx | 149 + client/src/pages/admin/AdminSettings.tsx | 53 + client/src/pages/admin/AdminViews.tsx | 167 + client/src/styles/global.scss | 233 + client/src/vite-env.d.ts | 1 + client/tsconfig.json | 22 + client/vite.config.ts | 23 + package-lock.json | 6572 +++++++++++++++++ package.json | 25 + public/.well-known/matrix/client | 14 + public/.well-known/matrix/server | 3 + public/.well-known/webfinger | 9 + server/package.json | 43 + .../20260723100000_init/migration.sql | 168 + server/prisma/migrations/migration_lock.toml | 3 + server/prisma/schema.prisma | 192 + server/prisma/seed.ts | 213 + server/scripts/import-artstation.ts | 298 + server/src/config.ts | 58 + server/src/index.ts | 176 + server/src/lib/auth.ts | 61 + server/src/lib/media.ts | 168 + server/src/lib/prisma.ts | 5 + server/src/lib/project-include.ts | 21 + server/src/lib/slug.ts | 39 + server/src/lib/typesense.ts | 175 + server/src/routes/admin-meta.ts | 282 + server/src/routes/admin-projects.ts | 316 + server/src/routes/health.ts | 28 + server/src/routes/public.ts | 356 + server/src/routes/resume-pdf.ts | 159 + server/tsconfig.json | 18 + 61 files changed, 13218 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 client/index.html create mode 100644 client/package.json create mode 100644 client/src/App.tsx create mode 100644 client/src/components/Layout.scss create mode 100644 client/src/components/Layout.tsx create mode 100644 client/src/components/ProjectCard.scss create mode 100644 client/src/components/ProjectCard.tsx create mode 100644 client/src/lib/api.ts create mode 100644 client/src/main.tsx create mode 100644 client/src/pages/AboutPage.scss create mode 100644 client/src/pages/AboutPage.tsx create mode 100644 client/src/pages/HomePage.scss create mode 100644 client/src/pages/HomePage.tsx create mode 100644 client/src/pages/PortfolioPage.scss create mode 100644 client/src/pages/PortfolioPage.tsx create mode 100644 client/src/pages/ProjectPage.scss create mode 100644 client/src/pages/ProjectPage.tsx create mode 100644 client/src/pages/ResumePage.scss create mode 100644 client/src/pages/ResumePage.tsx create mode 100644 client/src/pages/admin/Admin.scss create mode 100644 client/src/pages/admin/AdminCategories.tsx create mode 100644 client/src/pages/admin/AdminDashboard.tsx create mode 100644 client/src/pages/admin/AdminLayout.tsx create mode 100644 client/src/pages/admin/AdminProjectEdit.tsx create mode 100644 client/src/pages/admin/AdminProjects.tsx create mode 100644 client/src/pages/admin/AdminResume.tsx create mode 100644 client/src/pages/admin/AdminSettings.tsx create mode 100644 client/src/pages/admin/AdminViews.tsx create mode 100644 client/src/styles/global.scss create mode 100644 client/src/vite-env.d.ts create mode 100644 client/tsconfig.json create mode 100644 client/vite.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/.well-known/matrix/client create mode 100644 public/.well-known/matrix/server create mode 100644 public/.well-known/webfinger create mode 100644 server/package.json create mode 100644 server/prisma/migrations/20260723100000_init/migration.sql create mode 100644 server/prisma/migrations/migration_lock.toml create mode 100644 server/prisma/schema.prisma create mode 100644 server/prisma/seed.ts create mode 100644 server/scripts/import-artstation.ts create mode 100644 server/src/config.ts create mode 100644 server/src/index.ts create mode 100644 server/src/lib/auth.ts create mode 100644 server/src/lib/media.ts create mode 100644 server/src/lib/prisma.ts create mode 100644 server/src/lib/project-include.ts create mode 100644 server/src/lib/slug.ts create mode 100644 server/src/lib/typesense.ts create mode 100644 server/src/routes/admin-meta.ts create mode 100644 server/src/routes/admin-projects.ts create mode 100644 server/src/routes/health.ts create mode 100644 server/src/routes/public.ts create mode 100644 server/src/routes/resume-pdf.ts create mode 100644 server/tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9e5d404 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e55603a --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..696b75e --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5fe88e0 --- /dev/null +++ b/README.md @@ -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` diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..fc2b1a0 --- /dev/null +++ b/client/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + + + + jmartgraphix + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..aaa7027 --- /dev/null +++ b/client/package.json @@ -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" + } +} diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..9baea2d --- /dev/null +++ b/client/src/App.tsx @@ -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 ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/client/src/components/Layout.scss b/client/src/components/Layout.scss new file mode 100644 index 0000000..7408757 --- /dev/null +++ b/client/src/components/Layout.scss @@ -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; + } +} diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx new file mode 100644 index 0000000..1bf59e4 --- /dev/null +++ b/client/src/components/Layout.tsx @@ -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(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 ( + <> +
+
+ + + {name} + + + +
+
+
+ +
+
+
+
+ {name} +

{site?.tagline || "Creative professional portfolio"}

+
+
+ {site?.social?.youtube && ( + + YouTube + + )} + {site?.social?.vimeo && ( + + Vimeo + + )} + {site?.social?.artstation && ( + + ArtStation + + )} + Resume +
+

© {new Date().getFullYear()} {name}

+
+
+ + ); +} diff --git a/client/src/components/ProjectCard.scss b/client/src/components/ProjectCard.scss new file mode 100644 index 0000000..69a9694 --- /dev/null +++ b/client/src/components/ProjectCard.scss @@ -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; +} diff --git a/client/src/components/ProjectCard.tsx b/client/src/components/ProjectCard.tsx new file mode 100644 index 0000000..bf94b2a --- /dev/null +++ b/client/src/components/ProjectCard.tsx @@ -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 ( +
+ +
+ {thumb ? ( + + ) : ( +
+ {project.title.slice(0, 1)} +
+ )} + {project.featured && Featured} +
+
+
+ {project.categories.slice(0, 2).map((c) => ( + {c.name} + ))} + {year && {year}} +
+

{project.title}

+ {project.shortDescription && ( +

{project.shortDescription}

+ )} +
+ +
+ ); +} diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts new file mode 100644 index 0000000..1408d07 --- /dev/null +++ b/client/src/lib/api.ts @@ -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; + 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(path: string, init?: RequestInit): Promise { + 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; +} + +export const api = { + site: () => request("/api/v1/site"), + categories: () => request("/api/v1/categories"), + tags: () => request("/api/v1/tags"), + projects: (params: Record = {}) => { + 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(`/api/v1/projects/${slug}`), + featured: () => request("/api/v1/featured"), + views: () => request("/api/v1/views"), + view: (slug: string) => request(`/api/v1/views/${slug}`), + resume: () => request("/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 = {}) => { + 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(`/api/v1/admin/projects/${id}`), + createProject: (body: unknown) => + request("/api/v1/admin/projects", { method: "POST", body: JSON.stringify(body) }), + updateProject: (id: string, body: unknown) => + request(`/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("/api/v1/admin/media/upload", { method: "POST", body: fd }); + }, + addVideoLink: (url: string, projectId?: string) => + request("/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(`/api/v1/admin/projects/${projectId}/thumbnail`, { + method: "POST", + body: JSON.stringify({ mediaId }), + }), + adminCategories: () => request("/api/v1/admin/categories"), + createCategory: (body: unknown) => + request("/api/v1/admin/categories", { method: "POST", body: JSON.stringify(body) }), + updateCategory: (id: string, body: unknown) => + request(`/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("/api/v1/admin/tags"), + deleteTag: (id: string) => + request<{ ok: boolean }>(`/api/v1/admin/tags/${id}`, { method: "DELETE" }), + adminViews: () => request("/api/v1/admin/views"), + createView: (body: unknown) => + request("/api/v1/admin/views", { method: "POST", body: JSON.stringify(body) }), + updateView: (id: string, body: unknown) => + request(`/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("/api/v1/admin/resume"), + saveResume: (body: unknown) => + request("/api/v1/admin/resume", { method: "PUT", body: JSON.stringify(body) }), + adminSettings: () => request>("/api/v1/admin/settings"), + saveSiteSettings: (body: unknown) => + request("/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) || + "" + ); +} diff --git a/client/src/main.tsx b/client/src/main.tsx new file mode 100644 index 0000000..d4df6f1 --- /dev/null +++ b/client/src/main.tsx @@ -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( + + + + + +); diff --git a/client/src/pages/AboutPage.scss b/client/src/pages/AboutPage.scss new file mode 100644 index 0000000..c9997d5 --- /dev/null +++ b/client/src/pages/AboutPage.scss @@ -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); + } +} diff --git a/client/src/pages/AboutPage.tsx b/client/src/pages/AboutPage.tsx new file mode 100644 index 0000000..60c4550 --- /dev/null +++ b/client/src/pages/AboutPage.tsx @@ -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(null); + + useEffect(() => { + api.site().then(setSite).catch(() => {}); + }, []); + + return ( +
+

About

+

{site?.name || "jmartgraphix"}

+

+ {site?.about || + "Creative professional specializing in 3D art, technical design, animation, and visual media."} +

+
+
+

Disciplines

+
    +
  • 3D Modeling & Sculpting
  • +
  • CAD & Product Visualization
  • +
  • Animation & Motion
  • +
  • Graphic Design & Illustration
  • +
  • Photography & Video
  • +
  • AI-assisted creative pipelines
  • +
+
+
+

Connect

+ +
+
+
+ ); +} diff --git a/client/src/pages/HomePage.scss b/client/src/pages/HomePage.scss new file mode 100644 index 0000000..479aaab --- /dev/null +++ b/client/src/pages/HomePage.scss @@ -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); + } +} diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx new file mode 100644 index 0000000..da97688 --- /dev/null +++ b/client/src/pages/HomePage.tsx @@ -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(null); + const [featured, setFeatured] = useState([]); + const [recent, setRecent] = useState([]); + const [categories, setCategories] = useState([]); + + 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 ( +
+
+
+

Portfolio

+

+ {heroTitle} +

+

{heroSub}

+

+ {site?.about || + "A curated collection of 3D modeling, CAD, animation, and visual design."} +

+
+ + View portfolio + + + Resume + +
+
+
+ +
+
+ {categories.map((c) => ( + + {c.name} + {typeof c.projectCount === "number" && ( + {c.projectCount} + )} + + ))} + + Game Dev view + + + Engineering view + +
+
+ + {featured.length > 0 && ( +
+
+

Featured

+ All work → +
+
+ {featured.map((p, i) => ( + + ))} +
+
+ )} + +
+
+

Recent

+
+ {recent.length === 0 ? ( +

+ Projects will appear here once published. Use the{" "} + admin panel to add work, or import from ArtStation. +

+ ) : ( +
+ {recent.map((p, i) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/client/src/pages/PortfolioPage.scss b/client/src/pages/PortfolioPage.scss new file mode 100644 index 0000000..1d665d9 --- /dev/null +++ b/client/src/pages/PortfolioPage.scss @@ -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; + } +} diff --git a/client/src/pages/PortfolioPage.tsx b/client/src/pages/PortfolioPage.tsx new file mode 100644 index 0000000..897f1e9 --- /dev/null +++ b/client/src/pages/PortfolioPage.tsx @@ -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([]); + const [meta, setMeta] = useState(null); + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+

Work

+

{title}

+

{description}

+ {viewSlug && ( +

+ Viewing curated list /portfolio/{viewSlug} + {" · "} + clear view +

+ )} +
+
+ +
+ { + if (e.key === "Enter") updateParam("q", (e.target as HTMLInputElement).value); + }} + onBlur={(e) => updateParam("q", e.target.value)} + aria-label="Search projects" + /> + + +
+ +
+ {categories.map((c) => ( + + {c.name} + + ))} +
+ + {loading &&

Loading…

} + {error &&

{error}

} + + {!loading && !error && projects.length === 0 && ( +

No published projects match these filters.

+ )} + +
+ {projects.map((p, i) => ( + + ))} +
+ + {meta && meta.totalPages > 1 && ( +
+ + + Page {meta.page} of {meta.totalPages} · {meta.total} projects + + +
+ )} +
+ ); +} diff --git a/client/src/pages/ProjectPage.scss b/client/src/pages/ProjectPage.scss new file mode 100644 index 0000000..4b0233c --- /dev/null +++ b/client/src/pages/ProjectPage.scss @@ -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); + } +} diff --git a/client/src/pages/ProjectPage.tsx b/client/src/pages/ProjectPage.tsx new file mode 100644 index 0000000..ed318b3 --- /dev/null +++ b/client/src/pages/ProjectPage.tsx @@ -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(null); + const [error, setError] = useState(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 ( +
+

{error}

+ ← Back to portfolio +
+ ); + } + + if (!project) { + return ( +
+

Loading…

+
+ ); + } + + 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 ( +
+
+ + ← Portfolio + +
+
+ {project.categories.map((c) => ( + + {c.name} + + ))} +
+

{project.title}

+
+ {project.date && ( + {new Date(project.date).toLocaleDateString(undefined, { year: "numeric", month: "long" })} + )} + {project.software.length > 0 && ( + {project.software.join(" · ")} + )} +
+
+ + {project.description && ( +
+ {project.description.split("\n").map((para, i) => ( +

{para}

+ ))} +
+ )} + + {videos.length > 0 && ( +
+ {videos.map((v) => ( +
+ {v.videoSource === "youtube" || v.videoSource === "vimeo" ? ( +