Initial commit

This commit is contained in:
jessy-david-dev
2026-01-18 23:33:05 +01:00
commit d926a2acd1
65 changed files with 6331 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { FlatCompat } from '@eslint/eslintrc';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [...compat.extends('next/core-web-vitals', 'next/typescript')];
export default eslintConfig;
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+11
View File
@@ -0,0 +1,11 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
transpilePackages: ['@prisma-uml/parser'],
output: 'standalone',
experimental: {
optimizePackageImports: ['@heroicons/react'],
},
};
export default nextConfig;
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@prisma-uml/web",
"version": "1.0.0",
"author": "Jessy DAVID - https://jessy-david.dev",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@dagrejs/dagre": "^1.1.8",
"@heroicons/react": "^2.2.0",
"@icons-pack/react-simple-icons": "^13.8.0",
"@monaco-editor/react": "^4.7.0",
"@prisma-uml/parser": "workspace:*",
"@xyflow/react": "^12.4.0",
"clsx": "^2.1.0",
"html-to-image": "^1.11.0",
"next": "^16.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwind-merge": "^2.6.0",
"use-debounce": "^10.0.4",
"zod": "^3.24.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/dagre": "^0.7.53",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.7.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 884 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+21
View File
@@ -0,0 +1,21 @@
{
"name": "Prisma UML Viewer",
"short_name": "Prisma UML",
"description": "A online tool to visualize and explore your Prisma schema as an interactive ERD diagram.",
"theme_color": "#2563eb",
"background_color": "#09090b",
"display": "standalone",
"start_url": "/",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://prisma.jessy-david.dev/sitemap.xml
+46
View File
@@ -0,0 +1,46 @@
// Export endpoint - converts Prisma schema to various diagram formats
import { NextRequest, NextResponse } from 'next/server';
import { parsePrismaSchema, toMermaid, toPlantUML, toDBML } from '@prisma-uml/parser';
import { z } from 'zod';
const requestSchema = z.object({
schema: z.string().min(1, 'Schema is required'),
format: z.enum(['mermaid', 'plantuml', 'dbml']),
});
// map format names to renderer functions
const renderers = {
mermaid: toMermaid,
plantuml: toPlantUML,
dbml: toDBML,
} as const;
type ExportFormat = keyof typeof renderers;
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { schema, format } = requestSchema.parse(body);
// first parse the schema, then render to requested format
const parsed = await parsePrismaSchema(schema);
const render = renderers[format as ExportFormat];
const output = render(parsed);
return NextResponse.json({ success: true, output, format });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ success: false, message: 'Validation error', errors: error.errors },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ success: false, message: error.message }, { status: 500 });
}
return NextResponse.json({ success: false, message: 'Internal server error' }, { status: 500 });
}
}
+53
View File
@@ -0,0 +1,53 @@
/**
* API endpoint to parse Prisma schemas
* Returns a structured representation of the schema for visualization
*/
import { NextRequest, NextResponse } from 'next/server';
import { parsePrismaSchema, validatePrismaSchema } from '@prisma-uml/parser';
import { z } from 'zod';
// validate incoming request body
const requestSchema = z.object({
schema: z.string().min(1, 'Schema is required'),
version: z.enum(['5', '6', '7']).optional(),
});
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { schema, version } = requestSchema.parse(body);
// quick validation before parsing
const validation = validatePrismaSchema(schema);
if (!validation.valid) {
return NextResponse.json(
{ success: false, message: 'Invalid schema', errors: validation.errors },
{ status: 422 }
);
}
// parse the schema - version is auto-detected if not specified
const parsedSchema = await parsePrismaSchema(schema, {
version: version ? (parseInt(version) as 5 | 6 | 7) : undefined,
});
return NextResponse.json({ success: true, data: parsedSchema });
} catch (error) {
// handle validation errors from zod
if (error instanceof z.ZodError) {
return NextResponse.json(
{ success: false, message: 'Validation error', errors: error.errors },
{ status: 400 }
);
}
// handle parsing errors
if (error instanceof Error) {
return NextResponse.json({ success: false, message: error.message }, { status: 422 });
}
// something unexpected happened
return NextResponse.json({ success: false, message: 'Internal server error' }, { status: 500 });
}
}
+42
View File
@@ -0,0 +1,42 @@
@import 'tailwindcss';
@theme {
--font-sans: var(--font-ibm-plex-sans), ui-sans-serif, system-ui, sans-serif;
}
/* React Flow Controls - Dark Theme */
.react-flow__controls {
background-color: #27272a !important;
border: 1px solid #3f3f46 !important;
border-radius: 8px !important;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3) !important;
}
.react-flow__controls-button {
background-color: #27272a !important;
border: none !important;
border-bottom: 1px solid #3f3f46 !important;
fill: #a1a1aa !important;
color: #a1a1aa !important;
}
.react-flow__controls-button:hover {
background-color: #3f3f46 !important;
fill: #ffffff !important;
color: #ffffff !important;
}
.react-flow__controls-button:last-child {
border-bottom: none !important;
}
.react-flow__controls-button svg {
fill: currentColor !important;
}
/* MiniMap - Dark Theme */
.react-flow__minimap {
background-color: #18181b !important;
border: 1px solid #3f3f46 !important;
border-radius: 8px !important;
}
+89
View File
@@ -0,0 +1,89 @@
import type { Metadata } from 'next';
import { IBM_Plex_Sans } from 'next/font/google';
import './globals.css';
const ibmPlexSans = IBM_Plex_Sans({
subsets: ['latin'],
weight: ['300', '400', '500', '600', '700'],
variable: '--font-ibm-plex-sans',
});
export const metadata: Metadata = {
title: {
default: 'Prisma UML Viewer',
template: '%s | Prisma UML Viewer',
},
description:
'A online tool to visualize and explore your Prisma schema as an interactive ERD diagram.',
keywords: [
'prisma',
'uml',
'erd',
'diagram',
'database',
'schema',
'visualization',
'prisma schema',
'database diagram',
'entity relationship diagram',
'prisma viewer',
'schema visualization',
'prisma 7',
'prisma 6',
'prisma 5',
],
authors: [{ name: 'Jessy DAVID', url: 'https://jessy-david.dev' }],
creator: 'Jessy DAVID',
publisher: 'Jessy DAVID',
metadataBase: new URL('https://prisma.jessy-david.dev'),
alternates: {
canonical: '/',
},
icons: {
icon: [
{ url: '/favicon-16x16.png', sizes: '16x16', type: 'image/png' },
{ url: '/favicon-32x32.png', sizes: '32x32', type: 'image/png' },
],
apple: '/apple-touch-icon.png',
shortcut: '/favicon.ico',
},
manifest: '/manifest.json',
openGraph: {
title: 'Prisma UML Viewer',
description:
' A online tool to visualize and explore your Prisma schema as an interactive ERD diagram. ',
type: 'website',
locale: 'en_US',
url: 'https://prisma.jessy-david.dev',
siteName: 'Prisma UML Viewer',
},
twitter: {
card: 'summary_large_image',
title: 'Prisma UML Viewer',
description: 'Visualize your Prisma schemas as interactive UML/ERD diagrams',
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
category: 'technology',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark">
<body className={`${ibmPlexSans.variable} font-sans antialiased`}>{children}</body>
</html>
);
}
+115
View File
@@ -0,0 +1,115 @@
'use client';
// Main page component
import { useState, useCallback } from 'react';
import { SchemaEditor } from '@/components/schema-editor';
import { UMLDiagram } from '@/components/uml-diagram';
import { DiagramControls } from '@/components/diagram-controls';
import { ExportButtons } from '@/components/export-buttons';
import { Header } from '@/components/header';
import { Footer } from '@/components/footer';
import type { PrismaSchema } from '@prisma-uml/parser';
// I went with 40% for the editor panel, feels like a good balance
const DEFAULT_PANEL_SIZE = 40;
const MIN_PANEL = 20;
const MAX_PANEL = 80;
export default function HomePage() {
const [schema, setSchema] = useState<PrismaSchema | null>(null);
const [rawSchema, setRawSchema] = useState('');
const [error, setError] = useState<string | null>(null);
const [panelSize, setPanelSize] = useState(DEFAULT_PANEL_SIZE);
const [isResizing, setIsResizing] = useState(false);
// when parsing succeeds, update both the parsed schema and raw text
const handleSchemaChange = useCallback((parsedSchema: PrismaSchema, raw: string) => {
setSchema(parsedSchema);
setRawSchema(raw);
setError(null); // clear any previous errors
}, []);
// if there's an error, we still want to show it but clear the diagram
const handleError = useCallback((errorMessage: string | null) => {
setError(errorMessage);
if (errorMessage) setSchema(null);
}, []);
const handleMouseDown = useCallback(() => {
setIsResizing(true);
}, []);
const handleMouseUp = useCallback(() => {
setIsResizing(false);
}, []);
// drag handler for the resizable divider
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!isResizing) return;
const container = e.currentTarget as HTMLElement;
const rect = container.getBoundingClientRect();
const percentage = ((e.clientX - rect.left) / rect.width) * 100;
// clamp to reasonable bounds so panels don't get too small
const clamped = Math.min(Math.max(percentage, MIN_PANEL), MAX_PANEL);
setPanelSize(clamped);
},
[isResizing]
);
return (
<div className="h-screen flex flex-col bg-zinc-950 text-zinc-100">
<Header>
<ExportButtons schema={schema} rawSchema={rawSchema} />
</Header>
<main
className="flex-1 flex overflow-hidden"
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{/* Editor Panel */}
<div
className="border-r border-zinc-800 overflow-hidden flex flex-col"
style={{ width: `${panelSize}%` }}
>
<div className="px-4 py-2 border-b border-zinc-800 bg-zinc-900/50">
<h2 className="text-sm font-medium text-zinc-400">Prisma Schema</h2>
</div>
<div className="flex-1 overflow-hidden">
<SchemaEditor onSchemaChange={handleSchemaChange} onError={handleError} />
</div>
</div>
{/* Resizer */}
<div
className={`w-1 cursor-col-resize transition-colors ${
isResizing ? 'bg-blue-600' : 'bg-zinc-800 hover:bg-blue-600'
}`}
onMouseDown={handleMouseDown}
/>
{/* Diagram Panel */}
<div className="flex-1 flex flex-col overflow-hidden">
<DiagramControls schema={schema} />
{error ? (
<div className="flex-1 flex items-center justify-center p-4">
<div className="max-w-md p-4 bg-red-500/10 border border-red-500/50 rounded-lg">
<h3 className="text-red-400 font-medium mb-2">Parsing Error</h3>
<p className="text-red-300 text-sm">{error}</p>
</div>
</div>
) : (
<UMLDiagram schema={schema} />
)}
</div>
</main>
<Footer />
</div>
);
}
@@ -0,0 +1,51 @@
'use client';
// Toolbar above the diagram showing schema stats
import type { PrismaSchema } from '@prisma-uml/parser';
import { CubeIcon, RectangleStackIcon, CircleStackIcon } from '@heroicons/react/24/outline';
interface DiagramControlsProps {
schema: PrismaSchema | null;
}
export function DiagramControls({ schema }: DiagramControlsProps) {
// show a minimal bar when no schema is loaded
if (!schema) {
return (
<div className="px-4 py-2 border-b border-zinc-800 bg-zinc-900/50">
<div className="flex items-center gap-4 text-zinc-500 text-sm">
<span>UML Diagram</span>
</div>
</div>
);
}
return (
<div className="px-4 py-2 border-b border-zinc-800 bg-zinc-900/50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<h2 className="text-sm font-medium text-zinc-400">UML Diagram</h2>
<div className="h-4 w-px bg-zinc-700" />
<span className="text-xs text-zinc-500 flex items-center gap-1.5">
<CircleStackIcon className="w-4 h-4" />
Prisma {schema.version}
</span>
</div>
<div className="flex items-center gap-4 text-xs text-zinc-500">
<span className="flex items-center gap-1.5">
<CubeIcon className="w-4 h-4 text-blue-400" />
{schema.models.length} models
</span>
{schema.enums.length > 0 && (
<span className="flex items-center gap-1.5">
<RectangleStackIcon className="w-4 h-4 text-purple-400" />
{schema.enums.length} enums
</span>
)}
</div>
</div>
</div>
);
}
+191
View File
@@ -0,0 +1,191 @@
'use client';
import { useState, useCallback } from 'react';
import type { PrismaSchema } from '@prisma-uml/parser';
import {
ArrowDownTrayIcon,
DocumentDuplicateIcon,
CheckIcon,
PhotoIcon,
} from '@heroicons/react/24/outline';
import { useExportImage, type ImageFormat } from '@/hooks/use-export-image';
type ExportFormat = 'mermaid' | 'plantuml' | 'dbml';
interface ExportButtonsProps {
schema: PrismaSchema | null;
rawSchema: string;
}
export function ExportButtons({ schema, rawSchema }: ExportButtonsProps) {
const [isExporting, setIsExporting] = useState(false);
const [copiedFormat, setCopiedFormat] = useState<string | null>(null);
const [showDropdown, setShowDropdown] = useState(false);
const { downloadImage } = useExportImage();
const exportAs = useCallback(
async (format: ExportFormat) => {
if (!rawSchema) return;
setIsExporting(true);
try {
const response = await fetch('/api/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schema: rawSchema, format }),
});
const result = await response.json();
if (result.success) {
await navigator.clipboard.writeText(result.output);
setCopiedFormat(format);
setTimeout(() => setCopiedFormat(null), 2000);
}
} catch (error) {
console.error('Export failed:', error);
} finally {
setIsExporting(false);
setShowDropdown(false);
}
},
[rawSchema]
);
const downloadAs = useCallback(
async (format: ExportFormat) => {
if (!rawSchema) return;
setIsExporting(true);
try {
const response = await fetch('/api/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schema: rawSchema, format }),
});
const result = await response.json();
if (result.success) {
const extensions: Record<ExportFormat, string> = {
mermaid: 'mmd',
plantuml: 'puml',
dbml: 'dbml',
};
const blob = new Blob([result.output], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `schema.${extensions[format]}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
} catch (error) {
console.error('Download failed:', error);
} finally {
setIsExporting(false);
setShowDropdown(false);
}
},
[rawSchema]
);
const downloadImageAs = useCallback(
async (format: ImageFormat) => {
setIsExporting(true);
try {
await downloadImage({
format,
filename: 'prisma-diagram',
backgroundColor: '#18181b',
});
} catch (error) {
console.error('Image export failed:', error);
} finally {
setIsExporting(false);
setShowDropdown(false);
}
},
[downloadImage]
);
if (!schema) {
return null;
}
return (
<div className="relative">
<button
onClick={() => setShowDropdown(!showDropdown)}
disabled={isExporting}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 disabled:bg-blue-600/50 text-white rounded-lg transition-colors"
>
<ArrowDownTrayIcon className="w-4 h-4" />
<span>Export</span>
</button>
{showDropdown && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowDropdown(false)} />
<div className="absolute right-0 top-full mt-2 z-50 w-56 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl overflow-hidden">
<div className="p-2">
<p className="text-xs text-zinc-500 px-2 py-1 mb-1">Copy to clipboard</p>
{(['mermaid', 'plantuml', 'dbml'] as ExportFormat[]).map((format) => (
<button
key={format}
onClick={() => exportAs(format)}
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm text-zinc-300 hover:bg-zinc-700 rounded transition-colors"
>
{copiedFormat === format ? (
<CheckIcon className="w-4 h-4 text-green-400" />
) : (
<DocumentDuplicateIcon className="w-4 h-4" />
)}
<span className="capitalize">{format}</span>
{copiedFormat === format && (
<span className="ml-auto text-xs text-green-400">Copied!</span>
)}
</button>
))}
</div>
<div className="border-t border-zinc-700 p-2">
<p className="text-xs text-zinc-500 px-2 py-1 mb-1">Download Code</p>
{(['mermaid', 'plantuml', 'dbml'] as ExportFormat[]).map((format) => (
<button
key={`download-${format}`}
onClick={() => downloadAs(format)}
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm text-zinc-300 hover:bg-zinc-700 rounded transition-colors"
>
<ArrowDownTrayIcon className="w-4 h-4" />
<span className="capitalize">{format}</span>
<span className="ml-auto text-xs text-zinc-500">
.{format === 'mermaid' ? 'mmd' : format === 'plantuml' ? 'puml' : 'dbml'}
</span>
</button>
))}
</div>
<div className="border-t border-zinc-700 p-2">
<p className="text-xs text-zinc-500 px-2 py-1 mb-1">Download Image</p>
{(['png', 'svg', 'jpeg'] as ImageFormat[]).map((format) => (
<button
key={`image-${format}`}
onClick={() => downloadImageAs(format)}
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm text-zinc-300 hover:bg-zinc-700 rounded transition-colors"
>
<PhotoIcon className="w-4 h-4" />
<span className="uppercase">{format}</span>
<span className="ml-auto text-xs text-zinc-500">.{format}</span>
</button>
))}
</div>
</div>
</>
)}
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
// Simple footer with GitHub link and credits
import { SiGithub } from '@icons-pack/react-simple-icons';
import packageJson from '../../package.json';
export function Footer() {
return (
<footer className="px-6 py-3 border-t border-zinc-800 bg-zinc-900/50 flex items-center justify-between">
{/* repo link */}
<div className="flex items-center gap-4 text-xs">
<a
href="https://github.com/jessy-david-dev/Prisma-UML-Viewer"
target="_blank"
rel="noopener noreferrer"
className="text-zinc-500 hover:text-white transition-colors flex items-center gap-1.5"
>
<SiGithub className="w-4 h-4" />
GitHub
</a>
<img
src={`https://img.shields.io/badge/version-${packageJson.version}-green`}
alt={`Version ${packageJson.version}`}
className="h-5"
/>
</div>
<span className="text-zinc-600 text-xs">
A tool by{' '}
<a
href="https://jessy-david.dev"
target="_blank"
rel="noopener noreferrer"
className="text-zinc-400 hover:text-white transition-colors"
>
Jessy DAVID
</a>
</span>
</footer>
);
}
+41
View File
@@ -0,0 +1,41 @@
// Site header with branding and nav links
import type { ReactNode } from 'react';
import { SiPrisma } from '@icons-pack/react-simple-icons';
interface HeaderProps {
children?: ReactNode;
}
export function Header({ children }: HeaderProps) {
return (
<header className="flex items-center justify-between px-6 py-4 border-b border-zinc-800 bg-zinc-900/50">
{/* logo + title */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<SiPrisma className="w-6 h-6 text-white" />
<h1 className="text-xl font-bold text-white">Prisma UML Viewer</h1>
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 text-xs bg-blue-600 text-white rounded-full font-medium">
v5/v6/v7
</span>
<span className="px-2 py-0.5 text-xs bg-zinc-700 text-zinc-300 rounded-full">beta</span>
</div>
</div>
{/* right side: docs link + export buttons */}
<div className="flex items-center gap-4">
<a
href="https://www.prisma.io/docs"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-zinc-400 hover:text-white transition-colors"
>
Documentation Prisma
</a>
{children}
</div>
</header>
);
}
+6
View File
@@ -0,0 +1,6 @@
export { SchemaEditor } from './schema-editor';
export { UMLDiagram } from './uml-diagram';
export { DiagramControls } from './diagram-controls';
export { ExportButtons } from './export-buttons';
export { Header } from './header';
export { Footer } from './footer';
@@ -0,0 +1,67 @@
'use client';
// ReactFlow node for Prisma enums
// Purple theme to distinguish from models (blue)
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import type { NodeProps, Node } from '@xyflow/react';
import type { PrismaEnum } from '@prisma-uml/parser';
type EnumNodeData = { enum: PrismaEnum };
type EnumNodeType = Node<EnumNodeData, 'enum'>;
export const EnumNode = memo(function EnumNode({ data }: NodeProps<EnumNodeType>) {
const enumDef = data.enum;
const values = enumDef.values;
return (
<div className="bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl min-w-50 max-w-65 overflow-hidden">
<div className="bg-purple-600 px-4 py-3 flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-purple-300" />
<span className="text-white font-semibold">{enumDef.name}</span>
<span className="ml-auto text-purple-200 text-xs bg-purple-700/50 px-2 py-0.5 rounded">
enum
</span>
</div>
<div className="divide-y divide-zinc-800 max-h-62.5 overflow-y-auto">
{enumDef.values.map((value, index) => (
<div
key={value.name}
className="px-4 py-2 flex items-center justify-between text-sm hover:bg-zinc-800/50 transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-zinc-500 text-xs font-mono w-4">{index}</span>
<span className="text-zinc-300">{value.name}</span>
</div>
{value.dbName && (
<span className="text-zinc-500 text-xs font-mono">
@map(&quot;{value.dbName}&quot;)
</span>
)}
</div>
))}
</div>
{enumDef.documentation && (
<div className="px-4 py-2 bg-zinc-800/50 text-xs text-zinc-500 border-t border-zinc-800">
{enumDef.documentation}
</div>
)}
<Handle
type="target"
position={Position.Left}
id="target"
className="w-3! h-3! bg-purple-500! border-2! border-purple-300!"
/>
<Handle
type="source"
position={Position.Right}
id="source"
className="w-3! h-3! bg-purple-500! border-2! border-purple-300!"
/>
</div>
);
});
+2
View File
@@ -0,0 +1,2 @@
export { ModelNode } from './model-node';
export { EnumNode } from './enum-node';
@@ -0,0 +1,113 @@
'use client';
// Custom ReactFlow node for Prisma models
// Shows fields with their types and various indicators (PK, FK, unique, etc)
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import type { NodeProps, Node } from '@xyflow/react';
import type { PrismaModel, PrismaField } from '@prisma-uml/parser';
import {
KeyIcon,
LinkIcon,
ListBulletIcon,
QuestionMarkCircleIcon,
} from '@heroicons/react/24/outline';
type ModelNodeData = { model: PrismaModel };
type ModelNodeType = Node<ModelNodeData, 'model'>;
export const ModelNode = memo(function ModelNode({ data }: NodeProps<ModelNodeType>) {
const { model } = data;
return (
<div className="bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl min-w-70 max-w-80 overflow-visible relative">
<div className="bg-blue-600 px-4 py-3 flex items-center justify-between rounded-t-lg">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-300" />
<span className="text-white font-semibold">{model.name}</span>
</div>
{model.dbName && (
<span className="text-blue-200 text-xs bg-blue-700/50 px-2 py-0.5 rounded">
{model.dbName}
</span>
)}
</div>
<div className="divide-y divide-zinc-800 max-h-75 overflow-y-auto">
{model.fields.map((field) => (
<FieldRow key={field.name} field={field} />
))}
</div>
{model.primaryKey && (
<div className="px-4 py-2 bg-zinc-800/50 text-xs text-zinc-500 rounded-b-lg">
Composite PK: {model.primaryKey.fields.join(', ')}
</div>
)}
<Handle
type="target"
position={Position.Left}
id="target"
className="w-3! h-3! bg-blue-500! border-2! border-blue-300!"
/>
<Handle
type="source"
position={Position.Right}
id="source"
className="w-3! h-3! bg-blue-500! border-2! border-blue-300!"
/>
</div>
);
});
// individual field row with icons and badges
function FieldRow({ field }: { field: PrismaField }) {
const isRelation = field.kind === 'object';
// build the type string (e.g., "User[]" or "String?")
let typeStr = field.type;
if (field.isList) typeStr += '[]';
else if (!field.isRequired) typeStr += '?';
return (
<div className="px-4 py-2 flex items-center justify-between text-sm hover:bg-zinc-800/50 transition-colors group">
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex items-center gap-1 shrink-0">
{field.isId && <KeyIcon className="w-4 h-4 text-yellow-500" title="Primary Key" />}
{isRelation && <LinkIcon className="w-4 h-4 text-blue-400" title="Relation" />}
{field.isList && <ListBulletIcon className="w-4 h-4 text-green-400" title="Array" />}
{!field.isRequired && !field.isId && (
<QuestionMarkCircleIcon className="w-4 h-4 text-zinc-500" title="Optional" />
)}
</div>
<span
className={`truncate ${field.isId ? 'font-semibold text-yellow-400' : 'text-zinc-300'}`}
>
{field.name}
</span>
</div>
<div className="flex items-center gap-2 shrink-0 ml-2">
<span className={`font-mono text-xs ${isRelation ? 'text-blue-400' : 'text-zinc-500'}`}>
{typeStr}
</span>
{field.isUnique && !field.isId && (
<span className="px-1.5 py-0.5 text-2.5 bg-purple-500/20 text-purple-400 rounded font-medium">
UK
</span>
)}
{field.isOmitted && (
<span className="px-1.5 py-0.5 text-2.5 bg-orange-500/20 text-orange-400 rounded font-medium">
omit
</span>
)}
</div>
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
'use client';
// Monaco-based editor for Prisma schemas
// Uses debouncing to avoid hammering the API on every keystroke
import { useState, useCallback, useRef } from 'react';
import Editor from '@monaco-editor/react';
import type { OnMount } from '@monaco-editor/react';
import { useDebouncedCallback } from 'use-debounce';
import type { PrismaSchema } from '@prisma-uml/parser';
interface SchemaEditorProps {
initialValue?: string;
onSchemaChange: (schema: PrismaSchema, raw: string) => void;
onError: (error: string | null) => void;
}
// sample schema to show when the page loads
// includes common patterns: relations, enums, optional fields
const DEFAULT_SCHEMA = `// Paste your Prisma schema here
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
role Role @default(USER)
posts Post[]
profile Profile?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Profile {
id Int @id @default(autoincrement())
bio String?
avatar String?
user User @relation(fields: [userId], references: [id])
userId Int @unique
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId Int
tags Tag[]
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Comment {
id Int @id @default(autoincrement())
content String
post Post @relation(fields: [postId], references: [id])
postId Int
createdAt DateTime @default(now())
}
model Tag {
id Int @id @default(autoincrement())
name String @unique
posts Post[]
}
enum Role {
ADMIN
USER
GUEST
}
`;
export function SchemaEditor({
initialValue = DEFAULT_SCHEMA,
onSchemaChange,
onError,
}: SchemaEditorProps) {
const [value, setValue] = useState(initialValue);
const [isLoading, setIsLoading] = useState(false);
const editorRef = useRef<unknown>(null);
// sends the schema to our API endpoint for parsing
async function parseSchema(schema: string) {
setIsLoading(true);
try {
const res = await fetch('/api/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schema }),
});
const result = await res.json();
if (!res.ok || !result.success) {
// try to extract a meaningful error message
const msg = result.errors
? result.errors.map((e: { message?: string }) => e.message ?? String(e)).join(', ')
: (result.message ?? 'Parsing error');
onError(msg);
return;
}
onError(null);
onSchemaChange(result.data, schema);
} catch (err) {
// network error or something unexpected
onError('Server connection error');
} finally {
setIsLoading(false);
}
}
// 500ms debounce seems like a good tradeoff between responsiveness and API load
const debouncedParse = useDebouncedCallback(parseSchema, 500);
const handleChange = useCallback(
(newValue: string | undefined) => {
if (newValue === undefined) return;
setValue(newValue);
debouncedParse(newValue);
},
[debouncedParse]
);
// parse the initial schema when editor mounts
const handleEditorMount: OnMount = (editor) => {
editorRef.current = editor;
parseSchema(initialValue);
};
return (
<div className="h-full relative">
{isLoading && (
<div className="absolute top-2 right-2 z-10">
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
<Editor
height="100%"
defaultLanguage="prisma"
theme="vs-dark"
value={value}
onChange={handleChange}
onMount={handleEditorMount}
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
wordWrap: 'on',
padding: { top: 16 },
scrollbar: {
verticalScrollbarSize: 8,
horizontalScrollbarSize: 8,
},
}}
/>
</div>
);
}
+234
View File
@@ -0,0 +1,234 @@
'use client';
import { useMemo, useCallback, useEffect, useState } from 'react';
import {
ReactFlow,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
useReactFlow,
ReactFlowProvider,
MarkerType,
BackgroundVariant,
Panel,
} from '@xyflow/react';
import type { Node, Edge } from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { ArrowsPointingOutIcon } from '@heroicons/react/24/outline';
import { ModelNode } from './nodes/model-node';
import { EnumNode } from './nodes/enum-node';
import { useAutoLayout } from '../hooks';
import type { PrismaSchema } from '@prisma-uml/parser';
// custom node types for models and enums
const nodeTypes = {
model: ModelNode,
enum: EnumNode,
} as const;
interface UMLDiagramProps {
schema: PrismaSchema | null;
}
// wrapper component to provide ReactFlow context
export function UMLDiagram({ schema }: UMLDiagramProps) {
return (
<ReactFlowProvider>
<DiagramCanvas schema={schema} />
</ReactFlowProvider>
);
}
// the actual diagram logic lives here
function DiagramCanvas({ schema }: UMLDiagramProps) {
const [mounted, setMounted] = useState(false);
const { fitView } = useReactFlow();
const { getLayoutedElements } = useAutoLayout();
useEffect(() => {
setMounted(true);
}, []);
const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
if (!schema) return { nodes: [], edges: [] };
return schemaToReactFlow(schema);
}, [schema]);
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
useEffect(() => {
if (schema) {
const { nodes: newNodes, edges: newEdges } = schemaToReactFlow(schema);
setNodes(newNodes);
setEdges(newEdges);
}
}, [schema, setNodes, setEdges]);
// auto-layout using dagre - tweaked these values until it looked good
const onAutoLayout = useCallback(() => {
const layoutConfig = {
direction: 'LR' as const,
nodeWidth: 300,
baseNodeHeight: 150,
fieldHeight: 36,
rankSep: 80, // space between columns
nodeSep: 30, // space between nodes in same column
};
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(
nodes,
edges,
layoutConfig
);
setNodes(layoutedNodes);
setEdges(layoutedEdges);
// small delay before fitting view, otherwise it doesn't work properly
setTimeout(() => fitView({ padding: 0.2, duration: 300 }), 50);
}, [nodes, edges, getLayoutedElements, setNodes, setEdges, fitView]);
if (!mounted) {
return (
<div className="flex-1 flex items-center justify-center bg-zinc-900">
<div className="text-zinc-500">Loading diagram...</div>
</div>
);
}
if (!schema) {
return (
<div className="flex-1 flex items-center justify-center bg-zinc-900">
<div className="text-center text-zinc-500">
<p className="text-lg mb-2">No schema to display</p>
<p className="text-sm">Enter a valid Prisma schema in the editor</p>
</div>
</div>
);
}
return (
<div className="flex-1 w-full bg-zinc-900">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
attributionPosition="bottom-left"
proOptions={{ hideAttribution: true }}
>
<Background color="#3f3f46" gap={50} variant={BackgroundVariant.Lines} lineWidth={1} />
<Controls className="bg-zinc-800! border-zinc-700! shadow-lg!" showInteractive={false} />
<MiniMap
nodeStrokeColor="#3b82f6"
nodeColor="#18181b"
maskColor="rgba(0, 0, 0, 0.8)"
className="bg-zinc-800! border-zinc-700!"
/>
<Panel position="top-left" className="flex gap-2">
<button
onClick={onAutoLayout}
className="flex items-center gap-2 px-3 py-2 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg text-sm text-zinc-300 transition-colors shadow-lg"
title="Automatically organize the diagram layout"
>
<ArrowsPointingOutIcon className="w-4 h-4" />
<span>Auto-organize</span>
</button>
</Panel>
<Panel position="top-right" className="text-xs text-zinc-500">
{schema.models.length} models {schema.enums.length} enums
</Panel>
</ReactFlow>
</div>
);
}
function schemaToReactFlow(schema: PrismaSchema): { nodes: Node[]; edges: Edge[] } {
const nodes: Node[] = [];
const edges: Edge[] = [];
const GRID_COLS = Math.min(3, Math.ceil(Math.sqrt(schema.models.length + schema.enums.length)));
const NODE_WIDTH = 300;
const NODE_HEIGHT = 250;
const GAP_X = 120;
const GAP_Y = 100;
schema.models.forEach((model, index) => {
const col = index % GRID_COLS;
const row = Math.floor(index / GRID_COLS);
nodes.push({
id: model.name,
type: 'model',
position: {
x: col * (NODE_WIDTH + GAP_X),
y: row * (NODE_HEIGHT + GAP_Y),
},
data: { model },
});
model.fields
.filter((field) => field.kind === 'object' && field.relationFromFields?.length)
.forEach((field) => {
const targetModel = field.type.replace('[]', '').replace('?', '');
edges.push({
id: `${model.name}-${field.name}-${targetModel}`,
source: model.name,
target: targetModel,
sourceHandle: 'source',
targetHandle: 'target',
type: 'smoothstep',
animated: false,
markerEnd: {
type: MarkerType.ArrowClosed,
color: '#3b82f6',
width: 20,
height: 20,
},
style: {
stroke: '#3b82f6',
strokeWidth: 2,
},
label: field.relationName || undefined,
labelStyle: {
fill: '#a1a1aa',
fontSize: 11,
fontWeight: 500,
},
labelBgStyle: {
fill: '#18181b',
fillOpacity: 0.9,
},
labelBgPadding: [4, 4] as [number, number],
labelBgBorderRadius: 4,
});
});
});
schema.enums.forEach((enumDef, index) => {
const totalModels = schema.models.length;
const enumIndex = totalModels + index;
const col = enumIndex % GRID_COLS;
const row = Math.floor(enumIndex / GRID_COLS);
nodes.push({
id: enumDef.name,
type: 'enum',
position: {
x: col * (NODE_WIDTH + GAP_X),
y: row * (NODE_HEIGHT + GAP_Y),
},
data: { enum: enumDef },
});
});
return { nodes, edges };
}
+2
View File
@@ -0,0 +1,2 @@
export { useExportImage, type ImageFormat } from './use-export-image';
export { useAutoLayout } from './use-auto-layout';
+95
View File
@@ -0,0 +1,95 @@
'use client';
import { useCallback } from 'react';
import Dagre from '@dagrejs/dagre';
import type { Node, Edge } from '@xyflow/react';
interface LayoutOptions {
direction?: 'TB' | 'LR' | 'BT' | 'RL';
nodeWidth?: number;
baseNodeHeight?: number;
fieldHeight?: number;
rankSep?: number;
nodeSep?: number;
}
function calculateNodeHeight(node: Node, baseHeight: number, fieldHeight: number): number {
const data = node.data as { model?: { fields?: unknown[] }; enum?: { values?: unknown[] } };
if (data.model?.fields) {
const fieldsCount = data.model.fields.length;
return Math.max(baseHeight, 52 + fieldsCount * 36 + 16);
}
if (data.enum?.values) {
const valuesCount = data.enum.values.length;
return Math.max(baseHeight, 44 + valuesCount * 32 + 16);
}
return baseHeight;
}
export function useAutoLayout() {
const getLayoutedElements = useCallback(
(
nodes: Node[],
edges: Edge[],
options: LayoutOptions = {}
): { nodes: Node[]; edges: Edge[] } => {
const {
direction = 'LR',
nodeWidth = 300,
baseNodeHeight = 150,
fieldHeight = 36,
rankSep = 100,
nodeSep = 50,
} = options;
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
g.setGraph({
rankdir: direction,
ranksep: rankSep,
nodesep: nodeSep,
marginx: 50,
marginy: 50,
});
const nodeHeights = new Map<string, number>();
nodes.forEach((node) => {
const height = calculateNodeHeight(node, baseNodeHeight, fieldHeight);
nodeHeights.set(node.id, height);
g.setNode(node.id, {
width: nodeWidth,
height: height,
});
});
edges.forEach((edge) => {
g.setEdge(edge.source, edge.target);
});
Dagre.layout(g);
const layoutedNodes = nodes.map((node) => {
const nodeWithPosition = g.node(node.id);
const height = nodeHeights.get(node.id) || baseNodeHeight;
return {
...node,
position: {
x: nodeWithPosition.x - nodeWidth / 2,
y: nodeWithPosition.y - height / 2,
},
};
});
return { nodes: layoutedNodes, edges };
},
[]
);
return { getLayoutedElements };
}
+203
View File
@@ -0,0 +1,203 @@
'use client';
import { useCallback } from 'react';
import { toPng, toSvg, toJpeg } from 'html-to-image';
export type ImageFormat = 'png' | 'svg' | 'jpeg';
interface ExportImageOptions {
format: ImageFormat;
quality?: number;
backgroundColor?: string;
filename?: string;
includeGrid?: boolean;
}
function drawGridBackground(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
gridSize: number = 50,
backgroundColor: string = '#18181b',
gridColor: string = '#3f3f46'
) {
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, width, height);
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
for (let x = 0; x <= width; x += gridSize) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 0; y <= height; y += gridSize) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
}
export function useExportImage() {
const downloadImage = useCallback(async (options: ExportImageOptions) => {
const {
format,
quality = 0.92,
backgroundColor = '#18181b',
filename = 'prisma-schema',
includeGrid = true,
} = options;
const reactFlowContainer = document.querySelector('.react-flow') as HTMLElement;
if (!reactFlowContainer) {
console.error('React Flow container not found');
return false;
}
const nodeElements = document.querySelectorAll('.react-flow__node') as NodeListOf<HTMLElement>;
if (nodeElements.length === 0) {
console.error('No nodes to export');
return false;
}
const viewport = document.querySelector('.react-flow__viewport') as HTMLElement;
if (!viewport) {
console.error('React Flow viewport not found');
return false;
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
nodeElements.forEach((nodeEl) => {
const transform = nodeEl.style.transform;
const match = transform.match(/translate\((-?[\d.]+)px,\s*(-?[\d.]+)px\)/);
if (match) {
const x = parseFloat(match[1]);
const y = parseFloat(match[2]);
const width = nodeEl.offsetWidth;
const height = nodeEl.offsetHeight;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + width);
maxY = Math.max(maxY, y + height);
}
});
if (minX === Infinity) {
console.error('Could not calculate node bounds');
return false;
}
const padding = 80;
const contentWidth = maxX - minX;
const contentHeight = maxY - minY;
const imageWidth = Math.ceil(contentWidth + padding * 2);
const imageHeight = Math.ceil(contentHeight + padding * 2);
const originalTransform = viewport.style.transform;
viewport.style.transform = `translate(${-minX + padding}px, ${-minY + padding}px) scale(1)`;
await new Promise((resolve) => setTimeout(resolve, 30));
const imageOptions = {
backgroundColor: includeGrid ? 'transparent' : backgroundColor,
width: imageWidth,
height: imageHeight,
quality,
pixelRatio: 2,
skipFonts: true,
cacheBust: true,
filter: (node: Element) => {
const classList = (node as HTMLElement).classList;
if (!classList) return true;
if (
classList.contains('react-flow__controls') ||
classList.contains('react-flow__minimap') ||
classList.contains('react-flow__panel') ||
classList.contains('react-flow__background')
) {
return false;
}
return true;
},
};
try {
const diagramDataUrl = await toPng(reactFlowContainer, imageOptions);
viewport.style.transform = originalTransform;
if (includeGrid && format !== 'svg') {
const finalCanvas = document.createElement('canvas');
const pixelRatio = 2;
finalCanvas.width = imageWidth * pixelRatio;
finalCanvas.height = imageHeight * pixelRatio;
const ctx = finalCanvas.getContext('2d')!;
ctx.scale(pixelRatio, pixelRatio);
drawGridBackground(ctx, imageWidth, imageHeight, 50, backgroundColor, '#3f3f46');
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = reject;
img.src = diagramDataUrl;
});
ctx.drawImage(img, 0, 0, imageWidth, imageHeight);
let finalDataUrl: string;
if (format === 'jpeg') {
finalDataUrl = finalCanvas.toDataURL('image/jpeg', quality);
} else {
finalDataUrl = finalCanvas.toDataURL('image/png');
}
const link = document.createElement('a');
link.download = `${filename}.${format}`;
link.href = finalDataUrl;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
let dataUrl: string;
if (format === 'svg') {
viewport.style.transform = `translate(${-minX + padding}px, ${-minY + padding}px) scale(1)`;
await new Promise((resolve) => setTimeout(resolve, 30));
dataUrl = await toSvg(reactFlowContainer, { ...imageOptions, backgroundColor });
viewport.style.transform = originalTransform;
} else {
dataUrl = diagramDataUrl;
}
const link = document.createElement('a');
link.download = `${filename}.${format}`;
link.href = dataUrl;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
return true;
} catch (error) {
console.error('Failed to export image:', error);
viewport.style.transform = originalTransform;
return false;
}
}, []);
return { downloadImage };
}
+2
View File
@@ -0,0 +1,2 @@
export { parsePrismaSchema, validatePrismaSchema, detectPrismaVersion } from './prisma-parser';
export type { PrismaSchema } from './prisma-parser';
+17
View File
@@ -0,0 +1,17 @@
import {
parsePrismaSchema as parse,
validatePrismaSchema,
detectPrismaVersion,
type PrismaSchema,
type ParserOptions,
} from '@prisma-uml/parser';
export async function parsePrismaSchema(
schemaContent: string,
options?: ParserOptions
): Promise<PrismaSchema> {
return parse(schemaContent, options);
}
export { validatePrismaSchema, detectPrismaVersion };
export type { PrismaSchema };
+42
View File
@@ -0,0 +1,42 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* Merge Tailwind CSS classes with clsx
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function encodeSchema(schema: string): string {
return btoa(encodeURIComponent(schema));
}
export function decodeSchema(encoded: string): string {
try {
return decodeURIComponent(atob(encoded));
} catch {
return '';
}
}
export async function copyToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
export function downloadFile(content: string, filename: string, mimeType = 'text/plain'): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
+4
View File
@@ -0,0 +1,4 @@
declare module '*.css' {
const content: { [className: string]: string };
export default content;
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"],
"@prisma-uml/parser": ["./../../packages/prisma-parser/src"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}