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_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 {
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 <<PROXY >> /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 <<PROXY >> /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;
+93 -15
View File
@@ -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,
],
);
+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 {
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'
? <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(
(event) => {
onSearchChange?.(event.target.value);
@@ -350,6 +385,50 @@ const Sidebar = ({
);
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 (
<aside className="sidebar">
<PanelHeader
@@ -606,36 +685,8 @@ const Sidebar = ({
})}
</ul>
</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>
{themeControls ? <div className="sidebar__footer">{themeControls}</div> : null}
</aside>
);
};
+98 -7
View File
@@ -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,
+30
View File
@@ -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 }) => (
<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 }) => (
<IconCheck
className={composeClassName('icon', className)}