diff --git a/docker-compose.yml b/docker-compose.yml index c3f9b646..34a97a19 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,36 @@ 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: build: context: . @@ -10,7 +42,6 @@ services: - ./output:/app/output - ./jobs:/app/jobs - ./scripts:/app/scripts - # Access rSwag designs for integration - /opt/apps/rswag/designs:/app/rswag-designs:ro environment: - PYTHONUNBUFFERED=1 @@ -18,11 +49,12 @@ services: - RSWAG_DESIGNS_PATH=/app/rswag-designs labels: - "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.priority=200" - "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.accesscontrolalloworiginlist=https://scribus.rspace.online,https://rswag.online" - "traefik.http.middlewares.rdesign-cors.headers.accesscontrolmaxage=86400" diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..4381b3f5 --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 00000000..43676880 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,12 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", + experimental: { + serverActions: { + bodySizeLimit: "50mb", + }, + }, +}; + +export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..fe5b165f --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 00000000..7059fe95 --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,6 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; +export default config; diff --git a/frontend/src/app/api/me/route.ts b/frontend/src/app/api/me/route.ts new file mode 100644 index 00000000..1dbe71ad --- /dev/null +++ b/frontend/src/app/api/me/route.ts @@ -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, + }, + }); +} diff --git a/frontend/src/app/api/spaces/route.ts b/frontend/src/app/api/spaces/route.ts new file mode 100644 index 00000000..894e6123 --- /dev/null +++ b/frontend/src/app/api/spaces/route.ts @@ -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 = {}; + 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: [] }); +} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx new file mode 100644 index 00000000..14925b0f --- /dev/null +++ b/frontend/src/app/dashboard/page.tsx @@ -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('templates'); + const [templates, setTemplates] = useState([]); + const [rswagDesigns, setRswagDesigns] = useState([]); + const [jobs, setJobs] = useState([]); + 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 ( +
+
+ +
+ {/* Tabs */} +
+ {([ + { id: 'templates', label: 'Templates', icon: }, + { id: 'rswag', label: 'rSwag Designs', icon: }, + { id: 'import', label: 'Import IDML', icon: }, + { id: 'jobs', label: 'Jobs', icon: , count: jobs.filter((j) => j.status === 'processing').length }, + ] as const).map((tab) => ( + + ))} +
+ + {loading ? ( +
+ + Loading... +
+ ) : ( + <> + {activeTab === 'templates' && ( + api.getTemplates().then(setTemplates)} /> + )} + {activeTab === 'rswag' && } + {activeTab === 'import' && api.getTemplates().then(setTemplates)} />} + {activeTab === 'jobs' && api.getJobs(20).then(setJobs)} />} + + )} +
+ + +
+ ); +} + +function TemplatesTab({ templates, onRefresh }: { templates: Template[]; onRefresh: () => void }) { + const [exporting, setExporting] = useState(null); + const [uploadOpen, setUploadOpen] = useState(false); + const fileRef = useRef(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) => { + 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 ( + <> +
+

Scribus Templates

+
+ + + Open Studio + +
+
+ + {uploadOpen && ( +
+
+ + +
+ +
+ + + +
+
+ )} + + {templates.length === 0 ? ( +
+ +

No templates yet

+

Upload a Scribus .sla file or import an IDML document to get started.

+
+ ) : ( +
+ {templates.map((t) => ( +
+
+ + {t.category} +
+

{t.name}

+

{t.description || 'No description'}

+ {t.variables.length > 0 && ( +
+ {t.variables.map((v) => ( + + %{v}% + + ))} +
+ )} + +
+ ))} +
+ )} + + ); +} + +function RswagTab({ designs }: { designs: RswagDesign[] }) { + const [exporting, setExporting] = useState(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 ( +
+ +

No rSwag designs found

+

Create designs in rSwag first, then export them here for print production.

+
+ ); + } + + return ( +
+ {designs.map((d) => ( +
+
+ + {d.category} +
+

{d.name}

+

{d.description || 'rSwag design'}

+ +
+ ))} +
+ ); +} + +function ImportTab({ onImported }: { onImported: () => void }) { + const [uploading, setUploading] = useState(false); + const [result, setResult] = useState(null); + + const handleImport = async (e: React.ChangeEvent) => { + 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 ( +
+
+ +

Import from InDesign

+

+ Upload an IDML file (InDesign Markup Language) to convert it to Scribus format and PDF. + Export from InDesign via File → Save As → IDML. +

+
+ + + + {result && ( +
+ {result.status === 'completed' ? ( + <> +
+ + Conversion complete +
+ {(result as Record).results && ( +
+ {Object.entries((result as Record).results as Record).map(([key, url]) => ( + + {key === 'sla_url' ? 'Download .sla (Scribus)' : key === 'pdf_url' ? 'Download .pdf' : key} + + ))} +
+ )} + + ) : ( +
+ + {result.error || 'Conversion failed'} +
+ )} +
+ )} +
+ ); +} + +function JobsTab({ jobs, onRefresh }: { jobs: Job[]; onRefresh: () => void }) { + return ( + <> +
+

Recent Jobs

+ +
+ + {jobs.length === 0 ? ( +
No jobs yet. Export a template or design to see jobs here.
+ ) : ( +
+ {jobs.map((j) => ( +
+
+
+
+
{j.job_id.slice(0, 8)}...
+
{new Date(j.created_at).toLocaleString()}
+
+
+
+ {j.status} {j.progress > 0 && j.progress < 100 ? `(${j.progress}%)` : ''} + {j.result_url && ( + + + + )} +
+
+ ))} +
+ )} + + ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 00000000..2d729d6e --- /dev/null +++ b/frontend/src/app/globals.css @@ -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; } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 00000000..dc483259 --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -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,🎨" }, +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 00000000..02e662fa --- /dev/null +++ b/frontend/src/app/page.tsx @@ -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 ( +
+
+ +
+ {/* Hero */} +
+
+ AI-Powered Document Design +
+ +

+ Design documents.{' '} + + Generate PDFs. + +

+ +

+ 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. +

+ +
+ + Open Dashboard + + + Launch Studio + +
+
+ + {/* Features */} +
+
+ {[ + { + icon: , + title: 'Template Export', + desc: 'Upload Scribus templates, fill in variables, export to print-ready PDF. Supports bleed, crop marks, and CMYK.', + }, + { + icon: , + title: 'Batch Generation', + desc: 'Mail-merge style: one template + CSV/JSON data = hundreds of personalized documents. Name badges, certificates, invoices.', + }, + { + icon: , + title: 'IDML Import', + desc: 'Convert InDesign IDML files to Scribus format. Edit in the browser-based Studio, export to PDF.', + }, + { + icon: , + title: 'Print-Ready Output', + desc: 'PDF/X-3 compliant output with ICC color management, bleed areas, and crop marks for professional printing.', + }, + { + icon: , + title: 'rSwag Integration', + desc: 'Export rSwag merchandise designs to print-ready PDFs with proper bleed and trim marks for production.', + }, + { + icon: , + title: 'AI Design Assistant', + desc: 'Mycelial intelligence helps you create documents, suggest templates, batch-export with natural language commands.', + }, + ].map((f) => ( +
+
{f.icon}
+

{f.title}

+

{f.desc}

+
+ ))} +
+
+
+ + + + +
+ ); +} diff --git a/frontend/src/app/studio/page.tsx b/frontend/src/app/studio/page.tsx new file mode 100644 index 00000000..0f2ef451 --- /dev/null +++ b/frontend/src/app/studio/page.tsx @@ -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 ( +
+ {!fullscreen && ( +
+ + + + + +
+ } + /> + )} + +
+ {fullscreen && ( + + )} +