refactor(web): unify memo stats/filters with context-aware MainLayout

Create unified architecture for memo statistics, filters, and sorting
across all pages (Home, Explore, Archived, Profile) with proper
visibility filtering and consistent data flow.

Key changes:
- Rename HomeLayout → MainLayout to reflect broader usage
- Create useFilteredMemoStats hook for unified stats computation
- Create useMemoFilters/useMemoSorting hooks to eliminate duplication
- Refactor all pages to use unified hooks (~147 lines removed)
- Move Explore route under MainLayout (was sibling before)
- Fix masonry column calculation threshold (1024px → 688px+)

Architecture improvements:
- MainLayout computes filter/stats per route context
- Stats/tags based on same filter as memo list (consistency)
- Proper visibility filtering (PUBLIC/PROTECTED) on Explore
- MemoExplorer/StatisticsView accept stats as required props
- Eliminated optional fallbacks and redundant data fetching

Benefits:
- Single source of truth for stats computation
- Stats remain static (don't change with user filters)
- Reduced code duplication across 4 pages
- Better maintainability and type safety
- Proper security (no private memo leakage on Explore)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steven 2025-11-05 08:46:52 +08:00
parent d30ff2898f
commit 1d7efb1580
20 changed files with 736 additions and 337 deletions

View File

@ -45,7 +45,9 @@ export function useMasonryLayout(
const containerWidth = containerRef.current.offsetWidth;
const scale = containerWidth / MINIMUM_MEMO_VIEWPORT_WIDTH;
return scale >= 2 ? Math.round(scale) : 1;
// Use ceiling to maximize columns: 688px (1.34x) → 2 cols, 1280px (2.5x) → 3 cols
// Only use single column if scale is very small (< 1.2)
return scale >= 1.2 ? Math.ceil(scale) : 1;
}, [containerRef, listMode]);
/**

View File

@ -25,10 +25,10 @@ const VisibilitySelector = (props: Props) => {
return (
<DropdownMenu onOpenChange={props.onOpenChange}>
<DropdownMenuTrigger asChild>
<button className="inline-flex items-center gap-1.5 px-2 text-sm text-muted-foreground opacity-80 hover:opacity-100 transition-colors">
<VisibilityIcon visibility={value} className="opacity-60" />
<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="w-3 h-3 opacity-60" />
<ChevronDownIcon className="ml-0.5 w-4 h-4 opacity-60" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">

View File

@ -514,8 +514,9 @@ const MemoEditor = observer((props: Props) => {
}
/>
</div>
<div className="shrink-0 flex flex-row justify-end items-center gap-1">
<div className="shrink-0 flex flex-row justify-end items-center">
<VisibilitySelector value={state.memoVisibility} onChange={(visibility) => handleMemoVisibilityChange(visibility)} />
<div className="flex flex-row justify-end gap-1">
{props.onCancel && (
<Button variant="ghost" disabled={state.isRequesting} onClick={handleCancelBtnClick}>
{t("common.cancel")}
@ -527,6 +528,7 @@ const MemoEditor = observer((props: Props) => {
</div>
</div>
</div>
</div>
{/* Show memo metadata if memoName is provided */}
{memoName && (

View File

@ -2,29 +2,134 @@ import { observer } from "mobx-react-lite";
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";
interface Props {
className?: string;
export type MemoExplorerContext = "home" | "explore" | "archived" | "profile";
export interface MemoExplorerFeatures {
/**
* Show search bar at the top
* Default: true
*/
search?: boolean;
/**
* Show statistics section (activity calendar + stat cards)
* Default: true
*/
statistics?: boolean;
/**
* Show shortcuts section (user-defined filter shortcuts)
* Default: true for authenticated users on home/profile, false for explore
*/
shortcuts?: boolean;
/**
* Show tags section
* Default: true
*/
tags?: boolean;
/**
* Context for statistics view (affects which stats to show)
* Default: "user"
*/
statisticsContext?: MemoExplorerContext;
}
interface Props {
className?: string;
/**
* Context for the explorer (determines default features)
*/
context?: MemoExplorerContext;
/**
* Feature configuration (overrides context defaults)
*/
features?: MemoExplorerFeatures;
/**
* Statistics data computed from filtered memos
* Should be computed using useFilteredMemoStats with the same filter as the memo list
*/
statisticsData: StatisticsData;
/**
* Tag counts computed from filtered memos
* Should be computed using useFilteredMemoStats with the same filter as the memo list
*/
tagCount: Record<string, number>;
}
/**
* Default features based on context
*/
const getDefaultFeatures = (context: MemoExplorerContext): MemoExplorerFeatures => {
switch (context) {
case "explore":
return {
search: true,
statistics: true,
shortcuts: false, // Global explore doesn't use shortcuts
tags: true,
statisticsContext: "explore",
};
case "archived":
return {
search: true,
statistics: true,
shortcuts: false, // Archived doesn't typically use shortcuts
tags: true,
statisticsContext: "archived",
};
case "profile":
return {
search: true,
statistics: true,
shortcuts: false, // Profile view doesn't use shortcuts
tags: true,
statisticsContext: "profile",
};
case "home":
default:
return {
search: true,
statistics: true,
shortcuts: true,
tags: true,
statisticsContext: "home",
};
}
};
const MemoExplorer = observer((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",
props.className,
className,
)}
>
<SearchBar />
{features.search && <SearchBar />}
<div className="mt-1 px-1 w-full">
<StatisticsView />
{currentUser && <ShortcutsSection />}
<TagsSection />
{features.statistics && <StatisticsView context={features.statisticsContext} statisticsData={statisticsData} />}
{features.shortcuts && currentUser && <ShortcutsSection />}
{features.tags && <TagsSection readonly={context === "explore"} tagCount={tagCount} />}
</div>
</aside>
);

View File

@ -3,9 +3,33 @@ 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 MemoExplorer from "./MemoExplorer";
import type { StatisticsData } from "@/types/statistics";
import MemoExplorer, { MemoExplorerContext, MemoExplorerFeatures } from "./MemoExplorer";
const MemoExplorerDrawer = () => {
interface Props {
/**
* Context for the explorer
*/
context?: MemoExplorerContext;
/**
* Feature configuration
*/
features?: MemoExplorerFeatures;
/**
* Statistics data computed from filtered memos
*/
statisticsData: StatisticsData;
/**
* Tag counts computed from filtered memos
*/
tagCount: Record<string, number>;
}
const MemoExplorerDrawer = (props: Props) => {
const { context, features, statisticsData, tagCount } = props;
const location = useLocation();
const [open, setOpen] = useState(false);
@ -24,7 +48,7 @@ const MemoExplorerDrawer = () => {
<SheetHeader>
<SheetTitle />
</SheetHeader>
<MemoExplorer className="px-4" />
<MemoExplorer className="px-4" context={context} features={features} statisticsData={statisticsData} tagCount={tagCount} />
</SheetContent>
</Sheet>
);

View File

@ -3,7 +3,6 @@ import { observer } from "mobx-react-lite";
import useLocalStorage from "react-use/lib/useLocalStorage";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { userStore } from "@/store";
import memoFilterStore, { MemoFilter } from "@/store/memoFilter";
import { useTranslate } from "@/utils/i18n";
import TagTree from "../TagTree";
@ -11,13 +10,19 @@ import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
interface Props {
readonly?: boolean;
/**
* Tag count computed from filtered memos
* Should be provided by parent component using useFilteredMemoStats
*/
tagCount: Record<string, number>;
}
const TagsSection = observer((props: Props) => {
const t = useTranslate();
const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", false);
const [treeAutoExpand, setTreeAutoExpand] = useLocalStorage<boolean>("tag-tree-auto-expand", false);
const tags = Object.entries(userStore.state.tagCount)
const tags = Object.entries(props.tagCount)
.sort((a, b) => a[0].localeCompare(b[0]))
.sort((a, b) => b[1] - a[1]);

View File

@ -2,3 +2,4 @@ import MemoExplorer from "./MemoExplorer";
import MemoExplorerDrawer from "./MemoExplorerDrawer";
export { MemoExplorer, MemoExplorerDrawer };
export type { MemoExplorerContext, MemoExplorerFeatures } from "./MemoExplorer";

View File

@ -4,20 +4,40 @@ import { observer } from "mobx-react-lite";
import { useState, useCallback } from "react";
import { matchPath, useLocation } from "react-router-dom";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useStatisticsData } from "@/hooks/useStatisticsData";
import { Routes } from "@/router";
import { userStore } from "@/store";
import memoFilterStore, { FilterFactor } from "@/store/memoFilter";
import type { StatisticsData } from "@/types/statistics";
import { useTranslate } from "@/utils/i18n";
import ActivityCalendar from "../ActivityCalendar";
import { MonthNavigator } from "./MonthNavigator";
import { StatCard } from "./StatCard";
const StatisticsView = observer(() => {
export type StatisticsViewContext = "home" | "explore" | "archived" | "profile";
interface Props {
/**
* Context for the statistics view
* Affects which stat cards are shown
* Default: "home"
*/
context?: StatisticsViewContext;
/**
* Statistics data computed from filtered memos
* Should be provided by parent component using useFilteredMemoStats
*/
statisticsData: StatisticsData;
}
const StatisticsView = observer((props: Props) => {
const { context = "home", statisticsData } = props;
const t = useTranslate();
const location = useLocation();
const currentUser = useCurrentUser();
const { memoTypeStats, activityStats } = useStatisticsData();
const { memoTypeStats, activityStats } = statisticsData;
const [selectedDate] = useState(new Date());
const [visibleMonthString, setVisibleMonthString] = useState(dayjs().format("YYYY-MM"));
@ -33,6 +53,11 @@ const StatisticsView = observer(() => {
const isRootPath = matchPath(Routes.ROOT, location.pathname);
const hasPinnedMemos = currentUser && (userStore.state.currentUserStats?.pinnedMemos || []).length > 0;
// Determine if we should show the pinned stat card
// Only show on home page (root path) for the current user with pinned memos
// Don't show on explore page since it's global
const shouldShowPinned = context === "home" && isRootPath && hasPinnedMemos;
return (
<div className="group w-full mt-2 space-y-1 text-muted-foreground animate-fade-in">
<MonthNavigator visibleMonth={visibleMonthString} onMonthChange={setVisibleMonthString} />
@ -47,7 +72,7 @@ const StatisticsView = observer(() => {
</div>
<div className="pt-2 w-full flex flex-wrap items-center gap-2">
{isRootPath && hasPinnedMemos && (
{shouldShowPinned && (
<StatCard
icon={<BookmarkIcon className="opacity-70" />}
label={t("common.pinned")}

View File

@ -3,3 +3,6 @@ export * from "./useCurrentUser";
export * from "./useNavigateTo";
export * from "./useAsyncEffect";
export * from "./useResponsiveWidth";
export * from "./useMemoFilters";
export * from "./useMemoSorting";
export * from "./useFilteredMemoStats";

View File

@ -0,0 +1,135 @@
import dayjs from "dayjs";
import { countBy } from "lodash-es";
import { useEffect, useState } from "react";
import { memoServiceClient } from "@/grpcweb";
import { State } from "@/types/proto/api/v1/common";
import { UserStats_MemoTypeStats } from "@/types/proto/api/v1/user_service";
import type { StatisticsData } from "@/types/statistics";
export interface FilteredMemoStats {
statistics: StatisticsData;
tags: Record<string, number>;
loading: boolean;
}
/**
* Hook to fetch and compute statistics and tags from memos matching a filter.
*
* This provides a unified approach for all pages (Home, Explore, Archived, Profile):
* - Uses the same filter as PagedMemoList for consistency
* - Fetches all memos matching the filter once
* - Computes statistics and tags from those memos
* - Stats/tags remain static and don't change when user applies additional filters
*
* @param filter - CEL filter expression (same as used for memo list)
* @param state - Memo state (NORMAL for most pages, ARCHIVED for archived page)
* @param orderBy - Optional sort order (not used for stats, but ensures consistency)
* @returns Object with statistics data, tag counts, and loading state
*
* @example Home page
* const { statistics, tags } = useFilteredMemoStats(
* `creator_id == ${currentUserId}`,
* State.NORMAL
* );
*
* @example Explore page
* const { statistics, tags } = useFilteredMemoStats(
* `visibility in ["PUBLIC", "PROTECTED"]`,
* State.NORMAL
* );
*
* @example Archived page
* const { statistics, tags } = useFilteredMemoStats(
* `creator_id == ${currentUserId}`,
* State.ARCHIVED
* );
*/
export const useFilteredMemoStats = (filter?: string, state: State = State.NORMAL, orderBy?: string): FilteredMemoStats => {
const [data, setData] = useState<FilteredMemoStats>({
statistics: {
memoTypeStats: UserStats_MemoTypeStats.fromPartial({}),
activityStats: {},
},
tags: {},
loading: true,
});
useEffect(() => {
const fetchMemosAndComputeStats = async () => {
setData((prev) => ({ ...prev, loading: true }));
try {
// Fetch all memos matching the filter
// Use large page size to ensure we get all memos for accurate stats
const response = await memoServiceClient.listMemos({
state,
filter,
orderBy,
pageSize: 10000, // Large enough to get all memos
});
// Compute statistics and tags from fetched memos
const memoTypeStats = UserStats_MemoTypeStats.fromPartial({});
const displayTimeList: Date[] = [];
const tagCount: Record<string, number> = {};
if (response.memos) {
for (const memo of response.memos) {
// Add display time for calendar
if (memo.displayTime) {
displayTimeList.push(memo.displayTime);
}
// Count tags
if (memo.tags && memo.tags.length > 0) {
for (const tag of memo.tags) {
tagCount[tag] = (tagCount[tag] || 0) + 1;
}
}
// Count memo properties
if (memo.property) {
if (memo.property.hasLink) {
memoTypeStats.linkCount += 1;
}
if (memo.property.hasTaskList) {
memoTypeStats.todoCount += 1;
// Check if there are undone tasks
const undoneMatches = memo.content.match(/- \[ \]/g);
if (undoneMatches && undoneMatches.length > 0) {
memoTypeStats.undoCount += 1;
}
}
if (memo.property.hasCode) {
memoTypeStats.codeCount += 1;
}
}
}
}
// Compute activity calendar data
const activityStats = countBy(displayTimeList.map((date) => dayjs(date).format("YYYY-MM-DD")));
setData({
statistics: { memoTypeStats, activityStats },
tags: tagCount,
loading: false,
});
} catch (error) {
console.error("Failed to fetch memos for statistics:", error);
setData({
statistics: {
memoTypeStats: UserStats_MemoTypeStats.fromPartial({}),
activityStats: {},
},
tags: {},
loading: false,
});
}
};
fetchMemosAndComputeStats();
}, [filter, state, orderBy]);
return data;
};

View File

@ -0,0 +1,148 @@
import { useMemo } from "react";
import { userStore, workspaceStore } from "@/store";
import { extractUserIdFromName } from "@/store/common";
import memoFilterStore from "@/store/memoFilter";
import { Visibility } from "@/types/proto/api/v1/memo_service";
import { WorkspaceSetting_Key } from "@/types/proto/api/v1/workspace_service";
// Helper function to extract shortcut ID from resource name
// Format: users/{user}/shortcuts/{shortcut}
const getShortcutId = (name: string): string => {
const parts = name.split("/");
return parts.length === 4 ? parts[3] : "";
};
export interface UseMemoFiltersOptions {
/**
* User name to scope memos to (e.g., "users/123")
* If undefined, no creator filter is applied (useful for Explore page)
*/
creatorName?: string;
/**
* Whether to include shortcut filter from memoFilterStore
* Default: false
*/
includeShortcuts?: boolean;
/**
* Whether to include pinned filter from memoFilterStore
* Default: false
*/
includePinned?: boolean;
/**
* Visibility levels to filter by (for Explore page)
* If provided, adds visibility filter to show only specified visibility levels
* Default: undefined (no visibility filter)
*
* **Security Note**: This filter is enforced at the API level. The backend is responsible
* for respecting visibility permissions when:
* - Returning memo lists (filtered by this parameter)
* - Calculating statistics (should only count visible memos)
* - Aggregating tags (should only include tags from visible memos)
*
* This ensures that private memo data never leaks to unauthorized users through
* stats, tags, or direct memo access.
*
* @example
* // For logged-in users on Explore
* visibilities: [Visibility.PUBLIC, Visibility.PROTECTED]
*
* @example
* // For visitors on Explore
* visibilities: [Visibility.PUBLIC]
*/
visibilities?: Visibility[];
}
/**
* Hook to build memo filter string based on active filters and options.
*
* This hook consolidates filter building logic that was previously duplicated
* across Home, Explore, Archived, and UserProfile pages.
*
* @param options - Configuration for filter building
* @returns Filter string to pass to API, or undefined if no filters
*
* @example
* // Home page - include everything
* const filter = useMemoFilters({
* creatorName: user.name,
* includeShortcuts: true,
* includePinned: true
* });
*
* @example
* // Explore page - no creator scoping
* const filter = useMemoFilters({
* includeShortcuts: false,
* includePinned: false
* });
*/
export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | undefined => {
const { creatorName, includeShortcuts = false, includePinned = false, visibilities } = options;
// Get selected shortcut if needed
const selectedShortcut = useMemo(() => {
if (!includeShortcuts) return undefined;
return userStore.state.shortcuts.find((shortcut) => getShortcutId(shortcut.name) === memoFilterStore.shortcut);
}, [includeShortcuts, memoFilterStore.shortcut, userStore.state.shortcuts]);
// Build filter - wrapped in useMemo but also using observer for reactivity
return useMemo(() => {
const conditions: string[] = [];
// Add creator filter if provided
if (creatorName) {
conditions.push(`creator_id == ${extractUserIdFromName(creatorName)}`);
}
// Add shortcut filter if enabled and selected
if (includeShortcuts && selectedShortcut?.filter) {
conditions.push(selectedShortcut.filter);
}
// Add active filters from memoFilterStore
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 === "pinned") {
if (includePinned) {
conditions.push(`pinned`);
}
// Skip pinned filter if not enabled
} 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") {
// Check workspace setting for display time factor
const displayWithUpdateTime = workspaceStore.getWorkspaceSettingByKey(WorkspaceSetting_Key.MEMO_RELATED).memoRelatedSetting
?.displayWithUpdateTime;
const factor = displayWithUpdateTime ? "updated_ts" : "created_ts";
// Convert date to UTC timestamp range
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}`);
}
}
// Add visibility filter if specified (for Explore page)
if (visibilities && visibilities.length > 0) {
// Build visibility filter based on allowed visibility levels
// Format: visibility in ["PUBLIC", "PROTECTED"]
const visibilityValues = visibilities.map((v) => `"${v}"`).join(", ");
conditions.push(`visibility in [${visibilityValues}]`);
}
return conditions.length > 0 ? conditions.join(" && ") : undefined;
}, [creatorName, includeShortcuts, includePinned, visibilities, selectedShortcut, memoFilterStore.filters]);
};

View File

@ -0,0 +1,85 @@
import dayjs from "dayjs";
import { useMemo } from "react";
import { viewStore } from "@/store";
import { State } from "@/types/proto/api/v1/common";
import { Memo } from "@/types/proto/api/v1/memo_service";
export interface UseMemoSortingOptions {
/**
* Whether to sort pinned memos first
* Default: false
*/
pinnedFirst?: boolean;
/**
* State to filter memos by (NORMAL, ARCHIVED, etc.)
* Default: State.NORMAL
*/
state?: State;
}
export interface UseMemoSortingResult {
/**
* Sort function to pass to PagedMemoList's listSort prop
*/
listSort: (memos: Memo[]) => Memo[];
/**
* Order by string to pass to PagedMemoList's orderBy prop
*/
orderBy: string;
}
/**
* Hook to generate memo sorting logic based on options.
*
* This hook consolidates sorting logic that was previously duplicated
* across Home, Explore, Archived, and UserProfile pages.
*
* @param options - Configuration for sorting
* @returns Object with listSort function and orderBy string
*
* @example
* // Home page - pinned first, then by time
* const { listSort, orderBy } = useMemoSorting({
* pinnedFirst: true,
* state: State.NORMAL
* });
*
* @example
* // Explore page - only by time
* const { listSort, orderBy } = useMemoSorting({
* pinnedFirst: false,
* state: State.NORMAL
* });
*/
export const useMemoSorting = (options: UseMemoSortingOptions = {}): UseMemoSortingResult => {
const { pinnedFirst = false, state = State.NORMAL } = options;
// Generate orderBy string for API
const orderBy = useMemo(() => {
const timeOrder = viewStore.state.orderByTimeAsc ? "display_time asc" : "display_time desc";
return pinnedFirst ? `pinned desc, ${timeOrder}` : timeOrder;
}, [pinnedFirst, viewStore.state.orderByTimeAsc]);
// Generate listSort function for client-side sorting
const listSort = useMemo(() => {
return (memos: Memo[]): Memo[] => {
return memos
.filter((memo) => memo.state === state)
.sort((a, b) => {
// First, sort by pinned status if enabled
if (pinnedFirst && a.pinned !== b.pinned) {
return b.pinned ? 1 : -1;
}
// Then sort by display time
return viewStore.state.orderByTimeAsc
? dayjs(a.displayTime).unix() - dayjs(b.displayTime).unix()
: dayjs(b.displayTime).unix() - dayjs(a.displayTime).unix();
});
};
}, [pinnedFirst, state, viewStore.state.orderByTimeAsc]);
return { listSort, orderBy };
};

View File

@ -1,27 +0,0 @@
import dayjs from "dayjs";
import { countBy } from "lodash-es";
import { useMemo } from "react";
import { userStore } from "@/store";
import { UserStats_MemoTypeStats } from "@/types/proto/api/v1/user_service";
import type { StatisticsData } from "@/types/statistics";
export const useStatisticsData = (): StatisticsData => {
return useMemo(() => {
const memoTypeStats = UserStats_MemoTypeStats.fromPartial({});
const displayTimeList: Date[] = [];
for (const stats of Object.values(userStore.state.userStatsByName)) {
displayTimeList.push(...stats.memoDisplayTimestamps);
if (stats.memoTypeStats) {
memoTypeStats.codeCount += stats.memoTypeStats.codeCount;
memoTypeStats.linkCount += stats.memoTypeStats.linkCount;
memoTypeStats.todoCount += stats.memoTypeStats.todoCount;
memoTypeStats.undoCount += stats.memoTypeStats.undoCount;
}
}
const activityStats = countBy(displayTimeList.map((date) => dayjs(date).format("YYYY-MM-DD")));
return { memoTypeStats, activityStats };
}, [userStore.state.userStatsByName]);
};

View File

@ -1,55 +0,0 @@
import { last } from "lodash-es";
import { observer } from "mobx-react-lite";
import { matchPath, Outlet } from "react-router-dom";
import { useDebounce } from "react-use";
import { MemoExplorer, MemoExplorerDrawer } from "@/components/MemoExplorer";
import MobileHeader from "@/components/MobileHeader";
import useCurrentUser from "@/hooks/useCurrentUser";
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
import { cn } from "@/lib/utils";
import { Routes } from "@/router";
import { memoStore, userStore } from "@/store";
const HomeLayout = observer(() => {
const { md, lg } = useResponsiveWidth();
const currentUser = useCurrentUser();
useDebounce(
async () => {
let parent: string | undefined = undefined;
if (location.pathname === Routes.ROOT && currentUser) {
parent = currentUser.name;
}
if (matchPath("/u/:username", location.pathname) !== null) {
const username = last(location.pathname.split("/"));
const user = await userStore.getOrFetchUserByUsername(username || "");
parent = user.name;
}
await userStore.fetchUserStats(parent);
},
300,
[memoStore.state.memos.length, userStore.state.statsStateId, location.pathname],
);
return (
<section className="@container w-full min-h-full flex flex-col justify-start items-center">
{!md && (
<MobileHeader>
<MemoExplorerDrawer />
</MobileHeader>
)}
{md && (
<div className={cn("fixed top-0 left-16 shrink-0 h-svh transition-all", "border-r border-border", lg ? "w-72" : "w-56")}>
<MemoExplorer className={cn("px-3 py-6")} />
</div>
)}
<div className={cn("w-full min-h-full", lg ? "pl-72" : md ? "pl-56" : "")}>
<div className={cn("w-full mx-auto px-4 sm:px-6 md:pt-6 pb-8")}>
<Outlet />
</div>
</div>
</section>
);
});
export default HomeLayout;

View File

@ -0,0 +1,90 @@
import { last } from "lodash-es";
import { observer } from "mobx-react-lite";
import { useMemo } from "react";
import { matchPath, Outlet, useLocation } from "react-router-dom";
import { MemoExplorer, MemoExplorerDrawer, MemoExplorerContext } from "@/components/MemoExplorer";
import MobileHeader from "@/components/MobileHeader";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useFilteredMemoStats } from "@/hooks/useFilteredMemoStats";
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
import { cn } from "@/lib/utils";
import { Routes } from "@/router";
import { userStore } from "@/store";
import { extractUserIdFromName } from "@/store/common";
import { State } from "@/types/proto/api/v1/common";
import { Visibility } from "@/types/proto/api/v1/memo_service";
const MainLayout = observer(() => {
const { md, lg } = useResponsiveWidth();
const location = useLocation();
const currentUser = useCurrentUser();
// Determine context based on current route
const context: MemoExplorerContext = useMemo(() => {
if (location.pathname === Routes.ROOT) return "home";
if (location.pathname === Routes.EXPLORE) return "explore";
if (matchPath("/archived", location.pathname)) return "archived";
if (matchPath("/u/:username", location.pathname)) return "profile";
return "home"; // fallback
}, [location.pathname]);
// Compute filter and state based on context
// This should match what each page uses for their memo list
const { filter, state } = useMemo(() => {
if (location.pathname === Routes.ROOT && currentUser) {
// Home: current user's normal memos
return {
filter: `creator_id == ${extractUserIdFromName(currentUser.name)}`,
state: State.NORMAL,
};
} else if (location.pathname === Routes.EXPLORE) {
// Explore: visible memos (PUBLIC for visitors, PUBLIC+PROTECTED for logged-in)
const visibilities = currentUser ? [Visibility.PUBLIC, Visibility.PROTECTED] : [Visibility.PUBLIC];
const visibilityValues = visibilities.map((v) => `"${v}"`).join(", ");
return {
filter: `visibility in [${visibilityValues}]`,
state: State.NORMAL,
};
} else if (matchPath("/archived", location.pathname) && currentUser) {
// Archived: current user's archived memos
return {
filter: `creator_id == ${extractUserIdFromName(currentUser.name)}`,
state: State.ARCHIVED,
};
} else if (matchPath("/u/:username", location.pathname)) {
// Profile: specific user's normal memos
const username = last(location.pathname.split("/"));
const user = userStore.getUserByName(`users/${username}`);
return {
filter: user ? `creator_id == ${extractUserIdFromName(user.name)}` : undefined,
state: State.NORMAL,
};
}
return { filter: undefined, state: State.NORMAL };
}, [location.pathname, currentUser]);
// Fetch stats using the same filter as the memo list
const { statistics, tags } = useFilteredMemoStats(filter, state);
return (
<section className="@container w-full min-h-full flex flex-col justify-start items-center">
{!md && (
<MobileHeader>
<MemoExplorerDrawer context={context} statisticsData={statistics} tagCount={tags} />
</MobileHeader>
)}
{md && (
<div className={cn("fixed top-0 left-16 shrink-0 h-svh transition-all", "border-r border-border", lg ? "w-72" : "w-56")}>
<MemoExplorer className={cn("px-3 py-6")} context={context} statisticsData={statistics} tagCount={tags} />
</div>
)}
<div className={cn("w-full min-h-full", lg ? "pl-72" : md ? "pl-56" : "")}>
<div className={cn("w-full mx-auto px-4 sm:px-6 md:pt-6 pb-8")}>
<Outlet />
</div>
</div>
</section>
);
});
export default MainLayout;

View File

@ -1,69 +1,36 @@
import dayjs from "dayjs";
import { observer } from "mobx-react-lite";
import { MemoRenderContext } from "@/components/MasonryView";
import MemoView from "@/components/MemoView";
import PagedMemoList from "@/components/PagedMemoList";
import { useMemoFilters, useMemoSorting } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser";
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
const buildMemoFilter = () => {
const conditions = [`creator_id == ${extractUserIdFromName(user.name)}`];
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;
};
// Build filter using unified hook (no shortcuts or pinned filter)
const memoFilter = useMemoFilters({
creatorName: user.name,
includeShortcuts: false,
includePinned: false,
});
const memoFilter = buildMemoFilter();
// Get sorting logic using unified hook (pinned first, archived state)
const { listSort, orderBy } = useMemoSorting({
pinnedFirst: true,
state: State.ARCHIVED,
});
return (
<PagedMemoList
renderer={(memo: Memo, context?: MemoRenderContext) => (
<MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility compact={context?.compact} />
)}
listSort={(memos: Memo[]) =>
memos
.filter((memo) => memo.state === State.ARCHIVED)
.sort((a, b) => {
// First, sort by pinned status (pinned memos first)
if (a.pinned !== b.pinned) {
return b.pinned ? 1 : -1;
}
// Then sort by display time
return viewStore.state.orderByTimeAsc
? dayjs(a.displayTime).unix() - dayjs(b.displayTime).unix()
: dayjs(b.displayTime).unix() - dayjs(a.displayTime).unix();
})
}
listSort={listSort}
state={State.ARCHIVED}
orderBy={viewStore.state.orderByTimeAsc ? "pinned desc, display_time asc" : "pinned desc, display_time desc"}
orderBy={orderBy}
filter={memoFilter}
/>
);

View File

@ -1,71 +1,44 @@
import dayjs from "dayjs";
import { observer } from "mobx-react-lite";
import { MemoRenderContext } from "@/components/MasonryView";
import MemoView from "@/components/MemoView";
import MobileHeader from "@/components/MobileHeader";
import PagedMemoList from "@/components/PagedMemoList";
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
import { viewStore, workspaceStore } from "@/store";
import memoFilterStore from "@/store/memoFilter";
import { useMemoFilters, useMemoSorting } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser";
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";
import { Memo, Visibility } from "@/types/proto/api/v1/memo_service";
const Explore = observer(() => {
const { md } = useResponsiveWidth();
const currentUser = useCurrentUser();
// 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;
};
// Determine visibility filter based on authentication status
// - Logged-in users: Can see PUBLIC and PROTECTED memos
// - Visitors: Can only see PUBLIC memos
// Note: The backend is responsible for filtering stats based on visibility permissions.
const visibilities = currentUser ? [Visibility.PUBLIC, Visibility.PROTECTED] : [Visibility.PUBLIC];
const memoFilter = buildMemoFilter();
// Build filter using unified hook (no creator scoping for Explore)
const memoFilter = useMemoFilters({
includeShortcuts: false,
includePinned: false,
visibilities,
});
// Get sorting logic using unified hook (no pinned sorting)
const { listSort, orderBy } = useMemoSorting({
pinnedFirst: false,
state: State.NORMAL,
});
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 />}
<div className="w-full px-4 sm:px-6">
<PagedMemoList
renderer={(memo: Memo, context?: MemoRenderContext) => (
<MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showCreator showVisibility compact={context?.compact} />
)}
listSort={(memos: Memo[]) =>
memos
.filter((memo) => memo.state === State.NORMAL)
.sort((a, b) =>
viewStore.state.orderByTimeAsc
? dayjs(a.displayTime).unix() - dayjs(b.displayTime).unix()
: dayjs(b.displayTime).unix() - dayjs(a.displayTime).unix(),
)
}
orderBy={viewStore.state.orderByTimeAsc ? "display_time asc" : "display_time desc"}
listSort={listSort}
orderBy={orderBy}
filter={memoFilter}
showCreator
/>
</div>
</section>
);
});

View File

@ -1,60 +1,27 @@
import dayjs from "dayjs";
import { observer } from "mobx-react-lite";
import { MemoRenderContext } from "@/components/MasonryView";
import MemoView from "@/components/MemoView";
import PagedMemoList from "@/components/PagedMemoList";
import { useMemoFilters, useMemoSorting } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser";
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 { WorkspaceSetting_Key } from "@/types/proto/api/v1/workspace_service";
// Helper function to extract shortcut ID from resource name
// Format: users/{user}/shortcuts/{shortcut}
const getShortcutId = (name: string): string => {
const parts = name.split("/");
return parts.length === 4 ? parts[3] : "";
};
const Home = observer(() => {
const user = useCurrentUser();
const selectedShortcut = userStore.state.shortcuts.find((shortcut) => getShortcutId(shortcut.name) === memoFilterStore.shortcut);
// Build filter from active filters - no useMemo needed since component is MobX observer
const buildMemoFilter = () => {
const conditions = [`creator_id == ${extractUserIdFromName(user.name)}`];
if (selectedShortcut?.filter) {
conditions.push(selectedShortcut.filter);
}
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 === "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;
};
// Build filter using unified hook
const memoFilter = useMemoFilters({
creatorName: user.name,
includeShortcuts: true,
includePinned: true,
});
const memoFilter = buildMemoFilter();
// Get sorting logic using unified hook
const { listSort, orderBy } = useMemoSorting({
pinnedFirst: true,
state: State.NORMAL,
});
return (
<div className="w-full min-h-full bg-background text-foreground">
@ -62,21 +29,8 @@ const Home = observer(() => {
renderer={(memo: Memo, context?: MemoRenderContext) => (
<MemoView key={`${memo.name}-${memo.displayTime}`} memo={memo} showVisibility showPinned compact={context?.compact} />
)}
listSort={(memos: Memo[]) =>
memos
.filter((memo) => memo.state === State.NORMAL)
.sort((a, b) => {
// First, sort by pinned status (pinned memos first)
if (a.pinned !== b.pinned) {
return b.pinned ? 1 : -1;
}
// Then sort by display time
return viewStore.state.orderByTimeAsc
? dayjs(a.displayTime).unix() - dayjs(b.displayTime).unix()
: dayjs(b.displayTime).unix() - dayjs(a.displayTime).unix();
})
}
orderBy={viewStore.state.orderByTimeAsc ? "pinned desc, display_time asc" : "pinned desc, display_time desc"}
listSort={listSort}
orderBy={orderBy}
filter={memoFilter}
/>
</div>

View File

@ -1,5 +1,4 @@
import copy from "copy-to-clipboard";
import dayjs from "dayjs";
import { ExternalLinkIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
@ -10,14 +9,12 @@ import MemoView from "@/components/MemoView";
import PagedMemoList from "@/components/PagedMemoList";
import UserAvatar from "@/components/UserAvatar";
import { Button } from "@/components/ui/button";
import { useMemoFilters, useMemoSorting } from "@/hooks";
import useLoading from "@/hooks/useLoading";
import { viewStore, userStore, workspaceStore } from "@/store";
import { extractUserIdFromName } from "@/store/common";
import memoFilterStore from "@/store/memoFilter";
import { userStore } from "@/store";
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(() => {
@ -44,40 +41,18 @@ const UserProfile = observer(() => {
});
}, [params.username]);
// Build filter from active filters
const buildMemoFilter = () => {
if (!user) {
return undefined;
}
// Build filter using unified hook (no shortcuts, but includes pinned)
const memoFilter = useMemoFilters({
creatorName: user?.name,
includeShortcuts: false,
includePinned: true,
});
const conditions = [`creator_id == ${extractUserIdFromName(user.name)}`];
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 === "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;
};
const memoFilter = buildMemoFilter();
// Get sorting logic using unified hook
const { listSort, orderBy } = useMemoSorting({
pinnedFirst: true,
state: State.NORMAL,
});
const handleCopyProfileLink = () => {
if (!user) {
@ -113,21 +88,8 @@ const UserProfile = observer(() => {
renderer={(memo: Memo, context?: MemoRenderContext) => (
<MemoView key={`${memo.name}-${memo.displayTime}`} memo={memo} showVisibility showPinned compact={context?.compact} />
)}
listSort={(memos: Memo[]) =>
memos
.filter((memo) => memo.state === State.NORMAL)
.sort((a, b) => {
// First, sort by pinned status (pinned memos first)
if (a.pinned !== b.pinned) {
return b.pinned ? 1 : -1;
}
// Then sort by display time
return viewStore.state.orderByTimeAsc
? dayjs(a.displayTime).unix() - dayjs(b.displayTime).unix()
: dayjs(b.displayTime).unix() - dayjs(a.displayTime).unix();
})
}
orderBy={viewStore.state.orderByTimeAsc ? "pinned desc, display_time asc" : "pinned desc, display_time desc"}
listSort={listSort}
orderBy={orderBy}
filter={memoFilter}
/>
</>

View File

@ -1,7 +1,7 @@
import { Suspense, lazy } from "react";
import { createBrowserRouter } from "react-router-dom";
import App from "@/App";
import HomeLayout from "@/layouts/HomeLayout";
import MainLayout from "@/layouts/MainLayout";
import RootLayout from "@/layouts/RootLayout";
import Home from "@/pages/Home";
import Loading from "@/pages/Loading";
@ -78,12 +78,20 @@ const router = createBrowserRouter([
element: <RootLayout />,
children: [
{
element: <HomeLayout />,
element: <MainLayout />,
children: [
{
path: "",
element: <Home />,
},
{
path: Routes.EXPLORE,
element: (
<Suspense fallback={<Loading />}>
<Explore />
</Suspense>
),
},
{
path: Routes.ARCHIVED,
element: (
@ -102,14 +110,6 @@ const router = createBrowserRouter([
},
],
},
{
path: Routes.EXPLORE,
element: (
<Suspense fallback={<Loading />}>
<Explore />
</Suspense>
),
},
{
path: Routes.ATTACHMENTS,
element: (