refactor(web): refactor MemoFilters component and add comprehensive filter support

- Refactored MemoFilters.tsx for better maintainability:
  * Centralized filter configuration with FILTER_CONFIGS object
  * Added TypeScript interfaces for type safety
  * Removed separate FactorIcon component
  * Extracted handleRemoveFilter function
  * Improved imports organization

- Polished MemoFilters UI styles:
  * Changed to modern pill/badge design with rounded-full
  * Enhanced spacing and color schemes
  * Added smooth transitions and hover effects
  * Improved interactive remove button with destructive color hints
  * Better text readability with font-medium

- Added comprehensive filter support to all pages:
  * Explore page: Added full filter support (was missing)
  * Archived page: Enhanced from basic to full filter support
  * UserProfile page: Enhanced from basic to full filter support
  * All pages now support: content search, tag search, pinned, hasLink, hasTaskList, hasCode, and displayTime filters

- Consistency improvements:
  * All pages using PagedMemoList now have identical filter logic
  * Respects workspace settings for display time (created/updated)
  * Unified filter behavior across Home, Explore, Archived, and UserProfile pages
pull/5078/merge
Steven 3 days ago
parent 1e954070b9
commit e915e3a46b

@ -2,7 +2,6 @@ import { observer } from "mobx-react-lite";
import SearchBar from "@/components/SearchBar";
import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils";
import MemoFilters from "../MemoFilters";
import StatisticsView from "../StatisticsView";
import ShortcutsSection from "./ShortcutsSection";
import TagsSection from "./TagsSection";
@ -22,7 +21,6 @@ const HomeSidebar = observer((props: Props) => {
)}
>
<SearchBar />
<MemoFilters />
<div className="mt-1 px-1 w-full">
<StatisticsView />
{currentUser && <ShortcutsSection />}

@ -1,5 +1,16 @@
import { isEqual } from "lodash-es";
import { CalendarIcon, CheckCircleIcon, CodeIcon, EyeIcon, HashIcon, LinkIcon, BookmarkIcon, SearchIcon, XIcon } from "lucide-react";
import {
BookmarkIcon,
CalendarIcon,
CheckCircleIcon,
CodeIcon,
EyeIcon,
HashIcon,
LinkIcon,
LucideIcon,
SearchIcon,
XIcon,
} from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect } from "react";
import { useSearchParams } from "react-router-dom";
@ -7,6 +18,46 @@ import { memoFilterStore } from "@/store";
import { FilterFactor, getMemoFilterKey, MemoFilter, stringifyFilters } from "@/store/memoFilter";
import { useTranslate } from "@/utils/i18n";
interface FilterConfig {
icon: LucideIcon;
getLabel: (value: string, t: ReturnType<typeof useTranslate>) => string;
}
const FILTER_CONFIGS: Record<FilterFactor, FilterConfig> = {
tagSearch: {
icon: HashIcon,
getLabel: (value) => value,
},
visibility: {
icon: EyeIcon,
getLabel: (value) => value,
},
contentSearch: {
icon: SearchIcon,
getLabel: (value) => value,
},
displayTime: {
icon: CalendarIcon,
getLabel: (value) => value,
},
pinned: {
icon: BookmarkIcon,
getLabel: (value) => value,
},
"property.hasLink": {
icon: LinkIcon,
getLabel: (_, t) => t("filters.has-link"),
},
"property.hasTaskList": {
icon: CheckCircleIcon,
getLabel: (_, t) => t("filters.has-task-list"),
},
"property.hasCode": {
icon: CodeIcon,
getLabel: (_, t) => t("filters.has-code"),
},
};
const MemoFilters = observer(() => {
const t = useTranslate();
const [, setSearchParams] = useSearchParams();
@ -18,63 +69,51 @@ const MemoFilters = observer(() => {
searchParams.set("filter", stringifyFilters(filters));
}
setSearchParams(searchParams);
}, [filters]);
}, [filters, setSearchParams]);
const getFilterDisplayText = (filter: MemoFilter) => {
if (filter.value) {
return filter.value;
}
if (filter.factor.startsWith("property.")) {
const factorLabel = filter.factor.replace("property.", "");
switch (factorLabel) {
case "hasLink":
return t("filters.has-link");
case "hasCode":
return t("filters.has-code");
case "hasTaskList":
return t("filters.has-task-list");
default:
return factorLabel;
}
const handleRemoveFilter = (filter: MemoFilter) => {
memoFilterStore.removeFilter((f: MemoFilter) => isEqual(f, filter));
};
const getFilterDisplayText = (filter: MemoFilter): string => {
const config = FILTER_CONFIGS[filter.factor];
if (!config) {
return filter.value || filter.factor;
}
return filter.factor;
return config.getLabel(filter.value, t);
};
if (filters.length === 0) {
return undefined;
return null;
}
return (
<div className="w-full mt-2 flex flex-row justify-start items-center flex-wrap gap-x-2 gap-y-1">
{filters.map((filter: MemoFilter) => (
<div
key={getMemoFilterKey(filter)}
className="w-auto leading-7 h-7 shrink-0 flex flex-row items-center gap-1 bg-background border pl-1.5 pr-1 rounded-md hover:line-through cursor-pointer"
onClick={() => memoFilterStore.removeFilter((f: MemoFilter) => isEqual(f, filter))}
>
<FactorIcon className="w-4 h-auto text-muted-foreground opacity-60" factor={filter.factor} />
<span className="text-muted-foreground text-sm max-w-32 truncate">{getFilterDisplayText(filter)}</span>
<button className="text-muted-foreground opacity-60 hover:opacity-100">
<XIcon className="w-4 h-auto" />
</button>
</div>
))}
<div className="w-full mb-2 flex flex-row justify-start items-center flex-wrap gap-2">
{filters.map((filter) => {
const config = FILTER_CONFIGS[filter.factor];
const Icon = config?.icon;
return (
<div
key={getMemoFilterKey(filter)}
className="group inline-flex items-center gap-1.5 h-7 px-2.5 bg-accent/50 hover:bg-accent border border-border/50 rounded-full text-sm transition-all duration-200 hover:shadow-sm"
>
{Icon && <Icon className="w-3.5 h-3.5 text-muted-foreground shrink-0" />}
<span className="text-foreground/80 font-medium max-w-32 truncate">{getFilterDisplayText(filter)}</span>
<button
onClick={() => handleRemoveFilter(filter)}
className="ml-0.5 -mr-1 p-0.5 text-muted-foreground/60 hover:text-destructive hover:bg-destructive/10 rounded-full transition-colors"
aria-label="Remove filter"
>
<XIcon className="w-3 h-3" />
</button>
</div>
);
})}
</div>
);
});
const FactorIcon = ({ factor, className }: { factor: FilterFactor; className?: string }) => {
const iconMap = {
tagSearch: <HashIcon className={className} />,
visibility: <EyeIcon className={className} />,
contentSearch: <SearchIcon className={className} />,
displayTime: <CalendarIcon className={className} />,
pinned: <BookmarkIcon className={className} />,
"property.hasLink": <LinkIcon className={className} />,
"property.hasTaskList": <CheckCircleIcon className={className} />,
"property.hasCode": <CodeIcon className={className} />,
};
return iconMap[factor as keyof typeof iconMap] || <></>;
};
MemoFilters.displayName = "MemoFilters";
export default MemoFilters;

