0.29.1原版

This commit is contained in:
anian
2026-07-02 19:14:14 +08:00
commit d94008f0fb
947 changed files with 174905 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
import { useEffect } from "react";
import { Outlet } from "react-router-dom";
import { useInstance } from "./contexts/InstanceContext";
import { MemoFilterProvider } from "./contexts/MemoFilterContext";
import useNavigateTo from "./hooks/useNavigateTo";
import { useUserLocale } from "./hooks/useUserLocale";
import { useUserTheme } from "./hooks/useUserTheme";
import { cleanupExpiredOAuthState } from "./utils/oauth";
const App = () => {
const navigateTo = useNavigateTo();
const { profile: instanceProfile, profileLoaded, generalSetting: instanceGeneralSetting } = useInstance();
// Apply user preferences reactively
useUserLocale();
useUserTheme();
// Clean up expired OAuth states on app initialization
useEffect(() => {
cleanupExpiredOAuthState();
}, []);
// Redirect to sign up page if instance not initialized (no admin account exists yet).
// Guard with profileLoaded so a fetch failure doesn't incorrectly trigger the redirect.
useEffect(() => {
if (profileLoaded && !instanceProfile.admin) {
navigateTo("/auth/signup");
}
}, [profileLoaded, instanceProfile.admin, navigateTo]);
useEffect(() => {
if (instanceGeneralSetting.additionalStyle) {
const styleEl = document.createElement("style");
styleEl.innerHTML = instanceGeneralSetting.additionalStyle;
styleEl.setAttribute("type", "text/css");
document.body.insertAdjacentElement("beforeend", styleEl);
}
}, [instanceGeneralSetting.additionalStyle]);
useEffect(() => {
if (instanceGeneralSetting.additionalScript) {
const scriptEl = document.createElement("script");
scriptEl.innerHTML = instanceGeneralSetting.additionalScript;
document.head.appendChild(scriptEl);
}
}, [instanceGeneralSetting.additionalScript]);
// Dynamic update metadata with customized profile
useEffect(() => {
if (!instanceGeneralSetting.customProfile) {
return;
}
document.title = instanceGeneralSetting.customProfile.title;
const link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
link.href = instanceGeneralSetting.customProfile.logoUrl || "/logo.webp";
}, [instanceGeneralSetting.customProfile]);
return (
<MemoFilterProvider>
<Outlet />
</MemoFilterProvider>
);
};
export default App;
+128
View File
@@ -0,0 +1,128 @@
// Access token storage using localStorage for persistence across tabs and sessions.
// Tokens are cleared on logout or expiry.
let accessToken: string | null = null;
let tokenExpiresAt: Date | null = null;
const TOKEN_KEY = "memos_access_token";
const EXPIRES_KEY = "memos_token_expires_at";
// BroadcastChannel lets tabs share freshly-refreshed tokens so that only one
// tab needs to hit the refresh endpoint. When another tab successfully refreshes
// we adopt the new token immediately, avoiding a redundant (and potentially
// conflicting) refresh request of our own.
const TOKEN_CHANNEL_NAME = "memos_token_sync";
// Token refresh policy:
// - REQUEST_TOKEN_EXPIRY_BUFFER_MS: used for normal API requests.
// - FOCUS_TOKEN_EXPIRY_BUFFER_MS: used on tab visibility restore to refresh earlier.
export const REQUEST_TOKEN_EXPIRY_BUFFER_MS = 30 * 1000;
export const FOCUS_TOKEN_EXPIRY_BUFFER_MS = 2 * 60 * 1000;
interface TokenBroadcastMessage {
token: string;
expiresAt: string; // ISO string
}
let tokenChannel: BroadcastChannel | null = null;
function getTokenChannel(): BroadcastChannel | null {
if (tokenChannel) return tokenChannel;
try {
tokenChannel = new BroadcastChannel(TOKEN_CHANNEL_NAME);
tokenChannel.onmessage = (event: MessageEvent<TokenBroadcastMessage>) => {
const { token, expiresAt } = event.data ?? {};
if (token && expiresAt) {
// Another tab refreshed — adopt the token in-memory so we don't
// fire our own refresh request.
accessToken = token;
tokenExpiresAt = new Date(expiresAt);
}
};
} catch {
// BroadcastChannel not available (e.g. some privacy modes)
tokenChannel = null;
}
return tokenChannel;
}
// Initialize the channel at module load so the listener is registered
// before any token refresh can occur in any tab.
getTokenChannel();
export const getAccessToken = (): string | null => {
if (!accessToken) {
try {
const storedToken = localStorage.getItem(TOKEN_KEY);
const storedExpires = localStorage.getItem(EXPIRES_KEY);
if (storedToken && storedExpires) {
const expiresAt = new Date(storedExpires);
if (expiresAt > new Date()) {
accessToken = storedToken;
tokenExpiresAt = expiresAt;
}
// Do NOT remove expired tokens here. getRequestToken() in connect.ts calls
// hasStoredToken() to decide whether to attempt a refresh — if we eagerly delete
// the expired token, it returns null immediately, skipping the refresh and sending
// the request without credentials.
// clearAccessToken() handles proper cleanup after a confirmed auth failure or logout.
}
} catch (e) {
// localStorage might not be available (e.g., in some privacy modes)
console.warn("Failed to access localStorage:", e);
}
}
return accessToken;
};
export const setAccessToken = (token: string | null, expiresAt?: Date): void => {
accessToken = token;
tokenExpiresAt = expiresAt || null;
try {
if (token && expiresAt) {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(EXPIRES_KEY, expiresAt.toISOString());
// Broadcast to other tabs so they adopt the new token without refreshing.
const msg: TokenBroadcastMessage = { token, expiresAt: expiresAt.toISOString() };
getTokenChannel()?.postMessage(msg);
} else {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(EXPIRES_KEY);
}
} catch (e) {
// localStorage might not be available (e.g., in some privacy modes)
console.warn("Failed to write to localStorage:", e);
}
};
export const isTokenExpired = (bufferMs: number = REQUEST_TOKEN_EXPIRY_BUFFER_MS): boolean => {
if (!tokenExpiresAt) return true;
// Consider expired with a safety buffer before actual expiry.
return new Date() >= new Date(tokenExpiresAt.getTime() - bufferMs);
};
// Returns true if a token exists in localStorage, even if it is expired.
// Used to decide whether to attempt GetCurrentUser on app init — if no token
// was ever stored, the user is definitively not logged in and there is nothing
// to refresh, so we can skip the network round-trip entirely.
export const hasStoredToken = (): boolean => {
if (accessToken) return true;
try {
return !!localStorage.getItem(TOKEN_KEY);
} catch {
return false;
}
};
export const clearAccessToken = (): void => {
accessToken = null;
tokenExpiresAt = null;
try {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(EXPIRES_KEY);
} catch (e) {
console.warn("Failed to clear localStorage:", e);
}
};
@@ -0,0 +1,81 @@
import { memo } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { DEFAULT_CELL_SIZE, SMALL_CELL_SIZE } from "./constants";
import type { CalendarDayCell, CalendarSize } from "./types";
import { getCalendarCellStateClass, getCellIntensityClass } from "./utils";
export interface CalendarCellProps {
day: CalendarDayCell;
maxCount: number;
tooltipText: string;
onClick?: (date: string) => void;
size?: CalendarSize;
disableTooltip?: boolean;
}
export const CalendarCell = memo((props: CalendarCellProps) => {
const { day, maxCount, tooltipText, onClick, size = "default", disableTooltip = false } = props;
const handleClick = () => {
if (onClick) {
onClick(day.date);
}
};
const sizeConfig = size === "small" ? SMALL_CELL_SIZE : DEFAULT_CELL_SIZE;
const smallExtraClasses = size === "small" ? `${SMALL_CELL_SIZE.dimensions} min-h-0` : "";
const baseClasses = cn(
"aspect-square w-full flex items-center justify-center text-center transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 select-none border border-border/10 bg-muted/20",
sizeConfig.font,
sizeConfig.borderRadius,
smallExtraClasses,
);
const isInteractive = Boolean(onClick);
const ariaLabel = day.isSelected ? `${tooltipText} (selected)` : tooltipText;
if (!day.isCurrentMonth) {
return <div className={cn(baseClasses, "text-muted-foreground/30 bg-transparent border-transparent cursor-default")}>{day.label}</div>;
}
const intensityClass = getCellIntensityClass(day, maxCount);
const buttonClasses = cn(
baseClasses,
intensityClass,
getCalendarCellStateClass(day),
isInteractive ? "cursor-pointer hover:bg-muted/40 hover:border-border/30" : "cursor-default",
);
const button = (
<button
type="button"
onClick={handleClick}
tabIndex={isInteractive ? 0 : -1}
aria-label={ariaLabel}
aria-current={day.isToday ? "date" : undefined}
aria-disabled={!isInteractive}
className={buttonClasses}
>
{day.label}
</button>
);
const shouldShowTooltip = tooltipText && !disableTooltip;
if (!shouldShowTooltip) {
return button;
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="top">
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
);
});
CalendarCell.displayName = "CalendarCell";
@@ -0,0 +1,87 @@
import { memo, useMemo } from "react";
import { useInstance } from "@/contexts/InstanceContext";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { CalendarCell } from "./CalendarCell";
import { useTodayDate, useWeekdayLabels } from "./hooks";
import type { CalendarSize, MonthCalendarProps } from "./types";
import { useCalendarMatrix } from "./useCalendar";
import { getTooltipText } from "./utils";
const GRID_STYLES: Record<CalendarSize, { gap: string; headerText: string }> = {
small: { gap: "gap-1.5", headerText: "text-[10px]" },
default: { gap: "gap-2", headerText: "text-xs" },
};
interface WeekdayHeaderProps {
weekDays: string[];
size: CalendarSize;
}
const WeekdayHeader = memo(({ weekDays, size }: WeekdayHeaderProps) => (
<div className={cn("grid grid-cols-7 mb-1", GRID_STYLES[size].gap, GRID_STYLES[size].headerText)} role="row">
{weekDays.map((label, index) => (
<div
key={index}
className="flex h-4 items-center justify-center uppercase tracking-wide text-muted-foreground/60"
role="columnheader"
aria-label={label}
>
{label}
</div>
))}
</div>
));
WeekdayHeader.displayName = "WeekdayHeader";
export const MonthCalendar = memo((props: MonthCalendarProps) => {
const {
month,
data,
maxCount,
size = "default",
onClick,
selectedDate,
className,
disableTooltips = false,
timeBasis = "create_time",
} = props;
const t = useTranslate();
const { generalSetting } = useInstance();
const today = useTodayDate();
const weekDays = useWeekdayLabels();
const gridStyle = GRID_STYLES[size];
const { weeks, weekDays: rotatedWeekDays } = useCalendarMatrix({
month,
data,
weekDays,
weekStartDayOffset: generalSetting.weekStartDayOffset,
today,
selectedDate: selectedDate ?? "",
});
const flatDays = useMemo(() => weeks.flatMap((week) => week.days), [weeks]);
return (
<div className={cn("flex flex-col", className)} role="grid" aria-label={`Calendar for ${month}`}>
<WeekdayHeader weekDays={rotatedWeekDays} size={size} />
<div className={cn("grid grid-cols-7", gridStyle.gap)} role="rowgroup">
{flatDays.map((day) => (
<CalendarCell
key={day.date}
day={day}
maxCount={maxCount}
tooltipText={getTooltipText(day.count, day.date, t, timeBasis)}
onClick={onClick}
size={size}
disableTooltip={disableTooltips}
/>
))}
</div>
</div>
);
});
MonthCalendar.displayName = "MonthCalendar";
@@ -0,0 +1,118 @@
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { memo, useMemo } from "react";
import { Button } from "@/components/ui/button";
import type { MemoTimeBasis } from "@/contexts/ViewContext";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { getMaxYear, MIN_YEAR } from "./constants";
import { MonthCalendar } from "./MonthCalendar";
import type { CalendarData, YearCalendarProps } from "./types";
import { calculateMaxCount, filterDataByYear, generateMonthsForYear, getMonthLabel } from "./utils";
interface YearNavigationProps {
selectedYear: number;
currentYear: number;
onPrev: () => void;
onNext: () => void;
onToday: () => void;
canGoPrev: boolean;
canGoNext: boolean;
}
const YearNavigation = memo(({ selectedYear, currentYear, onPrev, onNext, onToday, canGoPrev, canGoNext }: YearNavigationProps) => {
const t = useTranslate();
const isCurrentYear = selectedYear === currentYear;
return (
<div className="flex items-center justify-between px-1">
<h2 className="text-2xl font-semibold text-foreground tracking-tight">{selectedYear}</h2>
<nav className="inline-flex items-center gap-0.5 rounded-lg border border-border/30 bg-muted/10 p-0.5" aria-label="Year navigation">
<Button
variant="ghost"
size="sm"
onClick={onPrev}
disabled={!canGoPrev}
aria-label="Previous year"
className="h-7 w-7 p-0 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/40"
>
<ChevronLeftIcon className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onToday}
disabled={isCurrentYear}
aria-label={t("common.today")}
className={cn(
"h-7 px-2.5 rounded-md text-[10px] font-medium uppercase tracking-wider",
isCurrentYear ? "text-muted-foreground/50 cursor-default" : "text-muted-foreground hover:text-foreground hover:bg-muted/40",
)}
>
{t("common.today")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={onNext}
disabled={!canGoNext}
aria-label="Next year"
className="h-7 w-7 p-0 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/40"
>
<ChevronRightIcon className="w-4 h-4" />
</Button>
</nav>
</div>
);
});
YearNavigation.displayName = "YearNavigation";
interface MonthCardProps {
month: string;
data: CalendarData;
maxCount: number;
onDateClick: (date: string) => void;
timeBasis?: MemoTimeBasis;
}
const MonthCard = memo(({ month, data, maxCount, onDateClick, timeBasis }: MonthCardProps) => (
<article className="flex flex-col gap-2 rounded-xl border border-border/20 bg-muted/5 p-3 transition-colors hover:bg-muted/10">
<header className="text-[10px] font-medium text-muted-foreground/80 uppercase tracking-widest">{getMonthLabel(month)}</header>
<MonthCalendar month={month} data={data} maxCount={maxCount} size="small" onClick={onDateClick} disableTooltips timeBasis={timeBasis} />
</article>
));
MonthCard.displayName = "MonthCard";
export const YearCalendar = memo(({ selectedYear, data, onYearChange, onDateClick, className, timeBasis }: YearCalendarProps) => {
const currentYear = useMemo(() => new Date().getFullYear(), []);
const yearData = useMemo(() => filterDataByYear(data, selectedYear), [data, selectedYear]);
const months = useMemo(() => generateMonthsForYear(selectedYear), [selectedYear]);
const yearMaxCount = useMemo(() => calculateMaxCount(yearData), [yearData]);
const canGoPrev = selectedYear > MIN_YEAR;
const canGoNext = selectedYear < getMaxYear();
return (
<section className={cn("w-full flex flex-col gap-5 px-4 py-4 select-none", className)} aria-label={`Year ${selectedYear} calendar`}>
<YearNavigation
selectedYear={selectedYear}
currentYear={currentYear}
onPrev={() => canGoPrev && onYearChange(selectedYear - 1)}
onNext={() => canGoNext && onYearChange(selectedYear + 1)}
onToday={() => onYearChange(currentYear)}
canGoPrev={canGoPrev}
canGoNext={canGoNext}
/>
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 animate-fade-in">
{months.map((month) => (
<MonthCard key={month} month={month} data={yearData} maxCount={yearMaxCount} onDateClick={onDateClick} timeBasis={timeBasis} />
))}
</div>
</section>
);
});
YearCalendar.displayName = "YearCalendar";
@@ -0,0 +1,34 @@
export const DAYS_IN_WEEK = 7;
export const MONTHS_IN_YEAR = 12;
export const MIN_COUNT = 1;
export const MIN_YEAR = 1970;
export const getMaxYear = () => new Date().getFullYear() + 1;
export const INTENSITY_THRESHOLDS = {
HIGH: 0.75,
MEDIUM: 0.5,
LOW: 0.25,
MINIMAL: 0,
} as const;
export const CELL_STYLES = {
HIGH: "bg-primary text-primary-foreground shadow-sm border-transparent",
MEDIUM: "bg-primary/85 text-primary-foreground shadow-sm border-transparent",
LOW: "bg-primary/70 text-primary-foreground border-transparent",
MINIMAL: "bg-primary/50 text-foreground border-transparent",
EMPTY: "bg-muted/20 text-muted-foreground hover:bg-muted/30 border-border/10",
} as const;
export const SMALL_CELL_SIZE = {
font: "text-[11px]",
dimensions: "w-full h-full",
borderRadius: "rounded-lg",
gap: "gap-1.5",
} as const;
export const DEFAULT_CELL_SIZE = {
font: "text-xs",
borderRadius: "rounded-lg",
gap: "gap-2",
} as const;
@@ -0,0 +1,23 @@
import dayjs from "dayjs";
import { useMemo } from "react";
import { useTranslate } from "@/utils/i18n";
export const useWeekdayLabels = () => {
const t = useTranslate();
return useMemo(
() => [
t("common.days.sun"),
t("common.days.mon"),
t("common.days.tue"),
t("common.days.wed"),
t("common.days.thu"),
t("common.days.fri"),
t("common.days.sat"),
],
[t],
);
};
export const useTodayDate = () => {
return dayjs().format("YYYY-MM-DD");
};
@@ -0,0 +1,4 @@
export * from "./MonthCalendar";
export * from "./types";
export * from "./utils";
export * from "./YearCalendar";
@@ -0,0 +1,43 @@
import type { MemoTimeBasis } from "@/contexts/ViewContext";
export type CalendarSize = "default" | "small";
export type CalendarData = Record<string, number>;
export interface CalendarDayCell {
date: string;
label: number;
count: number;
isCurrentMonth: boolean;
isToday: boolean;
isSelected: boolean;
}
export interface CalendarDayRow {
days: CalendarDayCell[];
}
export interface CalendarMatrixResult {
weeks: CalendarDayRow[];
weekDays: string[];
}
export interface MonthCalendarProps {
month: string;
data: CalendarData;
maxCount: number;
size?: CalendarSize;
onClick?: (date: string) => void;
selectedDate?: string;
className?: string;
disableTooltips?: boolean;
timeBasis?: MemoTimeBasis;
}
export interface YearCalendarProps {
selectedYear: number;
data: CalendarData;
onYearChange: (year: number) => void;
onDateClick: (date: string) => void;
className?: string;
timeBasis?: MemoTimeBasis;
}
@@ -0,0 +1,90 @@
import dayjs from "dayjs";
import { useMemo } from "react";
import { DAYS_IN_WEEK } from "./constants";
import type { CalendarData, CalendarDayCell, CalendarMatrixResult } from "./types";
export interface UseCalendarMatrixParams {
month: string;
data: CalendarData;
weekDays: string[];
weekStartDayOffset: number;
today: string;
selectedDate: string;
}
const createCalendarDayCell = (
current: dayjs.Dayjs,
monthKey: string,
data: CalendarData,
today: string,
selectedDate: string,
): CalendarDayCell => {
const isoDate = current.format("YYYY-MM-DD");
const isCurrentMonth = current.format("YYYY-MM") === monthKey;
const count = data[isoDate] ?? 0;
return {
date: isoDate,
label: current.date(),
count,
isCurrentMonth,
isToday: isoDate === today,
isSelected: isoDate === selectedDate,
};
};
const calculateCalendarBoundaries = (monthStart: dayjs.Dayjs, weekStartDayOffset: number) => {
const monthEnd = monthStart.endOf("month");
const startOffset = (monthStart.day() - weekStartDayOffset + DAYS_IN_WEEK) % DAYS_IN_WEEK;
const endOffset = (weekStartDayOffset + (DAYS_IN_WEEK - 1) - monthEnd.day() + DAYS_IN_WEEK) % DAYS_IN_WEEK;
const calendarStart = monthStart.subtract(startOffset, "day");
const calendarEnd = monthEnd.add(endOffset, "day");
const dayCount = calendarEnd.diff(calendarStart, "day") + 1;
return { calendarStart, dayCount };
};
/**
* Generates a matrix of calendar days for a given month, handling week alignment and data mapping.
*/
export const useCalendarMatrix = ({
month,
data,
weekDays,
weekStartDayOffset,
today,
selectedDate,
}: UseCalendarMatrixParams): CalendarMatrixResult => {
return useMemo(() => {
// Determine the start of the month and its formatted key (YYYY-MM)
const monthStart = dayjs(month).startOf("month");
const monthKey = monthStart.format("YYYY-MM");
// Rotate week labels based on the user's preferred start of the week
const rotatedWeekDays = weekDays.slice(weekStartDayOffset).concat(weekDays.slice(0, weekStartDayOffset));
// Calculate the start and end dates for the calendar grid to ensure full weeks
const { calendarStart, dayCount } = calculateCalendarBoundaries(monthStart, weekStartDayOffset);
const weeks: CalendarMatrixResult["weeks"] = [];
// Iterate through each day in the calendar grid
for (let index = 0; index < dayCount; index += 1) {
const current = calendarStart.add(index, "day");
const weekIndex = Math.floor(index / DAYS_IN_WEEK);
if (!weeks[weekIndex]) {
weeks[weekIndex] = { days: [] };
}
// Create the day cell object with data and status flags
const dayCell = createCalendarDayCell(current, monthKey, data, today, selectedDate);
weeks[weekIndex].days.push(dayCell);
}
return {
weeks,
weekDays: rotatedWeekDays,
};
}, [month, data, weekDays, weekStartDayOffset, today, selectedDate]);
};
@@ -0,0 +1,75 @@
import dayjs from "dayjs";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import type { MemoTimeBasis } from "@/contexts/ViewContext";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { CELL_STYLES, INTENSITY_THRESHOLDS, MIN_COUNT, MONTHS_IN_YEAR } from "./constants";
import type { CalendarData, CalendarDayCell } from "./types";
dayjs.extend(isSameOrAfter);
dayjs.extend(isSameOrBefore);
export type TranslateFunction = ReturnType<typeof useTranslate>;
export const getCellIntensityClass = (day: CalendarDayCell, maxCount: number): string => {
if (!day.isCurrentMonth || day.count === 0) {
return CELL_STYLES.EMPTY;
}
const ratio = day.count / maxCount;
if (ratio > INTENSITY_THRESHOLDS.HIGH) return CELL_STYLES.HIGH;
if (ratio > INTENSITY_THRESHOLDS.MEDIUM) return CELL_STYLES.MEDIUM;
if (ratio > INTENSITY_THRESHOLDS.LOW) return CELL_STYLES.LOW;
return CELL_STYLES.MINIMAL;
};
export const getCalendarCellStateClass = (day: Pick<CalendarDayCell, "isToday" | "isSelected">): string => {
return cn(day.isToday && "font-semibold z-10", day.isSelected && "font-bold z-10");
};
export const generateMonthsForYear = (year: number): string[] => {
return Array.from({ length: MONTHS_IN_YEAR }, (_, i) => dayjs(`${year}-01-01`).add(i, "month").format("YYYY-MM"));
};
export const calculateMaxCount = (data: CalendarData): number => {
let max = 0;
for (const count of Object.values(data)) {
max = Math.max(max, count);
}
return Math.max(max, MIN_COUNT);
};
export const getMonthLabel = (month: string): string => {
return dayjs(month).format("MMM");
};
export const filterDataByYear = (data: Record<string, number>, year: number): Record<string, number> => {
if (!data) return {};
const filtered: Record<string, number> = {};
const yearStart = dayjs(`${year}-01-01`);
const yearEnd = dayjs(`${year}-12-31`);
for (const [dateStr, count] of Object.entries(data)) {
const date = dayjs(dateStr);
if (date.isSameOrAfter(yearStart, "day") && date.isSameOrBefore(yearEnd, "day")) {
filtered[dateStr] = count;
}
}
return filtered;
};
export const getTooltipText = (count: number, date: string, t: TranslateFunction, timeBasis: MemoTimeBasis = "create_time"): string => {
if (count === 0) {
return date;
}
const key = timeBasis === "update_time" ? "memo.count-memos-updated-in-date" : "memo.count-memos-in-date";
return t(key, {
count,
memos: count === 1 ? t("common.memo") : t("common.memos"),
date,
}).toLowerCase();
};
+108
View File
@@ -0,0 +1,108 @@
import {
BinaryIcon,
BookIcon,
FileArchiveIcon,
FileAudioIcon,
FileEditIcon,
FileIcon,
FileTextIcon,
FileVideo2Icon,
SheetIcon,
} from "lucide-react";
import React, { useState } from "react";
import { cn } from "@/lib/utils";
import { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { getAttachmentThumbnailUrl, getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
import SquareDiv from "./kit/SquareDiv";
import PreviewImageDialog from "./PreviewImageDialog";
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={getAttachmentThumbnailUrl(attachment)}
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);
@@ -0,0 +1,129 @@
import { FileAudioIcon, FileIcon, PlayIcon } from "lucide-react";
import AudioAttachmentItem from "@/components/MemoMetadata/Attachment/AudioAttachmentItem";
import VideoPoster from "@/components/VideoPoster";
import type { AttachmentLibraryListItem } from "@/hooks/useAttachmentLibrary";
import { cn } from "@/lib/utils";
import { getAttachmentThumbnailUrl, getAttachmentType, isMotionAttachment } from "@/utils/attachment";
import { AttachmentMetadataLine, AttachmentOpenButton, AttachmentSourceChip } from "./AttachmentLibraryPrimitives";
const AttachmentThumb = ({ item, className }: { item: AttachmentLibraryListItem; className?: string }) => {
const type = getAttachmentType(item.attachment);
const isMotion = isMotionAttachment(item.attachment);
if (type === "image/*" || isMotion) {
return (
<div className={cn("overflow-hidden rounded-xl bg-muted/35", className)}>
<img
src={getAttachmentThumbnailUrl(item.attachment)}
alt={item.attachment.filename}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
</div>
);
}
if (type === "video/*") {
return (
<div className={cn("relative overflow-hidden rounded-xl bg-muted/35", className)}>
<VideoPoster sourceUrl={item.sourceUrl} alt={item.attachment.filename} className="h-full w-full object-cover" />
<span className="absolute bottom-2 right-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-background/85 text-foreground shadow-sm">
<PlayIcon className="h-3.5 w-3.5 fill-current" />
</span>
</div>
);
}
return (
<div className={cn("flex items-center justify-center rounded-xl bg-muted/45 text-muted-foreground", className)}>
{type === "audio/*" ? <FileAudioIcon className="h-5 w-5" /> : <FileIcon className="h-5 w-5" />}
</div>
);
};
export const AttachmentDocumentRows = ({ items }: { items: AttachmentLibraryListItem[] }) => {
return (
<div className="space-y-3">
{items.map((item) => (
<article
key={item.attachment.name}
className="flex items-center gap-2.5 rounded-[18px] border border-border/60 bg-background/90 p-3 shadow-sm shadow-black/[0.02]"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted/45 text-muted-foreground">
<FileIcon className="h-4.5 w-4.5" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground" title={item.attachment.filename}>
{item.attachment.filename}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
<AttachmentMetadataLine className="min-w-0 max-w-full" items={[item.fileTypeLabel, item.fileSizeLabel, item.createdLabel]} />
<AttachmentSourceChip memoName={item.memoName} />
</div>
</div>
<AttachmentOpenButton href={item.sourceUrl} />
</article>
))}
</div>
);
};
export const AttachmentAudioRows = ({ items }: { items: AttachmentLibraryListItem[] }) => {
return (
<div className="space-y-2.5">
{items.map((item) => (
<article
key={item.attachment.name}
className="rounded-[18px] border border-border/60 bg-background/90 p-2.5 shadow-sm shadow-black/[0.02]"
>
<AudioAttachmentItem
filename={item.attachment.filename}
sourceUrl={item.sourceUrl}
mimeType={item.attachment.type}
size={Number(item.attachment.size)}
/>
<div className="mt-2.5 flex items-center justify-between gap-2 border-t border-border/60 px-0.5 pt-2.5">
<div className="min-w-0 flex flex-wrap items-center gap-1.5">
<AttachmentMetadataLine className="min-w-0 max-w-full" items={[item.createdLabel]} />
<AttachmentSourceChip memoName={item.memoName} />
</div>
<AttachmentOpenButton href={item.sourceUrl} />
</div>
</article>
))}
</div>
);
};
export const AttachmentUnusedRows = ({ items }: { items: AttachmentLibraryListItem[] }) => {
return (
<div className="space-y-2.5">
{items.map((item) => (
<article
key={item.attachment.name}
className="flex items-center gap-2.5 rounded-[18px] border border-amber-200/70 bg-amber-50/50 p-3 shadow-sm shadow-black/[0.02] dark:border-amber-900/50 dark:bg-amber-950/10"
>
<AttachmentThumb item={item} className="h-10 w-10 shrink-0" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground" title={item.attachment.filename}>
{item.attachment.filename}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
<AttachmentMetadataLine className="min-w-0 max-w-full" items={[item.fileTypeLabel, item.fileSizeLabel, item.createdLabel]} />
<AttachmentSourceChip unlinkedLabelKey="attachment-library.labels.not-linked" />
</div>
</div>
<AttachmentOpenButton
className="text-amber-900/80 hover:text-amber-950 dark:text-amber-100/80 dark:hover:text-amber-50"
href={item.sourceUrl}
/>
</article>
))}
</div>
);
};
@@ -0,0 +1,57 @@
import { FileAudioIcon, FileStackIcon, ImageIcon } from "lucide-react";
import type { ComponentType } from "react";
import type { AttachmentLibraryTab } from "@/hooks/useAttachmentLibrary";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
interface AttachmentLibraryEmptyStateProps {
className?: string;
tab: AttachmentLibraryTab;
}
const EMPTY_STATE_CONFIG: Record<
AttachmentLibraryTab,
{
descriptionKey: "attachment-library.empty.audio" | "attachment-library.empty.documents" | "attachment-library.empty.media";
icon: ComponentType<{ className?: string }>;
titleKey: "attachment-library.tabs.audio" | "attachment-library.tabs.documents" | "attachment-library.tabs.media";
}
> = {
audio: {
descriptionKey: "attachment-library.empty.audio",
icon: FileAudioIcon,
titleKey: "attachment-library.tabs.audio",
},
documents: {
descriptionKey: "attachment-library.empty.documents",
icon: FileStackIcon,
titleKey: "attachment-library.tabs.documents",
},
media: {
descriptionKey: "attachment-library.empty.media",
icon: ImageIcon,
titleKey: "attachment-library.tabs.media",
},
};
const AttachmentLibraryEmptyState = ({ className, tab }: AttachmentLibraryEmptyStateProps) => {
const t = useTranslate();
const { descriptionKey, icon: Icon, titleKey } = EMPTY_STATE_CONFIG[tab];
return (
<div
className={cn(
"flex min-h-[18rem] flex-col items-center justify-center rounded-[28px] border border-dashed border-border/70 bg-background/80 px-6 py-16 text-center",
className,
)}
>
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted/45 text-muted-foreground">
<Icon className="h-7 w-7" />
</div>
<div className="mt-5 text-sm font-medium text-foreground">{t(titleKey)}</div>
<p className="mt-2 max-w-sm text-sm leading-6 text-muted-foreground">{t(descriptionKey)}</p>
</div>
);
};
export default AttachmentLibraryEmptyState;
@@ -0,0 +1,90 @@
import { ExternalLinkIcon } from "lucide-react";
import { Link } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
interface AttachmentMetadataLineProps {
className?: string;
items: Array<string | undefined>;
}
interface AttachmentSourceChipProps {
memoName?: string;
unlinkedLabelKey?: "attachment-library.labels.not-linked" | "attachment-library.labels.unused";
}
interface AttachmentOpenButtonProps {
className?: string;
href: string;
}
export const AttachmentMetadataLine = ({ className, items }: AttachmentMetadataLineProps) => {
const visibleItems = items.filter((item): item is string => Boolean(item));
if (visibleItems.length === 0) {
return null;
}
return (
<div
className={cn(
"flex items-center gap-1.5 overflow-x-auto whitespace-nowrap text-xs text-muted-foreground [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
className,
)}
>
{visibleItems.map((item, index) => (
<span key={`${item}-${index}`} className="contents">
{index > 0 && <span className="shrink-0 text-muted-foreground/50"></span>}
<span className="shrink-0">{item}</span>
</span>
))}
</div>
);
};
export const AttachmentSourceChip = ({
memoName,
unlinkedLabelKey = "attachment-library.labels.not-linked",
}: AttachmentSourceChipProps) => {
const t = useTranslate();
if (!memoName) {
return (
<Badge
variant="outline"
className="rounded-full border-amber-300/70 bg-amber-50/70 px-1.5 py-0.5 text-[11px] text-amber-900 dark:border-amber-700/60 dark:bg-amber-950/20 dark:text-amber-100"
>
{t(unlinkedLabelKey)}
</Badge>
);
}
return (
<Link
to={`/${memoName}`}
className="inline-flex max-w-full items-center truncate rounded-full border border-border/60 bg-muted/30 px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted/50"
>
<span className="truncate">{t("attachment-library.labels.memo")}</span>
</Link>
);
};
export const AttachmentOpenButton = ({ className, href }: AttachmentOpenButtonProps) => {
const t = useTranslate();
return (
<Button
asChild
variant="ghost"
size="icon"
className={cn("size-7 shrink-0 rounded-full text-muted-foreground hover:text-foreground", className)}
>
<a href={href} target="_blank" rel="noreferrer">
<ExternalLinkIcon className="h-3.5 w-3.5" />
<span className="sr-only">{t("attachment-library.actions.open")}</span>
</a>
</Button>
);
};
@@ -0,0 +1,77 @@
import { LoaderCircleIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslate } from "@/utils/i18n";
interface AttachmentLibraryErrorStateProps {
error?: Error;
onRetry: () => void;
}
interface AttachmentLibrarySkeletonGridProps {
count?: number;
}
interface AttachmentLibraryUnusedPanelProps {
count: number;
isDeleting: boolean;
isExpanded: boolean;
onDelete: () => void;
onToggle: () => void;
}
export const AttachmentLibrarySkeletonGrid = ({ count = 8 }: AttachmentLibrarySkeletonGridProps) => {
return (
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: count }).map((_, index) => (
<div key={index} className="overflow-hidden rounded-[20px] border border-border/60 bg-background/90">
<div className="aspect-[5/4] animate-pulse bg-muted/50" />
<div className="space-y-2.5 p-3">
<div className="h-4 w-2/3 animate-pulse rounded bg-muted/50" />
<div className="h-3 w-1/2 animate-pulse rounded bg-muted/40" />
<div className="h-7 w-full animate-pulse rounded bg-muted/40" />
</div>
</div>
))}
</div>
);
};
export const AttachmentLibraryErrorState = ({ error, onRetry }: AttachmentLibraryErrorStateProps) => {
const t = useTranslate();
return (
<div className="rounded-[20px] border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-muted-foreground">{error?.message ?? t("attachment-library.errors.load")}</p>
<Button className="mt-4 rounded-full" onClick={onRetry}>
{t("attachment-library.actions.retry")}
</Button>
</div>
);
};
export const AttachmentLibraryUnusedPanel = ({ count, isDeleting, isExpanded, onDelete, onToggle }: AttachmentLibraryUnusedPanelProps) => {
const t = useTranslate();
return (
<div className="rounded-2xl border border-amber-200/70 bg-amber-50/50 p-4 dark:border-amber-900/50 dark:bg-amber-950/10">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">
{t("attachment-library.unused.title")} ({count})
</div>
<p className="mt-1 text-sm text-muted-foreground">{t("attachment-library.unused.description")}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" className="rounded-full border-amber-300/70 bg-background/80 px-3" onClick={onToggle}>
{isExpanded ? t("common.close") : t("attachment-library.labels.unused")}
</Button>
<Button variant="destructive" className="rounded-full" onClick={onDelete} disabled={isDeleting}>
{isDeleting ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : null}
{t("resource.delete-all-unused")}
</Button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,64 @@
import { FileAudioIcon, FileStackIcon, ImageIcon } from "lucide-react";
import type { ComponentType } from "react";
import { Button } from "@/components/ui/button";
import type { AttachmentLibraryStats, AttachmentLibraryTab } from "@/hooks/useAttachmentLibrary";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
interface AttachmentLibraryToolbarProps {
activeTab: AttachmentLibraryTab;
onTabChange: (tab: AttachmentLibraryTab) => void;
stats: AttachmentLibraryStats;
}
const TAB_CONFIG: Array<{
key: AttachmentLibraryTab;
labelKey: "media" | "documents" | "audio";
icon: ComponentType<{ className?: string }>;
count: (stats: AttachmentLibraryStats) => number;
}> = [
{ key: "media", labelKey: "media", icon: ImageIcon, count: (stats) => stats.media },
{ key: "audio", labelKey: "audio", icon: FileAudioIcon, count: (stats) => stats.audio },
{ key: "documents", labelKey: "documents", icon: FileStackIcon, count: (stats) => stats.documents },
];
const AttachmentLibraryToolbar = ({ activeTab, onTabChange, stats }: AttachmentLibraryToolbarProps) => {
const t = useTranslate();
return (
<div className="-mx-1 overflow-x-auto px-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="flex min-w-max items-center gap-1.5">
{TAB_CONFIG.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.key;
return (
<Button
key={tab.key}
type="button"
variant="ghost"
className={cn(
"h-9 rounded-md px-2.5 text-sm font-medium sm:px-3",
isActive ? "bg-muted/60 text-foreground shadow-none" : "text-muted-foreground hover:bg-muted/40 hover:text-foreground",
)}
onClick={() => onTabChange(tab.key)}
>
<Icon className="h-4 w-4" />
<span>{t(`attachment-library.tabs.${tab.labelKey}`)}</span>
<span
className={cn(
"rounded-full px-1.5 py-0.5 text-[11px]",
isActive ? "bg-background text-muted-foreground" : "bg-muted/50 text-muted-foreground",
)}
>
{tab.count(stats)}
</span>
</Button>
);
})}
</div>
</div>
);
};
export default AttachmentLibraryToolbar;
@@ -0,0 +1,97 @@
import { PlayIcon } from "lucide-react";
import MotionPhotoPreview from "@/components/MotionPhotoPreview";
import { Badge } from "@/components/ui/badge";
import VideoPoster from "@/components/VideoPoster";
import type { AttachmentLibraryMediaItem, AttachmentLibraryMonthGroup } from "@/hooks/useAttachmentLibrary";
import { useTranslate } from "@/utils/i18n";
import { AttachmentMetadataLine, AttachmentOpenButton } from "./AttachmentLibraryPrimitives";
interface AttachmentMediaGridProps {
groups: AttachmentLibraryMonthGroup[];
onPreview: (itemId: string) => void;
}
const AttachmentMediaCard = ({ item, onPreview }: { item: AttachmentLibraryMediaItem; onPreview: () => void }) => {
const t = useTranslate();
return (
<article className="overflow-hidden rounded-[20px] border border-border/60 bg-background/90 shadow-sm shadow-black/[0.03]">
<button type="button" className="relative block w-full cursor-pointer text-left" onClick={onPreview}>
<div className="relative aspect-[5/4] overflow-hidden bg-muted/40">
{item.kind === "video" ? (
<>
<VideoPoster
sourceUrl={item.sourceUrl}
posterUrl={item.posterUrl}
alt={item.filename}
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-linear-to-t from-black/35 via-black/5 to-transparent" />
<span className="absolute bottom-2.5 right-2.5 inline-flex h-8 w-8 items-center justify-center rounded-full bg-background/85 text-foreground shadow-sm backdrop-blur-sm">
<PlayIcon className="h-3.5 w-3.5 fill-current" />
</span>
</>
) : item.kind === "motion" ? (
<MotionPhotoPreview
posterUrl={item.posterUrl}
motionUrl={item.previewItem.kind === "motion" ? item.previewItem.motionUrl : item.sourceUrl}
alt={item.filename}
presentationTimestampUs={item.previewItem.kind === "motion" ? item.previewItem.presentationTimestampUs : undefined}
containerClassName="h-full w-full"
mediaClassName="h-full w-full object-cover"
badgeClassName="left-3 top-3"
/>
) : (
<img src={item.posterUrl} alt={item.filename} className="h-full w-full object-cover" loading="lazy" decoding="async" />
)}
</div>
</button>
<div className="flex flex-col gap-2 p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 truncate text-sm font-medium leading-5 text-foreground" title={item.filename}>
{item.filename}
</div>
{item.kind === "motion" && (
<Badge variant="outline" className="rounded-full border-border/60 bg-background/70 px-1.5 py-0.5 text-[11px]">
{t("attachment-library.labels.live")}
</Badge>
)}
</div>
<div className="flex items-center justify-between gap-2">
<AttachmentMetadataLine
className="min-w-0 flex-1"
items={[item.fileTypeLabel, item.createdLabel !== "—" ? item.createdLabel : undefined]}
/>
<AttachmentOpenButton href={item.sourceUrl} />
</div>
</div>
</article>
);
};
const AttachmentMediaGrid = ({ groups, onPreview }: AttachmentMediaGridProps) => {
return (
<div className="flex flex-col gap-6 sm:gap-8">
{groups.map((group) => (
<section key={group.key} className="space-y-2.5 sm:space-y-3">
<div className="flex items-center gap-2.5">
<div className="text-xs font-medium uppercase tracking-[0.24em] text-muted-foreground">{group.label}</div>
<div className="h-px flex-1 bg-border/70" />
</div>
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
{group.items.map((item) => (
<AttachmentMediaCard key={item.id} item={item} onPreview={() => onPreview(item.previewItem.id)} />
))}
</div>
</section>
))}
</div>
);
};
export default AttachmentMediaGrid;
@@ -0,0 +1,6 @@
export { AttachmentAudioRows, AttachmentDocumentRows, AttachmentUnusedRows } from "./AttachmentFileRows";
export { default as AttachmentLibraryEmptyState } from "./AttachmentLibraryEmptyState";
export { AttachmentMetadataLine, AttachmentOpenButton, AttachmentSourceChip } from "./AttachmentLibraryPrimitives";
export { AttachmentLibraryErrorState, AttachmentLibrarySkeletonGrid, AttachmentLibraryUnusedPanel } from "./AttachmentLibraryStates";
export { default as AttachmentLibraryToolbar } from "./AttachmentLibraryToolbar";
export { default as AttachmentMediaGrid } from "./AttachmentMediaGrid";
+35
View File
@@ -0,0 +1,35 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { loadLocale } from "@/utils/i18n";
import { getInitialTheme, loadTheme, Theme } from "@/utils/theme";
import LocaleSelect from "./LocaleSelect";
import ThemeSelect from "./ThemeSelect";
interface Props {
className?: string;
}
const AuthFooter = ({ className }: Props) => {
const { i18n: i18nInstance } = useTranslation();
const currentLocale = i18nInstance.language as Locale;
const [currentTheme, setCurrentTheme] = useState(getInitialTheme());
const handleLocaleChange = (locale: Locale) => {
loadLocale(locale);
};
const handleThemeChange = (theme: string) => {
loadTheme(theme);
setCurrentTheme(theme as Theme);
};
return (
<div className={cn("mt-4 flex flex-row items-center justify-center w-full gap-2", className)}>
<LocaleSelect value={currentLocale} onChange={handleLocaleChange} />
<ThemeSelect value={currentTheme} onValueChange={handleThemeChange} />
</div>
);
};
export default AuthFooter;
@@ -0,0 +1,114 @@
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 { useUpdateUser } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
import { User } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
user?: User;
onSuccess?: () => void;
}
function ChangeMemberPasswordDialog({ open, onOpenChange, user, onSuccess }: Props) {
const t = useTranslate();
const { mutateAsync: updateUser } = useUpdateUser();
const [newPassword, setNewPassword] = useState("");
const [newPasswordAgain, setNewPasswordAgain] = useState("");
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 updateUser({
user: {
name: user.name,
password: newPassword,
},
updateMask: ["password"],
});
toast(t("message.password-changed"));
onSuccess?.();
onOpenChange(false);
} catch (error: unknown) {
await handleError(error, toast.error, {
context: "Change member password",
});
}
};
if (!user) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t("setting.account.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;
+131
View File
@@ -0,0 +1,131 @@
# ConfirmDialog - Accessible Confirmation Dialog
## Overview
`ConfirmDialog` standardizes confirmation flows across the app. It replaces adhoc `window.confirm` usage with an accessible, themeable dialog that supports asynchronous operations.
## Key Features
### 1. Accessibility & UX
- Uses shared `Dialog` primitives (focus trap, ARIA roles)
- Blocks dismissal while async confirm is pending
- Clear separation of title (action) vs description (context)
### 2. Async-Aware
- Accepts sync or async `onConfirm`
- Auto-closes on resolve; remains open on error for retry / toast
### 3. Internationalization Ready
- All labels / text provided by caller through i18n hook
- Supports interpolation for dynamic context
### 4. Minimal Surface, Easy Extension
- Lightweight API (few required props)
- Style hook via `.container` class (SCSS module)
## Architecture
```
ConfirmDialog
├── State: loading (tracks pending confirm action)
├── Dialog primitives: Header (title + description), Footer (buttons)
└── External control: parent owns open state via onOpenChange
```
## Usage
```tsx
import { useTranslate } from "@/utils/i18n";
import ConfirmDialog from "@/components/ConfirmDialog";
const t = useTranslate();
<ConfirmDialog
open={open}
onOpenChange={setOpen}
title={t("memo.delete-confirm")}
description={t("memo.delete-confirm-description")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={handleDelete}
confirmVariant="destructive"
/>;
```
## Props
| Prop | Type | Required | Acceptable Values |
|------|------|----------|------------------|
| `open` | `boolean` | Yes | `true` (visible) / `false` (hidden) |
| `onOpenChange` | `(open: boolean) => void` | Yes | Callback receiving next state; should update parent state |
| `title` | `React.ReactNode` | Yes | Short localized action summary (text / node) |
| `description` | `React.ReactNode` | No | Optional contextual message |
| `confirmLabel` | `string` | Yes | Non-empty localized action text (12 words) |
| `cancelLabel` | `string` | Yes | Localized cancel label |
| `onConfirm` | `() => void | Promise<void>` | Yes | Sync or async handler; resolve = close, reject = stay open |
| `confirmVariant` | `"default" | "destructive"` | No | Defaults to `"default"`; use `"destructive"` for irreversible actions |
## Benefits vs Previous Implementation
### Before (window.confirm / adhoc dialogs)
- Blocking native prompt, inconsistent styling
- No async progress handling
- No rich formatting
- Hard to localize consistently
### After (ConfirmDialog)
- Unified styling + accessibility semantics
- Async-safe with loading state shielding
- Plain description flexibility
- i18n-first via externalized labels
## Technical Implementation Details
### Async Handling
```tsx
const handleConfirm = async () => {
setLoading(true);
try {
await onConfirm(); // resolve -> close
onOpenChange(false);
} catch (e) {
console.error(e); // remain open for retry
} finally {
setLoading(false);
}
};
```
### Close Guard
```tsx
<Dialog open={open} onOpenChange={(next) => !loading && onOpenChange(next)} />
```
## Browser / Environment Support
- Works anywhere the existing `Dialog` primitives work (modern browsers)
- No ResizeObserver / layout dependencies
## Performance Considerations
1. Minimal renders: loading state toggles once per confirm attempt
2. No portal churn—relies on underlying dialog infra
## Future Enhancements
1. Severity icon / header accent
2. Auto-focus destructive button toggle
3. Secondary action (e.g. "Archive" vs "Delete")
4. Built-in retry / error slot
5. Optional checkbox confirmation ("I understand the consequences")
6. Motion/animation tokens integration
## Styling
The `ConfirmDialog.module.scss` file provides a `.container` hook. It currently only hosts a harmless custom property so the stylesheet is non-empty. Add real layout or variant tokens there instead of inline styles.
## Internationalization
All visible strings must come from the translation system. Use `useTranslate()` and pass localized values into props. Separate keys for title/description.
## Error Handling
Errors thrown in `onConfirm` are caught and logged. The dialog stays open so the caller can surface a toast or inline message and allow retry. (Consider routing serious errors to a higher-level handler.)
---
If you extend this component, update this README to keep usage discoverable.
@@ -0,0 +1,59 @@
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
export interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: React.ReactNode;
description?: React.ReactNode;
confirmLabel: string;
cancelLabel: string;
onConfirm: () => void | Promise<void>;
confirmVariant?: "default" | "destructive";
}
export default function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel,
cancelLabel,
onConfirm,
confirmVariant = "default",
}: ConfirmDialogProps) {
const [loading, setLoading] = React.useState(false);
const handleConfirm = async () => {
try {
setLoading(true);
await onConfirm();
onOpenChange(false);
} catch (e) {
// Intentionally swallow errors so user can retry; surface via caller's toast/logging
console.error("ConfirmDialog error for action:", title, e);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={(o: boolean) => !loading && onOpenChange(o)}>
<DialogContent size="sm">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
<DialogFooter>
<Button variant="ghost" disabled={loading} onClick={() => onOpenChange(false)}>
{cancelLabel}
</Button>
<Button variant={confirmVariant} disabled={loading} onClick={handleConfirm} data-loading={loading}>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,183 @@
import copy from "copy-to-clipboard";
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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { userServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser";
import useLoading from "@/hooks/useLoading";
import { handleError } from "@/lib/error";
import { CreatePersonalAccessTokenResponse } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: (response: CreatePersonalAccessTokenResponse) => void;
}
interface State {
description: string;
expiration: number;
}
function CreateAccessTokenDialog({ open, onOpenChange, onSuccess }: Props) {
const t = useTranslate();
const currentUser = useCurrentUser();
const [state, setState] = useState({
description: "",
expiration: 30, // Default: 30 days
});
const [createdToken, setCreatedToken] = useState<string | null>(null);
const requestState = useLoading(false);
// Expiration options in days (0 = never expires)
const expirationOptions = [
{
label: t("setting.access-token.create-dialog.duration-1m"),
value: 30,
},
{
label: "90 Days",
value: 90,
},
{
label: t("setting.access-token.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();
const response = await userServiceClient.createPersonalAccessToken({
parent: currentUser?.name,
description: state.description,
expiresInDays: state.expiration,
});
requestState.setFinish();
onSuccess(response);
if (response.token) {
setCreatedToken(response.token);
} else {
onOpenChange(false);
}
} catch (error: unknown) {
handleError(error, toast.error, {
context: "Create access token",
onError: () => requestState.setError(),
});
}
};
const handleCopyToken = () => {
if (!createdToken) return;
copy(createdToken);
toast.success(t("message.copied"));
};
useEffect(() => {
if (!open) return;
setState({
description: "",
expiration: 30,
});
setCreatedToken(null);
}, [open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("setting.access-token.create-dialog.create-access-token")}</DialogTitle>
</DialogHeader>
{createdToken ? (
<div className="flex flex-col gap-4">
<div className="grid gap-2">
<Label>{t("setting.access-token.token")}</Label>
<Textarea value={createdToken} readOnly rows={3} className="font-mono text-xs" />
</div>
</div>
) : (
<div className="flex flex-col gap-4">
<div className="grid gap-2">
<Label htmlFor="description">
{t("setting.access-token.create-dialog.description")} <span className="text-destructive">*</span>
</Label>
<Input
id="description"
type="text"
placeholder={t("setting.access-token.create-dialog.some-description")}
value={state.description}
onChange={handleDescriptionInputChange}
/>
</div>
<div className="grid gap-2">
<Label>
{t("setting.access-token.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>
{createdToken ? (
<>
<Button variant="ghost" onClick={handleCopyToken}>
{t("common.copy")}
</Button>
<Button onClick={() => onOpenChange(false)}>{t("common.close")}</Button>
</>
) : (
<>
<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;
@@ -0,0 +1,615 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { type ReactNode, useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { identityProviderServiceClient } from "@/connect";
import { absolutifyLink } from "@/helpers/utils";
import { handleError } from "@/lib/error";
import {
FieldMapping,
FieldMappingSchema,
IdentityProvider,
IdentityProvider_Type,
IdentityProviderConfigSchema,
IdentityProviderSchema,
OAuth2Config,
OAuth2ConfigSchema,
} from "@/types/proto/api/v1/idp_service_pb";
import { useTranslate } from "@/utils/i18n";
const DEFAULT_TEMPLATE = "GitHub";
const templateList: IdentityProvider[] = [
create(IdentityProviderSchema, {
name: "",
title: "GitHub",
type: IdentityProvider_Type.OAUTH2,
identifierFilter: "",
config: create(IdentityProviderConfigSchema, {
config: {
case: "oauth2Config",
value: create(OAuth2ConfigSchema, {
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: create(FieldMappingSchema, {
identifier: "login",
displayName: "name",
email: "email",
}),
}),
},
}),
}),
create(IdentityProviderSchema, {
name: "",
title: "GitLab",
type: IdentityProvider_Type.OAUTH2,
identifierFilter: "",
config: create(IdentityProviderConfigSchema, {
config: {
case: "oauth2Config",
value: create(OAuth2ConfigSchema, {
clientId: "",
clientSecret: "",
authUrl: "https://gitlab.com/oauth/authorize",
tokenUrl: "https://gitlab.com/oauth/token",
userInfoUrl: "https://gitlab.com/oauth/userinfo",
scopes: ["openid"],
fieldMapping: create(FieldMappingSchema, {
identifier: "name",
displayName: "name",
email: "email",
}),
}),
},
}),
}),
create(IdentityProviderSchema, {
name: "",
title: "Google",
type: IdentityProvider_Type.OAUTH2,
identifierFilter: "",
config: create(IdentityProviderConfigSchema, {
config: {
case: "oauth2Config",
value: create(OAuth2ConfigSchema, {
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: create(FieldMappingSchema, {
identifier: "email",
displayName: "name",
email: "email",
}),
}),
},
}),
}),
create(IdentityProviderSchema, {
name: "",
title: "Custom",
type: IdentityProvider_Type.OAUTH2,
identifierFilter: "",
config: create(IdentityProviderConfigSchema, {
config: {
case: "oauth2Config",
value: create(OAuth2ConfigSchema, {
clientId: "",
clientSecret: "",
authUrl: "",
tokenUrl: "",
userInfoUrl: "",
scopes: [],
fieldMapping: create(FieldMappingSchema, {
identifier: "",
displayName: "",
email: "",
}),
}),
},
}),
}),
];
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
identityProvider?: IdentityProvider;
onSuccess?: () => void;
}
interface BasicInfoState {
title: string;
identifier: string;
identifierFilter: string;
}
function createEmptyFieldMapping(): FieldMapping {
return create(FieldMappingSchema, {
identifier: "",
displayName: "",
email: "",
avatarUrl: "",
});
}
function createEmptyOAuth2Config(): OAuth2Config {
return create(OAuth2ConfigSchema, {
clientId: "",
clientSecret: "",
authUrl: "",
tokenUrl: "",
userInfoUrl: "",
scopes: [],
fieldMapping: createEmptyFieldMapping(),
});
}
function createEmptyBasicInfo(): BasicInfoState {
return {
title: "",
identifier: "",
identifierFilter: "",
};
}
function sanitizeIdentifier(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/--+/g, "-")
.replace(/^-+|-+$/g, "");
}
function normalizeScopes(value: string): string[] {
return value
.split(/\s+/)
.map((scope) => scope.trim())
.filter(Boolean);
}
function buildDialogStateFromTemplate(templateName: string) {
const template = templateList.find((item) => item.title === templateName) ?? templateList[0];
const oauth2Config =
template.type === IdentityProvider_Type.OAUTH2 && template.config?.config.case === "oauth2Config"
? create(OAuth2ConfigSchema, template.config.config.value)
: createEmptyOAuth2Config();
return {
basicInfo: {
title: template.title,
identifier: sanitizeIdentifier(template.title),
identifierFilter: template.identifierFilter,
},
type: template.type,
oauth2Config,
oauth2Scopes: oauth2Config.scopes.join(" "),
};
}
function buildDialogStateFromProvider(identityProvider: IdentityProvider) {
const oauth2Config =
identityProvider.type === IdentityProvider_Type.OAUTH2 && identityProvider.config?.config.case === "oauth2Config"
? create(OAuth2ConfigSchema, identityProvider.config.config.value)
: createEmptyOAuth2Config();
return {
basicInfo: {
title: identityProvider.title,
identifier: "",
identifierFilter: identityProvider.identifierFilter,
},
type: identityProvider.type,
oauth2Config,
oauth2Scopes: oauth2Config.scopes.join(" "),
};
}
function FormSection({ title, description, children }: { title: string; description?: string; children: ReactNode }) {
return (
<section className="space-y-4 rounded-lg border bg-muted/20 p-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
</div>
<div className="space-y-4">{children}</div>
</section>
);
}
function FormField({
label,
required = false,
description,
children,
}: {
label: string;
required?: boolean;
description?: string;
children: ReactNode;
}) {
return (
<div className="space-y-2">
<Label>
{label}
{required ? <span className="text-destructive">*</span> : null}
</Label>
{children}
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
</div>
);
}
function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, onSuccess }: Props) {
const t = useTranslate();
const identityProviderTypes = [...new Set(templateList.map((template) => template.type))];
const [basicInfo, setBasicInfo] = useState<BasicInfoState>(createEmptyBasicInfo);
const [type, setType] = useState<IdentityProvider_Type>(IdentityProvider_Type.OAUTH2);
const [oauth2Config, setOAuth2Config] = useState<OAuth2Config>(createEmptyOAuth2Config);
const [oauth2Scopes, setOAuth2Scopes] = useState<string>("");
const [selectedTemplate, setSelectedTemplate] = useState<string>(DEFAULT_TEMPLATE);
const [isSubmitting, setIsSubmitting] = useState(false);
const isCreating = identityProvider === undefined;
const oauth2FieldMapping = oauth2Config.fieldMapping ?? createEmptyFieldMapping();
useEffect(() => {
if (!open) {
setSelectedTemplate(DEFAULT_TEMPLATE);
setBasicInfo(createEmptyBasicInfo());
setType(IdentityProvider_Type.OAUTH2);
setOAuth2Config(createEmptyOAuth2Config());
setOAuth2Scopes("");
setIsSubmitting(false);
return;
}
const nextState = isCreating ? buildDialogStateFromTemplate(selectedTemplate) : buildDialogStateFromProvider(identityProvider!);
setBasicInfo(nextState.basicInfo);
setType(nextState.type);
setOAuth2Config(nextState.oauth2Config);
setOAuth2Scopes(nextState.oauth2Scopes);
}, [open, isCreating, identityProvider, selectedTemplate]);
const handleDialogClose = (nextOpen: boolean) => {
if (isSubmitting && !nextOpen) {
return;
}
onOpenChange(nextOpen);
};
const handleCloseBtnClick = () => {
if (isSubmitting) {
return;
}
handleDialogClose(false);
};
const allowConfirmAction = () => {
if (basicInfo.title.trim() === "") {
return false;
}
if (isCreating && basicInfo.identifier.trim() === "") {
return false;
}
if (type === IdentityProvider_Type.OAUTH2) {
if (
oauth2Config.clientId.trim() === "" ||
oauth2Config.authUrl.trim() === "" ||
oauth2Config.tokenUrl.trim() === "" ||
oauth2Config.userInfoUrl.trim() === "" ||
normalizeScopes(oauth2Scopes).length === 0 ||
oauth2FieldMapping.identifier.trim() === ""
) {
return false;
}
if (isCreating && oauth2Config.clientSecret.trim() === "") {
return false;
}
}
return !isSubmitting;
};
const handleConfirmBtnClick = async () => {
setIsSubmitting(true);
const normalizedScopes = normalizeScopes(oauth2Scopes);
try {
if (isCreating) {
await identityProviderServiceClient.createIdentityProvider({
identityProviderId: basicInfo.identifier,
identityProvider: create(IdentityProviderSchema, {
title: basicInfo.title.trim(),
identifierFilter: basicInfo.identifierFilter.trim(),
type,
config: create(IdentityProviderConfigSchema, {
config: {
case: "oauth2Config",
value: {
...oauth2Config,
scopes: normalizedScopes,
},
},
}),
}),
});
toast.success(t("setting.sso.sso-created", { name: basicInfo.title }));
} else {
await identityProviderServiceClient.updateIdentityProvider({
identityProvider: create(IdentityProviderSchema, {
name: identityProvider!.name,
title: basicInfo.title.trim(),
identifierFilter: basicInfo.identifierFilter.trim(),
type,
config: create(IdentityProviderConfigSchema, {
config: {
case: "oauth2Config",
value: {
...oauth2Config,
scopes: normalizedScopes,
},
},
}),
}),
updateMask: create(FieldMaskSchema, { paths: ["title", "identifier_filter", "config"] }),
});
toast.success(t("setting.sso.sso-updated", { name: basicInfo.title }));
}
} catch (error: unknown) {
setIsSubmitting(false);
await handleError(error, toast.error, {
context: isCreating ? "Create identity provider" : "Update identity provider",
});
return;
}
setIsSubmitting(false);
onSuccess?.();
handleDialogClose(false);
};
const setPartialOAuth2Config = (state: Partial<OAuth2Config>) => {
setOAuth2Config((current) => ({
...current,
...state,
}));
};
const setPartialFieldMapping = (state: Partial<FieldMapping>) => {
setPartialOAuth2Config({
fieldMapping: {
...oauth2FieldMapping,
...state,
} as FieldMapping,
});
};
return (
<Dialog open={open} onOpenChange={handleDialogClose}>
<DialogContent size="2xl">
<DialogHeader>
<DialogTitle>{t(isCreating ? "setting.sso.create-sso" : "setting.sso.update-sso")}</DialogTitle>
<DialogDescription>
{t(isCreating ? "setting.sso.create-sso-description" : "setting.sso.update-sso-description")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<FormSection title={t("setting.sso.basic-settings")} description={t("setting.sso.basic-settings-description")}>
{isCreating ? (
<div className="grid gap-4 md:grid-cols-2">
<FormField label={t("common.type")} required>
<Select value={String(type)} onValueChange={(value) => setType(Number(value) as IdentityProvider_Type)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{identityProviderTypes.map((kind) => (
<SelectItem key={kind} value={String(kind)}>
{IdentityProvider_Type[kind] || kind}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField label={t("setting.sso.template")} required description={t("setting.sso.template-description")}>
<Select value={selectedTemplate} onValueChange={setSelectedTemplate}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{templateList.map((template) => (
<SelectItem key={template.title} value={template.title}>
{template.title}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
{isCreating ? (
<FormField label={t("setting.sso.provider-id")} required description={t("setting.sso.provider-id-description")}>
<Input
className="font-mono"
placeholder="e.g. github, okta-corp"
maxLength={32}
value={basicInfo.identifier}
onChange={(e) =>
setBasicInfo((current) => ({
...current,
identifier: sanitizeIdentifier(e.target.value),
}))
}
/>
</FormField>
) : null}
<FormField label={t("common.name")} required>
<Input
placeholder={t("common.name")}
value={basicInfo.title}
onChange={(e) =>
setBasicInfo((current) => ({
...current,
title: e.target.value,
}))
}
/>
</FormField>
</div>
<FormField label={t("setting.sso.identifier-filter")} description={t("setting.sso.identifier-filter-description")}>
<Input
placeholder={t("setting.sso.identifier-filter")}
value={basicInfo.identifierFilter}
onChange={(e) =>
setBasicInfo((current) => ({
...current,
identifierFilter: e.target.value,
}))
}
/>
</FormField>
</FormSection>
{type === IdentityProvider_Type.OAUTH2 ? (
<>
<FormSection title={t("setting.sso.oauth-configuration")} description={t("setting.sso.oauth-configuration-description")}>
<div className="rounded-md border bg-background px-3 py-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("setting.sso.redirect-url")}</p>
<p className="mt-2 break-all font-mono text-xs text-foreground sm:text-sm">{absolutifyLink("/auth/callback")}</p>
<p className="mt-2 text-xs text-muted-foreground">{t("setting.sso.redirect-url-description")}</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField label={t("setting.sso.client-id")} required>
<Input
placeholder={t("setting.sso.client-id")}
value={oauth2Config.clientId}
onChange={(e) => setPartialOAuth2Config({ clientId: e.target.value })}
/>
</FormField>
<FormField
label={t("setting.sso.client-secret")}
required={isCreating}
description={isCreating ? undefined : t("setting.sso.client-secret-optional-description")}
>
<Input
type="password"
autoComplete="off"
placeholder={t("setting.sso.client-secret")}
value={oauth2Config.clientSecret}
onChange={(e) => setPartialOAuth2Config({ clientSecret: e.target.value })}
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField label={t("setting.sso.authorization-endpoint")} required>
<Input
placeholder={t("setting.sso.authorization-endpoint")}
value={oauth2Config.authUrl}
onChange={(e) => setPartialOAuth2Config({ authUrl: e.target.value })}
/>
</FormField>
<FormField label={t("setting.sso.token-endpoint")} required>
<Input
placeholder={t("setting.sso.token-endpoint")}
value={oauth2Config.tokenUrl}
onChange={(e) => setPartialOAuth2Config({ tokenUrl: e.target.value })}
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField label={t("setting.sso.user-endpoint")} required>
<Input
placeholder={t("setting.sso.user-endpoint")}
value={oauth2Config.userInfoUrl}
onChange={(e) => setPartialOAuth2Config({ userInfoUrl: e.target.value })}
/>
</FormField>
<FormField label={t("setting.sso.scopes")} required description={t("setting.sso.scopes-description")}>
<Input placeholder={t("setting.sso.scopes")} value={oauth2Scopes} onChange={(e) => setOAuth2Scopes(e.target.value)} />
</FormField>
</div>
</FormSection>
<FormSection title={t("setting.sso.field-mapping")} description={t("setting.sso.field-mapping-description")}>
<div className="grid gap-4 md:grid-cols-2">
<FormField
label={t("setting.sso.identifier")}
required
description={t("setting.sso.field-mapping-identifier-description")}
>
<Input
placeholder={t("setting.sso.identifier")}
value={oauth2FieldMapping.identifier}
onChange={(e) => setPartialFieldMapping({ identifier: e.target.value })}
/>
</FormField>
<FormField label={t("setting.sso.display-name")}>
<Input
placeholder={t("setting.sso.display-name")}
value={oauth2FieldMapping.displayName}
onChange={(e) => setPartialFieldMapping({ displayName: e.target.value })}
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField label={t("common.email")}>
<Input
placeholder={t("common.email")}
value={oauth2FieldMapping.email}
onChange={(e) => setPartialFieldMapping({ email: e.target.value })}
/>
</FormField>
<FormField label={t("setting.sso.avatar-url")}>
<Input
placeholder={t("setting.sso.avatar-url")}
value={oauth2FieldMapping.avatarUrl}
onChange={(e) => setPartialFieldMapping({ avatarUrl: e.target.value })}
/>
</FormField>
</div>
</FormSection>
</>
) : null}
</div>
<DialogFooter>
<Button variant="ghost" onClick={handleCloseBtnClick} disabled={isSubmitting}>
{t("common.cancel")}
</Button>
<Button onClick={handleConfirmBtnClick} disabled={!allowConfirmAction()}>
{t(isCreating ? "common.create" : "common.update")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CreateIdentityProviderDialog;
+150
View File
@@ -0,0 +1,150 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { userServiceClient } from "@/connect";
import useLoading from "@/hooks/useLoading";
import { handleError } from "@/lib/error";
import { User, User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
user?: User;
onSuccess?: () => void;
}
function CreateUserDialog({ open, onOpenChange, user: initialUser, onSuccess }: Props) {
const t = useTranslate();
const [user, setUser] = useState(
create(UserSchema, initialUser ? { name: initialUser.name, username: initialUser.username, role: initialUser.role } : {}),
);
const requestState = useLoading(false);
const isCreating = !initialUser;
useEffect(() => {
if (initialUser) {
setUser(create(UserSchema, { name: initialUser.name, username: initialUser.username, role: initialUser.role }));
} else {
setUser(create(UserSchema, {}));
}
}, [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");
}
const userToUpdate = create(UserSchema, { ...user, name: initialUser?.name ?? user.name });
await userServiceClient.updateUser({ user: userToUpdate, updateMask: create(FieldMaskSchema, { paths: updateMask }) });
toast.success("Update user successfully");
}
requestState.setFinish();
onSuccess?.();
onOpenChange(false);
} catch (error: unknown) {
handleError(error, toast.error, {
context: user ? "Update user" : "Create user",
onError: () => 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={String(user.role)}
onValueChange={(value) => setPartialUser({ role: Number(value) as User_Role })}
className="flex flex-row gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value={String(User_Role.USER)} id="user" />
<Label htmlFor="user">{t("setting.member.user")}</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value={String(User_Role.ADMIN)} id="admin" />
<Label htmlFor="admin">{t("setting.member.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;
+166
View File
@@ -0,0 +1,166 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
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 { userServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser";
import useLoading from "@/hooks/useLoading";
import { handleError } from "@/lib/error";
import { useTranslate } from "@/utils/i18n";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
webhookName?: string;
onSuccess?: () => void;
}
interface State {
displayName: string;
url: string;
}
function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Props) {
const t = useTranslate();
const currentUser = useCurrentUser();
const [state, setState] = useState<State>({
displayName: "",
url: "",
});
const requestState = useLoading(false);
const isCreating = webhookName === undefined;
useEffect(() => {
if (webhookName && currentUser) {
// For editing, we need to get the webhook data
// Since we're using user webhooks now, we need to list all webhooks and find the one we want
userServiceClient
.listUserWebhooks({
parent: currentUser.name,
})
.then((response) => {
const webhook = response.webhooks.find((w) => w.name === webhookName);
if (webhook) {
setState({
displayName: webhook.displayName,
url: webhook.url,
});
}
});
}
}, [webhookName, currentUser]);
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 userServiceClient.createUserWebhook({
parent: currentUser.name,
webhook: {
displayName: state.displayName,
url: state.url,
},
});
} else {
await userServiceClient.updateUserWebhook({
webhook: {
name: webhookName,
displayName: state.displayName,
url: state.url,
},
updateMask: create(FieldMaskSchema, { paths: ["display_name", "url"] }),
});
}
onSuccess?.();
onOpenChange(false);
requestState.setFinish();
} catch (error: unknown) {
handleError(error, toast.error, {
context: webhookName ? "Update webhook" : "Create webhook",
onError: () => requestState.setError(),
});
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{isCreating ? t("setting.webhook.create-dialog.create-webhook") : t("setting.webhook.create-dialog.edit-webhook")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid gap-2">
<Label htmlFor="displayName">
{t("setting.webhook.create-dialog.title")} <span className="text-destructive">*</span>
</Label>
<Input
id="displayName"
type="text"
placeholder={t("setting.webhook.create-dialog.an-easy-to-remember-name")}
value={state.displayName}
onChange={handleTitleInputChange}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="url">
{t("setting.webhook.create-dialog.payload-url")} <span className="text-destructive">*</span>
</Label>
<Input
id="url"
type="text"
placeholder={t("setting.webhook.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
View File
@@ -0,0 +1,43 @@
import dayjs from "dayjs";
import toast from "react-hot-toast";
import { cn } from "@/lib/utils";
// must be compatible with JS Date.parse(), we use ISO 8601 (almost)
const DATE_TIME_FORMAT = "YYYY-MM-DD HH:mm:ss";
// convert Date to datetime string.
const formatDate = (date: Date): string => {
return dayjs(date).format(DATE_TIME_FORMAT);
};
interface Props {
value: Date;
onChange: (date: Date) => void;
}
const DateTimeInput: React.FC<Props> = ({ value, onChange }) => {
return (
<input
type="datetime-local"
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;
+103
View File
@@ -0,0 +1,103 @@
import { AlertCircle, RefreshCw } from "lucide-react";
import { Component, type ErrorInfo, type ReactNode } from "react";
import { useRouteError } from "react-router-dom";
import { Button } from "./ui/button";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("ErrorBoundary caught an error:", error, errorInfo);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
window.location.reload();
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="max-w-md w-full p-6 space-y-4">
<div className="flex items-center gap-3 text-destructive">
<AlertCircle className="w-8 h-8" />
<h1 className="text-2xl font-bold">Something went wrong</h1>
</div>
<p className="text-foreground/70">
An unexpected error occurred. This could be due to a network issue or a problem with the application.
</p>
{this.state.error && (
<details className="bg-muted p-3 rounded-md text-sm">
<summary className="cursor-pointer font-medium mb-2">Error details</summary>
<pre className="whitespace-pre-wrap break-words text-xs text-foreground/60">{this.state.error.message}</pre>
</details>
)}
<Button onClick={this.handleReset} className="w-full gap-2">
<RefreshCw className="w-4 h-4" />
Reload Application
</Button>
</div>
</div>
);
}
return this.props.children;
}
}
// React Router errorElement for route-level errors (e.g., failed chunk loads after redeployment).
export function ChunkLoadErrorFallback() {
const error = useRouteError() as Error | undefined;
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="max-w-md w-full p-6 space-y-4">
<div className="flex items-center gap-3 text-destructive">
<AlertCircle className="w-8 h-8" />
<h1 className="text-2xl font-bold">Something went wrong</h1>
</div>
<p className="text-foreground/70">
An unexpected error occurred. This could be due to a network issue or an application update. Reloading usually fixes it.
</p>
{error?.message && (
<details className="bg-muted p-3 rounded-md text-sm">
<summary className="cursor-pointer font-medium mb-2">Error details</summary>
<pre className="whitespace-pre-wrap break-words text-xs text-foreground/60">{error.message}</pre>
</details>
)}
<Button onClick={() => window.location.reload()} className="w-full gap-2">
<RefreshCw className="w-4 h-4" />
Reload Application
</Button>
</div>
</div>
);
}
@@ -0,0 +1,158 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema, timestampDate } from "@bufbuild/protobuf/wkt";
import { CheckIcon, MessageCircleIcon, TrashIcon, XIcon } from "lucide-react";
import toast from "react-hot-toast";
import UserAvatar from "@/components/UserAvatar";
import { userServiceClient } from "@/connect";
import useNavigateTo from "@/hooks/useNavigateTo";
import { cn } from "@/lib/utils";
import { UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
interface Props {
notification: UserNotification;
}
function MemoCommentMessage({ notification }: Props) {
const t = useTranslate();
const navigateTo = useNavigateTo();
const commentPayload = notification.payload?.case === "memoComment" ? notification.payload.value : undefined;
const sender = notification.senderUser;
const handleArchiveMessage = async (silence = false) => {
await userServiceClient.updateUserNotification({
notification: {
name: notification.name,
status: UserNotification_Status.ARCHIVED,
},
updateMask: create(FieldMaskSchema, { paths: ["status"] }),
});
if (!silence) {
toast.success(t("message.archived-successfully"));
}
};
const handleDeleteMessage = async () => {
await userServiceClient.deleteUserNotification({
name: notification.name,
});
toast.success(t("message.deleted-successfully"));
};
if (!commentPayload) {
return (
<div className="w-full px-5 py-4 border-b border-border/60 last:border-b-0 bg-destructive/[0.04] group">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-destructive/15 flex items-center justify-center shrink-0 ring-1 ring-destructive/20">
<XIcon className="w-5 h-5 text-destructive" strokeWidth={2} />
</div>
<span className="text-sm text-destructive/80 font-medium">{t("inbox.failed-to-load")}</span>
</div>
<button
onClick={handleDeleteMessage}
className="p-1.5 hover:bg-destructive/15 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
title={t("common.delete")}
>
<TrashIcon className="w-4 h-4 text-destructive/70 hover:text-destructive transition-colors" strokeWidth={2} />
</button>
</div>
</div>
);
}
const isUnread = notification.status === UserNotification_Status.UNREAD;
const handleNavigateToMemo = async () => {
navigateTo(`/${commentPayload.relatedMemo}`);
if (isUnread) {
await handleArchiveMessage(true);
}
};
return (
<div
className={cn(
"w-full px-5 py-4 border-b border-border/60 last:border-b-0 transition-all duration-200 group relative",
isUnread ? "bg-primary/[0.03] hover:bg-primary/[0.05]" : "hover:bg-muted/30",
)}
>
{isUnread && <div className="absolute left-0 top-0 bottom-0 w-0.5 bg-gradient-to-b from-primary to-primary/60" />}
<div className="flex items-start gap-3">
<div className="relative shrink-0">
<UserAvatar className="w-10 h-10 ring-1 ring-border/40" avatarUrl={sender?.avatarUrl} />
<div
className={cn(
"absolute -bottom-1 -right-1 w-5 h-5 rounded-full border-2 border-background flex items-center justify-center shadow-md transition-all",
isUnread ? "bg-primary text-primary-foreground" : "bg-muted/80 text-muted-foreground",
)}
>
<MessageCircleIcon className="w-2.5 h-2.5" strokeWidth={2.5} />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-3 mb-1">
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<span className="font-semibold text-sm text-foreground/95">{sender?.displayName || sender?.username}</span>
<span className="text-sm text-muted-foreground/80">commented on your memo</span>
<span className="text-xs text-muted-foreground/60">
{notification.createTime &&
timestampDate(notification.createTime)?.toLocaleDateString([], { month: "short", day: "numeric" })}{" "}
at{" "}
{notification.createTime &&
timestampDate(notification.createTime)?.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{isUnread ? (
<button
onClick={() => handleArchiveMessage()}
className="p-1.5 hover:bg-primary/10 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
title={t("common.archive")}
>
<CheckIcon className="w-4 h-4 text-muted-foreground hover:text-primary transition-colors" strokeWidth={2} />
</button>
) : (
<button
onClick={handleDeleteMessage}
className="p-1.5 hover:bg-destructive/10 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
title={t("common.delete")}
>
<TrashIcon className="w-4 h-4 text-muted-foreground hover:text-destructive transition-colors" strokeWidth={2} />
</button>
)}
</div>
</div>
<div className="pl-3 border-l-2 border-muted-foreground/20 mb-3">
<p className="text-sm text-foreground/60 line-clamp-1 leading-relaxed">
<span className="text-xs text-muted-foreground/50 font-medium mr-2 uppercase tracking-wide">Original:</span>
{commentPayload.relatedMemoSnippet || <span className="italic text-muted-foreground/40">Empty memo</span>}
</p>
</div>
<div
onClick={handleNavigateToMemo}
className="p-2 sm:p-3 rounded-lg bg-gradient-to-br from-primary/[0.06] to-primary/[0.03] hover:from-primary/[0.1] hover:to-primary/[0.06] cursor-pointer border border-primary/30 hover:border-primary/50 transition-all duration-200 group/comment shadow-sm hover:shadow"
>
<div className="flex items-start gap-2">
<div className="w-5 h-5 flex items-center justify-center shrink-0">
<MessageCircleIcon className="w-4 h-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-primary/60 font-semibold mb-1 uppercase tracking-wider">Comment</p>
<p className="text-sm text-foreground/90 line-clamp-2">
{commentPayload.memoSnippet || <span className="italic text-muted-foreground/50">Empty comment</span>}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default MemoCommentMessage;
@@ -0,0 +1,164 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema, timestampDate } from "@bufbuild/protobuf/wkt";
import { AtSignIcon, CheckIcon, MessageSquareIcon, TrashIcon, XIcon } from "lucide-react";
import toast from "react-hot-toast";
import UserAvatar from "@/components/UserAvatar";
import { userServiceClient } from "@/connect";
import useNavigateTo from "@/hooks/useNavigateTo";
import { cn } from "@/lib/utils";
import { UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
interface Props {
notification: UserNotification;
}
function MemoMentionMessage({ notification }: Props) {
const t = useTranslate();
const navigateTo = useNavigateTo();
const mentionPayload = notification.payload?.case === "memoMention" ? notification.payload.value : undefined;
const sender = notification.senderUser;
const handleArchiveMessage = async (silence = false) => {
await userServiceClient.updateUserNotification({
notification: {
name: notification.name,
status: UserNotification_Status.ARCHIVED,
},
updateMask: create(FieldMaskSchema, { paths: ["status"] }),
});
if (!silence) {
toast.success(t("message.archived-successfully"));
}
};
const handleDeleteMessage = async () => {
await userServiceClient.deleteUserNotification({
name: notification.name,
});
toast.success(t("message.deleted-successfully"));
};
if (!mentionPayload) {
return (
<div className="w-full px-5 py-4 border-b border-border/60 last:border-b-0 bg-destructive/[0.04] group">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-destructive/15 flex items-center justify-center shrink-0 ring-1 ring-destructive/20">
<XIcon className="w-5 h-5 text-destructive" strokeWidth={2} />
</div>
<span className="text-sm text-destructive/80 font-medium">{t("inbox.failed-to-load")}</span>
</div>
<button
onClick={handleDeleteMessage}
className="p-1.5 hover:bg-destructive/15 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
title={t("common.delete")}
>
<TrashIcon className="w-4 h-4 text-destructive/70 hover:text-destructive transition-colors" strokeWidth={2} />
</button>
</div>
</div>
);
}
const isUnread = notification.status === UserNotification_Status.UNREAD;
const isCommentMention = Boolean(mentionPayload.relatedMemo);
const targetName = mentionPayload.relatedMemo || mentionPayload.memo;
const handleNavigate = async () => {
navigateTo(`/${targetName}`);
if (isUnread) {
await handleArchiveMessage(true);
}
};
return (
<div
className={cn(
"w-full px-5 py-4 border-b border-border/60 last:border-b-0 transition-all duration-200 group relative",
isUnread ? "bg-primary/[0.03] hover:bg-primary/[0.05]" : "hover:bg-muted/30",
)}
>
{isUnread && <div className="absolute left-0 top-0 bottom-0 w-0.5 bg-gradient-to-b from-primary to-primary/60" />}
<div className="flex items-start gap-3">
<div className="relative shrink-0">
<UserAvatar className="w-10 h-10 ring-1 ring-border/40" avatarUrl={sender?.avatarUrl} />
<div
className={cn(
"absolute -bottom-1 -right-1 w-5 h-5 rounded-full border-2 border-background flex items-center justify-center shadow-md transition-all",
isUnread ? "bg-primary text-primary-foreground" : "bg-muted/80 text-muted-foreground",
)}
>
<AtSignIcon className="w-2.5 h-2.5" strokeWidth={2.5} />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-3 mb-1">
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<span className="font-semibold text-sm text-foreground/95">{sender?.displayName || sender?.username}</span>
<span className="text-sm text-muted-foreground/80">mentioned you {isCommentMention ? "in a comment" : "in a memo"}</span>
<span className="text-xs text-muted-foreground/60">
{notification.createTime &&
timestampDate(notification.createTime)?.toLocaleDateString([], { month: "short", day: "numeric" })}{" "}
at{" "}
{notification.createTime &&
timestampDate(notification.createTime)?.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{isUnread ? (
<button
onClick={() => handleArchiveMessage()}
className="p-1.5 hover:bg-primary/10 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
title={t("common.archive")}
>
<CheckIcon className="w-4 h-4 text-muted-foreground hover:text-primary transition-colors" strokeWidth={2} />
</button>
) : (
<button
onClick={handleDeleteMessage}
className="p-1.5 hover:bg-destructive/10 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
title={t("common.delete")}
>
<TrashIcon className="w-4 h-4 text-muted-foreground hover:text-destructive transition-colors" strokeWidth={2} />
</button>
)}
</div>
</div>
{mentionPayload.relatedMemo && (
<div className="pl-3 border-l-2 border-muted-foreground/20 mb-3">
<p className="text-sm text-foreground/60 line-clamp-1 leading-relaxed">
<span className="text-xs text-muted-foreground/50 font-medium mr-2 uppercase tracking-wide">Memo:</span>
{mentionPayload.relatedMemoSnippet || <span className="italic text-muted-foreground/40">Empty memo</span>}
</p>
</div>
)}
<div
onClick={handleNavigate}
className="p-2 sm:p-3 rounded-lg bg-gradient-to-br from-primary/[0.06] to-primary/[0.03] hover:from-primary/[0.1] hover:to-primary/[0.06] cursor-pointer border border-primary/30 hover:border-primary/50 transition-all duration-200 group/comment shadow-sm hover:shadow"
>
<div className="flex items-start gap-2">
<div className="w-5 h-5 flex items-center justify-center shrink-0">
<MessageSquareIcon className="w-4 h-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-primary/60 font-semibold mb-1 uppercase tracking-wider">
{isCommentMention ? "Comment" : "Memo"}
</p>
<p className="text-sm text-foreground/90 line-clamp-2">
{mentionPayload.memoSnippet || <span className="italic text-muted-foreground/50">Empty memo</span>}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default MemoMentionMessage;
+31
View File
@@ -0,0 +1,31 @@
import { ExternalLinkIcon } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useTranslate } from "@/utils/i18n";
interface Props {
className?: string;
url: string;
title?: string;
}
const LearnMore: React.FC<Props> = (props: Props) => {
const { className, url, title } = props;
const t = useTranslate();
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<a className={`text-muted-foreground hover:text-primary ${className}`} href={url} target="_blank">
<ExternalLinkIcon className="w-4 h-auto" />
</a>
</TooltipTrigger>
<TooltipContent>
<p>{title ?? t("common.learn-more")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
export default LearnMore;
+41
View File
@@ -0,0 +1,41 @@
import { GlobeIcon } from "lucide-react";
import { FC } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { locales } from "@/i18n";
import { getLocaleDisplayName, loadLocale } from "@/utils/i18n";
interface Props {
value: Locale;
onChange: (locale: Locale) => void;
}
const LocaleSelect: FC<Props> = (props: Props) => {
const { onChange, value } = props;
const handleSelectChange = async (locale: Locale) => {
// Apply locale globally immediately
loadLocale(locale);
// Also notify parent component
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) => (
<SelectItem key={locale} value={locale}>
{getLocaleDisplayName(locale)}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
export default LocaleSelect;
@@ -0,0 +1,165 @@
import {
ArchiveIcon,
ArchiveRestoreIcon,
BookmarkMinusIcon,
BookmarkPlusIcon,
CheckCheckIcon,
CopyIcon,
Edit3Icon,
FileTextIcon,
LinkIcon,
ListChecksIcon,
ListRestartIcon,
MoreVerticalIcon,
TrashIcon,
} from "lucide-react";
import { useState } from "react";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { State } from "@/types/proto/api/v1/common_pb";
import { useTranslate } from "@/utils/i18n";
import { countTasks } from "@/utils/markdown-manipulation";
import { useMemoActionHandlers } from "./hooks";
import type { MemoActionMenuProps } from "./types";
const MemoActionMenu = (props: MemoActionMenuProps) => {
const { memo, readonly } = props;
const t = useTranslate();
// Dialog state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
// Derived state
const isComment = Boolean(memo.parent);
const isArchived = memo.state === State.ARCHIVED;
const taskStats = countTasks(memo.content);
const canMutateTasks = !readonly && !isArchived && taskStats.total > 0;
const hasOpenTasks = taskStats.completed < taskStats.total;
const hasCompletedTasks = taskStats.completed > 0;
// Action handlers
const {
handleTogglePinMemoBtnClick,
handleEditMemoClick,
handleToggleMemoStatusClick,
handleCopyLink,
handleCopyContent,
handleCheckAllTaskListItemsClick,
handleUncheckAllTaskListItemsClick,
handleDeleteMemoClick,
confirmDeleteMemo,
} = useMemoActionHandlers({
memo,
onEdit: props.onEdit,
setDeleteDialogOpen,
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-4">
<MoreVerticalIcon className="text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={2}>
{/* Edit actions (non-readonly, non-archived) */}
{!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>
</>
)}
{/* Copy submenu (non-archived) */}
{!isArchived && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<CopyIcon className="w-4 h-auto" />
{t("common.copy")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={handleCopyLink}>
<LinkIcon className="w-4 h-auto" />
{t("memo.copy-link")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCopyContent}>
<FileTextIcon className="w-4 h-auto" />
{t("memo.copy-content")}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{/* Task submenu (writable task memos) */}
{canMutateTasks && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ListChecksIcon className="w-4 h-auto" />
{t("memo.task-actions.title")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem disabled={!hasOpenTasks} onClick={handleCheckAllTaskListItemsClick}>
<CheckCheckIcon className="w-4 h-auto" />
{t("memo.task-actions.check-all")}
</DropdownMenuItem>
<DropdownMenuItem disabled={!hasCompletedTasks} onClick={handleUncheckAllTaskListItemsClick}>
<ListRestartIcon className="w-4 h-auto" />
{t("memo.task-actions.uncheck-all")}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{/* Write actions (non-readonly) */}
{!readonly && (
<>
{/* Archive/Restore (non-comment) */}
{!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>
)}
{/* Delete */}
<DropdownMenuItem onClick={handleDeleteMemoClick}>
<TrashIcon className="w-4 h-auto" />
{t("common.delete")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
{/* Delete confirmation dialog */}
<ConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title={t("memo.delete-confirm")}
confirmLabel={t("common.delete")}
description={t("memo.delete-confirm-description")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteMemo}
confirmVariant="destructive"
/>
</DropdownMenu>
);
};
export default MemoActionMenu;
@@ -0,0 +1,137 @@
import { DownloadIcon, ImageIcon, Loader2Icon, Share2Icon } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useTranslate } from "@/utils/i18n";
import { useMemoViewContext } from "../MemoView/MemoViewContext";
import MemoShareImagePreview from "./MemoShareImagePreview";
import {
buildMemoShareImageFileName,
createMemoShareImageBlob,
getMemoShareDialogWidth,
getMemoSharePreviewWidth,
getMemoShareRenderWidth,
} from "./memoShareImage";
interface MemoShareImageDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const MemoShareImageDialog = ({ open, onOpenChange }: MemoShareImageDialogProps) => {
const t = useTranslate();
const { memo, cardWidth } = useMemoViewContext();
const previewRef = useRef<HTMLDivElement>(null);
const [isRendering, setIsRendering] = useState(false);
const previewWidth = useMemo(() => getMemoSharePreviewWidth(cardWidth), [cardWidth]);
const dialogWidth = useMemo(() => getMemoShareDialogWidth(previewWidth), [previewWidth]);
const previewRenderWidth = useMemo(() => getMemoShareRenderWidth(previewWidth, dialogWidth), [dialogWidth, previewWidth]);
const createShareBlob = useCallback(async () => {
const preview = previewRef.current;
if (!preview) {
throw new Error("Preview is not ready");
}
return createMemoShareImageBlob(preview);
}, []);
const handleDownload = useCallback(async () => {
setIsRendering(true);
try {
const blob = await createShareBlob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = buildMemoShareImageFileName(memo.name);
anchor.click();
URL.revokeObjectURL(url);
toast.success(t("memo.share.image-downloaded"));
} catch {
toast.error(t("memo.share.image-download-failed"));
} finally {
setIsRendering(false);
}
}, [createShareBlob, memo.name, t]);
const handleNativeShare = useCallback(async () => {
if (typeof navigator.share !== "function") {
return;
}
setIsRendering(true);
try {
const blob = await createShareBlob();
const file = new File([blob], buildMemoShareImageFileName(memo.name), { type: "image/png" });
if (typeof navigator.canShare === "function" && !navigator.canShare({ files: [file] })) {
toast.error(t("memo.share.image-share-failed"));
return;
}
await navigator.share({
files: [file],
title: memo.content.slice(0, 60),
});
} catch (error) {
if (!(error instanceof DOMException && error.name === "AbortError")) {
toast.error(t("memo.share.image-share-failed"));
}
} finally {
setIsRendering(false);
}
}, [createShareBlob, memo.content, memo.name, t]);
const supportsNativeShare =
typeof navigator !== "undefined" && typeof navigator.share === "function" && typeof navigator.canShare === "function";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
size="full"
className="min-h-0 overflow-hidden !gap-0 !p-0 md:w-auto md:max-w-none"
style={{ width: `${dialogWidth}px` }}
>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<DialogHeader className="shrink-0 border-b border-border/60 px-4 py-3 sm:px-5">
<DialogTitle className="flex items-center gap-2 text-base font-medium">
<ImageIcon className="h-4 w-4 text-muted-foreground" />
{t("memo.share.image-title")}
</DialogTitle>
<DialogDescription className="text-xs">{t("memo.share.image-description", { width: previewRenderWidth })}</DialogDescription>
</DialogHeader>
<div className="relative flex min-h-0 flex-1 items-start justify-center overflow-auto bg-muted/20 px-4 py-3 sm:px-5 sm:py-4">
<MemoShareImagePreview ref={previewRef} width={previewRenderWidth} />
</div>
<DialogFooter className="shrink-0 border-t border-border/60 px-4 py-3 sm:px-5">
{supportsNativeShare && (
<Button
variant="ghost"
className="text-muted-foreground hover:bg-muted/60 hover:text-foreground"
onClick={handleNativeShare}
disabled={isRendering}
>
{isRendering ? <Loader2Icon className="mr-2 h-4 w-4 animate-spin" /> : <Share2Icon className="mr-2 h-4 w-4" />}
{t("memo.share.image-share")}
</Button>
)}
<Button
variant="outline"
className="border-border/70 text-muted-foreground hover:bg-muted/60 hover:text-foreground"
onClick={handleDownload}
disabled={isRendering}
>
{isRendering ? <Loader2Icon className="mr-2 h-4 w-4 animate-spin" /> : <DownloadIcon className="mr-2 h-4 w-4" />}
{t("memo.share.image-download")}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
);
};
export default MemoShareImageDialog;
@@ -0,0 +1,87 @@
import { forwardRef, useMemo } from "react";
import MemoContent from "@/components/MemoContent";
import UserAvatar from "@/components/UserAvatar";
import i18n from "@/i18n";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { useMemoViewContext } from "../MemoView/MemoViewContext";
import { buildMemoShareImagePreviewModel } from "./memoShareImagePreviewModel";
const MemoShareImagePreview = forwardRef<HTMLDivElement, { width: number }>(({ width }, ref) => {
const t = useTranslate();
const { memo, creator, blurred, showBlurredContent } = useMemoViewContext();
const fallbackDisplayName = t("common.memo");
const locale = i18n.language;
const preview = useMemo(
() =>
buildMemoShareImagePreviewModel({
memo,
creator,
fallbackDisplayName,
locale,
}),
[creator, fallbackDisplayName, locale, memo],
);
return (
<div ref={ref} className="overflow-hidden rounded-xl border border-border/50 bg-background p-2 sm:p-2.5" style={{ width }}>
<div className="overflow-hidden rounded-lg border border-border/60 bg-background p-4 sm:p-5">
<div className="flex items-start gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<UserAvatar avatarUrl={preview.avatarUrl} className="h-8 w-8 rounded-xl" />
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold text-foreground">{preview.displayName}</div>
{preview.formattedDisplayTime && <div className="truncate text-xs text-muted-foreground">{preview.formattedDisplayTime}</div>}
</div>
</div>
</div>
<div className="mt-4">
<div className={cn("pointer-events-none", blurred && !showBlurredContent && "blur-lg")}>
<MemoContent content={memo.content} compact={false} contentClassName="text-[14px] leading-6.5 sm:text-[15px]" />
</div>
</div>
{preview.visualItems.length > 0 && (
<div className={cn("mt-4 grid gap-1.5", preview.visualItems.length === 1 ? "grid-cols-1" : "grid-cols-2")}>
{preview.visualItems.slice(0, 4).map((item, index) => (
<div
key={item.id}
className={cn(
"relative overflow-hidden rounded-md border border-border/70 bg-muted/30",
preview.visualItems.length === 1 ? "aspect-[4/3]" : "aspect-square",
preview.visualItems.length === 3 && index === 0 && "col-span-2 aspect-[2.2/1]",
)}
>
<img src={item.posterUrl} alt={item.filename} className="h-full w-full object-cover" loading="eager" decoding="async" />
{index === 3 && preview.visualItems.length > 4 && (
<div className="absolute inset-0 flex items-center justify-center bg-foreground/35 text-lg font-semibold text-background">
+{preview.visualItems.length - 4}
</div>
)}
</div>
))}
</div>
)}
{preview.footerBadges.length > 0 && (
<div className="mt-4 flex flex-wrap items-center gap-1.5">
{preview.footerBadges.map((badge) => (
<span
key={badge.type}
className="inline-flex rounded-full border border-border/70 bg-muted/55 px-2 py-0.5 text-[11px] text-muted-foreground"
>
{badge.count} {t("common.attachments").toLowerCase()}
</span>
))}
</div>
)}
</div>
</div>
);
});
MemoShareImagePreview.displayName = "MemoShareImagePreview";
export default MemoShareImagePreview;
+163
View File
@@ -0,0 +1,163 @@
import { useQueryClient } from "@tanstack/react-query";
import copy from "copy-to-clipboard";
import { useCallback } from "react";
import toast from "react-hot-toast";
import { useLocation } from "react-router-dom";
import { useInstance } from "@/contexts/InstanceContext";
import { memoKeys, useDeleteMemo, useUpdateMemo } from "@/hooks/useMemoQueries";
import useNavigateTo from "@/hooks/useNavigateTo";
import { userKeys } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
import { ROUTES } from "@/router/routes";
import { State } from "@/types/proto/api/v1/common_pb";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import { checkAllTasks, uncheckAllTasks } from "@/utils/markdown-task-actions";
interface UseMemoActionHandlersOptions {
memo: Memo;
onEdit?: () => void;
setDeleteDialogOpen: (open: boolean) => void;
}
export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen }: UseMemoActionHandlersOptions) => {
const t = useTranslate();
const location = useLocation();
const navigateTo = useNavigateTo();
const queryClient = useQueryClient();
const { profile } = useInstance();
const { mutateAsync: updateMemo } = useUpdateMemo();
const { mutateAsync: deleteMemo } = useDeleteMemo();
const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`);
const memoUpdatedCallback = useCallback(() => {
// Invalidate user stats to trigger refetch
queryClient.invalidateQueries({ queryKey: userKeys.stats() });
}, [queryClient]);
const updateMemoContent = useCallback(
async (nextContent: string, context: string) => {
if (nextContent === memo.content) {
return;
}
try {
await updateMemo({
update: {
name: memo.name,
content: nextContent,
},
updateMask: ["content", "update_time"],
});
toast.success(t("memo.task-actions.updated"));
} catch (error: unknown) {
handleError(error, toast.error, {
context,
fallbackMessage: "An error occurred",
});
}
},
[memo.content, memo.name, t, updateMemo],
);
const handleTogglePinMemoBtnClick = useCallback(async () => {
try {
await updateMemo({
update: {
name: memo.name,
pinned: !memo.pinned,
},
updateMask: ["pinned"],
});
} catch {
// do nothing
}
}, [memo.name, memo.pinned, updateMemo]);
const handleEditMemoClick = useCallback(() => {
onEdit?.();
}, [onEdit]);
const handleToggleMemoStatusClick = useCallback(async () => {
const isArchiving = memo.state !== State.ARCHIVED;
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 updateMemo({
update: {
name: memo.name,
state,
},
updateMask: ["state"],
});
toast.success(message);
} catch (error: unknown) {
handleError(error, toast.error, {
context: `${isArchiving ? "Archive" : "Restore"} memo`,
fallbackMessage: "An error occurred",
});
return;
}
if (isInMemoDetailPage) {
navigateTo(memo.state === State.ARCHIVED ? ROUTES.HOME : ROUTES.ARCHIVED);
}
memoUpdatedCallback();
}, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, updateMemo]);
const handleCopyLink = useCallback(() => {
let host = profile.instanceUrl;
if (host === "") {
host = window.location.origin;
}
copy(`${host}/${memo.name}`);
toast.success(t("message.succeed-copy-link"));
}, [memo.name, t, profile.instanceUrl]);
const handleCopyContent = useCallback(() => {
copy(memo.content);
toast.success(t("message.succeed-copy-content"));
}, [memo.content, t]);
const handleCheckAllTaskListItemsClick = useCallback(async () => {
await updateMemoContent(checkAllTasks(memo.content), "Check memo task list items");
}, [memo.content, updateMemoContent]);
const handleUncheckAllTaskListItemsClick = useCallback(async () => {
await updateMemoContent(uncheckAllTasks(memo.content), "Uncheck memo task list items");
}, [memo.content, updateMemoContent]);
const handleDeleteMemoClick = useCallback(() => {
setDeleteDialogOpen(true);
}, [setDeleteDialogOpen]);
const confirmDeleteMemo = useCallback(async () => {
try {
await deleteMemo(memo.name);
} catch (error: unknown) {
handleError(error, toast.error, { context: "Delete memo", fallbackMessage: "An error occurred" });
return;
}
toast.success(t("message.deleted-successfully"));
if (memo.parent) {
queryClient.invalidateQueries({ queryKey: memoKeys.comments(memo.parent) });
}
if (isInMemoDetailPage) {
navigateTo(ROUTES.HOME);
}
memoUpdatedCallback();
}, [memo.name, memo.parent, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, deleteMemo, queryClient]);
return {
handleTogglePinMemoBtnClick,
handleEditMemoClick,
handleToggleMemoStatusClick,
handleCopyLink,
handleCopyContent,
handleCheckAllTaskListItemsClick,
handleUncheckAllTaskListItemsClick,
handleDeleteMemoClick,
confirmDeleteMemo,
};
};
@@ -0,0 +1,3 @@
export { useMemoActionHandlers } from "./hooks";
export { default, default as MemoActionMenu } from "./MemoActionMenu";
export type { MemoActionMenuProps } from "./types";
@@ -0,0 +1,117 @@
import { toBlob } from "html-to-image";
const WINDOW_HORIZONTAL_MARGIN = 32;
const PREVIEW_HORIZONTAL_PADDING_IN_DIALOG = 40;
const PREVIEW_WIDTH_BOOST_IN_DIALOG = 48;
export const MEMO_SHARE_IMAGE_CONFIG = {
dialogExtraWidth: 80,
maxWidth: 520,
minWidth: 260,
previewScale: 0.9,
viewportMargin: 48,
} as const;
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
const isExportableImageUrl = (value?: string) => {
if (!value) {
return false;
}
if (value.startsWith("/") || value.startsWith("data:") || value.startsWith("blob:")) {
return true;
}
try {
return new URL(value, window.location.origin).origin === window.location.origin;
} catch {
return false;
}
};
const waitForPreviewAssets = async (node: HTMLElement) => {
try {
await document.fonts?.ready;
} catch {
// Ignore font loading failures and continue with the best available render.
}
const images = Array.from(node.querySelectorAll("img"));
await Promise.all(
images.map(
(image) =>
new Promise<void>((resolve) => {
if (image.complete) {
resolve();
return;
}
image.addEventListener("load", () => resolve(), { once: true });
image.addEventListener("error", () => resolve(), { once: true });
}),
),
);
};
export const buildMemoShareImageFileName = (memoName: string) => {
const suffix = memoName.split("/").pop() || "memo";
return `memo-${suffix}.png`;
};
export const getMemoSharePreviewWidth = (cardWidth: number) => {
const viewportWidth =
typeof window === "undefined" ? MEMO_SHARE_IMAGE_CONFIG.maxWidth : window.innerWidth - MEMO_SHARE_IMAGE_CONFIG.viewportMargin;
const baseWidth = cardWidth || viewportWidth;
return clamp(
Math.round(baseWidth * MEMO_SHARE_IMAGE_CONFIG.previewScale),
MEMO_SHARE_IMAGE_CONFIG.minWidth,
MEMO_SHARE_IMAGE_CONFIG.maxWidth,
);
};
export const getMemoShareDialogWidth = (previewWidth: number) => {
const viewportWidth =
typeof window === "undefined" ? previewWidth + MEMO_SHARE_IMAGE_CONFIG.dialogExtraWidth : window.innerWidth - WINDOW_HORIZONTAL_MARGIN;
return Math.min(previewWidth + MEMO_SHARE_IMAGE_CONFIG.dialogExtraWidth, viewportWidth);
};
export const getMemoShareRenderWidth = (previewWidth: number, dialogWidth: number) => {
const maxRenderWidth = Math.max(MEMO_SHARE_IMAGE_CONFIG.minWidth, dialogWidth - PREVIEW_HORIZONTAL_PADDING_IN_DIALOG);
return clamp(previewWidth + PREVIEW_WIDTH_BOOST_IN_DIALOG, MEMO_SHARE_IMAGE_CONFIG.minWidth, maxRenderWidth);
};
export const getMemoSharePreviewAvatarUrl = (avatarUrl?: string) => (isExportableImageUrl(avatarUrl) ? avatarUrl : undefined);
export const createMemoShareImageBlob = async (node: HTMLElement) => {
await waitForPreviewAssets(node);
const rect = node.getBoundingClientRect();
const width = Math.ceil(rect.width || node.offsetWidth || node.clientWidth);
const height = Math.ceil(rect.height || node.offsetHeight || node.clientHeight);
const blob = await toBlob(node, {
cacheBust: true,
height,
pixelRatio: Math.max(2, Math.min(window.devicePixelRatio || 1, 3)),
width,
filter: (currentNode) => {
if (!(currentNode instanceof HTMLElement)) {
return true;
}
if (currentNode instanceof HTMLImageElement) {
return isExportableImageUrl(currentNode.currentSrc || currentNode.src);
}
return !(currentNode instanceof HTMLVideoElement);
},
});
if (!blob) {
throw new Error("Failed to render image");
}
return blob;
};
@@ -0,0 +1,58 @@
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { separateAttachments } from "@/components/MemoMetadata/Attachment/attachmentHelpers";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import type { User } from "@/types/proto/api/v1/user_service_pb";
import { type AttachmentVisualItem, buildAttachmentVisualItems, countLogicalAttachmentItems } from "@/utils/media-item";
import { getMemoSharePreviewAvatarUrl } from "./memoShareImage";
interface BuildMemoShareImagePreviewModelOptions {
memo: Memo;
creator?: User;
fallbackDisplayName: string;
locale: string;
}
export interface MemoShareImageAttachmentSummaryBadge {
type: "attachment-summary";
count: number;
}
export type MemoShareImageFooterBadge = MemoShareImageAttachmentSummaryBadge;
export interface MemoShareImagePreviewModel {
displayName: string;
avatarUrl?: string;
formattedDisplayTime?: string;
visualItems: AttachmentVisualItem[];
footerBadges: MemoShareImageFooterBadge[];
}
export const buildMemoShareImagePreviewModel = ({
memo,
creator,
fallbackDisplayName,
locale,
}: BuildMemoShareImagePreviewModelOptions): MemoShareImagePreviewModel => {
const displayName = creator?.displayName || creator?.username || fallbackDisplayName;
const avatarUrl = getMemoSharePreviewAvatarUrl(creator?.avatarUrl);
const displayTime = memo.createTime ? timestampDate(memo.createTime) : undefined;
const formattedDisplayTime = displayTime?.toLocaleString(locale, {
dateStyle: "medium",
timeStyle: "short",
});
const attachmentGroups = separateAttachments(memo.attachments);
const visualItems = buildAttachmentVisualItems(attachmentGroups.visual);
const attachmentCount = countLogicalAttachmentItems(memo.attachments);
const nonVisualAttachmentCount = Math.max(attachmentCount - visualItems.length, 0);
const footerBadges: MemoShareImageFooterBadge[] =
nonVisualAttachmentCount > 0 ? [{ type: "attachment-summary", count: attachmentCount }] : [];
return {
displayName,
avatarUrl,
formattedDisplayTime,
visualItems,
footerBadges,
};
};
@@ -0,0 +1,8 @@
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
export interface MemoActionMenuProps {
memo: Memo;
readonly?: boolean;
className?: string;
onEdit?: () => void;
}
+79
View File
@@ -0,0 +1,79 @@
import { MessageCircleIcon } from "lucide-react";
import { useState } from "react";
import MemoEditor from "@/components/MemoEditor";
import MemoView from "@/components/MemoView";
import { Button } from "@/components/ui/button";
import { extractMemoIdFromName } from "@/helpers/resource-names";
import useCurrentUser from "@/hooks/useCurrentUser";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
interface Props {
memo: Memo;
comments: Memo[];
parentPage?: string;
}
const MemoCommentSection = ({ memo, comments, parentPage }: Props) => {
const t = useTranslate();
const currentUser = useCurrentUser();
const [showEditor, setShowEditor] = useState(false);
const showCreateButton = currentUser && !showEditor;
const handleCommentCreated = async (_memoCommentName: string) => {
setShowEditor(false);
};
return (
<div className="pt-8 pb-16 w-full">
<h2 id="comments" className="sr-only">
{t("memo.comment.self")}
</h2>
<div className="relative mx-auto grow w-full min-h-full flex flex-col justify-start items-start gap-y-1">
{comments.length === 0 ? (
showCreateButton && (
<div className="w-full flex flex-row justify-center items-center py-6">
<Button variant="ghost" onClick={() => setShowEditor(true)}>
<span className="text-muted-foreground">{t("memo.comment.write-a-comment")}</span>
<MessageCircleIcon className="ml-2 w-5 h-auto text-muted-foreground" />
</Button>
</div>
)
) : (
<div className="w-full flex flex-row justify-between items-center h-8 pl-3 mb-2">
<div className="flex flex-row justify-start items-center">
<MessageCircleIcon className="w-5 h-auto text-muted-foreground mr-1" />
<span className="text-muted-foreground text-sm">{t("memo.comment.self")}</span>
<span className="text-muted-foreground text-sm ml-1">({comments.length})</span>
</div>
{showCreateButton && (
<Button variant="ghost" className="text-muted-foreground" onClick={() => setShowEditor(true)}>
{t("memo.comment.write-a-comment")}
</Button>
)}
</div>
)}
{showEditor && (
<div className="w-full mb-2">
<MemoEditor
cacheKey={`${memo.name}-${memo.updateTime}-comment`}
placeholder={t("editor.add-your-comment-here")}
parentMemoName={memo.name}
autoFocus
onConfirm={handleCommentCreated}
onCancel={() => setShowEditor(false)}
/>
</div>
)}
{comments.map((comment) => (
<div className="w-full" key={`${comment.name}-${comment.updateTime}`} id={extractMemoIdFromName(comment.name)}>
<MemoView memo={comment} parentPage={parentPage} showCreator compact />
</div>
))}
</div>
</div>
);
};
export default MemoCommentSection;
@@ -0,0 +1,156 @@
import copy from "copy-to-clipboard";
import hljs from "highlight.js";
import { CheckIcon, CopyIcon } from "lucide-react";
import { isValidElement, type ReactElement, type ReactNode, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { cn } from "@/lib/utils";
import { getThemeWithFallback, resolveTheme } from "@/utils/theme";
import { MermaidBlock } from "./MermaidBlock";
import type { ReactMarkdownProps } from "./markdown/types";
import { extractCodeContent, extractLanguage } from "./utils";
interface CodeBlockProps extends ReactMarkdownProps {
children?: ReactNode;
className?: string;
}
export const CodeBlock = ({ children, className, node: _node, ...props }: CodeBlockProps) => {
const { userGeneralSetting } = useAuth();
const [copied, setCopied] = useState(false);
const codeElement = isValidElement(children) ? (children as ReactElement<{ className?: string }>) : null;
const codeClassName = codeElement?.props.className || "";
const codeContent = extractCodeContent(children);
const language = extractLanguage(codeClassName);
// If it's a mermaid block, render with MermaidBlock component
if (language === "mermaid") {
return (
<pre className="relative">
<MermaidBlock className={cn(className)} {...props}>
{children}
</MermaidBlock>
</pre>
);
}
const theme = getThemeWithFallback(userGeneralSetting?.theme);
const resolvedTheme = resolveTheme(theme);
const isDarkTheme = resolvedTheme.includes("dark");
// Dynamically load highlight.js theme based on app theme
useEffect(() => {
const dynamicImportStyle = async () => {
// Remove any existing highlight.js style
const existingStyle = document.querySelector("style[data-hljs-theme]");
if (existingStyle) {
existingStyle.remove();
}
try {
const cssModule = isDarkTheme
? await import("highlight.js/styles/github-dark-dimmed.css?inline")
: await import("highlight.js/styles/github.css?inline");
// Create and inject the style
const style = document.createElement("style");
style.textContent = cssModule.default;
style.setAttribute("data-hljs-theme", isDarkTheme ? "dark" : "light");
document.head.appendChild(style);
} catch (error) {
console.warn("Failed to load highlight.js theme:", error);
}
};
dynamicImportStyle();
}, [resolvedTheme, isDarkTheme]);
// Highlight code using highlight.js
const highlightedCode = useMemo(() => {
try {
const lang = hljs.getLanguage(language);
if (lang) {
return hljs.highlight(codeContent, {
language: language,
}).value;
}
} catch {
// Skip error and use default highlighted code.
}
// Escape any HTML entities when rendering original content.
return Object.assign(document.createElement("span"), {
textContent: codeContent,
}).innerHTML;
}, [language, codeContent]);
const handleCopy = async () => {
try {
// Try native clipboard API first (requires HTTPS or localhost)
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(codeContent);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
// Fallback to copy-to-clipboard library for non-secure contexts
const success = await copy(codeContent);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
console.error("Failed to copy code");
}
}
} catch (err) {
// If native API fails, try fallback
console.warn("Native clipboard failed, using fallback:", err);
const success = await copy(codeContent);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
console.error("Failed to copy code:", err);
}
}
};
return (
<pre className="relative my-2 rounded-lg border border-border bg-muted/20 overflow-hidden">
{/* Header with language label and copy button */}
<div className="flex items-center justify-between px-2 py-1 border-b border-border bg-muted/30">
<span className="text-xs text-foreground select-none">{language || "text"}</span>
<button
onClick={handleCopy}
className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs",
"transition-colors duration-200",
"hover:bg-accent active:scale-95",
copied ? "text-primary" : "text-muted-foreground hover:text-foreground",
)}
aria-label={copied ? "Copied" : "Copy code"}
title={copied ? "Copied!" : "Copy code"}
>
{copied ? (
<>
<CheckIcon className="w-3.5 h-3.5" />
<span>Copied</span>
</>
) : (
<>
<CopyIcon className="w-3.5 h-3.5" />
<span>Copy</span>
</>
)}
</button>
</div>
{/* Code content */}
<div className="overflow-x-auto">
<code
className={cn("block px-3 py-2 text-sm leading-relaxed", `language-${language}`)}
dangerouslySetInnerHTML={{ __html: highlightedCode }}
/>
</div>
</pre>
);
};
@@ -0,0 +1,70 @@
import type React from "react";
import { useEffect, useState } from "react";
import { useLinkMetadata } from "@/hooks/useMemoQueries";
import { cn } from "@/lib/utils";
interface LinkMetadataCardProps {
url: string;
fallback: React.ReactNode;
}
function getHostname(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return "";
}
}
const LinkMetadataCard = ({ url, fallback }: LinkMetadataCardProps) => {
const [imageFailed, setImageFailed] = useState(false);
const { data: metadata, isSuccess } = useLinkMetadata(url);
const title = metadata?.title.trim() ?? "";
const description = metadata?.description.trim() ?? "";
const image = metadata?.image.trim() ?? "";
const hostname = getHostname(metadata?.url || url);
const hasUsefulMetadata = title !== "" || description !== "";
useEffect(() => {
setImageFailed(false);
}, [url, image]);
if (!isSuccess || !hasUsefulMetadata) {
return fallback;
}
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"group my-0 mb-2 flex w-full max-w-full overflow-hidden rounded-md border border-border bg-muted/20 text-foreground no-underline transition-colors",
"hover:border-primary/35 hover:bg-accent/20",
)}
>
<span className="flex min-w-0 flex-1 flex-col gap-0.5 px-2.5 py-2 sm:gap-1 sm:px-3 sm:py-2.5">
{hostname && <span className="truncate text-[11px] leading-4 text-muted-foreground sm:text-xs">{hostname}</span>}
{title && <span className="line-clamp-2 text-sm font-medium leading-5 text-foreground">{title}</span>}
{description && <span className="line-clamp-1 text-xs leading-4 text-muted-foreground sm:line-clamp-2">{description}</span>}
</span>
{image && !imageFailed && (
<span className="flex w-24 shrink-0 items-center border-l border-border/70 bg-muted/40 sm:w-40">
<span className="aspect-[1.91/1] w-full overflow-hidden">
<img
src={image}
alt=""
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-[1.01]"
loading="lazy"
decoding="async"
onError={() => setImageFailed(true)}
/>
</span>
</span>
)}
</a>
);
};
export default LinkMetadataCard;
@@ -0,0 +1,22 @@
import { createContext, useContext, useMemo } from "react";
interface MarkdownRenderContextValue {
blockDepth: number;
}
export const rootMarkdownRenderContext: MarkdownRenderContextValue = {
blockDepth: 0,
};
export const MarkdownRenderContext = createContext<MarkdownRenderContextValue>(rootMarkdownRenderContext);
export const useMarkdownRenderContext = () => {
return useContext(MarkdownRenderContext);
};
export const NestedMarkdownRenderContext = ({ children }: { children: React.ReactNode }) => {
const { blockDepth } = useMarkdownRenderContext();
const value = useMemo<MarkdownRenderContextValue>(() => ({ blockDepth: blockDepth + 1 }), [blockDepth]);
return <MarkdownRenderContext.Provider value={value}>{children}</MarkdownRenderContext.Provider>;
};
@@ -0,0 +1,148 @@
import type { Element } from "hast";
import "katex/dist/katex.min.css";
import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { isMentionElement, isTagElement, isTaskListItemElement } from "@/types/markdown";
import { rehypeHeadingId } from "@/utils/rehype-plugins/rehype-heading-id";
import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext";
import { remarkMention } from "@/utils/remark-plugins/remark-mention";
import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type";
import { remarkSplitMixedTaskLists } from "@/utils/remark-plugins/remark-split-mixed-task-lists";
import { remarkTag } from "@/utils/remark-plugins/remark-tag";
import { CodeBlock } from "./CodeBlock";
import { SANITIZE_SCHEMA } from "./constants";
import { MarkdownRenderContext, rootMarkdownRenderContext } from "./MarkdownRenderContext";
import { Mention } from "./Mention";
import { Blockquote, Heading, HorizontalRule, Image, InlineCode, Link, List, ListItem, Paragraph } from "./markdown";
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table";
import { Tag } from "./Tag";
import { TaskListItem } from "./TaskListItem";
import { TrustedIframe } from "./TrustedIframe";
interface MemoMarkdownRendererProps {
content: string;
resolvedMentionUsernames: Set<string>;
}
function getMentionUsername(node: Element, children?: React.ReactNode): string {
const dataMention = node.properties?.["data-mention"];
if (typeof dataMention === "string" && dataMention !== "") {
return dataMention;
}
const camelDataMention = (node.properties as Record<string, unknown> | undefined)?.dataMention;
if (typeof camelDataMention === "string" && camelDataMention !== "") {
return camelDataMention;
}
const text = Array.isArray(children) ? children.join("") : children;
if (typeof text === "string" && text.startsWith("@")) {
return text.slice(1).toLowerCase();
}
return "";
}
export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames }: MemoMarkdownRendererProps) => {
const markdownComponents: Components = {
input: ({ node, ...inputProps }) => {
if (node && isTaskListItemElement(node)) {
return <TaskListItem {...inputProps} node={node} />;
}
return <input {...inputProps} />;
},
span: ({ node, ...spanProps }) => {
if (node && isMentionElement(node)) {
const username = getMentionUsername(node, spanProps.children);
return <Mention {...spanProps} node={node} data-mention={username} resolved={resolvedMentionUsernames.has(username)} />;
}
if (node && isTagElement(node)) {
return <Tag {...spanProps} node={node} />;
}
return <span {...spanProps} />;
},
h1: ({ children, ...props }) => (
<Heading level={1} {...props}>
{children}
</Heading>
),
h2: ({ children, ...props }) => (
<Heading level={2} {...props}>
{children}
</Heading>
),
h3: ({ children, ...props }) => (
<Heading level={3} {...props}>
{children}
</Heading>
),
h4: ({ children, ...props }) => (
<Heading level={4} {...props}>
{children}
</Heading>
),
h5: ({ children, ...props }) => (
<Heading level={5} {...props}>
{children}
</Heading>
),
h6: ({ children, ...props }) => (
<Heading level={6} {...props}>
{children}
</Heading>
),
p: ({ children, ...props }) => <Paragraph {...props}>{children}</Paragraph>,
blockquote: ({ children, ...props }) => <Blockquote {...props}>{children}</Blockquote>,
hr: (props) => <HorizontalRule {...props} />,
ul: ({ children, ...props }) => <List {...props}>{children}</List>,
ol: ({ children, ...props }) => (
<List ordered {...props}>
{children}
</List>
),
li: ({ children, ...props }) => <ListItem {...props}>{children}</ListItem>,
a: ({ children, ...props }) => <Link {...props}>{children}</Link>,
code: ({ children, ...props }) => <InlineCode {...props}>{children}</InlineCode>,
iframe: TrustedIframe,
img: (props) => <Image {...props} />,
pre: CodeBlock,
table: ({ children, ...props }) => <Table {...props}>{children}</Table>,
thead: ({ children, ...props }) => <TableHead {...props}>{children}</TableHead>,
tbody: ({ children, ...props }) => <TableBody {...props}>{children}</TableBody>,
tr: ({ children, ...props }) => <TableRow {...props}>{children}</TableRow>,
th: ({ children, ...props }) => <TableHeaderCell {...props}>{children}</TableHeaderCell>,
td: ({ children, ...props }) => <TableCell {...props}>{children}</TableCell>,
};
return (
<MarkdownRenderContext.Provider value={rootMarkdownRenderContext}>
<ReactMarkdown
remarkPlugins={[
remarkDisableSetext,
remarkMath,
remarkGfm,
remarkSplitMixedTaskLists,
remarkBreaks,
remarkMention,
remarkTag,
remarkPreserveType,
]}
rehypePlugins={[
rehypeRaw,
[rehypeSanitize, SANITIZE_SCHEMA],
rehypeHeadingId,
[rehypeKatex, { throwOnError: false, strict: false }],
]}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</MarkdownRenderContext.Provider>
);
};
@@ -0,0 +1,40 @@
import type { Element } from "hast";
import { cn } from "@/lib/utils";
interface MentionProps extends React.HTMLAttributes<HTMLSpanElement> {
node?: Element;
"data-mention"?: string;
children?: React.ReactNode;
resolved?: boolean;
}
export const Mention: React.FC<MentionProps> = ({
"data-mention": dataMention,
children,
className,
node: _node,
resolved = false,
...props
}) => {
const username = dataMention || "";
if (!resolved) {
return (
<span data-mention={username} title={`@${username}`} className={className} {...props}>
{children}
</span>
);
}
return (
<a
href={`/u/${username}`}
className={cn("text-blue-600 underline-offset-2 hover:underline dark:text-blue-400", className)}
data-mention={username}
title={`@${username}`}
{...props}
>
{children}
</a>
);
};
@@ -0,0 +1,41 @@
import { createContext, type ReactNode, useContext, useMemo } from "react";
import { useUsersByUsernames } from "@/hooks/useUserQueries";
import { extractMentionUsernames } from "@/utils/remark-plugins/remark-mention";
const MentionResolutionContext = createContext<Set<string> | null>(null);
interface MentionResolutionProviderProps {
contents: string[];
children: ReactNode;
}
export const MentionResolutionProvider = ({ contents, children }: MentionResolutionProviderProps) => {
const mentionUsernames = useMemo(() => Array.from(new Set(contents.flatMap((content) => extractMentionUsernames(content)))), [contents]);
const { data: mentionUsers } = useUsersByUsernames(mentionUsernames);
const resolvedMentionUsernames = useMemo(() => {
if (!mentionUsers) {
return new Set<string>();
}
return new Set(Array.from(mentionUsers.entries()).flatMap(([username, user]) => (user ? [username] : [])));
}, [mentionUsers]);
return <MentionResolutionContext.Provider value={resolvedMentionUsernames}>{children}</MentionResolutionContext.Provider>;
};
export function useResolvedMentionUsernames(usernames: string[]) {
const sharedResolvedMentionUsernames = useContext(MentionResolutionContext);
const shouldUseSharedResolution = sharedResolvedMentionUsernames !== null;
const { data: mentionUsers } = useUsersByUsernames(usernames, { enabled: !shouldUseSharedResolution });
return useMemo(() => {
if (sharedResolvedMentionUsernames) {
return sharedResolvedMentionUsernames;
}
if (!mentionUsers) {
return new Set<string>();
}
return new Set(Array.from(mentionUsers.entries()).flatMap(([username, user]) => (user ? [username] : [])));
}, [sharedResolvedMentionUsernames, mentionUsers]);
}
@@ -0,0 +1,101 @@
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { cn } from "@/lib/utils";
import { getThemeWithFallback, resolveTheme, setupSystemThemeListener } from "@/utils/theme";
import { extractCodeContent } from "./utils";
interface MermaidBlockProps {
children?: React.ReactNode;
className?: string;
}
type MermaidTheme = "default" | "dark";
const toMermaidTheme = (appTheme: string): MermaidTheme => (appTheme === "default-dark" ? "dark" : "default");
const formatErrorMessage = (err: unknown): string => {
const msg = err instanceof Error ? err.message : "Failed to render diagram";
if (/no diagram type detected/i.test(msg)) {
return `${msg} — check that the diagram type is valid (e.g. sequenceDiagram, classDiagram, erDiagram)`;
}
return msg;
};
export const MermaidBlock = ({ children, className }: MermaidBlockProps) => {
const { userGeneralSetting } = useAuth();
const [svg, setSvg] = useState<string>("");
const [error, setError] = useState<string>("");
const [systemThemeChange, setSystemThemeChange] = useState(0);
const codeContent = extractCodeContent(children);
const themePreference = getThemeWithFallback(userGeneralSetting?.theme);
const currentTheme = useMemo(() => resolveTheme(themePreference), [themePreference, systemThemeChange]);
// Re-resolve theme when OS preference changes (only relevant when using "system" theme)
useEffect(() => {
if (themePreference !== "system") return;
return setupSystemThemeListener(() => setSystemThemeChange((n) => n + 1));
}, [themePreference]);
// Render diagram when content or theme changes
useEffect(() => {
if (!codeContent) {
setSvg("");
setError("");
return;
}
let cancelled = false;
const renderDiagram = async () => {
try {
const { default: mermaid } = await import("mermaid");
if (cancelled) return;
mermaid.initialize({
startOnLoad: false,
theme: toMermaidTheme(currentTheme),
securityLevel: "strict",
fontFamily: "inherit",
suppressErrorRendering: true,
});
const id = `mermaid-${Math.random().toString(36).substring(7)}`;
const { svg: renderedSvg } = await mermaid.render(id, codeContent);
if (cancelled) return;
setSvg(renderedSvg);
setError("");
} catch (err) {
if (cancelled) return;
console.error("Failed to render mermaid diagram:", err);
setSvg("");
setError(formatErrorMessage(err));
}
};
renderDiagram();
return () => {
cancelled = true;
};
}, [codeContent, currentTheme]);
if (error) {
return (
<div className="w-full">
<div className="text-sm text-destructive mb-2 whitespace-normal break-words">Mermaid Error: {error}</div>
<code className="block language-mermaid whitespace-pre text-sm">{codeContent}</code>
</div>
);
}
if (!svg) return null;
return (
<div
className={cn("mermaid-diagram w-full flex justify-center items-center my-2 overflow-x-auto", className)}
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
};
+77
View File
@@ -0,0 +1,77 @@
import { cn } from "@/lib/utils";
import { NestedMarkdownRenderContext } from "./MarkdownRenderContext";
import type { ReactMarkdownProps } from "./markdown/types";
interface TableProps extends React.HTMLAttributes<HTMLTableElement>, ReactMarkdownProps {
children: React.ReactNode;
}
export const Table = ({ children, className, node: _node, ...props }: TableProps) => {
return (
<div className="my-2 w-full overflow-x-auto rounded-lg border border-border bg-muted/20">
<table className={cn("w-full border-collapse text-sm", className)} {...props}>
{children}
</table>
</div>
);
};
interface TableHeadProps extends React.HTMLAttributes<HTMLTableSectionElement>, ReactMarkdownProps {
children: React.ReactNode;
}
export const TableHead = ({ children, className, node: _node, ...props }: TableHeadProps) => {
return (
<thead className={cn("border-b border-border bg-muted/30", className)} {...props}>
{children}
</thead>
);
};
interface TableBodyProps extends React.HTMLAttributes<HTMLTableSectionElement>, ReactMarkdownProps {
children: React.ReactNode;
}
export const TableBody = ({ children, className, node: _node, ...props }: TableBodyProps) => {
return (
<tbody className={cn("divide-y divide-border", className)} {...props}>
{children}
</tbody>
);
};
interface TableRowProps extends React.HTMLAttributes<HTMLTableRowElement>, ReactMarkdownProps {
children: React.ReactNode;
}
export const TableRow = ({ children, className, node: _node, ...props }: TableRowProps) => {
return (
<tr className={cn("transition-colors hover:bg-accent/20", className)} {...props}>
{children}
</tr>
);
};
interface TableHeaderCellProps extends React.ThHTMLAttributes<HTMLTableCellElement>, ReactMarkdownProps {
children: React.ReactNode;
}
export const TableHeaderCell = ({ children, className, node: _node, ...props }: TableHeaderCellProps) => {
return (
<th className={cn("px-2 py-1 text-left align-middle text-sm font-medium text-muted-foreground", className)} {...props}>
<NestedMarkdownRenderContext>{children}</NestedMarkdownRenderContext>
</th>
);
};
interface TableCellProps extends React.TdHTMLAttributes<HTMLTableCellElement>, ReactMarkdownProps {
children: React.ReactNode;
}
export const TableCell = ({ children, className, node: _node, ...props }: TableCellProps) => {
return (
<td className={cn("px-2 py-1 text-left align-middle text-sm", className)} {...props}>
<NestedMarkdownRenderContext>{children}</NestedMarkdownRenderContext>
</td>
);
};
+81
View File
@@ -0,0 +1,81 @@
import type { Element } from "hast";
import { useLocation } from "react-router-dom";
import { useInstance } from "@/contexts/InstanceContext";
import { type MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import useNavigateTo from "@/hooks/useNavigateTo";
import { colorToHex } from "@/lib/color";
import { findTagMetadata } from "@/lib/tag";
import { cn } from "@/lib/utils";
import { Routes } from "@/router";
import { useMemoViewContext } from "../MemoView/MemoViewContext";
interface TagProps extends React.HTMLAttributes<HTMLSpanElement> {
node?: Element; // AST node from react-markdown
"data-tag"?: string;
children?: React.ReactNode;
}
export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, className, style, node: _node, ...props }) => {
const { parentPage } = useMemoViewContext();
const location = useLocation();
const navigateTo = useNavigateTo();
const { getFiltersByFactor, removeFilter, addFilter } = useMemoFilterContext();
const { tagsSetting } = useInstance();
const tag = dataTag || "";
// Custom color from admin tag metadata. Dynamic hex values must use inline styles
// because Tailwind can't scan dynamically constructed class names.
// Text uses a darkened variant (40% color + black) for contrast on light backgrounds.
const bgHex = colorToHex(findTagMetadata(tag, tagsSetting)?.backgroundColor);
const tagStyle: React.CSSProperties | undefined = bgHex
? {
borderColor: bgHex,
color: `color-mix(in srgb, ${bgHex} 60%, black)`,
backgroundColor: `color-mix(in srgb, ${bgHex} 15%, transparent)`,
...style,
}
: style;
const handleTagClick = (e: React.MouseEvent) => {
e.stopPropagation();
// If the tag is clicked in a memo detail page, we should navigate to the memo list page.
if (location.pathname.startsWith("/m")) {
const pathname = parentPage || Routes.HOME;
const searchParams = new URLSearchParams();
searchParams.set("filter", stringifyFilters([{ factor: "tagSearch", value: tag }]));
navigateTo(`${pathname}?${searchParams.toString()}`);
return;
}
const isActive = getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag);
if (isActive) {
removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag);
} else {
// Remove all existing tag filters first, then add the new one
removeFilter((f: MemoFilter) => f.factor === "tagSearch");
addFilter({
factor: "tagSearch",
value: tag,
});
}
};
return (
<span
className={cn(
"inline-flex items-center align-baseline px-1.5 py-0.5 text-[0.9em] leading-none font-normal rounded-full border cursor-pointer transition-opacity hover:opacity-75",
!bgHex && "border-primary text-primary bg-primary/15",
className,
)}
style={tagStyle}
data-tag={tag}
{...props}
onClick={handleTagClick}
>
{children}
</span>
);
};
@@ -0,0 +1,70 @@
import { useRef } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { useUpdateMemo } from "@/hooks/useMemoQueries";
import { toggleTaskAtIndex } from "@/utils/markdown-manipulation";
import { useMemoViewContext, useMemoViewDerived } from "../MemoView/MemoViewContext";
import { TASK_LIST_ITEM_CLASS } from "./constants";
import type { ReactMarkdownProps } from "./markdown/types";
interface TaskListItemProps extends React.InputHTMLAttributes<HTMLInputElement>, ReactMarkdownProps {
checked?: boolean;
}
export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, node: _node, ...props }) => {
const { memo } = useMemoViewContext();
const { readonly } = useMemoViewDerived();
const checkboxRef = useRef<HTMLButtonElement>(null);
const { mutate: updateMemo } = useUpdateMemo();
const handleChange = async (newChecked: boolean) => {
// Don't update if readonly or no memo
if (readonly || !memo) {
return;
}
// Find the task index by walking up the DOM
const listItem = checkboxRef.current?.closest("li.task-list-item");
if (!listItem) {
return;
}
// Get task index from data attribute, or calculate by counting
const taskIndexStr = listItem.getAttribute("data-task-index");
let taskIndex = 0;
if (taskIndexStr !== null) {
taskIndex = parseInt(taskIndexStr);
} else {
// Fallback: Calculate index by counting all task list items in the entire memo
// We need to search from the root memo content container, not just the nearest list
// to ensure nested tasks are counted in document order
let searchRoot = listItem.closest("[data-memo-content]");
// If memo content container not found, search from document body
if (!searchRoot) {
searchRoot = document.body;
}
const allTaskItems = searchRoot.querySelectorAll(`li.${TASK_LIST_ITEM_CLASS}`);
for (let i = 0; i < allTaskItems.length; i++) {
if (allTaskItems[i] === listItem) {
taskIndex = i;
break;
}
}
}
// Update memo content using the string manipulation utility
const newContent = toggleTaskAtIndex(memo.content, taskIndex, newChecked);
updateMemo({
update: {
name: memo.name,
content: newContent,
},
updateMask: ["content", "update_time"],
});
};
// Override the disabled prop from remark-gfm (which defaults to true)
return <Checkbox ref={checkboxRef} checked={checked} disabled={readonly} onCheckedChange={handleChange} className={props.className} />;
};
@@ -0,0 +1,14 @@
import { createElement } from "react";
import { cn } from "@/lib/utils";
import { isTrustedIframeSrc } from "./constants";
export const TrustedIframe = (props: React.ComponentProps<"iframe">) => {
if (typeof props.src !== "string" || !isTrustedIframeSrc(props.src)) {
return null;
}
return createElement("iframe", {
...props,
className: cn("max-w-full rounded-lg border border-border", props.className),
});
};
@@ -0,0 +1,73 @@
import { defaultSchema } from "rehype-sanitize";
// Class names added by remark-gfm for task lists
export const TASK_LIST_CLASS = "contains-task-list";
export const TASK_LIST_ITEM_CLASS = "task-list-item";
// Compact mode display settings
export const COMPACT_MODE_CONFIG = {
maxHeightVh: 60, // 60% of viewport height
gradientHeight: "h-24", // Tailwind class for gradient overlay
} as const;
export const getMaxDisplayHeight = () => window.innerHeight * (COMPACT_MODE_CONFIG.maxHeightVh / 100);
export const COMPACT_STATES: Record<"ALL" | "SNIPPET", { textKey: string; next: "ALL" | "SNIPPET" }> = {
ALL: { textKey: "memo.show-more", next: "SNIPPET" },
SNIPPET: { textKey: "memo.show-less", next: "ALL" },
};
const TRUSTED_IFRAME_SRC_PATTERNS = [
/^https:\/\/www\.youtube\.com\/embed\/[^?#]+(?:\?.*)?$/i,
/^https:\/\/www\.youtube-nocookie\.com\/embed\/[^?#]+(?:\?.*)?$/i,
/^https:\/\/player\.vimeo\.com\/video\/[^?#]+(?:\?.*)?$/i,
/^https:\/\/open\.spotify\.com\/embed\/[^?#]+(?:\?.*)?$/i,
/^https:\/\/w\.soundcloud\.com\/player\/?(?:\?.*)?$/i,
/^https:\/\/www\.loom\.com\/embed\/[^?#]+(?:\?.*)?$/i,
/^https:\/\/www\.google\.com\/maps\/embed(?:\/[^?#]*)?(?:\?.*)?$/i,
/^https:\/\/(?:app\.)?diagrams\.net\/(?:[^?#]+)?(?:\?.*)?$/i,
/^https:\/\/(?:www\.)?draw\.io\/(?:[^?#]+)?(?:\?.*)?$/i,
];
const KATEX_INLINE_CLASS_NAMES = ["language-math", "math-inline"] as const;
const KATEX_BLOCK_CLASS_NAMES = ["language-math", "math-display"] as const;
const SPAN_CLASS_NAMES = ["mention", "tag"] as const;
const INPUT_ATTRIBUTES = [...(defaultSchema.attributes?.input || []), ["checked", true]] as const;
export const isTrustedIframeSrc = (src: string): boolean => TRUSTED_IFRAME_SRC_PATTERNS.some((pattern) => pattern.test(src));
/**
* Sanitization schema for markdown HTML content.
* Extends the default schema to allow:
* - KaTeX marker classes used before trusted KaTeX rendering runs
* - Mention/tag metadata generated by trusted remark plugins
* - iframe embeds only from trusted video providers
*
* This prevents XSS attacks while preserving math rendering functionality.
*/
export const SANITIZE_SCHEMA = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
img: [...(defaultSchema.attributes?.img || []), "height", "width"],
input: INPUT_ATTRIBUTES,
code: [...(defaultSchema.attributes?.code || []), ["className", ...KATEX_INLINE_CLASS_NAMES, ...KATEX_BLOCK_CLASS_NAMES]],
span: [...(defaultSchema.attributes?.span || []), ["className", ...SPAN_CLASS_NAMES], ["aria*"], ["data*"]],
iframe: [
["src", ...TRUSTED_IFRAME_SRC_PATTERNS],
"width",
"height",
"frameborder",
"allowfullscreen",
"allow",
"title",
"referrerpolicy",
"loading",
],
},
tagNames: [...(defaultSchema.tagNames || []), "iframe"],
protocols: {
...defaultSchema.protocols,
src: ["https"],
},
};
+28
View File
@@ -0,0 +1,28 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { COMPACT_STATES, getMaxDisplayHeight } from "./constants";
import type { ContentCompactView } from "./types";
export const useCompactMode = (enabled: boolean) => {
const containerRef = useRef<HTMLDivElement>(null);
const [mode, setMode] = useState<ContentCompactView | undefined>(undefined);
useEffect(() => {
if (!enabled || !containerRef.current) return;
const maxHeight = getMaxDisplayHeight();
if (containerRef.current.getBoundingClientRect().height > maxHeight) {
setMode("ALL");
}
}, [enabled]);
const toggle = useCallback(() => {
if (!mode) return;
setMode(COMPACT_STATES[mode].next);
}, [mode]);
return { containerRef, mode, toggle };
};
export const useCompactLabel = (mode: ContentCompactView | undefined, t: (key: string) => string): string => {
if (!mode) return "";
return t(COMPACT_STATES[mode].textKey);
};
+70
View File
@@ -0,0 +1,70 @@
import { ChevronDown, ChevronUp } from "lucide-react";
import { memo, useMemo } from "react";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { extractMentionUsernames } from "@/utils/remark-plugins/remark-mention";
import { COMPACT_MODE_CONFIG } from "./constants";
import { useCompactLabel, useCompactMode } from "./hooks";
import { MemoMarkdownRenderer } from "./MemoMarkdownRenderer";
import { useResolvedMentionUsernames } from "./MentionResolutionContext";
import type { MemoContentProps } from "./types";
const MemoContent = (props: MemoContentProps) => {
const { className, contentClassName, content, onClick, onDoubleClick } = props;
const t = useTranslate();
const {
containerRef: memoContentContainerRef,
mode: showCompactMode,
toggle: toggleCompactMode,
} = useCompactMode(Boolean(props.compact));
const mentionUsernames = useMemo(() => extractMentionUsernames(content), [content]);
const resolvedMentionUsernames = useResolvedMentionUsernames(mentionUsernames);
const compactLabel = useCompactLabel(showCompactMode, t as (key: string) => string);
return (
<div className={`w-full flex flex-col justify-start items-start text-foreground ${className || ""}`}>
<div
ref={memoContentContainerRef}
data-memo-content
className={cn(
"relative w-full max-w-full wrap-break-word text-base leading-6",
"[&>*:last-child]:mb-0",
"[&_.katex-display]:max-w-full",
"[&_.katex-display]:overflow-x-auto",
"[&_.katex-display]:overflow-y-hidden",
showCompactMode === "ALL" && "overflow-hidden",
contentClassName,
)}
style={showCompactMode === "ALL" ? { maxHeight: `${COMPACT_MODE_CONFIG.maxHeightVh}vh` } : undefined}
onMouseUp={onClick}
onDoubleClick={onDoubleClick}
>
<MemoMarkdownRenderer content={content} resolvedMentionUsernames={resolvedMentionUsernames} />
{showCompactMode === "ALL" && (
<div
className={cn(
"absolute inset-x-0 bottom-0 pointer-events-none",
COMPACT_MODE_CONFIG.gradientHeight,
"bg-linear-to-t from-background from-0% via-background/60 via-40% to-transparent to-100%",
)}
/>
)}
</div>
{showCompactMode !== undefined && (
<div className="relative w-full mt-2">
<button
type="button"
className="group inline-flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={toggleCompactMode}
>
<span>{compactLabel}</span>
{showCompactMode === "ALL" ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />}
</button>
</div>
)}
</div>
);
};
export default memo(MemoContent);
@@ -0,0 +1,18 @@
import { cn } from "@/lib/utils";
import { NestedMarkdownRenderContext } from "../MarkdownRenderContext";
import type { ReactMarkdownProps } from "./types";
interface BlockquoteProps extends React.BlockquoteHTMLAttributes<HTMLQuoteElement>, ReactMarkdownProps {
children: React.ReactNode;
}
/**
* Blockquote component with left border accent
*/
export const Blockquote = ({ children, className, node: _node, ...props }: BlockquoteProps) => {
return (
<blockquote className={cn("my-0 mb-2 border-l-4 border-primary/30 pl-3 text-muted-foreground italic", className)} {...props}>
<NestedMarkdownRenderContext>{children}</NestedMarkdownRenderContext>
</blockquote>
);
};
@@ -0,0 +1,31 @@
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface HeadingProps extends React.HTMLAttributes<HTMLHeadingElement>, ReactMarkdownProps {
level: 1 | 2 | 3 | 4 | 5 | 6;
children: React.ReactNode;
}
/**
* Heading component for h1-h6 elements.
* Renders semantic heading levels with consistent styling.
* Anchor IDs are assigned by the rehypeHeadingId plugin.
*/
export const Heading = ({ level, children, className, node: _node, ...props }: HeadingProps) => {
const Component = `h${level}` as const;
const levelClasses = {
1: "text-3xl font-bold border-b border-border pb-2",
2: "text-2xl font-semibold border-b border-border pb-1.5",
3: "text-xl font-semibold",
4: "text-lg font-semibold",
5: "text-base font-semibold",
6: "text-base font-medium text-muted-foreground",
};
return (
<Component className={cn("mt-3 mb-2 leading-tight", levelClasses[level], className)} {...props}>
{children}
</Component>
);
};
@@ -0,0 +1,11 @@
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface HorizontalRuleProps extends React.HTMLAttributes<HTMLHRElement>, ReactMarkdownProps {}
/**
* Horizontal rule separator
*/
export const HorizontalRule = ({ className, node: _node, ...props }: HorizontalRuleProps) => {
return <hr className={cn("my-2 h-0 border-0 border-b border-border", className)} {...props} />;
};
@@ -0,0 +1,19 @@
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface ImageProps extends React.ImgHTMLAttributes<HTMLImageElement>, ReactMarkdownProps {}
/**
* Image component for markdown images
* Responsive with rounded corners
*/
export const Image = ({ className, alt, node: _node, height, width, style, ...props }: ImageProps) => {
return (
<img
className={cn("max-w-full my-2", !height && "h-auto", className)}
alt={alt}
style={{ height: height ? `${height}px` : undefined, width: width ? `${width}px` : undefined, ...style }}
{...props}
/>
);
};
@@ -0,0 +1,17 @@
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface InlineCodeProps extends React.HTMLAttributes<HTMLElement>, ReactMarkdownProps {
children: React.ReactNode;
}
/**
* Inline code component with background and monospace font
*/
export const InlineCode = ({ children, className, node: _node, ...props }: InlineCodeProps) => {
return (
<code className={cn("font-mono text-sm bg-muted px-1 py-0.5 rounded-md", className)} {...props}>
{children}
</code>
);
};
@@ -0,0 +1,27 @@
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement>, ReactMarkdownProps {
children: React.ReactNode;
}
/**
* Link component for external links
* Opens in new tab with security attributes
*/
export const Link = ({ children, className, href, node: _node, ...props }: LinkProps) => {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={cn(
"text-primary underline decoration-primary/50 underline-offset-2 transition-colors hover:decoration-primary",
className,
)}
{...props}
>
{children}
</a>
);
};
@@ -0,0 +1,129 @@
import { Children, cloneElement, isValidElement, type ReactElement, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { TASK_LIST_CLASS, TASK_LIST_ITEM_CLASS } from "../constants";
import { NestedMarkdownRenderContext } from "../MarkdownRenderContext";
import type { ReactMarkdownProps } from "./types";
interface TaskListChildProps {
children?: ReactNode;
node?: {
tagName?: string;
properties?: {
type?: unknown;
};
};
type?: string;
}
const isCheckboxInput = (child: ReactNode): child is ReactElement<TaskListChildProps> => {
return (
isValidElement<TaskListChildProps>(child) && (child.props.type === "checkbox" || child.props.node?.properties?.type === "checkbox")
);
};
const isParagraphElement = (child: ReactNode): child is ReactElement<TaskListChildProps> => {
return isValidElement<TaskListChildProps>(child) && (child.type === "p" || child.props.node?.tagName === "p");
};
const splitTaskListItemChildren = (children: ReactNode) => {
let checkbox: ReactNode;
const content: ReactNode[] = [];
Children.toArray(children).forEach((child) => {
if (!checkbox && isCheckboxInput(child)) {
checkbox = child;
return;
}
if (!checkbox && isParagraphElement(child)) {
const paragraphChildren: ReactNode[] = [];
Children.toArray(child.props.children).forEach((paragraphChild) => {
if (!checkbox && isCheckboxInput(paragraphChild)) {
checkbox = paragraphChild;
return;
}
paragraphChildren.push(paragraphChild);
});
if (checkbox) {
if (paragraphChildren.length > 0) {
content.push(cloneElement(child, undefined, ...paragraphChildren));
}
return;
}
}
content.push(child);
});
return { checkbox, content };
};
interface ListProps extends React.HTMLAttributes<HTMLUListElement | HTMLOListElement>, ReactMarkdownProps {
ordered?: boolean;
children: React.ReactNode;
}
/**
* List component for both regular and task lists (GFM)
* Detects task lists via the "contains-task-list" class added by remark-gfm
*/
export const List = ({ ordered, children, className, node: _node, ...domProps }: ListProps) => {
const Component = ordered ? "ol" : "ul";
const isTaskList = className?.includes(TASK_LIST_CLASS);
return (
<Component
className={cn(
"my-0 mb-2 list-outside",
isTaskList
? // Task list indentation is handled by task item grid columns.
"list-none"
: // Regular list: standard padding and list style
cn("pl-6", ordered ? "list-decimal" : "list-disc"),
className,
)}
{...domProps}
>
{children}
</Component>
);
};
interface ListItemProps extends React.LiHTMLAttributes<HTMLLIElement>, ReactMarkdownProps {
children: React.ReactNode;
}
/**
* List item component for both regular and task list items
* Detects task items via the "task-list-item" class added by remark-gfm
*/
export const ListItem = ({ children, className, node: _node, ...domProps }: ListItemProps) => {
const isTaskListItem = className?.includes(TASK_LIST_ITEM_CLASS);
if (isTaskListItem) {
const { checkbox, content } = splitTaskListItemChildren(children);
return (
<li
className={cn(
"mt-0.5 min-w-0 leading-6 list-none grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-2 [&>[data-slot=checkbox]]:mt-1",
className,
)}
{...domProps}
>
<NestedMarkdownRenderContext>
{checkbox}
<div className="min-w-0 [overflow-wrap:anywhere] [&>*:last-child]:mb-0">{content}</div>
</NestedMarkdownRenderContext>
</li>
);
}
return (
<li className={cn("mt-0.5 leading-6", className)} {...domProps}>
<NestedMarkdownRenderContext>{children}</NestedMarkdownRenderContext>
</li>
);
};
@@ -0,0 +1,60 @@
import type { Element } from "hast";
import { cn } from "@/lib/utils";
import LinkMetadataCard from "../LinkMetadataCard";
import { useMarkdownRenderContext } from "../MarkdownRenderContext";
import type { ReactMarkdownProps } from "./types";
interface ParagraphProps extends React.HTMLAttributes<HTMLParagraphElement>, ReactMarkdownProps {
children: React.ReactNode;
}
export function getSingleLinkHref(node?: Element): string | undefined {
if (!node || node.tagName !== "p") {
return undefined;
}
const meaningfulChildren = node.children.filter((child) => {
return !(child.type === "text" && child.value.trim() === "");
});
if (meaningfulChildren.length !== 1) {
return undefined;
}
const onlyChild = meaningfulChildren[0];
if (onlyChild.type !== "element" || onlyChild.tagName !== "a") {
return undefined;
}
const href = onlyChild.properties?.href;
if (typeof href !== "string") {
return undefined;
}
const meaningfulLinkChildren = onlyChild.children.filter((child) => {
return !(child.type === "text" && child.value.trim() === "");
});
if (meaningfulLinkChildren.length !== 1) {
return undefined;
}
const onlyLinkChild = meaningfulLinkChildren[0];
return onlyLinkChild.type === "text" && onlyLinkChild.value === href ? href : undefined;
}
export const Paragraph = ({ children, className, node, ...props }: ParagraphProps) => {
const { blockDepth } = useMarkdownRenderContext();
const href = blockDepth === 0 ? getSingleLinkHref(node) : undefined;
const paragraph = (
<p className={cn("my-0 mb-2 leading-6", className)} {...props}>
{children}
</p>
);
if (href) {
return <LinkMetadataCard url={href} fallback={paragraph} />;
}
return paragraph;
};
@@ -0,0 +1,19 @@
# Markdown Components
Small React components used by `MemoMarkdownRenderer` to style HTML emitted by `react-markdown`.
## Responsibilities
- Keep element styling local to each semantic HTML element.
- Strip the `node` prop from DOM output through `ReactMarkdownProps`.
- Preserve existing markdown behavior while avoiding structural fixes in CSS.
## Task Lists
GFM task lists are normalized before rendering by `remarkSplitMixedTaskLists`.
- Mixed task/bullet lists are split into separate lists so regular bullets keep bullets.
- Single-block split items are rendered as tight list items, preventing accidental `<p>` wrappers.
- `ListItem` uses a two-column grid: checkbox/control in the first column and a single task-body wrapper in the second.
- Task text, emphasis, links, tags, and nested content stay inside the body wrapper so inline markdown does not become separate grid items.
- Loose task items keep paragraph structure inside the task-body wrapper.
@@ -0,0 +1,8 @@
export { Blockquote } from "./Blockquote";
export { Heading } from "./Heading";
export { HorizontalRule } from "./HorizontalRule";
export { Image } from "./Image";
export { InlineCode } from "./InlineCode";
export { Link } from "./Link";
export { List, ListItem } from "./List";
export { Paragraph } from "./Paragraph";
@@ -0,0 +1,9 @@
import type { Element } from "hast";
/**
* Props passed by react-markdown to custom components
* Includes the AST node for advanced use cases
*/
export interface ReactMarkdownProps {
node?: Element;
}
+12
View File
@@ -0,0 +1,12 @@
import type React from "react";
export interface MemoContentProps {
content: string;
compact?: boolean;
className?: string;
contentClassName?: string;
onClick?: (e: React.MouseEvent) => void;
onDoubleClick?: (e: React.MouseEvent) => void;
}
export type ContentCompactView = "ALL" | "SNIPPET";
+25
View File
@@ -0,0 +1,25 @@
import { isValidElement, type ReactElement, type ReactNode } from "react";
/**
* Extracts code content from a react-markdown code element.
* Handles the nested structure where code is passed as children.
*
* @param children - The children prop from react-markdown (typically a code element)
* @returns The extracted code content as a string with trailing newline removed
*/
export const extractCodeContent = (children: ReactNode): string => {
const codeElement = isValidElement(children) ? (children as ReactElement<{ children?: ReactNode }>) : null;
return String(codeElement?.props.children || "").replace(/\n$/, "");
};
/**
* Extracts the language identifier from a code block's className.
* react-markdown uses the format "language-xxx" for code blocks.
*
* @param className - The className string from a code element
* @returns The language identifier, or empty string if none found
*/
export const extractLanguage = (className: string): string => {
const match = /language-(\w+)/.exec(className);
return match ? match[1] : "";
};
@@ -0,0 +1,139 @@
import { create } from "@bufbuild/protobuf";
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { isEqual } from "lodash-es";
import { CheckCircleIcon, ChevronRightIcon, Code2Icon, HashIcon, ImageIcon, LinkIcon, type LucideIcon, Share2Icon } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils";
import { Memo, Memo_PropertySchema } from "@/types/proto/api/v1/memo_service_pb";
import { type Translations, useTranslate } from "@/utils/i18n";
import { extractHeadings } from "@/utils/markdown-manipulation";
import { isSuperUser } from "@/utils/user";
import MemoOutline from "./MemoOutline";
import MemoSharePanel from "./MemoSharePanel";
interface Props {
memo: Memo;
className?: string;
onShareImageOpen?: () => void;
}
interface PropertyBadge {
icon: LucideIcon;
labelKey: Translations;
}
const SidebarSection = ({ label, count, children }: { label: string; count?: number; children: React.ReactNode }) => (
<div className="w-full space-y-2">
<div className="flex items-center gap-1.5">
<p className="text-xs font-medium text-muted-foreground/50 uppercase tracking-wider">{label}</p>
{count != null && <span className="text-xs text-muted-foreground/30">({count})</span>}
</div>
{children}
</div>
);
const PROPERTY_BADGE_CLASSES =
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border border-border/60 bg-muted/60 text-xs text-muted-foreground";
const TAG_BADGE_CLASSES =
"inline-flex items-center gap-1 px-1 rounded-md border border-border/60 bg-muted/60 text-sm text-muted-foreground hover:bg-muted hover:text-foreground/80 transition-colors cursor-pointer";
const SHARE_ACTION_ROW_CLASSES =
"h-auto min-h-0 w-full justify-between rounded-none px-2 py-1.5 text-xs font-normal leading-tight text-muted-foreground transition-colors hover:bg-muted/40 hover:text-muted-foreground focus-visible:ring-offset-0 gap-1.5";
const MemoDetailSidebar = ({ memo, className, onShareImageOpen }: Props) => {
const t = useTranslate();
const currentUser = useCurrentUser();
const [sharePanelOpen, setSharePanelOpen] = useState(false);
const property = create(Memo_PropertySchema, memo.property || {});
const canManageShares = !memo.parent && (memo.creator === currentUser?.name || isSuperUser(currentUser));
const hasUpdated = !isEqual(memo.createTime, memo.updateTime);
const headings = useMemo(() => extractHeadings(memo.content), [memo.content]);
const propertyBadges = useMemo(() => {
const badges: PropertyBadge[] = [];
if (property.hasLink) badges.push({ icon: LinkIcon, labelKey: "memo.links" });
if (property.hasTaskList) badges.push({ icon: CheckCircleIcon, labelKey: "memo.to-do" });
if (property.hasCode) badges.push({ icon: Code2Icon, labelKey: "memo.code" });
return badges;
}, [property.hasLink, property.hasTaskList, property.hasCode]);
return (
<aside className={cn("relative w-full h-auto max-h-screen overflow-auto flex flex-col gap-5", className)}>
{headings.length > 0 && (
<SidebarSection label={t("memo.outline")}>
<MemoOutline headings={headings} />
</SidebarSection>
)}
{(canManageShares || onShareImageOpen) && (
<SidebarSection label={t("memo.share.section-label")}>
<div className="overflow-hidden rounded-md border border-border/50 bg-muted/20">
{onShareImageOpen && (
<Button variant="ghost" size="sm" className={SHARE_ACTION_ROW_CLASSES} onClick={onShareImageOpen}>
<span className="flex min-w-0 flex-1 items-center gap-2">
<ImageIcon className="size-3.5 shrink-0 text-muted-foreground/90" />
<span className="truncate">{t("memo.share.open-image")}</span>
</span>
<ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground/35" />
</Button>
)}
{onShareImageOpen && canManageShares && <div className="border-t border-border/50" />}
{canManageShares && (
<Button variant="ghost" size="sm" className={SHARE_ACTION_ROW_CLASSES} onClick={() => setSharePanelOpen(true)}>
<span className="flex min-w-0 flex-1 items-center gap-2">
<Share2Icon className="size-3.5 shrink-0 text-muted-foreground/90" />
<span className="truncate">{t("memo.share.open-panel")}</span>
</span>
<ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground/35" />
</Button>
)}
</div>
</SidebarSection>
)}
<SidebarSection label={t("common.created-at")}>
<div className="flex flex-col gap-1">
<p className="text-sm text-foreground/70">{memo.createTime ? timestampDate(memo.createTime).toLocaleString() : "—"}</p>
{hasUpdated && (
<p className="text-xs text-muted-foreground">
{t("common.last-updated-at")}: {memo.updateTime ? timestampDate(memo.updateTime).toLocaleString() : "—"}
</p>
)}
</div>
</SidebarSection>
{propertyBadges.length > 0 && (
<SidebarSection label={t("common.properties")}>
<div className="flex flex-wrap gap-1.5">
{propertyBadges.map(({ icon: Icon, labelKey }) => (
<span key={labelKey} className={PROPERTY_BADGE_CLASSES}>
<Icon className="w-3.5 h-3.5" />
{t(labelKey)}
</span>
))}
</div>
</SidebarSection>
)}
{memo.tags.length > 0 && (
<SidebarSection label={t("common.tags")} count={memo.tags.length}>
<div className="flex flex-wrap gap-1.5">
{memo.tags.map((tag) => (
<span key={tag} className={TAG_BADGE_CLASSES}>
<HashIcon className="w-3 h-3 opacity-50" />
{tag}
</span>
))}
</div>
</SidebarSection>
)}
{sharePanelOpen && <MemoSharePanel memoName={memo.name} open={sharePanelOpen} onClose={() => setSharePanelOpen(false)} />}
</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_pb";
import MemoDetailSidebar from "./MemoDetailSidebar";
interface Props {
memo: Memo;
onShareImageOpen?: () => void;
}
const MemoDetailSidebarDrawer = ({ memo, onShareImageOpen }: 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" size="sm" className="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-background">
<MemoDetailSidebar className="py-4" memo={memo} onShareImageOpen={onShareImageOpen} />
</SheetContent>
</Sheet>
);
};
export default MemoDetailSidebarDrawer;
@@ -0,0 +1,52 @@
import { cn } from "@/lib/utils";
import type { HeadingItem } from "@/utils/markdown-manipulation";
interface MemoOutlineProps {
headings: HeadingItem[];
}
const levelIndent: Record<number, string> = {
1: "ml-0",
2: "ml-3",
3: "ml-6",
4: "ml-8",
};
/** Outline navigation for memo headings (h1h4). */
const MemoOutline = ({ headings }: MemoOutlineProps) => {
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, slug: string) => {
e.preventDefault();
const el = document.getElementById(slug);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
window.history.replaceState(null, "", `#${slug}`);
}
};
return (
<nav className="relative flex flex-col">
{headings.map((heading, index) => (
<a
key={`${heading.slug}-${index}`}
href={`#${heading.slug}`}
onClick={(e) => handleClick(e, heading.slug)}
className={cn(
"group relative block py-[5px] pr-1 text-[13px] leading-snug truncate",
"text-muted-foreground/60 hover:text-foreground/90",
"transition-colors duration-200 ease-out",
levelIndent[heading.level],
heading.level === 1 && "font-medium text-muted-foreground/80",
)}
title={heading.text}
>
<span className="relative">
{heading.text}
<span className="absolute -bottom-px left-0 h-px w-0 bg-foreground/30 transition-all duration-200 group-hover:w-full" />
</span>
</a>
))}
</nav>
);
};
export default MemoOutline;
@@ -0,0 +1,158 @@
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { ConnectError } from "@connectrpc/connect";
import { CheckIcon, CopyIcon, LinkIcon, Loader2Icon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { getShareUrl, useCreateMemoShare, useDeleteMemoShare, useMemoShares } from "@/hooks/useMemoShareQueries";
import type { MemoShare } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
type ExpiryOption = "never" | "1d" | "7d" | "30d";
function getExpireDate(option: ExpiryOption): Date | undefined {
if (option === "never") return undefined;
const d = new Date();
if (option === "1d") d.setDate(d.getDate() + 1);
else if (option === "7d") d.setDate(d.getDate() + 7);
else if (option === "30d") d.setDate(d.getDate() + 30);
return d;
}
function formatExpiry(share: MemoShare, t: ReturnType<typeof useTranslate>): string {
if (!share.expireTime) return t("memo.share.never-expires");
const d = timestampDate(share.expireTime);
return t("memo.share.expires-on", { date: d.toLocaleDateString() });
}
interface ShareLinkRowProps {
share: MemoShare;
memoName: string;
}
function ShareLinkRow({ share, memoName }: ShareLinkRowProps) {
const t = useTranslate();
const [copied, setCopied] = useState(false);
const deleteShare = useDeleteMemoShare();
const url = getShareUrl(share);
const handleCopy = async () => {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleRevoke = async () => {
try {
await deleteShare.mutateAsync({ name: share.name, memoName });
toast.success(t("memo.share.revoked"));
} catch (e) {
toast.error((e as ConnectError).message || t("memo.share.revoke-failed"));
}
};
return (
<div className="flex flex-col gap-1 rounded-md border border-border p-3">
<div className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-xs text-muted-foreground">{url}</span>
<div className="flex shrink-0 items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopy} title={t("memo.share.copy")}>
{copied ? <CheckIcon className="h-3.5 w-3.5 text-green-500" /> : <CopyIcon className="h-3.5 w-3.5" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={handleRevoke}
disabled={deleteShare.isPending}
title={t("memo.share.revoke")}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">{formatExpiry(share, t)}</p>
</div>
);
}
interface MemoSharePanelProps {
open: boolean;
onClose: () => void;
memoName: string;
}
const MemoSharePanel = ({ open, onClose, memoName }: MemoSharePanelProps) => {
const t = useTranslate();
const [expiry, setExpiry] = useState<ExpiryOption>("never");
const { data: shares = [], isLoading } = useMemoShares(memoName, { enabled: open });
const createShare = useCreateMemoShare();
const handleCreate = async () => {
try {
await createShare.mutateAsync({ memoName, expireTime: getExpireDate(expiry) });
} catch (e) {
toast.error((e as ConnectError).message || t("memo.share.create-failed"));
}
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LinkIcon className="h-4 w-4" />
{t("memo.share.title")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
{/* Active links */}
<div className="flex flex-col gap-2">
<p className="text-sm font-medium text-muted-foreground">{t("memo.share.active-links")}</p>
{isLoading ? (
<Loader2Icon className="h-4 w-4 animate-spin text-muted-foreground" />
) : shares.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("memo.share.no-links")}</p>
) : (
<div className="flex flex-col gap-2">
{shares.map((share) => (
<ShareLinkRow key={share.name} share={share} memoName={memoName} />
))}
</div>
)}
</div>
{/* Create new link */}
<div className="flex items-center gap-2">
<Select value={expiry} onValueChange={(v) => setExpiry(v as ExpiryOption)}>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="never">{t("memo.share.expiry-never")}</SelectItem>
<SelectItem value="1d">{t("memo.share.expiry-1-day")}</SelectItem>
<SelectItem value="7d">{t("memo.share.expiry-7-days")}</SelectItem>
<SelectItem value="30d">{t("memo.share.expiry-30-days")}</SelectItem>
</SelectContent>
</Select>
<Button onClick={handleCreate} disabled={createShare.isPending} className="flex-1">
{createShare.isPending ? (
<>
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
{t("memo.share.creating")}
</>
) : (
t("memo.share.create-link")
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};
export default MemoSharePanel;
@@ -0,0 +1,4 @@
import MemoDetailSidebar from "./MemoDetailSidebar";
import MemoDetailSidebarDrawer from "./MemoDetailSidebarDrawer";
export { MemoDetailSidebar, MemoDetailSidebarDrawer };
@@ -0,0 +1,61 @@
import { Settings2Icon } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useView } from "@/contexts/ViewContext";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
interface Props {
className?: string;
}
function MemoDisplaySettingMenu({ className }: Props) {
const t = useTranslate();
const { orderByTimeAsc, timeBasis, setTimeBasis, toggleSortOrder } = useView();
const isApplying = orderByTimeAsc !== false || timeBasis !== "create_time";
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.shown-time")}</span>
<Select value={timeBasis} onValueChange={(value) => setTimeBasis(value === "update_time" ? "update_time" : "create_time")}>
<SelectTrigger size="sm" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="create_time">{t("common.created-at")}</SelectItem>
<SelectItem value="update_time">{t("common.last-updated-at")}</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("memo.order")}</span>
<Select
value={orderByTimeAsc.toString()}
onValueChange={(value) => {
if ((value === "true") !== orderByTimeAsc) {
toggleSortOrder();
}
}}
>
<SelectTrigger size="sm" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="false">{t("memo.newest-first")}</SelectItem>
<SelectItem value="true">{t("memo.oldest-first")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</PopoverContent>
</Popover>
);
}
export default MemoDisplaySettingMenu;
@@ -0,0 +1,45 @@
import type { SlashCommandsProps } from "../types";
import type { EditorRefActions } from ".";
import { SuggestionsPopup } from "./SuggestionsPopup";
import { useSuggestions } from "./useSuggestions";
const SlashCommands = ({ editorRef, editorActions, commands }: SlashCommandsProps) => {
const handleCommandAutocomplete = (cmd: (typeof commands)[0], word: string, index: number, actions: EditorRefActions) => {
// Remove trigger char + word, then insert command output
actions.removeText(index, word.length);
actions.insertText(cmd.run());
// Position cursor relative to insertion point, if specified
if (cmd.cursorOffset) {
actions.setCursorPosition(index + cmd.cursorOffset);
}
};
const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({
editorRef,
editorActions,
triggerChar: "/",
items: commands,
filterItems: (items, query) => (!query ? items : items.filter((cmd) => cmd.name.toLowerCase().startsWith(query))),
onAutocomplete: handleCommandAutocomplete,
});
if (!isVisible || !position) return null;
return (
<SuggestionsPopup
position={position}
suggestions={suggestions}
selectedIndex={selectedIndex}
onItemSelect={handleItemSelect}
getItemKey={(cmd) => cmd.name}
renderItem={(cmd) => (
<span className="tracking-wide">
<span className="text-muted-foreground">/</span>
{cmd.name}
</span>
)}
/>
);
};
export default SlashCommands;
@@ -0,0 +1,49 @@
import { ReactNode, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { Position } from "./useSuggestions";
interface SuggestionsPopupProps<T> {
position: Position;
suggestions: T[];
selectedIndex: number;
onItemSelect: (item: T) => void;
renderItem: (item: T, isSelected: boolean) => ReactNode;
getItemKey: (item: T, index: number) => string;
}
const POPUP_STYLES = {
container:
"z-20 absolute p-1 mt-1 -ml-2 max-w-48 max-h-60 rounded border bg-popover text-popover-foreground shadow-lg font-mono flex flex-col overflow-y-auto overflow-x-hidden",
item: "rounded p-1 px-2 w-full text-sm cursor-pointer transition-colors select-none hover:bg-accent hover:text-accent-foreground",
};
export function SuggestionsPopup<T>({
position,
suggestions,
selectedIndex,
onItemSelect,
renderItem,
getItemKey,
}: SuggestionsPopupProps<T>) {
const containerRef = useRef<HTMLDivElement>(null);
const selectedItemRef = useRef<HTMLDivElement>(null);
useEffect(() => {
selectedItemRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [selectedIndex]);
return (
<div ref={containerRef} className={POPUP_STYLES.container} style={{ left: position.left, top: position.top + position.height }}>
{suggestions.map((item, i) => (
<div
key={getItemKey(item, i)}
ref={i === selectedIndex ? selectedItemRef : null}
onMouseDown={() => onItemSelect(item)}
className={cn(POPUP_STYLES.item, i === selectedIndex && "bg-accent text-accent-foreground")}
>
{renderItem(item, i === selectedIndex)}
</div>
))}
</div>
);
}
@@ -0,0 +1,50 @@
import { useMemo } from "react";
import { matchPath } from "react-router-dom";
import OverflowTip from "@/components/kit/OverflowTip";
import { useTagCounts } from "@/hooks/useUserQueries";
import { Routes } from "@/router";
import type { TagSuggestionsProps } from "../types";
import { SuggestionsPopup } from "./SuggestionsPopup";
import { useSuggestions } from "./useSuggestions";
export default function TagSuggestions({ editorRef, editorActions }: TagSuggestionsProps) {
// On explore page, show all users' tags; otherwise show current user's tags
const isExplorePage = Boolean(matchPath(Routes.EXPLORE, window.location.pathname));
const { data: tagCount = {} } = useTagCounts(!isExplorePage);
const sortedTags = useMemo(() => {
return Object.entries(tagCount)
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([tag]) => tag);
}, [tagCount]);
const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({
editorRef,
editorActions,
triggerChar: "#",
items: sortedTags,
filterItems: (items, query) => (!query ? items : items.filter((tag) => tag.toLowerCase().includes(query))),
onAutocomplete: (tag, word, index, actions) => {
actions.removeText(index, word.length);
actions.insertText(`#${tag} `);
},
});
if (!isVisible || !position) return null;
return (
<SuggestionsPopup
position={position}
suggestions={suggestions}
selectedIndex={selectedIndex}
onItemSelect={handleItemSelect}
getItemKey={(tag) => tag}
renderItem={(tag) => (
<OverflowTip>
<span className="text-muted-foreground mr-1">#</span>
{tag}
</OverflowTip>
)}
/>
);
}
@@ -0,0 +1,28 @@
export interface Command {
name: string;
run: () => string;
cursorOffset?: number;
}
export const editorCommands: Command[] = [
{
name: "todo",
run: () => "- [ ] ",
cursorOffset: 6,
},
{
name: "code",
run: () => "```\n\n```",
cursorOffset: 4,
},
{
name: "link",
run: () => "[text](url)",
cursorOffset: 1,
},
{
name: "table",
run: () => "| Header | Header |\n| ------ | ------ |\n| Cell | Cell |",
cursorOffset: 1,
},
];
@@ -0,0 +1,236 @@
import { type ClipboardEvent, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
import getCaretCoordinates from "textarea-caret";
import { cn } from "@/lib/utils";
import { EDITOR_HEIGHT } from "../constants";
import type { EditorProps } from "../types";
import { editorCommands } from "./commands";
import SlashCommands from "./SlashCommands";
import { getMarkdownLinkForPastedUrl } from "./shortcuts";
import TagSuggestions from "./TagSuggestions";
import { useListCompletion } from "./useListCompletion";
export interface EditorRefActions {
getEditor: () => HTMLTextAreaElement | null;
focus: () => void;
scrollToCursor: () => void;
insertText: (text: string, prefix?: string, suffix?: string) => void;
removeText: (start: number, length: number) => void;
setContent: (text: string) => void;
getContent: () => string;
getSelectedContent: () => string;
getCursorPosition: () => number;
setCursorPosition: (startPos: number, endPos?: number) => void;
getCursorLineNumber: () => number;
getLine: (lineNumber: number) => string;
setLine: (lineNumber: number, text: string) => void;
}
const Editor = forwardRef(function Editor(props: EditorProps, ref: React.ForwardedRef<EditorRefActions>) {
const {
className,
initialContent,
placeholder,
onPaste,
onContentChange: handleContentChangeCallback,
isFocusMode,
isInIME = false,
onCompositionStart,
onCompositionEnd,
} = props;
const editorRef = useRef<HTMLTextAreaElement>(null);
const updateEditorHeight = useCallback(() => {
if (editorRef.current) {
editorRef.current.style.height = "auto";
editorRef.current.style.height = `${editorRef.current.scrollHeight ?? 0}px`;
}
}, []);
const updateContent = useCallback(() => {
if (editorRef.current) {
handleContentChangeCallback(editorRef.current.value);
updateEditorHeight();
}
}, [handleContentChangeCallback, updateEditorHeight]);
const scrollToCaret = useCallback((options: { force?: boolean } = {}) => {
const editor = editorRef.current;
if (!editor) return;
const { force = false } = options;
const caret = getCaretCoordinates(editor, editor.selectionEnd);
if (force) {
editor.scrollTop = Math.max(0, caret.top - editor.clientHeight / 2);
return;
}
const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 24;
const viewportBottom = editor.scrollTop + editor.clientHeight;
// Scroll if cursor is near or beyond bottom edge (within 2 lines)
if (caret.top + lineHeight * 2 > viewportBottom) {
editor.scrollTop = Math.max(0, caret.top - editor.clientHeight / 2);
}
}, []);
useEffect(() => {
if (editorRef.current && initialContent) {
editorRef.current.value = initialContent;
handleContentChangeCallback(initialContent);
updateEditorHeight();
}
// Only run once on mount to set initial content
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Update editor when content is externally changed (e.g., reset after save)
useEffect(() => {
if (editorRef.current && editorRef.current.value !== initialContent) {
editorRef.current.value = initialContent;
updateEditorHeight();
}
}, [initialContent, updateEditorHeight]);
const editorActions: EditorRefActions = useMemo(
() => ({
getEditor: () => editorRef.current,
focus: () => editorRef.current?.focus(),
scrollToCursor: () => {
scrollToCaret({ force: true });
},
insertText: (content = "", prefix = "", suffix = "") => {
const editor = editorRef.current;
if (!editor) return;
const cursorPos = editor.selectionStart;
const endPos = editor.selectionEnd;
const prev = editor.value;
const actual = content || prev.slice(cursorPos, endPos);
editor.value = prev.slice(0, cursorPos) + prefix + actual + suffix + prev.slice(endPos);
editor.focus();
editor.setSelectionRange(cursorPos + prefix.length + actual.length, cursorPos + prefix.length + actual.length);
updateContent();
},
removeText: (start: number, length: number) => {
const editor = editorRef.current;
if (!editor) return;
editor.value = editor.value.slice(0, start) + editor.value.slice(start + length);
editor.focus();
editor.setSelectionRange(start, start);
updateContent();
},
setContent: (text: string) => {
const editor = editorRef.current;
if (editor) {
editor.value = text;
updateContent();
}
},
getContent: () => editorRef.current?.value ?? "",
getCursorPosition: () => editorRef.current?.selectionStart ?? 0,
getSelectedContent: () => {
const editor = editorRef.current;
if (!editor) return "";
return editor.value.slice(editor.selectionStart, editor.selectionEnd);
},
setCursorPosition: (startPos: number, endPos?: number) => {
const editor = editorRef.current;
if (!editor) return;
// setSelectionRange requires valid arguments; default to startPos if endPos is undefined
const endPosition = endPos !== undefined && !Number.isNaN(endPos) ? endPos : startPos;
editor.setSelectionRange(startPos, endPosition);
},
getCursorLineNumber: () => {
const editor = editorRef.current;
if (!editor) return 0;
const lines = editor.value.slice(0, editor.selectionStart).split("\n");
return lines.length - 1;
},
getLine: (lineNumber: number) => editorRef.current?.value.split("\n")[lineNumber] ?? "",
setLine: (lineNumber: number, text: string) => {
const editor = editorRef.current;
if (!editor) return;
const lines = editor.value.split("\n");
lines[lineNumber] = text;
editor.value = lines.join("\n");
editor.focus();
updateContent();
},
}),
[updateContent, scrollToCaret],
);
useImperativeHandle(ref, () => editorActions, [editorActions]);
const handleEditorInput = useCallback(() => {
if (editorRef.current) {
handleContentChangeCallback(editorRef.current.value);
updateEditorHeight();
// Auto-scroll to keep cursor visible when typing
// See: https://github.com/usememos/memos/issues/5469
scrollToCaret();
}
}, [handleContentChangeCallback, updateEditorHeight, scrollToCaret]);
// Auto-complete markdown lists when pressing Enter
useListCompletion({
editorRef,
editorActions,
isInIME,
});
const handleEditorPaste = useCallback(
(event: ClipboardEvent<HTMLTextAreaElement>) => {
const editor = editorRef.current;
const pastedText = event.clipboardData?.getData("text/plain") || event.clipboardData?.getData("text");
const markdownLink = editor ? getMarkdownLinkForPastedUrl(editorActions.getSelectedContent(), pastedText) : undefined;
if (markdownLink) {
event.preventDefault();
editorActions.insertText(markdownLink);
return;
}
onPaste(event);
},
[editorActions, onPaste],
);
// Recalculate editor height when focus mode changes
useEffect(() => {
updateEditorHeight();
}, [isFocusMode, updateEditorHeight]);
return (
<div
className={cn(
"flex flex-col justify-start items-start relative w-full bg-inherit",
// Focus mode: flex-1 to grow and fill space; Normal: h-auto with max-height
isFocusMode ? "flex-1" : `h-auto ${EDITOR_HEIGHT.normal}`,
className,
)}
>
<textarea
className={cn(
"w-full text-base resize-none overflow-x-hidden overflow-y-auto bg-transparent outline-none placeholder:opacity-70 whitespace-pre-wrap wrap-break-word",
// Focus mode: flex-1 h-0 to grow within flex container; Normal: h-full to fill wrapper
isFocusMode ? "flex-1 h-0" : "h-full",
)}
rows={1}
placeholder={placeholder}
ref={editorRef}
onPaste={handleEditorPaste}
onInput={handleEditorInput}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
></textarea>
<TagSuggestions editorRef={editorRef} editorActions={ref} />
<SlashCommands editorRef={editorRef} editorActions={ref} commands={editorCommands} />
</div>
);
});
export default Editor;
@@ -0,0 +1,80 @@
import type { EditorRefActions } from "./index";
const SHORTCUTS = {
BOLD: { key: "b", delimiter: "**" },
ITALIC: { key: "i", delimiter: "*" },
LINK: { key: "k" },
} as const;
const URL_PLACEHOLDER = "url";
const URL_REGEX = /^https?:\/\/[^\s]+$/;
const LINK_OFFSET = 3; // Length of "]()"
export function handleMarkdownShortcuts(event: React.KeyboardEvent, editor: EditorRefActions): void {
const key = event.key.toLowerCase();
if (key === SHORTCUTS.BOLD.key) {
event.preventDefault();
toggleTextStyle(editor, SHORTCUTS.BOLD.delimiter);
} else if (key === SHORTCUTS.ITALIC.key) {
event.preventDefault();
toggleTextStyle(editor, SHORTCUTS.ITALIC.delimiter);
} else if (key === SHORTCUTS.LINK.key) {
event.preventDefault();
insertHyperlink(editor);
}
}
export function insertHyperlink(editor: EditorRefActions, url?: string): void {
const cursorPosition = editor.getCursorPosition();
const selectedContent = editor.getSelectedContent();
const isUrlSelected = !url && URL_REGEX.test(selectedContent.trim());
if (isUrlSelected) {
editor.insertText(`[](${selectedContent})`);
editor.setCursorPosition(cursorPosition + 1, cursorPosition + 1);
return;
}
const href = url ?? URL_PLACEHOLDER;
editor.insertText(`[${selectedContent}](${href})`);
if (href === URL_PLACEHOLDER) {
const urlStart = cursorPosition + selectedContent.length + LINK_OFFSET;
editor.setCursorPosition(urlStart, urlStart + href.length);
}
}
function toggleTextStyle(editor: EditorRefActions, delimiter: string): void {
const cursorPosition = editor.getCursorPosition();
const selectedContent = editor.getSelectedContent();
const isStyled = selectedContent.startsWith(delimiter) && selectedContent.endsWith(delimiter);
if (isStyled) {
const unstyled = selectedContent.slice(delimiter.length, -delimiter.length);
editor.insertText(unstyled);
editor.setCursorPosition(cursorPosition, cursorPosition + unstyled.length);
} else {
editor.insertText(`${delimiter}${selectedContent}${delimiter}`);
editor.setCursorPosition(cursorPosition + delimiter.length, cursorPosition + delimiter.length + selectedContent.length);
}
}
export function hyperlinkHighlightedText(editor: EditorRefActions, url: string): void {
const selectedContent = editor.getSelectedContent();
const cursorPosition = editor.getCursorPosition();
editor.insertText(`[${selectedContent}](${url})`);
const newPosition = cursorPosition + selectedContent.length + url.length + 4;
editor.setCursorPosition(newPosition, newPosition);
}
export function getMarkdownLinkForPastedUrl(selectedContent: string, pastedText: string): string | undefined {
const url = pastedText.trim();
if (!selectedContent || !URL_REGEX.test(url) || URL_REGEX.test(selectedContent.trim())) {
return undefined;
}
return `[${selectedContent}](${url})`;
}
@@ -0,0 +1,82 @@
import { type RefObject, useEffect, useRef } from "react";
import { detectLastListItem, generateListContinuation } from "@/utils/markdown-list-detection";
import { EditorRefActions } from ".";
interface UseListCompletionOptions {
editorRef: RefObject<HTMLTextAreaElement | null>;
editorActions: EditorRefActions;
isInIME: boolean;
}
// Patterns to detect empty list items
const EMPTY_LIST_PATTERNS = [
/^(\s*)([-*+])\s*$/, // Empty unordered list
/^(\s*)([-*+])\s+\[([ xX])\]\s*$/, // Empty task list
/^(\s*)(\d+)[.)]\s*$/, // Empty ordered list
];
const isEmptyListItem = (line: string) => EMPTY_LIST_PATTERNS.some((pattern) => pattern.test(line));
export function useListCompletion({ editorRef, editorActions, isInIME }: UseListCompletionOptions) {
const isInIMERef = useRef(isInIME);
isInIMERef.current = isInIME;
const editorActionsRef = useRef(editorActions);
editorActionsRef.current = editorActions;
// Track when composition ends to handle Safari race condition
// Safari fires keydown(Enter) immediately after compositionend, while Chrome doesn't
// See: https://github.com/usememos/memos/issues/5469
const lastCompositionEndRef = useRef(0);
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
const handleCompositionEnd = () => {
lastCompositionEndRef.current = Date.now();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" || isInIMERef.current || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) {
return;
}
// Safari fix: Ignore Enter key within 100ms of composition end
// This prevents double-enter behavior when confirming IME input in lists
if (Date.now() - lastCompositionEndRef.current < 100) {
return;
}
const actions = editorActionsRef.current;
const cursorPosition = actions.getCursorPosition();
const contentBeforeCursor = actions.getContent().substring(0, cursorPosition);
const listInfo = detectLastListItem(contentBeforeCursor);
if (!listInfo.type) return;
event.preventDefault();
const lines = contentBeforeCursor.split("\n");
const currentLine = lines[lines.length - 1];
if (isEmptyListItem(currentLine)) {
const lineStartPos = cursorPosition - currentLine.length;
actions.removeText(lineStartPos, currentLine.length);
} else {
const continuation = generateListContinuation(listInfo);
actions.insertText("\n" + continuation);
// Auto-scroll to keep cursor visible after inserting list item
setTimeout(() => actions.scrollToCursor(), 0);
}
};
editor.addEventListener("compositionend", handleCompositionEnd);
editor.addEventListener("keydown", handleKeyDown);
return () => {
editor.removeEventListener("compositionend", handleCompositionEnd);
editor.removeEventListener("keydown", handleKeyDown);
};
}, []);
}
@@ -0,0 +1,165 @@
import { type ForwardedRef, type RefObject, useEffect, useRef, useState } from "react";
import getCaretCoordinates from "textarea-caret";
import { EditorRefActions } from ".";
export interface Position {
left: number;
top: number;
height: number;
}
export interface UseSuggestionsOptions<T> {
editorRef: RefObject<HTMLTextAreaElement | null>;
editorActions: ForwardedRef<EditorRefActions>;
triggerChar: string;
items: T[];
filterItems: (items: T[], searchQuery: string) => T[];
onAutocomplete: (item: T, word: string, startIndex: number, actions: EditorRefActions) => void;
}
export interface UseSuggestionsReturn<T> {
position: Position | null;
suggestions: T[];
selectedIndex: number;
isVisible: boolean;
searchQuery: string;
handleItemSelect: (item: T) => void;
}
export function useSuggestions<T>({
editorRef,
editorActions,
triggerChar,
items,
filterItems,
onAutocomplete,
}: UseSuggestionsOptions<T>): UseSuggestionsReturn<T> {
const [position, setPosition] = useState<Position | null>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const isProcessingRef = useRef(false);
const selectedRef = useRef(selectedIndex);
selectedRef.current = selectedIndex;
const getCurrentWord = (): [word: string, startIndex: number] => {
const editor = editorRef.current;
if (!editor) return ["", 0];
const cursorPos = editor.selectionEnd;
const before = editor.value.slice(0, cursorPos).match(/\S*$/) || { 0: "", index: cursorPos };
const after = editor.value.slice(cursorPos).match(/^\S*/) || { 0: "" };
return [before[0] + after[0], before.index ?? cursorPos];
};
const hide = () => setPosition(null);
const suggestionsRef = useRef<T[]>([]);
const searchQueryRef = useRef("");
suggestionsRef.current = (() => {
const [word] = getCurrentWord();
if (!word.startsWith(triggerChar)) return [];
searchQueryRef.current = word.slice(triggerChar.length).toLowerCase();
return filterItems(items, searchQueryRef.current);
})();
if (suggestionsRef.current.length === 0) {
const [word] = getCurrentWord();
searchQueryRef.current = word.startsWith(triggerChar) ? word.slice(triggerChar.length).toLowerCase() : "";
}
const isVisibleRef = useRef(false);
isVisibleRef.current = !!(position && suggestionsRef.current.length > 0);
const handleAutocomplete = (item: T) => {
if (!editorActions || !("current" in editorActions) || !editorActions.current) {
console.warn("useSuggestions: editorActions not available");
return;
}
isProcessingRef.current = true;
const [word, index] = getCurrentWord();
onAutocomplete(item, word, index, editorActions.current);
hide();
// Re-enable input handling after all DOM operations complete
queueMicrotask(() => {
isProcessingRef.current = false;
});
};
const handleNavigation = (e: KeyboardEvent, selected: number, suggestionsCount: number) => {
if (e.code === "ArrowDown") {
setSelectedIndex((selected + 1) % suggestionsCount);
e.preventDefault();
e.stopPropagation();
} else if (e.code === "ArrowUp") {
setSelectedIndex((selected - 1 + suggestionsCount) % suggestionsCount);
e.preventDefault();
e.stopPropagation();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!isVisibleRef.current) return;
const suggestions = suggestionsRef.current;
const selected = selectedRef.current;
if (["Escape", "ArrowLeft", "ArrowRight"].includes(e.code)) {
hide();
return;
}
if (["ArrowDown", "ArrowUp"].includes(e.code)) {
handleNavigation(e, selected, suggestions.length);
return;
}
if (["Enter", "Tab"].includes(e.code)) {
handleAutocomplete(suggestions[selected]);
e.preventDefault();
e.stopImmediatePropagation();
}
};
const handleInput = () => {
if (isProcessingRef.current) return;
const editor = editorRef.current;
if (!editor) return;
setSelectedIndex(0);
const [word, index] = getCurrentWord();
const currentChar = editor.value[editor.selectionEnd];
const isActive = word.startsWith(triggerChar) && currentChar !== triggerChar;
if (isActive) {
const coords = getCaretCoordinates(editor, index);
coords.top -= editor.scrollTop;
setPosition(coords);
} else {
hide();
}
};
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
const handlers = { click: hide, blur: hide, keydown: handleKeyDown, input: handleInput };
Object.entries(handlers).forEach(([event, handler]) => {
editor.addEventListener(event, handler as EventListener);
});
return () => {
Object.entries(handlers).forEach(([event, handler]) => {
editor.removeEventListener(event, handler as EventListener);
});
};
}, []);
return {
position,
suggestions: suggestionsRef.current,
selectedIndex,
isVisible: isVisibleRef.current,
searchQuery: searchQueryRef.current,
handleItemSelect: handleAutocomplete,
};
}
+73
View File
@@ -0,0 +1,73 @@
# MemoEditor Architecture
## Overview
MemoEditor uses a three-layer architecture for better separation of concerns and testability.
## Architecture
```
┌─────────────────────────────────────────┐
│ Presentation Layer (Components) │
│ - EditorToolbar, EditorContent, etc. │
└─────────────────┬───────────────────────┘
┌─────────────────▼───────────────────────┐
│ State Layer (Reducer + Context) │
│ - state/, useEditorContext() │
└─────────────────┬───────────────────────┘
┌─────────────────▼───────────────────────┐
│ Service Layer (Business Logic) │
│ - services/ (pure functions) │
└─────────────────────────────────────────┘
```
## Directory Structure
```
MemoEditor/
├── state/ # State management (reducer, actions, context)
├── services/ # Business logic (pure functions)
├── components/ # UI components
├── hooks/ # React hooks (utilities)
├── Editor/ # Core editor component
├── Toolbar/ # Toolbar components
├── constants.ts
└── types/
```
## Key Concepts
### State Management
Uses `useReducer` + Context for predictable state transitions. All state changes go through action creators.
### Services
Pure TypeScript functions containing business logic. No React hooks, easy to test.
### Components
Thin presentation components that dispatch actions and render UI.
## Usage
```typescript
import MemoEditor from "@/components/MemoEditor";
<MemoEditor
memoName="memos/123"
onConfirm={(name) => console.log('Saved:', name)}
onCancel={() => console.log('Cancelled')}
/>
```
## Testing
Services are pure functions - easy to unit test without React.
```typescript
const state = mockEditorState();
const result = await memoService.save(state, { memoName: 'memos/123' });
```
@@ -0,0 +1,256 @@
import { uniqBy } from "lodash-es";
import {
FileIcon,
ImageIcon,
LinkIcon,
LoaderIcon,
type LucideIcon,
MapPinIcon,
Maximize2Icon,
MicIcon,
MoreHorizontalIcon,
PlusIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { LinkMemoDialog, LocationDialog } from "@/components/MemoMetadata";
import type { MapPoint } from "@/components/map/types";
import { useReverseGeocoding } from "@/components/map/useReverseGeocoding";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
useDropdownMenuSubHoverDelay,
} from "@/components/ui/dropdown-menu";
import { useDebouncedEffect } from "@/hooks";
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import { useFileUpload, useLinkMemo, useLocation } from "../hooks";
import { useEditorContext } from "../state";
import type { InsertMenuProps } from "../types";
import type { LocalFile } from "../types/attachment";
const InsertMenu = (props: InsertMenuProps) => {
const t = useTranslate();
const { state, actions, dispatch } = useEditorContext();
const { location: initialLocation, onLocationChange, onToggleFocusMode, isUploading: isUploadingProp } = props;
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
const [moreSubmenuOpen, setMoreSubmenuOpen] = useState(false);
const { handleTriggerEnter, handleTriggerLeave, handleContentEnter, handleContentLeave } = useDropdownMenuSubHoverDelay(
150,
setMoreSubmenuOpen,
);
const { fileInputRef, selectingFlag, handleFileInputChange, handleUploadClick } = useFileUpload((newFiles: LocalFile[]) => {
newFiles.forEach((file) => dispatch(actions.addLocalFile(file)));
});
const linkMemo = useLinkMemo({
isOpen: linkDialogOpen,
currentMemoName: props.memoName,
existingRelations: state.metadata.relations,
onAddRelation: (relation: MemoRelation) => {
dispatch(actions.setMetadata({ relations: uniqBy([...state.metadata.relations, relation], (r) => r.relatedMemo?.name) }));
setLinkDialogOpen(false);
},
});
const location = useLocation(props.location);
const {
state: locationState,
locationInitialized,
handlePositionChange: handleLocationPositionChange,
getLocation,
reset: locationReset,
updateCoordinate,
setPlaceholder,
} = location;
const [debouncedPosition, setDebouncedPosition] = useState<MapPoint | undefined>(undefined);
useDebouncedEffect(
() => {
setDebouncedPosition(locationState.position);
},
1000,
[locationState.position],
);
const { data: displayName } = useReverseGeocoding(debouncedPosition?.lat, debouncedPosition?.lng);
useEffect(() => {
if (displayName) {
setPlaceholder(displayName);
}
}, [displayName, setPlaceholder]);
const isUploading = selectingFlag || isUploadingProp;
const handleOpenLinkDialog = useCallback(() => {
setLinkDialogOpen(true);
}, []);
const handleLocationClick = useCallback(() => {
setLocationDialogOpen(true);
if (!initialLocation && !locationInitialized) {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
handleLocationPositionChange({ lat: position.coords.latitude, lng: position.coords.longitude });
},
(error) => {
console.error("Geolocation error:", error);
},
);
}
}
}, [initialLocation, locationInitialized, handleLocationPositionChange]);
const handleLocationConfirm = useCallback(() => {
const newLocation = getLocation();
if (newLocation) {
onLocationChange(newLocation);
setLocationDialogOpen(false);
}
}, [getLocation, onLocationChange]);
const handleLocationCancel = useCallback(() => {
locationReset();
setLocationDialogOpen(false);
}, [locationReset]);
const handleToggleFocusMode = useCallback(() => {
onToggleFocusMode?.();
setMoreSubmenuOpen(false);
}, [onToggleFocusMode]);
const handleMediaUploadClick = useCallback(() => {
handleUploadClick("image/*,video/*");
}, [handleUploadClick]);
const handleFileUploadClick = useCallback(() => {
handleUploadClick();
}, [handleUploadClick]);
const menuItems = useMemo(
() =>
[
{
key: "upload-media",
label: t("attachment-library.tabs.media"),
icon: ImageIcon,
onClick: handleMediaUploadClick,
},
{
key: "record-audio",
label: t("editor.audio-recorder.trigger"),
icon: MicIcon,
onClick: () => props.onAudioRecorderClick?.(),
},
{
key: "upload-file",
label: t("common.file"),
icon: FileIcon,
onClick: handleFileUploadClick,
},
{
key: "link",
label: t("editor.insert-menu.link-memo"),
icon: LinkIcon,
onClick: handleOpenLinkDialog,
},
{
key: "location",
label: t("editor.insert-menu.add-location"),
icon: MapPinIcon,
onClick: handleLocationClick,
},
] satisfies Array<{ key: string; label: string; icon: LucideIcon; onClick: () => void }>,
[handleFileUploadClick, handleLocationClick, handleMediaUploadClick, handleOpenLinkDialog, props, t],
);
return (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="shadow-none" disabled={isUploading}>
{isUploading ? <LoaderIcon className="size-4 animate-spin" /> : <PlusIcon className="size-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{menuItems.slice(0, 3).map((item) => (
<DropdownMenuItem key={item.key} onClick={item.onClick}>
<item.icon className="w-4 h-4" />
{item.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{menuItems.slice(3).map((item) => (
<DropdownMenuItem key={item.key} onClick={item.onClick}>
<item.icon className="w-4 h-4" />
{item.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{/* View submenu with Focus Mode */}
<DropdownMenuSub open={moreSubmenuOpen} onOpenChange={setMoreSubmenuOpen}>
<DropdownMenuSubTrigger onPointerEnter={handleTriggerEnter} onPointerLeave={handleTriggerLeave}>
<MoreHorizontalIcon className="w-4 h-4" />
{t("common.more")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent onPointerEnter={handleContentEnter} onPointerLeave={handleContentLeave}>
<DropdownMenuItem onClick={handleToggleFocusMode}>
<Maximize2Icon className="w-4 h-4" />
{t("editor.focus-mode")}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<div className="px-2 py-1 text-xs text-muted-foreground opacity-80">{t("editor.slash-commands")}</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Hidden file input */}
<input
className="hidden"
ref={fileInputRef}
disabled={isUploading}
onChange={handleFileInputChange}
type="file"
multiple={true}
accept="*"
/>
<LinkMemoDialog
open={linkDialogOpen}
onOpenChange={setLinkDialogOpen}
searchText={linkMemo.searchText}
onSearchChange={linkMemo.setSearchText}
filteredMemos={linkMemo.filteredMemos}
isFetching={linkMemo.isFetching}
onSelectMemo={linkMemo.addMemoRelation}
isAlreadyLinked={linkMemo.isAlreadyLinked}
/>
<LocationDialog
open={locationDialogOpen}
onOpenChange={setLocationDialogOpen}
state={locationState}
onPositionChange={handleLocationPositionChange}
onUpdateCoordinate={updateCoordinate}
onPlaceholderChange={setPlaceholder}
onCancel={handleLocationCancel}
onConfirm={handleLocationConfirm}
/>
</>
);
};
export default InsertMenu;
@@ -0,0 +1,42 @@
import { CheckIcon, ChevronDownIcon } from "lucide-react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import VisibilityIcon from "@/components/VisibilityIcon";
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import type { VisibilitySelectorProps } from "../types";
const VisibilitySelector = (props: VisibilitySelectorProps) => {
const { value, onChange } = props;
const t = useTranslate();
const visibilityOptions = [
{ value: Visibility.PRIVATE, label: t("memo.visibility.private") },
{ value: Visibility.PROTECTED, label: t("memo.visibility.protected") },
{ value: Visibility.PUBLIC, label: t("memo.visibility.public") },
] as const;
const currentLabel = visibilityOptions.find((option) => option.value === value)?.label || "";
return (
<DropdownMenu onOpenChange={props.onOpenChange}>
<DropdownMenuTrigger asChild>
<button className="inline-flex items-center px-2 text-sm text-muted-foreground opacity-80 hover:opacity-100 transition-colors">
<VisibilityIcon visibility={value} className="opacity-60 mr-1.5" />
<span>{currentLabel}</span>
<ChevronDownIcon className="ml-0.5 w-4 h-4 opacity-60" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{visibilityOptions.map((option) => (
<DropdownMenuItem key={option.value} className="cursor-pointer gap-2" onClick={() => onChange(option.value)}>
<VisibilityIcon visibility={option.value} />
<span className="flex-1">{option.label}</span>
{value === option.value && <CheckIcon className="w-4 h-4 text-primary" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};
export default VisibilitySelector;
@@ -0,0 +1,3 @@
// Toolbar components for MemoEditor
export { default as InsertMenu } from "./InsertMenu";
export { default as VisibilitySelector } from "./VisibilitySelector";
@@ -0,0 +1,100 @@
import { AudioWaveformIcon, LoaderCircleIcon, SquareIcon, XIcon } from "lucide-react";
import type { FC } from "react";
import { formatAudioTime } from "@/components/MemoMetadata/Attachment/attachmentHelpers";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { useAudioWaveform } from "../hooks/useAudioWaveform";
import type { AudioRecorderPanelProps } from "../types/components";
import { VoiceWaveform } from "./VoiceWaveform";
export const AudioRecorderPanel: FC<AudioRecorderPanelProps> = ({
audioRecorder,
mediaStream,
onStop,
onCancel,
onTranscribe,
canTranscribe = false,
isTranscribing = false,
}) => {
const t = useTranslate();
const { status, elapsedSeconds } = audioRecorder;
const isRequestingPermission = status === "requesting_permission";
const isRecording = status === "recording";
const isTranscribeDisabled = isRequestingPermission || isTranscribing;
const waveformLevels = useAudioWaveform(mediaStream, isRecording && mediaStream !== null);
const srStatusText = isTranscribing
? t("editor.audio-recorder.transcribing")
: isRequestingPermission
? t("editor.audio-recorder.requesting-permission")
: t("editor.audio-recorder.recording");
return (
<div
className={cn(
"flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-muted/30 px-2.5 py-1.5",
"dark:bg-muted/20",
)}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
{isRequestingPermission || isTranscribing ? (
<LoaderCircleIcon className="size-3.5 shrink-0 animate-spin text-muted-foreground" aria-hidden />
) : null}
<span className="sr-only">{srStatusText}</span>
<VoiceWaveform levels={waveformLevels} className="max-w-[200px] overflow-hidden" />
<span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
{isTranscribing ? t("editor.audio-recorder.transcribing") : formatAudioTime(elapsedSeconds)}
</span>
</div>
<div className="flex shrink-0 items-center gap-1 border-l border-border/60 pl-2">
<Button
type="button"
variant="ghost"
size="icon"
className="rounded-full"
onClick={onCancel}
disabled={isTranscribing}
aria-label={t("common.cancel")}
>
<XIcon className="size-4" />
</Button>
{canTranscribe && (
<Tooltip>
<TooltipTrigger asChild>
<span className="-ml-2 inline-flex">
<Button
type="button"
variant="ghost"
size="icon"
className="rounded-full"
onClick={onTranscribe}
disabled={isTranscribeDisabled}
aria-label={t("editor.audio-recorder.transcribe")}
>
<AudioWaveformIcon className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="top">
<p>{t("editor.audio-recorder.transcribe")}</p>
</TooltipContent>
</Tooltip>
)}
<Button
type="button"
variant="destructive"
size="icon"
className="rounded-full"
onClick={onStop}
disabled={isRequestingPermission || isTranscribing}
aria-label={t("editor.audio-recorder.stop")}
>
<SquareIcon className="size-4" />
</Button>
</div>
</div>
);
};
@@ -0,0 +1,77 @@
import { forwardRef } from "react";
import Editor, { type EditorRefActions } from "../Editor";
import { useBlobUrls, useDragAndDrop } from "../hooks";
import { useEditorContext } from "../state";
import type { EditorContentProps } from "../types";
import type { LocalFile } from "../types/attachment";
export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({ placeholder }, ref) => {
const { state, actions, dispatch } = useEditorContext();
const { createBlobUrl } = useBlobUrls();
const { dragHandlers } = useDragAndDrop((files: FileList) => {
const localFiles: LocalFile[] = Array.from(files).map((file) => ({
file,
previewUrl: createBlobUrl(file),
origin: "upload",
}));
localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile)));
});
const handleCompositionStart = () => {
dispatch(actions.setComposing(true));
};
const handleCompositionEnd = () => {
dispatch(actions.setComposing(false));
};
const handleContentChange = (content: string) => {
dispatch(actions.updateContent(content));
};
const handlePaste = (event: React.ClipboardEvent<Element>) => {
const clipboard = event.clipboardData;
if (!clipboard) return;
const files: File[] = [];
if (clipboard.items && clipboard.items.length > 0) {
for (const item of Array.from(clipboard.items)) {
if (item.kind !== "file") continue;
const file = item.getAsFile();
if (file) files.push(file);
}
} else if (clipboard.files && clipboard.files.length > 0) {
files.push(...Array.from(clipboard.files));
}
if (files.length === 0) return;
const localFiles: LocalFile[] = files.map((file) => ({
file,
previewUrl: createBlobUrl(file),
origin: "upload",
}));
localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile)));
event.preventDefault();
};
return (
<div className="w-full flex flex-col flex-1" {...dragHandlers}>
<Editor
ref={ref}
className="memo-editor-content"
initialContent={state.content}
placeholder={placeholder || ""}
isFocusMode={state.ui.isFocusMode}
isInIME={state.ui.isComposing}
onContentChange={handleContentChange}
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
/>
</div>
);
});
EditorContent.displayName = "EditorContent";
@@ -0,0 +1,30 @@
import type { FC } from "react";
import { AttachmentListEditor, LocationDisplayEditor, RelationListEditor } from "@/components/MemoMetadata";
import { useEditorContext } from "../state";
import type { EditorMetadataProps } from "../types";
export const EditorMetadata: FC<EditorMetadataProps> = ({ memoName }) => {
const { state, actions, dispatch } = useEditorContext();
return (
<div className="w-full flex flex-col gap-2">
<AttachmentListEditor
attachments={state.metadata.attachments}
localFiles={state.localFiles}
onAttachmentsChange={(attachments) => dispatch(actions.setMetadata({ attachments }))}
onLocalFilesChange={(localFiles) => dispatch(actions.setLocalFiles(localFiles))}
onRemoveLocalFile={(previewUrl) => dispatch(actions.removeLocalFile(previewUrl))}
/>
<RelationListEditor
relations={state.metadata.relations}
onRelationsChange={(relations) => dispatch(actions.setMetadata({ relations }))}
memoName={memoName}
/>
{state.metadata.location && (
<LocationDisplayEditor location={state.metadata.location} onRemove={() => dispatch(actions.setMetadata({ location: undefined }))} />
)}
</div>
);
};
@@ -0,0 +1,57 @@
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import { useTranslate } from "@/utils/i18n";
import { validationService } from "../services";
import { useEditorContext } from "../state";
import InsertMenu from "../Toolbar/InsertMenu";
import VisibilitySelector from "../Toolbar/VisibilitySelector";
import type { EditorToolbarProps } from "../types";
export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoName, onAudioRecorderClick }) => {
const t = useTranslate();
const { state, actions, dispatch } = useEditorContext();
const { valid } = validationService.canSave(state);
const isSaving = state.ui.isLoading.saving;
const handleLocationChange = (location: typeof state.metadata.location) => {
dispatch(actions.setMetadata({ location }));
};
const handleToggleFocusMode = () => {
dispatch(actions.toggleFocusMode());
};
const handleVisibilityChange = (visibility: typeof state.metadata.visibility) => {
dispatch(actions.setMetadata({ visibility }));
};
return (
<div className="w-full flex flex-row justify-between items-center mb-2">
<div className="flex flex-row justify-start items-center">
<InsertMenu
isUploading={state.ui.isLoading.uploading}
location={state.metadata.location}
onLocationChange={handleLocationChange}
onToggleFocusMode={handleToggleFocusMode}
memoName={memoName}
onAudioRecorderClick={onAudioRecorderClick}
/>
</div>
<div className="flex flex-row justify-end items-center gap-2">
<VisibilitySelector value={state.metadata.visibility} onChange={handleVisibilityChange} />
{onCancel && (
<Button variant="ghost" onClick={onCancel} disabled={isSaving}>
{t("common.cancel")}
</Button>
)}
<Button onClick={onSave} disabled={!valid || isSaving}>
{isSaving ? t("editor.saving") : t("editor.save")}
</Button>
</div>
</div>
);
};
@@ -0,0 +1,20 @@
import { Minimize2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { FOCUS_MODE_STYLES } from "../constants";
import type { FocusModeExitButtonProps, FocusModeOverlayProps } from "../types";
export function FocusModeOverlay({ isActive, onToggle }: FocusModeOverlayProps) {
if (!isActive) return null;
return <button type="button" className={FOCUS_MODE_STYLES.backdrop} onClick={onToggle} aria-label="Exit focus mode" />;
}
export function FocusModeExitButton({ isActive, onToggle, title }: FocusModeExitButtonProps) {
if (!isActive) return null;
return (
<Button variant="ghost" size="icon" className={FOCUS_MODE_STYLES.exitButton} onClick={onToggle} title={title}>
<Minimize2Icon className="w-4 h-4" />
</Button>
);
}
@@ -0,0 +1,89 @@
import { type FC, useRef, useState } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useTranslate } from "@/utils/i18n";
import { useEditorContext } from "../state";
const DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ss";
function formatDate(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function parseDate(value: string): Date | undefined {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
if (!match) return undefined;
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4]), Number(match[5]), Number(match[6]));
return Number.isNaN(date.getTime()) ? undefined : date;
}
const TimestampInput: FC<{
label: string;
date: Date | undefined;
onChange: (date: Date) => void;
}> = ({ label, date, onChange }) => {
const initialValue = useRef(date ? formatDate(date) : "");
const [value, setValue] = useState(initialValue.current);
const [invalid, setInvalid] = useState(false);
const handleBlur = () => {
const parsed = parseDate(value);
if (parsed) {
setInvalid(false);
onChange(parsed);
} else {
setInvalid(true);
}
};
return (
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
{label}
{value !== initialValue.current && <span className="text-primary ml-0.5">*</span>}
</label>
<input
type="text"
className="block w-full rounded-md border border-border bg-background px-2 py-1 text-sm font-mono data-[invalid=true]:border-destructive"
data-invalid={invalid}
placeholder={DATETIME_FORMAT}
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={handleBlur}
/>
</div>
);
};
export const TimestampPopover: FC = () => {
const t = useTranslate();
const { state, actions, dispatch } = useEditorContext();
const { createTime, updateTime } = state.timestamps;
if (!createTime) return null;
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="w-auto text-sm text-muted-foreground text-left hover:text-foreground transition-colors cursor-pointer"
>
{formatDate(createTime)}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-2 pt-1 space-y-1">
<TimestampInput
label={t("common.created-at")}
date={createTime}
onChange={(d) => dispatch(actions.setTimestamps({ createTime: d }))}
/>
<TimestampInput
label={t("common.last-updated-at")}
date={updateTime}
onChange={(d) => dispatch(actions.setTimestamps({ updateTime: d }))}
/>
</PopoverContent>
</Popover>
);
};
@@ -0,0 +1,34 @@
import type { FC } from "react";
import { cn } from "@/lib/utils";
/** Max half-height of each bar (px); bars are centered vertically. */
const MAX_BAR_PX = 11;
const MIN_BAR_PX = 2;
type VoiceWaveformProps = {
levels: number[];
className?: string;
};
/**
* Tight-packed vertical bars (rounded caps): fixed bar width + minimal gap — no `flex-1` columns
* so bars stay visually dense like compact voice-memo waveforms.
*/
export const VoiceWaveform: FC<VoiceWaveformProps> = ({ levels, className }) => {
return (
<div className={cn("flex h-5 w-max max-w-full shrink-0 items-center gap-px", className)} aria-hidden>
{levels.map((level, i) => {
const h = Math.max(MIN_BAR_PX, level * MAX_BAR_PX);
const centerDistance = Math.abs(i - (levels.length - 1) / 2) / (levels.length / 2);
const opacity = 0.35 + (1 - centerDistance) * 0.35;
return (
<span
key={`bar-${i}`}
className="w-[2px] shrink-0 rounded-full bg-muted-foreground transition-[height,opacity] duration-75 ease-out"
style={{ height: `${h}px`, opacity }}
/>
);
})}
</div>
);
};
@@ -0,0 +1,8 @@
// UI components for MemoEditor
export * from "./AudioRecorderPanel";
export * from "./EditorContent";
export * from "./EditorMetadata";
export * from "./EditorToolbar";
export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay";
export { TimestampPopover } from "./TimestampPopover";
@@ -0,0 +1,14 @@
export const FOCUS_MODE_STYLES = {
backdrop: "fixed inset-0 bg-black/20 backdrop-blur-sm z-40",
container: {
base: "fixed z-50 w-auto max-w-5xl mx-auto shadow-2xl border-border h-auto overflow-y-auto",
spacing: "top-2 left-2 right-2 bottom-2 sm:top-4 sm:left-4 sm:right-4 sm:bottom-4 md:top-8 md:left-8 md:right-8 md:bottom-8",
},
transition: "transition-all duration-300 ease-in-out",
exitButton: "absolute top-2 right-2 z-10 opacity-60 hover:opacity-100",
} as const;
export const EDITOR_HEIGHT = {
// Max height for normal mode - focus mode uses flex-1 to grow dynamically
normal: "max-h-[50vh]",
} as const;
@@ -0,0 +1,12 @@
// Custom hooks for MemoEditor (internal use only)
export { useAudioRecorder } from "./useAudioRecorder";
export { useAudioWaveform } from "./useAudioWaveform";
export { useAutoSave } from "./useAutoSave";
export { useBlobUrls } from "./useBlobUrls";
export { useDragAndDrop } from "./useDragAndDrop";
export { useFileUpload } from "./useFileUpload";
export { useFocusMode } from "./useFocusMode";
export { useKeyboard } from "./useKeyboard";
export { useLinkMemo } from "./useLinkMemo";
export { useLocation } from "./useLocation";
export { useMemoInit } from "./useMemoInit";
@@ -0,0 +1,241 @@
import { useEffect, useRef, useState } from "react";
import type { LocalFile } from "../types/attachment";
import { useBlobUrls } from "./useBlobUrls";
const FALLBACK_AUDIO_MIME_TYPE = "audio/webm";
export type AudioRecordingCompleteMode = "attach" | "transcribe";
interface AudioRecorderActions {
setAudioRecorderSupport: (value: boolean) => void;
setAudioRecorderPermission: (value: "unknown" | "granted" | "denied") => void;
setAudioRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "error" | "unsupported") => void;
setAudioRecorderElapsed: (value: number) => void;
setAudioRecorderError: (value?: string) => void;
onRecordingComplete: (localFile: LocalFile, mode: AudioRecordingCompleteMode) => void;
onRecordingEmpty?: (mode: AudioRecordingCompleteMode) => void;
}
const AUDIO_MIME_TYPE_CANDIDATES = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"] as const;
function getSupportedAudioMimeType(): string | undefined {
if (typeof window === "undefined" || typeof MediaRecorder === "undefined") {
return undefined;
}
for (const candidate of AUDIO_MIME_TYPE_CANDIDATES) {
if (MediaRecorder.isTypeSupported(candidate)) {
return candidate;
}
}
return undefined;
}
function getFileExtension(mimeType: string): string {
if (mimeType.includes("ogg")) return "ogg";
if (mimeType.includes("mp4")) return "m4a";
return "webm";
}
function createRecordedFile(blob: Blob, mimeType: string): File {
const extension = getFileExtension(mimeType);
const now = new Date();
const datePart = [now.getFullYear(), String(now.getMonth() + 1).padStart(2, "0"), String(now.getDate()).padStart(2, "0")].join("");
const timePart = [
String(now.getHours()).padStart(2, "0"),
String(now.getMinutes()).padStart(2, "0"),
String(now.getSeconds()).padStart(2, "0"),
].join("");
return new File([blob], `voice-note-${datePart}-${timePart}.${extension}`, { type: mimeType });
}
export const useAudioRecorder = (actions: AudioRecorderActions) => {
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const mediaStreamRef = useRef<MediaStream | null>(null);
const [recordingStream, setRecordingStream] = useState<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startedAtRef = useRef<number | null>(null);
const elapsedTimerRef = useRef<number | null>(null);
const recorderMimeTypeRef = useRef<string>(FALLBACK_AUDIO_MIME_TYPE);
const completionModeRef = useRef<AudioRecordingCompleteMode>("attach");
const startRequestIdRef = useRef(0);
const { createBlobUrl } = useBlobUrls();
const cleanupTimer = () => {
if (elapsedTimerRef.current !== null) {
window.clearInterval(elapsedTimerRef.current);
elapsedTimerRef.current = null;
}
};
const cleanupStream = () => {
mediaStreamRef.current?.getTracks().forEach((track) => track.stop());
mediaStreamRef.current = null;
setRecordingStream(null);
};
const resetRecorderRefs = () => {
cleanupTimer();
cleanupStream();
mediaRecorderRef.current = null;
chunksRef.current = [];
startedAtRef.current = null;
};
useEffect(() => {
const isSupported =
typeof window !== "undefined" &&
typeof navigator !== "undefined" &&
typeof navigator.mediaDevices?.getUserMedia === "function" &&
typeof MediaRecorder !== "undefined";
actions.setAudioRecorderSupport(isSupported);
if (!isSupported) {
actions.setAudioRecorderStatus("unsupported");
actions.setAudioRecorderError("Audio recording is not supported in this browser.");
return;
}
actions.setAudioRecorderStatus("idle");
actions.setAudioRecorderError(undefined);
return () => {
resetRecorderRefs();
};
}, [actions]);
const startRecording = async () => {
const requestId = startRequestIdRef.current + 1;
startRequestIdRef.current = requestId;
if (
typeof navigator === "undefined" ||
typeof navigator.mediaDevices?.getUserMedia !== "function" ||
typeof MediaRecorder === "undefined"
) {
actions.setAudioRecorderSupport(false);
actions.setAudioRecorderStatus("unsupported");
actions.setAudioRecorderError("Audio recording is not supported in this browser.");
return;
}
actions.setAudioRecorderError(undefined);
actions.setAudioRecorderStatus("requesting_permission");
actions.setAudioRecorderElapsed(0);
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (startRequestIdRef.current !== requestId) {
stream.getTracks().forEach((track) => track.stop());
return;
}
const mimeType = getSupportedAudioMimeType() ?? FALLBACK_AUDIO_MIME_TYPE;
const mediaRecorder = new MediaRecorder(stream, getSupportedAudioMimeType() ? { mimeType } : undefined);
recorderMimeTypeRef.current = mimeType;
mediaStreamRef.current = stream;
setRecordingStream(stream);
mediaRecorderRef.current = mediaRecorder;
chunksRef.current = [];
mediaRecorder.addEventListener("dataavailable", (event) => {
if (startRequestIdRef.current !== requestId) {
return;
}
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
});
mediaRecorder.addEventListener("stop", () => {
if (startRequestIdRef.current !== requestId) {
return;
}
const durationSeconds = startedAtRef.current ? Math.max(0, Math.round((Date.now() - startedAtRef.current) / 1000)) : 0;
const blob = new Blob(chunksRef.current, { type: recorderMimeTypeRef.current });
const completionMode = completionModeRef.current;
completionModeRef.current = "attach";
if (blob.size === 0) {
actions.setAudioRecorderElapsed(0);
actions.setAudioRecorderError(undefined);
actions.setAudioRecorderStatus("idle");
actions.onRecordingEmpty?.(completionMode);
resetRecorderRefs();
return;
}
const file = createRecordedFile(blob, recorderMimeTypeRef.current);
const previewUrl = createBlobUrl(file);
actions.onRecordingComplete(
{
file,
previewUrl,
origin: "audio_recording",
audioMeta: {
durationSeconds,
},
},
completionMode,
);
actions.setAudioRecorderElapsed(0);
actions.setAudioRecorderError(undefined);
actions.setAudioRecorderStatus("idle");
resetRecorderRefs();
});
mediaRecorder.start();
startedAtRef.current = Date.now();
actions.setAudioRecorderPermission("granted");
actions.setAudioRecorderStatus("recording");
elapsedTimerRef.current = window.setInterval(() => {
if (startedAtRef.current) {
actions.setAudioRecorderElapsed(Math.max(0, Math.floor((Date.now() - startedAtRef.current) / 1000)));
}
}, 250);
} catch (error) {
if (startRequestIdRef.current !== requestId) {
return;
}
const permissionDenied =
error instanceof DOMException && (error.name === "NotAllowedError" || error.name === "PermissionDeniedError");
actions.setAudioRecorderPermission(permissionDenied ? "denied" : "unknown");
actions.setAudioRecorderStatus("error");
actions.setAudioRecorderError(permissionDenied ? "Microphone permission was denied." : "Failed to start audio recording.");
resetRecorderRefs();
}
};
const stopRecording = (mode: AudioRecordingCompleteMode = "attach") => {
if (!mediaRecorderRef.current || mediaRecorderRef.current.state === "inactive") {
return false;
}
completionModeRef.current = mode;
cleanupTimer();
mediaRecorderRef.current.stop();
return true;
};
const resetRecording = () => {
startRequestIdRef.current += 1;
completionModeRef.current = "attach";
resetRecorderRefs();
actions.setAudioRecorderElapsed(0);
actions.setAudioRecorderError(undefined);
actions.setAudioRecorderStatus("idle");
};
return {
startRecording,
stopRecording,
resetRecording,
recordingStream,
};
};
@@ -0,0 +1,99 @@
import { useEffect, useState } from "react";
const BAR_COUNT = 40;
const IDLE_LEVEL = 0.08;
const UPDATE_MS = 33;
const CENTER_EMPHASIS = 0.35;
const BOOST_OFFSET = 0.04;
const BOOST_CURVE = 0.55;
const BOOST_GAIN = 2.8;
const createIdleLevels = (): number[] => Array.from({ length: BAR_COUNT }, () => IDLE_LEVEL);
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
function computeLevels(dataArray: Uint8Array, bufferLength: number): number[] {
const sampled: number[] = [];
for (let i = 0; i < BAR_COUNT; i++) {
const start = Math.floor((i / BAR_COUNT) * bufferLength);
const end = Math.floor(((i + 1) / BAR_COUNT) * bufferLength);
let sum = 0;
for (let j = start; j < end; j++) {
sum += dataArray[j];
}
const span = Math.max(1, end - start);
const avg = sum / (255 * span);
const boosted = Math.min(1, (avg + BOOST_OFFSET) ** BOOST_CURVE * BOOST_GAIN);
sampled.push(Math.max(IDLE_LEVEL, boosted));
}
const mirrored: number[] = [];
for (let i = 0; i < BAR_COUNT; i++) {
const j = BAR_COUNT - 1 - i;
const v = (sampled[i] + sampled[j]) / 2;
const centerDistance = Math.abs(i - (BAR_COUNT - 1) / 2) / (BAR_COUNT / 2);
const envelope = 1 - centerDistance * CENTER_EMPHASIS;
mirrored.push(Math.max(IDLE_LEVEL, clamp01(v * envelope)));
}
for (let i = 1; i < BAR_COUNT - 1; i++) {
mirrored[i] = Math.max(IDLE_LEVEL, (mirrored[i - 1] + mirrored[i] * 2 + mirrored[i + 1]) / 4);
}
return mirrored;
}
/**
* Derives normalized bar levels (01) from a microphone MediaStream for live waveform UI.
*/
export function useAudioWaveform(stream: MediaStream | null, enabled: boolean): number[] {
const [levels, setLevels] = useState<number[]>(createIdleLevels);
useEffect(() => {
if (!enabled || !stream) {
setLevels(createIdleLevels());
return;
}
const audioContext = new AudioContext();
const source = audioContext.createMediaStreamSource(stream);
const analyser = audioContext.createAnalyser();
analyser.fftSize = 512;
analyser.minDecibels = -72;
analyser.maxDecibels = -8;
analyser.smoothingTimeConstant = 0.72;
source.connect(analyser);
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
let rafId = 0;
let lastEmit = 0;
let first = true;
const tick = (now: number) => {
analyser.getByteFrequencyData(dataArray);
if (first || now - lastEmit >= UPDATE_MS) {
first = false;
lastEmit = now;
setLevels(computeLevels(dataArray, bufferLength));
}
rafId = requestAnimationFrame(tick);
};
const start = async () => {
await audioContext.resume();
rafId = requestAnimationFrame(tick);
};
void start();
return () => {
cancelAnimationFrame(rafId);
source.disconnect();
analyser.disconnect();
void audioContext.close();
};
}, [stream, enabled]);
return levels;
}
@@ -0,0 +1,58 @@
import { useCallback, useEffect, useRef } from "react";
import { cacheService } from "../services";
export const useAutoSave = (content: string, username: string, cacheKey: string | undefined, enabled = true) => {
const latestContentRef = useRef(content);
const discardedContentRef = useRef<string | undefined>(undefined);
useEffect(() => {
latestContentRef.current = content;
if (discardedContentRef.current !== undefined && discardedContentRef.current !== content) {
discardedContentRef.current = undefined;
}
}, [content]);
useEffect(() => {
if (!enabled) return;
const key = cacheService.key(username, cacheKey);
cacheService.save(key, content);
}, [content, username, cacheKey, enabled]);
useEffect(() => {
if (!enabled) return;
const key = cacheService.key(username, cacheKey);
const flushDraft = () => {
if (discardedContentRef.current === latestContentRef.current) {
return;
}
cacheService.saveNow(key, latestContentRef.current);
};
const handleVisibilityChange = () => {
if (document.visibilityState === "hidden") {
flushDraft();
}
};
window.addEventListener("pagehide", flushDraft);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
// Flush on unmount (e.g. editor closes) to ensure the draft is persisted
// before the component is torn down — distinct from the visibility flush above.
flushDraft();
window.removeEventListener("pagehide", flushDraft);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [username, cacheKey, enabled]);
const discardDraft = useCallback(() => {
const key = cacheService.key(username, cacheKey);
discardedContentRef.current = latestContentRef.current;
cacheService.clear(key);
}, [username, cacheKey]);
return { discardDraft };
};

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