diff --git a/entrypoints/popup/App.tsx b/entrypoints/popup/App.tsx index 4de933a..125791c 100644 --- a/entrypoints/popup/App.tsx +++ b/entrypoints/popup/App.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef, useCallback } from "react"; +import { Button, Card, Toast } from "../../src/components/ui"; import QRCode from "qrcode"; import "./App.css"; import Settings from "./components/Settings"; @@ -709,7 +710,7 @@ function App() { // skipcq: JS-0415 return ( -
+
{/* Show Welcome Screen for first-time users */} {!hasEmailAccounts ? (
@@ -725,56 +726,63 @@ function App() { // skipcq: JS-0415 <> {/* Header */} -
-
-
- -
-

- {t("extensionName")} -

-

- {t("headerSubtitle")} -

+
+
+
+
+
+ +
+
+

+ {t("extensionName")} +

+

+ {t("headerSubtitle")} +

+
-
- + + +
{/* Main Content */} -
-
+
+ {/* Base Email Selector - Dropdown */} -
+
@@ -822,7 +830,7 @@ function App() { base_email: selectedEmail, }); }} - className="w-full pl-9 pr-10 py-2.5 border border-gray-300 dark:border-gray-600 rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent appearance-none bg-white dark:bg-gray-700 dark:text-gray-100 truncate" + className="w-full appearance-none rounded-2xl border border-gray-200/90 bg-white/80 py-2.5 pl-9 pr-10 text-sm text-gray-900 shadow-sm outline-none transition-all focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700/80 dark:bg-gray-800/80 dark:text-gray-100 truncate" > {emailAccounts.length > 0 ? ( emailAccounts.map((account) => ( @@ -853,7 +861,7 @@ function App() {
@@ -1058,14 +1066,10 @@ function App() { {/* Statistics - Collapsible */} -
+
{/* Toast Notification */} - {toastMessage && ( -
- {toastMessage} -
- )} + {toastMessage && }
)} @@ -1077,7 +1081,7 @@ function App() { onClick={() => setQrAlias(null)} >
e.stopPropagation()} >

@@ -1090,13 +1094,13 @@ function App() {
diff --git a/entrypoints/popup/components/Button.tsx b/entrypoints/popup/components/Button.tsx index e34179a..ca3c6c8 100644 --- a/entrypoints/popup/components/Button.tsx +++ b/entrypoints/popup/components/Button.tsx @@ -1,56 +1,2 @@ -import { ReactNode } from "react"; - -interface ButtonProps { - children: ReactNode; - onClick?: () => void; - variant?: "primary" | "secondary" | "danger" | "success"; - size?: "sm" | "md" | "lg"; - disabled?: boolean; - fullWidth?: boolean; - icon?: ReactNode; -} - -/** Styled button with variant, size, and optional icon support. */ -export default function Button({ - children, - onClick, - variant = "primary", - size = "md", - disabled = false, - fullWidth = false, - icon, -}: ButtonProps) { - const baseClasses = - "font-medium rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2"; - - const variantClasses = { - primary: - "bg-gradient-to-r from-blue-600 to-purple-600 text-white hover:from-blue-700 hover:to-purple-700 focus:ring-blue-500", - secondary: - "bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-500 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600", - danger: - "bg-red-50 text-red-700 hover:bg-red-100 focus:ring-red-500 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50", - success: - "bg-green-50 text-green-700 hover:bg-green-100 focus:ring-green-500 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50", - }; - - const sizeClasses = { - sm: "px-3 py-1.5 text-xs", - md: "px-4 py-2 text-sm", - lg: "px-6 py-3 text-base", - }; - - const widthClass = fullWidth ? "w-full" : ""; - const disabledClass = disabled ? "opacity-50 cursor-not-allowed" : ""; - - return ( - - ); -} +export { default } from "../../../src/components/ui/Button"; +export type { ButtonProps } from "../../../src/components/ui/Button"; diff --git a/entrypoints/popup/components/Input.tsx b/entrypoints/popup/components/Input.tsx index 7e5b5b7..dbc8d89 100644 --- a/entrypoints/popup/components/Input.tsx +++ b/entrypoints/popup/components/Input.tsx @@ -1,39 +1,2 @@ -interface InputProps { - type?: "text" | "email" | "number"; - value: string | number; - onChange: (value: string) => void; - placeholder?: string; - label?: string; - disabled?: boolean; - onKeyPress?: (e: React.KeyboardEvent) => void; -} - -/** Styled text input with optional label. */ -export default function Input({ - type = "text", - value, - onChange, - placeholder, - label, - disabled = false, - onKeyPress, -}: InputProps) { - return ( -
- {label && ( - - )} - onChange(e.target.value)} - onKeyPress={onKeyPress} - placeholder={placeholder} - disabled={disabled} - className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" - /> -
- ); -} +export { default } from "../../../src/components/ui/Input"; +export type { InputProps } from "../../../src/components/ui/Input"; diff --git a/entrypoints/popup/components/Toggle.tsx b/entrypoints/popup/components/Toggle.tsx index c73be47..7130bf6 100644 --- a/entrypoints/popup/components/Toggle.tsx +++ b/entrypoints/popup/components/Toggle.tsx @@ -1,43 +1,2 @@ -interface ToggleProps { - enabled: boolean; - onChange: (enabled: boolean) => void; - label: string; - description?: string; -} - -/** Accessible on/off switch with a label and optional description. */ -export default function Toggle({ - enabled, - onChange, - label, - description, -}: ToggleProps) { - return ( -
-
- - {description && ( -

- {description} -

- )} -
- -
- ); -} +export { default } from "../../../src/components/ui/ToggleSwitch"; +export type { ToggleSwitchProps as ToggleProps } from "../../../src/components/ui/ToggleSwitch"; diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx new file mode 100644 index 0000000..4b5f0fe --- /dev/null +++ b/src/components/ui/Badge.tsx @@ -0,0 +1,16 @@ +import type { HTMLAttributes } from "react"; +import { cn } from "./utils"; +export default function Badge({ + className, + ...props +}: HTMLAttributes) { + return ( + + ); +} diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx new file mode 100644 index 0000000..b1540b7 --- /dev/null +++ b/src/components/ui/Button.tsx @@ -0,0 +1,57 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import { cn } from "./utils"; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: "primary" | "secondary" | "ghost" | "danger" | "success"; + size?: "sm" | "md" | "lg" | "icon"; + fullWidth?: boolean; + icon?: ReactNode; +} + +export default function Button({ + children, + className, + variant = "primary", + size = "md", + fullWidth = false, + icon, + type = "button", + ...props +}: ButtonProps) { + const variants = { + primary: + "bg-gradient-to-r from-blue-600 to-violet-600 text-white shadow-soft hover:from-blue-700 hover:to-violet-700 focus-visible:ring-blue-500", + secondary: + "border border-gray-200/80 bg-white/80 text-gray-700 shadow-sm hover:bg-gray-50 dark:border-gray-700/80 dark:bg-gray-800/80 dark:text-gray-200 dark:hover:bg-gray-700/80 focus-visible:ring-gray-400", + ghost: + "text-gray-600 hover:bg-gray-100/80 dark:text-gray-300 dark:hover:bg-gray-800/80 focus-visible:ring-gray-400", + danger: + "bg-red-50 text-red-700 hover:bg-red-100 dark:bg-red-950/40 dark:text-red-300 dark:hover:bg-red-900/50 focus-visible:ring-red-500", + success: + "bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-300 dark:hover:bg-emerald-900/50 focus-visible:ring-emerald-500", + }; + const sizes = { + sm: "px-3 py-1.5 text-xs", + md: "px-4 py-2.5 text-sm", + lg: "px-5 py-3 text-base", + icon: "h-10 w-10 p-0", + }; + + return ( + + ); +} diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx new file mode 100644 index 0000000..d9b864a --- /dev/null +++ b/src/components/ui/Card.tsx @@ -0,0 +1,16 @@ +import type { HTMLAttributes } from "react"; +import { cn } from "./utils"; +export default function Card({ + className, + ...props +}: HTMLAttributes) { + return ( +
+ ); +} diff --git a/src/components/ui/EmptyState.tsx b/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000..edf9996 --- /dev/null +++ b/src/components/ui/EmptyState.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from "react"; +export default function EmptyState({ + icon, + title, + description, +}: { + icon?: ReactNode; + title: string; + description?: string; +}) { + return ( +
+
+ {icon} +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+ ); +} diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx new file mode 100644 index 0000000..cce0e49 --- /dev/null +++ b/src/components/ui/Input.tsx @@ -0,0 +1,50 @@ +import type { InputHTMLAttributes, ReactNode } from "react"; +import { cn } from "./utils"; + +export interface InputProps extends Omit< + InputHTMLAttributes, + "onChange" +> { + label?: string; + leftIcon?: ReactNode; + onChange?: (value: string) => void; +} + +export default function Input({ + label, + leftIcon, + className, + onChange, + id, + ...props +}: InputProps) { + return ( +
+ {label && ( + + )} +
+ {leftIcon && ( + + {leftIcon} + + )} + onChange?.(e.target.value)} + className={cn( + "w-full rounded-xl border border-gray-200/90 bg-white/80 px-3 py-2.5 text-sm text-gray-900 shadow-sm outline-none transition-all placeholder:text-gray-400 focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700/80 dark:bg-gray-800/80 dark:text-gray-100 dark:focus:border-blue-500", + leftIcon && "pl-10", + className, + )} + {...props} + /> +
+
+ ); +} diff --git a/src/components/ui/SectionHeader.tsx b/src/components/ui/SectionHeader.tsx new file mode 100644 index 0000000..94f4e18 --- /dev/null +++ b/src/components/ui/SectionHeader.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from "react"; +export interface SectionHeaderProps { + title: string; + description?: string; + action?: ReactNode; +} +export default function SectionHeader({ + title, + description, + action, +}: SectionHeaderProps) { + return ( +
+
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+ {action} +
+ ); +} diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx new file mode 100644 index 0000000..0cdf893 --- /dev/null +++ b/src/components/ui/Select.tsx @@ -0,0 +1,35 @@ +import type { SelectHTMLAttributes } from "react"; +import { cn } from "./utils"; +export interface SelectProps extends SelectHTMLAttributes { + label?: string; +} +export default function Select({ + label, + className, + id, + children, + ...props +}: SelectProps) { + return ( +
+ {label && ( + + )} + +
+ ); +} diff --git a/src/components/ui/StatCard.tsx b/src/components/ui/StatCard.tsx new file mode 100644 index 0000000..b9746a2 --- /dev/null +++ b/src/components/ui/StatCard.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from "react"; +export default function StatCard({ + icon, + value, + label, +}: { + icon: ReactNode; + value: ReactNode; + label: string; +}) { + return ( +
+
+ {icon} +
+
+ {value} +
+
+ {label} +
+
+ ); +} diff --git a/src/components/ui/Tabs.tsx b/src/components/ui/Tabs.tsx new file mode 100644 index 0000000..c05dda9 --- /dev/null +++ b/src/components/ui/Tabs.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from "react"; +import { cn } from "./utils"; +export interface TabItem { + value: T; + label: ReactNode; + icon?: ReactNode; +} +export interface TabsProps { + items: TabItem[]; + value: T; + onChange: (value: T) => void; +} +export default function Tabs({ + items, + value, + onChange, +}: TabsProps) { + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx new file mode 100644 index 0000000..ae99e4c --- /dev/null +++ b/src/components/ui/Toast.tsx @@ -0,0 +1,7 @@ +export default function Toast({ message }: { message: string }) { + return ( +
+ {message} +
+ ); +} diff --git a/src/components/ui/ToggleSwitch.tsx b/src/components/ui/ToggleSwitch.tsx new file mode 100644 index 0000000..5bde9f2 --- /dev/null +++ b/src/components/ui/ToggleSwitch.tsx @@ -0,0 +1,49 @@ +import { cn } from "./utils"; +export interface ToggleSwitchProps { + enabled: boolean; + onChange: (enabled: boolean) => void; + label?: string; + description?: string; +} +export default function ToggleSwitch({ + enabled, + onChange, + label, + description, +}: ToggleSwitchProps) { + return ( +
+
+ {label && ( +

+ {label} +

+ )} + {description && ( +

+ {description} +

+ )} +
+ +
+ ); +} diff --git a/src/components/ui/Tooltip.tsx b/src/components/ui/Tooltip.tsx new file mode 100644 index 0000000..4e9ca4f --- /dev/null +++ b/src/components/ui/Tooltip.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from "react"; +export default function Tooltip({ + children, + label, +}: { + children: ReactNode; + label: string; +}) { + return ( + + {children} + + {label} + + + ); +} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts new file mode 100644 index 0000000..abbb5ed --- /dev/null +++ b/src/components/ui/index.ts @@ -0,0 +1,13 @@ +export { default as Badge } from "./Badge"; +export { default as Button } from "./Button"; +export { default as Card } from "./Card"; +export { default as EmptyState } from "./EmptyState"; +export { default as Input } from "./Input"; +export { default as SectionHeader } from "./SectionHeader"; +export { default as Select } from "./Select"; +export { default as StatCard } from "./StatCard"; +export { default as Tabs } from "./Tabs"; +export { default as Toast } from "./Toast"; +export { default as ToggleSwitch } from "./ToggleSwitch"; +export { default as Tooltip } from "./Tooltip"; +export { cn } from "./utils"; diff --git a/src/components/ui/utils.ts b/src/components/ui/utils.ts new file mode 100644 index 0000000..3e882b1 --- /dev/null +++ b/src/components/ui/utils.ts @@ -0,0 +1,6 @@ +export type ClassValue = string | number | bigint | boolean | null | undefined; + +/** Lightweight class name joiner kept local to avoid popup bundle bloat. */ +export function cn(...classes: ClassValue[]) { + return classes.filter(Boolean).join(" "); +} diff --git a/tailwind.config.ts b/tailwind.config.ts index d50d125..65cb70c 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,10 +1,13 @@ import type { Config } from "tailwindcss"; export default { - content: ["./entrypoints/**/*.{html,tsx,ts}"], + content: ["./entrypoints/**/*.{html,tsx,ts}", "./src/**/*.{tsx,ts}"], darkMode: "class", theme: { extend: { + boxShadow: { + soft: "0 14px 35px -22px rgb(15 23 42 / 0.45)", + }, colors: { primary: { 50: "#eff6ff",