diff --git a/.github/workflows/pr-checks.yaml b/.github/workflows/pr-checks.yaml
index f322884..0238e40 100644
--- a/.github/workflows/pr-checks.yaml
+++ b/.github/workflows/pr-checks.yaml
@@ -34,6 +34,9 @@ jobs:
- name: Install Dependencies
run: npm ci --legacy-peer-deps
+ - name: Mock Portfolio Template Library for CI
+ run: node scripts/mock-template-library.mjs
+
- name: Linting & Formatting
run: |
npm run lint
diff --git a/.hallmark/log.json b/.hallmark/log.json
new file mode 100644
index 0000000..68525aa
--- /dev/null
+++ b/.hallmark/log.json
@@ -0,0 +1,18 @@
+[
+ {
+ "date": "2026-05-30",
+ "scope": "app",
+ "macrostructure": "Readable portfolio tour + Tabbed studio",
+ "theme": "VeriWorkly platform",
+ "enrichment": "Interactive full-template preview",
+ "brief": "Corrective portfolio makeover: readable landing page, compact studio, complete billing decision page, populated demos, and retuned templates."
+ },
+ {
+ "date": "2026-05-30",
+ "scope": "app",
+ "macrostructure": "Editorial product tour + Workbench",
+ "theme": "VeriWorkly platform",
+ "enrichment": "Tier-A CSS grid surface",
+ "brief": "Full production makeover for the portfolio builder, editor, billing view, gallery, and public templates."
+ }
+]
diff --git a/apps/blog-platform/package.json b/apps/blog-platform/package.json
index e873156..c975c6f 100644
--- a/apps/blog-platform/package.json
+++ b/apps/blog-platform/package.json
@@ -1,6 +1,6 @@
{
"name": "@veriworkly/blog-platform",
- "version": "3.11.0",
+ "version": "3.12.0",
"private": true,
"scripts": {
"dev": "next dev",
diff --git a/apps/docs-platform/package.json b/apps/docs-platform/package.json
index 41be754..5348794 100644
--- a/apps/docs-platform/package.json
+++ b/apps/docs-platform/package.json
@@ -1,6 +1,6 @@
{
"name": "@veriworkly/docs-platform",
- "version": "3.11.0",
+ "version": "3.12.0",
"private": true,
"scripts": {
"dev": "next dev",
diff --git a/apps/portfolio/.env.example b/apps/portfolio/.env.example
new file mode 100644
index 0000000..6b8eed3
--- /dev/null
+++ b/apps/portfolio/.env.example
@@ -0,0 +1,2 @@
+NEXT_PUBLIC_BACKEND_URL=http://localhost:8080/api/v1
+BACKEND_INTERNAL_URL=http://localhost:8080/api/v1
diff --git a/apps/portfolio/.gitignore b/apps/portfolio/.gitignore
new file mode 100644
index 0000000..0df97fb
--- /dev/null
+++ b/apps/portfolio/.gitignore
@@ -0,0 +1,51 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+
+# except example file
+!.env.example
+!.env.docker.example
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+
+# fumadocs generated files
+.source
+
+# testing
+/app/og-generator
diff --git a/apps/portfolio/README.md b/apps/portfolio/README.md
new file mode 100644
index 0000000..45c61aa
--- /dev/null
+++ b/apps/portfolio/README.md
@@ -0,0 +1,41 @@
+# VeriWorkly Portfolio
+
+The public portfolio platform. Private template implementations are mounted as a Git submodule at `template-library/`.
+
+## Setup
+
+Clone with the private templates:
+
+```bash
+git clone --recurse-submodules git@github.com:VeriWorkly/veriworkly.git
+```
+
+For an existing checkout:
+
+```bash
+git submodule update --init --recursive
+```
+
+The private repository uses GitHub SSH access. The machine running development or deployment must trust GitHub's SSH host key and have access to `VeriWorkly/portfolio-templates`.
+
+## Add A Template
+
+1. Add a folder in `template-library/` with its own React component and optional scoped stylesheet.
+2. Add one dynamic loader entry in `template-library/registry.ts`.
+3. Add public gallery metadata in `templates/catalog/templates.ts`.
+4. Commit and push the private repository first.
+5. Commit the updated submodule pointer in this repository.
+
+Do not import template styles from `app/globals.css`. Template modules own their styles so Next.js can emit per-template assets.
+
+## Production Deployment
+
+Portfolio publishing requires:
+
+1. Point `portfolio.veriworkly.com` and `*.veriworkly.com` at the portfolio Next.js deployment.
+2. Provision TLS coverage for `portfolio.veriworkly.com` and `*.veriworkly.com`.
+3. Configure `NEXT_PUBLIC_BACKEND_URL` and `BACKEND_INTERNAL_URL`.
+4. Configure the server Dodo Payments and Cloudflare R2 variables documented in `apps/server/.env.example`.
+5. Set the auth cookie domain to `.veriworkly.com` so Studio and Portfolio share the signed-in session.
+
+Only VeriWorkly subdomains are supported at launch. Custom-domain routing and certificate automation are intentionally out of scope.
diff --git a/apps/portfolio/app/api/render/route.ts b/apps/portfolio/app/api/render/route.ts
new file mode 100644
index 0000000..be3b97d
--- /dev/null
+++ b/apps/portfolio/app/api/render/route.ts
@@ -0,0 +1,26 @@
+import { NextResponse } from "next/server";
+
+import { parsePortfolioContent } from "@/lib/portfolio";
+
+import { renderTemplate } from "@/templates/runtime/registry";
+
+export async function POST(request: Request) {
+ try {
+ const project = parsePortfolioContent(await request.json());
+
+ const element = await renderTemplate(project);
+
+ const { renderToString } = await import("react-dom/server");
+ const html = renderToString(element);
+
+ return new NextResponse(html, {
+ headers: {
+ "Content-Type": "text/html; charset=utf-8",
+ "Cache-Control": "no-store, max-age=0",
+ },
+ });
+ } catch (error) {
+ console.error("Failed to render preview template:", error);
+ return new NextResponse("Error rendering template", { status: 500 });
+ }
+}
diff --git a/apps/portfolio/app/api/revalidate/route.ts b/apps/portfolio/app/api/revalidate/route.ts
new file mode 100644
index 0000000..5404ee1
--- /dev/null
+++ b/apps/portfolio/app/api/revalidate/route.ts
@@ -0,0 +1,41 @@
+import { revalidatePath, revalidateTag } from "next/cache";
+import { NextResponse } from "next/server";
+
+export async function POST(request: Request) {
+ let body: { paths?: string[]; tags?: string[]; secret?: string };
+
+ try {
+ body = (await request.json()) as { paths?: string[]; tags?: string[]; secret?: string };
+ } catch {
+ return NextResponse.json({ message: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const { paths, tags, secret } = body;
+ const expectedSecret = process.env.PORTFOLIO_REVALIDATE_SECRET || "dev-revalidate-secret";
+
+ if (secret !== expectedSecret) {
+ return NextResponse.json({ message: "Invalid secret" }, { status: 401 });
+ }
+
+ if (tags && Array.isArray(tags)) {
+ for (const tag of tags) {
+ try {
+ (revalidateTag as unknown as (tag: string) => void)(tag);
+ } catch (err) {
+ console.error(`Failed to revalidate tag: ${tag}`, err);
+ }
+ }
+ }
+
+ if (paths && Array.isArray(paths)) {
+ for (const path of paths) {
+ try {
+ revalidatePath(path);
+ } catch (err) {
+ console.error(`Failed to revalidate path: ${path}`, err);
+ }
+ }
+ }
+
+ return NextResponse.json({ revalidated: true, paths, tags });
+}
diff --git a/apps/portfolio/app/billing/page.tsx b/apps/portfolio/app/billing/page.tsx
new file mode 100644
index 0000000..4fe1a56
--- /dev/null
+++ b/apps/portfolio/app/billing/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from "next";
+import { BillingWorkspace } from "@/components/BillingWorkspace";
+
+export const metadata: Metadata = {
+ title: "Portfolio Pro billing",
+ robots: { index: false, follow: false },
+};
+export default function BillingPage() {
+ return ;
+}
diff --git a/apps/portfolio/app/dashboard/page.tsx b/apps/portfolio/app/dashboard/page.tsx
new file mode 100644
index 0000000..8d8fbd4
--- /dev/null
+++ b/apps/portfolio/app/dashboard/page.tsx
@@ -0,0 +1,7 @@
+import type { Metadata } from "next";
+import { DashboardWorkspace } from "@/components/DashboardWorkspace";
+
+export const metadata: Metadata = { title: "Dashboard", robots: { index: false, follow: false } };
+export default function DashboardPage() {
+ return ;
+}
diff --git a/apps/portfolio/app/error.tsx b/apps/portfolio/app/error.tsx
new file mode 100644
index 0000000..479655a
--- /dev/null
+++ b/apps/portfolio/app/error.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+export default function ErrorPage({ reset }: { reset: () => void }) {
+ return (
+
+
+
+ Temporary issue
+
+
This page could not load.
+
+ Your work has not been lost. Try the request again, or return after the service connection
+ recovers.
+
+
+ Try again
+
+
+
+ );
+}
diff --git a/apps/portfolio/app/globals.css b/apps/portfolio/app/globals.css
new file mode 100644
index 0000000..cc1938b
--- /dev/null
+++ b/apps/portfolio/app/globals.css
@@ -0,0 +1,96 @@
+/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V5
+ * genre: modern-minimal · macrostructure: Editorial product tour + Workbench · tone: structured confidence · anchor hue: blue
+ * contrast: pass (46-50) · nav: N1 · footer: Ft1 · tokens: pass (58) · mobile: pass (36, 59, 61-69)
+ */
+@import "tailwindcss";
+@import "./tokens.css";
+
+* {
+ box-sizing: border-box;
+}
+html,
+body {
+ margin: 0;
+ overflow-x: clip;
+ scroll-behavior: smooth;
+}
+body {
+ background: var(--color-paper);
+ color: var(--color-ink);
+ font-family: var(--font-body);
+}
+.surface-grid {
+ background-image:
+ linear-gradient(to right, var(--color-line) 1px, transparent 1px),
+ linear-gradient(to bottom, var(--color-line) 1px, transparent 1px);
+ background-size: 28px 28px;
+}
+.paper-noise {
+ background:
+ radial-gradient(circle at 10% 0%, var(--color-accent-soft), transparent 26rem),
+ var(--color-paper);
+}
+.reveal {
+ animation: reveal var(--dur-slow) var(--ease-out) both;
+}
+.reveal-delay {
+ animation-delay: 120ms;
+}
+@keyframes reveal {
+ from {
+ opacity: 0;
+ transform: translateY(14px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+a {
+ color: inherit;
+ text-decoration: none;
+}
+button,
+input,
+textarea {
+ font: inherit;
+}
+button,
+a {
+ -webkit-tap-highlight-color: transparent;
+}
+button:focus-visible,
+a:focus-visible,
+input:focus-visible,
+textarea:focus-visible {
+ outline: 3px solid var(--color-accent);
+ outline-offset: 3px;
+}
+button:active,
+a:active {
+ opacity: 0.72;
+}
+button:disabled,
+input:disabled,
+textarea:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+h1,
+h2 {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+@media (prefers-reduced-motion: reduce) {
+ html,
+ body {
+ scroll-behavior: auto;
+ }
+ *,
+ *::before,
+ *::after {
+ animation-duration: 1ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 1ms !important;
+ }
+}
diff --git a/apps/portfolio/app/layout.tsx b/apps/portfolio/app/layout.tsx
new file mode 100644
index 0000000..3a0b1f9
--- /dev/null
+++ b/apps/portfolio/app/layout.tsx
@@ -0,0 +1,48 @@
+import type { Metadata } from "next";
+import "./globals.css";
+import { portfolioSiteConfig } from "@/config/site";
+
+export const metadata: Metadata = {
+ metadataBase: new URL(portfolioSiteConfig.url),
+ title: {
+ default: "Professional Portfolio Builder | VeriWorkly",
+ template: "%s | VeriWorkly Portfolio",
+ },
+ description: portfolioSiteConfig.description,
+ keywords: [...portfolioSiteConfig.keywords],
+ authors: [{ name: "VeriWorkly" }],
+ creator: "VeriWorkly",
+ publisher: "VeriWorkly",
+ openGraph: {
+ type: "website",
+ url: portfolioSiteConfig.url,
+ title: "Professional Portfolio Builder | VeriWorkly",
+ description: portfolioSiteConfig.description,
+ siteName: portfolioSiteConfig.name,
+ },
+ twitter: {
+ card: "summary",
+ title: "Professional Portfolio Builder | VeriWorkly",
+ description: portfolioSiteConfig.description,
+ },
+ robots: {
+ index: true,
+ follow: true,
+ googleBot: {
+ index: true,
+ follow: true,
+ "max-image-preview": "large",
+ "max-snippet": -1,
+ "max-video-preview": -1,
+ },
+ },
+ alternates: { canonical: "/" },
+};
+
+export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
+ return (
+
+
{children}
+
+ );
+}
diff --git a/apps/portfolio/app/page.tsx b/apps/portfolio/app/page.tsx
new file mode 100644
index 0000000..6def580
--- /dev/null
+++ b/apps/portfolio/app/page.tsx
@@ -0,0 +1,188 @@
+import Image from "next/image";
+import Link from "next/link";
+import { ArrowRight, Check, CirclePlay, LayoutTemplate, MoveUpRight, Sparkles } from "lucide-react";
+import { TemplateFrame } from "@/components/TemplateFrame";
+
+const shell = "mx-auto w-[min(1240px,calc(100%-48px))] max-sm:w-[min(100%-32px,1240px)]";
+const cta =
+ "inline-flex min-h-11 items-center gap-2 whitespace-nowrap rounded-full bg-[var(--color-accent)] px-5 text-sm font-extrabold text-[var(--color-accent-ink)] transition-transform duration-150 hover:-translate-y-0.5";
+
+export default function HomePage() {
+ return (
+
+
+
+
+
VeriWorkly Portfolio
+
+
+
+
+
+
+
+
+ Portfolio studio
+
+
+ Your work.
+
+ Presented with intent.
+
+
+ Build a portfolio that reads clearly, looks considered, and stays easy to update. Start
+ with a complete template, then shape every section in one focused workspace.
+
+
+
+
+
+
+
+
+
+
+
+
+ A focused workflow
+
+
+ Edit less.
+
+ Show more.
+
+
+ The studio keeps the work visible and the forms compact. You always know which part of
+ the portfolio you are shaping.
+
+
+
+ {[
+ {
+ Icon: LayoutTemplate,
+ title: "Pick a complete direction",
+ copy: "Preview realistic templates with projects, experience, services, skills, writing, and testimonials already represented.",
+ },
+ {
+ Icon: Sparkles,
+ title: "Work section by section",
+ copy: "Use a compact studio with dedicated tabs instead of one endless form.",
+ },
+ {
+ Icon: MoveUpRight,
+ title: "Publish when it reads well",
+ copy: "Preview privately, then publish the finished portfolio on your VeriWorkly subdomain.",
+ },
+ ].map(({ Icon, title, copy }) => (
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ Draft for free
+
+
+ Build privately. Publish when it feels right.
+
+
+
+ Portfolio Pro
+
+ $12
+
+ / month
+
+
+
+ One published subdomain and privacy-first portfolio analytics.
+
+
+ See plans
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/portfolio/app/portfolios/[username]/page.tsx b/apps/portfolio/app/portfolios/[username]/page.tsx
new file mode 100644
index 0000000..9b3dd07
--- /dev/null
+++ b/apps/portfolio/app/portfolios/[username]/page.tsx
@@ -0,0 +1,82 @@
+import { notFound } from "next/navigation";
+import type { Metadata } from "next";
+import { portfolioPublicUrl } from "@/config/site";
+
+import { getPublishedPortfolio } from "@/lib/published-portfolio";
+import { renderTemplate } from "@/templates/runtime/registry";
+import { PublicViewTracker } from "@/components/PublicViewTracker";
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ username: string }>;
+}): Promise {
+ const { username } = await params;
+ const publication = await getPublishedPortfolio(username);
+ if (!publication)
+ return { title: "Portfolio not found", robots: { index: false, follow: false } };
+ const project = publication.snapshot;
+ const url = portfolioPublicUrl(publication.subdomain);
+ const title = project.seo.title || `${project.identity.name} | Portfolio`;
+ const images = project.seo.socialImage ? [{ url: project.seo.socialImage.url }] : undefined;
+ return {
+ title,
+ description: project.seo.description,
+ alternates: { canonical: url },
+ robots: {
+ index: true,
+ follow: true,
+ googleBot: {
+ index: true,
+ follow: true,
+ "max-image-preview": "large",
+ "max-snippet": -1,
+ "max-video-preview": -1,
+ },
+ },
+ openGraph: {
+ type: "profile",
+ url,
+ title,
+ description: project.seo.description,
+ siteName: "VeriWorkly Portfolio",
+ images,
+ },
+ twitter: {
+ card: images ? "summary_large_image" : "summary",
+ title,
+ description: project.seo.description,
+ images,
+ },
+ };
+}
+
+export default async function Portfolio({ params }: { params: Promise<{ username: string }> }) {
+ const { username } = await params;
+ const publication = await getPublishedPortfolio(username);
+ if (!publication) notFound();
+ const project = publication.snapshot;
+ const jsonLd = {
+ "@context": "https://schema.org",
+ "@type": "ProfilePage",
+ name: `${project.identity.name} portfolio`,
+ url: portfolioPublicUrl(publication.subdomain),
+ mainEntity: {
+ "@type": "Person",
+ name: project.identity.name,
+ jobTitle: project.identity.headline,
+ email: project.identity.email,
+ address: project.identity.location,
+ },
+ };
+ return (
+ <>
+
+
+ {await renderTemplate(project)}
+ >
+ );
+}
diff --git a/apps/portfolio/app/preview/[documentId]/page.tsx b/apps/portfolio/app/preview/[documentId]/page.tsx
new file mode 100644
index 0000000..078672d
--- /dev/null
+++ b/apps/portfolio/app/preview/[documentId]/page.tsx
@@ -0,0 +1,51 @@
+import { cookies } from "next/headers";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { backendApiUrl } from "@/lib/backend";
+import { parsePortfolioContent } from "@/lib/portfolio";
+import { LivePortfolioPreview } from "@/components/LivePortfolioPreview";
+
+export const metadata = {
+ title: "Private portfolio preview",
+ robots: { index: false, follow: false },
+};
+
+export default async function PreviewPage({ params }: { params: Promise<{ documentId: string }> }) {
+ const { documentId } = await params;
+ let response: Response;
+ try {
+ response = await fetch(
+ backendApiUrl(`/portfolios/preview/${encodeURIComponent(documentId)}`, true),
+ {
+ headers: { Cookie: (await cookies()).toString() },
+ cache: "no-store",
+ },
+ );
+ } catch {
+ return ;
+ }
+ if (response.status === 404) notFound();
+ if (!response.ok) return ;
+ const payload = await response.json();
+ return ;
+}
+
+function PreviewUnavailable() {
+ return (
+
+
+
Preview temporarily unavailable
+
+ We could not load this private preview. Return to the workspace and try again after the
+ connection recovers.
+
+
+ Return to workspace
+
+
+
+ );
+}
diff --git a/apps/portfolio/app/robots.ts b/apps/portfolio/app/robots.ts
new file mode 100644
index 0000000..bc1efca
--- /dev/null
+++ b/apps/portfolio/app/robots.ts
@@ -0,0 +1,9 @@
+import type { MetadataRoute } from "next";
+import { portfolioSiteConfig } from "@/config/site";
+
+export default function robots(): MetadataRoute.Robots {
+ return {
+ rules: [{ userAgent: "*", allow: "/", disallow: ["/dashboard", "/templates/*/preview"] }],
+ sitemap: `${portfolioSiteConfig.url}/sitemap.xml`,
+ };
+}
diff --git a/apps/portfolio/app/sitemap.ts b/apps/portfolio/app/sitemap.ts
new file mode 100644
index 0000000..9a08207
--- /dev/null
+++ b/apps/portfolio/app/sitemap.ts
@@ -0,0 +1,36 @@
+import type { MetadataRoute } from "next";
+import { portfolioPublicUrl, portfolioSiteConfig } from "@/config/site";
+import { backendApiUrl } from "@/lib/backend";
+
+export const revalidate = 3600;
+
+export default async function sitemap(): Promise {
+ const routes: MetadataRoute.Sitemap = [
+ {
+ url: portfolioSiteConfig.url,
+ lastModified: new Date(),
+ changeFrequency: "weekly",
+ priority: 1,
+ },
+ ];
+ try {
+ const response = await fetch(backendApiUrl("/portfolios/public", true), {
+ next: { revalidate, tags: ["portfolios-list"] },
+ });
+ if (!response.ok) return routes;
+ const payload = (await response.json()) as {
+ data?: Array<{ subdomain: string; updatedAt: string }>;
+ };
+ return [
+ ...routes,
+ ...(payload.data ?? []).map((publication) => ({
+ url: portfolioPublicUrl(publication.subdomain),
+ lastModified: new Date(publication.updatedAt),
+ changeFrequency: "weekly" as const,
+ priority: 0.8,
+ })),
+ ];
+ } catch {
+ return routes;
+ }
+}
diff --git a/apps/portfolio/app/templates/[id]/preview/page.tsx b/apps/portfolio/app/templates/[id]/preview/page.tsx
new file mode 100644
index 0000000..5a9ac0c
--- /dev/null
+++ b/apps/portfolio/app/templates/[id]/preview/page.tsx
@@ -0,0 +1,15 @@
+import { notFound } from "next/navigation";
+import type { Metadata } from "next";
+import { DraftPreview } from "@/components/DraftPreview";
+import { isTemplateId } from "@/templates/catalog/templates";
+
+export const metadata: Metadata = {
+ title: "Portfolio preview",
+ robots: { index: false, follow: false },
+};
+
+export default async function Preview({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ if (!isTemplateId(id)) notFound();
+ return ;
+}
diff --git a/apps/portfolio/app/tokens.css b/apps/portfolio/app/tokens.css
new file mode 100644
index 0000000..6d7beb7
--- /dev/null
+++ b/apps/portfolio/app/tokens.css
@@ -0,0 +1,49 @@
+:root {
+ --color-paper: oklch(97% 0.008 95);
+ --color-paper-2: oklch(94% 0.01 95);
+ --color-panel: oklch(100% 0 0);
+ --color-panel-raised: oklch(99% 0.004 95);
+ --color-ink: oklch(20% 0.012 250);
+ --color-ink-soft: oklch(31% 0.018 250);
+ --color-muted: oklch(49% 0.018 250);
+ --color-line: oklch(20% 0.012 250 / 14%);
+ --color-line-strong: oklch(20% 0.012 250 / 24%);
+ --color-accent: oklch(55% 0.2 258);
+ --color-accent-strong: oklch(46% 0.2 258);
+ --color-accent-soft: oklch(93% 0.035 258);
+ --color-accent-ink: oklch(99% 0.005 258);
+ --color-success: oklch(52% 0.15 158);
+ --color-success-soft: oklch(94% 0.04 158);
+ --color-warning: oklch(59% 0.14 68);
+ --color-warning-soft: oklch(95% 0.05 68);
+ --color-danger: oklch(54% 0.2 28);
+ --color-danger-soft: oklch(95% 0.035 28);
+ --color-panel-18: oklch(100% 0 0 / 18%);
+ --color-panel-20: oklch(100% 0 0 / 20%);
+ --color-panel-22: oklch(100% 0 0 / 22%);
+ --color-shadow: oklch(10% 0.02 250 / 18%);
+ --color-paper-glass: oklch(97% 0.008 95 / 90%);
+ --font-body: "Geist", "Inter", Arial, Helvetica, sans-serif;
+ --font-display: "Geist", "Inter", Arial, Helvetica, sans-serif;
+ --font-mono: "Geist Mono", "SFMono-Regular", Consolas, monospace;
+ --space-xs: 4px;
+ --space-sm: 8px;
+ --space-md: 16px;
+ --space-lg: 24px;
+ --space-xl: 32px;
+ --space-2xl: 48px;
+ --space-3xl: 72px;
+ --space-4xl: 112px;
+ --radius-xs: 6px;
+ --radius-sm: 10px;
+ --radius-md: 16px;
+ --radius-lg: 24px;
+ --radius-xl: 32px;
+ --rule: 1px solid var(--color-line);
+ --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
+ --ease-in: cubic-bezier(0.7, 0, 0.84, 0);
+ --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
+ --dur-fast: 160ms;
+ --dur-medium: 320ms;
+ --dur-slow: 700ms;
+}
diff --git a/apps/portfolio/components/BillingWorkspace.tsx b/apps/portfolio/components/BillingWorkspace.tsx
new file mode 100644
index 0000000..c00c4fb
--- /dev/null
+++ b/apps/portfolio/components/BillingWorkspace.tsx
@@ -0,0 +1,241 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import Link from "next/link";
+import { ArrowLeft, Check, ExternalLink, LoaderCircle, ShieldCheck, Sparkles } from "lucide-react";
+import { backendApiUrl } from "@/lib/backend";
+
+type BillingSummary = {
+ plan: string;
+ status: string;
+ interval: "MONTHLY" | "ANNUAL" | null;
+ currentPeriodEnd: string | null;
+ cancelAtPeriodEnd: boolean;
+ graceEndsAt: string | null;
+ canPublish: boolean;
+};
+
+const benefits = [
+ "One published VeriWorkly subdomain",
+ "Privacy-first portfolio view analytics",
+ "Unlimited draft edits and private previews",
+ "Both production portfolio templates",
+];
+
+export function BillingWorkspace() {
+ const [billing, setBilling] = useState(null);
+ const [loading, setLoading] = useState("");
+ const [message, setMessage] = useState("");
+ const loadBilling = useCallback(async () => {
+ setLoading("/billing/me");
+ setMessage("");
+ try {
+ const response = await fetch(backendApiUrl("/billing/me"), { credentials: "include" });
+ const payload = await response.json().catch(() => ({}));
+ if (!response.ok || !payload.data)
+ throw new Error(payload.message || "Could not load billing status.");
+ setBilling(payload.data);
+ } catch (error) {
+ setMessage(error instanceof Error ? error.message : "Could not load billing status.");
+ } finally {
+ setLoading("");
+ }
+ }, []);
+ useEffect(() => {
+ const timer = window.setTimeout(() => void loadBilling(), 0);
+ return () => window.clearTimeout(timer);
+ }, [loadBilling]);
+
+ const open = async (path: string, body?: object) => {
+ setLoading(path);
+ setMessage("");
+ try {
+ const response = await fetch(backendApiUrl(path), {
+ method: "POST",
+ credentials: "include",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body ?? {}),
+ });
+ const payload = await response.json();
+ if (!response.ok) throw new Error(payload.message || "Billing request failed.");
+ window.location.assign(payload.data.url);
+ } catch (error) {
+ setMessage(error instanceof Error ? error.message : "Billing request failed.");
+ setLoading("");
+ }
+ };
+
+ return (
+
+
+
+
+
Portfolio studio
+
+
Secure billing
+
+
+
+
+
+
+ Portfolio Pro
+
+
+ Publish the portfolio you are proud to send.
+
+
+ Drafts and private previews stay free. Upgrade only when your site is ready for the
+ world.
+
+
+
+
+
No pressure to publish early.
+
+ Build privately for as long as you need. Your draft remains editable on either plan.
+
+
+
+
+ {message ? (
+
+
{message}
+ {!billing ? (
+
void loadBilling()}>
+ Try again
+
+ ) : null}
+
+ ) : null}
+
+
+
+
+ Included with Pro
+
+
+ {benefits.map((benefit) => (
+
+
+ {benefit}
+
+ ))}
+
+ {billing ? (
+
+
+ Current access
+
+
+
+ {billing.plan === "PORTFOLIO_PRO" ? "Portfolio Pro" : "Free drafts"}{" "}
+ · {billing.status}
+
+ {billing.plan === "PORTFOLIO_PRO" ? (
+
void open("/billing/portal")}
+ >
+ Manage billing
+
+ ) : null}
+
+ {billing.currentPeriodEnd ? (
+
+ {billing.cancelAtPeriodEnd ? "Access ends" : "Renews"}{" "}
+ {new Date(billing.currentPeriodEnd).toLocaleDateString()}.
+
+ ) : null}
+ {billing.graceEndsAt ? (
+
+ Payment recovery grace period ends{" "}
+ {new Date(billing.graceEndsAt).toLocaleDateString()}.
+
+ ) : null}
+
+ ) : null}
+
+
+
void open("/billing/checkout", { interval: "monthly" })}
+ loading={loading === "/billing/checkout"}
+ disabled={loading !== ""}
+ />
+ void open("/billing/checkout", { interval: "annual" })}
+ loading={loading === "/billing/checkout"}
+ disabled={loading !== ""}
+ />
+
+
+
+
+ );
+}
+
+function Plan({
+ title,
+ price,
+ suffix,
+ copy,
+ note,
+ featured,
+ onClick,
+ loading,
+ disabled,
+}: {
+ title: string;
+ price: string;
+ suffix: string;
+ copy: string;
+ note?: string;
+ featured?: boolean;
+ onClick: () => void;
+ loading: boolean;
+ disabled: boolean;
+}) {
+ return (
+
+
+
{title}
+ {featured ? (
+
+ Best value
+
+ ) : null}
+
+
+ {price}
+ {suffix}
+
+ {copy}
+
+ {note ?? "Cancel when you need to"}
+
+
+ {loading ? : null} Choose{" "}
+ {title.toLowerCase()}
+
+
+ );
+}
diff --git a/apps/portfolio/components/DashboardWorkspace.tsx b/apps/portfolio/components/DashboardWorkspace.tsx
new file mode 100644
index 0000000..276518f
--- /dev/null
+++ b/apps/portfolio/components/DashboardWorkspace.tsx
@@ -0,0 +1,1122 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import Image from "next/image";
+import Link from "next/link";
+import {
+ ArrowDown,
+ ArrowUp,
+ ChevronDown,
+ ExternalLink,
+ Eye,
+ EyeOff,
+ Globe2,
+ LoaderCircle,
+ Palette,
+ Plus,
+ Save,
+ Share2,
+ Trash2,
+ Upload,
+ UserRound,
+ Files,
+} from "lucide-react";
+
+import { portfolioPublicUrl } from "@/config/site";
+import {
+ createId,
+ normalizeSlug,
+ portfolioSectionTypes,
+ type PortfolioContent,
+ type PortfolioSectionType,
+} from "@/lib/portfolio";
+import { useShallow } from "zustand/react/shallow";
+import { usePortfolioStore } from "@/store/portfolio-store";
+
+const sectionTypes = portfolioSectionTypes;
+const inputClass =
+ "w-full rounded-[var(--radius-sm)] border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2.5 text-sm text-[var(--color-ink)] outline-none transition duration-150 placeholder:text-[var(--color-muted)]/70 hover:border-[var(--color-line-strong)] focus:border-[var(--color-accent)] focus:ring-4 focus:ring-[var(--color-accent-soft)]";
+const buttonClass =
+ "inline-flex min-h-10 items-center justify-center gap-2 whitespace-nowrap rounded-[var(--radius-sm)] bg-[var(--color-accent)] px-3.5 text-xs font-extrabold text-[var(--color-accent-ink)] transition duration-150 hover:-translate-y-0.5 hover:bg-[var(--color-accent-strong)] disabled:cursor-not-allowed disabled:opacity-50";
+
+const sectionDetails: Record = {
+ projects: { label: "Projects", description: "Case studies and shipped work." },
+ experience: { label: "Experience", description: "Roles, companies, and outcomes." },
+ services: { label: "Services", description: "Ways clients can work with you." },
+ skills: { label: "Skills", description: "Tools and areas of expertise." },
+ education: { label: "Education", description: "Courses, degrees, and training." },
+ writing: { label: "Writing", description: "Articles, talks, or publications." },
+ testimonials: { label: "Testimonials", description: "Credible words from collaborators." },
+ awards: { label: "Awards", description: "Recognition and milestones." },
+ contact: { label: "Contact", description: "A closing invitation to reach out." },
+};
+
+export function DashboardWorkspace() {
+ const loadWorkspace = usePortfolioStore((state) => state.loadWorkspace);
+ const workspaceState = usePortfolioStore((state) => state.workspaceState);
+ const saveDraft = usePortfolioStore((state) => state.saveDraft);
+ const ready = usePortfolioStore((state) => state.ready);
+
+ // Load workspace on mount
+ useEffect(() => {
+ void loadWorkspace();
+ }, [loadWorkspace]);
+
+ // Interval-based background autosave (runs every 15 seconds, only if isDirty is true)
+ useEffect(() => {
+ const interval = setInterval(() => {
+ const state = usePortfolioStore.getState();
+ if (state.ready && state.isDirty) {
+ void saveDraft();
+ }
+ }, 15000); // 15 seconds interval
+
+ return () => clearInterval(interval);
+ }, [saveDraft]);
+
+ if (workspaceState === "loading" && !ready) {
+ return (
+
+
+
+
+ Loading your workspace...
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ );
+}
+
+function WorkspaceHeader() {
+ const status = usePortfolioStore((state) => state.status);
+ const canPublish = usePortfolioStore((state) => state.billing.canPublish);
+ const saveDraft = usePortfolioStore((state) => state.saveDraft);
+ const publish = usePortfolioStore((state) => state.publish);
+
+ return (
+
+ );
+}
+
+function WorkspaceTitleSection() {
+ const name = usePortfolioStore((state) => state.content.identity.name);
+ const analytics = usePortfolioStore((state) => state.analytics);
+ const publication = usePortfolioStore(useShallow((state) => state.publication));
+ const unpublish = usePortfolioStore((state) => state.unpublish);
+
+ return (
+
+
+
+
+ Editing portfolio
+
+
+ {name || "Untitled portfolio"}
+
+
+
+ {analytics} views
+
+
+
+ {publication?.status === "LIVE" ? (
+
+ Open live site
+
+ ) : null}
+ {publication && publication.status !== "SUSPENDED" ? (
+
void unpublish()}
+ >
+ Unpublish
+
+ ) : null}
+
+
+ );
+}
+
+function WorkspaceMessage() {
+ const message = usePortfolioStore((state) => state.message);
+ if (!message) return null;
+ return (
+
+ {message}
+
+ );
+}
+
+function EditorPanelSelector() {
+ const activePanel = usePortfolioStore((state) => state.activePanel);
+ const setActivePanel = usePortfolioStore((state) => state.setActivePanel);
+
+ return (
+
+ }
+ label="Profile"
+ onClick={() => setActivePanel("profile")}
+ />
+ }
+ label="Sections"
+ onClick={() => setActivePanel("sections")}
+ />
+ }
+ label="Style"
+ onClick={() => setActivePanel("style")}
+ />
+ }
+ label="Sharing"
+ onClick={() => setActivePanel("sharing")}
+ />
+
+ );
+}
+
+function ProUpgradeCard() {
+ const canPublish = usePortfolioStore((state) => state.billing.canPublish);
+ if (canPublish) return null;
+ return (
+
+
+ Ready to publish? Portfolio Pro unlocks your live subdomain.
+
+
+ See plans
+
+
+ );
+}
+
+function EditorPanelContainer() {
+ const activePanel = usePortfolioStore((state) => state.activePanel);
+ if (activePanel === "profile") return ;
+ if (activePanel === "sections") return ;
+ if (activePanel === "style") return ;
+ if (activePanel === "sharing") return ;
+ return null;
+}
+
+function ProfilePanel() {
+ return (
+
+
+
+ );
+}
+
+function ProfileField({
+ fieldName,
+ label,
+ hint,
+ required,
+ type = "text",
+ placeholder,
+ textarea,
+ rows,
+ maxLength,
+}: {
+ fieldName: keyof PortfolioContent["identity"];
+ label: string;
+ hint?: string;
+ required?: boolean;
+ type?: string;
+ placeholder?: string;
+ textarea?: boolean;
+ rows?: number;
+ maxLength?: number;
+}) {
+ const value = usePortfolioStore((state) => {
+ const val = state.content.identity[fieldName];
+ return typeof val === "string" ? val : "";
+ });
+ const updateIdentity = usePortfolioStore((state) => state.updateIdentity);
+
+ const lengthHint = textarea && maxLength ? `${value.length}/${maxLength} characters. ` : "";
+ const fullHint = hint ? `${lengthHint}${hint}` : lengthHint || undefined;
+
+ return (
+
+ {textarea ? (
+
+ );
+}
+
+function SubdomainField() {
+ const slug = usePortfolioStore((state) => state.slug);
+ const updateSlug = usePortfolioStore((state) => state.updateSlug);
+
+ return (
+
+ updateSlug(normalizeSlug(event.target.value))}
+ />
+
+ );
+}
+
+function AvatarField() {
+ const avatar = usePortfolioStore((state) => state.content.identity.avatar);
+ const updateIdentity = usePortfolioStore((state) => state.updateIdentity);
+
+ return (
+
+ updateIdentity({ avatar: uploadedAvatar })}
+ />
+
+ );
+}
+
+function StylePanel() {
+ const templateId = usePortfolioStore((state) => state.content.templateId);
+ const updateContent = usePortfolioStore((state) => state.updateContent);
+
+ return (
+
+
+ {(["signal", "atelier"] as const).map((id) => (
+ updateContent({ templateId: id })}
+ >
+ {id}
+
+ {id === "signal"
+ ? "Clean, technical, proof-first."
+ : "Editorial, expressive, image-led."}
+
+
+ ))}
+
+
+ );
+}
+
+function SharingPanel() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+function SEOField({
+ fieldName,
+ label,
+ hint,
+ textarea,
+ rows,
+}: {
+ fieldName: keyof PortfolioContent["seo"];
+ label: string;
+ hint?: string;
+ textarea?: boolean;
+ rows?: number;
+}) {
+ const value = usePortfolioStore((state) => {
+ const val = state.content.seo[fieldName];
+ return typeof val === "string" ? val : "";
+ });
+ const updateContent = usePortfolioStore((state) => state.updateContent);
+
+ const onChange = (val: string) => {
+ const currentSeo = usePortfolioStore.getState().content.seo;
+ updateContent({ seo: { ...currentSeo, [fieldName]: val } });
+ };
+
+ return (
+
+ {textarea ? (
+
+ );
+}
+
+function SEOSocialImageField() {
+ const socialImage = usePortfolioStore((state) => state.content.seo.socialImage);
+ const updateContent = usePortfolioStore((state) => state.updateContent);
+
+ return (
+
+ {
+ const currentSeo = usePortfolioStore.getState().content.seo;
+ updateContent({ seo: { ...currentSeo, socialImage: img } });
+ }}
+ />
+
+ );
+}
+
+function SocialLinksCard() {
+ const linkCount = usePortfolioStore((state) => state.content.socialLinks.length);
+ const updateContent = usePortfolioStore((state) => state.updateContent);
+
+ const addLink = () => {
+ const currentLinks = usePortfolioStore.getState().content.socialLinks;
+ updateContent({ socialLinks: [...currentLinks, { id: createId("link"), label: "", url: "" }] });
+ };
+
+ return (
+
+
+ {Array.from({ length: linkCount }).map((_, index) => (
+
+ ))}
+
+
+ Add social link
+
+
+
+ );
+}
+
+function SocialLinkItemRow({ index }: { index: number }) {
+ const link = usePortfolioStore(useShallow((state) => state.content.socialLinks[index]));
+ const updateContent = usePortfolioStore((state) => state.updateContent);
+
+ if (!link) return null;
+
+ const updateField = (key: "label" | "url", val: string) => {
+ const currentLinks = usePortfolioStore.getState().content.socialLinks;
+ const updated = currentLinks.map((item, i) => (i === index ? { ...item, [key]: val } : item));
+ updateContent({ socialLinks: updated });
+ };
+
+ const removeLink = () => {
+ const currentLinks = usePortfolioStore.getState().content.socialLinks;
+ updateContent({ socialLinks: currentLinks.filter((_, i) => i !== index) });
+ };
+
+ return (
+
+ updateField("label", event.target.value)}
+ />
+ updateField("url", event.target.value)}
+ />
+
+
+
+
+ );
+}
+
+function SectionsPanel() {
+ const sectionIds = usePortfolioStore(
+ useShallow((state) => state.content.sections.map((s) => s.id)),
+ );
+ const activeSectionTypes = usePortfolioStore(
+ useShallow((state) => state.content.sections.map((s) => s.type)),
+ );
+ const addSection = usePortfolioStore((state) => state.addSection);
+
+ return (
+
+
+ {sectionIds.map((id, index) => (
+
+ ))}
+
+ {sectionTypes
+ .filter((type) => !activeSectionTypes.includes(type))
+ .map((type) => (
+
addSection(type)}
+ >
+
+ {sectionDetails[type].label}
+
+ ))}
+
+
+
+ );
+}
+
+function SectionEditor({ sectionId, index }: { sectionId: string; index: number }) {
+ const sectionMeta = usePortfolioStore(
+ useShallow((state) => {
+ const s = state.content.sections.find((sec) => sec.id === sectionId);
+ if (!s) return null;
+ return {
+ title: s.title,
+ type: s.type,
+ visible: s.visible,
+ itemsCount: s.items.length,
+ };
+ }),
+ );
+ const itemIds = usePortfolioStore(
+ useShallow((state) => {
+ const s = state.content.sections.find((sec) => sec.id === sectionId);
+ return s ? s.items.map((item) => String(item.id ?? "")) : [];
+ }),
+ );
+
+ const updateSection = usePortfolioStore((state) => state.updateSection);
+ const moveSection = usePortfolioStore((state) => state.moveSection);
+ const removeSection = usePortfolioStore((state) => state.removeSection);
+
+ const [expanded, setExpanded] = useState(sectionMeta?.type === "projects");
+
+ if (!sectionMeta) return null;
+ const details = sectionDetails[sectionMeta.type];
+
+ const onAdd = () => {
+ const section = usePortfolioStore.getState().content.sections.find((s) => s.id === sectionId);
+ if (!section) return;
+ updateSection(sectionId, {
+ items: [
+ ...section.items,
+ { id: createId("item"), title: "", summary: "", year: "", tags: [] },
+ ],
+ });
+ };
+
+ return (
+
+
+
setExpanded((current) => !current)}
+ aria-expanded={expanded}
+ >
+
+
+
{sectionMeta.title}
+
+ {details.label} · {sectionMeta.itemsCount}{" "}
+ {sectionMeta.itemsCount === 1 ? "item" : "items"}
+
+
+
+
+
moveSection(index, -1)}>
+
+
+
moveSection(index, 1)}>
+
+
+
updateSection(sectionId, { visible: !sectionMeta.visible })}
+ >
+ {sectionMeta.visible ? : }
+
+
removeSection(sectionId)}>
+
+
+
+
+ {expanded ? (
+
+
+ updateSection(sectionId, { title: event.target.value })}
+ />
+
+ {sectionMeta.type !== "contact" ? (
+
+ {itemIds.map((itemId, itemIdx) => (
+
+ ))}
+
+
+ Add {sectionMeta.type === "projects" ? "project" : "item"}
+
+
+ ) : (
+
+ Your email and social links automatically appear in this closing section.
+
+ )}
+
+ ) : null}
+
+ );
+}
+
+function SectionItemEditor({ sectionId, itemIndex }: { sectionId: string; itemIndex: number }) {
+ const item = usePortfolioStore(
+ useShallow((state) => {
+ const section = state.content.sections.find((s) => s.id === sectionId);
+ return section?.items[itemIndex] as Record | undefined;
+ }),
+ );
+ const type = usePortfolioStore(
+ (state) => state.content.sections.find((s) => s.id === sectionId)?.type,
+ );
+ const updateSection = usePortfolioStore((state) => state.updateSection);
+
+ if (!item || !type) return null;
+
+ const onChange = (patch: Record) => {
+ const section = usePortfolioStore.getState().content.sections.find((s) => s.id === sectionId);
+ if (!section) return;
+ const updatedItems = section.items.map((it, idx) =>
+ idx === itemIndex ? { ...it, ...patch } : it,
+ );
+ updateSection(sectionId, { items: updatedItems });
+ };
+
+ const onRemove = () => {
+ const section = usePortfolioStore.getState().content.sections.find((s) => s.id === sectionId);
+ if (!section) return;
+ const updatedItems = section.items.filter((_, idx) => idx !== itemIndex);
+ updateSection(sectionId, { items: updatedItems });
+ };
+
+ const titleLabel =
+ type === "testimonials"
+ ? "Person or company"
+ : type === "experience"
+ ? "Role and company"
+ : type === "education"
+ ? "Course or institution"
+ : "Title";
+ const summaryLabel =
+ type === "testimonials" ? "Quote" : type === "skills" ? "Details or tools" : "Summary";
+
+ return (
+
+
+
+ {type === "projects" ? "Project" : "Item"} {itemIndex + 1}
+
+
+
+
+
+
+
+ );
+}
+
+function PreviewPane() {
+ const draftId = usePortfolioStore((state) => state.draft?.id);
+ const draftExists = usePortfolioStore((state) => !!state.draft);
+ const workspaceState = usePortfolioStore((state) => state.workspaceState);
+ const status = usePortfolioStore((state) => state.status);
+ const previewIssue = usePortfolioStore((state) => state.previewIssue);
+ const loadWorkspace = usePortfolioStore((state) => state.loadWorkspace);
+
+ const previewRef = useRef(null);
+
+ const postPreview = useCallback(() => {
+ const content = usePortfolioStore.getState().content;
+ previewRef.current?.contentWindow?.postMessage(
+ { type: "veriworkly:portfolio-preview", content },
+ window.location.origin,
+ );
+ }, []);
+
+ // Send updates to iframe preview via postMessage whenever the content slice changes
+ useEffect(() => {
+ const unsubscribe = usePortfolioStore.subscribe(
+ (state) => state.content,
+ (content) => {
+ const isReady = usePortfolioStore.getState().ready;
+ if (!isReady) return;
+ previewRef.current?.contentWindow?.postMessage(
+ { type: "veriworkly:portfolio-preview", content },
+ window.location.origin,
+ );
+ },
+ );
+ return unsubscribe;
+ }, []);
+
+ return (
+
+
+ Private live preview
+ {draftExists && draftId ? (
+
+ Full preview
+
+ ) : workspaceState === "loading" || status === "Saving" ? (
+ Saving first draft...
+ ) : (
+ Preview paused
+ )}
+
+ {draftExists && draftId ? (
+
+ ) : workspaceState === "loading" || status === "Saving" ? (
+
+
+
+
+ Preparing your private preview...
+
+
+
+ ) : (
+ void loadWorkspace()} />
+ )}
+
+ );
+}
+
+function AssetUpload({
+ kind,
+ value,
+ onUploaded,
+}: {
+ kind: "AVATAR" | "PROJECT_COVER" | "SOCIAL_IMAGE";
+ value?: string;
+ onUploaded: (asset: { id: string; url: string }) => void;
+}) {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ const upload = async (file?: File) => {
+ if (!file) return;
+ setLoading(true);
+ setError("");
+ try {
+ const { backendApiUrl: getBackendUrl } = await import("@/lib/backend");
+ const prepared = await fetch(getBackendUrl("/portfolio-assets/upload-url"), {
+ method: "POST",
+ credentials: "include",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ kind, mimeType: file.type, sizeBytes: file.size }),
+ }).then((response) => response.json());
+ if (!prepared.data?.uploadUrl) throw new Error(prepared.message || "Upload could not start.");
+ const uploaded = await fetch(prepared.data.uploadUrl, {
+ method: "PUT",
+ headers: { "Content-Type": file.type },
+ body: file,
+ });
+ if (!uploaded.ok) throw new Error("Image upload failed.");
+ const completed = await fetch(getBackendUrl("/portfolio-assets/complete"), {
+ method: "POST",
+ credentials: "include",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ assetId: prepared.data.assetId }),
+ }).then((response) => response.json());
+ if (!completed.data) throw new Error(completed.message || "Upload could not complete.");
+ onUploaded(completed.data);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Image upload failed.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ {value ? (
+
+ ) : (
+
+ )}
+
+ {loading ? "Uploading..." : value ? "Replace image" : "Upload JPG, PNG, or WebP image"}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ void upload(event.target.files?.[0])}
+ disabled={loading}
+ />
+
+ );
+}
+
+function PreviewUnavailable({ message, onRetry }: { message: string; onRetry: () => void }) {
+ return (
+
+
+
Preview temporarily unavailable
+
+ {message ||
+ "Your changes remain saved in this browser. Reconnect to sync and restore the live preview."}
+
+
+ Try again
+
+
+
+ );
+}
+
+function EditorCard({
+ title,
+ description,
+ children,
+}: {
+ title: string;
+ description?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
{title}
+ {description ? (
+
{description}
+ ) : null}
+
+ {children}
+
+ );
+}
+
+function Field({
+ label,
+ hint,
+ wide,
+ children,
+}: {
+ label: string;
+ hint?: string;
+ wide?: boolean;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {label}
+ {children}
+ {hint ? (
+ {hint}
+ ) : null}
+
+ );
+}
+
+function IconButton({
+ title,
+ danger,
+ onClick,
+ children,
+}: {
+ title: string;
+ danger?: boolean;
+ onClick: () => void;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function PanelButton({
+ active,
+ icon,
+ label,
+ onClick,
+}: {
+ active: boolean;
+ icon: React.ReactNode;
+ label: string;
+ onClick: () => void;
+}) {
+ return (
+
+ {icon}
+ {label}
+
+ );
+}
diff --git a/apps/portfolio/components/DraftPreview.tsx b/apps/portfolio/components/DraftPreview.tsx
new file mode 100644
index 0000000..df6a58b
--- /dev/null
+++ b/apps/portfolio/components/DraftPreview.tsx
@@ -0,0 +1,14 @@
+"use client";
+
+import dynamic from "next/dynamic";
+import { demoPortfolio, type TemplateId } from "@/lib/portfolio";
+
+const templates = {
+ signal: dynamic(() => import("@/template-library/signal/SignalTemplate")),
+ atelier: dynamic(() => import("@/template-library/atelier/AtelierTemplate")),
+};
+
+export function DraftPreview({ templateId }: { templateId: TemplateId }) {
+ const Template = templates[templateId];
+ return ;
+}
diff --git a/apps/portfolio/components/LivePortfolioPreview.tsx b/apps/portfolio/components/LivePortfolioPreview.tsx
new file mode 100644
index 0000000..c0b48eb
--- /dev/null
+++ b/apps/portfolio/components/LivePortfolioPreview.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import dynamic from "next/dynamic";
+import { useEffect, useState } from "react";
+import { parsePortfolioContent, type PortfolioContent } from "@/lib/portfolio";
+
+const templates = {
+ signal: dynamic(() => import("@/template-library/signal/SignalTemplate")),
+ atelier: dynamic(() => import("@/template-library/atelier/AtelierTemplate")),
+};
+
+export function LivePortfolioPreview({ initialContent }: { initialContent: PortfolioContent }) {
+ const [content, setContent] = useState(initialContent);
+ useEffect(() => {
+ const receive = (event: MessageEvent) => {
+ if (
+ event.origin !== window.location.origin ||
+ event.data?.type !== "veriworkly:portfolio-preview"
+ )
+ return;
+ setContent((current) => parsePortfolioContent(event.data.content, current));
+ };
+ window.addEventListener("message", receive);
+ return () => window.removeEventListener("message", receive);
+ }, []);
+ const Template = templates[content.templateId];
+ return ;
+}
diff --git a/apps/portfolio/components/PortfolioSite.tsx b/apps/portfolio/components/PortfolioSite.tsx
new file mode 100644
index 0000000..7466efb
--- /dev/null
+++ b/apps/portfolio/components/PortfolioSite.tsx
@@ -0,0 +1,10 @@
+import type { PortfolioContent } from "@/lib/portfolio";
+import { renderTemplate } from "@/templates/runtime/registry";
+
+/**
+ * Compatibility wrapper for routes or integrations that render a portfolio.
+ * Template implementations live in the server-only runtime registry.
+ */
+export async function PortfolioSite({ project }: { project: PortfolioContent }) {
+ return await renderTemplate(project);
+}
diff --git a/apps/portfolio/components/PublicViewTracker.tsx b/apps/portfolio/components/PublicViewTracker.tsx
new file mode 100644
index 0000000..0191a5d
--- /dev/null
+++ b/apps/portfolio/components/PublicViewTracker.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import { useEffect } from "react";
+import { backendApiUrl } from "@/lib/backend";
+
+export function PublicViewTracker({ subdomain }: { subdomain: string }) {
+ useEffect(() => {
+ try {
+ void fetch(backendApiUrl(`/portfolios/public/${encodeURIComponent(subdomain)}/view`), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ referrer: document.referrer }),
+ keepalive: true,
+ }).catch(() => undefined);
+ } catch {
+ // Analytics must never interrupt the published portfolio.
+ }
+ }, [subdomain]);
+ return null;
+}
diff --git a/apps/portfolio/components/TemplateFrame.tsx b/apps/portfolio/components/TemplateFrame.tsx
new file mode 100644
index 0000000..ec0999c
--- /dev/null
+++ b/apps/portfolio/components/TemplateFrame.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { useState } from "react";
+import { templates, type TemplateId } from "@/lib/portfolio";
+
+export function TemplateFrame() {
+ const [templateId, setTemplateId] = useState("signal");
+ return (
+
+
+
+
+ Two distinct directions
+
+
+ Choose a voice, not a skin.
+
+
+
+ {templates.map((template) => (
+ setTemplateId(template.id)}
+ >
+ {template.name}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/portfolio/config/site.ts b/apps/portfolio/config/site.ts
new file mode 100644
index 0000000..4f68d6c
--- /dev/null
+++ b/apps/portfolio/config/site.ts
@@ -0,0 +1,21 @@
+const isDev = process.env.NODE_ENV === "development";
+
+export const portfolioSiteConfig = {
+ name: "VeriWorkly Portfolio",
+ url:
+ process.env.SITE_URL ||
+ (isDev ? "http://portfolio.localhost:3004" : "https://portfolio.veriworkly.com"),
+ description:
+ "Build and publish a professional portfolio website on your own VeriWorkly subdomain.",
+ keywords: [
+ "portfolio builder",
+ "professional portfolio website",
+ "developer portfolio",
+ "designer portfolio",
+ "online portfolio builder",
+ ],
+} as const;
+
+export function portfolioPublicUrl(subdomain: string) {
+ return isDev ? `http://${subdomain}.localhost:3004` : `https://${subdomain}.veriworkly.com`;
+}
diff --git a/apps/portfolio/lib/backend.ts b/apps/portfolio/lib/backend.ts
new file mode 100644
index 0000000..e2f4988
--- /dev/null
+++ b/apps/portfolio/lib/backend.ts
@@ -0,0 +1,8 @@
+const publicBackendUrl = process.env.NEXT_PUBLIC_BACKEND_URL?.replace(/\/+$/, "") || "";
+const internalBackendUrl = process.env.BACKEND_INTERNAL_URL?.replace(/\/+$/, "") || "";
+
+export function backendApiUrl(path: string, serverSide = false) {
+ const baseUrl = serverSide ? internalBackendUrl || publicBackendUrl : publicBackendUrl;
+ if (!baseUrl) throw new Error("Portfolio backend URL is not configured.");
+ return `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
+}
diff --git a/apps/portfolio/lib/portfolio-storage.ts b/apps/portfolio/lib/portfolio-storage.ts
new file mode 100644
index 0000000..2362892
--- /dev/null
+++ b/apps/portfolio/lib/portfolio-storage.ts
@@ -0,0 +1,30 @@
+"use client";
+
+import {
+ PORTFOLIO_CACHE_KEY,
+ parsePortfolioContent,
+ type CloudPortfolioDraft,
+ type PortfolioContent,
+} from "@/lib/portfolio";
+
+export function loadPortfolioCache(): { slug: string; content: PortfolioContent } | null {
+ try {
+ const raw = window.localStorage.getItem(PORTFOLIO_CACHE_KEY);
+ if (!raw) return null;
+ const value = JSON.parse(raw) as { slug?: unknown; content?: unknown };
+ return {
+ slug: typeof value.slug === "string" ? value.slug : "portfolio",
+ content: parsePortfolioContent(value.content),
+ };
+ } catch {
+ window.localStorage.removeItem(PORTFOLIO_CACHE_KEY);
+ return null;
+ }
+}
+
+export function savePortfolioCache(draft: Pick) {
+ window.localStorage.setItem(
+ PORTFOLIO_CACHE_KEY,
+ JSON.stringify({ slug: draft.slug, content: draft.content }),
+ );
+}
diff --git a/apps/portfolio/lib/portfolio.ts b/apps/portfolio/lib/portfolio.ts
new file mode 100644
index 0000000..5d96dd8
--- /dev/null
+++ b/apps/portfolio/lib/portfolio.ts
@@ -0,0 +1,380 @@
+import type { TemplateId } from "@/templates/catalog/templates";
+
+export { templates } from "@/templates/catalog/templates";
+export type { TemplateId } from "@/templates/catalog/templates";
+
+export type PortfolioSectionType =
+ | "projects"
+ | "experience"
+ | "services"
+ | "skills"
+ | "education"
+ | "writing"
+ | "testimonials"
+ | "awards"
+ | "contact";
+
+export const portfolioSectionTypes: PortfolioSectionType[] = [
+ "projects",
+ "experience",
+ "services",
+ "skills",
+ "education",
+ "writing",
+ "testimonials",
+ "awards",
+ "contact",
+];
+
+export interface PortfolioAssetReference {
+ id: string;
+ url: string;
+}
+
+export interface PortfolioLink {
+ id: string;
+ label: string;
+ url: string;
+}
+
+export interface PortfolioSection {
+ id: string;
+ type: PortfolioSectionType;
+ title: string;
+ visible: boolean;
+ items: Array>;
+}
+
+export interface PortfolioContent {
+ schemaVersion: 1;
+ templateId: TemplateId;
+ identity: {
+ name: string;
+ headline: string;
+ bio: string;
+ location: string;
+ email: string;
+ availability: string;
+ avatar: PortfolioAssetReference | null;
+ };
+ seo: {
+ title: string;
+ description: string;
+ socialImage: PortfolioAssetReference | null;
+ };
+ socialLinks: PortfolioLink[];
+ sections: PortfolioSection[];
+}
+
+export interface CloudPortfolioDraft {
+ id: string;
+ slug: string;
+ templateId: TemplateId;
+ content: PortfolioContent;
+ revision: number;
+ updatedAt: string;
+}
+
+export const PORTFOLIO_CACHE_KEY = "veriworkly:portfolio:draft-cache:v4";
+
+export function createId(prefix: string) {
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
+}
+
+export function createDefaultPortfolio(user?: {
+ name?: string | null;
+ email?: string | null;
+}): PortfolioContent {
+ const name = user?.name?.trim() || "VeriWorkly User";
+ const email = user?.email?.trim() || "hello@veriworkly.com";
+ return {
+ schemaVersion: 1,
+ templateId: "signal",
+ identity: {
+ name,
+ email,
+ headline: "Professional building useful, considered work.",
+ bio: "Introduce your work, your point of view, and the problems you solve.",
+ location: "",
+ availability: "Available for selected opportunities",
+ avatar: null,
+ },
+ seo: { title: `${name} | Portfolio`, description: "Professional portfolio", socialImage: null },
+ socialLinks: [],
+ sections: [
+ {
+ id: createId("section"),
+ type: "projects",
+ title: "Selected work",
+ visible: true,
+ items: [
+ {
+ id: createId("project"),
+ title: "Your strongest project",
+ summary: "Explain the problem, the work, and the result with specific details.",
+ description: "",
+ year: new Date().getFullYear().toString(),
+ tags: ["Case study"],
+ links: [],
+ coverImage: null,
+ },
+ ],
+ },
+ { id: createId("section"), type: "contact", title: "Contact", visible: true, items: [] },
+ ],
+ };
+}
+
+export const demoPortfolio: PortfolioContent = {
+ schemaVersion: 1,
+ templateId: "signal",
+ identity: {
+ name: "Avery Morgan",
+ email: "hello@averymorgan.dev",
+ headline: "Product engineer turning complex systems into calm software.",
+ bio: "I design and build digital products for teams working through operational complexity. My practice combines product thinking, interface design, and frontend engineering.",
+ location: "Brooklyn, New York",
+ availability: "Available for selected collaborations",
+ avatar: null,
+ },
+ seo: {
+ title: "Avery Morgan | Product engineer",
+ description: "Selected product engineering work by Avery Morgan.",
+ socialImage: null,
+ },
+ socialLinks: [
+ { id: "linkedin", label: "LinkedIn", url: "https://www.linkedin.com" },
+ { id: "github", label: "GitHub", url: "https://github.com" },
+ { id: "writing", label: "Writing", url: "https://example.com" },
+ ],
+ sections: [
+ {
+ id: "projects",
+ type: "projects",
+ title: "Selected work",
+ visible: true,
+ items: [
+ {
+ id: "project-1",
+ title: "Field Notes",
+ summary:
+ "A planning workspace that helps distributed product teams turn research into clear delivery decisions.",
+ year: "2026",
+ tags: ["Product design", "Frontend", "Systems"],
+ },
+ {
+ id: "project-2",
+ title: "Northline",
+ summary:
+ "A service operations platform redesigned around the daily decisions of dispatch teams and field managers.",
+ year: "2025",
+ tags: ["UX strategy", "Design system"],
+ },
+ {
+ id: "project-3",
+ title: "Ledger",
+ summary:
+ "A focused financial reporting experience for independent studios that need clarity without accounting overhead.",
+ year: "2024",
+ tags: ["Research", "React", "Data"],
+ },
+ ],
+ },
+ {
+ id: "experience",
+ type: "experience",
+ title: "Experience",
+ visible: true,
+ items: [
+ {
+ id: "experience-1",
+ title: "Independent product engineer",
+ summary:
+ "Partnering with early-stage teams on product strategy, interface systems, and production frontend builds.",
+ year: "2023 — now",
+ },
+ {
+ id: "experience-2",
+ title: "Senior product designer · Northstar",
+ summary:
+ "Led the design system and core workflow redesign for a multi-product operations platform.",
+ year: "2020 — 2023",
+ },
+ ],
+ },
+ {
+ id: "services",
+ type: "services",
+ title: "Ways to work together",
+ visible: true,
+ items: [
+ {
+ id: "service-1",
+ title: "Product direction",
+ summary:
+ "Clarify the problem, shape the experience, and define the smallest useful release.",
+ },
+ {
+ id: "service-2",
+ title: "Interface systems",
+ summary: "Build a reusable visual language that remains coherent as the product grows.",
+ },
+ ],
+ },
+ {
+ id: "skills",
+ type: "skills",
+ title: "Capabilities",
+ visible: true,
+ items: [
+ {
+ id: "skill-1",
+ title: "Design and research",
+ summary:
+ "Product strategy, interface design, prototyping, usability testing, design systems.",
+ },
+ {
+ id: "skill-2",
+ title: "Engineering",
+ summary:
+ "React, Next.js, TypeScript, accessible component systems, frontend architecture.",
+ },
+ ],
+ },
+ {
+ id: "writing",
+ type: "writing",
+ title: "Writing and notes",
+ visible: true,
+ items: [
+ {
+ id: "writing-1",
+ title: "Designing for the decision",
+ summary:
+ "Why the best workflow interfaces reduce interpretation before they reduce clicks.",
+ year: "2026",
+ },
+ {
+ id: "writing-2",
+ title: "A smaller, stronger design system",
+ summary: "How product teams can choose consistency without flattening every interaction.",
+ year: "2025",
+ },
+ ],
+ },
+ {
+ id: "testimonials",
+ type: "testimonials",
+ title: "Kind words",
+ visible: true,
+ items: [
+ {
+ id: "testimonial-1",
+ title: "Mina Patel · Northline",
+ summary:
+ "Avery made a complicated product feel obvious without making it simplistic. The work changed how our team makes decisions.",
+ },
+ {
+ id: "testimonial-2",
+ title: "Jon Bell · Field Notes",
+ summary:
+ "The rare partner who can move from product framing to a production interface without losing the thread.",
+ },
+ ],
+ },
+ {
+ id: "awards",
+ type: "awards",
+ title: "Recognition",
+ visible: true,
+ items: [
+ {
+ id: "award-1",
+ title: "Independent Site of the Day",
+ summary: "Field Notes product story.",
+ year: "2026",
+ },
+ ],
+ },
+ { id: "contact", type: "contact", title: "Contact", visible: true, items: [] },
+ ],
+};
+
+function text(value: unknown, fallback = "", max = 2000) {
+ return typeof value === "string" ? value.slice(0, max) : fallback;
+}
+
+function asset(value: unknown): PortfolioAssetReference | null {
+ if (!value || typeof value !== "object") return null;
+ const item = value as Record;
+ return typeof item.id === "string" && typeof item.url === "string"
+ ? { id: item.id, url: item.url }
+ : null;
+}
+
+export function normalizeSlug(value: string) {
+ return value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9-]+/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/^-|-$/g, "")
+ .slice(0, 63);
+}
+
+export function isPortfolioSectionType(value: unknown): value is PortfolioSectionType {
+ return typeof value === "string" && portfolioSectionTypes.includes(value as PortfolioSectionType);
+}
+
+export function parsePortfolioContent(input: unknown, fallback = demoPortfolio): PortfolioContent {
+ if (!input || typeof input !== "object") return fallback;
+ const value = input as Record;
+ if (value.schemaVersion !== 1 || !value.identity || !Array.isArray(value.sections)) {
+ const legacy = value as Record;
+ const migrated = createDefaultPortfolio({
+ name: text(legacy.name, fallback.identity.name, 120),
+ email: text(legacy.email, fallback.identity.email, 254),
+ });
+ migrated.templateId = legacy.templateId === "atelier" ? "atelier" : "signal";
+ migrated.identity.headline = text(legacy.role, migrated.identity.headline, 240);
+ migrated.identity.bio = text(legacy.intro, migrated.identity.bio, 1600);
+ migrated.identity.location = text(legacy.location, "", 120);
+ migrated.identity.availability = text(legacy.availability, migrated.identity.availability, 160);
+ if (Array.isArray(legacy.projects))
+ migrated.sections[0].items = legacy.projects as Array>;
+ return migrated;
+ }
+ const identity = value.identity as Record;
+ const seo = (value.seo ?? {}) as Record;
+ return {
+ schemaVersion: 1,
+ templateId: value.templateId === "atelier" ? "atelier" : "signal",
+ identity: {
+ name: text(identity.name, fallback.identity.name, 120),
+ headline: text(identity.headline, fallback.identity.headline, 240),
+ bio: text(identity.bio, fallback.identity.bio, 1600),
+ location: text(identity.location, "", 120),
+ email: text(identity.email, fallback.identity.email, 254),
+ availability: text(identity.availability, fallback.identity.availability, 160),
+ avatar: asset(identity.avatar),
+ },
+ seo: {
+ title: text(seo.title, fallback.seo.title, 120),
+ description: text(seo.description, fallback.seo.description, 300),
+ socialImage: asset(seo.socialImage),
+ },
+ socialLinks: Array.isArray(value.socialLinks)
+ ? (value.socialLinks as PortfolioLink[]).slice(0, 12)
+ : [],
+ sections: (value.sections as PortfolioSection[])
+ .slice(0, 24)
+ .filter((section) => isPortfolioSectionType(section.type))
+ .map((section) => ({
+ id: text(section.id, createId("section"), 128),
+ type: section.type,
+ title: text(section.title, "Section", 120),
+ visible: section.visible !== false,
+ items: Array.isArray(section.items) ? section.items.slice(0, 24) : [],
+ })),
+ };
+}
diff --git a/apps/portfolio/lib/published-portfolio.ts b/apps/portfolio/lib/published-portfolio.ts
new file mode 100644
index 0000000..196c49e
--- /dev/null
+++ b/apps/portfolio/lib/published-portfolio.ts
@@ -0,0 +1,28 @@
+import { cache } from "react";
+import { backendApiUrl } from "@/lib/backend";
+import { parsePortfolioContent, type PortfolioContent } from "@/lib/portfolio";
+
+export interface PublishedPortfolio {
+ subdomain: string;
+ snapshot: PortfolioContent;
+ templateId: string;
+ updatedAt: string;
+}
+
+export const getPublishedPortfolio = cache(
+ async (subdomain: string): Promise => {
+ const response = await fetch(
+ backendApiUrl(`/portfolios/public/${encodeURIComponent(subdomain)}`, true),
+ { next: { revalidate: 3600, tags: [`portfolio-${subdomain}`] } },
+ );
+ if (!response.ok) return null;
+ const payload = (await response.json()) as { data?: Partial };
+ if (!payload.data?.snapshot || !payload.data.subdomain) return null;
+ return {
+ subdomain: payload.data.subdomain,
+ snapshot: parsePortfolioContent(payload.data.snapshot),
+ templateId: String(payload.data.templateId ?? ""),
+ updatedAt: String(payload.data.updatedAt ?? ""),
+ };
+ },
+);
diff --git a/apps/portfolio/next.config.ts b/apps/portfolio/next.config.ts
new file mode 100644
index 0000000..320d5fa
--- /dev/null
+++ b/apps/portfolio/next.config.ts
@@ -0,0 +1,32 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ reactStrictMode: true,
+ async headers() {
+ return [
+ {
+ source: "/:path*",
+ headers: [
+ {
+ key: "X-Frame-Options",
+ value: "SAMEORIGIN",
+ },
+ {
+ key: "X-Content-Type-Options",
+ value: "nosniff",
+ },
+ {
+ key: "Referrer-Policy",
+ value: "strict-origin-when-cross-origin",
+ },
+ {
+ key: "Permissions-Policy",
+ value: "camera=(), microphone=(), geolocation=()",
+ },
+ ],
+ },
+ ];
+ },
+};
+
+export default nextConfig;
diff --git a/apps/portfolio/package.json b/apps/portfolio/package.json
new file mode 100644
index 0000000..0a3a0fc
--- /dev/null
+++ b/apps/portfolio/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@veriworkly/portfolio",
+ "version": "3.12.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "eslint .",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "lucide-react": "^1.16.0",
+ "next": "16.2.6",
+ "react": "^19.2.5",
+ "react-dom": "^19.2.5",
+ "zustand": "^5.0.14"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4",
+ "@types/node": "^25",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "eslint": "^9",
+ "eslint-config-next": "16.2.6",
+ "postcss": "8.5.14",
+ "tailwindcss": "^4",
+ "typescript": "^6",
+ "vitest": "^4.1.5"
+ }
+}
diff --git a/apps/portfolio/postcss.config.mjs b/apps/portfolio/postcss.config.mjs
new file mode 100644
index 0000000..a898ee3
--- /dev/null
+++ b/apps/portfolio/postcss.config.mjs
@@ -0,0 +1,3 @@
+const config = { plugins: { "@tailwindcss/postcss": {} } };
+
+export default config;
diff --git a/apps/portfolio/proxy.ts b/apps/portfolio/proxy.ts
new file mode 100644
index 0000000..cc9d7fa
--- /dev/null
+++ b/apps/portfolio/proxy.ts
@@ -0,0 +1,42 @@
+import { NextResponse, type NextRequest } from "next/server";
+
+const PLATFORM_HOST = "portfolio.veriworkly.com";
+const protectedPaths = ["/dashboard", "/billing", "/preview"];
+
+export default function proxy(request: NextRequest) {
+ const hostname = (request.headers.get("host") ?? "").split(":")[0];
+ const path = request.nextUrl.pathname;
+ if (protectedPaths.some((prefix) => path === prefix || path.startsWith(`${prefix}/`))) {
+ const hasSession = request.cookies
+ .getAll()
+ .some((cookie) => cookie.name.startsWith("veriworkly-auth"));
+ if (!hasSession) {
+ const loginUrl =
+ process.env.NODE_ENV === "development"
+ ? "http://localhost:3001/login"
+ : "https://app.veriworkly.com/login";
+ return NextResponse.redirect(`${loginUrl}?callbackURL=${encodeURIComponent(request.url)}`);
+ }
+ }
+ if (path.startsWith("/_next") || path.startsWith("/api") || path.includes(".")) {
+ return NextResponse.next();
+ }
+ const isPlatformHost =
+ hostname === PLATFORM_HOST || hostname === "localhost" || hostname === "portfolio.localhost";
+ if (isPlatformHost) {
+ const match = path.match(/^\/user\/([^/]+)(.*)$/);
+ return match
+ ? NextResponse.rewrite(new URL(`/portfolios/${match[1]}${match[2]}`, request.url))
+ : NextResponse.next();
+ }
+ const username = hostname.endsWith(".veriworkly.com")
+ ? hostname.replace(".veriworkly.com", "")
+ : hostname.endsWith(".localhost")
+ ? hostname.replace(".localhost", "")
+ : null;
+ return username
+ ? NextResponse.rewrite(new URL(`/portfolios/${username}${path}`, request.url))
+ : NextResponse.next();
+}
+
+export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };
diff --git a/apps/portfolio/public/veriworkly-logo.png b/apps/portfolio/public/veriworkly-logo.png
new file mode 100644
index 0000000..424a50f
Binary files /dev/null and b/apps/portfolio/public/veriworkly-logo.png differ
diff --git a/apps/portfolio/store/portfolio-store.ts b/apps/portfolio/store/portfolio-store.ts
new file mode 100644
index 0000000..10c1e0c
--- /dev/null
+++ b/apps/portfolio/store/portfolio-store.ts
@@ -0,0 +1,337 @@
+"use client";
+
+import { create } from "zustand";
+import { subscribeWithSelector } from "zustand/middleware";
+import { backendApiUrl } from "@/lib/backend";
+import {
+ createDefaultPortfolio,
+ createId,
+ normalizeSlug,
+ parsePortfolioContent,
+ type CloudPortfolioDraft,
+ type PortfolioContent,
+ type PortfolioSection,
+ type PortfolioSectionType,
+} from "@/lib/portfolio";
+import { loadPortfolioCache, savePortfolioCache } from "@/lib/portfolio-storage";
+
+export type SaveStatus =
+ | "Saving"
+ | "Saved"
+ | "Offline"
+ | "Conflict"
+ | "Publish pending"
+ | "Unsaved changes";
+export type WorkspaceState = "loading" | "ready" | "error";
+export type Publication = { subdomain: string; status: "LIVE" | "GRACE" | "SUSPENDED" } | null;
+export type Billing = { canPublish: boolean; status: string; graceEndsAt?: string | null };
+export type EditorPanel = "profile" | "sections" | "style" | "sharing";
+
+async function fetchPayload(path: string, fallbackMessage: string, init?: RequestInit) {
+ const response = await fetch(backendApiUrl(path), { credentials: "include", ...init });
+ const payload = await response.json().catch(() => ({}));
+ if (!response.ok) throw new Error(payload.message || fallbackMessage);
+ return payload;
+}
+
+interface PortfolioStoreState {
+ content: PortfolioContent;
+ slug: string;
+ draft: CloudPortfolioDraft | null;
+ publication: Publication;
+ billing: Billing;
+ analytics: number;
+ status: SaveStatus;
+ message: string;
+ ready: boolean;
+ workspaceState: WorkspaceState;
+ previewIssue: string;
+ activePanel: EditorPanel;
+ isDirty: boolean;
+
+ // Setters
+ setContent: (content: PortfolioContent) => void;
+ setSlug: (slug: string) => void;
+ updateSlug: (slug: string) => void;
+ setDraft: (draft: CloudPortfolioDraft | null) => void;
+ setPublication: (publication: Publication) => void;
+ setBilling: (billing: Billing) => void;
+ setAnalytics: (analytics: number) => void;
+ setStatus: (status: SaveStatus) => void;
+ setMessage: (message: string) => void;
+ setReady: (ready: boolean) => void;
+ setWorkspaceState: (workspaceState: WorkspaceState) => void;
+ setPreviewIssue: (previewIssue: string) => void;
+ setActivePanel: (activePanel: EditorPanel) => void;
+ setIsDirty: (isDirty: boolean) => void;
+
+ // State Updaters
+ updateContent: (patch: Partial) => void;
+ updateIdentity: (patch: Partial) => void;
+ updateSection: (id: string, patch: Partial) => void;
+ moveSection: (index: number, direction: -1 | 1) => void;
+ addSection: (type: PortfolioSectionType) => void;
+ removeSection: (id: string) => void;
+
+ // Async Actions
+ loadWorkspace: () => Promise;
+ saveDraft: () => Promise;
+ publish: () => Promise;
+ unpublish: () => Promise;
+}
+
+export const usePortfolioStore = create()(
+ subscribeWithSelector((set, get) => ({
+ content: createDefaultPortfolio(),
+ slug: "portfolio",
+ draft: null,
+ publication: null,
+ billing: { canPublish: false, status: "INACTIVE" },
+ analytics: 0,
+ status: "Saved",
+ message: "",
+ ready: false,
+ workspaceState: "loading",
+ previewIssue: "",
+ activePanel: "profile",
+ isDirty: false,
+
+ setContent: (content) => set({ content }),
+ setSlug: (slug) => set({ slug }),
+ updateSlug: (slug) =>
+ set((state) => ({
+ slug,
+ isDirty: state.ready ? true : state.isDirty,
+ status: state.ready ? "Unsaved changes" : state.status,
+ })),
+ setDraft: (draft) => set({ draft }),
+ setPublication: (publication) => set({ publication }),
+ setBilling: (billing) => set({ billing }),
+ setAnalytics: (analytics) => set({ analytics }),
+ setStatus: (status) => set({ status }),
+ setMessage: (message) => set({ message }),
+ setReady: (ready) => set({ ready }),
+ setWorkspaceState: (workspaceState) => set({ workspaceState }),
+ setPreviewIssue: (previewIssue) => set({ previewIssue }),
+ setActivePanel: (activePanel) => set({ activePanel }),
+ setIsDirty: (isDirty) => set({ isDirty }),
+
+ updateContent: (patch) =>
+ set((state) => ({
+ content: { ...state.content, ...patch },
+ isDirty: state.ready ? true : state.isDirty,
+ status: state.ready ? "Unsaved changes" : state.status,
+ })),
+
+ updateIdentity: (patch) =>
+ set((state) => ({
+ content: {
+ ...state.content,
+ identity: { ...state.content.identity, ...patch },
+ },
+ isDirty: state.ready ? true : state.isDirty,
+ status: state.ready ? "Unsaved changes" : state.status,
+ })),
+
+ updateSection: (id, patch) =>
+ set((state) => ({
+ content: {
+ ...state.content,
+ sections: state.content.sections.map((section) =>
+ section.id === id ? { ...section, ...patch } : section,
+ ),
+ },
+ isDirty: state.ready ? true : state.isDirty,
+ status: state.ready ? "Unsaved changes" : state.status,
+ })),
+
+ moveSection: (index, direction) =>
+ set((state) => {
+ const sections = [...state.content.sections];
+ const target = index + direction;
+ if (target < 0 || target >= sections.length) return {};
+ return {
+ content: { ...state.content, sections },
+ isDirty: state.ready ? true : state.isDirty,
+ status: state.ready ? "Unsaved changes" : state.status,
+ };
+ }),
+
+ addSection: (type) =>
+ set((state) => {
+ const label = type[0].toUpperCase() + type.slice(1);
+ return {
+ content: {
+ ...state.content,
+ sections: [
+ ...state.content.sections,
+ { id: createId("section"), type, title: label, visible: true, items: [] },
+ ],
+ },
+ isDirty: state.ready ? true : state.isDirty,
+ status: state.ready ? "Unsaved changes" : state.status,
+ };
+ }),
+
+ removeSection: (id) =>
+ set((state) => ({
+ content: {
+ ...state.content,
+ sections: state.content.sections.filter((item) => item.id !== id),
+ },
+ isDirty: state.ready ? true : state.isDirty,
+ status: state.ready ? "Unsaved changes" : state.status,
+ })),
+
+ loadWorkspace: async () => {
+ const cached = loadPortfolioCache();
+ if (cached) {
+ set({ content: cached.content, slug: cached.slug });
+ }
+ set({ workspaceState: "loading", previewIssue: "" });
+ try {
+ const [userPayload, portfolioPayload, analyticsPayload] = await Promise.all([
+ fetchPayload("/users/me", "Could not load your account.").catch(() => null),
+ fetchPayload("/portfolios/me", "Could not load your portfolio workspace."),
+ fetchPayload("/portfolios/analytics", "Could not load portfolio analytics.").catch(
+ () => null,
+ ),
+ ]);
+ const cloud = portfolioPayload?.data?.draft;
+ if (cloud) {
+ const restored = {
+ ...cloud,
+ content: parsePortfolioContent(cloud.content),
+ } as CloudPortfolioDraft;
+ set({
+ draft: restored,
+ content: restored.content,
+ slug: restored.slug,
+ });
+ savePortfolioCache(restored);
+ } else if (!cached) {
+ const seeded = createDefaultPortfolio(userPayload?.data);
+ set({
+ content: seeded,
+ slug: normalizeSlug(userPayload?.data?.name || "portfolio") || "portfolio",
+ });
+ }
+ set({
+ publication: portfolioPayload?.data?.publication ?? null,
+ billing: portfolioPayload?.data?.billing ?? { canPublish: false, status: "INACTIVE" },
+ analytics: analyticsPayload?.data?.totalViews ?? 0,
+ message: "",
+ workspaceState: "ready",
+ ready: true,
+ });
+ } catch (error) {
+ set({
+ status: "Offline",
+ workspaceState: "error",
+ previewIssue: "Live preview is unavailable until the workspace reconnects.",
+ message:
+ error instanceof Error ? error.message : "Could not load your portfolio workspace.",
+ ready: true,
+ });
+ }
+ },
+
+ saveDraft: async () => {
+ const current = get();
+ set({ status: "Saving" });
+ savePortfolioCache({ slug: current.slug, content: current.content });
+ try {
+ const response = await fetch(backendApiUrl("/portfolios/draft"), {
+ method: "PUT",
+ credentials: "include",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ documentId: current.draft?.id,
+ subdomain: current.slug,
+ revision: current.draft?.revision,
+ snapshot: current.content,
+ }),
+ });
+ const payload = await response.json().catch(() => ({}));
+ if (response.status === 409) {
+ set({
+ status: "Conflict",
+ message: "This draft changed in another session. Refresh before continuing.",
+ });
+ return null;
+ }
+ if (!response.ok) throw new Error(payload.message || "Draft sync failed.");
+ const saved = {
+ ...payload.data,
+ content: parsePortfolioContent(payload.data.content),
+ } as CloudPortfolioDraft;
+ set({
+ draft: saved,
+ status: "Saved",
+ isDirty: false,
+ message: "",
+ previewIssue: "",
+ });
+ return saved;
+ } catch (error) {
+ set({
+ status: "Offline",
+ previewIssue: "Live preview is unavailable while your draft cannot sync.",
+ message:
+ error instanceof Error
+ ? `${error.message} Your changes remain in this browser.`
+ : "Draft sync failed. Your changes remain in this browser.",
+ });
+ return null;
+ }
+ },
+
+ publish: async () => {
+ const current = get();
+ set({ status: "Publish pending" });
+ const saved = await current.saveDraft();
+ if (!saved) return;
+ try {
+ const response = await fetch(backendApiUrl("/portfolios/publish"), {
+ method: "POST",
+ credentials: "include",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ documentId: saved.id,
+ subdomain: saved.slug,
+ revision: saved.revision,
+ }),
+ });
+ const payload = await response.json().catch(() => ({}));
+ if (!response.ok) throw new Error(payload.message || "Unable to publish.");
+ set({
+ publication: { subdomain: payload.data.subdomain, status: "LIVE" },
+ status: "Saved",
+ message: `Published at ${payload.data.publicUrl}`,
+ });
+ } catch (error) {
+ set({
+ status: "Saved",
+ message: error instanceof Error ? error.message : "Unable to publish.",
+ });
+ }
+ },
+
+ unpublish: async () => {
+ try {
+ await fetchPayload("/portfolios/unpublish", "Unable to unpublish your portfolio.", {
+ method: "POST",
+ });
+ const pub = get().publication;
+ set({
+ publication: pub ? { ...pub, status: "SUSPENDED" } : null,
+ message: "Public site unpublished. Your draft is retained.",
+ });
+ } catch (error) {
+ set({
+ message: error instanceof Error ? error.message : "Unable to unpublish your portfolio.",
+ });
+ }
+ },
+ })),
+);
diff --git a/apps/portfolio/template-library b/apps/portfolio/template-library
new file mode 160000
index 0000000..9530d5a
--- /dev/null
+++ b/apps/portfolio/template-library
@@ -0,0 +1 @@
+Subproject commit 9530d5abd38de7c53a9634805f386cb70cf26824
diff --git a/apps/portfolio/templates/catalog/templates.ts b/apps/portfolio/templates/catalog/templates.ts
new file mode 100644
index 0000000..4a52fd2
--- /dev/null
+++ b/apps/portfolio/templates/catalog/templates.ts
@@ -0,0 +1,29 @@
+export const templateIds = ["signal", "atelier"] as const;
+
+export type TemplateId = (typeof templateIds)[number];
+
+export interface TemplateSummary {
+ id: TemplateId;
+ name: string;
+ note: string;
+ mood: string;
+}
+
+export const templates: TemplateSummary[] = [
+ {
+ id: "signal",
+ name: "Signal",
+ note: "A precise, proof-first profile for product engineers.",
+ mood: "Structured / technical",
+ },
+ {
+ id: "atelier",
+ name: "Atelier",
+ note: "An editorial canvas for designers and independent builders.",
+ mood: "Expressive / editorial",
+ },
+];
+
+export function isTemplateId(value: string): value is TemplateId {
+ return templateIds.includes(value as TemplateId);
+}
diff --git a/apps/portfolio/templates/runtime/registry.tsx b/apps/portfolio/templates/runtime/registry.tsx
new file mode 100644
index 0000000..fe08d4d
--- /dev/null
+++ b/apps/portfolio/templates/runtime/registry.tsx
@@ -0,0 +1,11 @@
+import "server-only";
+
+import { notFound } from "next/navigation";
+import type { PortfolioContent } from "@/lib/portfolio";
+import { hasPrivateTemplate, templateLoaders } from "@/template-library/registry";
+
+export async function renderTemplate(project: PortfolioContent) {
+ if (!hasPrivateTemplate(project.templateId)) notFound();
+ const { default: Template } = await templateLoaders[project.templateId]();
+ return ;
+}
diff --git a/apps/portfolio/tests/portfolio-contract.test.tsx b/apps/portfolio/tests/portfolio-contract.test.tsx
new file mode 100644
index 0000000..09cd128
--- /dev/null
+++ b/apps/portfolio/tests/portfolio-contract.test.tsx
@@ -0,0 +1,151 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+
+import AtelierTemplate from "@/template-library/atelier/AtelierTemplate";
+import SignalTemplate from "@/template-library/signal/SignalTemplate";
+import { createDefaultPortfolio, demoPortfolio, parsePortfolioContent } from "@/lib/portfolio";
+
+describe("portfolio content contract", () => {
+ it("seeds signed-in identity without placeholder names", () => {
+ const content = createDefaultPortfolio({ name: "Gautam Raj", email: "gautam@veriworkly.com" });
+ expect(content.identity.name).toBe("Gautam Raj");
+ expect(content.identity.email).toBe("gautam@veriworkly.com");
+ });
+
+ it("migrates legacy portfolio snapshots", () => {
+ const content = parsePortfolioContent({
+ name: "Legacy User",
+ email: "legacy@example.com",
+ role: "Engineer",
+ intro: "Builds products.",
+ templateId: "atelier",
+ projects: [{ name: "Old project", summary: "Still useful", tags: [], year: "2025" }],
+ });
+ expect(content.templateId).toBe("atelier");
+ expect(content.identity.headline).toBe("Engineer");
+ expect(content.sections[0].items).toHaveLength(1);
+ });
+
+ it("omits hidden projects and renders empty project sections safely", () => {
+ const hidden = createDefaultPortfolio();
+ hidden.sections[0].visible = false;
+ expect(renderToStaticMarkup( )).not.toContain(
+ "Your strongest project",
+ );
+
+ const empty = createDefaultPortfolio();
+ empty.sections[0].items = [];
+ expect(() => renderToStaticMarkup( )).not.toThrow();
+ });
+
+ it("renders visible supporting sections in both launch templates", () => {
+ const content = createDefaultPortfolio();
+ content.sections.push({
+ id: "services",
+ type: "services",
+ title: "Services",
+ visible: true,
+ items: [{ id: "strategy", title: "Product strategy", summary: "Focused product direction." }],
+ });
+ expect(renderToStaticMarkup( )).toContain(
+ "Product strategy",
+ );
+ expect(renderToStaticMarkup( )).toContain(
+ "Product strategy",
+ );
+ });
+
+ it("renders sections in the order selected in the editor", () => {
+ const content = createDefaultPortfolio();
+ content.sections = [
+ { id: "services", type: "services", title: "Services first", visible: true, items: [] },
+ ...content.sections,
+ ];
+ const signal = renderToStaticMarkup( );
+ const atelier = renderToStaticMarkup( );
+ expect(signal.indexOf("Services first")).toBeLessThan(signal.indexOf("Selected work"));
+ expect(atelier.indexOf("Services first")).toBeLessThan(
+ atelier.indexOf("Your strongest project"),
+ );
+ });
+
+ it("does not render unsafe or incomplete social links", () => {
+ const content = createDefaultPortfolio();
+ content.socialLinks = [
+ { id: "unsafe", label: "Unsafe", url: "javascript:alert(1)" },
+ { id: "incomplete", label: "", url: "" },
+ { id: "safe", label: "Website", url: "https://example.com" },
+ ];
+ const markup = renderToStaticMarkup( );
+ expect(markup).not.toContain("javascript:");
+ expect(markup).not.toContain("Unsafe");
+ expect(markup).toContain("https://example.com/");
+ expect(markup).toContain("Website");
+ expect(renderToStaticMarkup( )).toContain("Website");
+ });
+
+ it("drops unsupported section types from stored snapshots", () => {
+ const content = createDefaultPortfolio();
+ const parsed = parsePortfolioContent({
+ ...content,
+ sections: [
+ ...content.sections,
+ { id: "unknown", type: "script", title: "Unsafe", visible: true, items: [] },
+ ],
+ });
+ expect(parsed.sections.some((section) => section.id === "unknown")).toBe(false);
+ });
+
+ it("ships a complete template gallery demo", () => {
+ expect(demoPortfolio.sections.map((section) => section.type)).toEqual([
+ "projects",
+ "experience",
+ "services",
+ "skills",
+ "writing",
+ "testimonials",
+ "awards",
+ "contact",
+ ]);
+ expect(
+ demoPortfolio.sections.find((section) => section.type === "projects")?.items,
+ ).toHaveLength(3);
+ expect(demoPortfolio.socialLinks).toHaveLength(3);
+ });
+
+ it("renders supporting content with section-specific compositions", () => {
+ const content = {
+ ...demoPortfolio,
+ sections: [
+ ...demoPortfolio.sections,
+ {
+ id: "education",
+ type: "education",
+ title: "Education",
+ visible: true,
+ items: [
+ { id: "course", title: "Systems course", summary: "A focused course.", year: "2024" },
+ ],
+ },
+ ],
+ };
+ const signal = renderToStaticMarkup( );
+ const atelier = renderToStaticMarkup( );
+ for (const section of [
+ "experience",
+ "services",
+ "skills",
+ "education",
+ "writing",
+ "testimonials",
+ "awards",
+ ]) {
+ expect(signal).toContain(`data-section="${section}"`);
+ expect(atelier).toContain(`data-section="${section}"`);
+ }
+ expect(signal).toContain("signal-timeline");
+ expect(signal).toContain("signal-quote-grid");
+ expect(atelier).toContain("atelier-service-list");
+ expect(atelier).toContain("atelier-testimonial-list");
+ });
+});
diff --git a/apps/portfolio/tsconfig.json b/apps/portfolio/tsconfig.json
new file mode 100644
index 0000000..705f5ce
--- /dev/null
+++ b/apps/portfolio/tsconfig.json
@@ -0,0 +1,33 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/apps/portfolio/vitest.config.ts b/apps/portfolio/vitest.config.ts
new file mode 100644
index 0000000..9e3941c
--- /dev/null
+++ b/apps/portfolio/vitest.config.ts
@@ -0,0 +1,7 @@
+import { fileURLToPath } from "node:url";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ resolve: { alias: { "@": fileURLToPath(new URL(".", import.meta.url)) } },
+ test: { environment: "node" },
+});
diff --git a/apps/server/package.json b/apps/server/package.json
index 731bdbe..ef2e052 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -1,6 +1,6 @@
{
"name": "@veriworkly/server",
- "version": "3.11.0",
+ "version": "3.12.0",
"description": "VeriWorkly Resume Backend API",
"main": "dist/index.js",
"type": "module",
diff --git a/apps/site/package.json b/apps/site/package.json
index f1d3798..0ad5447 100644
--- a/apps/site/package.json
+++ b/apps/site/package.json
@@ -1,6 +1,6 @@
{
"name": "@veriworkly/site",
- "version": "3.11.0",
+ "version": "3.12.0",
"private": true,
"scripts": {
"dev": "next dev",
diff --git a/apps/studio/components/dashboard/StudioNavigation.tsx b/apps/studio/components/dashboard/StudioNavigation.tsx
index 560ee5e..0df5cc3 100644
--- a/apps/studio/components/dashboard/StudioNavigation.tsx
+++ b/apps/studio/components/dashboard/StudioNavigation.tsx
@@ -11,6 +11,7 @@ import {
Newspaper,
FolderOpen,
HelpCircle,
+ PanelsTopLeft,
} from "lucide-react";
import Link from "next/link";
@@ -47,6 +48,14 @@ export const mainNav: StudioNavItem[] = [
external: true,
match: () => false,
},
+
+ {
+ href: siteConfig.links.portfolio,
+ label: "Portfolio",
+ icon: PanelsTopLeft,
+ external: true,
+ match: () => false,
+ },
];
export const supportNav: StudioNavItem[] = [
diff --git a/apps/studio/components/dashboard/StudioShell.tsx b/apps/studio/components/dashboard/StudioShell.tsx
index af405d8..8c9c031 100644
--- a/apps/studio/components/dashboard/StudioShell.tsx
+++ b/apps/studio/components/dashboard/StudioShell.tsx
@@ -35,7 +35,7 @@ interface StudioShellProps {
mainClassName?: string;
}
-const STUDIO_VERSION = "v3.11.0";
+const STUDIO_VERSION = "v3.12.0";
const StudioShell = ({ children, mainClassName }: StudioShellProps) => {
const router = useRouter();
diff --git a/apps/studio/config/site.ts b/apps/studio/config/site.ts
index 38490b4..1e4eee8 100644
--- a/apps/studio/config/site.ts
+++ b/apps/studio/config/site.ts
@@ -22,6 +22,9 @@ export const siteConfig = {
app: isDev ? "http://localhost:3001" : "https://app.veriworkly.com",
docs: isDev ? "http://localhost:3002" : "https://docs.veriworkly.com",
blog: isDev ? "http://localhost:3003" : "https://blog.veriworkly.com",
+ portfolio: isDev
+ ? "http://localhost:3004/dashboard"
+ : "https://portfolio.veriworkly.com/dashboard",
},
keywords: [
diff --git a/apps/studio/package.json b/apps/studio/package.json
index 1b69cfa..2dbfd64 100644
--- a/apps/studio/package.json
+++ b/apps/studio/package.json
@@ -1,6 +1,6 @@
{
"name": "@veriworkly/studio",
- "version": "3.11.0",
+ "version": "3.12.0",
"private": true,
"scripts": {
"dev": "next dev",
diff --git a/package.json b/package.json
index 720b1c6..3e342d1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "veriworkly",
- "version": "3.11.0",
+ "version": "3.12.0",
"private": true,
"repository": {
"type": "git",
diff --git a/scripts/mock-template-library.mjs b/scripts/mock-template-library.mjs
new file mode 100644
index 0000000..05490e8
--- /dev/null
+++ b/scripts/mock-template-library.mjs
@@ -0,0 +1,134 @@
+import fs from "fs";
+import path from "path";
+import { execSync } from "child_process";
+
+const projectRoot = process.cwd();
+const templateLibPath = path.join(projectRoot, "apps", "portfolio", "template-library");
+const registryFilePath = path.join(templateLibPath, "registry.ts");
+
+if (fs.existsSync(registryFilePath)) {
+ console.log("Template library already exists. Skipping mock setup.");
+ process.exit(0);
+}
+
+console.log("Template library not found. Setting up mock templates for CI build...");
+
+// Create directories
+fs.mkdirSync(templateLibPath, { recursive: true });
+fs.mkdirSync(path.join(templateLibPath, "atelier"), { recursive: true });
+fs.mkdirSync(path.join(templateLibPath, "signal"), { recursive: true });
+
+// Types
+const typesContent = `export interface PortfolioSection {
+ id: string;
+ type: string;
+ title: string;
+ visible: boolean;
+ items: Array>;
+}
+
+export interface PortfolioProject {
+ schemaVersion: 1;
+ templateId: string;
+ identity: {
+ name: string;
+ headline: string;
+ bio: string;
+ location: string;
+ email: string;
+ availability: string;
+ avatar: { id: string; url: string } | null;
+ };
+ seo: { title: string; description: string; socialImage: { id: string; url: string } | null };
+ socialLinks: Array<{ id: string; label: string; url: string }>;
+ sections: PortfolioSection[];
+}
+
+export function visibleSection(project: PortfolioProject, type: string) {
+ return project.sections.find((section) => section.type === type && section.visible);
+}
+
+export function itemText(item: Record, key: string, fallback = "") {
+ return typeof item[key] === "string" ? item[key] : fallback;
+}
+
+export function itemTags(item: Record) {
+ return Array.isArray(item.tags)
+ ? item.tags.filter((tag): tag is string => typeof tag === "string")
+ : [];
+}
+
+export function itemAssetUrl(item: Record, key: string) {
+ const value = item[key];
+ return value && typeof value === "object" && typeof (value as { url?: unknown }).url === "string"
+ ? (value as { url: string }).url
+ : "";
+}
+
+export function safeExternalUrl(value: string) {
+ try {
+ const url = new URL(value);
+ return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : "";
+ } catch {
+ return "";
+ }
+}
+`;
+
+fs.writeFileSync(path.join(templateLibPath, "types.ts"), typesContent);
+
+// Registry
+const registryContent = `import type { ComponentType } from "react";
+import type { PortfolioProject } from "./types";
+
+export type TemplateComponent = ComponentType<{ project: PortfolioProject }>;
+
+export const templateLoaders = {
+ atelier: () => import("./atelier/AtelierTemplate"),
+ signal: () => import("./signal/SignalTemplate"),
+} satisfies Record Promise<{ default: TemplateComponent }>>;
+
+export type PrivateTemplateId = keyof typeof templateLoaders;
+
+export function hasPrivateTemplate(id: string): id is PrivateTemplateId {
+ return id in templateLoaders;
+}
+`;
+
+fs.writeFileSync(registryFilePath, registryContent);
+
+// AtelierTemplate
+const atelierContent = `import React from "react";
+import type { PortfolioProject } from "../types";
+import "./styles.css";
+
+export default function AtelierTemplate({ project }: { project: PortfolioProject }) {
+ return React.createElement("div", null, "Mock Atelier Template: " + project.identity.name);
+}
+`;
+
+fs.writeFileSync(path.join(templateLibPath, "atelier", "AtelierTemplate.tsx"), atelierContent);
+fs.writeFileSync(path.join(templateLibPath, "atelier", "styles.css"), "/* Mock styles */");
+
+// SignalTemplate
+const signalContent = `import React from "react";
+import type { PortfolioProject } from "../types";
+import "./styles.css";
+
+export default function SignalTemplate({ project }: { project: PortfolioProject }) {
+ return React.createElement("div", null, "Mock Signal Template: " + project.identity.name);
+}
+`;
+
+fs.writeFileSync(path.join(templateLibPath, "signal", "SignalTemplate.tsx"), signalContent);
+fs.writeFileSync(path.join(templateLibPath, "signal", "styles.css"), "/* Mock styles */");
+
+console.log("Mock templates set up successfully.");
+
+try {
+ console.log("Formatting mocked files...");
+ execSync("npx prettier --write apps/portfolio/template-library", { stdio: "inherit" });
+ console.log("Formatting completed.");
+} catch (err) {
+ console.error("Failed to run prettier on mocked files:", err.message);
+}