Compare commits

..

No commits in common. "e7f8650ff4292f294b028cb2ea704324d2aef9f5" and "c51931af9c475cd00ecf38296be9410d98c7d07b" have entirely different histories.

58 changed files with 2002 additions and 3101 deletions

View File

@ -12,5 +12,5 @@
"hasMasterSlide": true "hasMasterSlide": true
} }
}, },
"generated": "2025-08-21T18:19:19.993Z" "generated": "2025-08-21T14:43:50.916Z"
} }

View File

@ -6,16 +6,16 @@
* Version: 0.1.0 * Version: 0.1.0
*/ */
:root { :root {
--theme-primary: #9563eb; --theme-primary: #2563eb;
--theme-secondary: #94748b; --theme-secondary: #64748b;
--theme-accent: #eea5e9; --theme-accent: #0ea5e9;
--theme-background: #001112; --theme-background: #000000;
--theme-text: #ffffff; --theme-text: #ffffff;
--theme-text-secondary: #94a4ab; --theme-text-secondary: #64748b;
--theme-font-heading: 'Inter', system-ui, sans-serif; --theme-font-heading: 'Inter', system-ui, sans-serif;
--theme-font-body: 'Inter', system-ui, sans-serif; --theme-font-body: 'Inter', system-ui, sans-serif;
--theme-font-code: 'Consolas', monospace; --theme-font-code: 'JetBrains Mono', 'Consolas', monospace;
--slide-padding: 5%; --slide-padding: 5%;
--content-max-width: 90%; --content-max-width: 90%;

View File

@ -12,8 +12,8 @@
} }
.app-header { .app-header {
border-bottom: 1px solid var(--border-primary); border-bottom: 1px solid #e5e7eb;
background: var(--bg-secondary); background: #ffffff;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
@ -23,19 +23,19 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1rem 2rem; padding: 1rem 2rem;
border-bottom: 1px solid var(--border-primary); border-bottom: 1px solid #f1f5f9;
} }
.app-logo { .app-logo {
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: #1e293b;
text-decoration: none; text-decoration: none;
transition: color 0.2s ease; transition: color 0.2s ease;
} }
.app-logo:hover { .app-logo:hover {
color: var(--text-accent); color: #3b82f6;
} }
.nav-actions { .nav-actions {
@ -56,16 +56,16 @@
} }
.nav-button.primary { .nav-button.primary {
background: var(--btn-primary-bg); background: #3b82f6;
color: var(--btn-primary-text); color: white;
} }
.nav-button.primary:hover { .nav-button.primary:hover {
background: var(--btn-primary-bg-hover); background: #2563eb;
} }
.nav-link { .nav-link {
color: var(--text-secondary); color: #64748b;
text-decoration: none; text-decoration: none;
font-weight: 500; font-weight: 500;
font-size: 0.875rem; font-size: 0.875rem;
@ -73,7 +73,7 @@
} }
.nav-link:hover { .nav-link:hover {
color: var(--text-primary); color: #334155;
} }
.page-title-section { .page-title-section {
@ -83,14 +83,14 @@
.app-header h1 { .app-header h1 {
margin: 0; margin: 0;
color: var(--text-primary); color: #1f2937;
font-size: 2rem; font-size: 2rem;
font-weight: 600; font-weight: 600;
} }
.app-header p { .app-header p {
margin: 0.5rem 0 0 0; margin: 0.5rem 0 0 0;
color: var(--text-tertiary); color: #6b7280;
font-size: 1rem; font-size: 1rem;
} }
@ -105,14 +105,14 @@
.selected-theme-section { .selected-theme-section {
margin-top: 2rem; margin-top: 2rem;
padding: 1.5rem; padding: 1.5rem;
border: 1px solid var(--border-secondary); border: 1px solid #d1d5db;
border-radius: 0.5rem; border-radius: 0.5rem;
background: var(--bg-muted); background: #f9fafb;
} }
.selected-theme-title { .selected-theme-title {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
color: var(--text-primary); color: #1f2937;
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
} }
@ -125,11 +125,11 @@
} }
.selected-theme-item { .selected-theme-item {
color: var(--text-secondary); color: #374151;
} }
.selected-theme-item strong { .selected-theme-item strong {
color: var(--text-primary); color: #1f2937;
font-weight: 600; font-weight: 600;
} }

View File

@ -7,7 +7,7 @@ import { NewPresentationPage } from './components/presentations/NewPresentationP
import { PresentationViewer } from './components/presentations/PresentationViewer.tsx'; import { PresentationViewer } from './components/presentations/PresentationViewer.tsx';
import { PresentationMode } from './components/presentations/PresentationMode.tsx'; import { PresentationMode } from './components/presentations/PresentationMode.tsx';
import { PresentationEditor } from './components/presentations/PresentationEditor.tsx'; import { PresentationEditor } from './components/presentations/PresentationEditor.tsx';
import { SlideEditor } from './components/slide-editor/SlideEditor.tsx'; import { SlideEditor } from './components/presentations/SlideEditor.tsx';
import { PresentationsList } from './components/presentations/PresentationsList.tsx'; import { PresentationsList } from './components/presentations/PresentationsList.tsx';
import { AppHeader } from './components/AppHeader.tsx'; import { AppHeader } from './components/AppHeader.tsx';
import { Welcome } from './components/Welcome.tsx'; import { Welcome } from './components/Welcome.tsx';

View File

