Add rDesign rApp frontend with AI assistant and MCP server

- Next.js 15 frontend following rNotes/rSpace rApp pattern
- Mycelial Intelligence AI chat assistant using LiteLLM + tool calling
  - 7 MCP-style tools: templates, export, batch, rSwag, jobs, studio
  - Natural language document creation and export
- Dashboard with tabs: Templates, rSwag Designs, IDML Import, Jobs
- Interactive Scribus Studio page (embedded noVNC)
- MCP server (@rdesign/mcp-server) for Claude/CopilotKit integration
  - Tools: list_templates, export_template, batch_export, rswag, jobs
  - Resources: rdesign://templates, rdesign://health
- Shared rStack components: AppSwitcher, SpaceSwitcher, AuthButton
- EncryptID passkey authentication
- Space-aware subdomain routing via middleware

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 02:14:06 +00:00
parent ad74b2b55f
commit 328e27cb6d
25 changed files with 2421 additions and 3 deletions

View File

@ -1,4 +1,36 @@
services: services:
rdesign-frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: rdesign-frontend
restart: unless-stopped
environment:
- NEXT_PUBLIC_RDESIGN_API_URL=https://scribus.rspace.online
- NEXT_PUBLIC_STUDIO_URL=https://scribus.rspace.online/vnc/vnc.html?autoconnect=true&resize=scale
- NEXT_PUBLIC_LLM_API_URL=https://llm.jeffemmett.com
- NEXT_PUBLIC_ENCRYPTID_SERVER_URL=https://auth.ridentity.online
- ENCRYPTID_SERVER_URL=https://auth.ridentity.online
- RSPACE_API_URL=https://rspace.online/api
- HOSTNAME=0.0.0.0
labels:
- "traefik.enable=true"
# Catch-all for the frontend (lowest priority of our services)
- "traefik.http.routers.rdesign-frontend.rule=Host(`scribus.rspace.online`) && !PathPrefix(`/vnc`) && !Path(`/health`) && !PathPrefix(`/output`) && !PathPrefix(`/templates/{path:.*}`) && !PathPrefix(`/rswag`) && !PathPrefix(`/export`) && !PathPrefix(`/convert`) && !PathPrefix(`/jobs`) && !PathPrefix(`/docs`) && !PathPrefix(`/openapi`)"
- "traefik.http.routers.rdesign-frontend.entrypoints=web"
- "traefik.http.routers.rdesign-frontend.priority=190"
- "traefik.http.services.rdesign-frontend.loadbalancer.server.port=3000"
networks:
- traefik-public
deploy:
resources:
limits:
cpus: "1"
memory: 1G
reservations:
cpus: "0.25"
memory: 256M
rdesign-api: rdesign-api:
build: build:
context: . context: .
@ -10,7 +42,6 @@ services:
- ./output:/app/output - ./output:/app/output
- ./jobs:/app/jobs - ./jobs:/app/jobs
- ./scripts:/app/scripts - ./scripts:/app/scripts
# Access rSwag designs for integration
- /opt/apps/rswag/designs:/app/rswag-designs:ro - /opt/apps/rswag/designs:/app/rswag-designs:ro
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
@ -18,11 +49,12 @@ services:
- RSWAG_DESIGNS_PATH=/app/rswag-designs - RSWAG_DESIGNS_PATH=/app/rswag-designs
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.rdesign-api.rule=Host(`scribus.rspace.online`) && !PathPrefix(`/vnc`)" # API backend — serves /api/*, /health, /output/*, /templates/*, etc.
- "traefik.http.routers.rdesign-api.rule=Host(`scribus.rspace.online`) && (Path(`/health`) || PathPrefix(`/output`) || PathPrefix(`/templates`) || PathPrefix(`/rswag`) || PathPrefix(`/export`) || PathPrefix(`/convert`) || PathPrefix(`/jobs`) || PathPrefix(`/docs`) || PathPrefix(`/openapi`))"
- "traefik.http.routers.rdesign-api.entrypoints=web" - "traefik.http.routers.rdesign-api.entrypoints=web"
- "traefik.http.routers.rdesign-api.priority=200" - "traefik.http.routers.rdesign-api.priority=200"
- "traefik.http.services.rdesign-api.loadbalancer.server.port=8080" - "traefik.http.services.rdesign-api.loadbalancer.server.port=8080"
- "traefik.http.middlewares.rdesign-cors.headers.accesscontrolallowmethods=GET,POST,OPTIONS" - "traefik.http.middlewares.rdesign-cors.headers.accesscontrolallowmethods=GET,POST,OPTIONS,DELETE"
- "traefik.http.middlewares.rdesign-cors.headers.accesscontrolallowheaders=*" - "traefik.http.middlewares.rdesign-cors.headers.accesscontrolallowheaders=*"
- "traefik.http.middlewares.rdesign-cors.headers.accesscontrolalloworiginlist=https://scribus.rspace.online,https://rswag.online" - "traefik.http.middlewares.rdesign-cors.headers.accesscontrolalloworiginlist=https://scribus.rspace.online,https://rswag.online"
- "traefik.http.middlewares.rdesign-cors.headers.accesscontrolmaxage=86400" - "traefik.http.middlewares.rdesign-cors.headers.accesscontrolmaxage=86400"

36
frontend/Dockerfile Normal file
View File

@ -0,0 +1,36 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy SDK
COPY ../encryptid-sdk/ /opt/encryptid-sdk/
# Install dependencies
COPY package.json package-lock.json* ./
RUN sed -i 's|"file:../../encryptid-sdk"|"file:/opt/encryptid-sdk"|' package.json
RUN npm install --legacy-peer-deps 2>/dev/null || npm install --force
# Copy source
COPY src/ ./src/
COPY public/ ./public/
COPY next.config.ts tsconfig.json postcss.config.mjs ./
# Build
RUN npm run build
# ── Production ────────────────────────────────────────────
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
USER nextjs
EXPOSE 3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "server.js"]

12
frontend/next.config.ts Normal file
View File

@ -0,0 +1,12 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
experimental: {
serverActions: {
bodySizeLimit: "50mb",
},
},
};
export default nextConfig;

33
frontend/package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "rdesign-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "node .next/standalone/server.js"
},
"dependencies": {
"@copilotkit/react-core": "^1.10.6",
"@copilotkit/react-ui": "^1.10.6",
"@encryptid/sdk": "file:../../encryptid-sdk",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.6",
"lucide-react": "^0.468.0",
"next": "^15.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sonner": "^1.7.4",
"zustand": "^5.0.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0"
}
}

View File

@ -0,0 +1,6 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyEncryptIDToken, extractToken } from '@/lib/encryptid';
export async function GET(req: NextRequest) {
const token = extractToken(req.headers, req.cookies);
if (!token) {
return NextResponse.json({ authenticated: false });
}
const claims = await verifyEncryptIDToken(token);
if (!claims) {
return NextResponse.json({ authenticated: false });
}
return NextResponse.json({
authenticated: true,
user: {
username: claims.username || null,
did: claims.did,
},
});
}

View File

