diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 11a0aae..38b0278 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -7,6 +7,7 @@ import { SessionProvider } from "next-auth/react"; import { TRPCReactProvider } from "@/trpc/react"; import { AppLayout } from "@features/shared/layout/app-layout"; import { SidebarProvider } from "@features/shared/layout/sidebar-context"; +import { ThemeProvider } from "@features/shared/theme"; export const metadata: Metadata = { title: "Red Team Assessment Platform (RTAP)", @@ -23,17 +24,19 @@ export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( - + - - - - -
{children}
-
-
-
-
+ + + + + +
{children}
+
+
+
+
+
); diff --git a/src/features/shared/export/export-to-png-button.tsx b/src/features/shared/export/export-to-png-button.tsx index bf9fa1c..55cab5d 100644 --- a/src/features/shared/export/export-to-png-button.tsx +++ b/src/features/shared/export/export-to-png-button.tsx @@ -1,47 +1,136 @@ "use client"; import { useState, type RefObject } from "react"; -import { Download } from "lucide-react"; +import { Download, Settings } from "lucide-react"; import { Button, type ButtonProps } from "@/components/ui/button"; import { captureElementToPng } from "@/lib/exportImage"; +import { useTheme } from "../theme"; +import type { ThemeKey } from "../theme"; interface ExportToPngButtonProps extends Omit { targetRef: RefObject; fileName: string; label?: string; + exportTheme?: ThemeKey; + optimizeForPrint?: boolean; + showThemeOptions?: boolean; } export function ExportToPngButton({ targetRef, fileName, label = "Export PNG", + exportTheme, + optimizeForPrint = false, + showThemeOptions = false, disabled, ...buttonProps }: ExportToPngButtonProps) { const [isExporting, setIsExporting] = useState(false); + const [showOptions, setShowOptions] = useState(false); + const { theme: currentTheme, isLight } = useTheme(); - const handleExport = async () => { + const handleExport = async (forceTheme?: ThemeKey, forceOptimize?: boolean) => { if (isExporting) return; const node = targetRef.current; if (!node) { window.alert("Unable to export image. Please try again once the content loads."); return; } + setIsExporting(true); try { - const dataUrl = await captureElementToPng(node); + const dataUrl = await captureElementToPng(node, { + forceTheme: forceTheme ?? exportTheme, + optimizeForPrint: forceOptimize ?? optimizeForPrint, + }); + const link = document.createElement("a"); link.href = dataUrl; link.download = fileName.endsWith(".png") ? fileName : `${fileName}.png`; link.click(); - } catch { + } catch (error) { + console.error("Export failed:", error); window.alert("Export failed. Please try again."); } finally { setIsExporting(false); + setShowOptions(false); } }; + if (showThemeOptions) { + return ( +
+
+ + + +
+ + {showOptions && ( +
+
+
+ Export Options +
+ + + + + + +
+
+ )} +
+ ); + } + return ( ); } + if (variant === "compact") { + // Compact cycling button with mode indicator + const currentThemes = getThemesByMode(mode); + const currentIndex = currentThemes.findIndex(t => t.key === theme); + + const nextTheme = () => { + const nextIndex = (currentIndex + 1) % currentThemes.length; + const next = currentThemes[nextIndex]; + if (next) setTheme(next.key); + }; + + const toggleMode = () => { + setMode(mode === "dark" ? "light" : "dark"); + }; + + return ( +
+ + +
+ ); + } + + // Full theme selector + const currentThemes = getThemesByMode(mode); + return ( -
- {THEMES.map((t, i) => { - const selected = theme === t.key; - return ( +
+ {/* Mode Toggle */} +
+ + Theme Mode + +
- ); - })} + +
+
+ + {/* Theme Color Selector */} +
+ + Color Theme + +
+ {currentThemes.map((t, i) => { + const selected = theme === t.key; + return ( + + ); + })} +
+
); } diff --git a/src/features/shared/theme/index.ts b/src/features/shared/theme/index.ts new file mode 100644 index 0000000..8d4ab13 --- /dev/null +++ b/src/features/shared/theme/index.ts @@ -0,0 +1,12 @@ +export { ThemeProvider, useTheme } from "./theme-context"; +export type { ThemeKey, ThemeMode, LightThemeKey, DarkThemeKey, ThemeInfo } from "./theme-types"; +export { + DARK_THEMES, + LIGHT_THEMES, + ALL_THEMES, + getThemeMode, + isLightTheme, + isDarkTheme, + getThemesByMode, + getDefaultTheme +} from "./theme-types"; \ No newline at end of file diff --git a/src/features/shared/theme/theme-context.tsx b/src/features/shared/theme/theme-context.tsx new file mode 100644 index 0000000..488cfea --- /dev/null +++ b/src/features/shared/theme/theme-context.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; +import type { ThemeKey, ThemeMode } from "./theme-types"; +import { getThemeMode, getDefaultTheme, ALL_THEMES } from "./theme-types"; + +interface ThemeContextType { + theme: ThemeKey; + mode: ThemeMode; + setTheme: (theme: ThemeKey) => void; + setMode: (mode: ThemeMode) => void; + isLight: boolean; + isDark: boolean; +} + +const ThemeContext = createContext(undefined); + +interface ThemeProviderProps { + children: ReactNode; + defaultTheme?: ThemeKey; +} + +export function ThemeProvider({ children, defaultTheme = "theme-modern-teal" }: ThemeProviderProps) { + const [theme, setThemeState] = useState(defaultTheme); + const mode = getThemeMode(theme); + + const applyTheme = (newTheme: ThemeKey) => { + if (typeof document !== 'undefined') { + const root = document.documentElement; + // Remove all theme classes + ALL_THEMES.forEach(t => root.classList.remove(t.key)); + // Add new theme class + root.classList.add(newTheme); + } + + if (typeof localStorage !== 'undefined') { + localStorage.setItem('rtap.theme', newTheme); + } + + setThemeState(newTheme); + }; + + const setTheme = (newTheme: ThemeKey) => { + applyTheme(newTheme); + }; + + const setMode = (newMode: ThemeMode) => { + const currentMode = getThemeMode(theme); + if (currentMode !== newMode) { + const newTheme = getDefaultTheme(newMode); + applyTheme(newTheme); + } + }; + + // Load stored theme on mount + useEffect(() => { + if (typeof window !== 'undefined') { + const stored = localStorage.getItem('rtap.theme') as ThemeKey | null; + const isValidTheme = stored && ALL_THEMES.some(t => t.key === stored); + const initial = isValidTheme ? stored : defaultTheme; + applyTheme(initial); + } + }, [defaultTheme]); + + const value: ThemeContextType = { + theme, + mode, + setTheme, + setMode, + isLight: mode === "light", + isDark: mode === "dark", + }; + + return ( + + {children} + + ); +} + +export function useTheme() { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/features/shared/theme/theme-types.ts b/src/features/shared/theme/theme-types.ts new file mode 100644 index 0000000..2e9b720 --- /dev/null +++ b/src/features/shared/theme/theme-types.ts @@ -0,0 +1,44 @@ +export type DarkThemeKey = "theme-modern-teal" | "theme-modern-blue" | "theme-modern-ember"; +export type LightThemeKey = "theme-light-neutral" | "theme-light-blue" | "theme-light-warm"; +export type ThemeKey = DarkThemeKey | LightThemeKey; +export type ThemeMode = "dark" | "light"; + +export interface ThemeInfo { + key: ThemeKey; + label: string; + mode: ThemeMode; +} + +export const DARK_THEMES: ThemeInfo[] = [ + { key: "theme-modern-teal", label: "Teal", mode: "dark" }, + { key: "theme-modern-blue", label: "Blue", mode: "dark" }, + { key: "theme-modern-ember", label: "Ember", mode: "dark" }, +]; + +export const LIGHT_THEMES: ThemeInfo[] = [ + { key: "theme-light-neutral", label: "Neutral", mode: "light" }, + { key: "theme-light-blue", label: "Blue", mode: "light" }, + { key: "theme-light-warm", label: "Warm", mode: "light" }, +]; + +export const ALL_THEMES: ThemeInfo[] = [...DARK_THEMES, ...LIGHT_THEMES]; + +export function getThemeMode(theme: ThemeKey): ThemeMode { + return theme.startsWith("theme-light-") ? "light" : "dark"; +} + +export function isLightTheme(theme: ThemeKey): theme is LightThemeKey { + return theme.startsWith("theme-light-"); +} + +export function isDarkTheme(theme: ThemeKey): theme is DarkThemeKey { + return theme.startsWith("theme-modern-"); +} + +export function getThemesByMode(mode: ThemeMode): ThemeInfo[] { + return mode === "light" ? LIGHT_THEMES : DARK_THEMES; +} + +export function getDefaultTheme(mode: ThemeMode): ThemeKey { + return mode === "light" ? "theme-light-neutral" : "theme-modern-teal"; +} \ No newline at end of file diff --git a/src/lib/exportImage.ts b/src/lib/exportImage.ts index 7c4ff49..56bb99e 100644 --- a/src/lib/exportImage.ts +++ b/src/lib/exportImage.ts @@ -1,6 +1,8 @@ "use client"; import { toPng } from "html-to-image"; +import type { ThemeKey } from "@features/shared/theme"; +import { isLightTheme, getDefaultTheme } from "@features/shared/theme"; import { shouldExportNode } from "./shouldExportNode"; @@ -11,6 +13,12 @@ type StyleOverride = { priority: string; }; +interface ExportOptions { + forceTheme?: ThemeKey; + optimizeForPrint?: boolean; + backgroundColor?: string; +} + function restoreStyleOverrides(overrides: StyleOverride[]) { for (let i = overrides.length - 1; i >= 0; i -= 1) { const override = overrides[i]; @@ -91,15 +99,76 @@ function applyExportDirectivesInPlace(root: HTMLElement) { }; } -export async function captureElementToPng(element: HTMLElement): Promise { +function applyExportThemeOverride(root: HTMLElement, targetTheme?: ThemeKey, optimizeForPrint = false) { + const overrides: StyleOverride[] = []; + const htmlElement = document.documentElement; + + if (targetTheme || optimizeForPrint) { + // Determine the theme to use for export + const exportTheme = optimizeForPrint ? getDefaultTheme("light") : targetTheme; + + if (exportTheme) { + // Store current theme classes + const currentClasses = Array.from(htmlElement.classList); + const themeClasses = currentClasses.filter(cls => + cls.startsWith("theme-modern-") || cls.startsWith("theme-light-") + ); + + // Apply export theme + themeClasses.forEach(cls => htmlElement.classList.remove(cls)); + htmlElement.classList.add(exportTheme); + + // If using light theme for export, ensure proper contrast and visibility + if (isLightTheme(exportTheme) || optimizeForPrint) { + applyStyleOverride(root, "background-color", "#ffffff", "important", overrides); + applyStyleOverride(root, "color", "#0f172a", "important", overrides); + + // Remove any glow effects that don't work well in light mode + const glowElements = root.querySelectorAll(".glow-accent, .glow-subtle, .glow-error"); + glowElements.forEach(el => { + applyStyleOverride(el, "box-shadow", "none", "important", overrides); + }); + } + + return () => { + // Restore original theme classes + htmlElement.classList.remove(exportTheme); + themeClasses.forEach(cls => htmlElement.classList.add(cls)); + restoreStyleOverrides(overrides); + }; + } + } + + return () => { + restoreStyleOverrides(overrides); + }; +} + +export async function captureElementToPng( + element: HTMLElement, + options: ExportOptions = {} +): Promise { + const { forceTheme, optimizeForPrint = false, backgroundColor } = options; + const rootOverrides: StyleOverride[] = []; const restore = applyExportDirectivesInPlace(element); + const restoreTheme = applyExportThemeOverride(element, forceTheme, optimizeForPrint); try { const width = element.scrollWidth || element.clientWidth; const height = element.scrollHeight || element.clientHeight; const computed = getComputedStyle(element); - const { background, backgroundColor } = computed; + + // Determine background color + let finalBackgroundColor = backgroundColor; + if (!finalBackgroundColor) { + if (optimizeForPrint || (forceTheme && isLightTheme(forceTheme))) { + finalBackgroundColor = "#ffffff"; + } else { + const { background, backgroundColor: computedBg } = computed; + finalBackgroundColor = computedBg === "rgba(0, 0, 0, 0)" ? undefined : computedBg; + } + } applyStyleOverride(element, "width", `${width}px`, "important", rootOverrides); applyStyleOverride(element, "height", `${height}px`, "important", rootOverrides); @@ -109,12 +178,13 @@ export async function captureElementToPng(element: HTMLElement): Promise skipFonts: true, width, height, - backgroundColor: backgroundColor === "rgba(0, 0, 0, 0)" ? undefined : backgroundColor, - style: background ? { background } : undefined, + backgroundColor: finalBackgroundColor, + style: finalBackgroundColor ? { background: finalBackgroundColor } : undefined, filter: shouldExportNode, }); } finally { restoreStyleOverrides(rootOverrides); + restoreTheme(); restore(); } } diff --git a/src/styles/globals.css b/src/styles/globals.css index 7498049..a0aa473 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -135,6 +135,96 @@ --ring: var(--color-accent); } +/* Light theme - Neutral */ +:root.theme-light-neutral { + /* Accent - maintain brand consistency with teal */ + --color-accent-rgb: 59, 201, 186; + --color-accent: rgb(var(--color-accent-rgb)); + --color-accent-muted: rgba(var(--color-accent-rgb), 0.8); + --color-accent-dim: rgba(var(--color-accent-rgb), 0.85); + + /* Surfaces - inverted hierarchy for light mode */ + --surface-0: #ffffff; + --surface-1: #f8fafc; + --surface-2: #f1f5f9; + --glass-alpha: 0.02; + + --color-surface: var(--surface-0); + --color-surface-elevated: var(--surface-1); + --color-border: #e2e8f0; + --color-border-light: #cbd5e1; + + /* Text - inverted hierarchy for light backgrounds */ + --text-primary: #0f172a; + --text-secondary: #475569; + --text-muted: #64748b; + --color-text-primary: var(--text-primary); + --color-text-secondary: var(--text-secondary); + --color-text-muted: var(--text-muted); + + --ring: var(--color-accent); +} + +/* Light theme - Blue */ +:root.theme-light-blue { + /* Accent - light mode blue */ + --color-accent-rgb: 37, 99, 235; /* blue-600 for better contrast on light */ + --color-accent: rgb(var(--color-accent-rgb)); + --color-accent-muted: rgba(var(--color-accent-rgb), 0.8); + --color-accent-dim: rgba(var(--color-accent-rgb), 0.85); + + /* Surfaces */ + --surface-0: #ffffff; + --surface-1: #f1f5f9; + --surface-2: #e2e8f0; + --glass-alpha: 0.02; + + --color-surface: var(--surface-0); + --color-surface-elevated: var(--surface-1); + --color-border: #cbd5e1; + --color-border-light: #94a3b8; + + /* Text */ + --text-primary: #0f172a; + --text-secondary: #334155; + --text-muted: #64748b; + --color-text-primary: var(--text-primary); + --color-text-secondary: var(--text-secondary); + --color-text-muted: var(--text-muted); + + --ring: var(--color-accent); +} + +/* Light theme - Warm */ +:root.theme-light-warm { + /* Accent - warm orange for light mode */ + --color-accent-rgb: 234, 88, 12; /* orange-600 */ + --color-accent: rgb(var(--color-accent-rgb)); + --color-accent-muted: rgba(var(--color-accent-rgb), 0.8); + --color-accent-dim: rgba(var(--color-accent-rgb), 0.85); + + /* Surfaces - warm neutral palette */ + --surface-0: #fefefe; + --surface-1: #fafaf9; + --surface-2: #f5f5f4; + --glass-alpha: 0.02; + + --color-surface: var(--surface-0); + --color-surface-elevated: var(--surface-1); + --color-border: #e7e5e4; + --color-border-light: #d6d3d1; + + /* Text */ + --text-primary: #1c1917; + --text-secondary: #44403c; + --text-muted: #78716c; + --color-text-primary: var(--text-primary); + --color-text-secondary: var(--text-secondary); + --color-text-muted: var(--text-muted); + + --ring: var(--color-accent); +} + /* Base styles */ * { border-color: var(--color-border); @@ -205,6 +295,20 @@ body { background: linear-gradient(135deg, var(--surface-0) 0%, var(--surface-1) --status-info-bg: rgba(var(--color-accent-rgb, 90,177,255), 0.16); } +/* Light theme status tokens */ +:root.theme-light-neutral, +:root.theme-light-blue, +:root.theme-light-warm { + --status-success-fg: #059669; /* green-600 for better contrast */ + --status-success-bg: rgba(5,150,105,0.1); + --status-warn-fg: #d97706; /* amber-600 */ + --status-warn-bg: rgba(217,119,6,0.1); + --status-error-fg: #dc2626; /* red-600 */ + --status-error-bg: rgba(220,38,38,0.1); + --status-info-fg: var(--color-accent); + --status-info-bg: rgba(var(--color-accent-rgb), 0.1); +} + /* Scrollbar styling */ ::-webkit-scrollbar { width: 8px; @@ -222,3 +326,32 @@ body { background: linear-gradient(135deg, var(--surface-0) 0%, var(--surface-1) ::-webkit-scrollbar-thumb:hover { background: var(--color-accent); } + +/* Print optimization - force light mode for printing */ +@media print { + :root { + --surface-0: #ffffff !important; + --surface-1: #f8fafc !important; + --surface-2: #f1f5f9 !important; + --color-surface: #ffffff !important; + --color-surface-elevated: #f8fafc !important; + --color-border: #e2e8f0 !important; + --color-border-light: #cbd5e1 !important; + --text-primary: #0f172a !important; + --text-secondary: #475569 !important; + --text-muted: #64748b !important; + --color-text-primary: #0f172a !important; + --color-text-secondary: #475569 !important; + --color-text-muted: #64748b !important; + } + + body { + background: white !important; + } + + .glow-accent, + .glow-subtle, + .glow-error { + box-shadow: none !important; + } +}