init commit

This commit is contained in:
2025-11-30 13:01:24 -05:00
parent f4596a372d
commit 29355260ed
607 changed files with 136371 additions and 234 deletions

7
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules
.pnpm-store
.DS_Store
dist
dist-ssr
*.local
src/types/proto/store

8
web/.prettierrc.js Normal file
View File

@@ -0,0 +1,8 @@
module.exports = {
printWidth: 140,
useTabs: false,
semi: true,
singleQuote: false,
plugins: [require.resolve("@trivago/prettier-plugin-sort-imports")],
importOrder: ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "^@/((?!css).+)", "^[./]", "^(.+).css"],
};

1
web/README.md Normal file
View File

@@ -0,0 +1 @@
# The frontend of Memos

21
web/components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

34
web/eslint.config.mjs Normal file
View File

@@ -0,0 +1,34 @@
import eslint from "@eslint/js";
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
import tseslint from "typescript-eslint";
export default [
...tseslint.config(eslint.configs.recommended, tseslint.configs.recommended),
eslintPluginPrettierRecommended,
{
ignores: ["**/dist/**", "**/node_modules/**", "**/proto/**"],
},
{
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": ["off"],
"react/react-in-jsx-scope": "off",
"react/jsx-no-target-blank": "off",
"no-restricted-syntax": [
"error",
{
selector:
"VariableDeclarator[init.callee.name='useTranslation'] > ObjectPattern > Property[key.name='t']:not([parent.declarations.0.init.callee.object.name='i18n'])",
message: "Destructuring 't' from useTranslation is not allowed. Please use the 'useTranslate' hook from '@/utils/i18n'.",
},
],
},
},
{
files: ["src/utils/i18n.ts"],
rules: {
"no-restricted-syntax": "off",
},
},
];

19
web/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/webp" href="/logo.webp" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f4f4f5" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#18181b" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<!-- memos.metadata.head -->
<title>Memos</title>
</head>
<body class="text-base w-full min-h-svh">
<div id="root" class="relative w-full min-h-full"></div>
<script type="module" src="/src/main.tsx"></script>
<!-- memos.metadata.body -->
</body>
</html>

9947
web/package-lock.json generated Normal file
View File

File diff suppressed because it is too large Load Diff

93
web/package.json Normal file
View File

@@ -0,0 +1,93 @@
{
"name": "memos",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"release": "vite build --mode release --outDir=../server/router/frontend/dist --emptyOutDir",
"lint": "tsc --noEmit --skipLibCheck && eslint --ext .js,.ts,.tsx, src"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@github/relative-time-element": "^4.4.8",
"@matejmazur/react-katex": "^3.1.3",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.11",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"copy-to-clipboard": "^3.3.3",
"dayjs": "^1.11.13",
"fuse.js": "^7.1.0",
"highlight.js": "^11.11.1",
"i18next": "^25.3.0",
"katex": "^0.16.22",
"leaflet": "^1.9.4",
"lodash-es": "^4.17.21",
"lucide-react": "^0.486.0",
"mermaid": "^11.8.0",
"mobx": "^6.13.7",
"mobx-react-lite": "^4.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-force-graph-2d": "^1.28.0",
"react-hot-toast": "^2.5.2",
"react-i18next": "^15.5.3",
"react-leaflet": "^4.2.1",
"react-router-dom": "^7.6.3",
"react-simple-pull-to-refresh": "^1.3.3",
"react-use": "^17.6.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"textarea-caret": "^3.1.0",
"uuid": "^11.1.0"
},
"devDependencies": {
"@bufbuild/protobuf": "^2.6.0",
"@eslint/js": "^9.30.1",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/d3": "^7.4.3",
"@types/katex": "^0.16.7",
"@types/leaflet": "^1.9.19",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.0.10",
"@types/qs": "^6.14.0",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@types/textarea-caret": "^3.0.4",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.6.0",
"code-inspector-plugin": "^0.18.3",
"eslint": "^9.30.1",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.5.1",
"eslint-plugin-react": "^7.37.5",
"long": "^5.3.2",
"nice-grpc-web": "^3.3.7",
"prettier": "^3.6.2",
"terser": "^5.43.1",
"tw-animate-css": "^1.3.4",
"typescript": "^5.8.3",
"typescript-eslint": "^8.35.1",
"vite": "^7.0.1"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
}
}

7056
web/pnpm-lock.yaml generated Normal file
View File

File diff suppressed because it is too large Load Diff

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
web/public/full-logo.webp Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
web/public/logo.webp Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1,10 @@
{
"name": "Memos",
"short_name": "Memos",
"icons": [
{ "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
],
"display": "standalone",
"start_url": "/"
}

117
web/src/App.tsx Normal file
View File

@@ -0,0 +1,117 @@
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Outlet } from "react-router-dom";
import { getSystemColorScheme } from "./helpers/utils";
import useNavigateTo from "./hooks/useNavigateTo";
import { userStore, workspaceStore } from "./store";
import { loadTheme } from "./utils/theme";
const App = observer(() => {
const { i18n } = useTranslation();
const navigateTo = useNavigateTo();
const [mode, setMode] = useState<"light" | "dark">("light");
const workspaceProfile = workspaceStore.state.profile;
const userSetting = userStore.state.userSetting;
const workspaceGeneralSetting = workspaceStore.state.generalSetting;
// Redirect to sign up page if no instance owner.
useEffect(() => {
if (!workspaceProfile.owner) {
navigateTo("/auth/signup");
}
}, [workspaceProfile.owner]);
useEffect(() => {
const darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleColorSchemeChange = (e: MediaQueryListEvent) => {
const mode = e.matches ? "dark" : "light";
setMode(mode);
};
try {
darkMediaQuery.addEventListener("change", handleColorSchemeChange);
} catch (error) {
console.error("failed to initial color scheme listener", error);
}
}, []);
useEffect(() => {
if (workspaceGeneralSetting.additionalStyle) {
const styleEl = document.createElement("style");
styleEl.innerHTML = workspaceGeneralSetting.additionalStyle;
styleEl.setAttribute("type", "text/css");
document.body.insertAdjacentElement("beforeend", styleEl);
}
}, [workspaceGeneralSetting.additionalStyle]);
useEffect(() => {
if (workspaceGeneralSetting.additionalScript) {
const scriptEl = document.createElement("script");
scriptEl.innerHTML = workspaceGeneralSetting.additionalScript;
document.head.appendChild(scriptEl);
}
}, [workspaceGeneralSetting.additionalScript]);
// Dynamic update metadata with customized profile.
useEffect(() => {
if (!workspaceGeneralSetting.customProfile) {
return;
}
document.title = workspaceGeneralSetting.customProfile.title;
const link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
link.href = workspaceGeneralSetting.customProfile.logoUrl || "/logo.webp";
}, [workspaceGeneralSetting.customProfile]);
useEffect(() => {
const currentLocale = workspaceStore.state.locale;
// This will trigger re-rendering of the whole app.
i18n.changeLanguage(currentLocale);
document.documentElement.setAttribute("lang", currentLocale);
if (["ar", "fa"].includes(currentLocale)) {
document.documentElement.setAttribute("dir", "rtl");
} else {
document.documentElement.setAttribute("dir", "ltr");
}
}, [workspaceStore.state.locale]);
useEffect(() => {
let currentAppearance = workspaceStore.state.appearance as Appearance;
if (currentAppearance === "system") {
currentAppearance = getSystemColorScheme();
}
setMode(currentAppearance);
}, [workspaceStore.state.appearance]);
useEffect(() => {
const root = document.documentElement;
if (mode === "light") {
root.classList.remove("dark");
} else if (mode === "dark") {
root.classList.add("dark");
}
}, [mode]);
useEffect(() => {
if (!userSetting) {
return;
}
workspaceStore.state.setPartial({
locale: userSetting.locale || workspaceStore.state.locale,
appearance: userSetting.appearance || workspaceStore.state.appearance,
});
}, [userSetting?.locale, userSetting?.appearance]);
// Load theme when user setting changes (user theme is already backfilled with workspace theme)
useEffect(() => {
if (userSetting?.theme) {
loadTheme(userSetting.theme);
}
}, [userSetting?.theme]);
return <Outlet />;
});
export default App;

View File

