diff --git a/frontend/src/app/PanelManagerContext.tsx b/frontend/src/app/PanelManagerContext.tsx index 714a640..feca22d 100644 --- a/frontend/src/app/PanelManagerContext.tsx +++ b/frontend/src/app/PanelManagerContext.tsx @@ -10,13 +10,16 @@ import React, { } from 'react'; import type { CSSProperties, PointerEvent as ReactPointerEvent, RefObject } from 'react'; import { useSidebarContext } from '../sidebar/SidebarContext'; - -type PanelKey = 'sidebar' | 'detail'; - -interface PanelLimits { - maxRatio: number; - minPx: number; -} +import { + DEFAULT_DETAIL_WIDTH, + DEFAULT_SIDEBAR_WIDTH, + MINIMAL_FREE_RATIO, + MINIMUM_MAIN_CONTENT_WIDTH, + PANEL_LIMITS, + PANEL_STORAGE_KEYS, + SIDEBAR_SOLO_THRESHOLD, + type PanelKey, +} from '../constants/layout'; interface SetPanelWidthOptions { commit?: boolean; @@ -50,29 +53,6 @@ type PanelResizeBindings = { const PanelManagerContext = createContext(null); -const PANEL_LIMITS: Record = { - sidebar: { - maxRatio: 1 / 3, - minPx: 280, - }, - detail: { - maxRatio: 2 / 3, - minPx: 320, - }, -}; - -const STORAGE_KEYS: Record = { - sidebar: 'papercrate_sidebar_width', - detail: 'papercrate_detail_width', -}; - -const DEFAULT_SIDEBAR_WIDTH = 320; -const DEFAULT_DETAIL_WIDTH = 420; - -const MINIMAL_FREE_RATIO = 1 / 3; -const SIDEBAR_SOLO_THRESHOLD = 1 / 2; -const MINIMUM_MAIN_CONTENT_WIDTH = 160; - const clampPanelWidth = (panel: PanelKey, value: number): number => { const numeric = Number(value); const limits = PANEL_LIMITS[panel]; @@ -86,7 +66,7 @@ const clampPanelWidth = (panel: PanelKey, value: number): number => { }; const readStoredWidth = (panel: PanelKey, fallback: number): number => { - const raw = window.localStorage.getItem(STORAGE_KEYS[panel]); + const raw = window.localStorage.getItem(PANEL_STORAGE_KEYS[panel]); if (!raw) { return fallback; } @@ -95,7 +75,7 @@ const readStoredWidth = (panel: PanelKey, fallback: number): number => { }; const persistWidth = (panel: PanelKey, value: number): void => { - window.localStorage.setItem(STORAGE_KEYS[panel], String(Math.round(value))); + window.localStorage.setItem(PANEL_STORAGE_KEYS[panel], String(Math.round(value))); }; const applyPanelWidthToRoot = (panel: PanelKey, width: number, active: boolean): void => { diff --git a/frontend/src/app/appState.tsx b/frontend/src/app/appState.tsx index 0f3357d..2b2c485 100644 --- a/frontend/src/app/appState.tsx +++ b/frontend/src/app/appState.tsx @@ -2,6 +2,7 @@ import React, { useContext, useEffect, useMemo, useReducer } from 'react'; import { clearAuthToken, setAuthToken, setAuthRefreshHandlers } from '../lib/apiClient'; import { ApiProvider } from './ApiContext'; import { listTenants } from '../lib/apiClient'; +import { STORED_TOKEN_KEY } from '../constants/app'; type Tenant = Record | null; @@ -47,7 +48,7 @@ type AppAction = const storage = window.sessionStorage; -const STORED_TOKEN = storage?.getItem('papercrate_token') ?? ''; +const storedToken = storage?.getItem(STORED_TOKEN_KEY) ?? ''; let STORED_TENANT: Tenant = null; if (storage) { @@ -61,13 +62,13 @@ if (storage) { } } -if (STORED_TOKEN) { - setAuthToken(STORED_TOKEN); +if (storedToken) { + setAuthToken(storedToken); } const initialAppState: AppState = { - status: STORED_TOKEN ? 'authenticated' : 'logged-out', - token: STORED_TOKEN, + status: storedToken ? 'authenticated' : 'logged-out', + token: storedToken, error: null, isRefreshing: false, tenantSelection: null, diff --git a/frontend/src/app/entryKey.ts b/frontend/src/app/entryKey.ts index a95d0ad..74e304b 100644 --- a/frontend/src/app/entryKey.ts +++ b/frontend/src/app/entryKey.ts @@ -1,10 +1,9 @@ import type { DocumentId, FolderId } from '../types/identifiers'; +import { ENTRY_KEY_SEPARATOR } from '../constants/app'; // Entry key utilities for workspace selection // Entry keys are strings in the format "document:id" or "folder:id" -const ENTRY_KEY_SEPARATOR = ':'; - // Create entry key strings export const createDocumentEntryKey = (documentId: DocumentId): string => `document${ENTRY_KEY_SEPARATOR}${documentId}`; diff --git a/frontend/src/app/useDocumentsPreferences.ts b/frontend/src/app/useDocumentsPreferences.ts index 8371916..91ed717 100644 --- a/frontend/src/app/useDocumentsPreferences.ts +++ b/frontend/src/app/useDocumentsPreferences.ts @@ -4,11 +4,12 @@ import { DEFAULT_SORT_FIELD, SORT_FIELD_VALUES, } from './workspaceUtils'; - -const VIEW_MODE_STORAGE_KEY = 'papercrate_view_mode'; -const SORT_FIELD_STORAGE_KEY = 'papercrate_sort_field'; -const SORT_DIRECTION_STORAGE_KEY = 'papercrate_sort_direction'; -const INCLUDE_DESCENDANTS_STORAGE_KEY = 'papercrate_include_descendants'; +import { + INCLUDE_DESCENDANTS_STORAGE_KEY, + SORT_DIRECTION_STORAGE_KEY, + SORT_FIELD_STORAGE_KEY, + VIEW_MODE_STORAGE_KEY, +} from '../constants/workspace'; const readSessionStorage = (key: string): string | null => { try { diff --git a/frontend/src/app/useManagementModals.tsx b/frontend/src/app/useManagementModals.tsx index 8f44ca2..322763a 100644 --- a/frontend/src/app/useManagementModals.tsx +++ b/frontend/src/app/useManagementModals.tsx @@ -4,9 +4,7 @@ import TagsPanel from '../tags/TagsPanel'; import CorrespondentsPanel, { CorrespondentsPanelProps } from '../correspondents/CorrespondentsPanel'; import PanelHeader from '../ui/PanelHeader'; import { CloseIcon } from '../ui/icons'; - -const TAGS_MODAL = 'tags'; -const CORRESPONDENTS_MODAL = 'correspondents'; +import { CORRESPONDENTS_MODAL, TAGS_MODAL } from '../constants/app'; interface TagRecord { id?: string; diff --git a/frontend/src/app/workspaceUtils.ts b/frontend/src/app/workspaceUtils.ts index b30fedb..55b1adb 100644 --- a/frontend/src/app/workspaceUtils.ts +++ b/frontend/src/app/workspaceUtils.ts @@ -1,8 +1,18 @@ -export const DEFAULT_FOLDER_NAME = 'Documents'; -export const DEFAULT_SORT_FIELD = 'title'; -export const DEFAULT_SORT_DIRECTION = 'asc'; -export const SORT_FIELD_VALUES = ['title', 'issued_at', 'created_at', 'updated_at']; -export const TAG_FILTER_UNTAGGED = '__UNTAGGED__'; +import { + DEFAULT_FOLDER_NAME, + DEFAULT_SORT_DIRECTION, + DEFAULT_SORT_FIELD, + SORT_FIELD_VALUES, + TAG_FILTER_UNTAGGED, +} from '../constants/workspace'; + +export { + DEFAULT_FOLDER_NAME, + DEFAULT_SORT_DIRECTION, + DEFAULT_SORT_FIELD, + SORT_FIELD_VALUES, + TAG_FILTER_UNTAGGED, +}; export const hasFiles = (event) => Array.from(event.dataTransfer?.types || []).includes('Files'); diff --git a/frontend/src/constants/app.ts b/frontend/src/constants/app.ts new file mode 100644 index 0000000..0de6b27 --- /dev/null +++ b/frontend/src/constants/app.ts @@ -0,0 +1,4 @@ +export const ENTRY_KEY_SEPARATOR = ':'; +export const TAGS_MODAL = 'tags'; +export const CORRESPONDENTS_MODAL = 'correspondents'; +export const STORED_TOKEN_KEY = 'papercrate_token'; diff --git a/frontend/src/constants/colors.ts b/frontend/src/constants/colors.ts new file mode 100644 index 0000000..b769eb1 --- /dev/null +++ b/frontend/src/constants/colors.ts @@ -0,0 +1 @@ +export const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/; diff --git a/frontend/src/constants/desktop.ts b/frontend/src/constants/desktop.ts new file mode 100644 index 0000000..b24ec25 --- /dev/null +++ b/frontend/src/constants/desktop.ts @@ -0,0 +1,48 @@ +export const DB_NAME = 'papercrate_desk'; +export const DB_VERSION = 1; +export const LAYOUT_STORE = 'layouts'; + +export const CLICK_ACTIONS = { + selectSingle: 'selectSingle', + openDetail: 'openDetail', + addCard: 'addCard', + addStack: 'addStack', + none: 'none', +} as const; + +export const DRAG_ACTIONS = { + dragSelectSingle: 'dragSelectSingle', + dragSelection: 'dragSelection', + dragSelectStack: 'dragSelectStack', + none: 'none', +} as const; + +export const STACK_HIT_EPSILON = 4; +export const POINTER_DRAG_THRESHOLD_SQUARED = 16; +export const LONG_PRESS_DURATION_MS = 450; + +export const DRAG_HYSTERESIS_PX = 4; +export const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX; +export const EDGE_COLLISION_THRESHOLD = 0.5; + +export const TAG_REMOVE_DISTANCE = 160; + +export const DEBUG_DRAG = false; +export const DEBUG_FOCUS = false; + +export const DESK_CANVAS_PADDING = 24; +export const DESK_ROTATION_RANGE = 7; +export const DESK_DEFAULT_CANVAS_WIDTH = 1024; +export const DESK_DEFAULT_CANVAS_HEIGHT = 680; +export const DESK_CARD_MIN = 240; +export const DESK_CARD_MAX = 340; +export const CARD_PAGE_WEIGHT_GRAMS = 5; +export const CARD_BASE_WEIGHT_GRAMS = 30; +export const DEFAULT_Z_START = 10; +export const MIN_TIMESTEP = 1 / 120; +export const MAX_TIMESTEP = 1 / 20; +export const MAX_DYNAMIC_ROTATION = 7; +export const MAX_ANGULAR_VELOCITY = 210; +export const ANGULAR_DAMPING = 10; +export const TORQUE_TO_ACCELERATION = 0.007; +export const SETTLE_ANGULAR_VELOCITY = 0.45; diff --git a/frontend/src/constants/documents.ts b/frontend/src/constants/documents.ts new file mode 100644 index 0000000..c1872a5 --- /dev/null +++ b/frontend/src/constants/documents.ts @@ -0,0 +1,18 @@ +export const DEFAULT_THUMBNAIL_SIZE = 48; + +export const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'] as const; +export const TAG_TEXT_MIME_TYPE = 'text/plain'; + +export const DEFAULT_GRID_ICON_SIZE = 144; + +export const SORT_OPTIONS = [ + { value: 'title', label: 'Title' }, + { value: 'issued_at', label: 'Issued date' }, + { value: 'created_at', label: 'Added' }, + { value: 'updated_at', label: 'Updated date' }, +] as const; + +export const SORT_LABEL_LOOKUP = SORT_OPTIONS.reduce>((acc, option) => { + acc[option.value] = option.label; + return acc; +}, {}); diff --git a/frontend/src/constants/layout.ts b/frontend/src/constants/layout.ts new file mode 100644 index 0000000..74a6ace --- /dev/null +++ b/frontend/src/constants/layout.ts @@ -0,0 +1,23 @@ +export const DEFAULT_SIDEBAR_WIDTH = 320; +export const DEFAULT_DETAIL_WIDTH = 420; +export const MINIMAL_FREE_RATIO = 1 / 3; +export const SIDEBAR_SOLO_THRESHOLD = 1 / 2; +export const MINIMUM_MAIN_CONTENT_WIDTH = 160; + +export type PanelKey = 'sidebar' | 'detail'; + +export const PANEL_LIMITS: Record = { + sidebar: { + maxRatio: 1 / 3, + minPx: 280, + }, + detail: { + maxRatio: 2 / 3, + minPx: 320, + }, +}; + +export const PANEL_STORAGE_KEYS: Record = { + sidebar: 'papercrate_sidebar_width', + detail: 'papercrate_detail_width', +}; diff --git a/frontend/src/constants/preview.ts b/frontend/src/constants/preview.ts new file mode 100644 index 0000000..d568611 --- /dev/null +++ b/frontend/src/constants/preview.ts @@ -0,0 +1,32 @@ +export const RERENDER_DELTA = 48; +export const MAX_PIXEL_RATIO = 2; +export const FAST_SCROLL_VELOCITY_THRESHOLD = 0.8; +export const FAST_SCROLL_DWELL_THRESHOLD_MS = 120; +export const SCROLL_VELOCITY_MIN_DELTA = 0.05; + +export const AUDIO_EXTENSIONS = new Set([ + 'aac', + 'aiff', + 'flac', + 'm4a', + 'mp3', + 'ogg', + 'oga', + 'opus', + 'wav', + 'weba', +]); + +export const VIDEO_EXTENSIONS = new Set([ + 'avi', + 'mkv', + 'mov', + 'mp4', + 'm4v', + 'webm', + 'wmv', +]); + +export const DOCUMENT_VIEWER_PORTRAIT_HEIGHT_RATIO = 0.4; +export const PORTRAIT_RATIO_STYLE_ID = 'document-viewer-portrait-ratio-style'; +export const MIN_STACKED_BREAKPOINT = 480; diff --git a/frontend/src/constants/settings.ts b/frontend/src/constants/settings.ts new file mode 100644 index 0000000..c483948 --- /dev/null +++ b/frontend/src/constants/settings.ts @@ -0,0 +1,28 @@ +import type { SettingsSectionConfig } from '../settings/SettingsModal'; +import ApiTokensSection from '../settings/sections/ApiTokensSection'; +import CapabilitySetsSection from '../settings/sections/CapabilitySetsSection'; +import PasskeysSection from '../settings/sections/PasskeysSection'; + +export const PASSKEYS_SECTION: SettingsSectionConfig = { + id: 'passkeys', + label: 'Passkeys', + component: PasskeysSection, +}; + +export const API_TOKENS_SECTION: SettingsSectionConfig = { + id: 'apiTokens', + label: 'API tokens', + component: ApiTokensSection, +}; + +export const CAPABILITY_SETS_SECTION: SettingsSectionConfig = { + id: 'capabilitySets', + label: 'Capability sets', + component: CapabilitySetsSection, +}; + +export const DEFAULT_SETTINGS_SECTIONS = [ + PASSKEYS_SECTION, + API_TOKENS_SECTION, + CAPABILITY_SETS_SECTION, +]; diff --git a/frontend/src/constants/sidebar.ts b/frontend/src/constants/sidebar.ts new file mode 100644 index 0000000..ed24baf --- /dev/null +++ b/frontend/src/constants/sidebar.ts @@ -0,0 +1,13 @@ +export const SIDEBAR_COLLAPSE_STORAGE_KEY = 'papercrate_sidebar_collapsed'; +export const THEME_STORAGE_KEY = 'papercrate_theme_settings'; +export const DEFAULT_NEUTRAL_HUE = 29; +export const DEFAULT_NEUTRAL_CHROMA = 0.44; +export const DEFAULT_NEUTRAL_CONTRAST = 0.56; +export const DEFAULT_THEME_MODE = 'system'; +export const THEME_MODES = ['system', 'light', 'dark']; +export const THEME_MODE_LABELS = { + system: 'System default', + light: 'Light', + dark: 'Dark', +}; +export const DARK_MODE_MEDIA_QUERY = '(prefers-color-scheme: dark)'; diff --git a/frontend/src/constants/ui.ts b/frontend/src/constants/ui.ts new file mode 100644 index 0000000..60c7d12 --- /dev/null +++ b/frontend/src/constants/ui.ts @@ -0,0 +1,7 @@ +export const NBSP = String.fromCharCode(160); +export const DEFAULT_VIEWPORT_MARGIN = 8; + +export const ELLIPSIS = { id: '__breadcrumbs_ellipsis__', label: '…', onClick: null, raw: null } as const; +export const WIDTH_TOLERANCE = 1; +export const WIDTH_BUFFER_RATIO = 0.95; +export const WIDTH_CHANGE_TOLERANCE = 0.25; diff --git a/frontend/src/constants/workspace.ts b/frontend/src/constants/workspace.ts new file mode 100644 index 0000000..33bec91 --- /dev/null +++ b/frontend/src/constants/workspace.ts @@ -0,0 +1,12 @@ +export const DEFAULT_FOLDER_NAME = 'Documents'; +export const DEFAULT_SORT_FIELD = 'title'; +export const DEFAULT_SORT_DIRECTION = 'asc'; +export const SORT_FIELD_VALUES = ['title', 'issued_at', 'created_at', 'updated_at']; +export const TAG_FILTER_UNTAGGED = '__UNTAGGED__'; + +export const VIEW_MODE_STORAGE_KEY = 'papercrate_view_mode'; +export const SORT_FIELD_STORAGE_KEY = 'papercrate_sort_field'; +export const SORT_DIRECTION_STORAGE_KEY = 'papercrate_sort_direction'; +export const INCLUDE_DESCENDANTS_STORAGE_KEY = 'papercrate_include_descendants'; + +export const ROOT_FOLDER_LABEL = DEFAULT_FOLDER_NAME; diff --git a/frontend/src/desktop/DesktopWorkspace.tsx b/frontend/src/desktop/DesktopWorkspace.tsx index 7a95b93..9d9ad6f 100644 --- a/frontend/src/desktop/DesktopWorkspace.tsx +++ b/frontend/src/desktop/DesktopWorkspace.tsx @@ -25,6 +25,7 @@ import useDeskPointer from './pointer/useDeskPointer'; import useDeskTagInteractions from './tags/useDeskTagInteractions'; import DesktopDocumentCard from './DesktopDocumentCard'; import usePreviewMetadata from './hooks/usePreviewMetadata'; +import { DEBUG_DRAG, DEBUG_FOCUS } from '../constants/desktop'; import '../styles/workspace/workspace-layout.css'; import '../styles/workspace/workspace-items.css'; import '../styles/workspace/workspace-cards.css'; @@ -186,8 +187,6 @@ interface DesktopWorkspaceViewProps { markLayoutDirty: () => void; } -const DEBUG_DRAG = false; -const DEBUG_FOCUS = false; const defaultGetDocumentAsset: GetAsset = () => null; const DesktopWorkspace: React.FC = ({ diff --git a/frontend/src/desktop/db.ts b/frontend/src/desktop/db.ts index d4c3116..2396025 100644 --- a/frontend/src/desktop/db.ts +++ b/frontend/src/desktop/db.ts @@ -1,10 +1,7 @@ import type { DocumentId } from '../types/identifiers'; +import { DB_NAME, DB_VERSION, LAYOUT_STORE } from '../constants/desktop'; type TenantId = import('../types/identifiers').TenantId; -const DB_NAME = 'papercrate_desk'; -const DB_VERSION = 1; -const LAYOUT_STORE = 'layouts'; - const currentDbPromise: { value: Promise | null } = { value: null }; const openDatabase = (): Promise => { diff --git a/frontend/src/desktop/pointer/pointerUtils.ts b/frontend/src/desktop/pointer/pointerUtils.ts index 0d1e99f..31a7a2c 100644 --- a/frontend/src/desktop/pointer/pointerUtils.ts +++ b/frontend/src/desktop/pointer/pointerUtils.ts @@ -1,28 +1,18 @@ import { safeInvoke } from '../events'; import type { DocumentId } from '../../types/identifiers'; +import { + CLICK_ACTIONS, + DRAG_ACTIONS, + LONG_PRESS_DURATION_MS, + POINTER_DRAG_THRESHOLD_SQUARED, + STACK_HIT_EPSILON, +} from '../../constants/desktop'; -export const CLICK_ACTIONS = { - selectSingle: 'selectSingle', - openDetail: 'openDetail', - addCard: 'addCard', - addStack: 'addStack', - none: 'none', -} as const; - -export const DRAG_ACTIONS = { - dragSelectSingle: 'dragSelectSingle', - dragSelection: 'dragSelection', - dragSelectStack: 'dragSelectStack', - none: 'none', -} as const; +export { CLICK_ACTIONS, DRAG_ACTIONS, STACK_HIT_EPSILON, POINTER_DRAG_THRESHOLD_SQUARED, LONG_PRESS_DURATION_MS }; export type ClickAction = (typeof CLICK_ACTIONS)[keyof typeof CLICK_ACTIONS]; export type DragAction = (typeof DRAG_ACTIONS)[keyof typeof DRAG_ACTIONS]; -export const STACK_HIT_EPSILON = 4; -export const POINTER_DRAG_THRESHOLD_SQUARED = 16; -export const LONG_PRESS_DURATION_MS = 450; - export const withinThreshold = (dx: number, dy: number, thresholdSquared: number): boolean => (dx * dx + dy * dy) <= thresholdSquared; interface PointerIntentArgs { diff --git a/frontend/src/desktop/tags/useDeskTagInteractions.js b/frontend/src/desktop/tags/useDeskTagInteractions.js index cc064b8..830e08d 100644 --- a/frontend/src/desktop/tags/useDeskTagInteractions.js +++ b/frontend/src/desktop/tags/useDeskTagInteractions.js @@ -9,8 +9,7 @@ import { parseTagTransferPayload, writeTagTransferData, } from '../../documents/tagTransfer'; - -const TAG_REMOVE_DISTANCE = 160; +import { TAG_REMOVE_DISTANCE } from '../../constants/desktop'; const createDragPreview = (node, clientX, clientY) => { if (!(node instanceof HTMLElement)) { diff --git a/frontend/src/desktop/useDocumentDrag.ts b/frontend/src/desktop/useDocumentDrag.ts index cae7c07..1134741 100644 --- a/frontend/src/desktop/useDocumentDrag.ts +++ b/frontend/src/desktop/useDocumentDrag.ts @@ -19,6 +19,7 @@ import { applyDomTransform, type WorkspaceEngine, } from './workspaceEngine'; +import { DRAG_HYSTERESIS_SQUARED, EDGE_COLLISION_THRESHOLD } from '../constants/desktop'; import type { DocumentId, Identifier } from '../types/identifiers'; interface DocumentLike { @@ -168,10 +169,6 @@ interface DragTapMetadata { docTitle: string; } -const DRAG_HYSTERESIS_PX = 4; -const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX; -const EDGE_COLLISION_THRESHOLD = 0.5; - const getDocumentPageCount = (doc?: DocumentLike | null): number | null => { const raw = doc?.current_version?.metadata?.page_count ?? doc?.metadata?.page_count; if (raw == null) { diff --git a/frontend/src/desktop/workspaceEngine.ts b/frontend/src/desktop/workspaceEngine.ts index e3495d0..6341ea4 100644 --- a/frontend/src/desktop/workspaceEngine.ts +++ b/frontend/src/desktop/workspaceEngine.ts @@ -1,8 +1,44 @@ import { clamp, formatTransform } from '../utils/math'; import { fetchLayoutRecords, upsertLayoutRecords } from './db'; +import { + ANGULAR_DAMPING, + CARD_BASE_WEIGHT_GRAMS, + CARD_PAGE_WEIGHT_GRAMS, + DEFAULT_Z_START, + DESK_CANVAS_PADDING, + DESK_CARD_MAX, + DESK_CARD_MIN, + DESK_DEFAULT_CANVAS_HEIGHT, + DESK_DEFAULT_CANVAS_WIDTH, + DESK_ROTATION_RANGE, + MAX_ANGULAR_VELOCITY, + MAX_DYNAMIC_ROTATION, + MAX_TIMESTEP, + MIN_TIMESTEP, + SETTLE_ANGULAR_VELOCITY, + TORQUE_TO_ACCELERATION, +} from '../constants/desktop'; import type { DocumentId } from '../types/identifiers'; type TenantId = import('../types/identifiers').TenantId; +export { + ANGULAR_DAMPING, + CARD_BASE_WEIGHT_GRAMS, + CARD_PAGE_WEIGHT_GRAMS, + DESK_CANVAS_PADDING, + DESK_CARD_MAX, + DESK_CARD_MIN, + DESK_DEFAULT_CANVAS_HEIGHT, + DESK_DEFAULT_CANVAS_WIDTH, + DESK_ROTATION_RANGE, + MAX_ANGULAR_VELOCITY, + MAX_DYNAMIC_ROTATION, + MAX_TIMESTEP, + MIN_TIMESTEP, + SETTLE_ANGULAR_VELOCITY, + TORQUE_TO_ACCELERATION, +}; + interface Point { x: number; y: number; @@ -128,24 +164,6 @@ type UseSyncExternalStoreHook = ( getServerSnapshot: () => State, ) => State; -export const DESK_CANVAS_PADDING = 24; -export const DESK_ROTATION_RANGE = 7; -export const DESK_DEFAULT_CANVAS_WIDTH = 1024; -export const DESK_DEFAULT_CANVAS_HEIGHT = 680; -export const DESK_CARD_MIN = 240; -export const DESK_CARD_MAX = 340; -export const CARD_PAGE_WEIGHT_GRAMS = 5; -export const CARD_BASE_WEIGHT_GRAMS = 30; - -const DEFAULT_Z_START = 10; -export const MIN_TIMESTEP = 1 / 120; -export const MAX_TIMESTEP = 1 / 20; -export const MAX_DYNAMIC_ROTATION = 7; -export const MAX_ANGULAR_VELOCITY = 210; -export const ANGULAR_DAMPING = 10; -export const TORQUE_TO_ACCELERATION = 0.007; -export const SETTLE_ANGULAR_VELOCITY = 0.45; - const getMassScale = (massGrams?: number): number => { if (!Number.isFinite(massGrams) || Number(massGrams) <= 0) { return 1; diff --git a/frontend/src/documents/CorrespondentLinks.tsx b/frontend/src/documents/CorrespondentLinks.tsx index ff717de..e4612a1 100644 --- a/frontend/src/documents/CorrespondentLinks.tsx +++ b/frontend/src/documents/CorrespondentLinks.tsx @@ -1,6 +1,5 @@ import React from 'react'; - -const NBSP = String.fromCharCode(160); +import { NBSP } from '../constants/ui'; export interface CorrespondentLinkEntry { id?: string | null; diff --git a/frontend/src/documents/DocumentThumbnailImage.tsx b/frontend/src/documents/DocumentThumbnailImage.tsx index 8b9a1ae..2542505 100644 --- a/frontend/src/documents/DocumentThumbnailImage.tsx +++ b/frontend/src/documents/DocumentThumbnailImage.tsx @@ -5,6 +5,7 @@ import { resolveDocumentAssetUrl, resolveAssetUrl, } from '../asset_manager'; +import { DEFAULT_THUMBNAIL_SIZE } from '../constants/documents'; import type { DocumentLike as AssetManagerDocumentLike, AssetLike as AssetManagerAssetLike, @@ -12,8 +13,6 @@ import type { GetAsset as AssetManagerGetAsset, } from '../asset_manager'; -const DEFAULT_THUMBNAIL_SIZE = 48; - // Detect when an element becomes visible within a scroll container so we can delay loading. const useLazyVisibility = ( rootRef: MutableRefObject | null, diff --git a/frontend/src/documents/SelectionFloatingActions.tsx b/frontend/src/documents/SelectionFloatingActions.tsx index ded7c66..759cdc8 100644 --- a/frontend/src/documents/SelectionFloatingActions.tsx +++ b/frontend/src/documents/SelectionFloatingActions.tsx @@ -12,10 +12,9 @@ import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './Selectio import SelectionSummary from './SelectionSummary'; import { useAppState } from '../app/appState'; import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext'; +import { DEFAULT_FOLDER_NAME } from '../constants/workspace'; import type { DocumentId } from '../types/identifiers'; -const ROOT_FOLDER_LABEL = 'Documents'; - type NullableDocumentId = DocumentId | null; type SelectedIdList = NullableDocumentId[] | null; @@ -128,11 +127,11 @@ const buildFolderTreeOptions = (tree?: FolderTreeNode[] | null): SelectionAssign }); }; - traverse(Array.isArray(tree) ? tree : [], [ROOT_FOLDER_LABEL]); + traverse(Array.isArray(tree) ? tree : [], [DEFAULT_FOLDER_NAME]); entries.sort((a, b) => (a.label || '').localeCompare(b.label || '', undefined, { sensitivity: 'base' })); - return [{ id: 'root', label: ROOT_FOLDER_LABEL, state: 'none', payload: { id: 'root' } }, ...entries]; + return [{ id: 'root', label: DEFAULT_FOLDER_NAME, state: 'none', payload: { id: 'root' } }, ...entries]; }; const buildTagAssignments = ( diff --git a/frontend/src/documents/panel/DocumentsPanel.tsx b/frontend/src/documents/panel/DocumentsPanel.tsx index 2079f7d..6ef3b24 100644 --- a/frontend/src/documents/panel/DocumentsPanel.tsx +++ b/frontend/src/documents/panel/DocumentsPanel.tsx @@ -18,10 +18,9 @@ import DocumentsPanelHeader, { import { SelectionFloatingPanel } from '../SelectionFloatingActions'; import { createDocumentsTableHeaderActions } from './DocumentsToolbar'; import { useDocumentsFilter } from '../context/DocumentsFilterContext'; +import { DEFAULT_GRID_ICON_SIZE } from '../../constants/documents'; import type { Identifier } from '../../types/identifiers'; -const DEFAULT_GRID_ICON_SIZE = 144; - const EntryType = { folder: 'folder', document: 'document', diff --git a/frontend/src/documents/panel/SortFieldQuickMenu.tsx b/frontend/src/documents/panel/SortFieldQuickMenu.tsx index f21db84..d603e78 100644 --- a/frontend/src/documents/panel/SortFieldQuickMenu.tsx +++ b/frontend/src/documents/panel/SortFieldQuickMenu.tsx @@ -1,19 +1,7 @@ import React, { useCallback, useMemo } from 'react'; +import { SORT_LABEL_LOOKUP, SORT_OPTIONS } from '../../constants/documents'; import QuickAddMenu from '../../ui/QuickAddMenu'; -const SORT_OPTIONS = [ - { value: 'title', label: 'Title' }, - { value: 'issued_at', label: 'Issued date' }, - { value: 'created_at', label: 'Added' }, - { value: 'updated_at', label: 'Updated date' }, -]; - -const SORT_LABEL_LOOKUP = SORT_OPTIONS.reduce((acc, option) => { - const next = acc; - next[option.value] = option.label; - return next; -}, {}); - interface SortFieldQuickMenuProps { sortField: string; onChange?: (value: string) => void; diff --git a/frontend/src/documents/tagTransfer.ts b/frontend/src/documents/tagTransfer.ts index 5c74201..b679466 100644 --- a/frontend/src/documents/tagTransfer.ts +++ b/frontend/src/documents/tagTransfer.ts @@ -1,7 +1,5 @@ import type { DocumentId, TagId } from '../types/identifiers'; - -const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag']; -const TAG_TEXT_MIME_TYPE = 'text/plain'; +import { TAG_MIME_TYPES, TAG_TEXT_MIME_TYPE } from '../constants/documents'; interface TagPayload { id: TagId; diff --git a/frontend/src/preview/DocumentViewerLayout.tsx b/frontend/src/preview/DocumentViewerLayout.tsx index 73760bd..efe8b41 100644 --- a/frontend/src/preview/DocumentViewerLayout.tsx +++ b/frontend/src/preview/DocumentViewerLayout.tsx @@ -3,6 +3,7 @@ import type { JSX } from 'react'; import DocumentInfoPanel from '../documents/DocumentInfoPanel'; import { DownloadIcon } from '../ui/icons'; import PdfViewer from './PdfViewer'; +import { AUDIO_EXTENSIONS, VIDEO_EXTENSIONS } from '../constants/preview'; interface DocumentLike { id?: string; @@ -48,29 +49,6 @@ interface DocumentViewerLayoutProps { layoutMode?: LayoutMode; } -const AUDIO_EXTENSIONS = new Set([ - 'aac', - 'aiff', - 'flac', - 'm4a', - 'mp3', - 'ogg', - 'oga', - 'opus', - 'wav', - 'weba', -]); - -const VIDEO_EXTENSIONS = new Set([ - 'avi', - 'mkv', - 'mov', - 'mp4', - 'm4v', - 'webm', - 'wmv', -]); - const getFileExtension = (filename?: string | null) => { if (!filename) { return ''; diff --git a/frontend/src/preview/PdfViewer.tsx b/frontend/src/preview/PdfViewer.tsx index df39cc2..14ab38f 100644 --- a/frontend/src/preview/PdfViewer.tsx +++ b/frontend/src/preview/PdfViewer.tsx @@ -17,6 +17,13 @@ import type { PDFDocumentProxy, RenderTask, } from 'pdfjs-dist/types/src/display/api'; +import { + FAST_SCROLL_DWELL_THRESHOLD_MS, + FAST_SCROLL_VELOCITY_THRESHOLD, + MAX_PIXEL_RATIO, + RERENDER_DELTA, + SCROLL_VELOCITY_MIN_DELTA, +} from '../constants/preview'; GlobalWorkerOptions.workerSrc = new URL( 'pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url, @@ -45,12 +52,6 @@ interface RenderQueueRequest { cancel: () => void; } -const RERENDER_DELTA = 48; -const MAX_PIXEL_RATIO = 2; -const FAST_SCROLL_VELOCITY_THRESHOLD = 0.8; // px/ms ~ 800px/s -const FAST_SCROLL_DWELL_THRESHOLD_MS = 120; -const SCROLL_VELOCITY_MIN_DELTA = 0.05; - const getAvailableViewportSize = (viewportNode: Element, stackNode: Element) => { const viewportStyle = window.getComputedStyle(viewportNode); const viewportPaddingX = parseFloat(viewportStyle.paddingLeft || '0') diff --git a/frontend/src/preview/useViewerLayoutMode.ts b/frontend/src/preview/useViewerLayoutMode.ts index 0d8dbf5..c16ee60 100644 --- a/frontend/src/preview/useViewerLayoutMode.ts +++ b/frontend/src/preview/useViewerLayoutMode.ts @@ -1,7 +1,11 @@ import { MutableRefObject, useLayoutEffect, useState } from 'react'; +import { + DOCUMENT_VIEWER_PORTRAIT_HEIGHT_RATIO, + MIN_STACKED_BREAKPOINT, + PORTRAIT_RATIO_STYLE_ID, +} from '../constants/preview'; -export const DOCUMENT_VIEWER_PORTRAIT_HEIGHT_RATIO = 0.4; -const PORTRAIT_RATIO_STYLE_ID = 'document-viewer-portrait-ratio-style'; +export { DOCUMENT_VIEWER_PORTRAIT_HEIGHT_RATIO }; const ensurePortraitRatioStyle = () => { const cssValue = String(DOCUMENT_VIEWER_PORTRAIT_HEIGHT_RATIO); @@ -19,8 +23,6 @@ const ensurePortraitRatioStyle = () => { } }; -const MIN_STACKED_BREAKPOINT = 480; - const computeStackedLayoutBreakpoint = () => Math.max(window.innerWidth / 2, MIN_STACKED_BREAKPOINT); export const useViewerLayoutMode = ( diff --git a/frontend/src/settings/sections/ApiTokensSection.tsx b/frontend/src/settings/sections/ApiTokensSection.tsx index 0803b81..abb292c 100644 --- a/frontend/src/settings/sections/ApiTokensSection.tsx +++ b/frontend/src/settings/sections/ApiTokensSection.tsx @@ -489,10 +489,4 @@ const ApiTokensSection = ({ ); }; -export const API_TOKENS_SECTION = { - id: 'apiTokens', - label: 'API tokens', - component: ApiTokensSection, -}; - export default ApiTokensSection; diff --git a/frontend/src/settings/sections/CapabilitySetsSection.tsx b/frontend/src/settings/sections/CapabilitySetsSection.tsx index 3806f14..f88174c 100644 --- a/frontend/src/settings/sections/CapabilitySetsSection.tsx +++ b/frontend/src/settings/sections/CapabilitySetsSection.tsx @@ -5,7 +5,6 @@ import React, { useMemo, useState, } from 'react'; -import type { SettingsSectionConfig } from '../SettingsModal'; import { IconX } from '../../ui/icons'; import CapabilityDropdown, { CapabilityDropdownOption } from '../components/CapabilityDropdown'; import type { CapabilitySetId, CapabilityValue } from '../../types/identifiers'; @@ -641,10 +640,4 @@ const CapabilitySetsSection: React.FC = ({ ); }; -export const CAPABILITY_SETS_SECTION: SettingsSectionConfig = { - id: 'capabilitySets', - label: 'Capability sets', - component: CapabilitySetsSection, -}; - export default CapabilitySetsSection; diff --git a/frontend/src/settings/sections/PasskeysSection.tsx b/frontend/src/settings/sections/PasskeysSection.tsx index a3dee9f..ad1e4f4 100644 --- a/frontend/src/settings/sections/PasskeysSection.tsx +++ b/frontend/src/settings/sections/PasskeysSection.tsx @@ -172,10 +172,4 @@ const PasskeysSection = ({ ); }; -export const PASSKEYS_SECTION = { - id: 'passkeys', - label: 'Passkeys', - component: PasskeysSection, -}; - export default PasskeysSection; diff --git a/frontend/src/settings/sections/index.ts b/frontend/src/settings/sections/index.ts index f0bad0d..6fe29ce 100644 --- a/frontend/src/settings/sections/index.ts +++ b/frontend/src/settings/sections/index.ts @@ -1,15 +1,12 @@ -import PasskeysSection, { PASSKEYS_SECTION } from './PasskeysSection'; -import ApiTokensSection, { API_TOKENS_SECTION } from './ApiTokensSection'; -import CapabilitySetsSection, { CAPABILITY_SETS_SECTION } from './CapabilitySetsSection'; - -export const DEFAULT_SETTINGS_SECTIONS = [ - PASSKEYS_SECTION, +import PasskeysSection from './PasskeysSection'; +import ApiTokensSection from './ApiTokensSection'; +import CapabilitySetsSection from './CapabilitySetsSection'; +import { API_TOKENS_SECTION, CAPABILITY_SETS_SECTION, -]; + DEFAULT_SETTINGS_SECTIONS, + PASSKEYS_SECTION, +} from '../../constants/settings'; -export { - PasskeysSection, - ApiTokensSection, - CapabilitySetsSection, -}; +export { PasskeysSection, ApiTokensSection, CapabilitySetsSection }; +export { PASSKEYS_SECTION, API_TOKENS_SECTION, CAPABILITY_SETS_SECTION, DEFAULT_SETTINGS_SECTIONS }; diff --git a/frontend/src/sidebar/Sidebar.tsx b/frontend/src/sidebar/Sidebar.tsx index 3cc0a91..4bacf50 100644 --- a/frontend/src/sidebar/Sidebar.tsx +++ b/frontend/src/sidebar/Sidebar.tsx @@ -32,6 +32,7 @@ import { useSidebarContext } from './SidebarContext'; import { usePanelManager, usePanelResizeBindings } from '../app/PanelManagerContext'; import type { StatusMessage } from '../hooks/documents/store/useDocumentsStore'; import type { Identifier } from '../types/identifiers'; +import { THEME_MODE_LABELS, THEME_MODES } from '../constants/sidebar'; interface CommunityLink { label: string; @@ -260,13 +261,6 @@ const FolderNode: React.FC = ({ ); }; -const THEME_MODES = ['system', 'light', 'dark']; -const THEME_MODE_LABELS = { - system: 'System', - light: 'Light', - dark: 'Dark', -}; - interface FloatingMenuControls { isOpen: boolean; toggle: () => void; diff --git a/frontend/src/sidebar/SidebarContext.tsx b/frontend/src/sidebar/SidebarContext.tsx index 402ba68..ff6caa5 100644 --- a/frontend/src/sidebar/SidebarContext.tsx +++ b/frontend/src/sidebar/SidebarContext.tsx @@ -6,6 +6,16 @@ import React, { useCallback, useEffect, } from 'react'; +import { + DARK_MODE_MEDIA_QUERY, + DEFAULT_NEUTRAL_CHROMA, + DEFAULT_NEUTRAL_CONTRAST, + DEFAULT_NEUTRAL_HUE, + DEFAULT_THEME_MODE, + SIDEBAR_COLLAPSE_STORAGE_KEY, + THEME_MODES, + THEME_STORAGE_KEY, +} from '../constants/sidebar'; interface SidebarContextValue { collapsed: boolean; @@ -25,20 +35,10 @@ interface SidebarContextValue { themeModes: ThemeMode[]; } -type ThemeMode = 'system' | 'light' | 'dark'; - -const SIDEBAR_COLLAPSE_STORAGE_KEY = 'papercrate_sidebar_collapsed'; +type ThemeMode = (typeof THEME_MODES)[number]; const SidebarContext = createContext(null); -const THEME_STORAGE_KEY = 'papercrate_theme_settings'; -const DEFAULT_NEUTRAL_HUE = 29; -const DEFAULT_NEUTRAL_CHROMA = 0.44; -const DEFAULT_NEUTRAL_CONTRAST = 0.56; -const DEFAULT_THEME_MODE = 'system'; -const THEME_MODES = ['system', 'light', 'dark']; -const DARK_MODE_MEDIA_QUERY = '(prefers-color-scheme: dark)'; - const loadInitialThemeSettings = () => { const defaults = { neutralHue: DEFAULT_NEUTRAL_HUE, diff --git a/frontend/src/ui/BreadcrumbTrail.tsx b/frontend/src/ui/BreadcrumbTrail.tsx index 3c99321..0c83064 100644 --- a/frontend/src/ui/BreadcrumbTrail.tsx +++ b/frontend/src/ui/BreadcrumbTrail.tsx @@ -8,6 +8,12 @@ import React, { import { createPortal } from 'react-dom'; import PropTypes from 'prop-types'; import useFloatingMenu from './useFloatingMenu'; +import { + ELLIPSIS, + WIDTH_BUFFER_RATIO, + WIDTH_CHANGE_TOLERANCE, + WIDTH_TOLERANCE, +} from '../constants/ui'; const normalizeEntries = (entries) => (Array.isArray(entries) ? entries : []) @@ -22,11 +28,6 @@ const normalizeEntries = (entries) => }) .filter(Boolean); -const ELLIPSIS = { id: '__breadcrumbs_ellipsis__', label: '…', onClick: null, raw: null }; -const WIDTH_TOLERANCE = 1; -const WIDTH_BUFFER_RATIO = 0.95; -const WIDTH_CHANGE_TOLERANCE = 0.25; - const BreadcrumbTrail = ({ entries = [], className = '', diff --git a/frontend/src/ui/useFloatingMenu.ts b/frontend/src/ui/useFloatingMenu.ts index 33e5af7..67c90ab 100644 --- a/frontend/src/ui/useFloatingMenu.ts +++ b/frontend/src/ui/useFloatingMenu.ts @@ -1,8 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { MutableRefObject, CSSProperties } from 'react'; import { clamp } from '../utils/math'; - -const DEFAULT_VIEWPORT_MARGIN = 8; +import { DEFAULT_VIEWPORT_MARGIN } from '../constants/ui'; type PositionStrategy = 'fixed' | 'absolute' | (string & {}); diff --git a/frontend/src/utils/colors.ts b/frontend/src/utils/colors.ts index 15043af..de6eb18 100644 --- a/frontend/src/utils/colors.ts +++ b/frontend/src/utils/colors.ts @@ -1,4 +1,4 @@ -const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/; +import { HEX_COLOR_PATTERN } from '../constants/colors'; const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));