chore: tweak comments

This commit is contained in:
Johnny 2025-11-30 13:16:02 +08:00
parent 07072b75a7
commit a6a8997f4c
79 changed files with 144 additions and 1648 deletions

View File

@ -3,30 +3,16 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
export interface ConfirmDialogProps {
/** Whether the dialog is open */
open: boolean;
/** Open state change callback (closing disabled while loading) */
onOpenChange: (open: boolean) => void;
/** Title content (plain text or React nodes) */
title: React.ReactNode;
/** Optional description (plain text or React nodes) */
description?: React.ReactNode;
/** Confirm / primary action button label */
confirmLabel: string;
/** Cancel button label */
cancelLabel: string;
/** Async or sync confirm handler. Dialog auto-closes on resolve, stays open on reject */
onConfirm: () => void | Promise<void>;
/** Variant style of confirm button */
confirmVariant?: "default" | "destructive";
}
/**
* Accessible confirmation dialog.
* - Renders optional description content
* - Prevents closing while async confirm action is in-flight
* - Minimal opinionated styling; leverages existing UI primitives
*/
export default function ConfirmDialog({
open,
onOpenChange,

View File

@ -1,15 +1,6 @@
import { MasonryItem } from "./MasonryItem";
import { MasonryColumnProps } from "./types";
/**
* Column component for masonry layout
*
* Responsibilities:
* - Render a single column in the masonry grid
* - Display prefix element in the first column (e.g., memo editor)
* - Render all assigned memo items in order
* - Pass render context to items (includes compact mode flag)
*/
export function MasonryColumn({
memoIndices,
memoList,

View File

@ -1,19 +1,6 @@
import { useEffect, useRef } from "react";
import { MasonryItemProps } from "./types";
/**
* Individual item wrapper component for masonry layout
*
* Responsibilities:
* - Render the memo using the provided renderer with context
* - Measure its own height using ResizeObserver
* - Report height changes to parent for redistribution
*
* The ResizeObserver automatically tracks dynamic content changes such as:
* - Images loading
* - Expanded/collapsed text
* - Any other content size changes
*/
export function MasonryItem({ memo, renderer, renderContext, onHeightChange }: MasonryItemProps) {
const itemRef = useRef<HTMLDivElement>(null);
const resizeObserverRef = useRef<ResizeObserver | null>(null);

View File

@ -4,24 +4,6 @@ import { MasonryColumn } from "./MasonryColumn";
import { MasonryViewProps, MemoRenderContext } from "./types";
import { useMasonryLayout } from "./useMasonryLayout";
/**
* Masonry layout component for displaying memos in a balanced, multi-column grid
*
* Features:
* - Responsive column count based on viewport width
* - Longest Processing-Time First (LPT) algorithm for optimal distribution
* - Pins editor and first memo to first column for stability
* - Debounced redistribution for performance
* - Automatic height tracking with ResizeObserver
* - Auto-enables compact mode in multi-column layouts
*
* The layout automatically adjusts to:
* - Window resizing
* - Content changes (images loading, text expansion)
* - Dynamic memo additions/removals
*
* Algorithm guarantee: Layout is never more than 34% longer than optimal (proven)
*/
const MasonryView = ({ memoList, renderer, prefixElement, listMode = false }: MasonryViewProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const prefixElementRef = useRef<HTMLDivElement>(null);

View File

@ -1,11 +1,3 @@
/**
* Minimum width required to show more than one column in masonry layout
* When viewport is narrower, layout falls back to single column
*/
export const MINIMUM_MEMO_VIEWPORT_WIDTH = 512;
/**
* Debounce delay for redistribution in milliseconds
* Balances responsiveness with performance by batching rapid height changes
*/
export const REDISTRIBUTION_DEBOUNCE_MS = 100;

View File

@ -1,27 +1,12 @@
import { Memo } from "@/types/proto/api/v1/memo_service";
import { DistributionResult } from "./types";
/**
* Distributes memos into columns using a height-aware greedy approach.
*
* Algorithm steps:
* 1. Pin editor and first memo to the first column (keep feed stable)
* 2. Place remaining memos into the currently shortest column
* 3. Break height ties by preferring the column with fewer items
*
* @param memos - Array of memos to distribute
* @param columns - Number of columns to distribute across
* @param itemHeights - Map of memo names to their measured heights
* @param prefixElementHeight - Height of prefix element (e.g., editor) in first column
* @returns Distribution result with memo indices per column and column heights
*/
export function distributeItemsToColumns(
memos: Memo[],
columns: number,
itemHeights: Map<string, number>,
prefixElementHeight: number = 0,
): DistributionResult {
// Single column mode: all memos in one column
if (columns === 1) {
const totalHeight = memos.reduce((sum, memo) => sum + (itemHeights.get(memo.name) || 0), prefixElementHeight);
return {
@ -30,19 +15,16 @@ export function distributeItemsToColumns(
};
}
// Initialize columns and their heights
const distribution: number[][] = Array.from({ length: columns }, () => []);
const columnHeights: number[] = Array(columns).fill(0);
const columnCounts: number[] = Array(columns).fill(0);
// Add prefix element height to first column
if (prefixElementHeight > 0) {
columnHeights[0] = prefixElementHeight;
}
let startIndex = 0;
// Pin the first memo to the first column to keep top-of-feed stable
if (memos.length > 0) {
const firstMemoHeight = itemHeights.get(memos[0].name) || 0;
distribution[0].push(0);
@ -55,7 +37,6 @@ export function distributeItemsToColumns(
const memo = memos[i];
const height = itemHeights.get(memo.name) || 0;
// Find column with minimum height
const shortestColumnIndex = findShortestColumnIndex(columnHeights, columnCounts);
distribution[shortestColumnIndex].push(i);
@ -66,12 +47,6 @@ export function distributeItemsToColumns(
return { distribution, columnHeights };
}
/**
* Finds the index of the column with the minimum height
* @param columnHeights - Array of column heights
* @param columnCounts - Array of items per column (for tie-breaking)
* @returns Index of the shortest column
*/
function findShortestColumnIndex(columnHeights: number[], columnCounts: number[]): number {
let minIndex = 0;
let minHeight = columnHeights[0];
@ -84,7 +59,6 @@ function findShortestColumnIndex(columnHeights: number[], columnCounts: number[]
continue;
}
// Tie-breaker: prefer column with fewer items to avoid stacking
if (currentHeight === minHeight && columnCounts[i] < columnCounts[minIndex]) {
minIndex = i;
}

View File

@ -1,81 +1,41 @@
import { Memo } from "@/types/proto/api/v1/memo_service";
/**
* Render context passed to memo renderer
*/
export interface MemoRenderContext {
/** Whether to render in compact mode (automatically enabled for multi-column layouts) */
compact: boolean;
/** Current number of columns in the layout */
columns: number;
}
/**
* Props for the main MasonryView component
*/
export interface MasonryViewProps {
/** List of memos to display in masonry layout */
memoList: Memo[];
/** Render function for each memo. Second parameter provides layout context. */
renderer: (memo: Memo, context?: MemoRenderContext) => JSX.Element;
/** Optional element to display at the top of the first column (e.g., memo editor) */
prefixElement?: JSX.Element;
/** Force single column layout regardless of viewport width */
listMode?: boolean;
}
/**
* Props for individual MasonryItem component
*/
export interface MasonryItemProps {
/** The memo to render */
memo: Memo;
/** Render function for the memo */
renderer: (memo: Memo, context?: MemoRenderContext) => JSX.Element;
/** Render context for the memo */
renderContext: MemoRenderContext;
/** Callback when item height changes */
onHeightChange: (memoName: string, height: number) => void;
}
/**
* Props for MasonryColumn component
*/
export interface MasonryColumnProps {
/** Indices of memos in this column */
memoIndices: number[];
/** Full list of memos */
memoList: Memo[];
/** Render function for each memo */
renderer: (memo: Memo, context?: MemoRenderContext) => JSX.Element;
/** Render context for memos */
renderContext: MemoRenderContext;
/** Callback when item height changes */
onHeightChange: (memoName: string, height: number) => void;
/** Whether this is the first column (for prefix element) */
isFirstColumn: boolean;
/** Optional prefix element (only rendered in first column) */
prefixElement?: JSX.Element;
/** Ref for prefix element height measurement */
prefixElementRef?: React.RefObject<HTMLDivElement>;
}
/**
* Result of the distribution algorithm
*/
export interface DistributionResult {
/** Array of arrays, where each inner array contains memo indices for that column */
distribution: number[][];
/** Height of each column after distribution */
columnHeights: number[];
}
/**
* Memo item with measured height
*/
export interface MemoWithHeight {
/** Index of the memo in the original list */
index: number;
/** Measured height in pixels */
height: number;
}

View File

@ -3,21 +3,6 @@ import { Memo } from "@/types/proto/api/v1/memo_service";
import { MINIMUM_MEMO_VIEWPORT_WIDTH, REDISTRIBUTION_DEBOUNCE_MS } from "./constants";
import { distributeItemsToColumns } from "./distributeItems";
/**
* Custom hook for managing masonry layout state and logic
*
* Responsibilities:
* - Calculate optimal number of columns based on viewport width
* - Track item heights and trigger redistribution
* - Debounce redistribution to prevent excessive reflows
* - Handle window resize events
*
* @param memoList - Array of memos to layout
* @param listMode - Force single column mode
* @param containerRef - Reference to the container element
* @param prefixElementRef - Reference to the prefix element
* @returns Layout state and handlers
*/
export function useMasonryLayout(
memoList: Memo[],
listMode: boolean,
@ -31,28 +16,18 @@ export function useMasonryLayout(
const redistributionTimeoutRef = useRef<number | null>(null);
const itemHeightsRef = useRef<Map<string, number>>(itemHeights);
// Keep ref in sync with state
useEffect(() => {
itemHeightsRef.current = itemHeights;
}, [itemHeights]);
/**
* Calculate optimal number of columns based on container width
* Uses a scale factor to determine column count
*/
const calculateColumns = useCallback(() => {
if (!containerRef.current || listMode) return 1;
const containerWidth = containerRef.current.offsetWidth;
const scale = containerWidth / MINIMUM_MEMO_VIEWPORT_WIDTH;
// Use ceiling to maximize columns: 688px (1.34x) → 2 cols, 1280px (2.5x) → 3 cols
// Only use single column if scale is very small (< 1.2)
return scale >= 1.2 ? Math.ceil(scale) : 1;
}, [containerRef, listMode]);
/**
* Recalculate memo distribution when layout changes
*/
const redistributeMemos = useCallback(() => {
const prefixHeight = prefixElementRef.current?.offsetHeight || 0;
setDistribution(() => {
@ -61,17 +36,12 @@ export function useMasonryLayout(
});
}, [memoList, columns, prefixElementRef]);
/**
* Debounced redistribution to batch multiple height changes and prevent excessive reflows
*/
const debouncedRedistribute = useCallback(
(newItemHeights: Map<string, number>) => {
// Clear any pending redistribution
if (redistributionTimeoutRef.current) {
clearTimeout(redistributionTimeoutRef.current);
}
// Schedule new redistribution after debounce delay
redistributionTimeoutRef.current = window.setTimeout(() => {
const prefixHeight = prefixElementRef.current?.offsetHeight || 0;
setDistribution(() => {
@ -83,34 +53,24 @@ export function useMasonryLayout(
[memoList, columns, prefixElementRef],
);
/**
* Handle height changes from individual memo items
*/
const handleHeightChange = useCallback(
(memoName: string, height: number) => {
setItemHeights((prevHeights) => {
const newItemHeights = new Map(prevHeights);
const previousHeight = prevHeights.get(memoName);
// Skip if height hasn't changed (avoid unnecessary updates)
if (previousHeight === height) {
return prevHeights;
}
newItemHeights.set(memoName, height);
// Use debounced redistribution to batch updates
debouncedRedistribute(newItemHeights);
return newItemHeights;
});
},
[debouncedRedistribute],
);
/**
* Handle window resize and calculate new column count
*/
useEffect(() => {
const handleResize = () => {
if (!containerRef.current) return;
@ -126,16 +86,10 @@ export function useMasonryLayout(
return () => window.removeEventListener("resize", handleResize);
}, [calculateColumns, columns, containerRef]);
/**
* Redistribute memos when columns or memo list change
*/
useEffect(() => {
redistributeMemos();
}, [columns, memoList, redistributeMemos]);
/**
* Cleanup timeout on unmount
*/
useEffect(() => {
return () => {
if (redistributionTimeoutRef.current) {

View File

@ -30,15 +30,6 @@ import { hasCompletedTasks } from "@/utils/markdown-manipulation";
import { useMemoActionHandlers } from "./hooks";
import type { MemoActionMenuProps } from "./types";
/**
* MemoActionMenu component provides a dropdown menu with actions for a memo:
* - Pin/Unpin
* - Edit
* - Copy (link/content)
* - Remove completed tasks
* - Archive/Restore
* - Delete
*/
const MemoActionMenu = observer((props: MemoActionMenuProps) => {
const { memo, readonly } = props;
const t = useTranslate();

View File

@ -16,9 +16,6 @@ interface UseMemoActionHandlersOptions {
setRemoveTasksDialogOpen: (open: boolean) => void;
}
/**
* Hook for handling memo action menu operations
*/
export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRemoveTasksDialogOpen }: UseMemoActionHandlersOptions) => {
const t = useTranslate();
const location = useLocation();

View File

@ -1,22 +1,12 @@
import type { Memo } from "@/types/proto/api/v1/memo_service";
/**
* Props for MemoActionMenu component
*/
export interface MemoActionMenuProps {
/** The memo to display actions for */
memo: Memo;
/** Whether the current user can only view (not edit) */
readonly?: boolean;
/** Additional CSS classes */
className?: string;
/** Callback when edit action is triggered */
onEdit?: () => void;
}
/**
* Return type for useMemoActionHandlers hook
*/
export interface UseMemoActionHandlersReturn {
handleTogglePinMemoBtnClick: () => Promise<void>;
handleEditMemoClick: () => void;

View File

@ -1,16 +1,5 @@
import React from "react";
/**
* Creates a conditional component wrapper that checks AST node properties
* before deciding which component to render.
*
* This is more efficient than having every component check its own props,
* and allows us to use specific HTML element types as defaults.
*
* @param CustomComponent - Component to render when condition is met
* @param DefaultComponent - Component/element to render otherwise
* @param condition - Function to check if node matches custom component criteria
*/
export const createConditionalComponent = <P extends Record<string, any>>(
CustomComponent: React.ComponentType<P>,
DefaultComponent: React.ComponentType<P> | keyof JSX.IntrinsicElements,
@ -32,13 +21,7 @@ export const createConditionalComponent = <P extends Record<string, any>>(
};
};
/**
* Condition checkers for AST node types
*
* These check the original MDAST node type preserved during transformation:
* - First checks node.data.mdastType (preserved by remarkPreserveType plugin)
* - Falls back to checking HAST properties/className for compatibility
*/
// Condition checkers for AST node types
export const isTagNode = (node: any): boolean => {
// Check preserved mdast type first
if (node?.data?.mdastType === "tagNode") {

View File

@ -1,26 +1,10 @@
import { createContext } from "react";
/**
* Context for MemoContent rendering
*
* Provides memo metadata and configuration to child components
* Used by custom react-markdown components (TaskListItem, Tag, etc.)
*/
export interface MemoContentContextType {
/** The memo resource name (e.g., "memos/123") */
memoName?: string;
/** Whether content is readonly (non-editable) */
readonly: boolean;
/** Whether to disable tag/link filtering */
disableFilter?: boolean;
/** Parent page path (for navigation) */
parentPage?: string;
/** Reference to the container element for the memo content */
containerRef?: React.RefObject<HTMLDivElement>;
}

View File

@ -10,9 +10,6 @@ interface MermaidBlockProps {
className?: string;
}
/**
* Maps app theme to Mermaid theme
*/
const getMermaidTheme = (appTheme: string): "default" | "dark" => {
return appTheme === "default-dark" ? "dark" : "default";
};

View File

@ -7,16 +7,6 @@ import { memoFilterStore } from "@/store";
import { MemoFilter, stringifyFilters } from "@/store/memoFilter";
import { MemoContentContext } from "./MemoContentContext";
/**
* Custom span component for #tag elements
*
* Handles tag clicks for filtering memos.
* The remark-tag plugin creates span elements with class="tag".
*
* Note: This component should only be used for tags.
* Regular spans are handled by the default span element.
*/
interface TagProps extends React.HTMLAttributes<HTMLSpanElement> {
node?: any; // AST node from react-markdown
"data-tag"?: string;

View File

@ -4,16 +4,6 @@ import { memoStore } from "@/store";
import { toggleTaskAtIndex } from "@/utils/markdown-manipulation";
import { MemoContentContext } from "./MemoContentContext";
/**
* Custom checkbox component for react-markdown task lists
*
* Handles interactive task checkbox clicks and updates memo content.
* This component is used via react-markdown's components prop.
*
* Note: This component should only be used for task list checkboxes.
* Regular inputs are handled by the default input element.
*/
interface TaskListItemProps extends React.InputHTMLAttributes<HTMLInputElement> {
node?: any; // AST node from react-markdown
checked?: boolean;

View File

@ -11,15 +11,6 @@ interface CommandSuggestionsProps {
commands: Command[];
}
/**
* Command suggestions popup that appears when typing "/" in the editor.
* Shows available editor commands like formatting options, insertions, etc.
*
* Usage:
* - Type "/" to trigger
* - Continue typing to filter commands
* - Use Arrow keys to navigate, Enter/Tab to select
*/
const CommandSuggestions = observer(({ editorRef, editorActions, commands }: CommandSuggestionsProps) => {
const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({
editorRef,

View File

@ -11,16 +11,6 @@ interface SuggestionsPopupProps<T> {
getItemKey: (item: T, index: number) => string;
}
/**
* Shared popup component for displaying suggestion items.
* Provides consistent styling and behavior across different suggestion types.
*
* Features:
* - Automatically scrolls selected item into view
* - Handles keyboard navigation highlighting
* - Prevents text selection during mouse interaction
* - Consistent styling with max height constraints
*/
export function SuggestionsPopup<T>({
position,
suggestions,

View File

@ -11,16 +11,6 @@ interface TagSuggestionsProps {
editorActions: React.ForwardedRef<EditorRefActions>;
}
/**
* Tag suggestions popup that appears when typing "#" in the editor.
* Shows previously used tags sorted by frequency.
*
* Usage:
* - Type "#" to trigger
* - Continue typing to filter tags
* - Use Arrow keys to navigate, Enter/Tab to select
* - Tags are sorted by usage count (most used first)
*/
const TagSuggestions = observer(({ editorRef, editorActions }: TagSuggestionsProps) => {
// Sort tags by usage count (descending), then alphabetically for ties
const sortedTags = useMemo(

View File

@ -1,6 +1,3 @@
/**
* Command type for slash commands in the editor
*/
export interface Command {
name: string;
run: () => string;

View File

@ -28,13 +28,9 @@ interface Props {
placeholder: string;
onContentChange: (content: string) => void;
onPaste: (event: React.ClipboardEvent) => void;
/** Whether Focus Mode is active - adjusts height constraints for immersive writing */
isFocusMode?: boolean;
/** Whether IME composition is in progress (for Asian language input) */
isInIME?: boolean;
/** Called when IME composition starts */
onCompositionStart?: () => void;
/** Called when IME composition ends */
onCompositionEnd?: () => void;
}

View File

@ -1,18 +1,14 @@
import type { EditorRefActions } from "./index";
/**
* Handles keyboard shortcuts for markdown formatting
* Requires Cmd/Ctrl key to be pressed
*/
export function handleMarkdownShortcuts(event: React.KeyboardEvent, editor: EditorRefActions): void {
switch (event.key.toLowerCase()) {
case "b":
event.preventDefault();
toggleTextStyle(editor, "**"); // Bold
toggleTextStyle(editor, "**");
break;
case "i":
event.preventDefault();
toggleTextStyle(editor, "*"); // Italic
toggleTextStyle(editor, "*");
break;
case "k":
event.preventDefault();
@ -21,21 +17,14 @@ export function handleMarkdownShortcuts(event: React.KeyboardEvent, editor: Edit
}
}
/**
* Inserts a hyperlink for the selected text
* If selected text is a URL, creates a link with empty text
* Otherwise, creates a link with placeholder URL
*/
export function insertHyperlink(editor: EditorRefActions, url?: string): void {
const cursorPosition = editor.getCursorPosition();
const selectedContent = editor.getSelectedContent();
const placeholderUrl = "url";
const urlRegex = /^https?:\/\/[^\s]+$/;
// If selected content looks like a URL and no URL provided, use it as the href
if (!url && urlRegex.test(selectedContent.trim())) {
editor.insertText(`[](${selectedContent})`);
// Move cursor between brackets for text input
editor.setCursorPosition(cursorPosition + 1, cursorPosition + 1);
return;
}
@ -43,44 +32,32 @@ export function insertHyperlink(editor: EditorRefActions, url?: string): void {
const href = url ?? placeholderUrl;
editor.insertText(`[${selectedContent}](${href})`);
// If using placeholder URL, select it for easy replacement
if (href === placeholderUrl) {
const urlStart = cursorPosition + selectedContent.length + 3; // After "]("
const urlStart = cursorPosition + selectedContent.length + 3;
editor.setCursorPosition(urlStart, urlStart + href.length);
}
}
/**
* Toggles text styling (bold, italic, etc.)
* If already styled, removes the style; otherwise adds it
*/
function toggleTextStyle(editor: EditorRefActions, delimiter: string): void {
const cursorPosition = editor.getCursorPosition();
const selectedContent = editor.getSelectedContent();
// Check if already styled - remove style
if (selectedContent.startsWith(delimiter) && selectedContent.endsWith(delimiter)) {
const unstyled = selectedContent.slice(delimiter.length, -delimiter.length);
editor.insertText(unstyled);
editor.setCursorPosition(cursorPosition, cursorPosition + unstyled.length);
} else {
// Add style
editor.insertText(`${delimiter}${selectedContent}${delimiter}`);
editor.setCursorPosition(cursorPosition + delimiter.length, cursorPosition + delimiter.length + selectedContent.length);
}
}
/**
* Hyperlinks the currently highlighted/selected text with the given URL
* Used when pasting a URL while text is selected
*/
export function hyperlinkHighlightedText(editor: EditorRefActions, url: string): void {
const selectedContent = editor.getSelectedContent();
const cursorPosition = editor.getCursorPosition();
editor.insertText(`[${selectedContent}](${url})`);
// Position cursor after the link
const newPosition = cursorPosition + selectedContent.length + url.length + 4; // []()
const newPosition = cursorPosition + selectedContent.length + url.length + 4;
editor.setCursorPosition(newPosition, newPosition);
}

View File

@ -8,19 +8,6 @@ interface UseListAutoCompletionOptions {
isInIME: boolean;
}
/**
* Custom hook for handling markdown list auto-completion.
* When the user presses Enter on a list item, this hook automatically
* continues the list with the appropriate formatting.
*
* Supports:
* - Ordered lists (1. item, 2. item, etc.)
* - Unordered lists (- item, * item, + item)
* - Task lists (- [ ] task, - [x] task)
* - Nested lists with proper indentation
*
* This hook manages its own event listeners and cleanup.
*/
export function useListAutoCompletion({ editorRef, editorActions, isInIME }: UseListAutoCompletionOptions) {
// Use refs to avoid stale closures in event handlers
const isInIMERef = useRef(isInIME);

View File

@ -9,58 +9,22 @@ export interface Position {
}
export interface UseSuggestionsOptions<T> {
/** Reference to the textarea element */
editorRef: React.RefObject<HTMLTextAreaElement>;
/** Reference to editor actions for text manipulation */
editorActions: React.ForwardedRef<EditorRefActions>;
/** Character that triggers the suggestions (e.g., '/', '#', '@') */
triggerChar: string;
/** Array of items to show in suggestions */
items: T[];
/** Function to filter items based on search query */
filterItems: (items: T[], searchQuery: string) => T[];
/** Callback when an item is selected for autocomplete */
onAutocomplete: (item: T, word: string, startIndex: number, actions: EditorRefActions) => void;
}
export interface UseSuggestionsReturn<T> {
/** Current position of the popup, or null if hidden */
position: Position | null;
/** Filtered suggestions based on current search */
suggestions: T[];
/** Index of the currently selected suggestion */
selectedIndex: number;
/** Whether the suggestions popup is visible */
isVisible: boolean;
/** Handler to select a suggestion item */
handleItemSelect: (item: T) => void;
}
/**
* Shared hook for managing suggestion popups in the editor.
* Handles positioning, keyboard navigation, filtering, and autocomplete logic.
*
* Features:
* - Auto-positioning based on caret location
* - Keyboard navigation (Arrow Up/Down, Enter, Tab, Escape)
* - Smart filtering based on trigger character
* - Proper event cleanup
*
* @example
* ```tsx
* const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({
* editorRef,
* editorActions,
* triggerChar: '#',
* items: tags,
* filterItems: (items, query) => items.filter(tag => tag.includes(query)),
* onAutocomplete: (tag, word, index, actions) => {
* actions.removeText(index, word.length);
* actions.insertText(`#${tag}`);
* },
* });
* ```
*/
export function useSuggestions<T>({
editorRef,
editorActions,

View File

@ -84,15 +84,11 @@ const InsertMenu = observer((props: Props) => {
};
const handleLocationCancel = () => {
abortGeocoding(); // Cancel any pending geocoding request
abortGeocoding();
location.reset();
setLocationDialogOpen(false);
};
/**
* Fetches human-readable address from coordinates using reverse geocoding
* Falls back to coordinate string if geocoding fails
*/
const fetchReverseGeocode = async (position: LatLng, signal: AbortSignal): Promise<string> => {
const coordString = `${position.lat.toFixed(6)}, ${position.lng.toFixed(6)}`;
try {

View File

@ -11,11 +11,6 @@ interface State {
error: Error | null;
}
/**
* Error Boundary for MemoEditor
* Catches JavaScript errors anywhere in the editor component tree,
* logs the error, and displays a fallback UI instead of crashing the entire app.
*/
class MemoEditorErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);

View File

@ -7,10 +7,6 @@ interface FocusModeOverlayProps {
onToggle: () => void;
}
/**
* Focus mode overlay with backdrop and exit button
* Renders the semi-transparent backdrop when focus mode is active
*/
export function FocusModeOverlay({ isActive, onToggle }: FocusModeOverlayProps) {
if (!isActive) return null;
@ -31,10 +27,6 @@ interface FocusModeExitButtonProps {
title: string;
}
/**
* Exit button for focus mode
* Displayed in the top-right corner when focus mode is active
*/
export function FocusModeExitButton({ isActive, onToggle, title }: FocusModeExitButtonProps) {
if (!isActive) return null;

View File

@ -3,9 +3,6 @@ import { Input } from "@/components/ui/input";
import { Memo } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
/**
* Highlights search text within content string
*/
function highlightSearchText(content: string, searchText: string): React.ReactNode {
if (!searchText) return content;

View File

@ -1,47 +1,18 @@
/**
* MemoEditor Constants
* Centralized configuration for the memo editor component
*/
/**
* Debounce delay for localStorage writes (in milliseconds)
* Prevents excessive writes on every keystroke
*/
export const LOCALSTORAGE_DEBOUNCE_DELAY = 500;
/**
* Focus Mode styling constants
* Centralized to make it easy to adjust appearance
*/
export const FOCUS_MODE_STYLES = {
backdrop: "fixed inset-0 bg-black/20 backdrop-blur-sm z-40",
container: {
base: "fixed z-50 w-auto max-w-5xl mx-auto shadow-2xl border-border h-auto overflow-y-auto",
/**
* Responsive spacing using explicit positioning:
* - Mobile (< 640px): 8px margin
* - Tablet (640-768px): 16px margin
* - Desktop (> 768px): 32px margin
*/
spacing: "top-2 left-2 right-2 bottom-2 sm:top-4 sm:left-4 sm:right-4 sm:bottom-4 md:top-8 md:left-8 md:right-8 md:bottom-8",
},
transition: "transition-all duration-300 ease-in-out",
exitButton: "absolute top-2 right-2 z-10 opacity-60 hover:opacity-100",
} as const;
/**
* Focus Mode keyboard shortcuts
* - Toggle: Cmd/Ctrl + Shift + F (matches GitHub, Google Docs convention)
* - Exit: Escape key
*/
export const FOCUS_MODE_TOGGLE_KEY = "f";
export const FOCUS_MODE_EXIT_KEY = "Escape";
/**
* Editor height constraints
* - Normal mode: Limited to 50% viewport height to avoid excessive scrolling
* - Focus mode: Minimum 50vh on mobile, 60vh on desktop for immersive writing
*/
export const EDITOR_HEIGHT = {
normal: "max-h-[50vh]",
focusMode: {
@ -50,9 +21,6 @@ export const EDITOR_HEIGHT = {
},
} as const;
/**
* Geocoding API configuration
*/
export const GEOCODING = {
endpoint: "https://nominatim.openstreetmap.org/reverse",
userAgent: "Memos/1.0 (https://github.com/usememos/memos)",

View File

@ -1,8 +1,5 @@
import { useEffect, useRef } from "react";
/**
* Hook for managing AbortController lifecycle
*/
export function useAbortController() {
const controllerRef = useRef<AbortController | null>(null);

View File

@ -1,8 +1,5 @@
import { useEffect, useRef } from "react";
/**
* Hook for managing blob URLs lifecycle with automatic cleanup
*/
export function useBlobUrls() {
const urlsRef = useRef<Set<string>>(new Set());

View File

@ -1,8 +1,5 @@
import { useState } from "react";
/**
* Hook for handling drag-and-drop file uploads
*/
export function useDragAndDrop(onDrop: (files: FileList) => void) {
const [isDragging, setIsDragging] = useState(false);

View File

@ -1,8 +1,5 @@
import { useEffect } from "react";
/**
* Hook to lock body scroll when focus mode is active
*/
export function useFocusMode(isFocusMode: boolean): void {
useEffect(() => {
document.body.style.overflow = isFocusMode ? "hidden" : "";

View File

@ -2,33 +2,10 @@ import { useState } from "react";
import type { LocalFile } from "@/components/memo-metadata";
import { useBlobUrls } from "./useBlobUrls";
/**
* Custom hook for managing local file uploads with preview
* Handles file state, blob URL creation, and cleanup
*
* @returns Object with file state and management functions
*
* @example
* ```tsx
* const { localFiles, addFiles, removeFile, clearFiles } = useLocalFileManager();
*
* // Add files from input or drag-drop
* addFiles(fileList);
*
* // Remove specific file
* removeFile(previewUrl);
*
* // Clear all (e.g., after successful upload)
* clearFiles();
* ```
*/
export function useLocalFileManager() {
const [localFiles, setLocalFiles] = useState<LocalFile[]>([]);
const { createBlobUrl, revokeBlobUrl } = useBlobUrls();
/**
* Adds files to local state with blob URL previews
*/
const addFiles = (files: FileList | File[]): void => {
const fileArray = Array.from(files);
const newLocalFiles: LocalFile[] = fileArray.map((file) => ({
@ -38,9 +15,6 @@ export function useLocalFileManager() {
setLocalFiles((prev) => [...prev, ...newLocalFiles]);
};
/**
* Removes a specific file by preview URL
*/
const removeFile = (previewUrl: string): void => {
setLocalFiles((prev) => {
const toRemove = prev.find((f) => f.previewUrl === previewUrl);
@ -51,9 +25,6 @@ export function useLocalFileManager() {
});
};
/**
* Clears all files and revokes their blob URLs
*/
const clearFiles = (): void => {
localFiles.forEach(({ previewUrl }) => revokeBlobUrl(previewUrl));
setLocalFiles([]);

View File

@ -17,10 +17,6 @@ export interface UseMemoEditorHandlersReturn {
handleEditorFocus: () => void;
}
/**
* Hook for managing MemoEditor event handlers
* Centralizes composition, paste, and focus handling
*/
export const useMemoEditorHandlers = (options: UseMemoEditorHandlersOptions): UseMemoEditorHandlersReturn => {
const { editorRef, onFilesAdded, setComposing } = options;

View File

@ -27,10 +27,6 @@ export interface UseMemoEditorInitReturn {
setUpdateTime: (time: Date | undefined) => void;
}
/**
* Hook for initializing MemoEditor state
* Handles loading existing memo data and setting initial visibility
*/
export const useMemoEditorInit = (options: UseMemoEditorInitOptions): UseMemoEditorInitReturn => {
const {
editorRef,

View File

@ -12,10 +12,6 @@ export interface UseMemoEditorKeyboardOptions {
onToggleFocusMode: () => void;
}
/**
* Hook for handling keyboard shortcuts in MemoEditor
* Centralizes all keyboard event handling logic
*/
export const useMemoEditorKeyboard = (options: UseMemoEditorKeyboardOptions) => {
const { editorRef, isFocusMode, isComposing, onSave, onToggleFocusMode } = options;

View File

@ -15,9 +15,6 @@ interface MemoEditorState {
isDraggingFile: boolean;
}
/**
* Hook for managing MemoEditor state
*/
export const useMemoEditorState = (initialVisibility: Visibility = Visibility.PRIVATE) => {
const [state, setState] = useState<MemoEditorState>({
memoVisibility: initialVisibility,

View File

@ -9,44 +9,26 @@ import type { Location, Memo, MemoRelation, Visibility } from "@/types/proto/api
import type { Translations } from "@/utils/i18n";
interface MemoSaveContext {
/** Current memo name (for update mode) */
memoName?: string;
/** Parent memo name (for comment mode) */
parentMemoName?: string;
/** Current visibility setting */
visibility: Visibility;
/** Current attachments */
attachmentList: Attachment[];
/** Current relations */
relationList: MemoRelation[];
/** Current location */
location?: Location;
/** Local files pending upload */
localFiles: LocalFile[];
/** Create time override */
createTime?: Date;
/** Update time override */
updateTime?: Date;
}
interface MemoSaveCallbacks {
/** Called when upload state changes */
onUploadingChange: (uploading: boolean) => void;
/** Called when request state changes */
onRequestingChange: (requesting: boolean) => void;
/** Called on successful save */
onSuccess: (memoName: string) => void;
/** Called on cancellation (no changes) */
onCancel: () => void;
/** Called to reset after save */
onReset: () => void;
/** Translation function */
t: (key: Translations, params?: Record<string, any>) => string;
}
/**
* Uploads local files and creates attachments
*/
async function uploadLocalFiles(localFiles: LocalFile[], onUploadingChange: (uploading: boolean) => void): Promise<Attachment[]> {
if (localFiles.length === 0) return [];
@ -72,9 +54,6 @@ async function uploadLocalFiles(localFiles: LocalFile[], onUploadingChange: (upl
}
}
/**
* Builds an update mask by comparing memo properties
*/
function buildUpdateMask(
prevMemo: Memo,
content: string,
@ -126,10 +105,6 @@ function buildUpdateMask(
return { mask, patch };
}
/**
* Hook for saving/updating memos
* Extracts complex save logic from MemoEditor
*/
export function useMemoSave(callbacks: MemoSaveCallbacks) {
const { onUploadingChange, onRequestingChange, onSuccess, onCancel, onReset, t } = callbacks;

View File

@ -3,26 +3,14 @@ import type { Attachment } from "@/types/proto/api/v1/attachment_service";
import type { MemoRelation } from "@/types/proto/api/v1/memo_service";
import type { LocalFile } from "../../memo-metadata";
/**
* Context interface for MemoEditor
* Provides access to editor state and actions for child components
*/
export interface MemoEditorContextValue {
/** List of uploaded attachments */
attachmentList: Attachment[];
/** List of memo relations/links */
relationList: MemoRelation[];
/** Update the attachment list */
setAttachmentList: (attachmentList: Attachment[]) => void;
/** Update the relation list */
setRelationList: (relationList: MemoRelation[]) => void;
/** Name of memo being edited (undefined for new memos) */
memoName?: string;
/** Add local files for upload preview */
addLocalFiles?: (files: LocalFile[]) => void;
/** Remove a local file by preview URL */
removeLocalFile?: (previewUrl: string) => void;
/** List of local files pending upload */
localFiles?: LocalFile[];
}

View File

@ -10,66 +10,21 @@ import TagsSection from "./TagsSection";
export type MemoExplorerContext = "home" | "explore" | "archived" | "profile";
export interface MemoExplorerFeatures {
/**
* Show search bar at the top
* Default: true
*/
search?: boolean;
/**
* Show statistics section (activity calendar + stat cards)
* Default: true
*/
statistics?: boolean;
/**
* Show shortcuts section (user-defined filter shortcuts)
* Default: true for authenticated users on home/profile, false for explore
*/
shortcuts?: boolean;
/**
* Show tags section
* Default: true
*/
tags?: boolean;
/**
* Context for statistics view (affects which stats to show)
* Default: "user"
*/
statisticsContext?: MemoExplorerContext;
}
interface Props {
className?: string;
/**
* Context for the explorer (determines default features)
*/
context?: MemoExplorerContext;
/**
* Feature configuration (overrides context defaults)
*/
features?: MemoExplorerFeatures;
/**
* Statistics data computed from filtered memos
* Should be computed using useFilteredMemoStats with the same filter as the memo list
*/
statisticsData: StatisticsData;
/**
* Tag counts computed from filtered memos
* Should be computed using useFilteredMemoStats with the same filter as the memo list
*/
tagCount: Record<string, number>;
}
/**
* Default features based on context
*/
const getDefaultFeatures = (context: MemoExplorerContext): MemoExplorerFeatures => {
switch (context) {
case "explore":

View File

@ -7,24 +7,9 @@ import type { StatisticsData } from "@/types/statistics";
import MemoExplorer, { MemoExplorerContext, MemoExplorerFeatures } from "./MemoExplorer";
interface Props {
/**
* Context for the explorer
*/
context?: MemoExplorerContext;
/**
* Feature configuration
*/
features?: MemoExplorerFeatures;
/**
* Statistics data computed from filtered memos
*/
statisticsData: StatisticsData;
/**
* Tag counts computed from filtered memos
*/
tagCount: Record<string, number>;
}

View File

@ -10,10 +10,6 @@ import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
interface Props {
readonly?: boolean;
/**
* Tag count computed from filtered memos
* Should be provided by parent component using useFilteredMemoStats
*/
tagCount: Record<string, number>;
}

View File

@ -1,13 +1,3 @@
/**
* MemoView component and related exports
*
* This module provides a fully refactored MemoView component with:
* - Separation of concerns via custom hooks
* - Smaller, focused sub-components
* - Proper TypeScript types
* - Better maintainability and testability
*/
export { MemoBody, MemoHeader } from "./components";
export * from "./constants";
export { default, default as MemoView } from "./MemoView";

View File

@ -0,0 +1,70 @@
import type { Memo } from "@/types/proto/api/v1/memo_service";
import type { User } from "@/types/proto/api/v1/user_service";
export interface MemoViewProps {
memo: Memo;
compact?: boolean;
showCreator?: boolean;
showVisibility?: boolean;
showPinned?: boolean;
showNsfwContent?: boolean;
className?: string;
parentPage?: string;
}
export interface MemoHeaderProps {
// Display options
showCreator?: boolean;
showVisibility?: boolean;
showPinned?: boolean;
// Callbacks
onEdit: () => void;
onGotoDetail: () => void;
onUnpin: () => void;
onToggleNsfwVisibility?: () => void;
// Reaction state
reactionSelectorOpen: boolean;
onReactionSelectorOpenChange: (open: boolean) => void;
}
export interface MemoBodyProps {
// Display options
compact?: boolean;
// Callbacks
onContentClick: (e: React.MouseEvent) => void;
onContentDoubleClick: (e: React.MouseEvent) => void;
onToggleNsfwVisibility: () => void;
}
export interface ImagePreviewState {
open: boolean;
urls: string[];
index: number;
}
export interface UseMemoActionsReturn {
archiveMemo: () => Promise<void>;
unpinMemo: () => Promise<void>;
}
export interface UseKeyboardShortcutsOptions {
enabled: boolean;
readonly: boolean;
showEditor: boolean;
isArchived: boolean;
onEdit: () => void;
onArchive: () => Promise<void>;
}
export interface UseNsfwContentReturn {
nsfw: boolean;
showNSFWContent: boolean;
toggleNsfwVisibility: () => void;
}
export interface UseImagePreviewReturn {
previewState: ImagePreviewState;
openPreview: (url: string) => void;
closePreview: () => void;
setPreviewOpen: (open: boolean) => void;
}

View File

@ -10,10 +10,6 @@ interface SettingGroupProps {
showSeparator?: boolean;
}
/**
* Groups related settings together with optional title and separator
* Use this to organize multiple SettingRows under a common category
*/
const SettingGroup: React.FC<SettingGroupProps> = ({ title, description, children, className, showSeparator = false }) => {
return (
<>

View File

@ -12,10 +12,6 @@ interface SettingRowProps {
vertical?: boolean;
}
/**
* Standardized row component for individual settings
* Provides consistent label/control layout with optional tooltip
*/
const SettingRow: React.FC<SettingRowProps> = ({ label, description, tooltip, children, className, vertical = false }) => {
return (
<div className={cn("w-full flex gap-3", vertical ? "flex-col" : "flex-row justify-between items-center", className)}>

View File

@ -9,10 +9,6 @@ interface SettingSectionProps {
actions?: React.ReactNode;
}
/**
* Wrapper component for consistent section layout in settings pages
* Provides standardized spacing, titles, and descriptions
*/
const SettingSection: React.FC<SettingSectionProps> = ({ title, description, children, className, actions }) => {
return (
<div className={cn("w-full flex flex-col gap-4 pt-2 pb-4", className)}>

View File

@ -16,10 +16,6 @@ interface SettingTableProps {
getRowKey?: (row: any, index: number) => string;
}
/**
* Standardized table component for settings data lists
* Provides consistent styling for tables in settings pages
*/
const SettingTable: React.FC<SettingTableProps> = ({ columns, data, emptyMessage = "No data", className, getRowKey }) => {
return (
<div className={cn("w-full overflow-x-auto", className)}>

View File

@ -9,17 +9,9 @@ import { MonthNavigator } from "./MonthNavigator";
export type StatisticsViewContext = "home" | "explore" | "archived" | "profile";
interface Props {
/**
* Context for the statistics view
* Affects which stat cards are shown
* Default: "home"
*/
// Context for the statistics view (affects which stat cards are shown)
context?: StatisticsViewContext;
/**
* Statistics data computed from filtered memos
* Should be provided by parent component using useFilteredMemoStats
*/
// Statistics data computed from filtered memos (use useFilteredMemoStats)
statisticsData: StatisticsData;
}

View File

@ -3,7 +3,6 @@ import { cn } from "@/lib/utils";
import type { AttachmentItem, DisplayMode } from "./types";
interface AttachmentCardProps {
/** Unified attachment item (uploaded or local file) */
item: AttachmentItem;
mode: DisplayMode;
onRemove?: () => void;
@ -12,10 +11,6 @@ interface AttachmentCardProps {
showThumbnail?: boolean;
}
/**
* Unified attachment card component for all file types
* Renders differently based on mode (edit/view) and file category
*/
const AttachmentCard = ({ item, mode, onRemove, onClick, className, showThumbnail = true }: AttachmentCardProps) => {
const { category, filename, thumbnailUrl, sourceUrl } = item;
const isMedia = category === "image" || category === "video";

View File

@ -17,20 +17,6 @@ interface AttachmentListProps extends BaseMetadataProps {
onRemoveLocalFile?: (previewUrl: string) => void;
}
/**
* Unified AttachmentList component for both editor and view modes
*
* Editor mode:
* - Shows all attachments as sortable badges with thumbnails
* - Supports drag-and-drop reordering
* - Shows remove buttons
* - Shows pending files (not yet uploaded) with preview
*
* View mode:
* - Separates media (images/videos) from other files
* - Shows media in gallery layout with preview
* - Shows other files as clickable cards
*/
const AttachmentList = ({ attachments, mode, onAttachmentsChange, localFiles = [], onRemoveLocalFile }: AttachmentListProps) => {
const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor));
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number }>({

View File

@ -6,10 +6,6 @@ interface MetadataCardProps {
className?: string;
}
/**
* Shared card component for structured metadata (Relations, Comments, etc.)
* Provides consistent card styling across editor and view modes
*/
const MetadataCard = ({ children, className }: MetadataCardProps) => {
return (
<div

View File

@ -13,12 +13,6 @@ interface RelationCardProps {
className?: string;
}
/**
* Shared relation card component for displaying linked memos
*
* Editor mode: Badge with remove button, click to remove
* View mode: Link with memo ID and snippet, click to navigate
*/
const RelationCard = ({ memo, mode, onRemove, parentPage, className }: RelationCardProps) => {
const memoId = extractMemoIdFromName(memo.name);

View File

@ -16,20 +16,6 @@ interface RelationListProps extends BaseMetadataProps {
parentPage?: string;
}
/**
* Unified RelationList component for both editor and view modes
*
* Editor mode:
* - Shows only outgoing relations (referencing)
* - Badge-style display with remove buttons
* - Compact inline layout
*
* View mode:
* - Shows bidirectional relations in tabbed card
* - "Referencing" tab: Memos this memo links to
* - "Referenced by" tab: Memos that link to this memo
* - Navigable links with memo IDs
*/
const RelationList = observer(({ relations, currentMemoName, mode, onRelationsChange, parentPage, className }: RelationListProps) => {
const t = useTranslate();
const [referencingMemos, setReferencingMemos] = useState<Memo[]>([]);

View File

@ -1,8 +1,3 @@
/**
* Unified memo metadata components
* Provides consistent styling and behavior across editor and view modes
*/
export { default as AttachmentCard } from "./AttachmentCard";
export { default as AttachmentList } from "./AttachmentList";
export { default as LocationDisplay } from "./LocationDisplay";

View File

@ -1,7 +1,3 @@
/**
* Common types for memo metadata components
*/
import type { Attachment } from "@/types/proto/api/v1/attachment_service";
import { getAttachmentThumbnailUrl, getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
@ -12,47 +8,26 @@ export interface BaseMetadataProps {
className?: string;
}
/**
* File type categories for consistent handling across components
*/
export type FileCategory = "image" | "video" | "document";
/**
* Pure view model for rendering attachments and local files
* Contains only presentation data needed by UI components
* Does not store references to original domain objects for cleaner architecture
*/
// Pure view model for rendering attachments and local files
export interface AttachmentItem {
/** Unique identifier - stable across renders */
readonly id: string;
/** Display name for the file */
readonly filename: string;
/** Categorized file type */
readonly category: FileCategory;
/** MIME type for detailed handling if needed */
readonly mimeType: string;
/** URL for thumbnail/preview display */
readonly thumbnailUrl: string;
/** URL for full file access */
readonly sourceUrl: string;
/** Size in bytes (optional) */
readonly size?: number;
/** Whether this represents a local file not yet uploaded */
readonly isLocal: boolean;
}
/**
* Determine file category from MIME type
*/
function categorizeFile(mimeType: string): FileCategory {
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("video/")) return "video";
return "document";
}
/**
* Convert an uploaded Attachment to AttachmentItem view model
*/
export function attachmentToItem(attachment: Attachment): AttachmentItem {
const attachmentType = getAttachmentType(attachment);
const sourceUrl = getAttachmentUrl(attachment);
@ -69,9 +44,6 @@ export function attachmentToItem(attachment: Attachment): AttachmentItem {
};
}
/**
* Convert a local File with blob URL to AttachmentItem view model
*/
export function fileToItem(file: File, blobUrl: string): AttachmentItem {
return {
id: blobUrl, // Use blob URL as unique ID since we don't have a server ID yet
@ -85,34 +57,20 @@ export function fileToItem(file: File, blobUrl: string): AttachmentItem {
};
}
/**
* Simple container for local files with their blob URLs
* Kept minimal to avoid unnecessary abstraction
*/
export interface LocalFile {
readonly file: File;
readonly previewUrl: string;
}
/**
* Batch convert attachments and local files to AttachmentItems
* Returns items in order: uploaded first, then local
*/
export function toAttachmentItems(attachments: Attachment[], localFiles: LocalFile[] = []): AttachmentItem[] {
return [...attachments.map(attachmentToItem), ...localFiles.map(({ file, previewUrl }) => fileToItem(file, previewUrl))];
}
/**
* Filter items by category for specialized rendering
*/
export function filterByCategory(items: AttachmentItem[], categories: FileCategory[]): AttachmentItem[] {
const categorySet = new Set(categories);
return items.filter((item) => categorySet.has(item.category));
}
/**
* Separate items into media (image/video) and documents
*/
export function separateMediaAndDocs(items: AttachmentItem[]): { media: AttachmentItem[]; docs: AttachmentItem[] } {
const media: AttachmentItem[] = [];
const docs: AttachmentItem[] = [];

View File

@ -28,22 +28,6 @@ const DialogOverlay = React.forwardRef<
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
/**
* Dialog content variants with improved mobile responsiveness.
*
* Mobile behavior:
* - Mobile phones (< 640px): Uses calc(100% - 2rem) width with better 1rem margin on each side
* - Small tablets ( 640px): Uses calc(100% - 3rem) width with 1.5rem margin on each side
* - Medium screens and up ( 768px): Uses fixed max-widths based on size variant
*
* Size variants:
* - sm: max-w-sm (384px) for compact dialogs
* - default: max-w-md (448px) for standard dialogs
* - lg: max-w-lg (512px) for larger forms
* - xl: max-w-xl (576px) for detailed content
* - 2xl: max-w-2xl (672px) for wide layouts
* - full: Takes available width with margins
*/
const dialogContentVariants = cva(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 flex flex-col translate-x-[-50%] translate-y-[-50%] rounded-lg border shadow-lg duration-200 max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] md:max-h-[calc(100vh-4rem)]",
{

View File

@ -1,24 +1,5 @@
import { useCallback, useState } from "react";
/**
* Hook for managing dialog state with a clean API
*
* @returns Object with dialog state and handlers
*
* @example
* const dialog = useDialog();
*
* return (
* <>
* <Button onClick={dialog.open}>Open Dialog</Button>
* <SomeDialog
* open={dialog.isOpen}
* onOpenChange={dialog.setOpen}
* onSuccess={dialog.close}
* />
* </>
* );
*/
export function useDialog(defaultOpen = false) {
const [isOpen, setIsOpen] = useState(defaultOpen);
@ -35,30 +16,6 @@ export function useDialog(defaultOpen = false) {
};
}
/**
* Hook for managing multiple dialogs with named keys
*
* @returns Object with dialog management functions
*
* @example
* const dialogs = useDialogs();
*
* return (
* <>
* <Button onClick={() => dialogs.open('create')}>Create User</Button>
* <Button onClick={() => dialogs.open('edit')}>Edit User</Button>
*
* <CreateUserDialog
* open={dialogs.isOpen('create')}
* onOpenChange={(open) => dialogs.setOpen('create', open)}
* />
* <EditUserDialog
* open={dialogs.isOpen('edit')}
* onOpenChange={(open) => dialogs.setOpen('edit', open)}
* />
* </>
* );
*/
export function useDialogs() {
const [openDialogs, setOpenDialogs] = useState<Set<string>>(new Set());

View File

@ -10,50 +10,14 @@ export interface FilteredMemoStats {
loading: boolean;
}
/**
* Convert user name to user stats key.
* Backend returns UserStats with name "users/{id}/stats" but we pass "users/{id}"
* @param userName - User name in format "users/{id}"
* @returns Stats key in format "users/{id}/stats"
*/
const getUserStatsKey = (userName: string): string => {
return `${userName}/stats`;
};
export interface UseFilteredMemoStatsOptions {
/**
* User name to fetch stats for (e.g., "users/123")
*
* When provided:
* - Fetches backend user stats via GetUserStats API
* - Returns unfiltered tags and activity (all NORMAL memos for that user)
* - Tags remain stable even when memo filters are applied
*
* When undefined:
* - Computes stats from cached memos in the store
* - Reflects current filters (useful for Explore/Archived pages)
*
* IMPORTANT: Backend user stats only include NORMAL (non-archived) memos.
* Do NOT use for Archived page context.
*/
userName?: string;
}
/**
* Hook to compute statistics and tags for the sidebar.
*
* Data sources by context:
* - **Home/Profile**: Uses backend UserStats API (unfiltered, normal memos only)
* - **Archived/Explore**: Computes from cached memos (filtered by page context)
*
* Benefits of using backend stats:
* - Tag list remains stable when memo filters are applied
* - Activity calendar shows full history, not just filtered results
* - Prevents "disappearing tags" issue when filtering by tag
*
* @param options - Configuration options
* @returns Object with statistics data, tag counts, and loading state
*/
export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): FilteredMemoStats => {
const { userName } = options;
const [data, setData] = useState<FilteredMemoStats>({

View File

@ -5,81 +5,18 @@ import memoFilterStore from "@/store/memoFilter";
import { InstanceSetting_Key } from "@/types/proto/api/v1/instance_service";
import { Visibility } from "@/types/proto/api/v1/memo_service";
// Helper function to extract shortcut ID from resource name
// Format: users/{user}/shortcuts/{shortcut}
const getShortcutId = (name: string): string => {
const parts = name.split("/");
return parts.length === 4 ? parts[3] : "";
};
export interface UseMemoFiltersOptions {
/**
* User name to scope memos to (e.g., "users/123")
* If undefined, no creator filter is applied (useful for Explore page)
*/
creatorName?: string;
/**
* Whether to include shortcut filter from memoFilterStore
* Default: false
*/
includeShortcuts?: boolean;
/**
* Whether to include pinned filter from memoFilterStore
* Default: false
*/
includePinned?: boolean;
/**
* Visibility levels to filter by (for Explore page)
* If provided, adds visibility filter to show only specified visibility levels
* Default: undefined (no visibility filter)
*
* **Security Note**: This filter is enforced at the API level. The backend is responsible
* for respecting visibility permissions when:
* - Returning memo lists (filtered by this parameter)
* - Calculating statistics (should only count visible memos)
* - Aggregating tags (should only include tags from visible memos)
*
* This ensures that private memo data never leaks to unauthorized users through
* stats, tags, or direct memo access.
*
* @example
* // For logged-in users on Explore
* visibilities: [Visibility.PUBLIC, Visibility.PROTECTED]
*
* @example
* // For visitors on Explore
* visibilities: [Visibility.PUBLIC]
*/
visibilities?: Visibility[];
}
/**
* Hook to build memo filter string based on active filters and options.
*
* This hook consolidates filter building logic that was previously duplicated
* across Home, Explore, Archived, and UserProfile pages.
*
* @param options - Configuration for filter building
* @returns Filter string to pass to API, or undefined if no filters
*
* @example
* // Home page - include everything
* const filter = useMemoFilters({
* creatorName: user.name,
* includeShortcuts: true,
* includePinned: true
* });
*
* @example
* // Explore page - no creator scoping
* const filter = useMemoFilters({
* includeShortcuts: false,
* includePinned: false
* });
*/
export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | undefined => {
const { creatorName, includeShortcuts = false, includePinned = false, visibilities } = options;

View File

@ -5,54 +5,15 @@ import { State } from "@/types/proto/api/v1/common";
import { Memo } from "@/types/proto/api/v1/memo_service";
export interface UseMemoSortingOptions {
/**
* Whether to sort pinned memos first
* Default: false
*/
pinnedFirst?: boolean;
/**
* State to filter memos by (NORMAL, ARCHIVED, etc.)
* Default: State.NORMAL
*/
state?: State;
}
export interface UseMemoSortingResult {
/**
* Sort function to pass to PagedMemoList's listSort prop
*/
listSort: (memos: Memo[]) => Memo[];
/**
* Order by string to pass to PagedMemoList's orderBy prop
*/
orderBy: string;
}
/**
* Hook to generate memo sorting logic based on options.
*
* This hook consolidates sorting logic that was previously duplicated
* across Home, Explore, Archived, and UserProfile pages.
*
* @param options - Configuration for sorting
* @returns Object with listSort function and orderBy string
*
* @example
* // Home page - pinned first, then by time
* const { listSort, orderBy } = useMemoSorting({
* pinnedFirst: true,
* state: State.NORMAL
* });
*
* @example
* // Explore page - only by time
* const { listSort, orderBy } = useMemoSorting({
* pinnedFirst: false,
* state: State.NORMAL
* });
*/
export const useMemoSorting = (options: UseMemoSortingOptions = {}): UseMemoSortingResult => {
const { pinnedFirst = false, state = State.NORMAL } = options;

View File

@ -22,9 +22,6 @@ import { useTranslate } from "@/utils/i18n";
const PAGE_SIZE = 50;
/**
* Groups attachments by month for organized display
*/
const groupAttachmentsByDate = (attachments: Attachment[]): Map<string, Attachment[]> => {
const grouped = new Map<string, Attachment[]>();
const sorted = [...attachments].sort((a, b) => dayjs(b.createTime).unix() - dayjs(a.createTime).unix());
@ -39,18 +36,12 @@ const groupAttachmentsByDate = (attachments: Attachment[]): Map<string, Attachme
return grouped;
};
/**
* Filters attachments based on search query
*/
const filterAttachments = (attachments: Attachment[], searchQuery: string): Attachment[] => {
if (!searchQuery.trim()) return attachments;
const query = searchQuery.toLowerCase();
return attachments.filter((attachment) => attachment.filename.toLowerCase().includes(query));
};
/**
* Individual attachment item component
*/
interface AttachmentItemProps {
attachment: Attachment;
}

View File

@ -1,23 +1,12 @@
/**
* Attachment Store
*
* Manages file attachment state including uploads and metadata.
* This is a server state store that fetches and caches attachment data.
*/
// Attachment Store - manages file attachment state including uploads and metadata
import { computed, makeObservable, observable } from "mobx";
import { attachmentServiceClient } from "@/grpcweb";
import { Attachment, CreateAttachmentRequest, UpdateAttachmentRequest } from "@/types/proto/api/v1/attachment_service";
import { createServerStore, StandardState } from "./base-store";
import { createRequestKey } from "./store-utils";
/**
* Attachment store state
* Uses a name-based map for efficient lookups
*/
class AttachmentState extends StandardState {
/**
* Map of attachments indexed by resource name (e.g., "attachments/123")
*/
// Map of attachments indexed by resource name (e.g., "attachments/123")
attachmentMapByName: Record<string, Attachment> = {};
constructor() {
@ -29,24 +18,15 @@ class AttachmentState extends StandardState {
});
}
/**
* Computed getter for all attachments as an array
*/
get attachments(): Attachment[] {
return Object.values(this.attachmentMapByName);
}
/**
* Get attachment count
*/
get size(): number {
return Object.keys(this.attachmentMapByName).length;
}
}
/**
* Attachment store instance
*/
const attachmentStore = (() => {
const base = createServerStore(new AttachmentState(), {
name: "attachment",
@ -55,13 +35,6 @@ const attachmentStore = (() => {
const { state, executeRequest } = base;
/**
* Fetch attachment by resource name
* Results are cached in the store
*
* @param name - Resource name (e.g., "attachments/123")
* @returns The attachment object
*/
const fetchAttachmentByName = async (name: string): Promise<Attachment> => {
const requestKey = createRequestKey("fetchAttachment", { name });
@ -84,24 +57,10 @@ const attachmentStore = (() => {
);
};
/**
* Get attachment from cache by resource name
* Does not trigger a fetch if not found
*
* @param name - Resource name
* @returns The cached attachment or undefined
*/
const getAttachmentByName = (name: string): Attachment | undefined => {
return state.attachmentMapByName[name];
};
/**
* Get or fetch attachment by name
* Checks cache first, fetches if not found
*
* @param name - Resource name
* @returns The attachment object
*/
const getOrFetchAttachmentByName = async (name: string): Promise<Attachment> => {
const cached = getAttachmentByName(name);
if (cached) {
@ -110,12 +69,6 @@ const attachmentStore = (() => {
return fetchAttachmentByName(name);
};
/**
* Create a new attachment
*
* @param request - Attachment creation request
* @returns The created attachment
*/
const createAttachment = async (request: CreateAttachmentRequest): Promise<Attachment> => {
return executeRequest(
"", // No deduplication for creates
@ -136,12 +89,6 @@ const attachmentStore = (() => {
);
};
/**
* Update an existing attachment
*
* @param request - Attachment update request
* @returns The updated attachment
*/
const updateAttachment = async (request: UpdateAttachmentRequest): Promise<Attachment> => {
return executeRequest(
"", // No deduplication for updates
@ -162,11 +109,6 @@ const attachmentStore = (() => {
);
};
/**
* Delete an attachment
*
* @param name - Resource name of the attachment to delete
*/
const deleteAttachment = async (name: string): Promise<void> => {
return executeRequest(
"", // No deduplication for deletes
@ -182,9 +124,6 @@ const attachmentStore = (() => {
);
};
/**
* Clear all cached attachments
*/
const clearCache = (): void => {
state.setPartial({ attachmentMapByName: {} });
};

View File

@ -1,56 +1,18 @@
/**
* Base store classes and utilities for consistent store patterns
*
* This module provides:
* - BaseServerStore: For stores that fetch data from APIs
* - BaseClientStore: For stores that manage UI/client state
* - Common patterns for all stores
*/
// Base store classes and utilities for consistent store patterns
// - BaseServerStore: For stores that fetch data from APIs
// - BaseClientStore: For stores that manage UI/client state
import { action, makeObservable } from "mobx";
import { RequestDeduplicator, StoreError } from "./store-utils";
/**
* Base interface for all store states
* Ensures all stores have a consistent setPartial method
*/
export interface BaseState {
setPartial(partial: Partial<this>): void;
}
/**
* Base class for server state stores (data fetching)
*
* Server stores:
* - Fetch data from APIs
* - Cache responses in memory
* - Handle errors with StoreError
* - Support request deduplication
*
* @example
* class MemoState implements BaseState {
* memoMapByName: Record<string, Memo> = {};
* constructor() { makeAutoObservable(this); }
* setPartial(partial: Partial<this>) { Object.assign(this, partial); }
* }
*
* const store = createServerStore(new MemoState());
*/
export interface ServerStoreConfig {
/**
* Enable request deduplication
* Prevents multiple identical requests from running simultaneously
*/
enableDeduplication?: boolean;
/**
* Store name for debugging and error messages
*/
name: string;
}
/**
* Create a server store with built-in utilities
*/
export function createServerStore<TState extends BaseState>(state: TState, config: ServerStoreConfig) {
const deduplicator = config.enableDeduplication !== false ? new RequestDeduplicator() : null;
@ -59,9 +21,6 @@ export function createServerStore<TState extends BaseState>(state: TState, confi
deduplicator,
name: config.name,
/**
* Wrap an async operation with error handling and optional deduplication
*/
async executeRequest<T>(key: string, operation: () => Promise<T>, errorCode?: string): Promise<T> {
try {
if (deduplicator && key) {
@ -70,7 +29,7 @@ export function createServerStore<TState extends BaseState>(state: TState, confi
return await operation();
} catch (error) {
if (StoreError.isAbortError(error)) {
throw error; // Re-throw abort errors as-is
throw error;
}
throw StoreError.wrap(errorCode || `${config.name.toUpperCase()}_OPERATION_FAILED`, error);
}
@ -78,35 +37,8 @@ export function createServerStore<TState extends BaseState>(state: TState, confi
};
}
/**
* Base class for client state stores (UI state)
*
* Client stores:
* - Manage UI preferences and transient state
* - May persist to localStorage or URL
* - No API calls
* - Instant updates
*
* @example
* class ViewState implements BaseState {
* orderByTimeAsc = false;
* layout: "LIST" | "MASONRY" = "LIST";
* constructor() { makeAutoObservable(this); }
* setPartial(partial: Partial<this>) {
* Object.assign(this, partial);
* localStorage.setItem("view", JSON.stringify(this));
* }
* }
*/
export interface ClientStoreConfig {
/**
* Store name for debugging
*/
name: string;
/**
* Enable localStorage persistence
*/
persistence?: {
key: string;
serialize?: (state: any) => string;
@ -114,9 +46,6 @@ export interface ClientStoreConfig {
};
}
/**
* Create a client store with optional persistence
*/
export function createClientStore<TState extends BaseState>(state: TState, config: ClientStoreConfig) {
// Load from localStorage if enabled
if (config.persistence) {
@ -135,9 +64,6 @@ export function createClientStore<TState extends BaseState>(state: TState, confi
state,
name: config.name,
/**
* Save state to localStorage if persistence is enabled
*/
persist(): void {
if (config.persistence) {
try {
@ -149,9 +75,6 @@ export function createClientStore<TState extends BaseState>(state: TState, confi
}
},
/**
* Clear persisted state
*/
clearPersistence(): void {
if (config.persistence) {
localStorage.removeItem(config.persistence.key);
@ -160,10 +83,6 @@ export function createClientStore<TState extends BaseState>(state: TState, confi
};
}
/**
* Standard state class implementation
* Use this as a base for your state classes
*/
export abstract class StandardState implements BaseState {
constructor() {
makeObservable(this, {

View File

@ -1,72 +1,32 @@
/**
* MobX configuration for strict state management
*
* This configuration enforces best practices to prevent common mistakes:
* - All state changes must happen in actions (prevents accidental mutations)
* - Computed values cannot have side effects (ensures purity)
* - Observables must be accessed within reactions (helps catch missing observers)
*
* This file is imported early in the application lifecycle to configure MobX
* before any stores are created.
*/
// MobX configuration for strict state management
// Enforces best practices: state changes must happen in actions, computed values cannot have side effects
import { configure } from "mobx";
/**
* Configure MobX with production-safe settings
* This runs immediately when the module is imported
*/
configure({
/**
* Enforce that all state mutations happen within actions
* Since we use makeAutoObservable, all methods are automatically actions
* This prevents bugs from direct mutations like:
* store.state.value = 5 // ERROR: This will throw
*
* Instead, you must use action methods:
* store.state.setPartial({ value: 5 }) // Correct
*/
enforceActions: "never", // Start with "never", can be upgraded to "observed" or "always"
/**
* Use Proxies for better performance and ES6 compatibility
* makeAutoObservable requires this to be enabled
*/
// Enforce that all state mutations happen within actions (start permissive, can upgrade later)
enforceActions: "never",
// Use Proxies for better performance and ES6 compatibility (required for makeAutoObservable)
useProxies: "always",
/**
* Isolate global state to prevent accidental sharing between tests
*/
// Isolate global state to prevent accidental sharing between tests
isolateGlobalState: true,
/**
* Disable error boundaries so errors propagate normally
* This ensures React error boundaries can catch store errors
*/
// Disable error boundaries so errors propagate normally
disableErrorBoundaries: false,
});
/**
* Enable strict mode for development
* Call this in main.tsx if you want stricter checking
*/
export function enableStrictMode() {
if (import.meta.env.DEV) {
configure({
enforceActions: "observed", // Enforce actions only for observed values
computedRequiresReaction: false, // Don't warn about computed access
reactionRequiresObservable: false, // Don't warn about reactions
enforceActions: "observed",
computedRequiresReaction: false,
reactionRequiresObservable: false,
});
console.info("✓ MobX strict mode enabled");
}
}
/**
* Enable production mode for maximum performance
* This is automatically called in production builds
*/
export function enableProductionMode() {
configure({
enforceActions: "never", // No runtime checks for performance
enforceActions: "never",
disableErrorBoundaries: false,
});
}

View File

@ -1,50 +1,6 @@
/**
* Store Module
*
* This module exports all application stores and their types.
*
* ## Store Architecture
*
* Stores are divided into two categories:
*
* ### Server State Stores (Data Fetching)
* These stores fetch and cache data from the backend API:
* - **memoStore**: Memo CRUD operations
* - **userStore**: User authentication and settings
* - **instanceStore**: Instance configuration
* - **attachmentStore**: File attachment management
*
* Features:
* - Request deduplication
* - Error handling with StoreError
* - Optimistic updates (memo updates)
* - Computed property memoization
*
* ### Client State Stores (UI State)
* These stores manage UI preferences and transient state:
* - **viewStore**: Display preferences (sort order, layout)
* - **memoFilterStore**: Active search filters
*
* Features:
* - localStorage persistence (viewStore)
* - URL synchronization (memoFilterStore)
* - No API calls
*
* ## Usage
*
* ```typescript
* import { memoStore, userStore, viewStore } from "@/store";
* import { observer } from "mobx-react-lite";
*
* const MyComponent = observer(() => {
* const memos = memoStore.state.memos;
* const user = userStore.state.currentUser;
*
* return <div>...</div>;
* });
* ```
*/
// Server State Stores
// Store Module - exports all application stores and their types
// Server State Stores (fetch/cache backend data): memoStore, userStore, instanceStore, attachmentStore
// Client State Stores (UI preferences): viewStore, memoFilterStore
import attachmentStore from "./attachment";
import instanceStore from "./instance";
import memoStore from "./memo";
@ -89,9 +45,6 @@ export {
viewStore,
};
/**
* All stores grouped by category for convenience
*/
export const stores = {
// Server state
server: {

View File

@ -1,9 +1,4 @@
/**
* Instance Store
*
* Manages instance-level configuration and settings.
* This is a server state store that fetches instance profile and settings.
*/
// Instance Store - manages instance-level configuration and settings
import { uniqBy } from "lodash-es";
import { computed } from "mobx";
import { instanceServiceClient } from "@/grpcweb";
@ -19,48 +14,20 @@ import { createServerStore, StandardState } from "./base-store";
import { instanceSettingNamePrefix } from "./common";
import { createRequestKey } from "./store-utils";
/**
* Valid theme options
*/
const VALID_THEMES = ["system", "default", "default-dark", "midnight", "paper", "whitewall"] as const;
export type Theme = (typeof VALID_THEMES)[number];
/**
* Check if a string is a valid theme
*/
export function isValidTheme(theme: string): theme is Theme {
return VALID_THEMES.includes(theme as Theme);
}
/**
* Instance store state
*/
class InstanceState extends StandardState {
/**
* Current locale (e.g., "en", "zh", "ja")
*/
locale: string = "en";
/**
* Current theme
* Note: Accepts string for flexibility, but validates to Theme
*/
theme: Theme | string = "system";
/**
* Instance profile containing owner and metadata
*/
profile: InstanceProfile = InstanceProfile.fromPartial({});
/**
* Array of instance settings
*/
settings: InstanceSetting[] = [];
/**
* Computed property for general settings
* Memoized for performance
*/
// Computed property for general settings (memoized)
get generalSetting(): InstanceSetting_GeneralSetting {
return computed(() => {
const setting = this.settings.find((s) => s.name === `${instanceSettingNamePrefix}${InstanceSetting_Key.GENERAL}`);
@ -68,10 +35,7 @@ class InstanceState extends StandardState {
}).get();
}
/**
* Computed property for memo-related settings
* Memoized for performance
*/
// Computed property for memo-related settings (memoized)
get memoRelatedSetting(): InstanceSetting_MemoRelatedSetting {
return computed(() => {
const setting = this.settings.find((s) => s.name === `${instanceSettingNamePrefix}${InstanceSetting_Key.MEMO_RELATED}`);
@ -79,9 +43,6 @@ class InstanceState extends StandardState {
}).get();
}
/**
* Override setPartial to validate locale and theme
*/
setPartial(partial: Partial<InstanceState>): void {
const finalState = { ...this, ...partial };
@ -106,9 +67,6 @@ class InstanceState extends StandardState {
}
}
/**
* Instance store instance
*/
const instanceStore = (() => {
const base = createServerStore(new InstanceState(), {
name: "instance",
@ -117,11 +75,6 @@ const instanceStore = (() => {
const { state, executeRequest } = base;
/**
* Fetch a specific instance setting by key
*
* @param settingKey - The setting key to fetch
*/
const fetchInstanceSetting = async (settingKey: InstanceSetting_Key): Promise<void> => {
const requestKey = createRequestKey("fetchInstanceSetting", { key: settingKey });
@ -141,11 +94,6 @@ const instanceStore = (() => {
);
};
/**
* Update or create an instance setting
*
* @param setting - The setting to upsert
*/
const upsertInstanceSetting = async (setting: InstanceSetting): Promise<void> => {
return executeRequest(
"", // No deduplication for updates
@ -161,24 +109,11 @@ const instanceStore = (() => {
);
};
/**
* Get an instance setting from cache by key
* Does not trigger a fetch
*
* @param settingKey - The setting key
* @returns The cached setting or an empty setting
*/
const getInstanceSettingByKey = (settingKey: InstanceSetting_Key): InstanceSetting => {
const setting = state.settings.find((s) => s.name === `${instanceSettingNamePrefix}${settingKey}`);
return setting || InstanceSetting.fromPartial({});
};
/**
* Set the instance theme
* Updates both local state and persists to server
*
* @param theme - The theme to set
*/
const setTheme = async (theme: string): Promise<void> => {
// Validate theme
if (!isValidTheme(theme)) {
@ -206,9 +141,6 @@ const instanceStore = (() => {
);
};
/**
* Fetch instance profile
*/
const fetchInstanceProfile = async (): Promise<InstanceProfile> => {
const requestKey = createRequestKey("fetchInstanceProfile");
@ -233,12 +165,7 @@ const instanceStore = (() => {
};
})();
/**
* Initialize the instance store
* Called once at app startup to load instance profile and settings
*
* @throws Never - errors are logged but not thrown
*/
// Initialize the instance store - called once at app startup
export const initialInstanceStore = async (): Promise<void> => {
try {
// Fetch instance profile

View File

@ -1,49 +1,26 @@
/**
* Memo Filter Store
*
* Manages active memo filters and search state.
* This is a client state store that syncs with URL query parameters.
*
* Filters are URL-driven and shareable - copying the URL preserves the filter state.
*/
// Memo Filter Store - manages active memo filters and search state
// This is a client state store that syncs with URL query parameters
import { uniqBy } from "lodash-es";
import { action, computed, makeObservable, observable } from "mobx";
import { StandardState } from "./base-store";
/**
* Filter factor types
* Defines what aspect of a memo to filter by
*/
export type FilterFactor =
| "tagSearch" // Filter by tag name
| "visibility" // Filter by visibility (public/private)
| "contentSearch" // Search in memo content
| "displayTime" // Filter by date
| "pinned" // Show only pinned memos
| "property.hasLink" // Memos containing links
| "property.hasTaskList" // Memos with task lists
| "property.hasCode"; // Memos with code blocks
| "tagSearch"
| "visibility"
| "contentSearch"
| "displayTime"
| "pinned"
| "property.hasLink"
| "property.hasTaskList"
| "property.hasCode";
/**
* Memo filter object
*/
export interface MemoFilter {
factor: FilterFactor;
value: string;
}
/**
* Generate a unique key for a filter
* Used for deduplication
*/
export const getMemoFilterKey = (filter: MemoFilter): string => `${filter.factor}:${filter.value}`;
/**
* Parse filter query string from URL into filter objects
*
* @param query - URL query string (e.g., "tagSearch:work,pinned:true")
* @returns Array of filter objects
*/
export const parseFilterQuery = (query: string | null): MemoFilter[] => {
if (!query) return [];
@ -61,34 +38,14 @@ export const parseFilterQuery = (query: string | null): MemoFilter[] => {
}
};
/**
* Convert filter objects into URL query string
*
* @param filters - Array of filter objects
* @returns URL-encoded query string
*/
export const stringifyFilters = (filters: MemoFilter[]): string => {
return filters.map((filter) => `${filter.factor}:${encodeURIComponent(filter.value)}`).join(",");
};
/**
* Memo filter store state
*/
class MemoFilterState extends StandardState {
/**
* Active filters
*/
filters: MemoFilter[] = [];
/**
* Currently selected shortcut ID
* Shortcuts are predefined filter combinations
*/
shortcut?: string = undefined;
/**
* Initialize from URL on construction
*/
constructor() {
super();
makeObservable(this, {
@ -104,9 +61,6 @@ class MemoFilterState extends StandardState {
this.initFromURL();
}
/**
* Load filters from current URL query parameters
*/
private initFromURL(): void {
try {
const searchParams = new URLSearchParams(window.location.search);
@ -117,144 +71,60 @@ class MemoFilterState extends StandardState {
}
}
/**
* Get all filters for a specific factor
*
* @param factor - The filter factor to query
* @returns Array of matching filters
*/
getFiltersByFactor(factor: FilterFactor): MemoFilter[] {
return this.filters.filter((f) => f.factor === factor);
}
/**
* Add a filter (deduplicates automatically)
*
* @param filter - The filter to add
*/
addFilter(filter: MemoFilter): void {
this.filters = uniqBy([...this.filters, filter], getMemoFilterKey);
}
/**
* Remove filters matching the predicate
*
* @param predicate - Function that returns true for filters to remove
*/
removeFilter(predicate: (f: MemoFilter) => boolean): void {
this.filters = this.filters.filter((f) => !predicate(f));
}
/**
* Remove all filters for a specific factor
*
* @param factor - The filter factor to remove
*/
removeFiltersByFactor(factor: FilterFactor): void {
this.filters = this.filters.filter((f) => f.factor !== factor);
}
/**
* Clear all filters
*/
clearAllFilters(): void {
this.filters = [];
this.shortcut = undefined;
}
/**
* Set the current shortcut
*
* @param shortcut - Shortcut ID or undefined to clear
*/
setShortcut(shortcut?: string): void {
this.shortcut = shortcut;
}
/**
* Check if a specific filter is active
*
* @param filter - The filter to check
* @returns True if the filter is active
*/
hasFilter(filter: MemoFilter): boolean {
return this.filters.some((f) => getMemoFilterKey(f) === getMemoFilterKey(filter));
}
/**
* Check if any filters are active
*/
get hasActiveFilters(): boolean {
return this.filters.length > 0 || this.shortcut !== undefined;
}
}
/**
* Memo filter store instance
*/
const memoFilterStore = (() => {
const state = new MemoFilterState();
return {
/**
* Direct access to state for observers
*/
state,
/**
* Get all active filters
*/
get filters(): MemoFilter[] {
return state.filters;
},
/**
* Get current shortcut ID
*/
get shortcut(): string | undefined {
return state.shortcut;
},
/**
* Check if any filters are active
*/
get hasActiveFilters(): boolean {
return state.hasActiveFilters;
},
/**
* Get filters by factor
*/
getFiltersByFactor: (factor: FilterFactor): MemoFilter[] => state.getFiltersByFactor(factor),
/**
* Add a filter
*/
addFilter: (filter: MemoFilter): void => state.addFilter(filter),
/**
* Remove filters matching predicate
*/
removeFilter: (predicate: (f: MemoFilter) => boolean): void => state.removeFilter(predicate),
/**
* Remove all filters for a factor
*/
removeFiltersByFactor: (factor: FilterFactor): void => state.removeFiltersByFactor(factor),
/**
* Clear all filters
*/
clearAllFilters: (): void => state.clearAllFilters(),
/**
* Set current shortcut
*/
setShortcut: (shortcut?: string): void => state.setShortcut(shortcut),
/**
* Check if a filter is active
*/
hasFilter: (filter: MemoFilter): boolean => state.hasFilter(filter),
};
})();

View File

@ -1,12 +1,6 @@
/**
* Store utilities for MobX stores
* Provides request deduplication, error handling, and other common patterns
*/
// Store utilities for MobX stores
// Provides request deduplication, error handling, and other common patterns
/**
* Custom error class for store operations
* Provides structured error information for better debugging and error handling
*/
export class StoreError extends Error {
constructor(
public readonly code: string,
@ -17,16 +11,10 @@ export class StoreError extends Error {
this.name = "StoreError";
}
/**
* Check if an error is an AbortError from a cancelled request
*/
static isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === "AbortError";
}
/**
* Wrap an unknown error in a StoreError for consistent error handling
*/
static wrap(code: string, error: unknown, customMessage?: string): StoreError {
if (error instanceof StoreError) {
return error;
@ -37,21 +25,10 @@ export class StoreError extends Error {
}
}
/**
* Request deduplication manager
* Prevents multiple identical requests from being made simultaneously
*/
// Request deduplication manager - prevents multiple identical requests
export class RequestDeduplicator {
private pendingRequests = new Map<string, Promise<any>>();
/**
* Execute a request with deduplication
* If the same request key is already pending, returns the existing promise
*
* @param key - Unique identifier for this request (e.g., JSON.stringify(params))
* @param requestFn - Function that executes the actual request
* @returns Promise that resolves with the request result
*/
async execute<T>(key: string, requestFn: () => Promise<T>): Promise<T> {
// Check if this request is already pending
if (this.pendingRequests.has(key)) {
@ -70,32 +47,19 @@ export class RequestDeduplicator {
return promise;
}
/**
* Cancel all pending requests
*/
clear(): void {
this.pendingRequests.clear();
}
/**
* Check if a request with the given key is pending
*/
isPending(key: string): boolean {
return this.pendingRequests.has(key);
}
/**
* Get the number of pending requests
*/
get size(): number {
return this.pendingRequests.size;
}
}
/**
* Create a request key from parameters
* Useful for generating consistent keys for request deduplication
*/
export function createRequestKey(prefix: string, params?: Record<string, any>): string {
if (!params) {
return prefix;
@ -115,23 +79,13 @@ export function createRequestKey(prefix: string, params?: Record<string, any>):
return `${prefix}:${JSON.stringify(sortedParams)}`;
}
/**
* Optimistic update helper
* Handles optimistic updates with rollback on error
*/
// Optimistic update helper with rollback on error
export class OptimisticUpdate<T> {
constructor(
private getCurrentState: () => T,
private setState: (state: T) => void,
) {}
/**
* Execute an update with optimistic UI updates
*
* @param optimisticState - State to apply immediately
* @param updateFn - Async function that performs the actual update
* @returns Promise that resolves with the update result
*/
async execute<R>(optimisticState: T, updateFn: () => Promise<R>): Promise<R> {
const previousState = this.getCurrentState();

View File

@ -31,11 +31,7 @@ class LocalState {
// The state id of user stats map.
statsStateId = uniqueId();
/**
* Computed property that aggregates tag counts across all users.
* Uses @computed to memoize the result and only recalculate when userStatsByName changes.
* This prevents unnecessary recalculations on every access.
*/
// Computed property that aggregates tag counts across all users (memoized)
get tagCount() {
return computed(() => {
const tagCount: Record<string, number> = {};
@ -306,17 +302,11 @@ const userStore = (() => {
};
})();
/**
* Initializes the user store with proper sequencing to avoid temporal coupling.
*
* Initialization steps (order is critical):
* 1. Fetch current authenticated user session
* 2. Set current user in store (required for subsequent calls)
* 3. Fetch user settings (depends on currentUser being set)
* 4. Apply user preferences to instance store
*
* @throws Never - errors are handled internally with fallback behavior
*/
// Initializes the user store with proper sequencing:
// 1. Fetch current authenticated user session
// 2. Set current user in store (required for subsequent calls)
// 3. Fetch user settings (depends on currentUser being set)
// 4. Apply user preferences to instance store
export const initialUserStore = async () => {
try {
// Step 1: Authenticate and get current user

View File

@ -1,34 +1,14 @@
/**
* View Store
*
* Manages UI display preferences and layout settings.
* This is a client state store that persists to localStorage.
*/
import { makeObservable, observable } from "mobx";
import { StandardState } from "./base-store";
const LOCAL_STORAGE_KEY = "memos-view-setting";
/**
* Layout mode options
*/
export type LayoutMode = "LIST" | "MASONRY";
/**
* View store state
* Contains UI preferences for displaying memos
*/
class ViewState extends StandardState {
/**
* Sort order: true = ascending (oldest first), false = descending (newest first)
*/
// Sort order: true = ascending (oldest first), false = descending (newest first)
orderByTimeAsc: boolean = false;
/**
* Display layout mode
* - LIST: Traditional vertical list
* - MASONRY: Pinterest-style grid layout
*/
// Display layout mode: LIST (vertical list) or MASONRY (Pinterest-style grid)
layout: LayoutMode = "LIST";
constructor() {
@ -39,9 +19,6 @@ class ViewState extends StandardState {
});
}
/**
* Override setPartial to persist to localStorage
*/
setPartial(partial: Partial<ViewState>): void {
// Validate layout if provided
if (partial.layout !== undefined && !["LIST", "MASONRY"].includes(partial.layout)) {
@ -66,9 +43,6 @@ class ViewState extends StandardState {
}
}
/**
* View store instance
*/
const viewStore = (() => {
const state = new ViewState();
@ -92,25 +66,14 @@ const viewStore = (() => {
console.warn("Failed to load view settings from localStorage:", error);
}
/**
* Toggle sort order between ascending and descending
*/
const toggleSortOrder = (): void => {
state.setPartial({ orderByTimeAsc: !state.orderByTimeAsc });
};
/**
* Set the layout mode
*
* @param layout - The layout mode to set
*/
const setLayout = (layout: LayoutMode): void => {
state.setPartial({ layout });
};
/**
* Reset to default settings
*/
const resetToDefaults = (): void => {
state.setPartial({
orderByTimeAsc: false,
@ -118,9 +81,6 @@ const viewStore = (() => {
});
};
/**
* Clear persisted settings
*/
const clearStorage = (): void => {
localStorage.removeItem(LOCAL_STORAGE_KEY);
};

View File

@ -52,11 +52,7 @@ export const isValidateLocale = (locale: string | undefined | null): boolean =>
return locales.includes(locale);
};
/**
* Get the display name for a locale in its native language
* @param locale - The locale code (e.g., "en", "zh-Hans", "fr")
* @returns The display name with capitalized first letter, or the locale code if display name is unavailable
*/
// Get the display name for a locale in its native language
export const getLocaleDisplayName = (locale: string): string => {
try {
const displayName = new Intl.DisplayNames([locale], { type: "language" }).of(locale);

View File

@ -1,9 +1,3 @@
/**
* Utilities for detecting list patterns in markdown text
*
* Used by the editor for auto-continuation of lists when user presses Enter
*/
export interface ListItemInfo {
type: "task" | "unordered" | "ordered" | null;
symbol?: string; // For task/unordered lists: "- ", "* ", "+ "
@ -11,12 +5,7 @@ export interface ListItemInfo {
indent?: string; // Leading whitespace
}
/**
* Detect the list item type of the last line before cursor
*
* @param contentBeforeCursor - Markdown content from start to cursor position
* @returns List item information, or null if not a list item
*/
// Detect the list item type of the last line before cursor
export function detectLastListItem(contentBeforeCursor: string): ListItemInfo {
const lines = contentBeforeCursor.split("\n");
const lastLine = lines[lines.length - 1];
@ -61,12 +50,7 @@ export function detectLastListItem(contentBeforeCursor: string): ListItemInfo {
};
}
/**
* Generate the text to insert when pressing Enter on a list item
*
* @param listInfo - Information about the current list item
* @returns Text to insert at cursor
*/
// Generate the text to insert when pressing Enter on a list item
export function generateListContinuation(listInfo: ListItemInfo): string {
const indent = listInfo.indent || "";

View File

@ -1,18 +1,6 @@
/**
* Utilities for manipulating markdown strings (GitHub-style approach)
*
* These functions modify the raw markdown text directly without parsing to AST.
* This is the same approach GitHub uses for task list updates.
*/
// Utilities for manipulating markdown strings (GitHub-style approach)
// These functions modify the raw markdown text directly without parsing to AST
/**
* Toggle a task checkbox at a specific line number
*
* @param markdown - The full markdown content
* @param lineNumber - Zero-based line number
* @param checked - New checked state
* @returns Updated markdown string
*/
export function toggleTaskAtLine(markdown: string, lineNumber: number, checked: boolean): string {
const lines = markdown.split("\n");
@ -27,7 +15,6 @@ export function toggleTaskAtLine(markdown: string, lineNumber: number, checked:
const match = line.match(taskPattern);
if (!match) {
// Not a task list item
return markdown;
}
@ -38,14 +25,6 @@ export function toggleTaskAtLine(markdown: string, lineNumber: number, checked:
return lines.join("\n");
}
/**
* Toggle a task checkbox by its index (nth task in the document)
*
* @param markdown - The full markdown content
* @param taskIndex - Zero-based index of the task (0 = first task, 1 = second task, etc.)
* @param checked - New checked state
* @returns Updated markdown string
*/
export function toggleTaskAtIndex(markdown: string, taskIndex: number, checked: boolean): string {
const lines = markdown.split("\n");
const taskPattern = /^(\s*[-*+]\s+)\[([ xX])\](\s+.*)$/;
@ -70,12 +49,6 @@ export function toggleTaskAtIndex(markdown: string, taskIndex: number, checked:
return lines.join("\n");
}
/**
* Remove all completed tasks from markdown
*
* @param markdown - The full markdown content
* @returns Markdown with completed tasks removed
*/
export function removeCompletedTasks(markdown: string): string {
const lines = markdown.split("\n");
const completedTaskPattern = /^(\s*[-*+]\s+)\[([xX])\](\s+.*)$/;
@ -88,7 +61,7 @@ export function removeCompletedTasks(markdown: string): string {
if (completedTaskPattern.test(line)) {
// Also skip the following line if it's empty (preserve spacing)
if (i + 1 < lines.length && lines[i + 1].trim() === "") {
i++; // Skip next line
i++;
}
continue;
}
@ -99,12 +72,6 @@ export function removeCompletedTasks(markdown: string): string {
return result.join("\n");
}
/**
* Count tasks in markdown
*
* @param markdown - The full markdown content
* @returns Object with task counts
*/
export function countTasks(markdown: string): {
total: number;
completed: number;
@ -134,24 +101,11 @@ export function countTasks(markdown: string): {
};
}
/**
* Check if markdown has any completed tasks
*
* @param markdown - The full markdown content
* @returns True if there are completed tasks
*/
export function hasCompletedTasks(markdown: string): boolean {
const completedTaskPattern = /^(\s*[-*+]\s+)\[([xX])\](\s+.*)$/m;
return completedTaskPattern.test(markdown);
}
/**
* Get the line number of the nth task
*
* @param markdown - The full markdown content
* @param taskIndex - Zero-based task index
* @returns Line number, or -1 if not found
*/
export function getTaskLineNumber(markdown: string, taskIndex: number): number {
const lines = markdown.split("\n");
const taskPattern = /^(\s*[-*+]\s+)\[([ xX])\](\s+.*)$/;
@ -170,12 +124,6 @@ export function getTaskLineNumber(markdown: string, taskIndex: number): number {
return -1;
}
/**
* Extract all task items with their metadata
*
* @param markdown - The full markdown content
* @returns Array of task metadata
*/
export interface TaskItem {
lineNumber: number;
taskIndex: number;

View File

@ -1,9 +1,3 @@
/**
* OAuth state management utilities
* Implements secure state parameter handling following Auth0 best practices
* @see https://auth0.com/docs/secure/attack-protection/state-parameters
*/
const STATE_STORAGE_KEY = "oauth_state";
const STATE_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes
@ -14,20 +8,14 @@ interface OAuthState {
returnUrl?: string;
}
/**
* Generate a cryptographically secure random state value
* Uses Web Crypto API for strong randomness
*/
// Generate a cryptographically secure random state value
function generateSecureState(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
/**
* Store OAuth state in sessionStorage with metadata
* State is stored temporarily and will be validated on callback
*/
// Store OAuth state in sessionStorage
export function storeOAuthState(identityProviderId: number, returnUrl?: string): string {
const state = generateSecureState();
const stateData: OAuthState = {
@ -47,11 +35,7 @@ export function storeOAuthState(identityProviderId: number, returnUrl?: string):
return state;
}
/**
* Validate and retrieve OAuth state from storage
* Implements CSRF protection by verifying state matches
* Cleans up expired or used states
*/
// Validate and retrieve OAuth state from storage (CSRF protection)
export function validateOAuthState(stateParam: string): { identityProviderId: number; returnUrl?: string } | null {
try {
const storedData = sessionStorage.getItem(STATE_STORAGE_KEY);
@ -89,10 +73,7 @@ export function validateOAuthState(stateParam: string): { identityProviderId: nu
}
}
/**
* Clean up expired OAuth states
* Should be called on app initialization
*/
// Clean up expired OAuth states (call on app init)
export function cleanupExpiredOAuthState(): void {
try {
const storedData = sessionStorage.getItem(STATE_STORAGE_KEY);

View File

@ -1,15 +1,7 @@
import type { Root } from "mdast";
import { visit } from "unist-util-visit";
/**
* Remark plugin to preserve original mdast node types in the data field
*
* This allows us to check the original node type even after
* transformation to hast (HTML AST).
*
* The original type is stored in data.mdastType and will be available
* in the hast node as data.mdastType.
*/
// Remark plugin to preserve original mdast node types in the data field
export const remarkPreserveType = () => {
return (tree: Root) => {
visit(tree, (node: any) => {

View File

@ -1,38 +1,9 @@
import type { Root, Text } from "mdast";
import { visit } from "unist-util-visit";
/**
* Custom remark plugin for #tag syntax
*
* Parses #tag patterns in text nodes and converts them to HTML nodes.
* This matches the goldmark backend TagNode implementation.
*
* Examples:
* #work <span class="tag" data-tag="work">#work</span>
* #2024_plans <span class="tag" data-tag="2024_plans">#2024_plans</span>
* #work-notes <span class="tag" data-tag="work-notes">#work-notes</span>
* #tag1/subtag/subtag2 <span class="tag" data-tag="tag1/subtag/subtag2">#tag1/subtag/subtag2</span>
*
* Rules:
* - Tag must start with # followed by valid tag characters
* - Valid characters: Unicode letters, Unicode digits, underscore (_), hyphen (-), forward slash (/)
* - Maximum length: 100 characters
* - Stops at: whitespace, punctuation, or other invalid characters
* - Tags at start of line after ## are headings, not tags
*/
const MAX_TAG_LENGTH = 100;
/**
* Check if character is valid for tag content using Unicode categories.
* Uses Unicode property escapes for proper international character support.
*
* Valid characters:
* - \p{L}: Unicode letters (any script: Latin, CJK, Arabic, Cyrillic, etc.)
* - \p{N}: Unicode numbers/digits
* - \p{S}: Unicode symbols (includes emoji)
* - Special symbols: underscore (_), hyphen (-), forward slash (/)
*/
// Check if character is valid for tag content (Unicode letters, digits, symbols, _, -, /)
function isTagChar(char: string): boolean {
// Allow Unicode letters (any script)
if (/\p{L}/u.test(char)) {
@ -62,9 +33,7 @@ function isTagChar(char: string): boolean {
return false;
}
/**
* Parse tags from text and return segments
*/
// Parse tags from text and return segments
function parseTagsFromText(text: string): Array<{ type: "text" | "tag"; value: string }> {
const segments: Array<{ type: "text" | "tag"; value: string }> = [];
let i = 0;
@ -111,9 +80,7 @@ function parseTagsFromText(text: string): Array<{ type: "text" | "tag"; value: s
return segments;
}
/**
* Remark plugin to parse #tag syntax
*/
// Remark plugin to parse #tag syntax
export const remarkTag = () => {
return (tree: Root) => {
// Process text nodes in all node types (paragraphs, headings, etc.)

View File

@ -33,9 +33,6 @@ const validateTheme = (theme: string): ValidTheme => {
return VALID_THEMES.includes(theme as ValidTheme) ? (theme as ValidTheme) : "default";
};
/**
* Detects system theme preference
*/
export const getSystemTheme = (): "default" | "default-dark" => {
if (typeof window !== "undefined" && window.matchMedia) {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "default-dark" : "default";
@ -43,10 +40,7 @@ export const getSystemTheme = (): "default" | "default-dark" => {
return "default";
};
/**
* Resolves the actual theme to apply based on user preference
* If theme is "system", returns the system preference, otherwise returns the theme as-is
*/
// Resolves "system" to actual theme based on OS preference
export const resolveTheme = (theme: string): "default" | "default-dark" | "midnight" | "paper" | "whitewall" => {
if (theme === "system") {
return getSystemTheme();
@ -55,12 +49,9 @@ export const resolveTheme = (theme: string): "default" | "default-dark" | "midni
return validTheme === "system" ? getSystemTheme() : validTheme;
};
/**
* Gets the theme that should be applied on initial load
* Priority: stored user preference -> system preference -> default
*/
// Gets the theme that should be applied on initial load
export const getInitialTheme = (): ValidTheme => {
// Try to get stored theme from localStorage (where user settings might be cached)
// Try to get stored theme from localStorage
try {
const storedTheme = localStorage.getItem("memos-theme");
if (storedTheme && VALID_THEMES.includes(storedTheme as ValidTheme)) {
@ -70,13 +61,10 @@ export const getInitialTheme = (): ValidTheme => {
// localStorage might not be available
}
// Fall back to system preference (return "system" to enable auto-switching)
return "system";
};
/**
* Applies the theme early to prevent flash of wrong theme
*/
// Applies the theme early to prevent flash of wrong theme
export const applyThemeEarly = (): void => {
const theme = getInitialTheme();
loadTheme(theme);
@ -113,10 +101,7 @@ export const loadTheme = (themeName: string): void => {
}
};
/**
* Sets up a listener for system theme preference changes
* Returns a cleanup function to remove the listener
*/
// Sets up a listener for system theme preference changes
export const setupSystemThemeListener = (onThemeChange: () => void): (() => void) => {
if (typeof window === "undefined" || !window.matchMedia) {
return () => {}; // No-op cleanup