@ -1,63 +1,99 @@
import React from 'react'; import React from 'react';
import { Link, useLocation, useParams } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
export const AppHeader: React.FC = () => { export const AppHeader: React.FC = () => {
const location = useLocation(); const location = useLocation();
const params = useParams();
const getPageTitle = () => { const getPageTitle = () => {
const { pathname } = location; if (location.pathname === '/') {
return 'Welcome to Slideshare';
// Route patterns with titles (cleaner than manual parsing) }
const routes = [ if (location.pathname === '/presentations') {
{ pattern: /^\/$/, title: 'Welcome to Slideshare' }, return 'My Presentations';
{ pattern: /^\/presentations$/, title: 'My Presentations' }, }
{ pattern: /^\/presentations\/new$/, title: 'Create New Presentation' }, if (location.pathname === '/presentations/new') {
{ pattern: /^\/presentations\/[^/]+\/edit\/slides\/(\d+)$/, title: () => `Editing Slide ${params.slideNumber}` }, return 'Create New Presentation';
{ pattern: /^\/presentations\/[^/]+\/view\/slides\/(\d+)$/, title: () => `Viewing Slide ${params.slideNumber}` }, }
{ pattern: /^\/presentations\/[^/]+\/present\/(\d+)$/, title: () => `Presenting Slide ${params.slideNumber}` }, if (location.pathname.includes('/slide/') && location.pathname.includes('/edit')) {
{ pattern: /^\/presentations\/[^/]+\/slide\/new\/edit$/, title: 'Add New Slide' }, const segments = location.pathname.split('/');
{ pattern: /^\/presentations\/[^/]+\/slide\/[^/]+\/edit$/, title: 'Edit Slide' }, const slideId = segments[4];
{ pattern: /^\/themes$/, title: 'Theme Browser' }, if (slideId === 'new') {
{ pattern: /^\/themes\/([^/]+)$/, title: () => `Theme: ${params.themeId}` }, return 'Add New Slide';
{ pattern: /^\/themes\/[^/]+\/layouts\/([^/]+)$/, title: () => `Layout: ${params.layoutId}` }, } else {
{ pattern: /^\/themes\/[^/]+\/layouts\/[^/]+\/preview$/, title: () => `Preview Layout` }, return 'Edit Slide';
]; }
}
for (const route of routes) { if (location.pathname.includes('/presentations/') && location.pathname.includes('/slides/')) {
if (route.pattern.test(pathname)) { const segments = location.pathname.split('/');
return typeof route.title === 'function' ? route.title() : route.title; if (segments.length === 6 && segments[1] === 'presentations') {
const mode = segments[3]; // 'edit' or 'view'
const slideNumber = segments[5];
if (mode === 'edit') {
return `Editing Slide ${slideNumber}`;
} else if (mode === 'view') {
return `Viewing Slide ${slideNumber}`;
}
return `Presentation Slide ${slideNumber}`;
}
}
if (location.pathname === '/themes') {
return 'Theme Browser';
}
if (location.pathname.includes('/themes/')) {
const segments = location.pathname.split('/');
if (segments.length === 3) {
return `Theme: ${segments[2]}`;
}
if (segments.length === 5 && segments[3] === 'layouts') {
return `Layout: ${segments[4]}`;
} }
} }
return 'Slideshare'; return 'Slideshare';
}; };
const getPageDescription = () => { const getPageDescription = () => {
const { pathname } = location; if (location.pathname === '/') {
return 'Create beautiful presentations with customizable themes';
// Route patterns with descriptions }
const routes = [ if (location.pathname === '/presentations') {
{ pattern: /^\/$/, description: 'Create beautiful presentations with customizable themes' }, return 'View and manage all your presentations';
{ pattern: /^\/presentations$/, description: 'View and manage all your presentations' }, }
{ pattern: /^\/presentations\/new$/, description: 'Enter details for your new presentation' }, if (location.pathname === '/presentations/new') {
{ pattern: /^\/presentations\/[^/]+\/edit\/slides\/(\d+)$/, description: 'Edit slide content, add notes, and manage your presentation' }, return 'Select a theme and enter details for your new presentation';
{ pattern: /^\/presentations\/[^/]+\/view\/slides\/(\d+)$/, description: 'View your presentation slides in read-only mode' }, }
{ pattern: /^\/presentations\/[^/]+\/present\/(\d+)$/, description: 'Present your slides in full screen mode' }, if (location.pathname.includes('/slide/') && location.pathname.includes('/edit')) {
{ pattern: /^\/presentations\/[^/]+\/slide\/new\/edit$/, description: 'Choose a layout and add content for your new slide' }, const segments = location.pathname.split('/');
{ pattern: /^\/presentations\/[^/]+\/slide\/[^/]+\/edit$/, description: 'Edit slide content and layout' }, const slideId = segments[4];
{ pattern: /^\/themes$/, description: 'Browse and select themes for your presentations' }, if (slideId === 'new') {
{ pattern: /^\/themes\/([^/]+)$/, description: 'View theme details, layouts, and color palette' }, return 'Choose a layout and add content for your new slide';
{ pattern: /^\/themes\/[^/]+\/layouts\/([^/]+)$/, description: 'View layout template, slots, and configuration' }, } else {
{ pattern: /^\/themes\/[^/]+\/layouts\/[^/]+\/preview$/, description: 'Preview layout with sample content' }, return 'Edit slide content and layout';
]; }
}
for (const route of routes) { if (location.pathname.includes('/presentations/') && location.pathname.includes('/slides/')) {
if (route.pattern.test(pathname)) { const segments = location.pathname.split('/');
return route.description; if (segments.length === 6 && segments[1] === 'presentations') {
const mode = segments[3];
if (mode === 'edit') {
return 'Edit slide content, add notes, and manage your presentation';
} else if (mode === 'view') {
return 'View your presentation slides in read-only mode';
}
}
return 'View and manage your presentation slides';
}
if (location.pathname === '/themes') {
return 'Browse and select themes for your presentations';
}
if (location.pathname.includes('/themes/')) {
const segments = location.pathname.split('/');
if (segments.length === 3) {
return 'View theme details, layouts, and color palette';
}
if (segments.length === 5 && segments[3] === 'layouts') {
return 'View layout template, slots, and configuration';
} }
} }
return 'Slide authoring and presentation tool'; return 'Slide authoring and presentation tool';
}; };
@ -74,12 +110,9 @@ export const AppHeader: React.FC = () => {
<Link to="/presentations/new" className="nav-button primary"> <Link to="/presentations/new" className="nav-button primary">
Create Presentation Create Presentation
</Link> </Link>
{/* Theme browsing - Coming soon */} <Link to="/themes" className="nav-link">
<div style={{ display: 'none' }}> Themes
<Link to="/themes" className="nav-link"> </Link>
Themes
</Link>
</div>
</div> </div>
</nav> </nav>
<div className="page-title-section"> <div className="page-title-section">

View File

@ -1,10 +1,7 @@
import React from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Button } from './ui/buttons/Button.tsx';
export const Welcome: React.FC = () => { export const Welcome: React.FC = () => {
const navigate = useNavigate();
return ( return (
<div className="welcome-page"> <div className="welcome-page">
<section className="hero-section"> <section className="hero-section">
@ -13,13 +10,12 @@ export const Welcome: React.FC = () => {
Create beautiful presentations with customizable themes and layouts Create beautiful presentations with customizable themes and layouts
</p> </p>
<div className="hero-actions"> <div className="hero-actions">
<Button <Link to="/presentations/new" className="primary-button">
variant="primary" Create Presentation
size="large" </Link>
onClick={() => navigate('/presentations/new')} <Link to="/themes" className="secondary-button">
> Browse Themes
Get started with a new presentation </Link>
</Button>
</div> </div>
</section> </section>
@ -73,8 +69,7 @@ export const Welcome: React.FC = () => {
<section className="getting-started-section"> <section className="getting-started-section">
<h2 className="section-title">Getting Started</h2> <h2 className="section-title">Getting Started</h2>
<div className="steps-list"> <div className="steps-list">
{/* Theme browsing step - Coming soon */} <div className="step-item">
<div className="step-item" style={{ display: 'none' }}>
<div className="step-number">1</div> <div className="step-number">1</div>
<div className="step-content"> <div className="step-content">
<h3 className="step-title">Browse Themes</h3> <h3 className="step-title">Browse Themes</h3>
@ -85,17 +80,17 @@ export const Welcome: React.FC = () => {
</div> </div>
<div className="step-item"> <div className="step-item">
<div className="step-number">1</div> <div className="step-number">2</div>
<div className="step-content"> <div className="step-content">
<h3 className="step-title">Create Presentation</h3> <h3 className="step-title">Create Presentation</h3>
<p className="step-description"> <p className="step-description">
Start a new presentation with the default theme Start a new presentation using your chosen theme
</p> </p>
</div> </div>
</div> </div>
<div className="step-item"> <div className="step-item">
<div className="step-number">2</div> <div className="step-number">3</div>
<div className="step-content"> <div className="step-content">
<h3 className="step-title">Add Content</h3> <h3 className="step-title">Add Content</h3>
<p className="step-description"> <p className="step-description">
@ -105,7 +100,7 @@ export const Welcome: React.FC = () => {
</div> </div>
<div className="step-item"> <div className="step-item">
<div className="step-number">3</div> <div className="step-number">4</div>
<div className="step-content"> <div className="step-content">
<h3 className="step-title">Present</h3> <h3 className="step-title">Present</h3>
<p className="step-description"> <p className="step-description">
@ -122,13 +117,9 @@ export const Welcome: React.FC = () => {
<p className="cta-description"> <p className="cta-description">
Start building your presentation with our theme collection Start building your presentation with our theme collection
</p> </p>
<Button <Link to="/presentations/new" className="primary-button">
variant="primary"
size="large"
onClick={() => navigate('/presentations/new')}
>
Create Your First Presentation Create Your First Presentation
</Button> </Link>
</div> </div>
</section> </section>
</div> </div>

View File

@ -53,7 +53,7 @@ export const CreationActions: React.FC<CreationActionsProps> = ({
onClick={onCreate} onClick={onCreate}
className="button primary" className="button primary"
type="button" type="button"
disabled={!selectedTheme || presentationTitle.trim().length < 3 || creating} disabled={!selectedTheme || !presentationTitle.trim() || creating}
> >
{creating ? 'Creating...' : 'Create Presentation'} {creating ? 'Creating...' : 'Create Presentation'}
</button> </button>

View File

@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { Theme } from '../../types/theme.ts'; import type { Theme } from '../../types/theme.ts';
import type { AspectRatio } from '../../types/presentation.ts'; import type { AspectRatio } from '../../types/presentation.ts';
import { discoverThemes as getThemes } from '../../utils/themeLoader.ts'; import { getThemes } from '../../themes/index.ts';
import { createPresentation } from '../../utils/presentationStorage.ts'; import { createPresentation } from '../../utils/presentationStorage.ts';
import { loggers } from '../../utils/logger.ts'; import { loggers } from '../../utils/logger.ts';
import { AlertDialog } from '../ui/AlertDialog.tsx'; import { AlertDialog } from '../ui/AlertDialog.tsx';
@ -34,10 +34,9 @@ export const NewPresentationPage: React.FC = () => {
const discoveredThemes = await getThemes(); const discoveredThemes = await getThemes();
setThemes(discoveredThemes); setThemes(discoveredThemes);
// Auto-select default theme or first theme if available // Auto-select first theme if available
if (discoveredThemes.length > 0) { if (discoveredThemes.length > 0) {
const defaultTheme = discoveredThemes.find(theme => theme.id === 'default') || discoveredThemes[0]; setSelectedTheme(discoveredThemes[0]);
setSelectedTheme(defaultTheme);
} }
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load themes'); setError(err instanceof Error ? err.message : 'Failed to load themes');
@ -59,10 +58,10 @@ export const NewPresentationPage: React.FC = () => {
return; return;
} }
if (presentationTitle.trim().length < 3) { if (!presentationTitle.trim()) {
setAlertDialog({ setAlertDialog({
isOpen: true, isOpen: true,
message: 'Please enter a title with at least 3 characters', message: 'Please enter a title for your presentation',
type: 'warning' type: 'warning'
}); });
return; return;
@ -81,8 +80,8 @@ export const NewPresentationPage: React.FC = () => {
loggers.presentation.info('Presentation created successfully', { presentationId: presentation.metadata.id, name: presentation.metadata.name }); loggers.presentation.info('Presentation created successfully', { presentationId: presentation.metadata.id, name: presentation.metadata.name });
// Navigate directly to new slide creation // Navigate to the new presentation editor (slide 1)
navigate(`/presentations/${presentation.metadata.id}/slide/new/edit`); navigate(`/presentations/${presentation.metadata.id}/edit/slides/1`);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create presentation'); setError(err instanceof Error ? err.message : 'Failed to create presentation');
loggers.presentation.error('Failed to create presentation', err instanceof Error ? err : new Error(String(err))); loggers.presentation.error('Failed to create presentation', err instanceof Error ? err : new Error(String(err)));
@ -107,8 +106,8 @@ export const NewPresentationPage: React.FC = () => {
<div className="error-content"> <div className="error-content">
<h2>Error Loading Themes</h2> <h2>Error Loading Themes</h2>
<p>{error}</p> <p>{error}</p>
<button onClick={() => navigate(-1)} className="button secondary"> <button onClick={() => navigate('/themes')} className="button secondary">
Back Back to Themes
</button> </button>
</div> </div>
</div> </div>
@ -119,11 +118,11 @@ export const NewPresentationPage: React.FC = () => {
<div className="new-presentation-page"> <div className="new-presentation-page">
<header className="page-header"> <header className="page-header">
<button <button
onClick={() => navigate(-1)} onClick={() => navigate('/themes')}
className="back-button" className="back-button"
type="button" type="button"
> >
Back Back to Themes
</button> </button>
<div className="header-content"> <div className="header-content">
<h1>Create New Presentation</h1> <h1>Create New Presentation</h1>
@ -140,27 +139,23 @@ export const NewPresentationPage: React.FC = () => {
onDescriptionChange={setPresentationDescription} onDescriptionChange={setPresentationDescription}
/> />
<div style={{ display: 'none' }}> <AspectRatioSelector
<AspectRatioSelector selectedAspectRatio={selectedAspectRatio}
selectedAspectRatio={selectedAspectRatio} onAspectRatioChange={setSelectedAspectRatio}
onAspectRatioChange={setSelectedAspectRatio} />
/>
</div>
<div style={{ display: 'none' }}> <ThemeSelectionSection
<ThemeSelectionSection themes={themes}
themes={themes} selectedTheme={selectedTheme}
selectedTheme={selectedTheme} onThemeSelect={setSelectedTheme}
onThemeSelect={setSelectedTheme} />
/>
</div>
<CreationActions <CreationActions
selectedTheme={selectedTheme} selectedTheme={selectedTheme}
error={error} error={error}
creating={creating} creating={creating}
presentationTitle={presentationTitle} presentationTitle={presentationTitle}
onCancel={() => navigate(-1)} onCancel={() => navigate('/themes')}
onCreate={handleCreatePresentation} onCreate={handleCreatePresentation}
/> />
</div> </div>

View File

@ -1,14 +1,14 @@
.presentation-editor { .presentation-editor {
min-height: 100vh; min-height: 100vh;
background: var(--bg-primary); background: #f8fafc;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
/* Header */ /* Header */
.editor-header { .editor-header {
background: var(--bg-secondary); background: white;
border-bottom: 1px solid var(--border-primary); border-bottom: 1px solid #e2e8f0;
padding: 1rem 2rem; padding: 1rem 2rem;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -26,7 +26,7 @@
} }
.back-link { .back-link {
color: var(--text-secondary); color: #64748b;
text-decoration: none; text-decoration: none;
font-weight: 500; font-weight: 500;
font-size: 0.875rem; font-size: 0.875rem;
@ -37,12 +37,12 @@
} }
.back-link:hover { .back-link:hover {
background: var(--bg-hover); background: #f1f5f9;
color: var(--text-primary); color: #334155;
} }
.presentation-title span { .presentation-title span {
color: var(--text-secondary); color: #64748b;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@ -56,8 +56,8 @@
} }
.theme-badge { .theme-badge {
background: var(--color-blue-100); background: #dbeafe;
color: var(--color-blue-800); color: #1e40af;
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
border-radius: 0.375rem; border-radius: 0.375rem;
font-size: 0.75rem; font-size: 0.75rem;
@ -65,13 +65,13 @@
} }
.slide-counter { .slide-counter {
color: var(--text-tertiary); color: #6b7280;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
} }
.saving-indicator { .saving-indicator {
color: var(--text-warning); color: #f59e0b;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
} }
@ -97,23 +97,23 @@
} }
.action-button.primary { .action-button.primary {
background: var(--btn-primary-bg); background: #3b82f6;
color: var(--btn-primary-text); color: white;
} }
.action-button.primary:hover:not(:disabled) { .action-button.primary:hover:not(:disabled) {
background: var(--btn-primary-bg-hover); background: #2563eb;
} }
.action-button.secondary { .action-button.secondary {
background: var(--btn-secondary-bg); background: #f8fafc;
color: var(--btn-secondary-text); color: #64748b;
border: 1px solid var(--btn-secondary-border); border: 1px solid #e2e8f0;
} }
.action-button.secondary:hover:not(:disabled) { .action-button.secondary:hover:not(:disabled) {
background: var(--btn-secondary-bg-hover); background: #f1f5f9;
color: var(--btn-secondary-text-hover); color: #475569;
} }
.action-button.large { .action-button.large {
@ -149,20 +149,20 @@
.empty-content h2 { .empty-content h2 {
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
color: var(--text-secondary); color: #374151;
font-size: 1.5rem; font-size: 1.5rem;
} }
.empty-content p { .empty-content p {
margin: 0 0 2rem 0; margin: 0 0 2rem 0;
color: var(--text-tertiary); color: #6b7280;
font-size: 1rem; font-size: 1rem;
} }
/* Theme Preview in Empty State */ /* Theme Preview in Empty State */
.theme-preview { .theme-preview {
background: var(--bg-primary); background: #f8fafc;
border: 1px solid var(--border-primary); border: 1px solid #e2e8f0;
border-radius: 0.75rem; border-radius: 0.75rem;
padding: 2rem; padding: 2rem;
margin: 2rem 0; margin: 2rem 0;
@ -175,14 +175,14 @@
.theme-preview h3 { .theme-preview h3 {
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
color: var(--text-primary); color: #1e293b;
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
} }
.theme-description { .theme-description {
margin: 0 0 1.5rem 0; margin: 0 0 1.5rem 0;
color: var(--text-secondary); color: #64748b;
font-size: 0.875rem; font-size: 0.875rem;
} }
@ -195,7 +195,7 @@
.available-layouts h4 { .available-layouts h4 {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
color: var(--text-secondary); color: #374151;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
flex-shrink: 0; flex-shrink: 0;
@ -216,48 +216,48 @@
} }
.layouts-grid::-webkit-scrollbar-track { .layouts-grid::-webkit-scrollbar-track {
background: var(--bg-tertiary); background: #f1f5f9;
border-radius: 3px; border-radius: 3px;
} }
.layouts-grid::-webkit-scrollbar-thumb { .layouts-grid::-webkit-scrollbar-thumb {
background: var(--border-hover); background: #cbd5e1;
border-radius: 3px; border-radius: 3px;
} }
.layouts-grid::-webkit-scrollbar-thumb:hover { .layouts-grid::-webkit-scrollbar-thumb:hover {
background: var(--color-slate-400); background: #94a3b8;
} }
.layout-preview-card { .layout-preview-card {
background: var(--bg-secondary); background: white;
border: 1px solid var(--border-primary); border: 1px solid #e2e8f0;
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 1rem; padding: 1rem;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.layout-preview-card:hover { .layout-preview-card:hover {
border-color: var(--border-hover); border-color: #cbd5e1;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
} }
.layout-name { .layout-name {
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: #1e293b;
font-size: 0.875rem; font-size: 0.875rem;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
.layout-description { .layout-description {
color: var(--text-secondary); color: #64748b;
font-size: 0.75rem; font-size: 0.75rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
line-height: 1.4; line-height: 1.4;
} }
.slot-count { .slot-count {
color: var(--color-emerald-600); color: #059669;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 500; font-weight: 500;
} }
@ -266,11 +266,11 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: var(--bg-tertiary); background: #f1f5f9;
border: 1px dashed var(--border-hover); border: 1px dashed #cbd5e1;
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 1rem; padding: 1rem;
color: var(--text-secondary); color: #64748b;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
} }
@ -285,15 +285,15 @@
/* Slide Sidebar */ /* Slide Sidebar */
.slide-sidebar { .slide-sidebar {
width: 280px; width: 280px;
background: var(--bg-secondary); background: white;
border-right: 1px solid var(--border-primary); border-right: 1px solid #e2e8f0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.sidebar-header { .sidebar-header {
padding: 1rem; padding: 1rem;
border-bottom: 1px solid var(--border-primary); border-bottom: 1px solid #e2e8f0;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@ -303,18 +303,18 @@
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: #1e293b;
} }
.add-slide-button { .add-slide-button {
width: 32px; width: 32px;
height: 32px; height: 32px;
border: 1px solid var(--border-primary); border: 1px solid #e2e8f0;
background: var(--bg-secondary); background: white;
border-radius: 0.375rem; border-radius: 0.375rem;
cursor: pointer; cursor: pointer;
font-size: 1.25rem; font-size: 1.25rem;
color: var(--text-accent); color: #3b82f6;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -322,8 +322,8 @@
} }
.add-slide-button:hover:not(:disabled) { .add-slide-button:hover:not(:disabled) {
background: var(--bg-hover); background: #f8fafc;
border-color: var(--border-accent); border-color: #3b82f6;
} }
.add-slide-button:disabled { .add-slide-button:disabled {
@ -337,13 +337,126 @@
padding: 0.5rem; padding: 0.5rem;
} }
/* Slide thumbnails specific to editor layout - positioning only */
.slide-thumbnail { .slide-thumbnail {
background: white;
border: 2px solid #e2e8f0;
border-radius: 0.5rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
} }
/* Layout-related thumbnail styles moved to SlideThumbnail.css */ .slide-thumbnail:hover {
border-color: #cbd5e1;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.slide-thumbnail.active {
border-color: #3b82f6;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2);
}
.thumbnail-number {
position: absolute;
top: 0.5rem;
left: 0.5rem;
background: #374151;
color: white;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 500;
z-index: 10;
}
.slide-thumbnail.active .thumbnail-number {
background: #3b82f6;
}
.thumbnail-preview {
padding: 2rem 1rem 1rem;
min-height: 80px;
background: #f8fafc;
border-radius: 0.25rem;
margin: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.thumbnail-content {
text-align: center;
}
.layout-name {
display: block;
font-size: 0.75rem;
font-weight: 500;
color: #374151;
margin-bottom: 0.25rem;
}
.content-count {
display: block;
font-size: 0.625rem;
color: #6b7280;
}
.thumbnail-actions {
display: flex;
gap: 0.25rem;
padding: 0.5rem;
justify-content: center;
border-top: 1px solid #f1f5f9;
}
.thumbnail-action {
width: 28px;
height: 28px;
border: 1px solid #e2e8f0;
background: white;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
color: #6b7280;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.thumbnail-action:hover:not(:disabled) {
background: #f8fafc;
border-color: #cbd5e1;
}
.thumbnail-action.edit {
background: #eff6ff;
border-color: #3b82f6;
color: #3b82f6;
}
.thumbnail-action.edit:hover:not(:disabled) {
background: #dbeafe;
border-color: #2563eb;
color: #2563eb;
}
.thumbnail-action.delete:hover:not(:disabled) {
background: #fef2f2;
border-color: #fecaca;
color: #dc2626;
}
.thumbnail-action:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Slide Editor Area */ /* Slide Editor Area */
.slide-editor-area { .slide-editor-area {
@ -373,7 +486,7 @@
margin: 0; margin: 0;
font-size: 1.125rem; font-size: 1.125rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: #1e293b;
} }
.slide-controls { .slide-controls {
@ -383,9 +496,9 @@
.control-button { .control-button {
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
border: 1px solid var(--border-primary); border: 1px solid #e2e8f0;
background: var(--bg-secondary); background: white;
color: var(--text-secondary); color: #374151;
border-radius: 0.375rem; border-radius: 0.375rem;
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
@ -394,8 +507,8 @@
} }
.control-button:hover:not(:disabled) { .control-button:hover:not(:disabled) {
background: var(--bg-hover); background: #f8fafc;
border-color: var(--border-hover); border-color: #cbd5e1;
} }
.control-button:disabled { .control-button:disabled {
@ -404,15 +517,15 @@
} }
.control-button.edit-slide-button { .control-button.edit-slide-button {
background: var(--btn-primary-bg); background: #3b82f6;
color: var(--btn-primary-text); color: white;
border-color: var(--btn-primary-border); border-color: #3b82f6;
font-weight: 600; font-weight: 600;
} }
.control-button.edit-slide-button:hover:not(:disabled) { .control-button.edit-slide-button:hover:not(:disabled) {
background: var(--btn-primary-bg-hover); background: #2563eb;
border-color: var(--btn-primary-border-hover); border-color: #2563eb;
} }
.slide-content-editor { .slide-content-editor {
@ -424,8 +537,8 @@
.content-preview { .content-preview {
flex: 1; flex: 1;
background: var(--bg-secondary); background: white;
border: 1px solid var(--border-primary); border: 1px solid #e2e8f0;
border-radius: 0.75rem; border-radius: 0.75rem;
padding: 2rem; padding: 2rem;
min-height: 400px; min-height: 400px;
@ -433,12 +546,12 @@
.editor-placeholder { .editor-placeholder {
text-align: center; text-align: center;
color: var(--text-tertiary); color: #6b7280;
} }
.editor-placeholder h4 { .editor-placeholder h4 {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
color: var(--text-secondary); color: #374151;
} }
.editor-placeholder p { .editor-placeholder p {
@ -453,32 +566,32 @@
.content-slot { .content-slot {
margin-bottom: 1rem; margin-bottom: 1rem;
padding: 0.75rem; padding: 0.75rem;
background: var(--bg-primary); background: #f8fafc;
border-radius: 0.375rem; border-radius: 0.375rem;
} }
.content-slot label { .content-slot label {
display: block; display: block;
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: #374151;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
font-size: 0.875rem; font-size: 0.875rem;
} }
.slot-content { .slot-content {
color: var(--text-tertiary); color: #6b7280;
font-size: 0.875rem; font-size: 0.875rem;
} }
.placeholder-note { .placeholder-note {
font-style: italic; font-style: italic;
color: var(--text-muted); color: #9ca3af;
margin-top: 1.5rem; margin-top: 1.5rem;
} }
.slide-notes-editor { .slide-notes-editor {
background: var(--bg-secondary); background: white;
border: 1px solid var(--border-primary); border: 1px solid #e2e8f0;
border-radius: 0.75rem; border-radius: 0.75rem;
padding: 1rem; padding: 1rem;
} }
@ -487,18 +600,18 @@
margin: 0 0 0.75rem 0; margin: 0 0 0.75rem 0;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
color: var(--text-secondary); color: #374151;
} }
.notes-textarea { .notes-textarea {
width: 100%; width: 100%;
min-height: 80px; min-height: 80px;
padding: 0.75rem; padding: 0.75rem;
border: 1px solid var(--border-secondary); border: 1px solid #d1d5db;
border-radius: 0.5rem; border-radius: 0.5rem;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-secondary); color: #374151;
background: var(--bg-secondary); background: white;
box-sizing: border-box; box-sizing: border-box;
resize: vertical; resize: vertical;
font-family: inherit; font-family: inherit;
@ -506,7 +619,7 @@
.notes-textarea:focus { .notes-textarea:focus {
outline: none; outline: none;
border-color: var(--border-focus); border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
} }
@ -517,7 +630,7 @@
justify-content: center; justify-content: center;
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
color: var(--text-error); color: #dc2626;
} }
/* Loading and Error States */ /* Loading and Error States */
@ -534,19 +647,19 @@
} }
.loading-spinner { .loading-spinner {
color: var(--text-secondary); color: #64748b;
font-size: 1.125rem; font-size: 1.125rem;
} }
.error-content h2, .error-content h2,
.not-found-content h2 { .not-found-content h2 {
color: var(--text-error); color: #dc2626;
margin: 0; margin: 0;
} }
.error-content p, .error-content p,
.not-found-content p { .not-found-content p {
color: var(--text-secondary); color: #64748b;
margin: 0.5rem 0 1.5rem 0; margin: 0.5rem 0 1.5rem 0;
} }
@ -593,7 +706,7 @@
width: 100%; width: 100%;
max-height: 200px; max-height: 200px;
border-right: none; border-right: none;
border-bottom: 1px solid var(--border-primary); border-bottom: 1px solid #e2e8f0;
} }
.slides-list { .slides-list {

View File

@ -2,8 +2,8 @@ import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom'; import { useParams, useNavigate, Link } from 'react-router-dom';
import type { Presentation } from '../../types/presentation.ts'; import type { Presentation } from '../../types/presentation.ts';
import type { Theme } from '../../types/theme.ts'; import type { Theme } from '../../types/theme.ts';
import { getPresentationById } from '../../utils/presentationStorage.ts'; import { getPresentationById, updatePresentation } from '../../utils/presentationStorage.ts';
import { loadTheme } from '../../utils/themeLoader.ts'; import { getTheme } from '../../themes/index.ts';
import { useDialog } from '../../hooks/useDialog.ts'; import { useDialog } from '../../hooks/useDialog.ts';
import { AlertDialog } from '../ui/AlertDialog.tsx'; import { AlertDialog } from '../ui/AlertDialog.tsx';
import { ConfirmDialog } from '../ui/ConfirmDialog.tsx'; import { ConfirmDialog } from '../ui/ConfirmDialog.tsx';
@ -69,7 +69,7 @@ export const PresentationEditor: React.FC = () => {
setPresentation(presentationData); setPresentation(presentationData);
// Load theme // Load theme
const themeData = await loadTheme(presentationData.metadata.theme, false); const themeData = await getTheme(presentationData.metadata.theme);
if (!themeData) { if (!themeData) {
setError(`Theme not found: ${presentationData.metadata.theme}`); setError(`Theme not found: ${presentationData.metadata.theme}`);
return; return;
@ -115,8 +115,8 @@ export const PresentationEditor: React.FC = () => {
<ErrorState <ErrorState
title="Error Loading Presentation" title="Error Loading Presentation"
message={error} message={error}
backLink="/presentations" backLink="/themes"
backText="← Back to Presentations" backText="← Back to Themes"
/> />
</div> </div>
); );
@ -128,8 +128,8 @@ export const PresentationEditor: React.FC = () => {
<ErrorState <ErrorState
title="Presentation Not Found" title="Presentation Not Found"
message="The requested presentation could not be found." message="The requested presentation could not be found."
backLink="/presentations" backLink="/themes"
backText="← Back to Presentations" backText="← Back to Themes"
/> />
</div> </div>
); );
@ -142,7 +142,7 @@ export const PresentationEditor: React.FC = () => {
<div className="presentation-editor"> <div className="presentation-editor">
<header className="editor-header"> <header className="editor-header">
<div className="presentation-info"> <div className="presentation-info">
<Link to="/presentations" className="back-link"> Back to Presentations</Link> <Link to="/themes" className="back-link"> Back</Link>
<div className="presentation-title"> <div className="presentation-title">
<span>{presentation.metadata.name}</span> <span>{presentation.metadata.name}</span>
{presentation.metadata.description && ( {presentation.metadata.description && (

View File

@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import type { Presentation, SlideContent } from '../../types/presentation.ts'; import type { Presentation, SlideContent } from '../../types/presentation.ts';
import type { Theme } from '../../types/theme.ts'; import type { Theme } from '../../types/theme.ts';
import { getPresentationById } from '../../utils/presentationStorage.ts'; import { getPresentationById } from '../../utils/presentationStorage.ts';
import { loadTheme } from '../../utils/themeLoader.ts'; import { getTheme } from '../../themes/index.ts';
import { renderTemplateWithSampleData } from '../../utils/templateRenderer.ts'; import { renderTemplateWithSampleData } from '../../utils/templateRenderer.ts';
import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer.ts'; import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer.ts';
import { loggers } from '../../utils/logger.ts'; import { loggers } from '../../utils/logger.ts';
@ -116,7 +116,7 @@ export const PresentationMode: React.FC = () => {
setPresentation(presentationData); setPresentation(presentationData);
// Load theme // Load theme
const themeData = await loadTheme(presentationData.metadata.theme, false); const themeData = await getTheme(presentationData.metadata.theme);
if (!themeData) { if (!themeData) {
setError(`Theme not found: ${presentationData.metadata.theme}`); setError(`Theme not found: ${presentationData.metadata.theme}`);
return; return;
@ -156,8 +156,7 @@ export const PresentationMode: React.FC = () => {
}, [presentationId]); }, [presentationId]);
const exitPresentationMode = () => { const exitPresentationMode = () => {
// Navigate back to the previous page in history navigate(`/presentations/${presentationId}/view/slides/${currentSlideIndex + 1}`);
navigate(-1);
}; };
const renderSlideContent = (slide: SlideContent): string => { const renderSlideContent = (slide: SlideContent): string => {

View File

@ -3,7 +3,7 @@ import { useParams, useNavigate, Link } from 'react-router-dom';
import type { Presentation } from '../../types/presentation.ts'; import type { Presentation } from '../../types/presentation.ts';
import type { Theme } from '../../types/theme.ts'; import type { Theme } from '../../types/theme.ts';
import { getPresentationById } from '../../utils/presentationStorage.ts'; import { getPresentationById } from '../../utils/presentationStorage.ts';
import { loadTheme } from '../../utils/themeLoader.ts'; import { getTheme } from '../../themes/index.ts';
import './PresentationViewer.css'; import './PresentationViewer.css';
export const PresentationViewer: React.FC = () => { export const PresentationViewer: React.FC = () => {
@ -41,7 +41,7 @@ export const PresentationViewer: React.FC = () => {
setPresentation(presentationData); setPresentation(presentationData);
// Load theme // Load theme
const themeData = await loadTheme(presentationData.metadata.theme, false); const themeData = await getTheme(presentationData.metadata.theme);
if (!themeData) { if (!themeData) {
setError(`Theme not found: ${presentationData.metadata.theme}`); setError(`Theme not found: ${presentationData.metadata.theme}`);
return; return;

View File

@ -1,14 +1,14 @@
.presentations-list { .presentations-list {
min-height: 100vh; min-height: 100vh;
background: var(--bg-primary); background: #f8fafc;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
/* Header */ /* Header */
.list-header { .list-header {
background: var(--bg-secondary); background: white;
border-bottom: 1px solid var(--border-primary); border-bottom: 1px solid #e2e8f0;
padding: 2rem; padding: 2rem;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -26,12 +26,12 @@
margin: 0; margin: 0;
font-size: 2rem; font-size: 2rem;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: #1e293b;
} }
.header-content p { .header-content p {
margin: 0.5rem 0 0 0; margin: 0.5rem 0 0 0;
color: var(--text-secondary); color: #64748b;
font-size: 1rem; font-size: 1rem;
} }
@ -101,13 +101,13 @@
.empty-content h2 { .empty-content h2 {
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
color: var(--text-secondary); color: #374151;
font-size: 1.5rem; font-size: 1.5rem;
} }
.empty-content p { .empty-content p {
margin: 0 0 2rem 0; margin: 0 0 2rem 0;
color: var(--text-tertiary); color: #6b7280;
font-size: 1rem; font-size: 1rem;
} }
@ -120,8 +120,8 @@
} }
.presentation-card { .presentation-card {
background: var(--bg-secondary); background: white;
border: 1px solid var(--border-primary); border: 1px solid #e2e8f0;
border-radius: 0.75rem; border-radius: 0.75rem;
overflow: hidden; overflow: hidden;
transition: all 0.2s ease; transition: all 0.2s ease;
@ -130,7 +130,7 @@
} }
.presentation-card:hover { .presentation-card:hover {
border-color: var(--border-hover); border-color: #cbd5e1;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
} }
@ -152,7 +152,7 @@
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: #1e293b;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@ -160,7 +160,7 @@
.presentation-description { .presentation-description {
margin: 0; margin: 0;
color: var(--text-secondary); color: #64748b;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.4; line-height: 1.4;
display: -webkit-box; display: -webkit-box;
@ -178,8 +178,8 @@
.action-icon { .action-icon {
width: 32px; width: 32px;
height: 32px; height: 32px;
border: 1px solid var(--border-primary); border: 1px solid #e2e8f0;
background: var(--bg-secondary); background: white;
border-radius: 0.375rem; border-radius: 0.375rem;
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 0.875rem;
@ -190,13 +190,13 @@
} }
.action-icon:hover:not(:disabled) { .action-icon:hover:not(:disabled) {
background: var(--bg-hover); background: #f8fafc;
border-color: var(--border-hover); border-color: #cbd5e1;
} }
.action-icon.delete:hover:not(:disabled) { .action-icon.delete:hover:not(:disabled) {
background: var(--bg-error); background: #fef2f2;
border-color: var(--border-error); border-color: #fecaca;
} }
.action-icon:disabled { .action-icon:disabled {
@ -226,7 +226,7 @@
.stat-label { .stat-label {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 500; font-weight: 500;
color: var(--text-tertiary); color: #6b7280;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
@ -234,12 +234,12 @@
.stat-value { .stat-value {
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: #374151;
} }
.theme-name { .theme-name {
background: var(--color-blue-100); background: #dbeafe;
color: var(--color-blue-800); color: #1e40af;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
border-radius: 0.25rem; border-radius: 0.25rem;
font-size: 0.75rem; font-size: 0.75rem;
@ -247,7 +247,7 @@
} }
.slides-count { .slides-count {
color: var(--color-emerald-600); color: #059669;
font-weight: 600; font-weight: 600;
} }
@ -256,7 +256,7 @@
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
padding: 1rem 0; padding: 1rem 0;
border-top: 1px solid var(--border-primary); border-top: 1px solid #f1f5f9;
} }
.meta-item { .meta-item {
@ -267,19 +267,19 @@
.meta-label { .meta-label {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-tertiary); color: #6b7280;
} }
.meta-value { .meta-value {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-secondary); color: #374151;
font-weight: 500; font-weight: 500;
} }
/* Card Footer */ /* Card Footer */
.card-footer { .card-footer {
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
border-top: 1px solid var(--border-primary); border-top: 1px solid #f1f5f9;
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
} }
@ -322,14 +322,14 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 2rem 0; padding: 2rem 0;
border-top: 1px solid var(--border-primary); border-top: 1px solid #e2e8f0;
gap: 1rem; gap: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.summary-stats p { .summary-stats p {
margin: 0; margin: 0;
color: var(--text-tertiary); color: #6b7280;
font-size: 0.875rem; font-size: 0.875rem;
} }
@ -351,17 +351,17 @@
} }
.loading-spinner { .loading-spinner {
color: var(--text-secondary); color: #64748b;
font-size: 1.125rem; font-size: 1.125rem;
} }
.error-content h2 { .error-content h2 {
color: var(--text-error); color: #dc2626;
margin: 0; margin: 0;
} }
.error-content p { .error-content p {
color: var(--text-secondary); color: #64748b;
margin: 0.5rem 0 1.5rem 0; margin: 0.5rem 0 1.5rem 0;
} }

View File

@ -1,11 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import type { Presentation } from '../../types/presentation.ts'; import type { Presentation } from '../../types/presentation.ts';
import { getAllPresentations, deletePresentation } from '../../utils/presentationStorage.ts'; import { getAllPresentations, deletePresentation } from '../../utils/presentationStorage.ts';
import { loggers } from '../../utils/logger.ts'; import { loggers } from '../../utils/logger.ts';
import { ConfirmDialog } from '../ui/ConfirmDialog.tsx';
import { AlertDialog } from '../ui/AlertDialog.tsx';
import { Button } from '../ui/buttons/Button.tsx';
import './PresentationsList.css'; import './PresentationsList.css';
export const PresentationsList: React.FC = () => { export const PresentationsList: React.FC = () => {
@ -15,19 +12,6 @@ export const PresentationsList: React.FC = () => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(null); const [deleting, setDeleting] = useState<string | null>(null);
// Dialog states
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
presentationId: string;
presentationName: string;
}>({ isOpen: false, presentationId: '', presentationName: '' });
const [alertDialog, setAlertDialog] = useState<{
isOpen: boolean;
message: string;
type?: 'error' | 'success';
}>({ isOpen: false, message: '' });
useEffect(() => { useEffect(() => {
loadPresentations(); loadPresentations();
}, []); }, []);
@ -45,34 +29,18 @@ export const PresentationsList: React.FC = () => {
} }
}; };
const handleDeletePresentation = (id: string, name: string) => { const handleDeletePresentation = async (id: string, name: string) => {
setConfirmDialog({ if (!confirm(`Are you sure you want to delete "${name}"? This action cannot be undone.`)) {
isOpen: true, return;
presentationId: id, }
presentationName: name
});
};
const confirmDeletePresentation = async () => {
const { presentationId } = confirmDialog;
setConfirmDialog({ isOpen: false, presentationId: '', presentationName: '' });
try { try {
setDeleting(presentationId); setDeleting(id);
await deletePresentation(presentationId); await deletePresentation(id);
setPresentations(prev => prev.filter(p => p.metadata.id !== presentationId)); setPresentations(prev => prev.filter(p => p.metadata.id !== id));
setAlertDialog({
isOpen: true,
message: 'Presentation deleted successfully',
type: 'success'
});
} catch (err) { } catch (err) {
loggers.presentation.error('Failed to delete presentation', err instanceof Error ? err : new Error(String(err))); loggers.presentation.error('Failed to delete presentation', err instanceof Error ? err : new Error(String(err)));
setAlertDialog({ alert('Failed to delete presentation. Please try again.');
isOpen: true,
message: 'Failed to delete presentation. Please try again.',
type: 'error'
});
} finally { } finally {
setDeleting(null); setDeleting(null);
} }
@ -132,6 +100,14 @@ export const PresentationsList: React.FC = () => {
<h1>My Presentations</h1> <h1>My Presentations</h1>
<p>Manage and organize your presentation library</p> <p>Manage and organize your presentation library</p>
</div> </div>
<div className="header-actions">
<Link
to="/presentations/new"
className="action-button primary"
>
Create New Presentation
</Link>
</div>
</header> </header>
<main className="list-content"> <main className="list-content">
@ -140,13 +116,12 @@ export const PresentationsList: React.FC = () => {
<div className="empty-content"> <div className="empty-content">
<h2>No presentations yet</h2> <h2>No presentations yet</h2>
<p>Create your first presentation to get started</p> <p>Create your first presentation to get started</p>
<Button <Link
variant="primary" to="/presentations/new"
size="large" className="action-button primary large"
onClick={() => navigate('/presentations/new')}
> >
Create Your First Presentation Create Your First Presentation
</Button> </Link>
</div> </div>
</div> </div>
) : ( ) : (
@ -258,36 +233,22 @@ export const PresentationsList: React.FC = () => {
</p> </p>
</div> </div>
<div className="footer-actions"> <div className="footer-actions">
<Button <button
variant="primary" onClick={loadPresentations}
onClick={() => navigate('/presentations/new')} className="action-button secondary"
>
Refresh
</button>
<Link
to="/presentations/new"
className="action-button primary"
> >
Create New Create New
</Button> </Link>
</div> </div>
</div> </div>
)} )}
</main> </main>
{/* Confirmation Dialog for Deletion */}
<ConfirmDialog
isOpen={confirmDialog.isOpen}
onClose={() => setConfirmDialog({ isOpen: false, presentationId: '', presentationName: '' })}
onConfirm={confirmDeletePresentation}
title="Delete Presentation"
message={`Are you sure you want to delete "${confirmDialog.presentationName}"? This action cannot be undone.`}
type="danger"
confirmText="Delete"
isDestructive={true}
/>
{/* Alert Dialog for Success/Error Messages */}
<AlertDialog
isOpen={alertDialog.isOpen}
onClose={() => setAlertDialog({ isOpen: false, message: '' })}
message={alertDialog.message}
type={alertDialog.type}
/>
</div> </div>
); );
}; };

View File

@ -0,0 +1,740 @@
.slide-editor {
min-height: 100vh;
background: #f8fafc;
display: flex;
flex-direction: column;
}
/* Header */
.slide-editor-header {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 0.75rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1.5rem;
flex-wrap: wrap;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.editor-info {
display: flex;
align-items: center;
gap: 1rem;
flex: 1;
min-width: 0;
}
.back-button {
background: none;
border: none;
color: #64748b;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
transition: all 0.2s ease;
}
.back-button:hover {
background: #f1f5f9;
color: #334155;
}
.editor-title {
flex: 1;
min-width: 0;
}
.editor-title h1 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #1e293b;
}
.editor-title p {
margin: 0.125rem 0 0 0;
color: #64748b;
font-size: 0.75rem;
}
.editor-actions {
display: flex;
gap: 0.75rem;
flex-shrink: 0;
}
.preview-button {
display: flex;
align-items: center;
gap: 0.5rem;
}
.action-button {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
font-size: 0.875rem;
border: none;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.action-button.primary {
background: #3b82f6;
color: white;
}
.action-button.primary:hover:not(:disabled) {
background: #2563eb;
}
.action-button.secondary {
background: #f8fafc;
color: #64748b;
border: 1px solid #e2e8f0;
}
.action-button.secondary:hover:not(:disabled) {
background: #f1f5f9;
color: #475569;
}
.action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Main Content */
.slide-editor-content {
flex: 1;
padding: 2rem;
}
/* Step Header */
.step-header {
text-align: center;
margin-bottom: 1.5rem;
}
.step-header h2 {
margin: 0 0 0.25rem 0;
font-size: 1.25rem;
font-weight: 600;
color: #1e293b;
}
.step-header p {
margin: 0;
color: #64748b;
font-size: 0.875rem;
}
/* Layout Selection */
.layout-selection {
position: fixed;
top: 80px;
left: 0;
right: 0;
bottom: 0;
background: #f8fafc;
z-index: 10;
overflow-y: auto;
padding: 2rem;
box-sizing: border-box;
}
.layouts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1.5rem;
}
.layout-card {
background: white;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
}
.layout-card:hover {
border-color: #cbd5e1;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.layout-card.selected {
border-color: #3b82f6;
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.2);
}
.layout-preview {
height: 300px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
overflow: hidden;
position: relative;
}
.layout-rendered {
transform: scale(0.4);
transform-origin: top left;
width: 250%;
height: 250%;
pointer-events: none;
}
.layout-info {
padding: 1.5rem;
}
.layout-info h3 {
margin: 0 0 0.5rem 0;
font-size: 1.125rem;
font-weight: 600;
color: #1e293b;
}
.layout-info p {
margin: 0 0 1rem 0;
color: #64748b;
font-size: 0.875rem;
line-height: 1.4;
}
.layout-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.slot-count {
font-size: 0.75rem;
font-weight: 500;
color: #6b7280;
background: #f1f5f9;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.slot-types {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.slot-type-badge {
font-size: 0.625rem;
font-weight: 500;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
text-transform: capitalize;
}
.slot-type-badge.title {
background-color: #fef3c7;
color: #92400e;
}
.slot-type-badge.subtitle {
background-color: #e0e7ff;
color: #3730a3;
}
.slot-type-badge.text {
background-color: #d1fae5;
color: #047857;
}
.slot-type-badge.image {
background-color: #fce7f3;
color: #be185d;
}
.slot-type-badge.video {
background-color: #ddd6fe;
color: #6b21a8;
}
.slot-type-badge.list {
background-color: #fed7d7;
color: #c53030;
}
/* Content Editing */
.content-editing {
/* Use absolute positioning to break out of all DOM constraints */
position: fixed;
top: 80px;
left: 0;
right: 0;
bottom: 0;
background: #f8fafc;
z-index: 10;
overflow: hidden;
}
.editing-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
align-items: stretch;
height: 100%;
width: 100%;
padding: 2rem;
box-sizing: border-box;
max-width: none;
}
.content-form {
background: white;
border-radius: 0.75rem;
border: 1px solid #e2e8f0;
padding: 1.5rem;
overflow-y: auto;
height: 100%;
display: flex;
flex-direction: column;
}
.content-fields {
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
overflow-y: auto;
min-height: 0;
}
.content-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-label {
font-weight: 500;
color: #374151;
font-size: 0.875rem;
}
.required {
color: #dc2626;
margin-left: 0.25rem;
}
.field-input,
.field-textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
color: #374151;
background: white;
box-sizing: border-box;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.field-input:focus,
.field-textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.field-textarea {
resize: vertical;
min-height: 80px;
font-family: inherit;
}
.content-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
margin-top: 1rem;
}
.action-links {
display: flex;
align-items: center;
}
.action-buttons {
display: flex;
gap: 1rem;
}
.cancel-link {
background: none;
border: none;
color: #64748b;
font-size: 0.875rem;
font-weight: 400;
text-decoration: underline;
cursor: pointer;
padding: 0;
transition: color 0.2s ease;
}
.cancel-link:hover:not(:disabled) {
color: #374151;
text-decoration: underline;
}
.cancel-link:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.content-actions .action-button {
padding: 0.75rem 1.5rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
border: none;
cursor: pointer;
transition: all 0.2s ease;
min-width: 120px;
}
.content-actions .action-button.primary {
background: #3b82f6;
color: white;
}
.content-actions .action-button.primary:hover:not(:disabled) {
background: #2563eb;
}
.content-actions .action-button.secondary {
background: #f8fafc;
color: #64748b;
border: 1px solid #e2e8f0;
}
.content-actions .action-button.secondary:hover:not(:disabled) {
background: #f1f5f9;
color: #475569;
}
.content-actions .action-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.field-hint {
margin: 0;
font-size: 0.75rem;
color: #6b7280;
font-style: italic;
}
/* Content Preview */
.content-preview {
background: white;
border-radius: 0.75rem;
border: 1px solid #e2e8f0;
padding: 1rem;
height: 100%;
display: flex;
flex-direction: column;
}
.content-preview h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
font-weight: 600;
color: #1e293b;
text-align: center;
}
.preview-description {
margin: 0 0 1rem 0;
font-size: 0.75rem;
color: #64748b;
text-align: center;
font-style: italic;
}
.preview-container {
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
background: white;
overflow: hidden;
flex: 1;
display: flex;
flex-direction: column;
}
.slide-preview-wrapper {
background: #f8fafc;
padding: 1rem;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
position: relative;
/* Ensure this container doesn't exceed available space */
max-height: 100%;
overflow: hidden;
}
/* Use the global aspect ratio classes for proper slide display */
.slide-preview-wrapper .slide-container {
/* Use a width-based approach and let aspect-ratio handle height */
width: min(80%, 70vw);
max-height: 60vh;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
border-radius: 0.5rem;
background: white;
border: 1px solid #e2e8f0;
overflow: hidden;
}
/* Override global aspect ratio classes for preview context */
.slide-preview-wrapper .slide-container.aspect-16-9 {
aspect-ratio: 16 / 9;
width: min(80%, min(70vw, 60vh * (16/9)));
}
.slide-preview-wrapper .slide-container.aspect-4-3 {
aspect-ratio: 4 / 3;
width: min(80%, min(70vw, 60vh * (4/3)));
}
.slide-preview-wrapper .slide-container.aspect-16-10 {
aspect-ratio: 16 / 10;
width: min(80%, min(70vw, 60vh * (16/10)));
}
.preview-meta {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
flex-wrap: wrap;
gap: 0.5rem;
}
.layout-name {
font-size: 0.75rem;
font-weight: 500;
color: #374151;
}
.aspect-ratio-info {
font-size: 0.625rem;
color: #3b82f6;
background: #eff6ff;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-weight: 500;
}
.content-count {
font-size: 0.625rem;
color: #6b7280;
background: #e5e7eb;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
/* Error State */
.editor-error {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 0.5rem;
padding: 1rem;
margin-top: 1rem;
color: #dc2626;
text-align: center;
}
.editor-error p {
margin: 0;
font-weight: 500;
}
/* Loading and Error States */
.loading-content,
.error-content,
.not-found-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 50vh;
text-align: center;
gap: 1rem;
}
.loading-spinner {
color: #64748b;
font-size: 1.125rem;
}
.error-content h2,
.not-found-content h2 {
color: #dc2626;
margin: 0;
}
.error-content p,
.not-found-content p {
color: #64748b;
margin: 0.5rem 0 1.5rem 0;
}
.back-link {
color: #3b82f6;
text-decoration: none;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
border: 1px solid #e2e8f0;
transition: all 0.2s ease;
}
.back-link:hover {
background: #eff6ff;
border-color: #3b82f6;
}
/* Responsive Design */
@media (max-width: 1024px) {
.content-editing {
top: 60px;
}
.layout-selection {
top: 60px;
}
.editing-layout {
grid-template-columns: 1fr;
gap: 1.5rem;
padding: 1rem;
}
.content-preview {
height: 300px;
min-height: 300px;
}
.slide-preview-wrapper {
padding: 0.5rem;
}
/* Adjust viewport calculations for smaller screens */
.slide-preview-wrapper .slide-container {
width: min(90%, 90vw);
max-height: 250px;
}
.slide-preview-wrapper .slide-container.aspect-16-9,
.slide-preview-wrapper .slide-container.aspect-4-3,
.slide-preview-wrapper .slide-container.aspect-16-10 {
width: min(90%, min(90vw, 250px * var(--aspect-multiplier, 1.78)));
}
}
@media (max-width: 768px) {
.content-editing {
top: 50px;
}
.layout-selection {
top: 50px;
padding: 1rem;
}
.slide-editor-header {
padding: 0.5rem 1rem;
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
}
.editor-info {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.editor-actions {
justify-content: stretch;
}
.action-button {
flex: 1;
}
.slide-editor-content {
padding: 1rem;
}
.layouts-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.content-form {
padding: 1rem;
}
.content-actions {
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
}
.action-links {
justify-content: center;
order: 2;
}
.action-buttons {
justify-content: stretch;
order: 1;
}
.action-buttons .action-button {
flex: 1;
}
.preview-container {
min-height: 200px;
}
}
/* Image slot field integration */
.content-field .image-slot-field {
margin-top: 0.5rem;
border: 1px solid #e2e8f0;
background: white;
}
.content-field .image-slot-field:focus-within {
border-color: #3b82f6;
}

View File

@ -0,0 +1,470 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import type { Presentation, SlideContent } from '../../types/presentation.ts';
import type { Theme, SlideLayout } from '../../types/theme.ts';
import { getPresentationById, updatePresentation } from '../../utils/presentationStorage.ts';
import { getTheme } from '../../themes/index.ts';
import { renderTemplateWithSampleData } from '../../utils/templateRenderer.ts';
import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer.ts';
import { loggers } from '../../utils/logger.ts';
import { SlidePreviewModal } from './SlidePreviewModal.tsx';
import { ImageUploadField } from '../ui/ImageUploadField.tsx';
import './SlideEditor.css';
export const SlideEditor: React.FC = () => {
const { presentationId, slideId } = useParams<{
presentationId: string;
slideId: string;
}>();
const navigate = useNavigate();
const [presentation, setPresentation] = useState<Presentation | null>(null);
const [theme, setTheme] = useState<Theme | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const isEditingExisting = slideId !== 'new';
// Editor state
const [selectedLayout, setSelectedLayout] = useState<SlideLayout | null>(null);
const [slideContent, setSlideContent] = useState<Record<string, string>>({});
const [slideNotes, setSlideNotes] = useState('');
const [currentStep, setCurrentStep] = useState<'layout' | 'content'>(isEditingExisting ? 'content' : 'layout');
const existingSlide = isEditingExisting && presentation
? presentation.slides.find(s => s.id === slideId)
: null;
useEffect(() => {
const loadPresentationAndTheme = async () => {
if (!presentationId) {
setError('No presentation ID provided');
setLoading(false);
return;
}
try {
setLoading(true);
// Load presentation
const presentationData = await getPresentationById(presentationId);
if (!presentationData) {
setError(`Presentation not found: ${presentationId}`);
return;
}
setPresentation(presentationData);
// Load theme
const themeData = await getTheme(presentationData.metadata.theme);
if (!themeData) {
setError(`Theme not found: ${presentationData.metadata.theme}`);
return;
}
setTheme(themeData);
// If editing existing slide, populate data
if (isEditingExisting && slideId !== 'new') {
const slide = presentationData.slides.find(s => s.id === slideId);
if (slide) {
const layout = themeData.layouts.find(l => l.id === slide.layoutId);
if (layout) {
setSelectedLayout(layout);
setSlideContent(slide.content);
setSlideNotes(slide.notes || '');
// No need to set currentStep here since it's already 'content' for existing slides
}
} else {
setError(`Slide not found: ${slideId}`);
return;
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load presentation');
} finally {
setLoading(false);
}
};
loadPresentationAndTheme();
}, [presentationId, slideId, isEditingExisting]);
// Load theme CSS for layout previews
useEffect(() => {
if (theme) {
const themeStyleId = 'slide-editor-theme-style';
const existingStyle = document.getElementById(themeStyleId);
if (existingStyle) {
existingStyle.remove();
}
const link = document.createElement('link');
link.id = themeStyleId;
link.rel = 'stylesheet';
link.href = `${theme.basePath}/${theme.cssFile}`;
document.head.appendChild(link);
return () => {
const styleToRemove = document.getElementById(themeStyleId);
if (styleToRemove) {
styleToRemove.remove();
}
};
}
}, [theme]);
const selectLayout = (layout: SlideLayout) => {
setSelectedLayout(layout);
// Initialize content with empty values for all slots
const initialContent: Record<string, string> = {};
layout.slots.forEach(slot => {
initialContent[slot.id] = slideContent[slot.id] || '';
});
setSlideContent(initialContent);
// Automatically move to content editing after layout selection
setCurrentStep('content');
};
const updateSlotContent = (slotId: string, content: string) => {
setSlideContent(prev => ({
...prev,
[slotId]: content
}));
};
const saveSlide = async () => {
if (!presentation || !selectedLayout) return;
try {
setSaving(true);
setError(null);
const slideData: SlideContent = {
id: isEditingExisting ? slideId! : `slide_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
layoutId: selectedLayout.id,
content: slideContent,
notes: slideNotes,
order: isEditingExisting
? (existingSlide?.order ?? presentation.slides.length)
: presentation.slides.length
};
const updatedPresentation = { ...presentation };
if (isEditingExisting) {
// Update existing slide
const slideIndex = updatedPresentation.slides.findIndex(s => s.id === slideId);
if (slideIndex !== -1) {
updatedPresentation.slides[slideIndex] = slideData;
}
} else {
// Add new slide
updatedPresentation.slides.push(slideData);
}
await updatePresentation(updatedPresentation);
// Navigate back to editor with the updated slide
const slideNumber = isEditingExisting
? (updatedPresentation.slides.findIndex(s => s.id === slideData.id) + 1)
: updatedPresentation.slides.length;
navigate(`/presentations/${presentationId}/edit/slides/${slideNumber}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save slide');
loggers.presentation.error('Failed to save slide', err instanceof Error ? err : new Error(String(err)));
} finally {
setSaving(false);
}
};
const cancelEditing = () => {
if (isEditingExisting) {
const slideIndex = presentation?.slides.findIndex(s => s.id === slideId) ?? 0;
const slideNumber = slideIndex + 1;
navigate(`/presentations/${presentationId}/edit/slides/${slideNumber}`);
} else {
navigate(`/presentations/${presentationId}/edit/slides/1`);
}
};
if (loading) {
return (
<div className="slide-editor">
<div className="loading-content">
<div className="loading-spinner">Loading slide editor...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="slide-editor">
<div className="error-content">
<h2>Error Loading Slide Editor</h2>
<p>{error}</p>
<Link to="/themes" className="back-link"> Back to Themes</Link>
</div>
</div>
);
}
if (!presentation || !theme) {
return (
<div className="slide-editor">
<div className="not-found-content">
<h2>Presentation Not Found</h2>
<p>The requested presentation could not be found.</p>
<Link to="/themes" className="back-link"> Back to Themes</Link>
</div>
</div>
);
}
return (
<div className="slide-editor">
<header className="slide-editor-header">
<div className="editor-info">
<button
onClick={cancelEditing}
className="back-button"
type="button"
>
Back to Presentation
</button>
<div className="editor-title">
<h1>{isEditingExisting ? 'Edit Slide' : 'Add New Slide'}</h1>
<p>{presentation.metadata.name} {theme.name} theme</p>
</div>
</div>
<div className="editor-actions">
{selectedLayout && currentStep === 'content' && (
<button
type="button"
className="action-button secondary preview-button"
onClick={() => setShowPreview(true)}
disabled={saving}
title="Preview slide in full screen"
>
🔍 Preview
</button>
)}
<button
type="button"
className="action-button primary"
onClick={saveSlide}
disabled={!selectedLayout || saving}
>
{saving ? 'Saving...' : (isEditingExisting ? 'Update Slide' : 'Add Slide')}
</button>
</div>
</header>
<main className="slide-editor-content">
{currentStep === 'layout' && (
<div className="layout-selection">
<div className="step-header">
<h2>Choose a Layout</h2>
<p>Select the layout that best fits your content</p>
</div>
<div className="layouts-grid">
{theme.layouts.map((layout) => (
<div
key={layout.id}
className={`layout-card ${selectedLayout?.id === layout.id ? 'selected' : ''}`}
onClick={() => selectLayout(layout)}
>
<div className="layout-preview">
<div
className="layout-rendered"
dangerouslySetInnerHTML={{
__html: sanitizeSlideTemplate(renderTemplateWithSampleData(layout.htmlTemplate, layout))
}}
/>
</div>
<div className="layout-info">
<h3>{layout.name}</h3>
<p>{layout.description}</p>
<div className="layout-meta">
<span className="slot-count">{layout.slots.length} slots</span>
<div className="slot-types">
{Array.from(new Set(layout.slots.map(slot => slot.type))).map(type => (
<span key={type} className={`slot-type-badge ${type}`}>
{type}
</span>
))}
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{currentStep === 'content' && selectedLayout && presentation && (
<div className="content-editing">
<div className="editing-layout">
<div className="content-form">
<div className="step-header">
<h2>Edit Slide Content</h2>
<p>Fill in the content for your {selectedLayout.name} slide</p>
</div>
<div className="content-fields">
{selectedLayout.slots.map((slot) => (
<div key={slot.id} className="content-field">
<label htmlFor={slot.id} className="field-label">
{slot.id.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
{slot.required && <span className="required">*</span>}
</label>
{slot.type === 'image' ? (
<ImageUploadField
id={slot.id}
value={slideContent[slot.id] || ''}
onChange={(value) => updateSlotContent(slot.id, value)}
placeholder={slot.placeholder || `Upload image or enter URL for ${slot.id}`}
className="image-slot-field"
/>
) : slot.type === 'text' && slot.id.includes('content') ? (
<textarea
id={slot.id}
value={slideContent[slot.id] || ''}
onChange={(e) => updateSlotContent(slot.id, e.target.value)}
placeholder={slot.placeholder || `Enter ${slot.id}`}
className="field-textarea"
rows={4}
/>
) : (
<input
id={slot.id}
type="text"
value={slideContent[slot.id] || ''}
onChange={(e) => updateSlotContent(slot.id, e.target.value)}
placeholder={slot.placeholder || `Enter ${slot.id}`}
className="field-input"
/>
)}
{slot.placeholder && slot.type !== 'image' && (
<p className="field-hint">{slot.placeholder}</p>
)}
</div>
))}
<div className="content-field">
<label htmlFor="slide-notes" className="field-label">
Speaker Notes
</label>
<textarea
id="slide-notes"
value={slideNotes}
onChange={(e) => setSlideNotes(e.target.value)}
placeholder="Add notes for this slide (optional)"
className="field-textarea"
rows={3}
/>
</div>
<div className="content-actions">
<div className="action-links">
<button
type="button"
className="cancel-link"
onClick={cancelEditing}
disabled={saving}
>
Cancel editing
</button>
</div>
<div className="action-buttons">
<button
type="button"
className="action-button primary"
onClick={saveSlide}
disabled={saving}
>
{saving ? 'Saving...' : (isEditingExisting ? 'Update Slide' : 'Save Slide')}
</button>
</div>
</div>
</div>
</div>
<div className="content-preview">
<h3>Live Preview</h3>
<p className="preview-description">
Updates automatically as you type
</p>
<div className="preview-container">
<div className="slide-preview-wrapper">
<div
className={`slide-container ${presentation.metadata.aspectRatio ? `aspect-${presentation.metadata.aspectRatio.replace(':', '-')}` : 'aspect-16-9'}`}
dangerouslySetInnerHTML={{
__html: sanitizeSlideTemplate(renderTemplateWithContent(selectedLayout, slideContent))
}}
/>
</div>
<div className="preview-meta">
<span className="layout-name">{selectedLayout.name}</span>
<span className="aspect-ratio-info">
{presentation.metadata.aspectRatio || '16:9'} aspect ratio
</span>
<span className="content-count">
{Object.values(slideContent).filter(v => v.trim()).length} / {selectedLayout.slots.length} slots filled
</span>
</div>
</div>
</div>
</div>
</div>
)}
{error && (
<div className="editor-error">
<p>Error: {error}</p>
</div>
)}
</main>
{/* Preview Modal */}
{selectedLayout && presentation && (
<SlidePreviewModal
isOpen={showPreview}
onClose={() => setShowPreview(false)}
layout={selectedLayout}
content={slideContent}
aspectRatio={presentation.metadata.aspectRatio || '16:9'}
themeName={theme?.name || 'Unknown Theme'}
/>
)}
</div>
);
};
// Helper function to render template with actual content
const renderTemplateWithContent = (layout: SlideLayout, content: Record<string, string>): string => {
let rendered = layout.htmlTemplate;
// Replace content placeholders
Object.entries(content).forEach(([slotId, value]) => {
const placeholder = new RegExp(`\\{\\{${slotId}\\}\\}`, 'g');
rendered = rendered.replace(placeholder, value || '');
});
// Clean up any remaining placeholders
rendered = rendered.replace(/\{\{[^}]+\}\}/g, '');
return rendered;
};

View File

@ -0,0 +1,133 @@
.slide-preview-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.slide-preview-modal.theme-background {
background: var(--theme-background, #000000);
}
.preview-hint {
position: absolute;
top: 2rem;
left: 50%;
transform: translateX(-50%);
background: rgba(255, 255, 255, 0.9);
color: #1f2937;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
animation: fadeInOut 3s ease-in-out;
pointer-events: none;
}
@keyframes fadeInOut {
0% { opacity: 0; transform: translateX(-50%) translateY(-10px); }
15% { opacity: 1; transform: translateX(-50%) translateY(0); }
85% { opacity: 1; transform: translateX(-50%) translateY(0); }
100% { opacity: 0; transform: translateX(-50%) translateY(-10px); }
}
.preview-close-button {
position: absolute;
top: 2rem;
right: 2rem;
background: rgba(255, 255, 255, 0.9);
border: none;
width: 44px;
height: 44px;
border-radius: 50%;
font-size: 1.25rem;
color: #374151;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.preview-close-button:hover {
background: rgba(255, 255, 255, 1);
transform: scale(1.05);
}
.preview-info {
position: absolute;
bottom: 2rem;
left: 2rem;
display: flex;
gap: 1rem;
color: rgba(255, 255, 255, 0.8);
font-size: 0.875rem;
}
.preview-info span {
background: rgba(0, 0, 0, 0.5);
padding: 0.5rem 1rem;
border-radius: 0.25rem;
backdrop-filter: blur(8px);
}
.theme-name {
font-weight: 600;
}
.layout-name {
font-style: italic;
}
.aspect-ratio {
font-family: monospace;
}
/* Slide preview - use same selectors as SlideEditor */
.slide-preview-wrapper {
/* This wrapper provides the context for aspect ratio classes */
}
.slide-preview-wrapper .slide-container {
box-shadow: none;
border-radius: 0;
overflow: hidden;
background: white;
}
/* Full screen aspect ratio handling - maintain proper aspect ratio */
.slide-preview-wrapper .slide-container {
position: relative;
}
.slide-preview-wrapper .slide-container.aspect-16-9 {
aspect-ratio: 16 / 9;
width: min(100vw, calc(100vh * 16 / 9));
height: min(100vh, calc(100vw * 9 / 16));
}
.slide-preview-wrapper .slide-container.aspect-4-3 {
aspect-ratio: 4 / 3;
width: min(100vw, calc(100vh * 4 / 3));
height: min(100vh, calc(100vw * 3 / 4));
}
.slide-preview-wrapper .slide-container.aspect-16-10 {
aspect-ratio: 16 / 10;
width: min(100vw, calc(100vh * 16 / 10));
height: min(100vh, calc(100vw * 10 / 16));
}
.slide-preview-wrapper .slide-container.aspect-1-1 {
aspect-ratio: 1 / 1;
width: min(100vw, 100vh);
height: min(100vh, 100vw);
}

View File

@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import type { SlideLayout } from '../../types/theme.ts'; import type { SlideLayout } from '../../types/theme.ts';
import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer.ts'; import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer.ts';
import { CloseButton } from '../ui/buttons/CloseButton.tsx'; import './SlidePreviewModal.css';
interface SlidePreviewModalProps { interface SlidePreviewModalProps {
isOpen: boolean; isOpen: boolean;
@ -88,12 +88,14 @@ export const SlidePreviewModal: React.FC<SlidePreviewModalProps> = ({
)} )}
{/* Close button */} {/* Close button */}
<CloseButton <button
size="large" className="preview-close-button"
variant="preview"
onClick={onClose} onClick={onClose}
type="button"
title="Close preview (ESC)" title="Close preview (ESC)"
/> >
</button>
{/* Theme info */} {/* Theme info */}
<div className="preview-info"> <div className="preview-info">

View File

@ -0,0 +1,6 @@
export { NewPresentationPage } from './NewPresentationPage.tsx';
export { PresentationViewer } from './PresentationViewer.tsx';
export { PresentationEditor } from './PresentationEditor.tsx';
export { SlideEditor } from './SlideEditor.tsx';
export { ThemeSelector } from './ThemeSelector.tsx';
export { PresentationsList } from './PresentationsList.tsx';

View File

@ -1,87 +1,78 @@
/* Slide Thumbnail Component */
.slide-thumbnail { .slide-thumbnail {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border: 2px solid var(--border-primary); border: 2px solid #e5e7eb;
border-radius: 0.5rem; border-radius: 0.5rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
background: var(--bg-secondary); background: #ffffff;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
} }
/* Interactive States - shared box-shadow */ .slide-thumbnail:hover {
.slide-thumbnail:hover, border-color: #3b82f6;
.slide-thumbnail.active {
border-color: var(--border-accent);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
} }
.slide-thumbnail.active { .slide-thumbnail.active {
background: var(--bg-accent); border-color: #3b82f6;
background: #eff6ff;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
} }
/* Thumbnail Number Badge */
.thumbnail-number { .thumbnail-number {
position: absolute; position: absolute;
top: 0.5rem; top: 0.5rem;
left: 0.5rem; left: 0.5rem;
background: var(--color-gray-700); background: #374151;
color: var(--color-white); color: white;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
z-index: 10; z-index: 10;
} }
.slide-thumbnail.active .thumbnail-number { .slide-thumbnail.active .thumbnail-number {
background: var(--color-blue-500); background: #3b82f6;
} }
/* Preview Area */
.thumbnail-preview { .thumbnail-preview {
padding: 2rem 1rem 1rem; padding: 2rem 1rem 1rem;
min-height: 80px; min-height: 80px;
background: var(--bg-muted);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #f9fafb;
} }
.thumbnail-content { .thumbnail-content {
text-align: center; text-align: center;
} }
/* Content Labels - shared text styling */ .layout-name {
.thumbnail-content span {
display: block; display: block;
font-size: 0.75rem; font-size: 0.75rem;
}
.layout-name {
font-weight: 600; font-weight: 600;
color: var(--text-secondary); color: #374151;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
.content-count { .content-count {
font-size: 0.625rem; /* Override smaller size */ font-size: 0.625rem;
color: var(--text-tertiary); color: #6b7280;
} }
/* Action Bar */
.thumbnail-actions { .thumbnail-actions {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 0.25rem; gap: 0.25rem;
padding: 0.5rem; padding: 0.5rem;
background: var(--bg-secondary); background: #ffffff;
border-top: 1px solid var(--border-primary); border-top: 1px solid #e5e7eb;
} }
/* Action Buttons - consolidated common properties */
.thumbnail-action { .thumbnail-action {
background: none; background: none;
border: none; border: none;
@ -97,9 +88,8 @@
height: 28px; height: 28px;
} }
/* Button States - cascading hover effects */
.thumbnail-action:hover:not(:disabled) { .thumbnail-action:hover:not(:disabled) {
background: var(--bg-hover); background: #f3f4f6;
} }
.thumbnail-action:disabled { .thumbnail-action:disabled {
@ -107,11 +97,10 @@
cursor: not-allowed; cursor: not-allowed;
} }
/* Specific Action Types - override general hover */
.thumbnail-action.edit:hover:not(:disabled) { .thumbnail-action.edit:hover:not(:disabled) {
background: var(--color-blue-100); background: #dbeafe;
} }
.thumbnail-action.delete:hover:not(:disabled) { .thumbnail-action.delete:hover:not(:disabled) {
background: var(--color-red-100); background: #fee2e2;
} }

View File

@ -1,163 +0,0 @@
import React from 'react';
import type { Presentation } from '../../types/presentation.ts';
import type { SlideLayout } from '../../types/theme.ts';
import { renderTemplateWithContent } from './utils.ts';
import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer.ts';
import { ImageUploadField } from '../ui/ImageUploadField.tsx';
import { CancelLink } from '../ui/buttons/CancelLink.tsx';
import { ActionButton } from '../ui/buttons/ActionButton.tsx';
interface ContentEditorProps {
presentation: Presentation;
selectedLayout: SlideLayout;
slideContent: Record<string, string>;
slideNotes: string;
isEditingExisting: boolean;
saving: boolean;
onSlotContentChange: (slotId: string, content: string) => void;
onNotesChange: (notes: string) => void;
onSave: () => void;
onCancel: () => void;
}
export const ContentEditor: React.FC<ContentEditorProps> = ({
presentation,
selectedLayout,
slideContent,
slideNotes,
isEditingExisting,
saving,
onSlotContentChange,
onNotesChange,
onSave,
onCancel,
}) => {
const renderSlotField = (slot: any) => {
if (slot.type === 'image') {
return (
<ImageUploadField
id={slot.id}
value={slideContent[slot.id] || ''}
onChange={(value) => onSlotContentChange(slot.id, value)}
placeholder={slot.placeholder || `Upload image or enter URL for ${slot.id}`}
className="image-slot-field"
/>
);
}
if (slot.type === 'text' && slot.id.includes('content')) {
return (
<textarea
id={slot.id}
value={slideContent[slot.id] || ''}
onChange={(e) => onSlotContentChange(slot.id, e.target.value)}
placeholder={slot.placeholder || `Enter ${slot.id}`}
className="field-textarea"
rows={4}
/>
);
}
return (
<input
id={slot.id}
type="text"
value={slideContent[slot.id] || ''}
onChange={(e) => onSlotContentChange(slot.id, e.target.value)}
placeholder={slot.placeholder || `Enter ${slot.id}`}
className="field-input"
/>
);
};
return (
<div className="content-editing">
<div className="editing-layout">
<div className="content-form">
<div className="step-header">
<h2>Edit Slide Content</h2>
<p>Fill in the content for your {selectedLayout.name} slide</p>
</div>
<div className="content-fields">
{selectedLayout.slots.map((slot) => (
<div key={slot.id} className="content-field">
<label htmlFor={slot.id} className="field-label">
{slot.id.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
{slot.required && <span className="required">*</span>}
</label>
{renderSlotField(slot)}
{slot.placeholder && slot.type !== 'image' && (
<p className="field-hint">{slot.placeholder}</p>
)}
</div>
))}
<div className="content-field">
<label htmlFor="slide-notes" className="field-label">
Speaker Notes
</label>
<textarea
id="slide-notes"
value={slideNotes}
onChange={(e) => onNotesChange(e.target.value)}
placeholder="Add notes for this slide (optional)"
className="field-textarea"
rows={3}
/>
</div>
<div className="content-actions">
<div className="action-links">
<CancelLink
onClick={onCancel}
disabled={saving}
>
Cancel editing
</CancelLink>
</div>
<div className="action-buttons">
<ActionButton
variant="primary"
onClick={onSave}
disabled={saving}
loading={saving}
>
{isEditingExisting ? 'Update Slide' : 'Save Slide'}
</ActionButton>
</div>
</div>
</div>
</div>
<div className="content-preview">
<h3>Live Preview</h3>
<p className="preview-description">
Updates automatically as you type
</p>
<div className="preview-container">
<div className="slide-preview-wrapper">
<div
className={`slide-container ${presentation.metadata.aspectRatio ? `aspect-${presentation.metadata.aspectRatio.replace(':', '-')}` : 'aspect-16-9'}`}
dangerouslySetInnerHTML={{
__html: sanitizeSlideTemplate(renderTemplateWithContent(selectedLayout, slideContent))
}}
/>
</div>
<div className="preview-meta">
<span className="layout-name">{selectedLayout.name}</span>
<span className="aspect-ratio-info">
{presentation.metadata.aspectRatio || '16:9'} aspect ratio
</span>
<span className="content-count">
{Object.values(slideContent).filter(v => v.trim()).length} / {selectedLayout.slots.length} slots filled
</span>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@ -1,65 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
interface ErrorStateProps {
error: string;
presentationId?: string;
}
export const ErrorState: React.FC<ErrorStateProps> = ({ error, presentationId }) => {
return (
<div className="slide-editor">
<header className="slide-editor-header">
<div className="editor-info">
{presentationId ? (
<Link to={`/presentations/${presentationId}/edit/slides/1`} className="back-button">
Back to Presentation
</Link>
) : (
<Link to="/presentations" className="back-button">
Back to Presentations
</Link>
)}
<div className="editor-title">
<h1>Slide Editor Error</h1>
</div>
</div>
</header>
<main className="slide-editor-content">
<div className="error-container">
<div className="error-content">
<h2>Unable to Load Slide Editor</h2>
<p>{error}</p>
<div className="error-actions">
<button
type="button"
onClick={() => window.location.reload()}
className="action-button primary"
>
Reload Page
</button>
{presentationId ? (
<Link
to={`/presentations/${presentationId}/edit/slides/1`}
className="action-button secondary"
>
Return to Presentation
</Link>
) : (
<Link
to="/presentations"
className="action-button secondary"
>
Back to Presentations
</Link>
)}
</div>
</div>
</div>
</main>
</div>
);
};

View File

@ -1,60 +0,0 @@
import React from 'react';
import type { Theme, SlideLayout } from '../../types/theme.ts';
import { renderTemplateWithSampleData } from '../../utils/templateRenderer.ts';
import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer.ts';
interface LayoutSelectionProps {
theme: Theme;
selectedLayout: SlideLayout | null;
onLayoutSelect: (layout: SlideLayout) => void;
}
export const LayoutSelection: React.FC<LayoutSelectionProps> = ({
theme,
selectedLayout,
onLayoutSelect,
}) => {
return (
<div className="layout-selection">
<div className="step-header">
<h2>Choose a Layout</h2>
<p>Select the layout that best fits your content</p>
</div>
<div className="layouts-container">
<div className="layouts-grid">
{theme.layouts.map((layout) => (
<div
key={layout.id}
className={`layout-card ${selectedLayout?.id === layout.id ? 'selected' : ''}`}
onClick={() => onLayoutSelect(layout)}
>
<div className="layout-preview">
<div
className="layout-rendered"
dangerouslySetInnerHTML={{
__html: sanitizeSlideTemplate(renderTemplateWithSampleData(layout.htmlTemplate, layout))
}}
/>
</div>
<div className="layout-info">
<h3>{layout.name}</h3>
<p>{layout.description}</p>
<div className="layout-meta">
<span className="slot-count">{layout.slots.length} slots</span>
<div className="slot-types">
{Array.from(new Set(layout.slots.map(slot => slot.type))).map(type => (
<span key={type} className={`slot-type-badge ${type}`}>
{type}
</span>
))}
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
};

View File

@ -1,38 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
interface LoadingStateProps {
presentationId?: string;
}
export const LoadingState: React.FC<LoadingStateProps> = ({ presentationId }) => {
return (
<div className="slide-editor">
<header className="slide-editor-header">
<div className="editor-info">
{presentationId ? (
<Link to={`/presentations/${presentationId}/edit/slides/1`} className="back-button">
Back to Presentation
</Link>
) : (
<Link to="/presentations" className="back-button">
Back to Presentations
</Link>
)}
<div className="editor-title">
<h1>Loading Slide Editor...</h1>
</div>
</div>
</header>
<main className="slide-editor-content">
<div className="loading-container">
<div className="loading-content">
<div className="loading-spinner">Loading slide editor...</div>
<p>Please wait while we prepare your slide editing environment.</p>
</div>
</div>
</main>
</div>
);
};

View File

@ -1,793 +0,0 @@
/* =========================
CSS VARIABLES & COMPONENT THEME
========================= */
.slide-editor {
/* Use global color system - map local names to global variables */
--color-bg-primary: var(--bg-primary);
--color-bg-secondary: var(--bg-secondary);
--color-bg-tertiary: var(--bg-tertiary);
--color-bg-accent: var(--bg-accent);
--color-bg-error: var(--bg-error);
--color-border-primary: var(--border-primary);
--color-border-secondary: var(--border-secondary);
--color-border-accent: var(--border-accent);
--color-border-hover: var(--border-hover);
--color-border-error: var(--border-error);
--color-text-primary: var(--text-primary);
--color-text-secondary: var(--text-secondary);
--color-text-tertiary: var(--text-tertiary);
--color-text-accent: var(--text-accent);
--color-text-error: var(--text-error);
--color-text-muted: var(--text-muted);
--color-button-primary: var(--btn-primary-bg);
--color-button-primary-hover: var(--btn-primary-bg-hover);
--color-button-secondary: var(--btn-secondary-bg);
/* Spacing */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 0.75rem;
--space-lg: 1rem;
--space-xl: 1.5rem;
--space-2xl: 2rem;
/* Border Radius */
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 4px 12px rgba(0, 0, 0, 0.3);
/* Layout */
--header-height: 80px;
--header-height-tablet: 60px;
--header-height-mobile: 50px;
}
/* =========================
BASE COMPONENT STYLES
========================= */
.slide-editor {
min-height: 100vh;
background: var(--color-bg-primary);
display: flex;
flex-direction: column;
}
/* =========================
HEADER COMPONENT
========================= */
.slide-editor-header {
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border-primary);
padding: var(--space-md) var(--space-2xl);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-xl);
flex-wrap: wrap;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
box-shadow: var(--shadow-md);
}
.editor-info {
display: flex;
align-items: center;
gap: var(--space-lg);
flex: 1;
min-width: 0;
}
.editor-title h1 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-primary);
}
.presentation-context {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.875rem;
}
/* =========================
REUSABLE BUTTON SYSTEM
========================= */
.back-button,
.action-button,
.cancel-link {
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border-radius: var(--radius-md);
transition: all 0.2s ease;
border: 1px solid transparent;
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
.back-button {
background: none;
color: var(--color-text-secondary);
padding: var(--space-sm) var(--space-md);
}
.back-button:hover {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.action-button {
padding: var(--space-md) var(--space-xl);
font-weight: 600;
}
.action-button.primary {
background: var(--color-button-primary);
color: white;
border-color: var(--color-button-primary);
}
.action-button.primary:hover:not(:disabled) {
background: var(--color-button-primary-hover);
border-color: var(--color-button-primary-hover);
}
.action-button.secondary {
background: var(--color-button-secondary);
color: var(--color-text-primary);
border-color: var(--color-border-secondary);
}
.action-button.secondary:hover:not(:disabled) {
background: var(--color-bg-tertiary);
border-color: var(--color-border-hover);
}
.action-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.cancel-link {
background: none;
color: var(--color-text-secondary);
padding: var(--space-sm) var(--space-md);
text-decoration: underline;
}
.cancel-link:hover:not(:disabled) {
color: var(--color-text-primary);
}
/* =========================
LAYOUT CONTAINERS
========================= */
.slide-editor-content {
flex: 1;
padding-top: var(--header-height);
}
.layout-selection,
.content-editing {
position: fixed;
top: var(--header-height);
left: 0;
right: 0;
bottom: 0;
background: var(--color-bg-primary);
z-index: 10;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
/* Layout Selection */
.layout-selection .step-header {
flex-shrink: 0;
padding: var(--space-2xl) var(--space-2xl) var(--space-lg) var(--space-2xl);
}
.layout-selection .layouts-container {
flex: 1;
overflow-y: auto;
padding: 0 var(--space-2xl) var(--space-2xl) var(--space-2xl);
}
.layouts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--space-xl);
min-height: 100%;
}
/* Content Editing */
.content-editing .editing-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-2xl);
align-items: stretch;
height: 100%;
width: 100%;
padding: var(--space-2xl);
box-sizing: border-box;
}
/* =========================
CARD COMPONENTS
========================= */
/* Layout Card - increase specificity */
.slide-editor .layout-card {
background: var(--color-bg-secondary);
border: 2px solid var(--color-border-primary);
border-radius: var(--radius-xl);
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
}
.slide-editor .layout-card:hover {
border-color: var(--color-border-hover);
box-shadow: var(--shadow-lg);
}
.slide-editor .layout-card.selected {
border-color: var(--color-border-accent);
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.2);
}
/* Layout Preview - increase specificity to prevent overrides */
.slide-editor .layout-preview {
height: 300px;
background: var(--color-bg-primary);
border-bottom: 1px solid var(--color-border-primary);
overflow: hidden;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
/* Layout Rendered Content - increase specificity */
.slide-editor .layout-rendered {
width: 100%;
height: 100%;
transform: scale(0.95);
transform-origin: center;
background: var(--color-bg-secondary);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-primary);
overflow: hidden;
}
/* Layout Info - increase specificity */
.slide-editor .layout-info {
padding: var(--space-xl);
}
.slide-editor .layout-info h3 {
margin: 0 0 var(--space-sm) 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-primary);
}
.slide-editor .layout-info p {
margin: 0 0 var(--space-lg) 0;
color: var(--color-text-secondary);
font-size: 0.875rem;
line-height: 1.4;
}
/* =========================
FORM COMPONENTS
========================= */
.step-header h2 {
margin: 0 0 var(--space-sm) 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text-primary);
}
.step-header p {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.content-form {
background: var(--color-bg-secondary);
border-radius: var(--radius-xl);
padding: var(--space-2xl);
border: 1px solid var(--color-border-primary);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
.content-fields {
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
.content-field {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.field-label {
font-weight: 600;
color: var(--color-text-primary);
font-size: 0.875rem;
}
.field-input,
.field-textarea {
padding: var(--space-md);
border: 1px solid var(--color-border-secondary);
border-radius: var(--radius-lg);
font-size: 0.875rem;
transition: border-color 0.2s ease;
font-family: inherit;
}
.field-input:focus,
.field-textarea:focus {
outline: none;
border-color: var(--color-border-accent);
box-shadow: 0 0 0 1px var(--color-border-accent);
}
.field-hint {
font-size: 0.75rem;
color: var(--color-text-tertiary);
margin: 0;
}
.required {
color: var(--color-text-error);
margin-left: var(--space-xs);
}
/* =========================
PREVIEW COMPONENTS
========================= */
.content-preview {
background: var(--color-bg-secondary);
border-radius: var(--radius-xl);
padding: var(--space-2xl);
border: 1px solid var(--color-border-primary);
overflow: hidden;
display: flex;
flex-direction: column;
}
.content-preview h3 {
margin: 0 0 var(--space-sm) 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-primary);
}
.preview-description {
margin: 0 0 var(--space-lg) 0;
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.preview-container {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-lg);
min-height: 400px;
}
/* Base slide preview wrapper */
.slide-preview-wrapper {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-primary);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-primary);
overflow: hidden;
min-height: 350px;
padding: var(--space-lg);
}
/* Base slide container */
.slide-preview-wrapper .slide-container {
box-shadow: none;
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-bg-secondary);
position: relative;
border: 1px solid var(--color-border-primary);
transform-origin: center;
}
/* Ensure slide content fills the container properly */
.slide-preview-wrapper .slide-container > * {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
/* Theme styles should apply within the preview */
.slide-preview-wrapper .slide-container .slot {
border: 1px dashed transparent;
min-height: 1em;
padding: 0.25rem;
}
/* =========================
BADGE & METADATA COMPONENTS
========================= */
/* Layout Meta - increase specificity */
.slide-editor .layout-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-lg);
}
.slot-count {
font-size: 0.75rem;
font-weight: 500;
color: var(--color-text-tertiary);
background: var(--color-bg-tertiary);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
}
.slot-types {
display: flex;
gap: var(--space-xs);
flex-wrap: wrap;
}
.slot-type-badge {
font-size: 0.625rem;
font-weight: 500;
padding: 0.125rem var(--space-md);
border-radius: var(--radius-sm);
text-transform: capitalize;
}
/* Slot type colors */
.slot-type-badge.title { background-color: var(--badge-title-bg); color: var(--badge-title-text); }
.slot-type-badge.subtitle { background-color: var(--badge-subtitle-bg); color: var(--badge-subtitle-text); }
.slot-type-badge.text { background-color: var(--badge-text-bg); color: var(--badge-text-text); }
.slot-type-badge.image { background-color: var(--badge-image-bg); color: var(--badge-image-text); }
.slot-type-badge.video { background-color: var(--badge-video-bg); color: var(--badge-video-text); }
.slot-type-badge.list { background-color: var(--badge-list-bg); color: var(--badge-list-text); }
.preview-meta {
display: flex;
gap: var(--space-lg);
font-size: 0.75rem;
color: var(--color-text-tertiary);
padding: var(--space-lg);
background: var(--color-bg-tertiary);
border-top: 1px solid var(--color-border-primary);
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
}
/* =========================
ACTION COMPONENTS
========================= */
.content-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-lg);
padding-top: var(--space-lg);
border-top: 1px solid var(--color-border-primary);
}
.action-links {
display: flex;
gap: var(--space-lg);
}
.action-buttons {
display: flex;
gap: var(--space-md);
}
.editor-actions {
display: flex;
gap: var(--space-md);
}
/* =========================
ERROR & LOADING STATES
========================= */
.error-boundary-container,
.loading-container,
.error-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: var(--space-2xl);
}
.error-boundary-content,
.loading-content,
.error-content {
max-width: 600px;
text-align: center;
}
.error-boundary-content h2,
.error-content h2 {
margin: 0 0 var(--space-lg) 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text-primary);
}
.error-boundary-content p,
.loading-content p,
.error-content p {
margin: 0 0 var(--space-2xl) 0;
color: var(--color-text-secondary);
line-height: 1.6;
}
.error-actions {
display: flex;
gap: var(--space-lg);
justify-content: center;
flex-wrap: wrap;
}
.loading-spinner {
font-size: 1.1rem;
margin-bottom: var(--space-lg);
color: var(--color-text-accent);
}
/* =========================
MODAL COMPONENTS
========================= */
.slide-preview-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: var(--theme-background, var(--color-black));
}
.preview-hint {
position: absolute;
top: var(--space-2xl);
left: 50%;
transform: translateX(-50%);
background: rgba(255, 255, 255, 0.9);
color: var(--color-text-primary);
padding: var(--space-md) var(--space-xl);
border-radius: var(--radius-lg);
font-size: 0.875rem;
font-weight: 500;
box-shadow: var(--shadow-xl);
animation: fadeInOut 3s ease-in-out;
pointer-events: none;
}
.preview-close-button {
position: absolute;
top: var(--space-2xl);
right: var(--space-2xl);
background: rgba(255, 255, 255, 0.9);
border: none;
width: 44px;
height: 44px;
border-radius: 50%;
font-size: 1.25rem;
color: var(--color-text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
box-shadow: var(--shadow-xl);
}
.preview-close-button:hover {
background: rgba(255, 255, 255, 1);
transform: scale(1.05);
}
.preview-info {
position: absolute;
bottom: var(--space-2xl);
left: var(--space-2xl);
display: flex;
gap: var(--space-lg);
color: rgba(255, 255, 255, 0.8);
font-size: 0.875rem;
}
.preview-info span {
background: rgba(0, 0, 0, 0.5);
padding: var(--space-sm) var(--space-lg);
border-radius: var(--radius-sm);
backdrop-filter: blur(8px);
}
/* =========================
ASPECT RATIO HANDLING
========================= */
/* Content Editor Preview Aspect Ratios */
.content-preview .slide-preview-wrapper .slide-container.aspect-16-9 {
width: min(100%, 533px); /* 300px * 16/9 = 533px max width to fit 300px height */
height: min(300px, calc(100% * 9 / 16));
aspect-ratio: 16 / 9;
}
.content-preview .slide-preview-wrapper .slide-container.aspect-4-3 {
width: min(100%, 400px); /* 300px * 4/3 = 400px max width */
height: min(300px, calc(100% * 3 / 4));
aspect-ratio: 4 / 3;
}
.content-preview .slide-preview-wrapper .slide-container.aspect-16-10 {
width: min(100%, 480px); /* 300px * 16/10 = 480px max width */
height: min(300px, calc(100% * 10 / 16));
aspect-ratio: 16 / 10;
}
/* Modal Full-Screen Aspect Ratios (for preview modal) */
.slide-preview-modal .slide-preview-wrapper .slide-container.aspect-16-9 {
aspect-ratio: 16 / 9;
width: min(90vw, calc(90vh * 16 / 9));
height: min(90vh, calc(90vw * 9 / 16));
}
.slide-preview-modal .slide-preview-wrapper .slide-container.aspect-4-3 {
aspect-ratio: 4 / 3;
width: min(90vw, calc(90vh * 4 / 3));
height: min(90vh, calc(90vw * 3 / 4));
}
.slide-preview-modal .slide-preview-wrapper .slide-container.aspect-16-10 {
aspect-ratio: 16 / 10;
width: min(90vw, calc(90vh * 16 / 10));
height: min(90vh, calc(90vw * 10 / 16));
}
/* =========================
IMAGE INTEGRATION
========================= */
.content-field .image-slot-field {
margin-top: var(--space-sm);
border: 1px solid var(--color-border-primary);
background: var(--color-bg-secondary);
border-radius: var(--radius-lg);
}
.content-field .image-slot-field:focus-within {
border-color: var(--color-border-accent);
}
/* =========================
ANIMATIONS
========================= */
@keyframes fadeInOut {
0% { opacity: 0; transform: translateX(-50%) translateY(-10px); }
15% { opacity: 1; transform: translateX(-50%) translateY(0); }
85% { opacity: 1; transform: translateX(-50%) translateY(0); }
100% { opacity: 0; transform: translateX(-50%) translateY(-10px); }
}
/* =========================
RESPONSIVE DESIGN
========================= */
@media (max-width: 1024px) {
.slide-editor {
--header-height: var(--header-height-tablet);
}
.slide-editor-header {
padding: var(--space-md) var(--space-xl);
}
.layout-selection .step-header {
padding: var(--space-xl) var(--space-xl) var(--space-lg) var(--space-xl);
}
.layout-selection .layouts-container {
padding: 0 var(--space-xl) var(--space-xl) var(--space-xl);
}
.content-editing .editing-layout {
grid-template-columns: 1fr;
gap: var(--space-xl);
padding: var(--space-lg);
}
}
@media (max-width: 768px) {
.slide-editor {
--header-height: var(--header-height-mobile);
}
.slide-editor-header {
padding: var(--space-sm) var(--space-lg);
flex-direction: column;
align-items: stretch;
gap: var(--space-sm);
position: relative;
box-shadow: none;
border-bottom: 1px solid var(--color-border-primary);
}
.slide-editor-content {
padding-top: 0;
}
.layout-selection,
.content-editing {
position: relative;
top: 0;
}
.layout-selection .step-header {
padding: var(--space-lg) var(--space-lg) var(--space-md) var(--space-lg);
}
.layout-selection .layouts-container {
padding: 0 var(--space-lg) var(--space-lg) var(--space-lg);
}
.layouts-grid {
grid-template-columns: 1fr;
gap: var(--space-lg);
}
.editor-actions {
flex-direction: column;
align-items: stretch;
gap: var(--space-md);
}
.content-actions {
flex-direction: column;
align-items: stretch;
gap: var(--space-md);
}
.action-buttons {
flex-direction: column;
}
}

View File

@ -1,152 +0,0 @@
import React from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { SlideEditorErrorBoundary } from './SlideEditorErrorBoundary.tsx';
import { LoadingState } from './LoadingState.tsx';
import { ErrorState } from './ErrorState.tsx';
import { LayoutSelection } from './LayoutSelection.tsx';
import { ContentEditor } from './ContentEditor.tsx';
import { SlidePreviewModal } from './SlidePreviewModal.tsx';
import { ActionButton } from '../ui/buttons/ActionButton.tsx';
import { useSlideEditor } from './useSlideEditor.ts';
import './SlideEditor.css';
export const SlideEditor: React.FC = () => {
const navigate = useNavigate();
const { presentationId, slideId } = useParams<{
presentationId: string;
slideId: string;
}>();
const {
// Data
presentation,
theme,
selectedLayout,
slideContent,
slideNotes,
currentStep,
// States
loading,
error,
saving,
showPreview,
// Computed
isEditingExisting,
// Actions
updateSlotContent,
setSlideNotes,
setShowPreview,
selectLayout,
saveSlide,
cancelEditing,
} = useSlideEditor({ presentationId, slideId });
// Helper function to get current slide number for presentation
const getCurrentSlideNumber = (): number => {
if (!presentation || !slideId || slideId === 'new') {
return 1; // Default to first slide for new slides
}
const slideIndex = presentation.slides.findIndex(slide => slide.id === slideId);
return slideIndex !== -1 ? slideIndex + 1 : 1;
};
const handlePresentFromHere = () => {
const slideNumber = getCurrentSlideNumber();
navigate(`/presentations/${presentationId}/present/${slideNumber}`);
};
if (loading) {
return <LoadingState presentationId={presentationId} />;
}
if (error) {
return <ErrorState error={error} presentationId={presentationId} />;
}
if (!presentation || !theme) {
return <ErrorState error="Presentation or theme not found" presentationId={presentationId} />;
}
return (
<SlideEditorErrorBoundary presentationId={presentationId}>
<div className="slide-editor">
<header className="slide-editor-header">
<div className="editor-info">
<Link
to={`/presentations/${presentationId}/edit/slides/1`}
className="back-button"
>
Back to Presentation
</Link>
<div className="editor-title">
<h1>{isEditingExisting ? 'Edit Slide' : 'New Slide'}</h1>
<span className="presentation-context">
in "{presentation.metadata.name}"
</span>
</div>
</div>
<div className="editor-actions">
{currentStep === 'content' && selectedLayout && (
<>
<ActionButton
variant="secondary"
onClick={() => setShowPreview(true)}
>
Full Preview
</ActionButton>
<ActionButton
variant="primary"
onClick={handlePresentFromHere}
>
Present from here
</ActionButton>
</>
)}
</div>
</header>
<main className="slide-editor-content">
{currentStep === 'layout' && (
<LayoutSelection
theme={theme}
selectedLayout={selectedLayout}
onLayoutSelect={selectLayout}
/>
)}
{currentStep === 'content' && selectedLayout && (
<ContentEditor
presentation={presentation}
selectedLayout={selectedLayout}
slideContent={slideContent}
slideNotes={slideNotes}
isEditingExisting={isEditingExisting}
saving={saving}
onSlotContentChange={updateSlotContent}
onNotesChange={setSlideNotes}
onSave={saveSlide}
onCancel={cancelEditing}
/>
)}
</main>
{/* Preview Modal */}
{selectedLayout && presentation && (
<SlidePreviewModal
isOpen={showPreview}
onClose={() => setShowPreview(false)}
layout={selectedLayout}
content={slideContent}
aspectRatio={presentation.metadata.aspectRatio || '16:9'}
themeName={theme?.name || 'Unknown Theme'}
/>
)}
</div>
</SlideEditorErrorBoundary>
);
};

View File

@ -1,115 +0,0 @@
import React, { Component, type ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { loggers } from '../../utils/logger.ts';
interface Props {
children: ReactNode;
presentationId?: string;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: string | null;
}
export class SlideEditorErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null,
errorInfo: null,
};
public static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
errorInfo: null,
};
}
public componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
loggers.ui.error('SlideEditor error boundary caught an error', error);
this.setState({
error,
errorInfo: errorInfo.componentStack || null,
});
}
private handleReset = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
};
public render() {
if (this.state.hasError) {
return (
<div className="slide-editor">
<header className="slide-editor-header">
<div className="editor-info">
<Link to="/presentations" className="back-button">
Back to Presentations
</Link>
<div className="editor-title">
<h1>Slide Editor Error</h1>
</div>
</div>
</header>
<main className="slide-editor-content">
<div className="error-boundary-container">
<div className="error-boundary-content">
<h2>Something went wrong</h2>
<p>
The slide editor encountered an unexpected error. This has been logged for investigation.
</p>
<div className="error-actions">
<button
type="button"
onClick={this.handleReset}
className="action-button primary"
>
Try Again
</button>
{this.props.presentationId && (
<Link
to={`/presentations/${this.props.presentationId}/edit/slides/1`}
className="action-button secondary"
>
Return to Presentation
</Link>
)}
<Link
to="/presentations"
className="action-button secondary"
>
Back to Presentations
</Link>
</div>
{process.env.NODE_ENV === 'development' && this.state.error && (
<details className="error-details">
<summary>Error Details (Development Only)</summary>
<pre className="error-stack">
{this.state.error.toString()}
{this.state.errorInfo}
</pre>
</details>
)}
</div>
</div>
</main>
</div>
);
}
return this.props.children;
}
}

View File

@ -1,254 +0,0 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import type { Presentation, SlideContent } from '../../types/presentation.ts';
import type { Theme, SlideLayout } from '../../types/theme.ts';
import { getPresentationById, updatePresentation } from '../../utils/presentationStorage.ts';
import { loadTheme } from '../../utils/themeLoader.ts';
import { loggers } from '../../utils/logger.ts';
interface UseSlideEditorProps {
presentationId: string | undefined;
slideId: string | undefined;
}
interface UseSlideEditorReturn {
// Data
presentation: Presentation | null;
theme: Theme | null;
selectedLayout: SlideLayout | null;
slideContent: Record<string, string>;
slideNotes: string;
currentStep: 'layout' | 'content';
// States
loading: boolean;
error: string | null;
saving: boolean;
showPreview: boolean;
// Computed
isEditingExisting: boolean;
existingSlide: SlideContent | null;
// Actions
setSelectedLayout: (layout: SlideLayout | null) => void;
updateSlotContent: (slotId: string, content: string) => void;
setSlideNotes: (notes: string) => void;
setCurrentStep: (step: 'layout' | 'content') => void;
setShowPreview: (show: boolean) => void;
selectLayout: (layout: SlideLayout) => void;
saveSlide: () => Promise<void>;
cancelEditing: () => void;
}
export const useSlideEditor = ({ presentationId, slideId }: UseSlideEditorProps): UseSlideEditorReturn => {
const navigate = useNavigate();
// Core state
const [presentation, setPresentation] = useState<Presentation | null>(null);
const [theme, setTheme] = useState<Theme | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [showPreview, setShowPreview] = useState(false);
// Editor state
const [selectedLayout, setSelectedLayout] = useState<SlideLayout | null>(null);
const [slideContent, setSlideContent] = useState<Record<string, string>>({});
const [slideNotes, setSlideNotes] = useState('');
const [currentStep, setCurrentStep] = useState<'layout' | 'content'>('layout');
// Computed values
const isEditingExisting = slideId !== 'new';
const existingSlide = isEditingExisting && presentation
? presentation.slides.find(s => s.id === slideId) || null
: null;
// Initialize current step based on editing mode
useEffect(() => {
setCurrentStep(isEditingExisting ? 'content' : 'layout');
}, [isEditingExisting]);
// Load presentation and theme
useEffect(() => {
const loadPresentationAndTheme = async () => {
if (!presentationId) {
setError('No presentation ID provided');
setLoading(false);
return;
}
try {
setLoading(true);
// Load presentation
const presentationData = await getPresentationById(presentationId);
if (!presentationData) {
setError(`Presentation not found: ${presentationId}`);
return;
}
setPresentation(presentationData);
// Load theme
const themeData = await loadTheme(presentationData.metadata.theme, false);
if (!themeData) {
setError(`Theme not found: ${presentationData.metadata.theme}`);
return;
}
setTheme(themeData);
// If editing existing slide, populate data
if (isEditingExisting && slideId !== 'new') {
const slide = presentationData.slides.find(s => s.id === slideId);
if (slide) {
const layout = themeData.layouts.find(l => l.id === slide.layoutId);
if (layout) {
setSelectedLayout(layout);
setSlideContent(slide.content);
setSlideNotes(slide.notes || '');
}
} else {
setError(`Slide not found: ${slideId}`);
return;
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load presentation');
} finally {
setLoading(false);
}
};
loadPresentationAndTheme();
}, [presentationId, slideId, isEditingExisting]);
// Load theme CSS for layout previews
useEffect(() => {
if (theme) {
const themeStyleId = 'slide-editor-theme-style';
const existingStyle = document.getElementById(themeStyleId);
if (existingStyle) {
existingStyle.remove();
}
const style = document.createElement('link');
style.id = themeStyleId;
style.rel = 'stylesheet';
style.href = `${theme.basePath}/${theme.cssFile}`;
document.head.appendChild(style);
return () => {
const styleToRemove = document.getElementById(themeStyleId);
if (styleToRemove) {
styleToRemove.remove();
}
};
}
}, [theme]);
// Actions
const updateSlotContent = (slotId: string, content: string) => {
setSlideContent(prev => ({ ...prev, [slotId]: content }));
};
const selectLayout = (layout: SlideLayout) => {
setSelectedLayout(layout);
setCurrentStep('content');
};
const saveSlide = async () => {
if (!presentation || !selectedLayout || !presentationId) return;
try {
setSaving(true);
setError(null);
const slideData: SlideContent = {
id: isEditingExisting ? slideId! : `slide-${Date.now()}`,
layoutId: selectedLayout.id,
content: slideContent,
notes: slideNotes,
order: isEditingExisting
? existingSlide?.order || presentation.slides.length
: presentation.slides.length
};
let updatedSlides: SlideContent[];
if (isEditingExisting) {
updatedSlides = presentation.slides.map(slide =>
slide.id === slideId ? slideData : slide
);
} else {
updatedSlides = [...presentation.slides, slideData];
}
const updatedPresentation: Presentation = {
...presentation,
slides: updatedSlides,
metadata: {
...presentation.metadata,
updatedAt: new Date().toISOString()
}
};
await updatePresentation(updatedPresentation);
// Navigate back to the presentation editor
const slideNumber = isEditingExisting
? presentation.slides.findIndex(s => s.id === slideId) + 1
: updatedSlides.length;
navigate(`/presentations/${presentationId}/edit/slides/${slideNumber}`);
} catch (err) {
loggers.ui.error('Failed to save slide', err instanceof Error ? err : new Error(String(err)));
setError('Failed to save slide. Please try again.');
} finally {
setSaving(false);
}
};
const cancelEditing = () => {
if (!presentation) return;
const slideNumber = isEditingExisting && existingSlide
? presentation.slides.findIndex(s => s.id === slideId) + 1
: 1;
navigate(`/presentations/${presentationId}/edit/slides/${slideNumber}`);
};
return {
// Data
presentation,
theme,
selectedLayout,
slideContent,
slideNotes,
currentStep,
// States
loading,
error,
saving,
showPreview,
// Computed
isEditingExisting,
existingSlide,
// Actions
setSelectedLayout,
updateSlotContent,
setSlideNotes,
setCurrentStep,
setShowPreview,
selectLayout,
saveSlide,
cancelEditing,
};
};

View File

@ -1,17 +0,0 @@
import type { SlideLayout } from '../../types/theme.ts';
// Helper function to render template with actual content
export const renderTemplateWithContent = (layout: SlideLayout, content: Record<string, string>): string => {
let rendered = layout.htmlTemplate;
// Replace content placeholders
Object.entries(content).forEach(([slotId, value]) => {
const placeholder = new RegExp(`\\{\\{${slotId}\\}\\}`, 'g');
rendered = rendered.replace(placeholder, value || '');
});
// Clean up any remaining placeholders
rendered = rendered.replace(/\{\{[^}]+\}\}/g, '');
return rendered;
};

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import type { Theme, SlideLayout } from '../../types/theme.ts'; import type { Theme, SlideLayout } from '../../types/theme.ts';
import { loadTheme } from '../../utils/themeLoader.ts'; import { getTheme } from '../../themes/index.ts';
import './LayoutDetailPage.css'; import './LayoutDetailPage.css';
export const LayoutDetailPage: React.FC = () => { export const LayoutDetailPage: React.FC = () => {
@ -22,7 +22,7 @@ export const LayoutDetailPage: React.FC = () => {
try { try {
setLoading(true); setLoading(true);
const themeData = await loadTheme(themeId, false); const themeData = await getTheme(themeId);
if (!themeData) { if (!themeData) {
setError(`Theme "${themeId}" not found`); setError(`Theme "${themeId}" not found`);
return; return;

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import type { Theme, SlideLayout } from '../../types/theme.ts'; import type { Theme, SlideLayout } from '../../types/theme.ts';
import { loadTheme } from '../../utils/themeLoader.ts'; import { getTheme } from '../../themes/index.ts';
import { renderTemplateWithSampleData } from '../../utils/templateRenderer.ts'; import { renderTemplateWithSampleData } from '../../utils/templateRenderer.ts';
import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer.ts'; import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer.ts';
import './LayoutPreviewPage.css'; import './LayoutPreviewPage.css';
@ -24,7 +24,7 @@ export const LayoutPreviewPage: React.FC = () => {
try { try {
setLoading(true); setLoading(true);
const themeData = await loadTheme(themeId, false); const themeData = await getTheme(themeId);
if (!themeData) { if (!themeData) {
setError(`Theme "${themeId}" not found`); setError(`Theme "${themeId}" not found`);
return; return;

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { Theme } from '../../types/theme.ts'; import type { Theme } from '../../types/theme.ts';
import { discoverThemes as getThemes } from '../../utils/themeLoader.ts'; import { getThemes } from '../../themes/index.ts';
import { LayoutPreview } from './LayoutPreview.tsx'; import { LayoutPreview } from './LayoutPreview.tsx';
export const ThemeBrowser: React.FC = () => { export const ThemeBrowser: React.FC = () => {

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import type { Theme } from '../../types/theme.ts'; import type { Theme } from '../../types/theme.ts';
import { loadTheme } from '../../utils/themeLoader.ts'; import { getTheme } from '../../themes/index.ts';
import './ThemeDetailPage.css'; import './ThemeDetailPage.css';
export const ThemeDetailPage: React.FC = () => { export const ThemeDetailPage: React.FC = () => {
@ -11,7 +11,7 @@ export const ThemeDetailPage: React.FC = () => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const loadThemeData = async () => { const loadTheme = async () => {
if (!themeId) { if (!themeId) {
setError('No theme ID provided'); setError('No theme ID provided');
setLoading(false); setLoading(false);
@ -20,7 +20,7 @@ export const ThemeDetailPage: React.FC = () => {
try { try {
setLoading(true); setLoading(true);
const themeData = await loadTheme(themeId, false); const themeData = await getTheme(themeId);
if (!themeData) { if (!themeData) {
setError(`Theme "${themeId}" not found`); setError(`Theme "${themeId}" not found`);
return; return;
@ -33,7 +33,7 @@ export const ThemeDetailPage: React.FC = () => {
} }
}; };
loadThemeData(); loadTheme();
}, [themeId]); }, [themeId]);
if (loading) { if (loading) {

View File

@ -0,0 +1,6 @@
export { ThemeBrowser } from './ThemeBrowser.tsx';
export { LayoutPreview } from './LayoutPreview.tsx';
export { ThemeDetailPage } from './ThemeDetailPage.tsx';
export { LayoutDetailPage } from './LayoutDetailPage.tsx';
export { LayoutPreviewPage } from './LayoutPreviewPage.tsx';
export type { Theme } from '../../types/theme.ts';

View File

@ -1,26 +0,0 @@
/* Alert Dialog Styles */
.alert-dialog {
text-align: center;
}
.alert-content {
margin-bottom: 2rem;
}
.alert-icon {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.alert-message {
color: var(--text-secondary);
font-size: 1rem;
line-height: 1.5;
margin: 0;
}
.alert-actions {
display: flex;
justify-content: center;
gap: 0.75rem;
}

View File

@ -1,7 +1,5 @@
import React from 'react'; import React from 'react';
import { Modal } from './Modal.tsx'; import { Modal } from './Modal.tsx';
import { Button } from './buttons/Button.tsx';
import './AlertDialog.css';
interface AlertDialogProps { interface AlertDialogProps {
isOpen: boolean; isOpen: boolean;
@ -56,14 +54,69 @@ export const AlertDialog: React.FC<AlertDialogProps> = ({
<p className="alert-message">{message}</p> <p className="alert-message">{message}</p>
</div> </div>
<div className="alert-actions"> <div className="alert-actions">
<Button <button
variant="primary" type="button"
className="button primary"
onClick={onClose} onClick={onClose}
autoFocus
> >
{confirmText} {confirmText}
</Button> </button>
</div> </div>
</div> </div>
<style jsx>{`
.alert-dialog {
text-align: center;
}
.alert-content {
margin-bottom: 2rem;
}
.alert-icon {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.alert-message {
color: #374151;
font-size: 1rem;
line-height: 1.5;
margin: 0;
}
.alert-actions {
display: flex;
justify-content: center;
gap: 0.75rem;
}
.button {
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 500;
font-size: 0.875rem;
border: none;
cursor: pointer;
transition: all 0.2s ease;
min-width: 80px;
}
.button.primary {
background: #3b82f6;
color: white;
}
.button.primary:hover {
background: #2563eb;
}
.button.primary:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
`}</style>
</Modal> </Modal>
); );
}; };

View File

@ -1,27 +0,0 @@
/* Confirm Dialog Styles */
.confirm-dialog {
text-align: center;
}
.confirm-content {
margin-bottom: 2rem;
}
.confirm-icon {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.confirm-message {
color: var(--text-secondary);
font-size: 1rem;
line-height: 1.5;
margin: 0;
text-align: left;
}
.confirm-actions {
display: flex;
justify-content: center;
gap: 0.75rem;
}

View File

@ -1,7 +1,5 @@
import React from 'react'; import React from 'react';
import { Modal } from './Modal.tsx'; import { Modal } from './Modal.tsx';
import { Button } from './buttons/Button.tsx';
import './ConfirmDialog.css';
interface ConfirmDialogProps { interface ConfirmDialogProps {
isOpen: boolean; isOpen: boolean;
@ -82,20 +80,100 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
<p className="confirm-message">{message}</p> <p className="confirm-message">{message}</p>
</div> </div>
<div className="confirm-actions"> <div className="confirm-actions">
<Button <button
variant="secondary" type="button"
className="button secondary"
onClick={onClose} onClick={onClose}
> >
{cancelText} {cancelText}
</Button> </button>
<Button <button
variant={isDestructive || type === 'danger' ? 'danger' : 'primary'} type="button"
className={`button ${isDestructive || type === 'danger' ? 'danger' : 'primary'}`}
onClick={handleConfirm} onClick={handleConfirm}
autoFocus
> >
{getConfirmText()} {getConfirmText()}
</Button> </button>
</div> </div>
</div> </div>
<style jsx>{`
.confirm-dialog {
text-align: center;
}
.confirm-content {
margin-bottom: 2rem;
}
.confirm-icon {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.confirm-message {
color: #374151;
font-size: 1rem;
line-height: 1.5;
margin: 0;
text-align: left;
}
.confirm-actions {
display: flex;
justify-content: center;
gap: 0.75rem;
}
.button {
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 500;
font-size: 0.875rem;
border: none;
cursor: pointer;
transition: all 0.2s ease;
min-width: 80px;
}
.button.primary {
background: #3b82f6;
color: white;
}
.button.primary:hover {
background: #2563eb;
}
.button.secondary {
background: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
}
.button.secondary:hover {
background: #e5e7eb;
}
.button.danger {
background: #dc2626;
color: white;
}
.button.danger:hover {
background: #b91c1c;
}
.button:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.button.danger:focus {
outline-color: #dc2626;
}
`}</style>
</Modal> </Modal>
); );
}; };

View File

@ -60,7 +60,7 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1.5rem 1.5rem 0 1.5rem; padding: 1.5rem 1.5rem 0 1.5rem;
border-bottom: 1px solid var(--border-primary); border-bottom: 1px solid #e5e7eb;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@ -68,13 +68,13 @@
margin: 0; margin: 0;
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: #111827;
} }
.modal-close-button { .modal-close-button {
background: none; background: none;
border: none; border: none;
color: var(--text-tertiary); color: #6b7280;
cursor: pointer; cursor: pointer;
font-size: 1.25rem; font-size: 1.25rem;
padding: 0.25rem; padding: 0.25rem;
@ -88,8 +88,8 @@
} }
.modal-close-button:hover { .modal-close-button:hover {
background: var(--bg-muted); background: #f3f4f6;
color: var(--text-secondary); color: #374151;
} }
/* Modal Body */ /* Modal Body */

View File

@ -1,5 +1,4 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { CloseButton } from './buttons/CloseButton.tsx';
import './Modal.css'; import './Modal.css';
interface ModalProps { interface ModalProps {
@ -62,12 +61,14 @@ export const Modal: React.FC<ModalProps> = ({
{title && ( {title && (
<header className="modal-header"> <header className="modal-header">
<h2 className="modal-title">{title}</h2> <h2 className="modal-title">{title}</h2>
<CloseButton <button
size="medium" type="button"
variant="modal" className="modal-close-button"
onClick={onClose} onClick={onClose}
title="Close modal" aria-label="Close modal"
/> >
</button>
</header> </header>
)} )}
<div className="modal-body"> <div className="modal-body">

View File

@ -1,78 +0,0 @@
/* Action Button Styles */
.action-button {
/* Layout */
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
/* Typography */
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
/* Interaction */
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid transparent;
/* Appearance */
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
outline: none;
}
.action-button:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: var(--focus-ring-offset);
}
.action-button:disabled {
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
/* Size Variants */
.action-button.large {
padding: 1rem 2rem;
font-size: 1rem;
font-weight: 600;
}
/* Primary Variant */
.action-button.primary {
background: var(--btn-primary-bg);
color: var(--btn-primary-text);
border-color: var(--btn-primary-border);
}
.action-button.primary:hover:not(:disabled) {
background: var(--btn-primary-bg-hover);
border-color: var(--btn-primary-border-hover);
}
.action-button.primary:active:not(:disabled) {
background: var(--btn-primary-bg-active);
border-color: var(--btn-primary-bg-active);
}
/* Secondary Variant */
.action-button.secondary {
background: var(--btn-secondary-bg);
color: var(--btn-secondary-text);
border-color: var(--btn-secondary-border);
}
.action-button.secondary:hover:not(:disabled) {
background: var(--btn-secondary-bg-hover);
color: var(--btn-secondary-text-hover);
border-color: var(--btn-secondary-border-hover);
}
.action-button.secondary:active:not(:disabled) {
background: var(--btn-secondary-bg-active);
color: var(--btn-secondary-text-active);
}

View File

@ -1,50 +0,0 @@
import React from 'react';
import type { ReactNode } from 'react';
import './ActionButton.css';
interface ActionButtonProps {
children: ReactNode;
variant?: 'primary' | 'secondary';
size?: 'medium' | 'large';
disabled?: boolean;
loading?: boolean;
onClick?: () => void;
type?: 'button' | 'submit' | 'reset';
className?: string;
title?: string;
}
export const ActionButton: React.FC<ActionButtonProps> = ({
children,
variant = 'primary',
size = 'medium',
disabled = false,
loading = false,
onClick,
type = 'button',
className = '',
title,
}) => {
const baseClasses = 'action-button';
const variantClass = variant;
const sizeClass = size === 'large' ? 'large' : '';
const classes = [
baseClasses,
variantClass,
sizeClass,
className
].filter(Boolean).join(' ');
return (
<button
type={type}
className={classes}
disabled={disabled || loading}
onClick={onClick}
title={title}
>
{loading ? 'Loading...' : children}
</button>
);
};

View File

@ -1,43 +0,0 @@
/* Back Button Styles */
.back-button {
/* Layout */
display: inline-flex;
align-items: center;
gap: 0.25rem;
/* Typography */
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
/* Interaction */
background: none;
border: none;
cursor: pointer;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
transition: all 0.2s ease;
text-decoration: none;
}
.back-button:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
.back-button:active:not(:disabled) {
background: var(--bg-active);
color: var(--text-primary);
}
.back-button:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.back-button:disabled {
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}

View File

@ -1,31 +0,0 @@
import React from 'react';
import type { ReactNode } from 'react';
import './BackButton.css';
interface BackButtonProps {
children?: ReactNode;
onClick?: () => void;
disabled?: boolean;
className?: string;
title?: string;
}
export const BackButton: React.FC<BackButtonProps> = ({
children = '← Back',
onClick,
disabled = false,
className = '',
title,
}) => {
return (
<button
type="button"
className={`back-button ${className}`.trim()}
onClick={onClick}
disabled={disabled}
title={title}
>
{children}
</button>
);
};

View File

@ -1,144 +0,0 @@
/* Base Button Styles */
.button {
/* Layout */
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
/* Typography */
font-family: inherit;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
/* Interaction */
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid transparent;
/* Appearance */
border-radius: 0.375rem;
outline: none;
}
.button:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: var(--focus-ring-offset);
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
/* Size Variants */
.button-small {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
border-radius: 0.25rem;
}
.button-medium {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
.button-large {
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 600;
}
/* Primary Variant */
.button-primary {
background: var(--btn-primary-bg);
color: var(--btn-primary-text);
border-color: var(--btn-primary-border);
}
.button-primary:hover:not(:disabled) {
background: var(--btn-primary-bg-hover);
border-color: var(--btn-primary-border-hover);
}
.button-primary:active:not(:disabled) {
background: var(--btn-primary-bg-active);
border-color: var(--btn-primary-bg-active);
}
/* Secondary Variant */
.button-secondary {
background: var(--btn-secondary-bg);
color: var(--btn-secondary-text);
border-color: var(--btn-secondary-border);
}
.button-secondary:hover:not(:disabled) {
background: var(--btn-secondary-bg-hover);
color: var(--btn-secondary-text-hover);
border-color: var(--btn-secondary-border-hover);
}
.button-secondary:active:not(:disabled) {
background: var(--btn-secondary-bg-active);
color: var(--btn-secondary-text-active);
}
/* Danger Variant */
.button-danger {
background: var(--btn-danger-bg);
color: var(--btn-danger-text);
border-color: var(--btn-danger-border);
}
.button-danger:hover:not(:disabled) {
background: var(--btn-danger-bg-hover);
border-color: var(--btn-danger-border-hover);
}
.button-danger:active:not(:disabled) {
background: var(--btn-danger-bg-active);
border-color: var(--btn-danger-bg-active);
}
/* Link Variant */
.button-link {
background: none;
color: var(--text-secondary);
border: none;
text-decoration: underline;
padding: 0.25rem 0.5rem;
}
.button-link:hover:not(:disabled) {
color: var(--text-primary);
}
.button-link:active:not(:disabled) {
color: var(--text-primary);
}
/* Loading State */
.button-loading {
position: relative;
color: transparent;
}
.button-loading::after {
content: '';
position: absolute;
width: 1rem;
height: 1rem;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: button-spin 1s linear infinite;
color: inherit;
}
@keyframes button-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

View File

@ -1,52 +0,0 @@
import React from 'react';
import type { ReactNode } from 'react';
import './Button.css';
interface ButtonProps {
children: ReactNode;
variant?: 'primary' | 'secondary' | 'danger' | 'link';
size?: 'small' | 'medium' | 'large';
disabled?: boolean;
loading?: boolean;
onClick?: () => void;
type?: 'button' | 'submit' | 'reset';
className?: string;
title?: string;
}
export const Button: React.FC<ButtonProps> = ({
children,
variant = 'primary',
size = 'medium',
disabled = false,
loading = false,
onClick,
type = 'button',
className = '',
title,
}) => {
const baseClasses = 'button';
const variantClass = `button-${variant}`;
const sizeClass = `button-${size}`;
const loadingClass = loading ? 'button-loading' : '';
const classes = [
baseClasses,
variantClass,
sizeClass,
loadingClass,
className
].filter(Boolean).join(' ');
return (
<button
type={type}
className={classes}
disabled={disabled || loading}
onClick={onClick}
title={title}
>
{loading ? 'Loading...' : children}
</button>
);
};

View File

@ -1,41 +0,0 @@
/* Cancel Link Styles */
.cancel-link {
/* Layout */
display: inline-flex;
align-items: center;
/* Typography */
font-family: inherit;
font-size: 0.875rem;
font-weight: 400;
color: #64748b;
text-decoration: underline;
/* Interaction */
background: none;
border: none;
cursor: pointer;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
transition: color 0.2s ease;
}
.cancel-link:hover:not(:disabled) {
color: #374151;
text-decoration: underline;
}
.cancel-link:active:not(:disabled) {
color: #1f2937;
}
.cancel-link:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.cancel-link:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}

View File

@ -1,31 +0,0 @@
import React from 'react';
import type { ReactNode } from 'react';
import './CancelLink.css';
interface CancelLinkProps {
children: ReactNode;
onClick?: () => void;
disabled?: boolean;
className?: string;
title?: string;
}
export const CancelLink: React.FC<CancelLinkProps> = ({
children,
onClick,
disabled = false,
className = '',
title,
}) => {
return (
<button
type="button"
className={`cancel-link ${className}`.trim()}
onClick={onClick}
disabled={disabled}
title={title}
>
{children}
</button>
);
};

View File

@ -1,83 +0,0 @@
/* Close Button Styles */
.close-button {
/* Layout */
display: flex;
align-items: center;
justify-content: center;
/* Typography */
font-family: inherit;
font-weight: 500;
color: #374151;
/* Interaction */
background: rgba(255, 255, 255, 0.9);
border: none;
cursor: pointer;
transition: all 0.2s ease;
/* Appearance */
border-radius: 50%;
outline: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.close-button:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.close-button:disabled {
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
/* Size Variants */
.close-button-small {
width: 28px;
height: 28px;
font-size: 0.875rem;
}
.close-button-medium {
width: 36px;
height: 36px;
font-size: 1rem;
}
.close-button-large {
width: 44px;
height: 44px;
font-size: 1.25rem;
}
/* Style Variants */
.close-button-default:hover:not(:disabled) {
background: rgba(255, 255, 255, 1);
transform: scale(1.05);
}
.close-button-modal {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 10;
}
.close-button-modal:hover:not(:disabled) {
background: rgba(255, 255, 255, 1);
transform: scale(1.05);
}
.close-button-preview {
position: absolute;
top: 2rem;
right: 2rem;
z-index: 10;
}
.close-button-preview:hover:not(:disabled) {
background: rgba(255, 255, 255, 1);
transform: scale(1.05);
}

View File

@ -1,43 +0,0 @@
import React from 'react';
import './CloseButton.css';
interface CloseButtonProps {
onClick?: () => void;
disabled?: boolean;
className?: string;
title?: string;
size?: 'small' | 'medium' | 'large';
variant?: 'default' | 'modal' | 'preview';
}
export const CloseButton: React.FC<CloseButtonProps> = ({
onClick,
disabled = false,
className = '',
title = 'Close',
size = 'medium',
variant = 'default',
}) => {
const baseClasses = 'close-button';
const sizeClass = `close-button-${size}`;
const variantClass = `close-button-${variant}`;
const classes = [
baseClasses,
sizeClass,
variantClass,
className
].filter(Boolean).join(' ');
return (
<button
type="button"
className={classes}
onClick={onClick}
disabled={disabled}
title={title}
>
</button>
);
};

View File

@ -1,59 +0,0 @@
/* Navigation Button Styles */
.nav-button {
/* Layout */
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
/* Typography */
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
color: #64748b;
/* Interaction */
background: rgba(255, 255, 255, 0.9);
border: 1px solid #e2e8f0;
cursor: pointer;
transition: all 0.2s ease;
/* Appearance */
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
outline: none;
backdrop-filter: blur(8px);
}
.nav-button:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.nav-button:disabled {
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
.nav-button:hover:not(:disabled) {
background: rgba(255, 255, 255, 1);
color: #374151;
border-color: #cbd5e1;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.nav-button:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
/* Direction-specific styles */
.nav-button-previous {
padding-left: 0.5rem;
}
.nav-button-next {
padding-right: 0.5rem;
}

View File

@ -1,42 +0,0 @@
import React from 'react';
import type { ReactNode } from 'react';
import './NavigationButton.css';
interface NavigationButtonProps {
children: ReactNode;
onClick?: () => void;
disabled?: boolean;
className?: string;
title?: string;
direction?: 'previous' | 'next' | 'none';
}
export const NavigationButton: React.FC<NavigationButtonProps> = ({
children,
onClick,
disabled = false,
className = '',
title,
direction = 'none',
}) => {
const baseClasses = 'nav-button';
const directionClass = direction !== 'none' ? `nav-button-${direction}` : '';
const classes = [
baseClasses,
directionClass,
className
].filter(Boolean).join(' ');
return (
<button
type="button"
className={classes}
onClick={onClick}
disabled={disabled}
title={title}
>
{children}
</button>
);
};

View File

@ -1,14 +1,11 @@
/* Import global color system */
@import './styles/colors.css';
:root { :root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
color-scheme: light dark; color-scheme: light dark;
color: var(--color-dark-text); color: rgba(255, 255, 255, 0.87);
background-color: var(--color-dark-bg); background-color: #242424;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
@ -18,11 +15,11 @@
a { a {
font-weight: 500; font-weight: 500;
color: var(--color-light-link); color: #646cff;
text-decoration: inherit; text-decoration: inherit;
} }
a:hover { a:hover {
color: var(--color-light-link-hover); color: #535bf2;
} }
body { body {
@ -43,12 +40,12 @@ button {
font-size: 1em; font-size: 1em;
font-weight: 500; font-weight: 500;
font-family: inherit; font-family: inherit;
background-color: var(--color-dark-surface); background-color: #1a1a1a;
cursor: pointer; cursor: pointer;
transition: border-color 0.25s; transition: border-color 0.25s;
} }
button:hover { button:hover {
border-color: var(--color-light-link); border-color: #646cff;
} }
button:focus, button:focus,
button:focus-visible { button:focus-visible {
@ -57,13 +54,13 @@ button:focus-visible {
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
:root { :root {
color: var(--color-dark-secondary); color: #213547;
background-color: var(--color-white); background-color: #ffffff;
} }
a:hover { a:hover {
color: var(--color-light-link-alt); color: #747bff;
} }
button { button {
background-color: var(--color-gray-50); background-color: #f9f9f9;
} }
} }

View File

@ -17,7 +17,7 @@ export function themeWatcherPlugin(): Plugin {
const watcher = fs.watch( const watcher = fs.watch(
publicThemesPath, publicThemesPath,
{ recursive: true }, { recursive: true },
(_eventType: string, filename: string | null) => { (eventType: string, filename: string | null) => {
if (filename && (filename.endsWith('.css') || filename.endsWith('.html'))) { if (filename && (filename.endsWith('.css') || filename.endsWith('.html'))) {
console.log(`Theme file changed: ${filename}`); console.log(`Theme file changed: ${filename}`);

View File

@ -1,265 +0,0 @@
/* Global Color System
*
* This file defines a comprehensive color system using CSS custom properties (variables)
* that replaces hardcoded colors throughout the application.
*
* Structure:
* - Base colors (white, black)
* - Gray and Slate scales (neutrals)
* - Blue scale (primary brand color)
* - Red scale (errors/danger)
* - Green scale (success)
* - Yellow/Amber scale (warnings)
* - Purple/Indigo scale (accents)
* - Pink/Rose scale (additional accents)
* - Dark mode specific colors
* - Semantic color mappings for consistent usage across components
*
* Usage: Always use semantic color variables (e.g., --text-primary, --bg-secondary)
* rather than specific color values (e.g., --color-slate-800) for better maintainability
* and theme consistency.
*/
:root {
/* Base Colors */
--color-white: #ffffff;
--color-black: #000000;
/* Gray Scale */
--color-gray-50: #f9fafb;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-300: #d1d5db;
--color-gray-400: #9ca3af;
--color-gray-500: #6b7280;
--color-gray-600: #4b5563;
--color-gray-700: #374151;
--color-gray-800: #1f2937;
--color-gray-900: #111827;
/* Slate Scale (for UI elements) */
--color-slate-50: #f8fafc;
--color-slate-100: #f1f5f9;
--color-slate-200: #e2e8f0;
--color-slate-300: #cbd5e1;
--color-slate-400: #94a3b8;
--color-slate-500: #64748b;
--color-slate-600: #475569;
--color-slate-700: #334155;
--color-slate-800: #1e293b;
--color-slate-900: #0f172a;
/* Blue Scale (Primary) */
--color-blue-50: #eff6ff;
--color-blue-100: #dbeafe;
--color-blue-200: #bfdbfe;
--color-blue-300: #93c5fd;
--color-blue-400: #60a5fa;
--color-blue-500: #3b82f6;
--color-blue-600: #2563eb;
--color-blue-700: #1d4ed8;
--color-blue-800: #1e40af;
--color-blue-900: #1e3a8a;
/* Red Scale (Error/Danger) */
--color-red-50: #fef2f2;
--color-red-100: #fee2e2;
--color-red-200: #fecaca;
--color-red-300: #fca5a5;
--color-red-400: #f87171;
--color-red-500: #ef4444;
--color-red-600: #dc2626;
--color-red-700: #b91c1c;
--color-red-800: #991b1b;
--color-red-900: #7f1d1d;
/* Green Scale (Success) */
--color-green-50: #f0fdf4;
--color-green-100: #dcfce7;
--color-green-200: #bbf7d0;
--color-green-300: #86efac;
--color-green-400: #4ade80;
--color-green-500: #22c55e;
--color-green-600: #16a34a;
--color-green-700: #15803d;
--color-green-800: #166534;
--color-green-900: #14532d;
/* Emerald Scale (Alternative Success) */
--color-emerald-600: #059669;
--color-emerald-700: #047857;
/* Yellow/Amber Scale (Warning) */
--color-amber-50: #fffbeb;
--color-amber-100: #fef3c7;
--color-amber-200: #fde68a;
--color-amber-300: #fcd34d;
--color-amber-400: #fbbf24;
--color-amber-500: #f59e0b;
--color-amber-600: #d97706;
--color-amber-700: #b45309;
--color-amber-800: #92400e;
--color-amber-900: #78350f;
/* Purple/Indigo Scale (Accent) */
--color-indigo-50: #eef2ff;
--color-indigo-100: #e0e7ff;
--color-indigo-200: #c7d2fe;
--color-indigo-300: #a5b4fc;
--color-indigo-400: #818cf8;
--color-indigo-500: #6366f1;
--color-indigo-600: #4f46e5;
--color-indigo-700: #4338ca;
--color-indigo-800: #3730a3;
--color-indigo-900: #312e81;
--color-purple-100: #f3e8ff;
--color-purple-200: #e9d5ff;
--color-purple-300: #d8b4fe;
--color-purple-400: #c084fc;
--color-purple-500: #a855f7;
--color-purple-600: #9333ea;
--color-purple-700: #7c3aed;
--color-purple-800: #6b21a8;
--color-purple-900: #581c87;
--color-violet-100: #ede9fe;
--color-violet-200: #ddd6fe;
/* Pink/Rose Scale */
--color-pink-100: #fce7f3;
--color-pink-600: #be185d;
--color-rose-600: #c53030;
--color-rose-100: #fed7d7;
/* Dark Mode Colors */
--color-dark-bg: #242424;
--color-dark-surface: #1a1a1a;
--color-dark-text: rgba(255, 255, 255, 0.87);
--color-dark-secondary: #213547;
/* Light Mode Link Colors */
--color-light-link: #646cff;
--color-light-link-hover: #535bf2;
--color-light-link-alt: #747bff;
/* Semantic Color Mapping */
/* Backgrounds */
--bg-primary: var(--color-slate-50);
--bg-secondary: var(--color-white);
--bg-tertiary: var(--color-slate-100);
--bg-accent: var(--color-blue-50);
--bg-error: var(--color-red-50);
--bg-success: var(--color-green-50);
--bg-warning: var(--color-amber-50);
--bg-muted: var(--color-gray-50);
--bg-hover: var(--color-slate-100);
--bg-active: var(--color-slate-200);
/* Borders */
--border-primary: var(--color-slate-200);
--border-secondary: var(--color-gray-300);
--border-accent: var(--color-blue-500);
--border-hover: var(--color-slate-300);
--border-error: var(--color-red-200);
--border-success: var(--color-green-200);
--border-warning: var(--color-amber-300);
--border-focus: var(--color-blue-500);
/* Text Colors */
--text-primary: var(--color-slate-800);
--text-secondary: var(--color-slate-500);
--text-tertiary: var(--color-gray-500);
--text-accent: var(--color-blue-500);
--text-error: var(--color-red-600);
--text-success: var(--color-green-600);
--text-warning: var(--color-amber-600);
--text-muted: var(--color-gray-400);
--text-link: var(--color-blue-600);
--text-link-hover: var(--color-blue-700);
--text-on-dark: var(--color-white);
--text-on-light: var(--color-gray-900);
/* Button Colors */
--btn-primary-bg: var(--color-blue-500);
--btn-primary-bg-hover: var(--color-blue-600);
--btn-primary-bg-active: var(--color-blue-700);
--btn-primary-text: var(--color-white);
--btn-primary-border: var(--color-blue-500);
--btn-primary-border-hover: var(--color-blue-600);
--btn-secondary-bg: var(--color-slate-50);
--btn-secondary-bg-hover: var(--color-slate-100);
--btn-secondary-bg-active: var(--color-slate-200);
--btn-secondary-text: var(--color-slate-500);
--btn-secondary-text-hover: var(--color-slate-600);
--btn-secondary-text-active: var(--color-slate-700);
--btn-secondary-border: var(--color-slate-200);
--btn-secondary-border-hover: var(--color-slate-300);
--btn-danger-bg: var(--color-red-600);
--btn-danger-bg-hover: var(--color-red-700);
--btn-danger-bg-active: var(--color-red-800);
--btn-danger-text: var(--color-white);
--btn-danger-border: var(--color-red-600);
--btn-danger-border-hover: var(--color-red-700);
/* Badge/Tag Colors */
--badge-title-bg: var(--color-amber-100);
--badge-title-text: var(--color-amber-800);
--badge-subtitle-bg: var(--color-indigo-100);
--badge-subtitle-text: var(--color-indigo-800);
--badge-text-bg: var(--color-green-100);
--badge-text-text: var(--color-green-700);
--badge-image-bg: var(--color-pink-100);
--badge-image-text: var(--color-pink-600);
--badge-video-bg: var(--color-violet-200);
--badge-video-text: var(--color-purple-800);
--badge-list-bg: var(--color-rose-100);
--badge-list-text: var(--color-rose-600);
/* Focus & Selection */
--focus-ring: var(--color-blue-500);
--focus-ring-offset: 2px;
--selection-bg: var(--color-blue-100);
--selection-text: var(--color-blue-800);
}
/* Dark mode overrides */
@media (prefers-color-scheme: dark) {
:root {
/* Override semantic colors for dark mode */
--bg-primary: var(--color-gray-900);
--bg-secondary: var(--color-gray-800);
--bg-tertiary: var(--color-gray-700);
--bg-accent: var(--color-blue-900);
--bg-error: var(--color-red-900);
--bg-success: var(--color-green-900);
--bg-warning: var(--color-amber-900);
--bg-muted: var(--color-gray-800);
--bg-hover: var(--color-gray-700);
--bg-active: var(--color-gray-600);
--border-primary: var(--color-gray-600);
--border-secondary: var(--color-gray-500);
--border-hover: var(--color-gray-500);
--border-accent: var(--color-blue-400);
--border-focus: var(--color-blue-400);
--text-primary: var(--color-gray-100);
--text-secondary: var(--color-gray-300);
--text-tertiary: var(--color-gray-400);
--text-accent: var(--color-blue-400);
--text-link: var(--color-blue-400);
--text-link-hover: var(--color-blue-300);
--text-muted: var(--color-gray-500);
--btn-secondary-bg: var(--color-gray-700);
--btn-secondary-bg-hover: var(--color-gray-600);
--btn-secondary-bg-active: var(--color-gray-500);
--btn-secondary-text: var(--color-gray-300);
--btn-secondary-text-hover: var(--color-gray-200);
--btn-secondary-text-active: var(--color-gray-100);
--btn-secondary-border: var(--color-gray-600);
--btn-secondary-border-hover: var(--color-gray-500);
}
}

10
src/themes/index.ts Normal file
View File

@ -0,0 +1,10 @@
// Re-export from the theme loader utility
export { discoverThemes as getThemes, loadTheme } from '../utils/themeLoader.ts';
// Import for internal use
import { loadTheme } from '../utils/themeLoader.ts';
// Helper function to get a single theme by ID
export const getTheme = async (themeId: string) => {
return loadTheme(themeId, false);
};

View File

@ -5,29 +5,25 @@ import log from 'loglevel';
*/ */
// Define log levels for different parts of the application // Define log levels for different parts of the application
export const LogLevel = { export enum LogLevel {
TRACE: 0, TRACE = 0,
DEBUG: 1, DEBUG = 1,
INFO: 2, INFO = 2,
WARN: 3, WARN = 3,
ERROR: 4, ERROR = 4,
SILENT: 5, SILENT = 5,
} as const; }
export type LogLevel = typeof LogLevel[keyof typeof LogLevel];
// Log categories for better organization // Log categories for better organization
export const LogCategory = { export enum LogCategory {
PRESENTATION: 'presentation', PRESENTATION = 'presentation',
THEME: 'theme', THEME = 'theme',
STORAGE: 'storage', STORAGE = 'storage',
UI: 'ui', UI = 'ui',
SECURITY: 'security', SECURITY = 'security',
PERFORMANCE: 'performance', PERFORMANCE = 'performance',
GENERAL: 'general', GENERAL = 'general',
} as const; }
export type LogCategory = typeof LogCategory[keyof typeof LogCategory];
/** /**
* Configure logging based on environment * Configure logging based on environment