0.29.1原版
This commit is contained in:
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
import MemoExplorer from "./MemoExplorer";
|
||||
import MemoExplorerDrawer from "./MemoExplorerDrawer";
|
||||
|
||||
export type { MemoExplorerContext, MemoExplorerFeatures } from "./MemoExplorer";
|
||||
export { MemoExplorer, MemoExplorerDrawer };
|
||||
Reference in New Issue
Block a user