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,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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user