mirror of
https://github.com/usememos/memos.git
synced 2025-06-05 22:09:59 +02:00
feat: implement masonry view
This commit is contained in:
66
web/src/components/MasonryView/MasonryView.tsx
Normal file
66
web/src/components/MasonryView/MasonryView.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service";
|
||||
import { cn } from "@/utils";
|
||||
|
||||
interface Props {
|
||||
memoList: Memo[];
|
||||
renderer: (memo: Memo) => JSX.Element;
|
||||
prefixElement?: JSX.Element;
|
||||
listMode?: boolean;
|
||||
}
|
||||
|
||||
interface LocalState {
|
||||
columns: number;
|
||||
}
|
||||
|
||||
const MINIMUM_MEMO_VIEWPORT_WIDTH = 512;
|
||||
|
||||
const MasonryView = (props: Props) => {
|
||||
const [state, setState] = useState<LocalState>({
|
||||
columns: 1,
|
||||
});
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
if (props.listMode) {
|
||||
setState({
|
||||
columns: 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const containerWidth = containerRef.current.offsetWidth;
|
||||
const scale = containerWidth / MINIMUM_MEMO_VIEWPORT_WIDTH;
|
||||
setState({
|
||||
columns: scale > 2 ? Math.floor(scale) : 1,
|
||||
});
|
||||
};
|
||||
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [props.listMode]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("w-full grid gap-2")}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${state.columns}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: state.columns }).map((_, columnIndex) => (
|
||||
<div key={columnIndex} className="min-w-0 mx-auto w-full max-w-2xl">
|
||||
{props.prefixElement && columnIndex === 0 && <div className="mb-2">{props.prefixElement}</div>}
|
||||
{props.memoList.filter((_, index) => index % state.columns === columnIndex).map((memo) => props.renderer(memo))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MasonryView;
|
3
web/src/components/MasonryView/index.ts
Normal file
3
web/src/components/MasonryView/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import MasonryView from "./MasonryView";
|
||||
|
||||
export default MasonryView;
|
@@ -1,5 +1,6 @@
|
||||
import { Option, Select } from "@mui/joy";
|
||||
import { Option, Select, Switch } from "@mui/joy";
|
||||
import { Settings2Icon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useMemoFilterStore } from "@/store/v1";
|
||||
import { cn } from "@/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
@@ -9,10 +10,10 @@ interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MemoDisplaySettingMenu = ({ className }: Props) => {
|
||||
const MemoDisplaySettingMenu = observer(({ className }: Props) => {
|
||||
const t = useTranslate();
|
||||
const memoFilterStore = useMemoFilterStore();
|
||||
const isApplying = Boolean(memoFilterStore.orderByTimeAsc) !== false;
|
||||
const isApplying = Boolean(memoFilterStore.orderByTimeAsc) !== false || memoFilterStore.masonry;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
@@ -36,10 +37,14 @@ const MemoDisplaySettingMenu = ({ className }: Props) => {
|
||||
<Option value={true}>{t("memo.direction-asc")}</Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<span className="text-sm shrink-0 mr-3">Masonry View</span>
|
||||
<Switch checked={memoFilterStore.masonry} onChange={(event) => memoFilterStore.setMasonry(event.target.checked)} />
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default MemoDisplaySettingMenu;
|
||||
|
@@ -1,14 +1,19 @@
|
||||
import { Button } from "@usememos/mui";
|
||||
import { ArrowDownIcon, ArrowUpIcon, LoaderIcon, SlashIcon } from "lucide-react";
|
||||
import { ArrowDownIcon, ArrowUpIcon, LoaderIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import { matchPath } from "react-router-dom";
|
||||
import PullToRefresh from "react-simple-pull-to-refresh";
|
||||
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
|
||||
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
||||
import { useMemoList, useMemoStore } from "@/store/v1";
|
||||
import { Routes } from "@/router";
|
||||
import { useMemoFilterStore, useMemoList, useMemoStore } from "@/store/v1";
|
||||
import { Direction, State } from "@/types/proto/api/v1/common";
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import Empty from "../Empty";
|
||||
import MasonryView from "../MasonryView";
|
||||
import MemoEditor from "../MemoEditor";
|
||||
|
||||
interface Props {
|
||||
renderer: (memo: Memo) => JSX.Element;
|
||||
@@ -26,16 +31,18 @@ interface LocalState {
|
||||
nextPageToken: string;
|
||||
}
|
||||
|
||||
const PagedMemoList = (props: Props) => {
|
||||
const PagedMemoList = observer((props: Props) => {
|
||||
const t = useTranslate();
|
||||
const { md } = useResponsiveWidth();
|
||||
const memoStore = useMemoStore();
|
||||
const memoList = useMemoList();
|
||||
const memoFilterStore = useMemoFilterStore();
|
||||
const [state, setState] = useState<LocalState>({
|
||||
isRequesting: true, // Initial request
|
||||
nextPageToken: "",
|
||||
});
|
||||
const sortedMemoList = props.listSort ? props.listSort(memoList.value) : memoList.value;
|
||||
const showMemoEditor = Boolean(matchPath(Routes.ROOT, window.location.pathname));
|
||||
|
||||
const fetchMoreMemos = async (nextPageToken: string) => {
|
||||
setState((state) => ({ ...state, isRequesting: true }));
|
||||
@@ -66,7 +73,12 @@ const PagedMemoList = (props: Props) => {
|
||||
|
||||
const children = (
|
||||
<div className="flex flex-col justify-start items-start w-full max-w-full">
|
||||
{sortedMemoList.map((memo) => props.renderer(memo))}
|
||||
<MasonryView
|
||||
memoList={sortedMemoList}
|
||||
renderer={props.renderer}
|
||||
prefixElement={showMemoEditor ? <MemoEditor className="mb-2" cacheKey="home-memo-editor" /> : undefined}
|
||||
listMode={!memoFilterStore.masonry}
|
||||
/>
|
||||
{state.isRequesting && (
|
||||
<div className="w-full flex flex-row justify-center items-center my-4">
|
||||
<LoaderIcon className="animate-spin text-zinc-500" />
|
||||
@@ -82,13 +94,10 @@ const PagedMemoList = (props: Props) => {
|
||||
) : (
|
||||
<div className="w-full flex flex-row justify-center items-center my-4">
|
||||
{state.nextPageToken && (
|
||||
<>
|
||||
<Button variant="plain" onClick={() => fetchMoreMemos(state.nextPageToken)}>
|
||||
{t("memo.load-more")}
|
||||
<ArrowDownIcon className="ml-1 w-4 h-auto" />
|
||||
</Button>
|
||||
<SlashIcon className="mx-1 w-4 h-auto opacity-40" />
|
||||
</>
|
||||
<Button variant="plain" onClick={() => fetchMoreMemos(state.nextPageToken)}>
|
||||
{t("memo.load-more")}
|
||||
<ArrowDownIcon className="ml-1 w-4 h-auto" />
|
||||
</Button>
|
||||
)}
|
||||
<BackToTop />
|
||||
</div>
|
||||
@@ -120,7 +129,7 @@ const PagedMemoList = (props: Props) => {
|
||||
{children}
|
||||
</PullToRefresh>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const BackToTop = () => {
|
||||
const t = useTranslate();
|
||||
|
@@ -39,7 +39,7 @@ const SearchBar = () => {
|
||||
onChange={onTextChange}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<MemoDisplaySettingMenu className="absolute right-2 top-2.5" />
|
||||
<MemoDisplaySettingMenu className="absolute right-2 top-2" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -27,7 +27,7 @@ const HomeLayout = observer(() => {
|
||||
</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", md && "max-w-3xl")}>
|
||||
<div className={cn("w-full mx-auto px-4 sm:px-6 md:pt-6 pb-8")}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,19 +1,13 @@
|
||||
import dayjs from "dayjs";
|
||||
import { ArchiveIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import MemoFilters from "@/components/MemoFilters";
|
||||
import MemoView from "@/components/MemoView";
|
||||
import MobileHeader from "@/components/MobileHeader";
|
||||
import PagedMemoList from "@/components/PagedMemoList";
|
||||
import SearchBar from "@/components/SearchBar";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { useMemoFilterStore } from "@/store/v1";
|
||||
import { Direction, State } from "@/types/proto/api/v1/common";
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
const Archived = () => {
|
||||
const t = useTranslate();
|
||||
const user = useCurrentUser();
|
||||
const memoFilterStore = useMemoFilterStore();
|
||||
|
||||
@@ -38,39 +32,22 @@ const Archived = () => {
|
||||
}, [user, memoFilterStore.filters]);
|
||||
|
||||
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">
|
||||
<MobileHeader />
|
||||
<div className="w-full px-4 sm:px-6">
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
<div className="w-full flex flex-row justify-between items-center mb-2">
|
||||
<div className="flex flex-row justify-start items-center gap-1">
|
||||
<ArchiveIcon className="w-5 h-auto opacity-70 shrink-0" />
|
||||
<span>{t("common.archived")}</span>
|
||||
</div>
|
||||
<div className="w-44">
|
||||
<SearchBar />
|
||||
</div>
|
||||
</div>
|
||||
<MemoFilters />
|
||||
<PagedMemoList
|
||||
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility compact />}
|
||||
listSort={(memos: Memo[]) =>
|
||||
memos
|
||||
.filter((memo) => memo.state === State.ARCHIVED)
|
||||
.sort((a, b) =>
|
||||
memoFilterStore.orderByTimeAsc
|
||||
? dayjs(a.displayTime).unix() - dayjs(b.displayTime).unix()
|
||||
: dayjs(b.displayTime).unix() - dayjs(a.displayTime).unix(),
|
||||
)
|
||||
}
|
||||
owner={user.name}
|
||||
state={State.ARCHIVED}
|
||||
direction={memoFilterStore.orderByTimeAsc ? Direction.ASC : Direction.DESC}
|
||||
oldFilter={memoListFilter}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<PagedMemoList
|
||||
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility compact />}
|
||||
listSort={(memos: Memo[]) =>
|
||||
memos
|
||||
.filter((memo) => memo.state === State.ARCHIVED)
|
||||
.sort((a, b) =>
|
||||
memoFilterStore.orderByTimeAsc
|
||||
? dayjs(a.displayTime).unix() - dayjs(b.displayTime).unix()
|
||||
: dayjs(b.displayTime).unix() - dayjs(a.displayTime).unix(),
|
||||
)
|
||||
}
|
||||
owner={user.name}
|
||||
state={State.ARCHIVED}
|
||||
direction={memoFilterStore.orderByTimeAsc ? Direction.ASC : Direction.DESC}
|
||||
oldFilter={memoListFilter}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@@ -44,24 +44,20 @@ const Explore = () => {
|
||||
}, [user, memoFilterStore.filters, memoFilterStore.orderByTimeAsc]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col justify-start items-start w-full max-w-full">
|
||||
<PagedMemoList
|
||||
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showCreator showVisibility compact />}
|
||||
listSort={(memos: Memo[]) =>
|
||||
memos
|
||||
.filter((memo) => memo.state === State.NORMAL)
|
||||
.sort((a, b) =>
|
||||
memoFilterStore.orderByTimeAsc
|
||||
? dayjs(a.displayTime).unix() - dayjs(b.displayTime).unix()
|
||||
: dayjs(b.displayTime).unix() - dayjs(a.displayTime).unix(),
|
||||
)
|
||||
}
|
||||
direction={memoFilterStore.orderByTimeAsc ? Direction.ASC : Direction.DESC}
|
||||
oldFilter={memoListFilter}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<PagedMemoList
|
||||
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showCreator showVisibility compact />}
|
||||
listSort={(memos: Memo[]) =>
|
||||
memos
|
||||
.filter((memo) => memo.state === State.NORMAL)
|
||||
.sort((a, b) =>
|
||||
memoFilterStore.orderByTimeAsc
|
||||
? dayjs(a.displayTime).unix() - dayjs(b.displayTime).unix()
|
||||
: dayjs(b.displayTime).unix() - dayjs(a.displayTime).unix(),
|
||||
)
|
||||
}
|
||||
direction={memoFilterStore.orderByTimeAsc ? Direction.ASC : Direction.DESC}
|
||||
oldFilter={memoListFilter}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import dayjs from "dayjs";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useMemo } from "react";
|
||||
import MemoEditor from "@/components/MemoEditor";
|
||||
import MemoView from "@/components/MemoView";
|
||||
import PagedMemoList from "@/components/PagedMemoList";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
@@ -48,28 +47,23 @@ const Home = observer(() => {
|
||||
}, [user, memoFilterStore.filters, memoFilterStore.orderByTimeAsc]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MemoEditor className="mb-2" cacheKey="home-memo-editor" />
|
||||
<div className="flex flex-col justify-start items-start w-full max-w-full">
|
||||
<PagedMemoList
|
||||
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.displayTime}`} memo={memo} showVisibility showPinned compact />}
|
||||
listSort={(memos: Memo[]) =>
|
||||
memos
|
||||
.filter((memo) => memo.state === State.NORMAL)
|
||||
.sort((a, b) =>
|
||||
memoFilterStore.orderByTimeAsc
|
||||
? dayjs(a.displayTime).unix() - dayjs(b.displayTime).unix()
|
||||
: dayjs(b.displayTime).unix() - dayjs(a.displayTime).unix(),
|
||||
)
|
||||
.sort((a, b) => Number(b.pinned) - Number(a.pinned))
|
||||
}
|
||||
owner={user.name}
|
||||
direction={memoFilterStore.orderByTimeAsc ? Direction.ASC : Direction.DESC}
|
||||
filter={selectedShortcut?.filter || ""}
|
||||
oldFilter={memoListFilter}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<PagedMemoList
|
||||
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.displayTime}`} memo={memo} showVisibility showPinned compact />}
|
||||
listSort={(memos: Memo[]) =>
|
||||
memos
|
||||
.filter((memo) => memo.state === State.NORMAL)
|
||||
.sort((a, b) =>
|
||||
memoFilterStore.orderByTimeAsc
|
||||
? dayjs(a.displayTime).unix() - dayjs(b.displayTime).unix()
|
||||
: dayjs(b.displayTime).unix() - dayjs(a.displayTime).unix(),
|
||||
)
|
||||
.sort((a, b) => Number(b.pinned) - Number(a.pinned))
|
||||
}
|
||||
owner={user.name}
|
||||
direction={memoFilterStore.orderByTimeAsc ? Direction.ASC : Direction.DESC}
|
||||
filter={selectedShortcut?.filter || ""}
|
||||
oldFilter={memoListFilter}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
@@ -76,7 +76,7 @@ const UserProfile = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="w-full max-w-5xl min-h-full flex flex-col justify-start items-center pb-8">
|
||||
<section className="w-full max-w-3xl mx-auto min-h-full flex flex-col justify-start items-center pb-8">
|
||||
<div className="w-full px-4 sm:px-6 flex flex-col justify-start items-center">
|
||||
{!loadingState.isLoading &&
|
||||
(user ? (
|
||||
|
@@ -43,6 +43,8 @@ interface State {
|
||||
orderByTimeAsc: boolean;
|
||||
// The id of selected shortcut.
|
||||
shortcut?: string;
|
||||
// TODO: Remove this when the masonry view is implemented.
|
||||
masonry: boolean;
|
||||
}
|
||||
|
||||
const getInitialState = (): State => {
|
||||
@@ -50,6 +52,7 @@ const getInitialState = (): State => {
|
||||
return {
|
||||
filters: parseFilterQuery(searchParams.get("filter")),
|
||||
orderByTimeAsc: searchParams.get("orderBy") === "asc",
|
||||
masonry: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -62,5 +65,6 @@ export const useMemoFilterStore = create(
|
||||
removeFilter: (filterFn: (f: MemoFilter) => boolean) => set((state) => ({ filters: state.filters.filter((f) => !filterFn(f)) })),
|
||||
setOrderByTimeAsc: (orderByTimeAsc: boolean) => set({ orderByTimeAsc }),
|
||||
setShortcut: (shortcut?: string) => set({ shortcut }),
|
||||
setMasonry: (masonry: boolean) => set({ masonry }),
|
||||
})),
|
||||
);
|
||||
|
Reference in New Issue
Block a user