mirror of https://github.com/usememos/memos.git
chore: tweak comments
This commit is contained in:
parent
07072b75a7
commit
a6a8997f4c
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
/**
|
||||
* Command type for slash commands in the editor
|
||||
*/
|
||||
export interface Command {
|
||||
name: string;
|
||||
run: () => string;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Hook for managing AbortController lifecycle
|
||||
*/
|
||||
export function useAbortController() {
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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" : "";
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 }>({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]>([]);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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)]",
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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>({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {} });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 || "";
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue