Skip to content
Draft
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
6 changes: 4 additions & 2 deletions src/components/ExcalidrawViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ interface Props {
* `window._excalidrawApis[registrationKey]` so that the page-level JS
* can trigger a PNG export without going through React. */
registrationKey?: string;
/** When true the canvas starts in edit mode instead of view mode. */
defaultEditMode?: boolean;
}

export default function ExcalidrawViewer({ elements, registrationKey }: Props) {
export default function ExcalidrawViewer({ elements, registrationKey, defaultEditMode = false }: Props) {
const apiRef = useRef<ExcalidrawImperativeAPI | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [editMode, setEditMode] = useState(false);
const [editMode, setEditMode] = useState(defaultEditMode);

useEffect(() => {
logger.info(`Mounted with ${elements.length} element(s)`);
Expand Down
142 changes: 142 additions & 0 deletions src/pages/create.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
---
import ExcalidrawViewer from '../components/ExcalidrawViewer';
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Create New Diagram – Draw and export a new architecture diagram with Excalidraw" />
<title>Create New Diagram | Tooda</title>
<script is:inline>
(function () {
const stored = localStorage.getItem('theme');
const theme = stored === 'light' ? 'light' : 'dark';
document.documentElement.classList.toggle('dark', theme === 'dark');
})();
</script>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}

:root {
--bg-color: #f8fafc;
--grad1: rgba(20,184,166,0.15);
--grad2: rgba(6,182,212,0.10);
--grad3: rgba(16,185,129,0.07);
--grid-line: rgba(20,184,166,0.04);
}

:root.dark {
--bg-color: #0f172a;
--grad1: rgba(20,184,166,0.28);
--grad2: rgba(6,182,212,0.18);
--grad3: rgba(16,185,129,0.14);
--grid-line: rgba(20,184,166,0.08);
}

body {
background-color: var(--bg-color);
background-image:
radial-gradient(ellipse 80% 60% at 50% -10%, var(--grad1) 0%, transparent 70%),
radial-gradient(ellipse 50% 40% at 90% 80%, var(--grad2) 0%, transparent 60%),
radial-gradient(ellipse 40% 40% at 10% 90%, var(--grad3) 0%, transparent 60%);
@apply min-h-dvh font-sans text-slate-900 dark:text-slate-50;
}

body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 60px 60px;
pointer-events: none;
z-index: 0;
}

.page-content {
position: relative;
z-index: 1;
}

@keyframes glow-pulse {
0%, 100% { filter: drop-shadow(0 0 16px rgba(20,184,166,0.5)); }
50% { filter: drop-shadow(0 0 36px rgba(6,182,212,0.8)); }
}

header {
@apply px-4 pb-4 pt-8 text-center;
}

header h1 {
animation: glow-pulse 3s ease-in-out infinite;
@apply mb-2 bg-gradient-to-br from-teal-400 via-cyan-400 to-emerald-400 bg-clip-text text-[clamp(2rem,6vw,3.5rem)] font-extrabold text-transparent;
}

header p {
@apply mb-3 text-base text-slate-500 dark:text-slate-400;
}

.canvas-wrapper {
@apply mx-auto max-w-[1000px] px-4;
}

.back-btn {
@apply inline-flex items-center gap-2 rounded-lg border border-slate-300 px-4 py-2 text-sm text-slate-600 no-underline transition-all duration-200 active:scale-95 active:duration-75 hover:border-slate-400 hover:text-slate-800 dark:border-slate-700 dark:text-slate-400 dark:hover:border-slate-500 dark:hover:text-slate-200;
}

/* Theme toggle button */
.theme-toggle-btn {
@apply fixed right-4 top-4 z-50 flex h-10 w-10 items-center justify-center rounded-full border border-slate-300 bg-white/80 text-slate-600 shadow-md backdrop-blur-sm transition-all duration-200 hover:border-teal-400 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-800/80 dark:text-slate-300 dark:hover:border-teal-500/50 dark:hover:text-slate-50;
}
</style>
</head>
<body>
<!-- Theme toggle button -->
<button id="theme-toggle" type="button" aria-label="Switch to light mode" class="theme-toggle-btn">
<svg class="hidden size-5 dark:block" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
<svg class="block size-5 dark:hidden" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
<div class="page-content">
<header>
<h1>Create New Diagram</h1>
<p>Draw your architecture diagram on the canvas below, then export it as JSON.</p>
</header>

<main class="pb-16">
<div class="canvas-wrapper">
<ExcalidrawViewer client:only="react" elements={[]} defaultEditMode={true} />
</div>
</main>

<footer class="pb-10 text-center">
<a href="/Tooda/" class="back-btn">← Back to Home</a>
</footer>
</div>
<script>
const btn = document.getElementById('theme-toggle');
if (btn) {
btn.setAttribute('aria-label', document.documentElement.classList.contains('dark') ? 'Switch to light mode' : 'Switch to dark mode');
btn.addEventListener('click', function () {
const isDark = document.documentElement.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
btn.setAttribute('aria-label', isDark ? 'Switch to light mode' : 'Switch to dark mode');
});
}
</script>
</body>
</html>
8 changes: 8 additions & 0 deletions src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,14 @@
<a href="/Tooda/slideshow" class="cta-primary">🎞️ View Slide Deck →</a>
</div>

<!-- Create New Diagram card -->
<div class="feature-card">
<div class="mb-3 text-3xl">✏️</div>
<h2 class="mb-1 text-lg font-bold text-slate-800 dark:text-slate-100">Create New Diagram</h2>
<p class="mb-4 text-sm leading-relaxed text-slate-600 dark:text-slate-400">Start with a blank canvas and draw your own architecture diagram using Excalidraw, then export it as JSON.</p>
<a href="/Tooda/create" class="cta-primary">✏️ Create New Diagram →</a>
</div>

</div>

<!-- Footer links -->
Expand Down
82 changes: 82 additions & 0 deletions tests/create.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { test, expect } from '@playwright/test';

const CREATE_URL = '/Tooda/create';
const EXCALIDRAW_SELECTOR = '.excalidraw';
const EXCALIDRAW_TIMEOUT = 15000;

test.describe('Create New Diagram page', () => {
test.beforeEach(async ({ page }) => {
await page.goto(CREATE_URL);
});

test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Create New Diagram | Tooda');
});

test('displays the main heading', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Create New Diagram' })).toBeVisible();
});

test('has a back link to the home page', async ({ page }) => {
const back = page.getByRole('link', { name: /Back to Home/ });
await expect(back).toBeVisible();
await expect(back).toHaveAttribute('href', '/Tooda/');
});

test('Excalidraw canvas renders on the page', async ({ page }) => {
await page.waitForSelector(EXCALIDRAW_SELECTOR, { state: 'visible', timeout: EXCALIDRAW_TIMEOUT });
await expect(page.locator(EXCALIDRAW_SELECTOR)).toBeVisible();
});

test('canvas starts in edit mode (View button is shown)', async ({ page }) => {
await page.waitForSelector(EXCALIDRAW_SELECTOR, { state: 'visible', timeout: EXCALIDRAW_TIMEOUT });
await expect(page.getByRole('button', { name: 'View' })).toBeVisible();
});

test('Export JSON button is visible', async ({ page }) => {
await page.waitForSelector(EXCALIDRAW_SELECTOR, { state: 'visible', timeout: EXCALIDRAW_TIMEOUT });
await expect(page.getByTestId('export-json-btn')).toBeVisible();
});

test('clicking View button switches to view mode (Edit button is shown)', async ({ page }) => {
await page.waitForSelector(EXCALIDRAW_SELECTOR, { state: 'visible', timeout: EXCALIDRAW_TIMEOUT });
await page.getByRole('button', { name: 'View' }).click();
await expect(page.getByRole('button', { name: 'Edit' })).toBeVisible();
});

test('Export JSON button triggers a file download', async ({ page }) => {
await page.waitForSelector(EXCALIDRAW_SELECTOR, { state: 'visible', timeout: EXCALIDRAW_TIMEOUT });
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByTestId('export-json-btn').click(),
]);
expect(download.suggestedFilename()).toMatch(/^diagram-.*\.json$/);
});
});

test.describe('Home page – Create New Diagram link', () => {
test('has a link to the create page', async ({ page }) => {
await page.goto('/Tooda/');
await expect(page.getByRole('link', { name: /Create New Diagram/ })).toHaveAttribute('href', '/Tooda/create');
});

test('Create New Diagram link is visible', async ({ page }) => {
await page.goto('/Tooda/');
await expect(page.getByRole('link', { name: /Create New Diagram/ })).toBeVisible();
});
});

test.describe('Home page – Create New Diagram link mobile tap', () => {
test.use({ hasTouch: true });

test('Create New Diagram link is clickable on mobile', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/Tooda/');
const link = page.getByRole('link', { name: /Create New Diagram/ });
await expect(link).toBeVisible();
await Promise.all([
page.waitForURL(/\/Tooda\/create/),
link.tap(),
]);
});
});