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
@@ -0,0 +1,87 @@
import SearchBar from "@/components/SearchBar";
import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils";
import type { StatisticsData } from "@/types/statistics";
import StatisticsView from "../StatisticsView";
import ShortcutsSection from "./ShortcutsSection";
import TagsSection from "./TagsSection";
export type MemoExplorerContext = "home" | "explore" | "archived" | "profile";
export interface MemoExplorerFeatures {
search?: boolean;
statistics?: boolean;
shortcuts?: boolean;
tags?: boolean;
}
interface Props {
className?: string;
context?: MemoExplorerContext;
features?: MemoExplorerFeatures;
statisticsData: StatisticsData;
tagCount: Record<string, number>;
}
const getDefaultFeatures = (context: MemoExplorerContext): MemoExplorerFeatures => {
switch (context) {
case "explore":
return {
search: true,
statistics: true,
shortcuts: false, // Global explore doesn't use shortcuts
tags: true,
};
case "archived":
return {
search: true,
statistics: true,
shortcuts: false, // Archived doesn't typically use shortcuts
tags: true,
};
case "profile":
return {
search: true,
statistics: true,
shortcuts: false, // Profile view doesn't use shortcuts
tags: true,
};
case "home":
default:
return {
search: true,
statistics: true,
shortcuts: true,
tags: true,
};
}
};
const MemoExplorer = (props: Props) => {
const { className, context = "home", features: featureOverrides = {}, statisticsData, tagCount } = props;
const currentUser = useCurrentUser();
// Merge default features with overrides
const features = {
...getDefaultFeatures(context),
...featureOverrides,
};
return (
<aside
className={cn(
"relative w-full h-full overflow-auto flex flex-col justify-start items-start bg-background text-sidebar-foreground",
className,
)}
>
{features.search && <SearchBar />}
<div className="mt-1 px-1 w-full">
{features.statistics && <StatisticsView statisticsData={statisticsData} />}
{features.shortcuts && currentUser && <ShortcutsSection />}
{features.tags && <TagsSection readonly={context === "explore"} tagCount={tagCount} />}
</div>
</aside>
);
};
export default MemoExplorer;
@@ -0,0 +1,42 @@
import { MenuIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import type { StatisticsData } from "@/types/statistics";
import MemoExplorer, { MemoExplorerContext, MemoExplorerFeatures } from "./MemoExplorer";
interface Props {
context?: MemoExplorerContext;
features?: MemoExplorerFeatures;
statisticsData: StatisticsData;
tagCount: Record<string, number>;
}
const MemoExplorerDrawer = (props: Props) => {
const { context, features, statisticsData, tagCount } = 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">
<MenuIcon className="size-5 text-foreground" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-80 max-w-full bg-background">
<SheetHeader>
<SheetTitle />
</SheetHeader>
<MemoExplorer className="px-4" context={context} features={features} statisticsData={statisticsData} tagCount={tagCount} />
</SheetContent>
</Sheet>
);
};
export default MemoExplorerDrawer;
@@ -0,0 +1,118 @@
import { Edit3Icon, MoreVerticalIcon, PlusIcon, TrashIcon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { shortcutServiceClient } from "@/connect";
import { useAuth } from "@/contexts/AuthContext";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { cn } from "@/lib/utils";
import { ROUTES } from "@/router";
import { Shortcut } from "@/types/proto/api/v1/shortcut_service_pb";
import { useTranslate } from "@/utils/i18n";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)$/u;
// Helper function to extract shortcut ID from resource name
// Format: users/{username}/shortcuts/{shortcut}
const getShortcutId = (name: string): string => {
const parts = name.split("/");
return parts.length === 4 ? parts[3] : "";
};
function ShortcutsSection() {
const t = useTranslate();
const navigate = useNavigate();
const { shortcuts, refetchSettings } = useAuth();
const { shortcut: selectedShortcut, setShortcut } = useMemoFilterContext();
const [deleteTarget, setDeleteTarget] = useState<Shortcut | undefined>();
const handleDeleteShortcut = async (shortcut: Shortcut) => {
setDeleteTarget(shortcut);
};
const confirmDeleteShortcut = async () => {
if (!deleteTarget) return;
await shortcutServiceClient.deleteShortcut({ name: deleteTarget.name });
await refetchSettings();
toast.success(t("setting.shortcut.delete-success", { title: deleteTarget.title }));
setDeleteTarget(undefined);
};
const handleCreateShortcut = () => {
navigate(ROUTES.SHORTCUTS, { state: { openCreate: true } });
};
const handleEditShortcut = (shortcut: Shortcut) => {
navigate(ROUTES.SHORTCUTS, { state: { shortcut } });
};
return (
<div className="w-full flex flex-col justify-start items-start mt-3 px-1 h-auto shrink-0 flex-nowrap">
<div className="flex flex-row justify-between items-center w-full gap-1 mb-1 text-sm leading-6 text-muted-foreground select-none">
<span>{t("common.shortcuts")}</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PlusIcon className="w-4 h-auto cursor-pointer" onClick={handleCreateShortcut} />
</TooltipTrigger>
<TooltipContent>
<p>{t("common.create")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="w-full flex flex-row justify-start items-center relative flex-wrap gap-x-2 gap-y-1">
{shortcuts.map((shortcut) => {
const shortcutId = getShortcutId(shortcut.name);
const maybeEmoji = shortcut.title.split(" ")[0];
const emoji = emojiRegex.test(maybeEmoji) ? maybeEmoji : undefined;
const title = emoji ? shortcut.title.replace(emoji, "") : shortcut.title;
const selected = selectedShortcut === shortcutId;
return (
<div
key={shortcutId}
className="shrink-0 w-full text-sm rounded-md leading-6 flex flex-row justify-between items-center select-none gap-2 text-muted-foreground"
>
<span
className={cn("truncate cursor-pointer text-muted-foreground", selected && "text-primary font-medium")}
onClick={() => (selected ? setShortcut(undefined) : setShortcut(shortcutId))}
>
{emoji && <span className="text-base mr-1">{emoji}</span>}
{title.trim()}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<MoreVerticalIcon className="w-4 h-auto shrink-0 text-muted-foreground cursor-pointer hover:text-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" alignOffset={-12}>
<DropdownMenuItem onClick={() => handleEditShortcut(shortcut)}>
<Edit3Icon className="w-4 h-auto" />
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDeleteShortcut(shortcut)}>
<TrashIcon className="w-4 h-auto" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
title={t("setting.shortcut.delete-confirm", { title: deleteTarget?.title ?? "" })}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteShortcut}
confirmVariant="destructive"
/>
</div>
);
}
export default ShortcutsSection;
@@ -0,0 +1,100 @@
import { HashIcon, MoreVerticalIcon, TagsIcon } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { useLocalStorage } from "@/hooks";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import TagTree from "../TagTree";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
interface Props {
readonly?: boolean;
tagCount: Record<string, number>;
}
const TagsSection = (props: Props) => {
const t = useTranslate();
const { getFiltersByFactor, addFilter, removeFilter } = useMemoFilterContext();
const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", false);
const [treeAutoExpand, setTreeAutoExpand] = useLocalStorage<boolean>("tag-tree-auto-expand", false);
const tags = Object.entries(props.tagCount)
.sort((a, b) => a[0].localeCompare(b[0]))
.sort((a, b) => b[1] - a[1]);
const handleTagClick = (tag: string) => {
const isActive = getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag);
if (isActive) {
removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag);
} else {
// Remove all existing tag filters first, then add the new one
removeFilter((f: MemoFilter) => f.factor === "tagSearch");
addFilter({
factor: "tagSearch",
value: tag,
});
}
};
return (
<div className="w-full flex flex-col justify-start items-start mt-3 px-1 h-auto shrink-0 flex-nowrap">
<div className="flex flex-row justify-between items-center w-full gap-1 mb-1 text-sm leading-6 text-muted-foreground select-none">
<span>{t("common.tags")}</span>
{tags.length > 0 && (
<Popover>
<PopoverTrigger>
<MoreVerticalIcon className="w-4 h-auto shrink-0 text-muted-foreground cursor-pointer hover:text-foreground" />
</PopoverTrigger>
<PopoverContent align="end" alignOffset={-12}>
<div className="w-auto flex flex-row justify-between items-center gap-2 p-1">
<span className="text-sm shrink-0">{t("common.tree-mode")}</span>
<Switch checked={treeMode} onCheckedChange={(checked) => setTreeMode(checked)} />
</div>
<div className="w-auto flex flex-row justify-between items-center gap-2 p-1">
<span className="text-sm shrink-0">{t("common.auto-expand")}</span>
<Switch disabled={!treeMode} checked={treeAutoExpand} onCheckedChange={(checked) => setTreeAutoExpand(checked)} />
</div>
</PopoverContent>
</Popover>
)}
</div>
{tags.length > 0 ? (
treeMode ? (
<TagTree tagAmounts={tags} expandSubTags={!!treeAutoExpand} />
) : (
<div className="w-full flex flex-row justify-start items-center relative flex-wrap gap-x-2 gap-y-1.5">
{tags.map(([tag, amount]) => {
const isActive = getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag);
return (
<div
key={tag}
className={cn(
"shrink-0 w-auto max-w-full text-sm rounded-md leading-6 flex flex-row justify-start items-center select-none cursor-pointer transition-colors",
"hover:opacity-80",
isActive ? "text-primary" : "text-muted-foreground",
)}
onClick={() => handleTagClick(tag)}
>
<HashIcon className="w-4 h-auto shrink-0" />
<div className="inline-flex flex-nowrap ml-0.5 gap-0.5 max-w-[calc(100%-16px)]">
<span className={cn("truncate", isActive ? "font-medium" : "")}>{tag}</span>
{amount > 1 && <span className="opacity-60 shrink-0">({amount})</span>}
</div>
</div>
);
})}
</div>
)
) : (
!props.readonly && (
<div className="p-2 border border-dashed rounded-md flex flex-row justify-start items-start gap-2 text-muted-foreground">
<TagsIcon className="w-5 h-5 shrink-0" />
<p className="text-sm leading-snug italic">{t("tag.create-tags-guide")}</p>
</div>
)
)}
</div>
);
};
export default TagsSection;
+5
View File
@@ -0,0 +1,5 @@
import MemoExplorer from "./MemoExplorer";
import MemoExplorerDrawer from "./MemoExplorerDrawer";
export type { MemoExplorerContext, MemoExplorerFeatures } from "./MemoExplorer";
export { MemoExplorer, MemoExplorerDrawer };