feat: implement masonry view

This commit is contained in:
Johnny
2025-03-02 23:27:12 +08:00
parent a8713ec639
commit d6be20b917
11 changed files with 153 additions and 99 deletions

View 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;

View File

@@ -0,0 +1,3 @@
import MasonryView from "./MasonryView";
export default MasonryView;

View File

@@ -1,5 +1,6 @@
import { Option, Select } from "@mui/joy"; import { Option, Select, Switch } from "@mui/joy";
import { Settings2Icon } from "lucide-react"; import { Settings2Icon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useMemoFilterStore } from "@/store/v1"; import { useMemoFilterStore } from "@/store/v1";
import { cn } from "@/utils"; import { cn } from "@/utils";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
@@ -9,10 +10,10 @@ interface Props {
className?: string; className?: string;
} }
const MemoDisplaySettingMenu = ({ className }: Props) => { const MemoDisplaySettingMenu = observer(({ className }: Props) => {
const t = useTranslate(); const t = useTranslate();
const memoFilterStore = useMemoFilterStore(); const memoFilterStore = useMemoFilterStore();
const isApplying = Boolean(memoFilterStore.orderByTimeAsc) !== false; const isApplying = Boolean(memoFilterStore.orderByTimeAsc) !== false || memoFilterStore.masonry;
return ( return (
<Popover> <Popover>
@@ -36,10 +37,14 @@ const MemoDisplaySettingMenu = ({ className }: Props) => {
<Option value={true}>{t("memo.direction-asc")}</Option> <Option value={true}>{t("memo.direction-asc")}</Option>
</Select> </Select>
</div> </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> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
}; });
export default MemoDisplaySettingMenu; export default MemoDisplaySettingMenu;

View File

@@ -1,14 +1,19 @@
import { Button } from "@usememos/mui"; 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 { useEffect, useState } from "react";
import { matchPath } from "react-router-dom";
import PullToRefresh from "react-simple-pull-to-refresh"; import PullToRefresh from "react-simple-pull-to-refresh";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts"; import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import useResponsiveWidth from "@/hooks/useResponsiveWidth"; 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 { Direction, State } from "@/types/proto/api/v1/common";
import { Memo } from "@/types/proto/api/v1/memo_service"; import { Memo } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import Empty from "../Empty"; import Empty from "../Empty";
import MasonryView from "../MasonryView";
import MemoEditor from "../MemoEditor";
interface Props { interface Props {
renderer: (memo: Memo) => JSX.Element; renderer: (memo: Memo) => JSX.Element;
@@ -26,16 +31,18 @@ interface LocalState {
nextPageToken: string; nextPageToken: string;
} }
const PagedMemoList = (props: Props) => { const PagedMemoList = observer((props: Props) => {
const t = useTranslate(); const t = useTranslate();
const { md } = useResponsiveWidth(); const { md } = useResponsiveWidth();
const memoStore = useMemoStore(); const memoStore = useMemoStore();
const memoList = useMemoList(); const memoList = useMemoList();
const memoFilterStore = useMemoFilterStore();
const [state, setState] = useState<LocalState>({ const [state, setState] = useState<LocalState>({
isRequesting: true, // Initial request isRequesting: true, // Initial request
nextPageToken: "", nextPageToken: "",
}); });
const sortedMemoList = props.listSort ? props.listSort(memoList.value) : memoList.value; const sortedMemoList = props.listSort ? props.listSort(memoList.value) : memoList.value;
const showMemoEditor = Boolean(matchPath(Routes.ROOT, window.location.pathname));
const fetchMoreMemos = async (nextPageToken: string) => { const fetchMoreMemos = async (nextPageToken: string) => {
setState((state) => ({ ...state, isRequesting: true })); setState((state) => ({ ...state, isRequesting: true }));
@@ -66,7 +73,12 @@ const PagedMemoList = (props: Props) => {
const children = ( const children = (
<div className="flex flex-col justify-start items-start w-full max-w-full"> <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 && ( {state.isRequesting && (
<div className="w-full flex flex-row justify-center items-center my-4"> <div className="w-full flex flex-row justify-center items-center my-4">
<LoaderIcon className="animate-spin text-zinc-500" /> <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"> <div className="w-full flex flex-row justify-center items-center my-4">
{state.nextPageToken && ( {state.nextPageToken && (
<>
<Button variant="plain" onClick={() => fetchMoreMemos(state.nextPageToken)}> <Button variant="plain" onClick={() => fetchMoreMemos(state.nextPageToken)}>
{t("memo.load-more")} {t("memo.load-more")}
<ArrowDownIcon className="ml-1 w-4 h-auto" /> <ArrowDownIcon className="ml-1 w-4 h-auto" />
</Button> </Button>
<SlashIcon className="mx-1 w-4 h-auto opacity-40" />
</>
)} )}
<BackToTop /> <BackToTop />
</div> </div>
@@ -120,7 +129,7 @@ const PagedMemoList = (props: Props) => {
{children} {children}
</PullToRefresh> </PullToRefresh>
); );
}; });
const BackToTop = () => { const BackToTop = () => {
const t = useTranslate(); const t = useTranslate();

View File

@@ -39,7 +39,7 @@ const SearchBar = () => {
onChange={onTextChange} onChange={onTextChange}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
/> />
<MemoDisplaySettingMenu className="absolute right-2 top-2.5" /> <MemoDisplaySettingMenu className="absolute right-2 top-2" />
</div> </div>
); );
}; };

View File

@@ -27,7 +27,7 @@ const HomeLayout = observer(() => {
</div> </div>
)} )}
<div className={cn("w-full min-h-full", lg ? "pl-72" : md ? "pl-56" : "")}> <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 /> <Outlet />
</div> </div>
</div> </div>

View File

@@ -1,19 +1,13 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { ArchiveIcon } from "lucide-react";
import { useMemo } from "react"; import { useMemo } from "react";
import MemoFilters from "@/components/MemoFilters";
import MemoView from "@/components/MemoView"; import MemoView from "@/components/MemoView";
import MobileHeader from "@/components/MobileHeader";
import PagedMemoList from "@/components/PagedMemoList"; import PagedMemoList from "@/components/PagedMemoList";
import SearchBar from "@/components/SearchBar";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { useMemoFilterStore } from "@/store/v1"; import { useMemoFilterStore } from "@/store/v1";
import { Direction, State } from "@/types/proto/api/v1/common"; import { Direction, State } from "@/types/proto/api/v1/common";
import { Memo } from "@/types/proto/api/v1/memo_service"; import { Memo } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
const Archived = () => { const Archived = () => {
const t = useTranslate();
const user = useCurrentUser(); const user = useCurrentUser();
const memoFilterStore = useMemoFilterStore(); const memoFilterStore = useMemoFilterStore();
@@ -38,20 +32,6 @@ const Archived = () => {
}, [user, memoFilterStore.filters]); }, [user, memoFilterStore.filters]);
return ( return (
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
<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 <PagedMemoList
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility compact />} renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility compact />}
listSort={(memos: Memo[]) => listSort={(memos: Memo[]) =>
@@ -68,9 +48,6 @@ const Archived = () => {
direction={memoFilterStore.orderByTimeAsc ? Direction.ASC : Direction.DESC} direction={memoFilterStore.orderByTimeAsc ? Direction.ASC : Direction.DESC}
oldFilter={memoListFilter} oldFilter={memoListFilter}
/> />
</div>
</div>
</section>
); );
}; };

View File

@@ -44,8 +44,6 @@ const Explore = () => {
}, [user, memoFilterStore.filters, memoFilterStore.orderByTimeAsc]); }, [user, memoFilterStore.filters, memoFilterStore.orderByTimeAsc]);
return ( return (
<>
<div className="flex flex-col justify-start items-start w-full max-w-full">
<PagedMemoList <PagedMemoList
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showCreator showVisibility compact />} renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showCreator showVisibility compact />}
listSort={(memos: Memo[]) => listSort={(memos: Memo[]) =>
@@ -60,8 +58,6 @@ const Explore = () => {
direction={memoFilterStore.orderByTimeAsc ? Direction.ASC : Direction.DESC} direction={memoFilterStore.orderByTimeAsc ? Direction.ASC : Direction.DESC}
oldFilter={memoListFilter} oldFilter={memoListFilter}
/> />
</div>
</>
); );
}; };

View File

@@ -1,7 +1,6 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useMemo } from "react"; import { useMemo } from "react";
import MemoEditor from "@/components/MemoEditor";
import MemoView from "@/components/MemoView"; import MemoView from "@/components/MemoView";
import PagedMemoList from "@/components/PagedMemoList"; import PagedMemoList from "@/components/PagedMemoList";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
@@ -48,9 +47,6 @@ const Home = observer(() => {
}, [user, memoFilterStore.filters, memoFilterStore.orderByTimeAsc]); }, [user, memoFilterStore.filters, memoFilterStore.orderByTimeAsc]);
return ( return (
<>
<MemoEditor className="mb-2" cacheKey="home-memo-editor" />
<div className="flex flex-col justify-start items-start w-full max-w-full">
<PagedMemoList <PagedMemoList
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.displayTime}`} memo={memo} showVisibility showPinned compact />} renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.displayTime}`} memo={memo} showVisibility showPinned compact />}
listSort={(memos: Memo[]) => listSort={(memos: Memo[]) =>
@@ -68,8 +64,6 @@ const Home = observer(() => {
filter={selectedShortcut?.filter || ""} filter={selectedShortcut?.filter || ""}
oldFilter={memoListFilter} oldFilter={memoListFilter}
/> />
</div>
</>
); );
}); });

