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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand All @@ -23,17 +24,19 @@ export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" suppressHydrationWarning className={`${geist.variable} theme-modern-teal`}>
<html lang="en" suppressHydrationWarning className={geist.variable}>
<body>
<TRPCReactProvider>
<SessionProvider>
<SidebarProvider>
<AppLayout>
<main className="min-h-screen">{children}</main>
</AppLayout>
</SidebarProvider>
</SessionProvider>
</TRPCReactProvider>
<ThemeProvider defaultTheme="theme-modern-teal">
<TRPCReactProvider>
<SessionProvider>
<SidebarProvider>
<AppLayout>
<main className="min-h-screen">{children}</main>
</AppLayout>
</SidebarProvider>
</SessionProvider>
</TRPCReactProvider>
</ThemeProvider>
</body>
</html>
);
Expand Down
99 changes: 94 additions & 5 deletions src/features/shared/export/export-to-png-button.tsx
Original file line number Diff line number Diff line change
@@ -1,55 +1,144 @@
"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<ButtonProps, "onClick"> {
targetRef: RefObject<HTMLElement | null>;
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 (
<div className="relative">
<div className="flex items-center gap-1">
<Button
type="button"
variant="secondary"
size="sm"
{...buttonProps}
disabled={disabled ?? isExporting}
onClick={() => handleExport()}
aria-label={label}
>
<Download className="mr-2 h-4 w-4" aria-hidden="true" />
{isExporting ? "Exporting…" : label}
</Button>

<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowOptions(!showOptions)}
aria-label="Export options"
disabled={disabled ?? isExporting}
>
<Settings className="h-4 w-4" />
</Button>
</div>

{showOptions && (
<div className="absolute top-full right-0 mt-1 z-50 min-w-48 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-elevated)] p-2 shadow-lg">
<div className="space-y-2">
<div className="text-xs font-medium text-[var(--color-text-primary)]">
Export Options
</div>

<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-xs"
onClick={() => handleExport()}
>
Current Theme ({isLight ? "Light" : "Dark"})
</Button>

<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-xs"
onClick={() => handleExport("theme-light-neutral")}
>
Light Mode (Print-friendly)
</Button>

<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-xs"
onClick={() => handleExport("theme-modern-teal")}
>
Dark Mode
</Button>
</div>
</div>
)}
</div>
);
}

return (
<Button
type="button"
variant="secondary"
size="sm"
{...buttonProps}
disabled={disabled ?? isExporting}
onClick={handleExport}
onClick={() => handleExport()}
aria-label={label}
>
<Download className="mr-2 h-4 w-4" aria-hidden="true" />
Expand Down
179 changes: 123 additions & 56 deletions src/features/shared/layout/theme-toggle.tsx
Original file line number Diff line number Diff line change
@@ -1,81 +1,148 @@
"use client";

import { useEffect, useState } from "react";
import { useState } from "react";
import { Sun, Moon, Palette } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTheme } from "../theme";
import { getThemesByMode, type ThemeMode } from "../theme";

type ThemeKey = "theme-modern-teal" | "theme-modern-blue" | "theme-modern-ember";

const THEMES: { key: ThemeKey; label: string }[] = [
{ key: "theme-modern-teal", label: "Teal" },
{ key: "theme-modern-blue", label: "Blue" },
{ key: "theme-modern-ember", label: "Ember" },
];