@@ -0,0 +1,180 @@
import dayjs from "dayjs";
import { observer } from "mobx-react-lite";
import { memo, useMemo } from "react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { workspaceStore } from "@/store";
import type { ActivityCalendarProps, CalendarDay } from "@/types/statistics";
import { useTranslate } from "@/utils/i18n";
const getCellOpacity = (ratio: number): string => {
if (ratio === 0) return "";
if (ratio > 0.75) return "bg-destructive text-destructive-foreground";
if (ratio > 0.5) return "bg-destructive/70 text-destructive-foreground";
if (ratio > 0.25) return "bg-destructive/50 text-destructive-foreground";
return "bg-destructive/30 text-destructive-foreground";
};
const CalendarCell = memo(
({
dayInfo,
count,
maxCount,
isToday,
isSelected,
onClick,
tooltipText,
}: {
dayInfo: CalendarDay;
count: number;
maxCount: number;
isToday: boolean;
isSelected: boolean;
onClick?: () => void;
tooltipText: string;
}) => {
const cellContent = (
<div
className={cn(
"w-6 h-6 text-xs lg:text-[13px] flex justify-center items-center cursor-default",
"rounded-lg border-2 text-muted-foreground transition-all duration-200",
dayInfo.isCurrentMonth && getCellOpacity(count / maxCount),
dayInfo.isCurrentMonth && isToday && "border-border",
dayInfo.isCurrentMonth && isSelected && "font-medium border-border",
dayInfo.isCurrentMonth && !isToday && !isSelected && "border-transparent",
count > 0 && "cursor-pointer hover:scale-110",
)}
onClick={count > 0 ? onClick : undefined}
>
{dayInfo.day}
</div>
);
if (!dayInfo.isCurrentMonth) {
return (
<div
className={cn("w-6 h-6 text-xs lg:text-[13px] flex justify-center items-center cursor-default opacity-60 text-muted-foreground")}
>
{dayInfo.day}
</div>
);
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="shrink-0">{cellContent}</div>
</TooltipTrigger>
<TooltipContent>
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
);
export const ActivityCalendar = memo(
observer((props: ActivityCalendarProps) => {
const t = useTranslate();
const { month: monthStr, data, onClick } = props;
const weekStartDayOffset = workspaceStore.state.generalSetting.weekStartDayOffset;
const { days, weekDays, maxCount } = useMemo(() => {
const yearValue = dayjs(monthStr).toDate().getFullYear();
const monthValue = dayjs(monthStr).toDate().getMonth();
const dayInMonth = new Date(yearValue, monthValue + 1, 0).getDate();
const firstDay = (((new Date(yearValue, monthValue, 1).getDay() - weekStartDayOffset) % 7) + 7) % 7;
const lastDay = new Date(yearValue, monthValue, dayInMonth).getDay() - weekStartDayOffset;
const prevMonthDays = new Date(yearValue, monthValue, 0).getDate();
const WEEK_DAYS = [t("days.sun"), t("days.mon"), t("days.tue"), t("days.wed"), t("days.thu"), t("days.fri"), t("days.sat")];
const weekDaysOrdered = WEEK_DAYS.slice(weekStartDayOffset).concat(WEEK_DAYS.slice(0, weekStartDayOffset));
const daysArray: CalendarDay[] = [];
// Previous month's days
for (let i = firstDay - 1; i >= 0; i--) {
daysArray.push({ day: prevMonthDays - i, isCurrentMonth: false });
}
// Current month's days
for (let i = 1; i <= dayInMonth; i++) {
const date = dayjs(`${yearValue}-${monthValue + 1}-${i}`).format("YYYY-MM-DD");
daysArray.push({ day: i, isCurrentMonth: true, date });
}
// Next month's days
for (let i = 1; i < 7 - lastDay; i++) {
daysArray.push({ day: i, isCurrentMonth: false });
}
const maxCountValue = Math.max(...Object.values(data), 1);
return {
year: yearValue,
month: monthValue,
days: daysArray,
weekDays: weekDaysOrdered,
maxCount: maxCountValue,
};
}, [monthStr, data, weekStartDayOffset, t]);
const today = useMemo(() => dayjs().format("YYYY-MM-DD"), []);
const selectedDateFormatted = useMemo(() => dayjs(props.selectedDate).format("YYYY-MM-DD"), [props.selectedDate]);
return (
<div className={cn("w-full h-auto shrink-0 grid grid-cols-7 grid-flow-row gap-1")}>
{weekDays.map((day, index) => (
<div key={index} className={cn("w-6 h-5 text-xs flex justify-center items-center cursor-default opacity-60")}>
{day}
</div>
))}
{days.map((dayInfo, index) => {
if (!dayInfo.isCurrentMonth) {
return (
<CalendarCell
key={`prev-next-${index}`}
dayInfo={dayInfo}
count={0}
maxCount={maxCount}
isToday={false}
isSelected={false}
tooltipText=""
/>
);
}
const date = dayInfo.date!;
const count = data[date] || 0;
const isToday = today === date;
const isSelected = selectedDateFormatted === date;
const tooltipText =
count === 0
? date
: t("memo.count-memos-in-date", {
count: count,
memos: count === 1 ? t("common.memo") : t("common.memos"),
date: date,
}).toLowerCase();
return (
<CalendarCell
key={date}
dayInfo={dayInfo}
count={count}
maxCount={maxCount}
isToday={isToday}
isSelected={isSelected}
onClick={() => onClick?.(date)}
tooltipText={tooltipText}
/>
);
})}
</div>
);
}),
);
ActivityCalendar.displayName = "ActivityCalendar";

View File

@@ -0,0 +1 @@
export { ActivityCalendar as default } from "./ActivityCalendar";

View File

@@ -0,0 +1,51 @@
import { SunIcon, MoonIcon, SmileIcon } from "lucide-react";
import { FC } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useTranslate } from "@/utils/i18n";
interface Props {
value: Appearance;
onChange: (appearance: Appearance) => void;
}
const appearanceList = ["system", "light", "dark"] as const;
const AppearanceSelect: FC<Props> = (props: Props) => {
const { onChange, value } = props;
const t = useTranslate();
const getPrefixIcon = (appearance: Appearance) => {
const className = "w-4 h-auto";
if (appearance === "light") {
return <SunIcon className={className} />;
} else if (appearance === "dark") {
return <MoonIcon className={className} />;
} else {
return <SmileIcon className={className} />;
}
};
const handleSelectChange = async (appearance: Appearance) => {
onChange(appearance);
};
return (
<Select value={value} onValueChange={handleSelectChange}>
<SelectTrigger>
<SelectValue placeholder="Select appearance" />
</SelectTrigger>
<SelectContent>
{appearanceList.map((item) => (
<SelectItem key={item} value={item} className="whitespace-nowrap">
<div className="flex items-center gap-2">
{getPrefixIcon(item)}
{t(`setting.appearance-option.${item}`)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
);
};
export default AppearanceSelect;

View File

@@ -0,0 +1,108 @@
import {
BinaryIcon,
BookIcon,
FileArchiveIcon,
FileAudioIcon,
FileEditIcon,
FileIcon,
FileTextIcon,
FileVideo2Icon,
SheetIcon,
} from "lucide-react";
import React, { useState } from "react";
import { cn } from "@/lib/utils";
import { Attachment } from "@/types/proto/api/v1/attachment_service";
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
import { PreviewImageDialog } from "./PreviewImageDialog";
import SquareDiv from "./kit/SquareDiv";
interface Props {
attachment: Attachment;
className?: string;
strokeWidth?: number;
}
const AttachmentIcon = (props: Props) => {
const { attachment } = props;
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number }>({
open: false,
urls: [],
index: 0,
});
const resourceType = getAttachmentType(attachment);
const attachmentUrl = getAttachmentUrl(attachment);
const className = cn("w-full h-auto", props.className);
const strokeWidth = props.strokeWidth;
const previewResource = () => {
window.open(attachmentUrl);
};
const handleImageClick = () => {
setPreviewImage({ open: true, urls: [attachmentUrl], index: 0 });
};
if (resourceType === "image/*") {
return (
<>
<SquareDiv className={cn(className, "flex items-center justify-center overflow-clip")}>
<img
className="min-w-full min-h-full object-cover"
src={attachment.externalLink ? attachmentUrl : attachmentUrl + "?thumbnail=true"}
onClick={handleImageClick}
onError={(e) => {
// Fallback to original image if thumbnail fails
const target = e.target as HTMLImageElement;
if (target.src.includes("?thumbnail=true")) {
console.warn("Thumbnail failed, falling back to original image:", attachmentUrl);
target.src = attachmentUrl;
}
}}
decoding="async"
loading="lazy"
/>
</SquareDiv>
<PreviewImageDialog
open={previewImage.open}
onOpenChange={(open) => setPreviewImage((prev) => ({ ...prev, open }))}
imgUrls={previewImage.urls}
initialIndex={previewImage.index}
/>
</>
);
}
const getAttachmentIcon = () => {
switch (resourceType) {
case "video/*":
return <FileVideo2Icon strokeWidth={strokeWidth} className="w-full h-auto" />;
case "audio/*":
return <FileAudioIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
case "text/*":
return <FileTextIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
case "application/epub+zip":
return <BookIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
case "application/pdf":
return <BookIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
case "application/msword":
return <FileEditIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
case "application/msexcel":
return <SheetIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
case "application/zip":
return <FileArchiveIcon onClick={previewResource} strokeWidth={strokeWidth} className="w-full h-auto" />;
case "application/x-java-archive":
return <BinaryIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
default:
return <FileIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
}
};
return (
<div onClick={previewResource} className={cn(className, "max-w-16 opacity-50")}>
{getAttachmentIcon()}
</div>
);
};
export default React.memo(AttachmentIcon);

View File

@@ -0,0 +1,28 @@
import { observer } from "mobx-react-lite";
import { cn } from "@/lib/utils";
import { workspaceStore } from "@/store";
import AppearanceSelect from "./AppearanceSelect";
import LocaleSelect from "./LocaleSelect";
interface Props {
className?: string;
}
const AuthFooter = observer(({ className }: Props) => {
const handleLocaleSelectChange = (locale: Locale) => {
workspaceStore.state.setPartial({ locale });
};
const handleAppearanceSelectChange = (appearance: Appearance) => {
workspaceStore.state.setPartial({ appearance });
};
return (
<div className={cn("mt-4 flex flex-row items-center justify-center w-full gap-2", className)}>
<LocaleSelect value={workspaceStore.state.locale} onChange={handleLocaleSelectChange} />
<AppearanceSelect value={workspaceStore.state.appearance as Appearance} onChange={handleAppearanceSelectChange} />
</div>
);
});
export default AuthFooter;

View File

@@ -0,0 +1,27 @@
import { observer } from "mobx-react-lite";
import { cn } from "@/lib/utils";
import { workspaceStore } from "@/store";
import UserAvatar from "./UserAvatar";
interface Props {
className?: string;
collapsed?: boolean;
}
const BrandBanner = observer((props: Props) => {
const { collapsed } = props;
const workspaceGeneralSetting = workspaceStore.state.generalSetting;
const title = workspaceGeneralSetting.customProfile?.title || "Memos";
const avatarUrl = workspaceGeneralSetting.customProfile?.logoUrl || "/full-logo.webp";
return (
<div className={cn("relative w-full h-auto shrink-0", props.className)}>
<div className={cn("w-auto flex flex-row justify-start items-center text-foreground", collapsed ? "px-1" : "px-3")}>
<UserAvatar className="shrink-0" avatarUrl={avatarUrl} />
{!collapsed && <span className="ml-2 text-lg font-medium text-foreground shrink truncate">{title}</span>}
</div>
</div>
);
});
export default BrandBanner;

View File

@@ -0,0 +1,115 @@
import { useEffect, 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 { userStore } from "@/store";
import { User } from "@/types/proto/api/v1/user_service";
import { useTranslate } from "@/utils/i18n";
interface ChangeMemberPasswordDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
user?: User;
onSuccess?: () => void;
}
export function ChangeMemberPasswordDialog({ open, onOpenChange, user, onSuccess }: ChangeMemberPasswordDialogProps) {
const t = useTranslate();
const [newPassword, setNewPassword] = useState("");
const [newPasswordAgain, setNewPasswordAgain] = useState("");
useEffect(() => {
// do nth
}, []);
const handleCloseBtnClick = () => {
onOpenChange(false);
};
const handleNewPasswordChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setNewPassword(text);
};
const handleNewPasswordAgainChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setNewPasswordAgain(text);
};
const handleSaveBtnClick = async () => {
if (!user) return;
if (newPassword === "" || newPasswordAgain === "") {
toast.error(t("message.fill-all"));
return;
}
if (newPassword !== newPasswordAgain) {
toast.error(t("message.new-password-not-match"));
setNewPasswordAgain("");
return;
}
try {
await userStore.updateUser(
{
name: user.name,
password: newPassword,
},
["password"],
);
toast(t("message.password-changed"));
onSuccess?.();
onOpenChange(false);
} catch (error: any) {
console.error(error);
toast.error(error.details);
}
};
if (!user) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t("setting.account-section.change-password")} ({user.displayName})
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid gap-2">
<Label htmlFor="newPassword">{t("auth.new-password")}</Label>
<Input
id="newPassword"
type="password"
placeholder={t("auth.new-password")}
value={newPassword}
onChange={handleNewPasswordChanged}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="newPasswordAgain">{t("auth.repeat-new-password")}</Label>
<Input
id="newPasswordAgain"
type="password"
placeholder={t("auth.repeat-new-password")}
value={newPasswordAgain}
onChange={handleNewPasswordAgainChanged}
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={handleCloseBtnClick}>
{t("common.cancel")}
</Button>
<Button onClick={handleSaveBtnClick}>{t("common.save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default ChangeMemberPasswordDialog;

View File

@@ -0,0 +1,139 @@
import React, { 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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { userServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser";
import useLoading from "@/hooks/useLoading";
import { useTranslate } from "@/utils/i18n";
interface CreateAccessTokenDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
interface State {
description: string;
expiration: number;
}
export function CreateAccessTokenDialog({ open, onOpenChange, onSuccess }: CreateAccessTokenDialogProps) {
const t = useTranslate();
const currentUser = useCurrentUser();
const [state, setState] = useState({
description: "",
expiration: 3600 * 8,
});
const requestState = useLoading(false);
const expirationOptions = [
{
label: t("setting.access-token-section.create-dialog.duration-8h"),
value: 3600 * 8,
},
{
label: t("setting.access-token-section.create-dialog.duration-1m"),
value: 3600 * 24 * 30,
},
{
label: t("setting.access-token-section.create-dialog.duration-never"),
value: 0,
},
];
const setPartialState = (partialState: Partial<State>) => {
setState({
...state,
...partialState,
});
};
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
description: e.target.value,
});
};
const handleRoleInputChange = (value: string) => {
setPartialState({
expiration: Number(value),
});
};
const handleSaveBtnClick = async () => {
if (!state.description) {
toast.error(t("message.description-is-required"));
return;
}
try {
requestState.setLoading();
await userServiceClient.createUserAccessToken({
parent: currentUser.name,
accessToken: {
description: state.description,
expiresAt: state.expiration ? new Date(Date.now() + state.expiration * 1000) : undefined,
},
});
requestState.setFinish();
onSuccess();
onOpenChange(false);
} catch (error: any) {
toast.error(error.details);
console.error(error);
requestState.setError();
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("setting.access-token-section.create-dialog.create-access-token")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid gap-2">
<Label htmlFor="description">
{t("setting.access-token-section.create-dialog.description")} <span className="text-destructive">*</span>
</Label>
<Input
id="description"
type="text"
placeholder={t("setting.access-token-section.create-dialog.some-description")}
value={state.description}
onChange={handleDescriptionInputChange}
/>
</div>
<div className="grid gap-2">
<Label>
{t("setting.access-token-section.create-dialog.expiration")} <span className="text-destructive">*</span>
</Label>
<RadioGroup value={state.expiration.toString()} onValueChange={handleRoleInputChange} className="flex flex-row gap-4">
{expirationOptions.map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem value={option.value.toString()} id={`expiration-${option.value}`} />
<Label htmlFor={`expiration-${option.value}`}>{option.label}</Label>
</div>
))}
</RadioGroup>
</div>
</div>
<DialogFooter>
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
{t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CreateAccessTokenDialog;

View File

@@ -0,0 +1,433 @@
import { useEffect, 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { identityProviderServiceClient } from "@/grpcweb";
import { absolutifyLink } from "@/helpers/utils";
import { FieldMapping, IdentityProvider, IdentityProvider_Type, OAuth2Config } from "@/types/proto/api/v1/idp_service";
import { useTranslate } from "@/utils/i18n";
const templateList: IdentityProvider[] = [
{
name: "",
title: "GitHub",
type: IdentityProvider_Type.OAUTH2,
identifierFilter: "",
config: {
oauth2Config: {
clientId: "",
clientSecret: "",
authUrl: "https://github.com/login/oauth/authorize",
tokenUrl: "https://github.com/login/oauth/access_token",
userInfoUrl: "https://api.github.com/user",
scopes: ["read:user"],
fieldMapping: FieldMapping.fromPartial({
identifier: "login",
displayName: "name",
email: "email",
}),
},
},
},
{
name: "",
title: "GitLab",
type: IdentityProvider_Type.OAUTH2,
identifierFilter: "",
config: {
oauth2Config: {
clientId: "",
clientSecret: "",
authUrl: "https://gitlab.com/oauth/authorize",
tokenUrl: "https://gitlab.com/oauth/token",
userInfoUrl: "https://gitlab.com/oauth/userinfo",
scopes: ["openid"],
fieldMapping: FieldMapping.fromPartial({
identifier: "name",
displayName: "name",
email: "email",
}),
},
},
},
{
name: "",
title: "Google",
type: IdentityProvider_Type.OAUTH2,
identifierFilter: "",
config: {
oauth2Config: {
clientId: "",
clientSecret: "",
authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
userInfoUrl: "https://www.googleapis.com/oauth2/v2/userinfo",
scopes: ["https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"],
fieldMapping: FieldMapping.fromPartial({
identifier: "email",
displayName: "name",
email: "email",
}),
},
},
},
{
name: "",
title: "Custom",
type: IdentityProvider_Type.OAUTH2,
identifierFilter: "",
config: {
oauth2Config: {
clientId: "",
clientSecret: "",
authUrl: "",
tokenUrl: "",
userInfoUrl: "",
scopes: [],
fieldMapping: FieldMapping.fromPartial({
identifier: "",
displayName: "",
email: "",
}),
},
},
},
];
interface CreateIdentityProviderDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
identityProvider?: IdentityProvider;
onSuccess?: () => void;
}
export function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, onSuccess }: CreateIdentityProviderDialogProps) {
const t = useTranslate();
const identityProviderTypes = [...new Set(templateList.map((t) => t.type))];
const [basicInfo, setBasicInfo] = useState({
title: "",
identifierFilter: "",
});
const [type, setType] = useState<IdentityProvider_Type>(IdentityProvider_Type.OAUTH2);
const [oauth2Config, setOAuth2Config] = useState<OAuth2Config>({
clientId: "",
clientSecret: "",
authUrl: "",
tokenUrl: "",
userInfoUrl: "",
scopes: [],
fieldMapping: FieldMapping.fromPartial({
identifier: "",
displayName: "",
email: "",
}),
});
const [oauth2Scopes, setOAuth2Scopes] = useState<string>("");
const [selectedTemplate, setSelectedTemplate] = useState<string>("GitHub");
const isCreating = identityProvider === undefined;
useEffect(() => {
if (identityProvider) {
setBasicInfo({
title: identityProvider.title,
identifierFilter: identityProvider.identifierFilter,
});
setType(identityProvider.type);
if (identityProvider.type === IdentityProvider_Type.OAUTH2) {
const oauth2Config = OAuth2Config.fromPartial(identityProvider.config?.oauth2Config || {});
setOAuth2Config(oauth2Config);
setOAuth2Scopes(oauth2Config.scopes.join(" "));
}
}
}, []);
useEffect(() => {
if (!isCreating) {
return;
}
const template = templateList.find((t) => t.title === selectedTemplate);
if (template) {
setBasicInfo({
title: template.title,
identifierFilter: template.identifierFilter,
});
setType(template.type);
if (template.type === IdentityProvider_Type.OAUTH2) {
const oauth2Config = OAuth2Config.fromPartial(template.config?.oauth2Config || {});
setOAuth2Config(oauth2Config);
setOAuth2Scopes(oauth2Config.scopes.join(" "));
}
}
}, [selectedTemplate]);
const handleCloseBtnClick = () => {
onOpenChange(false);
};
const allowConfirmAction = () => {
if (basicInfo.title === "") {
return false;
}
if (type === "OAUTH2") {
if (
oauth2Config.clientId === "" ||
oauth2Config.authUrl === "" ||
oauth2Config.tokenUrl === "" ||
oauth2Config.userInfoUrl === "" ||
oauth2Scopes === "" ||
oauth2Config.fieldMapping?.identifier === ""
) {
return false;
}
if (isCreating) {
if (oauth2Config.clientSecret === "") {
return false;
}
}
}
return true;
};
const handleConfirmBtnClick = async () => {
try {
if (isCreating) {
await identityProviderServiceClient.createIdentityProvider({
identityProvider: {
...basicInfo,
type: type,
config: {
oauth2Config: {
...oauth2Config,
scopes: oauth2Scopes.split(" "),
},
},
},
});
toast.success(t("setting.sso-section.sso-created", { name: basicInfo.title }));
} else {
await identityProviderServiceClient.updateIdentityProvider({
identityProvider: {
...basicInfo,
name: identityProvider!.name,
type: type,
config: {
oauth2Config: {
...oauth2Config,
scopes: oauth2Scopes.split(" "),
},
},
},
updateMask: ["title", "identifier_filter", "config"],
});
toast.success(t("setting.sso-section.sso-updated", { name: basicInfo.title }));
}
} catch (error: any) {
toast.error(error.details);
console.error(error);
}
onSuccess?.();
onOpenChange(false);
};
const setPartialOAuth2Config = (state: Partial<OAuth2Config>) => {
setOAuth2Config({
...oauth2Config,
...state,
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t(isCreating ? "setting.sso-section.create-sso" : "setting.sso-section.update-sso")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col justify-start items-start w-full space-y-4">
{isCreating && (
<>
<p className="mb-1!">{t("common.type")}</p>
<Select value={String(type)} onValueChange={(value) => setType(parseInt(value) as unknown as IdentityProvider_Type)}>
<SelectTrigger className="w-full mb-4">
<SelectValue />
</SelectTrigger>
<SelectContent>
{identityProviderTypes.map((kind) => (
<SelectItem key={kind} value={String(kind)}>
{IdentityProvider_Type[kind] || kind}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mb-2 text-sm font-medium">{t("setting.sso-section.template")}</p>
<Select value={selectedTemplate} onValueChange={(value) => setSelectedTemplate(value)}>
<SelectTrigger className="mb-1 h-auto w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{templateList.map((template) => (
<SelectItem key={template.title} value={template.title}>
{template.title}
</SelectItem>
))}
</SelectContent>
</Select>
<Separator className="my-2" />
</>
)}
<p className="mb-1 text-sm font-medium">
{t("common.name")}
<span className="text-destructive">*</span>
</p>
<Input
className="mb-2 w-full"
placeholder={t("common.name")}
value={basicInfo.title}
onChange={(e) =>
setBasicInfo({
...basicInfo,
title: e.target.value,
})
}
/>
<p className="mb-1 text-sm font-medium">{t("setting.sso-section.identifier-filter")}</p>
<Input
className="mb-2 w-full"
placeholder={t("setting.sso-section.identifier-filter")}
value={basicInfo.identifierFilter}
onChange={(e) =>
setBasicInfo({
...basicInfo,
identifierFilter: e.target.value,
})
}
/>
<Separator className="my-2" />
{type === "OAUTH2" && (
<>
{isCreating && (
<p className="border border-border rounded-md p-2 text-sm w-full mb-2 break-all">
{t("setting.sso-section.redirect-url")}: {absolutifyLink("/auth/callback")}
</p>
)}
<p className="mb-1 text-sm font-medium">
{t("setting.sso-section.client-id")}
<span className="text-destructive">*</span>
</p>
<Input
className="mb-2 w-full"
placeholder={t("setting.sso-section.client-id")}
value={oauth2Config.clientId}
onChange={(e) => setPartialOAuth2Config({ clientId: e.target.value })}
/>
<p className="mb-1 text-sm font-medium">
{t("setting.sso-section.client-secret")}
<span className="text-destructive">*</span>
</p>
<Input
className="mb-2 w-full"
placeholder={t("setting.sso-section.client-secret")}
value={oauth2Config.clientSecret}
onChange={(e) => setPartialOAuth2Config({ clientSecret: e.target.value })}
/>
<p className="mb-1 text-sm font-medium">
{t("setting.sso-section.authorization-endpoint")}
<span className="text-destructive">*</span>
</p>
<Input
className="mb-2 w-full"
placeholder={t("setting.sso-section.authorization-endpoint")}
value={oauth2Config.authUrl}
onChange={(e) => setPartialOAuth2Config({ authUrl: e.target.value })}
/>
<p className="mb-1 text-sm font-medium">
{t("setting.sso-section.token-endpoint")}
<span className="text-destructive">*</span>
</p>
<Input
className="mb-2 w-full"
placeholder={t("setting.sso-section.token-endpoint")}
value={oauth2Config.tokenUrl}
onChange={(e) => setPartialOAuth2Config({ tokenUrl: e.target.value })}
/>
<p className="mb-1 text-sm font-medium">
{t("setting.sso-section.user-endpoint")}
<span className="text-destructive">*</span>
</p>
<Input
className="mb-2 w-full"
placeholder={t("setting.sso-section.user-endpoint")}
value={oauth2Config.userInfoUrl}
onChange={(e) => setPartialOAuth2Config({ userInfoUrl: e.target.value })}
/>
<p className="mb-1 text-sm font-medium">
{t("setting.sso-section.scopes")}
<span className="text-destructive">*</span>
</p>
<Input
className="mb-2 w-full"
placeholder={t("setting.sso-section.scopes")}
value={oauth2Scopes}
onChange={(e) => setOAuth2Scopes(e.target.value)}
/>
<Separator className="my-2" />
<p className="mb-1 text-sm font-medium">
{t("setting.sso-section.identifier")}
<span className="text-destructive">*</span>
</p>
<Input
className="mb-2 w-full"
placeholder={t("setting.sso-section.identifier")}
value={oauth2Config.fieldMapping!.identifier}
onChange={(e) =>
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, identifier: e.target.value } as FieldMapping })
}
/>
<p className="mb-1 text-sm font-medium">{t("setting.sso-section.display-name")}</p>
<Input
className="mb-2 w-full"
placeholder={t("setting.sso-section.display-name")}
value={oauth2Config.fieldMapping!.displayName}
onChange={(e) =>
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, displayName: e.target.value } as FieldMapping })
}
/>
<p className="mb-1 text-sm font-medium">{t("common.email")}</p>
<Input
className="mb-2 w-full"
placeholder={t("common.email")}
value={oauth2Config.fieldMapping!.email}
onChange={(e) =>
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, email: e.target.value } as FieldMapping })
}
/>
<p className="mb-1 text-sm font-medium">Avatar URL</p>
<Input
className="mb-2 w-full"
placeholder={"Avatar URL"}
value={oauth2Config.fieldMapping!.avatarUrl}
onChange={(e) =>
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, avatarUrl: e.target.value } as FieldMapping })
}
/>
</>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={handleCloseBtnClick}>
{t("common.cancel")}
</Button>
<Button onClick={handleConfirmBtnClick} disabled={!allowConfirmAction()}>
{t(isCreating ? "common.create" : "common.update")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CreateIdentityProviderDialog;

View File

@@ -0,0 +1,141 @@
import React, { 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 { shortcutServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser";
import useLoading from "@/hooks/useLoading";
import { userStore } from "@/store";
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
import { useTranslate } from "@/utils/i18n";
interface CreateShortcutDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
shortcut?: Shortcut;
onSuccess?: () => void;
}
export function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, onSuccess }: CreateShortcutDialogProps) {
const t = useTranslate();
const user = useCurrentUser();
const [shortcut, setShortcut] = useState<Shortcut>({
name: initialShortcut?.name || "",
title: initialShortcut?.title || "",
filter: initialShortcut?.filter || "",
});
const requestState = useLoading(false);
const isCreating = !initialShortcut;
const onShortcutTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setShortcut({ ...shortcut, title: e.target.value });
};
const onShortcutFilterChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setShortcut({ ...shortcut, filter: e.target.value });
};
const handleConfirm = async () => {
if (!shortcut.title || !shortcut.filter) {
toast.error("Title and filter cannot be empty");
return;
}
try {
requestState.setLoading();
if (isCreating) {
await shortcutServiceClient.createShortcut({
parent: user.name,
shortcut: {
name: "", // Will be set by server
title: shortcut.title,
filter: shortcut.filter,
},
});
toast.success("Create shortcut successfully");
} else {
await shortcutServiceClient.updateShortcut({
shortcut: {
...shortcut,
name: initialShortcut!.name, // Keep the original resource name
},
updateMask: ["title", "filter"],
});
toast.success("Update shortcut successfully");
}
// Refresh shortcuts.
await userStore.fetchShortcuts();
requestState.setFinish();
onSuccess?.();
onOpenChange(false);
} catch (error: any) {
console.error(error);
toast.error(error.details);
requestState.setError();
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{`${isCreating ? t("common.create") : t("common.edit")} ${t("common.shortcuts")}`}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid gap-2">
<Label htmlFor="title">{t("common.title")}</Label>
<Input id="title" type="text" placeholder="" value={shortcut.title} onChange={onShortcutTitleChange} />
</div>
<div className="grid gap-2">
<Label htmlFor="filter">{t("common.filter")}</Label>
<Textarea
id="filter"
rows={3}
placeholder={t("common.shortcut-filter")}
value={shortcut.filter}
onChange={onShortcutFilterChange}
/>
</div>
<div className="text-sm text-muted-foreground">
<p className="mb-2">{t("common.learn-more")}:</p>
<ul className="list-disc list-inside space-y-1">
<li>
<a
className="text-primary hover:underline"
href="https://www.usememos.com/docs/getting-started/shortcuts"
target="_blank"
rel="noopener noreferrer"
>
Docs - Shortcuts
</a>
</li>
<li>
<a
className="text-primary hover:underline"
href="https://www.usememos.com/docs/getting-started/shortcuts#how-to-write-a-filter"
target="_blank"
rel="noopener noreferrer"
>
How to Write a Filter?
</a>
</li>
</ul>
</div>
</div>
<DialogFooter>
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button disabled={requestState.isLoading} onClick={handleConfirm}>
{t("common.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CreateShortcutDialog;

View File

@@ -0,0 +1,135 @@
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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { userServiceClient } from "@/grpcweb";
import useLoading from "@/hooks/useLoading";
import { User, User_Role } from "@/types/proto/api/v1/user_service";
import { useTranslate } from "@/utils/i18n";
interface CreateUserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
user?: User;
onSuccess?: () => void;
}
export function CreateUserDialog({ open, onOpenChange, user: initialUser, onSuccess }: CreateUserDialogProps) {
const t = useTranslate();
const [user, setUser] = useState(User.fromPartial({ ...initialUser }));
const requestState = useLoading(false);
const isCreating = !initialUser;
const setPartialUser = (state: Partial<User>) => {
setUser({
...user,
...state,
});
};
const handleConfirm = async () => {
if (isCreating && (!user.username || !user.password)) {
toast.error("Username and password cannot be empty");
return;
}
try {
requestState.setLoading();
if (isCreating) {
await userServiceClient.createUser({ user });
toast.success("Create user successfully");
} else {
const updateMask = [];
if (user.username !== initialUser?.username) {
updateMask.push("username");
}
if (user.password) {
updateMask.push("password");
}
if (user.role !== initialUser?.role) {
updateMask.push("role");
}
await userServiceClient.updateUser({ user, updateMask });
toast.success("Update user successfully");
}
requestState.setFinish();
onSuccess?.();
onOpenChange(false);
} catch (error: any) {
console.error(error);
toast.error(error.details);
requestState.setError();
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{`${isCreating ? t("common.create") : t("common.edit")} ${t("common.user")}`}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid gap-2">
<Label htmlFor="username">{t("common.username")}</Label>
<Input
id="username"
type="text"
placeholder={t("common.username")}
value={user.username}
onChange={(e) =>
setPartialUser({
username: e.target.value,
})
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">{t("common.password")}</Label>
<Input
id="password"
type="password"
placeholder={t("common.password")}
autoComplete="off"
value={user.password}
onChange={(e) =>
setPartialUser({
password: e.target.value,
})
}
/>
</div>
<div className="grid gap-2">
<Label>{t("common.role")}</Label>
<RadioGroup
value={user.role}
onValueChange={(value) => setPartialUser({ role: value as User_Role })}
className="flex flex-row gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value={User_Role.USER} id="user" />
<Label htmlFor="user">{t("setting.member-section.user")}</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value={User_Role.ADMIN} id="admin" />
<Label htmlFor="admin">{t("setting.member-section.admin")}</Label>
</div>
</RadioGroup>
</div>
</div>
<DialogFooter>
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button disabled={requestState.isLoading} onClick={handleConfirm}>
{t("common.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CreateUserDialog;

View File

@@ -0,0 +1,159 @@
import React, { useEffect, 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 { webhookServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser";
import useLoading from "@/hooks/useLoading";
import { useTranslate } from "@/utils/i18n";
interface CreateWebhookDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
webhookName?: string;
onSuccess?: () => void;
}
interface State {
displayName: string;
url: string;
}
export function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: CreateWebhookDialogProps) {
const t = useTranslate();
const currentUser = useCurrentUser();
const [state, setState] = useState<State>({
displayName: "",
url: "",
});
const requestState = useLoading(false);
const isCreating = webhookName === undefined;
useEffect(() => {
if (webhookName) {
webhookServiceClient
.getWebhook({
name: webhookName,
})
.then((webhook) => {
setState({
displayName: webhook.displayName,
url: webhook.url,
});
});
}
}, [webhookName]);
const setPartialState = (partialState: Partial<State>) => {
setState({
...state,
...partialState,
});
};
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
displayName: e.target.value,
});
};
const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
url: e.target.value,
});
};
const handleSaveBtnClick = async () => {
if (!state.displayName || !state.url) {
toast.error(t("message.fill-all-required-fields"));
return;
}
if (!currentUser) {
toast.error("User not authenticated");
return;
}
try {
requestState.setLoading();
if (isCreating) {
await webhookServiceClient.createWebhook({
parent: currentUser.name,
webhook: {
displayName: state.displayName,
url: state.url,
},
});
} else {
await webhookServiceClient.updateWebhook({
webhook: {
name: webhookName,
displayName: state.displayName,
url: state.url,
},
updateMask: ["display_name", "url"],
});
}
onSuccess?.();
onOpenChange(false);
requestState.setFinish();
} catch (error: any) {
console.error(error);
toast.error(error.details);
requestState.setError();
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{isCreating
? t("setting.webhook-section.create-dialog.create-webhook")
: t("setting.webhook-section.create-dialog.edit-webhook")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid gap-2">
<Label htmlFor="displayName">
{t("setting.webhook-section.create-dialog.title")} <span className="text-destructive">*</span>
</Label>
<Input
id="displayName"
type="text"
placeholder={t("setting.webhook-section.create-dialog.an-easy-to-remember-name")}
value={state.displayName}
onChange={handleTitleInputChange}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="url">
{t("setting.webhook-section.create-dialog.payload-url")} <span className="text-destructive">*</span>
</Label>
<Input
id="url"
type="text"
placeholder={t("setting.webhook-section.create-dialog.url-example-post-receive")}
value={state.url}
onChange={handleUrlInputChange}
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
{t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CreateWebhookDialog;

View File

@@ -0,0 +1,43 @@
import dayjs from "dayjs";
import toast from "react-hot-toast";
import { cn } from "@/lib/utils";
// must be compatible with JS Date.parse(), we use ISO 8601 (almost)
const DATE_TIME_FORMAT = "YYYY-MM-DD HH:mm:ss";
// convert Date to datetime string.
const formatDate = (date: Date): string => {
return dayjs(date).format(DATE_TIME_FORMAT);
};
interface Props {
value: Date;
onChange: (date: Date) => void;
}
const DateTimeInput: React.FC<Props> = ({ value, onChange }) => {
return (
<input
type="text"
className={cn("px-1 bg-transparent rounded text-xs transition-all", "border-transparent outline-none focus:border-border", "border")}
defaultValue={formatDate(value)}
onBlur={(e) => {
const inputValue = e.target.value;
if (inputValue) {
// note: inputValue must be compatible with JS Date.parse()
const date = dayjs(inputValue).toDate();
// Check if the date is valid.
if (!isNaN(date.getTime())) {
onChange(date);
} else {
toast.error("Invalid datetime format. Use format: 2023-12-31 23:59:59");
e.target.value = formatDate(value);
}
}
}}
placeholder={DATE_TIME_FORMAT}
/>
);
};
export default DateTimeInput;

View File

@@ -0,0 +1,11 @@
import { BirdIcon } from "lucide-react";
const Empty = () => {
return (
<div className="mx-auto">
<BirdIcon strokeWidth={0.5} absoluteStrokeWidth={true} className="w-24 h-auto text-muted-foreground" />
</div>
);
};
export default Empty;

View File

@@ -0,0 +1,435 @@
import React, { useState } from 'react';
import { Download, Upload, FileText, AlertCircle, CheckCircle, X } from 'lucide-react';
const ExportImportComponent = () => {
const [exportLoading, setExportLoading] = useState(false);
const [importLoading, setImportLoading] = useState(false);
const [exportOptions, setExportOptions] = useState({
format: 'json',
excludeArchived: false,
includeAttachments: true,
includeRelations: true,
filter: ''
});
const [importOptions, setImportOptions] = useState({
overwriteExisting: false,
validateOnly: false,
preserveTimestamps: true,
skipAttachments: false,
skipRelations: false
});
const [importFile, setImportFile] = useState(null);
const [importResult, setImportResult] = useState(null);
const [showImportResult, setShowImportResult] = useState(false);
const handleExport = async () => {
setExportLoading(true);
try {
const response = await fetch('/api/v1/memos:export', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(exportOptions),
});
if (!response.ok) {
throw new Error('Export failed');
}
const data = await response.json();
// The backend sends the JSON data as a base64-encoded bytes field
// We need to decode it back to JSON string
let jsonString;
if (data.data) {
// If data.data is a base64 string, decode it
if (typeof data.data === 'string') {
try {
// Try to decode as base64 first
jsonString = atob(data.data);
} catch (e) {
// If base64 decode fails, assume it's already a JSON string
jsonString = data.data;
}
} else {
// If it's already an object, stringify it
jsonString = JSON.stringify(data.data, null, 2);
}
} else {
throw new Error('No data received from server');
}
// Create blob and download
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = data.filename || 'memos_export.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Show success message
alert(`Export completed! ${data.memoCount} memos exported.`);
} catch (error) {
console.error('Export error:', error);
alert('Export failed: ' + error.message);
} finally {
setExportLoading(false);
}
};
const handleImport = async () => {
if (!importFile) {
alert('Please select a file to import');
return;
}
setImportLoading(true);
try {
const fileContent = await importFile.text();
// Validate JSON format
try {
JSON.parse(fileContent);
} catch (e) {
throw new Error('Invalid JSON file');
}
const response = await fetch('/api/v1/memos:import', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
data: btoa(fileContent), // Base64 encode the JSON
format: 'json',
overwriteExisting: importOptions.overwriteExisting,
validateOnly: importOptions.validateOnly,
preserveTimestamps: importOptions.preserveTimestamps,
skipAttachments: importOptions.skipAttachments,
skipRelations: importOptions.skipRelations,
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Import failed: ${errorText}`);
}
const result = await response.json();
setImportResult(result);
setShowImportResult(true);
} catch (error) {
console.error('Import error:', error);
alert('Import failed: ' + error.message);
} finally {
setImportLoading(false);
}
};
const handleFileSelect = (e) => {
const file = e.target.files[0];
if (file) {
// Accept both .json files and any text file that might contain JSON
if (file.type === 'application/json' || file.name.endsWith('.json') || file.type === 'text/plain') {
setImportFile(file);
} else {
alert('Please select a JSON file');
e.target.value = '';
}
}
};
return (
<div className="max-w-4xl mx-auto p-6 space-y-8">
<div className="text-center">
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">Export & Import Memos</h1>
<p className="text-gray-600 dark:text-gray-400">Backup and restore your memos in JSON format</p>
</div>
{/* Export Section */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center mb-4">
<Download className="w-6 h-6 text-blue-600 mr-3" />
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Export Memos</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Filter (optional)
</label>
<input
type="text"
value={exportOptions.filter}
onChange={(e) => setExportOptions({...exportOptions, filter: e.target.value})}
placeholder="e.g., tag:important"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
</div>
<div className="space-y-3">
<label className="flex items-center">
<input
type="checkbox"
checked={exportOptions.excludeArchived}
onChange={(e) => setExportOptions({...exportOptions, excludeArchived: e.target.checked})}
className="mr-2 h-4 w-4 text-blue-600"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Exclude archived memos</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={exportOptions.includeAttachments}
onChange={(e) => setExportOptions({...exportOptions, includeAttachments: e.target.checked})}
className="mr-2 h-4 w-4 text-blue-600"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Include attachments</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={exportOptions.includeRelations}
onChange={(e) => setExportOptions({...exportOptions, includeRelations: e.target.checked})}
className="mr-2 h-4 w-4 text-blue-600"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Include memo relations</span>
</label>
</div>
</div>
<button
onClick={handleExport}
disabled={exportLoading}
className="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white px-6 py-2 rounded-md font-medium flex items-center transition-colors"
>
{exportLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Exporting...
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
Export Memos
</>
)}
</button>
</div>
{/* Import Section */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center mb-4">
<Upload className="w-6 h-6 text-green-600 mr-3" />
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Import Memos</h2>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select JSON file
</label>
<div className="flex items-center">
<input
type="file"
accept=".json,application/json,text/plain"
onChange={handleFileSelect}
className="block w-full text-sm text-gray-500 dark:text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-gray-50 dark:file:bg-gray-600 file:text-gray-700 dark:file:text-gray-200 hover:file:bg-gray-100 dark:hover:file:bg-gray-500 transition-colors"
/>
{importFile && (
<div className="ml-4 flex items-center text-green-600">
<FileText className="w-4 h-4 mr-1" />
<span className="text-sm">{importFile.name}</span>
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div className="space-y-3">
<label className="flex items-center">
<input
type="checkbox"
checked={importOptions.overwriteExisting}
onChange={(e) => setImportOptions({...importOptions, overwriteExisting: e.target.checked})}
className="mr-2 h-4 w-4 text-green-600"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Overwrite existing memos</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={importOptions.validateOnly}
onChange={(e) => setImportOptions({...importOptions, validateOnly: e.target.checked})}
className="mr-2 h-4 w-4 text-green-600"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Validate only (dry run)</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={importOptions.preserveTimestamps}
onChange={(e) => setImportOptions({...importOptions, preserveTimestamps: e.target.checked})}
className="mr-2 h-4 w-4 text-green-600"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Preserve original timestamps</span>
</label>
</div>
<div className="space-y-3">
<label className="flex items-center">
<input
type="checkbox"
checked={importOptions.skipAttachments}
onChange={(e) => setImportOptions({...importOptions, skipAttachments: e.target.checked})}
className="mr-2 h-4 w-4 text-green-600"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Skip attachments</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={importOptions.skipRelations}
onChange={(e) => setImportOptions({...importOptions, skipRelations: e.target.checked})}
className="mr-2 h-4 w-4 text-green-600"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Skip memo relations</span>
</label>
</div>
</div>
<button
onClick={handleImport}
disabled={importLoading || !importFile}
className="bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white px-6 py-2 rounded-md font-medium flex items-center transition-colors"
>
{importLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Importing...
</>
) : (
<>
<Upload className="w-4 h-4 mr-2" />
Import Memos
</>
)}
</button>
</div>
{/* Import Result Modal */}
{showImportResult && importResult && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto border border-gray-200 dark:border-gray-700">
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Import Results</h3>
<button
onClick={() => setShowImportResult(false)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="space-y-4">
{/* Summary Statistics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-green-50 dark:bg-green-900/20 p-3 rounded-lg text-center border border-green-200 dark:border-green-800">
<div className="text-2xl font-bold text-green-600 dark:text-green-400">{importResult.importedCount}</div>
<div className="text-sm text-green-700 dark:text-green-300">Imported</div>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 p-3 rounded-lg text-center border border-yellow-200 dark:border-yellow-800">
<div className="text-2xl font-bold text-yellow-600 dark:text-yellow-400">{importResult.skippedCount}</div>
<div className="text-sm text-yellow-700 dark:text-yellow-300">Skipped</div>
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg text-center border border-blue-200 dark:border-blue-800">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">{importResult.summary?.createdCount || 0}</div>
<div className="text-sm text-blue-700 dark:text-blue-300">Created</div>
</div>
<div className="bg-purple-50 dark:bg-purple-900/20 p-3 rounded-lg text-center border border-purple-200 dark:border-purple-800">
<div className="text-2xl font-bold text-purple-600 dark:text-purple-400">{importResult.summary?.updatedCount || 0}</div>
<div className="text-sm text-purple-700 dark:text-purple-300">Updated</div>
</div>
</div>
{/* Detailed Summary */}
{importResult.summary && (
<div className="bg-gray-50 dark:bg-gray-700 p-4 rounded-lg border border-gray-200 dark:border-gray-600">
<h4 className="font-medium mb-2 text-gray-900 dark:text-gray-100">Summary</h4>
<div className="text-sm text-gray-700 dark:text-gray-300 space-y-1">
<div>Total memos in file: {importResult.summary.totalMemos}</div>
<div>Duration: {importResult.summary.durationMs}ms</div>
{importResult.summary.attachmentsImported > 0 && (
<div>Attachments imported: {importResult.summary.attachmentsImported}</div>
)}
{importResult.summary.relationsImported > 0 && (
<div>Relations imported: {importResult.summary.relationsImported}</div>
)}
</div>
</div>
)}
{/* Warnings */}
{importResult.warnings && importResult.warnings.length > 0 && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<div className="flex items-center mb-2">
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-400 mr-2" />
<h4 className="font-medium text-yellow-800 dark:text-yellow-200">Warnings</h4>
</div>
<ul className="list-disc list-inside text-sm text-yellow-700 dark:text-yellow-300 space-y-1">
{importResult.warnings.map((warning, index) => (
<li key={index}>{warning}</li>
))}
</ul>
</div>
)}
{/* Errors */}
{importResult.errors && importResult.errors.length > 0 && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-center mb-2">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 mr-2" />
<h4 className="font-medium text-red-800 dark:text-red-200">Errors</h4>
</div>
<ul className="list-disc list-inside text-sm text-red-700 dark:text-red-300 space-y-1">
{importResult.errors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
)}
{/* Success message */}
{importResult.importedCount > 0 && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="flex items-center">
<CheckCircle className="w-5 h-5 text-green-600 dark:text-green-400 mr-2" />
<span className="text-green-800 dark:text-green-200 font-medium">
Import completed successfully!
</span>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* Help Section */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
<h3 className="text-lg font-semibold text-blue-900 dark:text-blue-100 mb-2">Export/Import Guidelines</h3>
<div className="text-sm text-blue-800 dark:text-blue-200 space-y-2">
<p><strong>Export:</strong> Creates a JSON file containing all your memos with metadata, tags, and optionally attachments and relations.</p>
<p><strong>Import:</strong> Restores memos from a JSON file. Use "Validate only" to check for issues before importing.</p>
<p><strong>UID Conflicts:</strong> Each memo has a unique identifier (UID). Enable "Overwrite existing" to update memos with matching UIDs.</p>
<p><strong>Timestamps:</strong> Original creation and update times are preserved by default during import.</p>
</div>
</div>
</div>
);
};
export default ExportImportComponent;

View File

@@ -0,0 +1,58 @@
import { last } from "lodash-es";
import { observer } from "mobx-react-lite";
import { matchPath, useLocation } from "react-router-dom";
import useDebounce from "react-use/lib/useDebounce";
import SearchBar from "@/components/SearchBar";
import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils";
import { Routes } from "@/router";
import { memoStore, userStore } from "@/store";
import MemoFilters from "../MemoFilters";
import StatisticsView from "../StatisticsView";
import ShortcutsSection from "./ShortcutsSection";
import TagsSection from "./TagsSection";
interface Props {
className?: string;
}
const HomeSidebar = observer((props: Props) => {
const location = useLocation();
const currentUser = useCurrentUser();
useDebounce(
async () => {
let parent: string | undefined = undefined;
if (location.pathname === Routes.ROOT && currentUser) {
parent = currentUser.name;
}
if (matchPath("/u/:username", location.pathname) !== null) {
const username = last(location.pathname.split("/"));
const user = await userStore.getOrFetchUserByUsername(username || "");
parent = user.name;
}
await userStore.fetchUserStats(parent);
},
300,
[memoStore.state.memos.length, userStore.state.statsStateId, location.pathname],
);
return (
<aside
className={cn(
"relative w-full h-full overflow-auto flex flex-col justify-start items-start bg-background text-sidebar-foreground",
props.className,
)}
>
<SearchBar />
<MemoFilters />
<div className="mt-1 px-1 w-full">
<StatisticsView />
{currentUser && <ShortcutsSection />}
<TagsSection />
</div>
</aside>
);
});
export default HomeSidebar;

View File

@@ -0,0 +1,33 @@
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 HomeSidebar from "./HomeSidebar";
const HomeSidebarDrawer = () => {
const location = useLocation();
const [open, setOpen] = useState(false);
useEffect(() => {
setOpen(false);
}, [location.pathname]);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="ghost">
<MenuIcon className="size-5 text-foreground" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-80 max-w-full bg-background">
<SheetHeader>
<SheetTitle />
</SheetHeader>
<HomeSidebar className="px-4" />
</SheetContent>
</Sheet>
);
};
export default HomeSidebarDrawer;

View File

@@ -0,0 +1,120 @@
import { Edit3Icon, MoreVerticalIcon, TrashIcon, PlusIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState } from "react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { shortcutServiceClient } from "@/grpcweb";
import useAsyncEffect from "@/hooks/useAsyncEffect";
import { cn } from "@/lib/utils";
import { userStore } from "@/store";
import memoFilterStore from "@/store/memoFilter";
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
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] : "";
};
const ShortcutsSection = observer(() => {
const t = useTranslate();
const shortcuts = userStore.state.shortcuts;
const [isCreateShortcutDialogOpen, setIsCreateShortcutDialogOpen] = useState(false);
const [editingShortcut, setEditingShortcut] = useState<Shortcut | undefined>();
useAsyncEffect(async () => {
await userStore.fetchShortcuts();
}, []);
const handleDeleteShortcut = async (shortcut: Shortcut) => {
const confirmed = window.confirm("Are you sure you want to delete this shortcut?");
if (confirmed) {
await shortcutServiceClient.deleteShortcut({ name: shortcut.name });
await userStore.fetchShortcuts();
}
};
const handleCreateShortcut = () => {
setEditingShortcut(undefined);
setIsCreateShortcutDialogOpen(true);
};
const handleEditShortcut = (shortcut: Shortcut) => {
setEditingShortcut(shortcut);
setIsCreateShortcutDialogOpen(true);
};
const handleShortcutDialogSuccess = () => {
setIsCreateShortcutDialogOpen(false);
setEditingShortcut(undefined);
};
return (
<div className="w-full flex flex-col justify-start items-start mt-3 px-1 h-auto shrink-0 flex-nowrap hide-scrollbar">
<div className="flex flex-row justify-between items-center w-full gap-1 mb-1 text-sm leading-6 text-muted-foreground select-none">
<span>{t("common.shortcuts")}</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PlusIcon className="w-4 h-auto cursor-pointer" onClick={handleCreateShortcut} />
</TooltipTrigger>
<TooltipContent>
<p>{t("common.create")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="w-full flex flex-row justify-start items-center relative flex-wrap gap-x-2 gap-y-1">
{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 = memoFilterStore.shortcut === shortcutId;
return (
<div
key={shortcutId}
className="shrink-0 w-full text-sm rounded-md leading-6 flex flex-row justify-between items-center select-none gap-2 text-muted-foreground"
>
<span
className={cn("truncate cursor-pointer text-muted-foreground", selected && "text-primary font-medium")}
onClick={() => (selected ? memoFilterStore.setShortcut(undefined) : memoFilterStore.setShortcut(shortcutId))}
>
{emoji && <span className="text-base mr-1">{emoji}</span>}
{title.trim()}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<MoreVerticalIcon className="w-4 h-auto shrink-0 text-muted-foreground cursor-pointer hover:text-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" alignOffset={-12}>
<DropdownMenuItem onClick={() => handleEditShortcut(shortcut)}>
<Edit3Icon className="w-4 h-auto" />
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDeleteShortcut(shortcut)}>
<TrashIcon className="w-4 h-auto" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
<CreateShortcutDialog
open={isCreateShortcutDialogOpen}
onOpenChange={setIsCreateShortcutDialogOpen}
shortcut={editingShortcut}
onSuccess={handleShortcutDialogSuccess}
/>
</div>
);
});
export default ShortcutsSection;

View File

@@ -0,0 +1,141 @@
import { Edit3Icon, HashIcon, MoreVerticalIcon, TagsIcon, TrashIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState } from "react";
import toast from "react-hot-toast";
import useLocalStorage from "react-use/lib/useLocalStorage";
import { Switch } from "@/components/ui/switch";
import { memoServiceClient } from "@/grpcweb";
import { useDialog } from "@/hooks/useDialog";
import { cn } from "@/lib/utils";
import { userStore } from "@/store";
import memoFilterStore, { MemoFilter } from "@/store/memoFilter";
import { useTranslate } from "@/utils/i18n";
import RenameTagDialog from "../RenameTagDialog";
import TagTree from "../TagTree";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
interface Props {
readonly?: boolean;
}
const TagsSection = observer((props: Props) => {
const t = useTranslate();
const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", false);
const renameTagDialog = useDialog();
const [selectedTag, setSelectedTag] = useState<string>("");
const tags = Object.entries(userStore.state.tagCount)
.sort((a, b) => a[0].localeCompare(b[0]))
.sort((a, b) => b[1] - a[1]);
const handleTagClick = (tag: string) => {
const isActive = memoFilterStore.getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag);
if (isActive) {
memoFilterStore.removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag);
} else {
memoFilterStore.addFilter({
factor: "tagSearch",
value: tag,
});
}
};
const handleRenameTag = (tag: string) => {
setSelectedTag(tag);
renameTagDialog.open();
};
const handleRenameSuccess = () => {
// Refresh tags after rename
userStore.fetchUsers();
};
const handleDeleteTag = async (tag: string) => {
const confirmed = window.confirm(t("tag.delete-confirm"));
if (confirmed) {
await memoServiceClient.deleteMemoTag({
parent: "memos/-",
tag: tag,
});
toast.success(t("message.deleted-successfully"));
}
};
return (
<div className="flex flex-col justify-start items-start w-full mt-3 px-1 h-auto shrink-0 flex-nowrap hide-scrollbar">
<div className="flex flex-row justify-between items-center w-full gap-1 mb-1 text-sm leading-6 text-muted-foreground select-none">
<span>{t("common.tags")}</span>
{tags.length > 0 && (
<Popover>
<PopoverTrigger>
<MoreVerticalIcon className="w-4 h-auto shrink-0 text-muted-foreground" />
</PopoverTrigger>
<PopoverContent align="end" alignOffset={-12}>
<div className="w-auto flex flex-row justify-between items-center gap-2 p-1">
<span className="text-sm shrink-0">{t("common.tree-mode")}</span>
<Switch checked={treeMode} onCheckedChange={(checked) => setTreeMode(checked)} />
</div>
</PopoverContent>
</Popover>
)}
</div>
{tags.length > 0 ? (
treeMode ? (
<TagTree tagAmounts={tags} />
) : (
<div className="w-full flex flex-row justify-start items-center relative flex-wrap gap-x-2 gap-y-1">
{tags.map(([tag, amount]) => (
<div
key={tag}
className="shrink-0 w-auto max-w-full text-sm rounded-md leading-6 flex flex-row justify-start items-center select-none hover:opacity-80 text-muted-foreground"
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="shrink-0 group cursor-pointer">
<HashIcon className="group-hover:hidden w-4 h-auto shrink-0 text-muted-foreground" />
<MoreVerticalIcon className="hidden group-hover:block w-4 h-auto shrink-0 text-muted-foreground" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={2}>
<DropdownMenuItem onClick={() => handleRenameTag(tag)}>
<Edit3Icon className="w-4 h-auto" />
{t("common.rename")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDeleteTag(tag)}>
<TrashIcon className="w-4 h-auto" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div
className={cn("inline-flex flex-nowrap ml-0.5 gap-0.5 cursor-pointer max-w-[calc(100%-16px)]")}
onClick={() => handleTagClick(tag)}
>
<span className="truncate opacity-80">{tag}</span>
{amount > 1 && <span className="opacity-60 shrink-0">({amount})</span>}
</div>
</div>
))}
</div>
)
) : (
!props.readonly && (
<div className="p-2 border border-dashed rounded-md flex flex-row justify-start items-start gap-1 text-muted-foreground">
<TagsIcon />
<p className="mt-0.5 text-sm leading-snug italic">{t("tag.create-tags-guide")}</p>
</div>
)
)}
{/* Rename Tag Dialog */}
<RenameTagDialog
open={renameTagDialog.isOpen}
onOpenChange={renameTagDialog.setOpen}
tag={selectedTag}
onSuccess={handleRenameSuccess}
/>
</div>
);
});
export default TagsSection;

View File

@@ -0,0 +1,4 @@
import HomeSidebar from "./HomeSidebar";
import HomeSidebarDrawer from "./HomeSidebarDrawer";
export { HomeSidebar, HomeSidebarDrawer };

View File

@@ -0,0 +1,142 @@
import { InboxIcon, LoaderIcon, MessageCircleIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState } from "react";
import toast from "react-hot-toast";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { activityServiceClient } from "@/grpcweb";
import useAsyncEffect from "@/hooks/useAsyncEffect";
import useNavigateTo from "@/hooks/useNavigateTo";
import { cn } from "@/lib/utils";
import { memoStore, userStore } from "@/store";
import { activityNamePrefix } from "@/store/common";
import { Inbox, Inbox_Status } from "@/types/proto/api/v1/inbox_service";
import { Memo } from "@/types/proto/api/v1/memo_service";
import { User } from "@/types/proto/api/v1/user_service";
import { useTranslate } from "@/utils/i18n";
interface Props {
inbox: Inbox;
}
const MemoCommentMessage = observer(({ inbox }: Props) => {
const t = useTranslate();
const navigateTo = useNavigateTo();
const [relatedMemo, setRelatedMemo] = useState<Memo | undefined>(undefined);
const [sender, setSender] = useState<User | undefined>(undefined);
const [initialized, setInitialized] = useState<boolean>(false);
useAsyncEffect(async () => {
if (!inbox.activityId) {
return;
}
const activity = await activityServiceClient.getActivity({
name: `${activityNamePrefix}${inbox.activityId}`,
});
if (activity.payload?.memoComment) {
const memoCommentPayload = activity.payload.memoComment;
const memo = await memoStore.getOrFetchMemoByName(memoCommentPayload.relatedMemo, {
skipStore: true,
});
setRelatedMemo(memo);
const sender = await userStore.getOrFetchUserByName(inbox.sender);
setSender(sender);
setInitialized(true);
}
}, [inbox.activityId]);
const handleNavigateToMemo = async () => {
if (!relatedMemo) {
return;
}
navigateTo(`/${relatedMemo.name}`);
if (inbox.status === Inbox_Status.UNREAD) {
handleArchiveMessage(true);
}
};
const handleArchiveMessage = async (silence = false) => {
await userStore.updateInbox(
{
name: inbox.name,
status: Inbox_Status.ARCHIVED,
},
["status"],
);
if (!silence) {
toast.success(t("message.archived-successfully"));
}
};
return (
<div className="w-full flex flex-row justify-start items-start gap-3">
<div
className={cn(
"shrink-0 mt-2 p-2 rounded-full border",
inbox.status === Inbox_Status.UNREAD
? "border-primary text-primary bg-primary/10"
: "border-muted-foreground text-muted-foreground bg-popover",
)}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<MessageCircleIcon className="w-4 sm:w-5 h-auto" />
</TooltipTrigger>
<TooltipContent>
<p>Comment</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div
className={cn(
"border w-full p-2 px-3 rounded-lg flex flex-col justify-start items-start gap-1 border-border hover:bg-background",
inbox.status !== Inbox_Status.UNREAD && "opacity-60",
)}
>
{initialized ? (
<>
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm text-muted-foreground">{inbox.createTime?.toLocaleString()}</span>
<div>
{inbox.status === Inbox_Status.UNREAD && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<InboxIcon
className="w-4 h-auto cursor-pointer text-muted-foreground hover:text-primary"
onClick={() => handleArchiveMessage()}
/>
</TooltipTrigger>
<TooltipContent>
<p>{t("common.archive")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
<p
className="text-base leading-tight cursor-pointer text-muted-foreground hover:underline hover:text-primary"
onClick={handleNavigateToMemo}
>
{t("inbox.memo-comment", {
user: sender?.displayName || sender?.username,
memo: relatedMemo?.name,
interpolation: { escapeValue: false },
})}
</p>
</>
) : (
<div className="w-full flex flex-row justify-center items-center my-2">
<LoaderIcon className="animate-spin text-muted-foreground" />
</div>
)}
</div>
</div>
);
});
export default MemoCommentMessage;

View File

@@ -0,0 +1,61 @@
import { DivIcon, LatLng } from "leaflet";
import { MapPinIcon } from "lucide-react";
import { useEffect, useState } from "react";
import ReactDOMServer from "react-dom/server";
import { MapContainer, Marker, TileLayer, useMapEvents } from "react-leaflet";
const markerIcon = new DivIcon({
className: "relative border-none",
html: ReactDOMServer.renderToString(<MapPinIcon className="absolute bottom-1/2 -left-1/2" fill="pink" size={24} />),
});
interface MarkerProps {
position: LatLng | undefined;
onChange: (position: LatLng) => void;
readonly?: boolean;
}
const LocationMarker = (props: MarkerProps) => {
const [position, setPosition] = useState(props.position);
const map = useMapEvents({
click(e) {
if (props.readonly) {
return;
}
setPosition(e.latlng);
map.locate();
// Call the parent onChange function.
props.onChange(e.latlng);
},
locationfound() {},
});
useEffect(() => {
map.attributionControl.setPrefix("");
map.locate();
}, []);
return position === undefined ? null : <Marker position={position} icon={markerIcon}></Marker>;
};
interface MapProps {
readonly?: boolean;
latlng?: LatLng;
onChange?: (position: LatLng) => void;
}
const DEFAULT_CENTER_LAT_LNG = new LatLng(48.8584, 2.2945);
const LeafletMap = (props: MapProps) => {
const position = props.latlng || DEFAULT_CENTER_LAT_LNG;
return (
<MapContainer className="w-full h-72" center={position} zoom={13} scrollWheelZoom={false}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<LocationMarker position={position} readonly={props.readonly} onChange={props.onChange ? props.onChange : () => {}} />
</MapContainer>
);
};
export default LeafletMap;

View File

@@ -0,0 +1,31 @@
import { ExternalLinkIcon } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useTranslate } from "@/utils/i18n";
interface Props {
className?: string;
url: string;
title?: string;
}
const LearnMore: React.FC<Props> = (props: Props) => {
const { className, url, title } = props;
const t = useTranslate();
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<a className={`text-muted-foreground hover:text-primary ${className}`} href={url} target="_blank">
<ExternalLinkIcon className="w-4 h-auto" />
</a>
</TooltipTrigger>
<TooltipContent>
<p>{title ?? t("common.learn-more")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
export default LearnMore;

View File

@@ -0,0 +1,52 @@
import { GlobeIcon } from "lucide-react";
import { FC } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { locales } from "@/i18n";
interface Props {
value: Locale;
onChange: (locale: Locale) => void;
}
const LocaleSelect: FC<Props> = (props: Props) => {
const { onChange, value } = props;
const handleSelectChange = async (locale: Locale) => {
onChange(locale);
};
return (
<Select value={value} onValueChange={handleSelectChange}>
<SelectTrigger>
<div className="flex items-center gap-2">
<GlobeIcon className="w-4 h-auto" />
<SelectValue placeholder="Select language" />
</div>
</SelectTrigger>
<SelectContent>
{locales.map((locale) => {
try {
const languageName = new Intl.DisplayNames([locale], { type: "language" }).of(locale);
if (languageName) {
return (
<SelectItem key={locale} value={locale}>
{languageName.charAt(0).toUpperCase() + languageName.slice(1)}
</SelectItem>
);
}
} catch {
// do nth
}
return (
<SelectItem key={locale} value={locale}>
{locale}
</SelectItem>
);
})}
</SelectContent>
</Select>
);
};
export default LocaleSelect;

View File

@@ -0,0 +1,184 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { Memo } from "@/types/proto/api/v1/memo_service";
interface Props {
memoList: Memo[];
renderer: (memo: Memo) => JSX.Element;
prefixElement?: JSX.Element;
listMode?: boolean;
}
interface MemoItemProps {
memo: Memo;
renderer: (memo: Memo) => JSX.Element;
onHeightChange: (memoName: string, height: number) => void;
}
// Minimum width required to show more than one column
const MINIMUM_MEMO_VIEWPORT_WIDTH = 512;
const MemoItem = ({ memo, renderer, onHeightChange }: MemoItemProps) => {
const itemRef = useRef<HTMLDivElement>(null);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
useEffect(() => {
if (!itemRef.current) return;
const measureHeight = () => {
if (itemRef.current) {
const height = itemRef.current.offsetHeight;
onHeightChange(memo.name, height);
}
};
measureHeight();
// Set up ResizeObserver to track dynamic content changes (images, expanded text, etc.)
resizeObserverRef.current = new ResizeObserver(measureHeight);
resizeObserverRef.current.observe(itemRef.current);
return () => {
resizeObserverRef.current?.disconnect();
};
}, [memo.name, onHeightChange]);
return <div ref={itemRef}>{renderer(memo)}</div>;
};
/**
* Algorithm to distribute memos into columns based on height for balanced layout
* Uses greedy approach: always place next memo in the shortest column
*/
const distributeMemosToColumns = (
memos: Memo[],
columns: number,
itemHeights: Map<string, number>,
prefixElementHeight: number = 0,
): { distribution: number[][]; columnHeights: number[] } => {
// List mode: all memos in single column
if (columns === 1) {
const totalHeight = memos.reduce((sum, memo) => sum + (itemHeights.get(memo.name) || 0), prefixElementHeight);
return {
distribution: [Array.from({ length: memos.length }, (_, i) => i)],
columnHeights: [totalHeight],
};
}
// Initialize columns and heights
const distribution: number[][] = Array.from({ length: columns }, () => []);
const columnHeights: number[] = Array(columns).fill(0);
// Add prefix element height to first column
if (prefixElementHeight > 0) {
columnHeights[0] = prefixElementHeight;
}
// Distribute each memo to the shortest column
memos.forEach((memo, index) => {
const height = itemHeights.get(memo.name) || 0;
// Find column with minimum height
const shortestColumnIndex = columnHeights.indexOf(Math.min(...columnHeights));
distribution[shortestColumnIndex].push(index);
columnHeights[shortestColumnIndex] += height;
});
return { distribution, columnHeights };
};
const MasonryView = (props: Props) => {
const [columns, setColumns] = useState(1);
const [itemHeights, setItemHeights] = useState<Map<string, number>>(new Map());
const [distribution, setDistribution] = useState<number[][]>([[]]);
const containerRef = useRef<HTMLDivElement>(null);
const prefixElementRef = useRef<HTMLDivElement>(null);
// Calculate optimal number of columns based on container width
const calculateColumns = useCallback(() => {
if (!containerRef.current || props.listMode) return 1;
const containerWidth = containerRef.current.offsetWidth;
const scale = containerWidth / MINIMUM_MEMO_VIEWPORT_WIDTH;
return scale >= 2 ? Math.round(scale) : 1;
}, [props.listMode]);
// Recalculate memo distribution when layout changes
const redistributeMemos = useCallback(() => {
const prefixHeight = prefixElementRef.current?.offsetHeight || 0;
const { distribution: newDistribution } = distributeMemosToColumns(props.memoList, columns, itemHeights, prefixHeight);
setDistribution(newDistribution);
}, [props.memoList, columns, itemHeights]);
// Handle height changes from individual memo items
const handleHeightChange = useCallback(
(memoName: string, height: number) => {
setItemHeights((prevHeights) => {
const newItemHeights = new Map(prevHeights);
newItemHeights.set(memoName, height);
// Recalculate distribution with new heights
const prefixHeight = prefixElementRef.current?.offsetHeight || 0;
const { distribution: newDistribution } = distributeMemosToColumns(props.memoList, columns, newItemHeights, prefixHeight);
setDistribution(newDistribution);
return newItemHeights;
});
},
[props.memoList, columns],
);
// Handle window resize and calculate new column count
useEffect(() => {
const handleResize = () => {
if (!containerRef.current) return;
const newColumns = calculateColumns();
if (newColumns !== columns) {
setColumns(newColumns);
}
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [calculateColumns, columns]);
// Redistribute memos when columns, memo list, or heights change
useEffect(() => {
redistributeMemos();
}, [redistributeMemos]);
return (
<div
ref={containerRef}
className={cn("w-full grid gap-2")}
style={{
gridTemplateColumns: `repeat(${columns}, 1fr)`,
}}
>
{Array.from({ length: columns }).map((_, columnIndex) => (
<div key={columnIndex} className="min-w-0 mx-auto w-full max-w-2xl">
{/* Prefix element (like memo editor) goes in first column */}
{props.prefixElement && columnIndex === 0 && <div ref={prefixElementRef}>{props.prefixElement}</div>}
{distribution[columnIndex]?.map((memoIndex) => {
const memo = props.memoList[memoIndex];
return memo ? (
<MemoItem
key={`${memo.name}-${memo.displayTime}`}
memo={memo}
renderer={props.renderer}
onHeightChange={handleHeightChange}
/>
) : null;
})}
</div>
))}
</div>
);
};
export default MasonryView;

View File

@@ -0,0 +1,116 @@
# MasonryView - Height-Based Masonry Layout
## Overview
This improved MasonryView component implements a true masonry layout that distributes memo cards based on their actual rendered heights, creating a balanced waterfall-style layout instead of naive sequential distribution.
## Key Features
### 1. Height Measurement
- **MemoItem Wrapper**: Each memo is wrapped in a `MemoItem` component that measures its actual height
- **ResizeObserver**: Automatically detects height changes when content changes (e.g., images load, content expands)
- **Real-time Updates**: Heights are measured on mount and updated dynamically
### 2. Smart Distribution Algorithm
- **Shortest Column First**: Memos are assigned to the column with the smallest total height
- **Dynamic Balancing**: As new memos are added or heights change, the layout rebalances
- **Prefix Element Support**: Properly accounts for the MemoEditor height in the first column
### 3. Performance Optimizations
- **Memoized Callbacks**: `handleHeightChange` is memoized to prevent unnecessary re-renders
- **Efficient State Updates**: Only redistributes when necessary (memo list changes, column count changes)
- **ResizeObserver Cleanup**: Properly disconnects observers to prevent memory leaks
## Architecture
```
MasonryView
├── State Management
│ ├── columns: number of columns based on viewport width
│ ├── itemHeights: Map<memoName, height> for each memo
│ ├── columnHeights: current total height of each column
│ └── distribution: which memos belong to which column
├── MemoItem (for each memo)
│ ├── Ref for height measurement
│ ├── ResizeObserver for dynamic updates
│ └── Callback to parent on height changes
└── Distribution Algorithm
├── Finds shortest column
├── Assigns memo to that column
└── Updates column height tracking
```
## Usage
The component maintains the same API as before, so no changes are needed in consuming components:
```tsx
<MasonryView memoList={memos} renderer={(memo) => <MemoView memo={memo} />} prefixElement={<MemoEditor />} listMode={false} />
```
## Benefits vs Previous Implementation
### Before (Naive)
- Distributed memos by index: `memo[i % columns]`
- No consideration of actual heights
- Resulted in unbalanced columns
- Static layout that didn't adapt to content
### After (Height-Based)
- Distributes memos by actual rendered height
- Creates balanced columns with similar total heights
- Adapts to dynamic content changes
- Smoother visual layout
## Technical Implementation Details
### Height Measurement
```tsx
const measureHeight = () => {
if (itemRef.current) {
const height = itemRef.current.offsetHeight;
onHeightChange(memo.name, height);
}
};
```
### Distribution Algorithm
```tsx
const shortestColumnIndex = columnHeights.reduce(
(minIndex, currentHeight, currentIndex) => (currentHeight < columnHeights[minIndex] ? currentIndex : minIndex),
0,
);
```
### Dynamic Updates
- **Window Resize**: Recalculates column count and redistributes
- **Content Changes**: ResizeObserver triggers height remeasurement
- **Memo List Changes**: Redistributes all memos with new ordering
## Browser Support
- Modern browsers with ResizeObserver support
- Fallback behavior: Falls back to sequential distribution if ResizeObserver is not available
- CSS Grid support required for column layout
## Performance Considerations
1. **Initial Load**: Slight delay as heights are measured
2. **Memory Usage**: Stores height data for each memo
3. **Re-renders**: Optimized to only update when necessary
4. **Large Lists**: Scales well with proper virtualization (if needed in future)
## Future Enhancements
1. **Virtualization**: For very large memo lists
2. **Animation**: Smooth transitions when items change position
3. **Gap Optimization**: More sophisticated gap handling
4. **Estimated Heights**: Faster initial layout with height estimation

View File

@@ -0,0 +1,3 @@
import MasonryView from "./MasonryView";
export default MasonryView;

View File

@@ -0,0 +1,218 @@
import copy from "copy-to-clipboard";
import {
ArchiveIcon,
ArchiveRestoreIcon,
BookmarkMinusIcon,
BookmarkPlusIcon,
CopyIcon,
Edit3Icon,
MoreVerticalIcon,
TrashIcon,
SquareCheckIcon,
} from "lucide-react";
import { observer } from "mobx-react-lite";
import toast from "react-hot-toast";
import { useLocation } from "react-router-dom";
import { markdownServiceClient } from "@/grpcweb";
import useNavigateTo from "@/hooks/useNavigateTo";
import { memoStore, userStore } from "@/store";
import { State } from "@/types/proto/api/v1/common";
import { NodeType } from "@/types/proto/api/v1/markdown_service";
import { Memo } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
import { Button } from "./ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
interface Props {
memo: Memo;
readonly?: boolean;
className?: string;
onEdit?: () => void;
}
const checkHasCompletedTaskList = (memo: Memo) => {
for (const node of memo.nodes) {
if (node.type === NodeType.LIST && node.listNode?.children && node.listNode?.children?.length > 0) {
for (let j = 0; j < node.listNode.children.length; j++) {
if (node.listNode.children[j].type === NodeType.TASK_LIST_ITEM && node.listNode.children[j].taskListItemNode?.complete) {
return true;
}
}
}
}
return false;
};
const MemoActionMenu = observer((props: Props) => {
const { memo, readonly } = props;
const t = useTranslate();
const location = useLocation();
const navigateTo = useNavigateTo();
const hasCompletedTaskList = checkHasCompletedTaskList(memo);
const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`);
const isComment = Boolean(memo.parent);
const isArchived = memo.state === State.ARCHIVED;
const memoUpdatedCallback = () => {
// Refresh user stats.
userStore.setStatsStateId();
};
const handleTogglePinMemoBtnClick = async () => {
try {
if (memo.pinned) {
await memoStore.updateMemo(
{
name: memo.name,
pinned: false,
},
["pinned"],
);
} else {
await memoStore.updateMemo(
{
name: memo.name,
pinned: true,
},
["pinned"],
);
}
} catch {
// do nth
}
};
const handleEditMemoClick = () => {
if (props.onEdit) {
props.onEdit();
return;
}
};
const handleToggleMemoStatusClick = async () => {
const state = memo.state === State.ARCHIVED ? State.NORMAL : State.ARCHIVED;
const message = memo.state === State.ARCHIVED ? t("message.restored-successfully") : t("message.archived-successfully");
try {
await memoStore.updateMemo(
{
name: memo.name,
state,
},
["state"],
);
toast(message);
} catch (error: any) {
toast.error(error.details);
console.error(error);
return;
}
if (isInMemoDetailPage) {
navigateTo(memo.state === State.ARCHIVED ? "/" : "/archived");
}
memoUpdatedCallback();
};
const handleCopyLink = () => {
copy(`${window.location.origin}/${memo.name}`);
toast.success(t("message.succeed-copy-link"));
};
const handleDeleteMemoClick = async () => {
const confirmed = window.confirm(t("memo.delete-confirm"));
if (confirmed) {
await memoStore.deleteMemo(memo.name);
toast.success(t("message.deleted-successfully"));
if (isInMemoDetailPage) {
navigateTo("/");
}
memoUpdatedCallback();
}
};
const handleRemoveCompletedTaskListItemsClick = async () => {
const confirmed = window.confirm(t("memo.remove-completed-task-list-items-confirm"));
if (confirmed) {
const newNodes = JSON.parse(JSON.stringify(memo.nodes));
for (const node of newNodes) {
if (node.type === NodeType.LIST && node.listNode?.children?.length > 0) {
const children = node.listNode.children;
for (let i = 0; i < children.length; i++) {
if (children[i].type === NodeType.TASK_LIST_ITEM && children[i].taskListItemNode?.complete) {
// Remove completed taskList item and next line breaks
children.splice(i, 1);
if (children[i]?.type === NodeType.LINE_BREAK) {
children.splice(i, 1);
}
i--;
}
}
}
}
const { markdown } = await markdownServiceClient.restoreMarkdownNodes({ nodes: newNodes });
await memoStore.updateMemo(
{
name: memo.name,
content: markdown,
},
["content"],
);
toast.success(t("message.remove-completed-task-list-items-successfully"));
memoUpdatedCallback();
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-4">
<MoreVerticalIcon className="text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={2}>
{!readonly && !isArchived && (
<>
{!isComment && (
<DropdownMenuItem onClick={handleTogglePinMemoBtnClick}>
{memo.pinned ? <BookmarkMinusIcon className="w-4 h-auto" /> : <BookmarkPlusIcon className="w-4 h-auto" />}
{memo.pinned ? t("common.unpin") : t("common.pin")}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleEditMemoClick}>
<Edit3Icon className="w-4 h-auto" />
{t("common.edit")}
</DropdownMenuItem>
</>
)}
{!isArchived && (
<DropdownMenuItem onClick={handleCopyLink}>
<CopyIcon className="w-4 h-auto" />
{t("memo.copy-link")}
</DropdownMenuItem>
)}
{!readonly && (
<>
{!isArchived && !isComment && hasCompletedTaskList && (
<DropdownMenuItem onClick={handleRemoveCompletedTaskListItemsClick}>
<SquareCheckIcon className="w-4 h-auto" />
{t("memo.remove-completed-task-list-items")}
</DropdownMenuItem>
)}
{!isComment && (
<DropdownMenuItem onClick={handleToggleMemoStatusClick}>
{isArchived ? <ArchiveRestoreIcon className="w-4 h-auto" /> : <ArchiveIcon className="w-4 h-auto" />}
{isArchived ? t("common.restore") : t("common.archive")}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleDeleteMemoClick}>
<TrashIcon className="w-4 h-auto" />
{t("common.delete")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
});
export default MemoActionMenu;

View File

@@ -0,0 +1,36 @@
import { Attachment } from "@/types/proto/api/v1/attachment_service";
import { getAttachmentUrl } from "@/utils/attachment";
import AttachmentIcon from "./AttachmentIcon";
interface Props {
attachment: Attachment;
className?: string;
}
const MemoAttachment: React.FC<Props> = (props: Props) => {
const { className, attachment } = props;
const attachmentUrl = getAttachmentUrl(attachment);
const handlePreviewBtnClick = () => {
window.open(attachmentUrl);
};
return (
<div
className={`w-auto flex flex-row justify-start items-center text-muted-foreground hover:text-foreground hover:bg-accent rounded px-2 py-1 transition-colors ${className}`}
>
{attachment.type.startsWith("audio") ? (
<audio src={attachmentUrl} controls></audio>
) : (
<>
<AttachmentIcon className="w-4! h-4! mr-1" attachment={attachment} />
<span className="text-sm max-w-[256px] truncate cursor-pointer" onClick={handlePreviewBtnClick}>
{attachment.filename}
</span>
</>
)}
</div>
);
};
export default MemoAttachment;

View File

@@ -0,0 +1,112 @@
import { memo, useState } from "react";
import { cn } from "@/lib/utils";
import { Attachment } from "@/types/proto/api/v1/attachment_service";
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
import MemoAttachment from "./MemoAttachment";
import { PreviewImageDialog } from "./PreviewImageDialog";
const MemoAttachmentListView = ({ attachments = [] }: { attachments: Attachment[] }) => {
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number }>({
open: false,
urls: [],
index: 0,
});
const mediaAttachments: Attachment[] = [];
const otherAttachments: Attachment[] = [];
attachments.forEach((attachment) => {
const type = getAttachmentType(attachment);
if (type === "image/*" || type === "video/*") {
mediaAttachments.push(attachment);
return;
}
otherAttachments.push(attachment);
});
const handleImageClick = (imgUrl: string) => {
const imgUrls = mediaAttachments
.filter((attachment) => getAttachmentType(attachment) === "image/*")
.map((attachment) => getAttachmentUrl(attachment));
const index = imgUrls.findIndex((url) => url === imgUrl);
setPreviewImage({ open: true, urls: imgUrls, index });
};
const MediaCard = ({ attachment, className }: { attachment: Attachment; className?: string }) => {
const type = getAttachmentType(attachment);
const attachmentUrl = getAttachmentUrl(attachment);
if (type === "image/*") {
return (
<img
className={cn("cursor-pointer h-full w-auto rounded-lg border border-border/60 object-contain transition-colors", className)}
src={attachment.externalLink ? attachmentUrl : attachmentUrl + "?thumbnail=true"}
onError={(e) => {
// Fallback to original image if thumbnail fails
const target = e.target as HTMLImageElement;
if (target.src.includes("?thumbnail=true")) {
console.warn("Thumbnail failed, falling back to original image:", attachmentUrl);
target.src = attachmentUrl;
}
}}
onClick={() => handleImageClick(attachmentUrl)}
decoding="async"
loading="lazy"
/>
);
} else if (type === "video/*") {
return (
<video
className={cn(
"cursor-pointer h-full w-auto rounded-lg border border-border/60 object-contain bg-popover transition-colors",
className,
)}
preload="metadata"
crossOrigin="anonymous"
src={attachmentUrl}
controls
/>
);
} else {
return <></>;
}
};
const MediaList = ({ attachments = [] }: { attachments: Attachment[] }) => {
const cards = attachments.map((attachment) => (
<div key={attachment.name} className="max-w-[70%] grow flex flex-col justify-start items-start shrink-0">
<MediaCard className="max-h-64 grow" attachment={attachment} />
</div>
));
return <div className="w-full flex flex-row justify-start overflow-auto gap-2">{cards}</div>;
};
const OtherList = ({ attachments = [] }: { attachments: Attachment[] }) => {
if (attachments.length === 0) return <></>;
return (
<div className="w-full flex flex-row justify-start overflow-auto gap-2">
{otherAttachments.map((attachment) => (
<MemoAttachment key={attachment.name} attachment={attachment} />
))}
</div>
);
};
return (
<>
{mediaAttachments.length > 0 && <MediaList attachments={mediaAttachments} />}
<OtherList attachments={otherAttachments} />
<PreviewImageDialog
open={previewImage.open}
onOpenChange={(open) => setPreviewImage((prev) => ({ ...prev, open }))}
imgUrls={previewImage.urls}
initialIndex={previewImage.index}
/>
</>
);
};
export default memo(MemoAttachmentListView);

View File

@@ -0,0 +1,19 @@
import { Node } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
import { BaseProps } from "./types";
interface Props extends BaseProps {
children: Node[];
}
const Blockquote: React.FC<Props> = ({ children }: Props) => {
return (
<blockquote className="p-2 border-l-4 rounded border-border bg-muted/50 text-muted-foreground">
{children.map((child, index) => (
<Renderer key={`${child.type}-${index}`} index={String(index)} node={child} />
))}
</blockquote>
);
};
export default Blockquote;

View File

@@ -0,0 +1,19 @@
import { Node } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
interface Props {
symbol: string;
children: Node[];
}
const Bold: React.FC<Props> = ({ children }: Props) => {
return (
<strong>
{children.map((child, index) => (
<Renderer key={`${child.type}-${index}`} index={String(index)} node={child} />
))}
</strong>
);
};
export default Bold;

View File

@@ -0,0 +1,14 @@
interface Props {
symbol: string;
content: string;
}
const BoldItalic: React.FC<Props> = ({ content }: Props) => {
return (
<strong>
<em>{content}</em>
</strong>
);
};
export default BoldItalic;

View File

@@ -0,0 +1,9 @@
interface Props {
content: string;
}
const Code: React.FC<Props> = ({ content }: Props) => {
return <code className="inline break-all px-1 font-mono text-sm rounded bg-muted text-muted-foreground">{content}</code>;
};
export default Code;

View File

@@ -0,0 +1,80 @@
import copy from "copy-to-clipboard";
import hljs from "highlight.js";
import { CopyIcon } from "lucide-react";
import { useCallback, useMemo } from "react";
import toast from "react-hot-toast";
import { cn } from "@/lib/utils";
import MermaidBlock from "./MermaidBlock";
import { BaseProps } from "./types";
// Special languages that are rendered differently.
enum SpecialLanguage {
HTML = "__html",
MERMAID = "mermaid",
}
interface Props extends BaseProps {
language: string;
content: string;
}
const CodeBlock: React.FC<Props> = ({ language, content }: Props) => {
const formatedLanguage = useMemo(() => (language || "").toLowerCase() || "text", [language]);
// Users can set Markdown code blocks as `__html` to render HTML directly.
if (formatedLanguage === SpecialLanguage.HTML) {
return (
<div
className="w-full overflow-auto my-2!"
dangerouslySetInnerHTML={{
__html: content,
}}
/>
);
} else if (formatedLanguage === SpecialLanguage.MERMAID) {
return <MermaidBlock content={content} />;
}
const highlightedCode = useMemo(() => {
try {
const lang = hljs.getLanguage(formatedLanguage);
if (lang) {
return hljs.highlight(content, {
language: formatedLanguage,
}).value;
}
} catch {
// Skip error and use default highlighted code.
}
// Escape any HTML entities when rendering original content.
return Object.assign(document.createElement("span"), {
textContent: content,
}).innerHTML;
}, [formatedLanguage, content]);
const handleCopyButtonClick = useCallback(() => {
copy(content);
toast.success("Copied to clipboard!");
}, [content]);
return (
<div className="w-full my-1 bg-card border border-border rounded-md relative">
<div className="w-full px-2 py-0.5 flex flex-row justify-between items-center text-muted-foreground">
<span className="text-xs font-mono">{formatedLanguage}</span>
<CopyIcon className="w-3 h-auto cursor-pointer hover:text-foreground" onClick={handleCopyButtonClick} />
</div>
<div className="overflow-auto">
<pre className={cn("no-wrap overflow-auto", "w-full p-2 bg-muted/50 relative")}>
<code
className={cn(`language-${formatedLanguage}`, "block text-sm leading-5 text-foreground")}
dangerouslySetInnerHTML={{ __html: highlightedCode }}
></code>
</pre>
</div>
</div>
);
};
export default CodeBlock;

View File

@@ -0,0 +1,62 @@
import { observer } from "mobx-react-lite";
import { useEffect } from "react";
import MemoAttachmentListView from "@/components/MemoAttachmentListView";
import useLoading from "@/hooks/useLoading";
import { cn } from "@/lib/utils";
import { attachmentStore } from "@/store";
import Error from "./Error";
interface Props {
resourceId: string;
params: string;
}
const getAdditionalClassNameWithParams = (params: URLSearchParams) => {
const additionalClassNames = [];
if (params.has("align")) {
const align = params.get("align");
if (align === "center") {
additionalClassNames.push("mx-auto");
}
}
if (params.has("size")) {
const size = params.get("size");
if (size === "lg") {
additionalClassNames.push("w-full");
} else if (size === "md") {
additionalClassNames.push("w-2/3");
} else if (size === "sm") {
additionalClassNames.push("w-1/3");
}
}
if (params.has("width")) {
const width = params.get("width");
additionalClassNames.push(`w-[${width}]`);
}
return additionalClassNames.join(" ");
};
const EmbeddedAttachment = observer(({ resourceId: uid, params: paramsStr }: Props) => {
const loadingState = useLoading();
const attachment = attachmentStore.getAttachmentByName(uid);
const params = new URLSearchParams(paramsStr);
useEffect(() => {
attachmentStore.fetchAttachmentByName(`attachments/${uid}`).finally(() => loadingState.setFinish());
}, [uid]);
if (loadingState.isLoading) {
return null;
}
if (!attachment) {
return <Error message={`Attachment not found: ${uid}`} />;
}
return (
<div className={cn("max-w-full", getAdditionalClassNameWithParams(params))}>
<MemoAttachmentListView attachments={[attachment]} />
</div>
);
});
export default EmbeddedAttachment;

View File

@@ -0,0 +1,97 @@
import copy from "copy-to-clipboard";
import { ArrowUpRightIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useContext, useEffect } from "react";
import toast from "react-hot-toast";
import { Link } from "react-router-dom";
import MemoAttachmentListView from "@/components/MemoAttachmentListView";
import useLoading from "@/hooks/useLoading";
import { cn } from "@/lib/utils";
import { memoStore } from "@/store";
import { extractMemoIdFromName } from "@/store/common";
import MemoContent from "..";
import { RendererContext } from "../types";
import Error from "./Error";
interface Props {
resourceId: string;
params: string;
}
const EmbeddedMemo = observer(({ resourceId: uid, params: paramsStr }: Props) => {
const context = useContext(RendererContext);
const loadingState = useLoading();
const memoName = `memos/${uid}`;
const memo = memoStore.getMemoByName(memoName);
useEffect(() => {
memoStore.getOrFetchMemoByName(memoName).finally(() => loadingState.setFinish());
}, [memoName]);
if (loadingState.isLoading) {
return null;
}
if (!memo) {
return <Error message={`Memo not found: ${uid}`} />;
}
const params = new URLSearchParams(paramsStr);
const useSnippet = params.has("snippet");
const inlineMode = params.has("inline");
if (!useSnippet && (memo.name === context.memoName || context.embeddedMemos.has(memoName))) {
return <Error message={`Nested Rendering Error: ![[${memoName}]]`} />;
}
// Add the memo to the set of embedded memos. This is used to prevent infinite loops when a memo embeds itself.
context.embeddedMemos.add(memoName);
const contentNode = useSnippet ? (
<div className={cn("text-muted-foreground", inlineMode ? "" : "line-clamp-3")}>{memo.snippet}</div>
) : (
<>
<MemoContent
contentClassName={inlineMode ? "" : "line-clamp-3"}
memoName={memo.name}
nodes={memo.nodes}
embeddedMemos={context.embeddedMemos}
/>
<MemoAttachmentListView attachments={memo.attachments} />
</>
);
if (inlineMode) {
return <div className="w-full">{contentNode}</div>;
}
const copyMemoUid = (uid: string) => {
copy(uid);
toast.success("Copied memo UID to clipboard");
};
return (
<div className="relative flex flex-col justify-start items-start w-full px-3 py-2 bg-card rounded-lg border border-border hover:shadow-md transition-shadow">
<div className="w-full mb-1 flex flex-row justify-between items-center text-muted-foreground">
<div className="text-sm leading-5 select-none">
<relative-time datetime={memo.displayTime?.toISOString()} format="datetime"></relative-time>
</div>
<div className="flex justify-end items-center gap-1">
<span
className="text-xs text-muted-foreground leading-5 cursor-pointer hover:text-foreground"
onClick={() => copyMemoUid(extractMemoIdFromName(memo.name))}
>
{extractMemoIdFromName(memo.name).slice(0, 6)}
</span>
<Link
className="text-muted-foreground hover:text-foreground"
to={`/${memo.name}`}
state={{ from: context.parentPage }}
viewTransition
>
<ArrowUpRightIcon className="w-5 h-auto" />
</Link>
</div>
</div>
{contentNode}
</div>
);
});
export default EmbeddedMemo;

View File

@@ -0,0 +1,9 @@
interface Props {
message: string;
}
const Error = ({ message }: Props) => {
return <p className="font-mono text-sm text-destructive">{message}</p>;
};
export default Error;

View File

@@ -0,0 +1,25 @@
import EmbeddedAttachment from "./EmbeddedAttachment";
import EmbeddedMemo from "./EmbeddedMemo";
import Error from "./Error";
interface Props {
resourceName: string;
params: string;
}
const extractResourceTypeAndId = (resourceName: string) => {
const [resourceType, resourceId] = resourceName.split("/");
return { resourceType, resourceId };
};
const EmbeddedContent = ({ resourceName, params }: Props) => {
const { resourceType, resourceId } = extractResourceTypeAndId(resourceName);
if (resourceType === "memos") {
return <EmbeddedMemo resourceId={resourceId} params={params} />;
} else if (resourceType === "attachments") {
return <EmbeddedAttachment resourceId={resourceId} params={params} />;
}
return <Error message={`Unknown resource: ${resourceName}`} />;
};
export default EmbeddedContent;

View File

@@ -0,0 +1,9 @@
interface Props {
symbol: string;
}
const EscapingCharacter: React.FC<Props> = ({ symbol }: Props) => {
return <span>{symbol}</span>;
};
export default EscapingCharacter;

View File

@@ -0,0 +1,12 @@
import { createElement } from "react";
interface Props {
tagName: string;
attributes: { [key: string]: string };
}
const HTMLElement: React.FC<Props> = ({ tagName, attributes }: Props) => {
return createElement(tagName, attributes);
};
export default HTMLElement;

View File

@@ -0,0 +1,34 @@
import { Node } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
import { BaseProps } from "./types";
interface Props extends BaseProps {
level: number;
children: Node[];
}
const Heading: React.FC<Props> = ({ level, children }: Props) => {
const Head = `h${level}` as keyof JSX.IntrinsicElements;
const className = (() => {
switch (level) {
case 1:
return "text-5xl leading-normal font-bold";
case 2:
return "text-3xl leading-normal font-medium";
case 3:
return "text-xl leading-normal font-medium";
case 4:
return "text-lg font-bold";
}
})();
return (
<Head className={className}>
{children.map((child, index) => (
<Renderer key={`${child.type}-${index}`} index={String(index)} node={child} />
))}
</Head>
);
};
export default Heading;

View File

@@ -0,0 +1,9 @@
interface Props {
content: string;
}
const Highlight: React.FC<Props> = ({ content }: Props) => {
return <mark className="bg-yellow-200 text-foreground px-1 rounded">{content}</mark>;
};
export default Highlight;

View File

@@ -0,0 +1,12 @@
import { Separator } from "@/components/ui/separator";
import { BaseProps } from "./types";
interface Props extends BaseProps {
symbol: string;
}
const HorizontalRule: React.FC<Props> = () => {
return <Separator className="my-3!" />;
};
export default HorizontalRule;

View File

@@ -0,0 +1,10 @@
interface Props {
altText: string;
url: string;
}
const Image: React.FC<Props> = ({ altText, url }: Props) => {
return <img src={url} alt={altText} decoding="async" loading="lazy" />;
};
export default Image;

View File

@@ -0,0 +1,19 @@
import { Node } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
interface Props {
symbol: string;
children: Node[];
}
const Italic: React.FC<Props> = ({ children }: Props) => {
return (
<em>
{children.map((child, index) => (
<Renderer key={index} index={index.toString()} node={child} />
))}
</em>
);
};
export default Italic;

View File

@@ -0,0 +1,5 @@
const LineBreak = () => {
return <br />;
};
export default LineBreak;

View File

@@ -0,0 +1,81 @@
import { useState } from "react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { markdownServiceClient } from "@/grpcweb";
import { workspaceStore } from "@/store";
import { LinkMetadata, Node } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
interface Props {
url: string;
content?: Node[];
}
const getFaviconWithGoogleS2 = (url: string) => {
try {
const urlObject = new URL(url);
return `https://www.google.com/s2/favicons?sz=128&domain=${urlObject.hostname}`;
} catch {
return undefined;
}
};
const Link: React.FC<Props> = ({ content, url }: Props) => {
const workspaceMemoRelatedSetting = workspaceStore.state.memoRelatedSetting;
const [initialized, setInitialized] = useState<boolean>(false);
const [showTooltip, setShowTooltip] = useState<boolean>(false);
const [linkMetadata, setLinkMetadata] = useState<LinkMetadata | undefined>();
const handleMouseEnter = async () => {
if (!workspaceMemoRelatedSetting.enableLinkPreview) {
return;
}
setShowTooltip(true);
if (!initialized) {
try {
const linkMetadata = await markdownServiceClient.getLinkMetadata({ link: url });
setLinkMetadata(linkMetadata);
} catch (error) {
console.error("Error fetching URL metadata:", error);
}
setInitialized(true);
}
};
return (
<TooltipProvider>
<Tooltip open={showTooltip}>
<TooltipTrigger asChild>
<a
className="underline text-primary hover:text-primary/80"
target="_blank"
href={url}
rel="noopener noreferrer"
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setShowTooltip(false)}
>
{content ? content.map((child, index) => <Renderer key={`${child.type}-${index}`} index={String(index)} node={child} />) : url}
</a>
</TooltipTrigger>
{linkMetadata && (
<TooltipContent className="w-full max-w-64 sm:max-w-96 p-1">
<div className="w-full flex flex-col">
<div className="w-full flex flex-row justify-start items-center gap-1">
<img className="w-5 h-5 rounded" src={getFaviconWithGoogleS2(url)} alt={linkMetadata?.title} />
<h3 className="text-base truncate">{linkMetadata?.title}</h3>
</div>
{linkMetadata.description && (
<p className="mt-1 w-full text-sm leading-snug opacity-80 line-clamp-3">{linkMetadata.description}</p>
)}
{linkMetadata.image && (
<img className="mt-1 w-full h-32 object-cover rounded" src={linkMetadata.image} alt={linkMetadata.title} />
)}
</div>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
);
};
export default Link;

View File

@@ -0,0 +1,63 @@
import { head } from "lodash-es";
import React from "react";
import { cn } from "@/lib/utils";
import { ListNode_Kind, Node, NodeType } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
interface Props {
index: string;
kind: ListNode_Kind;
indent: number;
children: Node[];
}
const List: React.FC<Props> = ({ kind, indent, children }: Props) => {
let prevNode: Node | null = null;
let skipNextLineBreakFlag = false;
const getListContainer = () => {
switch (kind) {
case ListNode_Kind.ORDERED:
return "ol";
case ListNode_Kind.UNORDERED:
return "ul";
case ListNode_Kind.DESCRIPTION:
return "dl";
default:
return "div";
}
};
const getAttributes = () => {
const attrs: any = {
style: { paddingLeft: `${indent > 0 ? indent * 10 : 20}px` },
};
const firstChild = head(children);
if (firstChild?.type === NodeType.ORDERED_LIST_ITEM) {
attrs.start = firstChild.orderedListItemNode?.number;
} else if (firstChild?.type === NodeType.TASK_LIST_ITEM) {
attrs.style = { paddingLeft: `${indent * 8}px` };
}
return attrs;
};
return React.createElement(
getListContainer(),
{
className: cn(kind === ListNode_Kind.ORDERED ? "list-decimal" : kind === ListNode_Kind.UNORDERED ? "list-disc" : "list-none"),
...getAttributes(),
},
children.map((child, index) => {
if (prevNode?.type !== NodeType.LINE_BREAK && child.type === NodeType.LINE_BREAK && skipNextLineBreakFlag) {
skipNextLineBreakFlag = false;
return null;
}
prevNode = child;
skipNextLineBreakFlag = true;
return <Renderer key={`${child.type}-${index}`} index={String(index)} node={child} />;
}),
);
};
export default List;

View File

@@ -0,0 +1,14 @@
import TeX from "@matejmazur/react-katex";
import { cn } from "@/lib/utils";
import "katex/dist/katex.min.css";
interface Props {
content: string;
block?: boolean;
}
const Math: React.FC<Props> = ({ content, block }: Props) => {
return <TeX className={cn("max-w-full", block ? "w-full block" : "inline text-sm")} block={block} math={content}></TeX>;
};
export default Math;

View File

@@ -0,0 +1,55 @@
import { useEffect, useRef, useState } from "react";
interface Props {
content: string;
}
const MermaidBlock: React.FC<Props> = ({ content }: Props) => {
const [colorMode, setColorMode] = useState<"light" | "dark">("light");
const mermaidDockBlock = useRef<null>(null);
// Simple dark mode detection
useEffect(() => {
const updateMode = () => {
const isDark = document.documentElement.classList.contains("dark");
setColorMode(isDark ? "dark" : "light");
};
updateMode();
// Watch for changes to the dark class
const observer = new MutationObserver(updateMode);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
useEffect(() => {
// Dynamically import mermaid to ensure compatibility with Vite
const initializeMermaid = async () => {
const mermaid = (await import("mermaid")).default;
mermaid.initialize({ startOnLoad: false, theme: colorMode == "dark" ? "dark" : "default" });
if (mermaidDockBlock.current) {
mermaid.run({
nodes: [mermaidDockBlock.current],
});
}
};
initializeMermaid();
}, [content]);
return (
<pre
ref={mermaidDockBlock}
className="w-full p-2 whitespace-pre-wrap relative bg-card border border-border rounded text-card-foreground"
>
{content}
</pre>
);
};
export default MermaidBlock;

View File

@@ -0,0 +1,21 @@
import { Node } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
import { BaseProps } from "./types";
interface Props extends BaseProps {
number: string;
indent: number;
children: Node[];
}
const OrderedListItem: React.FC<Props> = ({ children }: Props) => {
return (
<li>
{children.map((child, index) => (
<Renderer key={`${child.type}-${index}`} index={String(index)} node={child} />
))}
</li>
);
};
export default OrderedListItem;

View File

@@ -0,0 +1,19 @@
import { Node } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
import { BaseProps } from "./types";
interface Props extends BaseProps {
children: Node[];
}
const Paragraph: React.FC<Props> = ({ children }: Props) => {
return (
<p>
{children.map((child, index) => (
<Renderer key={`${child.type}-${index}`} index={String(index)} node={child} />
))}
</p>
);
};
export default Paragraph;

View File

@@ -0,0 +1,9 @@
interface Props {
message: string;
}
const Error = ({ message }: Props) => {
return <p className="font-mono text-sm text-destructive">{message}</p>;
};
export default Error;

View File

@@ -0,0 +1,55 @@
import { observer } from "mobx-react-lite";
import { useContext, useEffect } from "react";
import useLoading from "@/hooks/useLoading";
import useNavigateTo from "@/hooks/useNavigateTo";
import { memoStore } from "@/store";
import { memoNamePrefix } from "@/store/common";
import { RendererContext } from "../types";
import Error from "./Error";
interface Props {
resourceId: string;
params: string;
}
const ReferencedMemo = observer(({ resourceId: uid, params: paramsStr }: Props) => {
const navigateTo = useNavigateTo();
const loadingState = useLoading();
const memoName = `${memoNamePrefix}${uid}`;
const memo = memoStore.getMemoByName(memoName);
const params = new URLSearchParams(paramsStr);
const context = useContext(RendererContext);
useEffect(() => {
memoStore.getOrFetchMemoByName(memoName).finally(() => loadingState.setFinish());
}, [memoName]);
if (loadingState.isLoading) {
return null;
}
if (!memo) {
return <Error message={`Memo not found: ${uid}`} />;
}
const paramsText = params.has("text") ? params.get("text") : undefined;
const displayContent = paramsText || (memo.snippet.length > 12 ? `${memo.snippet.slice(0, 12)}...` : memo.snippet);
const handleGotoMemoDetailPage = () => {
navigateTo(`/${memo.name}`, {
state: {
from: context.parentPage,
},
});
};
return (
<span
className="text-primary whitespace-nowrap cursor-pointer underline break-all hover:text-primary/80 decoration-1"
onClick={handleGotoMemoDetailPage}
>
{displayContent}
</span>
);
});
export default ReferencedMemo;

View File

@@ -0,0 +1,22 @@
import Error from "./Error";
import ReferencedMemo from "./ReferencedMemo";
interface Props {
resourceName: string;
params: string;
}
const extractResourceTypeAndId = (resourceName: string) => {
const [resourceType, resourceId] = resourceName.split("/");
return { resourceType, resourceId };
};
const ReferencedContent = ({ resourceName, params }: Props) => {
const { resourceType, resourceId } = extractResourceTypeAndId(resourceName);
if (resourceType === "memos") {
return <ReferencedMemo resourceId={resourceId} params={params} />;
}
return <Error message={`Unknown resource: ${resourceName}`} />;
};
export default ReferencedContent;

View File

@@ -0,0 +1,139 @@
import {
AutoLinkNode,
BlockquoteNode,
BoldItalicNode,
BoldNode,
CodeBlockNode,
CodeNode,
EmbeddedContentNode,
EscapingCharacterNode,
HeadingNode,
HighlightNode,
HorizontalRuleNode,
HTMLElementNode,
ImageNode,
ItalicNode,
LinkNode,
ListNode,
MathBlockNode,
MathNode,
Node,
NodeType,
OrderedListItemNode,
ParagraphNode,
ReferencedContentNode,
SpoilerNode,
StrikethroughNode,
SubscriptNode,
SuperscriptNode,
TableNode,
TagNode,
TaskListItemNode,
TextNode,
UnorderedListItemNode,
} from "@/types/proto/api/v1/markdown_service";
import Blockquote from "./Blockquote";
import Bold from "./Bold";
import BoldItalic from "./BoldItalic";
import Code from "./Code";
import CodeBlock from "./CodeBlock";
import EmbeddedContent from "./EmbeddedContent";
import EscapingCharacter from "./EscapingCharacter";
import HTMLElement from "./HTMLElement";
import Heading from "./Heading";
import Highlight from "./Highlight";
import HorizontalRule from "./HorizontalRule";
import Image from "./Image";
import Italic from "./Italic";
import LineBreak from "./LineBreak";
import Link from "./Link";
import List from "./List";
import Math from "./Math";
import OrderedListItem from "./OrderedListItem";
import Paragraph from "./Paragraph";
import ReferencedContent from "./ReferencedContent";
import Spoiler from "./Spoiler";
import Strikethrough from "./Strikethrough";
import Subscript from "./Subscript";
import Superscript from "./Superscript";
import Table from "./Table";
import Tag from "./Tag";
import TaskListItem from "./TaskListItem";
import Text from "./Text";
import UnorderedListItem from "./UnorderedListItem";
interface Props {
index: string;
node: Node;
}
const Renderer: React.FC<Props> = ({ index, node }: Props) => {
switch (node.type) {
case NodeType.LINE_BREAK:
return <LineBreak />;
case NodeType.PARAGRAPH:
return <Paragraph index={index} {...(node.paragraphNode as ParagraphNode)} />;
case NodeType.CODE_BLOCK:
return <CodeBlock index={index} {...(node.codeBlockNode as CodeBlockNode)} />;
case NodeType.HEADING:
return <Heading index={index} {...(node.headingNode as HeadingNode)} />;
case NodeType.HORIZONTAL_RULE:
return <HorizontalRule index={index} {...(node.horizontalRuleNode as HorizontalRuleNode)} />;
case NodeType.BLOCKQUOTE:
return <Blockquote index={index} {...(node.blockquoteNode as BlockquoteNode)} />;
case NodeType.LIST:
return <List index={index} {...(node.listNode as ListNode)} />;
case NodeType.ORDERED_LIST_ITEM:
return <OrderedListItem index={index} {...(node.orderedListItemNode as OrderedListItemNode)} />;
case NodeType.UNORDERED_LIST_ITEM:
return <UnorderedListItem {...(node.unorderedListItemNode as UnorderedListItemNode)} />;
case NodeType.TASK_LIST_ITEM:
return <TaskListItem index={index} node={node} {...(node.taskListItemNode as TaskListItemNode)} />;
case NodeType.MATH_BLOCK:
return <Math {...(node.mathBlockNode as MathBlockNode)} block={true} />;
case NodeType.TABLE:
return <Table index={index} {...(node.tableNode as TableNode)} />;
case NodeType.EMBEDDED_CONTENT:
return <EmbeddedContent {...(node.embeddedContentNode as EmbeddedContentNode)} />;
case NodeType.TEXT:
return <Text {...(node.textNode as TextNode)} />;
case NodeType.BOLD:
return <Bold {...(node.boldNode as BoldNode)} />;
case NodeType.ITALIC:
return <Italic {...(node.italicNode as ItalicNode)} />;
case NodeType.BOLD_ITALIC:
return <BoldItalic {...(node.boldItalicNode as BoldItalicNode)} />;
case NodeType.CODE:
return <Code {...(node.codeNode as CodeNode)} />;
case NodeType.IMAGE:
return <Image {...(node.imageNode as ImageNode)} />;
case NodeType.LINK:
return <Link {...(node.linkNode as LinkNode)} />;
case NodeType.AUTO_LINK:
return <Link {...(node.autoLinkNode as AutoLinkNode)} />;
case NodeType.TAG:
return <Tag {...(node.tagNode as TagNode)} />;
case NodeType.STRIKETHROUGH:
return <Strikethrough {...(node.strikethroughNode as StrikethroughNode)} />;
case NodeType.MATH:
return <Math {...(node.mathNode as MathNode)} />;
case NodeType.HIGHLIGHT:
return <Highlight {...(node.highlightNode as HighlightNode)} />;
case NodeType.ESCAPING_CHARACTER:
return <EscapingCharacter {...(node.escapingCharacterNode as EscapingCharacterNode)} />;
case NodeType.SUBSCRIPT:
return <Subscript {...(node.subscriptNode as SubscriptNode)} />;
case NodeType.SUPERSCRIPT:
return <Superscript {...(node.superscriptNode as SuperscriptNode)} />;
case NodeType.REFERENCED_CONTENT:
return <ReferencedContent {...(node.referencedContentNode as ReferencedContentNode)} />;
case NodeType.SPOILER:
return <Spoiler {...(node.spoilerNode as SpoilerNode)} />;
case NodeType.HTML_ELEMENT:
return <HTMLElement {...(node.htmlElementNode as HTMLElementNode)} />;
default:
return null;
}
};
export default Renderer;

View File

@@ -0,0 +1,21 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
interface Props {
content: string;
}
const Spoiler: React.FC<Props> = ({ content }: Props) => {
const [isRevealed, setIsRevealed] = useState(false);
return (
<span
className={cn("inline cursor-pointer select-none", isRevealed ? "" : "bg-muted text-muted")}
onClick={() => setIsRevealed(!isRevealed)}
>
<span className={cn(isRevealed ? "opacity-100" : "opacity-0")}>{content}</span>
</span>
);
};
export default Spoiler;

View File

@@ -0,0 +1,9 @@
interface Props {
content: string;
}
const Strikethrough: React.FC<Props> = ({ content }: Props) => {
return <del>{content}</del>;
};
export default Strikethrough;

View File

@@ -0,0 +1,9 @@
interface Props {
content: string;
}
const Subscript: React.FC<Props> = ({ content }: Props) => {
return <sub>{content}</sub>;
};
export default Subscript;

View File

@@ -0,0 +1,9 @@
interface Props {
content: string;
}
const Superscript: React.FC<Props> = ({ content }: Props) => {
return <sup>{content}</sup>;
};
export default Superscript;

View File

@@ -0,0 +1,37 @@
import { Node, TableNode_Row } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
interface Props {
index: string;
header: Node[];
rows: TableNode_Row[];
}
const Table = ({ header, rows }: Props) => {
return (
<table className="w-auto max-w-full border border-border divide-y divide-border">
<thead className="text-sm font-medium leading-5 text-left text-foreground">
<tr className="divide-x divide-border">
{header.map((h, i) => (
<th key={i} className="py-1 px-2">
<Renderer key={`${h.type}-${i}`} index={String(i)} node={h} />
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border text-sm leading-5 text-left text-foreground">
{rows.map((row, i) => (
<tr key={i} className="divide-x divide-border">
{row.cells.map((r, j) => (
<td key={j} className="py-1 px-2">
<Renderer key={`${r.type}-${i}-${j}`} index={String(j)} node={r} />
</td>
))}
</tr>
))}
</tbody>
</table>
);
};
export default Table;

View File

@@ -0,0 +1,60 @@
import { observer } from "mobx-react-lite";
import { useContext } from "react";
import { useLocation } from "react-router-dom";
import useNavigateTo from "@/hooks/useNavigateTo";
import { cn } from "@/lib/utils";
import { Routes } from "@/router";
import { memoFilterStore } from "@/store";
import { stringifyFilters, MemoFilter } from "@/store/memoFilter";
import { RendererContext } from "./types";
interface Props {
content: string;
}
const Tag = observer(({ content }: Props) => {
const context = useContext(RendererContext);
const location = useLocation();
const navigateTo = useNavigateTo();
const handleTagClick = () => {
if (context.disableFilter) {
return;
}
// 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 = context.parentPage || Routes.ROOT;
const searchParams = new URLSearchParams();
searchParams.set("filter", stringifyFilters([{ factor: "tagSearch", value: content }]));
navigateTo(`${pathname}?${searchParams.toString()}`);
return;
}
const isActive = memoFilterStore.getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === content);
if (isActive) {
memoFilterStore.removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === content);
} else {
memoFilterStore.addFilter({
factor: "tagSearch",
value: content,
});
}
};
return (
<span
className={cn(
"inline-block w-auto px-1 py-px rounded-md text-sm bg-secondary text-secondary-foreground",
context.disableFilter ? "" : "cursor-pointer hover:opacity-80 transition-colors",
)}
onClick={handleTagClick}
>
<span className="opacity-70 font-mono">#</span>
{content}
</span>
);
});
export default Tag;

View File

@@ -0,0 +1,58 @@
import { observer } from "mobx-react-lite";
import { useContext } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { markdownServiceClient } from "@/grpcweb";
import { cn } from "@/lib/utils";
import { memoStore } from "@/store";
import { Node, TaskListItemNode } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
import { RendererContext } from "./types";
interface Props {
node: Node;
index: string;
symbol: string;
indent: number;
complete: boolean;
children: Node[];
}
const TaskListItem = observer(({ node, complete, children }: Props) => {
const context = useContext(RendererContext);
const handleCheckboxChange = async (on: boolean) => {
if (context.readonly || !context.memoName) {
return;
}
(node.taskListItemNode as TaskListItemNode)!.complete = on;
const { markdown } = await markdownServiceClient.restoreMarkdownNodes({ nodes: context.nodes });
await memoStore.updateMemo(
{
name: context.memoName,
content: markdown,
},
["content"],
);
};
return (
<li className={cn("w-full grid grid-cols-[24px_1fr]")}>
<span className="w-6 h-6 flex justify-start items-center">
<Checkbox
className="h-4 w-4"
checked={complete}
disabled={context.readonly}
onCheckedChange={(checked) => handleCheckboxChange(checked === true)}
/>
</span>
<p className={cn(complete && "line-through text-muted-foreground")}>
{children.map((child, index) => (
<Renderer key={`${child.type}-${index}`} index={String(index)} node={child} />
))}
</p>
</li>
);
});
export default TaskListItem;

View File

@@ -0,0 +1,9 @@
interface Props {
content: string;
}
const Text: React.FC<Props> = ({ content }: Props) => {
return <span>{content}</span>;
};
export default Text;

View File

@@ -0,0 +1,20 @@
import { Node } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
interface Props {
symbol: string;
indent: number;
children: Node[];
}
const UnorderedListItem: React.FC<Props> = ({ children }: Props) => {
return (
<li>
{children.map((child, index) => (
<Renderer key={`${child.type}-${index}`} index={String(index)} node={child} />
))}
</li>
);
};
export default UnorderedListItem;

View File

@@ -0,0 +1,127 @@
import { observer } from "mobx-react-lite";
import { memo, useEffect, useRef, useState } from "react";
import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils";
import { memoStore } from "@/store";
import { Node, NodeType } from "@/types/proto/api/v1/markdown_service";
import { useTranslate } from "@/utils/i18n";
import { isSuperUser } from "@/utils/user";
import Renderer from "./Renderer";
import { RendererContext } from "./types";
// MAX_DISPLAY_HEIGHT is the maximum height of the memo content to display in compact mode.
const MAX_DISPLAY_HEIGHT = 256;
interface Props {
nodes: Node[];
memoName?: string;
compact?: boolean;
readonly?: boolean;
disableFilter?: boolean;
// embeddedMemos is a set of memo resource names that are embedded in the current memo.
// This is used to prevent infinite loops when a memo embeds itself.
embeddedMemos?: Set<string>;
className?: string;
contentClassName?: string;
onClick?: (e: React.MouseEvent) => void;
onDoubleClick?: (e: React.MouseEvent) => void;
parentPage?: string;
}
type ContentCompactView = "ALL" | "SNIPPET";
const MemoContent = observer((props: Props) => {
const { className, contentClassName, nodes, memoName, embeddedMemos, onClick, onDoubleClick } = props;
const t = useTranslate();
const currentUser = useCurrentUser();
const memoContentContainerRef = useRef<HTMLDivElement>(null);
const [showCompactMode, setShowCompactMode] = useState<ContentCompactView | undefined>(undefined);
const memo = memoName ? memoStore.getMemoByName(memoName) : null;
const allowEdit = !props.readonly && memo && (currentUser?.name === memo.creator || isSuperUser(currentUser));
// Initial compact mode.
useEffect(() => {
if (!props.compact) {
return;
}
if (!memoContentContainerRef.current) {
return;
}
if ((memoContentContainerRef.current as HTMLDivElement).getBoundingClientRect().height > MAX_DISPLAY_HEIGHT) {
setShowCompactMode("ALL");
}
}, []);
const onMemoContentClick = async (e: React.MouseEvent) => {
if (onClick) {
onClick(e);
}
};
const onMemoContentDoubleClick = async (e: React.MouseEvent) => {
if (onDoubleClick) {
onDoubleClick(e);
}
};
let prevNode: Node | null = null;
let skipNextLineBreakFlag = false;
const compactStates = {
ALL: { text: t("memo.show-more"), nextState: "SNIPPET" },
SNIPPET: { text: t("memo.show-less"), nextState: "ALL" },
};
return (
<RendererContext.Provider
value={{
nodes,
memoName: memoName,
readonly: !allowEdit,
disableFilter: props.disableFilter,
embeddedMemos: embeddedMemos || new Set(),
parentPage: props.parentPage,
}}
>
<div className={`w-full flex flex-col justify-start items-start text-foreground ${className || ""}`}>
<div
ref={memoContentContainerRef}
className={cn(
"relative w-full max-w-full break-words text-base leading-6 space-y-1 whitespace-pre-wrap",
showCompactMode == "ALL" && "line-clamp-6 max-h-60",
contentClassName,
)}
onClick={onMemoContentClick}
onDoubleClick={onMemoContentDoubleClick}
>
{nodes.map((node, index) => {
if (prevNode?.type !== NodeType.LINE_BREAK && node.type === NodeType.LINE_BREAK && skipNextLineBreakFlag) {
skipNextLineBreakFlag = false;
return null;
}
prevNode = node;
skipNextLineBreakFlag = true;
return <Renderer key={`${node.type}-${index}`} index={String(index)} node={node} />;
})}
{showCompactMode == "ALL" && (
<div className="absolute bottom-0 left-0 w-full h-12 bg-linear-to-b from-transparent to-background pointer-events-none"></div>
)}
</div>
{showCompactMode != undefined && (
<div className="w-full mt-1">
<span
className="w-auto flex flex-row justify-start items-center cursor-pointer text-sm text-primary hover:opacity-80"
onClick={() => {
setShowCompactMode(compactStates[showCompactMode].nextState as ContentCompactView);
}}
>
{compactStates[showCompactMode].text}
</span>
</div>
)}
</div>
</RendererContext.Provider>
);
});
export default memo(MemoContent);

View File

@@ -0,0 +1,18 @@
import { createContext } from "react";
import { Node } from "@/types/proto/api/v1/markdown_service";
interface Context {
nodes: Node[];
// embeddedMemos is a set of memo resource names that are embedded in the current memo.
// This is used to prevent infinite loops when a memo embeds itself.
embeddedMemos: Set<string>;
memoName?: string;
readonly?: boolean;
disableFilter?: boolean;
parentPage?: string;
}
export const RendererContext = createContext<Context>({
nodes: [],
embeddedMemos: new Set(),
});

View File

@@ -0,0 +1,6 @@
export * from "./context";
export interface BaseProps {
index: string;
className?: string;
}

View File

@@ -0,0 +1,107 @@
import { isEqual } from "lodash-es";
import { CheckCircleIcon, Code2Icon, HashIcon, LinkIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Memo, MemoRelation_Type, Memo_Property } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
import MemoRelationForceGraph from "../MemoRelationForceGraph";
interface Props {
memo: Memo;
className?: string;
parentPage?: string;
}
const MemoDetailSidebar = ({ memo, className, parentPage }: Props) => {
const t = useTranslate();
const property = Memo_Property.fromPartial(memo.property || {});
const hasSpecialProperty = property.hasLink || property.hasTaskList || property.hasCode || property.hasIncompleteTasks;
const shouldShowRelationGraph = memo.relations.filter((r) => r.type === MemoRelation_Type.REFERENCE).length > 0;
return (
<aside
className={cn("relative w-full h-auto max-h-screen overflow-auto hide-scrollbar flex flex-col justify-start items-start", className)}
>
<div className="flex flex-col justify-start items-start w-full px-1 gap-2 h-auto shrink-0 flex-nowrap hide-scrollbar">
{shouldShowRelationGraph && (
<div className="relative w-full h-36 border border-border rounded-lg bg-muted">
<MemoRelationForceGraph className="w-full h-full" memo={memo} parentPage={parentPage} />
<div className="absolute top-1 left-2 text-xs opacity-60 font-mono gap-1 flex flex-row items-center">
<span>{t("common.relations")}</span>
<span className="text-xs opacity-60">(Beta)</span>
</div>
</div>
)}
<div className="w-full flex flex-col">
<p className="flex flex-row justify-start items-center w-full gap-1 mb-1 text-sm leading-6 text-muted-foreground select-none">
<span>{t("common.created-at")}</span>
</p>
<p className="text-sm text-muted-foreground">{memo.createTime?.toLocaleString()}</p>
</div>
{!isEqual(memo.createTime, memo.updateTime) && (
<div className="w-full flex flex-col">
<p className="flex flex-row justify-start items-center w-full gap-1 mb-1 text-sm leading-6 text-muted-foreground select-none">
<span>{t("common.last-updated-at")}</span>
</p>
<p className="text-sm text-muted-foreground">{memo.updateTime?.toLocaleString()}</p>
</div>
)}
{hasSpecialProperty && (
<div className="w-full flex flex-col">
<p className="flex flex-row justify-start items-center w-full gap-1 mb-1 text-sm leading-6 text-muted-foreground select-none">
<span>{t("common.properties")}</span>
</p>
<div className="w-full flex flex-row justify-start items-center gap-x-2 gap-y-1 flex-wrap text-muted-foreground">
{property.hasLink && (
<div className="w-auto border border-border pl-1 pr-1.5 rounded-md flex justify-between items-center">
<div className="w-auto flex justify-start items-center mr-1">
<LinkIcon className="w-4 h-auto mr-1" />
<span className="block text-sm">{t("memo.links")}</span>
</div>
</div>
)}
{property.hasTaskList && (
<div className="w-auto border border-border pl-1 pr-1.5 rounded-md flex justify-between items-center">
<div className="w-auto flex justify-start items-center mr-1">
<CheckCircleIcon className="w-4 h-auto mr-1" />
<span className="block text-sm">{t("memo.to-do")}</span>
</div>
</div>
)}
{property.hasCode && (
<div className="w-auto border border-border pl-1 pr-1.5 rounded-md flex justify-between items-center">
<div className="w-auto flex justify-start items-center mr-1">
<Code2Icon className="w-4 h-auto mr-1" />
<span className="block text-sm">{t("memo.code")}</span>
</div>
</div>
)}
</div>
</div>
)}
{memo.tags.length > 0 && (
<div className="w-full">
<div className="flex flex-row justify-start items-center w-full gap-1 mb-1 text-sm leading-6 text-muted-foreground select-none">
<span>{t("common.tags")}</span>
<span className="shrink-0">({memo.tags.length})</span>
</div>
<div className="w-full flex flex-row justify-start items-center relative flex-wrap gap-x-2 gap-y-1">
{memo.tags.map((tag) => (
<div
key={tag}
className="shrink-0 w-auto max-w-full text-sm rounded-md leading-6 flex flex-row justify-start items-center select-none hover:opacity-80 text-muted-foreground"
>
<HashIcon className="group-hover:hidden w-4 h-auto shrink-0 opacity-40" />
<div className={cn("inline-flex flex-nowrap ml-0.5 gap-0.5 cursor-pointer max-w-[calc(100%-16px)]")}>
<span className="truncate opacity-80">{tag}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
</aside>
);
};
export default MemoDetailSidebar;

View File

@@ -0,0 +1,36 @@
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";
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 (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="ghost" className="bg-transparent! px-2">
<GanttChartIcon className="w-5 h-auto text-muted-foreground" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-full sm:w-80 px-4 bg-popover">
<MemoDetailSidebar className="py-4" memo={memo} parentPage={parentPage} />
</SheetContent>
</Sheet>
);
};
export default MemoDetailSidebarDrawer;

View File

@@ -0,0 +1,4 @@
import MemoDetailSidebar from "./MemoDetailSidebar";
import MemoDetailSidebarDrawer from "./MemoDetailSidebarDrawer";
export { MemoDetailSidebar, MemoDetailSidebarDrawer };

View File

@@ -0,0 +1,68 @@
import { Settings2Icon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { viewStore } from "@/store";
import { useTranslate } from "@/utils/i18n";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
interface Props {
className?: string;
}
const MemoDisplaySettingMenu = observer(({ className }: Props) => {
const t = useTranslate();
const isApplying = viewStore.state.orderByTimeAsc !== false || viewStore.state.layout !== "LIST";
return (
<Popover>
<PopoverTrigger className={cn(className, isApplying ? "text-primary bg-primary/10 rounded" : "opacity-40")}>
<Settings2Icon className="w-4 h-auto shrink-0" />
</PopoverTrigger>
<PopoverContent align="end" alignOffset={-12} sideOffset={14}>
<div className="flex flex-col gap-2 p-1">
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.direction")}</span>
<Select
value={viewStore.state.orderByTimeAsc.toString()}
onValueChange={(value) =>
viewStore.state.setPartial({
orderByTimeAsc: value === "true",
})
}
>
<SelectTrigger size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="false">{t("memo.direction-desc")}</SelectItem>
<SelectItem value="true">{t("memo.direction-asc")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("common.layout")}</span>
<Select
value={viewStore.state.layout}
onValueChange={(value) =>
viewStore.state.setPartial({
layout: value as "LIST" | "MASONRY",
})
}
>
<SelectTrigger size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LIST">{t("memo.list")}</SelectItem>
<SelectItem value="MASONRY">{t("memo.masonry")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</PopoverContent>
</Popover>
);
});
export default MemoDisplaySettingMenu;

View File

@@ -0,0 +1,210 @@
import { uniqBy } from "lodash-es";
import { LinkIcon, X } from "lucide-react";
import React, { useContext, useState } from "react";
import { toast } from "react-hot-toast";
import useDebounce from "react-use/lib/useDebounce";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { memoServiceClient } from "@/grpcweb";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import useCurrentUser from "@/hooks/useCurrentUser";
import { extractMemoIdFromName } from "@/store/common";
import { Memo, MemoRelation_Memo, MemoRelation_Type } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
import { EditorRefActions } from "../Editor";
import { MemoEditorContext } from "../types";
interface Props {
editorRef: React.RefObject<EditorRefActions>;
}
const AddMemoRelationPopover = (props: Props) => {
const { editorRef } = props;
const t = useTranslate();
const context = useContext(MemoEditorContext);
const user = useCurrentUser();
const [searchText, setSearchText] = useState<string>("");
const [isFetching, setIsFetching] = useState<boolean>(true);
const [fetchedMemos, setFetchedMemos] = useState<Memo[]>([]);
const [selectedMemos, setSelectedMemos] = useState<Memo[]>([]);
const [embedded, setEmbedded] = useState<boolean>(false);
const [popoverOpen, setPopoverOpen] = useState<boolean>(false);
const filteredMemos = fetchedMemos.filter(
(memo) =>
!selectedMemos.includes(memo) &&
memo.name !== context.memoName &&
!context.relationList.some((relation) => relation.relatedMemo?.name === memo.name),
);
useDebounce(
async () => {
if (!popoverOpen) return;
setIsFetching(true);
try {
const conditions = [];
if (searchText) {
conditions.push(`content_search == [${JSON.stringify(searchText)}]`);
}
const { memos } = await memoServiceClient.listMemos({
parent: user.name,
pageSize: DEFAULT_LIST_MEMOS_PAGE_SIZE,
oldFilter: conditions.length > 0 ? conditions.join(" && ") : undefined,
});
setFetchedMemos(memos);
} catch (error: any) {
toast.error(error.details);
console.error(error);
}
setIsFetching(false);
},
300,
[popoverOpen, searchText],
);
const getHighlightedContent = (content: string) => {
const index = content.toLowerCase().indexOf(searchText.toLowerCase());
if (index === -1) {
return content;
}
let before = content.slice(0, index);
if (before.length > 20) {
before = "..." + before.slice(before.length - 20);
}
const highlighted = content.slice(index, index + searchText.length);
let after = content.slice(index + searchText.length);
if (after.length > 20) {
after = after.slice(0, 20) + "...";
}
return (
<>
{before}
<mark className="font-medium">{highlighted}</mark>
{after}
</>
);
};
const addMemoRelations = async () => {
// If embedded mode is enabled, embed the memo instead of creating a relation.
if (embedded) {
if (!editorRef.current) {
toast.error(t("message.failed-to-embed-memo"));
return;
}
const cursorPosition = editorRef.current.getCursorPosition();
const prevValue = editorRef.current.getContent().slice(0, cursorPosition);
if (prevValue !== "" && !prevValue.endsWith("\n")) {
editorRef.current.insertText("\n");
}
for (const memo of selectedMemos) {
editorRef.current.insertText(`![[memos/${extractMemoIdFromName(memo.name)}]]\n`);
}
setTimeout(() => {
editorRef.current?.scrollToCursor();
editorRef.current?.focus();
});
} else {
context.setRelationList(
uniqBy(
[
...selectedMemos.map((memo) => ({
memo: MemoRelation_Memo.fromPartial({ name: memo.name }),
relatedMemo: MemoRelation_Memo.fromPartial({ name: memo.name }),
type: MemoRelation_Type.REFERENCE,
})),
...context.relationList,
].filter((relation) => relation.relatedMemo !== context.memoName),
"relatedMemo",
),
);
}
setSelectedMemos([]);
setPopoverOpen(false);
};
return (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon">
<LinkIcon className="size-5" />
</Button>
</PopoverTrigger>
<PopoverContent align="center">
<div className="w-[16rem] p-1 flex flex-col justify-start items-start">
{/* Selected memos display */}
{selectedMemos.length > 0 && (
<div className="w-full mb-2 flex flex-wrap gap-1">
{selectedMemos.map((memo) => (
<Badge key={memo.name} variant="outline" className="max-w-full flex items-center gap-1 p-2">
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground select-none">{memo.displayTime?.toLocaleString()}</p>
<span className="text-sm leading-5 truncate block">{memo.content}</span>
</div>
<X
className="w-3 h-3 cursor-pointer hover:text-destructive flex-shrink-0"
onClick={() => setSelectedMemos((memos) => memos.filter((m) => m.name !== memo.name))}
/>
</Badge>
))}
</div>
)}
{/* Search and selection interface */}
<div className="w-full">
<Input
placeholder={t("reference.search-placeholder")}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="mb-2"
/>
<div className="max-h-[200px] overflow-y-auto">
{filteredMemos.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
{isFetching ? "Loading..." : t("reference.no-memos-found")}
</div>
) : (
filteredMemos.map((memo) => (
<div
key={memo.name}
className="relative flex cursor-pointer items-start gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
onClick={() => {
setSelectedMemos((prev) => [...prev, memo]);
}}
>
<div className="w-full flex flex-col justify-start items-start">
<p className="text-xs text-muted-foreground select-none">{memo.displayTime?.toLocaleString()}</p>
<p className="mt-0.5 text-sm leading-5 line-clamp-2">
{searchText ? getHighlightedContent(memo.content) : memo.snippet}
</p>
</div>
</div>
))
)}
</div>
</div>
<div className="mt-2 w-full flex flex-row justify-end items-center gap-2">
<div className="flex items-center space-x-2">
<Checkbox id="embed-checkbox" checked={embedded} onCheckedChange={(checked) => setEmbedded(checked === true)} />
<label htmlFor="embed-checkbox" className="text-sm">
Embed
</label>
</div>
<Button onClick={addMemoRelations} disabled={selectedMemos.length === 0}>
{t("common.add")}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
};
export default AddMemoRelationPopover;

View File

@@ -0,0 +1,158 @@
import { LatLng } from "leaflet";
import { MapPinIcon, XIcon } from "lucide-react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import LeafletMap from "@/components/LeafletMap";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Location } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
interface Props {
location?: Location;
onChange: (location?: Location) => void;
}
interface State {
initilized: boolean;
placeholder: string;
position?: LatLng;
}
const LocationSelector = (props: Props) => {
const t = useTranslate();
const [state, setState] = useState<State>({
initilized: false,
placeholder: props.location?.placeholder || "",
position: props.location ? new LatLng(props.location.latitude, props.location.longitude) : undefined,
});
const [popoverOpen, setPopoverOpen] = useState<boolean>(false);
useEffect(() => {
setState((state) => ({
...state,
placeholder: props.location?.placeholder || "",
position: new LatLng(props.location?.latitude || 0, props.location?.longitude || 0),
}));
}, [props.location]);
useEffect(() => {
if (popoverOpen && !props.location) {
const handleError = (error: any, errorMessage: string) => {
setState({ ...state, initilized: true });
toast.error(errorMessage);
console.error(error);
};
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
const lat = position.coords.latitude;
const lng = position.coords.longitude;
setState({ ...state, position: new LatLng(lat, lng), initilized: true });
},
(error) => {
handleError(error, "Failed to get current position");
},
);
} else {
handleError("Geolocation is not supported by this browser.", "Geolocation is not supported by this browser.");
}
}
}, [popoverOpen]);
useEffect(() => {
if (!state.position) {
setState({ ...state, placeholder: "" });
return;
}
// Fetch reverse geocoding data.
fetch(`https://nominatim.openstreetmap.org/reverse?lat=${state.position.lat}&lon=${state.position.lng}&format=json`)
.then((response) => response.json())
.then((data) => {
if (data && data.display_name) {
setState({ ...state, placeholder: data.display_name });
}
})
.catch((error) => {
toast.error("Failed to fetch reverse geocoding data");
console.error("Failed to fetch reverse geocoding data:", error);
});
}, [state.position]);
const onPositionChanged = (position: LatLng) => {
setState({ ...state, position });
};
const removeLocation = () => {
props.onChange(undefined);
};
return (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
{props.location ? (
<div className="flex items-center">
<PopoverTrigger asChild>
<Button variant="ghost" className="rounded-r-none">
<MapPinIcon className="size-5 shrink-0" />
<span className="ml-0.5 text-sm text-ellipsis whitespace-nowrap overflow-hidden max-w-28">{props.location.placeholder}</span>
</Button>
</PopoverTrigger>
<Button variant="ghost" size="icon" className="rounded-l-none opacity-60 hover:opacity-80" onClick={removeLocation}>
<XIcon className="size-4 shrink-0" />
</Button>
</div>
) : (
<PopoverTrigger asChild>
<Button variant="ghost" size="icon">
<MapPinIcon className="size-5 shrink-0" />
</Button>
</PopoverTrigger>
)}
<PopoverContent align="center">
<div className="min-w-80 sm:w-lg p-1 flex flex-col justify-start items-start">
<LeafletMap key={JSON.stringify(state.initilized)} latlng={state.position} onChange={onPositionChanged} />
<div className="mt-2 w-full flex flex-row justify-between items-center gap-2">
<div className="flex flex-row items-center justify-start gap-2 w-full">
<div className="relative flex-1">
{state.position && (
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-xs leading-6 opacity-60 z-10">
[{state.position.lat.toFixed(2)}, {state.position.lng.toFixed(2)}]
</div>
)}
<Input
placeholder="Choose a position first."
value={state.placeholder}
disabled={!state.position}
className={state.position ? "pl-24" : ""}
onChange={(e) => setState((state) => ({ ...state, placeholder: e.target.value }))}
/>
</div>
</div>
<Button
className="shrink-0"
color="primary"
onClick={() => {
props.onChange(
Location.fromPartial({
placeholder: state.placeholder,
latitude: state.position?.lat,
longitude: state.position?.lng,
}),
);
setPopoverOpen(false);
}}
disabled={!state.position || state.placeholder.length === 0}
>
{t("common.confirm")}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
};
export default LocationSelector;

View File

@@ -0,0 +1,93 @@
import { CheckSquareIcon, Code2Icon, SquareSlashIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslate } from "@/utils/i18n";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../ui/dropdown-menu";
import { EditorRefActions } from "../Editor";
interface Props {
editorRef: React.RefObject<EditorRefActions>;
}
const MarkdownMenu = (props: Props) => {
const { editorRef } = props;
const t = useTranslate();
const handleCodeBlockClick = () => {
if (!editorRef.current) {
return;
}
const cursorPosition = editorRef.current.getCursorPosition();
const prevValue = editorRef.current.getContent().slice(0, cursorPosition);
if (prevValue === "" || prevValue.endsWith("\n")) {
editorRef.current.insertText("", "```\n", "\n```");
} else {
editorRef.current.insertText("", "\n```\n", "\n```");
}
setTimeout(() => {
editorRef.current?.scrollToCursor();
editorRef.current?.focus();
});
};
const handleCheckboxClick = () => {
if (!editorRef.current) {
return;
}
const currentPosition = editorRef.current.getCursorPosition();
const currentLineNumber = editorRef.current.getCursorLineNumber();
const currentLine = editorRef.current.getLine(currentLineNumber);
let newLine = "";
let cursorChange = 0;
if (/^- \[( |x|X)\] /.test(currentLine)) {
newLine = currentLine.replace(/^- \[( |x|X)\] /, "");
cursorChange = -6;
} else if (/^\d+\. |- /.test(currentLine)) {
const match = currentLine.match(/^\d+\. |- /) ?? [""];
newLine = currentLine.replace(/^\d+\. |- /, "- [ ] ");
cursorChange = -match[0].length + 6;
} else {
newLine = "- [ ] " + currentLine;
cursorChange = 6;
}
editorRef.current.setLine(currentLineNumber, newLine);
editorRef.current.setCursorPosition(currentPosition + cursorChange);
setTimeout(() => {
editorRef.current?.scrollToCursor();
editorRef.current?.focus();
});
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<SquareSlashIcon className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={handleCodeBlockClick}>
<Code2Icon className="w-4 h-auto text-muted-foreground" />
{t("markdown.code-block")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCheckboxClick}>
<CheckSquareIcon className="w-4 h-auto text-muted-foreground" />
{t("markdown.checkbox")}
</DropdownMenuItem>
<div className="px-2 -mt-1">
<a
className="text-xs text-primary hover:underline"
href="https://www.usememos.com/docs/getting-started/content-syntax"
target="_blank"
rel="noopener noreferrer"
>
{t("markdown.content-syntax")}
</a>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
};
export default MarkdownMenu;

View File

@@ -0,0 +1,67 @@
import { HashIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import OverflowTip from "@/components/kit/OverflowTip";
import { Button } from "@/components/ui/button";
import { userStore } from "@/store";
import { useTranslate } from "@/utils/i18n";
import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover";
import { EditorRefActions } from "../Editor";
interface Props {
editorRef: React.RefObject<EditorRefActions>;
}
const TagSelector = observer((props: Props) => {
const t = useTranslate();
const { editorRef } = props;
const tags = Object.entries(userStore.state.tagCount)
.sort((a, b) => a[0].localeCompare(b[0]))
.sort((a, b) => b[1] - a[1])
.map(([tag]) => tag);
const handleTagClick = (tag: string) => {
const current = editorRef.current;
if (current === null) return;
const line = current.getLine(current.getCursorLineNumber());
const lastCharOfLine = line.slice(-1);
if (lastCharOfLine !== " " && lastCharOfLine !== " " && line !== "") {
current.insertText("\n");
}
current.insertText(`#${tag} `);
};
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon">
<HashIcon className="size-5" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" sideOffset={2}>
{tags.length > 0 ? (
<div className="flex flex-row justify-start items-start flex-wrap px-2 max-w-48 h-auto max-h-48 overflow-y-auto gap-x-2">
{tags.map((tag) => {
return (
<div
key={tag}
className="inline-flex w-auto max-w-full cursor-pointer text-base leading-6 text-muted-foreground hover:opacity-80"
onClick={() => handleTagClick(tag)}
>
<OverflowTip>#{tag}</OverflowTip>
</div>
);
})}
</div>
) : (
<p className="italic mx-2" onClick={(e) => e.stopPropagation()}>
{t("tag.no-tag-found")}
</p>
)}
</PopoverContent>
</Popover>
);
});
export default TagSelector;

View File

@@ -0,0 +1,92 @@
import { LoaderIcon, PaperclipIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useContext, useRef, useState } from "react";
import toast from "react-hot-toast";
import { Button } from "@/components/ui/button";
import { attachmentStore } from "@/store";
import { Attachment } from "@/types/proto/api/v1/attachment_service";
import { MemoEditorContext } from "../types";
interface Props {
isUploading?: boolean;
}
interface State {
uploadingFlag: boolean;
}
const UploadAttachmentButton = observer((props: Props) => {
const context = useContext(MemoEditorContext);
const [state, setState] = useState<State>({
uploadingFlag: false,
});
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileInputChange = async () => {
if (!fileInputRef.current || !fileInputRef.current.files || fileInputRef.current.files.length === 0) {
return;
}
if (state.uploadingFlag) {
return;
}
setState((state) => {
return {
...state,
uploadingFlag: true,
};
});
const createdAttachmentList: Attachment[] = [];
try {
if (!fileInputRef.current || !fileInputRef.current.files) {
return;
}
for (const file of fileInputRef.current.files) {
const { name: filename, size, type } = file;
const buffer = new Uint8Array(await file.arrayBuffer());
const attachment = await attachmentStore.createAttachment({
attachment: Attachment.fromPartial({
filename,
size,
type,
content: buffer,
}),
attachmentId: "",
});
createdAttachmentList.push(attachment);
}
} catch (error: any) {
console.error(error);
toast.error(error.details);
}
context.setAttachmentList([...context.attachmentList, ...createdAttachmentList]);
setState((state) => {
return {
...state,
uploadingFlag: false,
};
});
};
const isUploading = state.uploadingFlag || props.isUploading;
return (
<Button className="relative" variant="ghost" size="icon" disabled={isUploading}>
{isUploading ? <LoaderIcon className="size-5 animate-spin" /> : <PaperclipIcon className="size-5" />}
<input
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
ref={fileInputRef}
disabled={isUploading}
onChange={handleFileInputChange}
type="file"
id="files"
multiple={true}
accept="*"
/>
</Button>
);
});
export default UploadAttachmentButton;

View File

@@ -0,0 +1,45 @@
import VisibilityIcon from "@/components/VisibilityIcon";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Visibility } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
interface Props {
value: Visibility;
onChange: (visibility: Visibility) => void;
onOpenChange?: (open: boolean) => void;
}
const VisibilitySelector = (props: Props) => {
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") },
];
const handleOpenChange = (open: boolean) => {
if (props.onOpenChange) {
props.onOpenChange(open);
}
};
return (
<Select value={value.toString()} onValueChange={onChange} onOpenChange={handleOpenChange}>
<SelectTrigger size="xs" className="!bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
{visibilityOptions.map((option) => (
<SelectItem key={option.value} value={option.value.toString()}>
<VisibilityIcon className="size-3.5" visibility={option.value} />
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
export default VisibilitySelector;

View File

@@ -0,0 +1,60 @@
import { DndContext, closestCenter, MouseSensor, TouchSensor, useSensor, useSensors, DragEndEvent } from "@dnd-kit/core";
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { XIcon } from "lucide-react";
import { Attachment } from "@/types/proto/api/v1/attachment_service";
import AttachmentIcon from "../AttachmentIcon";
import SortableItem from "./SortableItem";
interface Props {
attachmentList: Attachment[];
setAttachmentList: (attachmentList: Attachment[]) => void;
}
const AttachmentListView = (props: Props) => {
const { attachmentList, setAttachmentList } = props;
const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor));
const handleDeleteAttachment = async (name: string) => {
setAttachmentList(attachmentList.filter((attachment) => attachment.name !== name));
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = attachmentList.findIndex((attachment) => attachment.name === active.id);
const newIndex = attachmentList.findIndex((attachment) => attachment.name === over.id);
setAttachmentList(arrayMove(attachmentList, oldIndex, newIndex));
}
};
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={attachmentList.map((attachment) => attachment.name)} strategy={verticalListSortingStrategy}>
{attachmentList.length > 0 && (
<div className="w-full flex flex-row justify-start flex-wrap gap-2 mt-2 max-h-[50vh] overflow-y-auto">
{attachmentList.map((attachment) => {
return (
<div
key={attachment.name}
className="max-w-full w-auto flex flex-row justify-start items-center flex-nowrap gap-x-1 bg-popover px-2 py-1 rounded hover:shadow-sm text-muted-foreground"
>
<SortableItem id={attachment.name} className="flex flex-row justify-start items-center gap-x-1">
<AttachmentIcon attachment={attachment} className="w-4! h-4! opacity-100!" />
<span className="text-sm max-w-32 truncate">{attachment.filename}</span>
</SortableItem>
<button className="shrink-0" onClick={() => handleDeleteAttachment(attachment.name)}>
<XIcon className="w-4 h-auto cursor-pointer opacity-60 hover:opacity-100" />
</button>
</div>
);
})}
</div>
)}
</SortableContext>
</DndContext>
);
};
export default AttachmentListView;

View File

@@ -0,0 +1,130 @@
import Fuse from "fuse.js";
import { observer } from "mobx-react-lite";
import { useEffect, useRef, useState } from "react";
import getCaretCoordinates from "textarea-caret";
import OverflowTip from "@/components/kit/OverflowTip";
import { cn } from "@/lib/utils";
import { userStore } from "@/store";
import { EditorRefActions } from ".";
type Props = {
editorRef: React.RefObject<HTMLTextAreaElement>;
editorActions: React.ForwardedRef<EditorRefActions>;
};
type Position = { left: number; top: number; height: number };
const TagSuggestions = observer(({ editorRef, editorActions }: Props) => {
const [position, setPosition] = useState<Position | null>(null);
const [selected, select] = useState(0);
const selectedRef = useRef(selected);
selectedRef.current = selected;
const tags = Object.entries(userStore.state.tagCount)
.sort((a, b) => a[0].localeCompare(b[0]))
.sort((a, b) => b[1] - a[1])
.map(([tag]) => tag);
const hide = () => setPosition(null);
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 suggestionsRef = useRef<string[]>([]);
suggestionsRef.current = (() => {
const search = getCurrentWord()[0].slice(1).toLowerCase();
const fuse = new Fuse(tags);
return fuse.search(search).map((result) => result.item);
})();
const isVisibleRef = useRef(false);
isVisibleRef.current = !!(position && suggestionsRef.current.length > 0);
const autocomplete = (tag: string) => {
if (!editorActions || !("current" in editorActions) || !editorActions.current) return;
const [word, index] = getCurrentWord();
editorActions.current.removeText(index, word.length);
editorActions.current.insertText(`#${tag}`);
hide();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!isVisibleRef.current) return;
const suggestions = suggestionsRef.current;
const selected = selectedRef.current;
if (["Escape", "ArrowLeft", "ArrowRight"].includes(e.code)) hide();
if ("ArrowDown" === e.code) {
select((selected + 1) % suggestions.length);
e.preventDefault();
e.stopPropagation();
}
if ("ArrowUp" === e.code) {
select((selected - 1 + suggestions.length) % suggestions.length);
e.preventDefault();
e.stopPropagation();
}
if (["Enter", "Tab"].includes(e.code)) {
autocomplete(suggestions[selected]);
e.preventDefault();
e.stopPropagation();
}
};
const handleInput = () => {
const editor = editorRef.current;
if (!editor) return;
select(0);
const [word, index] = getCurrentWord();
const currentChar = editor.value[editor.selectionEnd];
const isActive = word.startsWith("#") && currentChar !== "#";
const caretCordinates = getCaretCoordinates(editor, index);
caretCordinates.top -= editor.scrollTop;
if (isActive) {
setPosition(caretCordinates);
} else {
hide();
}
};
const listenersAreRegisteredRef = useRef(false);
const registerListeners = () => {
const editor = editorRef.current;
if (!editor || listenersAreRegisteredRef.current) return;
editor.addEventListener("click", hide);
editor.addEventListener("blur", hide);
editor.addEventListener("keydown", handleKeyDown);
editor.addEventListener("input", handleInput);
listenersAreRegisteredRef.current = true;
};
useEffect(registerListeners, [!!editorRef.current]);
if (!isVisibleRef.current || !position) return null;
return (
<div
className="z-20 p-1 mt-1 -ml-2 absolute max-w-48 gap-px rounded font-mono flex flex-col justify-start items-start overflow-auto shadow bg-popover"
style={{ left: position.left, top: position.top + position.height }}
>
{suggestionsRef.current.map((tag, i) => (
<div
key={tag}
onMouseDown={() => autocomplete(tag)}
className={cn(
"rounded p-1 px-2 w-full truncate text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground",
i === selected ? "bg-accent text-accent-foreground" : "",
)}
>
<OverflowTip>#{tag}</OverflowTip>
</div>
))}
</div>
);
});
export default TagSuggestions;

View File

@@ -0,0 +1,233 @@
import { last } from "lodash-es";
import { forwardRef, ReactNode, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
import { markdownServiceClient } from "@/grpcweb";
import { cn } from "@/lib/utils";
import { Node, NodeType, OrderedListItemNode, TaskListItemNode, UnorderedListItemNode } from "@/types/proto/api/v1/markdown_service";
import TagSuggestions from "./TagSuggestions";
export interface EditorRefActions {
getEditor: () => HTMLTextAreaElement | null;
focus: FunctionType;
scrollToCursor: FunctionType;
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;
}
interface Props {
className: string;
initialContent: string;
placeholder: string;
tools?: ReactNode;
onContentChange: (content: string) => void;
onPaste: (event: React.ClipboardEvent) => void;
}
const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<EditorRefActions>) {
const { className, initialContent, placeholder, onPaste, onContentChange: handleContentChangeCallback } = props;
const [isInIME, setIsInIME] = useState(false);
const editorRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (editorRef.current && initialContent) {
editorRef.current.value = initialContent;
handleContentChangeCallback(initialContent);
}
}, []);
useEffect(() => {
if (editorRef.current) {
updateEditorHeight();
}
}, [editorRef.current?.value]);
const editorActions = {
getEditor: () => {
return editorRef.current;
},
focus: () => {
editorRef.current?.focus();
},
scrollToCursor: () => {
if (editorRef.current) {
editorRef.current.scrollTop = editorRef.current.scrollHeight;
}
},
insertText: (content = "", prefix = "", suffix = "") => {
if (!editorRef.current) {
return;
}
const cursorPosition = editorRef.current.selectionStart;
const endPosition = editorRef.current.selectionEnd;
const prevValue = editorRef.current.value;
const value =
prevValue.slice(0, cursorPosition) +
prefix +
(content || prevValue.slice(cursorPosition, endPosition)) +
suffix +
prevValue.slice(endPosition);
editorRef.current.value = value;
editorRef.current.focus();
editorRef.current.selectionEnd = endPosition + prefix.length + content.length;
handleContentChangeCallback(editorRef.current.value);
updateEditorHeight();
},
removeText: (start: number, length: number) => {
if (!editorRef.current) {
return;
}
const prevValue = editorRef.current.value;
const value = prevValue.slice(0, start) + prevValue.slice(start + length);
editorRef.current.value = value;
editorRef.current.focus();
editorRef.current.selectionEnd = start;
handleContentChangeCallback(editorRef.current.value);
updateEditorHeight();
},
setContent: (text: string) => {
if (editorRef.current) {
editorRef.current.value = text;
handleContentChangeCallback(editorRef.current.value);
updateEditorHeight();
}
},
getContent: (): string => {
return editorRef.current?.value ?? "";
},
getCursorPosition: (): number => {
return editorRef.current?.selectionStart ?? 0;
},
getSelectedContent: () => {
const start = editorRef.current?.selectionStart;
const end = editorRef.current?.selectionEnd;
return editorRef.current?.value.slice(start, end) ?? "";
},
setCursorPosition: (startPos: number, endPos?: number) => {
const _endPos = isNaN(endPos as number) ? startPos : (endPos as number);
editorRef.current?.setSelectionRange(startPos, _endPos);
},
getCursorLineNumber: () => {
const cursorPosition = editorRef.current?.selectionStart ?? 0;
const lines = editorRef.current?.value.slice(0, cursorPosition).split("\n") ?? [];
return lines.length - 1;
},
getLine: (lineNumber: number) => {
return editorRef.current?.value.split("\n")[lineNumber] ?? "";
},
setLine: (lineNumber: number, text: string) => {
const lines = editorRef.current?.value.split("\n") ?? [];
lines[lineNumber] = text;
if (editorRef.current) {
editorRef.current.value = lines.join("\n");
editorRef.current.focus();
handleContentChangeCallback(editorRef.current.value);
updateEditorHeight();
}
},
};
useImperativeHandle(ref, () => editorActions, []);
const updateEditorHeight = () => {
if (editorRef.current) {
editorRef.current.style.height = "auto";
editorRef.current.style.height = (editorRef.current.scrollHeight ?? 0) + "px";
}
};
const handleEditorInput = useCallback(() => {
handleContentChangeCallback(editorRef.current?.value ?? "");
updateEditorHeight();
}, []);
const getLastNode = (nodes: Node[]): Node | undefined => {
const lastNode = last(nodes);
if (!lastNode) {
return undefined;
}
if (lastNode.type === NodeType.LIST) {
const children = lastNode.listNode?.children;
if (children) {
return getLastNode(children);
}
}
return lastNode;
};
const handleEditorKeyDown = async (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !isInIME) {
if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) {
return;
}
const cursorPosition = editorActions.getCursorPosition();
const prevContent = editorActions.getContent().substring(0, cursorPosition);
const { nodes } = await markdownServiceClient.parseMarkdown({ markdown: prevContent });
const lastNode = getLastNode(nodes);
if (!lastNode) {
return;
}
// Get the indentation of the previous line
const lines = prevContent.split("\n");
const lastLine = lines[lines.length - 1];
const indentationMatch = lastLine.match(/^\s*/);
let insertText = indentationMatch ? indentationMatch[0] : ""; // Keep the indentation of the previous line
if (lastNode.type === NodeType.TASK_LIST_ITEM) {
const { symbol } = lastNode.taskListItemNode as TaskListItemNode;
insertText += `${symbol} [ ] `;
} else if (lastNode.type === NodeType.UNORDERED_LIST_ITEM) {
const { symbol } = lastNode.unorderedListItemNode as UnorderedListItemNode;
insertText += `${symbol} `;
} else if (lastNode.type === NodeType.ORDERED_LIST_ITEM) {
const { number } = lastNode.orderedListItemNode as OrderedListItemNode;
insertText += `${Number(number) + 1}. `;
} else if (lastNode.type === NodeType.TABLE) {
const columns = lastNode.tableNode?.header.length;
if (!columns) {
return;
}
insertText += "| ";
for (let i = 1; i < columns; i++) {
insertText += " | ";
}
insertText += " |";
}
if (insertText) {
// Insert the text at the current cursor position.
editorActions.insertText(insertText);
}
}
};
return (
<div className={cn("flex flex-col justify-start items-start relative w-full h-auto max-h-[50vh] bg-inherit", className)}>
<textarea
className="w-full h-full my-1 text-base resize-none overflow-x-hidden overflow-y-auto bg-transparent outline-none placeholder:opacity-70 whitespace-pre-wrap break-words"
rows={2}
placeholder={placeholder}
ref={editorRef}
onPaste={onPaste}
onInput={handleEditorInput}
onKeyDown={handleEditorKeyDown}
onCompositionStart={() => setIsInIME(true)}
onCompositionEnd={() => setTimeout(() => setIsInIME(false))}
></textarea>
<TagSuggestions editorRef={editorRef} editorActions={ref} />
</div>
);
});
export default Editor;

View File

@@ -0,0 +1,55 @@
import { LinkIcon, XIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { memoStore } from "@/store";
import { Memo, MemoRelation, MemoRelation_Type } from "@/types/proto/api/v1/memo_service";
interface Props {
relationList: MemoRelation[];
setRelationList: (relationList: MemoRelation[]) => void;
}
const RelationListView = observer((props: Props) => {
const { relationList, setRelationList } = props;
const [referencingMemoList, setReferencingMemoList] = useState<Memo[]>([]);
useEffect(() => {
(async () => {
const requests = relationList
.filter((relation) => relation.type === MemoRelation_Type.REFERENCE)
.map(async (relation) => {
return await memoStore.getOrFetchMemoByName(relation.relatedMemo!.name, { skipStore: true });
});
const list = await Promise.all(requests);
setReferencingMemoList(list);
})();
}, [relationList]);
const handleDeleteRelation = async (memo: Memo) => {
setRelationList(relationList.filter((relation) => relation.relatedMemo?.name !== memo.name));
};
return (
<>
{referencingMemoList.length > 0 && (
<div className="w-full flex flex-row gap-2 mt-2 flex-wrap">
{referencingMemoList.map((memo) => {
return (
<div
key={memo.name}
className="w-auto max-w-xs overflow-hidden flex flex-row justify-start items-center bg-popover hover:opacity-80 rounded-md text-sm p-1 px-2 text-muted-foreground cursor-pointer hover:line-through"
onClick={() => handleDeleteRelation(memo)}
>
<LinkIcon className="w-4 h-auto shrink-0 opacity-80" />
<span className="mx-1 max-w-full text-ellipsis whitespace-nowrap overflow-hidden">{memo.snippet}</span>
<XIcon className="w-4 h-auto cursor-pointer shrink-0 opacity-60 hover:opacity-100" />
</div>
);
})}
</div>
)}
</>
);
});
export default RelationListView;

Some files were not shown because too many files have changed in this diff Show More