mirror of
https://github.com/usememos/memos.git
synced 2025-02-15 02:40:53 +01:00
chore: add statistics view
This commit is contained in:
parent
138b69e36e
commit
914c0620c4
@ -1,14 +1,15 @@
|
||||
import MemoCreationHeatMap from "./MemoCreationHeatMap";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import PersonalStatistics from "./PersonalStatistics";
|
||||
import SearchBar from "./SearchBar";
|
||||
import TagList from "./TagList";
|
||||
|
||||
const HomeSidebar = () => {
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
return (
|
||||
<aside className="relative w-full pr-2 h-full max-h-screen overflow-auto hide-scrollbar flex flex-col justify-start items-start py-4 sm:pt-6">
|
||||
<div className="px-4 pr-8 mb-4 w-full">
|
||||
<SearchBar />
|
||||
</div>
|
||||
<MemoCreationHeatMap />
|
||||
<aside className="relative w-full px-4 h-full max-h-screen overflow-auto hide-scrollbar flex flex-col justify-start items-start py-4 sm:pt-6">
|
||||
<SearchBar />
|
||||
<PersonalStatistics user={currentUser} />
|
||||
<TagList />
|
||||
</aside>
|
||||
);
|
||||
|
@ -26,7 +26,7 @@ const HomeSidebarDrawer = () => {
|
||||
<Icon.Search className="w-5 h-auto dark:text-gray-200" />
|
||||
</IconButton>
|
||||
<Drawer anchor="right" size="sm" open={open} onClose={toggleDrawer(false)}>
|
||||
<div className="w-full px-4">
|
||||
<div className="w-full px-1">
|
||||
<HomeSidebar />
|
||||
</div>
|
||||
</Drawer>
|
||||
|
@ -1,156 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { memoServiceClient } from "@/grpcweb";
|
||||
import { DAILY_TIMESTAMP } from "@/helpers/consts";
|
||||
import { getDateStampByDate, getDateString, getTimeStampByDate } from "@/helpers/datetime";
|
||||
import * as utils from "@/helpers/utils";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { useGlobalStore } from "@/store/module";
|
||||
import { useMemoStore } from "@/store/v1";
|
||||
import { useTranslate, Translations } from "@/utils/i18n";
|
||||
import "@/less/usage-heat-map.less";
|
||||
|
||||
interface DailyUsageStat {
|
||||
timestamp: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const tableConfig = {
|
||||
width: 10,
|
||||
height: 7,
|
||||
};
|
||||
|
||||
const getInitialCreationStats = (usedDaysAmount: number, beginDayTimestamp: number): DailyUsageStat[] => {
|
||||
const initialUsageStat: DailyUsageStat[] = [];
|
||||
for (let i = 1; i <= usedDaysAmount; i++) {
|
||||
initialUsageStat.push({
|
||||
timestamp: beginDayTimestamp + DAILY_TIMESTAMP * i,
|
||||
count: 0,
|
||||
});
|
||||
}
|
||||
return initialUsageStat;
|
||||
};
|
||||
|
||||
const MemoCreationHeatMap = () => {
|
||||
const t = useTranslate();
|
||||
const navigateTo = useNavigateTo();
|
||||
const user = useCurrentUser();
|
||||
const memoStore = useMemoStore();
|
||||
const todayTimeStamp = getDateStampByDate(Date.now());
|
||||
const weekDay = new Date(todayTimeStamp).getDay();
|
||||
const weekFromMonday = ["zh-Hans", "ko"].includes(useGlobalStore().state.locale);
|
||||
const dayTips = weekFromMonday ? ["mon", "", "wed", "", "fri", "", "sun"] : ["sun", "", "tue", "", "thu", "", "sat"];
|
||||
const todayDay = weekFromMonday ? (weekDay == 0 ? 7 : weekDay) : weekDay + 1;
|
||||
const nullCell = new Array(7 - todayDay).fill(0);
|
||||
const usedDaysAmount = (tableConfig.width - 1) * tableConfig.height + todayDay;
|
||||
const beginDayTimestamp = todayTimeStamp - usedDaysAmount * DAILY_TIMESTAMP;
|
||||
const [memoAmount, setMemoAmount] = useState(0);
|
||||
const [creationStatus, setCreationStatus] = useState<DailyUsageStat[]>(getInitialCreationStats(usedDaysAmount, beginDayTimestamp));
|
||||
const containerElRef = useRef<HTMLDivElement>(null);
|
||||
const memos = Object.values(memoStore.getState().memoMapById);
|
||||
const createdDays = Math.ceil((Date.now() - getTimeStampByDate(user.createTime)) / 1000 / 3600 / 24);
|
||||
|
||||
useEffect(() => {
|
||||
if (memos.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const { memoCreationStats } = await memoServiceClient.getUserMemosStats({
|
||||
name: user.name,
|
||||
});
|
||||
const tempStats = getInitialCreationStats(usedDaysAmount, beginDayTimestamp);
|
||||
Object.entries(memoCreationStats).forEach(([k, v]) => {
|
||||
const dayIndex = Math.floor((getDateStampByDate(k) - beginDayTimestamp) / DAILY_TIMESTAMP) - 1;
|
||||
if (tempStats[dayIndex]) {
|
||||
tempStats[dayIndex].count = v;
|
||||
}
|
||||
});
|
||||
setCreationStatus(tempStats);
|
||||
setMemoAmount(Object.values(memoCreationStats).reduce((acc, cur) => acc + cur, 0));
|
||||
})();
|
||||
}, [memos.length, user.name]);
|
||||
|
||||
const handleUsageStatItemMouseEnter = useCallback((event: React.MouseEvent, item: DailyUsageStat) => {
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.className = "usage-detail-container pop-up";
|
||||
const bounding = utils.getElementBounding(event.target as HTMLElement);
|
||||
tempDiv.style.left = bounding.left + "px";
|
||||
tempDiv.style.top = bounding.top - 2 + "px";
|
||||
const tMemoOnOpts = { amount: item.count, date: getDateString(item.timestamp as number) };
|
||||
tempDiv.innerHTML = item.count === 1 ? t("heatmap.memo-on", tMemoOnOpts) : t("heatmap.memos-on", tMemoOnOpts);
|
||||
document.body.appendChild(tempDiv);
|
||||
|
||||
if (tempDiv.offsetLeft - tempDiv.clientWidth / 2 < 0) {
|
||||
tempDiv.style.left = bounding.left + tempDiv.clientWidth * 0.4 + "px";
|
||||
tempDiv.className += " offset-left";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleUsageStatItemMouseLeave = useCallback(() => {
|
||||
document.body.querySelectorAll("div.usage-detail-container.pop-up").forEach((node) => node.remove());
|
||||
}, []);
|
||||
|
||||
const handleUsageStatItemClick = useCallback((item: DailyUsageStat) => {
|
||||
navigateTo(`/timeline?timestamp=${item.timestamp}`);
|
||||
}, []);
|
||||
|
||||
// This interpolation is not being used because of the current styling,
|
||||
// but it can improve translation quality by giving it a more meaningful context
|
||||
const tMemoInOpts = { amount: memoAmount, period: "", date: "" };
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="usage-heat-map-wrapper" ref={containerElRef}>
|
||||
<div className="usage-heat-map">
|
||||
{}
|
||||
{creationStatus.map((v, i) => {
|
||||
const count = v.count;
|
||||
const colorLevel =
|
||||
count <= 0
|
||||
? ""
|
||||
: count <= 1
|
||||
? "stat-day-l1-bg"
|
||||
: count <= 2
|
||||
? "stat-day-l2-bg"
|
||||
: count <= 4
|
||||
? "stat-day-l3-bg"
|
||||
: "stat-day-l4-bg";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="stat-wrapper"
|
||||
key={i}
|
||||
onMouseEnter={(e) => handleUsageStatItemMouseEnter(e, v)}
|
||||
onMouseLeave={handleUsageStatItemMouseLeave}
|
||||
onClick={() => handleUsageStatItemClick(v)}
|
||||
>
|
||||
<span className={`stat-container ${colorLevel} ${todayTimeStamp === v.timestamp ? "today" : ""}`}></span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{nullCell.map((_, i) => (
|
||||
<div className="stat-wrapper" key={i}>
|
||||
<span className="stat-container null"></span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="day-tip-text-container">
|
||||
{dayTips.map((v, i) => (
|
||||
<span className="tip-text" key={i}>
|
||||
{v && t(("days." + v) as Translations)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="w-full pl-4 text-xs -mt-2 mb-3 text-gray-400 dark:text-zinc-400">
|
||||
<span className="font-medium text-gray-500 dark:text-zinc-300 number">{memoAmount} </span>
|
||||
{memoAmount === 1 ? t("heatmap.memo-in", tMemoInOpts) : t("heatmap.memos-in", tMemoInOpts)}{" "}
|
||||
<span className="font-medium text-gray-500 dark:text-zinc-300">{createdDays} </span>
|
||||
{createdDays === 1 ? t("heatmap.day", tMemoInOpts) : t("heatmap.days", tMemoInOpts)}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoCreationHeatMap;
|
@ -31,6 +31,7 @@ const MemoResourceListView = ({ resourceList = [] }: { resourceList: Resource[]
|
||||
const MediaCard = ({ resource, thumbnail }: { resource: Resource; thumbnail?: boolean }) => {
|
||||
const type = getResourceType(resource);
|
||||
const url = getResourceUrl(resource);
|
||||
|
||||
if (type === "image/*") {
|
||||
return (
|
||||
<img
|
||||
@ -40,9 +41,7 @@ const MemoResourceListView = ({ resourceList = [] }: { resourceList: Resource[]
|
||||
decoding="async"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "video/*") {
|
||||
} else if (type === "video/*") {
|
||||
return (
|
||||
<video
|
||||
className="cursor-pointer w-full h-full object-contain bg-zinc-100 dark:bg-zinc-800"
|
||||
@ -52,9 +51,9 @@ const MemoResourceListView = ({ resourceList = [] }: { resourceList: Resource[]
|
||||
controls
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
const MediaList = ({ resources = [] }: { resources: Resource[] }) => {
|
||||
|
69
web/src/components/PersonalStatistics.tsx
Normal file
69
web/src/components/PersonalStatistics.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { memoServiceClient } from "@/grpcweb";
|
||||
import { useTagStore } from "@/store/module";
|
||||
import { useMemoStore } from "@/store/v1";
|
||||
import { User } from "@/types/proto/api/v2/user_service";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
|
||||
const PersonalStatistics = (props: Props) => {
|
||||
const { user } = props;
|
||||
const tagStore = useTagStore();
|
||||
const memoStore = useMemoStore();
|
||||
const [memoAmount, setMemoAmount] = useState(0);
|
||||
const [isRequesting, setIsRequesting] = useState(false);
|
||||
const days = Math.ceil((Date.now() - user.createTime!.getTime()) / 86400000);
|
||||
const memos = Object.values(memoStore.getState().memoMapById);
|
||||
const tags = tagStore.state.tags.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (memos.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
setIsRequesting(true);
|
||||
const { memoCreationStats } = await memoServiceClient.getUserMemosStats({
|
||||
name: user.name,
|
||||
});
|
||||
setIsRequesting(false);
|
||||
setMemoAmount(Object.values(memoCreationStats).reduce((acc, cur) => acc + cur, 0));
|
||||
})();
|
||||
}, [memos.length, user.name]);
|
||||
|
||||
return (
|
||||
<div className="w-full border mt-2 py-2 px-3 rounded-md space-y-0.5 bg-zinc-50 dark:bg-zinc-900 dark:border-zinc-800">
|
||||
<p className="text-sm font-medium text-gray-500">Statistics</p>
|
||||
<div className="w-full flex justify-between items-center">
|
||||
<div className="w-full flex justify-start items-center text-gray-500">
|
||||
<Icon.CalendarDays className="w-4 h-auto mr-1" />
|
||||
<span className="block text-base sm:text-sm">Days</span>
|
||||
</div>
|
||||
<span className="text-gray-500 font-mono">{days}</span>
|
||||
</div>
|
||||
<div className="w-full flex justify-between items-center">
|
||||
<div className="w-full flex justify-start items-center text-gray-500">
|
||||
<Icon.PencilLine className="w-4 h-auto mr-1" />
|
||||
<span className="block text-base sm:text-sm">Memos</span>
|
||||
</div>
|
||||
{isRequesting ? (
|
||||
<Icon.Loader className="animate-spin w-4 h-auto text-gray-400" />
|
||||
) : (
|
||||
<span className="text-gray-500 font-mono">{memoAmount}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full flex justify-between items-center">
|
||||
<div className="w-full flex justify-start items-center text-gray-500">
|
||||
<Icon.Hash className="w-4 h-auto mr-1" />
|
||||
<span className="block text-base sm:text-sm">Tags</span>
|
||||
</div>
|
||||
<span className="text-gray-500 font-mono">{tags}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersonalStatistics;
|
@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Input } from "@mui/joy";
|
||||
import { useEffect, useState } from "react";
|
||||
import useDebounce from "react-use/lib/useDebounce";
|
||||
import { useFilterStore } from "@/store/module";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
@ -8,7 +9,6 @@ const SearchBar = () => {
|
||||
const t = useTranslate();
|
||||
const filterStore = useFilterStore();
|
||||
const [queryText, setQueryText] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const text = filterStore.getState().text;
|
||||
@ -28,13 +28,12 @@ const SearchBar = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-9 flex flex-row justify-start items-center py-2 px-3 rounded-md bg-gray-200 dark:bg-zinc-700">
|
||||
<Icon.Search className="w-4 h-auto opacity-30 dark:text-gray-200" />
|
||||
<input
|
||||
className="flex ml-2 w-24 grow text-sm outline-none bg-transparent dark:text-gray-200"
|
||||
type="text"
|
||||
<div className="w-full h-9 flex flex-row justify-start items-center">
|
||||
<Input
|
||||
className="w-full !shadow-none !border-gray-200 dark:!border-zinc-800"
|
||||
size="md"
|
||||
startDecorator={<Icon.Search className="w-4 h-auto opacity-30" />}
|
||||
placeholder={t("memo.search-placeholder")}
|
||||
ref={inputRef}
|
||||
value={queryText}
|
||||
onChange={handleTextQueryInput}
|
||||
/>
|
||||
|
@ -70,8 +70,8 @@ const TagList = () => {
|
||||
}, [tagsText]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-start items-start w-full mt-2 h-auto shrink-0 flex-nowrap hide-scrollbar">
|
||||
<div className="flex flex-row justify-start items-center w-full px-4">
|
||||
<div className="flex flex-col justify-start items-start w-full mt-3 px-1 h-auto shrink-0 flex-nowrap hide-scrollbar">
|
||||
<div className="flex flex-row justify-start items-center w-full">
|
||||
<span className="text-sm leading-6 font-mono text-gray-400">{t("common.tags")}</span>
|
||||
<button
|
||||
onClick={() => showCreateTagDialog()}
|
||||
@ -80,7 +80,7 @@ const TagList = () => {
|
||||
<Icon.Plus className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col justify-start items-start relative w-full h-auto flex-nowrap mt-2 mb-2">
|
||||
<div className="flex flex-col justify-start items-start relative w-full h-auto flex-nowrap">
|
||||
{tags.map((t, idx) => (
|
||||
<TagItemContainer key={t.text + "-" + idx} tag={t} tagQuery={filter.tag} />
|
||||
))}
|
||||
@ -117,15 +117,15 @@ const TagItemContainer: React.FC<TagItemContainerProps> = (props: TagItemContain
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="relative group flex flex-row justify-between items-center w-full h-10 py-0 px-4 mt-px first:mt-1 rounded-lg text-base cursor-pointer select-none shrink-0 hover:opacity-60"
|
||||
className="relative group flex flex-row justify-between items-center w-full h-8 py-0 mt-px first:mt-1 rounded-lg text-base sm:text-sm cursor-pointer select-none shrink-0 hover:opacity-80"
|
||||
onClick={handleTagClick}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-row justify-start items-center truncate shrink leading-5 mr-1 text-black dark:text-gray-200 ${
|
||||
className={`flex flex-row justify-start items-center truncate shrink leading-5 mr-1 text-gray-600 dark:text-gray-400 ${
|
||||
isActive && "text-green-600"
|
||||
}`}
|
||||
>
|
||||
<span className="block w-4 shrink-0">#</span>
|
||||
<Icon.Hash className="w-4 h-auto shrink-0 opacity-60 mr-1" />
|
||||
<span className="truncate">{tag.key}</span>
|
||||
</div>
|
||||
<div className="flex flex-row justify-end items-center">
|
||||
@ -141,7 +141,7 @@ const TagItemContainer: React.FC<TagItemContainerProps> = (props: TagItemContain
|
||||
</div>
|
||||
{hasSubTags ? (
|
||||
<div
|
||||
className={`w-[calc(100%-1rem)] flex flex-col justify-start items-start h-auto ml-4 pl-1 border-l-2 border-l-gray-200 dark:border-l-gray-400 ${
|
||||
className={`w-[calc(100%-0.5rem)] flex flex-col justify-start items-start h-auto ml-2 pl-2 border-l-2 border-l-gray-200 dark:border-l-gray-400 ${
|
||||
!showSubTags && "!hidden"
|
||||
}`}
|
||||
>
|
||||
|
@ -1,4 +1,3 @@
|
||||
import Icon from "@/components/Icon";
|
||||
import MemoContent from "@/components/MemoContent";
|
||||
import MemoResourceListView from "@/components/MemoResourceListView";
|
||||
import { getTimeString } from "@/helpers/datetime";
|
||||
@ -18,8 +17,6 @@ const TimelineMemo = (props: Props) => {
|
||||
<div className="relative w-full flex flex-col justify-start items-start">
|
||||
<div className="w-full flex flex-row justify-start items-center mt-0.5 mb-1 text-sm font-mono text-gray-500 dark:text-gray-400">
|
||||
<span className="opacity-80">{getTimeString(memo.displayTime)}</span>
|
||||
<Icon.Dot className="w-5 h-auto opacity-60" />
|
||||
<span className="opacity-60">#{memo.id}</span>
|
||||
</div>
|
||||
<MemoContent nodes={memo.nodes} />
|
||||
<MemoResourceListView resourceList={memo.resources} />
|
||||
|
@ -1,73 +0,0 @@
|
||||
.usage-heat-map-wrapper {
|
||||
@apply flex flex-row justify-start items-center flex-nowrap w-full h-32 pl-4 pb-3 shrink-0;
|
||||
|
||||
> .usage-heat-map {
|
||||
@apply w-full h-full grid grid-rows-7 grid-cols-10 grid-flow-col;
|
||||
|
||||
> .stat-wrapper {
|
||||
> .stat-container {
|
||||
@apply block rounded bg-gray-200 dark:bg-zinc-700;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
|
||||
&.stat-day-l1-bg {
|
||||
@apply bg-green-400 dark:bg-green-800;
|
||||
}
|
||||
|
||||
&.stat-day-l2-bg {
|
||||
@apply bg-green-500 dark:bg-green-700;
|
||||
}
|
||||
|
||||
&.stat-day-l3-bg {
|
||||
@apply bg-green-600 dark:bg-green-600;
|
||||
}
|
||||
|
||||
&.stat-day-l4-bg {
|
||||
@apply bg-green-700 dark:bg-green-500;
|
||||
}
|
||||
|
||||
&.today {
|
||||
@apply border border-black dark:border-gray-400;
|
||||
}
|
||||
|
||||
&.null {
|
||||
@apply opacity-40;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .day-tip-text-container {
|
||||
@apply w-8 h-full grid grid-rows-7;
|
||||
|
||||
> .tip-text {
|
||||
@apply pl-1 w-full h-full text-left font-mono text-gray-400;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.usage-detail-container {
|
||||
@apply fixed left-0 top-0 ml-2 -mt-9 p-2 z-100 -translate-x-1/2 select-none text-white text-xs rounded whitespace-nowrap;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
|
||||
> .date-text {
|
||||
@apply text-gray-300;
|
||||
}
|
||||
|
||||
&.offset-left {
|
||||
&::before {
|
||||
left: calc(10% - 5px);
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
left: calc(50% - 5px);
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 4px solid rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
}
|
@ -13,6 +13,7 @@ import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
||||
import { useFilterStore } from "@/store/module";
|
||||
import { useMemoList, useMemoStore } from "@/store/v1";
|
||||
import { RowStatus } from "@/types/proto/api/v2/common";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
const Home = () => {
|
||||
@ -26,6 +27,7 @@ const Home = () => {
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
const { tag: tagQuery, text: textQuery } = filterStore.state;
|
||||
const sortedMemos = memoList.value
|
||||
.filter((memo) => memo.rowStatus === RowStatus.ACTIVE)
|
||||
.sort((a, b) => getTimeStampByDate(b.displayTime) - getTimeStampByDate(a.displayTime))
|
||||
.sort((a, b) => Number(b.pinned) - Number(a.pinned));
|
||||
|
||||
@ -62,7 +64,7 @@ const Home = () => {
|
||||
<MobileHeader>{!md && <HomeSidebarDrawer />}</MobileHeader>
|
||||
<div className="w-full px-4 sm:px-6 md:pr-2">
|
||||
<MemoEditor className="mb-2" cacheKey="home-memo-editor" />
|
||||
<div className="flex flex-col justify-start items-start w-full max-w-full overflow-y-scroll pb-28 hide-scrollbar">
|
||||
<div className="flex flex-col justify-start items-start w-full max-w-full pb-28">
|
||||
<MemoFilter />
|
||||
{sortedMemos.map((memo) => (
|
||||
<MemoView key={`${memo.id}-${memo.updateTime}`} memo={memo} showVisibility showPinnedStyle showParent />
|
||||
|
Loading…
x
Reference in New Issue
Block a user