init commit
This commit is contained in:
7
web/.gitignore
vendored
Normal file
7
web/.gitignore
vendored
Normal 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
8
web/.prettierrc.js
Normal 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
1
web/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# The frontend of Memos
|
||||
21
web/components.json
Normal file
21
web/components.json
Normal 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
34
web/eslint.config.mjs
Normal 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
19
web/index.html
Normal 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
9947
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
93
web/package.json
Normal file
93
web/package.json
Normal 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
7056
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
web/public/android-chrome-192x192.png
Normal file
BIN
web/public/android-chrome-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
web/public/android-chrome-512x512.png
Normal file
BIN
web/public/android-chrome-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
BIN
web/public/apple-touch-icon.png
Normal file
BIN
web/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
web/public/full-logo.webp
Normal file
BIN
web/public/full-logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
web/public/logo.webp
Normal file
BIN
web/public/logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
10
web/public/site.webmanifest
Normal file
10
web/public/site.webmanifest
Normal 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
117
web/src/App.tsx
Normal 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;
|
||||
180
web/src/components/ActivityCalendar/ActivityCalendar.tsx
Normal file
180
web/src/components/ActivityCalendar/ActivityCalendar.tsx
Normal 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";
|
||||
1
web/src/components/ActivityCalendar/index.ts
Normal file
1
web/src/components/ActivityCalendar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ActivityCalendar as default } from "./ActivityCalendar";
|
||||
51
web/src/components/AppearanceSelect.tsx
Normal file
51
web/src/components/AppearanceSelect.tsx
Normal 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;
|
||||
108
web/src/components/AttachmentIcon.tsx
Normal file
108
web/src/components/AttachmentIcon.tsx
Normal 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);
|
||||
28
web/src/components/AuthFooter.tsx
Normal file
28
web/src/components/AuthFooter.tsx
Normal 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;
|
||||
27
web/src/components/BrandBanner.tsx
Normal file
27
web/src/components/BrandBanner.tsx
Normal 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;
|
||||
115
web/src/components/ChangeMemberPasswordDialog.tsx
Normal file
115
web/src/components/ChangeMemberPasswordDialog.tsx
Normal 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;
|
||||
139
web/src/components/CreateAccessTokenDialog.tsx
Normal file
139
web/src/components/CreateAccessTokenDialog.tsx
Normal 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;
|
||||
433
web/src/components/CreateIdentityProviderDialog.tsx
Normal file
433
web/src/components/CreateIdentityProviderDialog.tsx
Normal 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;
|
||||
141
web/src/components/CreateShortcutDialog.tsx
Normal file
141
web/src/components/CreateShortcutDialog.tsx
Normal 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;
|
||||
135
web/src/components/CreateUserDialog.tsx
Normal file
135
web/src/components/CreateUserDialog.tsx
Normal 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;
|
||||
159
web/src/components/CreateWebhookDialog.tsx
Normal file
159
web/src/components/CreateWebhookDialog.tsx
Normal 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;
|
||||
43
web/src/components/DateTimeInput.tsx
Normal file
43
web/src/components/DateTimeInput.tsx
Normal 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;
|
||||
11
web/src/components/Empty.tsx
Normal file
11
web/src/components/Empty.tsx
Normal 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;
|
||||
435
web/src/components/ExportImport.tsx
Normal file
435
web/src/components/ExportImport.tsx
Normal 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;
|
||||
58
web/src/components/HomeSidebar/HomeSidebar.tsx
Normal file
58
web/src/components/HomeSidebar/HomeSidebar.tsx
Normal 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;
|
||||
33
web/src/components/HomeSidebar/HomeSidebarDrawer.tsx
Normal file
33
web/src/components/HomeSidebar/HomeSidebarDrawer.tsx
Normal 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;
|
||||
120
web/src/components/HomeSidebar/ShortcutsSection.tsx
Normal file
120
web/src/components/HomeSidebar/ShortcutsSection.tsx
Normal 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;
|
||||
141
web/src/components/HomeSidebar/TagsSection.tsx
Normal file
141
web/src/components/HomeSidebar/TagsSection.tsx
Normal 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;
|
||||
4
web/src/components/HomeSidebar/index.ts
Normal file
4
web/src/components/HomeSidebar/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import HomeSidebar from "./HomeSidebar";
|
||||
import HomeSidebarDrawer from "./HomeSidebarDrawer";
|
||||
|
||||
export { HomeSidebar, HomeSidebarDrawer };
|
||||
142
web/src/components/Inbox/MemoCommentMessage.tsx
Normal file
142
web/src/components/Inbox/MemoCommentMessage.tsx
Normal 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;
|
||||
61
web/src/components/LeafletMap.tsx
Normal file
61
web/src/components/LeafletMap.tsx
Normal 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;
|
||||
31
web/src/components/LearnMore.tsx
Normal file
31
web/src/components/LearnMore.tsx
Normal 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;
|
||||
52
web/src/components/LocaleSelect.tsx
Normal file
52
web/src/components/LocaleSelect.tsx
Normal 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;
|
||||
184
web/src/components/MasonryView/MasonryView.tsx
Normal file
184
web/src/components/MasonryView/MasonryView.tsx
Normal 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;
|
||||
116
web/src/components/MasonryView/README.md
Normal file
116
web/src/components/MasonryView/README.md
Normal 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
|
||||
3
web/src/components/MasonryView/index.ts
Normal file
3
web/src/components/MasonryView/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import MasonryView from "./MasonryView";
|
||||
|
||||
export default MasonryView;
|
||||
218
web/src/components/MemoActionMenu.tsx
Normal file
218
web/src/components/MemoActionMenu.tsx
Normal 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;
|
||||
36
web/src/components/MemoAttachment.tsx
Normal file
36
web/src/components/MemoAttachment.tsx
Normal 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;
|
||||
112
web/src/components/MemoAttachmentListView.tsx
Normal file
112
web/src/components/MemoAttachmentListView.tsx
Normal 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);
|
||||
19
web/src/components/MemoContent/Blockquote.tsx
Normal file
19
web/src/components/MemoContent/Blockquote.tsx
Normal 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;
|
||||
19
web/src/components/MemoContent/Bold.tsx
Normal file
19
web/src/components/MemoContent/Bold.tsx
Normal 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;
|
||||
14
web/src/components/MemoContent/BoldItalic.tsx
Normal file
14
web/src/components/MemoContent/BoldItalic.tsx
Normal 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;
|
||||
9
web/src/components/MemoContent/Code.tsx
Normal file
9
web/src/components/MemoContent/Code.tsx
Normal 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;
|
||||
80
web/src/components/MemoContent/CodeBlock.tsx
Normal file
80
web/src/components/MemoContent/CodeBlock.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
9
web/src/components/MemoContent/EmbeddedContent/Error.tsx
Normal file
9
web/src/components/MemoContent/EmbeddedContent/Error.tsx
Normal 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;
|
||||
25
web/src/components/MemoContent/EmbeddedContent/index.tsx
Normal file
25
web/src/components/MemoContent/EmbeddedContent/index.tsx
Normal 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;
|
||||
9
web/src/components/MemoContent/EscapingCharacter.tsx
Normal file
9
web/src/components/MemoContent/EscapingCharacter.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
interface Props {
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
const EscapingCharacter: React.FC<Props> = ({ symbol }: Props) => {
|
||||
return <span>{symbol}</span>;
|
||||
};
|
||||
|
||||
export default EscapingCharacter;
|
||||
12
web/src/components/MemoContent/HTMLElement.tsx
Normal file
12
web/src/components/MemoContent/HTMLElement.tsx
Normal 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;
|
||||
34
web/src/components/MemoContent/Heading.tsx
Normal file
34
web/src/components/MemoContent/Heading.tsx
Normal 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;
|
||||
9
web/src/components/MemoContent/Highlight.tsx
Normal file
9
web/src/components/MemoContent/Highlight.tsx
Normal 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;
|
||||
12
web/src/components/MemoContent/HorizontalRule.tsx
Normal file
12
web/src/components/MemoContent/HorizontalRule.tsx
Normal 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;
|
||||
10
web/src/components/MemoContent/Image.tsx
Normal file
10
web/src/components/MemoContent/Image.tsx
Normal 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;
|
||||
19
web/src/components/MemoContent/Italic.tsx
Normal file
19
web/src/components/MemoContent/Italic.tsx
Normal 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;
|
||||
5
web/src/components/MemoContent/LineBreak.tsx
Normal file
5
web/src/components/MemoContent/LineBreak.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
const LineBreak = () => {
|
||||
return <br />;
|
||||
};
|
||||
|
||||
export default LineBreak;
|
||||
81
web/src/components/MemoContent/Link.tsx
Normal file
81
web/src/components/MemoContent/Link.tsx
Normal 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;
|
||||
63
web/src/components/MemoContent/List.tsx
Normal file
63
web/src/components/MemoContent/List.tsx
Normal 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;
|
||||
14
web/src/components/MemoContent/Math.tsx
Normal file
14
web/src/components/MemoContent/Math.tsx
Normal 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;
|
||||
55
web/src/components/MemoContent/MermaidBlock.tsx
Normal file
55
web/src/components/MemoContent/MermaidBlock.tsx
Normal 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;
|
||||
21
web/src/components/MemoContent/OrderedListItem.tsx
Normal file
21
web/src/components/MemoContent/OrderedListItem.tsx
Normal 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;
|
||||
19
web/src/components/MemoContent/Paragraph.tsx
Normal file
19
web/src/components/MemoContent/Paragraph.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
22
web/src/components/MemoContent/ReferencedContent/index.tsx
Normal file
22
web/src/components/MemoContent/ReferencedContent/index.tsx
Normal 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;
|
||||
139
web/src/components/MemoContent/Renderer.tsx
Normal file
139
web/src/components/MemoContent/Renderer.tsx
Normal 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;
|
||||
21
web/src/components/MemoContent/Spoiler.tsx
Normal file
21
web/src/components/MemoContent/Spoiler.tsx
Normal 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;
|
||||
9
web/src/components/MemoContent/Strikethrough.tsx
Normal file
9
web/src/components/MemoContent/Strikethrough.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
interface Props {
|
||||
content: string;
|
||||
}
|
||||
|
||||
const Strikethrough: React.FC<Props> = ({ content }: Props) => {
|
||||
return <del>{content}</del>;
|
||||
};
|
||||
|
||||
export default Strikethrough;
|
||||
9
web/src/components/MemoContent/Subscript.tsx
Normal file
9
web/src/components/MemoContent/Subscript.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
interface Props {
|
||||
content: string;
|
||||
}
|
||||
|
||||
const Subscript: React.FC<Props> = ({ content }: Props) => {
|
||||
return <sub>{content}</sub>;
|
||||
};
|
||||
|
||||
export default Subscript;
|
||||
9
web/src/components/MemoContent/Superscript.tsx
Normal file
9
web/src/components/MemoContent/Superscript.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
interface Props {
|
||||
content: string;
|
||||
}
|
||||
|
||||
const Superscript: React.FC<Props> = ({ content }: Props) => {
|
||||
return <sup>{content}</sup>;
|
||||
};
|
||||
|
||||
export default Superscript;
|
||||
37
web/src/components/MemoContent/Table.tsx
Normal file
37
web/src/components/MemoContent/Table.tsx
Normal 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;
|
||||
60
web/src/components/MemoContent/Tag.tsx
Normal file
60
web/src/components/MemoContent/Tag.tsx
Normal 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;
|
||||
58
web/src/components/MemoContent/TaskListItem.tsx
Normal file
58
web/src/components/MemoContent/TaskListItem.tsx
Normal 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;
|
||||
9
web/src/components/MemoContent/Text.tsx
Normal file
9
web/src/components/MemoContent/Text.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
interface Props {
|
||||
content: string;
|
||||
}
|
||||
|
||||
const Text: React.FC<Props> = ({ content }: Props) => {
|
||||
return <span>{content}</span>;
|
||||
};
|
||||
|
||||
export default Text;
|
||||
20
web/src/components/MemoContent/UnorderedListItem.tsx
Normal file
20
web/src/components/MemoContent/UnorderedListItem.tsx
Normal 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;
|
||||
127
web/src/components/MemoContent/index.tsx
Normal file
127
web/src/components/MemoContent/index.tsx
Normal 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);
|
||||
18
web/src/components/MemoContent/types/context.ts
Normal file
18
web/src/components/MemoContent/types/context.ts
Normal 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(),
|
||||
});
|
||||
6
web/src/components/MemoContent/types/index.ts
Normal file
6
web/src/components/MemoContent/types/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./context";
|
||||
|
||||
export interface BaseProps {
|
||||
index: string;
|
||||
className?: string;
|
||||
}
|
||||
107
web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx
Normal file
107
web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx
Normal 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;
|
||||
@@ -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;
|
||||
4
web/src/components/MemoDetailSidebar/index.ts
Normal file
4
web/src/components/MemoDetailSidebar/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import MemoDetailSidebar from "./MemoDetailSidebar";
|
||||
import MemoDetailSidebarDrawer from "./MemoDetailSidebarDrawer";
|
||||
|
||||
export { MemoDetailSidebar, MemoDetailSidebarDrawer };
|
||||
68
web/src/components/MemoDisplaySettingMenu.tsx
Normal file
68
web/src/components/MemoDisplaySettingMenu.tsx
Normal 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;
|
||||
@@ -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;
|
||||
158
web/src/components/MemoEditor/ActionButton/LocationSelector.tsx
Normal file
158
web/src/components/MemoEditor/ActionButton/LocationSelector.tsx
Normal 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;
|
||||
93
web/src/components/MemoEditor/ActionButton/MarkdownMenu.tsx
Normal file
93
web/src/components/MemoEditor/ActionButton/MarkdownMenu.tsx
Normal 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;
|
||||
67
web/src/components/MemoEditor/ActionButton/TagSelector.tsx
Normal file
67
web/src/components/MemoEditor/ActionButton/TagSelector.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
60
web/src/components/MemoEditor/AttachmentListView.tsx
Normal file
60
web/src/components/MemoEditor/AttachmentListView.tsx
Normal 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;
|
||||
130
web/src/components/MemoEditor/Editor/TagSuggestions.tsx
Normal file
130
web/src/components/MemoEditor/Editor/TagSuggestions.tsx
Normal 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;
|
||||
233
web/src/components/MemoEditor/Editor/index.tsx
Normal file
233
web/src/components/MemoEditor/Editor/index.tsx
Normal 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;
|
||||
55
web/src/components/MemoEditor/RelationListView.tsx
Normal file
55
web/src/components/MemoEditor/RelationListView.tsx
Normal 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
Reference in New Issue
Block a user