From 497d8c6ecf8361f0d95d13d4bd00d18095dda7a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:53:02 +0000 Subject: [PATCH 01/12] Initial plan From 12dc8284fcc2e86a6256111491f4a09ff7cfdd07 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 18:05:27 +0000 Subject: [PATCH 02/12] Add theme builder pro feature - core implementation Co-authored-by: estruyf <2900833+estruyf@users.noreply.github.com> --- apps/vscode-extension/package.json | 6 + apps/vscode-extension/src/extension.ts | 2 + .../src/models/WebviewType.ts | 2 +- .../src/panels/ThemeBuilderPanel.ts | 327 +++++++++++++ .../src/services/ThemeBuilderService.ts | 53 +++ apps/vscode-extension/src/services/index.ts | 1 + .../components/webviews/ThemeBuilderView.tsx | 428 ++++++++++++++++++ apps/webviews/src/main.tsx | 1 + packages/common/src/constants/Command.ts | 1 + .../common/src/constants/WebViewMessages.ts | 9 + 10 files changed, 829 insertions(+), 1 deletion(-) create mode 100644 apps/vscode-extension/src/panels/ThemeBuilderPanel.ts create mode 100644 apps/vscode-extension/src/services/ThemeBuilderService.ts create mode 100644 apps/webviews/src/components/webviews/ThemeBuilderView.tsx diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index e6401a5c..ec820a88 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -564,6 +564,12 @@ "title": "Authenticate with GitHub (Unlock Pro Features)", "category": "Demo Time", "icon": "$(github)" + }, + { + "command": "demo-time.openThemeBuilder", + "title": "Open Theme Builder (Pro)", + "category": "Demo Time", + "icon": "$(paintcan)" } ], "menus": { diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index 07f9ec2c..5201df26 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -18,6 +18,7 @@ import { TextTypingService, TerminalService, ResourceService, + ThemeBuilderService, } from './services'; import { DemoPanel } from './panels/DemoPanel'; import { ResourcesPanel } from './panels/ResourcesPanel'; @@ -63,6 +64,7 @@ export async function activate(context: vscode.ExtensionContext) { TerminalService.register(); InputService.registerCommands(); SponsorService.init(context); + ThemeBuilderService.register(); console.log(`${Config.title} is active!`); } diff --git a/apps/vscode-extension/src/models/WebviewType.ts b/apps/vscode-extension/src/models/WebviewType.ts index 6439d4fc..9c382ba9 100644 --- a/apps/vscode-extension/src/models/WebviewType.ts +++ b/apps/vscode-extension/src/models/WebviewType.ts @@ -1 +1 @@ -export type WebviewType = 'presenter' | 'preview' | 'settings' | 'config-editor' | 'overview'; +export type WebviewType = 'presenter' | 'preview' | 'settings' | 'config-editor' | 'overview' | 'themeBuilder'; diff --git a/apps/vscode-extension/src/panels/ThemeBuilderPanel.ts b/apps/vscode-extension/src/panels/ThemeBuilderPanel.ts new file mode 100644 index 00000000..89f6ae62 --- /dev/null +++ b/apps/vscode-extension/src/panels/ThemeBuilderPanel.ts @@ -0,0 +1,327 @@ +import { Uri, ViewColumn, WebviewPanel } from 'vscode'; +import { WebviewType } from '../models'; +import { Extension } from '../services'; +import { Config, WebViewMessages } from '@demotime/common'; +import { BaseWebview } from '../webview/BaseWebviewPanel'; +import { General } from '../constants'; +import { fileExists, readFile, writeFile, sanitizeFileName } from '../utils'; + +export class ThemeBuilderPanel extends BaseWebview { + public static id: WebviewType = 'themeBuilder'; + public static title: string = `${Config.title}: Theme Builder`; + + /** + * Render the theme builder panel + */ + public static async render() { + if (ThemeBuilderPanel.isOpen) { + ThemeBuilderPanel.reveal(); + } else { + await ThemeBuilderPanel.create(); + } + } + + protected static onCreate() { + ThemeBuilderPanel.isDisposed = false; + } + + protected static onDispose() { + ThemeBuilderPanel.isDisposed = true; + } + + protected static async messageListener(message: any) { + const { command, requestId, payload } = message; + + if (!command) { + return; + } + + switch (command) { + case WebViewMessages.toVscode.themeBuilder.getExistingThemes: + await ThemeBuilderPanel.getExistingThemes(command, requestId); + break; + case WebViewMessages.toVscode.themeBuilder.getExistingLayouts: + await ThemeBuilderPanel.getExistingLayouts(command, requestId); + break; + case WebViewMessages.toVscode.themeBuilder.loadTheme: + await ThemeBuilderPanel.loadTheme(command, requestId, payload); + break; + case WebViewMessages.toVscode.themeBuilder.loadLayout: + await ThemeBuilderPanel.loadLayout(command, requestId, payload); + break; + case WebViewMessages.toVscode.themeBuilder.saveTheme: + await ThemeBuilderPanel.saveTheme(command, requestId, payload); + break; + case WebViewMessages.toVscode.themeBuilder.saveLayout: + await ThemeBuilderPanel.saveLayout(command, requestId, payload); + break; + case WebViewMessages.toVscode.themeBuilder.getPreviewHtml: + await ThemeBuilderPanel.getPreviewHtml(command, requestId, payload); + break; + } + } + + /** + * Get list of existing themes + */ + private static async getExistingThemes(command: string, requestId: string) { + try { + const wsFolder = Extension.getInstance().workspaceFolder; + if (!wsFolder) { + ThemeBuilderPanel.postRequestMessage(command, requestId, []); + return; + } + + const slidesFolder = Uri.joinPath(wsFolder.uri, General.demoFolder, General.slidesFolder); + const themeFiles: { name: string; path: string }[] = []; + + // Look for CSS files in slides folder + try { + const files = await Extension.getInstance().context.fs.readDirectory(slidesFolder); + for (const [file, type] of files) { + if (type === 1 && file.endsWith('.css')) { + // File type + themeFiles.push({ + name: file.replace('.css', ''), + path: file, + }); + } + } + } catch (error) { + // Folder might not exist yet + } + + ThemeBuilderPanel.postRequestMessage(command, requestId, themeFiles); + } catch (error) { + console.error('Error getting existing themes:', error); + ThemeBuilderPanel.postRequestMessage(command, requestId, []); + } + } + + /** + * Get list of existing layouts + */ + private static async getExistingLayouts(command: string, requestId: string) { + try { + const wsFolder = Extension.getInstance().workspaceFolder; + if (!wsFolder) { + ThemeBuilderPanel.postRequestMessage(command, requestId, []); + return; + } + + const layoutsFolder = Uri.joinPath(wsFolder.uri, General.demoFolder, 'layouts'); + const layoutFiles: { name: string; path: string }[] = []; + + // Look for HBS/Handlebars files in layouts folder + try { + const files = await Extension.getInstance().context.fs.readDirectory(layoutsFolder); + for (const [file, type] of files) { + if (type === 1 && (file.endsWith('.hbs') || file.endsWith('.handlebars'))) { + // File type + layoutFiles.push({ + name: file.replace(/\.(hbs|handlebars)$/, ''), + path: file, + }); + } + } + } catch (error) { + // Folder might not exist yet + } + + ThemeBuilderPanel.postRequestMessage(command, requestId, layoutFiles); + } catch (error) { + console.error('Error getting existing layouts:', error); + ThemeBuilderPanel.postRequestMessage(command, requestId, []); + } + } + + /** + * Load a theme file + */ + private static async loadTheme(command: string, requestId: string, payload: { name: string }) { + try { + const wsFolder = Extension.getInstance().workspaceFolder; + if (!wsFolder) { + ThemeBuilderPanel.postRequestMessage(command, requestId, null); + return; + } + + const themePath = Uri.joinPath( + wsFolder.uri, + General.demoFolder, + General.slidesFolder, + `${payload.name}.css`, + ); + + if (!(await fileExists(themePath))) { + ThemeBuilderPanel.postRequestMessage(command, requestId, null); + return; + } + + const content = await readFile(themePath); + ThemeBuilderPanel.postRequestMessage(command, requestId, { + name: payload.name, + content: content?.toString() || '', + }); + } catch (error) { + console.error('Error loading theme:', error); + ThemeBuilderPanel.postRequestMessage(command, requestId, null); + } + } + + /** + * Load a layout file + */ + private static async loadLayout(command: string, requestId: string, payload: { name: string }) { + try { + const wsFolder = Extension.getInstance().workspaceFolder; + if (!wsFolder) { + ThemeBuilderPanel.postRequestMessage(command, requestId, null); + return; + } + + const layoutPath = Uri.joinPath( + wsFolder.uri, + General.demoFolder, + 'layouts', + `${payload.name}.hbs`, + ); + + if (!(await fileExists(layoutPath))) { + ThemeBuilderPanel.postRequestMessage(command, requestId, null); + return; + } + + const content = await readFile(layoutPath); + ThemeBuilderPanel.postRequestMessage(command, requestId, { + name: payload.name, + content: content?.toString() || '', + }); + } catch (error) { + console.error('Error loading layout:', error); + ThemeBuilderPanel.postRequestMessage(command, requestId, null); + } + } + + /** + * Save a theme file + */ + private static async saveTheme( + command: string, + requestId: string, + payload: { name: string; content: string }, + ) { + try { + const wsFolder = Extension.getInstance().workspaceFolder; + if (!wsFolder) { + ThemeBuilderPanel.postRequestMessage(command, requestId, { success: false }); + return; + } + + const fileName = sanitizeFileName(payload.name, '.css'); + const themePath = Uri.joinPath( + wsFolder.uri, + General.demoFolder, + General.slidesFolder, + fileName, + ); + + await writeFile(themePath, payload.content); + + ThemeBuilderPanel.postRequestMessage(command, requestId, { + success: true, + path: fileName, + }); + } catch (error) { + console.error('Error saving theme:', error); + ThemeBuilderPanel.postRequestMessage(command, requestId, { success: false }); + } + } + + /** + * Save a layout file + */ + private static async saveLayout( + command: string, + requestId: string, + payload: { name: string; content: string }, + ) { + try { + const wsFolder = Extension.getInstance().workspaceFolder; + if (!wsFolder) { + ThemeBuilderPanel.postRequestMessage(command, requestId, { success: false }); + return; + } + + // Ensure layouts folder exists + const layoutsFolder = Uri.joinPath(wsFolder.uri, General.demoFolder, 'layouts'); + if (!(await fileExists(layoutsFolder))) { + await Extension.getInstance().context.fs.createDirectory(layoutsFolder); + } + + const fileName = sanitizeFileName(payload.name, '.hbs'); + const layoutPath = Uri.joinPath(layoutsFolder, fileName); + + await writeFile(layoutPath, payload.content); + + ThemeBuilderPanel.postRequestMessage(command, requestId, { + success: true, + path: fileName, + }); + } catch (error) { + console.error('Error saving layout:', error); + ThemeBuilderPanel.postRequestMessage(command, requestId, { success: false }); + } + } + + /** + * Get preview HTML for a slide with custom theme/layout + */ + private static async getPreviewHtml( + command: string, + requestId: string, + payload: { css?: string; html?: string }, + ) { + try { + // Generate a simple preview HTML + const previewHtml = ` + + + + + + + + +
+ ${payload.html || '

Preview

Add your HTML content to see the preview

'} +
+ + + `; + + ThemeBuilderPanel.postRequestMessage(command, requestId, { html: previewHtml }); + } catch (error) { + console.error('Error generating preview HTML:', error); + ThemeBuilderPanel.postRequestMessage(command, requestId, { html: '' }); + } + } +} diff --git a/apps/vscode-extension/src/services/ThemeBuilderService.ts b/apps/vscode-extension/src/services/ThemeBuilderService.ts new file mode 100644 index 00000000..017f4dae --- /dev/null +++ b/apps/vscode-extension/src/services/ThemeBuilderService.ts @@ -0,0 +1,53 @@ +import { commands } from 'vscode'; +import { Subscription } from '../models'; +import { Extension } from './Extension'; +import { SponsorService } from './SponsorService'; +import { Notifications } from './Notifications'; +import { COMMAND } from '@demotime/common'; +import { ThemeBuilderPanel } from '../panels/ThemeBuilderPanel'; + +export class ThemeBuilderService { + /** + * Register the theme builder commands + */ + public static register() { + const subscriptions: Subscription[] = Extension.getInstance().subscriptions; + + subscriptions.push( + commands.registerCommand(COMMAND.openThemeBuilder, ThemeBuilderService.openThemeBuilder), + ); + } + + /** + * Open the theme builder panel (pro feature) + */ + private static async openThemeBuilder() { + // Check if user is a sponsor (pro feature) + const isSponsor = SponsorService.getSponsorStatus(); + + if (!isSponsor) { + const selection = await Notifications.warning( + 'Theme Builder is a Pro feature. Please authenticate with GitHub to unlock it.', + 'Authenticate', + ); + + if (selection === 'Authenticate') { + await commands.executeCommand(COMMAND.authenticate); + + // Check again after authentication + const isNowSponsor = SponsorService.getSponsorStatus(); + if (!isNowSponsor) { + Notifications.info( + 'To access Pro features, please consider sponsoring the project.', + ); + return; + } + } else { + return; + } + } + + // Open the theme builder panel + await ThemeBuilderPanel.render(); + } +} diff --git a/apps/vscode-extension/src/services/index.ts b/apps/vscode-extension/src/services/index.ts index 4bac5dbc..ce1f2391 100644 --- a/apps/vscode-extension/src/services/index.ts +++ b/apps/vscode-extension/src/services/index.ts @@ -29,5 +29,6 @@ export * from './StateManager'; export * from './TemplateCreator'; export * from './TerminalService'; export * from './TextTypingService'; +export * from './ThemeBuilderService'; export * from './UriHandler'; export * from './ZoomService'; diff --git a/apps/webviews/src/components/webviews/ThemeBuilderView.tsx b/apps/webviews/src/components/webviews/ThemeBuilderView.tsx new file mode 100644 index 00000000..2ad8cd2a --- /dev/null +++ b/apps/webviews/src/components/webviews/ThemeBuilderView.tsx @@ -0,0 +1,428 @@ +import { useEffect, useState } from 'react'; +import { Button } from '../ui/Button'; +import { Save, Plus, FileCode, Paintbrush, Eye } from 'lucide-react'; +import { Loader as Spinner } from 'vscrui'; +import { messageHandler } from '@estruyf/vscode/dist/client'; +import { WebViewMessages } from '@demotime/common'; +import '../../styles/config.css'; +import { AppHeader } from '../layout'; + +interface ThemeFile { + name: string; + path: string; +} + +interface ThemeContent { + name: string; + content: string; +} + +const ThemeBuilderView = () => { + const [mode, setMode] = useState<'theme' | 'layout'>('theme'); + const [themes, setThemes] = useState([]); + const [layouts, setLayouts] = useState([]); + const [selectedTheme, setSelectedTheme] = useState(''); + const [selectedLayout, setSelectedLayout] = useState(''); + const [themeName, setThemeName] = useState(''); + const [themeContent, setThemeContent] = useState(''); + const [layoutName, setLayoutName] = useState(''); + const [layoutContent, setLayoutContent] = useState(''); + const [previewHtml, setPreviewHtml] = useState(''); + const [saveStatus, setSaveStatus] = useState<{ + type: 'blank' | 'success' | 'error'; + text: string; + }>({ type: 'blank', text: '' }); + const [loading, setLoading] = useState(false); + + useEffect(() => { + loadExistingFiles(); + }, []); + + const loadExistingFiles = async () => { + try { + const themesData = await messageHandler.request( + WebViewMessages.toVscode.themeBuilder.getExistingThemes, + ); + const layoutsData = await messageHandler.request( + WebViewMessages.toVscode.themeBuilder.getExistingLayouts, + ); + setThemes(themesData || []); + setLayouts(layoutsData || []); + } catch (error) { + console.error('Error loading existing files:', error); + } + }; + + const loadTheme = async (name: string) => { + try { + setLoading(true); + const data = await messageHandler.request( + WebViewMessages.toVscode.themeBuilder.loadTheme, + { name }, + ); + if (data) { + setThemeName(data.name); + setThemeContent(data.content); + setSelectedTheme(name); + updatePreview(data.content, layoutContent); + } + } catch (error) { + console.error('Error loading theme:', error); + } finally { + setLoading(false); + } + }; + + const loadLayout = async (name: string) => { + try { + setLoading(true); + const data = await messageHandler.request( + WebViewMessages.toVscode.themeBuilder.loadLayout, + { name }, + ); + if (data) { + setLayoutName(data.name); + setLayoutContent(data.content); + setSelectedLayout(name); + updatePreview(themeContent, data.content); + } + } catch (error) { + console.error('Error loading layout:', error); + } finally { + setLoading(false); + } + }; + + const updatePreview = async (css: string, html: string) => { + try { + const data = await messageHandler.request<{ html: string }>( + WebViewMessages.toVscode.themeBuilder.getPreviewHtml, + { css, html }, + ); + if (data) { + setPreviewHtml(data.html); + } + } catch (error) { + console.error('Error updating preview:', error); + } + }; + + const saveTheme = async () => { + if (!themeName.trim()) { + setSaveStatus({ type: 'error', text: 'Theme name is required' }); + return; + } + + try { + setLoading(true); + const result = await messageHandler.request<{ success: boolean; path?: string }>( + WebViewMessages.toVscode.themeBuilder.saveTheme, + { name: themeName, content: themeContent }, + ); + + if (result?.success) { + setSaveStatus({ type: 'success', text: 'Theme saved successfully!' }); + await loadExistingFiles(); + setTimeout(() => setSaveStatus({ type: 'blank', text: '' }), 3000); + } else { + setSaveStatus({ type: 'error', text: 'Failed to save theme' }); + } + } catch (error) { + console.error('Error saving theme:', error); + setSaveStatus({ type: 'error', text: 'Failed to save theme' }); + } finally { + setLoading(false); + } + }; + + const saveLayout = async () => { + if (!layoutName.trim()) { + setSaveStatus({ type: 'error', text: 'Layout name is required' }); + return; + } + + try { + setLoading(true); + const result = await messageHandler.request<{ success: boolean; path?: string }>( + WebViewMessages.toVscode.themeBuilder.saveLayout, + { name: layoutName, content: layoutContent }, + ); + + if (result?.success) { + setSaveStatus({ type: 'success', text: 'Layout saved successfully!' }); + await loadExistingFiles(); + setTimeout(() => setSaveStatus({ type: 'blank', text: '' }), 3000); + } else { + setSaveStatus({ type: 'error', text: 'Failed to save layout' }); + } + } catch (error) { + console.error('Error saving layout:', error); + setSaveStatus({ type: 'error', text: 'Failed to save layout' }); + } finally { + setLoading(false); + } + }; + + const handleThemeContentChange = (value: string) => { + setThemeContent(value); + updatePreview(value, layoutContent); + }; + + const handleLayoutContentChange = (value: string) => { + setLayoutContent(value); + updatePreview(themeContent, value); + }; + + const createNewTheme = () => { + setThemeName(''); + setThemeContent( + `/* Custom Theme CSS */ +body { + background: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); +} + +.slide { + background: var(--vscode-editor-background) !important; + color: var(--vscode-editor-foreground) !important; +} + +.intro { + h1 { + font-size: 3em; + font-weight: bold; + } +} +`, + ); + setSelectedTheme(''); + updatePreview(themeContent, layoutContent); + }; + + const createNewLayout = () => { + setLayoutName(''); + setLayoutContent( + ` + +
+

{{metadata.title}}

+ {{{content}}} +
+`, + ); + setSelectedLayout(''); + updatePreview(themeContent, layoutContent); + }; + + if (loading) { + return ; + } + + return ( +
+ + +
+ {/* Mode Selector */} +
+ + +
+ + {/* Theme Editor */} + {mode === 'theme' && ( +
+
+ + +
+ +
+ + setThemeName(e.target.value)} + /> +
+ +
+ +