export function ThemeToggle({ variant = "full" as "full" | "compact" }: { variant?: "full" | "compact" }) {
const [theme, setTheme] = useState<ThemeKey>("theme-modern-teal");

// Apply theme to <html> and persist
const apply = (key: ThemeKey) => {
setTheme(key);
if (typeof document !== 'undefined') {
const root = document.documentElement;
["theme-modern","theme-modern-teal","theme-modern-blue","theme-modern-ember"].forEach(c => root.classList.remove(c));
root.classList.add(key);
}
if (typeof localStorage !== 'undefined') {
localStorage.setItem('rtap.theme', key);
}
};
interface ThemeToggleProps {
variant?: "full" | "compact" | "mode-only";
}

// Load stored theme on mount and apply immediately
useEffect(() => {
const stored = (typeof window !== 'undefined' && (localStorage.getItem('rtap.theme') as ThemeKey | null)) ?? null;
const initial = stored && THEMES.some(t => t.key === stored) ? stored : "theme-modern-teal";
apply(initial);
}, []);
export function ThemeToggle({ variant = "full" }: ThemeToggleProps) {
const { theme, mode, setTheme, setMode } = useTheme();
const [showThemes, setShowThemes] = useState(false);

if (variant === "compact") {
// A tiny button that cycles themes on click
const nextTheme = () => {
if (THEMES.length === 0) return;
const idxRaw = THEMES.findIndex(t => t.key === theme);
const idx = idxRaw >= 0 ? idxRaw : 0;
const next = THEMES[(idx + 1) % THEMES.length];
if (next) apply(next.key);
if (variant === "mode-only") {
// Simple dark/light mode toggle
const toggleMode = () => {
setMode(mode === "dark" ? "light" : "dark");
};

return (
<Button
variant="ghost"
size="sm"
aria-label="Toggle theme"
title="Toggle theme"
onClick={nextTheme}
onClick={toggleMode}
aria-label={`Switch to ${mode === "dark" ? "light" : "dark"} mode`}
title={`Switch to ${mode === "dark" ? "light" : "dark"} mode`}
>
<span
className="inline-block w-3.5 h-3.5 rounded-full border"
style={{ background: 'var(--ring)', borderColor: 'var(--color-border)' }}
/>
{mode === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
);
}

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 (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={toggleMode}
aria-label={`Switch to ${mode === "dark" ? "light" : "dark"} mode`}
title={`Switch to ${mode === "dark" ? "light" : "dark"} mode`}
>
{mode === "dark" ? (
<Moon className="h-3 w-3" />
) : (
<Sun className="h-3 w-3" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={nextTheme}
aria-label="Change theme"
title="Change theme"
>
<span
className="inline-block w-3 h-3 rounded-full border"
style={{
background: 'var(--ring)',
borderColor: 'var(--color-border)'
}}
/>
</Button>
</div>
);
}

// Full theme selector
const currentThemes = getThemesByMode(mode);

return (
<div className="inline-flex items-center rounded-md border border-[var(--color-border)] overflow-hidden">
{THEMES.map((t, i) => {
const selected = theme === t.key;
return (
<div className="space-y-2">
{/* Mode Toggle */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-[var(--color-text-primary)]">
Theme Mode
</span>
<div className="inline-flex items-center rounded-md border border-[var(--color-border)] overflow-hidden">
<Button
key={t.key}
variant="ghost"
size="sm"
className={`rounded-none ${i === 0 ? 'rounded-l-md' : ''} ${i === THEMES.length - 1 ? 'rounded-r-md' : ''} ${selected ? 'ring-2 ring-[var(--ring)]' : ''}`}
aria-pressed={selected}
onClick={() => apply(t.key)}
className={`rounded-none rounded-l-md ${mode === "dark" ? 'ring-2 ring-[var(--ring)]' : ''}`}
onClick={() => setMode("dark")}
aria-pressed={mode === "dark"}
>
{t.label}
<Moon className="mr-2 h-3 w-3" />
Dark
</Button>
);
})}
<Button
variant="ghost"
size="sm"
className={`rounded-none rounded-r-md ${mode === "light" ? 'ring-2 ring-[var(--ring)]' : ''}`}
onClick={() => setMode("light")}
aria-pressed={mode === "light"}
>
<Sun className="mr-2 h-3 w-3" />
Light
</Button>
</div>
</div>

{/* Theme Color Selector */}
<div className="space-y-1">
<span className="text-sm font-medium text-[var(--color-text-primary)]">
Color Theme
</span>
<div className="inline-flex items-center rounded-md border border-[var(--color-border)] overflow-hidden">
{currentThemes.map((t, i) => {
const selected = theme === t.key;
return (
<Button
key={t.key}
variant="ghost"
size="sm"
className={`rounded-none ${i === 0 ? 'rounded-l-md' : ''} ${i === currentThemes.length - 1 ? 'rounded-r-md' : ''} ${selected ? 'ring-2 ring-[var(--ring)]' : ''}`}
aria-pressed={selected}
onClick={() => setTheme(t.key)}
>
{t.label}
</Button>
);
})}
</div>
</div>
</div>
);
}
Loading