@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
const RSPACE_API = process.env.RSPACE_API_URL || 'https://rspace.online/api';
export async function GET(req: NextRequest) {
const token =
req.headers.get('Authorization')?.replace('Bearer ', '') ||
req.cookies.get('encryptid_token')?.value;
try {
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${RSPACE_API}/spaces`, {
headers,
next: { revalidate: 30 },
});
if (res.ok) {
const data = await res.json();
return NextResponse.json(data);
}
} catch {
// rSpace unavailable
}
return NextResponse.json({ spaces: [] });
}

View File

@ -0,0 +1,367 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { Header } from '@/components/Header';
import { DesignAssistant } from '@/components/DesignAssistant';
import { api, pollJob, type Template, type Job, type RswagDesign } from '@/lib/api';
import {
Plus, Upload, FileText, Download, Loader2, Check, X,
Layers, Printer, Clock, ArrowRight, FolderOpen,
} from 'lucide-react';
type TabId = 'templates' | 'rswag' | 'import' | 'jobs';
export default function DashboardPage() {
const [activeTab, setActiveTab] = useState<TabId>('templates');
const [templates, setTemplates] = useState<Template[]>([]);
const [rswagDesigns, setRswagDesigns] = useState<RswagDesign[]>([]);
const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
api.getTemplates().catch(() => []),
api.getRswagDesigns().catch(() => []),
api.getJobs(10).catch(() => []),
]).then(([tpls, designs, jbs]) => {
setTemplates(tpls);
setRswagDesigns(designs);
setJobs(jbs);
setLoading(false);
});
}, []);
return (
<div className="min-h-screen flex flex-col">
<Header breadcrumbs={[{ label: 'Dashboard' }]} />
<main className="flex-1 max-w-6xl mx-auto w-full px-6 py-6">
{/* Tabs */}
<div className="flex items-center gap-1 mb-6 border-b border-slate-800 pb-0">
{([
{ id: 'templates', label: 'Templates', icon: <FileText size={14} /> },
{ id: 'rswag', label: 'rSwag Designs', icon: <Layers size={14} /> },
{ id: 'import', label: 'Import IDML', icon: <Upload size={14} /> },
{ id: 'jobs', label: 'Jobs', icon: <Clock size={14} />, count: jobs.filter((j) => j.status === 'processing').length },
] as const).map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
activeTab === tab.id
? 'border-primary text-primary'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
{tab.icon}
{tab.label}
{'count' in tab && tab.count > 0 && (
<span className="ml-1 px-1.5 py-0.5 text-[10px] bg-primary/20 text-primary rounded-full">
{tab.count}
</span>
)}
</button>
))}
</div>
{loading ? (
<div className="text-center text-slate-400 py-20">
<Loader2 className="animate-spin mx-auto mb-2" size={24} />
Loading...
</div>
) : (
<>
{activeTab === 'templates' && (
<TemplatesTab templates={templates} onRefresh={() => api.getTemplates().then(setTemplates)} />
)}
{activeTab === 'rswag' && <RswagTab designs={rswagDesigns} />}
{activeTab === 'import' && <ImportTab onImported={() => api.getTemplates().then(setTemplates)} />}
{activeTab === 'jobs' && <JobsTab jobs={jobs} onRefresh={() => api.getJobs(20).then(setJobs)} />}
</>
)}
</main>
<DesignAssistant />
</div>
);
}
function TemplatesTab({ templates, onRefresh }: { templates: Template[]; onRefresh: () => void }) {
const [exporting, setExporting] = useState<string | null>(null);
const [uploadOpen, setUploadOpen] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const handleExport = async (slug: string) => {
setExporting(slug);
try {
const { job_id } = await api.exportTemplate(slug);
const job = await pollJob(job_id);
if (job.status === 'completed' && job.result_url) {
window.open(job.result_url, '_blank');
}
} catch {}
setExporting(null);
};
const handleUpload = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
const file = form.get('file') as File;
const name = form.get('name') as string;
const desc = form.get('description') as string;
const cat = form.get('category') as string;
if (!file || !name) return;
await api.uploadTemplate(file, name, desc, cat);
setUploadOpen(false);
onRefresh();
};
return (
<>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Scribus Templates</h2>
<div className="flex gap-2">
<button onClick={() => setUploadOpen(!uploadOpen)} className="flex items-center gap-1.5 px-3 py-1.5 bg-primary text-primary-foreground rounded-lg text-sm font-medium hover:bg-primary/90">
<Plus size={14} /> Upload Template
</button>
<Link href="/studio" className="flex items-center gap-1.5 px-3 py-1.5 border border-slate-700 rounded-lg text-sm text-slate-300 hover:bg-slate-800">
Open Studio <ArrowRight size={14} />
</Link>
</div>
</div>
{uploadOpen && (
<form onSubmit={handleUpload} className="mb-6 p-4 bg-card border border-slate-700 rounded-xl space-y-3">
<div className="grid grid-cols-2 gap-3">
<input name="name" placeholder="Template name" required className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-sm" />
<select name="category" className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-sm">
<option value="general">General</option>
<option value="flyer">Flyer</option>
<option value="poster">Poster</option>
<option value="brochure">Brochure</option>
<option value="badge">Badge</option>
<option value="certificate">Certificate</option>
</select>
</div>
<input name="description" placeholder="Description (optional)" className="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-sm" />
<div className="flex items-center gap-3">
<input ref={fileRef} type="file" name="file" accept=".sla" required className="text-sm text-slate-400" />
<button type="submit" className="px-4 py-1.5 bg-primary text-primary-foreground rounded-lg text-sm">Upload</button>
<button type="button" onClick={() => setUploadOpen(false)} className="text-sm text-slate-400">Cancel</button>
</div>
</form>
)}
{templates.length === 0 ? (
<div className="text-center py-20">
<FolderOpen size={48} className="mx-auto mb-4 text-slate-600" />
<h3 className="text-xl font-semibold mb-2">No templates yet</h3>
<p className="text-slate-400 mb-4">Upload a Scribus .sla file or import an IDML document to get started.</p>
</div>
) : (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{templates.map((t) => (
<div key={t.slug} className="template-card p-5 rounded-xl bg-card border border-slate-800">
<div className="flex items-start justify-between mb-3">
<FileText size={20} className="text-primary" />
<span className="text-[10px] text-slate-500 bg-slate-800 px-2 py-0.5 rounded-full">{t.category}</span>
</div>
<h3 className="font-semibold mb-1">{t.name}</h3>
<p className="text-sm text-slate-400 line-clamp-2 mb-3">{t.description || 'No description'}</p>
{t.variables.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{t.variables.map((v) => (
<span key={v} className="text-[10px] px-1.5 py-0.5 bg-primary/10 text-primary rounded">
%{v}%
</span>
))}
</div>
)}
<button
onClick={() => handleExport(t.slug)}
disabled={exporting === t.slug}
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg text-sm transition-colors disabled:opacity-50"
>
{exporting === t.slug ? (
<><Loader2 size={14} className="animate-spin" /> Exporting...</>
) : (
<><Download size={14} /> Export PDF</>
)}
</button>
</div>
))}
</div>
)}
</>
);
}
function RswagTab({ designs }: { designs: RswagDesign[] }) {
const [exporting, setExporting] = useState<string | null>(null);
const handleExport = async (slug: string, category: string) => {
setExporting(slug);
try {
const { job_id } = await api.exportRswagDesign(slug, category);
const job = await pollJob(job_id);
if (job.status === 'completed' && job.result_url) {
window.open(job.result_url, '_blank');
}
} catch {}
setExporting(null);
};
if (designs.length === 0) {
return (
<div className="text-center py-20">
<Layers size={48} className="mx-auto mb-4 text-slate-600" />
<h3 className="text-xl font-semibold mb-2">No rSwag designs found</h3>
<p className="text-slate-400">Create designs in rSwag first, then export them here for print production.</p>
</div>
);
}
return (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{designs.map((d) => (
<div key={d.slug} className="template-card p-5 rounded-xl bg-card border border-slate-800">
<div className="flex items-start justify-between mb-3">
<Layers size={20} className="text-accent" />
<span className="text-[10px] text-slate-500 bg-slate-800 px-2 py-0.5 rounded-full">{d.category}</span>
</div>
<h3 className="font-semibold mb-1">{d.name}</h3>
<p className="text-sm text-slate-400 line-clamp-2 mb-3">{d.description || 'rSwag design'}</p>
<button
onClick={() => handleExport(d.slug, d.category)}
disabled={exporting === d.slug}
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg text-sm transition-colors disabled:opacity-50"
>
{exporting === d.slug ? (
<><Loader2 size={14} className="animate-spin" /> Exporting...</>
) : (
<><Printer size={14} /> Print-Ready PDF</>
)}
</button>
</div>
))}
</div>
);
}
function ImportTab({ onImported }: { onImported: () => void }) {
const [uploading, setUploading] = useState(false);
const [result, setResult] = useState<Job | null>(null);
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setResult(null);
try {
const { job_id } = await api.convertIdml(file);
const job = await pollJob(job_id);
setResult(job);
if (job.status === 'completed') onImported();
} catch {}
setUploading(false);
};
return (
<div className="max-w-xl mx-auto py-8">
<div className="text-center mb-8">
<Upload size={48} className="mx-auto mb-4 text-primary" />
<h2 className="text-2xl font-bold mb-2">Import from InDesign</h2>
<p className="text-slate-400">
Upload an IDML file (InDesign Markup Language) to convert it to Scribus format and PDF.
Export from InDesign via <code className="text-primary">File &rarr; Save As &rarr; IDML</code>.
</p>
</div>
<label className={`block border-2 border-dashed rounded-xl p-12 text-center cursor-pointer transition-colors ${uploading ? 'border-primary/40 bg-primary/5' : 'border-slate-700 hover:border-primary/40 hover:bg-slate-800/50'}`}>
<input type="file" accept=".idml,.idms" onChange={handleImport} disabled={uploading} className="hidden" />
{uploading ? (
<>
<Loader2 size={32} className="mx-auto mb-3 text-primary animate-spin" />
<p className="text-sm text-slate-400">Converting... this may take a few minutes for complex documents.</p>
</>
) : (
<>
<Upload size={32} className="mx-auto mb-3 text-slate-500" />
<p className="text-sm text-slate-400">Drop an .idml file here or click to browse</p>
<p className="text-xs text-slate-500 mt-1">Native .indd files are not supported export as IDML first</p>
</>
)}
</label>
{result && (
<div className={`mt-6 p-4 rounded-xl border ${result.status === 'completed' ? 'border-success/30 bg-success/5' : 'border-danger/30 bg-danger/5'}`}>
{result.status === 'completed' ? (
<>
<div className="flex items-center gap-2 mb-2">
<Check size={16} className="text-success" />
<span className="font-medium text-success">Conversion complete</span>
</div>
{(result as Record<string, unknown>).results && (
<div className="space-y-1 text-sm">
{Object.entries((result as Record<string, unknown>).results as Record<string, string>).map(([key, url]) => (
<a key={key} href={url} target="_blank" rel="noopener noreferrer" className="block text-primary hover:underline">
{key === 'sla_url' ? 'Download .sla (Scribus)' : key === 'pdf_url' ? 'Download .pdf' : key}
</a>
))}
</div>
)}
</>
) : (
<div className="flex items-center gap-2">
<X size={16} className="text-danger" />
<span className="text-sm text-danger">{result.error || 'Conversion failed'}</span>
</div>
)}
</div>
)}
</div>
);
}
function JobsTab({ jobs, onRefresh }: { jobs: Job[]; onRefresh: () => void }) {
return (
<>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Recent Jobs</h2>
<button onClick={onRefresh} className="text-sm text-primary hover:underline">Refresh</button>
</div>
{jobs.length === 0 ? (
<div className="text-center py-20 text-slate-400">No jobs yet. Export a template or design to see jobs here.</div>
) : (
<div className="space-y-2">
{jobs.map((j) => (
<div key={j.job_id} className="flex items-center justify-between p-4 bg-card border border-slate-800 rounded-xl">
<div className="flex items-center gap-3">
<div className={`w-2.5 h-2.5 rounded-full ${
j.status === 'completed' ? 'bg-success' :
j.status === 'failed' ? 'bg-danger' :
j.status === 'processing' ? 'bg-warning pulse-dot' :
'bg-slate-500'
}`} />
<div>
<div className="text-sm font-medium">{j.job_id.slice(0, 8)}...</div>
<div className="text-xs text-slate-500">{new Date(j.created_at).toLocaleString()}</div>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-slate-400">{j.status} {j.progress > 0 && j.progress < 100 ? `(${j.progress}%)` : ''}</span>
{j.result_url && (
<a href={j.result_url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline text-sm">
<Download size={14} />
</a>
)}
</div>
</div>
))}
</div>
)}
</>
);
}

View File

@ -0,0 +1,45 @@
@import "tailwindcss";
@theme {
--color-background: oklch(0.145 0.015 285);
--color-foreground: oklch(0.95 0.01 285);
--color-card: oklch(0.18 0.015 285);
--color-card-hover: oklch(0.20 0.015 285);
--color-border: oklch(0.25 0.01 285);
--color-primary: oklch(0.72 0.12 170);
--color-primary-foreground: oklch(0.15 0.02 170);
--color-secondary: oklch(0.65 0.10 280);
--color-muted: oklch(0.40 0.01 285);
--color-accent: oklch(0.70 0.14 55);
--color-success: oklch(0.70 0.15 155);
--color-warning: oklch(0.75 0.15 85);
--color-danger: oklch(0.65 0.18 25);
--radius: 0.625rem;
}
* { border-color: var(--color-border); }
body { background: var(--color-background); color: var(--color-foreground); }
/* Chat panel */
.chat-panel { scrollbar-width: thin; scrollbar-color: oklch(0.3 0 0) transparent; }
.chat-panel::-webkit-scrollbar { width: 4px; }
.chat-panel::-webkit-scrollbar-thumb { background: oklch(0.3 0 0); border-radius: 2px; }
/* AI message markdown */
.ai-message p { margin: 0.4em 0; line-height: 1.6; }
.ai-message code { background: oklch(0.2 0.01 285); padding: 0.15em 0.4em; border-radius: 4px; font-size: 0.9em; }
.ai-message pre { background: oklch(0.16 0.01 285); padding: 1em; border-radius: 8px; overflow-x: auto; margin: 0.5em 0; }
.ai-message pre code { background: none; padding: 0; }
.ai-message ul, .ai-message ol { margin: 0.4em 0; padding-left: 1.5em; }
.ai-message li { margin: 0.2em 0; }
/* Template card hover */
.template-card { transition: all 0.2s ease; }
.template-card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px oklch(0 0 0 / 0.3); }
/* Pulse animation for job status */
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.pulse-dot { animation: pulse-dot 1.5s ease-in-out infinite; }

View File

@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
export const metadata: Metadata = {
title: "rDesign — Document Design & PDF Generation",
description: "Self-hosted Scribus-powered document design with AI assistance. Create print-ready PDFs, brochures, posters, and more.",
icons: { icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><text y='28' font-size='28'>🎨</text></svg>" },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="dark">
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{children}
</body>
</html>
);
}

103
frontend/src/app/page.tsx Normal file
View File

@ -0,0 +1,103 @@
'use client';
import Link from 'next/link';
import { Header } from '@/components/Header';
import { DesignAssistant } from '@/components/DesignAssistant';
import { FileText, Upload, Layers, Printer, Sparkles, Palette } from 'lucide-react';
export default function LandingPage() {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
{/* Hero */}
<section className="max-w-4xl mx-auto px-6 pt-20 pb-16 text-center">
<div className="inline-flex items-center gap-2 px-3 py-1 mb-6 rounded-full bg-primary/10 border border-primary/20 text-primary text-sm">
<Sparkles size={14} /> AI-Powered Document Design
</div>
<h1 className="text-4xl sm:text-5xl font-bold leading-tight mb-4">
Design documents.{' '}
<span className="bg-gradient-to-r from-primary to-emerald-300 bg-clip-text text-transparent">
Generate PDFs.
</span>
</h1>
<p className="text-lg text-slate-400 max-w-2xl mx-auto mb-8">
Self-hosted Scribus-powered document design with mycelial AI assistance.
Create print-ready brochures, posters, badges, and more or batch-generate
from templates with data-driven automation.
</p>
<div className="flex items-center justify-center gap-3 flex-wrap">
<Link
href="/dashboard"
className="px-6 py-2.5 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors"
>
Open Dashboard
</Link>
<Link
href="/studio"
className="px-6 py-2.5 border border-slate-700 rounded-lg text-slate-300 hover:bg-slate-800 transition-colors"
>
Launch Studio
</Link>
</div>
</section>
{/* Features */}
<section className="max-w-5xl mx-auto px-6 pb-20">
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{[
{
icon: <FileText size={24} />,
title: 'Template Export',
desc: 'Upload Scribus templates, fill in variables, export to print-ready PDF. Supports bleed, crop marks, and CMYK.',
},
{
icon: <Layers size={24} />,
title: 'Batch Generation',
desc: 'Mail-merge style: one template + CSV/JSON data = hundreds of personalized documents. Name badges, certificates, invoices.',
},
{
icon: <Upload size={24} />,
title: 'IDML Import',
desc: 'Convert InDesign IDML files to Scribus format. Edit in the browser-based Studio, export to PDF.',
},
{
icon: <Printer size={24} />,
title: 'Print-Ready Output',
desc: 'PDF/X-3 compliant output with ICC color management, bleed areas, and crop marks for professional printing.',
},
{
icon: <Palette size={24} />,
title: 'rSwag Integration',
desc: 'Export rSwag merchandise designs to print-ready PDFs with proper bleed and trim marks for production.',
},
{
icon: <Sparkles size={24} />,
title: 'AI Design Assistant',
desc: 'Mycelial intelligence helps you create documents, suggest templates, batch-export with natural language commands.',
},
].map((f) => (
<div key={f.title} className="template-card p-5 rounded-xl bg-card border border-slate-800 hover:border-primary/30">
<div className="text-primary mb-3">{f.icon}</div>
<h3 className="font-semibold mb-1">{f.title}</h3>
<p className="text-sm text-slate-400">{f.desc}</p>
</div>
))}
</div>
</section>
</main>
<footer className="border-t border-slate-800 py-6 text-center text-xs text-slate-500">
<a href="https://rspace.online" className="hover:text-primary transition-colors">
rSpace.online self-hosted, community-run
</a>
</footer>
<DesignAssistant />
</div>
);
}

View File

@ -0,0 +1,74 @@
'use client';
import { useState, useEffect } from 'react';
import { Header } from '@/components/Header';
import { DesignAssistant } from '@/components/DesignAssistant';
import { Maximize2, Minimize2, ExternalLink, RefreshCw } from 'lucide-react';
const STUDIO_URL = process.env.NEXT_PUBLIC_STUDIO_URL || 'https://scribus.rspace.online/vnc/vnc.html?autoconnect=true&resize=scale';
export default function StudioPage() {
const [fullscreen, setFullscreen] = useState(false);
const [iframeKey, setIframeKey] = useState(0);
return (
<div className={`flex flex-col ${fullscreen ? 'fixed inset-0 z-[100] bg-background' : 'min-h-screen'}`}>
{!fullscreen && (
<Header
breadcrumbs={[
{ label: 'Dashboard', href: '/dashboard' },
{ label: 'Studio' },
]}
actions={
<div className="flex items-center gap-2">
<button
onClick={() => setIframeKey((k) => k + 1)}
className="p-1.5 text-slate-400 hover:text-white rounded hover:bg-slate-700"
title="Reload Studio"
>
<RefreshCw size={14} />
</button>
<a
href={STUDIO_URL}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 text-slate-400 hover:text-white rounded hover:bg-slate-700"
title="Open in new tab"
>
<ExternalLink size={14} />
</a>
<button
onClick={() => setFullscreen(true)}
className="p-1.5 text-slate-400 hover:text-white rounded hover:bg-slate-700"
title="Fullscreen"
>
<Maximize2 size={14} />
</button>
</div>
}
/>
)}
<div className="flex-1 relative">
{fullscreen && (
<button
onClick={() => setFullscreen(false)}
className="absolute top-3 right-3 z-10 p-2 bg-slate-900/80 text-white rounded-lg hover:bg-slate-800"
title="Exit fullscreen"
>
<Minimize2 size={16} />
</button>
)}
<iframe
key={iframeKey}
src={STUDIO_URL}
className="w-full h-full border-0"
style={{ minHeight: fullscreen ? '100vh' : 'calc(100vh - 52px)' }}
allow="clipboard-read; clipboard-write"
/>
</div>
{!fullscreen && <DesignAssistant />}
</div>
);
}

View File

@ -0,0 +1,268 @@
'use client';
import { useState, useRef, useEffect } from 'react';
export interface AppModule {
id: string;
name: string;
badge: string; // favicon-style abbreviation: rS, rN, rP, etc.
color: string; // Tailwind bg class for the pastel badge
emoji: string; // function emoji shown right of title
description: string;
domain?: string;
}
const MODULES: AppModule[] = [
// Creating
{ id: 'space', name: 'rSpace', badge: 'rS', color: 'bg-teal-300', emoji: '🎨', description: 'Real-time collaborative canvas', domain: 'rspace.online' },
{ id: 'notes', name: 'rNotes', badge: 'rN', color: 'bg-amber-300', emoji: '📝', description: 'Group note-taking & knowledge capture', domain: 'rnotes.online' },
{ id: 'pubs', name: 'rPubs', badge: 'rP', color: 'bg-rose-300', emoji: '📖', description: 'Collaborative publishing platform', domain: 'rpubs.online' },
{ id: 'tube', name: 'rTube', badge: 'rTu', color: 'bg-pink-300', emoji: '🎬', description: 'Community video platform', domain: 'rtube.online' },
{ id: 'swag', name: 'rSwag', badge: 'rSw', color: 'bg-red-200', emoji: '👕', description: 'Community merch & swag store', domain: 'rswag.online' },
{ id: 'design', name: 'rDesign', badge: 'rDe', color: 'bg-emerald-200', emoji: '🖌️', description: 'Document design & PDF generation', domain: 'scribus.rspace.online' },
// Planning
{ id: 'cal', name: 'rCal', badge: 'rC', color: 'bg-sky-300', emoji: '📅', description: 'Collaborative scheduling & events', domain: 'rcal.online' },
{ id: 'events', name: 'rEvents', badge: 'rEv', color: 'bg-violet-200', emoji: '🎪', description: 'Event aggregation & discovery', domain: 'revents.online' },
{ id: 'trips', name: 'rTrips', badge: 'rT', color: 'bg-emerald-300', emoji: '✈️', description: 'Group travel planning in real time', domain: 'rtrips.online' },
{ id: 'maps', name: 'rMaps', badge: 'rM', color: 'bg-green-300', emoji: '🗺️', description: 'Collaborative real-time mapping', domain: 'rmaps.online' },
// Communicating
{ id: 'chats', name: 'rChats', badge: 'rCh', color: 'bg-emerald-200', emoji: '💬', description: 'Real-time encrypted messaging', domain: 'rchats.online' },
{ id: 'inbox', name: 'rInbox', badge: 'rI', color: 'bg-indigo-300', emoji: '📬', description: 'Private group messaging', domain: 'rinbox.online' },
{ id: 'mail', name: 'rMail', badge: 'rMa', color: 'bg-blue-200', emoji: '✉️', description: 'Community email & newsletters', domain: 'rmail.online' },
{ id: 'forum', name: 'rForum', badge: 'rFo', color: 'bg-amber-200', emoji: '💭', description: 'Threaded community discussions', domain: 'rforum.online' },
// Deciding
{ id: 'choices', name: 'rChoices', badge: 'rCo', color: 'bg-fuchsia-300', emoji: '⚖️', description: 'Collaborative decision making', domain: 'rchoices.online' },
{ id: 'vote', name: 'rVote', badge: 'rV', color: 'bg-violet-300', emoji: '🗳️', description: 'Real-time polls & governance', domain: 'rvote.online' },
// Funding & Commerce
{ id: 'funds', name: 'rFunds', badge: 'rF', color: 'bg-lime-300', emoji: '💸', description: 'Collaborative fundraising & grants', domain: 'rfunds.online' },
{ id: 'wallet', name: 'rWallet', badge: 'rW', color: 'bg-yellow-300', emoji: '💰', description: 'Multi-chain crypto wallet', domain: 'rwallet.online' },
{ id: 'cart', name: 'rCart', badge: 'rCt', color: 'bg-orange-300', emoji: '🛒', description: 'Group commerce & shared shopping', domain: 'rcart.online' },
{ id: 'auctions', name: 'rAuctions', badge: 'rA', color: 'bg-red-300', emoji: '🔨', description: 'Live auction platform', domain: 'rauctions.online' },
// Sharing
{ id: 'photos', name: 'rPhotos', badge: 'rPh', color: 'bg-pink-200', emoji: '📸', description: 'Community photo commons', domain: 'rphotos.online' },
{ id: 'network', name: 'rNetwork', badge: 'rNe', color: 'bg-blue-300', emoji: '🕸️', description: 'Community network & social graph', domain: 'rnetwork.online' },
{ id: 'files', name: 'rFiles', badge: 'rFi', color: 'bg-cyan-300', emoji: '📁', description: 'Collaborative file storage', domain: 'rfiles.online' },
{ id: 'socials', name: 'rSocials', badge: 'rSo', color: 'bg-sky-200', emoji: '📢', description: 'Social media management', domain: 'rsocials.online' },
// Observing
{ id: 'data', name: 'rData', badge: 'rD', color: 'bg-purple-300', emoji: '📊', description: 'Analytics & insights dashboard', domain: 'rdata.online' },
// Learning
{ id: 'books', name: 'rBooks', badge: 'rB', color: 'bg-amber-200', emoji: '📚', description: 'Collaborative library', domain: 'rbooks.online' },
// Work & Productivity
{ id: 'work', name: 'rWork', badge: 'rWo', color: 'bg-slate-300', emoji: '📋', description: 'Project & task management', domain: 'rwork.online' },
// Identity & Infrastructure
{ id: 'ids', name: 'rIDs', badge: 'rId', color: 'bg-emerald-300', emoji: '🔐', description: 'Passkey identity & zero-knowledge auth', domain: 'ridentity.online' },
{ id: 'stack', name: 'rStack', badge: 'r*', color: 'bg-gradient-to-br from-cyan-300 via-violet-300 to-rose-300', emoji: '📦', description: 'Open-source community infrastructure', domain: 'rstack.online' },
];
const MODULE_CATEGORIES: Record<string, string> = {
space: 'Creating',
notes: 'Creating',
pubs: 'Creating',
tube: 'Creating',
swag: 'Creating',
cal: 'Planning',
events: 'Planning',
trips: 'Planning',
maps: 'Planning',
chats: 'Communicating',
inbox: 'Communicating',
mail: 'Communicating',
forum: 'Communicating',
choices: 'Deciding',
vote: 'Deciding',
funds: 'Funding & Commerce',
wallet: 'Funding & Commerce',
cart: 'Funding & Commerce',
auctions: 'Funding & Commerce',
photos: 'Sharing',
network: 'Sharing',
files: 'Sharing',
socials: 'Sharing',
data: 'Observing',
books: 'Learning',
work: 'Work & Productivity',
ids: 'Identity & Infrastructure',
stack: 'Identity & Infrastructure',
};
const CATEGORY_ORDER = [
'Creating',
'Planning',
'Communicating',
'Deciding',
'Funding & Commerce',
'Sharing',
'Observing',
'Learning',
'Work & Productivity',
'Identity & Infrastructure',
];
/** Read the username from the EncryptID session in localStorage */
function getSessionUsername(): string | null {
if (typeof window === 'undefined') return null;
try {
const stored = localStorage.getItem('encryptid_session');
if (!stored) return null;
const parsed = JSON.parse(stored);
const claims = parsed?.claims || parsed;
return claims?.eid?.username || claims?.username || null;
} catch {
return null;
}
}
/** Build the URL for a module, using username subdomain if logged in */
function getModuleUrl(m: AppModule, username: string | null): string {
if (!m.domain) return '#';
if (username) {
// Generate <username>.<domain> URL
return `https://${username}.${m.domain}`;
}
return `https://${m.domain}`;
}
interface AppSwitcherProps {
current?: string;
}
export function AppSwitcher({ current = 'notes' }: AppSwitcherProps) {
const [open, setOpen] = useState(false);
const [username, setUsername] = useState<string | null>(null);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, []);
// Read username from EncryptID session in localStorage
useEffect(() => {
const sessionUsername = getSessionUsername();
if (sessionUsername) {
setUsername(sessionUsername);
} else {
// Fallback: check /api/me
fetch('/api/me')
.then((r) => r.json())
.then((data) => {
if (data.authenticated && data.user?.username) {
setUsername(data.user.username);
}
})
.catch(() => {});
}
}, []);
const currentMod = MODULES.find((m) => m.id === current);
// Group modules by category
const groups = new Map<string, AppModule[]>();
for (const m of MODULES) {
const cat = MODULE_CATEGORIES[m.id] || 'Other';
if (!groups.has(cat)) groups.set(cat, []);
groups.get(cat)!.push(m);
}
return (
<div className="relative" ref={ref}>
{/* Trigger button */}
<button
onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm font-semibold bg-white/[0.08] hover:bg-white/[0.12] text-slate-200 transition-colors"
>
{currentMod && (
<span className={`w-6 h-6 rounded-md ${currentMod.color} flex items-center justify-center text-[10px] font-black text-slate-900 leading-none flex-shrink-0`}>
{currentMod.badge}
</span>
)}
<span>{currentMod?.name || 'rStack'}</span>
<span className="text-[0.7em] opacity-60">&#9662;</span>
</button>
{/* Dropdown */}
{open && (
<div className="absolute top-full left-0 mt-1.5 w-[300px] max-h-[70vh] overflow-y-auto rounded-xl bg-slate-800 border border-white/10 shadow-xl shadow-black/30 z-[200]">
{/* rStack header */}
<div className="px-3.5 py-3 border-b border-white/[0.08] flex items-center gap-2.5">
<span className="w-7 h-7 rounded-lg bg-gradient-to-br from-cyan-300 via-violet-300 to-rose-300 flex items-center justify-center text-[11px] font-black text-slate-900 leading-none">
r*
</span>
<div>
<div className="text-sm font-bold text-white">rStack</div>
<div className="text-[10px] text-slate-400">Self-hosted community app suite</div>
</div>
</div>
{/* Categories */}
{CATEGORY_ORDER.map((cat) => {
const items = groups.get(cat);
if (!items || items.length === 0) return null;
return (
<div key={cat}>
<div className="px-3.5 pt-3 pb-1 text-[0.6rem] font-bold uppercase tracking-widest text-slate-500 select-none">
{cat}
</div>
{items.map((m) => (
<div
key={m.id}
className={`flex items-center group ${
m.id === current ? 'bg-white/[0.07]' : 'hover:bg-white/[0.04]'
} transition-colors`}
>
<a
href={getModuleUrl(m, username)}
className="flex items-center gap-2.5 flex-1 px-3.5 py-2 text-slate-200 no-underline min-w-0"
onClick={() => setOpen(false)}
>
{/* Pastel favicon badge */}
<span className={`w-7 h-7 rounded-md ${m.color} flex items-center justify-center text-[10px] font-black text-slate-900 leading-none flex-shrink-0`}>
{m.badge}
</span>
{/* Name + description */}
<div className="flex flex-col min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold">{m.name}</span>
<span className="text-sm flex-shrink-0">{m.emoji}</span>
</div>
<span className="text-[11px] text-slate-400 truncate">{m.description}</span>
</div>
</a>
{m.domain && (
<a
href={`https://${m.domain}`}
target="_blank"
rel="noopener noreferrer"
className="w-8 flex items-center justify-center text-xs text-cyan-400 opacity-0 group-hover:opacity-50 hover:!opacity-100 transition-opacity flex-shrink-0"
title={m.domain}
onClick={(e) => e.stopPropagation()}
>
&#8599;
</a>
)}
</div>
))}
</div>
);
})}
{/* Footer */}
<div className="px-3.5 py-2.5 border-t border-white/[0.08] text-center">
<a
href="https://rstack.online"
className="text-[11px] text-slate-500 hover:text-cyan-400 transition-colors no-underline"
onClick={() => setOpen(false)}
>
rstack.online self-hosted, community-run
</a>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,112 @@
'use client';
import { useState, useEffect } from 'react';
const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL || 'https://auth.ridentity.online';
interface AuthState {
authenticated: boolean;
username: string | null;
did: string | null;
}
function getStoredSession(): AuthState {
if (typeof window === 'undefined') return { authenticated: false, username: null, did: null };
try {
const stored = localStorage.getItem('encryptid_session');
if (!stored) return { authenticated: false, username: null, did: null };
const parsed = JSON.parse(stored);
const claims = parsed?.claims || parsed;
if (claims?.sub) {
return {
authenticated: true,
did: claims.sub,
username: claims.eid?.username || claims.username || null,
};
}
} catch {}
return { authenticated: false, username: null, did: null };
}
export function AuthButton() {
const [auth, setAuth] = useState<AuthState>({ authenticated: false, username: null, did: null });
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
setAuth(getStoredSession());
}, []);
const handleLogin = async () => {
setLoading(true);
setError('');
try {
// Dynamic import to avoid SSR issues with WebAuthn
const { EncryptIDClient } = await import('@encryptid/sdk/client');
const client = new EncryptIDClient(ENCRYPTID_SERVER);
const result = await client.authenticate();
// Store token as cookie for API routes
document.cookie = `encryptid_token=${result.token};path=/;max-age=900;SameSite=Lax`;
// Store session in localStorage for SpaceSwitcher/AppSwitcher
localStorage.setItem('encryptid_token', result.token);
localStorage.setItem('encryptid_session', JSON.stringify({
claims: { sub: result.did, eid: { username: result.username } }
}));
setAuth({ authenticated: true, did: result.did, username: result.username });
} catch (e: unknown) {
if (e instanceof DOMException && (e.name === 'NotAllowedError' || e.name === 'AbortError')) {
setError('Passkey not found — register first at ridentity.online');
} else {
setError(e instanceof Error ? e.message : 'Sign in failed');
}
} finally {
setLoading(false);
}
};
const handleLogout = () => {
document.cookie = 'encryptid_token=;path=/;max-age=0;SameSite=Lax';
localStorage.removeItem('encryptid_token');
localStorage.removeItem('encryptid_session');
setAuth({ authenticated: false, username: null, did: null });
};
if (auth.authenticated) {
return (
<div className="flex items-center gap-3">
<div className="text-sm">
<span className="text-white/60">Signed in as </span>
<span className="text-primary font-medium">{auth.username || auth.did?.slice(0, 12) + '...'}</span>
</div>
<button
onClick={handleLogout}
className="text-xs text-white/40 hover:text-white/60 transition-colors"
>
Sign out
</button>
</div>
);
}
return (
<div className="flex items-center gap-2">
<button
onClick={handleLogin}
disabled={loading}
className="text-sm text-white/60 hover:text-primary transition-colors flex items-center gap-1.5"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="w-4 h-4">
<circle cx={12} cy={10} r={3} />
<path d="M12 13v8" />
<path d="M9 18h6" />
<circle cx={12} cy={10} r={7} />
</svg>
{loading ? 'Signing in...' : 'Sign in'}
</button>
{error && <span className="text-xs text-red-400 max-w-[200px] truncate">{error}</span>}
</div>
);
}

View File

@ -0,0 +1,436 @@
'use client';
import { useState, useRef, useEffect, useCallback } from 'react';
import { Send, Bot, User, Loader2, Sparkles, X, Paperclip } from 'lucide-react';
const LLM_API = process.env.NEXT_PUBLIC_LLM_API_URL || 'https://llm.jeffemmett.com';
const RDESIGN_API = process.env.NEXT_PUBLIC_RDESIGN_API_URL || 'https://scribus.rspace.online';
interface Message {
id: string;
role: 'user' | 'assistant' | 'system' | 'tool';
content: string;
timestamp: Date;
toolCalls?: ToolCall[];
toolResults?: ToolResult[];
}
interface ToolCall {
id: string;
name: string;
arguments: Record<string, unknown>;
}
interface ToolResult {
toolCallId: string;
name: string;
result: unknown;
}
// MCP-style tools for the design assistant
const DESIGN_TOOLS = [
{
type: 'function' as const,
function: {
name: 'list_templates',
description: 'List available Scribus document templates. Returns template names, categories, and variable placeholders.',
parameters: { type: 'object', properties: { category: { type: 'string', description: 'Filter by category (flyer, poster, brochure, imported, general)' } } },
},
},
{
type: 'function' as const,
function: {
name: 'export_template',
description: 'Export a Scribus template to PDF or PNG. Supports variable substitution for dynamic content.',
parameters: {
type: 'object',
properties: {
template: { type: 'string', description: 'Template slug' },
variables: { type: 'object', description: 'Key-value pairs for template variables (e.g. {"title": "My Event", "date": "2026-04-01"})' },
format: { type: 'string', enum: ['pdf', 'png'], description: 'Output format' },
},
required: ['template'],
},
},
},
{
type: 'function' as const,
function: {
name: 'batch_export',
description: 'Generate multiple documents from one template with different data. Like mail-merge: one template + multiple rows of variables = multiple PDFs.',
parameters: {
type: 'object',
properties: {
template: { type: 'string', description: 'Template slug' },
rows: { type: 'array', items: { type: 'object' }, description: 'Array of variable objects, one per document' },
},
required: ['template', 'rows'],
},
},
},
{
type: 'function' as const,
function: {
name: 'list_rswag_designs',
description: 'List available rSwag merchandise designs (stickers, shirts, prints) that can be exported to print-ready PDFs.',
parameters: { type: 'object', properties: { category: { type: 'string', description: 'stickers, shirts, or prints' } } },
},
},
{
type: 'function' as const,
function: {
name: 'export_rswag_design',
description: 'Export an rSwag design to a print-ready PDF with professional bleed and crop marks.',
parameters: {
type: 'object',
properties: {
design_slug: { type: 'string' },
category: { type: 'string', enum: ['stickers', 'shirts', 'prints'] },
paper_size: { type: 'string', enum: ['A4', 'A3', 'A5', 'Letter'] },
},
required: ['design_slug', 'category'],
},
},
},
{
type: 'function' as const,
function: {
name: 'check_job_status',
description: 'Check the status of a running export/conversion job.',
parameters: {
type: 'object',
properties: { job_id: { type: 'string' } },
required: ['job_id'],
},
},
},
{
type: 'function' as const,
function: {
name: 'open_studio',
description: 'Open the interactive Scribus Studio (GUI in browser via noVNC). Use this when the user wants to design something visually or edit a template by hand.',
parameters: { type: 'object', properties: {} },
},
},
];
async function executeToolCall(name: string, args: Record<string, unknown>): Promise<unknown> {
switch (name) {
case 'list_templates': {
const params = args.category ? `?category=${args.category}` : '';
const res = await fetch(`${RDESIGN_API}/templates${params}`);
return res.json();
}
case 'export_template': {
const res = await fetch(`${RDESIGN_API}/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
});
return res.json();
}
case 'batch_export': {
const res = await fetch(`${RDESIGN_API}/export/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
});
return res.json();
}
case 'list_rswag_designs': {
const params = args.category ? `?category=${args.category}` : '';
const res = await fetch(`${RDESIGN_API}/rswag/designs${params}`);
return res.json();
}
case 'export_rswag_design': {
const res = await fetch(`${RDESIGN_API}/rswag/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
});
return res.json();
}
case 'check_job_status': {
const res = await fetch(`${RDESIGN_API}/jobs/${args.job_id}`);
return res.json();
}
case 'open_studio': {
window.open('/studio', '_blank');
return { status: 'opened', url: '/studio' };
}
default:
return { error: `Unknown tool: ${name}` };
}
}
const SYSTEM_PROMPT = `You are the rDesign AI assistant — a mycelial intelligence helping users create beautiful documents, export print-ready PDFs, and manage design templates.
You have access to a self-hosted Scribus instance for professional desktop publishing. You can:
- List and export document templates with variable substitution
- Batch-generate documents (mail-merge style) from templates
- Export rSwag merchandise designs to print-ready PDFs with bleed/crop marks
- Convert InDesign IDML files to Scribus format
- Open the interactive Scribus Studio for visual editing
Be helpful, creative, and concise. When users want to create something, suggest appropriate templates or offer to open the Studio. When they need automated output, use the export tools.
Like mycelium connecting a forest, you connect the design tools into a unified creative experience.`;
export function DesignAssistant() {
const [messages, setMessages] = useState<Message[]>([
{
id: 'welcome',
role: 'assistant',
content: "Welcome to rDesign. I can help you create documents, export PDFs, batch-generate from templates, or prepare rSwag designs for print. What would you like to create?",
timestamp: new Date(),
},
]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
const sendMessage = async () => {
if (!input.trim() || isLoading) return;
const userMessage: Message = {
id: `user-${Date.now()}`,
role: 'user',
content: input.trim(),
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setInput('');
setIsLoading(true);
try {
// Build conversation for LLM
const conversationMessages = [
{ role: 'system' as const, content: SYSTEM_PROMPT },
...messages.filter((m) => m.role !== 'system' && m.id !== 'welcome').map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content,
})),
{ role: 'user' as const, content: userMessage.content },
];
// Call LiteLLM with tools
let response = await fetch(`${LLM_API}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'claude-sonnet',
messages: conversationMessages,
tools: DESIGN_TOOLS,
max_tokens: 2048,
}),
});
if (!response.ok) {
throw new Error(`LLM API error: ${response.status}`);
}
let data = await response.json();
let choice = data.choices?.[0];
// Handle tool calls in a loop
const updatedMessages = [...conversationMessages];
while (choice?.message?.tool_calls?.length) {
const toolCalls: ToolCall[] = choice.message.tool_calls.map((tc: { id: string; function: { name: string; arguments: string } }) => ({
id: tc.id,
name: tc.function.name,
arguments: JSON.parse(tc.function.arguments || '{}'),
}));
// Show tool call status
const toolMessage: Message = {
id: `tool-${Date.now()}`,
role: 'assistant',
content: toolCalls.map((tc) => `Running \`${tc.name}\`...`).join('\n'),
timestamp: new Date(),
toolCalls,
};
setMessages((prev) => [...prev, toolMessage]);
// Execute tools
updatedMessages.push({
role: 'assistant' as const,
content: choice.message.content || '',
tool_calls: choice.message.tool_calls,
} as { role: 'assistant'; content: string; tool_calls?: unknown[] });
for (const tc of toolCalls) {
const result = await executeToolCall(tc.name, tc.arguments);
updatedMessages.push({
role: 'tool' as const,
content: JSON.stringify(result),
tool_call_id: tc.id,
} as { role: 'tool'; content: string; tool_call_id?: string });
}
// Get next LLM response
response = await fetch(`${LLM_API}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'claude-sonnet',
messages: updatedMessages,
tools: DESIGN_TOOLS,
max_tokens: 2048,
}),
});
if (!response.ok) throw new Error(`LLM API error: ${response.status}`);
data = await response.json();
choice = data.choices?.[0];
}
// Final assistant message
const assistantContent = choice?.message?.content || 'I encountered an issue processing that request.';
const assistantMessage: Message = {
id: `assistant-${Date.now()}`,
role: 'assistant',
content: assistantContent,
timestamp: new Date(),
};
setMessages((prev) => [...prev, assistantMessage]);
} catch (error) {
const errorMessage: Message = {
id: `error-${Date.now()}`,
role: 'assistant',
content: `Something went wrong: ${error instanceof Error ? error.message : 'Unknown error'}. Try again or check the API status.`,
timestamp: new Date(),
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-6 w-14 h-14 bg-primary text-primary-foreground rounded-full shadow-lg hover:scale-105 transition-transform flex items-center justify-center z-50"
title="Design Assistant"
>
<Sparkles size={24} />
</button>
);
}
return (
<div className="fixed bottom-6 right-6 w-[420px] h-[600px] bg-card border border-slate-700 rounded-2xl shadow-2xl flex flex-col z-50 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700 bg-card">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center">
<Sparkles size={16} className="text-primary" />
</div>
<div>
<div className="text-sm font-semibold">Design Assistant</div>
<div className="text-[10px] text-muted">Mycelial Intelligence</div>
</div>
</div>
<button onClick={() => setIsOpen(false)} className="p-1 hover:bg-slate-700 rounded">
<X size={16} className="text-slate-400" />
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3 chat-panel">
{messages.map((msg) => (
<div key={msg.id} className={`flex gap-2 ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
{msg.role !== 'user' && (
<div className="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center flex-shrink-0 mt-1">
<Bot size={12} className="text-primary" />
</div>
)}
<div
className={`max-w-[85%] rounded-xl px-3 py-2 text-sm ${
msg.role === 'user'
? 'bg-primary text-primary-foreground'
: msg.toolCalls
? 'bg-slate-800/50 text-muted text-xs font-mono'
: 'bg-slate-800 text-foreground ai-message'
}`}
>
{msg.content.split('\n').map((line, i) => (
<p key={i} className={i > 0 ? 'mt-1' : ''}>
{/* Render links as clickable */}
{line.includes('http') ? (
line.split(/(https?:\/\/\S+)/).map((part, j) =>
part.match(/^https?:\/\//) ? (
<a key={j} href={part} target="_blank" rel="noopener noreferrer" className="underline text-primary hover:text-primary/80">
{part.includes('/output/') ? 'Download' : part}
</a>
) : (
part
)
)
) : (
line
)}
</p>
))}
</div>
{msg.role === 'user' && (
<div className="w-6 h-6 rounded-full bg-slate-700 flex items-center justify-center flex-shrink-0 mt-1">
<User size={12} className="text-slate-300" />
</div>
)}
</div>
))}
{isLoading && (
<div className="flex gap-2 items-center">
<div className="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center">
<Loader2 size={12} className="text-primary animate-spin" />
</div>
<div className="text-xs text-muted">Thinking...</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="border-t border-slate-700 p-3">
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask me to create a document, export a template..."
className="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-sm resize-none focus:outline-none focus:border-primary max-h-24 min-h-[40px]"
rows={1}
/>
<button
onClick={sendMessage}
disabled={!input.trim() || isLoading}
className="p-2 bg-primary text-primary-foreground rounded-lg disabled:opacity-40 hover:bg-primary/90 transition-colors"
>
<Send size={16} />
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,49 @@
'use client';
import Link from 'next/link';
import { AppSwitcher } from '@/components/AppSwitcher';
import { SpaceSwitcher } from '@/components/SpaceSwitcher';
import { AuthButton } from '@/components/AuthButton';
interface BreadcrumbItem {
label: string;
href?: string;
}
interface HeaderProps {
breadcrumbs?: BreadcrumbItem[];
actions?: React.ReactNode;
}
export function Header({ breadcrumbs, actions }: HeaderProps) {
return (
<header className="border-b border-slate-800 sticky top-0 z-50 bg-background/90 backdrop-blur-sm">
<div className="max-w-7xl mx-auto px-4 py-2.5 flex items-center justify-between gap-3">
<div className="flex items-center gap-1 min-w-0">
<AppSwitcher current="design" />
<SpaceSwitcher />
{breadcrumbs && breadcrumbs.length > 0 && (
<div className="flex items-center gap-1 text-sm text-slate-400 ml-1 min-w-0">
{breadcrumbs.map((b, i) => (
<span key={i} className="flex items-center gap-1 min-w-0">
<span className="opacity-40">/</span>
{b.href ? (
<Link href={b.href} className="hover:text-slate-200 truncate max-w-[160px]">
{b.label}
</Link>
) : (
<span className="text-slate-300 truncate max-w-[160px]">{b.label}</span>
)}
</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-3">
{actions}
<AuthButton />
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,263 @@
'use client';
import { useState, useRef, useEffect } from 'react';
interface SpaceInfo {
slug: string;
name: string;
icon?: string;
role?: string;
}
interface SpaceSwitcherProps {
/** Current app domain, e.g. 'rcal.online'. Space links become <space>.<domain> */
domain?: string;
}
/** Read the EncryptID token from localStorage (set by token-relay across r*.online) */
function getEncryptIDToken(): string | null {
if (typeof window === 'undefined') return null;
try {
return localStorage.getItem('encryptid_token');
} catch {
return null;
}
}
/** Read the username from the EncryptID session in localStorage */
function getSessionUsername(): string | null {
if (typeof window === 'undefined') return null;
try {
const stored = localStorage.getItem('encryptid_session');
if (!stored) return null;
const parsed = JSON.parse(stored);
const claims = parsed?.claims || parsed;
return claims?.eid?.username || claims?.username || null;
} catch {
return null;
}
}
/** Read the current space_id from the cookie set by middleware */
function getCurrentSpaceId(): string {
if (typeof document === 'undefined') return 'default';
const match = document.cookie.match(/(?:^|;\s*)space_id=([^;]*)/);
return match ? decodeURIComponent(match[1]) : 'default';
}
export function SpaceSwitcher({ domain }: SpaceSwitcherProps) {
const [open, setOpen] = useState(false);
const [spaces, setSpaces] = useState<SpaceInfo[]>([]);
const [loaded, setLoaded] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [username, setUsername] = useState<string | null>(null);
const ref = useRef<HTMLDivElement>(null);
// Derive domain from window.location if not provided
const appDomain = domain || (typeof window !== 'undefined'
? window.location.hostname.split('.').slice(-2).join('.')
: 'rspace.online');
const currentSpaceId = getCurrentSpaceId();
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, []);
// Check auth status on mount
useEffect(() => {
const token = getEncryptIDToken();
const sessionUsername = getSessionUsername();
if (token) {
setIsAuthenticated(true);
if (sessionUsername) {
setUsername(sessionUsername);
}
} else {
// Fallback: check /api/me
fetch('/api/me')
.then((r) => r.json())
.then((data) => {
if (data.authenticated) {
setIsAuthenticated(true);
if (data.user?.username) setUsername(data.user.username);
}
})
.catch(() => {});
}
}, []);
const loadSpaces = async () => {
if (loaded) return;
try {
// Pass EncryptID token so the proxy can forward it to rSpace
const token = getEncryptIDToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch('/api/spaces', { headers });
if (res.ok) {
const data = await res.json();
// Handle both flat array and { spaces: [...] } response formats
const raw: Array<{ id?: string; slug?: string; name: string; icon?: string; role?: string }> =
Array.isArray(data) ? data : (data.spaces || []);
setSpaces(raw.map((s) => ({
slug: s.slug || s.id || '',
name: s.name,
icon: s.icon,
role: s.role,
})));
}
} catch {
// API not available
}
setLoaded(true);
};
const handleOpen = async () => {
const nowOpen = !open;
setOpen(nowOpen);
if (nowOpen && !loaded) {
await loadSpaces();
}
};
/** Build URL for a space: <space>.<current-app-domain> */
const spaceUrl = (slug: string) => `https://${slug}.${appDomain}`;
// Build personal space entry for logged-in user
const personalSpace: SpaceInfo | null =
isAuthenticated && username
? { slug: username, name: 'Personal', icon: '👤', role: 'owner' }
: null;
// Deduplicate: remove personal space from fetched list if it already appears
const dedupedSpaces = personalSpace
? spaces.filter((s) => s.slug !== personalSpace.slug)
: spaces;
const mySpaces = dedupedSpaces.filter((s) => s.role);
const publicSpaces = dedupedSpaces.filter((s) => !s.role);
// Determine what to show in the button
const currentLabel = currentSpaceId === 'default'
? (personalSpace ? 'personal' : 'public')
: currentSpaceId;
return (
<div className="relative" ref={ref}>
<button
onClick={(e) => { e.stopPropagation(); handleOpen(); }}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm font-medium text-slate-400 hover:bg-white/[0.05] transition-colors"
>
<span className="opacity-40 font-light mr-0.5">/</span>
<span className="max-w-[160px] truncate">{currentLabel}</span>
<span className="text-[0.7em] opacity-50">&#9662;</span>
</button>
{open && (
<div className="absolute top-full left-0 mt-1.5 min-w-[240px] max-h-[400px] overflow-y-auto rounded-xl bg-slate-800 border border-white/10 shadow-xl shadow-black/30 z-[200]">
{!loaded ? (
<div className="px-4 py-4 text-center text-sm text-slate-400">Loading spaces...</div>
) : !isAuthenticated && spaces.length === 0 ? (
<>
<div className="px-4 py-4 text-center text-sm text-slate-400">
Sign in to see your spaces
</div>
</>
) : (
<>
{/* Personal space — always first when logged in */}
{personalSpace && (
<>
<div className="px-3.5 pt-2.5 pb-1 text-[0.65rem] font-bold uppercase tracking-wider text-slate-500 select-none">
Personal
</div>
<a
href={spaceUrl(personalSpace.slug)}
className={`flex items-center gap-2.5 px-3.5 py-2.5 text-slate-200 no-underline transition-colors hover:bg-white/[0.05] ${
currentSpaceId === personalSpace.slug ? 'bg-white/[0.07]' : ''
}`}
onClick={() => setOpen(false)}
>
<span className="text-base">{personalSpace.icon}</span>
<span className="text-sm font-medium flex-1">{username}</span>
<span className="text-[0.6rem] font-bold uppercase bg-cyan-500/15 text-cyan-300 px-1.5 py-0.5 rounded tracking-wide">
owner
</span>
</a>
</>
)}
{/* Other spaces the user belongs to */}
{mySpaces.length > 0 && (
<>
{personalSpace && <div className="h-px bg-white/[0.08] my-1" />}
<div className="px-3.5 pt-2.5 pb-1 text-[0.65rem] font-bold uppercase tracking-wider text-slate-500 select-none">
Your spaces
</div>
{mySpaces.map((s) => (
<a
key={s.slug}
href={spaceUrl(s.slug)}
className={`flex items-center gap-2.5 px-3.5 py-2.5 text-slate-200 no-underline transition-colors hover:bg-white/[0.05] ${
currentSpaceId === s.slug ? 'bg-white/[0.07]' : ''
}`}
onClick={() => setOpen(false)}
>
<span className="text-base">{s.icon || '🌐'}</span>
<span className="text-sm font-medium flex-1">{s.name}</span>
{s.role && (
<span className="text-[0.6rem] font-bold uppercase bg-cyan-500/15 text-cyan-300 px-1.5 py-0.5 rounded tracking-wide">
{s.role}
</span>
)}
</a>
))}
</>
)}
{/* Public spaces */}
{publicSpaces.length > 0 && (
<>
{(personalSpace || mySpaces.length > 0) && <div className="h-px bg-white/[0.08] my-1" />}
<div className="px-3.5 pt-2.5 pb-1 text-[0.65rem] font-bold uppercase tracking-wider text-slate-500 select-none">
Public spaces
</div>
{publicSpaces.map((s) => (
<a
key={s.slug}
href={spaceUrl(s.slug)}
className={`flex items-center gap-2.5 px-3.5 py-2.5 text-slate-200 no-underline transition-colors hover:bg-white/[0.05] ${
currentSpaceId === s.slug ? 'bg-white/[0.07]' : ''
}`}
onClick={() => setOpen(false)}
>
<span className="text-base">{s.icon || '🌐'}</span>
<span className="text-sm font-medium flex-1">{s.name}</span>
</a>
))}
</>
)}
<div className="h-px bg-white/[0.08] my-1" />
<a
href="https://rspace.online/new"
className="flex items-center px-3.5 py-2.5 text-sm font-semibold text-cyan-400 hover:bg-cyan-500/[0.08] transition-colors no-underline"
onClick={() => setOpen(false)}
>
+ Create new space
</a>
</>
)}
</div>
)}
</div>
);
}

142
frontend/src/lib/api.ts Normal file
View File

@ -0,0 +1,142 @@
const API_BASE = process.env.NEXT_PUBLIC_RDESIGN_API_URL || "https://scribus.rspace.online";
export interface Template {
slug: string;
name: string;
description: string;
category: string;
variables: string[];
preview_url: string | null;
created: string | null;
}
export interface Job {
job_id: string;
status: "queued" | "processing" | "completed" | "failed";
progress: number;
result_url?: string;
results?: Array<{ row_id: string; url?: string; status: string; error?: string }>;
error?: string;
created_at: string;
completed_at?: string;
}
export interface RswagDesign {
slug: string;
name: string;
category: string;
description: string;
status: string;
}
export const api = {
async getTemplates(category?: string): Promise<Template[]> {
const params = category ? `?category=${category}` : "";
const res = await fetch(`${API_BASE}/templates${params}`);
if (!res.ok) throw new Error("Failed to fetch templates");
return res.json();
},
async getTemplate(slug: string): Promise<Template> {
const res = await fetch(`${API_BASE}/templates/${slug}`);
if (!res.ok) throw new Error("Template not found");
return res.json();
},
async uploadTemplate(file: File, name: string, description: string, category: string) {
const form = new FormData();
form.append("file", file);
const params = new URLSearchParams({ name, description, category });
const res = await fetch(`${API_BASE}/templates/upload?${params}`, {
method: "POST",
body: form,
});
if (!res.ok) throw new Error("Upload failed");
return res.json();
},
async exportTemplate(template: string, variables: Record<string, string> = {}, format = "pdf") {
const res = await fetch(`${API_BASE}/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template, variables, output_format: format }),
});
if (!res.ok) throw new Error("Export failed");
return res.json();
},
async exportBatch(template: string, rows: Record<string, string>[]) {
const res = await fetch(`${API_BASE}/export/batch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template, rows }),
});
if (!res.ok) throw new Error("Batch export failed");
return res.json();
},
async convertIdml(file: File, outputSla = true, outputPdf = true) {
const form = new FormData();
form.append("file", file);
const params = new URLSearchParams({
output_sla: String(outputSla),
output_pdf: String(outputPdf),
});
const res = await fetch(`${API_BASE}/convert/idml?${params}`, {
method: "POST",
body: form,
});
if (!res.ok) throw new Error("IDML conversion failed");
return res.json();
},
async getRswagDesigns(category?: string): Promise<RswagDesign[]> {
const params = category ? `?category=${category}` : "";
const res = await fetch(`${API_BASE}/rswag/designs${params}`);
if (!res.ok) throw new Error("Failed to fetch designs");
return res.json();
},
async exportRswagDesign(designSlug: string, category: string, paperSize = "A4") {
const res = await fetch(`${API_BASE}/rswag/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
design_slug: designSlug,
category,
paper_size: paperSize,
add_bleed: true,
add_crop_marks: true,
}),
});
if (!res.ok) throw new Error("rSwag export failed");
return res.json();
},
async getJob(jobId: string): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}`);
if (!res.ok) throw new Error("Job not found");
return res.json();
},
async getJobs(limit = 20): Promise<Job[]> {
const res = await fetch(`${API_BASE}/jobs?limit=${limit}`);
if (!res.ok) throw new Error("Failed to fetch jobs");
return res.json();
},
async getHealth() {
const res = await fetch(`${API_BASE}/health`);
return res.json();
},
};
/** Poll a job until it completes or fails */
export async function pollJob(jobId: string, onProgress?: (job: Job) => void): Promise<Job> {
while (true) {
const job = await api.getJob(jobId);
onProgress?.(job);
if (job.status === "completed" || job.status === "failed") return job;
await new Promise((r) => setTimeout(r, 1500));
}
}

View File

@ -0,0 +1,42 @@
const ENCRYPTID_URL =
process.env.ENCRYPTID_SERVER_URL || "https://auth.ridentity.online";
export interface EncryptIDClaims {
sub: string;
did: string;
username?: string;
exp?: number;
}
export async function verifyEncryptIDToken(
token: string
): Promise<EncryptIDClaims | null> {
try {
const res = await fetch(`${ENCRYPTID_URL}/api/session/verify`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return null;
const data = await res.json();
if (!data.valid) return null;
return {
sub: data.did || data.userId,
did: data.did || data.userId,
username: data.username || undefined,
};
} catch {
return null;
}
}
export function extractToken(
headers: Headers,
cookies?: { get: (name: string) => { value: string } | undefined }
): string | null {
const auth = headers.get("Authorization");
if (auth?.startsWith("Bearer ")) return auth.slice(7);
if (cookies) {
const c = cookies.get("encryptid_token");
if (c) return c.value;
}
return null;
}

View File

@ -0,0 +1,5 @@
import { type ClassValue } from "clsx";
export function cn(...inputs: (string | undefined | null | false)[]) {
return inputs.filter(Boolean).join(" ");
}

View File

@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const host = request.headers.get("host") || "";
const hostname = host.split(":")[0].toLowerCase();
let spaceId = "default";
// Extract space from subdomain: <space>.scribus.rspace.online
// or <space>.rdesign.online (future domain)
const rspaceMatch = hostname.match(/^([a-z0-9-]+)\.scribus\.rspace\.online$/);
if (rspaceMatch) {
spaceId = rspaceMatch[1];
}
// Dev: use query param
if (hostname === "localhost" || hostname === "127.0.0.1") {
const url = new URL(request.url);
const spaceParam = url.searchParams.get("_space");
if (spaceParam) spaceId = spaceParam;
}
const response = NextResponse.next();
response.cookies.set("space_id", spaceId, {
path: "/",
sameSite: "lax",
httpOnly: false,
maxAge: 86400,
});
return response;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

23
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,23 @@
{
"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": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

22
mcp/package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "@rdesign/mcp-server",
"version": "0.1.0",
"description": "MCP server exposing rDesign/Scribus tools for AI assistants",
"type": "module",
"main": "dist/server.js",
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "tsx src/server.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"express": "^4.21.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}

225
mcp/src/server.ts Normal file
View File

@ -0,0 +1,225 @@
/**
* rDesign MCP Server
*
* Exposes Scribus document design tools via Model Context Protocol.
* Can be used by Claude, CopilotKit, or any MCP-compatible client.
*
* Transports:
* - HTTP: POST /mcp for network access
* - Stdio: for local CLI integration
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
const RDESIGN_API = process.env.RDESIGN_API_URL || "https://scribus.rspace.online";
async function apiCall(path: string, options?: RequestInit) {
const res = await fetch(`${RDESIGN_API}${path}`, options);
if (!res.ok) throw new Error(`API error ${res.status}: ${await res.text()}`);
return res.json();
}
const server = new Server(
{ name: "rdesign-mcp", version: "0.1.0" },
{ capabilities: { tools: { listChanged: true }, resources: {} } }
);
// ── Tools ───────────────────────────────────────────────────────────────
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "rdesign_list_templates",
description: "List available Scribus document templates with their variables and categories",
inputSchema: {
type: "object",
properties: {
category: { type: "string", description: "Filter by category: flyer, poster, brochure, imported, general" },
},
},
},
{
name: "rdesign_export_template",
description: "Export a Scribus template to PDF with variable substitution. Returns a job ID to poll.",
inputSchema: {
type: "object",
properties: {
template: { type: "string", description: "Template slug" },
variables: { type: "object", description: "Template variables as key-value pairs" },
output_format: { type: "string", enum: ["pdf", "png"], default: "pdf" },
},
required: ["template"],
},
},
{
name: "rdesign_batch_export",
description: "Generate multiple PDFs from one template with different data rows (mail-merge)",
inputSchema: {
type: "object",
properties: {
template: { type: "string", description: "Template slug" },
rows: { type: "array", items: { type: "object" }, description: "Array of variable dicts" },
},
required: ["template", "rows"],
},
},
{
name: "rdesign_list_rswag_designs",
description: "List rSwag merchandise designs available for print-ready PDF export",
inputSchema: {
type: "object",
properties: {
category: { type: "string", enum: ["stickers", "shirts", "prints"] },
},
},
},
{
name: "rdesign_export_rswag",
description: "Export an rSwag design to print-ready PDF with bleed and crop marks",
inputSchema: {
type: "object",
properties: {
design_slug: { type: "string" },
category: { type: "string", enum: ["stickers", "shirts", "prints"] },
paper_size: { type: "string", enum: ["A4", "A3", "A5", "Letter"], default: "A4" },
},
required: ["design_slug", "category"],
},
},
{
name: "rdesign_job_status",
description: "Check the status and result of an export/conversion job",
inputSchema: {
type: "object",
properties: { job_id: { type: "string" } },
required: ["job_id"],
},
},
{
name: "rdesign_health",
description: "Check the health status of the rDesign/Scribus service",
inputSchema: { type: "object", properties: {} },
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case "rdesign_list_templates": {
const params = args?.category ? `?category=${args.category}` : "";
const result = await apiCall(`/templates${params}`);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
case "rdesign_export_template": {
const result = await apiCall("/export", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(args),
});
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
case "rdesign_batch_export": {
const result = await apiCall("/export/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(args),
});
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
case "rdesign_list_rswag_designs": {
const params = args?.category ? `?category=${args.category}` : "";
const result = await apiCall(`/rswag/designs${params}`);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
case "rdesign_export_rswag": {
const result = await apiCall("/rswag/export", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(args),
});
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
case "rdesign_job_status": {
const result = await apiCall(`/jobs/${args?.job_id}`);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
case "rdesign_health": {
const result = await apiCall("/health");
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
default:
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
}
} catch (error) {
return {
content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
isError: true,
};
}
});
// ── Resources ───────────────────────────────────────────────────────────
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: [
{
uri: "rdesign://templates",
name: "Available Templates",
description: "List of all Scribus document templates",
mimeType: "application/json",
},
{
uri: "rdesign://health",
name: "Service Health",
description: "Current health status of the rDesign/Scribus service",
mimeType: "application/json",
},
],
}));
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
switch (uri) {
case "rdesign://templates": {
const templates = await apiCall("/templates");
return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(templates, null, 2) }] };
}
case "rdesign://health": {
const health = await apiCall("/health");
return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(health, null, 2) }] };
}
default:
throw new Error(`Unknown resource: ${uri}`);
}
});
// ── Start ───────────────────────────────────────────────────────────────
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("rDesign MCP server running on stdio");
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});

14
mcp/tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src"]
}