feat(web): calendar day panel refinements (#6280)
This commit is contained in:
parent
500bb716bc
commit
ad6433c51a
17 changed files with 206 additions and 176 deletions
|
|
@ -4,6 +4,7 @@ import type { MemoTimeBasis } from "@/contexts/ViewContext";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
import { CalendarLink } from "./CalendarLink";
|
import { CalendarLink } from "./CalendarLink";
|
||||||
|
import { CALENDAR_FOCUS_CLASSES } from "./controls";
|
||||||
import type { CalendarDaySummary } from "./dayModel";
|
import type { CalendarDaySummary } from "./dayModel";
|
||||||
import { buildCalendarPath, getMonthOfDate } from "./paths";
|
import { buildCalendarPath, getMonthOfDate } from "./paths";
|
||||||
|
|
||||||
|
|
@ -38,7 +39,7 @@ const CORNER_CLASSES = { ss: "rounded-ss-lg", se: "rounded-se-lg", es: "rounded-
|
||||||
export const CalendarDayCell = memo(
|
export const CalendarDayCell = memo(
|
||||||
({ day, summary, visibleRows, pending, timeBasis, tabIndex, isLastColumn, isLastRow, corner }: CalendarDayCellProps) => {
|
({ day, summary, visibleRows, pending, timeBasis, tabIndex, isLastColumn, isLastRow, corner }: CalendarDayCellProps) => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const count = summary?.count ?? day.count;
|
const count = summary ? summary.memos.length : day.count;
|
||||||
const entries = summary?.entries ?? [];
|
const entries = summary?.entries ?? [];
|
||||||
// The "+N more" line takes a row of its own, so an overflowing day shows one fewer memo.
|
// The "+N more" line takes a row of its own, so an overflowing day shows one fewer memo.
|
||||||
const overflows = count > visibleRows;
|
const overflows = count > visibleRows;
|
||||||
|
|
@ -58,7 +59,7 @@ export const CalendarDayCell = memo(
|
||||||
!isLastColumn && "border-e",
|
!isLastColumn && "border-e",
|
||||||
!isLastRow && "border-b",
|
!isLastRow && "border-b",
|
||||||
corner && CORNER_CLASSES[corner],
|
corner && CORNER_CLASSES[corner],
|
||||||
"focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-ring/60",
|
CALENDAR_FOCUS_CLASSES,
|
||||||
// The open day is the place you are, so it takes the fill the sidebar gives a current row.
|
// The open day is the place you are, so it takes the fill the sidebar gives a current row.
|
||||||
day.isSelected ? "bg-accent" : day.isCurrentMonth ? "bg-card hover:bg-muted/40" : "bg-muted/25 hover:bg-muted/45",
|
day.isSelected ? "bg-accent" : day.isCurrentMonth ? "bg-card hover:bg-muted/40" : "bg-muted/25 hover:bg-muted/45",
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { addMonths } from "@/lib/calendar-utils";
|
import { addMonths } from "@/lib/calendar-utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
import { CalendarLink } from "./CalendarLink";
|
import { CalendarLink } from "./CalendarLink";
|
||||||
|
import { CALENDAR_CONTROL_ACTIVE_CLASSES, CALENDAR_ICON_CONTROL_CLASSES, CALENDAR_TEXT_CONTROL_CLASSES } from "./controls";
|
||||||
import { MonthPicker } from "./MonthPicker";
|
import { MonthPicker } from "./MonthPicker";
|
||||||
import { buildCalendarPath, getMonthOfDate } from "./paths";
|
import { buildCalendarPath, getMonthOfDate } from "./paths";
|
||||||
|
|
||||||
|
|
@ -12,28 +13,28 @@ export interface CalendarHeaderProps {
|
||||||
monthLabel: string;
|
monthLabel: string;
|
||||||
/** `YYYY-MM-DD` */
|
/** `YYYY-MM-DD` */
|
||||||
today: string;
|
today: string;
|
||||||
/** `YYYY-MM-DD` of the open day, if any. */
|
/** `YYYY-MM-DD` of the day being shown, whether the URL names it or the layout defaulted to it. */
|
||||||
date?: string;
|
activeDate?: string;
|
||||||
|
/** Whether the shown day can be dismissed: a panel or sheet can, the phone's inline list cannot. */
|
||||||
|
closable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where "Today" goes: from another month it returns to this month; within this month it
|
* Where "Today" goes: from another month it returns to this month; within this month it opens
|
||||||
* toggles today's panel, so the button is also the quickest way to today's memos.
|
* today, and where the day can be dismissed a second press closes it again. On phones a day is
|
||||||
|
* always shown, so Today simply keeps today selected and reads as pressed while it is.
|
||||||
*/
|
*/
|
||||||
export const getTodayPath = (month: string, date: string | undefined, today: string): string => {
|
export const getTodayPath = (month: string, activeDate: string | undefined, today: string, closable: boolean): string => {
|
||||||
const currentMonth = getMonthOfDate(today);
|
const currentMonth = getMonthOfDate(today);
|
||||||
if (month !== currentMonth) return buildCalendarPath(currentMonth);
|
if (month !== currentMonth) return buildCalendarPath(currentMonth);
|
||||||
return date === today ? buildCalendarPath(currentMonth) : buildCalendarPath(currentMonth, today);
|
return closable && activeDate === today ? buildCalendarPath(currentMonth) : buildCalendarPath(currentMonth, today);
|
||||||
};
|
};
|
||||||
|
|
||||||
const NAV_LINK_CLASSES = cn(
|
export const CalendarHeader = ({ month, monthLabel, today, activeDate, closable }: CalendarHeaderProps) => {
|
||||||
buttonVariants({ variant: "ghost", size: "icon-sm" }),
|
|
||||||
"size-7 rounded-md text-muted-foreground/70 no-underline hover:bg-muted/60 hover:text-foreground",
|
|
||||||
);
|
|
||||||
|
|
||||||
export const CalendarHeader = ({ month, monthLabel, today, date }: CalendarHeaderProps) => {
|
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const todayOpen = date === today;
|
const navigate = useNavigate();
|
||||||
|
const { search } = useLocation();
|
||||||
|
const todayOpen = activeDate === today;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// The title's text starts on the grid's text axis (border + cell padding); the month
|
// The title's text starts on the grid's text axis (border + cell padding); the month
|
||||||
|
|
@ -41,23 +42,29 @@ export const CalendarHeader = ({ month, monthLabel, today, date }: CalendarHeade
|
||||||
<header className="flex h-9 shrink-0 items-center ps-px">
|
<header className="flex h-9 shrink-0 items-center ps-px">
|
||||||
<MonthPicker month={month} monthLabel={monthLabel} today={today} />
|
<MonthPicker month={month} monthLabel={monthLabel} today={today} />
|
||||||
<div className="ms-auto flex items-center gap-0.5">
|
<div className="ms-auto flex items-center gap-0.5">
|
||||||
<CalendarLink to={buildCalendarPath(addMonths(month, -1))} aria-label={t("common.previous-month")} className={NAV_LINK_CLASSES}>
|
<CalendarLink
|
||||||
<ChevronLeftIcon className="size-4 rtl:rotate-180" strokeWidth={1.75} />
|
to={buildCalendarPath(addMonths(month, -1))}
|
||||||
</CalendarLink>
|
aria-label={t("common.previous-month")}
|
||||||
<CalendarLink to={buildCalendarPath(addMonths(month, 1))} aria-label={t("common.next-month")} className={NAV_LINK_CLASSES}>
|
className={CALENDAR_ICON_CONTROL_CLASSES}
|
||||||
<ChevronRightIcon className="size-4 rtl:rotate-180" strokeWidth={1.75} />
|
>
|
||||||
|
<ChevronLeftIcon className="rtl:rotate-180" strokeWidth={1.75} />
|
||||||
</CalendarLink>
|
</CalendarLink>
|
||||||
<CalendarLink
|
<CalendarLink
|
||||||
to={getTodayPath(month, date, today)}
|
to={buildCalendarPath(addMonths(month, 1))}
|
||||||
|
aria-label={t("common.next-month")}
|
||||||
|
className={CALENDAR_ICON_CONTROL_CLASSES}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="rtl:rotate-180" strokeWidth={1.75} />
|
||||||
|
</CalendarLink>
|
||||||
|
{/* Today is a toggle, so it is a button that navigates; the search keeps the filter query. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
aria-pressed={todayOpen}
|
aria-pressed={todayOpen}
|
||||||
className={cn(
|
className={cn(CALENDAR_TEXT_CONTROL_CLASSES, "ms-1.5", todayOpen && CALENDAR_CONTROL_ACTIVE_CLASSES)}
|
||||||
buttonVariants({ variant: "outline", size: "sm" }),
|
onClick={() => navigate({ pathname: getTodayPath(month, activeDate, today, closable), search })}
|
||||||
"ms-1.5 no-underline",
|
|
||||||
todayOpen && "bg-accent text-accent-foreground",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{t("common.today")}
|
{t("common.today")}
|
||||||
</CalendarLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState }
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { SidebarResizeHandle } from "@/components/AppSidebar";
|
import { SidebarResizeHandle } from "@/components/AppSidebar";
|
||||||
|
import MemoListError from "@/components/PagedMemoList/MemoListError";
|
||||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useInstance } from "@/contexts/InstanceContext";
|
import { useInstance } from "@/contexts/InstanceContext";
|
||||||
|
|
@ -23,6 +24,8 @@ import { buildCalendarPath, getDefaultDate } from "./paths";
|
||||||
import { DAY_PANEL_DEFAULT_WIDTH, DAY_PANEL_WIDTH_VAR, useDayPanelWidth } from "./useDayPanelWidth";
|
import { DAY_PANEL_DEFAULT_WIDTH, DAY_PANEL_WIDTH_VAR, useDayPanelWidth } from "./useDayPanelWidth";
|
||||||
import { useMonthMemos } from "./useMonthMemos";
|
import { useMonthMemos } from "./useMonthMemos";
|
||||||
|
|
||||||
|
const NO_MEMOS: Memo[] = [];
|
||||||
|
|
||||||
export interface CalendarViewProps {
|
export interface CalendarViewProps {
|
||||||
/** `YYYY-MM` */
|
/** `YYYY-MM` */
|
||||||
month: string;
|
month: string;
|
||||||
|
|
@ -79,7 +82,7 @@ export const CalendarView = ({ month, date }: CalendarViewProps) => {
|
||||||
const isRedacted = useCallback((memo: Memo) => isMemoBlurred(memo, userTagsSetting), [userTagsSetting]);
|
const isRedacted = useCallback((memo: Memo) => isMemoBlurred(memo, userTagsSetting), [userTagsSetting]);
|
||||||
// Snippets and thumbnails must not appear before the tag settings that decide what to blur
|
// Snippets and thumbnails must not appear before the tag settings that decide what to blur
|
||||||
// have loaded; until then the predicate would let everything through.
|
// have loaded; until then the predicate would let everything through.
|
||||||
const { model, isLoading } = useMonthMemos({
|
const { model, isLoading, error, refetch } = useMonthMemos({
|
||||||
month,
|
month,
|
||||||
filter: monthFilter,
|
filter: monthFilter,
|
||||||
isRedacted,
|
isRedacted,
|
||||||
|
|
@ -94,13 +97,12 @@ export const CalendarView = ({ month, date }: CalendarViewProps) => {
|
||||||
|
|
||||||
// The sheet stays mounted after its day closes so it can slide out; it keeps showing the
|
// The sheet stays mounted after its day closes so it can slide out; it keeps showing the
|
||||||
// last open day while it does.
|
// last open day while it does.
|
||||||
|
// The sheet stays mounted across a close so its exit animation can play; it keeps showing the
|
||||||
|
// last open day. Set during render, so no frame ever commits the previous day's content.
|
||||||
const [sheetDate, setSheetDate] = useState(date);
|
const [sheetDate, setSheetDate] = useState(date);
|
||||||
useEffect(() => {
|
if (date && date !== sheetDate) setSheetDate(date);
|
||||||
if (date) setSheetDate(date);
|
const dayMemos = (day: string | undefined) => (day && model[day]?.memos) || NO_MEMOS;
|
||||||
}, [date]);
|
|
||||||
|
|
||||||
// Escape closes the side panel unless something else consumed it first: popups and the
|
|
||||||
// editor both preventDefault on the Escape they handle, and text fields keep theirs.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!date || !xl) return;
|
if (!date || !xl) return;
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
|
@ -111,13 +113,14 @@ export const CalendarView = ({ month, date }: CalendarViewProps) => {
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [date, xl, closeDay]);
|
}, [date, xl, closeDay]);
|
||||||
|
|
||||||
const isEmptyMonth = !isLoading && Object.keys(model).length === 0;
|
// A failed month must not pass for an empty one.
|
||||||
|
const isEmptyMonth = !isLoading && !error && Object.keys(model).length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-7xl items-start gap-6">
|
<div className="mx-auto flex w-full max-w-7xl items-start gap-6">
|
||||||
{/* From xl the section is sticky and viewport-tall so the grid can fill it beside the panel. */}
|
{/* From xl the section is sticky and viewport-tall so the grid can fill it beside the panel. */}
|
||||||
<section className="mx-auto flex w-full min-w-0 max-w-5xl flex-1 flex-col gap-1 xl:sticky xl:top-6 xl:h-[calc(100dvh-3.5rem)]">
|
<section className="mx-auto flex w-full min-w-0 max-w-5xl flex-1 flex-col gap-1 xl:sticky xl:top-6 xl:h-[calc(100dvh-3.5rem)]">
|
||||||
<CalendarHeader month={month} monthLabel={monthLabel} today={today} date={date} />
|
<CalendarHeader month={month} monthLabel={monthLabel} today={today} activeDate={activeDate} closable={md} />
|
||||||
<CalendarGrid
|
<CalendarGrid
|
||||||
month={month}
|
month={month}
|
||||||
monthLabel={monthLabel}
|
monthLabel={monthLabel}
|
||||||
|
|
@ -128,6 +131,7 @@ export const CalendarView = ({ month, date }: CalendarViewProps) => {
|
||||||
selectedDate={activeDate}
|
selectedDate={activeDate}
|
||||||
showRows={md}
|
showRows={md}
|
||||||
/>
|
/>
|
||||||
|
{error && <MemoListError error={error} onRetry={refetch} />}
|
||||||
{isEmptyMonth && md && (
|
{isEmptyMonth && md && (
|
||||||
<p className="mt-2 shrink-0 text-center text-ui text-muted-foreground">
|
<p className="mt-2 shrink-0 text-center text-ui text-muted-foreground">
|
||||||
{t("calendar.no-memos-in-month", { month: monthLabel })}
|
{t("calendar.no-memos-in-month", { month: monthLabel })}
|
||||||
|
|
@ -135,7 +139,7 @@ export const CalendarView = ({ month, date }: CalendarViewProps) => {
|
||||||
)}
|
)}
|
||||||
{activeDate && !md && (
|
{activeDate && !md && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<DayPanel date={activeDate} filter={memoFilter} />
|
<DayPanel date={activeDate} memos={dayMemos(activeDate)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -148,7 +152,7 @@ export const CalendarView = ({ month, date }: CalendarViewProps) => {
|
||||||
className="w-[28rem] max-w-[90vw] gap-0 overflow-y-auto px-6 pb-8 pt-5 sm:max-w-md [&_[data-slot=sheet-close]]:hidden"
|
className="w-[28rem] max-w-[90vw] gap-0 overflow-y-auto px-6 pb-8 pt-5 sm:max-w-md [&_[data-slot=sheet-close]]:hidden"
|
||||||
>
|
>
|
||||||
<SheetTitle className="sr-only">{sheetDate}</SheetTitle>
|
<SheetTitle className="sr-only">{sheetDate}</SheetTitle>
|
||||||
<DayPanel date={sheetDate} filter={memoFilter} onClose={closeDay} />
|
<DayPanel date={sheetDate} memos={dayMemos(sheetDate)} onClose={closeDay} />
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
)}
|
)}
|
||||||
|
|
@ -173,7 +177,7 @@ export const CalendarView = ({ month, date }: CalendarViewProps) => {
|
||||||
edge="start"
|
edge="start"
|
||||||
label={t("calendar.resize-panel")}
|
label={t("calendar.resize-panel")}
|
||||||
/>
|
/>
|
||||||
<DayPanel date={date} filter={memoFilter} onClose={closeDay} />
|
<DayPanel date={date} memos={dayMemos(date)} onClose={closeDay} />
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,113 +1,104 @@
|
||||||
import { SquarePenIcon, XIcon } from "lucide-react";
|
import { PlusIcon, XIcon } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext";
|
||||||
import MemoEditor from "@/components/MemoEditor";
|
import MemoEditor from "@/components/MemoEditor";
|
||||||
import { deriveDefaultCreateTimeFromDate } from "@/components/MemoEditor/utils/deriveDefaultCreateTime";
|
import { deriveDefaultCreateTimeFromDate } from "@/components/MemoEditor/utils/deriveDefaultCreateTime";
|
||||||
import MemoView from "@/components/MemoView";
|
import MemoView from "@/components/MemoView";
|
||||||
import PagedMemoList, { getMemoKey } from "@/components/PagedMemoList";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { NewMemoProvider } from "@/contexts/NewMemoContext";
|
import { NewMemoProvider } from "@/contexts/NewMemoContext";
|
||||||
import { useSpaceContext } from "@/contexts/SpaceContext";
|
import { useSpaceContext } from "@/contexts/SpaceContext";
|
||||||
import { useView } from "@/contexts/ViewContext";
|
import { useView } from "@/contexts/ViewContext";
|
||||||
import { useMemoSorting } from "@/hooks";
|
import { parseLocalDate } from "@/lib/calendar-utils";
|
||||||
import { getLocalDayTimestampRange, parseLocalDate, withTimestampRange } from "@/lib/calendar-utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { State } from "@/types/proto/api/v1/common_pb";
|
|
||||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
|
import { CALENDAR_ICON_CONTROL_CLASSES, CALENDAR_TEXT_CONTROL_CLASSES } from "./controls";
|
||||||
|
|
||||||
export interface DayPanelProps {
|
export interface DayPanelProps {
|
||||||
/** `YYYY-MM-DD` */
|
/** `YYYY-MM-DD` */
|
||||||
date: string;
|
date: string;
|
||||||
/** CEL fixing whose memos and which view or tags; the day's range is added here. */
|
/** The day's memos in time order, straight from the month model. */
|
||||||
filter?: string;
|
memos: Memo[];
|
||||||
/** Present when the panel can be dismissed (the side panel); the inline phone list cannot. */
|
/** Present when the panel can be dismissed (the side panel); the inline phone list cannot. */
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One day's memos as the ordinary paged list, so editing, reactions and comments behave
|
* One day's memos, oldest first, rendered from the month already in hand: no second fetch, and
|
||||||
* exactly as on Home. The composer is opt-in per day and seeds the memo's creation time to
|
* a day reads as it unfolded, like the rows in its grid cell. The cards are the ordinary memo
|
||||||
* that date, which is the one thing a calendar can do that the feed cannot.
|
* cards, so editing, reactions and comments behave exactly as on Home. The day ends with a
|
||||||
|
* quiet "new memo" row that expands into the editor in place, the way journals append at the
|
||||||
|
* end of a day; the memo it saves seeds its creation time to that date and lands right above it.
|
||||||
*/
|
*/
|
||||||
export const DayPanel = ({ date, filter, onClose }: DayPanelProps) => {
|
export const DayPanel = ({ date, memos, onClose }: DayPanelProps) => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const { isUserSettingsInitialized } = useAuth();
|
const { isUserSettingsInitialized } = useAuth();
|
||||||
const { memoFilter: contextFilter, selectedSpaceName } = useSpaceContext();
|
const { selectedSpaceName } = useSpaceContext();
|
||||||
const { timeBasis } = useView();
|
const { compactMode } = useView();
|
||||||
const [composing, setComposing] = useState(false);
|
// Remembering which day is being composed for closes the composer the moment the day
|
||||||
|
// changes, so a half-written memo can never silently move dates.
|
||||||
|
const [composingFor, setComposingFor] = useState<string>();
|
||||||
|
const composing = composingFor === date;
|
||||||
|
|
||||||
// Switching days closes the composer: a half-written memo must not silently move dates.
|
|
||||||
useEffect(() => setComposing(false), [date]);
|
|
||||||
|
|
||||||
const dayFilter = withTimestampRange(filter, getLocalDayTimestampRange(date), timeBasis);
|
|
||||||
const { listSort, orderBy } = useMemoSorting({ pinnedFirst: false, state: State.NORMAL });
|
|
||||||
const defaultCreateTime = useMemo(() => deriveDefaultCreateTimeFromDate(date), [date]);
|
const defaultCreateTime = useMemo(() => deriveDefaultCreateTimeFromDate(date), [date]);
|
||||||
|
|
||||||
const dateLabel = useMemo(
|
const dateLabel = useMemo(
|
||||||
() => parseLocalDate(date)?.toLocaleDateString(i18n.language, { weekday: "long", month: "long", day: "numeric" }) ?? date,
|
() => parseLocalDate(date)?.toLocaleDateString(i18n.language, { weekday: "long", month: "long", day: "numeric" }) ?? date,
|
||||||
[date, i18n.language],
|
[date, i18n.language],
|
||||||
);
|
);
|
||||||
|
const contents = useMemo(() => memos.map((memo) => memo.content), [memos]);
|
||||||
const editorCacheKey = `calendar-day-editor:${date}`;
|
const userNames = useMemo(
|
||||||
|
() => Array.from(new Set(memos.flatMap((memo) => memo.reactions.map((reaction) => reaction.creator)))),
|
||||||
|
[memos],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label={dateLabel} className="flex w-full flex-col">
|
<section aria-label={dateLabel} className="flex w-full flex-col">
|
||||||
<header className="mb-3 flex items-center gap-0.5 border-b border-border/70 pb-3">
|
<header className="mb-3 flex items-center gap-0.5 border-b border-border/70 pb-3">
|
||||||
<h2 className="min-w-0 flex-1 truncate text-base font-semibold tracking-tight text-foreground">{dateLabel}</h2>
|
<h2 className="min-w-0 flex-1 truncate text-base font-semibold tracking-tight text-foreground">{dateLabel}</h2>
|
||||||
{isUserSettingsInitialized && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={t("calendar.new-memo-on-day")}
|
|
||||||
aria-pressed={composing}
|
|
||||||
className="size-7 text-muted-foreground/70 hover:text-foreground aria-pressed:bg-accent aria-pressed:text-accent-foreground"
|
|
||||||
onClick={() => setComposing((value) => !value)}
|
|
||||||
>
|
|
||||||
<SquarePenIcon className="size-4" strokeWidth={1.8} />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<Button
|
<button type="button" aria-label={t("calendar.close-day")} className={CALENDAR_ICON_CONTROL_CLASSES} onClick={onClose}>
|
||||||
variant="ghost"
|
<XIcon strokeWidth={1.8} />
|
||||||
size="icon-sm"
|
</button>
|
||||||
aria-label={t("calendar.close-day")}
|
|
||||||
className="size-7 text-muted-foreground/70 hover:text-foreground"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<XIcon className="size-4" strokeWidth={1.8} />
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
<NewMemoProvider>
|
<NewMemoProvider>
|
||||||
<PagedMemoList
|
<MentionResolutionProvider contents={contents} userNames={userNames}>
|
||||||
renderer={(memo: Memo, { compact }) => (
|
{memos.map((memo) => (
|
||||||
<MemoView key={getMemoKey(memo)} memo={memo} showVisibility showPinned showSpace={!selectedSpaceName} compact={compact} />
|
<MemoView
|
||||||
)}
|
key={memo.name}
|
||||||
listSort={listSort}
|
memo={memo}
|
||||||
orderBy={orderBy}
|
timeDisplay="time"
|
||||||
filter={dayFilter}
|
showVisibility
|
||||||
contextFilter={contextFilter}
|
showPinned
|
||||||
emptyMessage={t("calendar.no-memos-on-day")}
|
showSpace={!selectedSpaceName}
|
||||||
// Filters live in the sidebar on this route; see CalendarView.
|
compact={compactMode}
|
||||||
showFilters={false}
|
/>
|
||||||
renderLeading={() =>
|
))}
|
||||||
composing && isUserSettingsInitialized ? (
|
</MentionResolutionProvider>
|
||||||
<MemoEditor
|
{isUserSettingsInitialized &&
|
||||||
key={editorCacheKey}
|
(composing ? (
|
||||||
cacheKey={editorCacheKey}
|
<MemoEditor
|
||||||
autoFocus
|
cacheKey={`calendar-day-editor:${date}`}
|
||||||
className="mb-2"
|
autoFocus
|
||||||
placeholder={t("editor.any-thoughts")}
|
placeholder={t("editor.any-thoughts")}
|
||||||
defaultCreateTime={defaultCreateTime}
|
defaultCreateTime={defaultCreateTime}
|
||||||
defaultSpace={selectedSpaceName}
|
defaultSpace={selectedSpaceName}
|
||||||
onConfirm={() => setComposing(false)}
|
onConfirm={() => setComposingFor(undefined)}
|
||||||
onCancel={() => setComposing(false)}
|
onCancel={() => setComposingFor(undefined)}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : (
|
||||||
}
|
<button
|
||||||
/>
|
type="button"
|
||||||
|
// The cards' own bottom margin sets the gap; the chip's edge lines up with theirs.
|
||||||
|
className={cn(CALENDAR_TEXT_CONTROL_CLASSES, "self-start")}
|
||||||
|
onClick={() => setComposingFor(date)}
|
||||||
|
>
|
||||||
|
<PlusIcon strokeWidth={1.8} />
|
||||||
|
<span>{t("calendar.new-memo-on-day")}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</NewMemoProvider>
|
</NewMemoProvider>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
import { CalendarLink } from "./CalendarLink";
|
import { CalendarLink } from "./CalendarLink";
|
||||||
|
import { CALENDAR_CONTROL_ACTIVE_CLASSES, CALENDAR_ICON_CONTROL_CLASSES, CALENDAR_TEXT_CONTROL_CLASSES } from "./controls";
|
||||||
import { buildCalendarPath, buildMonthKey, getMonthOfDate } from "./paths";
|
import { buildCalendarPath, buildMonthKey, getMonthOfDate } from "./paths";
|
||||||
|
|
||||||
export interface MonthPickerProps {
|
export interface MonthPickerProps {
|
||||||
|
|
@ -44,38 +44,30 @@ export const MonthPicker = ({ month, monthLabel, today }: MonthPickerProps) => {
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
render={
|
aria-label={t("calendar.select-month")}
|
||||||
<Button
|
className={cn(CALENDAR_TEXT_CONTROL_CLASSES, "text-base font-semibold tracking-tight text-foreground")}
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
aria-label={t("calendar.select-month")}
|
|
||||||
className="px-3 text-base font-semibold tracking-tight"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{monthLabel}
|
{monthLabel}
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent align="start" sideOffset={6} className="w-60 p-2">
|
<PopoverContent align="start" sideOffset={6} className="w-60 p-2">
|
||||||
<div className="mb-1 flex items-center">
|
<div className="mb-1 flex items-center">
|
||||||
<Button
|
<button
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="icon-sm"
|
|
||||||
aria-label={t("calendar.previous-year")}
|
aria-label={t("calendar.previous-year")}
|
||||||
className="size-7 text-muted-foreground/70 hover:text-foreground"
|
className={CALENDAR_ICON_CONTROL_CLASSES}
|
||||||
onClick={() => setYear((value) => value - 1)}
|
onClick={() => setYear((value) => value - 1)}
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon className="size-4 rtl:rotate-180" strokeWidth={1.75} />
|
<ChevronLeftIcon className="rtl:rotate-180" strokeWidth={1.75} />
|
||||||
</Button>
|
</button>
|
||||||
<span className="flex-1 text-center text-sm font-medium tabular-nums text-foreground">{year}</span>
|
<span className="flex-1 text-center text-ui font-medium tabular-nums text-foreground">{year}</span>
|
||||||
<Button
|
<button
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="icon-sm"
|
|
||||||
aria-label={t("calendar.next-year")}
|
aria-label={t("calendar.next-year")}
|
||||||
className="size-7 text-muted-foreground/70 hover:text-foreground"
|
className={CALENDAR_ICON_CONTROL_CLASSES}
|
||||||
onClick={() => setYear((value) => value + 1)}
|
onClick={() => setYear((value) => value + 1)}
|
||||||
>
|
>
|
||||||
<ChevronRightIcon className="size-4 rtl:rotate-180" strokeWidth={1.75} />
|
<ChevronRightIcon className="rtl:rotate-180" strokeWidth={1.75} />
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-0.5">
|
<div className="grid grid-cols-3 gap-0.5">
|
||||||
{MONTHS.map((value) => {
|
{MONTHS.map((value) => {
|
||||||
|
|
@ -88,8 +80,9 @@ export const MonthPicker = ({ month, monthLabel, today }: MonthPickerProps) => {
|
||||||
to={buildCalendarPath(key)}
|
to={buildCalendarPath(key)}
|
||||||
aria-current={isShown ? "page" : undefined}
|
aria-current={isShown ? "page" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex h-8 items-center justify-center rounded-md text-ui no-underline transition-colors",
|
CALENDAR_TEXT_CONTROL_CLASSES,
|
||||||
isShown ? "bg-accent font-medium text-accent-foreground" : "text-foreground hover:bg-accent hover:text-accent-foreground",
|
"relative justify-center text-foreground",
|
||||||
|
isShown && ["font-medium", CALENDAR_CONTROL_ACTIVE_CLASSES],
|
||||||
)}
|
)}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
25
web/src/components/CalendarView/controls.ts
Normal file
25
web/src/components/CalendarView/controls.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One quiet control grammar for the calendar, in the spirit of Notion and Linear: 28px tall,
|
||||||
|
* 13px text, muted ink that darkens on hover under a light wash, even padding, and the accent
|
||||||
|
* fill reserved for a pressed or current state. Raw elements carry these classes so the kit's
|
||||||
|
* variants never fight them.
|
||||||
|
*/
|
||||||
|
/** The ring-free focus treatment every calendar control and grid cell shares. */
|
||||||
|
export const CALENDAR_FOCUS_CLASSES =
|
||||||
|
"focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-ring/60";
|
||||||
|
|
||||||
|
const CALENDAR_CONTROL_CLASSES = cn(
|
||||||
|
"inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md text-ui text-muted-foreground/70 no-underline transition-colors hover:bg-muted/60 hover:text-foreground [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
CALENDAR_FOCUS_CLASSES,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** A control that is only a glyph: a 28px square. */
|
||||||
|
export const CALENDAR_ICON_CONTROL_CLASSES = cn(CALENDAR_CONTROL_CLASSES, "w-7 justify-center");
|
||||||
|
|
||||||
|
/** A control with a label: 8px of side padding, so the hover wash is a chip around the words. */
|
||||||
|
export const CALENDAR_TEXT_CONTROL_CLASSES = cn(CALENDAR_CONTROL_CLASSES, "px-2");
|
||||||
|
|
||||||
|
/** The one state that takes a fill: the place you are, or the thing that is on. */
|
||||||
|
export const CALENDAR_CONTROL_ACTIVE_CLASSES = "bg-accent text-accent-foreground hover:bg-accent";
|
||||||
|
|
@ -17,9 +17,9 @@ export interface CalendarDayEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CalendarDaySummary {
|
export interface CalendarDaySummary {
|
||||||
/** Every memo of the day, including ones that yield no entry. */
|
/** Every memo of the day in time order, including ones that yield no entry; the day panel renders these. */
|
||||||
count: number;
|
memos: Memo[];
|
||||||
/** Memos in time order, capped at MAX_DAY_ENTRIES. */
|
/** Grid rows in time order, capped at MAX_DAY_ENTRIES. */
|
||||||
entries: CalendarDayEntry[];
|
entries: CalendarDayEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,12 +56,12 @@ export const buildCalendarMonthModel = (
|
||||||
const model: CalendarMonthModel = {};
|
const model: CalendarMonthModel = {};
|
||||||
for (const { memo, time } of dated) {
|
for (const { memo, time } of dated) {
|
||||||
const date = dayjs(time).format(ISO_DATE_FORMAT);
|
const date = dayjs(time).format(ISO_DATE_FORMAT);
|
||||||
const summary = (model[date] ??= { count: 0, entries: [] });
|
const summary = (model[date] ??= { memos: [], entries: [] });
|
||||||
summary.count += 1;
|
summary.memos.push(memo);
|
||||||
if (isRedacted?.(memo) || summary.entries.length >= MAX_DAY_ENTRIES) continue;
|
if (isRedacted?.(memo) || summary.entries.length >= MAX_DAY_ENTRIES) continue;
|
||||||
|
|
||||||
const image = memo.attachments.find((attachment) => isImage(attachment.type));
|
const image = memo.attachments.find((attachment) => isImage(attachment.type));
|
||||||
const text = firstLine(memo.snippet || memo.content);
|
const text = firstLine(memo.snippet) || firstLine(memo.content);
|
||||||
if (!text && !image) continue;
|
if (!text && !image) continue;
|
||||||
summary.entries.push({
|
summary.entries.push({
|
||||||
memoName: memo.name,
|
memoName: memo.name,
|
||||||
|
|
|
||||||
|
|
@ -53,5 +53,5 @@ export const useMonthMemos = ({ month, filter, enabled = true, isRedacted }: Use
|
||||||
|
|
||||||
const memos = query.data ?? NO_MEMOS;
|
const memos = query.data ?? NO_MEMOS;
|
||||||
const model = useMemo(() => buildCalendarMonthModel(memos, timeBasis, { isRedacted }), [memos, timeBasis, isRedacted]);
|
const model = useMemo(() => buildCalendarMonthModel(memos, timeBasis, { isRedacted }), [memos, timeBasis, isRedacted]);
|
||||||
return { model, isLoading: query.isLoading };
|
return { model, isLoading: query.isLoading, error: query.isError ? query.error : undefined, refetch: query.refetch };
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ const MemoView = forwardRef<MemoViewHandle, MemoViewProps>((props, ref) => {
|
||||||
parentPage: parentPageProp,
|
parentPage: parentPageProp,
|
||||||
parentScope: parentScopeProp,
|
parentScope: parentScopeProp,
|
||||||
compact,
|
compact,
|
||||||
|
timeDisplay,
|
||||||
showCreator,
|
showCreator,
|
||||||
showVisibility,
|
showVisibility,
|
||||||
showPinned,
|
showPinned,
|
||||||
|
|
@ -167,7 +168,13 @@ const MemoView = forwardRef<MemoViewHandle, MemoViewProps>((props, ref) => {
|
||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
tabIndex={readonly ? -1 : 0}
|
tabIndex={readonly ? -1 : 0}
|
||||||
>
|
>
|
||||||
<MemoHeader showCreator={showCreator} showVisibility={showVisibility} showPinned={showPinned} showSpace={showSpace} />
|
<MemoHeader
|
||||||
|
timeDisplay={timeDisplay}
|
||||||
|
showCreator={showCreator}
|
||||||
|
showVisibility={showVisibility}
|
||||||
|
showPinned={showPinned}
|
||||||
|
showSpace={showSpace}
|
||||||
|
/>
|
||||||
|
|
||||||
<MemoBody compact={compact} />
|
<MemoBody compact={compact} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import MemoSpaceBadge from "./MemoSpaceBadge";
|
||||||
const MEMO_HEADER_ACTION_CLASSES =
|
const MEMO_HEADER_ACTION_CLASSES =
|
||||||
"size-6 shrink-0 rounded-md border-none bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50 data-popup-open:bg-accent data-popup-open:text-foreground";
|
"size-6 shrink-0 rounded-md border-none bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50 data-popup-open:bg-accent data-popup-open:text-foreground";
|
||||||
|
|
||||||
const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, showPinned, showSpace }) => {
|
const MemoHeader: React.FC<MemoHeaderProps> = ({ timeDisplay = "relative", showCreator, showVisibility, showPinned, showSpace }) => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
|
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -43,6 +43,8 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
|
||||||
|
|
||||||
const timeValue = isArchived ? (
|
const timeValue = isArchived ? (
|
||||||
memoDisplayTime?.toLocaleString(i18n.language)
|
memoDisplayTime?.toLocaleString(i18n.language)
|
||||||
|
) : timeDisplay === "time" ? (
|
||||||
|
memoDisplayTime?.toLocaleTimeString(i18n.language, { hour: "numeric", minute: "2-digit" })
|
||||||
) : (
|
) : (
|
||||||
<RelativeTime date={memoDisplayTime} format={relativeTimeFormat} />
|
<RelativeTime date={memoDisplayTime} format={relativeTimeFormat} />
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||||
import type { MemoOriginScope } from "./navigation";
|
import type { MemoOriginScope } from "./navigation";
|
||||||
|
|
||||||
|
/** How the header names the memo's time: relative to now, or just the clock time for lists that already name the day. */
|
||||||
|
export type MemoTimeDisplay = "relative" | "time";
|
||||||
|
|
||||||
export interface MemoViewProps {
|
export interface MemoViewProps {
|
||||||
memo: Memo;
|
memo: Memo;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
timeDisplay?: MemoTimeDisplay;
|
||||||
showCreator?: boolean;
|
showCreator?: boolean;
|
||||||
showVisibility?: boolean;
|
showVisibility?: boolean;
|
||||||
showPinned?: boolean;
|
showPinned?: boolean;
|
||||||
|
|
@ -20,6 +24,7 @@ export interface MemoViewHandle {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemoHeaderProps {
|
export interface MemoHeaderProps {
|
||||||
|
timeDisplay?: MemoTimeDisplay;
|
||||||
showCreator?: boolean;
|
showCreator?: boolean;
|
||||||
showVisibility?: boolean;
|
showVisibility?: boolean;
|
||||||
showPinned?: boolean;
|
showPinned?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,6 @@ interface Props {
|
||||||
renderHeader?: (options: { useGrid: boolean }) => ReactNode;
|
renderHeader?: (options: { useGrid: boolean }) => ReactNode;
|
||||||
/** Replaces the generic empty-state message when the route knows why the list is empty. */
|
/** Replaces the generic empty-state message when the route knows why the list is empty. */
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
/** Off when the host already shows the active filter chips elsewhere. */
|
|
||||||
showFilters?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function useAutoFetchWhenNotScrollable({
|
function useAutoFetchWhenNotScrollable({
|
||||||
|
|
@ -208,7 +206,6 @@ const PagedMemoList = (props: Props) => {
|
||||||
return () => window.removeEventListener("scroll", handleScroll);
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
}, [canPaginate, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
}, [canPaginate, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
const showFilters = props.showFilters ?? true;
|
|
||||||
const leadingContent = props.renderLeading?.({ useGrid });
|
const leadingContent = props.renderLeading?.({ useGrid });
|
||||||
const headerContent = props.renderHeader?.({ useGrid });
|
const headerContent = props.renderHeader?.({ useGrid });
|
||||||
|
|
||||||
|
|
@ -257,12 +254,12 @@ const PagedMemoList = (props: Props) => {
|
||||||
// empty state follows them. The newest memo also lands directly beneath them (priorityKey
|
// empty state follows them. The newest memo also lands directly beneath them (priorityKey
|
||||||
// above). Every vertical seam inside the stack uses GRID_GAP so y-spacing matches the
|
// above). Every vertical seam inside the stack uses GRID_GAP so y-spacing matches the
|
||||||
// grid's x-spacing exactly.
|
// grid's x-spacing exactly.
|
||||||
const hasFilters = showFilters && (filters.length > 0 || memoView !== undefined);
|
const hasFilters = filters.length > 0 || memoView !== undefined;
|
||||||
const gridLeading =
|
const gridLeading =
|
||||||
leadingContent || hasFilters || initialLoader || emptyPlaceholder || initialError ? (
|
leadingContent || hasFilters || initialLoader || emptyPlaceholder || initialError ? (
|
||||||
<div className="flex w-full flex-col" style={{ gap: GRID_GAP }}>
|
<div className="flex w-full flex-col" style={{ gap: GRID_GAP }}>
|
||||||
{leadingContent}
|
{leadingContent}
|
||||||
{showFilters && <MemoFilters />}
|
<MemoFilters />
|
||||||
{initialLoader}
|
{initialLoader}
|
||||||
{initialError}
|
{initialError}
|
||||||
{emptyPlaceholder}
|
{emptyPlaceholder}
|
||||||
|
|
@ -274,11 +271,7 @@ const PagedMemoList = (props: Props) => {
|
||||||
<>
|
<>
|
||||||
{pageError}
|
{pageError}
|
||||||
{isFetchingNextPage && <Loader />}
|
{isFetchingNextPage && <Loader />}
|
||||||
{!isFetchingNextPage && (hasNextPage || displayMemoList.length > 0) && (
|
{!isFetchingNextPage && (hasNextPage || displayMemoList.length > 0) && <BackToTop />}
|
||||||
<div className="w-full opacity-70 flex flex-row justify-center items-center my-4">
|
|
||||||
<BackToTop />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -305,7 +298,7 @@ const PagedMemoList = (props: Props) => {
|
||||||
<>
|
<>
|
||||||
{headerContent}
|
{headerContent}
|
||||||
{leadingContent}
|
{leadingContent}
|
||||||
{showFilters && <MemoFilters className="mb-2" />}
|
<MemoFilters className="mb-2" />
|
||||||
{initialLoader}
|
{initialLoader}
|
||||||
{initialError}
|
{initialError}
|
||||||
{displayMemoList.map((memo) => props.renderer(memo, { compact: effectiveCompact }))}
|
{displayMemoList.map((memo) => props.renderer(memo, { compact: effectiveCompact }))}
|
||||||
|
|
@ -342,16 +335,18 @@ const BackToTop = () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Don't render if not visible
|
// Render nothing at all while hidden, so the list's end carries no phantom spacing.
|
||||||
if (!isVisible) {
|
if (!isVisible) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button variant="ghost" onClick={scrollToTop}>
|
<div className="my-4 flex w-full flex-row items-center justify-center opacity-70">
|
||||||
{t("router.back-to-top")}
|
<Button variant="ghost" onClick={scrollToTop}>
|
||||||
<ArrowUpIcon className="ml-1 w-4 h-auto" />
|
{t("router.back-to-top")}
|
||||||
</Button>
|
<ArrowUpIcon className="ml-1 w-4 h-auto" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,6 @@
|
||||||
"new-memo-on-day": "New memo on this day",
|
"new-memo-on-day": "New memo on this day",
|
||||||
"next-year": "Next year",
|
"next-year": "Next year",
|
||||||
"no-memos-in-month": "No memos in {{month}}",
|
"no-memos-in-month": "No memos in {{month}}",
|
||||||
"no-memos-on-day": "No memos on this day",
|
|
||||||
"previous-year": "Previous year",
|
"previous-year": "Previous year",
|
||||||
"resize-panel": "Resize day panel",
|
"resize-panel": "Resize day panel",
|
||||||
"select-month": "Select month"
|
"select-month": "Select month"
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,6 @@
|
||||||
"new-memo-on-day": "在这一天新建备忘录",
|
"new-memo-on-day": "在这一天新建备忘录",
|
||||||
"next-year": "下一年",
|
"next-year": "下一年",
|
||||||
"no-memos-in-month": "{{month}} 没有备忘录",
|
"no-memos-in-month": "{{month}} 没有备忘录",
|
||||||
"no-memos-on-day": "这一天没有备忘录",
|
|
||||||
"previous-year": "上一年",
|
"previous-year": "上一年",
|
||||||
"resize-panel": "调整当天面板宽度",
|
"resize-panel": "调整当天面板宽度",
|
||||||
"select-month": "选择月份"
|
"select-month": "选择月份"
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,6 @@
|
||||||
"new-memo-on-day": "在這一天新增備忘錄",
|
"new-memo-on-day": "在這一天新增備忘錄",
|
||||||
"next-year": "下一年",
|
"next-year": "下一年",
|
||||||
"no-memos-in-month": "{{month}} 沒有備忘錄",
|
"no-memos-in-month": "{{month}} 沒有備忘錄",
|
||||||
"no-memos-on-day": "這一天沒有備忘錄",
|
|
||||||
"previous-year": "上一年",
|
"previous-year": "上一年",
|
||||||
"resize-panel": "調整當天面板寬度",
|
"resize-panel": "調整當天面板寬度",
|
||||||
"select-month": "選擇月份"
|
"select-month": "選擇月份"
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@ describe("calendar month model", () => {
|
||||||
"create_time",
|
"create_time",
|
||||||
);
|
);
|
||||||
expect(Object.keys(model).sort()).toEqual(["2026-08-02", "2026-08-03"]);
|
expect(Object.keys(model).sort()).toEqual(["2026-08-02", "2026-08-03"]);
|
||||||
expect(model["2026-08-02"].count).toBe(2);
|
expect(model["2026-08-02"].memos).toHaveLength(2);
|
||||||
expect(model["2026-08-03"].count).toBe(1);
|
expect(model["2026-08-03"].memos).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists memos as rows in time order with a thumbnail when they carry an image", () => {
|
it("lists memos as rows in time order with a thumbnail when they carry an image", () => {
|
||||||
|
|
@ -47,13 +47,13 @@ describe("calendar month model", () => {
|
||||||
const model = buildCalendarMonthModel(
|
const model = buildCalendarMonthModel(
|
||||||
[
|
[
|
||||||
memoAt(new Date(2026, 7, 2, 8), { snippet: " \nSecond line first\nmore" }),
|
memoAt(new Date(2026, 7, 2, 8), { snippet: " \nSecond line first\nmore" }),
|
||||||
memoAt(new Date(2026, 7, 2, 9), { content: "Raw content only" }),
|
memoAt(new Date(2026, 7, 2, 9), { snippet: " ", content: "Raw content only" }),
|
||||||
memoAt(new Date(2026, 7, 2, 10), { content: " " }),
|
memoAt(new Date(2026, 7, 2, 10), { content: " " }),
|
||||||
memoAt(new Date(2026, 7, 2, 11), { content: "", attachments: [image("photo")] }),
|
memoAt(new Date(2026, 7, 2, 11), { content: "", attachments: [image("photo")] }),
|
||||||
],
|
],
|
||||||
"create_time",
|
"create_time",
|
||||||
);
|
);
|
||||||
expect(model["2026-08-02"].count).toBe(4);
|
expect(model["2026-08-02"].memos).toHaveLength(4);
|
||||||
expect(model["2026-08-02"].entries.map((entry) => entry.text)).toEqual(["Second line first", "Raw content only", ""]);
|
expect(model["2026-08-02"].entries.map((entry) => entry.text)).toEqual(["Second line first", "Raw content only", ""]);
|
||||||
expect(model["2026-08-02"].entries[2].thumbnailUrl).toBeDefined();
|
expect(model["2026-08-02"].entries[2].thumbnailUrl).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
@ -61,7 +61,7 @@ describe("calendar month model", () => {
|
||||||
it("caps entries while still counting every memo", () => {
|
it("caps entries while still counting every memo", () => {
|
||||||
const memos = Array.from({ length: 10 }, (_, index) => memoAt(new Date(2026, 7, 2, index + 1), { snippet: `m${index}` }));
|
const memos = Array.from({ length: 10 }, (_, index) => memoAt(new Date(2026, 7, 2, index + 1), { snippet: `m${index}` }));
|
||||||
const model = buildCalendarMonthModel(memos, "create_time");
|
const model = buildCalendarMonthModel(memos, "create_time");
|
||||||
expect(model["2026-08-02"].count).toBe(10);
|
expect(model["2026-08-02"].memos).toHaveLength(10);
|
||||||
expect(model["2026-08-02"].entries).toHaveLength(8);
|
expect(model["2026-08-02"].entries).toHaveLength(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -79,7 +79,7 @@ describe("calendar month model", () => {
|
||||||
"create_time",
|
"create_time",
|
||||||
{ isRedacted: (memo) => memo.tags.includes("private") },
|
{ isRedacted: (memo) => memo.tags.includes("private") },
|
||||||
);
|
);
|
||||||
expect(model["2026-08-02"].count).toBe(2);
|
expect(model["2026-08-02"].memos).toHaveLength(2);
|
||||||
expect(model["2026-08-02"].entries.map((entry) => entry.text)).toEqual(["public"]);
|
expect(model["2026-08-02"].entries.map((entry) => entry.text)).toEqual(["public"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,19 @@ describe("calendar Today action", () => {
|
||||||
const today = "2026-09-05";
|
const today = "2026-09-05";
|
||||||
|
|
||||||
it("returns to the current month from another month without opening a day", () => {
|
it("returns to the current month from another month without opening a day", () => {
|
||||||
expect(getTodayPath("2026-07", undefined, today)).toBe("/calendar/2026/09");
|
expect(getTodayPath("2026-07", undefined, today, true)).toBe("/calendar/2026/09");
|
||||||
expect(getTodayPath("2026-07", "2026-07-16", today)).toBe("/calendar/2026/09");
|
expect(getTodayPath("2026-07", "2026-07-16", today, true)).toBe("/calendar/2026/09");
|
||||||
|
expect(getTodayPath("2026-07", "2026-07-01", today, false)).toBe("/calendar/2026/09");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens today's panel when the current month is already shown", () => {
|
it("opens today when the current month is already shown", () => {
|
||||||
expect(getTodayPath("2026-09", undefined, today)).toBe("/calendar/2026/09/05");
|
expect(getTodayPath("2026-09", undefined, today, true)).toBe("/calendar/2026/09/05");
|
||||||
expect(getTodayPath("2026-09", "2026-09-04", today)).toBe("/calendar/2026/09/05");
|
expect(getTodayPath("2026-09", "2026-09-04", today, true)).toBe("/calendar/2026/09/05");
|
||||||
|
expect(getTodayPath("2026-09", "2026-09-04", today, false)).toBe("/calendar/2026/09/05");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("closes today's panel when it is already open", () => {
|
it("closes today's panel on a second press only where the day can be dismissed", () => {
|
||||||
expect(getTodayPath("2026-09", today, today)).toBe("/calendar/2026/09");
|
expect(getTodayPath("2026-09", today, today, true)).toBe("/calendar/2026/09");
|
||||||
|
expect(getTodayPath("2026-09", today, today, false)).toBe("/calendar/2026/09/05");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue