Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions __tests__/flowcharts-get-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { GET } from '@/app/api/flowcharts/[id]/route'

const flowchartsQuery = {
select: jest.fn().mockReturnThis(),
eq: jest.fn().mockReturnThis(),
single: jest.fn(),
}
const versionsQuery = {
select: jest.fn().mockReturnThis(),
eq: jest.fn().mockReturnThis(),
order: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
single: jest.fn(),
}

jest.mock('@/lib/supabase/server', () => ({
createClient: jest.fn(async () => ({
auth: { getUser: jest.fn(async () => ({ data: { user: { id: 'user-1' } }, error: null })) },
from: (table: string) => (table === 'flowcharts' ? flowchartsQuery : versionsQuery),
})),
}))

describe('GET /api/flowcharts/[id]', () => {
it('issues the flowchart and version lookups concurrently', async () => {
let flowchartResolved = false
let versionStartedBeforeFlowchartResolved = false

flowchartsQuery.single.mockImplementation(
() =>
new Promise(resolve =>
setTimeout(() => {
flowchartResolved = true
resolve({ data: { id: 'fc-1', user_id: 'user-1' }, error: null })
}, 20)
)
)
versionsQuery.single.mockImplementation(() => {
versionStartedBeforeFlowchartResolved = !flowchartResolved
return Promise.resolve({ data: { code: 'x', version_number: 3 }, error: null })
})

const request = new Request('http://localhost/api/flowcharts/fc-1')
const response = await GET(request, { params: Promise.resolve({ id: 'fc-1' }) })
const body = await response.json()

expect(versionStartedBeforeFlowchartResolved).toBe(true)
expect(body).toMatchObject({ id: 'fc-1', code: 'x', version_number: 3 })
})
})
19 changes: 10 additions & 9 deletions app/api/flowcharts/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,18 @@ export async function GET(_req: Request, { params }: Params) {
const { data: { user }, error: authErr } = await supabase.auth.getUser()
if (authErr || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

const { data: fc, error } = await supabase
.from('flowcharts').select('*').eq('id', id).eq('user_id', user.id).single()
const [{ data: fc, error }, versionRes] = await Promise.all([
supabase.from('flowcharts').select('*').eq('id', id).eq('user_id', user.id).single(),
supabase
.from('flowchart_versions')
.select('code, version_number')
.eq('flowchart_id', id)
.order('version_number', { ascending: false })
.limit(1)
.single(),
])
if (error || !fc) return NextResponse.json({ error: 'Not found' }, { status: 404 })

const versionRes = await supabase
.from('flowchart_versions')
.select('code, version_number')
.eq('flowchart_id', id)
.order('version_number', { ascending: false })
.limit(1)
.single()
const version = versionRes.data as { code: string; version_number: number } | null

return NextResponse.json(Object.assign({}, fc, { code: version?.code ?? '', version_number: version?.version_number ?? 0 }))
Expand Down
8 changes: 7 additions & 1 deletion components/editor/CodeEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client'
import MonacoEditor, { loader } from '@monaco-editor/react'
import dynamic from 'next/dynamic'
import { loader } from '@monaco-editor/react'
import type { editor } from 'monaco-editor'
import type { SupportedLanguage } from '@/lib/parser'

Expand All @@ -8,6 +9,11 @@ import type { SupportedLanguage } from '@/lib/parser'
// app's CSP (script-src 'self') blocks.
loader.config({ paths: { vs: '/monaco/min/vs' } })

const MonacoEditor = dynamic(() => import('@monaco-editor/react').then(m => m.default), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">Loading editor…</div>,
})

interface Props {
value: string
language: SupportedLanguage
Expand Down
24 changes: 19 additions & 5 deletions components/editor/FlowchartPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import mermaid from 'mermaid'
import type MermaidType from 'mermaid'

mermaid.initialize({ startOnLoad: false, theme: 'dark', flowchart: { useMaxWidth: true, curve: 'basis' } })
// Deferred so `mermaid` isn't bundled into the initial route chunk — it's
// only fetched once this panel actually mounts and renders a diagram.
let mermaidPromise: Promise<typeof MermaidType> | null = null
function loadMermaid() {
if (!mermaidPromise) {
mermaidPromise = import('mermaid').then(m => {
m.default.initialize({ startOnLoad: false, theme: 'dark', flowchart: { useMaxWidth: true, curve: 'basis' } })
return m.default
})
}
return mermaidPromise
}

interface Props {
mermaidCode: string
Expand All @@ -16,10 +27,13 @@ export function FlowchartPanel({ mermaidCode, panelRef }: Props) {

useEffect(() => {
if (!mermaidCode) return
let cancelled = false
const id = `mermaid-${Date.now()}-${idRef.current++}`
mermaid.render(id, mermaidCode)
.then(({ svg }) => { setSvg(svg); setError('') })
.catch(err => setError(err.message))
loadMermaid()
.then(mermaid => mermaid.render(id, mermaidCode))
.then(({ svg }) => { if (!cancelled) { setSvg(svg); setError('') } })
.catch(err => { if (!cancelled) setError(err.message) })
return () => { cancelled = true }
}, [mermaidCode])

return (
Expand Down
7 changes: 6 additions & 1 deletion components/share/ShareView.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import MonacoEditor from '@monaco-editor/react'
import dynamic from 'next/dynamic'
import { toPng } from 'html-to-image'
import { codeToMermaid } from '@/lib/parser'
import type { SupportedLanguage } from '@/lib/parser'
Expand All @@ -10,6 +10,11 @@ import { EditorLayout } from '@/components/editor/EditorLayout'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'

const MonacoEditor = dynamic(() => import('@monaco-editor/react').then(m => m.default), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">Loading editor…</div>,
})

interface Props {
flowchart: { id: string; title: string; language: string; code: string; share_id: string | null }
currentUserId: string | null
Expand Down
153 changes: 9 additions & 144 deletions components/ui/DottedSurface.tsx
Original file line number Diff line number Diff line change
@@ -1,148 +1,13 @@
'use client';
import { cn } from '@/lib/utils';
import React, { useEffect, useRef } from 'react';
import * as THREE from 'three';
'use client'

type DottedSurfaceProps = Omit<React.ComponentProps<'div'>, 'ref'>;
import dynamic from 'next/dynamic'
import type { DottedSurfaceCanvasProps } from './DottedSurfaceCanvas'

export function DottedSurface({ className, ...props }: DottedSurfaceProps) {
const DottedSurfaceCanvas = dynamic(
() => import('./DottedSurfaceCanvas').then(mod => mod.DottedSurfaceCanvas),
{ ssr: false }
)

const containerRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef<{
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
renderer: THREE.WebGLRenderer;
particles: THREE.Points[];
animationId: number;
count: number;
} | null>(null);

useEffect(() => {
if (!containerRef.current) return;

const SEPARATION = 150;
const AMOUNTX = 40;
const AMOUNTY = 60;

const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0xffffff, 2000, 10000);

const camera = new THREE.PerspectiveCamera(
60,
window.innerWidth / window.innerHeight,
1,
10000,
);
camera.position.set(0, 355, 1220);

const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(scene.fog.color, 0);

containerRef.current.appendChild(renderer.domElement);

const positions: number[] = [];
const colors: number[] = [];
const geometry = new THREE.BufferGeometry();

for (let ix = 0; ix < AMOUNTX; ix++) {
for (let iy = 0; iy < AMOUNTY; iy++) {
positions.push(
ix * SEPARATION - (AMOUNTX * SEPARATION) / 2,
0,
iy * SEPARATION - (AMOUNTY * SEPARATION) / 2,
);
colors.push(0.78, 0.78, 0.78);
}
}

geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));

const material = new THREE.PointsMaterial({
size: 8,
vertexColors: true,
transparent: true,
opacity: 0.45,
sizeAttenuation: true,
});

const points = new THREE.Points(geometry, material);
scene.add(points);

let count = 0;
let animationId = 0;

const animate = () => {
animationId = requestAnimationFrame(animate);

const positionAttribute = geometry.attributes.position;
const pos = positionAttribute.array as Float32Array;

let i = 0;
for (let ix = 0; ix < AMOUNTX; ix++) {
for (let iy = 0; iy < AMOUNTY; iy++) {
pos[i * 3 + 1] =
Math.sin((ix + count) * 0.3) * 50 +
Math.sin((iy + count) * 0.5) * 50;
i++;
}
}

positionAttribute.needsUpdate = true;
renderer.render(scene, camera);
count += 0.1;
};

const handleResize = () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
};

window.addEventListener('resize', handleResize);
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reducedMotion) {
renderer.render(scene, camera);
} else {
animate();
}

sceneRef.current = { scene, camera, renderer, particles: [points], animationId, count };

const container = containerRef.current;

return () => {
window.removeEventListener('resize', handleResize);
if (sceneRef.current) {
cancelAnimationFrame(sceneRef.current.animationId);
sceneRef.current.scene.traverse((object) => {
if (object instanceof THREE.Points) {
object.geometry.dispose();
if (Array.isArray(object.material)) {
object.material.forEach((m) => m.dispose());
} else {
object.material.dispose();
}
}
});
sceneRef.current.renderer.dispose();
if (container && sceneRef.current.renderer.domElement) {
container.removeChild(sceneRef.current.renderer.domElement);
}
}
};
}, []);

return (
<div
ref={containerRef}
className={cn('pointer-events-none fixed inset-0', className)}
{...props}
/>
);
export function DottedSurface(props: DottedSurfaceCanvasProps) {
return <DottedSurfaceCanvas {...props} />
}
Loading
Loading