theme draft 1

This commit is contained in:
2026-03-22 13:05:00 -04:00
parent 63ad73755d
commit d38392ac9a
18 changed files with 678 additions and 209 deletions

View File

@@ -20,6 +20,8 @@ Also follow the repository root guide at `../AGENTS.md`.
- Avoid custom class names in JSX `className` values unless the Tailwind lint config already allows them.
- For decorative icons in inputs or labels, disable hover styling via the icon component API rather than overriding it ad hoc.
- Prefer `LoadingState` for result-area loading indicators; avoid early returns that unmount search/filter forms during fetches.
- Use theme tokens from `tailwind.config.js` / `src/index.css` (`bg-surface`, `text-content`, `border-border`, `primary`, etc.) for new UI work instead of adding raw light/dark color pairs.
- Store frontend-only preferences in `src/utils/localSettings.ts` so appearance and view settings share one local-storage shape.
## 3) Generated API client

View File

@@ -11,13 +11,13 @@ type LinkProps = BaseButtonProps & AnchorHTMLAttributes<HTMLAnchorElement> & { h
const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => {
const baseClass =
'transition duration-100 ease-in font-medium w-full h-full px-2 py-1 text-white disabled:cursor-not-allowed disabled:opacity-50';
'h-full w-full px-2 py-1 font-medium transition duration-100 ease-in disabled:cursor-not-allowed disabled:opacity-50';
if (variant === 'secondary') {
return `${baseClass} bg-black shadow-md hover:text-black hover:bg-white disabled:hover:text-white disabled:hover:bg-black`;
return `${baseClass} bg-content text-content-inverse shadow-md hover:bg-content-muted disabled:hover:bg-content`;
}
return `${baseClass} bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100 disabled:hover:bg-gray-500 dark:disabled:hover:bg-transparent`;
return `${baseClass} bg-primary-500 text-primary-foreground hover:bg-primary-700 disabled:hover:bg-primary-500`;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(

View File

@@ -26,7 +26,6 @@ const adminSubItems: NavItem[] = [
{ path: '/admin/logs', label: 'Logs', icon: SettingsIcon, title: 'Logs' },
];
// Helper function to check if pathname has a prefix
function hasPrefix(path: string, prefix: string): boolean {
return path.startsWith(prefix);
}
@@ -37,10 +36,9 @@ export default function HamburgerMenu() {
const [isOpen, setIsOpen] = useState(false);
const isAdmin = user?.is_admin ?? false;
// Fetch server info for version
const { data: infoData } = useGetInfo({
query: {
staleTime: Infinity, // Info doesn't change frequently
staleTime: Infinity,
},
});
const version =
@@ -50,7 +48,6 @@ export default function HamburgerMenu() {
return (
<div className="relative z-40 ml-6 flex flex-col">
{/* Checkbox input for state management */}
<input
type="checkbox"
className="absolute -top-2 z-50 flex size-7 cursor-pointer opacity-0 lg:hidden"
@@ -59,9 +56,8 @@ export default function HamburgerMenu() {
onChange={e => setIsOpen(e.target.checked)}
/>
{/* Hamburger icon lines with CSS animations - hidden on desktop */}
<span
className="z-40 mt-0.5 h-0.5 w-7 bg-black transition-opacity duration-500 lg:hidden dark:bg-white"
className="z-40 mt-0.5 h-0.5 w-7 bg-content transition-opacity duration-500 lg:hidden"
style={{
transformOrigin: '5px 0px',
transition:
@@ -70,7 +66,7 @@ export default function HamburgerMenu() {
}}
/>
<span
className="z-40 mt-1 h-0.5 w-7 bg-black transition-opacity duration-500 lg:hidden dark:bg-white"
className="z-40 mt-1 h-0.5 w-7 bg-content transition-opacity duration-500 lg:hidden"
style={{
transformOrigin: '0% 100%',
transition:
@@ -80,7 +76,7 @@ export default function HamburgerMenu() {
}}
/>
<span
className="z-40 mt-1 h-0.5 w-7 bg-black transition-opacity duration-500 lg:hidden dark:bg-white"
className="z-40 mt-1 h-0.5 w-7 bg-content transition-opacity duration-500 lg:hidden"
style={{
transformOrigin: '0% 0%',
transition:
@@ -89,21 +85,17 @@ export default function HamburgerMenu() {
}}
/>
{/* Navigation menu with slide animation */}
<div
id="menu"
className="fixed -ml-6 h-full w-56 bg-white shadow-lg lg:w-48 dark:bg-gray-700"
className="fixed -ml-6 h-full w-56 bg-surface shadow-lg lg:w-48"
style={{
top: 0,
paddingTop: 'env(safe-area-inset-top)',
transformOrigin: '0% 0%',
// On desktop (lg), always show the menu via CSS class
// On mobile, control via state
transform: isOpen ? 'none' : 'translate(-100%, 0)',
transition: 'transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1)',
}}
>
{/* Desktop override - always visible */}
<style>{`
@media (min-width: 1024px) {
#menu {
@@ -112,9 +104,7 @@ export default function HamburgerMenu() {
}
`}</style>
<div className="flex h-16 justify-end lg:justify-around">
<p className="my-auto pr-8 text-right text-xl font-bold lg:pr-0 dark:text-white">
AnthoLume
</p>
<p className="my-auto pr-8 text-right text-xl font-bold text-content lg:pr-0">AnthoLume</p>
</div>
<nav>
{navItems.map(item => (
@@ -124,8 +114,8 @@ export default function HamburgerMenu() {
onClick={() => setIsOpen(false)}
className={`my-2 flex w-full items-center justify-start border-l-4 p-2 pl-6 transition-colors duration-200 ${
location.pathname === item.path
? 'border-purple-500 dark:text-white'
: 'border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100'
? 'border-primary-500 text-content'
: 'border-transparent text-content-subtle hover:text-content'
}`}
>
<item.icon size={20} />
@@ -133,23 +123,21 @@ export default function HamburgerMenu() {
</Link>
))}
{/* Admin section - only visible for admins */}
{isAdmin && (
<div
className={`my-2 flex flex-col gap-4 border-l-4 p-2 pl-6 transition-colors duration-200 ${
hasPrefix(location.pathname, '/admin')
? 'border-purple-500 dark:text-white'
: 'border-transparent text-gray-400'
? 'border-primary-500 text-content'
: 'border-transparent text-content-subtle'
}`}
>
{/* Admin header - always shown */}
<Link
to="/admin"
onClick={() => setIsOpen(false)}
className={`flex w-full justify-start ${
location.pathname === '/admin' && !hasPrefix(location.pathname, '/admin/')
? 'dark:text-white'
: 'text-gray-400 hover:text-gray-800 dark:hover:text-gray-100'
? 'text-content'
: 'text-content-subtle hover:text-content'
}`}
>
<SettingsIcon size={20} />
@@ -165,8 +153,8 @@ export default function HamburgerMenu() {
onClick={() => setIsOpen(false)}
className={`flex w-full justify-start ${
location.pathname === item.path
? 'dark:text-white'
: 'text-gray-400 hover:text-gray-800 dark:hover:text-gray-100'
? 'text-content'
: 'text-content-subtle hover:text-content'
}`}
style={{ paddingLeft: '1.75em' }}
>
@@ -179,7 +167,7 @@ export default function HamburgerMenu() {
)}
</nav>
<a
className="absolute bottom-0 flex w-full flex-col items-center justify-center gap-2 p-6 text-black dark:text-white"
className="absolute bottom-0 flex w-full flex-col items-center justify-center gap-2 p-6 text-content"
target="_blank"
href="https://gitea.va.reichard.io/evan/AnthoLume"
rel="noreferrer"

View File

@@ -3,11 +3,16 @@ import { Link, useLocation, Outlet, Navigate } from 'react-router-dom';
import { useGetMe } from '../generated/anthoLumeAPIV1';
import { useAuth } from '../auth/AuthContext';
import { UserIcon, DropdownIcon } from '../icons';
import { useTheme } from '../theme/ThemeProvider';
import type { ThemeMode } from '../utils/localSettings';
import HamburgerMenu from './HamburgerMenu';
const themeModes: ThemeMode[] = ['light', 'dark', 'system'];
export default function Layout() {
const location = useLocation();
const { isAuthenticated, user, logout, isCheckingAuth } = useAuth();
const { themeMode, setThemeMode } = useTheme();
const { data } = useGetMe(isAuthenticated ? {} : undefined);
const fetchedUser =
data?.status === 200 && data.data && 'username' in data.data ? data.data : null;
@@ -20,7 +25,6 @@ export default function Layout() {
setIsUserDropdownOpen(false);
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
@@ -34,7 +38,6 @@ export default function Layout() {
};
}, []);
// Get current page title
const navItems = [
{ path: '/admin/import-results', title: 'Admin - Import' },
{ path: '/admin/import', title: 'Admin - Import' },
@@ -57,43 +60,62 @@ export default function Layout() {
document.title = `AnthoLume - ${currentPageTitle}`;
}, [currentPageTitle]);
// Show loading while checking authentication status
if (isCheckingAuth) {
return <div className="text-gray-500 dark:text-white">Loading...</div>;
return <div className="text-content-muted">Loading...</div>;
}
// Redirect to login if not authenticated
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-800">
{/* Header */}
<div className="min-h-screen bg-canvas">
<div className="flex h-16 w-full items-center justify-between">
{/* Mobile Navigation Button with CSS animations */}
<HamburgerMenu />
{/* Header Title */}
<h1 className="whitespace-nowrap px-6 text-xl font-bold lg:ml-44 dark:text-white">
<h1 className="whitespace-nowrap px-6 text-xl font-bold text-content lg:ml-44">
{currentPageTitle}
</h1>
{/* User Dropdown */}
<div
className="relative flex w-full items-center justify-end space-x-4 p-4"
ref={dropdownRef}
>
<button
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
className="relative block text-gray-800 dark:text-gray-200"
className="relative block text-content"
>
<UserIcon size={20} />
</button>
{isUserDropdownOpen && (
<div className="absolute right-4 top-16 z-20 pt-4 transition duration-200">
<div className="w-40 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black/5 dark:bg-gray-700 dark:shadow-gray-800">
<div className="w-64 origin-top-right rounded-md bg-surface shadow-lg ring-1 ring-black/5 dark:shadow-gray-800">
<div
className="border-b border-border px-4 py-3"
role="group"
aria-label="Theme mode"
>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-content-subtle">
Theme
</p>
<div className="inline-flex w-full rounded border border-border bg-surface-muted p-1">
{themeModes.map(mode => (
<button
key={mode}
type="button"
onClick={() => setThemeMode(mode)}
className={`flex-1 rounded px-2 py-1 text-xs font-medium capitalize transition-colors ${
themeMode === mode
? 'bg-content text-content-inverse'
: 'text-content-muted hover:bg-surface hover:text-content'
}`}
>
{mode}
</button>
))}
</div>
</div>
<div
className="py-1"
role="menu"
@@ -103,7 +125,7 @@ export default function Layout() {
<Link
to="/settings"
onClick={() => setIsUserDropdownOpen(false)}
className="block px-4 py-2 text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
className="block px-4 py-2 text-content-muted hover:bg-surface-muted hover:text-content"
role="menuitem"
>
<span className="flex flex-col">
@@ -112,7 +134,7 @@ export default function Layout() {
</Link>
<button
onClick={handleLogout}
className="block w-full px-4 py-2 text-left text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
className="block w-full px-4 py-2 text-left text-content-muted hover:bg-surface-muted hover:text-content"
role="menuitem"
>
<span className="flex flex-col">
@@ -126,11 +148,11 @@ export default function Layout() {
<button
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
className="flex cursor-pointer items-center gap-2 py-4 text-gray-500 dark:text-white"
className="flex cursor-pointer items-center gap-2 py-4 text-content-muted"
>
<span>{userData ? ('username' in userData ? userData.username : 'User') : 'User'}</span>
<span
className="text-gray-800 transition-transform duration-200 dark:text-gray-200"
className="text-content transition-transform duration-200"
style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
>
<DropdownIcon size={20} />
@@ -139,7 +161,6 @@ export default function Layout() {
</div>
</div>
{/* Main Content */}
<main
className="relative overflow-hidden"
style={{ height: 'calc(100dvh - 4rem - env(safe-area-inset-top))' }}

View File

@@ -13,13 +13,8 @@ export function LoadingState({
iconSize = 24,
}: LoadingStateProps) {
return (
<div
className={cn(
'flex items-center justify-center gap-3 text-gray-500 dark:text-gray-400',
className,
)}
>
<LoadingIcon size={iconSize} className="text-purple-600 dark:text-purple-400" />
<div className={cn('flex items-center justify-center gap-3 text-content-muted', className)}>
<LoadingIcon size={iconSize} className="text-primary-500" />
<span className="text-sm font-medium">{message}</span>
</div>
);

View File

@@ -19,7 +19,7 @@ export function Skeleton({
const variantClasses = {
default: 'rounded',
text: 'rounded-md h-4',
text: 'h-4 rounded-md',
circular: 'rounded-full',
rectangular: 'rounded-none',
};
@@ -97,12 +97,7 @@ export function SkeletonCard({
textLines = 3,
}: SkeletonCardProps) {
return (
<div
className={cn(
'bg-white dark:bg-gray-700 rounded-lg p-4 border dark:border-gray-600',
className
)}
>
<div className={cn('rounded-lg border border-border bg-surface p-4', className)}>
{showAvatar && (
<div className="mb-4 flex items-start gap-4">
<SkeletonAvatar />
@@ -132,11 +127,11 @@ export function SkeletonTable({
showHeader = true,
}: SkeletonTableProps) {
return (
<div className={cn('bg-white dark:bg-gray-700 rounded-lg overflow-hidden', className)}>
<div className={cn('overflow-hidden rounded-lg bg-surface', className)}>
<table className="min-w-full">
{showHeader && (
<thead>
<tr className="border-b dark:border-gray-600">
<tr className="border-b border-border">
{Array.from({ length: columns }).map((_, i) => (
<th key={i} className="p-3">
<Skeleton variant="text" className="h-5 w-3/4" />
@@ -147,7 +142,7 @@ export function SkeletonTable({
)}
<tbody>
{Array.from({ length: rows }).map((_, rowIndex) => (
<tr key={rowIndex} className="border-b last:border-0 dark:border-gray-600">
<tr key={rowIndex} className="border-b border-border last:border-0">
{Array.from({ length: columns }).map((_, colIndex) => (
<td key={colIndex} className="p-3">
<Skeleton
@@ -187,11 +182,11 @@ interface PageLoaderProps {
export function PageLoader({ message = 'Loading...', className = '' }: PageLoaderProps) {
return (
<div className={cn('flex flex-col items-center justify-center min-h-[400px] gap-4', className)}>
<div className={cn('flex min-h-[400px] flex-col items-center justify-center gap-4', className)}>
<div className="relative">
<div className="size-12 animate-spin rounded-full border-4 border-gray-200 border-t-blue-500 dark:border-gray-600" />
<div className="size-12 animate-spin rounded-full border-4 border-gray-200 border-t-secondary-500 dark:border-gray-600" />
</div>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{message}</p>
<p className="text-sm font-medium text-content-muted">{message}</p>
</div>
);
}
@@ -203,19 +198,18 @@ interface InlineLoaderProps {
export function InlineLoader({ size = 'md', className = '' }: InlineLoaderProps) {
const sizeMap = {
sm: 'w-4 h-4 border-2',
md: 'w-6 h-6 border-3',
lg: 'w-8 h-8 border-4',
sm: 'h-4 w-4 border-2',
md: 'h-6 w-6 border-[3px]',
lg: 'h-8 w-8 border-4',
};
return (
<div className={cn('flex items-center justify-center', className)}>
<div
className={`${sizeMap[size]} animate-spin rounded-full border-gray-200 border-t-blue-500 dark:border-gray-600`}
className={`${sizeMap[size]} animate-spin rounded-full border-gray-200 border-t-secondary-500 dark:border-gray-600`}
/>
</div>
);
}
// Re-export SkeletonTable for backward compatibility
export { SkeletonTable as SkeletonTableExport };

View File

@@ -27,10 +27,10 @@ function SkeletonTable({
className?: string;
}) {
return (
<div className={cn('overflow-hidden rounded-lg bg-white dark:bg-gray-700', className)}>
<div className={cn('overflow-hidden rounded-lg bg-surface', className)}>
<table className="min-w-full">
<thead>
<tr className="border-b dark:border-gray-600">
<tr className="border-b border-border">
{Array.from({ length: columns }).map((_, i) => (
<th key={i} className="p-3">
<Skeleton variant="text" className="h-5 w-3/4" />
@@ -40,7 +40,7 @@ function SkeletonTable({
</thead>
<tbody>
{Array.from({ length: rows }).map((_, rowIndex) => (
<tr key={rowIndex} className="border-b last:border-0 dark:border-gray-600">
<tr key={rowIndex} className="border-b border-border last:border-0">
{Array.from({ length: columns }).map((_, colIndex) => (
<td key={colIndex} className="p-3">
<Skeleton
@@ -81,13 +81,13 @@ export function Table<T extends object>({
return (
<div className="overflow-x-auto">
<div className="inline-block min-w-full overflow-hidden rounded shadow">
<table className="min-w-full bg-white dark:bg-gray-700">
<table className="min-w-full bg-surface">
<thead>
<tr className="border-b dark:border-gray-600">
<tr className="border-b border-border">
{columns.map(column => (
<th
key={String(column.key)}
className={`p-3 text-left text-gray-500 dark:text-white ${column.className || ''}`}
className={`p-3 text-left text-content-muted ${column.className || ''}`}
>
{column.header}
</th>
@@ -97,20 +97,17 @@ export function Table<T extends object>({
<tbody>
{data.length === 0 ? (
<tr>
<td
colSpan={columns.length}
className="p-3 text-center text-gray-700 dark:text-gray-300"
>
<td colSpan={columns.length} className="p-3 text-center text-content-muted">
{emptyMessage}
</td>
</tr>
) : (
data.map((row, index) => (
<tr key={getRowKey(row, index)} className="border-b dark:border-gray-600">
<tr key={getRowKey(row, index)} className="border-b border-border">
{columns.map(column => (
<td
key={`${getRowKey(row, index)}-${String(column.key)}`}
className={`p-3 text-gray-700 dark:text-gray-300 ${column.className || ''}`}
className={`p-3 text-content ${column.className || ''}`}
>
{column.render
? column.render(row[column.key], row, index)

View File

@@ -13,24 +13,24 @@ export interface ToastProps {
const getToastStyles = (_type: ToastType) => {
const baseStyles =
'flex items-center gap-3 p-4 rounded-lg shadow-lg border-l-4 transition-all duration-300';
'flex items-center gap-3 rounded-lg border-l-4 p-4 shadow-lg transition-all duration-300';
const typeStyles = {
info: 'bg-blue-50 dark:bg-blue-900/30 border-blue-500 dark:border-blue-400',
warning: 'bg-yellow-50 dark:bg-yellow-900/30 border-yellow-500 dark:border-yellow-400',
error: 'bg-red-50 dark:bg-red-900/30 border-red-500 dark:border-red-400',
info: 'border-secondary-500 bg-secondary-50 dark:bg-secondary-100/20',
warning: 'border-yellow-500 bg-yellow-50 dark:bg-yellow-100/20',
error: 'border-red-500 bg-red-50 dark:bg-red-100/20',
};
const iconStyles = {
info: 'text-blue-600 dark:text-blue-400',
warning: 'text-yellow-600 dark:text-yellow-400',
info: 'text-secondary-600 dark:text-secondary-500',
warning: 'text-yellow-700 dark:text-yellow-500',
error: 'text-red-600 dark:text-red-400',
};
const textStyles = {
info: 'text-blue-800 dark:text-blue-200',
warning: 'text-yellow-800 dark:text-yellow-200',
error: 'text-red-800 dark:text-red-200',
info: 'text-secondary-900 dark:text-secondary-700',
warning: 'text-yellow-900 dark:text-yellow-700',
error: 'text-red-900 dark:text-red-700',
};
return { baseStyles, typeStyles, iconStyles, textStyles };

View File

@@ -18,9 +18,9 @@ export function BaseIcon({
children,
}: BaseIconProps) {
const disabledClasses = disabled
? 'text-gray-200 dark:text-gray-600'
? 'text-content-subtle'
: hoverable
? 'hover:text-gray-800 dark:hover:text-gray-100'
? 'hover:text-content'
: '';
return (

View File

@@ -7,7 +7,7 @@ export function GitIcon({ size = 20, className = '' }: GitIconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={`${className} text-black dark:text-white`.trim()}
className={`${className} text-content`.trim()}
height={size}
viewBox="0 0 219 92"
fill="currentColor"

View File

@@ -2,16 +2,208 @@
@tailwind components;
@tailwind utilities;
/* PWA Styling */
html,
body {
overscroll-behavior-y: none;
margin: 0px;
:root {
--white: 255 255 255;
--black: 0 0 0;
--canvas: 243 244 246;
--surface: 255 255 255;
--surface-muted: 249 250 251;
--surface-strong: 209 213 219;
--overlay: 31 41 55;
--content: 0 0 0;
--content-muted: 107 114 128;
--content-subtle: 156 163 175;
--content-inverse: 255 255 255;
--border: 209 213 219;
--border-muted: 229 231 235;
--border-strong: 156 163 175;
--neutral-50: 249 250 251;
--neutral-100: 243 244 246;
--neutral-200: 229 231 235;
--neutral-300: 209 213 219;
--neutral-400: 156 163 175;
--neutral-500: 107 114 128;
--neutral-600: 75 85 99;
--neutral-700: 55 65 81;
--neutral-800: 31 41 55;
--neutral-900: 17 24 39;
--primary-50: 250 245 255;
--primary-100: 243 232 255;
--primary-200: 233 213 255;
--primary-300: 216 180 254;
--primary-400: 192 132 252;
--primary-500: 168 85 247;
--primary-600: 147 51 234;
--primary-700: 126 34 206;
--primary-800: 107 33 168;
--primary-900: 88 28 135;
--primary-foreground: 255 255 255;
--secondary-50: 239 246 255;
--secondary-100: 219 234 254;
--secondary-200: 191 219 254;
--secondary-300: 147 197 253;
--secondary-400: 96 165 250;
--secondary-500: 59 130 246;
--secondary-600: 37 99 235;
--secondary-700: 29 78 216;
--secondary-800: 30 64 175;
--secondary-900: 30 58 138;
--secondary-foreground: 255 255 255;
--tertiary-50: 236 253 245;
--tertiary-100: 209 250 229;
--tertiary-200: 167 243 208;
--tertiary-300: 110 231 183;
--tertiary-400: 52 211 153;
--tertiary-500: 16 185 129;
--tertiary-600: 5 150 105;
--tertiary-700: 4 120 87;
--tertiary-800: 6 95 70;
--tertiary-900: 6 78 59;
--tertiary-foreground: 255 255 255;
--warning-50: 254 252 232;
--warning-100: 254 249 195;
--warning-200: 254 240 138;
--warning-300: 253 224 71;
--warning-400: 250 204 21;
--warning-500: 234 179 8;
--warning-600: 202 138 4;
--warning-700: 161 98 7;
--warning-800: 133 77 14;
--warning-900: 113 63 18;
--warning-foreground: 17 24 39;
--error-50: 254 242 242;
--error-100: 254 226 226;
--error-200: 254 202 202;
--error-300: 252 165 165;
--error-400: 248 113 113;
--error-500: 239 68 68;
--error-600: 220 38 38;
--error-700: 185 28 28;
--error-800: 153 27 27;
--error-900: 127 29 29;
--error-foreground: 255 255 255;
}
html {
height: calc(100% + env(safe-area-inset-bottom));
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left);
.dark {
--white: 255 255 255;
--black: 0 0 0;
--canvas: 31 41 55;
--surface: 55 65 81;
--surface-muted: 75 85 99;
--surface-strong: 107 114 128;
--overlay: 229 231 235;
--content: 255 255 255;
--content-muted: 209 213 219;
--content-subtle: 156 163 175;
--content-inverse: 17 24 39;
--border: 75 85 99;
--border-muted: 55 65 81;
--border-strong: 107 114 128;
--neutral-50: 249 250 251;
--neutral-100: 243 244 246;
--neutral-200: 229 231 235;
--neutral-300: 209 213 219;
--neutral-400: 156 163 175;
--neutral-500: 107 114 128;
--neutral-600: 75 85 99;
--neutral-700: 55 65 81;
--neutral-800: 31 41 55;
--neutral-900: 17 24 39;
--primary-50: 250 245 255;
--primary-100: 243 232 255;
--primary-200: 233 213 255;
--primary-300: 216 180 254;
--primary-400: 192 132 252;
--primary-500: 168 85 247;
--primary-600: 147 51 234;
--primary-700: 126 34 206;
--primary-800: 107 33 168;
--primary-900: 88 28 135;
--primary-foreground: 255 255 255;
--secondary-50: 239 246 255;
--secondary-100: 219 234 254;
--secondary-200: 191 219 254;
--secondary-300: 147 197 253;
--secondary-400: 96 165 250;
--secondary-500: 59 130 246;
--secondary-600: 37 99 235;
--secondary-700: 29 78 216;
--secondary-800: 30 64 175;
--secondary-900: 30 58 138;
--secondary-foreground: 255 255 255;
--tertiary-50: 236 253 245;
--tertiary-100: 209 250 229;
--tertiary-200: 167 243 208;
--tertiary-300: 110 231 183;
--tertiary-400: 52 211 153;
--tertiary-500: 16 185 129;
--tertiary-600: 5 150 105;
--tertiary-700: 4 120 87;
--tertiary-800: 6 95 70;
--tertiary-900: 6 78 59;
--tertiary-foreground: 255 255 255;
--warning-50: 254 252 232;
--warning-100: 254 249 195;
--warning-200: 254 240 138;
--warning-300: 253 224 71;
--warning-400: 250 204 21;
--warning-500: 234 179 8;
--warning-600: 202 138 4;
--warning-700: 161 98 7;
--warning-800: 133 77 14;
--warning-900: 113 63 18;
--warning-foreground: 17 24 39;
--error-50: 254 242 242;
--error-100: 254 226 226;
--error-200: 254 202 202;
--error-300: 252 165 165;
--error-400: 248 113 113;
--error-500: 239 68 68;
--error-600: 220 38 38;
--error-700: 185 28 28;
--error-800: 153 27 27;
--error-900: 127 29 29;
--error-foreground: 255 255 255;
}
@layer base {
html,
body {
overscroll-behavior-y: none;
margin: 0;
}
html {
height: calc(100% + env(safe-area-inset-bottom));
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left);
background-color: rgb(var(--canvas));
}
body {
background-color: rgb(var(--canvas));
color: rgb(var(--content));
transition:
background-color 150ms ease,
color 150ms ease;
}
}
main {
@@ -46,7 +238,7 @@ main {
/* Mobile Navigation */
#mobile-nav-button span {
transform-origin: 5px 0px;
transform-origin: 5px 0;
transition:
transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1),
background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1),
@@ -54,11 +246,11 @@ main {
}
#mobile-nav-button span:first-child {
transform-origin: 0% 0%;
transform-origin: 0 0;
}
#mobile-nav-button span:nth-last-child(2) {
transform-origin: 0% 100%;
transform-origin: 0 100%;
}
#mobile-nav-button:checked ~ span {
@@ -88,7 +280,7 @@ main {
#menu {
top: 0;
padding-top: env(safe-area-inset-top);
transform-origin: 0% 0%;
transform-origin: 0 0;
transform: translate(-100%, 0);
transition: transform 0.5s cubic-bezier(0.77, 0.2, 0.05, 1);
}
@@ -112,9 +304,9 @@ main {
.animate-wave {
background: linear-gradient(
90deg,
rgb(229, 231, 235) 0%,
rgb(243, 244, 246) 50%,
rgb(229, 231, 235) 100%
rgb(var(--neutral-200)) 0%,
rgb(var(--neutral-100)) 50%,
rgb(var(--neutral-200)) 100%
);
background-size: 200% 100%;
animation: wave 1.5s ease-in-out infinite;
@@ -123,9 +315,9 @@ main {
.dark .animate-wave {
background: linear-gradient(
90deg,
rgb(75, 85, 99) 0%,
rgb(107, 114, 128) 50%,
rgb(75, 85, 99) 100%
rgb(var(--neutral-600)) 0%,
rgb(var(--neutral-500)) 50%,
rgb(var(--neutral-600)) 100%
);
background-size: 200% 100%;
}

View File

@@ -5,10 +5,12 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import axios from 'axios';
import { ToastProvider } from './components/ToastContext';
import { setupAuthInterceptors } from './auth/authInterceptor';
import { ThemeProvider, initializeThemeMode } from './theme/ThemeProvider';
import App from './App';
import './index.css';
setupAuthInterceptors(axios);
initializeThemeMode();
const queryClient = new QueryClient({
defaultOptions: {
@@ -26,9 +28,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<ToastProvider>
<App />
</ToastProvider>
<ThemeProvider>
<ToastProvider>
<App />
</ToastProvider>
</ThemeProvider>
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>

View File

@@ -8,19 +8,11 @@ import { useToasts } from '../components/ToastContext';
import { formatDuration } from '../utils/formatters';
import { useDebounce } from '../hooks/useDebounce';
import { getErrorMessage } from '../utils/errors';
const DOCUMENTS_VIEW_MODE_KEY = 'documents:view-mode';
type DocumentViewMode = 'grid' | 'list';
function getInitialViewMode(): DocumentViewMode {
if (typeof window === 'undefined') {
return 'grid';
}
const storedValue = window.localStorage.getItem(DOCUMENTS_VIEW_MODE_KEY);
return storedValue === 'list' ? 'list' : 'grid';
}
import {
getDocumentsViewMode,
setDocumentsViewMode,
type DocumentsViewMode,
} from '../utils/localSettings';
interface DocumentCardProps {
doc: Document;
@@ -36,7 +28,7 @@ function DocumentCard({ doc }: DocumentCardProps) {
<div
role="link"
tabIndex={0}
className="flex size-full cursor-pointer gap-4 rounded bg-white p-4 shadow-lg transition-colors hover:bg-gray-50 focus:outline-none dark:bg-gray-700 dark:hover:bg-gray-600"
className="flex size-full cursor-pointer gap-4 rounded bg-surface p-4 shadow-lg transition-colors hover:bg-surface-muted focus:outline-none"
onClick={() => navigate(`/documents/${doc.id}`)}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
@@ -52,33 +44,33 @@ function DocumentCard({ doc }: DocumentCardProps) {
alt={doc.title}
/>
</div>
<div className="flex w-full flex-col justify-around text-sm dark:text-white">
<div className="flex w-full flex-col justify-around text-sm text-content">
<div className="inline-flex shrink-0 items-center">
<div>
<p className="text-gray-400">Title</p>
<p className="text-content-subtle">Title</p>
<p className="font-medium">{doc.title || 'Unknown'}</p>
</div>
</div>
<div className="inline-flex shrink-0 items-center">
<div>
<p className="text-gray-400">Author</p>
<p className="text-content-subtle">Author</p>
<p className="font-medium">{doc.author || 'Unknown'}</p>
</div>
</div>
<div className="inline-flex shrink-0 items-center">
<div>
<p className="text-gray-400">Progress</p>
<p className="text-content-subtle">Progress</p>
<p className="font-medium">{percentage}%</p>
</div>
</div>
<div className="inline-flex shrink-0 items-center">
<div>
<p className="text-gray-400">Time Read</p>
<p className="text-content-subtle">Time Read</p>
<p className="font-medium">{formatDuration(totalTimeSeconds)}</p>
</div>
</div>
</div>
<div className="absolute bottom-4 right-4 flex flex-col gap-2 text-gray-500 dark:text-gray-400">
<div className="absolute bottom-4 right-4 flex flex-col gap-2 text-content-muted">
<Link to={`/activity?document=${doc.id}`} onClick={e => e.stopPropagation()}>
<ActivityIcon size={20} />
</Link>
@@ -108,7 +100,7 @@ function DocumentListItem({ doc }: DocumentListItemProps) {
<div
role="link"
tabIndex={0}
className="block cursor-pointer rounded bg-white p-4 shadow-lg transition-colors hover:bg-gray-50 focus:outline-none dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
className="block cursor-pointer rounded bg-surface p-4 text-content shadow-lg transition-colors hover:bg-surface-muted focus:outline-none"
onClick={() => navigate(`/documents/${doc.id}`)}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
@@ -120,24 +112,24 @@ function DocumentListItem({ doc }: DocumentListItemProps) {
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="grid flex-1 grid-cols-1 gap-3 text-sm md:grid-cols-4">
<div>
<p className="text-gray-400">Title</p>
<p className="text-content-subtle">Title</p>
<p className="font-medium">{doc.title || 'Unknown'}</p>
</div>
<div>
<p className="text-gray-400">Author</p>
<p className="text-content-subtle">Author</p>
<p className="font-medium">{doc.author || 'Unknown'}</p>
</div>
<div>
<p className="text-gray-400">Progress</p>
<p className="text-content-subtle">Progress</p>
<p className="font-medium">{percentage}%</p>
</div>
<div>
<p className="text-gray-400">Time Read</p>
<p className="text-content-subtle">Time Read</p>
<p className="font-medium">{formatDuration(totalTimeSeconds)}</p>
</div>
</div>
<div className="flex shrink-0 items-center justify-end gap-4 text-gray-500 dark:text-gray-400">
<div className="flex shrink-0 items-center justify-end gap-4 text-content-muted">
<Link to={`/activity?document=${doc.id}`} onClick={e => e.stopPropagation()}>
<ActivityIcon size={20} />
</Link>
@@ -159,17 +151,16 @@ export default function DocumentsPage() {
const [page, setPage] = useState(1);
const [limit] = useState(9);
const [uploadMode, setUploadMode] = useState(false);
const [viewMode, setViewMode] = useState<DocumentViewMode>(getInitialViewMode);
const [viewMode, setViewMode] = useState<DocumentsViewMode>(getDocumentsViewMode);
const fileInputRef = useRef<HTMLInputElement>(null);
const { showInfo, showWarning, showError } = useToasts();
const debouncedSearch = useDebounce(search, 300);
useEffect(() => {
window.localStorage.setItem(DOCUMENTS_VIEW_MODE_KEY, viewMode);
setDocumentsViewMode(viewMode);
}, [viewMode]);
// Reset to page 1 when search changes
useEffect(() => {
setPage(1);
}, [debouncedSearch]);
@@ -210,45 +201,44 @@ export default function DocumentsPage() {
}
};
const getViewModeButtonClasses = (mode: DocumentsViewMode) =>
`rounded px-3 py-1 text-sm font-medium transition-colors ${
viewMode === mode
? 'bg-content text-content-inverse'
: 'text-content-muted hover:bg-surface-muted'
}`;
return (
<div className="flex flex-col gap-4">
<div className="flex grow flex-col gap-4 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
<div className="flex grow flex-col gap-4 rounded bg-surface p-4 text-content-muted shadow-lg">
<div className="flex flex-col gap-4 lg:flex-row">
<div className="flex w-full grow flex-col">
<div className="relative flex">
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
<Search2Icon size={15} hoverable={false} />
</span>
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white p-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600"
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
placeholder="Search Author / Title"
name="search"
/>
</div>
</div>
<div className="inline-flex rounded border border-gray-300 bg-white p-1 dark:border-gray-600 dark:bg-gray-800">
<div className="inline-flex rounded border border-border bg-surface p-1">
<button
type="button"
onClick={() => setViewMode('grid')}
className={`rounded px-3 py-1 text-sm font-medium transition-colors ${
viewMode === 'grid'
? 'bg-gray-800 text-white dark:bg-gray-100 dark:text-gray-900'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
className={getViewModeButtonClasses('grid')}
>
Grid
</button>
<button
type="button"
onClick={() => setViewMode('list')}
className={`rounded px-3 py-1 text-sm font-medium transition-colors ${
viewMode === 'list'
? 'bg-gray-800 text-white dark:bg-gray-100 dark:text-gray-900'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
className={getViewModeButtonClasses('list')}
>
List
</button>
@@ -263,7 +253,7 @@ export default function DocumentsPage() {
) : docs && docs.length > 0 ? (
docs.map(doc => <DocumentCard key={doc.id} doc={doc} />)
) : (
<div className="col-span-full rounded bg-white p-6 text-center text-gray-500 shadow-lg dark:bg-gray-700 dark:text-gray-300">
<div className="col-span-full rounded bg-surface p-6 text-center text-content-muted shadow-lg">
No documents found.
</div>
)}
@@ -275,18 +265,18 @@ export default function DocumentsPage() {
) : docs && docs.length > 0 ? (
docs.map(doc => <DocumentListItem key={doc.id} doc={doc} />)
) : (
<div className="rounded bg-white p-6 text-center text-gray-500 shadow-lg dark:bg-gray-700 dark:text-gray-300">
<div className="rounded bg-surface p-6 text-center text-content-muted shadow-lg">
No documents found.
</div>
)}
</div>
)}
<div className="mt-4 flex w-full justify-center gap-4 text-black dark:text-white">
<div className="mt-4 flex w-full justify-center gap-4 text-content">
{previousPage && previousPage > 0 && (
<button
onClick={() => setPage(page - 1)}
className="w-24 rounded bg-white p-2 text-center text-sm font-medium shadow-lg hover:bg-gray-400 focus:outline-none dark:bg-gray-600 dark:hover:bg-gray-700"
className="w-24 rounded bg-surface p-2 text-center text-sm font-medium shadow-lg hover:bg-surface-strong focus:outline-none"
>
</button>
@@ -294,7 +284,7 @@ export default function DocumentsPage() {
{nextPage && nextPage > 0 && (
<button
onClick={() => setPage(page + 1)}
className="w-24 rounded bg-white p-2 text-center text-sm font-medium shadow-lg hover:bg-gray-400 focus:outline-none dark:bg-gray-600 dark:hover:bg-gray-700"
className="w-24 rounded bg-surface p-2 text-center text-sm font-medium shadow-lg hover:bg-surface-strong focus:outline-none"
>
</button>
@@ -310,7 +300,7 @@ export default function DocumentsPage() {
onChange={() => setUploadMode(!uploadMode)}
/>
<div
className={`absolute bottom-0 right-0 z-10 flex w-72 flex-col gap-2 rounded bg-gray-800 p-4 text-sm text-white transition-opacity duration-200 dark:bg-gray-200 dark:text-black ${uploadMode ? 'visible opacity-100' : 'invisible opacity-0'}`}
className={`absolute bottom-0 right-0 z-10 flex w-72 flex-col gap-2 rounded bg-content p-4 text-sm text-content-inverse transition-opacity duration-200 ${uploadMode ? 'visible opacity-100' : 'invisible opacity-0'}`}
>
<form method="POST" encType="multipart/form-data" className="flex flex-col gap-2">
<input
@@ -322,7 +312,7 @@ export default function DocumentsPage() {
onChange={handleFileChange}
/>
<button
className="bg-gray-500 px-2 py-1 font-medium text-gray-800 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-800"
className="bg-surface-strong px-2 py-1 font-medium text-content hover:bg-surface"
type="submit"
onClick={e => {
e.preventDefault();
@@ -336,7 +326,7 @@ export default function DocumentsPage() {
</form>
<label htmlFor="upload-file-button">
<div
className="mt-2 w-full cursor-pointer bg-gray-500 px-2 py-1 text-center font-medium text-gray-800 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-800"
className="mt-2 w-full cursor-pointer bg-surface-strong px-2 py-1 text-center font-medium text-content hover:bg-surface"
onClick={handleCancelUpload}
>
Cancel Upload
@@ -344,10 +334,10 @@ export default function DocumentsPage() {
</label>
</div>
<label
className="flex size-16 cursor-pointer items-center justify-center rounded-full bg-gray-800 opacity-30 transition-all duration-200 hover:opacity-100 dark:bg-gray-200"
className="flex size-16 cursor-pointer items-center justify-center rounded-full bg-content opacity-30 transition-all duration-200 hover:opacity-100"
htmlFor="upload-file-button"
>
<UploadIcon size={34} />
<UploadIcon size={34} className="text-content-inverse" />
</label>
</div>
</div>

View File

@@ -44,7 +44,7 @@ export function LoginPageView({
onSubmit,
}: LoginPageViewProps) {
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-800 dark:text-white">
<div className="min-h-screen bg-canvas text-content">
<div className="flex w-full flex-wrap">
<div className="flex w-full flex-col md:w-1/2">
<div className="my-auto flex flex-col justify-center px-8 pt-8 md:justify-start md:px-24 md:pt-0 lg:px-32">
@@ -56,7 +56,7 @@ export function LoginPageView({
type="text"
value={username}
onChange={e => onUsernameChange(e.target.value)}
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white px-4 py-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600"
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
placeholder="Username"
required
disabled={isLoading}
@@ -69,7 +69,7 @@ export function LoginPageView({
type="password"
value={password}
onChange={e => onPasswordChange(e.target.value)}
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white px-4 py-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600"
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
placeholder="Password"
required
disabled={isLoading}
@@ -103,8 +103,8 @@ export function LoginPageView({
</div>
</div>
<div className="relative hidden h-screen w-1/2 shadow-2xl md:block">
<div className="left-0 top-0 flex h-screen w-full items-center justify-center bg-gray-300 object-cover ease-in-out">
<span className="text-gray-500">AnthoLume</span>
<div className="left-0 top-0 flex h-screen w-full items-center justify-center bg-surface-strong object-cover ease-in-out">
<span className="text-content-muted">AnthoLume</span>
</div>
</div>
</div>

View File

@@ -5,12 +5,21 @@ import { UserIcon, PasswordIcon, ClockIcon } from '../icons';
import { Button } from '../components/Button';
import { useToasts } from '../components/ToastContext';
import { getErrorMessage } from '../utils/errors';
import { useTheme } from '../theme/ThemeProvider';
import type { ThemeMode } from '../utils/localSettings';
const themeModes: Array<{ value: ThemeMode; label: string; description: string }> = [
{ value: 'light', label: 'Light', description: 'Always use the light palette.' },
{ value: 'dark', label: 'Dark', description: 'Always use the dark palette.' },
{ value: 'system', label: 'System', description: 'Follow your device preference.' },
];
export default function SettingsPage() {
const { data, isLoading } = useGetSettings();
const updateSettings = useUpdateSettings();
const settingsData = data?.status === 200 ? (data.data as SettingsResponse) : null;
const { showInfo, showError } = useToasts();
const { themeMode, resolvedThemeMode, setThemeMode } = useTheme();
const [password, setPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
@@ -33,7 +42,7 @@ export default function SettingsPage() {
try {
await updateSettings.mutateAsync({
data: {
password: password,
password,
new_password: newPassword,
},
});
@@ -51,7 +60,7 @@ export default function SettingsPage() {
try {
await updateSettings.mutateAsync({
data: {
timezone: timezone,
timezone,
},
});
showInfo('Timezone updated successfully');
@@ -64,13 +73,13 @@ export default function SettingsPage() {
return (
<div className="flex w-full flex-col gap-4 md:flex-row">
<div>
<div className="flex flex-col items-center rounded bg-white p-4 shadow-lg md:w-60 lg:w-80 dark:bg-gray-700">
<div className="flex flex-col items-center rounded bg-surface p-4 shadow-lg md:w-60 lg:w-80">
<div className="mb-4 size-16 rounded-full bg-gray-200 dark:bg-gray-600" />
<div className="h-6 w-32 rounded bg-gray-200 dark:bg-gray-600" />
</div>
</div>
<div className="flex grow flex-col gap-4">
<div className="flex flex-col gap-2 rounded bg-white p-4 shadow-lg dark:bg-gray-700">
<div className="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
<div className="mb-4 h-6 w-48 rounded bg-gray-200 dark:bg-gray-600" />
<div className="flex gap-4">
<div className="h-12 flex-1 rounded bg-gray-200 dark:bg-gray-600" />
@@ -78,14 +87,22 @@ export default function SettingsPage() {
<div className="h-10 w-40 rounded bg-gray-200 dark:bg-gray-600" />
</div>
</div>
<div className="flex flex-col gap-2 rounded bg-white p-4 shadow-lg dark:bg-gray-700">
<div className="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
<div className="mb-4 h-6 w-48 rounded bg-gray-200 dark:bg-gray-600" />
<div className="flex gap-4">
<div className="h-12 flex-1 rounded bg-gray-200 dark:bg-gray-600" />
<div className="h-10 w-40 rounded bg-gray-200 dark:bg-gray-600" />
</div>
</div>
<div className="flex flex-col rounded bg-white p-4 shadow-lg dark:bg-gray-700">
<div className="flex flex-col gap-2 rounded bg-surface p-4 shadow-lg">
<div className="mb-4 h-6 w-48 rounded bg-gray-200 dark:bg-gray-600" />
<div className="grid gap-3 md:grid-cols-3">
{themeModes.map(mode => (
<div key={mode.value} className="h-24 rounded bg-gray-200 dark:bg-gray-600" />
))}
</div>
</div>
<div className="flex flex-col rounded bg-surface p-4 shadow-lg">
<div className="mb-4 h-6 w-24 rounded bg-gray-200 dark:bg-gray-600" />
<div className="mb-4 flex gap-4">
<div className="h-6 flex-1 rounded bg-gray-200 dark:bg-gray-600" />
@@ -101,43 +118,41 @@ export default function SettingsPage() {
return (
<div className="flex w-full flex-col gap-4 md:flex-row">
{/* User Profile Card */}
<div>
<div className="flex flex-col items-center rounded bg-white p-4 text-gray-500 shadow-lg md:w-60 lg:w-80 dark:bg-gray-700 dark:text-white">
<div className="flex flex-col items-center rounded bg-surface p-4 text-content-muted shadow-lg md:w-60 lg:w-80">
<UserIcon size={60} />
<p className="text-lg">{settingsData?.user.username || 'N/A'}</p>
<p className="text-lg text-content">{settingsData?.user.username || 'N/A'}</p>
</div>
</div>
<div className="flex grow flex-col gap-4">
{/* Change Password Form */}
<div className="flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
<p className="mb-2 text-lg font-semibold">Change Password</p>
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
<p className="mb-2 text-lg font-semibold text-content">Change Password</p>
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handlePasswordSubmit}>
<div className="flex grow flex-col">
<div className="relative flex">
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
<PasswordIcon size={15} />
</span>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white px-4 py-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600"
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
placeholder="Password"
/>
</div>
</div>
<div className="flex grow flex-col">
<div className="relative flex">
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
<PasswordIcon size={15} />
</span>
<input
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white px-4 py-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600"
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
placeholder="New Password"
/>
</div>
@@ -150,18 +165,56 @@ export default function SettingsPage() {
</form>
</div>
{/* Change Timezone Form */}
<div className="flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
<p className="mb-2 text-lg font-semibold">Change Timezone</p>
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
<div className="flex items-center justify-between gap-4">
<div>
<p className="mb-1 text-lg font-semibold text-content">Appearance</p>
<p>
Active mode: <span className="font-medium text-content">{resolvedThemeMode}</span>
</p>
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
{themeModes.map(mode => {
const isSelected = themeMode === mode.value;
return (
<button
key={mode.value}
type="button"
onClick={() => setThemeMode(mode.value)}
className={`rounded border p-4 text-left transition-colors ${
isSelected
? 'border-primary-500 bg-primary-50 text-content dark:bg-primary-100/20'
: 'border-border bg-surface-muted text-content-muted hover:border-primary-300 hover:bg-surface'
}`}
>
<div className="mb-3 flex items-center justify-between">
<span className="text-base font-semibold text-content">{mode.label}</span>
<span
className={`inline-flex size-4 rounded-full border ${
isSelected ? 'border-primary-500 bg-primary-500' : 'border-border-strong'
}`}
/>
</div>
<p className="text-sm">{mode.description}</p>
</button>
);
})}
</div>
</div>
<div className="flex grow flex-col gap-2 rounded bg-surface p-4 text-content-muted shadow-lg">
<p className="mb-2 text-lg font-semibold text-content">Change Timezone</p>
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleTimezoneSubmit}>
<div className="relative flex grow">
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
<span className="inline-flex items-center border-y border-l border-border bg-surface px-3 text-sm text-content-muted shadow-sm">
<ClockIcon size={15} />
</span>
<select
value={timezone || 'UTC'}
onChange={e => setTimezone(e.target.value)}
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white px-4 py-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600"
className="w-full flex-1 appearance-none rounded-none border border-border bg-surface px-4 py-2 text-base text-content shadow-sm placeholder:text-content-subtle focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600"
>
<option value="UTC">UTC</option>
<option value="America/New_York">America/New_York</option>
@@ -183,24 +236,23 @@ export default function SettingsPage() {
</form>
</div>
{/* Devices Table */}
<div className="flex grow flex-col rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
<p className="text-lg font-semibold">Devices</p>
<table className="min-w-full bg-white text-sm dark:bg-gray-700">
<thead className="text-gray-800 dark:text-gray-400">
<div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg">
<p className="text-lg font-semibold text-content">Devices</p>
<table className="min-w-full bg-surface text-sm">
<thead className="text-content-muted">
<tr>
<th className="border-b border-gray-200 p-3 pl-0 text-left font-normal uppercase dark:border-gray-800">
<th className="border-b border-border p-3 pl-0 text-left font-normal uppercase">
Name
</th>
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
<th className="border-b border-border p-3 text-left font-normal uppercase">
Last Sync
</th>
<th className="border-b border-gray-200 p-3 text-left font-normal uppercase dark:border-gray-800">
<th className="border-b border-border p-3 text-left font-normal uppercase">
Created
</th>
</tr>
</thead>
<tbody className="text-black dark:text-white">
<tbody className="text-content">
{!settingsData?.devices || settingsData.devices.length === 0 ? (
<tr>
<td className="p-3 text-center" colSpan={3}>

View File

@@ -0,0 +1,126 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import { getThemeMode, setThemeMode, type ThemeMode } from '../utils/localSettings';
export type ResolvedThemeMode = 'light' | 'dark';
interface ThemeContextValue {
themeMode: ThemeMode;
resolvedThemeMode: ResolvedThemeMode;
setThemeMode: (themeMode: ThemeMode) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
function getSystemThemeMode(): ResolvedThemeMode {
if (typeof window === 'undefined') {
return 'light';
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export function resolveThemeMode(themeMode: ThemeMode): ResolvedThemeMode {
return themeMode === 'system' ? getSystemThemeMode() : themeMode;
}
export function applyThemeMode(themeMode: ThemeMode): ResolvedThemeMode {
const resolvedThemeMode = resolveThemeMode(themeMode);
if (typeof document !== 'undefined') {
document.documentElement.classList.toggle('dark', resolvedThemeMode === 'dark');
document.documentElement.dataset.themeMode = themeMode;
document.documentElement.style.colorScheme = resolvedThemeMode;
}
return resolvedThemeMode;
}
export function initializeThemeMode(): ResolvedThemeMode {
return applyThemeMode(getThemeMode());
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [themeModeState, setThemeModeState] = useState<ThemeMode>(() => getThemeMode());
const [resolvedThemeMode, setResolvedThemeMode] = useState<ResolvedThemeMode>(() =>
resolveThemeMode(getThemeMode())
);
useEffect(() => {
setResolvedThemeMode(applyThemeMode(themeModeState));
}, [themeModeState]);
useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemThemeChange = () => {
if (themeModeState === 'system') {
setResolvedThemeMode(applyThemeMode('system'));
}
};
mediaQueryList.addEventListener('change', handleSystemThemeChange);
return () => {
mediaQueryList.removeEventListener('change', handleSystemThemeChange);
};
}, [themeModeState]);
useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const handleStorage = (event: StorageEvent) => {
if (event.key && event.key !== 'antholume:settings') {
return;
}
const nextThemeMode = getThemeMode();
setThemeModeState(nextThemeMode);
setResolvedThemeMode(applyThemeMode(nextThemeMode));
};
window.addEventListener('storage', handleStorage);
return () => {
window.removeEventListener('storage', handleStorage);
};
}, []);
const updateThemeMode = useCallback((nextThemeMode: ThemeMode) => {
setThemeMode(nextThemeMode);
setThemeModeState(nextThemeMode);
setResolvedThemeMode(applyThemeMode(nextThemeMode));
}, []);
const value = useMemo(
() => ({
themeMode: themeModeState,
resolvedThemeMode,
setThemeMode: updateThemeMode,
}),
[resolvedThemeMode, themeModeState, updateThemeMode]
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

View File

@@ -0,0 +1,66 @@
export type ThemeMode = 'light' | 'dark' | 'system';
export type DocumentsViewMode = 'grid' | 'list';
const LOCAL_SETTINGS_KEY = 'antholume:settings';
interface LocalSettings {
themeMode?: ThemeMode;
documentsViewMode?: DocumentsViewMode;
}
function canUseLocalStorage(): boolean {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
}
function readLocalSettings(): LocalSettings {
if (!canUseLocalStorage()) {
return {};
}
const rawValue = window.localStorage.getItem(LOCAL_SETTINGS_KEY);
if (!rawValue) {
return {};
}
try {
const parsedValue = JSON.parse(rawValue);
return typeof parsedValue === 'object' && parsedValue !== null ? parsedValue : {};
} catch {
return {};
}
}
function writeLocalSettings(settings: LocalSettings): void {
if (!canUseLocalStorage()) {
return;
}
window.localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings));
}
function updateLocalSettings(partialSettings: LocalSettings): void {
writeLocalSettings({
...readLocalSettings(),
...partialSettings,
});
}
export function getThemeMode(): ThemeMode {
const settings = readLocalSettings();
return settings.themeMode === 'light' || settings.themeMode === 'dark'
? settings.themeMode
: 'system';
}
export function setThemeMode(themeMode: ThemeMode): void {
updateLocalSettings({ themeMode });
}
export function getDocumentsViewMode(): DocumentsViewMode {
const settings = readLocalSettings();
return settings.documentsViewMode === 'list' ? 'list' : 'grid';
}
export function setDocumentsViewMode(documentsViewMode: DocumentsViewMode): void {
updateLocalSettings({ documentsViewMode });
}

View File

@@ -1,9 +1,51 @@
const withOpacity = cssVariable => `rgb(var(${cssVariable}) / <alpha-value>)`;
const buildScale = scaleName => ({
50: withOpacity(`--${scaleName}-50`),
100: withOpacity(`--${scaleName}-100`),
200: withOpacity(`--${scaleName}-200`),
300: withOpacity(`--${scaleName}-300`),
400: withOpacity(`--${scaleName}-400`),
500: withOpacity(`--${scaleName}-500`),
600: withOpacity(`--${scaleName}-600`),
700: withOpacity(`--${scaleName}-700`),
800: withOpacity(`--${scaleName}-800`),
900: withOpacity(`--${scaleName}-900`),
DEFAULT: withOpacity(`--${scaleName}-500`),
foreground: withOpacity(`--${scaleName}-foreground`),
});
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'media',
darkMode: 'class',
theme: {
extend: {},
extend: {
colors: {
canvas: withOpacity('--canvas'),
surface: withOpacity('--surface'),
'surface-muted': withOpacity('--surface-muted'),
'surface-strong': withOpacity('--surface-strong'),
overlay: withOpacity('--overlay'),
content: withOpacity('--content'),
'content-muted': withOpacity('--content-muted'),
'content-subtle': withOpacity('--content-subtle'),
'content-inverse': withOpacity('--content-inverse'),
border: withOpacity('--border'),
'border-muted': withOpacity('--border-muted'),
'border-strong': withOpacity('--border-strong'),
white: withOpacity('--white'),
black: withOpacity('--black'),
gray: buildScale('neutral'),
purple: buildScale('primary'),
blue: buildScale('secondary'),
yellow: buildScale('warning'),
red: buildScale('error'),
primary: buildScale('primary'),
secondary: buildScale('secondary'),
tertiary: buildScale('tertiary'),
},
},
},
plugins: [],
};