From 1d7efb158045924ab87f58d567bd79cd92edf6af Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 5 Nov 2025 08:46:52 +0800 Subject: [PATCH] refactor(web): unify memo stats/filters with context-aware MainLayout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../MasonryView/useMasonryLayout.ts | 4 +- .../ActionButton/VisibilitySelector.tsx | 6 +- web/src/components/MemoEditor/index.tsx | 18 ++- .../components/MemoExplorer/MemoExplorer.tsx | 119 +++++++++++++- .../MemoExplorer/MemoExplorerDrawer.tsx | 30 +++- .../components/MemoExplorer/TagsSection.tsx | 9 +- web/src/components/MemoExplorer/index.ts | 1 + .../StatisticsView/StatisticsView.tsx | 33 +++- web/src/hooks/index.ts | 3 + web/src/hooks/useFilteredMemoStats.ts | 135 ++++++++++++++++ web/src/hooks/useMemoFilters.ts | 148 ++++++++++++++++++ web/src/hooks/useMemoSorting.ts | 85 ++++++++++ web/src/hooks/useStatisticsData.ts | 27 ---- web/src/layouts/HomeLayout.tsx | 55 ------- web/src/layouts/MainLayout.tsx | 90 +++++++++++ web/src/pages/Archived.tsx | 61 ++------ web/src/pages/Explore.tsx | 87 ++++------ web/src/pages/Home.tsx | 74 ++------- web/src/pages/UserProfile.tsx | 68 ++------ web/src/router/index.tsx | 20 +-- 20 files changed, 736 insertions(+), 337 deletions(-) create mode 100644 web/src/hooks/useFilteredMemoStats.ts create mode 100644 web/src/hooks/useMemoFilters.ts create mode 100644 web/src/hooks/useMemoSorting.ts delete mode 100644 web/src/hooks/useStatisticsData.ts delete mode 100644 web/src/layouts/HomeLayout.tsx create mode 100644 web/src/layouts/MainLayout.tsx diff --git a/web/src/components/MasonryView/useMasonryLayout.ts b/web/src/components/MasonryView/useMasonryLayout.ts index c45daded2..12b7ad6d1 100644 --- a/web/src/components/MasonryView/useMasonryLayout.ts +++ b/web/src/components/MasonryView/useMasonryLayout.ts @@ -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]); /** diff --git a/web/src/components/MemoEditor/ActionButton/VisibilitySelector.tsx b/web/src/components/MemoEditor/ActionButton/VisibilitySelector.tsx index b2dfa4340..60a5a4c3e 100644 --- a/web/src/components/MemoEditor/ActionButton/VisibilitySelector.tsx +++ b/web/src/components/MemoEditor/ActionButton/VisibilitySelector.tsx @@ -25,10 +25,10 @@ const VisibilitySelector = (props: Props) => { return ( - diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index 166ad4af4..5e7d4b080 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -514,16 +514,18 @@ const MemoEditor = observer((props: Props) => { } /> -
+
handleMemoVisibilityChange(visibility)} /> - {props.onCancel && ( - + )} + - )} - +
diff --git a/web/src/components/MemoExplorer/MemoExplorer.tsx b/web/src/components/MemoExplorer/MemoExplorer.tsx index 4508520de..19fafedf0 100644 --- a/web/src/components/MemoExplorer/MemoExplorer.tsx +++ b/web/src/components/MemoExplorer/MemoExplorer.tsx @@ -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; +} + +/** + * 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 ( ); diff --git a/web/src/components/MemoExplorer/MemoExplorerDrawer.tsx b/web/src/components/MemoExplorer/MemoExplorerDrawer.tsx index 25558e38d..fb96b6284 100644 --- a/web/src/components/MemoExplorer/MemoExplorerDrawer.tsx +++ b/web/src/components/MemoExplorer/MemoExplorerDrawer.tsx @@ -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; +} + +const MemoExplorerDrawer = (props: Props) => { + const { context, features, statisticsData, tagCount } = props; const location = useLocation(); const [open, setOpen] = useState(false); @@ -24,7 +48,7 @@ const MemoExplorerDrawer = () => { - + ); diff --git a/web/src/components/MemoExplorer/TagsSection.tsx b/web/src/components/MemoExplorer/TagsSection.tsx index 30466c42d..6317f57e0 100644 --- a/web/src/components/MemoExplorer/TagsSection.tsx +++ b/web/src/components/MemoExplorer/TagsSection.tsx @@ -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; } const TagsSection = observer((props: Props) => { const t = useTranslate(); const [treeMode, setTreeMode] = useLocalStorage("tag-view-as-tree", false); const [treeAutoExpand, setTreeAutoExpand] = useLocalStorage("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]); diff --git a/web/src/components/MemoExplorer/index.ts b/web/src/components/MemoExplorer/index.ts index 5be9a70cb..c5df0ee94 100644 --- a/web/src/components/MemoExplorer/index.ts +++ b/web/src/components/MemoExplorer/index.ts @@ -2,3 +2,4 @@ import MemoExplorer from "./MemoExplorer"; import MemoExplorerDrawer from "./MemoExplorerDrawer"; export { MemoExplorer, MemoExplorerDrawer }; +export type { MemoExplorerContext, MemoExplorerFeatures } from "./MemoExplorer"; diff --git a/web/src/components/StatisticsView/StatisticsView.tsx b/web/src/components/StatisticsView/StatisticsView.tsx index 387a97a7c..6e3aa4b4c 100644 --- a/web/src/components/StatisticsView/StatisticsView.tsx +++ b/web/src/components/StatisticsView/StatisticsView.tsx @@ -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 (
@@ -47,7 +72,7 @@ const StatisticsView = observer(() => {
- {isRootPath && hasPinnedMemos && ( + {shouldShowPinned && ( } label={t("common.pinned")} diff --git a/web/src/hooks/index.ts b/web/src/hooks/index.ts index eec8f2143..35ed47ff0 100644 --- a/web/src/hooks/index.ts +++ b/web/src/hooks/index.ts @@ -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"; diff --git a/web/src/hooks/useFilteredMemoStats.ts b/web/src/hooks/useFilteredMemoStats.ts new file mode 100644 index 000000000..0576ac972 --- /dev/null +++ b/web/src/hooks/useFilteredMemoStats.ts @@ -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; + 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({ + 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 = {}; + + 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; +}; diff --git a/web/src/hooks/useMemoFilters.ts b/web/src/hooks/useMemoFilters.ts new file mode 100644 index 000000000..e3cd4e51e --- /dev/null +++ b/web/src/hooks/useMemoFilters.ts @@ -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]); +}; diff --git a/web/src/hooks/useMemoSorting.ts b/web/src/hooks/useMemoSorting.ts new file mode 100644 index 000000000..842ed1981 --- /dev/null +++ b/web/src/hooks/useMemoSorting.ts @@ -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 }; +}; diff --git a/web/src/hooks/useStatisticsData.ts b/web/src/hooks/useStatisticsData.ts deleted file mode 100644 index 11eee8da1..000000000 --- a/web/src/hooks/useStatisticsData.ts +++ /dev/null @@ -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]); -}; diff --git a/web/src/layouts/HomeLayout.tsx b/web/src/layouts/HomeLayout.tsx deleted file mode 100644 index c56faa88c..000000000 --- a/web/src/layouts/HomeLayout.tsx +++ /dev/null @@ -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 ( -
- {!md && ( - - - - )} - {md && ( -
- -
- )} -
-
- -
-
-
- ); -}); - -export default HomeLayout; diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx new file mode 100644 index 000000000..d9377e29d --- /dev/null +++ b/web/src/layouts/MainLayout.tsx @@ -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 ( +
+ {!md && ( + + + + )} + {md && ( +
+ +
+ )} +
+
+ +
+
+
+ ); +}); + +export default MainLayout; diff --git a/web/src/pages/Archived.tsx b/web/src/pages/Archived.tsx index 884312f52..4fa5f6514 100644 --- a/web/src/pages/Archived.tsx +++ b/web/src/pages/Archived.tsx @@ -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 ( ( )} - 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} /> ); diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index 7aeee0974..adeeb1021 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -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 ( -
- {!md && } -
- ( - - )} - 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"} - filter={memoFilter} - showCreator - /> -
-
+ ( + + )} + listSort={listSort} + orderBy={orderBy} + filter={memoFilter} + showCreator + /> ); }); diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index b9b777df6..93b39e0fe 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -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 (
@@ -62,21 +29,8 @@ const Home = observer(() => { renderer={(memo: Memo, context?: MemoRenderContext) => ( )} - 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} />
diff --git a/web/src/pages/UserProfile.tsx b/web/src/pages/UserProfile.tsx index 6cea26396..0a2110ac1 100644 --- a/web/src/pages/UserProfile.tsx +++ b/web/src/pages/UserProfile.tsx @@ -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) => ( )} - 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} /> diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index a1e0ecf32..d764b3c66 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -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: , children: [ { - element: , + element: , children: [ { path: "", element: , }, + { + path: Routes.EXPLORE, + element: ( + }> + + + ), + }, { path: Routes.ARCHIVED, element: ( @@ -102,14 +110,6 @@ const router = createBrowserRouter([ }, ], }, - { - path: Routes.EXPLORE, - element: ( - }> - - - ), - }, { path: Routes.ATTACHMENTS, element: (