Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note
Commits
a6a8997f
Commit
a6a8997f
authored
Nov 30, 2025
by
Johnny
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
chore: tweak comments
parent
07072b75
Changes
79
Show whitespace changes
Inline
Side-by-side
Showing
79 changed files
with
146 additions
and
1650 deletions
+146
-1650
index.tsx
web/src/components/ConfirmDialog/index.tsx
+0
-14
MasonryColumn.tsx
web/src/components/MasonryView/MasonryColumn.tsx
+0
-9
MasonryItem.tsx
web/src/components/MasonryView/MasonryItem.tsx
+0
-13
MasonryView.tsx
web/src/components/MasonryView/MasonryView.tsx
+0
-18
constants.ts
web/src/components/MasonryView/constants.ts
+0
-8
distributeItems.ts
web/src/components/MasonryView/distributeItems.ts
+0
-26
types.ts
web/src/components/MasonryView/types.ts
+0
-40
useMasonryLayout.ts
web/src/components/MasonryView/useMasonryLayout.ts
+0
-46
MemoActionMenu.tsx
web/src/components/MemoActionMenu/MemoActionMenu.tsx
+0
-9
hooks.ts
web/src/components/MemoActionMenu/hooks.ts
+0
-3
types.ts
web/src/components/MemoActionMenu/types.ts
+0
-10
ConditionalComponent.tsx
web/src/components/MemoContent/ConditionalComponent.tsx
+1
-18
MemoContentContext.tsx
web/src/components/MemoContent/MemoContentContext.tsx
+0
-16
MermaidBlock.tsx
web/src/components/MemoContent/MermaidBlock.tsx
+0
-3
Tag.tsx
web/src/components/MemoContent/Tag.tsx
+0
-10
TaskListItem.tsx
web/src/components/MemoContent/TaskListItem.tsx
+0
-10
CommandSuggestions.tsx
web/src/components/MemoEditor/Editor/CommandSuggestions.tsx
+0
-9
SuggestionsPopup.tsx
web/src/components/MemoEditor/Editor/SuggestionsPopup.tsx
+0
-10
TagSuggestions.tsx
web/src/components/MemoEditor/Editor/TagSuggestions.tsx
+0
-10
commands.ts
web/src/components/MemoEditor/Editor/commands.ts
+0
-3
index.tsx
web/src/components/MemoEditor/Editor/index.tsx
+0
-4
markdownShortcuts.ts
web/src/components/MemoEditor/Editor/markdownShortcuts.ts
+4
-27
useListAutoCompletion.ts
...src/components/MemoEditor/Editor/useListAutoCompletion.ts
+0
-13
useSuggestions.ts
web/src/components/MemoEditor/Editor/useSuggestions.ts
+0
-36
InsertMenu.tsx
web/src/components/MemoEditor/Toolbar/InsertMenu.tsx
+1
-5
ErrorBoundary.tsx
web/src/components/MemoEditor/components/ErrorBoundary.tsx
+0
-5
FocusModeOverlay.tsx
...src/components/MemoEditor/components/FocusModeOverlay.tsx
+0
-8
LinkMemoDialog.tsx
web/src/components/MemoEditor/components/LinkMemoDialog.tsx
+0
-3
constants.ts
web/src/components/MemoEditor/constants.ts
+0
-32
useAbortController.ts
web/src/components/MemoEditor/hooks/useAbortController.ts
+0
-3
useBlobUrls.ts
web/src/components/MemoEditor/hooks/useBlobUrls.ts
+0
-3
useDragAndDrop.ts
web/src/components/MemoEditor/hooks/useDragAndDrop.ts
+0
-3
useFocusMode.ts
web/src/components/MemoEditor/hooks/useFocusMode.ts
+0
-3
useLocalFileManager.ts
web/src/components/MemoEditor/hooks/useLocalFileManager.ts
+0
-29
useMemoEditorHandlers.ts
web/src/components/MemoEditor/hooks/useMemoEditorHandlers.ts
+0
-4
useMemoEditorInit.ts
web/src/components/MemoEditor/hooks/useMemoEditorInit.ts
+0
-4
useMemoEditorKeyboard.ts
web/src/components/MemoEditor/hooks/useMemoEditorKeyboard.ts
+0
-4
useMemoEditorState.ts
web/src/components/MemoEditor/hooks/useMemoEditorState.ts
+0
-3
useMemoSave.ts
web/src/components/MemoEditor/hooks/useMemoSave.ts
+0
-25
context.ts
web/src/components/MemoEditor/types/context.ts
+0
-12
MemoExplorer.tsx
web/src/components/MemoExplorer/MemoExplorer.tsx
+0
-45
MemoExplorerDrawer.tsx
web/src/components/MemoExplorer/MemoExplorerDrawer.tsx
+0
-15
TagsSection.tsx
web/src/components/MemoExplorer/TagsSection.tsx
+0
-4
index.ts
web/src/components/MemoView/index.ts
+0
-10
types.ts
web/src/components/MemoView/types.ts
+70
-0
SettingGroup.tsx
web/src/components/Settings/SettingGroup.tsx
+0
-4
SettingRow.tsx
web/src/components/Settings/SettingRow.tsx
+0
-4
SettingSection.tsx
web/src/components/Settings/SettingSection.tsx
+0
-4
SettingTable.tsx
web/src/components/Settings/SettingTable.tsx
+0
-4
StatisticsView.tsx
web/src/components/StatisticsView/StatisticsView.tsx
+2
-10
AttachmentCard.tsx
web/src/components/memo-metadata/AttachmentCard.tsx
+0
-5
AttachmentList.tsx
web/src/components/memo-metadata/AttachmentList.tsx
+0
-14
MetadataCard.tsx
web/src/components/memo-metadata/MetadataCard.tsx
+0
-4
RelationCard.tsx
web/src/components/memo-metadata/RelationCard.tsx
+0
-6
RelationList.tsx
web/src/components/memo-metadata/RelationList.tsx
+0
-14
index.ts
web/src/components/memo-metadata/index.ts
+0
-5
types.ts
web/src/components/memo-metadata/types.ts
+1
-43
dialog.tsx
web/src/components/ui/dialog.tsx
+0
-16
useDialog.ts
web/src/hooks/useDialog.ts
+0
-43
useFilteredMemoStats.ts
web/src/hooks/useFilteredMemoStats.ts
+0
-36
useMemoFilters.ts
web/src/hooks/useMemoFilters.ts
+0
-63
useMemoSorting.ts
web/src/hooks/useMemoSorting.ts
+0
-39
Attachments.tsx
web/src/pages/Attachments.tsx
+0
-9
attachment.ts
web/src/store/attachment.ts
+2
-63
base-store.ts
web/src/store/base-store.ts
+4
-85
config.ts
web/src/store/config.ts
+11
-51
index.ts
web/src/store/index.ts
+3
-50
instance.ts
web/src/store/instance.ts
+4
-77
memoFilter.ts
web/src/store/memoFilter.ts
+10
-140
store-utils.ts
web/src/store/store-utils.ts
+5
-51
user.ts
web/src/store/user.ts
+6
-16
view.ts
web/src/store/view.ts
+2
-42
i18n.ts
web/src/utils/i18n.ts
+1
-5
markdown-list-detection.ts
web/src/utils/markdown-list-detection.ts
+2
-18
markdown-manipulation.ts
web/src/utils/markdown-manipulation.ts
+4
-56
oauth.ts
web/src/utils/oauth.ts
+4
-23
remark-preserve-type.ts
web/src/utils/remark-plugins/remark-preserve-type.ts
+1
-9
remark-tag.ts
web/src/utils/remark-plugins/remark-tag.ts
+3
-36
theme.ts
web/src/utils/theme.ts
+5
-20
No files found.
web/src/components/ConfirmDialog/index.tsx
View file @
a6a8997f
...
@@ -3,30 +3,16 @@ import { Button } from "@/components/ui/button";
...
@@ -3,30 +3,16 @@ import { Button } from "@/components/ui/button";
import
{
Dialog
,
DialogContent
,
DialogDescription
,
DialogFooter
,
DialogHeader
,
DialogTitle
}
from
"@/components/ui/dialog"
;
import
{
Dialog
,
DialogContent
,
DialogDescription
,
DialogFooter
,
DialogHeader
,
DialogTitle
}
from
"@/components/ui/dialog"
;
export
interface
ConfirmDialogProps
{
export
interface
ConfirmDialogProps
{
/** Whether the dialog is open */
open
:
boolean
;
open
:
boolean
;
/** Open state change callback (closing disabled while loading) */
onOpenChange
:
(
open
:
boolean
)
=>
void
;
onOpenChange
:
(
open
:
boolean
)
=>
void
;
/** Title content (plain text or React nodes) */
title
:
React
.
ReactNode
;
title
:
React
.
ReactNode
;
/** Optional description (plain text or React nodes) */
description
?:
React
.
ReactNode
;
description
?:
React
.
ReactNode
;
/** Confirm / primary action button label */
confirmLabel
:
string
;
confirmLabel
:
string
;
/** Cancel button label */
cancelLabel
:
string
;
cancelLabel
:
string
;
/** Async or sync confirm handler. Dialog auto-closes on resolve, stays open on reject */
onConfirm
:
()
=>
void
|
Promise
<
void
>
;
onConfirm
:
()
=>
void
|
Promise
<
void
>
;
/** Variant style of confirm button */
confirmVariant
?:
"default"
|
"destructive"
;
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
({
export
default
function
ConfirmDialog
({
open
,
open
,
onOpenChange
,
onOpenChange
,
...
...
web/src/components/MasonryView/MasonryColumn.tsx
View file @
a6a8997f
import
{
MasonryItem
}
from
"./MasonryItem"
;
import
{
MasonryItem
}
from
"./MasonryItem"
;
import
{
MasonryColumnProps
}
from
"./types"
;
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
({
export
function
MasonryColumn
({
memoIndices
,
memoIndices
,
memoList
,
memoList
,
...
...
web/src/components/MasonryView/MasonryItem.tsx
View file @
a6a8997f
import
{
useEffect
,
useRef
}
from
"react"
;
import
{
useEffect
,
useRef
}
from
"react"
;
import
{
MasonryItemProps
}
from
"./types"
;
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
)
{
export
function
MasonryItem
({
memo
,
renderer
,
renderContext
,
onHeightChange
}:
MasonryItemProps
)
{
const
itemRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
itemRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
resizeObserverRef
=
useRef
<
ResizeObserver
|
null
>
(
null
);
const
resizeObserverRef
=
useRef
<
ResizeObserver
|
null
>
(
null
);
...
...
web/src/components/MasonryView/MasonryView.tsx
View file @
a6a8997f
...
@@ -4,24 +4,6 @@ import { MasonryColumn } from "./MasonryColumn";
...
@@ -4,24 +4,6 @@ import { MasonryColumn } from "./MasonryColumn";
import
{
MasonryViewProps
,
MemoRenderContext
}
from
"./types"
;
import
{
MasonryViewProps
,
MemoRenderContext
}
from
"./types"
;
import
{
useMasonryLayout
}
from
"./useMasonryLayout"
;
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
MasonryView
=
({
memoList
,
renderer
,
prefixElement
,
listMode
=
false
}:
MasonryViewProps
)
=>
{
const
containerRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
containerRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
prefixElementRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
prefixElementRef
=
useRef
<
HTMLDivElement
>
(
null
);
...
...
web/src/components/MasonryView/constants.ts
View file @
a6a8997f
/**
* 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
;
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
;
export
const
REDISTRIBUTION_DEBOUNCE_MS
=
100
;
web/src/components/MasonryView/distributeItems.ts
View file @
a6a8997f
import
{
Memo
}
from
"@/types/proto/api/v1/memo_service"
;
import
{
Memo
}
from
"@/types/proto/api/v1/memo_service"
;
import
{
DistributionResult
}
from
"./types"
;
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
(
export
function
distributeItemsToColumns
(
memos
:
Memo
[],
memos
:
Memo
[],
columns
:
number
,
columns
:
number
,
itemHeights
:
Map
<
string
,
number
>
,
itemHeights
:
Map
<
string
,
number
>
,
prefixElementHeight
:
number
=
0
,
prefixElementHeight
:
number
=
0
,
):
DistributionResult
{
):
DistributionResult
{
// Single column mode: all memos in one column
if
(
columns
===
1
)
{
if
(
columns
===
1
)
{
const
totalHeight
=
memos
.
reduce
((
sum
,
memo
)
=>
sum
+
(
itemHeights
.
get
(
memo
.
name
)
||
0
),
prefixElementHeight
);
const
totalHeight
=
memos
.
reduce
((
sum
,
memo
)
=>
sum
+
(
itemHeights
.
get
(
memo
.
name
)
||
0
),
prefixElementHeight
);
return
{
return
{
...
@@ -30,19 +15,16 @@ export function distributeItemsToColumns(
...
@@ -30,19 +15,16 @@ export function distributeItemsToColumns(
};
};
}
}
// Initialize columns and their heights
const
distribution
:
number
[][]
=
Array
.
from
({
length
:
columns
},
()
=>
[]);
const
distribution
:
number
[][]
=
Array
.
from
({
length
:
columns
},
()
=>
[]);
const
columnHeights
:
number
[]
=
Array
(
columns
).
fill
(
0
);
const
columnHeights
:
number
[]
=
Array
(
columns
).
fill
(
0
);
const
columnCounts
:
number
[]
=
Array
(
columns
).
fill
(
0
);
const
columnCounts
:
number
[]
=
Array
(
columns
).
fill
(
0
);
// Add prefix element height to first column
if
(
prefixElementHeight
>
0
)
{
if
(
prefixElementHeight
>
0
)
{
columnHeights
[
0
]
=
prefixElementHeight
;
columnHeights
[
0
]
=
prefixElementHeight
;
}
}
let
startIndex
=
0
;
let
startIndex
=
0
;
// Pin the first memo to the first column to keep top-of-feed stable
if
(
memos
.
length
>
0
)
{
if
(
memos
.
length
>
0
)
{
const
firstMemoHeight
=
itemHeights
.
get
(
memos
[
0
].
name
)
||
0
;
const
firstMemoHeight
=
itemHeights
.
get
(
memos
[
0
].
name
)
||
0
;
distribution
[
0
].
push
(
0
);
distribution
[
0
].
push
(
0
);
...
@@ -55,7 +37,6 @@ export function distributeItemsToColumns(
...
@@ -55,7 +37,6 @@ export function distributeItemsToColumns(
const
memo
=
memos
[
i
];
const
memo
=
memos
[
i
];
const
height
=
itemHeights
.
get
(
memo
.
name
)
||
0
;
const
height
=
itemHeights
.
get
(
memo
.
name
)
||
0
;
// Find column with minimum height
const
shortestColumnIndex
=
findShortestColumnIndex
(
columnHeights
,
columnCounts
);
const
shortestColumnIndex
=
findShortestColumnIndex
(
columnHeights
,
columnCounts
);
distribution
[
shortestColumnIndex
].
push
(
i
);
distribution
[
shortestColumnIndex
].
push
(
i
);
...
@@ -66,12 +47,6 @@ export function distributeItemsToColumns(
...
@@ -66,12 +47,6 @@ export function distributeItemsToColumns(
return
{
distribution
,
columnHeights
};
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
{
function
findShortestColumnIndex
(
columnHeights
:
number
[],
columnCounts
:
number
[]):
number
{
let
minIndex
=
0
;
let
minIndex
=
0
;
let
minHeight
=
columnHeights
[
0
];
let
minHeight
=
columnHeights
[
0
];
...
@@ -84,7 +59,6 @@ function findShortestColumnIndex(columnHeights: number[], columnCounts: number[]
...
@@ -84,7 +59,6 @@ function findShortestColumnIndex(columnHeights: number[], columnCounts: number[]
continue
;
continue
;
}
}
// Tie-breaker: prefer column with fewer items to avoid stacking
if
(
currentHeight
===
minHeight
&&
columnCounts
[
i
]
<
columnCounts
[
minIndex
])
{
if
(
currentHeight
===
minHeight
&&
columnCounts
[
i
]
<
columnCounts
[
minIndex
])
{
minIndex
=
i
;
minIndex
=
i
;
}
}
...
...
web/src/components/MasonryView/types.ts
View file @
a6a8997f
import
{
Memo
}
from
"@/types/proto/api/v1/memo_service"
;
import
{
Memo
}
from
"@/types/proto/api/v1/memo_service"
;
/**
* Render context passed to memo renderer
*/
export
interface
MemoRenderContext
{
export
interface
MemoRenderContext
{
/** Whether to render in compact mode (automatically enabled for multi-column layouts) */
compact
:
boolean
;
compact
:
boolean
;
/** Current number of columns in the layout */
columns
:
number
;
columns
:
number
;
}
}
/**
* Props for the main MasonryView component
*/
export
interface
MasonryViewProps
{
export
interface
MasonryViewProps
{
/** List of memos to display in masonry layout */
memoList
:
Memo
[];
memoList
:
Memo
[];
/** Render function for each memo. Second parameter provides layout context. */
renderer
:
(
memo
:
Memo
,
context
?:
MemoRenderContext
)
=>
JSX
.
Element
;
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
;
prefixElement
?:
JSX
.
Element
;
/** Force single column layout regardless of viewport width */
listMode
?:
boolean
;
listMode
?:
boolean
;
}
}
/**
* Props for individual MasonryItem component
*/
export
interface
MasonryItemProps
{
export
interface
MasonryItemProps
{
/** The memo to render */
memo
:
Memo
;
memo
:
Memo
;
/** Render function for the memo */
renderer
:
(
memo
:
Memo
,
context
?:
MemoRenderContext
)
=>
JSX
.
Element
;
renderer
:
(
memo
:
Memo
,
context
?:
MemoRenderContext
)
=>
JSX
.
Element
;
/** Render context for the memo */
renderContext
:
MemoRenderContext
;
renderContext
:
MemoRenderContext
;
/** Callback when item height changes */
onHeightChange
:
(
memoName
:
string
,
height
:
number
)
=>
void
;
onHeightChange
:
(
memoName
:
string
,
height
:
number
)
=>
void
;
}
}
/**
* Props for MasonryColumn component
*/
export
interface
MasonryColumnProps
{
export
interface
MasonryColumnProps
{
/** Indices of memos in this column */
memoIndices
:
number
[];
memoIndices
:
number
[];
/** Full list of memos */
memoList
:
Memo
[];
memoList
:
Memo
[];
/** Render function for each memo */
renderer
:
(
memo
:
Memo
,
context
?:
MemoRenderContext
)
=>
JSX
.
Element
;
renderer
:
(
memo
:
Memo
,
context
?:
MemoRenderContext
)
=>
JSX
.
Element
;
/** Render context for memos */
renderContext
:
MemoRenderContext
;
renderContext
:
MemoRenderContext
;
/** Callback when item height changes */
onHeightChange
:
(
memoName
:
string
,
height
:
number
)
=>
void
;
onHeightChange
:
(
memoName
:
string
,
height
:
number
)
=>
void
;
/** Whether this is the first column (for prefix element) */
isFirstColumn
:
boolean
;
isFirstColumn
:
boolean
;
/** Optional prefix element (only rendered in first column) */
prefixElement
?:
JSX
.
Element
;
prefixElement
?:
JSX
.
Element
;
/** Ref for prefix element height measurement */
prefixElementRef
?:
React
.
RefObject
<
HTMLDivElement
>
;
prefixElementRef
?:
React
.
RefObject
<
HTMLDivElement
>
;
}
}
/**
* Result of the distribution algorithm
*/
export
interface
DistributionResult
{
export
interface
DistributionResult
{
/** Array of arrays, where each inner array contains memo indices for that column */
distribution
:
number
[][];
distribution
:
number
[][];
/** Height of each column after distribution */
columnHeights
:
number
[];
columnHeights
:
number
[];
}
}
/**
* Memo item with measured height
*/
export
interface
MemoWithHeight
{
export
interface
MemoWithHeight
{
/** Index of the memo in the original list */
index
:
number
;
index
:
number
;
/** Measured height in pixels */
height
:
number
;
height
:
number
;
}
}
web/src/components/MasonryView/useMasonryLayout.ts
View file @
a6a8997f
...
@@ -3,21 +3,6 @@ import { Memo } from "@/types/proto/api/v1/memo_service";
...
@@ -3,21 +3,6 @@ import { Memo } from "@/types/proto/api/v1/memo_service";
import
{
MINIMUM_MEMO_VIEWPORT_WIDTH
,
REDISTRIBUTION_DEBOUNCE_MS
}
from
"./constants"
;
import
{
MINIMUM_MEMO_VIEWPORT_WIDTH
,
REDISTRIBUTION_DEBOUNCE_MS
}
from
"./constants"
;
import
{
distributeItemsToColumns
}
from
"./distributeItems"
;
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
(
export
function
useMasonryLayout
(
memoList
:
Memo
[],
memoList
:
Memo
[],
listMode
:
boolean
,
listMode
:
boolean
,
...
@@ -31,28 +16,18 @@ export function useMasonryLayout(
...
@@ -31,28 +16,18 @@ export function useMasonryLayout(
const
redistributionTimeoutRef
=
useRef
<
number
|
null
>
(
null
);
const
redistributionTimeoutRef
=
useRef
<
number
|
null
>
(
null
);
const
itemHeightsRef
=
useRef
<
Map
<
string
,
number
>>
(
itemHeights
);
const
itemHeightsRef
=
useRef
<
Map
<
string
,
number
>>
(
itemHeights
);
// Keep ref in sync with state
useEffect
(()
=>
{
useEffect
(()
=>
{
itemHeightsRef
.
current
=
itemHeights
;
itemHeightsRef
.
current
=
itemHeights
;
},
[
itemHeights
]);
},
[
itemHeights
]);
/**
* Calculate optimal number of columns based on container width
* Uses a scale factor to determine column count
*/
const
calculateColumns
=
useCallback
(()
=>
{
const
calculateColumns
=
useCallback
(()
=>
{
if
(
!
containerRef
.
current
||
listMode
)
return
1
;
if
(
!
containerRef
.
current
||
listMode
)
return
1
;
const
containerWidth
=
containerRef
.
current
.
offsetWidth
;
const
containerWidth
=
containerRef
.
current
.
offsetWidth
;
const
scale
=
containerWidth
/
MINIMUM_MEMO_VIEWPORT_WIDTH
;
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
;
return
scale
>=
1.2
?
Math
.
ceil
(
scale
)
:
1
;
},
[
containerRef
,
listMode
]);
},
[
containerRef
,
listMode
]);
/**
* Recalculate memo distribution when layout changes
*/
const
redistributeMemos
=
useCallback
(()
=>
{
const
redistributeMemos
=
useCallback
(()
=>
{
const
prefixHeight
=
prefixElementRef
.
current
?.
offsetHeight
||
0
;
const
prefixHeight
=
prefixElementRef
.
current
?.
offsetHeight
||
0
;
setDistribution
(()
=>
{
setDistribution
(()
=>
{
...
@@ -61,17 +36,12 @@ export function useMasonryLayout(
...
@@ -61,17 +36,12 @@ export function useMasonryLayout(
});
});
},
[
memoList
,
columns
,
prefixElementRef
]);
},
[
memoList
,
columns
,
prefixElementRef
]);
/**
* Debounced redistribution to batch multiple height changes and prevent excessive reflows
*/
const
debouncedRedistribute
=
useCallback
(
const
debouncedRedistribute
=
useCallback
(
(
newItemHeights
:
Map
<
string
,
number
>
)
=>
{
(
newItemHeights
:
Map
<
string
,
number
>
)
=>
{
// Clear any pending redistribution
if
(
redistributionTimeoutRef
.
current
)
{
if
(
redistributionTimeoutRef
.
current
)
{
clearTimeout
(
redistributionTimeoutRef
.
current
);
clearTimeout
(
redistributionTimeoutRef
.
current
);
}
}
// Schedule new redistribution after debounce delay
redistributionTimeoutRef
.
current
=
window
.
setTimeout
(()
=>
{
redistributionTimeoutRef
.
current
=
window
.
setTimeout
(()
=>
{
const
prefixHeight
=
prefixElementRef
.
current
?.
offsetHeight
||
0
;
const
prefixHeight
=
prefixElementRef
.
current
?.
offsetHeight
||
0
;
setDistribution
(()
=>
{
setDistribution
(()
=>
{
...
@@ -83,34 +53,24 @@ export function useMasonryLayout(
...
@@ -83,34 +53,24 @@ export function useMasonryLayout(
[
memoList
,
columns
,
prefixElementRef
],
[
memoList
,
columns
,
prefixElementRef
],
);
);
/**
* Handle height changes from individual memo items
*/
const
handleHeightChange
=
useCallback
(
const
handleHeightChange
=
useCallback
(
(
memoName
:
string
,
height
:
number
)
=>
{
(
memoName
:
string
,
height
:
number
)
=>
{
setItemHeights
((
prevHeights
)
=>
{
setItemHeights
((
prevHeights
)
=>
{
const
newItemHeights
=
new
Map
(
prevHeights
);
const
newItemHeights
=
new
Map
(
prevHeights
);
const
previousHeight
=
prevHeights
.
get
(
memoName
);
const
previousHeight
=
prevHeights
.
get
(
memoName
);
// Skip if height hasn't changed (avoid unnecessary updates)
if
(
previousHeight
===
height
)
{
if
(
previousHeight
===
height
)
{
return
prevHeights
;
return
prevHeights
;
}
}
newItemHeights
.
set
(
memoName
,
height
);
newItemHeights
.
set
(
memoName
,
height
);
// Use debounced redistribution to batch updates
debouncedRedistribute
(
newItemHeights
);
debouncedRedistribute
(
newItemHeights
);
return
newItemHeights
;
return
newItemHeights
;
});
});
},
},
[
debouncedRedistribute
],
[
debouncedRedistribute
],
);
);
/**
* Handle window resize and calculate new column count
*/
useEffect
(()
=>
{
useEffect
(()
=>
{
const
handleResize
=
()
=>
{
const
handleResize
=
()
=>
{
if
(
!
containerRef
.
current
)
return
;
if
(
!
containerRef
.
current
)
return
;
...
@@ -126,16 +86,10 @@ export function useMasonryLayout(
...
@@ -126,16 +86,10 @@ export function useMasonryLayout(
return
()
=>
window
.
removeEventListener
(
"resize"
,
handleResize
);
return
()
=>
window
.
removeEventListener
(
"resize"
,
handleResize
);
},
[
calculateColumns
,
columns
,
containerRef
]);
},
[
calculateColumns
,
columns
,
containerRef
]);
/**
* Redistribute memos when columns or memo list change
*/
useEffect
(()
=>
{
useEffect
(()
=>
{
redistributeMemos
();
redistributeMemos
();
},
[
columns
,
memoList
,
redistributeMemos
]);
},
[
columns
,
memoList
,
redistributeMemos
]);
/**
* Cleanup timeout on unmount
*/
useEffect
(()
=>
{
useEffect
(()
=>
{
return
()
=>
{
return
()
=>
{
if
(
redistributionTimeoutRef
.
current
)
{
if
(
redistributionTimeoutRef
.
current
)
{
...
...
web/src/components/MemoActionMenu/MemoActionMenu.tsx
View file @
a6a8997f
...
@@ -30,15 +30,6 @@ import { hasCompletedTasks } from "@/utils/markdown-manipulation";
...
@@ -30,15 +30,6 @@ import { hasCompletedTasks } from "@/utils/markdown-manipulation";
import
{
useMemoActionHandlers
}
from
"./hooks"
;
import
{
useMemoActionHandlers
}
from
"./hooks"
;
import
type
{
MemoActionMenuProps
}
from
"./types"
;
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
MemoActionMenu
=
observer
((
props
:
MemoActionMenuProps
)
=>
{
const
{
memo
,
readonly
}
=
props
;
const
{
memo
,
readonly
}
=
props
;
const
t
=
useTranslate
();
const
t
=
useTranslate
();
...
...
web/src/components/MemoActionMenu/hooks.ts
View file @
a6a8997f
...
@@ -16,9 +16,6 @@ interface UseMemoActionHandlersOptions {
...
@@ -16,9 +16,6 @@ interface UseMemoActionHandlersOptions {
setRemoveTasksDialogOpen
:
(
open
:
boolean
)
=>
void
;
setRemoveTasksDialogOpen
:
(
open
:
boolean
)
=>
void
;
}
}
/**
* Hook for handling memo action menu operations
*/
export
const
useMemoActionHandlers
=
({
memo
,
onEdit
,
setDeleteDialogOpen
,
setRemoveTasksDialogOpen
}:
UseMemoActionHandlersOptions
)
=>
{
export
const
useMemoActionHandlers
=
({
memo
,
onEdit
,
setDeleteDialogOpen
,
setRemoveTasksDialogOpen
}:
UseMemoActionHandlersOptions
)
=>
{
const
t
=
useTranslate
();
const
t
=
useTranslate
();
const
location
=
useLocation
();
const
location
=
useLocation
();
...
...
web/src/components/MemoActionMenu/types.ts
View file @
a6a8997f
import
type
{
Memo
}
from
"@/types/proto/api/v1/memo_service"
;
import
type
{
Memo
}
from
"@/types/proto/api/v1/memo_service"
;
/**
* Props for MemoActionMenu component
*/
export
interface
MemoActionMenuProps
{
export
interface
MemoActionMenuProps
{
/** The memo to display actions for */
memo
:
Memo
;
memo
:
Memo
;
/** Whether the current user can only view (not edit) */
readonly
?:
boolean
;
readonly
?:
boolean
;
/** Additional CSS classes */
className
?:
string
;
className
?:
string
;
/** Callback when edit action is triggered */
onEdit
?:
()
=>
void
;
onEdit
?:
()
=>
void
;
}
}
/**
* Return type for useMemoActionHandlers hook
*/
export
interface
UseMemoActionHandlersReturn
{
export
interface
UseMemoActionHandlersReturn
{
handleTogglePinMemoBtnClick
:
()
=>
Promise
<
void
>
;
handleTogglePinMemoBtnClick
:
()
=>
Promise
<
void
>
;
handleEditMemoClick
:
()
=>
void
;
handleEditMemoClick
:
()
=>
void
;
...
...
web/src/components/MemoContent/ConditionalComponent.tsx
View file @
a6a8997f
import
React
from
"react"
;
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
>
>
(
export
const
createConditionalComponent
=
<
P
extends
Record
<
string
,
any
>
>
(
CustomComponent: React.ComponentType
<
P
>
,
CustomComponent: React.ComponentType
<
P
>
,
DefaultComponent: React.ComponentType
<
P
>
| keyof JSX.IntrinsicElements,
DefaultComponent: React.ComponentType
<
P
>
| keyof JSX.IntrinsicElements,
...
@@ -32,13 +21,7 @@ export const createConditionalComponent = <P extends Record<string, any>>(
...
@@ -32,13 +21,7 @@ export const createConditionalComponent = <P extends Record<string, any>>(
};
};
}
;
}
;
/**
// Condition checkers for AST node types
* 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
*/
export const isTagNode = (node: any): boolean =
>
{
export const isTagNode = (node: any): boolean =
>
{
// Check preserved mdast type first
// Check preserved mdast type first
if
(
node
?.
data
?.
mdastType
===
"tagNode"
)
{
if
(
node
?.
data
?.
mdastType
===
"tagNode"
)
{
...
...
web/src/components/MemoContent/MemoContentContext.tsx
View file @
a6a8997f
import
{
createContext
}
from
"react"
;
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
{
export
interface
MemoContentContextType
{
/** The memo resource name (e.g., "memos/123") */
memoName
?:
string
;
memoName
?:
string
;
/** Whether content is readonly (non-editable) */
readonly
:
boolean
;
readonly
:
boolean
;
/** Whether to disable tag/link filtering */
disableFilter
?:
boolean
;
disableFilter
?:
boolean
;
/** Parent page path (for navigation) */
parentPage
?:
string
;
parentPage
?:
string
;
/** Reference to the container element for the memo content */
containerRef
?:
React
.
RefObject
<
HTMLDivElement
>
;
containerRef
?:
React
.
RefObject
<
HTMLDivElement
>
;
}
}
...
...
web/src/components/MemoContent/MermaidBlock.tsx
View file @
a6a8997f
...
@@ -10,9 +10,6 @@ interface MermaidBlockProps {
...
@@ -10,9 +10,6 @@ interface MermaidBlockProps {
className
?:
string
;
className
?:
string
;
}
}
/**
* Maps app theme to Mermaid theme
*/
const
getMermaidTheme
=
(
appTheme
:
string
):
"default"
|
"dark"
=>
{
const
getMermaidTheme
=
(
appTheme
:
string
):
"default"
|
"dark"
=>
{
return
appTheme
===
"default-dark"
?
"dark"
:
"default"
;
return
appTheme
===
"default-dark"
?
"dark"
:
"default"
;
};
};
...
...
web/src/components/MemoContent/Tag.tsx
View file @
a6a8997f
...
@@ -7,16 +7,6 @@ import { memoFilterStore } from "@/store";
...
@@ -7,16 +7,6 @@ import { memoFilterStore } from "@/store";
import
{
MemoFilter
,
stringifyFilters
}
from
"@/store/memoFilter"
;
import
{
MemoFilter
,
stringifyFilters
}
from
"@/store/memoFilter"
;
import
{
MemoContentContext
}
from
"./MemoContentContext"
;
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
>
{
interface
TagProps
extends
React
.
HTMLAttributes
<
HTMLSpanElement
>
{
node
?:
any
;
// AST node from react-markdown
node
?:
any
;
// AST node from react-markdown
"data-tag"
?:
string
;
"data-tag"
?:
string
;
...
...
web/src/components/MemoContent/TaskListItem.tsx
View file @
a6a8997f
...
@@ -4,16 +4,6 @@ import { memoStore } from "@/store";
...
@@ -4,16 +4,6 @@ import { memoStore } from "@/store";
import
{
toggleTaskAtIndex
}
from
"@/utils/markdown-manipulation"
;
import
{
toggleTaskAtIndex
}
from
"@/utils/markdown-manipulation"
;
import
{
MemoContentContext
}
from
"./MemoContentContext"
;
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
>
{
interface
TaskListItemProps
extends
React
.
InputHTMLAttributes
<
HTMLInputElement
>
{
node
?:
any
;
// AST node from react-markdown
node
?:
any
;
// AST node from react-markdown
checked
?:
boolean
;
checked
?:
boolean
;
...
...
web/src/components/MemoEditor/Editor/CommandSuggestions.tsx
View file @
a6a8997f
...
@@ -11,15 +11,6 @@ interface CommandSuggestionsProps {
...
@@ -11,15 +11,6 @@ interface CommandSuggestionsProps {
commands
:
Command
[];
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
CommandSuggestions
=
observer
(({
editorRef
,
editorActions
,
commands
}:
CommandSuggestionsProps
)
=>
{
const
{
position
,
suggestions
,
selectedIndex
,
isVisible
,
handleItemSelect
}
=
useSuggestions
({
const
{
position
,
suggestions
,
selectedIndex
,
isVisible
,
handleItemSelect
}
=
useSuggestions
({
editorRef
,
editorRef
,
...
...
web/src/components/MemoEditor/Editor/SuggestionsPopup.tsx
View file @
a6a8997f
...
@@ -11,16 +11,6 @@ interface SuggestionsPopupProps<T> {
...
@@ -11,16 +11,6 @@ interface SuggestionsPopupProps<T> {
getItemKey
:
(
item
:
T
,
index
:
number
)
=>
string
;
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
>
({
export
function
SuggestionsPopup
<
T
>
({
position
,
position
,
suggestions
,
suggestions
,
...
...
web/src/components/MemoEditor/Editor/TagSuggestions.tsx
View file @
a6a8997f
...
@@ -11,16 +11,6 @@ interface TagSuggestionsProps {
...
@@ -11,16 +11,6 @@ interface TagSuggestionsProps {
editorActions
:
React
.
ForwardedRef
<
EditorRefActions
>
;
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
)
=>
{
const
TagSuggestions
=
observer
(({
editorRef
,
editorActions
}:
TagSuggestionsProps
)
=>
{
// Sort tags by usage count (descending), then alphabetically for ties
// Sort tags by usage count (descending), then alphabetically for ties
const
sortedTags
=
useMemo
(
const
sortedTags
=
useMemo
(
...
...
web/src/components/MemoEditor/Editor/commands.ts
View file @
a6a8997f
/**
* Command type for slash commands in the editor
*/
export
interface
Command
{
export
interface
Command
{
name
:
string
;
name
:
string
;
run
:
()
=>
string
;
run
:
()
=>
string
;
...
...
web/src/components/MemoEditor/Editor/index.tsx
View file @
a6a8997f
...
@@ -28,13 +28,9 @@ interface Props {
...
@@ -28,13 +28,9 @@ interface Props {
placeholder
:
string
;
placeholder
:
string
;
onContentChange
:
(
content
:
string
)
=>
void
;
onContentChange
:
(
content
:
string
)
=>
void
;
onPaste
:
(
event
:
React
.
ClipboardEvent
)
=>
void
;
onPaste
:
(
event
:
React
.
ClipboardEvent
)
=>
void
;
/** Whether Focus Mode is active - adjusts height constraints for immersive writing */
isFocusMode
?:
boolean
;
isFocusMode
?:
boolean
;
/** Whether IME composition is in progress (for Asian language input) */
isInIME
?:
boolean
;
isInIME
?:
boolean
;
/** Called when IME composition starts */
onCompositionStart
?:
()
=>
void
;
onCompositionStart
?:
()
=>
void
;
/** Called when IME composition ends */
onCompositionEnd
?:
()
=>
void
;
onCompositionEnd
?:
()
=>
void
;
}
}
...
...
web/src/components/MemoEditor/Editor/markdownShortcuts.ts
View file @
a6a8997f
import
type
{
EditorRefActions
}
from
"./index"
;
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
{
export
function
handleMarkdownShortcuts
(
event
:
React
.
KeyboardEvent
,
editor
:
EditorRefActions
):
void
{
switch
(
event
.
key
.
toLowerCase
())
{
switch
(
event
.
key
.
toLowerCase
())
{
case
"b"
:
case
"b"
:
event
.
preventDefault
();
event
.
preventDefault
();
toggleTextStyle
(
editor
,
"**"
);
// Bold
toggleTextStyle
(
editor
,
"**"
);
break
;
break
;
case
"i"
:
case
"i"
:
event
.
preventDefault
();
event
.
preventDefault
();
toggleTextStyle
(
editor
,
"*"
);
// Italic
toggleTextStyle
(
editor
,
"*"
);
break
;
break
;
case
"k"
:
case
"k"
:
event
.
preventDefault
();
event
.
preventDefault
();
...
@@ -21,21 +17,14 @@ export function handleMarkdownShortcuts(event: React.KeyboardEvent, editor: Edit
...
@@ -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
{
export
function
insertHyperlink
(
editor
:
EditorRefActions
,
url
?:
string
):
void
{
const
cursorPosition
=
editor
.
getCursorPosition
();
const
cursorPosition
=
editor
.
getCursorPosition
();
const
selectedContent
=
editor
.
getSelectedContent
();
const
selectedContent
=
editor
.
getSelectedContent
();
const
placeholderUrl
=
"url"
;
const
placeholderUrl
=
"url"
;
const
urlRegex
=
/^https
?
:
\/\/[^\s]
+$/
;
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
()))
{
if
(
!
url
&&
urlRegex
.
test
(
selectedContent
.
trim
()))
{
editor
.
insertText
(
`[](
${
selectedContent
}
)`
);
editor
.
insertText
(
`[](
${
selectedContent
}
)`
);
// Move cursor between brackets for text input
editor
.
setCursorPosition
(
cursorPosition
+
1
,
cursorPosition
+
1
);
editor
.
setCursorPosition
(
cursorPosition
+
1
,
cursorPosition
+
1
);
return
;
return
;
}
}
...
@@ -43,44 +32,32 @@ export function insertHyperlink(editor: EditorRefActions, url?: string): void {
...
@@ -43,44 +32,32 @@ export function insertHyperlink(editor: EditorRefActions, url?: string): void {
const
href
=
url
??
placeholderUrl
;
const
href
=
url
??
placeholderUrl
;
editor
.
insertText
(
`[
${
selectedContent
}
](
${
href
}
)`
);
editor
.
insertText
(
`[
${
selectedContent
}
](
${
href
}
)`
);
// If using placeholder URL, select it for easy replacement
if
(
href
===
placeholderUrl
)
{
if
(
href
===
placeholderUrl
)
{
const
urlStart
=
cursorPosition
+
selectedContent
.
length
+
3
;
// After "]("
const
urlStart
=
cursorPosition
+
selectedContent
.
length
+
3
;
editor
.
setCursorPosition
(
urlStart
,
urlStart
+
href
.
length
);
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
{
function
toggleTextStyle
(
editor
:
EditorRefActions
,
delimiter
:
string
):
void
{
const
cursorPosition
=
editor
.
getCursorPosition
();
const
cursorPosition
=
editor
.
getCursorPosition
();
const
selectedContent
=
editor
.
getSelectedContent
();
const
selectedContent
=
editor
.
getSelectedContent
();
// Check if already styled - remove style
if
(
selectedContent
.
startsWith
(
delimiter
)
&&
selectedContent
.
endsWith
(
delimiter
))
{
if
(
selectedContent
.
startsWith
(
delimiter
)
&&
selectedContent
.
endsWith
(
delimiter
))
{
const
unstyled
=
selectedContent
.
slice
(
delimiter
.
length
,
-
delimiter
.
length
);
const
unstyled
=
selectedContent
.
slice
(
delimiter
.
length
,
-
delimiter
.
length
);
editor
.
insertText
(
unstyled
);
editor
.
insertText
(
unstyled
);
editor
.
setCursorPosition
(
cursorPosition
,
cursorPosition
+
unstyled
.
length
);
editor
.
setCursorPosition
(
cursorPosition
,
cursorPosition
+
unstyled
.
length
);
}
else
{
}
else
{
// Add style
editor
.
insertText
(
`
${
delimiter
}${
selectedContent
}${
delimiter
}
`
);
editor
.
insertText
(
`
${
delimiter
}${
selectedContent
}${
delimiter
}
`
);
editor
.
setCursorPosition
(
cursorPosition
+
delimiter
.
length
,
cursorPosition
+
delimiter
.
length
+
selectedContent
.
length
);
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
{
export
function
hyperlinkHighlightedText
(
editor
:
EditorRefActions
,
url
:
string
):
void
{
const
selectedContent
=
editor
.
getSelectedContent
();
const
selectedContent
=
editor
.
getSelectedContent
();
const
cursorPosition
=
editor
.
getCursorPosition
();
const
cursorPosition
=
editor
.
getCursorPosition
();
editor
.
insertText
(
`[
${
selectedContent
}
](
${
url
}
)`
);
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
);
editor
.
setCursorPosition
(
newPosition
,
newPosition
);
}
}
web/src/components/MemoEditor/Editor/useListAutoCompletion.ts
View file @
a6a8997f
...
@@ -8,19 +8,6 @@ interface UseListAutoCompletionOptions {
...
@@ -8,19 +8,6 @@ interface UseListAutoCompletionOptions {
isInIME
:
boolean
;
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
)
{
export
function
useListAutoCompletion
({
editorRef
,
editorActions
,
isInIME
}:
UseListAutoCompletionOptions
)
{
// Use refs to avoid stale closures in event handlers
// Use refs to avoid stale closures in event handlers
const
isInIMERef
=
useRef
(
isInIME
);
const
isInIMERef
=
useRef
(
isInIME
);
...
...
web/src/components/MemoEditor/Editor/useSuggestions.ts
View file @
a6a8997f
...
@@ -9,58 +9,22 @@ export interface Position {
...
@@ -9,58 +9,22 @@ export interface Position {
}
}
export
interface
UseSuggestionsOptions
<
T
>
{
export
interface
UseSuggestionsOptions
<
T
>
{
/** Reference to the textarea element */
editorRef
:
React
.
RefObject
<
HTMLTextAreaElement
>
;
editorRef
:
React
.
RefObject
<
HTMLTextAreaElement
>
;
/** Reference to editor actions for text manipulation */
editorActions
:
React
.
ForwardedRef
<
EditorRefActions
>
;
editorActions
:
React
.
ForwardedRef
<
EditorRefActions
>
;
/** Character that triggers the suggestions (e.g., '/', '#', '@') */
triggerChar
:
string
;
triggerChar
:
string
;
/** Array of items to show in suggestions */
items
:
T
[];
items
:
T
[];
/** Function to filter items based on search query */
filterItems
:
(
items
:
T
[],
searchQuery
:
string
)
=>
T
[];
filterItems
:
(
items
:
T
[],
searchQuery
:
string
)
=>
T
[];
/** Callback when an item is selected for autocomplete */
onAutocomplete
:
(
item
:
T
,
word
:
string
,
startIndex
:
number
,
actions
:
EditorRefActions
)
=>
void
;
onAutocomplete
:
(
item
:
T
,
word
:
string
,
startIndex
:
number
,
actions
:
EditorRefActions
)
=>
void
;
}
}
export
interface
UseSuggestionsReturn
<
T
>
{
export
interface
UseSuggestionsReturn
<
T
>
{
/** Current position of the popup, or null if hidden */
position
:
Position
|
null
;
position
:
Position
|
null
;
/** Filtered suggestions based on current search */
suggestions
:
T
[];
suggestions
:
T
[];
/** Index of the currently selected suggestion */
selectedIndex
:
number
;
selectedIndex
:
number
;
/** Whether the suggestions popup is visible */
isVisible
:
boolean
;
isVisible
:
boolean
;
/** Handler to select a suggestion item */
handleItemSelect
:
(
item
:
T
)
=>
void
;
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
>
({
export
function
useSuggestions
<
T
>
({
editorRef
,
editorRef
,
editorActions
,
editorActions
,
...
...
web/src/components/MemoEditor/Toolbar/InsertMenu.tsx
View file @
a6a8997f
...
@@ -84,15 +84,11 @@ const InsertMenu = observer((props: Props) => {
...
@@ -84,15 +84,11 @@ const InsertMenu = observer((props: Props) => {
};
};
const
handleLocationCancel
=
()
=>
{
const
handleLocationCancel
=
()
=>
{
abortGeocoding
();
// Cancel any pending geocoding request
abortGeocoding
();
location
.
reset
();
location
.
reset
();
setLocationDialogOpen
(
false
);
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
fetchReverseGeocode
=
async
(
position
:
LatLng
,
signal
:
AbortSignal
):
Promise
<
string
>
=>
{
const
coordString
=
`
${
position
.
lat
.
toFixed
(
6
)}
,
${
position
.
lng
.
toFixed
(
6
)}
`
;
const
coordString
=
`
${
position
.
lat
.
toFixed
(
6
)}
,
${
position
.
lng
.
toFixed
(
6
)}
`
;
try
{
try
{
...
...
web/src/components/MemoEditor/components/ErrorBoundary.tsx
View file @
a6a8997f
...
@@ -11,11 +11,6 @@ interface State {
...
@@ -11,11 +11,6 @@ interface State {
error
:
Error
|
null
;
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
>
{
class
MemoEditorErrorBoundary
extends
React
.
Component
<
Props
,
State
>
{
constructor
(
props
:
Props
)
{
constructor
(
props
:
Props
)
{
super
(
props
);
super
(
props
);
...
...
web/src/components/MemoEditor/components/FocusModeOverlay.tsx
View file @
a6a8997f
...
@@ -7,10 +7,6 @@ interface FocusModeOverlayProps {
...
@@ -7,10 +7,6 @@ interface FocusModeOverlayProps {
onToggle
:
()
=>
void
;
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
)
{
export
function
FocusModeOverlay
({
isActive
,
onToggle
}:
FocusModeOverlayProps
)
{
if
(
!
isActive
)
return
null
;
if
(
!
isActive
)
return
null
;
...
@@ -31,10 +27,6 @@ interface FocusModeExitButtonProps {
...
@@ -31,10 +27,6 @@ interface FocusModeExitButtonProps {
title
:
string
;
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
)
{
export
function
FocusModeExitButton
({
isActive
,
onToggle
,
title
}:
FocusModeExitButtonProps
)
{
if
(
!
isActive
)
return
null
;
if
(
!
isActive
)
return
null
;
...
...
web/src/components/MemoEditor/components/LinkMemoDialog.tsx
View file @
a6a8997f
...
@@ -3,9 +3,6 @@ import { Input } from "@/components/ui/input";
...
@@ -3,9 +3,6 @@ import { Input } from "@/components/ui/input";
import
{
Memo
}
from
"@/types/proto/api/v1/memo_service"
;
import
{
Memo
}
from
"@/types/proto/api/v1/memo_service"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
/**
* Highlights search text within content string
*/
function
highlightSearchText
(
content
:
string
,
searchText
:
string
):
React
.
ReactNode
{
function
highlightSearchText
(
content
:
string
,
searchText
:
string
):
React
.
ReactNode
{
if
(
!
searchText
)
return
content
;
if
(
!
searchText
)
return
content
;
...
...
web/src/components/MemoEditor/constants.ts
View file @
a6a8997f
/**
* 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
;
export
const
LOCALSTORAGE_DEBOUNCE_DELAY
=
500
;
/**
* Focus Mode styling constants
* Centralized to make it easy to adjust appearance
*/
export
const
FOCUS_MODE_STYLES
=
{
export
const
FOCUS_MODE_STYLES
=
{
backdrop
:
"fixed inset-0 bg-black/20 backdrop-blur-sm z-40"
,
backdrop
:
"fixed inset-0 bg-black/20 backdrop-blur-sm z-40"
,
container
:
{
container
:
{
base
:
"fixed z-50 w-auto max-w-5xl mx-auto shadow-2xl border-border h-auto overflow-y-auto"
,
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"
,
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"
,
transition
:
"transition-all duration-300 ease-in-out"
,
exitButton
:
"absolute top-2 right-2 z-10 opacity-60 hover:opacity-100"
,
exitButton
:
"absolute top-2 right-2 z-10 opacity-60 hover:opacity-100"
,
}
as
const
;
}
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_TOGGLE_KEY
=
"f"
;
export
const
FOCUS_MODE_EXIT_KEY
=
"Escape"
;
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
=
{
export
const
EDITOR_HEIGHT
=
{
normal
:
"max-h-[50vh]"
,
normal
:
"max-h-[50vh]"
,
focusMode
:
{
focusMode
:
{
...
@@ -50,9 +21,6 @@ export const EDITOR_HEIGHT = {
...
@@ -50,9 +21,6 @@ export const EDITOR_HEIGHT = {
},
},
}
as
const
;
}
as
const
;
/**
* Geocoding API configuration
*/
export
const
GEOCODING
=
{
export
const
GEOCODING
=
{
endpoint
:
"https://nominatim.openstreetmap.org/reverse"
,
endpoint
:
"https://nominatim.openstreetmap.org/reverse"
,
userAgent
:
"Memos/1.0 (https://github.com/usememos/memos)"
,
userAgent
:
"Memos/1.0 (https://github.com/usememos/memos)"
,
...
...
web/src/components/MemoEditor/hooks/useAbortController.ts
View file @
a6a8997f
import
{
useEffect
,
useRef
}
from
"react"
;
import
{
useEffect
,
useRef
}
from
"react"
;
/**
* Hook for managing AbortController lifecycle
*/
export
function
useAbortController
()
{
export
function
useAbortController
()
{
const
controllerRef
=
useRef
<
AbortController
|
null
>
(
null
);
const
controllerRef
=
useRef
<
AbortController
|
null
>
(
null
);
...
...
web/src/components/MemoEditor/hooks/useBlobUrls.ts
View file @
a6a8997f
import
{
useEffect
,
useRef
}
from
"react"
;
import
{
useEffect
,
useRef
}
from
"react"
;
/**
* Hook for managing blob URLs lifecycle with automatic cleanup
*/
export
function
useBlobUrls
()
{
export
function
useBlobUrls
()
{
const
urlsRef
=
useRef
<
Set
<
string
>>
(
new
Set
());
const
urlsRef
=
useRef
<
Set
<
string
>>
(
new
Set
());
...
...
web/src/components/MemoEditor/hooks/useDragAndDrop.ts
View file @
a6a8997f
import
{
useState
}
from
"react"
;
import
{
useState
}
from
"react"
;
/**
* Hook for handling drag-and-drop file uploads
*/
export
function
useDragAndDrop
(
onDrop
:
(
files
:
FileList
)
=>
void
)
{
export
function
useDragAndDrop
(
onDrop
:
(
files
:
FileList
)
=>
void
)
{
const
[
isDragging
,
setIsDragging
]
=
useState
(
false
);
const
[
isDragging
,
setIsDragging
]
=
useState
(
false
);
...
...
web/src/components/MemoEditor/hooks/useFocusMode.ts
View file @
a6a8997f
import
{
useEffect
}
from
"react"
;
import
{
useEffect
}
from
"react"
;
/**
* Hook to lock body scroll when focus mode is active
*/
export
function
useFocusMode
(
isFocusMode
:
boolean
):
void
{
export
function
useFocusMode
(
isFocusMode
:
boolean
):
void
{
useEffect
(()
=>
{
useEffect
(()
=>
{
document
.
body
.
style
.
overflow
=
isFocusMode
?
"hidden"
:
""
;
document
.
body
.
style
.
overflow
=
isFocusMode
?
"hidden"
:
""
;
...
...
web/src/components/MemoEditor/hooks/useLocalFileManager.ts
View file @
a6a8997f
...
@@ -2,33 +2,10 @@ import { useState } from "react";
...
@@ -2,33 +2,10 @@ import { useState } from "react";
import
type
{
LocalFile
}
from
"@/components/memo-metadata"
;
import
type
{
LocalFile
}
from
"@/components/memo-metadata"
;
import
{
useBlobUrls
}
from
"./useBlobUrls"
;
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
()
{
export
function
useLocalFileManager
()
{
const
[
localFiles
,
setLocalFiles
]
=
useState
<
LocalFile
[]
>
([]);
const
[
localFiles
,
setLocalFiles
]
=
useState
<
LocalFile
[]
>
([]);
const
{
createBlobUrl
,
revokeBlobUrl
}
=
useBlobUrls
();
const
{
createBlobUrl
,
revokeBlobUrl
}
=
useBlobUrls
();
/**
* Adds files to local state with blob URL previews
*/
const
addFiles
=
(
files
:
FileList
|
File
[]):
void
=>
{
const
addFiles
=
(
files
:
FileList
|
File
[]):
void
=>
{
const
fileArray
=
Array
.
from
(
files
);
const
fileArray
=
Array
.
from
(
files
);
const
newLocalFiles
:
LocalFile
[]
=
fileArray
.
map
((
file
)
=>
({
const
newLocalFiles
:
LocalFile
[]
=
fileArray
.
map
((
file
)
=>
({
...
@@ -38,9 +15,6 @@ export function useLocalFileManager() {
...
@@ -38,9 +15,6 @@ export function useLocalFileManager() {
setLocalFiles
((
prev
)
=>
[...
prev
,
...
newLocalFiles
]);
setLocalFiles
((
prev
)
=>
[...
prev
,
...
newLocalFiles
]);
};
};
/**
* Removes a specific file by preview URL
*/
const
removeFile
=
(
previewUrl
:
string
):
void
=>
{
const
removeFile
=
(
previewUrl
:
string
):
void
=>
{
setLocalFiles
((
prev
)
=>
{
setLocalFiles
((
prev
)
=>
{
const
toRemove
=
prev
.
find
((
f
)
=>
f
.
previewUrl
===
previewUrl
);
const
toRemove
=
prev
.
find
((
f
)
=>
f
.
previewUrl
===
previewUrl
);
...
@@ -51,9 +25,6 @@ export function useLocalFileManager() {
...
@@ -51,9 +25,6 @@ export function useLocalFileManager() {
});
});
};
};
/**
* Clears all files and revokes their blob URLs
*/
const
clearFiles
=
():
void
=>
{
const
clearFiles
=
():
void
=>
{
localFiles
.
forEach
(({
previewUrl
})
=>
revokeBlobUrl
(
previewUrl
));
localFiles
.
forEach
(({
previewUrl
})
=>
revokeBlobUrl
(
previewUrl
));
setLocalFiles
([]);
setLocalFiles
([]);
...
...
web/src/components/MemoEditor/hooks/useMemoEditorHandlers.ts
View file @
a6a8997f
...
@@ -17,10 +17,6 @@ export interface UseMemoEditorHandlersReturn {
...
@@ -17,10 +17,6 @@ export interface UseMemoEditorHandlersReturn {
handleEditorFocus
:
()
=>
void
;
handleEditorFocus
:
()
=>
void
;
}
}
/**
* Hook for managing MemoEditor event handlers
* Centralizes composition, paste, and focus handling
*/
export
const
useMemoEditorHandlers
=
(
options
:
UseMemoEditorHandlersOptions
):
UseMemoEditorHandlersReturn
=>
{
export
const
useMemoEditorHandlers
=
(
options
:
UseMemoEditorHandlersOptions
):
UseMemoEditorHandlersReturn
=>
{
const
{
editorRef
,
onFilesAdded
,
setComposing
}
=
options
;
const
{
editorRef
,
onFilesAdded
,
setComposing
}
=
options
;
...
...
web/src/components/MemoEditor/hooks/useMemoEditorInit.ts
View file @
a6a8997f
...
@@ -27,10 +27,6 @@ export interface UseMemoEditorInitReturn {
...
@@ -27,10 +27,6 @@ export interface UseMemoEditorInitReturn {
setUpdateTime
:
(
time
:
Date
|
undefined
)
=>
void
;
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
=>
{
export
const
useMemoEditorInit
=
(
options
:
UseMemoEditorInitOptions
):
UseMemoEditorInitReturn
=>
{
const
{
const
{
editorRef
,
editorRef
,
...
...
web/src/components/MemoEditor/hooks/useMemoEditorKeyboard.ts
View file @
a6a8997f
...
@@ -12,10 +12,6 @@ export interface UseMemoEditorKeyboardOptions {
...
@@ -12,10 +12,6 @@ export interface UseMemoEditorKeyboardOptions {
onToggleFocusMode
:
()
=>
void
;
onToggleFocusMode
:
()
=>
void
;
}
}
/**
* Hook for handling keyboard shortcuts in MemoEditor
* Centralizes all keyboard event handling logic
*/
export
const
useMemoEditorKeyboard
=
(
options
:
UseMemoEditorKeyboardOptions
)
=>
{
export
const
useMemoEditorKeyboard
=
(
options
:
UseMemoEditorKeyboardOptions
)
=>
{
const
{
editorRef
,
isFocusMode
,
isComposing
,
onSave
,
onToggleFocusMode
}
=
options
;
const
{
editorRef
,
isFocusMode
,
isComposing
,
onSave
,
onToggleFocusMode
}
=
options
;
...
...
web/src/components/MemoEditor/hooks/useMemoEditorState.ts
View file @
a6a8997f
...
@@ -15,9 +15,6 @@ interface MemoEditorState {
...
@@ -15,9 +15,6 @@ interface MemoEditorState {
isDraggingFile
:
boolean
;
isDraggingFile
:
boolean
;
}
}
/**
* Hook for managing MemoEditor state
*/
export
const
useMemoEditorState
=
(
initialVisibility
:
Visibility
=
Visibility
.
PRIVATE
)
=>
{
export
const
useMemoEditorState
=
(
initialVisibility
:
Visibility
=
Visibility
.
PRIVATE
)
=>
{
const
[
state
,
setState
]
=
useState
<
MemoEditorState
>
({
const
[
state
,
setState
]
=
useState
<
MemoEditorState
>
({
memoVisibility
:
initialVisibility
,
memoVisibility
:
initialVisibility
,
...
...
web/src/components/MemoEditor/hooks/useMemoSave.ts
View file @
a6a8997f
...
@@ -9,44 +9,26 @@ import type { Location, Memo, MemoRelation, Visibility } from "@/types/proto/api
...
@@ -9,44 +9,26 @@ import type { Location, Memo, MemoRelation, Visibility } from "@/types/proto/api
import
type
{
Translations
}
from
"@/utils/i18n"
;
import
type
{
Translations
}
from
"@/utils/i18n"
;
interface
MemoSaveContext
{
interface
MemoSaveContext
{
/** Current memo name (for update mode) */
memoName
?:
string
;
memoName
?:
string
;
/** Parent memo name (for comment mode) */
parentMemoName
?:
string
;
parentMemoName
?:
string
;
/** Current visibility setting */
visibility
:
Visibility
;
visibility
:
Visibility
;
/** Current attachments */
attachmentList
:
Attachment
[];
attachmentList
:
Attachment
[];
/** Current relations */
relationList
:
MemoRelation
[];
relationList
:
MemoRelation
[];
/** Current location */
location
?:
Location
;
location
?:
Location
;
/** Local files pending upload */
localFiles
:
LocalFile
[];
localFiles
:
LocalFile
[];
/** Create time override */
createTime
?:
Date
;
createTime
?:
Date
;
/** Update time override */
updateTime
?:
Date
;
updateTime
?:
Date
;
}
}
interface
MemoSaveCallbacks
{
interface
MemoSaveCallbacks
{
/** Called when upload state changes */
onUploadingChange
:
(
uploading
:
boolean
)
=>
void
;
onUploadingChange
:
(
uploading
:
boolean
)
=>
void
;
/** Called when request state changes */
onRequestingChange
:
(
requesting
:
boolean
)
=>
void
;
onRequestingChange
:
(
requesting
:
boolean
)
=>
void
;
/** Called on successful save */
onSuccess
:
(
memoName
:
string
)
=>
void
;
onSuccess
:
(
memoName
:
string
)
=>
void
;
/** Called on cancellation (no changes) */
onCancel
:
()
=>
void
;
onCancel
:
()
=>
void
;
/** Called to reset after save */
onReset
:
()
=>
void
;
onReset
:
()
=>
void
;
/** Translation function */
t
:
(
key
:
Translations
,
params
?:
Record
<
string
,
any
>
)
=>
string
;
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
[]
>
{
async
function
uploadLocalFiles
(
localFiles
:
LocalFile
[],
onUploadingChange
:
(
uploading
:
boolean
)
=>
void
):
Promise
<
Attachment
[]
>
{
if
(
localFiles
.
length
===
0
)
return
[];
if
(
localFiles
.
length
===
0
)
return
[];
...
@@ -72,9 +54,6 @@ async function uploadLocalFiles(localFiles: LocalFile[], onUploadingChange: (upl
...
@@ -72,9 +54,6 @@ async function uploadLocalFiles(localFiles: LocalFile[], onUploadingChange: (upl
}
}
}
}
/**
* Builds an update mask by comparing memo properties
*/
function
buildUpdateMask
(
function
buildUpdateMask
(
prevMemo
:
Memo
,
prevMemo
:
Memo
,
content
:
string
,
content
:
string
,
...
@@ -126,10 +105,6 @@ function buildUpdateMask(
...
@@ -126,10 +105,6 @@ function buildUpdateMask(
return
{
mask
,
patch
};
return
{
mask
,
patch
};
}
}
/**
* Hook for saving/updating memos
* Extracts complex save logic from MemoEditor
*/
export
function
useMemoSave
(
callbacks
:
MemoSaveCallbacks
)
{
export
function
useMemoSave
(
callbacks
:
MemoSaveCallbacks
)
{
const
{
onUploadingChange
,
onRequestingChange
,
onSuccess
,
onCancel
,
onReset
,
t
}
=
callbacks
;
const
{
onUploadingChange
,
onRequestingChange
,
onSuccess
,
onCancel
,
onReset
,
t
}
=
callbacks
;
...
...
web/src/components/MemoEditor/types/context.ts
View file @
a6a8997f
...
@@ -3,26 +3,14 @@ import type { Attachment } from "@/types/proto/api/v1/attachment_service";
...
@@ -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
{
MemoRelation
}
from
"@/types/proto/api/v1/memo_service"
;
import
type
{
LocalFile
}
from
"../../memo-metadata"
;
import
type
{
LocalFile
}
from
"../../memo-metadata"
;
/**
* Context interface for MemoEditor
* Provides access to editor state and actions for child components
*/
export
interface
MemoEditorContextValue
{
export
interface
MemoEditorContextValue
{
/** List of uploaded attachments */
attachmentList
:
Attachment
[];
attachmentList
:
Attachment
[];
/** List of memo relations/links */
relationList
:
MemoRelation
[];
relationList
:
MemoRelation
[];
/** Update the attachment list */
setAttachmentList
:
(
attachmentList
:
Attachment
[])
=>
void
;
setAttachmentList
:
(
attachmentList
:
Attachment
[])
=>
void
;
/** Update the relation list */
setRelationList
:
(
relationList
:
MemoRelation
[])
=>
void
;
setRelationList
:
(
relationList
:
MemoRelation
[])
=>
void
;
/** Name of memo being edited (undefined for new memos) */
memoName
?:
string
;
memoName
?:
string
;
/** Add local files for upload preview */
addLocalFiles
?:
(
files
:
LocalFile
[])
=>
void
;
addLocalFiles
?:
(
files
:
LocalFile
[])
=>
void
;
/** Remove a local file by preview URL */
removeLocalFile
?:
(
previewUrl
:
string
)
=>
void
;
removeLocalFile
?:
(
previewUrl
:
string
)
=>
void
;
/** List of local files pending upload */
localFiles
?:
LocalFile
[];
localFiles
?:
LocalFile
[];
}
}
...
...
web/src/components/MemoExplorer/MemoExplorer.tsx
View file @
a6a8997f
...
@@ -10,66 +10,21 @@ import TagsSection from "./TagsSection";
...
@@ -10,66 +10,21 @@ import TagsSection from "./TagsSection";
export
type
MemoExplorerContext
=
"home"
|
"explore"
|
"archived"
|
"profile"
;
export
type
MemoExplorerContext
=
"home"
|
"explore"
|
"archived"
|
"profile"
;
export
interface
MemoExplorerFeatures
{
export
interface
MemoExplorerFeatures
{
/**
* Show search bar at the top
* Default: true
*/
search
?:
boolean
;
search
?:
boolean
;
/**
* Show statistics section (activity calendar + stat cards)
* Default: true
*/
statistics
?:
boolean
;
statistics
?:
boolean
;
/**
* Show shortcuts section (user-defined filter shortcuts)
* Default: true for authenticated users on home/profile, false for explore
*/
shortcuts
?:
boolean
;
shortcuts
?:
boolean
;
/**
* Show tags section
* Default: true
*/
tags
?:
boolean
;
tags
?:
boolean
;
/**
* Context for statistics view (affects which stats to show)
* Default: "user"
*/
statisticsContext
?:
MemoExplorerContext
;
statisticsContext
?:
MemoExplorerContext
;
}
}
interface
Props
{
interface
Props
{
className
?:
string
;
className
?:
string
;
/**
* Context for the explorer (determines default features)
*/
context
?:
MemoExplorerContext
;
context
?:
MemoExplorerContext
;
/**
* Feature configuration (overrides context defaults)
*/
features
?:
MemoExplorerFeatures
;
features
?:
MemoExplorerFeatures
;
/**
* Statistics data computed from filtered memos
* Should be computed using useFilteredMemoStats with the same filter as the memo list
*/
statisticsData
:
StatisticsData
;
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
>
;
tagCount
:
Record
<
string
,
number
>
;
}
}
/**
* Default features based on context
*/
const
getDefaultFeatures
=
(
context
:
MemoExplorerContext
):
MemoExplorerFeatures
=>
{
const
getDefaultFeatures
=
(
context
:
MemoExplorerContext
):
MemoExplorerFeatures
=>
{
switch
(
context
)
{
switch
(
context
)
{
case
"explore"
:
case
"explore"
:
...
...
web/src/components/MemoExplorer/MemoExplorerDrawer.tsx
View file @
a6a8997f
...
@@ -7,24 +7,9 @@ import type { StatisticsData } from "@/types/statistics";
...
@@ -7,24 +7,9 @@ import type { StatisticsData } from "@/types/statistics";
import
MemoExplorer
,
{
MemoExplorerContext
,
MemoExplorerFeatures
}
from
"./MemoExplorer"
;
import
MemoExplorer
,
{
MemoExplorerContext
,
MemoExplorerFeatures
}
from
"./MemoExplorer"
;
interface
Props
{
interface
Props
{
/**
* Context for the explorer
*/
context
?:
MemoExplorerContext
;
context
?:
MemoExplorerContext
;
/**
* Feature configuration
*/
features
?:
MemoExplorerFeatures
;
features
?:
MemoExplorerFeatures
;
/**
* Statistics data computed from filtered memos
*/
statisticsData
:
StatisticsData
;
statisticsData
:
StatisticsData
;
/**
* Tag counts computed from filtered memos
*/
tagCount
:
Record
<
string
,
number
>
;
tagCount
:
Record
<
string
,
number
>
;
}
}
...
...
web/src/components/MemoExplorer/TagsSection.tsx
View file @
a6a8997f
...
@@ -10,10 +10,6 @@ import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
...
@@ -10,10 +10,6 @@ import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
interface
Props
{
interface
Props
{
readonly
?:
boolean
;
readonly
?:
boolean
;
/**
* Tag count computed from filtered memos
* Should be provided by parent component using useFilteredMemoStats
*/
tagCount
:
Record
<
string
,
number
>
;
tagCount
:
Record
<
string
,
number
>
;
}
}
...
...
web/src/components/MemoView/index.ts
View file @
a6a8997f
/**
* 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
{
MemoBody
,
MemoHeader
}
from
"./components"
;
export
*
from
"./constants"
;
export
*
from
"./constants"
;
export
{
default
,
default
as
MemoView
}
from
"./MemoView"
;
export
{
default
,
default
as
MemoView
}
from
"./MemoView"
;
web/src/components/MemoView/types.ts
0 → 100644
View file @
a6a8997f
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
;
}
web/src/components/Settings/SettingGroup.tsx
View file @
a6a8997f
...
@@ -10,10 +10,6 @@ interface SettingGroupProps {
...
@@ -10,10 +10,6 @@ interface SettingGroupProps {
showSeparator
?:
boolean
;
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
})
=>
{
const
SettingGroup
:
React
.
FC
<
SettingGroupProps
>
=
({
title
,
description
,
children
,
className
,
showSeparator
=
false
})
=>
{
return
(
return
(
<>
<>
...
...
web/src/components/Settings/SettingRow.tsx
View file @
a6a8997f
...
@@ -12,10 +12,6 @@ interface SettingRowProps {
...
@@ -12,10 +12,6 @@ interface SettingRowProps {
vertical
?:
boolean
;
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
})
=>
{
const
SettingRow
:
React
.
FC
<
SettingRowProps
>
=
({
label
,
description
,
tooltip
,
children
,
className
,
vertical
=
false
})
=>
{
return
(
return
(
<
div
className=
{
cn
(
"w-full flex gap-3"
,
vertical
?
"flex-col"
:
"flex-row justify-between items-center"
,
className
)
}
>
<
div
className=
{
cn
(
"w-full flex gap-3"
,
vertical
?
"flex-col"
:
"flex-row justify-between items-center"
,
className
)
}
>
...
...
web/src/components/Settings/SettingSection.tsx
View file @
a6a8997f
...
@@ -9,10 +9,6 @@ interface SettingSectionProps {
...
@@ -9,10 +9,6 @@ interface SettingSectionProps {
actions
?:
React
.
ReactNode
;
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
})
=>
{
const
SettingSection
:
React
.
FC
<
SettingSectionProps
>
=
({
title
,
description
,
children
,
className
,
actions
})
=>
{
return
(
return
(
<
div
className=
{
cn
(
"w-full flex flex-col gap-4 pt-2 pb-4"
,
className
)
}
>
<
div
className=
{
cn
(
"w-full flex flex-col gap-4 pt-2 pb-4"
,
className
)
}
>
...
...
web/src/components/Settings/SettingTable.tsx
View file @
a6a8997f
...
@@ -16,10 +16,6 @@ interface SettingTableProps {
...
@@ -16,10 +16,6 @@ interface SettingTableProps {
getRowKey
?:
(
row
:
any
,
index
:
number
)
=>
string
;
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
})
=>
{
const
SettingTable
:
React
.
FC
<
SettingTableProps
>
=
({
columns
,
data
,
emptyMessage
=
"No data"
,
className
,
getRowKey
})
=>
{
return
(
return
(
<
div
className=
{
cn
(
"w-full overflow-x-auto"
,
className
)
}
>
<
div
className=
{
cn
(
"w-full overflow-x-auto"
,
className
)
}
>
...
...
web/src/components/StatisticsView/StatisticsView.tsx
View file @
a6a8997f
...
@@ -9,17 +9,9 @@ import { MonthNavigator } from "./MonthNavigator";
...
@@ -9,17 +9,9 @@ import { MonthNavigator } from "./MonthNavigator";
export
type
StatisticsViewContext
=
"home"
|
"explore"
|
"archived"
|
"profile"
;
export
type
StatisticsViewContext
=
"home"
|
"explore"
|
"archived"
|
"profile"
;
interface
Props
{
interface
Props
{
/**
// Context for the statistics view (affects which stat cards are shown)
* Context for the statistics view
* Affects which stat cards are shown
* Default: "home"
*/
context
?:
StatisticsViewContext
;
context
?:
StatisticsViewContext
;
// Statistics data computed from filtered memos (use useFilteredMemoStats)
/**
* Statistics data computed from filtered memos
* Should be provided by parent component using useFilteredMemoStats
*/
statisticsData
:
StatisticsData
;
statisticsData
:
StatisticsData
;
}
}
...
...
web/src/components/memo-metadata/AttachmentCard.tsx
View file @
a6a8997f
...
@@ -3,7 +3,6 @@ import { cn } from "@/lib/utils";
...
@@ -3,7 +3,6 @@ import { cn } from "@/lib/utils";
import
type
{
AttachmentItem
,
DisplayMode
}
from
"./types"
;
import
type
{
AttachmentItem
,
DisplayMode
}
from
"./types"
;
interface
AttachmentCardProps
{
interface
AttachmentCardProps
{
/** Unified attachment item (uploaded or local file) */
item
:
AttachmentItem
;
item
:
AttachmentItem
;
mode
:
DisplayMode
;
mode
:
DisplayMode
;
onRemove
?:
()
=>
void
;
onRemove
?:
()
=>
void
;
...
@@ -12,10 +11,6 @@ interface AttachmentCardProps {
...
@@ -12,10 +11,6 @@ interface AttachmentCardProps {
showThumbnail
?:
boolean
;
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
AttachmentCard
=
({
item
,
mode
,
onRemove
,
onClick
,
className
,
showThumbnail
=
true
}:
AttachmentCardProps
)
=>
{
const
{
category
,
filename
,
thumbnailUrl
,
sourceUrl
}
=
item
;
const
{
category
,
filename
,
thumbnailUrl
,
sourceUrl
}
=
item
;
const
isMedia
=
category
===
"image"
||
category
===
"video"
;
const
isMedia
=
category
===
"image"
||
category
===
"video"
;
...
...
web/src/components/memo-metadata/AttachmentList.tsx
View file @
a6a8997f
...
@@ -17,20 +17,6 @@ interface AttachmentListProps extends BaseMetadataProps {
...
@@ -17,20 +17,6 @@ interface AttachmentListProps extends BaseMetadataProps {
onRemoveLocalFile
?:
(
previewUrl
:
string
)
=>
void
;
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
AttachmentList
=
({
attachments
,
mode
,
onAttachmentsChange
,
localFiles
=
[],
onRemoveLocalFile
}:
AttachmentListProps
)
=>
{
const
sensors
=
useSensors
(
useSensor
(
MouseSensor
),
useSensor
(
TouchSensor
));
const
sensors
=
useSensors
(
useSensor
(
MouseSensor
),
useSensor
(
TouchSensor
));
const
[
previewImage
,
setPreviewImage
]
=
useState
<
{
open
:
boolean
;
urls
:
string
[];
index
:
number
}
>
({
const
[
previewImage
,
setPreviewImage
]
=
useState
<
{
open
:
boolean
;
urls
:
string
[];
index
:
number
}
>
({
...
...
web/src/components/memo-metadata/MetadataCard.tsx
View file @
a6a8997f
...
@@ -6,10 +6,6 @@ interface MetadataCardProps {
...
@@ -6,10 +6,6 @@ interface MetadataCardProps {
className
?:
string
;
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
)
=>
{
const
MetadataCard
=
({
children
,
className
}:
MetadataCardProps
)
=>
{
return
(
return
(
<
div
<
div
...
...
web/src/components/memo-metadata/RelationCard.tsx
View file @
a6a8997f
...
@@ -13,12 +13,6 @@ interface RelationCardProps {
...
@@ -13,12 +13,6 @@ interface RelationCardProps {
className
?:
string
;
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
RelationCard
=
({
memo
,
mode
,
onRemove
,
parentPage
,
className
}:
RelationCardProps
)
=>
{
const
memoId
=
extractMemoIdFromName
(
memo
.
name
);
const
memoId
=
extractMemoIdFromName
(
memo
.
name
);
...
...
web/src/components/memo-metadata/RelationList.tsx
View file @
a6a8997f
...
@@ -16,20 +16,6 @@ interface RelationListProps extends BaseMetadataProps {
...
@@ -16,20 +16,6 @@ interface RelationListProps extends BaseMetadataProps {
parentPage
?:
string
;
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
RelationList
=
observer
(({
relations
,
currentMemoName
,
mode
,
onRelationsChange
,
parentPage
,
className
}:
RelationListProps
)
=>
{
const
t
=
useTranslate
();
const
t
=
useTranslate
();
const
[
referencingMemos
,
setReferencingMemos
]
=
useState
<
Memo
[]
>
([]);
const
[
referencingMemos
,
setReferencingMemos
]
=
useState
<
Memo
[]
>
([]);
...
...
web/src/components/memo-metadata/index.ts
View file @
a6a8997f
/**
* Unified memo metadata components
* Provides consistent styling and behavior across editor and view modes
*/
export
{
default
as
AttachmentCard
}
from
"./AttachmentCard"
;
export
{
default
as
AttachmentCard
}
from
"./AttachmentCard"
;
export
{
default
as
AttachmentList
}
from
"./AttachmentList"
;
export
{
default
as
AttachmentList
}
from
"./AttachmentList"
;
export
{
default
as
LocationDisplay
}
from
"./LocationDisplay"
;
export
{
default
as
LocationDisplay
}
from
"./LocationDisplay"
;
...
...
web/src/components/memo-metadata/types.ts
View file @
a6a8997f
/**
* Common types for memo metadata components
*/
import
type
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service"
;
import
type
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service"
;
import
{
getAttachmentThumbnailUrl
,
getAttachmentType
,
getAttachmentUrl
}
from
"@/utils/attachment"
;
import
{
getAttachmentThumbnailUrl
,
getAttachmentType
,
getAttachmentUrl
}
from
"@/utils/attachment"
;
...
@@ -12,47 +8,26 @@ export interface BaseMetadataProps {
...
@@ -12,47 +8,26 @@ export interface BaseMetadataProps {
className
?:
string
;
className
?:
string
;
}
}
/**
* File type categories for consistent handling across components
*/
export
type
FileCategory
=
"image"
|
"video"
|
"document"
;
export
type
FileCategory
=
"image"
|
"video"
|
"document"
;
/**
// Pure view model for rendering attachments and local files
* 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
*/
export
interface
AttachmentItem
{
export
interface
AttachmentItem
{
/** Unique identifier - stable across renders */
readonly
id
:
string
;
readonly
id
:
string
;
/** Display name for the file */
readonly
filename
:
string
;
readonly
filename
:
string
;
/** Categorized file type */
readonly
category
:
FileCategory
;
readonly
category
:
FileCategory
;
/** MIME type for detailed handling if needed */
readonly
mimeType
:
string
;
readonly
mimeType
:
string
;
/** URL for thumbnail/preview display */
readonly
thumbnailUrl
:
string
;
readonly
thumbnailUrl
:
string
;
/** URL for full file access */
readonly
sourceUrl
:
string
;
readonly
sourceUrl
:
string
;
/** Size in bytes (optional) */
readonly
size
?:
number
;
readonly
size
?:
number
;
/** Whether this represents a local file not yet uploaded */
readonly
isLocal
:
boolean
;
readonly
isLocal
:
boolean
;
}
}
/**
* Determine file category from MIME type
*/
function
categorizeFile
(
mimeType
:
string
):
FileCategory
{
function
categorizeFile
(
mimeType
:
string
):
FileCategory
{
if
(
mimeType
.
startsWith
(
"image/"
))
return
"image"
;
if
(
mimeType
.
startsWith
(
"image/"
))
return
"image"
;
if
(
mimeType
.
startsWith
(
"video/"
))
return
"video"
;
if
(
mimeType
.
startsWith
(
"video/"
))
return
"video"
;
return
"document"
;
return
"document"
;
}
}
/**
* Convert an uploaded Attachment to AttachmentItem view model
*/
export
function
attachmentToItem
(
attachment
:
Attachment
):
AttachmentItem
{
export
function
attachmentToItem
(
attachment
:
Attachment
):
AttachmentItem
{
const
attachmentType
=
getAttachmentType
(
attachment
);
const
attachmentType
=
getAttachmentType
(
attachment
);
const
sourceUrl
=
getAttachmentUrl
(
attachment
);
const
sourceUrl
=
getAttachmentUrl
(
attachment
);
...
@@ -69,9 +44,6 @@ export function attachmentToItem(attachment: Attachment): AttachmentItem {
...
@@ -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
{
export
function
fileToItem
(
file
:
File
,
blobUrl
:
string
):
AttachmentItem
{
return
{
return
{
id
:
blobUrl
,
// Use blob URL as unique ID since we don't have a server ID yet
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 {
...
@@ -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
{
export
interface
LocalFile
{
readonly
file
:
File
;
readonly
file
:
File
;
readonly
previewUrl
:
string
;
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
[]
{
export
function
toAttachmentItems
(
attachments
:
Attachment
[],
localFiles
:
LocalFile
[]
=
[]):
AttachmentItem
[]
{
return
[...
attachments
.
map
(
attachmentToItem
),
...
localFiles
.
map
(({
file
,
previewUrl
})
=>
fileToItem
(
file
,
previewUrl
))];
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
[]
{
export
function
filterByCategory
(
items
:
AttachmentItem
[],
categories
:
FileCategory
[]):
AttachmentItem
[]
{
const
categorySet
=
new
Set
(
categories
);
const
categorySet
=
new
Set
(
categories
);
return
items
.
filter
((
item
)
=>
categorySet
.
has
(
item
.
category
));
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
[]
}
{
export
function
separateMediaAndDocs
(
items
:
AttachmentItem
[]):
{
media
:
AttachmentItem
[];
docs
:
AttachmentItem
[]
}
{
const
media
:
AttachmentItem
[]
=
[];
const
media
:
AttachmentItem
[]
=
[];
const
docs
:
AttachmentItem
[]
=
[];
const
docs
:
AttachmentItem
[]
=
[];
...
...
web/src/components/ui/dialog.tsx
View file @
a6a8997f
...
@@ -28,22 +28,6 @@ const DialogOverlay = React.forwardRef<
...
@@ -28,22 +28,6 @@ const DialogOverlay = React.forwardRef<
));
));
DialogOverlay
.
displayName
=
DialogPrimitive
.
Overlay
.
displayName
;
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
(
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)]"
,
"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)]"
,
{
{
...
...
web/src/hooks/useDialog.ts
View file @
a6a8997f
import
{
useCallback
,
useState
}
from
"react"
;
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
)
{
export
function
useDialog
(
defaultOpen
=
false
)
{
const
[
isOpen
,
setIsOpen
]
=
useState
(
defaultOpen
);
const
[
isOpen
,
setIsOpen
]
=
useState
(
defaultOpen
);
...
@@ -35,30 +16,6 @@ export function useDialog(defaultOpen = false) {
...
@@ -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
()
{
export
function
useDialogs
()
{
const
[
openDialogs
,
setOpenDialogs
]
=
useState
<
Set
<
string
>>
(
new
Set
());
const
[
openDialogs
,
setOpenDialogs
]
=
useState
<
Set
<
string
>>
(
new
Set
());
...
...
web/src/hooks/useFilteredMemoStats.ts
View file @
a6a8997f
...
@@ -10,50 +10,14 @@ export interface FilteredMemoStats {
...
@@ -10,50 +10,14 @@ export interface FilteredMemoStats {
loading
:
boolean
;
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
=>
{
const
getUserStatsKey
=
(
userName
:
string
):
string
=>
{
return
`
${
userName
}
/stats`
;
return
`
${
userName
}
/stats`
;
};
};
export
interface
UseFilteredMemoStatsOptions
{
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
;
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
=>
{
export
const
useFilteredMemoStats
=
(
options
:
UseFilteredMemoStatsOptions
=
{}):
FilteredMemoStats
=>
{
const
{
userName
}
=
options
;
const
{
userName
}
=
options
;
const
[
data
,
setData
]
=
useState
<
FilteredMemoStats
>
({
const
[
data
,
setData
]
=
useState
<
FilteredMemoStats
>
({
...
...
web/src/hooks/useMemoFilters.ts
View file @
a6a8997f
...
@@ -5,81 +5,18 @@ import memoFilterStore from "@/store/memoFilter";
...
@@ -5,81 +5,18 @@ import memoFilterStore from "@/store/memoFilter";
import
{
InstanceSetting_Key
}
from
"@/types/proto/api/v1/instance_service"
;
import
{
InstanceSetting_Key
}
from
"@/types/proto/api/v1/instance_service"
;
import
{
Visibility
}
from
"@/types/proto/api/v1/memo_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
getShortcutId
=
(
name
:
string
):
string
=>
{
const
parts
=
name
.
split
(
"/"
);
const
parts
=
name
.
split
(
"/"
);
return
parts
.
length
===
4
?
parts
[
3
]
:
""
;
return
parts
.
length
===
4
?
parts
[
3
]
:
""
;
};
};
export
interface
UseMemoFiltersOptions
{
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
;
creatorName
?:
string
;
/**
* Whether to include shortcut filter from memoFilterStore
* Default: false
*/
includeShortcuts
?:
boolean
;
includeShortcuts
?:
boolean
;
/**
* Whether to include pinned filter from memoFilterStore
* Default: false
*/
includePinned
?:
boolean
;
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
[];
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
=>
{
export
const
useMemoFilters
=
(
options
:
UseMemoFiltersOptions
=
{}):
string
|
undefined
=>
{
const
{
creatorName
,
includeShortcuts
=
false
,
includePinned
=
false
,
visibilities
}
=
options
;
const
{
creatorName
,
includeShortcuts
=
false
,
includePinned
=
false
,
visibilities
}
=
options
;
...
...
web/src/hooks/useMemoSorting.ts
View file @
a6a8997f
...
@@ -5,54 +5,15 @@ import { State } from "@/types/proto/api/v1/common";
...
@@ -5,54 +5,15 @@ import { State } from "@/types/proto/api/v1/common";
import
{
Memo
}
from
"@/types/proto/api/v1/memo_service"
;
import
{
Memo
}
from
"@/types/proto/api/v1/memo_service"
;
export
interface
UseMemoSortingOptions
{
export
interface
UseMemoSortingOptions
{
/**
* Whether to sort pinned memos first
* Default: false
*/
pinnedFirst
?:
boolean
;
pinnedFirst
?:
boolean
;
/**
* State to filter memos by (NORMAL, ARCHIVED, etc.)
* Default: State.NORMAL
*/
state
?:
State
;
state
?:
State
;
}
}
export
interface
UseMemoSortingResult
{
export
interface
UseMemoSortingResult
{
/**
* Sort function to pass to PagedMemoList's listSort prop
*/
listSort
:
(
memos
:
Memo
[])
=>
Memo
[];
listSort
:
(
memos
:
Memo
[])
=>
Memo
[];
/**
* Order by string to pass to PagedMemoList's orderBy prop
*/
orderBy
:
string
;
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
=>
{
export
const
useMemoSorting
=
(
options
:
UseMemoSortingOptions
=
{}):
UseMemoSortingResult
=>
{
const
{
pinnedFirst
=
false
,
state
=
State
.
NORMAL
}
=
options
;
const
{
pinnedFirst
=
false
,
state
=
State
.
NORMAL
}
=
options
;
...
...
web/src/pages/Attachments.tsx
View file @
a6a8997f
...
@@ -22,9 +22,6 @@ import { useTranslate } from "@/utils/i18n";
...
@@ -22,9 +22,6 @@ import { useTranslate } from "@/utils/i18n";
const
PAGE_SIZE
=
50
;
const
PAGE_SIZE
=
50
;
/**
* Groups attachments by month for organized display
*/
const
groupAttachmentsByDate
=
(
attachments
:
Attachment
[]):
Map
<
string
,
Attachment
[]
>
=>
{
const
groupAttachmentsByDate
=
(
attachments
:
Attachment
[]):
Map
<
string
,
Attachment
[]
>
=>
{
const
grouped
=
new
Map
<
string
,
Attachment
[]
>
();
const
grouped
=
new
Map
<
string
,
Attachment
[]
>
();
const
sorted
=
[...
attachments
].
sort
((
a
,
b
)
=>
dayjs
(
b
.
createTime
).
unix
()
-
dayjs
(
a
.
createTime
).
unix
());
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
...
@@ -39,18 +36,12 @@ const groupAttachmentsByDate = (attachments: Attachment[]): Map<string, Attachme
return
grouped
;
return
grouped
;
};
};
/**
* Filters attachments based on search query
*/
const
filterAttachments
=
(
attachments
:
Attachment
[],
searchQuery
:
string
):
Attachment
[]
=>
{
const
filterAttachments
=
(
attachments
:
Attachment
[],
searchQuery
:
string
):
Attachment
[]
=>
{
if
(
!
searchQuery
.
trim
())
return
attachments
;
if
(
!
searchQuery
.
trim
())
return
attachments
;
const
query
=
searchQuery
.
toLowerCase
();
const
query
=
searchQuery
.
toLowerCase
();
return
attachments
.
filter
((
attachment
)
=>
attachment
.
filename
.
toLowerCase
().
includes
(
query
));
return
attachments
.
filter
((
attachment
)
=>
attachment
.
filename
.
toLowerCase
().
includes
(
query
));
};
};
/**
* Individual attachment item component
*/
interface
AttachmentItemProps
{
interface
AttachmentItemProps
{
attachment
:
Attachment
;
attachment
:
Attachment
;
}
}
...
...
web/src/store/attachment.ts
View file @
a6a8997f
/**
// Attachment Store - manages file attachment state including uploads and metadata
* Attachment Store
*
* Manages file attachment state including uploads and metadata.
* This is a server state store that fetches and caches attachment data.
*/
import
{
computed
,
makeObservable
,
observable
}
from
"mobx"
;
import
{
computed
,
makeObservable
,
observable
}
from
"mobx"
;
import
{
attachmentServiceClient
}
from
"@/grpcweb"
;
import
{
attachmentServiceClient
}
from
"@/grpcweb"
;
import
{
Attachment
,
CreateAttachmentRequest
,
UpdateAttachmentRequest
}
from
"@/types/proto/api/v1/attachment_service"
;
import
{
Attachment
,
CreateAttachmentRequest
,
UpdateAttachmentRequest
}
from
"@/types/proto/api/v1/attachment_service"
;
import
{
createServerStore
,
StandardState
}
from
"./base-store"
;
import
{
createServerStore
,
StandardState
}
from
"./base-store"
;
import
{
createRequestKey
}
from
"./store-utils"
;
import
{
createRequestKey
}
from
"./store-utils"
;
/**
* Attachment store state
* Uses a name-based map for efficient lookups
*/
class
AttachmentState
extends
StandardState
{
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
>
=
{};
attachmentMapByName
:
Record
<
string
,
Attachment
>
=
{};
constructor
()
{
constructor
()
{
...
@@ -29,24 +18,15 @@ class AttachmentState extends StandardState {
...
@@ -29,24 +18,15 @@ class AttachmentState extends StandardState {
});
});
}
}
/**
* Computed getter for all attachments as an array
*/
get
attachments
():
Attachment
[]
{
get
attachments
():
Attachment
[]
{
return
Object
.
values
(
this
.
attachmentMapByName
);
return
Object
.
values
(
this
.
attachmentMapByName
);
}
}
/**
* Get attachment count
*/
get
size
():
number
{
get
size
():
number
{
return
Object
.
keys
(
this
.
attachmentMapByName
).
length
;
return
Object
.
keys
(
this
.
attachmentMapByName
).
length
;
}
}
}
}
/**
* Attachment store instance
*/
const
attachmentStore
=
(()
=>
{
const
attachmentStore
=
(()
=>
{
const
base
=
createServerStore
(
new
AttachmentState
(),
{
const
base
=
createServerStore
(
new
AttachmentState
(),
{
name
:
"attachment"
,
name
:
"attachment"
,
...
@@ -55,13 +35,6 @@ const attachmentStore = (() => {
...
@@ -55,13 +35,6 @@ const attachmentStore = (() => {
const
{
state
,
executeRequest
}
=
base
;
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
fetchAttachmentByName
=
async
(
name
:
string
):
Promise
<
Attachment
>
=>
{
const
requestKey
=
createRequestKey
(
"fetchAttachment"
,
{
name
});
const
requestKey
=
createRequestKey
(
"fetchAttachment"
,
{
name
});
...
@@ -84,24 +57,10 @@ const attachmentStore = (() => {
...
@@ -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
=>
{
const
getAttachmentByName
=
(
name
:
string
):
Attachment
|
undefined
=>
{
return
state
.
attachmentMapByName
[
name
];
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
getOrFetchAttachmentByName
=
async
(
name
:
string
):
Promise
<
Attachment
>
=>
{
const
cached
=
getAttachmentByName
(
name
);
const
cached
=
getAttachmentByName
(
name
);
if
(
cached
)
{
if
(
cached
)
{
...
@@ -110,12 +69,6 @@ const attachmentStore = (() => {
...
@@ -110,12 +69,6 @@ const attachmentStore = (() => {
return
fetchAttachmentByName
(
name
);
return
fetchAttachmentByName
(
name
);
};
};
/**
* Create a new attachment
*
* @param request - Attachment creation request
* @returns The created attachment
*/
const
createAttachment
=
async
(
request
:
CreateAttachmentRequest
):
Promise
<
Attachment
>
=>
{
const
createAttachment
=
async
(
request
:
CreateAttachmentRequest
):
Promise
<
Attachment
>
=>
{
return
executeRequest
(
return
executeRequest
(
""
,
// No deduplication for creates
""
,
// No deduplication for creates
...
@@ -136,12 +89,6 @@ const attachmentStore = (() => {
...
@@ -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
>
=>
{
const
updateAttachment
=
async
(
request
:
UpdateAttachmentRequest
):
Promise
<
Attachment
>
=>
{
return
executeRequest
(
return
executeRequest
(
""
,
// No deduplication for updates
""
,
// No deduplication for updates
...
@@ -162,11 +109,6 @@ const attachmentStore = (() => {
...
@@ -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
>
=>
{
const
deleteAttachment
=
async
(
name
:
string
):
Promise
<
void
>
=>
{
return
executeRequest
(
return
executeRequest
(
""
,
// No deduplication for deletes
""
,
// No deduplication for deletes
...
@@ -182,9 +124,6 @@ const attachmentStore = (() => {
...
@@ -182,9 +124,6 @@ const attachmentStore = (() => {
);
);
};
};
/**
* Clear all cached attachments
*/
const
clearCache
=
():
void
=>
{
const
clearCache
=
():
void
=>
{
state
.
setPartial
({
attachmentMapByName
:
{}
});
state
.
setPartial
({
attachmentMapByName
:
{}
});
};
};
...
...
web/src/store/base-store.ts
View file @
a6a8997f
/**
// Base store classes and utilities for consistent store patterns
* 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
* This module provides:
* - BaseServerStore: For stores that fetch data from APIs
* - BaseClientStore: For stores that manage UI/client state
* - Common patterns for all stores
*/
import
{
action
,
makeObservable
}
from
"mobx"
;
import
{
action
,
makeObservable
}
from
"mobx"
;
import
{
RequestDeduplicator
,
StoreError
}
from
"./store-utils"
;
import
{
RequestDeduplicator
,
StoreError
}
from
"./store-utils"
;
/**
* Base interface for all store states
* Ensures all stores have a consistent setPartial method
*/
export
interface
BaseState
{
export
interface
BaseState
{
setPartial
(
partial
:
Partial
<
this
>
):
void
;
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
{
export
interface
ServerStoreConfig
{
/**
* Enable request deduplication
* Prevents multiple identical requests from running simultaneously
*/
enableDeduplication
?:
boolean
;
enableDeduplication
?:
boolean
;
/**
* Store name for debugging and error messages
*/
name
:
string
;
name
:
string
;
}
}
/**
* Create a server store with built-in utilities
*/
export
function
createServerStore
<
TState
extends
BaseState
>
(
state
:
TState
,
config
:
ServerStoreConfig
)
{
export
function
createServerStore
<
TState
extends
BaseState
>
(
state
:
TState
,
config
:
ServerStoreConfig
)
{
const
deduplicator
=
config
.
enableDeduplication
!==
false
?
new
RequestDeduplicator
()
:
null
;
const
deduplicator
=
config
.
enableDeduplication
!==
false
?
new
RequestDeduplicator
()
:
null
;
...
@@ -59,9 +21,6 @@ export function createServerStore<TState extends BaseState>(state: TState, confi
...
@@ -59,9 +21,6 @@ export function createServerStore<TState extends BaseState>(state: TState, confi
deduplicator
,
deduplicator
,
name
:
config
.
name
,
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
>
{
async
executeRequest
<
T
>
(
key
:
string
,
operation
:
()
=>
Promise
<
T
>
,
errorCode
?:
string
):
Promise
<
T
>
{
try
{
try
{
if
(
deduplicator
&&
key
)
{
if
(
deduplicator
&&
key
)
{
...
@@ -70,7 +29,7 @@ export function createServerStore<TState extends BaseState>(state: TState, confi
...
@@ -70,7 +29,7 @@ export function createServerStore<TState extends BaseState>(state: TState, confi
return
await
operation
();
return
await
operation
();
}
catch
(
error
)
{
}
catch
(
error
)
{
if
(
StoreError
.
isAbortError
(
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
);
throw
StoreError
.
wrap
(
errorCode
||
`
${
config
.
name
.
toUpperCase
()}
_OPERATION_FAILED`
,
error
);
}
}
...
@@ -78,35 +37,8 @@ export function createServerStore<TState extends BaseState>(state: TState, confi
...
@@ -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
{
export
interface
ClientStoreConfig
{
/**
* Store name for debugging
*/
name
:
string
;
name
:
string
;
/**
* Enable localStorage persistence
*/
persistence
?:
{
persistence
?:
{
key
:
string
;
key
:
string
;
serialize
?:
(
state
:
any
)
=>
string
;
serialize
?:
(
state
:
any
)
=>
string
;
...
@@ -114,9 +46,6 @@ export interface ClientStoreConfig {
...
@@ -114,9 +46,6 @@ export interface ClientStoreConfig {
};
};
}
}
/**
* Create a client store with optional persistence
*/
export
function
createClientStore
<
TState
extends
BaseState
>
(
state
:
TState
,
config
:
ClientStoreConfig
)
{
export
function
createClientStore
<
TState
extends
BaseState
>
(
state
:
TState
,
config
:
ClientStoreConfig
)
{
// Load from localStorage if enabled
// Load from localStorage if enabled
if
(
config
.
persistence
)
{
if
(
config
.
persistence
)
{
...
@@ -135,9 +64,6 @@ export function createClientStore<TState extends BaseState>(state: TState, confi
...
@@ -135,9 +64,6 @@ export function createClientStore<TState extends BaseState>(state: TState, confi
state
,
state
,
name
:
config
.
name
,
name
:
config
.
name
,
/**
* Save state to localStorage if persistence is enabled
*/
persist
():
void
{
persist
():
void
{
if
(
config
.
persistence
)
{
if
(
config
.
persistence
)
{
try
{
try
{
...
@@ -149,9 +75,6 @@ export function createClientStore<TState extends BaseState>(state: TState, confi
...
@@ -149,9 +75,6 @@ export function createClientStore<TState extends BaseState>(state: TState, confi
}
}
},
},
/**
* Clear persisted state
*/
clearPersistence
():
void
{
clearPersistence
():
void
{
if
(
config
.
persistence
)
{
if
(
config
.
persistence
)
{
localStorage
.
removeItem
(
config
.
persistence
.
key
);
localStorage
.
removeItem
(
config
.
persistence
.
key
);
...
@@ -160,10 +83,6 @@ export function createClientStore<TState extends BaseState>(state: TState, confi
...
@@ -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
{
export
abstract
class
StandardState
implements
BaseState
{
constructor
()
{
constructor
()
{
makeObservable
(
this
,
{
makeObservable
(
this
,
{
...
...
web/src/store/config.ts
View file @
a6a8997f
/**
// MobX configuration for strict state management
* MobX configuration for strict state management
// Enforces best practices: state changes must happen in actions, computed values cannot have side effects
*
* 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.
*/
import
{
configure
}
from
"mobx"
;
import
{
configure
}
from
"mobx"
;
/**
* Configure MobX with production-safe settings
* This runs immediately when the module is imported
*/
configure
({
configure
({
/**
// Enforce that all state mutations happen within actions (start permissive, can upgrade later)
* Enforce that all state mutations happen within actions
enforceActions
:
"never"
,
* Since we use makeAutoObservable, all methods are automatically actions
// Use Proxies for better performance and ES6 compatibility (required for makeAutoObservable)
* 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
*/
useProxies
:
"always"
,
useProxies
:
"always"
,
// Isolate global state to prevent accidental sharing between tests
/**
* Isolate global state to prevent accidental sharing between tests
*/
isolateGlobalState
:
true
,
isolateGlobalState
:
true
,
// Disable error boundaries so errors propagate normally
/**
* Disable error boundaries so errors propagate normally
* This ensures React error boundaries can catch store errors
*/
disableErrorBoundaries
:
false
,
disableErrorBoundaries
:
false
,
});
});
/**
* Enable strict mode for development
* Call this in main.tsx if you want stricter checking
*/
export
function
enableStrictMode
()
{
export
function
enableStrictMode
()
{
if
(
import
.
meta
.
env
.
DEV
)
{
if
(
import
.
meta
.
env
.
DEV
)
{
configure
({
configure
({
enforceActions
:
"observed"
,
// Enforce actions only for observed values
enforceActions
:
"observed"
,
computedRequiresReaction
:
false
,
// Don't warn about computed access
computedRequiresReaction
:
false
,
reactionRequiresObservable
:
false
,
// Don't warn about reactions
reactionRequiresObservable
:
false
,
});
});
console
.
info
(
"✓ MobX strict mode enabled"
);
console
.
info
(
"✓ MobX strict mode enabled"
);
}
}
}
}
/**
* Enable production mode for maximum performance
* This is automatically called in production builds
*/
export
function
enableProductionMode
()
{
export
function
enableProductionMode
()
{
configure
({
configure
({
enforceActions
:
"never"
,
// No runtime checks for performance
enforceActions
:
"never"
,
disableErrorBoundaries
:
false
,
disableErrorBoundaries
:
false
,
});
});
}
}
web/src/store/index.ts
View file @
a6a8997f
/**
// Store Module - exports all application stores and their types
* Store Module
// Server State Stores (fetch/cache backend data): memoStore, userStore, instanceStore, attachmentStore
*
// Client State Stores (UI preferences): viewStore, memoFilterStore
* 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
import
attachmentStore
from
"./attachment"
;
import
attachmentStore
from
"./attachment"
;
import
instanceStore
from
"./instance"
;
import
instanceStore
from
"./instance"
;
import
memoStore
from
"./memo"
;
import
memoStore
from
"./memo"
;
...
@@ -89,9 +45,6 @@ export {
...
@@ -89,9 +45,6 @@ export {
viewStore
,
viewStore
,
};
};
/**
* All stores grouped by category for convenience
*/
export
const
stores
=
{
export
const
stores
=
{
// Server state
// Server state
server
:
{
server
:
{
...
...
web/src/store/instance.ts
View file @
a6a8997f
/**
// Instance Store - manages instance-level configuration and settings
* Instance Store
*
* Manages instance-level configuration and settings.
* This is a server state store that fetches instance profile and settings.
*/
import
{
uniqBy
}
from
"lodash-es"
;
import
{
uniqBy
}
from
"lodash-es"
;
import
{
computed
}
from
"mobx"
;
import
{
computed
}
from
"mobx"
;
import
{
instanceServiceClient
}
from
"@/grpcweb"
;
import
{
instanceServiceClient
}
from
"@/grpcweb"
;
...
@@ -19,48 +14,20 @@ import { createServerStore, StandardState } from "./base-store";
...
@@ -19,48 +14,20 @@ import { createServerStore, StandardState } from "./base-store";
import
{
instanceSettingNamePrefix
}
from
"./common"
;
import
{
instanceSettingNamePrefix
}
from
"./common"
;
import
{
createRequestKey
}
from
"./store-utils"
;
import
{
createRequestKey
}
from
"./store-utils"
;
/**
* Valid theme options
*/
const
VALID_THEMES
=
[
"system"
,
"default"
,
"default-dark"
,
"midnight"
,
"paper"
,
"whitewall"
]
as
const
;
const
VALID_THEMES
=
[
"system"
,
"default"
,
"default-dark"
,
"midnight"
,
"paper"
,
"whitewall"
]
as
const
;
export
type
Theme
=
(
typeof
VALID_THEMES
)[
number
];
export
type
Theme
=
(
typeof
VALID_THEMES
)[
number
];
/**
* Check if a string is a valid theme
*/
export
function
isValidTheme
(
theme
:
string
):
theme
is
Theme
{
export
function
isValidTheme
(
theme
:
string
):
theme
is
Theme
{
return
VALID_THEMES
.
includes
(
theme
as
Theme
);
return
VALID_THEMES
.
includes
(
theme
as
Theme
);
}
}
/**
* Instance store state
*/
class
InstanceState
extends
StandardState
{
class
InstanceState
extends
StandardState
{
/**
* Current locale (e.g., "en", "zh", "ja")
*/
locale
:
string
=
"en"
;
locale
:
string
=
"en"
;
/**
* Current theme
* Note: Accepts string for flexibility, but validates to Theme
*/
theme
:
Theme
|
string
=
"system"
;
theme
:
Theme
|
string
=
"system"
;
/**
* Instance profile containing owner and metadata
*/
profile
:
InstanceProfile
=
InstanceProfile
.
fromPartial
({});
profile
:
InstanceProfile
=
InstanceProfile
.
fromPartial
({});
/**
* Array of instance settings
*/
settings
:
InstanceSetting
[]
=
[];
settings
:
InstanceSetting
[]
=
[];
/**
// Computed property for general settings (memoized)
* Computed property for general settings
* Memoized for performance
*/
get
generalSetting
():
InstanceSetting_GeneralSetting
{
get
generalSetting
():
InstanceSetting_GeneralSetting
{
return
computed
(()
=>
{
return
computed
(()
=>
{
const
setting
=
this
.
settings
.
find
((
s
)
=>
s
.
name
===
`
${
instanceSettingNamePrefix
}${
InstanceSetting_Key
.
GENERAL
}
`
);
const
setting
=
this
.
settings
.
find
((
s
)
=>
s
.
name
===
`
${
instanceSettingNamePrefix
}${
InstanceSetting_Key
.
GENERAL
}
`
);
...
@@ -68,10 +35,7 @@ class InstanceState extends StandardState {
...
@@ -68,10 +35,7 @@ class InstanceState extends StandardState {
}).
get
();
}).
get
();
}
}
/**
// Computed property for memo-related settings (memoized)
* Computed property for memo-related settings
* Memoized for performance
*/
get
memoRelatedSetting
():
InstanceSetting_MemoRelatedSetting
{
get
memoRelatedSetting
():
InstanceSetting_MemoRelatedSetting
{
return
computed
(()
=>
{
return
computed
(()
=>
{
const
setting
=
this
.
settings
.
find
((
s
)
=>
s
.
name
===
`
${
instanceSettingNamePrefix
}${
InstanceSetting_Key
.
MEMO_RELATED
}
`
);
const
setting
=
this
.
settings
.
find
((
s
)
=>
s
.
name
===
`
${
instanceSettingNamePrefix
}${
InstanceSetting_Key
.
MEMO_RELATED
}
`
);
...
@@ -79,9 +43,6 @@ class InstanceState extends StandardState {
...
@@ -79,9 +43,6 @@ class InstanceState extends StandardState {
}).
get
();
}).
get
();
}
}
/**
* Override setPartial to validate locale and theme
*/
setPartial
(
partial
:
Partial
<
InstanceState
>
):
void
{
setPartial
(
partial
:
Partial
<
InstanceState
>
):
void
{
const
finalState
=
{
...
this
,
...
partial
};
const
finalState
=
{
...
this
,
...
partial
};
...
@@ -106,9 +67,6 @@ class InstanceState extends StandardState {
...
@@ -106,9 +67,6 @@ class InstanceState extends StandardState {
}
}
}
}
/**
* Instance store instance
*/
const
instanceStore
=
(()
=>
{
const
instanceStore
=
(()
=>
{
const
base
=
createServerStore
(
new
InstanceState
(),
{
const
base
=
createServerStore
(
new
InstanceState
(),
{
name
:
"instance"
,
name
:
"instance"
,
...
@@ -117,11 +75,6 @@ const instanceStore = (() => {
...
@@ -117,11 +75,6 @@ const instanceStore = (() => {
const
{
state
,
executeRequest
}
=
base
;
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
fetchInstanceSetting
=
async
(
settingKey
:
InstanceSetting_Key
):
Promise
<
void
>
=>
{
const
requestKey
=
createRequestKey
(
"fetchInstanceSetting"
,
{
key
:
settingKey
});
const
requestKey
=
createRequestKey
(
"fetchInstanceSetting"
,
{
key
:
settingKey
});
...
@@ -141,11 +94,6 @@ const instanceStore = (() => {
...
@@ -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
>
=>
{
const
upsertInstanceSetting
=
async
(
setting
:
InstanceSetting
):
Promise
<
void
>
=>
{
return
executeRequest
(
return
executeRequest
(
""
,
// No deduplication for updates
""
,
// No deduplication for updates
...
@@ -161,24 +109,11 @@ const instanceStore = (() => {
...
@@ -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
getInstanceSettingByKey
=
(
settingKey
:
InstanceSetting_Key
):
InstanceSetting
=>
{
const
setting
=
state
.
settings
.
find
((
s
)
=>
s
.
name
===
`
${
instanceSettingNamePrefix
}${
settingKey
}
`
);
const
setting
=
state
.
settings
.
find
((
s
)
=>
s
.
name
===
`
${
instanceSettingNamePrefix
}${
settingKey
}
`
);
return
setting
||
InstanceSetting
.
fromPartial
({});
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
>
=>
{
const
setTheme
=
async
(
theme
:
string
):
Promise
<
void
>
=>
{
// Validate theme
// Validate theme
if
(
!
isValidTheme
(
theme
))
{
if
(
!
isValidTheme
(
theme
))
{
...
@@ -206,9 +141,6 @@ const instanceStore = (() => {
...
@@ -206,9 +141,6 @@ const instanceStore = (() => {
);
);
};
};
/**
* Fetch instance profile
*/
const
fetchInstanceProfile
=
async
():
Promise
<
InstanceProfile
>
=>
{
const
fetchInstanceProfile
=
async
():
Promise
<
InstanceProfile
>
=>
{
const
requestKey
=
createRequestKey
(
"fetchInstanceProfile"
);
const
requestKey
=
createRequestKey
(
"fetchInstanceProfile"
);
...
@@ -233,12 +165,7 @@ const instanceStore = (() => {
...
@@ -233,12 +165,7 @@ const instanceStore = (() => {
};
};
})();
})();
/**
// Initialize the instance store - called once at app startup
* Initialize the instance store
* Called once at app startup to load instance profile and settings
*
* @throws Never - errors are logged but not thrown
*/
export
const
initialInstanceStore
=
async
():
Promise
<
void
>
=>
{
export
const
initialInstanceStore
=
async
():
Promise
<
void
>
=>
{
try
{
try
{
// Fetch instance profile
// Fetch instance profile
...
...
web/src/store/memoFilter.ts
View file @
a6a8997f
/**
// Memo Filter Store - manages active memo filters and search state
* Memo Filter Store
// This is a client state store that syncs with URL query parameters
*
* 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.
*/
import
{
uniqBy
}
from
"lodash-es"
;
import
{
uniqBy
}
from
"lodash-es"
;
import
{
action
,
computed
,
makeObservable
,
observable
}
from
"mobx"
;
import
{
action
,
computed
,
makeObservable
,
observable
}
from
"mobx"
;
import
{
StandardState
}
from
"./base-store"
;
import
{
StandardState
}
from
"./base-store"
;
/**
* Filter factor types
* Defines what aspect of a memo to filter by
*/
export
type
FilterFactor
=
export
type
FilterFactor
=
|
"tagSearch"
// Filter by tag name
|
"tagSearch"
|
"visibility"
// Filter by visibility (public/private)
|
"visibility"
|
"contentSearch"
// Search in memo content
|
"contentSearch"
|
"displayTime"
// Filter by date
|
"displayTime"
|
"pinned"
// Show only pinned memos
|
"pinned"
|
"property.hasLink"
// Memos containing links
|
"property.hasLink"
|
"property.hasTaskList"
// Memos with task lists
|
"property.hasTaskList"
|
"property.hasCode"
;
// Memos with code blocks
|
"property.hasCode"
;
/**
* Memo filter object
*/
export
interface
MemoFilter
{
export
interface
MemoFilter
{
factor
:
FilterFactor
;
factor
:
FilterFactor
;
value
:
string
;
value
:
string
;
}
}
/**
* Generate a unique key for a filter
* Used for deduplication
*/
export
const
getMemoFilterKey
=
(
filter
:
MemoFilter
):
string
=>
`
${
filter
.
factor
}
:
${
filter
.
value
}
`
;
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
[]
=>
{
export
const
parseFilterQuery
=
(
query
:
string
|
null
):
MemoFilter
[]
=>
{
if
(
!
query
)
return
[];
if
(
!
query
)
return
[];
...
@@ -61,34 +38,14 @@ export const parseFilterQuery = (query: string | null): MemoFilter[] => {
...
@@ -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
=>
{
export
const
stringifyFilters
=
(
filters
:
MemoFilter
[]):
string
=>
{
return
filters
.
map
((
filter
)
=>
`
${
filter
.
factor
}
:
${
encodeURIComponent
(
filter
.
value
)}
`
).
join
(
","
);
return
filters
.
map
((
filter
)
=>
`
${
filter
.
factor
}
:
${
encodeURIComponent
(
filter
.
value
)}
`
).
join
(
","
);
};
};
/**
* Memo filter store state
*/
class
MemoFilterState
extends
StandardState
{
class
MemoFilterState
extends
StandardState
{
/**
* Active filters
*/
filters
:
MemoFilter
[]
=
[];
filters
:
MemoFilter
[]
=
[];
/**
* Currently selected shortcut ID
* Shortcuts are predefined filter combinations
*/
shortcut
?:
string
=
undefined
;
shortcut
?:
string
=
undefined
;
/**
* Initialize from URL on construction
*/
constructor
()
{
constructor
()
{
super
();
super
();
makeObservable
(
this
,
{
makeObservable
(
this
,
{
...
@@ -104,9 +61,6 @@ class MemoFilterState extends StandardState {
...
@@ -104,9 +61,6 @@ class MemoFilterState extends StandardState {
this
.
initFromURL
();
this
.
initFromURL
();
}
}
/**
* Load filters from current URL query parameters
*/
private
initFromURL
():
void
{
private
initFromURL
():
void
{
try
{
try
{
const
searchParams
=
new
URLSearchParams
(
window
.
location
.
search
);
const
searchParams
=
new
URLSearchParams
(
window
.
location
.
search
);
...
@@ -117,144 +71,60 @@ class MemoFilterState extends StandardState {
...
@@ -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
[]
{
getFiltersByFactor
(
factor
:
FilterFactor
):
MemoFilter
[]
{
return
this
.
filters
.
filter
((
f
)
=>
f
.
factor
===
factor
);
return
this
.
filters
.
filter
((
f
)
=>
f
.
factor
===
factor
);
}
}
/**
* Add a filter (deduplicates automatically)
*
* @param filter - The filter to add
*/
addFilter
(
filter
:
MemoFilter
):
void
{
addFilter
(
filter
:
MemoFilter
):
void
{
this
.
filters
=
uniqBy
([...
this
.
filters
,
filter
],
getMemoFilterKey
);
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
{
removeFilter
(
predicate
:
(
f
:
MemoFilter
)
=>
boolean
):
void
{
this
.
filters
=
this
.
filters
.
filter
((
f
)
=>
!
predicate
(
f
));
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
{
removeFiltersByFactor
(
factor
:
FilterFactor
):
void
{
this
.
filters
=
this
.
filters
.
filter
((
f
)
=>
f
.
factor
!==
factor
);
this
.
filters
=
this
.
filters
.
filter
((
f
)
=>
f
.
factor
!==
factor
);
}
}
/**
* Clear all filters
*/
clearAllFilters
():
void
{
clearAllFilters
():
void
{
this
.
filters
=
[];
this
.
filters
=
[];
this
.
shortcut
=
undefined
;
this
.
shortcut
=
undefined
;
}
}
/**
* Set the current shortcut
*
* @param shortcut - Shortcut ID or undefined to clear
*/
setShortcut
(
shortcut
?:
string
):
void
{
setShortcut
(
shortcut
?:
string
):
void
{
this
.
shortcut
=
shortcut
;
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
{
hasFilter
(
filter
:
MemoFilter
):
boolean
{
return
this
.
filters
.
some
((
f
)
=>
getMemoFilterKey
(
f
)
===
getMemoFilterKey
(
filter
));
return
this
.
filters
.
some
((
f
)
=>
getMemoFilterKey
(
f
)
===
getMemoFilterKey
(
filter
));
}
}
/**
* Check if any filters are active
*/
get
hasActiveFilters
():
boolean
{
get
hasActiveFilters
():
boolean
{
return
this
.
filters
.
length
>
0
||
this
.
shortcut
!==
undefined
;
return
this
.
filters
.
length
>
0
||
this
.
shortcut
!==
undefined
;
}
}
}
}
/**
* Memo filter store instance
*/
const
memoFilterStore
=
(()
=>
{
const
memoFilterStore
=
(()
=>
{
const
state
=
new
MemoFilterState
();
const
state
=
new
MemoFilterState
();
return
{
return
{
/**
* Direct access to state for observers
*/
state
,
state
,
/**
* Get all active filters
*/
get
filters
():
MemoFilter
[]
{
get
filters
():
MemoFilter
[]
{
return
state
.
filters
;
return
state
.
filters
;
},
},
/**
* Get current shortcut ID
*/
get
shortcut
():
string
|
undefined
{
get
shortcut
():
string
|
undefined
{
return
state
.
shortcut
;
return
state
.
shortcut
;
},
},
/**
* Check if any filters are active
*/
get
hasActiveFilters
():
boolean
{
get
hasActiveFilters
():
boolean
{
return
state
.
hasActiveFilters
;
return
state
.
hasActiveFilters
;
},
},
/**
* Get filters by factor
*/
getFiltersByFactor
:
(
factor
:
FilterFactor
):
MemoFilter
[]
=>
state
.
getFiltersByFactor
(
factor
),
getFiltersByFactor
:
(
factor
:
FilterFactor
):
MemoFilter
[]
=>
state
.
getFiltersByFactor
(
factor
),
/**
* Add a filter
*/
addFilter
:
(
filter
:
MemoFilter
):
void
=>
state
.
addFilter
(
filter
),
addFilter
:
(
filter
:
MemoFilter
):
void
=>
state
.
addFilter
(
filter
),
/**
* Remove filters matching predicate
*/
removeFilter
:
(
predicate
:
(
f
:
MemoFilter
)
=>
boolean
):
void
=>
state
.
removeFilter
(
predicate
),
removeFilter
:
(
predicate
:
(
f
:
MemoFilter
)
=>
boolean
):
void
=>
state
.
removeFilter
(
predicate
),
/**
* Remove all filters for a factor
*/
removeFiltersByFactor
:
(
factor
:
FilterFactor
):
void
=>
state
.
removeFiltersByFactor
(
factor
),
removeFiltersByFactor
:
(
factor
:
FilterFactor
):
void
=>
state
.
removeFiltersByFactor
(
factor
),
/**
* Clear all filters
*/
clearAllFilters
:
():
void
=>
state
.
clearAllFilters
(),
clearAllFilters
:
():
void
=>
state
.
clearAllFilters
(),
/**
* Set current shortcut
*/
setShortcut
:
(
shortcut
?:
string
):
void
=>
state
.
setShortcut
(
shortcut
),
setShortcut
:
(
shortcut
?:
string
):
void
=>
state
.
setShortcut
(
shortcut
),
/**
* Check if a filter is active
*/
hasFilter
:
(
filter
:
MemoFilter
):
boolean
=>
state
.
hasFilter
(
filter
),
hasFilter
:
(
filter
:
MemoFilter
):
boolean
=>
state
.
hasFilter
(
filter
),
};
};
})();
})();
...
...
web/src/store/store-utils.ts
View file @
a6a8997f
/**
// Store utilities for MobX stores
* Store utilities for MobX stores
// Provides request deduplication, error handling, and other common patterns
* 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
{
export
class
StoreError
extends
Error
{
constructor
(
constructor
(
public
readonly
code
:
string
,
public
readonly
code
:
string
,
...
@@ -17,16 +11,10 @@ export class StoreError extends Error {
...
@@ -17,16 +11,10 @@ export class StoreError extends Error {
this
.
name
=
"StoreError"
;
this
.
name
=
"StoreError"
;
}
}
/**
* Check if an error is an AbortError from a cancelled request
*/
static
isAbortError
(
error
:
unknown
):
boolean
{
static
isAbortError
(
error
:
unknown
):
boolean
{
return
error
instanceof
Error
&&
error
.
name
===
"AbortError"
;
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
{
static
wrap
(
code
:
string
,
error
:
unknown
,
customMessage
?:
string
):
StoreError
{
if
(
error
instanceof
StoreError
)
{
if
(
error
instanceof
StoreError
)
{
return
error
;
return
error
;
...
@@ -37,21 +25,10 @@ export class StoreError extends Error {
...
@@ -37,21 +25,10 @@ export class StoreError extends Error {
}
}
}
}
/**
// Request deduplication manager - prevents multiple identical requests
* Request deduplication manager
* Prevents multiple identical requests from being made simultaneously
*/
export
class
RequestDeduplicator
{
export
class
RequestDeduplicator
{
private
pendingRequests
=
new
Map
<
string
,
Promise
<
any
>>
();
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
>
{
async
execute
<
T
>
(
key
:
string
,
requestFn
:
()
=>
Promise
<
T
>
):
Promise
<
T
>
{
// Check if this request is already pending
// Check if this request is already pending
if
(
this
.
pendingRequests
.
has
(
key
))
{
if
(
this
.
pendingRequests
.
has
(
key
))
{
...
@@ -70,32 +47,19 @@ export class RequestDeduplicator {
...
@@ -70,32 +47,19 @@ export class RequestDeduplicator {
return
promise
;
return
promise
;
}
}
/**
* Cancel all pending requests
*/
clear
():
void
{
clear
():
void
{
this
.
pendingRequests
.
clear
();
this
.
pendingRequests
.
clear
();
}
}
/**
* Check if a request with the given key is pending
*/
isPending
(
key
:
string
):
boolean
{
isPending
(
key
:
string
):
boolean
{
return
this
.
pendingRequests
.
has
(
key
);
return
this
.
pendingRequests
.
has
(
key
);
}
}
/**
* Get the number of pending requests
*/
get
size
():
number
{
get
size
():
number
{
return
this
.
pendingRequests
.
size
;
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
{
export
function
createRequestKey
(
prefix
:
string
,
params
?:
Record
<
string
,
any
>
):
string
{
if
(
!
params
)
{
if
(
!
params
)
{
return
prefix
;
return
prefix
;
...
@@ -115,23 +79,13 @@ export function createRequestKey(prefix: string, params?: Record<string, any>):
...
@@ -115,23 +79,13 @@ export function createRequestKey(prefix: string, params?: Record<string, any>):
return
`
${
prefix
}
:
${
JSON
.
stringify
(
sortedParams
)}
`
;
return
`
${
prefix
}
:
${
JSON
.
stringify
(
sortedParams
)}
`
;
}
}
/**
// Optimistic update helper with rollback on error
* Optimistic update helper
* Handles optimistic updates with rollback on error
*/
export
class
OptimisticUpdate
<
T
>
{
export
class
OptimisticUpdate
<
T
>
{
constructor
(
constructor
(
private
getCurrentState
:
()
=>
T
,
private
getCurrentState
:
()
=>
T
,
private
setState
:
(
state
:
T
)
=>
void
,
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
>
{
async
execute
<
R
>
(
optimisticState
:
T
,
updateFn
:
()
=>
Promise
<
R
>
):
Promise
<
R
>
{
const
previousState
=
this
.
getCurrentState
();
const
previousState
=
this
.
getCurrentState
();
...
...
web/src/store/user.ts
View file @
a6a8997f
...
@@ -31,11 +31,7 @@ class LocalState {
...
@@ -31,11 +31,7 @@ class LocalState {
// The state id of user stats map.
// The state id of user stats map.
statsStateId
=
uniqueId
();
statsStateId
=
uniqueId
();
/**
// Computed property that aggregates tag counts across all users (memoized)
* 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.
*/
get
tagCount
()
{
get
tagCount
()
{
return
computed
(()
=>
{
return
computed
(()
=>
{
const
tagCount
:
Record
<
string
,
number
>
=
{};
const
tagCount
:
Record
<
string
,
number
>
=
{};
...
@@ -306,17 +302,11 @@ const userStore = (() => {
...
@@ -306,17 +302,11 @@ const userStore = (() => {
};
};
})();
})();
/**
// Initializes the user store with proper sequencing:
* Initializes the user store with proper sequencing to avoid temporal coupling.
// 1. Fetch current authenticated user session
*
// 2. Set current user in store (required for subsequent calls)
* Initialization steps (order is critical):
// 3. Fetch user settings (depends on currentUser being set)
* 1. Fetch current authenticated user session
// 4. Apply user preferences to instance store
* 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
*/
export
const
initialUserStore
=
async
()
=>
{
export
const
initialUserStore
=
async
()
=>
{
try
{
try
{
// Step 1: Authenticate and get current user
// Step 1: Authenticate and get current user
...
...
web/src/store/view.ts
View file @
a6a8997f
/**
* 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
{
makeObservable
,
observable
}
from
"mobx"
;
import
{
StandardState
}
from
"./base-store"
;
import
{
StandardState
}
from
"./base-store"
;
const
LOCAL_STORAGE_KEY
=
"memos-view-setting"
;
const
LOCAL_STORAGE_KEY
=
"memos-view-setting"
;
/**
* Layout mode options
*/
export
type
LayoutMode
=
"LIST"
|
"MASONRY"
;
export
type
LayoutMode
=
"LIST"
|
"MASONRY"
;
/**
* View store state
* Contains UI preferences for displaying memos
*/
class
ViewState
extends
StandardState
{
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
;
orderByTimeAsc
:
boolean
=
false
;
// Display layout mode: LIST (vertical list) or MASONRY (Pinterest-style grid)
/**
* Display layout mode
* - LIST: Traditional vertical list
* - MASONRY: Pinterest-style grid layout
*/
layout
:
LayoutMode
=
"LIST"
;
layout
:
LayoutMode
=
"LIST"
;
constructor
()
{
constructor
()
{
...
@@ -39,9 +19,6 @@ class ViewState extends StandardState {
...
@@ -39,9 +19,6 @@ class ViewState extends StandardState {
});
});
}
}
/**
* Override setPartial to persist to localStorage
*/
setPartial
(
partial
:
Partial
<
ViewState
>
):
void
{
setPartial
(
partial
:
Partial
<
ViewState
>
):
void
{
// Validate layout if provided
// Validate layout if provided
if
(
partial
.
layout
!==
undefined
&&
!
[
"LIST"
,
"MASONRY"
].
includes
(
partial
.
layout
))
{
if
(
partial
.
layout
!==
undefined
&&
!
[
"LIST"
,
"MASONRY"
].
includes
(
partial
.
layout
))
{
...
@@ -66,9 +43,6 @@ class ViewState extends StandardState {
...
@@ -66,9 +43,6 @@ class ViewState extends StandardState {
}
}
}
}
/**
* View store instance
*/
const
viewStore
=
(()
=>
{
const
viewStore
=
(()
=>
{
const
state
=
new
ViewState
();
const
state
=
new
ViewState
();
...
@@ -92,25 +66,14 @@ const viewStore = (() => {
...
@@ -92,25 +66,14 @@ const viewStore = (() => {
console
.
warn
(
"Failed to load view settings from localStorage:"
,
error
);
console
.
warn
(
"Failed to load view settings from localStorage:"
,
error
);
}
}
/**
* Toggle sort order between ascending and descending
*/
const
toggleSortOrder
=
():
void
=>
{
const
toggleSortOrder
=
():
void
=>
{
state
.
setPartial
({
orderByTimeAsc
:
!
state
.
orderByTimeAsc
});
state
.
setPartial
({
orderByTimeAsc
:
!
state
.
orderByTimeAsc
});
};
};
/**
* Set the layout mode
*
* @param layout - The layout mode to set
*/
const
setLayout
=
(
layout
:
LayoutMode
):
void
=>
{
const
setLayout
=
(
layout
:
LayoutMode
):
void
=>
{
state
.
setPartial
({
layout
});
state
.
setPartial
({
layout
});
};
};
/**
* Reset to default settings
*/
const
resetToDefaults
=
():
void
=>
{
const
resetToDefaults
=
():
void
=>
{
state
.
setPartial
({
state
.
setPartial
({
orderByTimeAsc
:
false
,
orderByTimeAsc
:
false
,
...
@@ -118,9 +81,6 @@ const viewStore = (() => {
...
@@ -118,9 +81,6 @@ const viewStore = (() => {
});
});
};
};
/**
* Clear persisted settings
*/
const
clearStorage
=
():
void
=>
{
const
clearStorage
=
():
void
=>
{
localStorage
.
removeItem
(
LOCAL_STORAGE_KEY
);
localStorage
.
removeItem
(
LOCAL_STORAGE_KEY
);
};
};
...
...
web/src/utils/i18n.ts
View file @
a6a8997f
...
@@ -52,11 +52,7 @@ export const isValidateLocale = (locale: string | undefined | null): boolean =>
...
@@ -52,11 +52,7 @@ export const isValidateLocale = (locale: string | undefined | null): boolean =>
return
locales
.
includes
(
locale
);
return
locales
.
includes
(
locale
);
};
};
/**
// Get the display name for a locale in its native language
* 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
*/
export
const
getLocaleDisplayName
=
(
locale
:
string
):
string
=>
{
export
const
getLocaleDisplayName
=
(
locale
:
string
):
string
=>
{
try
{
try
{
const
displayName
=
new
Intl
.
DisplayNames
([
locale
],
{
type
:
"language"
}).
of
(
locale
);
const
displayName
=
new
Intl
.
DisplayNames
([
locale
],
{
type
:
"language"
}).
of
(
locale
);
...
...
web/src/utils/markdown-list-detection.ts
View file @
a6a8997f
/**
* Utilities for detecting list patterns in markdown text
*
* Used by the editor for auto-continuation of lists when user presses Enter
*/
export
interface
ListItemInfo
{
export
interface
ListItemInfo
{
type
:
"task"
|
"unordered"
|
"ordered"
|
null
;
type
:
"task"
|
"unordered"
|
"ordered"
|
null
;
symbol
?:
string
;
// For task/unordered lists: "- ", "* ", "+ "
symbol
?:
string
;
// For task/unordered lists: "- ", "* ", "+ "
...
@@ -11,12 +5,7 @@ export interface ListItemInfo {
...
@@ -11,12 +5,7 @@ export interface ListItemInfo {
indent
?:
string
;
// Leading whitespace
indent
?:
string
;
// Leading whitespace
}
}
/**
// Detect the list item type of the last line before cursor
* 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
*/
export
function
detectLastListItem
(
contentBeforeCursor
:
string
):
ListItemInfo
{
export
function
detectLastListItem
(
contentBeforeCursor
:
string
):
ListItemInfo
{
const
lines
=
contentBeforeCursor
.
split
(
"
\n
"
);
const
lines
=
contentBeforeCursor
.
split
(
"
\n
"
);
const
lastLine
=
lines
[
lines
.
length
-
1
];
const
lastLine
=
lines
[
lines
.
length
-
1
];
...
@@ -61,12 +50,7 @@ export function detectLastListItem(contentBeforeCursor: string): ListItemInfo {
...
@@ -61,12 +50,7 @@ export function detectLastListItem(contentBeforeCursor: string): ListItemInfo {
};
};
}
}
/**
// Generate the text to insert when pressing Enter on a list item
* 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
*/
export
function
generateListContinuation
(
listInfo
:
ListItemInfo
):
string
{
export
function
generateListContinuation
(
listInfo
:
ListItemInfo
):
string
{
const
indent
=
listInfo
.
indent
||
""
;
const
indent
=
listInfo
.
indent
||
""
;
...
...
web/src/utils/markdown-manipulation.ts
View file @
a6a8997f
/**
// Utilities for manipulating markdown strings (GitHub-style approach)
* Utilities for manipulating markdown strings (GitHub-style approach)
// These functions modify the raw markdown text directly without parsing to AST
*
* These functions modify the raw markdown text directly without parsing to AST.
* This is the same approach GitHub uses for task list updates.
*/
/**
* 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
{
export
function
toggleTaskAtLine
(
markdown
:
string
,
lineNumber
:
number
,
checked
:
boolean
):
string
{
const
lines
=
markdown
.
split
(
"
\n
"
);
const
lines
=
markdown
.
split
(
"
\n
"
);
...
@@ -27,7 +15,6 @@ export function toggleTaskAtLine(markdown: string, lineNumber: number, checked:
...
@@ -27,7 +15,6 @@ export function toggleTaskAtLine(markdown: string, lineNumber: number, checked:
const
match
=
line
.
match
(
taskPattern
);
const
match
=
line
.
match
(
taskPattern
);
if
(
!
match
)
{
if
(
!
match
)
{
// Not a task list item
return
markdown
;
return
markdown
;
}
}
...
@@ -38,14 +25,6 @@ export function toggleTaskAtLine(markdown: string, lineNumber: number, checked:
...
@@ -38,14 +25,6 @@ export function toggleTaskAtLine(markdown: string, lineNumber: number, checked:
return
lines
.
join
(
"
\n
"
);
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
{
export
function
toggleTaskAtIndex
(
markdown
:
string
,
taskIndex
:
number
,
checked
:
boolean
):
string
{
const
lines
=
markdown
.
split
(
"
\n
"
);
const
lines
=
markdown
.
split
(
"
\n
"
);
const
taskPattern
=
/^
(\s
*
[
-*+
]\s
+
)\[([
xX
])\](\s
+.*
)
$/
;
const
taskPattern
=
/^
(\s
*
[
-*+
]\s
+
)\[([
xX
])\](\s
+.*
)
$/
;
...
@@ -70,12 +49,6 @@ export function toggleTaskAtIndex(markdown: string, taskIndex: number, checked:
...
@@ -70,12 +49,6 @@ export function toggleTaskAtIndex(markdown: string, taskIndex: number, checked:
return
lines
.
join
(
"
\n
"
);
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
{
export
function
removeCompletedTasks
(
markdown
:
string
):
string
{
const
lines
=
markdown
.
split
(
"
\n
"
);
const
lines
=
markdown
.
split
(
"
\n
"
);
const
completedTaskPattern
=
/^
(\s
*
[
-*+
]\s
+
)\[([
xX
])\](\s
+.*
)
$/
;
const
completedTaskPattern
=
/^
(\s
*
[
-*+
]\s
+
)\[([
xX
])\](\s
+.*
)
$/
;
...
@@ -88,7 +61,7 @@ export function removeCompletedTasks(markdown: string): string {
...
@@ -88,7 +61,7 @@ export function removeCompletedTasks(markdown: string): string {
if
(
completedTaskPattern
.
test
(
line
))
{
if
(
completedTaskPattern
.
test
(
line
))
{
// Also skip the following line if it's empty (preserve spacing)
// Also skip the following line if it's empty (preserve spacing)
if
(
i
+
1
<
lines
.
length
&&
lines
[
i
+
1
].
trim
()
===
""
)
{
if
(
i
+
1
<
lines
.
length
&&
lines
[
i
+
1
].
trim
()
===
""
)
{
i
++
;
// Skip next line
i
++
;
}
}
continue
;
continue
;
}
}
...
@@ -99,12 +72,6 @@ export function removeCompletedTasks(markdown: string): string {
...
@@ -99,12 +72,6 @@ export function removeCompletedTasks(markdown: string): string {
return
result
.
join
(
"
\n
"
);
return
result
.
join
(
"
\n
"
);
}
}
/**
* Count tasks in markdown
*
* @param markdown - The full markdown content
* @returns Object with task counts
*/
export
function
countTasks
(
markdown
:
string
):
{
export
function
countTasks
(
markdown
:
string
):
{
total
:
number
;
total
:
number
;
completed
:
number
;
completed
:
number
;
...
@@ -134,24 +101,11 @@ export function countTasks(markdown: string): {
...
@@ -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
{
export
function
hasCompletedTasks
(
markdown
:
string
):
boolean
{
const
completedTaskPattern
=
/^
(\s
*
[
-*+
]\s
+
)\[([
xX
])\](\s
+.*
)
$/m
;
const
completedTaskPattern
=
/^
(\s
*
[
-*+
]\s
+
)\[([
xX
])\](\s
+.*
)
$/m
;
return
completedTaskPattern
.
test
(
markdown
);
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
{
export
function
getTaskLineNumber
(
markdown
:
string
,
taskIndex
:
number
):
number
{
const
lines
=
markdown
.
split
(
"
\n
"
);
const
lines
=
markdown
.
split
(
"
\n
"
);
const
taskPattern
=
/^
(\s
*
[
-*+
]\s
+
)\[([
xX
])\](\s
+.*
)
$/
;
const
taskPattern
=
/^
(\s
*
[
-*+
]\s
+
)\[([
xX
])\](\s
+.*
)
$/
;
...
@@ -170,12 +124,6 @@ export function getTaskLineNumber(markdown: string, taskIndex: number): number {
...
@@ -170,12 +124,6 @@ export function getTaskLineNumber(markdown: string, taskIndex: number): number {
return
-
1
;
return
-
1
;
}
}
/**
* Extract all task items with their metadata
*
* @param markdown - The full markdown content
* @returns Array of task metadata
*/
export
interface
TaskItem
{
export
interface
TaskItem
{
lineNumber
:
number
;
lineNumber
:
number
;
taskIndex
:
number
;
taskIndex
:
number
;
...
...
web/src/utils/oauth.ts
View file @
a6a8997f
/**
* 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_STORAGE_KEY
=
"oauth_state"
;
const
STATE_EXPIRY_MS
=
10
*
60
*
1000
;
// 10 minutes
const
STATE_EXPIRY_MS
=
10
*
60
*
1000
;
// 10 minutes
...
@@ -14,20 +8,14 @@ interface OAuthState {
...
@@ -14,20 +8,14 @@ interface OAuthState {
returnUrl
?:
string
;
returnUrl
?:
string
;
}
}
/**
// Generate a cryptographically secure random state value
* Generate a cryptographically secure random state value
* Uses Web Crypto API for strong randomness
*/
function
generateSecureState
():
string
{
function
generateSecureState
():
string
{
const
array
=
new
Uint8Array
(
32
);
const
array
=
new
Uint8Array
(
32
);
crypto
.
getRandomValues
(
array
);
crypto
.
getRandomValues
(
array
);
return
Array
.
from
(
array
,
(
byte
)
=>
byte
.
toString
(
16
).
padStart
(
2
,
"0"
)).
join
(
""
);
return
Array
.
from
(
array
,
(
byte
)
=>
byte
.
toString
(
16
).
padStart
(
2
,
"0"
)).
join
(
""
);
}
}
/**
// Store OAuth state in sessionStorage
* Store OAuth state in sessionStorage with metadata
* State is stored temporarily and will be validated on callback
*/
export
function
storeOAuthState
(
identityProviderId
:
number
,
returnUrl
?:
string
):
string
{
export
function
storeOAuthState
(
identityProviderId
:
number
,
returnUrl
?:
string
):
string
{
const
state
=
generateSecureState
();
const
state
=
generateSecureState
();
const
stateData
:
OAuthState
=
{
const
stateData
:
OAuthState
=
{
...
@@ -47,11 +35,7 @@ export function storeOAuthState(identityProviderId: number, returnUrl?: string):
...
@@ -47,11 +35,7 @@ export function storeOAuthState(identityProviderId: number, returnUrl?: string):
return
state
;
return
state
;
}
}
/**
// Validate and retrieve OAuth state from storage (CSRF protection)
* Validate and retrieve OAuth state from storage
* Implements CSRF protection by verifying state matches
* Cleans up expired or used states
*/
export
function
validateOAuthState
(
stateParam
:
string
):
{
identityProviderId
:
number
;
returnUrl
?:
string
}
|
null
{
export
function
validateOAuthState
(
stateParam
:
string
):
{
identityProviderId
:
number
;
returnUrl
?:
string
}
|
null
{
try
{
try
{
const
storedData
=
sessionStorage
.
getItem
(
STATE_STORAGE_KEY
);
const
storedData
=
sessionStorage
.
getItem
(
STATE_STORAGE_KEY
);
...
@@ -89,10 +73,7 @@ export function validateOAuthState(stateParam: string): { identityProviderId: nu
...
@@ -89,10 +73,7 @@ export function validateOAuthState(stateParam: string): { identityProviderId: nu
}
}
}
}
/**
// Clean up expired OAuth states (call on app init)
* Clean up expired OAuth states
* Should be called on app initialization
*/
export
function
cleanupExpiredOAuthState
():
void
{
export
function
cleanupExpiredOAuthState
():
void
{
try
{
try
{
const
storedData
=
sessionStorage
.
getItem
(
STATE_STORAGE_KEY
);
const
storedData
=
sessionStorage
.
getItem
(
STATE_STORAGE_KEY
);
...
...
web/src/utils/remark-plugins/remark-preserve-type.ts
View file @
a6a8997f
import
type
{
Root
}
from
"mdast"
;
import
type
{
Root
}
from
"mdast"
;
import
{
visit
}
from
"unist-util-visit"
;
import
{
visit
}
from
"unist-util-visit"
;
/**
// Remark plugin to preserve original mdast node types in the data field
* 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.
*/
export
const
remarkPreserveType
=
()
=>
{
export
const
remarkPreserveType
=
()
=>
{
return
(
tree
:
Root
)
=>
{
return
(
tree
:
Root
)
=>
{
visit
(
tree
,
(
node
:
any
)
=>
{
visit
(
tree
,
(
node
:
any
)
=>
{
...
...
web/src/utils/remark-plugins/remark-tag.ts
View file @
a6a8997f
import
type
{
Root
,
Text
}
from
"mdast"
;
import
type
{
Root
,
Text
}
from
"mdast"
;
import
{
visit
}
from
"unist-util-visit"
;
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
;
const
MAX_TAG_LENGTH
=
100
;
/**
// Check if character is valid for tag content (Unicode letters, digits, symbols, _, -, /)
* 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 (/)
*/
function
isTagChar
(
char
:
string
):
boolean
{
function
isTagChar
(
char
:
string
):
boolean
{
// Allow Unicode letters (any script)
// Allow Unicode letters (any script)
if
(
/
\p
{L}/
u
.
test
(
char
))
{
if
(
/
\p
{L}/
u
.
test
(
char
))
{
...
@@ -62,9 +33,7 @@ function isTagChar(char: string): boolean {
...
@@ -62,9 +33,7 @@ function isTagChar(char: string): boolean {
return
false
;
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
}
>
{
function
parseTagsFromText
(
text
:
string
):
Array
<
{
type
:
"text"
|
"tag"
;
value
:
string
}
>
{
const
segments
:
Array
<
{
type
:
"text"
|
"tag"
;
value
:
string
}
>
=
[];
const
segments
:
Array
<
{
type
:
"text"
|
"tag"
;
value
:
string
}
>
=
[];
let
i
=
0
;
let
i
=
0
;
...
@@ -111,9 +80,7 @@ function parseTagsFromText(text: string): Array<{ type: "text" | "tag"; value: s
...
@@ -111,9 +80,7 @@ function parseTagsFromText(text: string): Array<{ type: "text" | "tag"; value: s
return
segments
;
return
segments
;
}
}
/**
// Remark plugin to parse #tag syntax
* Remark plugin to parse #tag syntax
*/
export
const
remarkTag
=
()
=>
{
export
const
remarkTag
=
()
=>
{
return
(
tree
:
Root
)
=>
{
return
(
tree
:
Root
)
=>
{
// Process text nodes in all node types (paragraphs, headings, etc.)
// Process text nodes in all node types (paragraphs, headings, etc.)
...
...
web/src/utils/theme.ts
View file @
a6a8997f
...
@@ -33,9 +33,6 @@ const validateTheme = (theme: string): ValidTheme => {
...
@@ -33,9 +33,6 @@ const validateTheme = (theme: string): ValidTheme => {
return
VALID_THEMES
.
includes
(
theme
as
ValidTheme
)
?
(
theme
as
ValidTheme
)
:
"default"
;
return
VALID_THEMES
.
includes
(
theme
as
ValidTheme
)
?
(
theme
as
ValidTheme
)
:
"default"
;
};
};
/**
* Detects system theme preference
*/
export
const
getSystemTheme
=
():
"default"
|
"default-dark"
=>
{
export
const
getSystemTheme
=
():
"default"
|
"default-dark"
=>
{
if
(
typeof
window
!==
"undefined"
&&
window
.
matchMedia
)
{
if
(
typeof
window
!==
"undefined"
&&
window
.
matchMedia
)
{
return
window
.
matchMedia
(
"(prefers-color-scheme: dark)"
).
matches
?
"default-dark"
:
"default"
;
return
window
.
matchMedia
(
"(prefers-color-scheme: dark)"
).
matches
?
"default-dark"
:
"default"
;
...
@@ -43,10 +40,7 @@ export const getSystemTheme = (): "default" | "default-dark" => {
...
@@ -43,10 +40,7 @@ export const getSystemTheme = (): "default" | "default-dark" => {
return
"default"
;
return
"default"
;
};
};
/**
// Resolves "system" to actual theme based on OS preference
* Resolves the actual theme to apply based on user preference
* If theme is "system", returns the system preference, otherwise returns the theme as-is
*/
export
const
resolveTheme
=
(
theme
:
string
):
"default"
|
"default-dark"
|
"midnight"
|
"paper"
|
"whitewall"
=>
{
export
const
resolveTheme
=
(
theme
:
string
):
"default"
|
"default-dark"
|
"midnight"
|
"paper"
|
"whitewall"
=>
{
if
(
theme
===
"system"
)
{
if
(
theme
===
"system"
)
{
return
getSystemTheme
();
return
getSystemTheme
();
...
@@ -55,12 +49,9 @@ export const resolveTheme = (theme: string): "default" | "default-dark" | "midni
...
@@ -55,12 +49,9 @@ export const resolveTheme = (theme: string): "default" | "default-dark" | "midni
return
validTheme
===
"system"
?
getSystemTheme
()
:
validTheme
;
return
validTheme
===
"system"
?
getSystemTheme
()
:
validTheme
;
};
};
/**
// Gets the theme that should be applied on initial load
* Gets the theme that should be applied on initial load
* Priority: stored user preference -> system preference -> default
*/
export
const
getInitialTheme
=
():
ValidTheme
=>
{
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
{
try
{
const
storedTheme
=
localStorage
.
getItem
(
"memos-theme"
);
const
storedTheme
=
localStorage
.
getItem
(
"memos-theme"
);
if
(
storedTheme
&&
VALID_THEMES
.
includes
(
storedTheme
as
ValidTheme
))
{
if
(
storedTheme
&&
VALID_THEMES
.
includes
(
storedTheme
as
ValidTheme
))
{
...
@@ -70,13 +61,10 @@ export const getInitialTheme = (): ValidTheme => {
...
@@ -70,13 +61,10 @@ export const getInitialTheme = (): ValidTheme => {
// localStorage might not be available
// localStorage might not be available
}
}
// Fall back to system preference (return "system" to enable auto-switching)
return
"system"
;
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
=>
{
export
const
applyThemeEarly
=
():
void
=>
{
const
theme
=
getInitialTheme
();
const
theme
=
getInitialTheme
();
loadTheme
(
theme
);
loadTheme
(
theme
);
...
@@ -113,10 +101,7 @@ export const loadTheme = (themeName: string): void => {
...
@@ -113,10 +101,7 @@ export const loadTheme = (themeName: string): void => {
}
}
};
};
/**
// Sets up a listener for system theme preference changes
* Sets up a listener for system theme preference changes
* Returns a cleanup function to remove the listener
*/
export
const
setupSystemThemeListener
=
(
onThemeChange
:
()
=>
void
):
(()
=>
void
)
=>
{
export
const
setupSystemThemeListener
=
(
onThemeChange
:
()
=>
void
):
(()
=>
void
)
=>
{
if
(
typeof
window
===
"undefined"
||
!
window
.
matchMedia
)
{
if
(
typeof
window
===
"undefined"
||
!
window
.
matchMedia
)
{
return
()
=>
{};
// No-op cleanup
return
()
=>
{};
// No-op cleanup
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment