= (props: Props) => {
const { className, attachment } = props;
const attachmentUrl = getAttachmentUrl(attachment);
const handlePreviewBtnClick = () => {
window.open(attachmentUrl);
};
return (
{attachment.type.startsWith("audio") && !isMidiFile(attachment.type) ? (
) : (
<>
{attachment.filename}
>
)}
);
};
export default MemoAttachment;
================================================
FILE: web/src/components/MemoContent/CodeBlock.tsx
================================================
import copy from "copy-to-clipboard";
import hljs from "highlight.js";
import { CheckIcon, CopyIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { cn } from "@/lib/utils";
import { getThemeWithFallback, resolveTheme } from "@/utils/theme";
import { MermaidBlock } from "./MermaidBlock";
import type { ReactMarkdownProps } from "./markdown/types";
import { extractCodeContent, extractLanguage } from "./utils";
interface CodeBlockProps extends ReactMarkdownProps {
children?: React.ReactNode;
className?: string;
}
export const CodeBlock = ({ children, className, node: _node, ...props }: CodeBlockProps) => {
const { userGeneralSetting } = useAuth();
const [copied, setCopied] = useState(false);
const codeElement = children as React.ReactElement;
const codeClassName = codeElement?.props?.className || "";
const codeContent = extractCodeContent(children);
const language = extractLanguage(codeClassName);
// If it's a mermaid block, render with MermaidBlock component
if (language === "mermaid") {
return (
{children}
);
}
const theme = getThemeWithFallback(userGeneralSetting?.theme);
const resolvedTheme = resolveTheme(theme);
const isDarkTheme = resolvedTheme.includes("dark");
// Dynamically load highlight.js theme based on app theme
useEffect(() => {
const dynamicImportStyle = async () => {
// Remove any existing highlight.js style
const existingStyle = document.querySelector("style[data-hljs-theme]");
if (existingStyle) {
existingStyle.remove();
}
try {
const cssModule = isDarkTheme
? await import("highlight.js/styles/github-dark-dimmed.css?inline")
: await import("highlight.js/styles/github.css?inline");
// Create and inject the style
const style = document.createElement("style");
style.textContent = cssModule.default;
style.setAttribute("data-hljs-theme", isDarkTheme ? "dark" : "light");
document.head.appendChild(style);
} catch (error) {
console.warn("Failed to load highlight.js theme:", error);
}
};
dynamicImportStyle();
}, [resolvedTheme, isDarkTheme]);
// Highlight code using highlight.js
const highlightedCode = useMemo(() => {
try {
const lang = hljs.getLanguage(language);
if (lang) {
return hljs.highlight(codeContent, {
language: language,
}).value;
}
} catch {
// Skip error and use default highlighted code.
}
// Escape any HTML entities when rendering original content.
return Object.assign(document.createElement("span"), {
textContent: codeContent,
}).innerHTML;
}, [language, codeContent]);
const handleCopy = async () => {
try {
// Try native clipboard API first (requires HTTPS or localhost)
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(codeContent);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
// Fallback to copy-to-clipboard library for non-secure contexts
const success = copy(codeContent);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
console.error("Failed to copy code");
}
}
} catch (err) {
// If native API fails, try fallback
console.warn("Native clipboard failed, using fallback:", err);
const success = copy(codeContent);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
console.error("Failed to copy code:", err);
}
}
};
return (
{/* Header with language label and copy button */}
{language || "text"}
{/* Code content */}
);
};
================================================
FILE: web/src/components/MemoContent/ConditionalComponent.tsx
================================================
import type { Element } from "hast";
import React from "react";
import { isTagElement, isTaskListItemElement } from "@/types/markdown";
/**
* Creates a conditional component that renders different components
* based on AST node type detection
*
* @param CustomComponent - Custom component to render when condition matches
* @param DefaultComponent - Default component/element to render otherwise
* @param condition - Function to test AST node
* @returns Conditional wrapper component
*/
export const createConditionalComponent = >(
CustomComponent: React.ComponentType
,
DefaultComponent: React.ComponentType
| keyof JSX.IntrinsicElements,
condition: (node: Element) => boolean,
) => {
return (props: P & { node?: Element }) => {
const { node, ...restProps } = props;
// Check AST node to determine which component to use
if (node && condition(node)) {
return ;
}
// Render default component/element
if (typeof DefaultComponent === "string") {
return React.createElement(DefaultComponent, restProps);
}
return ;
};
};
// Re-export type guards for convenience
export { isTagElement as isTagNode, isTaskListItemElement as isTaskListItemNode };
================================================
FILE: web/src/components/MemoContent/MermaidBlock.tsx
================================================
import mermaid from "mermaid";
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { cn } from "@/lib/utils";
import { getThemeWithFallback, resolveTheme, setupSystemThemeListener } from "@/utils/theme";
import { extractCodeContent } from "./utils";
interface MermaidBlockProps {
children?: React.ReactNode;
className?: string;
}
type MermaidTheme = "default" | "dark";
const toMermaidTheme = (appTheme: string): MermaidTheme => (appTheme === "default-dark" ? "dark" : "default");
const formatErrorMessage = (err: unknown): string => {
const msg = err instanceof Error ? err.message : "Failed to render diagram";
if (/no diagram type detected/i.test(msg)) {
return `${msg} — check that the diagram type is valid (e.g. sequenceDiagram, classDiagram, erDiagram)`;
}
return msg;
};
export const MermaidBlock = ({ children, className }: MermaidBlockProps) => {
const { userGeneralSetting } = useAuth();
const [svg, setSvg] = useState("");
const [error, setError] = useState("");
const [systemThemeChange, setSystemThemeChange] = useState(0);
const codeContent = extractCodeContent(children);
const themePreference = getThemeWithFallback(userGeneralSetting?.theme);
const currentTheme = useMemo(() => resolveTheme(themePreference), [themePreference, systemThemeChange]);
// Re-resolve theme when OS preference changes (only relevant when using "system" theme)
useEffect(() => {
if (themePreference !== "system") return;
return setupSystemThemeListener(() => setSystemThemeChange((n) => n + 1));
}, [themePreference]);
// Initialize Mermaid when theme changes
useEffect(() => {
mermaid.initialize({
startOnLoad: false,
theme: toMermaidTheme(currentTheme),
securityLevel: "strict",
fontFamily: "inherit",
suppressErrorRendering: true,
});
}, [currentTheme]);
// Render diagram when content or theme changes
useEffect(() => {
if (!codeContent) return;
const id = `mermaid-${Math.random().toString(36).substring(7)}`;
mermaid
.render(id, codeContent)
.then(({ svg: renderedSvg }) => {
setSvg(renderedSvg);
setError("");
})
.catch((err) => {
console.error("Failed to render mermaid diagram:", err);
setSvg("");
setError(formatErrorMessage(err));
});
}, [codeContent, currentTheme]);
if (error) {
return (
Mermaid Error: {error}
{codeContent}
);
}
if (!svg) return null;
return (
);
};
================================================
FILE: web/src/components/MemoContent/Table.tsx
================================================
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./markdown/types";
interface TableProps extends React.HTMLAttributes, ReactMarkdownProps {
children: React.ReactNode;
}
export const Table = ({ children, className, node: _node, ...props }: TableProps) => {
return (
);
};
interface TableHeadProps extends React.HTMLAttributes, ReactMarkdownProps {
children: React.ReactNode;
}
export const TableHead = ({ children, className, node: _node, ...props }: TableHeadProps) => {
return (
{children}
);
};
interface TableBodyProps extends React.HTMLAttributes, ReactMarkdownProps {
children: React.ReactNode;
}
export const TableBody = ({ children, className, node: _node, ...props }: TableBodyProps) => {
return (
{children}
);
};
interface TableRowProps extends React.HTMLAttributes, ReactMarkdownProps {
children: React.ReactNode;
}
export const TableRow = ({ children, className, node: _node, ...props }: TableRowProps) => {
return (
{children}
);
};
interface TableHeaderCellProps extends React.ThHTMLAttributes, ReactMarkdownProps {
children: React.ReactNode;
}
export const TableHeaderCell = ({ children, className, node: _node, ...props }: TableHeaderCellProps) => {
return (
{children}
|
);
};
interface TableCellProps extends React.TdHTMLAttributes, ReactMarkdownProps {
children: React.ReactNode;
}
export const TableCell = ({ children, className, node: _node, ...props }: TableCellProps) => {
return (
{children}
|
);
};
================================================
FILE: web/src/components/MemoContent/Tag.tsx
================================================
import type { Element } from "hast";
import { useLocation } from "react-router-dom";
import { type MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import useNavigateTo from "@/hooks/useNavigateTo";
import { cn } from "@/lib/utils";
import { Routes } from "@/router";
import { useMemoViewContext } from "../MemoView/MemoViewContext";
interface TagProps extends React.HTMLAttributes {
node?: Element; // AST node from react-markdown
"data-tag"?: string;
children?: React.ReactNode;
}
export const Tag: React.FC = ({ "data-tag": dataTag, children, className, ...props }) => {
const { parentPage } = useMemoViewContext();
const location = useLocation();
const navigateTo = useNavigateTo();
const { getFiltersByFactor, removeFilter, addFilter } = useMemoFilterContext();
const tag = dataTag || "";
const handleTagClick = (e: React.MouseEvent) => {
e.stopPropagation();
// If the tag is clicked in a memo detail page, we should navigate to the memo list page.
if (location.pathname.startsWith("/m")) {
const pathname = parentPage || Routes.ROOT;
const searchParams = new URLSearchParams();
searchParams.set("filter", stringifyFilters([{ factor: "tagSearch", value: tag }]));
navigateTo(`${pathname}?${searchParams.toString()}`);
return;
}
const isActive = getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag);
if (isActive) {
removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag);
} else {
// Remove all existing tag filters first, then add the new one
removeFilter((f: MemoFilter) => f.factor === "tagSearch");
addFilter({
factor: "tagSearch",
value: tag,
});
}
};
return (
{children}
);
};
================================================
FILE: web/src/components/MemoContent/TaskListItem.tsx
================================================
import { useRef } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { useUpdateMemo } from "@/hooks/useMemoQueries";
import { toggleTaskAtIndex } from "@/utils/markdown-manipulation";
import { useMemoViewContext, useMemoViewDerived } from "../MemoView/MemoViewContext";
import { TASK_LIST_ITEM_CLASS } from "./constants";
import type { ReactMarkdownProps } from "./markdown/types";
interface TaskListItemProps extends React.InputHTMLAttributes, ReactMarkdownProps {
checked?: boolean;
}
export const TaskListItem: React.FC = ({ checked, node: _node, ...props }) => {
const { memo } = useMemoViewContext();
const { readonly } = useMemoViewDerived();
const checkboxRef = useRef(null);
const { mutate: updateMemo } = useUpdateMemo();
const handleChange = async (newChecked: boolean) => {
// Don't update if readonly or no memo
if (readonly || !memo) {
return;
}
// Find the task index by walking up the DOM
const listItem = checkboxRef.current?.closest("li.task-list-item");
if (!listItem) {
return;
}
// Get task index from data attribute, or calculate by counting
const taskIndexStr = listItem.getAttribute("data-task-index");
let taskIndex = 0;
if (taskIndexStr !== null) {
taskIndex = parseInt(taskIndexStr);
} else {
// Fallback: Calculate index by counting all task list items in the entire memo
// We need to search from the root memo content container, not just the nearest list
// to ensure nested tasks are counted in document order
let searchRoot = listItem.closest("[data-memo-content]");
// If memo content container not found, search from document body
if (!searchRoot) {
searchRoot = document.body;
}
const allTaskItems = searchRoot.querySelectorAll(`li.${TASK_LIST_ITEM_CLASS}`);
for (let i = 0; i < allTaskItems.length; i++) {
if (allTaskItems[i] === listItem) {
taskIndex = i;
break;
}
}
}
// Update memo content using the string manipulation utility
const newContent = toggleTaskAtIndex(memo.content, taskIndex, newChecked);
updateMemo({
update: {
name: memo.name,
content: newContent,
},
updateMask: ["content"],
});
};
// Override the disabled prop from remark-gfm (which defaults to true)
return ;
};
================================================
FILE: web/src/components/MemoContent/constants.ts
================================================
import { defaultSchema } from "rehype-sanitize";
// Class names added by remark-gfm for task lists
export const TASK_LIST_CLASS = "contains-task-list";
export const TASK_LIST_ITEM_CLASS = "task-list-item";
// Compact mode display settings
export const COMPACT_MODE_CONFIG = {
maxHeightVh: 60, // 60% of viewport height
gradientHeight: "h-24", // Tailwind class for gradient overlay
} as const;
export const getMaxDisplayHeight = () => window.innerHeight * (COMPACT_MODE_CONFIG.maxHeightVh / 100);
export const COMPACT_STATES: Record<"ALL" | "SNIPPET", { textKey: string; next: "ALL" | "SNIPPET" }> = {
ALL: { textKey: "memo.show-more", next: "SNIPPET" },
SNIPPET: { textKey: "memo.show-less", next: "ALL" },
};
/**
* Sanitization schema for markdown HTML content.
* Extends the default schema to allow:
* - KaTeX math rendering elements (MathML tags)
* - KaTeX-specific attributes (className, style, aria-*, data-*)
* - Safe HTML elements for rich content
* - iframe embeds for trusted video providers (YouTube, Vimeo, etc.)
*
* This prevents XSS attacks while preserving math rendering functionality.
*/
export const SANITIZE_SCHEMA = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
div: [...(defaultSchema.attributes?.div || []), "className"],
img: [...(defaultSchema.attributes?.img || []), "height", "width"],
span: [...(defaultSchema.attributes?.span || []), "className", "style", ["aria*"], ["data*"]],
// iframe attributes for video embeds
iframe: ["src", "width", "height", "frameborder", "allowfullscreen", "allow", "title", "referrerpolicy", "loading"],
// MathML attributes for KaTeX rendering
annotation: ["encoding"],
math: ["xmlns"],
mi: [],
mn: [],
mo: [],
mrow: [],
mspace: [],
mstyle: [],
msup: [],
msub: [],
msubsup: [],
mfrac: [],
mtext: [],
semantics: [],
},
tagNames: [
...(defaultSchema.tagNames || []),
// iframe for video embeds
"iframe",
// MathML elements for KaTeX math rendering
"math",
"annotation",
"semantics",
"mi",
"mn",
"mo",
"mrow",
"mspace",
"mstyle",
"msup",
"msub",
"msubsup",
"mfrac",
"mtext",
],
protocols: {
...defaultSchema.protocols,
// Allow HTTPS iframe embeds only for security
iframe: { src: ["https"] },
},
};
================================================
FILE: web/src/components/MemoContent/hooks.ts
================================================
import { useCallback, useEffect, useRef, useState } from "react";
import { COMPACT_STATES, getMaxDisplayHeight } from "./constants";
import type { ContentCompactView } from "./types";
export const useCompactMode = (enabled: boolean) => {
const containerRef = useRef(null);
const [mode, setMode] = useState(undefined);
useEffect(() => {
if (!enabled || !containerRef.current) return;
const maxHeight = getMaxDisplayHeight();
if (containerRef.current.getBoundingClientRect().height > maxHeight) {
setMode("ALL");
}
}, [enabled]);
const toggle = useCallback(() => {
if (!mode) return;
setMode(COMPACT_STATES[mode].next);
}, [mode]);
return { containerRef, mode, toggle };
};
export const useCompactLabel = (mode: ContentCompactView | undefined, t: (key: string) => string): string => {
if (!mode) return "";
return t(COMPACT_STATES[mode].textKey);
};
================================================
FILE: web/src/components/MemoContent/index.tsx
================================================
import type { Element } from "hast";
import { ChevronDown, ChevronUp } from "lucide-react";
import { memo } from "react";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext";
import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type";
import { remarkTag } from "@/utils/remark-plugins/remark-tag";
import { CodeBlock } from "./CodeBlock";
import { isTagNode, isTaskListItemNode } from "./ConditionalComponent";
import { COMPACT_MODE_CONFIG, SANITIZE_SCHEMA } from "./constants";
import { useCompactLabel, useCompactMode } from "./hooks";
import { Blockquote, Heading, HorizontalRule, Image, InlineCode, Link, List, ListItem, Paragraph } from "./markdown";
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table";
import { Tag } from "./Tag";
import { TaskListItem } from "./TaskListItem";
import type { MemoContentProps } from "./types";
const MemoContent = (props: MemoContentProps) => {
const { className, contentClassName, content, onClick, onDoubleClick } = props;
const t = useTranslate();
const {
containerRef: memoContentContainerRef,
mode: showCompactMode,
toggle: toggleCompactMode,
} = useCompactMode(Boolean(props.compact));
const compactLabel = useCompactLabel(showCompactMode, t as (key: string) => string);
return (
*:last-child]:mb-0",
showCompactMode === "ALL" && "overflow-hidden",
contentClassName,
)}
style={showCompactMode === "ALL" ? { maxHeight: `${COMPACT_MODE_CONFIG.maxHeightVh}vh` } : undefined}
onMouseUp={onClick}
onDoubleClick={onDoubleClick}
>
& { node?: Element }) => {
if (inputProps.node && isTaskListItemNode(inputProps.node)) {
return ;
}
return ;
}) as React.ComponentType>,
span: ((spanProps: React.ComponentProps<"span"> & { node?: Element }) => {
const { node, ...rest } = spanProps;
if (node && isTagNode(node)) {
return ;
}
return ;
}) as React.ComponentType>,
// Headings
h1: ({ children }) => {children},
h2: ({ children }) => {children},
h3: ({ children }) => {children},
h4: ({ children }) => {children},
h5: ({ children }) => {children},
h6: ({ children }) => {children},
// Block elements
p: ({ children }) => {children},
blockquote: ({ children }) => {children}
,
hr: () => ,
// Lists
ul: ({ children, ...props }) => {children}
,
ol: ({ children, ...props }) => (
{children}
),
li: ({ children, ...props }) => {children},
// Inline elements
a: ({ children, ...props }) => {children},
code: ({ children }) => {children},
img: ({ ...props }) => ,
// Code blocks
pre: CodeBlock,
// Tables
table: ({ children }) => ,
thead: ({ children }) => {children},
tbody: ({ children }) => {children},
tr: ({ children }) => {children},
th: ({ children, ...props }) => {children},
td: ({ children, ...props }) => {children},
}}
>
{content}
{showCompactMode === "ALL" && (
)}
{showCompactMode !== undefined && (
)}
);
};
export default memo(MemoContent);
================================================
FILE: web/src/components/MemoContent/markdown/Blockquote.tsx
================================================
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface BlockquoteProps extends React.BlockquoteHTMLAttributes, ReactMarkdownProps {
children: React.ReactNode;
}
/**
* Blockquote component with left border accent
*/
export const Blockquote = ({ children, className, node: _node, ...props }: BlockquoteProps) => {
return (
{children}
);
};
================================================
FILE: web/src/components/MemoContent/markdown/Heading.tsx
================================================
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface HeadingProps extends React.HTMLAttributes, ReactMarkdownProps {
level: 1 | 2 | 3 | 4 | 5 | 6;
children: React.ReactNode;
}
/**
* Heading component for h1-h6 elements
* Renders semantic heading levels with consistent styling
*/
export const Heading = ({ level, children, className, node: _node, ...props }: HeadingProps) => {
const Component = `h${level}` as const;
const levelClasses = {
1: "text-3xl font-bold border-b border-border pb-2",
2: "text-2xl font-semibold border-b border-border pb-1.5",
3: "text-xl font-semibold",
4: "text-lg font-semibold",
5: "text-base font-semibold",
6: "text-base font-medium text-muted-foreground",
};
return (
{children}
);
};
================================================
FILE: web/src/components/MemoContent/markdown/HorizontalRule.tsx
================================================
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface HorizontalRuleProps extends React.HTMLAttributes, ReactMarkdownProps {}
/**
* Horizontal rule separator
*/
export const HorizontalRule = ({ className, node: _node, ...props }: HorizontalRuleProps) => {
return
;
};
================================================
FILE: web/src/components/MemoContent/markdown/Image.tsx
================================================
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface ImageProps extends React.ImgHTMLAttributes, ReactMarkdownProps {}
/**
* Image component for markdown images
* Responsive with rounded corners
*/
export const Image = ({ className, alt, node: _node, height, width, style, ...props }: ImageProps) => {
return (
);
};
================================================
FILE: web/src/components/MemoContent/markdown/InlineCode.tsx
================================================
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface InlineCodeProps extends React.HTMLAttributes, ReactMarkdownProps {
children: React.ReactNode;
}
/**
* Inline code component with background and monospace font
*/
export const InlineCode = ({ children, className, node: _node, ...props }: InlineCodeProps) => {
return (
{children}
);
};
================================================
FILE: web/src/components/MemoContent/markdown/Link.tsx
================================================
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface LinkProps extends React.AnchorHTMLAttributes, ReactMarkdownProps {
children: React.ReactNode;
}
/**
* Link component for external links
* Opens in new tab with security attributes
*/
export const Link = ({ children, className, href, node: _node, ...props }: LinkProps) => {
return (
{children}
);
};
================================================
FILE: web/src/components/MemoContent/markdown/List.tsx
================================================
import { cn } from "@/lib/utils";
import { TASK_LIST_CLASS, TASK_LIST_ITEM_CLASS } from "../constants";
import type { ReactMarkdownProps } from "./types";
interface ListProps extends React.HTMLAttributes, ReactMarkdownProps {
ordered?: boolean;
children: React.ReactNode;
}
/**
* List component for both regular and task lists (GFM)
* Detects task lists via the "contains-task-list" class added by remark-gfm
*/
export const List = ({ ordered, children, className, node: _node, ...domProps }: ListProps) => {
const Component = ordered ? "ol" : "ul";
const isTaskList = className?.includes(TASK_LIST_CLASS);
return (
{children}
);
};
interface ListItemProps extends React.LiHTMLAttributes, ReactMarkdownProps {
children: React.ReactNode;
}
/**
* List item component for both regular and task list items
* Detects task items via the "task-list-item" class added by remark-gfm
* Applies specialized styling for task checkboxes
*/
export const ListItem = ({ children, className, node: _node, ...domProps }: ListItemProps) => {
const isTaskListItem = className?.includes(TASK_LIST_ITEM_CLASS);
if (isTaskListItem) {
return (
button]:mr-2 [&>button]:align-middle",
// Inline paragraph for task text
"[&>p]:inline [&>p]:m-0",
className,
)}
{...domProps}
>
{children}
);
}
return (
{children}
);
};
================================================
FILE: web/src/components/MemoContent/markdown/Paragraph.tsx
================================================
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface ParagraphProps extends React.HTMLAttributes, ReactMarkdownProps {
children: React.ReactNode;
}
/**
* Paragraph component with compact spacing
*/
export const Paragraph = ({ children, className, node: _node, ...props }: ParagraphProps) => {
return (
{children}
);
};
================================================
FILE: web/src/components/MemoContent/markdown/README.md
================================================
# Markdown Components
Modern, type-safe React components for rendering markdown content via react-markdown.
## Architecture
### Component-Based Rendering
Following patterns from popular AI chat apps (ChatGPT, Claude, Perplexity), we use React components instead of CSS selectors for markdown rendering. This provides:
- **Type Safety**: Full TypeScript support with proper prop types
- **Maintainability**: Components are easier to test, modify, and understand
- **Performance**: No CSS specificity conflicts, cleaner DOM
- **Modularity**: Each element is independently styled and documented
### Type System
All components extend `ReactMarkdownProps` which includes the AST `node` prop passed by react-markdown. This is explicitly destructured as `node: _node` to:
1. Filter it from DOM props (avoids `node="[object Object]"` in HTML)
2. Keep it available for advanced use cases (e.g., detecting task lists)
3. Maintain type safety without `as any` casts
### GFM Task Lists
Task lists (from remark-gfm) are handled by:
- **Detection**: `contains-task-list` and `task-list-item` classes from remark-gfm
- **Styling**: Tailwind utilities with arbitrary variants for nested elements
- **Checkboxes**: Custom `TaskListItem` component with Radix UI checkbox
- **Interactivity**: Updates memo content via `toggleTaskAtIndex` utility
### Component Patterns
Each component follows this structure:
```tsx
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface ComponentProps extends React.HTMLAttributes, ReactMarkdownProps {
children?: React.ReactNode;
// component-specific props
}
/**
* JSDoc description
*/
export const Component = ({ children, className, node: _node, ...props }: ComponentProps) => {
return (
{children}
);
};
```
## Components
| Component | Element | Purpose |
|-----------|---------|---------|
| `Heading` | h1-h6 | Semantic headings with level-based styling |
| `Paragraph` | p | Compact paragraphs with consistent spacing |
| `Link` | a | External links with security attributes |
| `List` | ul/ol | Regular and GFM task lists |
| `ListItem` | li | List items with task checkbox support |
| `Blockquote` | blockquote | Quotes with left border accent |
| `InlineCode` | code | Inline code with background |
| `Image` | img | Responsive images with rounded corners |
| `HorizontalRule` | hr | Section separators |
## Styling Approach
- **Tailwind CSS**: All styling uses Tailwind utilities
- **Design Tokens**: Colors use CSS variables (e.g., `--primary`, `--muted-foreground`)
- **Responsive**: Max-width constraints, responsive images
- **Accessibility**: Semantic HTML, proper ARIA attributes via Radix UI
## Integration
Components are mapped to HTML elements in `MemoContent/index.tsx`:
```tsx
{children},
p: ({ children, ...props }) => {children},
// ... more mappings
}}
>
{content}
```
## Future Enhancements
- [ ] Syntax highlighting themes for code blocks
- [ ] Table sorting/filtering interactions
- [ ] Image lightbox/zoom functionality
- [ ] Collapsible sections for long content
- [ ] Copy button for code blocks
================================================
FILE: web/src/components/MemoContent/markdown/index.ts
================================================
export { Blockquote } from "./Blockquote";
export { Heading } from "./Heading";
export { HorizontalRule } from "./HorizontalRule";
export { Image } from "./Image";
export { InlineCode } from "./InlineCode";
export { Link } from "./Link";
export { List, ListItem } from "./List";
export { Paragraph } from "./Paragraph";
================================================
FILE: web/src/components/MemoContent/markdown/types.ts
================================================
import type { Element } from "hast";
/**
* Props passed by react-markdown to custom components
* Includes the AST node for advanced use cases
*/
export interface ReactMarkdownProps {
node?: Element;
}
================================================
FILE: web/src/components/MemoContent/types.ts
================================================
import type React from "react";
export interface MemoContentProps {
content: string;
compact?: boolean;
className?: string;
contentClassName?: string;
onClick?: (e: React.MouseEvent) => void;
onDoubleClick?: (e: React.MouseEvent) => void;
}
export type ContentCompactView = "ALL" | "SNIPPET";
================================================
FILE: web/src/components/MemoContent/utils.ts
================================================
import type React from "react";
/**
* Extracts code content from a react-markdown code element.
* Handles the nested structure where code is passed as children.
*
* @param children - The children prop from react-markdown (typically a code element)
* @returns The extracted code content as a string with trailing newline removed
*/
export const extractCodeContent = (children: React.ReactNode): string => {
const codeElement = children as React.ReactElement;
return String(codeElement?.props?.children || "").replace(/\n$/, "");
};
/**
* Extracts the language identifier from a code block's className.
* react-markdown uses the format "language-xxx" for code blocks.
*
* @param className - The className string from a code element
* @returns The language identifier, or empty string if none found
*/
export const extractLanguage = (className: string): string => {
const match = /language-(\w+)/.exec(className);
return match ? match[1] : "";
};
================================================
FILE: web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx
================================================
import { create } from "@bufbuild/protobuf";
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { isEqual } from "lodash-es";
import { CheckCircleIcon, Code2Icon, HashIcon, LinkIcon, Share2Icon } from "lucide-react";
import { useState } from "react";
import MemoSharePanel from "@/components/MemoSharePanel";
import { Button } from "@/components/ui/button";
import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils";
import { Memo, Memo_PropertySchema, MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import { isSuperUser } from "@/utils/user";
import MemoRelationForceGraph from "../MemoRelationForceGraph";
interface Props {
memo: Memo;
className?: string;
parentPage?: string;
}
const SectionLabel = ({ children }: { children: React.ReactNode }) => (
{children}
);
const MemoDetailSidebar = ({ memo, className, parentPage }: Props) => {
const t = useTranslate();
const currentUser = useCurrentUser();
const [sharePanelOpen, setSharePanelOpen] = useState(false);
const property = create(Memo_PropertySchema, memo.property || {});
const hasSpecialProperty = property.hasLink || property.hasTaskList || property.hasCode;
const hasReferenceRelations = memo.relations.some((r) => r.type === MemoRelation_Type.REFERENCE);
const canManageShares = !memo.parent && (memo.creator === currentUser?.name || isSuperUser(currentUser));
return (
);
};
export default MemoDetailSidebar;
================================================
FILE: web/src/components/MemoDetailSidebar/MemoDetailSidebarDrawer.tsx
================================================
import { GanttChartIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
import MemoDetailSidebar from "./MemoDetailSidebar";
interface Props {
memo: Memo;
parentPage?: string;
}
const MemoDetailSidebarDrawer = ({ memo, parentPage }: Props) => {
const location = useLocation();
const [open, setOpen] = useState(false);
useEffect(() => {
setOpen(false);
}, [location.pathname]);
return (
);
};
export default MemoDetailSidebarDrawer;
================================================
FILE: web/src/components/MemoDetailSidebar/index.ts
================================================
import MemoDetailSidebar from "./MemoDetailSidebar";
import MemoDetailSidebarDrawer from "./MemoDetailSidebarDrawer";
export { MemoDetailSidebar, MemoDetailSidebarDrawer };
================================================
FILE: web/src/components/MemoDisplaySettingMenu.tsx
================================================
import { Settings2Icon } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useView } from "@/contexts/ViewContext";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
interface Props {
className?: string;
}
function MemoDisplaySettingMenu({ className }: Props) {
const t = useTranslate();
const { orderByTimeAsc, toggleSortOrder } = useView();
const isApplying = orderByTimeAsc !== false;
return (
{t("memo.direction")}
);
}
export default MemoDisplaySettingMenu;
================================================
FILE: web/src/components/MemoEditor/Editor/SlashCommands.tsx
================================================
import type { SlashCommandsProps } from "../types";
import type { EditorRefActions } from ".";
import { SuggestionsPopup } from "./SuggestionsPopup";
import { useSuggestions } from "./useSuggestions";
const SlashCommands = ({ editorRef, editorActions, commands }: SlashCommandsProps) => {
const handleCommandAutocomplete = (cmd: (typeof commands)[0], word: string, index: number, actions: EditorRefActions) => {
// Remove trigger char + word, then insert command output
actions.removeText(index, word.length);
actions.insertText(cmd.run());
// Position cursor relative to insertion point, if specified
if (cmd.cursorOffset) {
actions.setCursorPosition(index + cmd.cursorOffset);
}
};
const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({
editorRef,
editorActions,
triggerChar: "/",
items: commands,
filterItems: (items, query) => (!query ? items : items.filter((cmd) => cmd.name.toLowerCase().startsWith(query))),
onAutocomplete: handleCommandAutocomplete,
});
if (!isVisible || !position) return null;
return (
cmd.name}
renderItem={(cmd) => (
/
{cmd.name}
)}
/>
);
};
export default SlashCommands;
================================================
FILE: web/src/components/MemoEditor/Editor/SuggestionsPopup.tsx
================================================
import { ReactNode, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { Position } from "./useSuggestions";
interface SuggestionsPopupProps {
position: Position;
suggestions: T[];
selectedIndex: number;
onItemSelect: (item: T) => void;
renderItem: (item: T, isSelected: boolean) => ReactNode;
getItemKey: (item: T, index: number) => string;
}
const POPUP_STYLES = {
container:
"z-20 absolute p-1 mt-1 -ml-2 max-w-48 max-h-60 rounded border bg-popover text-popover-foreground shadow-lg font-mono flex flex-col overflow-y-auto overflow-x-hidden",
item: "rounded p-1 px-2 w-full text-sm cursor-pointer transition-colors select-none hover:bg-accent hover:text-accent-foreground",
};
export function SuggestionsPopup({
position,
suggestions,
selectedIndex,
onItemSelect,
renderItem,
getItemKey,
}: SuggestionsPopupProps) {
const containerRef = useRef(null);
const selectedItemRef = useRef(null);
useEffect(() => {
selectedItemRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [selectedIndex]);
return (
{suggestions.map((item, i) => (
onItemSelect(item)}
className={cn(POPUP_STYLES.item, i === selectedIndex && "bg-accent text-accent-foreground")}
>
{renderItem(item, i === selectedIndex)}
))}
);
}
================================================
FILE: web/src/components/MemoEditor/Editor/TagSuggestions.tsx
================================================
import { useMemo } from "react";
import { matchPath } from "react-router-dom";
import OverflowTip from "@/components/kit/OverflowTip";
import { useTagCounts } from "@/hooks/useUserQueries";
import { Routes } from "@/router";
import type { TagSuggestionsProps } from "../types";
import { SuggestionsPopup } from "./SuggestionsPopup";
import { useSuggestions } from "./useSuggestions";
export default function TagSuggestions({ editorRef, editorActions }: TagSuggestionsProps) {
// On explore page, show all users' tags; otherwise show current user's tags
const isExplorePage = Boolean(matchPath(Routes.EXPLORE, window.location.pathname));
const { data: tagCount = {} } = useTagCounts(!isExplorePage);
const sortedTags = useMemo(() => {
return Object.entries(tagCount)
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([tag]) => tag);
}, [tagCount]);
const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({
editorRef,
editorActions,
triggerChar: "#",
items: sortedTags,
filterItems: (items, query) => (!query ? items : items.filter((tag) => tag.toLowerCase().includes(query))),
onAutocomplete: (tag, word, index, actions) => {
actions.removeText(index, word.length);
actions.insertText(`#${tag} `);
},
});
if (!isVisible || !position) return null;
return (
tag}
renderItem={(tag) => (
#
{tag}
)}
/>
);
}
================================================
FILE: web/src/components/MemoEditor/Editor/commands.ts
================================================
export interface Command {
name: string;
run: () => string;
cursorOffset?: number;
}
export const editorCommands: Command[] = [
{
name: "todo",
run: () => "- [ ] ",
cursorOffset: 6,
},
{
name: "code",
run: () => "```\n\n```",
cursorOffset: 4,
},
{
name: "link",
run: () => "[text](url)",
cursorOffset: 1,
},
{
name: "table",
run: () => "| Header | Header |\n| ------ | ------ |\n| Cell | Cell |",
cursorOffset: 1,
},
];
================================================
FILE: web/src/components/MemoEditor/Editor/index.tsx
================================================
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
import getCaretCoordinates from "textarea-caret";
import { cn } from "@/lib/utils";
import { EDITOR_HEIGHT } from "../constants";
import type { EditorProps } from "../types";
import { editorCommands } from "./commands";
import SlashCommands from "./SlashCommands";
import TagSuggestions from "./TagSuggestions";
import { useListCompletion } from "./useListCompletion";
export interface EditorRefActions {
getEditor: () => HTMLTextAreaElement | null;
focus: () => void;
scrollToCursor: () => void;
insertText: (text: string, prefix?: string, suffix?: string) => void;
removeText: (start: number, length: number) => void;
setContent: (text: string) => void;
getContent: () => string;
getSelectedContent: () => string;
getCursorPosition: () => number;
setCursorPosition: (startPos: number, endPos?: number) => void;
getCursorLineNumber: () => number;
getLine: (lineNumber: number) => string;
setLine: (lineNumber: number, text: string) => void;
}
const Editor = forwardRef(function Editor(props: EditorProps, ref: React.ForwardedRef) {
const {
className,
initialContent,
placeholder,
onPaste,
onContentChange: handleContentChangeCallback,
isFocusMode,
isInIME = false,
onCompositionStart,
onCompositionEnd,
} = props;
const editorRef = useRef(null);
const updateEditorHeight = useCallback(() => {
if (editorRef.current) {
editorRef.current.style.height = "auto";
editorRef.current.style.height = `${editorRef.current.scrollHeight ?? 0}px`;
}
}, []);
const updateContent = useCallback(() => {
if (editorRef.current) {
handleContentChangeCallback(editorRef.current.value);
updateEditorHeight();
}
}, [handleContentChangeCallback, updateEditorHeight]);
const scrollToCaret = useCallback((options: { force?: boolean } = {}) => {
const editor = editorRef.current;
if (!editor) return;
const { force = false } = options;
const caret = getCaretCoordinates(editor, editor.selectionEnd);
if (force) {
editor.scrollTop = Math.max(0, caret.top - editor.clientHeight / 2);
return;
}
const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 24;
const viewportBottom = editor.scrollTop + editor.clientHeight;
// Scroll if cursor is near or beyond bottom edge (within 2 lines)
if (caret.top + lineHeight * 2 > viewportBottom) {
editor.scrollTop = Math.max(0, caret.top - editor.clientHeight / 2);
}
}, []);
useEffect(() => {
if (editorRef.current && initialContent) {
editorRef.current.value = initialContent;
handleContentChangeCallback(initialContent);
updateEditorHeight();
}
// Only run once on mount to set initial content
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Update editor when content is externally changed (e.g., reset after save)
useEffect(() => {
if (editorRef.current && editorRef.current.value !== initialContent) {
editorRef.current.value = initialContent;
updateEditorHeight();
}
}, [initialContent, updateEditorHeight]);
const editorActions: EditorRefActions = useMemo(
() => ({
getEditor: () => editorRef.current,
focus: () => editorRef.current?.focus(),
scrollToCursor: () => {
scrollToCaret({ force: true });
},
insertText: (content = "", prefix = "", suffix = "") => {
const editor = editorRef.current;
if (!editor) return;
const cursorPos = editor.selectionStart;
const endPos = editor.selectionEnd;
const prev = editor.value;
const actual = content || prev.slice(cursorPos, endPos);
editor.value = prev.slice(0, cursorPos) + prefix + actual + suffix + prev.slice(endPos);
editor.focus();
editor.setSelectionRange(cursorPos + prefix.length + actual.length, cursorPos + prefix.length + actual.length);
updateContent();
},
removeText: (start: number, length: number) => {
const editor = editorRef.current;
if (!editor) return;
editor.value = editor.value.slice(0, start) + editor.value.slice(start + length);
editor.focus();
editor.setSelectionRange(start, start);
updateContent();
},
setContent: (text: string) => {
const editor = editorRef.current;
if (editor) {
editor.value = text;
updateContent();
}
},
getContent: () => editorRef.current?.value ?? "",
getCursorPosition: () => editorRef.current?.selectionStart ?? 0,
getSelectedContent: () => {
const editor = editorRef.current;
if (!editor) return "";
return editor.value.slice(editor.selectionStart, editor.selectionEnd);
},
setCursorPosition: (startPos: number, endPos?: number) => {
const editor = editorRef.current;
if (!editor) return;
// setSelectionRange requires valid arguments; default to startPos if endPos is undefined
const endPosition = endPos !== undefined && !Number.isNaN(endPos) ? endPos : startPos;
editor.setSelectionRange(startPos, endPosition);
},
getCursorLineNumber: () => {
const editor = editorRef.current;
if (!editor) return 0;
const lines = editor.value.slice(0, editor.selectionStart).split("\n");
return lines.length - 1;
},
getLine: (lineNumber: number) => editorRef.current?.value.split("\n")[lineNumber] ?? "",
setLine: (lineNumber: number, text: string) => {
const editor = editorRef.current;
if (!editor) return;
const lines = editor.value.split("\n");
lines[lineNumber] = text;
editor.value = lines.join("\n");
editor.focus();
updateContent();
},
}),
[updateContent, scrollToCaret],
);
useImperativeHandle(ref, () => editorActions, [editorActions]);
const handleEditorInput = useCallback(() => {
if (editorRef.current) {
handleContentChangeCallback(editorRef.current.value);
updateEditorHeight();
// Auto-scroll to keep cursor visible when typing
// See: https://github.com/usememos/memos/issues/5469
scrollToCaret();
}
}, [handleContentChangeCallback, updateEditorHeight, scrollToCaret]);
// Auto-complete markdown lists when pressing Enter
useListCompletion({
editorRef,
editorActions,
isInIME,
});
// Recalculate editor height when focus mode changes
useEffect(() => {
updateEditorHeight();
}, [isFocusMode, updateEditorHeight]);
return (
);
});
export default Editor;
================================================
FILE: web/src/components/MemoEditor/Editor/shortcuts.ts
================================================
import type { EditorRefActions } from "./index";
const SHORTCUTS = {
BOLD: { key: "b", delimiter: "**" },
ITALIC: { key: "i", delimiter: "*" },
LINK: { key: "k" },
} as const;
const URL_PLACEHOLDER = "url";
const URL_REGEX = /^https?:\/\/[^\s]+$/;
const LINK_OFFSET = 3; // Length of "]()"
export function handleMarkdownShortcuts(event: React.KeyboardEvent, editor: EditorRefActions): void {
const key = event.key.toLowerCase();
if (key === SHORTCUTS.BOLD.key) {
event.preventDefault();
toggleTextStyle(editor, SHORTCUTS.BOLD.delimiter);
} else if (key === SHORTCUTS.ITALIC.key) {
event.preventDefault();
toggleTextStyle(editor, SHORTCUTS.ITALIC.delimiter);
} else if (key === SHORTCUTS.LINK.key) {
event.preventDefault();
insertHyperlink(editor);
}
}
export function insertHyperlink(editor: EditorRefActions, url?: string): void {
const cursorPosition = editor.getCursorPosition();
const selectedContent = editor.getSelectedContent();
const isUrlSelected = !url && URL_REGEX.test(selectedContent.trim());
if (isUrlSelected) {
editor.insertText(`[](${selectedContent})`);
editor.setCursorPosition(cursorPosition + 1, cursorPosition + 1);
return;
}
const href = url ?? URL_PLACEHOLDER;
editor.insertText(`[${selectedContent}](${href})`);
if (href === URL_PLACEHOLDER) {
const urlStart = cursorPosition + selectedContent.length + LINK_OFFSET;
editor.setCursorPosition(urlStart, urlStart + href.length);
}
}
function toggleTextStyle(editor: EditorRefActions, delimiter: string): void {
const cursorPosition = editor.getCursorPosition();
const selectedContent = editor.getSelectedContent();
const isStyled = selectedContent.startsWith(delimiter) && selectedContent.endsWith(delimiter);
if (isStyled) {
const unstyled = selectedContent.slice(delimiter.length, -delimiter.length);
editor.insertText(unstyled);
editor.setCursorPosition(cursorPosition, cursorPosition + unstyled.length);
} else {
editor.insertText(`${delimiter}${selectedContent}${delimiter}`);
editor.setCursorPosition(cursorPosition + delimiter.length, cursorPosition + delimiter.length + selectedContent.length);
}
}
export function hyperlinkHighlightedText(editor: EditorRefActions, url: string): void {
const selectedContent = editor.getSelectedContent();
const cursorPosition = editor.getCursorPosition();
editor.insertText(`[${selectedContent}](${url})`);
const newPosition = cursorPosition + selectedContent.length + url.length + 4;
editor.setCursorPosition(newPosition, newPosition);
}
================================================
FILE: web/src/components/MemoEditor/Editor/useListCompletion.ts
================================================
import { useEffect, useRef } from "react";
import { detectLastListItem, generateListContinuation } from "@/utils/markdown-list-detection";
import { EditorRefActions } from ".";
interface UseListCompletionOptions {
editorRef: React.RefObject;
editorActions: EditorRefActions;
isInIME: boolean;
}
// Patterns to detect empty list items
const EMPTY_LIST_PATTERNS = [
/^(\s*)([-*+])\s*$/, // Empty unordered list
/^(\s*)([-*+])\s+\[([ xX])\]\s*$/, // Empty task list
/^(\s*)(\d+)[.)]\s*$/, // Empty ordered list
];
const isEmptyListItem = (line: string) => EMPTY_LIST_PATTERNS.some((pattern) => pattern.test(line));
export function useListCompletion({ editorRef, editorActions, isInIME }: UseListCompletionOptions) {
const isInIMERef = useRef(isInIME);
isInIMERef.current = isInIME;
const editorActionsRef = useRef(editorActions);
editorActionsRef.current = editorActions;
// Track when composition ends to handle Safari race condition
// Safari fires keydown(Enter) immediately after compositionend, while Chrome doesn't
// See: https://github.com/usememos/memos/issues/5469
const lastCompositionEndRef = useRef(0);
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
const handleCompositionEnd = () => {
lastCompositionEndRef.current = Date.now();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" || isInIMERef.current || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) {
return;
}
// Safari fix: Ignore Enter key within 100ms of composition end
// This prevents double-enter behavior when confirming IME input in lists
if (Date.now() - lastCompositionEndRef.current < 100) {
return;
}
const actions = editorActionsRef.current;
const cursorPosition = actions.getCursorPosition();
const contentBeforeCursor = actions.getContent().substring(0, cursorPosition);
const listInfo = detectLastListItem(contentBeforeCursor);
if (!listInfo.type) return;
event.preventDefault();
const lines = contentBeforeCursor.split("\n");
const currentLine = lines[lines.length - 1];
if (isEmptyListItem(currentLine)) {
const lineStartPos = cursorPosition - currentLine.length;
actions.removeText(lineStartPos, currentLine.length);
} else {
const continuation = generateListContinuation(listInfo);
actions.insertText("\n" + continuation);
// Auto-scroll to keep cursor visible after inserting list item
setTimeout(() => actions.scrollToCursor(), 0);
}
};
editor.addEventListener("compositionend", handleCompositionEnd);
editor.addEventListener("keydown", handleKeyDown);
return () => {
editor.removeEventListener("compositionend", handleCompositionEnd);
editor.removeEventListener("keydown", handleKeyDown);
};
}, []);
}
================================================
FILE: web/src/components/MemoEditor/Editor/useSuggestions.ts
================================================
import { useEffect, useRef, useState } from "react";
import getCaretCoordinates from "textarea-caret";
import { EditorRefActions } from ".";
export interface Position {
left: number;
top: number;
height: number;
}
export interface UseSuggestionsOptions {
editorRef: React.RefObject;
editorActions: React.ForwardedRef;
triggerChar: string;
items: T[];
filterItems: (items: T[], searchQuery: string) => T[];
onAutocomplete: (item: T, word: string, startIndex: number, actions: EditorRefActions) => void;
}
export interface UseSuggestionsReturn {
position: Position | null;
suggestions: T[];
selectedIndex: number;
isVisible: boolean;
handleItemSelect: (item: T) => void;
}
export function useSuggestions({
editorRef,
editorActions,
triggerChar,
items,
filterItems,
onAutocomplete,
}: UseSuggestionsOptions): UseSuggestionsReturn {
const [position, setPosition] = useState(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const isProcessingRef = useRef(false);
const selectedRef = useRef(selectedIndex);
selectedRef.current = selectedIndex;
const getCurrentWord = (): [word: string, startIndex: number] => {
const editor = editorRef.current;
if (!editor) return ["", 0];
const cursorPos = editor.selectionEnd;
const before = editor.value.slice(0, cursorPos).match(/\S*$/) || { 0: "", index: cursorPos };
const after = editor.value.slice(cursorPos).match(/^\S*/) || { 0: "" };
return [before[0] + after[0], before.index ?? cursorPos];
};
const hide = () => setPosition(null);
const suggestionsRef = useRef([]);
suggestionsRef.current = (() => {
const [word] = getCurrentWord();
if (!word.startsWith(triggerChar)) return [];
const searchQuery = word.slice(triggerChar.length).toLowerCase();
return filterItems(items, searchQuery);
})();
const isVisibleRef = useRef(false);
isVisibleRef.current = !!(position && suggestionsRef.current.length > 0);
const handleAutocomplete = (item: T) => {
if (!editorActions || !("current" in editorActions) || !editorActions.current) {
console.warn("useSuggestions: editorActions not available");
return;
}
isProcessingRef.current = true;
const [word, index] = getCurrentWord();
onAutocomplete(item, word, index, editorActions.current);
hide();
// Re-enable input handling after all DOM operations complete
queueMicrotask(() => {
isProcessingRef.current = false;
});
};
const handleNavigation = (e: KeyboardEvent, selected: number, suggestionsCount: number) => {
if (e.code === "ArrowDown") {
setSelectedIndex((selected + 1) % suggestionsCount);
e.preventDefault();
e.stopPropagation();
} else if (e.code === "ArrowUp") {
setSelectedIndex((selected - 1 + suggestionsCount) % suggestionsCount);
e.preventDefault();
e.stopPropagation();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!isVisibleRef.current) return;
const suggestions = suggestionsRef.current;
const selected = selectedRef.current;
if (["Escape", "ArrowLeft", "ArrowRight"].includes(e.code)) {
hide();
return;
}
if (["ArrowDown", "ArrowUp"].includes(e.code)) {
handleNavigation(e, selected, suggestions.length);
return;
}
if (["Enter", "Tab"].includes(e.code)) {
handleAutocomplete(suggestions[selected]);
e.preventDefault();
e.stopImmediatePropagation();
}
};
const handleInput = () => {
if (isProcessingRef.current) return;
const editor = editorRef.current;
if (!editor) return;
setSelectedIndex(0);
const [word, index] = getCurrentWord();
const currentChar = editor.value[editor.selectionEnd];
const isActive = word.startsWith(triggerChar) && currentChar !== triggerChar;
if (isActive) {
const coords = getCaretCoordinates(editor, index);
coords.top -= editor.scrollTop;
setPosition(coords);
} else {
hide();
}
};
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
const handlers = { click: hide, blur: hide, keydown: handleKeyDown, input: handleInput };
Object.entries(handlers).forEach(([event, handler]) => {
editor.addEventListener(event, handler as EventListener);
});
return () => {
Object.entries(handlers).forEach(([event, handler]) => {
editor.removeEventListener(event, handler as EventListener);
});
};
}, []);
return {
position,
suggestions: suggestionsRef.current,
selectedIndex,
isVisible: isVisibleRef.current,
handleItemSelect: handleAutocomplete,
};
}
================================================
FILE: web/src/components/MemoEditor/README.md
================================================
# MemoEditor Architecture
## Overview
MemoEditor uses a three-layer architecture for better separation of concerns and testability.
## Architecture
```
┌─────────────────────────────────────────┐
│ Presentation Layer (Components) │
│ - EditorToolbar, EditorContent, etc. │
└─────────────────┬───────────────────────┘
│
┌─────────────────▼───────────────────────┐
│ State Layer (Reducer + Context) │
│ - state/, useEditorContext() │
└─────────────────┬───────────────────────┘
│
┌─────────────────▼───────────────────────┐
│ Service Layer (Business Logic) │
│ - services/ (pure functions) │
└─────────────────────────────────────────┘
```
## Directory Structure
```
MemoEditor/
├── state/ # State management (reducer, actions, context)
├── services/ # Business logic (pure functions)
├── components/ # UI components
├── hooks/ # React hooks (utilities)
├── Editor/ # Core editor component
├── Toolbar/ # Toolbar components
├── constants.ts
└── types/
```
## Key Concepts
### State Management
Uses `useReducer` + Context for predictable state transitions. All state changes go through action creators.
### Services
Pure TypeScript functions containing business logic. No React hooks, easy to test.
### Components
Thin presentation components that dispatch actions and render UI.
## Usage
```typescript
import MemoEditor from "@/components/MemoEditor";
console.log('Saved:', name)}
onCancel={() => console.log('Cancelled')}
/>
```
## Testing
Services are pure functions - easy to unit test without React.
```typescript
const state = mockEditorState();
const result = await memoService.save(state, { memoName: 'memos/123' });
```
================================================
FILE: web/src/components/MemoEditor/Toolbar/InsertMenu.tsx
================================================
import { LatLng } from "leaflet";
import { uniqBy } from "lodash-es";
import { FileIcon, LinkIcon, LoaderIcon, type LucideIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useDebounce } from "react-use";
import { useReverseGeocoding } from "@/components/map";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
useDropdownMenuSubHoverDelay,
} from "@/components/ui/dropdown-menu";
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import { LinkMemoDialog, LocationDialog } from "../components";
import { useFileUpload, useLinkMemo, useLocation } from "../hooks";
import { useEditorContext } from "../state";
import type { InsertMenuProps } from "../types";
import type { LocalFile } from "../types/attachment";
const InsertMenu = (props: InsertMenuProps) => {
const t = useTranslate();
const { state, actions, dispatch } = useEditorContext();
const { location: initialLocation, onLocationChange, onToggleFocusMode, isUploading: isUploadingProp } = props;
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
const [moreSubmenuOpen, setMoreSubmenuOpen] = useState(false);
const { handleTriggerEnter, handleTriggerLeave, handleContentEnter, handleContentLeave } = useDropdownMenuSubHoverDelay(
150,
setMoreSubmenuOpen,
);
const { fileInputRef, selectingFlag, handleFileInputChange, handleUploadClick } = useFileUpload((newFiles: LocalFile[]) => {
newFiles.forEach((file) => dispatch(actions.addLocalFile(file)));
});
const linkMemo = useLinkMemo({
isOpen: linkDialogOpen,
currentMemoName: props.memoName,
existingRelations: state.metadata.relations,
onAddRelation: (relation: MemoRelation) => {
dispatch(actions.setMetadata({ relations: uniqBy([...state.metadata.relations, relation], (r) => r.relatedMemo?.name) }));
setLinkDialogOpen(false);
},
});
const location = useLocation(props.location);
const [debouncedPosition, setDebouncedPosition] = useState(undefined);
useDebounce(
() => {
setDebouncedPosition(location.state.position);
},
1000,
[location.state.position],
);
const { data: displayName } = useReverseGeocoding(debouncedPosition?.lat, debouncedPosition?.lng);
useEffect(() => {
if (displayName) {
location.setPlaceholder(displayName);
}
}, [displayName]);
const isUploading = selectingFlag || isUploadingProp;
const handleOpenLinkDialog = useCallback(() => {
setLinkDialogOpen(true);
}, []);
const handleLocationClick = useCallback(() => {
setLocationDialogOpen(true);
if (!initialLocation && !location.locationInitialized) {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
location.handlePositionChange(new LatLng(position.coords.latitude, position.coords.longitude));
},
(error) => {
console.error("Geolocation error:", error);
},
);
}
}
}, [initialLocation, location]);
const handleLocationConfirm = useCallback(() => {
const newLocation = location.getLocation();
if (newLocation) {
onLocationChange(newLocation);
setLocationDialogOpen(false);
}
}, [location, onLocationChange]);
const handleLocationCancel = useCallback(() => {
location.reset();
setLocationDialogOpen(false);
}, [location]);
const handlePositionChange = useCallback(
(position: LatLng) => {
location.handlePositionChange(position);
},
[location],
);
const handleToggleFocusMode = useCallback(() => {
onToggleFocusMode?.();
setMoreSubmenuOpen(false);
}, [onToggleFocusMode]);
const menuItems = useMemo(
() =>
[
{
key: "upload",
label: t("common.upload"),
icon: FileIcon,
onClick: handleUploadClick,
},
{
key: "link",
label: t("tooltip.link-memo"),
icon: LinkIcon,
onClick: handleOpenLinkDialog,
},
{
key: "location",
label: t("tooltip.select-location"),
icon: MapPinIcon,
onClick: handleLocationClick,
},
] satisfies Array<{ key: string; label: string; icon: LucideIcon; onClick: () => void }>,
[handleLocationClick, handleOpenLinkDialog, handleUploadClick, t],
);
return (
<>
{menuItems.map((item) => (
{item.label}
))}
{/* View submenu with Focus Mode */}
{t("common.more")}
{t("editor.focus-mode")}
{t("editor.slash-commands")}
{/* Hidden file input */}
>
);
};
export default InsertMenu;
================================================
FILE: web/src/components/MemoEditor/Toolbar/VisibilitySelector.tsx
================================================
import { CheckIcon, ChevronDownIcon } from "lucide-react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import VisibilityIcon from "@/components/VisibilityIcon";
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import type { VisibilitySelectorProps } from "../types";
const VisibilitySelector = (props: VisibilitySelectorProps) => {
const { value, onChange } = props;
const t = useTranslate();
const visibilityOptions = [
{ value: Visibility.PRIVATE, label: t("memo.visibility.private") },
{ value: Visibility.PROTECTED, label: t("memo.visibility.protected") },
{ value: Visibility.PUBLIC, label: t("memo.visibility.public") },
] as const;
const currentLabel = visibilityOptions.find((option) => option.value === value)?.label || "";
return (
{visibilityOptions.map((option) => (
onChange(option.value)}>
{option.label}
{value === option.value && }
))}
);
};
export default VisibilitySelector;
================================================
FILE: web/src/components/MemoEditor/Toolbar/index.ts
================================================
// Toolbar components for MemoEditor
export { default as InsertMenu } from "./InsertMenu";
export { default as VisibilitySelector } from "./VisibilitySelector";
================================================
FILE: web/src/components/MemoEditor/components/AttachmentList.tsx
================================================
import { ChevronDownIcon, ChevronUpIcon, FileIcon, PaperclipIcon, XIcon } from "lucide-react";
import type { FC } from "react";
import { cn } from "@/lib/utils";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { formatFileSize, getFileTypeLabel } from "@/utils/format";
import type { LocalFile } from "../types/attachment";
import { toAttachmentItems } from "../types/attachment";
interface AttachmentListProps {
attachments: Attachment[];
localFiles?: LocalFile[];
onAttachmentsChange?: (attachments: Attachment[]) => void;
onRemoveLocalFile?: (previewUrl: string) => void;
}
const AttachmentItemCard: FC<{
item: ReturnType[0];
onRemove?: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
canMoveUp?: boolean;
canMoveDown?: boolean;
}> = ({ item, onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => {
const { category, filename, thumbnailUrl, mimeType, size } = item;
const fileTypeLabel = getFileTypeLabel(mimeType);
const fileSizeLabel = size ? formatFileSize(size) : undefined;
return (
{category === "image" && thumbnailUrl ? (

) : (
)}
{filename}
{fileTypeLabel}
{fileSizeLabel && (
<>
•
{fileSizeLabel}
>
)}
{onMoveUp && (
)}
{onMoveDown && (
)}
{onRemove && (
)}
);
};
const AttachmentList: FC = ({ attachments, localFiles = [], onAttachmentsChange, onRemoveLocalFile }) => {
if (attachments.length === 0 && localFiles.length === 0) {
return null;
}
const items = toAttachmentItems(attachments, localFiles);
const handleMoveUp = (index: number) => {
if (index === 0 || !onAttachmentsChange) return;
const newAttachments = [...attachments];
[newAttachments[index - 1], newAttachments[index]] = [newAttachments[index], newAttachments[index - 1]];
onAttachmentsChange(newAttachments);
};
const handleMoveDown = (index: number) => {
if (index === attachments.length - 1 || !onAttachmentsChange) return;
const newAttachments = [...attachments];
[newAttachments[index], newAttachments[index + 1]] = [newAttachments[index + 1], newAttachments[index]];
onAttachmentsChange(newAttachments);
};
const handleRemoveAttachment = (name: string) => {
if (onAttachmentsChange) {
onAttachmentsChange(attachments.filter((attachment) => attachment.name !== name));
}
};
const handleRemoveItem = (item: (typeof items)[0]) => {
if (item.isLocal) {
onRemoveLocalFile?.(item.id);
} else {
handleRemoveAttachment(item.id);
}
};
return (
Attachments ({items.length})
{items.map((item) => {
const isLocalFile = item.isLocal;
const attachmentIndex = isLocalFile ? -1 : attachments.findIndex((a) => a.name === item.id);
return (
handleRemoveItem(item)}
onMoveUp={!isLocalFile ? () => handleMoveUp(attachmentIndex) : undefined}
onMoveDown={!isLocalFile ? () => handleMoveDown(attachmentIndex) : undefined}
canMoveUp={!isLocalFile && attachmentIndex > 0}
canMoveDown={!isLocalFile && attachmentIndex < attachments.length - 1}
/>
);
})}
);
};
export default AttachmentList;
================================================
FILE: web/src/components/MemoEditor/components/EditorContent.tsx
================================================
import { forwardRef } from "react";
import Editor, { type EditorRefActions } from "../Editor";
import { useBlobUrls, useDragAndDrop } from "../hooks";
import { useEditorContext } from "../state";
import type { EditorContentProps } from "../types";
import type { LocalFile } from "../types/attachment";
export const EditorContent = forwardRef(({ placeholder }, ref) => {
const { state, actions, dispatch } = useEditorContext();
const { createBlobUrl } = useBlobUrls();
const { dragHandlers } = useDragAndDrop((files: FileList) => {
const localFiles: LocalFile[] = Array.from(files).map((file) => ({
file,
previewUrl: createBlobUrl(file),
}));
localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile)));
});
const handleCompositionStart = () => {
dispatch(actions.setComposing(true));
};
const handleCompositionEnd = () => {
dispatch(actions.setComposing(false));
};
const handleContentChange = (content: string) => {
dispatch(actions.updateContent(content));
};
const handlePaste = (event: React.ClipboardEvent) => {
const clipboard = event.clipboardData;
if (!clipboard) return;
const files: File[] = [];
if (clipboard.items && clipboard.items.length > 0) {
for (const item of Array.from(clipboard.items)) {
if (item.kind !== "file") continue;
const file = item.getAsFile();
if (file) files.push(file);
}
} else if (clipboard.files && clipboard.files.length > 0) {
files.push(...Array.from(clipboard.files));
}
if (files.length === 0) return;
const localFiles: LocalFile[] = files.map((file) => ({
file,
previewUrl: createBlobUrl(file),
}));
localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile)));
event.preventDefault();
};
return (
);
});
EditorContent.displayName = "EditorContent";
================================================
FILE: web/src/components/MemoEditor/components/EditorMetadata.tsx
================================================
import type { FC } from "react";
import { useEditorContext } from "../state";
import type { EditorMetadataProps } from "../types";
import AttachmentList from "./AttachmentList";
import LocationDisplay from "./LocationDisplay";
import RelationList from "./RelationList";
export const EditorMetadata: FC = ({ memoName }) => {
const { state, actions, dispatch } = useEditorContext();
return (
dispatch(actions.setMetadata({ attachments }))}
onRemoveLocalFile={(previewUrl) => dispatch(actions.removeLocalFile(previewUrl))}
/>
dispatch(actions.setMetadata({ relations }))}
memoName={memoName}
/>
{state.metadata.location && (
dispatch(actions.setMetadata({ location: undefined }))} />
)}
);
};
================================================
FILE: web/src/components/MemoEditor/components/EditorToolbar.tsx
================================================
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import { useTranslate } from "@/utils/i18n";
import { validationService } from "../services";
import { useEditorContext } from "../state";
import InsertMenu from "../Toolbar/InsertMenu";
import VisibilitySelector from "../Toolbar/VisibilitySelector";
import type { EditorToolbarProps } from "../types";
export const EditorToolbar: FC = ({ onSave, onCancel, memoName }) => {
const t = useTranslate();
const { state, actions, dispatch } = useEditorContext();
const { valid } = validationService.canSave(state);
const isSaving = state.ui.isLoading.saving;
const handleLocationChange = (location: typeof state.metadata.location) => {
dispatch(actions.setMetadata({ location }));
};
const handleToggleFocusMode = () => {
dispatch(actions.toggleFocusMode());
};
const handleVisibilityChange = (visibility: typeof state.metadata.visibility) => {
dispatch(actions.setMetadata({ visibility }));
};
return (
{onCancel && (
)}
);
};
================================================
FILE: web/src/components/MemoEditor/components/FocusModeOverlay.tsx
================================================
import { Minimize2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { FOCUS_MODE_STYLES } from "../constants";
import type { FocusModeExitButtonProps, FocusModeOverlayProps } from "../types";
export function FocusModeOverlay({ isActive, onToggle }: FocusModeOverlayProps) {
if (!isActive) return null;
return ;
}
export function FocusModeExitButton({ isActive, onToggle, title }: FocusModeExitButtonProps) {
if (!isActive) return null;
return (
);
}
================================================
FILE: web/src/components/MemoEditor/components/LinkMemoDialog.tsx
================================================
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { LinkIcon } from "lucide-react";
import { MemoPreview } from "@/components/MemoPreview";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { VisuallyHidden } from "@/components/ui/visually-hidden";
import { extractMemoIdFromName } from "@/helpers/resource-names";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import type { LinkMemoDialogProps } from "../types";
export const LinkMemoDialog = ({
open,
onOpenChange,
searchText,
onSearchChange,
filteredMemos,
isFetching,
onSelectMemo,
isAlreadyLinked,
}: LinkMemoDialogProps) => {
const t = useTranslate();
return (
);
};
================================================
FILE: web/src/components/MemoEditor/components/LocationDialog.tsx
================================================
import { LocationPicker } from "@/components/map";
import { Button } from "@/components/ui/button";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { VisuallyHidden } from "@/components/ui/visually-hidden";
import { useTranslate } from "@/utils/i18n";
import type { LocationDialogProps } from "../types";
export const LocationDialog = ({
open,
onOpenChange,
state,
locationInitialized: _locationInitialized,
onPositionChange,
onUpdateCoordinate,
onPlaceholderChange,
onCancel,
onConfirm,
}: LocationDialogProps) => {
const t = useTranslate();
const { placeholder, position, latInput, lngInput } = state;
return (
);
};
================================================
FILE: web/src/components/MemoEditor/components/LocationDisplay.tsx
================================================
import { MapPinIcon, XIcon } from "lucide-react";
import type { FC } from "react";
import { cn } from "@/lib/utils";
import type { Location } from "@/types/proto/api/v1/memo_service_pb";
interface LocationDisplayProps {
location: Location;
onRemove?: () => void;
className?: string;
}
const LocationDisplay: FC = ({ location, onRemove, className }) => {
const displayText = location.placeholder || `${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}`;
return (
{displayText}
{location.latitude.toFixed(4)}°, {location.longitude.toFixed(4)}°
{onRemove && (
)}
);
};
export default LocationDisplay;
================================================
FILE: web/src/components/MemoEditor/components/RelationList.tsx
================================================
import { create } from "@bufbuild/protobuf";
import { LinkIcon, XIcon } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import RelationCard from "@/components/MemoView/components/metadata/RelationCard";
import { memoServiceClient } from "@/connect";
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
import { MemoRelation_Memo, MemoRelation_MemoSchema, MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
interface RelationListProps {
relations: MemoRelation[];
onRelationsChange?: (relations: MemoRelation[]) => void;
parentPage?: string;
memoName?: string;
}
const RelationItemCard: FC<{
memo: MemoRelation["relatedMemo"];
onRemove?: () => void;
parentPage?: string;
}> = ({ memo, onRemove, parentPage }) => {
return (
{onRemove && (
)}
);
};
const RelationList: FC = ({ relations, onRelationsChange, parentPage, memoName }) => {
const referenceRelations = relations.filter(
(r) => r.type === MemoRelation_Type.REFERENCE && (!memoName || !r.memo?.name || r.memo.name === memoName),
);
const [fetchedMemos, setFetchedMemos] = useState>({});
useEffect(() => {
(async () => {
const missingSnippetRelations = referenceRelations.filter((relation) => !relation.relatedMemo?.snippet && relation.relatedMemo?.name);
if (missingSnippetRelations.length > 0) {
const requests = missingSnippetRelations.map(async (relation) => {
const memo = await memoServiceClient.getMemo({ name: relation.relatedMemo!.name });
return create(MemoRelation_MemoSchema, { name: memo.name, snippet: memo.snippet });
});
const list = await Promise.all(requests);
setFetchedMemos((prev) => {
const next = { ...prev };
for (const memo of list) {
next[memo.name] = memo;
}
return next;
});
}
})();
}, [referenceRelations]);
const handleDeleteRelation = (memoName: string) => {
if (onRelationsChange) {
onRelationsChange(relations.filter((relation) => relation.relatedMemo?.name !== memoName));
}
};
if (referenceRelations.length === 0) {
return null;
}
return (
Relations ({referenceRelations.length})
{referenceRelations.map((relation) => {
const relatedMemo = relation.relatedMemo!;
const memo = relatedMemo.snippet ? relatedMemo : fetchedMemos[relatedMemo.name] || relatedMemo;
return handleDeleteRelation(memo.name)} parentPage={parentPage} />;
})}
);
};
export default RelationList;
================================================
FILE: web/src/components/MemoEditor/components/TimestampPopover.tsx
================================================
import { type FC, useRef, useState } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useTranslate } from "@/utils/i18n";
import { useEditorContext } from "../state";
const DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ss";
function formatDate(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function parseDate(value: string): Date | undefined {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
if (!match) return undefined;
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4]), Number(match[5]), Number(match[6]));
return Number.isNaN(date.getTime()) ? undefined : date;
}
const TimestampInput: FC<{
label: string;
date: Date | undefined;
onChange: (date: Date) => void;
}> = ({ label, date, onChange }) => {
const initialValue = useRef(date ? formatDate(date) : "");
const [value, setValue] = useState(initialValue.current);
const [invalid, setInvalid] = useState(false);
const handleBlur = () => {
const parsed = parseDate(value);
if (parsed) {
setInvalid(false);
onChange(parsed);
} else {
setInvalid(true);
}
};
return (
setValue(e.target.value)}
onBlur={handleBlur}
/>
);
};
export const TimestampPopover: FC = () => {
const t = useTranslate();
const { state, actions, dispatch } = useEditorContext();
const { createTime, updateTime } = state.timestamps;
if (!createTime) return null;
return (
dispatch(actions.setTimestamps({ createTime: d }))}
/>
dispatch(actions.setTimestamps({ updateTime: d }))}
/>
);
};
================================================
FILE: web/src/components/MemoEditor/components/index.ts
================================================
// UI components for MemoEditor
export { default as AttachmentList } from "./AttachmentList";
export * from "./EditorContent";
export * from "./EditorMetadata";
export * from "./EditorToolbar";
export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay";
export { LinkMemoDialog } from "./LinkMemoDialog";
export { LocationDialog } from "./LocationDialog";
export { default as LocationDisplay } from "./LocationDisplay";
export { default as RelationList } from "./RelationList";
export { TimestampPopover } from "./TimestampPopover";
================================================
FILE: web/src/components/MemoEditor/constants.ts
================================================
export const LOCALSTORAGE_DEBOUNCE_DELAY = 500;
export const FOCUS_MODE_STYLES = {
backdrop: "fixed inset-0 bg-black/20 backdrop-blur-sm z-40",
container: {
base: "fixed z-50 w-auto max-w-5xl mx-auto shadow-2xl border-border h-auto overflow-y-auto",
spacing: "top-2 left-2 right-2 bottom-2 sm:top-4 sm:left-4 sm:right-4 sm:bottom-4 md:top-8 md:left-8 md:right-8 md:bottom-8",
},
transition: "transition-all duration-300 ease-in-out",
exitButton: "absolute top-2 right-2 z-10 opacity-60 hover:opacity-100",
} as const;
export const EDITOR_HEIGHT = {
// Max height for normal mode - focus mode uses flex-1 to grow dynamically
normal: "max-h-[50vh]",
} as const;
================================================
FILE: web/src/components/MemoEditor/hooks/index.ts
================================================
// Custom hooks for MemoEditor (internal use only)
export { useAutoSave } from "./useAutoSave";
export { useBlobUrls } from "./useBlobUrls";
export { useDragAndDrop } from "./useDragAndDrop";
export { useFileUpload } from "./useFileUpload";
export { useFocusMode } from "./useFocusMode";
export { useKeyboard } from "./useKeyboard";
export { useLinkMemo } from "./useLinkMemo";
export { useLocation } from "./useLocation";
export { useMemoInit } from "./useMemoInit";
================================================
FILE: web/src/components/MemoEditor/hooks/useAutoSave.ts
================================================
import { useEffect } from "react";
import { cacheService } from "../services";
export const useAutoSave = (content: string, username: string, cacheKey: string | undefined) => {
useEffect(() => {
const key = cacheService.key(username, cacheKey);
cacheService.save(key, content);
}, [content, username, cacheKey]);
};
================================================
FILE: web/src/components/MemoEditor/hooks/useBlobUrls.ts
================================================
import { useEffect, useRef } from "react";
export function useBlobUrls() {
const urlsRef = useRef>(new Set());
useEffect(
() => () => {
for (const url of urlsRef.current) {
URL.revokeObjectURL(url);
}
},
[],
);
return {
createBlobUrl: (blob: Blob | File): string => {
const url = URL.createObjectURL(blob);
urlsRef.current.add(url);
return url;
},
revokeBlobUrl: (url: string) => {
if (urlsRef.current.has(url)) {
URL.revokeObjectURL(url);
urlsRef.current.delete(url);
}
},
};
}
================================================
FILE: web/src/components/MemoEditor/hooks/useDragAndDrop.ts
================================================
export function useDragAndDrop(onDrop: (files: FileList) => void) {
return {
dragHandlers: {
onDragOver: (e: React.DragEvent) => {
if (e.dataTransfer?.types.includes("Files")) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}
},
onDragLeave: (e: React.DragEvent) => {
e.preventDefault();
},
onDrop: (e: React.DragEvent) => {
if (e.dataTransfer?.files.length) {
e.preventDefault();
onDrop(e.dataTransfer.files);
}
},
},
};
}
================================================
FILE: web/src/components/MemoEditor/hooks/useFileUpload.ts
================================================
import { useRef } from "react";
import type { LocalFile } from "../types/attachment";
export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void) => {
const fileInputRef = useRef(null);
const selectingFlagRef = useRef(false);
const handleFileInputChange = (event?: React.ChangeEvent) => {
const files = Array.from(fileInputRef.current?.files || event?.target.files || []);
if (files.length === 0 || selectingFlagRef.current) {
return;
}
selectingFlagRef.current = true;
const localFiles: LocalFile[] = files.map((file) => ({
file,
previewUrl: URL.createObjectURL(file),
}));
onFilesSelected(localFiles);
selectingFlagRef.current = false;
// Optionally clear input value to allow re-selecting the same file
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleUploadClick = () => {
fileInputRef.current?.click();
};
return {
fileInputRef,
selectingFlag: selectingFlagRef.current,
handleFileInputChange,
handleUploadClick,
};
};
================================================
FILE: web/src/components/MemoEditor/hooks/useFocusMode.ts
================================================
import { useEffect } from "react";
export function useFocusMode(isFocusMode: boolean): void {
useEffect(() => {
document.body.style.overflow = isFocusMode ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [isFocusMode]);
}
================================================
FILE: web/src/components/MemoEditor/hooks/useKeyboard.ts
================================================
import { useEffect } from "react";
import type { EditorRefActions } from "../Editor";
interface UseKeyboardOptions {
onSave: () => void;
}
export const useKeyboard = (editorRef: React.RefObject, options: UseKeyboardOptions) => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.key !== "Enter") {
return;
}
const editor = editorRef.current?.getEditor();
if (!editor) {
return;
}
const activeElement = document.activeElement;
const target = event.target;
if (activeElement !== editor && target !== editor) {
return;
}
event.preventDefault();
options.onSave();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [editorRef, options]);
};
================================================
FILE: web/src/components/MemoEditor/hooks/useLinkMemo.ts
================================================
import { create } from "@bufbuild/protobuf";
import { useEffect, useMemo, useState } from "react";
import useDebounce from "react-use/lib/useDebounce";
import { memoServiceClient } from "@/connect";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import { extractUserIdFromName } from "@/helpers/resource-names";
import useCurrentUser from "@/hooks/useCurrentUser";
import {
type Memo,
type MemoRelation,
MemoRelation_MemoSchema,
MemoRelation_Type,
MemoRelationSchema,
} from "@/types/proto/api/v1/memo_service_pb";
interface UseLinkMemoParams {
isOpen: boolean;
currentMemoName?: string;
existingRelations: MemoRelation[];
onAddRelation: (relation: MemoRelation) => void;
}
export const useLinkMemo = ({ isOpen, currentMemoName, existingRelations, onAddRelation }: UseLinkMemoParams) => {
const user = useCurrentUser();
const [searchText, setSearchText] = useState("");
const [isFetching, setIsFetching] = useState(true);
const [fetchedMemos, setFetchedMemos] = useState([]);
const filteredMemos = fetchedMemos.filter((memo) => memo.name !== currentMemoName);
const linkedMemoNames = useMemo(() => new Set(existingRelations.map((r) => r.relatedMemo?.name)), [existingRelations]);
const isAlreadyLinked = (memoName: string): boolean => linkedMemoNames.has(memoName);
useEffect(() => {
if (isOpen) {
setSearchText("");
}
}, [isOpen]);
useDebounce(
async () => {
if (!isOpen) return;
setIsFetching(true);
try {
const conditions = [`creator_id == ${extractUserIdFromName(user?.name ?? "")}`];
if (searchText) {
conditions.push(`content.contains("${searchText}")`);
}
const { memos } = await memoServiceClient.listMemos({
pageSize: DEFAULT_LIST_MEMOS_PAGE_SIZE,
filter: conditions.join(" && "),
});
setFetchedMemos(memos);
} catch (error) {
console.error(error);
} finally {
setIsFetching(false);
}
},
300,
[isOpen, searchText],
);
const addMemoRelation = (memo: Memo) => {
const relation = create(MemoRelationSchema, {
type: MemoRelation_Type.REFERENCE,
relatedMemo: create(MemoRelation_MemoSchema, {
name: memo.name,
snippet: memo.snippet,
}),
});
onAddRelation(relation);
};
return {
searchText,
setSearchText,
isFetching,
filteredMemos,
addMemoRelation,
isAlreadyLinked,
};
};
================================================
FILE: web/src/components/MemoEditor/hooks/useLocation.ts
================================================
import { create } from "@bufbuild/protobuf";
import { LatLng } from "leaflet";
import { useState } from "react";
import { Location, LocationSchema } from "@/types/proto/api/v1/memo_service_pb";
import { LocationState } from "../types/insert-menu";
export const useLocation = (initialLocation?: Location) => {
const [locationInitialized, setLocationInitialized] = useState(false);
const [state, setState] = useState({
placeholder: initialLocation?.placeholder || "",
position: initialLocation ? new LatLng(initialLocation.latitude, initialLocation.longitude) : undefined,
latInput: initialLocation ? String(initialLocation.latitude) : "",
lngInput: initialLocation ? String(initialLocation.longitude) : "",
});
const updatePosition = (position?: LatLng) => {
setState((prev) => ({
...prev,
position,
latInput: position ? String(position.lat) : "",
lngInput: position ? String(position.lng) : "",
}));
};
const handlePositionChange = (position: LatLng) => {
if (!locationInitialized) setLocationInitialized(true);
updatePosition(position);
};
const updateCoordinate = (type: "lat" | "lng", value: string) => {
setState((prev) => ({ ...prev, [type === "lat" ? "latInput" : "lngInput"]: value }));
const num = parseFloat(value);
const isValid = type === "lat" ? !isNaN(num) && num >= -90 && num <= 90 : !isNaN(num) && num >= -180 && num <= 180;
if (isValid && state.position) {
updatePosition(type === "lat" ? new LatLng(num, state.position.lng) : new LatLng(state.position.lat, num));
}
};
const setPlaceholder = (placeholder: string) => {
setState((prev) => ({ ...prev, placeholder }));
};
const reset = () => {
setState({
placeholder: "",
position: undefined,
latInput: "",
lngInput: "",
});
setLocationInitialized(false);
};
const getLocation = (): Location | undefined => {
if (!state.position || !state.placeholder.trim()) {
return undefined;
}
return create(LocationSchema, {
latitude: state.position.lat,
longitude: state.position.lng,
placeholder: state.placeholder,
});
};
return {
state,
locationInitialized,
handlePositionChange,
updateCoordinate,
setPlaceholder,
reset,
getLocation,
};
};
================================================
FILE: web/src/components/MemoEditor/hooks/useMemoInit.ts
================================================
import { useEffect, useRef } from "react";
import type { Memo, Visibility } from "@/types/proto/api/v1/memo_service_pb";
import type { EditorRefActions } from "../Editor";
import { cacheService, memoService } from "../services";
import { useEditorContext } from "../state";
interface UseMemoInitOptions {
editorRef: React.RefObject;
memo?: Memo;
cacheKey?: string;
username: string;
autoFocus?: boolean;
defaultVisibility?: Visibility;
}
export const useMemoInit = ({ editorRef, memo, cacheKey, username, autoFocus, defaultVisibility }: UseMemoInitOptions) => {
const { actions, dispatch } = useEditorContext();
const initializedRef = useRef(false);
useEffect(() => {
if (initializedRef.current) return;
initializedRef.current = true;
if (memo) {
dispatch(actions.initMemo(memoService.fromMemo(memo)));
} else {
const cachedContent = cacheService.load(cacheService.key(username, cacheKey));
if (cachedContent) {
dispatch(actions.updateContent(cachedContent));
}
if (defaultVisibility !== undefined) {
dispatch(actions.setMetadata({ visibility: defaultVisibility }));
}
}
if (autoFocus) {
setTimeout(() => editorRef.current?.focus(), 100);
}
}, [memo, cacheKey, username, autoFocus, defaultVisibility, actions, dispatch, editorRef]);
};
================================================
FILE: web/src/components/MemoEditor/index.tsx
================================================
import { useQueryClient } from "@tanstack/react-query";
import { useRef } from "react";
import { toast } from "react-hot-toast";
import { useAuth } from "@/contexts/AuthContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import { memoKeys } from "@/hooks/useMemoQueries";
import { userKeys } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { convertVisibilityFromString } from "@/utils/memo";
import { EditorContent, EditorMetadata, EditorToolbar, FocusModeExitButton, FocusModeOverlay, TimestampPopover } from "./components";
import { FOCUS_MODE_STYLES } from "./constants";
import type { EditorRefActions } from "./Editor";
import { useAutoSave, useFocusMode, useKeyboard, useMemoInit } from "./hooks";
import { cacheService, errorService, memoService, validationService } from "./services";
import { EditorProvider, useEditorContext } from "./state";
import type { MemoEditorProps } from "./types";
const MemoEditor = (props: MemoEditorProps) => (
);
const MemoEditorImpl: React.FC = ({
className,
cacheKey,
memo,
parentMemoName,
autoFocus,
placeholder,
onConfirm,
onCancel,
}) => {
const t = useTranslate();
const queryClient = useQueryClient();
const currentUser = useCurrentUser();
const editorRef = useRef(null);
const { state, actions, dispatch } = useEditorContext();
const { userGeneralSetting } = useAuth();
const memoName = memo?.name;
// Get default visibility from user settings
const defaultVisibility = userGeneralSetting?.memoVisibility ? convertVisibilityFromString(userGeneralSetting.memoVisibility) : undefined;
useMemoInit({ editorRef, memo, cacheKey, username: currentUser?.name ?? "", autoFocus, defaultVisibility });
// Auto-save content to localStorage
useAutoSave(state.content, currentUser?.name ?? "", cacheKey);
// Focus mode management with body scroll lock
useFocusMode(state.ui.isFocusMode);
const handleToggleFocusMode = () => {
dispatch(actions.toggleFocusMode());
};
useKeyboard(editorRef, { onSave: handleSave });
async function handleSave() {
// Validate before saving
const { valid, reason } = validationService.canSave(state);
if (!valid) {
toast.error(reason || "Cannot save");
return;
}
dispatch(actions.setLoading("saving", true));
try {
const result = await memoService.save(state, { memoName, parentMemoName });
if (!result.hasChanges) {
toast.error(t("editor.no-changes-detected"));
onCancel?.();
return;
}
// Clear localStorage cache on successful save
cacheService.clear(cacheService.key(currentUser?.name ?? "", cacheKey));
// Invalidate React Query cache to refresh memo lists across the app
const invalidationPromises = [
queryClient.invalidateQueries({ queryKey: memoKeys.lists() }),
queryClient.invalidateQueries({ queryKey: userKeys.stats() }),
];
// Ensure memo detail pages don't keep stale cached content after edits.
if (memoName) {
invalidationPromises.push(queryClient.invalidateQueries({ queryKey: memoKeys.detail(memoName) }));
}
// If this was a comment, also invalidate the comments query for the parent memo
if (parentMemoName) {
invalidationPromises.push(queryClient.invalidateQueries({ queryKey: memoKeys.comments(parentMemoName) }));
}
await Promise.all(invalidationPromises);
// Reset editor state to initial values
dispatch(actions.reset());
if (!memoName && defaultVisibility) {
dispatch(actions.setMetadata({ visibility: defaultVisibility }));
}
// Notify parent component of successful save
onConfirm?.(result.memoName);
} catch (error) {
handleError(error, toast.error, {
context: "Failed to save memo",
fallbackMessage: errorService.getErrorMessage(error),
});
} finally {
dispatch(actions.setLoading("saving", false));
}
}
return (
<>
{/*
Layout structure:
- Uses justify-between to push content to top and bottom
- In focus mode: becomes fixed with specific spacing, editor grows to fill space
- In normal mode: stays relative with max-height constraint
*/}
{/* Exit button is absolutely positioned in top-right corner when active */}
{memoName && (
)}
{/* Editor content grows to fill available space in focus mode */}
{/* Metadata and toolbar grouped together at bottom */}
>
);
};
export default MemoEditor;
================================================
FILE: web/src/components/MemoEditor/services/cacheService.ts
================================================
import { debounce } from "lodash-es";
export const CACHE_DEBOUNCE_DELAY = 500;
export const cacheService = {
key: (username: string, cacheKey?: string): string => {
return `${username}-${cacheKey || ""}`;
},
save: debounce((key: string, content: string) => {
if (content.trim()) {
localStorage.setItem(key, content);
} else {
localStorage.removeItem(key);
}
}, CACHE_DEBOUNCE_DELAY),
load(key: string): string {
return localStorage.getItem(key) || "";
},
clear(key: string): void {
localStorage.removeItem(key);
},
};
================================================
FILE: web/src/components/MemoEditor/services/errorService.ts
================================================
export const errorService = {
getErrorMessage(error: unknown): string {
// Handle ConnectError or errors with details property
if (error && typeof error === "object" && "details" in error) {
return (error as { details?: string }).details || "An error occurred";
}
if (error instanceof Error) {
return error.message;
}
return "An unknown error occurred";
},
};
================================================
FILE: web/src/components/MemoEditor/services/index.ts
================================================
export * from "./cacheService";
export * from "./errorService";
export * from "./memoService";
export * from "./uploadService";
export * from "./validationService";
================================================
FILE: web/src/components/MemoEditor/services/memoService.ts
================================================
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema, timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt";
import { isEqual } from "lodash-es";
import { memoServiceClient } from "@/connect";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
import type { EditorState } from "../state";
import { uploadService } from "./uploadService";
/**
* Converts attachments to reference format for API requests.
* The backend only needs the attachment name to link it to a memo.
*/
function toAttachmentReferences(attachments: Attachment[]): Attachment[] {
return attachments.map((a) => create(AttachmentSchema, { name: a.name }));
}
function buildUpdateMask(
prevMemo: Memo,
state: EditorState,
allAttachments: typeof state.metadata.attachments,
): { mask: Set; patch: Partial } {
const mask = new Set();
const patch: Partial = {
name: prevMemo.name,
content: state.content,
};
if (!isEqual(state.content, prevMemo.content)) {
mask.add("content");
patch.content = state.content;
}
if (!isEqual(state.metadata.visibility, prevMemo.visibility)) {
mask.add("visibility");
patch.visibility = state.metadata.visibility;
}
if (!isEqual(allAttachments, prevMemo.attachments)) {
mask.add("attachments");
patch.attachments = toAttachmentReferences(allAttachments);
}
if (!isEqual(state.metadata.relations, prevMemo.relations)) {
mask.add("relations");
patch.relations = state.metadata.relations;
}
if (!isEqual(state.metadata.location, prevMemo.location)) {
mask.add("location");
patch.location = state.metadata.location;
}
// Auto-update timestamp if content changed
if (["content", "attachments", "relations", "location"].some((key) => mask.has(key))) {
mask.add("update_time");
}
// Handle custom timestamps
if (state.timestamps.createTime) {
const prevCreateTime = prevMemo.createTime ? timestampDate(prevMemo.createTime) : undefined;
if (!isEqual(state.timestamps.createTime, prevCreateTime)) {
mask.add("create_time");
patch.createTime = timestampFromDate(state.timestamps.createTime);
}
}
if (state.timestamps.updateTime) {
const prevUpdateTime = prevMemo.updateTime ? timestampDate(prevMemo.updateTime) : undefined;
if (!isEqual(state.timestamps.updateTime, prevUpdateTime)) {
mask.add("update_time");
patch.updateTime = timestampFromDate(state.timestamps.updateTime);
}
}
return { mask, patch };
}
export const memoService = {
async save(
state: EditorState,
options: {
memoName?: string;
parentMemoName?: string;
},
): Promise<{ memoName: string; hasChanges: boolean }> {
// 1. Upload local files first
const newAttachments = await uploadService.uploadFiles(state.localFiles);
const allAttachments = [...state.metadata.attachments, ...newAttachments];
// 2. Update existing memo
if (options.memoName) {
const prevMemo = await memoServiceClient.getMemo({ name: options.memoName });
const { mask, patch } = buildUpdateMask(prevMemo, state, allAttachments);
if (mask.size === 0) {
return { memoName: prevMemo.name, hasChanges: false };
}
const memo = await memoServiceClient.updateMemo({
memo: create(MemoSchema, patch as Record),
updateMask: create(FieldMaskSchema, { paths: Array.from(mask) }),
});
return { memoName: memo.name, hasChanges: true };
}
// 3. Create new memo or comment
const memoData = create(MemoSchema, {
content: state.content,
visibility: state.metadata.visibility,
attachments: toAttachmentReferences(allAttachments),
relations: state.metadata.relations,
location: state.metadata.location,
createTime: state.timestamps.createTime ? timestampFromDate(state.timestamps.createTime) : undefined,
updateTime: state.timestamps.updateTime ? timestampFromDate(state.timestamps.updateTime) : undefined,
});
const memo = options.parentMemoName
? await memoServiceClient.createMemoComment({
name: options.parentMemoName,
comment: memoData,
})
: await memoServiceClient.createMemo({ memo: memoData });
return { memoName: memo.name, hasChanges: true };
},
/** Build editor state from an already-loaded Memo entity (no network request). */
fromMemo(memo: Memo): EditorState {
return {
content: memo.content,
metadata: {
visibility: memo.visibility,
attachments: memo.attachments,
relations: memo.relations,
location: memo.location,
},
ui: {
isFocusMode: false,
isLoading: { saving: false, uploading: false, loading: false },
isDragging: false,
isComposing: false,
},
timestamps: {
createTime: memo.createTime ? timestampDate(memo.createTime) : undefined,
updateTime: memo.updateTime ? timestampDate(memo.updateTime) : undefined,
},
localFiles: [],
};
},
};
================================================
FILE: web/src/components/MemoEditor/services/uploadService.ts
================================================
import { create } from "@bufbuild/protobuf";
import { attachmentServiceClient } from "@/connect";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb";
import type { LocalFile } from "../types/attachment";
export const uploadService = {
async uploadFiles(localFiles: LocalFile[]): Promise {
if (localFiles.length === 0) return [];
const attachments: Attachment[] = [];
for (const { file } of localFiles) {
const buffer = new Uint8Array(await file.arrayBuffer());
const attachment = await attachmentServiceClient.createAttachment({
attachment: create(AttachmentSchema, {
filename: file.name,
size: BigInt(file.size),
type: file.type,
content: buffer,
}),
});
attachments.push(attachment);
}
return attachments;
},
};
================================================
FILE: web/src/components/MemoEditor/services/validationService.ts
================================================
import type { EditorState } from "../state";
export interface ValidationResult {
valid: boolean;
reason?: string;
}
export const validationService = {
canSave(state: EditorState): ValidationResult {
// Cannot save while loading initial content
if (state.ui.isLoading.loading) {
return { valid: false, reason: "Loading memo content" };
}
// Must have content, attachment, or local file
if (!state.content.trim() && state.metadata.attachments.length === 0 && state.localFiles.length === 0) {
return { valid: false, reason: "Content, attachment, or file required" };
}
// Cannot save while uploading
if (state.ui.isLoading.uploading) {
return { valid: false, reason: "Wait for upload to complete" };
}
// Cannot save while already saving
if (state.ui.isLoading.saving) {
return { valid: false, reason: "Save in progress" };
}
return { valid: true };
},
};
================================================
FILE: web/src/components/MemoEditor/state/actions.ts
================================================
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
import type { LocalFile } from "../types/attachment";
import type { EditorAction, EditorState, LoadingKey } from "./types";
export const editorActions = {
initMemo: (payload: { content: string; metadata: EditorState["metadata"]; timestamps: EditorState["timestamps"] }): EditorAction => ({
type: "INIT_MEMO",
payload,
}),
updateContent: (content: string): EditorAction => ({
type: "UPDATE_CONTENT",
payload: content,
}),
setMetadata: (metadata: Partial): EditorAction => ({
type: "SET_METADATA",
payload: metadata,
}),
addAttachment: (attachment: Attachment): EditorAction => ({
type: "ADD_ATTACHMENT",
payload: attachment,
}),
removeAttachment: (name: string): EditorAction => ({
type: "REMOVE_ATTACHMENT",
payload: name,
}),
addRelation: (relation: MemoRelation): EditorAction => ({
type: "ADD_RELATION",
payload: relation,
}),
removeRelation: (name: string): EditorAction => ({
type: "REMOVE_RELATION",
payload: name,
}),
addLocalFile: (file: LocalFile): EditorAction => ({
type: "ADD_LOCAL_FILE",
payload: file,
}),
removeLocalFile: (previewUrl: string): EditorAction => ({
type: "REMOVE_LOCAL_FILE",
payload: previewUrl,
}),
clearLocalFiles: (): EditorAction => ({
type: "CLEAR_LOCAL_FILES",
}),
toggleFocusMode: (): EditorAction => ({
type: "TOGGLE_FOCUS_MODE",
}),
setLoading: (key: LoadingKey, value: boolean): EditorAction => ({
type: "SET_LOADING",
payload: { key, value },
}),
setDragging: (value: boolean): EditorAction => ({
type: "SET_DRAGGING",
payload: value,
}),
setComposing: (value: boolean): EditorAction => ({
type: "SET_COMPOSING",
payload: value,
}),
setTimestamps: (timestamps: Partial): EditorAction => ({
type: "SET_TIMESTAMPS",
payload: timestamps,
}),
reset: (): EditorAction => ({
type: "RESET",
}),
};
================================================
FILE: web/src/components/MemoEditor/state/context.tsx
================================================
import { createContext, type Dispatch, type FC, type PropsWithChildren, useContext, useMemo, useReducer } from "react";
import { editorActions } from "./actions";
import { editorReducer } from "./reducer";
import type { EditorAction, EditorState } from "./types";
import { initialState } from "./types";
interface EditorContextValue {
state: EditorState;
dispatch: Dispatch;
actions: typeof editorActions;
}
const EditorContext = createContext(null);
export const useEditorContext = () => {
const context = useContext(EditorContext);
if (!context) {
throw new Error("useEditorContext must be used within EditorProvider");
}
return context;
};
interface EditorProviderProps extends PropsWithChildren {
initialEditorState?: EditorState;
}
export const EditorProvider: FC = ({ children, initialEditorState }) => {
const [state, dispatch] = useReducer(editorReducer, initialEditorState || initialState);
const value = useMemo(
() => ({
state,
dispatch,
actions: editorActions,
}),
[state],
);
return {children};
};
================================================
FILE: web/src/components/MemoEditor/state/index.ts
================================================
export * from "./actions";
export * from "./context";
export * from "./reducer";
export * from "./types";
================================================
FILE: web/src/components/MemoEditor/state/reducer.ts
================================================
import type { EditorAction, EditorState } from "./types";
import { initialState } from "./types";
export function editorReducer(state: EditorState, action: EditorAction): EditorState {
switch (action.type) {
case "INIT_MEMO":
return {
...state,
content: action.payload.content,
metadata: action.payload.metadata,
timestamps: action.payload.timestamps,
};
case "UPDATE_CONTENT":
return {
...state,
content: action.payload,
};
case "SET_METADATA":
return {
...state,
metadata: {
...state.metadata,
...action.payload,
},
};
case "ADD_ATTACHMENT":
return {
...state,
metadata: {
...state.metadata,
attachments: [...state.metadata.attachments, action.payload],
},
};
case "REMOVE_ATTACHMENT":
return {
...state,
metadata: {
...state.metadata,
attachments: state.metadata.attachments.filter((a) => a.name !== action.payload),
},
};
case "ADD_RELATION":
return {
...state,
metadata: {
...state.metadata,
relations: [...state.metadata.relations, action.payload],
},
};
case "REMOVE_RELATION":
return {
...state,
metadata: {
...state.metadata,
relations: state.metadata.relations.filter((r) => r.relatedMemo?.name !== action.payload),
},
};
case "ADD_LOCAL_FILE":
return {
...state,
localFiles: [...state.localFiles, action.payload],
};
case "REMOVE_LOCAL_FILE":
return {
...state,
localFiles: state.localFiles.filter((f) => f.previewUrl !== action.payload),
};
case "CLEAR_LOCAL_FILES":
return {
...state,
localFiles: [],
};
case "TOGGLE_FOCUS_MODE":
return {
...state,
ui: {
...state.ui,
isFocusMode: !state.ui.isFocusMode,
},
};
case "SET_LOADING":
return {
...state,
ui: {
...state.ui,
isLoading: {
...state.ui.isLoading,
[action.payload.key]: action.payload.value,
},
},
};
case "SET_DRAGGING":
return {
...state,
ui: {
...state.ui,
isDragging: action.payload,
},
};
case "SET_COMPOSING":
return {
...state,
ui: {
...state.ui,
isComposing: action.payload,
},
};
case "SET_TIMESTAMPS":
return {
...state,
timestamps: {
...state.timestamps,
...action.payload,
},
};
case "RESET":
return {
...initialState,
};
default:
return state;
}
}
================================================
FILE: web/src/components/MemoEditor/state/types.ts
================================================
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
import type { LocalFile } from "../types/attachment";
export type LoadingKey = "saving" | "uploading" | "loading";
export interface EditorState {
content: string;
metadata: {
visibility: Visibility;
attachments: Attachment[];
relations: MemoRelation[];
location?: Location;
};
ui: {
isFocusMode: boolean;
isLoading: {
saving: boolean;
uploading: boolean;
loading: boolean;
};
isDragging: boolean;
isComposing: boolean;
};
timestamps: {
createTime?: Date;
updateTime?: Date;
};
localFiles: LocalFile[];
}
export type EditorAction =
| { type: "INIT_MEMO"; payload: { content: string; metadata: EditorState["metadata"]; timestamps: EditorState["timestamps"] } }
| { type: "UPDATE_CONTENT"; payload: string }
| { type: "SET_METADATA"; payload: Partial }
| { type: "ADD_ATTACHMENT"; payload: Attachment }
| { type: "REMOVE_ATTACHMENT"; payload: string }
| { type: "ADD_RELATION"; payload: MemoRelation }
| { type: "REMOVE_RELATION"; payload: string }
| { type: "ADD_LOCAL_FILE"; payload: LocalFile }
| { type: "REMOVE_LOCAL_FILE"; payload: string }
| { type: "CLEAR_LOCAL_FILES" }
| { type: "TOGGLE_FOCUS_MODE" }
| { type: "SET_LOADING"; payload: { key: LoadingKey; value: boolean } }
| { type: "SET_DRAGGING"; payload: boolean }
| { type: "SET_COMPOSING"; payload: boolean }
| { type: "SET_TIMESTAMPS"; payload: Partial }
| { type: "RESET" };
export const initialState: EditorState = {
content: "",
metadata: {
visibility: Visibility.PRIVATE,
attachments: [],
relations: [],
location: undefined,
},
ui: {
isFocusMode: false,
isLoading: {
saving: false,
uploading: false,
loading: false,
},
isDragging: false,
isComposing: false,
},
timestamps: {
createTime: undefined,
updateTime: undefined,
},
localFiles: [],
};
================================================
FILE: web/src/components/MemoEditor/types/attachment.ts
================================================
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { getAttachmentThumbnailUrl, getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
export type FileCategory = "image" | "video" | "document";
// Unified view model for rendering attachments and local files
export interface AttachmentItem {
readonly id: string;
readonly filename: string;
readonly category: FileCategory;
readonly mimeType: string;
readonly thumbnailUrl: string;
readonly sourceUrl: string;
readonly size?: number;
readonly isLocal: boolean;
}
// For MemoEditor: local files being uploaded
export interface LocalFile {
readonly file: File;
readonly previewUrl: string;
}
function categorizeFile(mimeType: string): FileCategory {
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("video/")) return "video";
return "document";
}
export function attachmentToItem(attachment: Attachment): AttachmentItem {
const attachmentType = getAttachmentType(attachment);
const sourceUrl = getAttachmentUrl(attachment);
return {
id: attachment.name,
filename: attachment.filename,
category: categorizeFile(attachment.type),
mimeType: attachment.type,
thumbnailUrl: attachmentType === "image/*" ? getAttachmentThumbnailUrl(attachment) : sourceUrl,
sourceUrl,
size: Number(attachment.size),
isLocal: false,
};
}
export function fileToItem(file: File, blobUrl: string): AttachmentItem {
return {
id: blobUrl,
filename: file.name,
category: categorizeFile(file.type),
mimeType: file.type,
thumbnailUrl: blobUrl,
sourceUrl: blobUrl,
size: file.size,
isLocal: true,
};
}
export function toAttachmentItems(attachments: Attachment[], localFiles: LocalFile[] = []): AttachmentItem[] {
return [...attachments.map(attachmentToItem), ...localFiles.map(({ file, previewUrl }) => fileToItem(file, previewUrl))];
}
export function filterByCategory(items: AttachmentItem[], categories: FileCategory[]): AttachmentItem[] {
const categorySet = new Set(categories);
return items.filter((item) => categorySet.has(item.category));
}
export function separateMediaAndDocs(items: AttachmentItem[]): { media: AttachmentItem[]; docs: AttachmentItem[] } {
const media: AttachmentItem[] = [];
const docs: AttachmentItem[] = [];
for (const item of items) {
if (item.category === "image" || item.category === "video") {
media.push(item);
} else {
docs.push(item);
}
}
return { media, docs };
}
================================================
FILE: web/src/components/MemoEditor/types/components.ts
================================================
import type { LatLng } from "leaflet";
import type { Location, Memo, Visibility } from "@/types/proto/api/v1/memo_service_pb";
import type { EditorRefActions } from "../Editor";
import type { Command } from "../Editor/commands";
import type { LocationState } from "./insert-menu";
export interface MemoEditorProps {
className?: string;
cacheKey?: string;
placeholder?: string;
/** Existing memo to edit. When provided, the editor initializes from it without fetching. */
memo?: Memo;
parentMemoName?: string;
autoFocus?: boolean;
onConfirm?: (memoName: string) => void;
onCancel?: () => void;
}
export interface EditorContentProps {
placeholder?: string;
autoFocus?: boolean;
}
export interface EditorToolbarProps {
onSave: () => void;
onCancel?: () => void;
memoName?: string;
}
export interface EditorMetadataProps {
memoName?: string;
}
export interface FocusModeOverlayProps {
isActive: boolean;
onToggle: () => void;
}
export interface FocusModeExitButtonProps {
isActive: boolean;
onToggle: () => void;
title: string;
}
export interface LinkMemoDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
searchText: string;
onSearchChange: (text: string) => void;
filteredMemos: Memo[];
isFetching: boolean;
onSelectMemo: (memo: Memo) => void;
isAlreadyLinked: (memoName: string) => boolean;
}
export interface LocationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
state: LocationState;
locationInitialized: boolean;
onPositionChange: (position: LatLng) => void;
onUpdateCoordinate: (type: "lat" | "lng", value: string) => void;
onPlaceholderChange: (placeholder: string) => void;
onCancel: () => void;
onConfirm: () => void;
}
export interface InsertMenuProps {
isUploading?: boolean;
location?: Location;
onLocationChange: (location?: Location) => void;
onToggleFocusMode?: () => void;
memoName?: string;
}
export interface TagSuggestionsProps {
editorRef: React.RefObject;
editorActions: React.ForwardedRef;
}
export interface SlashCommandsProps {
editorRef: React.RefObject;
editorActions: React.ForwardedRef;
commands: Command[];
}
export interface EditorProps {
className: string;
initialContent: string;
placeholder: string;
onContentChange: (content: string) => void;
onPaste: (event: React.ClipboardEvent) => void;
isFocusMode?: boolean;
isInIME?: boolean;
onCompositionStart?: () => void;
onCompositionEnd?: () => void;
}
export interface VisibilitySelectorProps {
value: Visibility;
onChange: (visibility: Visibility) => void;
onOpenChange?: (open: boolean) => void;
}
================================================
FILE: web/src/components/MemoEditor/types/context.ts
================================================
import { createContext } from "react";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
import type { LocalFile } from "./attachment";
export interface MemoEditorContextValue {
attachmentList: Attachment[];
relationList: MemoRelation[];
setAttachmentList: (attachmentList: Attachment[]) => void;
setRelationList: (relationList: MemoRelation[]) => void;
memoName?: string;
addLocalFiles?: (files: LocalFile[]) => void;
removeLocalFile?: (previewUrl: string) => void;
localFiles?: LocalFile[];
}
const defaultContextValue: MemoEditorContextValue = {
attachmentList: [],
relationList: [],
setAttachmentList: () => {},
setRelationList: () => {},
addLocalFiles: () => {},
removeLocalFile: () => {},
localFiles: [],
};
export const MemoEditorContext = createContext(defaultContextValue);
================================================
FILE: web/src/components/MemoEditor/types/index.ts
================================================
// MemoEditor type exports
export type {
EditorContentProps,
EditorMetadataProps,
EditorProps,
EditorToolbarProps,
FocusModeExitButtonProps,
FocusModeOverlayProps,
InsertMenuProps,
LinkMemoDialogProps,
LocationDialogProps,
MemoEditorProps,
SlashCommandsProps,
TagSuggestionsProps,
VisibilitySelectorProps,
} from "./components";
export { MemoEditorContext, type MemoEditorContextValue } from "./context";
export type { LocationState } from "./insert-menu";
================================================
FILE: web/src/components/MemoEditor/types/insert-menu.ts
================================================
import { LatLng } from "leaflet";
export interface LocationState {
placeholder: string;
position?: LatLng;
latInput: string;
lngInput: string;
}
================================================
FILE: web/src/components/MemoExplorer/MemoExplorer.tsx
================================================
import SearchBar from "@/components/SearchBar";
import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils";
import type { StatisticsData } from "@/types/statistics";
import StatisticsView from "../StatisticsView";
import ShortcutsSection from "./ShortcutsSection";
import TagsSection from "./TagsSection";
export type MemoExplorerContext = "home" | "explore" | "archived" | "profile";
export interface MemoExplorerFeatures {
search?: boolean;
statistics?: boolean;
shortcuts?: boolean;
tags?: boolean;
}
interface Props {
className?: string;
context?: MemoExplorerContext;
features?: MemoExplorerFeatures;
statisticsData: StatisticsData;
tagCount: Record;
}
const getDefaultFeatures = (context: MemoExplorerContext): MemoExplorerFeatures => {
switch (context) {
case "explore":
return {
search: true,
statistics: true,
shortcuts: false, // Global explore doesn't use shortcuts
tags: true,
};
case "archived":
return {
search: true,
statistics: true,
shortcuts: false, // Archived doesn't typically use shortcuts
tags: true,
};
case "profile":
return {
search: true,
statistics: true,
shortcuts: false, // Profile view doesn't use shortcuts
tags: true,
};
case "home":
default:
return {
search: true,
statistics: true,
shortcuts: true,
tags: true,
};
}
};
const MemoExplorer = (props: Props) => {
const { className, context = "home", features: featureOverrides = {}, statisticsData, tagCount } = props;
const currentUser = useCurrentUser();
// Merge default features with overrides
const features = {
...getDefaultFeatures(context),
...featureOverrides,
};
return (
);
};
export default MemoExplorer;
================================================
FILE: web/src/components/MemoExplorer/MemoExplorerDrawer.tsx
================================================
import { MenuIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import type { StatisticsData } from "@/types/statistics";
import MemoExplorer, { MemoExplorerContext, MemoExplorerFeatures } from "./MemoExplorer";
interface Props {
context?: MemoExplorerContext;
features?: MemoExplorerFeatures;
statisticsData: StatisticsData;
tagCount: Record;
}
const MemoExplorerDrawer = (props: Props) => {
const { context, features, statisticsData, tagCount } = props;
const location = useLocation();
const [open, setOpen] = useState(false);
useEffect(() => {
setOpen(false);
}, [location.pathname]);
return (
);
};
export default MemoExplorerDrawer;
================================================
FILE: web/src/components/MemoExplorer/ShortcutsSection.tsx
================================================
import { Edit3Icon, MoreVerticalIcon, PlusIcon, TrashIcon } from "lucide-react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { shortcutServiceClient } from "@/connect";
import { useAuth } from "@/contexts/AuthContext";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { cn } from "@/lib/utils";
import { Shortcut } from "@/types/proto/api/v1/shortcut_service_pb";
import { useTranslate } from "@/utils/i18n";
import CreateShortcutDialog from "../CreateShortcutDialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)$/u;
// Helper function to extract shortcut ID from resource name
// Format: users/{user}/shortcuts/{shortcut}
const getShortcutId = (name: string): string => {
const parts = name.split("/");
return parts.length === 4 ? parts[3] : "";
};
function ShortcutsSection() {
const t = useTranslate();
const { shortcuts, refetchSettings } = useAuth();
const { shortcut: selectedShortcut, setShortcut } = useMemoFilterContext();
const [isCreateShortcutDialogOpen, setIsCreateShortcutDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState();
const [editingShortcut, setEditingShortcut] = useState();
useEffect(() => {
refetchSettings();
}, [refetchSettings]);
const handleDeleteShortcut = async (shortcut: Shortcut) => {
setDeleteTarget(shortcut);
};
const confirmDeleteShortcut = async () => {
if (!deleteTarget) return;
await shortcutServiceClient.deleteShortcut({ name: deleteTarget.name });
await refetchSettings();
toast.success(t("setting.shortcut.delete-success", { title: deleteTarget.title }));
setDeleteTarget(undefined);
};
const handleCreateShortcut = () => {
setEditingShortcut(undefined);
setIsCreateShortcutDialogOpen(true);
};
const handleEditShortcut = (shortcut: Shortcut) => {
setEditingShortcut(shortcut);
setIsCreateShortcutDialogOpen(true);
};
const handleShortcutDialogSuccess = () => {
setIsCreateShortcutDialogOpen(false);
setEditingShortcut(undefined);
};
return (
{t("common.shortcuts")}
{t("common.create")}
{shortcuts.map((shortcut) => {
const shortcutId = getShortcutId(shortcut.name);
const maybeEmoji = shortcut.title.split(" ")[0];
const emoji = emojiRegex.test(maybeEmoji) ? maybeEmoji : undefined;
const title = emoji ? shortcut.title.replace(emoji, "") : shortcut.title;
const selected = selectedShortcut === shortcutId;
return (
(selected ? setShortcut(undefined) : setShortcut(shortcutId))}
>
{emoji && {emoji}}
{title.trim()}
handleEditShortcut(shortcut)}>
{t("common.edit")}
handleDeleteShortcut(shortcut)}>
{t("common.delete")}
);
})}
!open && setDeleteTarget(undefined)}
title={t("setting.shortcut.delete-confirm", { title: deleteTarget?.title ?? "" })}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteShortcut}
confirmVariant="destructive"
/>
);
}
export default ShortcutsSection;
================================================
FILE: web/src/components/MemoExplorer/TagsSection.tsx
================================================
import { HashIcon, MoreVerticalIcon, TagsIcon } from "lucide-react";
import useLocalStorage from "react-use/lib/useLocalStorage";
import { Switch } from "@/components/ui/switch";
import { type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import TagTree from "../TagTree";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
interface Props {
readonly?: boolean;
tagCount: Record;
}
const TagsSection = (props: Props) => {
const t = useTranslate();
const { getFiltersByFactor, addFilter, removeFilter } = useMemoFilterContext();
const [treeMode, setTreeMode] = useLocalStorage("tag-view-as-tree", false);
const [treeAutoExpand, setTreeAutoExpand] = useLocalStorage("tag-tree-auto-expand", false);
const tags = Object.entries(props.tagCount)
.sort((a, b) => a[0].localeCompare(b[0]))
.sort((a, b) => b[1] - a[1]);
const handleTagClick = (tag: string) => {
const isActive = getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag);
if (isActive) {
removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag);
} else {
// Remove all existing tag filters first, then add the new one
removeFilter((f: MemoFilter) => f.factor === "tagSearch");
addFilter({
factor: "tagSearch",
value: tag,
});
}
};
return (
{t("common.tags")}
{tags.length > 0 && (
{t("common.tree-mode")}
setTreeMode(checked)} />
{t("common.auto-expand")}
setTreeAutoExpand(checked)} />
)}
{tags.length > 0 ? (
treeMode ? (
) : (
{tags.map(([tag, amount]) => {
const isActive = getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag);
return (
handleTagClick(tag)}
>
{tag}
{amount > 1 && ({amount})}
);
})}
)
) : (
!props.readonly && (
{t("tag.create-tags-guide")}
)
)}
);
};
export default TagsSection;
================================================
FILE: web/src/components/MemoExplorer/index.ts
================================================
import MemoExplorer from "./MemoExplorer";
import MemoExplorerDrawer from "./MemoExplorerDrawer";
export type { MemoExplorerContext, MemoExplorerFeatures } from "./MemoExplorer";
export { MemoExplorer, MemoExplorerDrawer };
================================================
FILE: web/src/components/MemoFilters.tsx
================================================
import { isEqual } from "lodash-es";
import {
BookmarkIcon,
CalendarIcon,
CheckCircleIcon,
CodeIcon,
EyeIcon,
HashIcon,
LinkIcon,
LucideIcon,
SearchIcon,
XIcon,
} from "lucide-react";
import { FilterFactor, getMemoFilterKey, MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { useTranslate } from "@/utils/i18n";
interface FilterConfig {
icon: LucideIcon;
getLabel: (value: string, t: ReturnType) => string;
}
const FILTER_CONFIGS: Record = {
tagSearch: {
icon: HashIcon,
getLabel: (value) => value,
},
visibility: {
icon: EyeIcon,
getLabel: (value) => value,
},
contentSearch: {
icon: SearchIcon,
getLabel: (value) => value,
},
displayTime: {
icon: CalendarIcon,
getLabel: (value) => value,
},
pinned: {
icon: BookmarkIcon,
getLabel: (value) => value,
},
"property.hasLink": {
icon: LinkIcon,
getLabel: (_, t) => t("memo.filters.has-link"),
},
"property.hasTaskList": {
icon: CheckCircleIcon,
getLabel: (_, t) => t("memo.filters.has-task-list"),
},
"property.hasCode": {
icon: CodeIcon,
getLabel: (_, t) => t("memo.filters.has-code"),
},
};
const MemoFilters = () => {
const t = useTranslate();
const { filters, removeFilter } = useMemoFilterContext();
const handleRemoveFilter = (filter: MemoFilter) => {
removeFilter((f: MemoFilter) => isEqual(f, filter));
};
const getFilterDisplayText = (filter: MemoFilter): string => {
const config = FILTER_CONFIGS[filter.factor];
if (!config) {
return filter.value || filter.factor;
}
return config.getLabel(filter.value, t);
};
if (filters.length === 0) {
return null;
}
return (
{filters.map((filter) => {
const config = FILTER_CONFIGS[filter.factor];
const Icon = config?.icon;
return (
{Icon && }
{getFilterDisplayText(filter)}
);
})}
);
};
MemoFilters.displayName = "MemoFilters";
export default MemoFilters;
================================================
FILE: web/src/components/MemoPreview/MemoPreview.tsx
================================================
import { create } from "@bufbuild/protobuf";
import { FileIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
import MemoContent from "../MemoContent";
import { MemoViewContext, type MemoViewContextValue } from "../MemoView/MemoViewContext";
interface MemoPreviewProps {
content: string;
attachments: Attachment[];
compact?: boolean;
className?: string;
}
const STUB_CONTEXT: MemoViewContextValue = {
memo: create(MemoSchema),
creator: undefined,
currentUser: undefined,
parentPage: "/",
isArchived: false,
readonly: true,
showNSFWContent: false,
nsfw: false,
};
const AttachmentThumbnails = ({ attachments }: { attachments: Attachment[] }) => {
const images: Attachment[] = [];
const others: Attachment[] = [];
for (const a of attachments) {
if (getAttachmentType(a) === "image/*") images.push(a);
else others.push(a);
}
return (
{images.map((a) => (
})
))}
{others.map((a) => (
{a.filename}
))}
);
};
const MemoPreview = ({ content, attachments, compact = true, className }: MemoPreviewProps) => {
const hasContent = content.trim().length > 0;
const hasAttachments = attachments.length > 0;
if (!hasContent && !hasAttachments) {
return null;
}
return (
{hasContent &&
}
{hasAttachments &&
}
);
};
export default MemoPreview;
================================================
FILE: web/src/components/MemoPreview/index.ts
================================================
export { default as MemoPreview } from "./MemoPreview";
================================================
FILE: web/src/components/MemoReactionListView/MemoReactionListView.tsx
================================================
import { memo } from "react";
import useCurrentUser from "@/hooks/useCurrentUser";
import { State } from "@/types/proto/api/v1/common_pb";
import type { Memo, Reaction } from "@/types/proto/api/v1/memo_service_pb";
import { useReactionGroups } from "./hooks";
import ReactionSelector from "./ReactionSelector";
import ReactionView from "./ReactionView";
interface Props {
memo: Memo;
reactions: Reaction[];
}
const MemoReactionListView = (props: Props) => {
const { memo: memoData, reactions } = props;
const currentUser = useCurrentUser();
const reactionGroup = useReactionGroups(reactions);
const readonly = memoData.state === State.ARCHIVED;
if (reactions.length === 0) {
return null;
}
return (
{Array.from(reactionGroup).map(([reactionType, users]) => (
))}
{!readonly && currentUser && }
);
};
export default memo(MemoReactionListView);
================================================
FILE: web/src/components/MemoReactionListView/ReactionSelector.tsx
================================================
import { SmilePlusIcon } from "lucide-react";
import { useState } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useInstance } from "@/contexts/InstanceContext";
import { cn } from "@/lib/utils";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useReactionActions } from "./hooks";
interface Props {
memo: Memo;
className?: string;
onOpenChange?: (open: boolean) => void;
}
const ReactionSelector = (props: Props) => {
const { memo, className, onOpenChange } = props;
const [open, setOpen] = useState(false);
const { memoRelatedSetting } = useInstance();
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
onOpenChange?.(newOpen);
};
const { hasReacted, handleReactionClick } = useReactionActions({
memo,
onComplete: () => handleOpenChange(false),
});
return (
{memoRelatedSetting.reactions.map((reactionType) => (
))}
);
};
export default ReactionSelector;
================================================
FILE: web/src/components/MemoReactionListView/ReactionView.tsx
================================================
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils";
import { State } from "@/types/proto/api/v1/common_pb";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import type { User } from "@/types/proto/api/v1/user_service_pb";
import { formatReactionTooltip, useReactionActions } from "./hooks";
interface Props {
memo: Memo;
reactionType: string;
users: User[];
}
const ReactionView = (props: Props) => {
const { memo, reactionType, users } = props;
const currentUser = useCurrentUser();
const hasReaction = users.some((user) => currentUser && user.username === currentUser.username);
const readonly = memo.state === State.ARCHIVED;
const { handleReactionClick } = useReactionActions({ memo });
const handleClick = () => {
if (!currentUser || readonly) return;
handleReactionClick(reactionType);
};
const isClickable = currentUser && !readonly;
return (
{formatReactionTooltip(users, reactionType)}
);
};
export default ReactionView;
================================================
FILE: web/src/components/MemoReactionListView/hooks.ts
================================================
import { useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";
import { memoServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser";
import { memoKeys } from "@/hooks/useMemoQueries";
import { useUsersByNames } from "@/hooks/useUserQueries";
import type { Memo, Reaction } from "@/types/proto/api/v1/memo_service_pb";
import type { User } from "@/types/proto/api/v1/user_service_pb";
export type ReactionGroup = Map;
export const useReactionGroups = (reactions: Reaction[]): ReactionGroup => {
const creatorNames = useMemo(() => reactions.map((r) => r.creator), [reactions]);
const { data: userMap } = useUsersByNames(creatorNames);
return useMemo(() => {
const reactionGroup = new Map();
for (const reaction of reactions) {
const user = userMap?.get(reaction.creator);
if (!user) continue;
const users = reactionGroup.get(reaction.reactionType) || [];
users.push(user);
reactionGroup.set(reaction.reactionType, users);
}
return reactionGroup;
}, [reactions, userMap]);
};
interface UseReactionActionsOptions {
memo: Memo;
onComplete?: () => void;
}
export const useReactionActions = ({ memo, onComplete }: UseReactionActionsOptions) => {
const currentUser = useCurrentUser();
const queryClient = useQueryClient();
const hasReacted = (reactionType: string) => {
return memo.reactions.some((r) => r.reactionType === reactionType && r.creator === currentUser?.name);
};
const handleReactionClick = async (reactionType: string) => {
if (!currentUser) return;
try {
if (hasReacted(reactionType)) {
const reactions = memo.reactions.filter(
(reaction) => reaction.reactionType === reactionType && reaction.creator === currentUser.name,
);
for (const reaction of reactions) {
await memoServiceClient.deleteMemoReaction({ name: reaction.name });
}
} else {
await memoServiceClient.upsertMemoReaction({
name: memo.name,
reaction: { contentId: memo.name, reactionType },
});
}
// Refetch the memo to get updated reactions and invalidate cache
const updatedMemo = await memoServiceClient.getMemo({ name: memo.name });
queryClient.setQueryData(memoKeys.detail(memo.name), updatedMemo);
queryClient.invalidateQueries({ queryKey: memoKeys.lists() });
// If this memo is a comment, refresh the parent's comments list so the comment's reactions update in the UI
if (memo.parent) {
queryClient.invalidateQueries({ queryKey: memoKeys.comments(memo.parent) });
}
} catch {
// skip error
}
onComplete?.();
};
return { hasReacted, handleReactionClick };
};
export const formatReactionTooltip = (users: User[], reactionType: string): string => {
if (users.length === 0) return "";
const formatUserName = (user: User) => user.displayName || user.username;
if (users.length < 5) {
return `${users.map(formatUserName).join(", ")} reacted with ${reactionType.toLowerCase()}`;
}
return `${users.slice(0, 4).map(formatUserName).join(", ")} and ${users.length - 4} more reacted with ${reactionType.toLowerCase()}`;
};
================================================
FILE: web/src/components/MemoReactionListView/index.ts
================================================
export { default, default as MemoReactionListView } from "./MemoReactionListView";
export { default as ReactionSelector } from "./ReactionSelector";
export { default as ReactionView } from "./ReactionView";
================================================
FILE: web/src/components/MemoRelationForceGraph/MemoRelationForceGraph.tsx
================================================
import { useEffect, useRef, useState } from "react";
import ForceGraph2D, { ForceGraphMethods, LinkObject, NodeObject } from "react-force-graph-2d";
import { extractMemoIdFromName } from "@/helpers/resource-names";
import useNavigateTo from "@/hooks/useNavigateTo";
import { cn } from "@/lib/utils";
import { Memo, MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import { LinkType, NodeType } from "./types";
import { convertMemoRelationsToGraphData } from "./utils";
interface Props {
memo: Memo;
className?: string;
parentPage?: string;
}
const MAIN_NODE_COLOR = "#14b8a6";
const DEFAULT_NODE_COLOR = "#a1a1aa";
const MemoRelationForceGraph = ({ className, memo, parentPage }: Props) => {
const navigateTo = useNavigateTo();
const [mode] = useState<"light">("light");
const containerRef = useRef(null);
const graphRef = useRef, LinkObject> | undefined>(undefined);
const [graphSize, setGraphSize] = useState({ width: 0, height: 0 });
useEffect(() => {
if (!containerRef.current) return;
setGraphSize(containerRef.current.getBoundingClientRect());
}, []);
const onNodeClick = (node: NodeObject) => {
if (node.memo.name === memo.name) return;
navigateTo(`/${memo.name}`, {
state: {
from: parentPage,
},
});
};
return (
(node.id === memo.name ? MAIN_NODE_COLOR : DEFAULT_NODE_COLOR)}
nodeRelSize={3}
nodeLabel={(node) => extractMemoIdFromName(node.memo.name).slice(0, 6).toLowerCase()}
linkColor={() => (mode === "light" ? "#e4e4e7" : "#3f3f46")}
graphData={convertMemoRelationsToGraphData(memo.relations.filter((r) => r.type === MemoRelation_Type.REFERENCE))}
onNodeClick={onNodeClick}
linkDirectionalArrowLength={3}
linkDirectionalArrowRelPos={1}
linkCurvature={0.25}
/>
);
};
export default MemoRelationForceGraph;
================================================
FILE: web/src/components/MemoRelationForceGraph/index.ts
================================================
import MemoRelationForceGraph from "./MemoRelationForceGraph";
export * from "./utils";
export default MemoRelationForceGraph;
================================================
FILE: web/src/components/MemoRelationForceGraph/types.ts
================================================
import { MemoRelation_Memo } from "@/types/proto/api/v1/memo_service_pb";
export interface NodeType {
memo: MemoRelation_Memo;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface LinkType {
// ...add more additional properties relevant to the link here.
}
================================================
FILE: web/src/components/MemoRelationForceGraph/utils.ts
================================================
import { GraphData, LinkObject, NodeObject } from "react-force-graph-2d";
import { MemoRelation, MemoRelation_Memo } from "@/types/proto/api/v1/memo_service_pb";
import { LinkType, NodeType } from "./types";
export const convertMemoRelationsToGraphData = (memoRelations: MemoRelation[]): GraphData => {
const nodesMap = new Map>();
const links: LinkObject[] = [];
// Iterate through memoRelations to populate nodes and links.
memoRelations.forEach((relation) => {
const memo = relation.memo as MemoRelation_Memo;
const relatedMemo = relation.relatedMemo as MemoRelation_Memo;
// Add memo node if not already present.
if (!nodesMap.has(memo.name)) {
nodesMap.set(memo.name, { id: memo.name, memo });
}
// Add related_memo node if not already present.
if (!nodesMap.has(relatedMemo.name)) {
nodesMap.set(relatedMemo.name, { id: relatedMemo.name, memo: relatedMemo });
}
// Create link between memo and relatedMemo.
links.push({
source: memo.name,
target: relatedMemo.name,
type: relation.type, // Include the type of relation as a property of the link.
});
});
return {
nodes: Array.from(nodesMap.values()),
links,
};
};
================================================
FILE: web/src/components/MemoResource.tsx
================================================
================================================
FILE: web/src/components/MemoSharePanel.tsx
================================================
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { ConnectError } from "@connectrpc/connect";
import { CheckIcon, CopyIcon, LinkIcon, Loader2Icon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { getShareUrl, useCreateMemoShare, useDeleteMemoShare, useMemoShares } from "@/hooks/useMemoShareQueries";
import type { MemoShare } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
type ExpiryOption = "never" | "1d" | "7d" | "30d";
function getExpireDate(option: ExpiryOption): Date | undefined {
if (option === "never") return undefined;
const d = new Date();
if (option === "1d") d.setDate(d.getDate() + 1);
else if (option === "7d") d.setDate(d.getDate() + 7);
else if (option === "30d") d.setDate(d.getDate() + 30);
return d;
}
function formatExpiry(share: MemoShare, t: ReturnType): string {
if (!share.expireTime) return t("memo.share.never-expires");
const d = timestampDate(share.expireTime);
return t("memo.share.expires-on", { date: d.toLocaleDateString() });
}
interface ShareLinkRowProps {
share: MemoShare;
memoName: string;
}
function ShareLinkRow({ share, memoName }: ShareLinkRowProps) {
const t = useTranslate();
const [copied, setCopied] = useState(false);
const deleteShare = useDeleteMemoShare();
const url = getShareUrl(share);
const handleCopy = async () => {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleRevoke = async () => {
try {
await deleteShare.mutateAsync({ name: share.name, memoName });
toast.success(t("memo.share.revoked"));
} catch (e) {
toast.error((e as ConnectError).message || t("memo.share.revoke-failed"));
}
};
return (
);
}
interface MemoSharePanelProps {
open: boolean;
onClose: () => void;
memoName: string;
}
const MemoSharePanel = ({ open, onClose, memoName }: MemoSharePanelProps) => {
const t = useTranslate();
const [expiry, setExpiry] = useState("never");
const { data: shares = [], isLoading } = useMemoShares(memoName, { enabled: open });
const createShare = useCreateMemoShare();
const handleCreate = async () => {
try {
await createShare.mutateAsync({ memoName, expireTime: getExpireDate(expiry) });
} catch (e) {
toast.error((e as ConnectError).message || t("memo.share.create-failed"));
}
};
return (
);
};
export default MemoSharePanel;
================================================
FILE: web/src/components/MemoView/MemoView.tsx
================================================
import { memo, useMemo, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useUser } from "@/hooks/useUserQueries";
import { cn } from "@/lib/utils";
import { State } from "@/types/proto/api/v1/common_pb";
import { isSuperUser } from "@/utils/user";
import MemoEditor from "../MemoEditor";
import PreviewImageDialog from "../PreviewImageDialog";
import { MemoBody, MemoCommentListView, MemoHeader } from "./components";
import { MEMO_CARD_BASE_CLASSES } from "./constants";
import { useImagePreview, useMemoActions, useMemoHandlers } from "./hooks";
import { computeCommentAmount, MemoViewContext } from "./MemoViewContext";
import type { MemoViewProps } from "./types";
const MemoView: React.FC = (props: MemoViewProps) => {
const { memo: memoData, className, parentPage: parentPageProp, compact, showCreator, showVisibility, showPinned } = props;
const cardRef = useRef(null);
const [showEditor, setShowEditor] = useState(false);
const currentUser = useCurrentUser();
const creator = useUser(memoData.creator).data;
const isArchived = memoData.state === State.ARCHIVED;
const readonly = memoData.creator !== currentUser?.name && !isSuperUser(currentUser);
const parentPage = parentPageProp || "/";
// NSFW content management: always blur content tagged with NSFW (case-insensitive)
const [showNSFWContent, setShowNSFWContent] = useState(false);
const nsfw = memoData.tags?.some((tag) => tag.toUpperCase() === "NSFW") ?? false;
const toggleNsfwVisibility = () => setShowNSFWContent((prev) => !prev);
const { previewState, openPreview, setPreviewOpen } = useImagePreview();
const { unpinMemo } = useMemoActions(memoData);
const closeEditor = () => setShowEditor(false);
const openEditor = () => setShowEditor(true);
const { handleGotoMemoDetailPage, handleMemoContentClick, handleMemoContentDoubleClick } = useMemoHandlers({
memoName: memoData.name,
parentPage,
readonly,
openEditor,
openPreview,
});
const location = useLocation();
const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`);
const showCommentPreview = !isInMemoDetailPage && computeCommentAmount(memoData) > 0;
const contextValue = useMemo(
() => ({
memo: memoData,
creator,
currentUser,
parentPage,
isArchived,
readonly,
showNSFWContent,
nsfw,
}),
[memoData, creator, currentUser, parentPage, isArchived, readonly, showNSFWContent, nsfw],
);
if (showEditor) {
return (
);
}
const article = (
);
return (
{showCommentPreview ? (
{article}
) : (
article
)}
);
};
export default memo(MemoView);
================================================
FILE: web/src/components/MemoView/MemoViewContext.tsx
================================================
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { createContext, useContext } from "react";
import { useLocation } from "react-router-dom";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import type { User } from "@/types/proto/api/v1/user_service_pb";
import { RELATIVE_TIME_THRESHOLD_MS } from "./constants";
export interface MemoViewContextValue {
memo: Memo;
creator: User | undefined;
currentUser: User | undefined;
parentPage: string;
isArchived: boolean;
readonly: boolean;
showNSFWContent: boolean;
nsfw: boolean;
}
export const MemoViewContext = createContext(null);
export const useMemoViewContext = (): MemoViewContextValue => {
const context = useContext(MemoViewContext);
if (!context) {
throw new Error("useMemoViewContext must be used within MemoViewContext.Provider");
}
return context;
};
export const computeCommentAmount = (memo: Memo): number =>
memo.relations.filter((r) => r.type === MemoRelation_Type.COMMENT && r.relatedMemo?.name === memo.name).length;
export const useMemoViewDerived = () => {
const { memo, isArchived, readonly } = useMemoViewContext();
const location = useLocation();
const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`);
const commentAmount = computeCommentAmount(memo);
const displayTime = memo.displayTime ? timestampDate(memo.displayTime) : undefined;
const relativeTimeFormat: "datetime" | "auto" =
displayTime && Date.now() - displayTime.getTime() > RELATIVE_TIME_THRESHOLD_MS ? "datetime" : "auto";
return {
isArchived,
readonly,
isInMemoDetailPage,
commentAmount,
relativeTimeFormat,
};
};
================================================
FILE: web/src/components/MemoView/components/MemoBody.tsx
================================================
import { cn } from "@/lib/utils";
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import MemoContent from "../../MemoContent";
import { MemoReactionListView } from "../../MemoReactionListView";
import { useMemoViewContext } from "../MemoViewContext";
import type { MemoBodyProps } from "../types";
import { AttachmentList, LocationDisplay, RelationList } from "./metadata";
const NsfwOverlay: React.FC<{ onClick?: () => void }> = ({ onClick }) => {
const t = useTranslate();
return (
);
};
const MemoBody: React.FC = ({ compact, onContentClick, onContentDoubleClick, onToggleNsfwVisibility }) => {
const { memo, parentPage, showNSFWContent, nsfw } = useMemoViewContext();
const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
return (
<>
{nsfw && !showNSFWContent && }
>
);
};
export default MemoBody;
================================================
FILE: web/src/components/MemoView/components/MemoCommentListView.tsx
================================================
import { ArrowUpRightIcon } from "lucide-react";
import { Link } from "react-router-dom";
import { extractMemoIdFromName } from "@/helpers/resource-names";
import { useMemoComments } from "@/hooks/useMemoQueries";
import { useMemoViewContext, useMemoViewDerived } from "../MemoViewContext";
import MemoSnippetLink from "./MemoSnippetLink";
const MemoCommentListView: React.FC = () => {
const { memo } = useMemoViewContext();
const { isInMemoDetailPage, commentAmount } = useMemoViewDerived();
const { data } = useMemoComments(memo.name, { enabled: !isInMemoDetailPage && commentAmount > 0 });
const comments = data?.memos ?? [];
if (isInMemoDetailPage || commentAmount === 0) {
return null;
}
const displayedComments = comments.slice(0, 3);
return (
Comments{commentAmount > 1 ? ` (${commentAmount})` : ""}
View all
{displayedComments.map((comment) => {
const uid = extractMemoIdFromName(comment.name);
return (
);
})}
);
};
export default MemoCommentListView;
================================================
FILE: web/src/components/MemoView/components/MemoHeader.tsx
================================================
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { BookmarkIcon } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import i18n from "@/i18n";
import { cn } from "@/lib/utils";
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
import type { User } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
import { convertVisibilityToString } from "@/utils/memo";
import MemoActionMenu from "../../MemoActionMenu";
import { ReactionSelector } from "../../MemoReactionListView";
import UserAvatar from "../../UserAvatar";
import VisibilityIcon from "../../VisibilityIcon";
import { useMemoViewContext, useMemoViewDerived } from "../MemoViewContext";
import type { MemoHeaderProps } from "../types";
const MemoHeader: React.FC = ({ showCreator, showVisibility, showPinned, onEdit, onGotoDetail, onUnpin }) => {
const t = useTranslate();
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
const { memo, creator, currentUser, isArchived, readonly } = useMemoViewContext();
const { relativeTimeFormat } = useMemoViewDerived();
const displayTime = isArchived ? (
(memo.displayTime ? timestampDate(memo.displayTime) : undefined)?.toLocaleString(i18n.language)
) : (
);
return (
{showCreator && creator ? (
) : (
)}
{currentUser && !isArchived && (
)}
{showVisibility && memo.visibility !== Visibility.PRIVATE && (
{t(`memo.visibility.${convertVisibilityToString(memo.visibility).toLowerCase()}` as Parameters[0])}
)}
{showPinned && memo.pinned && (
{t("common.unpin")}
)}
);
};
interface CreatorDisplayProps {
creator: User;
displayTime: React.ReactNode;
onGotoDetail: () => void;
}
const CreatorDisplay: React.FC = ({ creator, displayTime, onGotoDetail }) => (
{creator.displayName || creator.username}
);
interface TimeDisplayProps {
displayTime: React.ReactNode;
onGotoDetail: () => void;
}
const TimeDisplay: React.FC = ({ displayTime, onGotoDetail }) => (
);
export default MemoHeader;
================================================
FILE: web/src/components/MemoView/components/MemoSnippetLink.tsx
================================================
import { Link } from "react-router-dom";
import { extractMemoIdFromName } from "@/helpers/resource-names";
import { cn } from "@/lib/utils";
interface MemoSnippetLinkProps {
name: string;
snippet: string;
to: string;
state?: object;
className?: string;
}
const MemoSnippetLink = ({ name, snippet, to, state, className }: MemoSnippetLinkProps) => {
const memoId = extractMemoIdFromName(name);
return (
{memoId.slice(0, 6)}
{snippet || No content}
);
};
export default MemoSnippetLink;
================================================
FILE: web/src/components/MemoView/components/index.ts
================================================
export { default as MemoBody } from "./MemoBody";
export { default as MemoCommentListView } from "./MemoCommentListView";
export { default as MemoHeader } from "./MemoHeader";
================================================
FILE: web/src/components/MemoView/components/metadata/AttachmentCard.tsx
================================================
import { cn } from "@/lib/utils";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
interface AttachmentCardProps {
attachment: Attachment;
onClick?: () => void;
className?: string;
}
const AttachmentCard = ({ attachment, onClick, className }: AttachmentCardProps) => {
const attachmentType = getAttachmentType(attachment);
const sourceUrl = getAttachmentUrl(attachment);
if (attachmentType === "image/*") {
return (
);
}
if (attachmentType === "video/*") {
return ;
}
if (attachmentType === "audio/*") {
return ;
}
return null;
};
export default AttachmentCard;
================================================
FILE: web/src/components/MemoView/components/metadata/AttachmentList.tsx
================================================
import { FileAudioIcon, FileIcon, PaperclipIcon } from "lucide-react";
import { useMemo, useState } from "react";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
import { formatFileSize, getFileTypeLabel } from "@/utils/format";
import PreviewImageDialog from "../../../PreviewImageDialog";
import AttachmentCard from "./AttachmentCard";
import SectionHeader from "./SectionHeader";
interface AttachmentListProps {
attachments: Attachment[];
}
const isImageAttachment = (attachment: Attachment): boolean => getAttachmentType(attachment) === "image/*";
const isVideoAttachment = (attachment: Attachment): boolean => getAttachmentType(attachment) === "video/*";
const isAudioAttachment = (attachment: Attachment): boolean => getAttachmentType(attachment) === "audio/*";
const separateAttachments = (attachments: Attachment[]) => {
const visual: Attachment[] = [];
const audio: Attachment[] = [];
const docs: Attachment[] = [];
for (const attachment of attachments) {
if (isImageAttachment(attachment) || isVideoAttachment(attachment)) {
visual.push(attachment);
} else if (isAudioAttachment(attachment)) {
audio.push(attachment);
} else {
docs.push(attachment);
}
}
return { visual, audio, docs };
};
const DocumentItem = ({ attachment }: { attachment: Attachment }) => {
const fileTypeLabel = getFileTypeLabel(attachment.type);
const fileSizeLabel = attachment.size ? formatFileSize(Number(attachment.size)) : undefined;
return (
{attachment.filename}
•
{fileTypeLabel}
{fileSizeLabel && (
<>
•
{fileSizeLabel}
>
)}
);
};
const AudioItem = ({ attachment }: { attachment: Attachment }) => {
const sourceUrl = getAttachmentUrl(attachment);
return (
);
};
interface VisualItemProps {
attachment: Attachment;
onImageClick: (url: string) => void;
}
const VisualItem = ({ attachment, onImageClick }: VisualItemProps) => {
const handleClick = () => {
if (isImageAttachment(attachment)) {
onImageClick(getAttachmentUrl(attachment));
}
};
return (
);
};
const VisualGrid = ({ attachments, onImageClick }: { attachments: Attachment[]; onImageClick: (url: string) => void }) => (
{attachments.map((attachment) => (
))}
);
const AudioList = ({ attachments }: { attachments: Attachment[] }) => (
{attachments.map((attachment) => (
))}
);
const DocsList = ({ attachments }: { attachments: Attachment[] }) => (
{attachments.map((attachment) => (
))}
);
const Divider = () => ;
const AttachmentList = ({ attachments }: AttachmentListProps) => {
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number; mimeType?: string }>({
open: false,
urls: [],
index: 0,
mimeType: undefined,
});
const { visual, audio, docs } = useMemo(() => separateAttachments(attachments), [attachments]);
const imageAttachments = useMemo(() => visual.filter(isImageAttachment), [visual]);
const imageUrls = useMemo(() => imageAttachments.map(getAttachmentUrl), [imageAttachments]);
if (attachments.length === 0) {
return null;
}
const handleImageClick = (imgUrl: string) => {
const index = imageUrls.findIndex((url) => url === imgUrl);
const mimeType = imageAttachments[index]?.type;
setPreviewImage({ open: true, urls: imageUrls, index, mimeType });
};
const sections = [visual.length > 0, audio.length > 0, docs.length > 0];
const sectionCount = sections.filter(Boolean).length;
return (
<>
{visual.length > 0 &&
}
{visual.length > 0 && sectionCount > 1 &&
}
{audio.length > 0 &&
}
{audio.length > 0 && docs.length > 0 &&
}
{docs.length > 0 &&
}
setPreviewImage((prev) => ({ ...prev, open }))}
imgUrls={previewImage.urls}
initialIndex={previewImage.index}
/>
>
);
};
export default AttachmentList;
================================================
FILE: web/src/components/MemoView/components/metadata/LocationDisplay.tsx
================================================
import { LatLng } from "leaflet";
import { MapPinIcon } from "lucide-react";
import { useState } from "react";
import { LocationPicker } from "@/components/map";
import { cn } from "@/lib/utils";
import type { Location } from "@/types/proto/api/v1/memo_service_pb";
import { Popover, PopoverContent, PopoverTrigger } from "../../../ui/popover";
interface LocationDisplayProps {
location?: Location;
className?: string;
}
const LocationDisplay = ({ location, className }: LocationDisplayProps) => {
const [popoverOpen, setPopoverOpen] = useState(false);
if (!location) {
return null;
}
const displayText = location.placeholder || `Position: [${location.latitude}, ${location.longitude}]`;
return (
setPopoverOpen(true)}
>
[{location.latitude.toFixed(2)}°, {location.longitude.toFixed(2)}°]
{displayText}
);
};
export default LocationDisplay;
================================================
FILE: web/src/components/MemoView/components/metadata/RelationCard.tsx
================================================
import type { MemoRelation_Memo } from "@/types/proto/api/v1/memo_service_pb";
import MemoSnippetLink from "../MemoSnippetLink";
interface RelationCardProps {
memo: MemoRelation_Memo;
parentPage?: string;
className?: string;
}
const RelationCard = ({ memo, parentPage, className }: RelationCardProps) => {
return (
);
};
export default RelationCard;
================================================
FILE: web/src/components/MemoView/components/metadata/RelationList.tsx
================================================
import { LinkIcon, MilestoneIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import RelationCard from "./RelationCard";
import SectionHeader from "./SectionHeader";
interface RelationListProps {
relations: MemoRelation[];
currentMemoName?: string;
parentPage?: string;
className?: string;
}
function RelationList({ relations, currentMemoName, parentPage, className }: RelationListProps) {
const t = useTranslate();
const [activeTab, setActiveTab] = useState<"referencing" | "referenced">("referencing");
const { referencingRelations, referencedRelations } = useMemo(() => {
return {
referencingRelations: relations.filter(
(r) => r.type === MemoRelation_Type.REFERENCE && r.memo?.name === currentMemoName && r.relatedMemo?.name !== currentMemoName,
),
referencedRelations: relations.filter(
(r) => r.type === MemoRelation_Type.REFERENCE && r.memo?.name !== currentMemoName && r.relatedMemo?.name === currentMemoName,
),
};
}, [relations, currentMemoName]);
if (referencingRelations.length === 0 && referencedRelations.length === 0) {
return null;
}
const hasBothTabs = referencingRelations.length > 0 && referencedRelations.length > 0;
const defaultTab = referencingRelations.length > 0 ? "referencing" : "referenced";
const tab = hasBothTabs ? activeTab : defaultTab;
const isReferencing = tab === "referencing";
const icon = isReferencing ? LinkIcon : MilestoneIcon;
const activeRelations = isReferencing ? referencingRelations : referencedRelations;
return (
setActiveTab("referencing"),
},
{
id: "referenced",
label: t("common.referenced-by"),
count: referencedRelations.length,
active: !isReferencing,
onClick: () => setActiveTab("referenced"),
},
]
: undefined
}
/>
{activeRelations.map((relation) => (
))}
);
}
export default RelationList;
================================================
FILE: web/src/components/MemoView/components/metadata/SectionHeader.tsx
================================================
import { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface SectionHeaderProps {
icon: LucideIcon;
title: string;
count: number;
tabs?: Array<{
id: string;
label: string;
count: number;
active: boolean;
onClick: () => void;
}>;
}
const SectionHeader = ({ icon: Icon, title, count, tabs }: SectionHeaderProps) => {
return (
{tabs && tabs.length > 1 ? (
{tabs.map((tab, idx) => (
{idx < tabs.length - 1 && /}
))}
) : (
{title} ({count})
)}
);
};
export default SectionHeader;
================================================
FILE: web/src/components/MemoView/components/metadata/index.ts
================================================
export { default as AttachmentCard } from "./AttachmentCard";
export { default as AttachmentList } from "./AttachmentList";
export { default as LocationDisplay } from "./LocationDisplay";
export { default as RelationCard } from "./RelationCard";
export { default as RelationList } from "./RelationList";
================================================
FILE: web/src/components/MemoView/constants.ts
================================================
export const MEMO_CARD_BASE_CLASSES =
"relative group flex flex-col justify-start items-start bg-card w-full px-4 py-3 mb-2 gap-2 text-card-foreground rounded-lg border border-border transition-colors";
export const RELATIVE_TIME_THRESHOLD_MS = 1000 * 60 * 60 * 24;
================================================
FILE: web/src/components/MemoView/hooks/index.ts
================================================
export { useImagePreview } from "./useImagePreview";
export { useMemoActions } from "./useMemoActions";
export { useMemoHandlers } from "./useMemoHandlers";
================================================
FILE: web/src/components/MemoView/hooks/useImagePreview.ts
================================================
import { useState } from "react";
export interface ImagePreviewState {
open: boolean;
urls: string[];
index: number;
}
export interface UseImagePreviewReturn {
previewState: ImagePreviewState;
openPreview: (url: string) => void;
setPreviewOpen: (open: boolean) => void;
}
export const useImagePreview = (): UseImagePreviewReturn => {
const [previewState, setPreviewState] = useState({ open: false, urls: [], index: 0 });
return {
previewState,
openPreview: (url: string) => setPreviewState({ open: true, urls: [url], index: 0 }),
setPreviewOpen: (open: boolean) => setPreviewState((prev) => ({ ...prev, open })),
};
};
================================================
FILE: web/src/components/MemoView/hooks/useMemoActions.ts
================================================
import { useUpdateMemo } from "@/hooks/useMemoQueries";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
export const useMemoActions = (memo: Memo) => {
const { mutateAsync: updateMemo } = useUpdateMemo();
const unpinMemo = async () => {
if (!memo.pinned) return;
await updateMemo({ update: { name: memo.name, pinned: false }, updateMask: ["pinned"] });
};
return { unpinMemo };
};
================================================
FILE: web/src/components/MemoView/hooks/useMemoHandlers.ts
================================================
import { useCallback } from "react";
import { useInstance } from "@/contexts/InstanceContext";
import useNavigateTo from "@/hooks/useNavigateTo";
interface UseMemoHandlersOptions {
memoName: string;
parentPage: string;
readonly: boolean;
openEditor: () => void;
openPreview: (url: string) => void;
}
export const useMemoHandlers = (options: UseMemoHandlersOptions) => {
const { memoName, parentPage, readonly, openEditor, openPreview } = options;
const navigateTo = useNavigateTo();
const { memoRelatedSetting } = useInstance();
const handleGotoMemoDetailPage = useCallback(() => {
navigateTo(`/${memoName}`, { state: { from: parentPage } });
}, [memoName, parentPage, navigateTo]);
const handleMemoContentClick = useCallback(
(e: React.MouseEvent) => {
const targetEl = e.target as HTMLElement;
if (targetEl.tagName === "IMG") {
const linkElement = targetEl.closest("a");
if (linkElement) return; // If image is inside a link, don't show preview
const imgUrl = targetEl.getAttribute("src");
if (imgUrl) openPreview(imgUrl);
}
},
[openPreview],
);
const handleMemoContentDoubleClick = useCallback(
(e: React.MouseEvent) => {
if (readonly) return;
if (memoRelatedSetting.enableDoubleClickEdit) {
e.preventDefault();
openEditor();
}
},
[readonly, openEditor, memoRelatedSetting.enableDoubleClickEdit],
);
return { handleGotoMemoDetailPage, handleMemoContentClick, handleMemoContentDoubleClick };
};
================================================
FILE: web/src/components/MemoView/index.ts
================================================
export { default, default as MemoView } from "./MemoView";
export type { MemoViewProps } from "./types";
================================================
FILE: web/src/components/MemoView/types.ts
================================================
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
export interface MemoViewProps {
memo: Memo;
compact?: boolean;
showCreator?: boolean;
showVisibility?: boolean;
showPinned?: boolean;
className?: string;
parentPage?: string;
}
export interface MemoHeaderProps {
showCreator?: boolean;
showVisibility?: boolean;
showPinned?: boolean;
onEdit: () => void;
onGotoDetail: () => void;
onUnpin: () => void;
}
export interface MemoBodyProps {
compact?: boolean;
onContentClick: (e: React.MouseEvent) => void;
onContentDoubleClick: (e: React.MouseEvent) => void;
onToggleNsfwVisibility: () => void;
}
================================================
FILE: web/src/components/MemosLogo.tsx
================================================
import { useInstance } from "@/contexts/InstanceContext";
import { cn } from "@/lib/utils";
import UserAvatar from "./UserAvatar";
interface Props {
className?: string;
collapsed?: boolean;
}
function MemosLogo(props: Props) {
const { collapsed } = props;
const { generalSetting: instanceGeneralSetting } = useInstance();
const title = instanceGeneralSetting.customProfile?.title || "Memos";
const avatarUrl = instanceGeneralSetting.customProfile?.logoUrl || "/full-logo.webp";
return (
);
}
export default MemosLogo;
================================================
FILE: web/src/components/MobileHeader.tsx
================================================
import useWindowScroll from "react-use/lib/useWindowScroll";
import useMediaQuery from "@/hooks/useMediaQuery";
import { cn } from "@/lib/utils";
import NavigationDrawer from "./NavigationDrawer";
interface Props {
className?: string;
children?: React.ReactNode;
}
const MobileHeader = (props: Props) => {
const { className, children } = props;
const { y: offsetTop } = useWindowScroll();
const md = useMediaQuery("md");
const sm = useMediaQuery("sm");
if (md) return null;
return (
0 && "shadow-md",
className,
)}
>
{!sm &&
}
{children}
);
};
export default MobileHeader;
================================================
FILE: web/src/components/Navigation.tsx
================================================
import { BellIcon, EarthIcon, LibraryIcon, PaperclipIcon, UserCircleIcon } from "lucide-react";
import { NavLink } from "react-router-dom";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useNotifications } from "@/hooks/useUserQueries";
import { cn } from "@/lib/utils";
import { Routes } from "@/router";
import { UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
import MemosLogo from "./MemosLogo";
import UserMenu from "./UserMenu";
interface NavLinkItem {
id: string;
path: string;
title: string;
icon: React.ReactNode;
}
interface Props {
collapsed?: boolean;
className?: string;
}
const Navigation = (props: Props) => {
const { collapsed, className } = props;
const t = useTranslate();
const currentUser = useCurrentUser();
const { data: notifications = [] } = useNotifications();
const homeNavLink: NavLinkItem = {
id: "header-memos",
path: Routes.ROOT,
title: t("common.memos"),
icon: ,
};
const exploreNavLink: NavLinkItem = {
id: "header-explore",
path: Routes.EXPLORE,
title: t("common.explore"),
icon: ,
};
const attachmentsNavLink: NavLinkItem = {
id: "header-attachments",
path: Routes.ATTACHMENTS,
title: t("common.attachments"),
icon: ,
};
const unreadCount = notifications.filter((n) => n.status === UserNotification_Status.UNREAD).length;
const inboxNavLink: NavLinkItem = {
id: "header-inbox",
path: Routes.INBOX,
title: t("common.inbox"),
icon: (
{unreadCount > 0 && (
{unreadCount > 99 ? "99+" : unreadCount}
)}
),
};
const signInNavLink: NavLinkItem = {
id: "header-auth",
path: Routes.AUTH,
title: t("common.sign-in"),
icon: ,
};
const navLinks: NavLinkItem[] = currentUser
? [homeNavLink, exploreNavLink, attachmentsNavLink, inboxNavLink]
: [exploreNavLink, signInNavLink];
return (
);
};
export default Navigation;
================================================
FILE: web/src/components/NavigationDrawer.tsx
================================================
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { useInstance } from "@/contexts/InstanceContext";
import Navigation from "./Navigation";
import UserAvatar from "./UserAvatar";
const NavigationDrawer = () => {
const location = useLocation();
const [open, setOpen] = useState(false);
const { generalSetting } = useInstance();
const title = generalSetting.customProfile?.title || "Memos";
const avatarUrl = generalSetting.customProfile?.logoUrl || "/full-logo.webp";
useEffect(() => {
setOpen(false);
}, [location.key]);
return (
);
};
export default NavigationDrawer;
================================================
FILE: web/src/components/PagedMemoList/PagedMemoList.tsx
================================================
import { useQueryClient } from "@tanstack/react-query";
import { ArrowUpIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { matchPath } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { userServiceClient } from "@/connect";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import { useInfiniteMemos } from "@/hooks/useMemoQueries";
import { userKeys } from "@/hooks/useUserQueries";
import { Routes } from "@/router";
import { State } from "@/types/proto/api/v1/common_pb";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import Empty from "../Empty";
import MemoEditor from "../MemoEditor";
import MemoFilters from "../MemoFilters";
import Skeleton from "../Skeleton";
interface Props {
renderer: (memo: Memo) => JSX.Element;
listSort?: (list: Memo[]) => Memo[];
state?: State;
orderBy?: string;
filter?: string;
pageSize?: number;
showCreator?: boolean;
enabled?: boolean;
}
function useAutoFetchWhenNotScrollable({
hasNextPage,
isFetchingNextPage,
memoCount,
onFetchNext,
}: {
hasNextPage: boolean | undefined;
isFetchingNextPage: boolean;
memoCount: number;
onFetchNext: () => Promise;
}) {
const autoFetchTimeoutRef = useRef(null);
const isPageScrollable = useCallback(() => {
const documentHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
return documentHeight > window.innerHeight + 100;
}, []);
const checkAndFetchIfNeeded = useCallback(async () => {
if (autoFetchTimeoutRef.current) {
clearTimeout(autoFetchTimeoutRef.current);
}
await new Promise((resolve) => setTimeout(resolve, 200));
const shouldFetch = !isPageScrollable() && hasNextPage && !isFetchingNextPage && memoCount > 0;
if (shouldFetch) {
await onFetchNext();
autoFetchTimeoutRef.current = window.setTimeout(() => {
void checkAndFetchIfNeeded();
}, 500);
}
}, [hasNextPage, isFetchingNextPage, memoCount, isPageScrollable, onFetchNext]);
useEffect(() => {
if (!isFetchingNextPage && memoCount > 0) {
void checkAndFetchIfNeeded();
}
}, [memoCount, isFetchingNextPage, checkAndFetchIfNeeded]);
useEffect(() => {
return () => {
if (autoFetchTimeoutRef.current) {
clearTimeout(autoFetchTimeoutRef.current);
}
};
}, []);
}
const PagedMemoList = (props: Props) => {
const t = useTranslate();
const queryClient = useQueryClient();
// Show memo editor only on the root route
const showMemoEditor = Boolean(matchPath(Routes.ROOT, window.location.pathname));
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteMemos(
{
state: props.state || State.NORMAL,
orderBy: props.orderBy || "display_time desc",
filter: props.filter,
pageSize: props.pageSize || DEFAULT_LIST_MEMOS_PAGE_SIZE,
},
{ enabled: props.enabled ?? true },
);
// Flatten pages into a single array of memos
const memos = useMemo(() => data?.pages.flatMap((page) => page.memos) || [], [data]);
// Apply custom sorting if provided, otherwise use memos directly
const sortedMemoList = useMemo(() => (props.listSort ? props.listSort(memos) : memos), [memos, props.listSort]);
// Prefetch creators when new data arrives to improve performance
useEffect(() => {
if (!data?.pages || !props.showCreator) return;
const lastPage = data.pages[data.pages.length - 1];
if (!lastPage?.memos) return;
const uniqueCreators = Array.from(new Set(lastPage.memos.map((memo) => memo.creator)));
for (const creator of uniqueCreators) {
void queryClient.prefetchQuery({
queryKey: userKeys.detail(creator),
queryFn: async () => {
const user = await userServiceClient.getUser({ name: creator });
return user;
},
staleTime: 1000 * 60 * 5,
});
}
}, [data?.pages, props.showCreator, queryClient]);
// Auto-fetch hook: fetches more content when page isn't scrollable
useAutoFetchWhenNotScrollable({
hasNextPage,
isFetchingNextPage,
memoCount: sortedMemoList.length,
onFetchNext: fetchNextPage,
});
// Infinite scroll: fetch more when user scrolls near bottom
useEffect(() => {
if (!hasNextPage) return;
const handleScroll = () => {
const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 300;
if (nearBottom && !isFetchingNextPage) {
fetchNextPage();
}
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const children = (
{/* Show skeleton loader during initial load */}
{isLoading ? (
) : (
<>
{showMemoEditor ?
: null}
{sortedMemoList.map((memo) => props.renderer(memo))}
{/* Loading indicator for pagination */}
{isFetchingNextPage &&
}
{/* Empty state or back-to-top button */}
{!isFetchingNextPage && (
<>
{!hasNextPage && sortedMemoList.length === 0 ? (
) : (
)}
>
)}
>
)}
);
return children;
};
const BackToTop = () => {
const t = useTranslate();
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const handleScroll = () => {
const shouldShow = window.scrollY > 400;
setIsVisible(shouldShow);
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: "smooth",
});
};
// Don't render if not visible
if (!isVisible) {
return null;
}
return (
);
};
export default PagedMemoList;
================================================
FILE: web/src/components/PagedMemoList/index.ts
================================================
import PagedMemoList from "./PagedMemoList";
export default PagedMemoList;
================================================
FILE: web/src/components/PasswordSignInForm.tsx
================================================
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { LoaderIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { setAccessToken } from "@/auth-state";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { authServiceClient } from "@/connect";
import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext";
import useLoading from "@/hooks/useLoading";
import useNavigateTo from "@/hooks/useNavigateTo";
import { handleError } from "@/lib/error";
import { useTranslate } from "@/utils/i18n";
function PasswordSignInForm() {
const t = useTranslate();
const navigateTo = useNavigateTo();
const { profile } = useInstance();
const { initialize } = useAuth();
const actionBtnLoadingState = useLoading(false);
const [username, setUsername] = useState(profile.demo ? "demo" : "");
const [password, setPassword] = useState(profile.demo ? "secret" : "");
const handleUsernameInputChanged = (e: React.ChangeEvent) => {
const text = e.target.value as string;
setUsername(text);
};
const handlePasswordInputChanged = (e: React.ChangeEvent) => {
const text = e.target.value as string;
setPassword(text);
};
const handleFormSubmit = (e: React.FormEvent) => {
e.preventDefault();
handleSignInButtonClick();
};
const handleSignInButtonClick = async () => {
if (username === "" || password === "") {
return;
}
if (actionBtnLoadingState.isLoading) {
return;
}
try {
actionBtnLoadingState.setLoading();
const response = await authServiceClient.signIn({
credentials: {
case: "passwordCredentials",
value: { username, password },
},
});
// Store access token from login response
if (response.accessToken) {
setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined);
}
await initialize();
navigateTo("/");
} catch (error: unknown) {
handleError(error, toast.error, {
fallbackMessage: "Failed to sign in.",
});
}
actionBtnLoadingState.setFinish();
};
return (
);
}
export default PasswordSignInForm;
================================================
FILE: web/src/components/PreviewImageDialog.tsx
================================================
import { X } from "lucide-react";
import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
imgUrls: string[];
initialIndex?: number;
}
function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: Props) {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
// Update current index when initialIndex prop changes
useEffect(() => {
setCurrentIndex(initialIndex);
}, [initialIndex]);
// Handle keyboard navigation
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!open) return;
switch (event.key) {
case "Escape":
onOpenChange(false);
break;
case "ArrowRight":
setCurrentIndex((prev) => Math.min(prev + 1, imgUrls.length - 1));
break;
case "ArrowLeft":
setCurrentIndex((prev) => Math.max(prev - 1, 0));
break;
default:
break;
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onOpenChange]);
const handleClose = () => {
onOpenChange(false);
};
const handleBackdropClick = (event: React.MouseEvent) => {
if (event.target === event.currentTarget) {
handleClose();
}
};
// Return early if no images provided
if (!imgUrls.length) return null;
// Ensure currentIndex is within bounds
const safeIndex = Math.max(0, Math.min(currentIndex, imgUrls.length - 1));
return (
);
}
export default PreviewImageDialog;
================================================
FILE: web/src/components/RequiredBadge.tsx
================================================
interface Props {
className?: string;
}
const RequiredBadge: React.FC = (props: Props) => {
const { className } = props;
return *;
};
export default RequiredBadge;
================================================
FILE: web/src/components/SearchBar.tsx
================================================
import { SearchIcon } from "lucide-react";
import { useRef, useState } from "react";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import MemoDisplaySettingMenu from "./MemoDisplaySettingMenu";
const SearchBar = () => {
const t = useTranslate();
const { addFilter } = useMemoFilterContext();
const [queryText, setQueryText] = useState("");
const inputRef = useRef(null);
const onTextChange = (event: React.FormEvent) => {
setQueryText(event.currentTarget.value);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
const trimmedText = queryText.trim();
if (trimmedText !== "") {
const words = trimmedText.split(/\s+/);
words.forEach((word) => {
addFilter({
factor: "contentSearch",
value: word,
});
});
setQueryText("");
}
}
};
return (
);
};
export default SearchBar;
================================================
FILE: web/src/components/Settings/AccessTokenSection.tsx
================================================
import { timestampDate } from "@bufbuild/protobuf/wkt";
import copy from "copy-to-clipboard";
import { PlusIcon, TrashIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Button } from "@/components/ui/button";
import { userServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useDialog } from "@/hooks/useDialog";
import { handleError } from "@/lib/error";
import { CreatePersonalAccessTokenResponse, PersonalAccessToken } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
import CreateAccessTokenDialog from "../CreateAccessTokenDialog";
import SettingTable from "./SettingTable";
const listAccessTokens = async (parent: string) => {
const { personalAccessTokens } = await userServiceClient.listPersonalAccessTokens({ parent });
return personalAccessTokens.sort(
(a, b) =>
((b.createdAt ? timestampDate(b.createdAt) : undefined)?.getTime() ?? 0) -
((a.createdAt ? timestampDate(a.createdAt) : undefined)?.getTime() ?? 0),
);
};
const AccessTokenSection = () => {
const t = useTranslate();
const currentUser = useCurrentUser();
const [personalAccessTokens, setPersonalAccessTokens] = useState([]);
const createTokenDialog = useDialog();
const [deleteTarget, setDeleteTarget] = useState(undefined);
useEffect(() => {
if (!currentUser?.name) return;
let canceled = false;
listAccessTokens(currentUser.name)
.then((tokens) => {
if (!canceled) {
setPersonalAccessTokens(tokens);
}
})
.catch((error: unknown) => {
if (!canceled) {
handleError(error, toast.error, { context: "List access tokens" });
}
});
return () => {
canceled = true;
};
}, [currentUser?.name]);
const handleCreateAccessTokenDialogConfirm = async (response: CreatePersonalAccessTokenResponse) => {
const tokens = await listAccessTokens(currentUser?.name ?? "");
setPersonalAccessTokens(tokens);
// Copy the token to clipboard - this is the only time it will be shown
if (response.token) {
copy(response.token);
toast.success(t("setting.access-token.access-token-copied-to-clipboard"));
}
toast.success(
t("setting.access-token.create-dialog.access-token-created", {
description: response.personalAccessToken?.description ?? "",
}),
);
};
const handleCreateToken = () => {
createTokenDialog.open();
};
const handleDeleteAccessToken = async (token: PersonalAccessToken) => {
setDeleteTarget(token);
};
const confirmDeleteAccessToken = async () => {
if (!deleteTarget) return;
const { name: tokenName, description } = deleteTarget;
await userServiceClient.deletePersonalAccessToken({ name: tokenName });
setPersonalAccessTokens((prev) => prev.filter((token) => token.name !== tokenName));
setDeleteTarget(undefined);
toast.success(t("setting.access-token.access-token-deleted", { description }));
};
return (
{t("setting.access-token.title")}
{t("setting.access-token.description")}
{token.description},
},
{
key: "createdAt",
header: t("setting.access-token.create-dialog.created-at"),
render: (_, token: PersonalAccessToken) => (token.createdAt ? timestampDate(token.createdAt) : undefined)?.toLocaleString(),
},
{
key: "expiresAt",
header: t("setting.access-token.create-dialog.expires-at"),
render: (_, token: PersonalAccessToken) =>
(token.expiresAt ? timestampDate(token.expiresAt) : undefined)?.toLocaleString() ??
t("setting.access-token.create-dialog.duration-never"),
},
{
key: "actions",
header: "",
className: "text-right",
render: (_, token: PersonalAccessToken) => (
),
},
]}
data={personalAccessTokens}
emptyMessage="No access tokens found"
getRowKey={(token) => token.name}
/>
{/* Create Access Token Dialog */}
!open && setDeleteTarget(undefined)}
title={deleteTarget ? t("setting.access-token.access-token-deletion", { description: deleteTarget.description }) : ""}
description={t("setting.access-token.access-token-deletion-description")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteAccessToken}
confirmVariant="destructive"
/>
);
};
export default AccessTokenSection;
================================================
FILE: web/src/components/Settings/InstanceSection.tsx
================================================
import { create } from "@bufbuild/protobuf";
import { isEqual } from "lodash-es";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { identityProviderServiceClient } from "@/connect";
import { useInstance } from "@/contexts/InstanceContext";
import useDialog from "@/hooks/useDialog";
import { handleError } from "@/lib/error";
import { IdentityProvider } from "@/types/proto/api/v1/idp_service_pb";
import {
InstanceSetting_GeneralSetting,
InstanceSetting_GeneralSettingSchema,
InstanceSetting_Key,
InstanceSettingSchema,
} from "@/types/proto/api/v1/instance_service_pb";
import { useTranslate } from "@/utils/i18n";
import UpdateCustomizedProfileDialog from "../UpdateCustomizedProfileDialog";
import SettingGroup from "./SettingGroup";
import SettingRow from "./SettingRow";
import SettingSection from "./SettingSection";
const InstanceSection = () => {
const t = useTranslate();
const customizeDialog = useDialog();
const { generalSetting: originalSetting, profile, updateSetting, fetchSetting } = useInstance();
const [instanceGeneralSetting, setInstanceGeneralSetting] = useState(originalSetting);
const [identityProviderList, setIdentityProviderList] = useState([]);
useEffect(() => {
setInstanceGeneralSetting({ ...instanceGeneralSetting, customProfile: originalSetting.customProfile });
}, [originalSetting]);
const handleUpdateCustomizedProfileButtonClick = () => {
customizeDialog.open();
};
const updatePartialSetting = (partial: Partial) => {
setInstanceGeneralSetting(
create(InstanceSetting_GeneralSettingSchema, {
...instanceGeneralSetting,
...partial,
}),
);
};
const handleSaveGeneralSetting = async () => {
try {
await updateSetting(
create(InstanceSettingSchema, {
name: `instance/settings/${InstanceSetting_Key[InstanceSetting_Key.GENERAL]}`,
value: {
case: "generalSetting",
value: instanceGeneralSetting,
},
}),
);
await fetchSetting(InstanceSetting_Key.GENERAL);
} catch (error: unknown) {
await handleError(error, toast.error, {
context: "Update general settings",
});
return;
}
toast.success(t("message.update-succeed"));
};
useEffect(() => {
fetchIdentityProviderList();
}, []);
const fetchIdentityProviderList = async () => {
const { identityProviders } = await identityProviderServiceClient.listIdentityProviders({});
setIdentityProviderList(identityProviders);
};
return (
updatePartialSetting({ disallowUserRegistration: checked })}
/>
updatePartialSetting({ disallowPasswordAuth: checked })}
/>
updatePartialSetting({ disallowChangeUsername: checked })}
/>
updatePartialSetting({ disallowChangeNickname: checked })}
/>
{
// Refresh instance settings if needed
toast.success("Profile updated successfully!");
}}
/>
);
};
export default InstanceSection;
================================================
FILE: web/src/components/Settings/MemberSection.tsx
================================================
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { sortBy } from "lodash-es";
import { MoreVerticalIcon, PlusIcon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Button } from "@/components/ui/button";
import { userServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useDialog } from "@/hooks/useDialog";
import { useDeleteUser, useListUsers } from "@/hooks/useUserQueries";
import { State } from "@/types/proto/api/v1/common_pb";
import { User, User_Role } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
import CreateUserDialog from "../CreateUserDialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
import SettingSection from "./SettingSection";
import SettingTable from "./SettingTable";
const MemberSection = () => {
const t = useTranslate();
const currentUser = useCurrentUser();
const { data: users = [], refetch: refetchUsers } = useListUsers();
const deleteUserMutation = useDeleteUser();
const createDialog = useDialog();
const editDialog = useDialog();
const [editingUser, setEditingUser] = useState();
const sortedUsers = sortBy(users, "id");
const [archiveTarget, setArchiveTarget] = useState(undefined);
const [deleteTarget, setDeleteTarget] = useState(undefined);
const stringifyUserRole = (role: User_Role) => {
if (role === User_Role.ADMIN) {
return t("setting.member.admin");
} else {
return t("setting.member.user");
}
};
const handleCreateUser = () => {
setEditingUser(undefined);
createDialog.open();
};
const handleEditUser = (user: User) => {
setEditingUser(user);
editDialog.open();
};
const handleArchiveUserClick = async (user: User) => {
setArchiveTarget(user);
};
const confirmArchiveUser = async () => {
if (!archiveTarget) return;
const username = archiveTarget.username;
await userServiceClient.updateUser({
user: {
name: archiveTarget.name,
state: State.ARCHIVED,
},
updateMask: create(FieldMaskSchema, { paths: ["state"] }),
});
setArchiveTarget(undefined);
toast.success(t("setting.member.archive-success", { username }));
await refetchUsers();
};
const handleRestoreUserClick = async (user: User) => {
const { username } = user;
await userServiceClient.updateUser({
user: {
name: user.name,
state: State.NORMAL,
},
updateMask: create(FieldMaskSchema, { paths: ["state"] }),
});
toast.success(t("setting.member.restore-success", { username }));
await refetchUsers();
};
const handleDeleteUserClick = async (user: User) => {
setDeleteTarget(user);
};
const confirmDeleteUser = async () => {
if (!deleteTarget) return;
const { username, name } = deleteTarget;
deleteUserMutation.mutate(name);
setDeleteTarget(undefined);
toast.success(t("setting.member.delete-success", { username }));
};
return (
{t("common.create")}
}
>
(
{user.username}
{user.state === State.ARCHIVED && (Archived)}
),
},
{
key: "role",
header: t("common.role"),
render: (_, user: User) => stringifyUserRole(user.role),
},
{
key: "displayName",
header: t("common.nickname"),
render: (_, user: User) => user.displayName,
},
{
key: "email",
header: t("common.email"),
render: (_, user: User) => user.email,
},
{
key: "actions",
header: "",
className: "text-right",
render: (_, user: User) =>
currentUser?.name === user.name ? (
{t("common.yourself")}
) : (
handleEditUser(user)}>{t("common.update")}
{user.state === State.NORMAL ? (
handleArchiveUserClick(user)}>{t("setting.member.archive-member")}
) : (
<>
handleRestoreUserClick(user)}>{t("common.restore")}
handleDeleteUserClick(user)} className="text-destructive focus:text-destructive">
{t("setting.member.delete-member")}
>
)}
),
},
]}
data={sortedUsers}
emptyMessage="No members found"
getRowKey={(user) => user.name}
/>
{/* Create User Dialog */}
{/* Edit User Dialog */}
!open && setArchiveTarget(undefined)}
title={archiveTarget ? t("setting.member.archive-warning", { username: archiveTarget.username }) : ""}
description={archiveTarget ? t("setting.member.archive-warning-description") : ""}
confirmLabel={t("common.confirm")}
cancelLabel={t("common.cancel")}
onConfirm={confirmArchiveUser}
confirmVariant="default"
/>
!open && setDeleteTarget(undefined)}
title={deleteTarget ? t("setting.member.delete-warning", { username: deleteTarget.username }) : ""}
description={deleteTarget ? t("setting.member.delete-warning-description") : ""}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteUser}
confirmVariant="destructive"
/>
);
};
export default MemberSection;
================================================
FILE: web/src/components/Settings/MemoRelatedSettings.tsx
================================================
import { create } from "@bufbuild/protobuf";
import { isEqual, uniq } from "lodash-es";
import { CheckIcon, X } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useInstance } from "@/contexts/InstanceContext";
import { handleError } from "@/lib/error";
import {
InstanceSetting_Key,
InstanceSetting_MemoRelatedSetting,
InstanceSetting_MemoRelatedSettingSchema,
InstanceSettingSchema,
} from "@/types/proto/api/v1/instance_service_pb";
import { useTranslate } from "@/utils/i18n";
import SettingGroup from "./SettingGroup";
import SettingRow from "./SettingRow";
import SettingSection from "./SettingSection";
const MemoRelatedSettings = () => {
const t = useTranslate();
const { memoRelatedSetting: originalSetting, updateSetting, fetchSetting } = useInstance();
const [memoRelatedSetting, setMemoRelatedSetting] = useState(originalSetting);
const [editingReaction, setEditingReaction] = useState("");
const updatePartialSetting = (partial: Partial) => {
const newInstanceMemoRelatedSetting = create(InstanceSetting_MemoRelatedSettingSchema, {
...memoRelatedSetting,
...partial,
});
setMemoRelatedSetting(newInstanceMemoRelatedSetting);
};
const upsertReaction = () => {
if (!editingReaction) {
return;
}
updatePartialSetting({ reactions: uniq([...memoRelatedSetting.reactions, editingReaction.trim()]) });
setEditingReaction("");
};
const handleUpdateSetting = async () => {
if (memoRelatedSetting.reactions.length === 0) {
toast.error("Reactions must not be empty.");
return;
}
try {
await updateSetting(
create(InstanceSettingSchema, {
name: `instance/settings/${InstanceSetting_Key[InstanceSetting_Key.MEMO_RELATED]}`,
value: {
case: "memoRelatedSetting",
value: memoRelatedSetting,
},
}),
);
await fetchSetting(InstanceSetting_Key.MEMO_RELATED);
toast.success(t("message.update-succeed"));
} catch (error: unknown) {
await handleError(error, toast.error, {
context: "Update memo-related settings",
});
}
};
return (
updatePartialSetting({ displayWithUpdateTime: checked })}
/>
updatePartialSetting({ enableDoubleClickEdit: checked })}
/>
updatePartialSetting({ contentLengthLimit: Number(event.target.value) })}
/>
{memoRelatedSetting.reactions.map((reactionType) => (
{reactionType}
updatePartialSetting({ reactions: memoRelatedSetting.reactions.filter((r) => r !== reactionType) })}
>
))}
setEditingReaction(event.target.value.trim())}
onKeyDown={(e) => e.key === "Enter" && upsertReaction()}
/>
);
};
export default MemoRelatedSettings;
================================================
FILE: web/src/components/Settings/MyAccountSection.tsx
================================================
import { MoreVerticalIcon, PenLineIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useDialog } from "@/hooks/useDialog";
import { useTranslate } from "@/utils/i18n";
import ChangeMemberPasswordDialog from "../ChangeMemberPasswordDialog";
import UpdateAccountDialog from "../UpdateAccountDialog";
import UserAvatar from "../UserAvatar";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
import AccessTokenSection from "./AccessTokenSection";
import SettingGroup from "./SettingGroup";
import SettingSection from "./SettingSection";
const MyAccountSection = () => {
const t = useTranslate();
const user = useCurrentUser();
const accountDialog = useDialog();
const passwordDialog = useDialog();
const handleEditAccount = () => {
accountDialog.open();
};
const handleChangePassword = () => {
passwordDialog.open();
};
return (
{user?.displayName}
@{user?.username}
{user?.description &&
{user?.description}
}
{t("setting.account.change-password")}
{/* Update Account Dialog */}
{/* Change Password Dialog */}
);
};
export default MyAccountSection;
================================================
FILE: web/src/components/Settings/PreferencesSection.tsx
================================================
import { create } from "@bufbuild/protobuf";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useAuth } from "@/contexts/AuthContext";
import { useUpdateUserGeneralSetting } from "@/hooks/useUserQueries";
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
import { UserSetting_GeneralSetting, UserSetting_GeneralSettingSchema } from "@/types/proto/api/v1/user_service_pb";
import { loadLocale, useTranslate } from "@/utils/i18n";
import { convertVisibilityFromString, convertVisibilityToString } from "@/utils/memo";
import { loadTheme } from "@/utils/theme";
import LocaleSelect from "../LocaleSelect";
import ThemeSelect from "../ThemeSelect";
import VisibilityIcon from "../VisibilityIcon";
import SettingGroup from "./SettingGroup";
import SettingRow from "./SettingRow";
import SettingSection from "./SettingSection";
import WebhookSection from "./WebhookSection";
const PreferencesSection = () => {
const t = useTranslate();
const { currentUser, userGeneralSetting: generalSetting, refetchSettings } = useAuth();
const { mutate: updateUserGeneralSetting } = useUpdateUserGeneralSetting(currentUser?.name);
const handleLocaleSelectChange = async (locale: Locale) => {
// Apply locale immediately for instant UI feedback and persist to localStorage
loadLocale(locale);
// Persist to user settings
updateUserGeneralSetting(
{ generalSetting: { locale }, updateMask: ["locale"] },
{
onSuccess: () => {
refetchSettings();
},
},
);
};
const handleDefaultMemoVisibilityChanged = (value: string) => {
updateUserGeneralSetting(
{ generalSetting: { memoVisibility: value }, updateMask: ["memo_visibility"] },
{
onSuccess: () => {
refetchSettings();
},
},
);
};
const handleThemeChange = async (theme: string) => {
// Apply theme immediately for instant UI feedback
loadTheme(theme);
// Persist to user settings
updateUserGeneralSetting(
{ generalSetting: { theme }, updateMask: ["theme"] },
{
onSuccess: () => {
refetchSettings();
},
},
);
};
// Provide default values if setting is not loaded yet
const setting: UserSetting_GeneralSetting =
generalSetting ||
create(UserSetting_GeneralSettingSchema, {
locale: "en",
memoVisibility: "PRIVATE",
theme: "system",
});
return (
);
};
export default PreferencesSection;
================================================
FILE: web/src/components/Settings/SSOSection.tsx
================================================
import { MoreVerticalIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { identityProviderServiceClient } from "@/connect";
import { handleError } from "@/lib/error";
import { IdentityProvider } from "@/types/proto/api/v1/idp_service_pb";
import { useTranslate } from "@/utils/i18n";
import CreateIdentityProviderDialog from "../CreateIdentityProviderDialog";
import LearnMore from "../LearnMore";
import SettingSection from "./SettingSection";
import SettingTable from "./SettingTable";
const SSOSection = () => {
const t = useTranslate();
const [identityProviderList, setIdentityProviderList] = useState([]);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [editingIdentityProvider, setEditingIdentityProvider] = useState();
const [deleteTarget, setDeleteTarget] = useState(undefined);
useEffect(() => {
fetchIdentityProviderList();
}, []);
const fetchIdentityProviderList = async () => {
const { identityProviders } = await identityProviderServiceClient.listIdentityProviders({});
setIdentityProviderList(identityProviders);
};
const handleDeleteIdentityProvider = async (identityProvider: IdentityProvider) => {
setDeleteTarget(identityProvider);
};
const confirmDeleteIdentityProvider = async () => {
if (!deleteTarget) return;
try {
await identityProviderServiceClient.deleteIdentityProvider({ name: deleteTarget.name });
} catch (error: unknown) {
handleError(error, toast.error, {
context: "Delete identity provider",
});
}
await fetchIdentityProviderList();
setDeleteTarget(undefined);
};
const handleCreateIdentityProvider = () => {
setEditingIdentityProvider(undefined);
setIsCreateDialogOpen(true);
};
const handleEditIdentityProvider = (identityProvider: IdentityProvider) => {
setEditingIdentityProvider(identityProvider);
setIsCreateDialogOpen(true);
};
const handleDialogSuccess = async () => {
await fetchIdentityProviderList();
setIsCreateDialogOpen(false);
setEditingIdentityProvider(undefined);
};
const handleDialogOpenChange = (open: boolean) => {
setIsCreateDialogOpen(open);
// Clear editing state when dialog is closed
if (!open) {
setEditingIdentityProvider(undefined);
}
};
return (
{t("setting.sso.sso-list")}
}
actions={
}
>
(
{provider.title}
({provider.type})
),
},
{
key: "actions",
header: "",
className: "text-right",
render: (_, provider: IdentityProvider) => (
handleEditIdentityProvider(provider)}>{t("common.edit")}
handleDeleteIdentityProvider(provider)}
className="text-destructive focus:text-destructive"
>
{t("common.delete")}
),
},
]}
data={identityProviderList}
emptyMessage={t("setting.sso.no-sso-found")}
getRowKey={(provider) => provider.name}
/>
!open && setDeleteTarget(undefined)}
title={deleteTarget ? t("setting.sso.confirm-delete", { name: deleteTarget.title }) : ""}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteIdentityProvider}
confirmVariant="destructive"
/>
);
};
export default SSOSection;
================================================
FILE: web/src/components/Settings/SectionMenuItem.tsx
================================================
import { LucideIcon } from "lucide-react";
import React from "react";
interface SettingMenuItemProps {
text: string;
icon: LucideIcon;
isSelected: boolean;
onClick: () => void;
}
const SectionMenuItem: React.FC = ({ text, icon: IconComponent, isSelected, onClick }) => {
return (
{text}
);
};
export default SectionMenuItem;
================================================
FILE: web/src/components/Settings/SettingGroup.tsx
================================================
import React from "react";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
interface SettingGroupProps {
title?: string;
description?: string;
children: React.ReactNode;
className?: string;
showSeparator?: boolean;
}
const SettingGroup: React.FC = ({ title, description, children, className, showSeparator = false }) => {
return (
<>
{showSeparator && }
{(title || description) && (
{title &&
{title}
}
{description &&
{description}
}
)}
{children}
>
);
};
export default SettingGroup;
================================================
FILE: web/src/components/Settings/SettingRow.tsx
================================================
import { HelpCircleIcon } from "lucide-react";
import React from "react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
interface SettingRowProps {
label: string;
description?: string;
tooltip?: string;
children: React.ReactNode;
className?: string;
vertical?: boolean;
}
const SettingRow: React.FC = ({ label, description, tooltip, children, className, vertical = false }) => {
return (
{label}
{tooltip && (
{tooltip}
)}
{description &&
{description}
}
{children}
);
};
export default SettingRow;
================================================
FILE: web/src/components/Settings/SettingSection.tsx
================================================
import React from "react";
import { cn } from "@/lib/utils";
interface SettingSectionProps {
title?: React.ReactNode;
description?: string;
children: React.ReactNode;
className?: string;
actions?: React.ReactNode;
}
const SettingSection: React.FC = ({ title, description, children, className, actions }) => {
return (
{(title || description || actions) && (
{title && (
{typeof title === "string" ?
{title}
: title}
)}
{description &&
{description}
}
{actions &&
{actions}
}
)}
{children}
);
};
export default SettingSection;
================================================
FILE: web/src/components/Settings/SettingTable.tsx
================================================
import React from "react";
import { cn } from "@/lib/utils";
interface SettingTableColumn> {
key: string;
header: string;
className?: string;
render?: (value: T[keyof T], row: T) => React.ReactNode;
}
interface SettingTableProps> {
columns: SettingTableColumn[];
data: T[];
emptyMessage?: string;
className?: string;
getRowKey?: (row: T, index: number) => string;
}
const SettingTable = >({
columns,
data,
emptyMessage = "No data",
className,
getRowKey,
}: SettingTableProps) => {
return (
{columns.map((column) => (
|
{column.header}
|
))}
{data.length === 0 ? (
|
{emptyMessage}
|
) : (
data.map((row, rowIndex) => {
const rowKey = getRowKey ? getRowKey(row, rowIndex) : rowIndex.toString();
return (
{columns.map((column) => {
const value = row[column.key as keyof T] as T[keyof T];
const content = column.render ? column.render(value, row) : (value as React.ReactNode);
return (
|
{content}
|
);
})}
);
})
)}
);
};
export default SettingTable;
================================================
FILE: web/src/components/Settings/StorageSection.tsx
================================================
import { create } from "@bufbuild/protobuf";
import { isEqual } from "lodash-es";
import React, { useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import { useInstance } from "@/contexts/InstanceContext";
import { handleError } from "@/lib/error";
import {
InstanceSetting_Key,
InstanceSetting_StorageSetting,
InstanceSetting_StorageSetting_S3Config,
InstanceSetting_StorageSetting_S3ConfigSchema,
InstanceSetting_StorageSetting_StorageType,
InstanceSetting_StorageSettingSchema,
InstanceSettingSchema,
} from "@/types/proto/api/v1/instance_service_pb";
import { useTranslate } from "@/utils/i18n";
import SettingGroup from "./SettingGroup";
import SettingRow from "./SettingRow";
import SettingSection from "./SettingSection";
const StorageSection = () => {
const t = useTranslate();
const { storageSetting: originalSetting, updateSetting, fetchSetting } = useInstance();
const [instanceStorageSetting, setInstanceStorageSetting] = useState(originalSetting);
useEffect(() => {
setInstanceStorageSetting(originalSetting);
}, [originalSetting]);
const allowSaveStorageSetting = useMemo(() => {
if (instanceStorageSetting.uploadSizeLimitMb <= 0) {
return false;
}
if (instanceStorageSetting.storageType === InstanceSetting_StorageSetting_StorageType.LOCAL) {
if (instanceStorageSetting.filepathTemplate.length === 0) {
return false;
}
} else if (instanceStorageSetting.storageType === InstanceSetting_StorageSetting_StorageType.S3) {
if (
instanceStorageSetting.s3Config?.accessKeyId.length === 0 ||
instanceStorageSetting.s3Config?.accessKeySecret.length === 0 ||
instanceStorageSetting.s3Config?.endpoint.length === 0 ||
instanceStorageSetting.s3Config?.region.length === 0 ||
instanceStorageSetting.s3Config?.bucket.length === 0
) {
return false;
}
}
return !isEqual(originalSetting, instanceStorageSetting);
}, [instanceStorageSetting, originalSetting]);
const handleMaxUploadSizeChanged = async (event: React.FocusEvent) => {
let num = parseInt(event.target.value);
if (Number.isNaN(num)) {
num = 0;
}
const update = create(InstanceSetting_StorageSettingSchema, {
...instanceStorageSetting,
uploadSizeLimitMb: BigInt(num),
});
setInstanceStorageSetting(update);
};
const handleFilepathTemplateChanged = async (event: React.FocusEvent) => {
const update = create(InstanceSetting_StorageSettingSchema, {
...instanceStorageSetting,
filepathTemplate: event.target.value,
});
setInstanceStorageSetting(update);
};
const handlePartialS3ConfigChanged = async (s3Config: Partial) => {
const existingS3Config = instanceStorageSetting.s3Config;
const s3ConfigInit = {
accessKeyId: existingS3Config?.accessKeyId ?? "",
accessKeySecret: existingS3Config?.accessKeySecret ?? "",
endpoint: existingS3Config?.endpoint ?? "",
region: existingS3Config?.region ?? "",
bucket: existingS3Config?.bucket ?? "",
usePathStyle: existingS3Config?.usePathStyle ?? false,
...s3Config,
};
const update = create(InstanceSetting_StorageSettingSchema, {
storageType: instanceStorageSetting.storageType,
filepathTemplate: instanceStorageSetting.filepathTemplate,
uploadSizeLimitMb: instanceStorageSetting.uploadSizeLimitMb,
s3Config: create(InstanceSetting_StorageSetting_S3ConfigSchema, s3ConfigInit),
});
setInstanceStorageSetting(update);
};
const handleS3ConfigAccessKeyIdChanged = async (event: React.FocusEvent) => {
handlePartialS3ConfigChanged({ accessKeyId: event.target.value });
};
const handleS3ConfigAccessKeySecretChanged = async (event: React.FocusEvent) => {
handlePartialS3ConfigChanged({ accessKeySecret: event.target.value });
};
const handleS3ConfigEndpointChanged = async (event: React.FocusEvent) => {
handlePartialS3ConfigChanged({ endpoint: event.target.value });
};
const handleS3ConfigRegionChanged = async (event: React.FocusEvent) => {
handlePartialS3ConfigChanged({ region: event.target.value });
};
const handleS3ConfigBucketChanged = async (event: React.FocusEvent) => {
handlePartialS3ConfigChanged({ bucket: event.target.value });
};
const handleS3ConfigUsePathStyleChanged = (event: React.ChangeEvent) => {
handlePartialS3ConfigChanged({
usePathStyle: event.target.checked,
});
};
const handleStorageTypeChanged = async (storageType: InstanceSetting_StorageSetting_StorageType) => {
const update = create(InstanceSetting_StorageSettingSchema, {
...instanceStorageSetting,
storageType: storageType,
});
setInstanceStorageSetting(update);
};
const saveInstanceStorageSetting = async () => {
try {
await updateSetting(
create(InstanceSettingSchema, {
name: `instance/settings/${InstanceSetting_Key[InstanceSetting_Key.STORAGE]}`,
value: {
case: "storageSetting",
value: instanceStorageSetting,
},
}),
);
await fetchSetting(InstanceSetting_Key.STORAGE);
toast.success("Updated");
} catch (error: unknown) {
handleError(error, toast.error, {
context: "Update storage settings",
});
}
};
return (
{
handleStorageTypeChanged(Number(value) as InstanceSetting_StorageSetting_StorageType);
}}
className="flex flex-row gap-4"
>
{instanceStorageSetting.storageType !== InstanceSetting_StorageSetting_StorageType.DATABASE && (
)}
{instanceStorageSetting.storageType === InstanceSetting_StorageSetting_StorageType.S3 && (
handleS3ConfigUsePathStyleChanged({ target: { checked } } as React.ChangeEvent & {
target: { checked: boolean };
})
}
/>
)}
);
};
export default StorageSection;
================================================
FILE: web/src/components/Settings/WebhookSection.tsx
================================================
import { ExternalLinkIcon, PlusIcon, TrashIcon } from "lucide-react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { Link } from "react-router-dom";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Button } from "@/components/ui/button";
import { userServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser";
import { UserWebhook } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
import CreateWebhookDialog from "../CreateWebhookDialog";
import SettingTable from "./SettingTable";
const WebhookSection = () => {
const t = useTranslate();
const currentUser = useCurrentUser();
const [webhooks, setWebhooks] = useState([]);
const [isCreateWebhookDialogOpen, setIsCreateWebhookDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(undefined);
const listWebhooks = async () => {
if (!currentUser) return [];
const { webhooks } = await userServiceClient.listUserWebhooks({
parent: currentUser.name,
});
return webhooks;
};
useEffect(() => {
listWebhooks().then((webhooks) => {
setWebhooks(webhooks);
});
}, [currentUser]);
const handleCreateWebhookDialogConfirm = async () => {
const webhooks = await listWebhooks();
const name = webhooks[webhooks.length - 1]?.displayName || "";
setWebhooks(webhooks);
setIsCreateWebhookDialogOpen(false);
toast.success(t("setting.webhook.create-dialog.create-webhook-success", { name }));
};
const handleDeleteWebhook = async (webhook: UserWebhook) => {
setDeleteTarget(webhook);
};
const confirmDeleteWebhook = async () => {
if (!deleteTarget) return;
await userServiceClient.deleteUserWebhook({ name: deleteTarget.name });
setWebhooks(webhooks.filter((item) => item.name !== deleteTarget.name));
setDeleteTarget(undefined);
toast.success(t("setting.webhook.delete-dialog.delete-webhook-success", { name: deleteTarget.displayName }));
};
return (
{t("setting.webhook.title")}
{webhook.displayName},
},
{
key: "url",
header: t("setting.webhook.url"),
render: (_, webhook: UserWebhook) => (
{webhook.url}
),
},
{
key: "actions",
header: "",
className: "text-right",
render: (_, webhook: UserWebhook) => (
),
},
]}
data={webhooks}
emptyMessage={t("setting.webhook.no-webhooks-found")}
getRowKey={(webhook) => webhook.name}
/>
{t("common.learn-more")}
!open && setDeleteTarget(undefined)}
title={t("setting.webhook.delete-dialog.delete-webhook-title", { name: deleteTarget?.displayName || "" })}
description={t("setting.webhook.delete-dialog.delete-webhook-description")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteWebhook}
confirmVariant="destructive"
/>
);
};
export default WebhookSection;
================================================
FILE: web/src/components/Skeleton.tsx
================================================
import { cn } from "@/lib/utils";
interface SkeletonProps {
showCreator?: boolean;
count?: number;
}
const skeletonBase = "bg-muted/70 rounded animate-pulse";
const MemoCardSkeleton = ({ showCreator, index }: { showCreator?: boolean; index: number }) => (
{showCreator ? (
) : (
)}
);
/**
* Memo list loading skeleton - shows card structure while loading.
* Only use for memo lists in PagedMemoList component.
*/
const Skeleton = ({ showCreator = false, count = 4 }: SkeletonProps) => (
{Array.from({ length: count }, (_, i) => (
))}
);
export default Skeleton;
================================================
FILE: web/src/components/StatisticsView/MonthNavigator.tsx
================================================
import dayjs from "dayjs";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { memo, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { YearCalendar } from "@/components/ActivityCalendar";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { addMonths, formatMonth, getMonthFromDate, getYearFromDate, setYearAndMonth } from "@/lib/calendar-utils";
import type { MonthNavigatorProps } from "@/types/statistics";
export const MonthNavigator = memo(({ visibleMonth, onMonthChange, activityStats }: MonthNavigatorProps) => {
const { i18n } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const { currentMonth, currentYear, currentMonthNum } = useMemo(
() => ({
currentMonth: dayjs(visibleMonth).toDate(),
currentYear: getYearFromDate(visibleMonth),
currentMonthNum: getMonthFromDate(visibleMonth),
}),
[visibleMonth],
);
const monthLabel = useMemo(
() => currentMonth.toLocaleString(i18n.language, { year: "numeric", month: "long" }),
[currentMonth, i18n.language],
);
const handlePrevMonth = useCallback(() => onMonthChange(addMonths(visibleMonth, -1)), [visibleMonth, onMonthChange]);
const handleNextMonth = useCallback(() => onMonthChange(addMonths(visibleMonth, 1)), [visibleMonth, onMonthChange]);
const handleDateClick = useCallback(
(date: string) => {
onMonthChange(formatMonth(date));
setIsOpen(false);
},
[onMonthChange],
);
const handleYearChange = useCallback(
(year: number) => onMonthChange(setYearAndMonth(year, currentMonthNum)),
[currentMonthNum, onMonthChange],
);
return (
);
});
MonthNavigator.displayName = "MonthNavigator";
================================================
FILE: web/src/components/StatisticsView/StatisticsView.tsx
================================================
import dayjs from "dayjs";
import { useMemo, useState } from "react";
import { MonthCalendar } from "@/components/ActivityCalendar";
import { useDateFilterNavigation } from "@/hooks";
import type { StatisticsData } from "@/types/statistics";
import { MonthNavigator } from "./MonthNavigator";
interface Props {
statisticsData: StatisticsData;
}
const StatisticsView = (props: Props) => {
const { statisticsData } = props;
const { activityStats } = statisticsData;
const navigateToDateFilter = useDateFilterNavigation();
const [visibleMonthString, setVisibleMonthString] = useState(dayjs().format("YYYY-MM"));
const maxCount = useMemo(() => {
const counts = Object.values(activityStats);
return Math.max(...counts, 1);
}, [activityStats]);
return (
);
};
export default StatisticsView;
================================================
FILE: web/src/components/StatisticsView/index.ts
================================================
export { default } from "./StatisticsView";
================================================
FILE: web/src/components/TagTree.tsx
================================================
import { ChevronRightIcon, HashIcon } from "lucide-react";
import { useEffect, useState } from "react";
import useToggle from "react-use/lib/useToggle";
import { type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
interface Tag {
key: string;
text: string;
amount: number;
subTags: Tag[];
}
interface Props {
tagAmounts: [tag: string, amount: number][];
expandSubTags: boolean;
}
const TagTree = ({ tagAmounts: rawTagAmounts, expandSubTags }: Props) => {
const [tags, setTags] = useState([]);
useEffect(() => {
const sortedTagAmounts = Array.from(rawTagAmounts).sort();
const root: Tag = {
key: "",
text: "",
amount: 0,
subTags: [],
};
for (const tagAmount of sortedTagAmounts) {
const subtags = tagAmount[0].split("/");
let tempObj = root;
let tagText = "";
for (let i = 0; i < subtags.length; i++) {
const key = subtags[i];
let amount: number = 0;
if (i === 0) {
tagText += key;
} else {
tagText += "/" + key;
}
if (sortedTagAmounts.some(([tag, amount]) => tag === tagText && amount > 1)) {
amount = tagAmount[1];
}
let obj = null;
for (const t of tempObj.subTags) {
if (t.text === tagText) {
obj = t;
break;
}
}
if (!obj) {
obj = {
key,
text: tagText,
amount: amount,
subTags: [],
};
tempObj.subTags.push(obj);
}
tempObj = obj;
}
}
setTags(root.subTags as Tag[]);
}, [rawTagAmounts]);
return (
{tags.map((t, idx) => (
))}
);
};
interface TagItemContainerProps {
tag: Tag;
expandSubTags: boolean;
}
const TagItemContainer = (props: TagItemContainerProps) => {
const { tag, expandSubTags } = props;
const { getFiltersByFactor, addFilter, removeFilter } = useMemoFilterContext();
const tagFilters = getFiltersByFactor("tagSearch");
const isActive = tagFilters.some((f: MemoFilter) => f.value === tag.text);
const hasSubTags = tag.subTags.length > 0;
const [showSubTags, toggleSubTags] = useToggle(false);
useEffect(() => {
toggleSubTags(expandSubTags);
}, [expandSubTags]);
const handleTagClick = () => {
if (isActive) {
removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag.text);
} else {
// Remove all existing tag filters first, then add the new one
removeFilter((f: MemoFilter) => f.factor === "tagSearch");
addFilter({
factor: "tagSearch",
value: tag.text,
});
}
};
const handleToggleBtnClick = (event: React.MouseEvent) => {
event.stopPropagation();
toggleSubTags();
};
return (
<>
{tag.key} {tag.amount > 1 && ({tag.amount})}
{hasSubTags ? (
) : null}
{hasSubTags ? (
{tag.subTags.map((st, idx) => (
))}
) : null}
>
);
};
export default TagTree;
================================================
FILE: web/src/components/ThemeSelect.tsx
================================================
import { Monitor, Moon, Palette, Sun } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { loadTheme, THEME_OPTIONS } from "@/utils/theme";
interface ThemeSelectProps {
value?: string;
onValueChange?: (theme: string) => void;
className?: string;
}
const THEME_ICONS: Record = {
system: ,
default: ,
"default-dark": ,
paper: ,
};
const ThemeSelect = ({ value, onValueChange, className }: ThemeSelectProps = {}) => {
const currentTheme = value || "system";
const handleThemeChange = (newTheme: string) => {
// Apply theme globally immediately
loadTheme(newTheme);
// Also notify parent component if callback is provided
if (onValueChange) {
onValueChange(newTheme);
}
};
return (
);
};
export default ThemeSelect;
================================================
FILE: web/src/components/UpdateAccountDialog.tsx
================================================
import { isEqual } from "lodash-es";
import { XIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useInstance } from "@/contexts/InstanceContext";
import { convertFileToBase64 } from "@/helpers/utils";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useUpdateUser } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
import { useTranslate } from "@/utils/i18n";
import UserAvatar from "./UserAvatar";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
interface State {
avatarUrl: string;
username: string;
displayName: string;
email: string;
description: string;
}
function UpdateAccountDialog({ open, onOpenChange, onSuccess }: Props) {
const t = useTranslate();
const currentUser = useCurrentUser();
const { generalSetting: instanceGeneralSetting } = useInstance();
const { mutateAsync: updateUser } = useUpdateUser();
const [state, setState] = useState({
avatarUrl: currentUser?.avatarUrl ?? "",
username: currentUser?.username ?? "",
displayName: currentUser?.displayName ?? "",
email: currentUser?.email ?? "",
description: currentUser?.description ?? "",
});
const handleCloseBtnClick = () => {
onOpenChange(false);
};
const setPartialState = (partialState: Partial) => {
setState((state) => {
return {
...state,
...partialState,
};
});
};
const handleAvatarChanged = async (e: React.ChangeEvent) => {
const files = e.target.files;
if (files && files.length > 0) {
const image = files[0];
if (image.size > 2 * 1024 * 1024) {
toast.error("Max file size is 2MB");
return;
}
try {
const base64 = await convertFileToBase64(image);
setPartialState({
avatarUrl: base64,
});
} catch (error) {
console.error(error);
toast.error(`Failed to convert image to base64`);
}
}
};
const handleDisplayNameChanged = (e: React.ChangeEvent) => {
setPartialState({
displayName: e.target.value as string,
});
};
const handleUsernameChanged = (e: React.ChangeEvent) => {
setPartialState({
username: e.target.value as string,
});
};
const handleEmailChanged = (e: React.ChangeEvent) => {
setState((state) => {
return {
...state,
email: e.target.value as string,
};
});
};
const handleDescriptionChanged = (e: React.ChangeEvent) => {
setState((state) => {
return {
...state,
description: e.target.value as string,
};
});
};
const handleSaveBtnClick = async () => {
if (state.username === "") {
toast.error(t("message.fill-all"));
return;
}
try {
const updateMask = [];
if (!isEqual(currentUser?.username, state.username)) {
updateMask.push("username");
}
if (!isEqual(currentUser?.displayName, state.displayName)) {
updateMask.push("display_name");
}
if (!isEqual(currentUser?.email, state.email)) {
updateMask.push("email");
}
if (!isEqual(currentUser?.avatarUrl, state.avatarUrl)) {
updateMask.push("avatar_url");
}
if (!isEqual(currentUser?.description, state.description)) {
updateMask.push("description");
}
await updateUser({
user: {
name: currentUser?.name,
username: state.username,
displayName: state.displayName,
email: state.email,
avatarUrl: state.avatarUrl,
description: state.description,
},
updateMask,
});
toast.success(t("message.update-succeed"));
onSuccess?.();
onOpenChange(false);
} catch (error: unknown) {
await handleError(error, toast.error, {
context: "Update account",
});
}
};
return (