mirror of https://github.com/usememos/memos.git
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:
parent
d30ff2898f
commit
1d7efb1580
|
|
@ -45,7 +45,9 @@ export function useMasonryLayout(
|
||||||
|
|
||||||
const containerWidth = containerRef.current.offsetWidth;
|
const containerWidth = containerRef.current.offsetWidth;
|
||||||
const scale = containerWidth / MINIMUM_MEMO_VIEWPORT_WIDTH;
|
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]);
|
}, [containerRef, listMode]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,10 @@ const VisibilitySelector = (props: Props) => {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu onOpenChange={props.onOpenChange}>
|
<DropdownMenu onOpenChange={props.onOpenChange}>
|
||||||
<DropdownMenuTrigger asChild>
|
<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">
|
<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" />
|
<VisibilityIcon visibility={value} className="opacity-60 mr-1.5" />
|
||||||
<span>{currentLabel}</span>
|
<span>{currentLabel}</span>
|
||||||
<ChevronDownIcon className="w-3 h-3 opacity-60" />
|
<ChevronDownIcon className="ml-0.5 w-4 h-4 opacity-60" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
|
|
|
||||||
|
|
@ -514,16 +514,18 @@ const MemoEditor = observer((props: Props) => {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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)} />
|
<VisibilitySelector value={state.memoVisibility} onChange={(visibility) => handleMemoVisibilityChange(visibility)} />
|
||||||
{props.onCancel && (
|
<div className="flex flex-row justify-end gap-1">
|
||||||
<Button variant="ghost" disabled={state.isRequesting} onClick={handleCancelBtnClick}>
|
{props.onCancel && (
|
||||||
{t("common.cancel")}
|
<Button variant="ghost" disabled={state.isRequesting} onClick={handleCancelBtnClick}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button disabled={!allowSave || state.isRequesting} onClick={handleSaveBtnClick}>
|
||||||
|
{state.isRequesting ? <LoaderIcon className="w-4 h-4 animate-spin" /> : t("editor.save")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
<Button disabled={!allowSave || state.isRequesting} onClick={handleSaveBtnClick}>
|
|
||||||
{state.isRequesting ? <LoaderIcon className="w-4 h-4 animate-spin" /> : t("editor.save")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,29 +2,134 @@ import { observer } from "mobx-react-lite";
|
||||||
import SearchBar from "@/components/SearchBar";
|
import SearchBar from "@/components/SearchBar";
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { StatisticsData } from "@/types/statistics";
|
||||||
import StatisticsView from "../StatisticsView";
|
import StatisticsView from "../StatisticsView";
|
||||||
import ShortcutsSection from "./ShortcutsSection";
|
import ShortcutsSection from "./ShortcutsSection";
|
||||||
import TagsSection from "./TagsSection";
|
import TagsSection from "./TagsSection";
|
||||||
|
|
||||||
interface Props {
|
export type MemoExplorerContext = "home" | "explore" | "archived" | "profile";
|
||||||
className?: string;
|
|
||||||
|
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 MemoExplorer = observer((props: Props) => {
|
||||||
|
const { className, context = "home", features: featureOverrides = {}, statisticsData, tagCount } = props;
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
|
|
||||||
|
// Merge default features with overrides
|
||||||
|
const features = {
|
||||||
|
...getDefaultFeatures(context),
|
||||||
|
...featureOverrides,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative w-full h-full overflow-auto flex flex-col justify-start items-start bg-background text-sidebar-foreground",
|
"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">
|
<div className="mt-1 px-1 w-full">
|
||||||
<StatisticsView />
|
{features.statistics && <StatisticsView context={features.statisticsContext} statisticsData={statisticsData} />}
|
||||||
{currentUser && <ShortcutsSection />}
|
{features.shortcuts && currentUser && <ShortcutsSection />}
|
||||||
<TagsSection />
|
{features.tags && <TagsSection readonly={context === "explore"} tagCount={tagCount} />}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,33 @@ import { useEffect, useState } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
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 location = useLocation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -24,7 +48,7 @@ const MemoExplorerDrawer = () => {
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle />
|
<SheetTitle />
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<MemoExplorer className="px-4" />
|
<MemoExplorer className="px-4" context={context} features={features} statisticsData={statisticsData} tagCount={tagCount} />
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { observer } from "mobx-react-lite";
|
||||||
import useLocalStorage from "react-use/lib/useLocalStorage";
|
import useLocalStorage from "react-use/lib/useLocalStorage";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { userStore } from "@/store";
|
|
||||||
import memoFilterStore, { MemoFilter } from "@/store/memoFilter";
|
import memoFilterStore, { MemoFilter } from "@/store/memoFilter";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
import TagTree from "../TagTree";
|
import TagTree from "../TagTree";
|
||||||
|
|
@ -11,13 +10,19 @@ import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
readonly?: boolean;
|
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 TagsSection = observer((props: Props) => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", false);
|
const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", false);
|
||||||
const [treeAutoExpand, setTreeAutoExpand] = useLocalStorage<boolean>("tag-tree-auto-expand", 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) => a[0].localeCompare(b[0]))
|
||||||
.sort((a, b) => b[1] - a[1]);
|
.sort((a, b) => b[1] - a[1]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,4 @@ import MemoExplorer from "./MemoExplorer";
|
||||||
import MemoExplorerDrawer from "./MemoExplorerDrawer";
|
import MemoExplorerDrawer from "./MemoExplorerDrawer";
|
||||||
|
|
||||||
export { MemoExplorer, MemoExplorerDrawer };
|
export { MemoExplorer, MemoExplorerDrawer };
|
||||||
|
export type { MemoExplorerContext, MemoExplorerFeatures } from "./MemoExplorer";
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,40 @@ import { observer } from "mobx-react-lite";
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { matchPath, useLocation } from "react-router-dom";
|
import { matchPath, useLocation } from "react-router-dom";
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
import { useStatisticsData } from "@/hooks/useStatisticsData";
|
|
||||||
import { Routes } from "@/router";
|
import { Routes } from "@/router";
|
||||||
import { userStore } from "@/store";
|
import { userStore } from "@/store";
|
||||||
import memoFilterStore, { FilterFactor } from "@/store/memoFilter";
|
import memoFilterStore, { FilterFactor } from "@/store/memoFilter";
|
||||||
|
import type { StatisticsData } from "@/types/statistics";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
import ActivityCalendar from "../ActivityCalendar";
|
import ActivityCalendar from "../ActivityCalendar";
|
||||||
import { MonthNavigator } from "./MonthNavigator";
|
import { MonthNavigator } from "./MonthNavigator";
|
||||||
import { StatCard } from "./StatCard";
|
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 t = useTranslate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
const { memoTypeStats, activityStats } = useStatisticsData();
|
|
||||||
|
const { memoTypeStats, activityStats } = statisticsData;
|
||||||
|
|
||||||
const [selectedDate] = useState(new Date());
|
const [selectedDate] = useState(new Date());
|
||||||
const [visibleMonthString, setVisibleMonthString] = useState(dayjs().format("YYYY-MM"));
|
const [visibleMonthString, setVisibleMonthString] = useState(dayjs().format("YYYY-MM"));
|
||||||
|
|
||||||
|
|
@ -33,6 +53,11 @@ const StatisticsView = observer(() => {
|
||||||
const isRootPath = matchPath(Routes.ROOT, location.pathname);
|
const isRootPath = matchPath(Routes.ROOT, location.pathname);
|
||||||
const hasPinnedMemos = currentUser && (userStore.state.currentUserStats?.pinnedMemos || []).length > 0;
|
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 (
|
return (
|
||||||
<div className="group w-full mt-2 space-y-1 text-muted-foreground animate-fade-in">
|
<div className="group w-full mt-2 space-y-1 text-muted-foreground animate-fade-in">
|
||||||
<MonthNavigator visibleMonth={visibleMonthString} onMonthChange={setVisibleMonthString} />
|
<MonthNavigator visibleMonth={visibleMonthString} onMonthChange={setVisibleMonthString} />
|
||||||
|
|
@ -47,7 +72,7 @@ const StatisticsView = observer(() => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 w-full flex flex-wrap items-center gap-2">
|
<div className="pt-2 w-full flex flex-wrap items-center gap-2">
|
||||||
{isRootPath && hasPinnedMemos && (
|
{shouldShowPinned && (
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={<BookmarkIcon className="opacity-70" />}
|
icon={<BookmarkIcon className="opacity-70" />}
|
||||||
label={t("common.pinned")}
|
label={t("common.pinned")}
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,6 @@ export * from "./useCurrentUser";
|
||||||
export * from "./useNavigateTo";
|
export * from "./useNavigateTo";
|
||||||
export * from "./useAsyncEffect";
|
export * from "./useAsyncEffect";
|
||||||
export * from "./useResponsiveWidth";
|
export * from "./useResponsiveWidth";
|
||||||
|
export * from "./useMemoFilters";
|
||||||
|
export * from "./useMemoSorting";
|
||||||
|
export * from "./useFilteredMemoStats";
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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]);
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
};
|
||||||
|
|
@ -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]);
|
|
||||||
};
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -1,69 +1,36 @@
|
||||||
import dayjs from "dayjs";
|
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { MemoRenderContext } from "@/components/MasonryView";
|
import { MemoRenderContext } from "@/components/MasonryView";
|
||||||
import MemoView from "@/components/MemoView";
|
import MemoView from "@/components/MemoView";
|
||||||
import PagedMemoList from "@/components/PagedMemoList";
|
import PagedMemoList from "@/components/PagedMemoList";
|
||||||
|
import { useMemoFilters, useMemoSorting } from "@/hooks";
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
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 { State } from "@/types/proto/api/v1/common";
|
||||||
import { Memo } from "@/types/proto/api/v1/memo_service";
|
import { Memo } from "@/types/proto/api/v1/memo_service";
|
||||||
import { WorkspaceSetting_Key } from "@/types/proto/api/v1/workspace_service";
|
|
||||||
|
|
||||||
const Archived = observer(() => {
|
const Archived = observer(() => {
|
||||||
const user = useCurrentUser();
|
const user = useCurrentUser();
|
||||||
|
|
||||||
// Build filter from active filters
|
// Build filter using unified hook (no shortcuts or pinned filter)
|
||||||
const buildMemoFilter = () => {
|
const memoFilter = useMemoFilters({
|
||||||
const conditions = [`creator_id == ${extractUserIdFromName(user.name)}`];
|
creatorName: user.name,
|
||||||
for (const filter of memoFilterStore.filters) {
|
includeShortcuts: false,
|
||||||
if (filter.factor === "contentSearch") {
|
includePinned: false,
|
||||||
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();
|
// Get sorting logic using unified hook (pinned first, archived state)
|
||||||
|
const { listSort, orderBy } = useMemoSorting({
|
||||||
|
pinnedFirst: true,
|
||||||
|
state: State.ARCHIVED,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PagedMemoList
|
<PagedMemoList
|
||||||
renderer={(memo: Memo, context?: MemoRenderContext) => (
|
renderer={(memo: Memo, context?: MemoRenderContext) => (
|
||||||
<MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility compact={context?.compact} />
|
<MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility compact={context?.compact} />
|
||||||
)}
|
)}
|
||||||
listSort={(memos: Memo[]) =>
|
listSort={listSort}
|
||||||
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();
|
|
||||||
})
|
|
||||||
}
|
|
||||||
state={State.ARCHIVED}
|
state={State.ARCHIVED}
|
||||||
orderBy={viewStore.state.orderByTimeAsc ? "pinned desc, display_time asc" : "pinned desc, display_time desc"}
|
orderBy={orderBy}
|
||||||
filter={memoFilter}
|
filter={memoFilter}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,71 +1,44 @@
|
||||||
import dayjs from "dayjs";
|
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { MemoRenderContext } from "@/components/MasonryView";
|
import { MemoRenderContext } from "@/components/MasonryView";
|
||||||
import MemoView from "@/components/MemoView";
|
import MemoView from "@/components/MemoView";
|
||||||
import MobileHeader from "@/components/MobileHeader";
|
|
||||||
import PagedMemoList from "@/components/PagedMemoList";
|
import PagedMemoList from "@/components/PagedMemoList";
|
||||||
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
import { useMemoFilters, useMemoSorting } from "@/hooks";
|
||||||
import { viewStore, workspaceStore } from "@/store";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
import memoFilterStore from "@/store/memoFilter";
|
|
||||||
import { State } from "@/types/proto/api/v1/common";
|
import { State } from "@/types/proto/api/v1/common";
|
||||||
import { Memo } from "@/types/proto/api/v1/memo_service";
|
import { Memo, Visibility } from "@/types/proto/api/v1/memo_service";
|
||||||
import { WorkspaceSetting_Key } from "@/types/proto/api/v1/workspace_service";
|
|
||||||
|
|
||||||
const Explore = observer(() => {
|
const Explore = observer(() => {
|
||||||
const { md } = useResponsiveWidth();
|
const currentUser = useCurrentUser();
|
||||||
|
|
||||||
// Build filter from active filters
|
// Determine visibility filter based on authentication status
|
||||||
const buildMemoFilter = () => {
|
// - Logged-in users: Can see PUBLIC and PROTECTED memos
|
||||||
const conditions: string[] = [];
|
// - Visitors: Can only see PUBLIC memos
|
||||||
for (const filter of memoFilterStore.filters) {
|
// Note: The backend is responsible for filtering stats based on visibility permissions.
|
||||||
if (filter.factor === "contentSearch") {
|
const visibilities = currentUser ? [Visibility.PUBLIC, Visibility.PROTECTED] : [Visibility.PUBLIC];
|
||||||
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();
|
// 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 (
|
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">
|
<PagedMemoList
|
||||||
{!md && <MobileHeader />}
|
renderer={(memo: Memo, context?: MemoRenderContext) => (
|
||||||
<div className="w-full px-4 sm:px-6">
|
<MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showCreator showVisibility compact={context?.compact} />
|
||||||
<PagedMemoList
|
)}
|
||||||
renderer={(memo: Memo, context?: MemoRenderContext) => (
|
listSort={listSort}
|
||||||
<MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showCreator showVisibility compact={context?.compact} />
|
orderBy={orderBy}
|
||||||
)}
|
filter={memoFilter}
|
||||||
listSort={(memos: Memo[]) =>
|
showCreator
|
||||||
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"}
|
|
||||||
filter={memoFilter}
|
|
||||||
showCreator
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,27 @@
|
||||||
import dayjs from "dayjs";
|
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { MemoRenderContext } from "@/components/MasonryView";
|
import { MemoRenderContext } from "@/components/MasonryView";
|
||||||
import MemoView from "@/components/MemoView";
|
import MemoView from "@/components/MemoView";
|
||||||
import PagedMemoList from "@/components/PagedMemoList";
|
import PagedMemoList from "@/components/PagedMemoList";
|
||||||
|
import { useMemoFilters, useMemoSorting } from "@/hooks";
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
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 { State } from "@/types/proto/api/v1/common";
|
||||||
import { Memo } from "@/types/proto/api/v1/memo_service";
|
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 Home = observer(() => {
|
||||||
const user = useCurrentUser();
|
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
|
// Build filter using unified hook
|
||||||
const buildMemoFilter = () => {
|
const memoFilter = useMemoFilters({
|
||||||
const conditions = [`creator_id == ${extractUserIdFromName(user.name)}`];
|
creatorName: user.name,
|
||||||
if (selectedShortcut?.filter) {
|
includeShortcuts: true,
|
||||||
conditions.push(selectedShortcut.filter);
|
includePinned: true,
|
||||||
}
|
});
|
||||||
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,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full min-h-full bg-background text-foreground">
|
<div className="w-full min-h-full bg-background text-foreground">
|
||||||
|
|
@ -62,21 +29,8 @@ const Home = observer(() => {
|
||||||
renderer={(memo: Memo, context?: MemoRenderContext) => (
|
renderer={(memo: Memo, context?: MemoRenderContext) => (
|
||||||
<MemoView key={`${memo.name}-${memo.displayTime}`} memo={memo} showVisibility showPinned compact={context?.compact} />
|
<MemoView key={`${memo.name}-${memo.displayTime}`} memo={memo} showVisibility showPinned compact={context?.compact} />
|
||||||
)}
|
)}
|
||||||
listSort={(memos: Memo[]) =>
|
listSort={listSort}
|
||||||
memos
|
orderBy={orderBy}
|
||||||
.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"}
|
|
||||||
filter={memoFilter}
|
filter={memoFilter}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import dayjs from "dayjs";
|
|
||||||
import { ExternalLinkIcon } from "lucide-react";
|
import { ExternalLinkIcon } from "lucide-react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
@ -10,14 +9,12 @@ import MemoView from "@/components/MemoView";
|
||||||
import PagedMemoList from "@/components/PagedMemoList";
|
import PagedMemoList from "@/components/PagedMemoList";
|
||||||
import UserAvatar from "@/components/UserAvatar";
|
import UserAvatar from "@/components/UserAvatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useMemoFilters, useMemoSorting } from "@/hooks";
|
||||||
import useLoading from "@/hooks/useLoading";
|
import useLoading from "@/hooks/useLoading";
|
||||||
import { viewStore, userStore, workspaceStore } from "@/store";
|
import { userStore } from "@/store";
|
||||||
import { extractUserIdFromName } from "@/store/common";
|
|
||||||
import memoFilterStore from "@/store/memoFilter";
|
|
||||||
import { State } from "@/types/proto/api/v1/common";
|
import { State } from "@/types/proto/api/v1/common";
|
||||||
import { Memo } from "@/types/proto/api/v1/memo_service";
|
import { Memo } from "@/types/proto/api/v1/memo_service";
|
||||||
import { User } from "@/types/proto/api/v1/user_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";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
|
|
||||||
const UserProfile = observer(() => {
|
const UserProfile = observer(() => {
|
||||||
|
|
@ -44,40 +41,18 @@ const UserProfile = observer(() => {
|
||||||
});
|
});
|
||||||
}, [params.username]);
|
}, [params.username]);
|
||||||
|
|
||||||
// Build filter from active filters
|
// Build filter using unified hook (no shortcuts, but includes pinned)
|
||||||
const buildMemoFilter = () => {
|
const memoFilter = useMemoFilters({
|
||||||
if (!user) {
|
creatorName: user?.name,
|
||||||
return undefined;
|
includeShortcuts: false,
|
||||||
}
|
includePinned: true,
|
||||||
|
});
|
||||||
|
|
||||||
const conditions = [`creator_id == ${extractUserIdFromName(user.name)}`];
|
// Get sorting logic using unified hook
|
||||||
for (const filter of memoFilterStore.filters) {
|
const { listSort, orderBy } = useMemoSorting({
|
||||||
if (filter.factor === "contentSearch") {
|
pinnedFirst: true,
|
||||||
conditions.push(`content.contains("${filter.value}")`);
|
state: State.NORMAL,
|
||||||
} 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();
|
|
||||||
|
|
||||||
const handleCopyProfileLink = () => {
|
const handleCopyProfileLink = () => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|
@ -113,21 +88,8 @@ const UserProfile = observer(() => {
|
||||||
renderer={(memo: Memo, context?: MemoRenderContext) => (
|
renderer={(memo: Memo, context?: MemoRenderContext) => (
|
||||||
<MemoView key={`${memo.name}-${memo.displayTime}`} memo={memo} showVisibility showPinned compact={context?.compact} />
|
<MemoView key={`${memo.name}-${memo.displayTime}`} memo={memo} showVisibility showPinned compact={context?.compact} />
|
||||||
)}
|
)}
|
||||||
listSort={(memos: Memo[]) =>
|
listSort={listSort}
|
||||||
memos
|
orderBy={orderBy}
|
||||||
.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"}
|
|
||||||
filter={memoFilter}
|
filter={memoFilter}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Suspense, lazy } from "react";
|
import { Suspense, lazy } from "react";
|
||||||
import { createBrowserRouter } from "react-router-dom";
|
import { createBrowserRouter } from "react-router-dom";
|
||||||
import App from "@/App";
|
import App from "@/App";
|
||||||
import HomeLayout from "@/layouts/HomeLayout";
|
import MainLayout from "@/layouts/MainLayout";
|
||||||
import RootLayout from "@/layouts/RootLayout";
|
import RootLayout from "@/layouts/RootLayout";
|
||||||
import Home from "@/pages/Home";
|
import Home from "@/pages/Home";
|
||||||
import Loading from "@/pages/Loading";
|
import Loading from "@/pages/Loading";
|
||||||
|
|
@ -78,12 +78,20 @@ const router = createBrowserRouter([
|
||||||
element: <RootLayout />,
|
element: <RootLayout />,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
element: <HomeLayout />,
|
element: <MainLayout />,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "",
|
path: "",
|
||||||
element: <Home />,
|
element: <Home />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: Routes.EXPLORE,
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<Explore />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: Routes.ARCHIVED,
|
path: Routes.ARCHIVED,
|
||||||
element: (
|
element: (
|
||||||
|
|
@ -102,14 +110,6 @@ const router = createBrowserRouter([
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: Routes.EXPLORE,
|
|
||||||
element: (
|
|
||||||
<Suspense fallback={<Loading />}>
|
|
||||||
<Explore />
|
|
||||||
</Suspense>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: Routes.ATTACHMENTS,
|
path: Routes.ATTACHMENTS,
|
||||||
element: (
|
element: (
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue