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:
parent
ad74b2b55f
commit
328e27cb6d
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
experimental: {
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: "50mb",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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: [] });
|
||||||
|
}
|
||||||
|
|
@ -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 → Save As → 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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">▾</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()}
|
||||||
|
>
|
||||||
|
↗
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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">▾</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { type ClassValue } from "clsx";
|
||||||
|
|
||||||
|
export function cn(...inputs: (string | undefined | null | false)[]) {
|
||||||
|
return inputs.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
@ -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).*)"],
|
||||||
|
};
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue