This commit is contained in:
2025-10-31 13:39:06 +01:00
parent 3af656b09f
commit b254c651a7
5 changed files with 314 additions and 53 deletions
+12 -1
View File
@@ -4,11 +4,20 @@ set -euo pipefail
API_PROXY_PASS_TRIMMED="${API_PROXY_PASS:-}" API_PROXY_PASS_TRIMMED="${API_PROXY_PASS:-}"
API_PROXY_PASS_TRIMMED="${API_PROXY_PASS_TRIMMED%%/}" 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 <<BASE > /etc/nginx/conf.d/default.conf
server { server {
listen 80; listen 80;
server_name _; server_name _;
client_max_body_size ${MAX_BODY_SIZE};
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
@@ -21,6 +30,7 @@ if [ -n "$API_PROXY_PASS_TRIMMED" ]; then
cat <<PROXY >> /etc/nginx/conf.d/default.conf cat <<PROXY >> /etc/nginx/conf.d/default.conf
location /api/ { location /api/ {
client_max_body_size ${MAX_BODY_SIZE};
proxy_pass ${API_PROXY_PASS_TRIMMED}; proxy_pass ${API_PROXY_PASS_TRIMMED};
proxy_set_header Host \$host; proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr; proxy_set_header X-Real-IP \$remote_addr;
@@ -29,6 +39,7 @@ cat <<PROXY >> /etc/nginx/conf.d/default.conf
} }
location /download/ { location /download/ {
client_max_body_size ${MAX_BODY_SIZE};
proxy_pass ${API_PROXY_PASS_TRIMMED}; proxy_pass ${API_PROXY_PASS_TRIMMED};
proxy_set_header Host \$host; proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr; proxy_set_header X-Real-IP \$remote_addr;
+91 -13
View File
@@ -28,6 +28,7 @@ const DEFAULT_FOLDER_NAME = 'All Documents';
const ROW_KEY_SEPARATOR = ':'; const ROW_KEY_SEPARATOR = ':';
const DOCUMENT_ROW_PREFIX = 'document'; const DOCUMENT_ROW_PREFIX = 'document';
const FOLDER_ROW_PREFIX = 'folder'; const FOLDER_ROW_PREFIX = 'folder';
const THEME_STORAGE_KEY = 'papercrate_theme_settings';
const resolveApiPath = (path = '') => path; const resolveApiPath = (path = '') => path;
@@ -197,22 +198,66 @@ const AppLayout = () => {
const stored = window.localStorage.getItem('papercrate_view_mode'); const stored = window.localStorage.getItem('papercrate_view_mode');
return stored === 'grid' ? 'grid' : 'list'; return stored === 'grid' ? 'grid' : 'list';
}); });
const [neutralHue, setNeutralHue] = useState(() => {
const loadThemeSettings = () => {
const defaults = { neutralHue: 260, mode: 'system' };
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return 260; if (typeof document !== 'undefined') {
} const current = document.documentElement.style.getPropertyValue('--neutral-hue');
const stored = window.localStorage.getItem('papercrate_neutral_hue'); const parsed = Number.parseInt(current, 10);
if (stored) { return {
const parsedStored = Number.parseInt(stored, 10); neutralHue: Number.isNaN(parsed) ? defaults.neutralHue : parsed,
if (!Number.isNaN(parsedStored)) { mode: defaults.mode,
return parsedStored; };
} }
return defaults;
} }
const root = document.documentElement; const root = document.documentElement;
const loadFromRoot = () => {
const current = root.style.getPropertyValue('--neutral-hue'); const current = root.style.getPropertyValue('--neutral-hue');
const parsed = Number.parseInt(current, 10); const parsed = Number.parseInt(current, 10);
return Number.isNaN(parsed) ? 260 : parsed; 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;
}
}
return { neutralHue: neutralHueValue, mode: modeValue };
};
const initialThemeSettings = loadThemeSettings();
const [neutralHue, setNeutralHue] = useState(initialThemeSettings.neutralHue);
const [themeMode, setThemeMode] = useState(initialThemeSettings.mode);
const initialRowSelection = []; const initialRowSelection = [];
const [selectedRowKeys, setSelectedRowKeys] = useState(initialRowSelection); const [selectedRowKeys, setSelectedRowKeys] = useState(initialRowSelection);
const [selectionOrder, setSelectionOrder] = useState(initialRowSelection); const [selectionOrder, setSelectionOrder] = useState(initialRowSelection);
@@ -314,11 +359,33 @@ const AppLayout = () => {
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
document.documentElement.style.setProperty('--neutral-hue', `${neutralHue}deg`); document.documentElement.style.setProperty('--neutral-hue', `${neutralHue}deg`);
} }
if (typeof window !== 'undefined') {
window.localStorage.setItem('papercrate_neutral_hue', String(neutralHue));
}
}, [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) => { const handleNeutralHueChange = useCallback((value) => {
if (value === '') { if (value === '') {
setNeutralHue(260); setNeutralHue(260);
@@ -331,6 +398,13 @@ const AppLayout = () => {
setNeutralHue(parsed); setNeutralHue(parsed);
}, []); }, []);
const handleThemeModeChange = useCallback((mode) => {
if (mode !== 'light' && mode !== 'dark' && mode !== 'system') {
return;
}
setThemeMode(mode);
}, []);
const clearFilters = useCallback(() => { const clearFilters = useCallback(() => {
setSearchQuery(''); setSearchQuery('');
setActiveTagFilters([]); setActiveTagFilters([]);
@@ -4764,6 +4838,8 @@ const AppLayout = () => {
creatingFolder, creatingFolder,
onNeutralHueChange: handleNeutralHueChange, onNeutralHueChange: handleNeutralHueChange,
neutralHue, neutralHue,
themeMode,
onThemeModeChange: handleThemeModeChange,
tags, tags,
activeTagIds: activeTagFilters, activeTagIds: activeTagFilters,
onToggleTagFilter: toggleTagFilter, onToggleTagFilter: toggleTagFilter,
@@ -4824,6 +4900,8 @@ const AppLayout = () => {
creatingFolder, creatingFolder,
handleNeutralHueChange, handleNeutralHueChange,
neutralHue, neutralHue,
themeMode,
handleThemeModeChange,
], ],
); );
+81 -30
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState, useId } from 'react';
import { import {
ChevronIcon, ChevronIcon,
TrashIcon, TrashIcon,
@@ -12,6 +12,9 @@ import {
PlusIcon, PlusIcon,
FolderPlusIcon, FolderPlusIcon,
RestoreIcon, RestoreIcon,
SunIcon,
MoonIcon,
DesktopIcon,
} from '../ui/icons'; } from '../ui/icons';
import PanelHeader from '../ui/PanelHeader'; 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 = ({ const Sidebar = ({
folderNodes, folderNodes,
onToggle, onToggle,
@@ -146,6 +156,8 @@ const Sidebar = ({
creatingFolder = false, creatingFolder = false,
onNeutralHueChange, onNeutralHueChange,
neutralHue, neutralHue,
themeMode = 'system',
onThemeModeChange,
tags = [], tags = [],
activeTagIds = [], activeTagIds = [],
onToggleTagFilter, onToggleTagFilter,
@@ -170,6 +182,7 @@ const Sidebar = ({
onSelectTenant, onSelectTenant,
onOpenSettings, onOpenSettings,
}) => { }) => {
const neutralHueInputId = useId();
const sortedCorrespondents = useMemo(() => { const sortedCorrespondents = useMemo(() => {
if (!Array.isArray(correspondents)) { if (!Array.isArray(correspondents)) {
return []; return [];
@@ -230,6 +243,28 @@ const Sidebar = ({
onNeutralHueChange?.(''); onNeutralHueChange?.('');
}, [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'
? <MoonIcon size={16} />
: resolvedThemeMode === 'light'
? <SunIcon size={16} />
: <DesktopIcon size={16} />;
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( const handleSearchInputChange = useCallback(
(event) => { (event) => {
onSearchChange?.(event.target.value); onSearchChange?.(event.target.value);
@@ -350,6 +385,50 @@ const Sidebar = ({
); );
const rootNode = folderNodes.get('root'); const rootNode = folderNodes.get('root');
const themeControls =
typeof neutralHue === 'number' || typeof neutralHue === 'string'
? (
<div className="sidebar-section sidebar-theme">
<div className="sidebar-section__header">
<h3>Theme</h3>
<div className="sidebar-section__actions">
<button
type="button"
className="icon-button"
onClick={handleThemeModeToggle}
aria-label={`Switch theme (next: ${nextThemeLabel})`}
title={`Theme: ${themeModeLabel} (next: ${nextThemeLabel})`}
>
{themeModeIcon}
</button>
<button
type="button"
className="icon-button"
onClick={handleNeutralHueReset}
aria-label="Reset neutral hue"
title="Reset neutral hue"
>
<RestoreIcon size={16} />
</button>
</div>
</div>
<label className="sidebar-slider" htmlFor={neutralHueInputId}>
<span className="sidebar-slider__label">Hue</span>
<input
id={neutralHueInputId}
type="range"
min="0"
max="360"
step="1"
value={neutralHue}
onChange={(event) => onNeutralHueChange?.(event.target.value)}
/>
<span className="sidebar-slider__value">{neutralHue}°</span>
</label>
</div>
)
: null;
return ( return (
<aside className="sidebar"> <aside className="sidebar">
<PanelHeader <PanelHeader
@@ -606,36 +685,8 @@ const Sidebar = ({
})} })}
</ul> </ul>
</div> </div>
{typeof neutralHue === 'number' || typeof neutralHue === 'string' ? (
<div className="sidebar-section">
<div className="sidebar-section__header">
<h3>Theme</h3>
<div className="sidebar-section__actions">
<button
type="button"
className="icon-button"
onClick={handleNeutralHueReset}
aria-label="Reset neutral hue"
>
<RestoreIcon size={16} />
</button>
</div>
</div>
<label className="sidebar-slider">
<span className="sidebar-slider__label">Neutral hue</span>
<input
type="range"
min="0"
max="360"
step="1"
value={neutralHue}
onChange={(event) => onNeutralHueChange?.(event.target.value)}
/>
<span className="sidebar-slider__value">{neutralHue}°</span>
</label>
</div>
) : null}
</div> </div>
{themeControls ? <div className="sidebar__footer">{themeControls}</div> : null}
</aside> </aside>
); );
}; };
+98 -7
View File
@@ -96,8 +96,88 @@
--documents-grid-title-size: 0.8rem; --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) { @media (prefers-color-scheme: dark) {
:root { :root:not([data-theme='light']) {
color-scheme: dark; color-scheme: dark;
--dark-neutral-hue: calc(var(--neutral-hue) + var(--dark-hue-offset)); --dark-neutral-hue: calc(var(--neutral-hue) + var(--dark-hue-offset));
@@ -1201,7 +1281,7 @@ button.danger:hover:not([disabled]) {
.sidebar { .sidebar {
gap: 0; gap: 0;
overflow-y: auto; overflow: hidden;
color: var(--sidebar-fg); color: var(--sidebar-fg);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1216,6 +1296,18 @@ button.danger:hover:not([disabled]) {
flex-direction: column; flex-direction: column;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
gap: 0.75rem; 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 { .sidebar__title {
@@ -1433,13 +1525,12 @@ button.danger:hover:not([disabled]) {
background: var(--sidebar-hover-bg); background: var(--sidebar-hover-bg);
} }
.sidebar__footer { .sidebar__footer .sidebar-section {
margin-top: auto; margin-top: 0;
padding-top: 1rem;
} }
.sidebar__footer button { .sidebar__footer .sidebar-section__actions {
width: 100%; gap: 0.4rem;
} }
.sidebar-section:first-of-type, .sidebar-section:first-of-type,
+30
View File
@@ -25,6 +25,9 @@ import {
IconSettings, IconSettings,
IconCheck, IconCheck,
IconPlus, IconPlus,
IconSun,
IconMoon,
IconDeviceLaptop,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import FolderSvg from '../assets/folder.svg'; 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 }) => (
<IconSun
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const MoonIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconMoon
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const DesktopIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconDeviceLaptop
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const CheckIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( export const CheckIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconCheck <IconCheck
className={composeClassName('icon', className)} className={composeClassName('icon', className)}