@ -14,6 +14,7 @@ import { useTranslate } from "@/utils/i18n";
import Empty from "../Empty";
import MasonryView, { MemoRenderContext } from "../MasonryView";
import MemoEditor from "../MemoEditor";
import MemoFilters from "../MemoFilters";
import MemoSkeleton from "../MemoSkeleton";
interface Props {
@ -153,7 +154,12 @@ const PagedMemoList = observer((props: Props) => {
<MasonryView
memoList={sortedMemoList}
renderer={props.renderer}
prefixElement={showMemoEditor ? <MemoEditor className="mb-2" cacheKey="home-memo-editor" /> : undefined}
prefixElement={
<>
{showMemoEditor ? <MemoEditor className="mb-2" cacheKey="home-memo-editor" /> : undefined}
<MemoFilters />
</>
}
listMode={viewStore.state.layout === "LIST"}
/>

@ -4,16 +4,17 @@ import { MemoRenderContext } from "@/components/MasonryView";
import MemoView from "@/components/MemoView";
import PagedMemoList from "@/components/PagedMemoList";
import useCurrentUser from "@/hooks/useCurrentUser";
import { viewStore } from "@/store";
import { viewStore, workspaceStore } from "@/store";
import { extractUserIdFromName } from "@/store/common";
import memoFilterStore from "@/store/memoFilter";
import { State } from "@/types/proto/api/v1/common";
import { Memo } from "@/types/proto/api/v1/memo_service";
import { WorkspaceSetting_Key } from "@/types/proto/api/v1/workspace_service";
const Archived = observer(() => {
const user = useCurrentUser();
// Build filter from active filters - no useMemo needed since component is MobX observer
// Build filter from active filters
const buildMemoFilter = () => {
const conditions = [`creator_id == ${extractUserIdFromName(user.name)}`];
for (const filter of memoFilterStore.filters) {
@ -21,6 +22,20 @@ const Archived = observer(() => {
conditions.push(`content.contains("${filter.value}")`);
} else if (filter.factor === "tagSearch") {
conditions.push(`tag in ["${filter.value}"]`);
} else if (filter.factor === "property.hasLink") {
conditions.push(`has_link`);
} else if (filter.factor === "property.hasTaskList") {
conditions.push(`has_task_list`);
} else if (filter.factor === "property.hasCode") {
conditions.push(`has_code`);
} else if (filter.factor === "displayTime") {
const displayWithUpdateTime = workspaceStore.getWorkspaceSettingByKey(WorkspaceSetting_Key.MEMO_RELATED).memoRelatedSetting
?.displayWithUpdateTime;
const factor = displayWithUpdateTime ? "updated_ts" : "created_ts";
const filterDate = new Date(filter.value);
const filterUtcTimestamp = filterDate.getTime() + filterDate.getTimezoneOffset() * 60 * 1000;
const timestampAfter = filterUtcTimestamp / 1000;
conditions.push(`${factor} >= ${timestampAfter} && ${factor} < ${timestampAfter + 60 * 60 * 24}`);
}
}
return conditions.length > 0 ? conditions.join(" && ") : undefined;

@ -5,13 +5,44 @@ import MemoView from "@/components/MemoView";
import MobileHeader from "@/components/MobileHeader";
import PagedMemoList from "@/components/PagedMemoList";
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
import { viewStore } from "@/store";
import { viewStore, workspaceStore } from "@/store";
import memoFilterStore from "@/store/memoFilter";
import { State } from "@/types/proto/api/v1/common";
import { Memo } from "@/types/proto/api/v1/memo_service";
import { WorkspaceSetting_Key } from "@/types/proto/api/v1/workspace_service";
const Explore = observer(() => {
const { md } = useResponsiveWidth();
// Build filter from active filters
const buildMemoFilter = () => {
const conditions: string[] = [];
for (const filter of memoFilterStore.filters) {
if (filter.factor === "contentSearch") {
conditions.push(`content.contains("${filter.value}")`);
} else if (filter.factor === "tagSearch") {
conditions.push(`tag in ["${filter.value}"]`);
} else if (filter.factor === "property.hasLink") {
conditions.push(`has_link`);
} else if (filter.factor === "property.hasTaskList") {
conditions.push(`has_task_list`);
} else if (filter.factor === "property.hasCode") {
conditions.push(`has_code`);
} else if (filter.factor === "displayTime") {
const displayWithUpdateTime = workspaceStore.getWorkspaceSettingByKey(WorkspaceSetting_Key.MEMO_RELATED).memoRelatedSetting
?.displayWithUpdateTime;
const factor = displayWithUpdateTime ? "updated_ts" : "created_ts";
const filterDate = new Date(filter.value);
const filterUtcTimestamp = filterDate.getTime() + filterDate.getTimezoneOffset() * 60 * 1000;
const timestampAfter = filterUtcTimestamp / 1000;
conditions.push(`${factor} >= ${timestampAfter} && ${factor} < ${timestampAfter + 60 * 60 * 24}`);
}
}
return conditions.length > 0 ? conditions.join(" && ") : undefined;
};
const memoFilter = buildMemoFilter();
return (
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
{!md && <MobileHeader />}
@ -30,6 +61,7 @@ const Explore = observer(() => {
)
}
orderBy={viewStore.state.orderByTimeAsc ? "display_time asc" : "display_time desc"}
filter={memoFilter}
showCreator
/>
</div>

@ -11,12 +11,13 @@ import PagedMemoList from "@/components/PagedMemoList";
import UserAvatar from "@/components/UserAvatar";
import { Button } from "@/components/ui/button";
import useLoading from "@/hooks/useLoading";
import { viewStore, userStore } from "@/store";
import { viewStore, userStore, workspaceStore } from "@/store";
import { extractUserIdFromName } from "@/store/common";
import memoFilterStore from "@/store/memoFilter";
import { State } from "@/types/proto/api/v1/common";
import { Memo } from "@/types/proto/api/v1/memo_service";
import { User } from "@/types/proto/api/v1/user_service";
import { WorkspaceSetting_Key } from "@/types/proto/api/v1/workspace_service";
import { useTranslate } from "@/utils/i18n";
const UserProfile = observer(() => {
@ -43,7 +44,7 @@ const UserProfile = observer(() => {
});
}, [params.username]);
// Build filter from active filters - no useMemo needed since component is MobX observer
// Build filter from active filters
const buildMemoFilter = () => {
if (!user) {
return undefined;
@ -55,6 +56,22 @@ const UserProfile = observer(() => {
conditions.push(`content.contains("${filter.value}")`);
} else if (filter.factor === "tagSearch") {
conditions.push(`tag in ["${filter.value}"]`);
} else if (filter.factor === "pinned") {
conditions.push(`pinned`);
} else if (filter.factor === "property.hasLink") {
conditions.push(`has_link`);
} else if (filter.factor === "property.hasTaskList") {
conditions.push(`has_task_list`);
} else if (filter.factor === "property.hasCode") {
conditions.push(`has_code`);
} else if (filter.factor === "displayTime") {
const displayWithUpdateTime = workspaceStore.getWorkspaceSettingByKey(WorkspaceSetting_Key.MEMO_RELATED).memoRelatedSetting
?.displayWithUpdateTime;
const factor = displayWithUpdateTime ? "updated_ts" : "created_ts";
const filterDate = new Date(filter.value);
const filterUtcTimestamp = filterDate.getTime() + filterDate.getTimezoneOffset() * 60 * 1000;
const timestampAfter = filterUtcTimestamp / 1000;
conditions.push(`${factor} >= ${timestampAfter} && ${factor} < ${timestampAfter + 60 * 60 * 24}`);
}
}
return conditions.length > 0 ? conditions.join(" && ") : undefined;

Loading…
Cancel
Save