diff --git a/frontend/docker-entrypoint.sh b/frontend/docker-entrypoint.sh index 1709544..9fcc7ac 100644 --- a/frontend/docker-entrypoint.sh +++ b/frontend/docker-entrypoint.sh @@ -4,11 +4,20 @@ set -euo pipefail API_PROXY_PASS_TRIMMED="${API_PROXY_PASS:-}" API_PROXY_PASS_TRIMMED="${API_PROXY_PASS_TRIMMED%%/}" -cat <<'BASE' > /etc/nginx/conf.d/default.conf +MAX_BODY_SIZE_RAW="${UPLOAD_BODY_LIMIT_BYTES:-}" +if [ -n "$MAX_BODY_SIZE_RAW" ]; then + MAX_BODY_SIZE=$(printf '%sm' "$((MAX_BODY_SIZE_RAW / (1024 * 1024)))") +else + MAX_BODY_SIZE="128m" +fi + +cat < /etc/nginx/conf.d/default.conf server { listen 80; server_name _; + client_max_body_size ${MAX_BODY_SIZE}; + root /usr/share/nginx/html; index index.html; @@ -21,6 +30,7 @@ if [ -n "$API_PROXY_PASS_TRIMMED" ]; then cat <> /etc/nginx/conf.d/default.conf location /api/ { + client_max_body_size ${MAX_BODY_SIZE}; proxy_pass ${API_PROXY_PASS_TRIMMED}; proxy_set_header Host \$host; proxy_set_header X-Real-IP \$remote_addr; @@ -29,6 +39,7 @@ cat <> /etc/nginx/conf.d/default.conf } location /download/ { + client_max_body_size ${MAX_BODY_SIZE}; proxy_pass ${API_PROXY_PASS_TRIMMED}; proxy_set_header Host \$host; proxy_set_header X-Real-IP \$remote_addr; diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx index 67c243d..4bd5411 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.jsx @@ -28,6 +28,7 @@ const DEFAULT_FOLDER_NAME = 'All Documents'; const ROW_KEY_SEPARATOR = ':'; const DOCUMENT_ROW_PREFIX = 'document'; const FOLDER_ROW_PREFIX = 'folder'; +const THEME_STORAGE_KEY = 'papercrate_theme_settings'; const resolveApiPath = (path = '') => path; @@ -197,22 +198,66 @@ const AppLayout = () => { const stored = window.localStorage.getItem('papercrate_view_mode'); return stored === 'grid' ? 'grid' : 'list'; }); - const [neutralHue, setNeutralHue] = useState(() => { + + const loadThemeSettings = () => { + const defaults = { neutralHue: 260, mode: 'system' }; if (typeof window === 'undefined') { - return 260; + if (typeof document !== 'undefined') { + const current = document.documentElement.style.getPropertyValue('--neutral-hue'); + const parsed = Number.parseInt(current, 10); + return { + neutralHue: Number.isNaN(parsed) ? defaults.neutralHue : parsed, + mode: defaults.mode, + }; + } + return defaults; } - const stored = window.localStorage.getItem('papercrate_neutral_hue'); - if (stored) { - const parsedStored = Number.parseInt(stored, 10); - if (!Number.isNaN(parsedStored)) { - return parsedStored; + + const root = document.documentElement; + const loadFromRoot = () => { + const current = root.style.getPropertyValue('--neutral-hue'); + const parsed = Number.parseInt(current, 10); + return Number.isNaN(parsed) ? defaults.neutralHue : parsed; + }; + + let neutralHueValue = loadFromRoot(); + let modeValue = defaults.mode; + + const composite = window.localStorage.getItem(THEME_STORAGE_KEY); + if (composite) { + try { + const parsed = JSON.parse(composite); + const storedHue = Number.parseInt(parsed?.neutralHue, 10); + if (!Number.isNaN(storedHue)) { + neutralHueValue = storedHue; + } + const storedMode = parsed?.mode; + if (storedMode === 'light' || storedMode === 'dark' || storedMode === 'system') { + modeValue = storedMode; + } + } catch (error) { + console.warn('[theme] failed to parse stored theme settings', error); + } + } else { + const legacyHue = window.localStorage.getItem('papercrate_neutral_hue'); + if (legacyHue) { + const parsedLegacyHue = Number.parseInt(legacyHue, 10); + if (!Number.isNaN(parsedLegacyHue)) { + neutralHueValue = parsedLegacyHue; + } + } + const legacyMode = window.localStorage.getItem('papercrate_theme_mode'); + if (legacyMode === 'light' || legacyMode === 'dark' || legacyMode === 'system') { + modeValue = legacyMode; } } - const root = document.documentElement; - const current = root.style.getPropertyValue('--neutral-hue'); - const parsed = Number.parseInt(current, 10); - return Number.isNaN(parsed) ? 260 : parsed; - }); + + return { neutralHue: neutralHueValue, mode: modeValue }; + }; + + const initialThemeSettings = loadThemeSettings(); + const [neutralHue, setNeutralHue] = useState(initialThemeSettings.neutralHue); + const [themeMode, setThemeMode] = useState(initialThemeSettings.mode); const initialRowSelection = []; const [selectedRowKeys, setSelectedRowKeys] = useState(initialRowSelection); const [selectionOrder, setSelectionOrder] = useState(initialRowSelection); @@ -314,11 +359,33 @@ const AppLayout = () => { if (typeof document !== 'undefined') { document.documentElement.style.setProperty('--neutral-hue', `${neutralHue}deg`); } - if (typeof window !== 'undefined') { - window.localStorage.setItem('papercrate_neutral_hue', String(neutralHue)); - } }, [neutralHue]); + useEffect(() => { + if (typeof document !== 'undefined') { + const root = document.documentElement; + if (themeMode === 'system') { + root.removeAttribute('data-theme'); + } else { + root.setAttribute('data-theme', themeMode); + } + } + }, [themeMode]); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + try { + const payload = JSON.stringify({ neutralHue, mode: themeMode }); + window.localStorage.setItem(THEME_STORAGE_KEY, payload); + window.localStorage.removeItem('papercrate_neutral_hue'); + window.localStorage.removeItem('papercrate_theme_mode'); + } catch (error) { + console.warn('[theme] failed to persist theme settings', error); + } + }, [neutralHue, themeMode]); + const handleNeutralHueChange = useCallback((value) => { if (value === '') { setNeutralHue(260); @@ -331,6 +398,13 @@ const AppLayout = () => { setNeutralHue(parsed); }, []); + const handleThemeModeChange = useCallback((mode) => { + if (mode !== 'light' && mode !== 'dark' && mode !== 'system') { + return; + } + setThemeMode(mode); + }, []); + const clearFilters = useCallback(() => { setSearchQuery(''); setActiveTagFilters([]); @@ -4764,6 +4838,8 @@ const AppLayout = () => { creatingFolder, onNeutralHueChange: handleNeutralHueChange, neutralHue, + themeMode, + onThemeModeChange: handleThemeModeChange, tags, activeTagIds: activeTagFilters, onToggleTagFilter: toggleTagFilter, @@ -4824,6 +4900,8 @@ const AppLayout = () => { creatingFolder, handleNeutralHueChange, neutralHue, + themeMode, + handleThemeModeChange, ], ); diff --git a/frontend/src/sidebar/Sidebar.jsx b/frontend/src/sidebar/Sidebar.jsx index 929a769..e272a19 100644 --- a/frontend/src/sidebar/Sidebar.jsx +++ b/frontend/src/sidebar/Sidebar.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState, useId } from 'react'; import { ChevronIcon, TrashIcon, @@ -12,6 +12,9 @@ import { PlusIcon, FolderPlusIcon, RestoreIcon, + SunIcon, + MoonIcon, + DesktopIcon, } from '../ui/icons'; import PanelHeader from '../ui/PanelHeader'; @@ -129,6 +132,13 @@ const FolderNode = ({ ); }; +const THEME_MODES = ['system', 'light', 'dark']; +const THEME_MODE_LABELS = { + system: 'System', + light: 'Light', + dark: 'Dark', +}; + const Sidebar = ({ folderNodes, onToggle, @@ -146,6 +156,8 @@ const Sidebar = ({ creatingFolder = false, onNeutralHueChange, neutralHue, + themeMode = 'system', + onThemeModeChange, tags = [], activeTagIds = [], onToggleTagFilter, @@ -170,6 +182,7 @@ const Sidebar = ({ onSelectTenant, onOpenSettings, }) => { + const neutralHueInputId = useId(); const sortedCorrespondents = useMemo(() => { if (!Array.isArray(correspondents)) { return []; @@ -230,6 +243,28 @@ const Sidebar = ({ onNeutralHueChange?.(''); }, [onNeutralHueChange]); + const themeModeIndex = THEME_MODES.indexOf(themeMode); + const safeThemeModeIndex = themeModeIndex === -1 ? 0 : themeModeIndex; + const resolvedThemeMode = THEME_MODES[safeThemeModeIndex]; + const nextThemeMode = THEME_MODES[(safeThemeModeIndex + 1) % THEME_MODES.length]; + const themeModeLabel = THEME_MODE_LABELS[resolvedThemeMode]; + const nextThemeLabel = THEME_MODE_LABELS[nextThemeMode]; + const themeModeIcon = resolvedThemeMode === 'dark' + ? + : resolvedThemeMode === 'light' + ? + : ; + + const handleThemeModeToggle = useCallback(() => { + if (!onThemeModeChange) { + return; + } + const currentIndex = THEME_MODES.indexOf(themeMode); + const safeIndex = currentIndex === -1 ? 0 : currentIndex; + const nextMode = THEME_MODES[(safeIndex + 1) % THEME_MODES.length]; + onThemeModeChange(nextMode); + }, [onThemeModeChange, themeMode]); + const handleSearchInputChange = useCallback( (event) => { onSearchChange?.(event.target.value); @@ -350,6 +385,50 @@ const Sidebar = ({ ); const rootNode = folderNodes.get('root'); + const themeControls = + typeof neutralHue === 'number' || typeof neutralHue === 'string' + ? ( +
+
+

Theme

+
+ + +
+
+ +
+ ) + : null; + return ( ); }; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index dad71d4..ee5c1ef 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -96,8 +96,88 @@ --documents-grid-title-size: 0.8rem; } +:root[data-theme='light'] { + color-scheme: light; +} + +:root[data-theme='dark'] { + color-scheme: dark; + + --dark-neutral-hue: calc(var(--neutral-hue) + var(--dark-hue-offset)); + --dark-foreground-hue: calc(var(--neutral-hue) + var(--dark-foreground-hue-offset)); + + --bg: oklch(0.15 0.01 var(--dark-neutral-hue)); + --surface: oklch(0.19 0.012 var(--dark-neutral-hue)); + --surface-subtle: oklch(0.23 0.012 var(--dark-neutral-hue)); + --fg: oklch(0.89 0.015 var(--dark-foreground-hue)); + --muted: oklch(0.72 0.02 var(--dark-foreground-hue)); + --sidebar-fg: oklch(0.78 0.02 var(--dark-foreground-hue)); + --border: oklch(0.33 0.01 var(--dark-foreground-hue)); + + --accent: oklch(0.75 0.2 calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); + --accent-hover: oklch(0.82 0.16 calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); + --on-accent: oklch(0.15 0.015 var(--dark-foreground-hue)); + + --folder-icon-back: color-mix(in oklch, var(--accent) 72%, black 12%); + --folder-icon-mid: color-mix(in oklch, var(--accent) 50%, white 24%); + --folder-icon-front: color-mix(in oklch, var(--accent) 30%, white 50%); + + --accent-soft: color-mix(in oklch, var(--accent) 22%, transparent); + --accent-elevated: color-mix(in oklch, var(--accent) 28%, transparent); + --accent-elevated-strong: color-mix(in oklch, var(--accent) 38%, transparent); + --accent-outline: color-mix(in oklch, var(--accent) 55%, transparent); + --accent-outline-strong: color-mix(in oklch, var(--accent) 90%, transparent); + --accent-focus: color-mix(in oklch, var(--accent) 80%, transparent); + --surface-overlay: color-mix(in oklch, black 60%, transparent); + + --selection: oklch(0.44 0.04 220deg); + --selection-soft: color-mix(in oklch, var(--selection) 18%, transparent); + + --shadow-faint: color-mix(in oklch, black 35%, transparent); + --shadow-soft: color-mix(in oklch, black 50%, transparent); + --shadow-medium: color-mix(in oklch, black 65%, transparent); + --shadow-strong: color-mix(in oklch, black 80%, transparent); + --shadow-deep: color-mix(in oklch, black 90%, transparent); + --shadow-pop: color-mix(in oklch, black 70%, transparent); + --outline-subtle: color-mix(in oklch, white 10%, transparent); + --overlay-dark: color-mix(in oklch, black 70%, transparent); + --overlay-dim: color-mix(in oklch, oklch(0.42 0.04 var(--dark-neutral-hue)) 35%, transparent); + --overlay-darker: color-mix(in oklch, black 85%, transparent); + --surface-danger-subtle: color-mix(in oklch, var(--danger) 26%, transparent); + --overlay-accent-subtle: color-mix(in oklch, var(--accent) 32%, transparent); + --border-strong: color-mix(in oklch, var(--fg) 30%, transparent); + --surface-hover: color-mix(in oklch, white 6%, transparent); + --overlay-accent-strong: color-mix(in oklch, var(--accent) 55%, transparent); + --overlay-backdrop: color-mix(in oklch, oklch(0.1 0.015 var(--dark-neutral-hue)) 70%, transparent); + --overlay-shadow: color-mix(in oklch, oklch(0.12 0.015 var(--dark-neutral-hue)) 55%, transparent); + + --success: oklch(0.62 0.16 var(--success-hue)); + --warning: oklch(0.68 0.17 var(--warning-hue)); + --danger: oklch(0.60 0.2 var(--danger-hue)); + + --success-subtle: color-mix(in oklch, var(--success) 20%, transparent); + --warning-subtle: color-mix(in oklch, var(--warning) 20%, transparent); + --danger-border: color-mix(in oklch, var(--danger) 45%, transparent); + --danger-soft: color-mix(in oklch, var(--danger) 24%, transparent); + --danger-subtle: color-mix(in oklch, var(--danger) 18%, transparent); + + --row-hover-bg: color-mix(in oklch, white 4%, transparent); + --row-active-bg: color-mix(in oklch, var(--selection) 20%, transparent); + --sidebar-hover-bg: color-mix(in oklch, white 6%, transparent); + --sidebar-active-bg: color-mix(in oklch, var(--selection) 26%, transparent); + --selection-ring: oklch(0.68 0.16 var(--dark-foreground-hue)); + --sidebar-active-pill-border: color-mix(in oklch, var(--accent) 72%, transparent); + --link: color-mix(in oklch, var(--accent) 92%, transparent); + --link-visited: color-mix(in oklch, var(--accent) 70%, white 20%); + --preview-nav-bg: oklch(0.3 0.04 var(--dark-neutral-hue)); + --preview-nav-bg-hover: oklch(0.35 0.04 var(--dark-neutral-hue)); + --preview-nav-fg: var(--fg); + --text-on-dark: color-mix(in oklch, white 92%, transparent); + --surface-ink-soft: color-mix(in oklch, white 12%, transparent); +} + @media (prefers-color-scheme: dark) { - :root { + :root:not([data-theme='light']) { color-scheme: dark; --dark-neutral-hue: calc(var(--neutral-hue) + var(--dark-hue-offset)); @@ -1201,7 +1281,7 @@ button.danger:hover:not([disabled]) { .sidebar { gap: 0; - overflow-y: auto; + overflow: hidden; color: var(--sidebar-fg); display: flex; flex-direction: column; @@ -1216,6 +1296,18 @@ button.danger:hover:not([disabled]) { flex-direction: column; padding: 0.5rem 1rem; gap: 0.75rem; + overflow-y: auto; + min-height: 0; +} + +.sidebar__footer { + margin-top: auto; + padding: 0.75rem 1rem 1rem; + border-top: 1px solid var(--border); + background: var(--surface-subtle); + display: flex; + flex-direction: column; + gap: 0.75rem; } .sidebar__title { @@ -1433,13 +1525,12 @@ button.danger:hover:not([disabled]) { background: var(--sidebar-hover-bg); } -.sidebar__footer { - margin-top: auto; - padding-top: 1rem; +.sidebar__footer .sidebar-section { + margin-top: 0; } -.sidebar__footer button { - width: 100%; +.sidebar__footer .sidebar-section__actions { + gap: 0.4rem; } .sidebar-section:first-of-type, diff --git a/frontend/src/ui/icons.js b/frontend/src/ui/icons.js index 91af1e2..a68552c 100644 --- a/frontend/src/ui/icons.js +++ b/frontend/src/ui/icons.js @@ -25,6 +25,9 @@ import { IconSettings, IconCheck, IconPlus, + IconSun, + IconMoon, + IconDeviceLaptop, } from '@tabler/icons-react'; import FolderSvg from '../assets/folder.svg'; @@ -226,6 +229,33 @@ export const PlusIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => /> ); +export const SunIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + +export const MoonIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + +export const DesktopIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + export const CheckIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (