mirror of https://github.com/usememos/memos
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>pull/5224/head
parent
d30ff2898f
commit
1d7efb1580
@ -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;
|
||||
Loading…
Reference in New Issue