View File

@@ -76,7 +76,7 @@ const UserProfile = () => {
}; };
return ( 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"> <div className="w-full px-4 sm:px-6 flex flex-col justify-start items-center">
{!loadingState.isLoading && {!loadingState.isLoading &&
(user ? ( (user ? (

View File

@@ -43,6 +43,8 @@ interface State {
orderByTimeAsc: boolean; orderByTimeAsc: boolean;
// The id of selected shortcut. // The id of selected shortcut.
shortcut?: string; shortcut?: string;
// TODO: Remove this when the masonry view is implemented.
masonry: boolean;
} }
const getInitialState = (): State => { const getInitialState = (): State => {
@@ -50,6 +52,7 @@ const getInitialState = (): State => {
return { return {
filters: parseFilterQuery(searchParams.get("filter")), filters: parseFilterQuery(searchParams.get("filter")),
orderByTimeAsc: searchParams.get("orderBy") === "asc", 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)) })), removeFilter: (filterFn: (f: MemoFilter) => boolean) => set((state) => ({ filters: state.filters.filter((f) => !filterFn(f)) })),
setOrderByTimeAsc: (orderByTimeAsc: boolean) => set({ orderByTimeAsc }), setOrderByTimeAsc: (orderByTimeAsc: boolean) => set({ orderByTimeAsc }),
setShortcut: (shortcut?: string) => set({ shortcut }), setShortcut: (shortcut?: string) => set({ shortcut }),
setMasonry: (masonry: boolean) => set({ masonry }),
})), })),
); );