hoàn thành tích hợp FSRS
This commit is contained in:
@@ -880,5 +880,20 @@
|
||||
"Try a different search term.": "Try a different search term.",
|
||||
"Try again": "Try again",
|
||||
"Untitled chat": "Untitled chat",
|
||||
"What can I help you with?": "What can I help you with?"
|
||||
"What can I help you with?": "What can I help you with?",
|
||||
"Need to Review": "Need to Review",
|
||||
"How well do you know this page?": "How well do you know this page?",
|
||||
"Due": "Due",
|
||||
"All caught up!": "All caught up!",
|
||||
"No pages need review today. Good job!": "No pages need review today. Good job!",
|
||||
"Review updated": "Review updated",
|
||||
"Failed to update review": "Failed to update review",
|
||||
"Hard": "Hard",
|
||||
"Good": "Good",
|
||||
"Easy": "Easy",
|
||||
"Again": "Again",
|
||||
"Plan": "Plan",
|
||||
"Next review": "Next review",
|
||||
"No learning plan yet": "No learning plan yet",
|
||||
"Try adding some cloze deletions to your notes to start learning.": "Try adding some cloze deletions to your notes to start learning."
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
IconUnderline,
|
||||
IconMessage,
|
||||
IconSparkles,
|
||||
IconEyeOff,
|
||||
IconCards,
|
||||
} from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import classes from "./bubble-menu.module.css";
|
||||
@@ -78,6 +80,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
isStrike: ctx.editor.isActive("strike"),
|
||||
isCode: ctx.editor.isActive("code"),
|
||||
isComment: ctx.editor.isActive("comment"),
|
||||
isCloze: ctx.editor.isActive("cloze"),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -113,6 +116,51 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
command: () => props.editor.chain().focus().toggleCode().run(),
|
||||
icon: IconCode,
|
||||
},
|
||||
{
|
||||
name: "Cloze Deletion",
|
||||
isActive: () => editorState?.isCloze,
|
||||
command: () => props.editor.chain().focus().toggleCloze().run(),
|
||||
icon: IconEyeOff,
|
||||
},
|
||||
{
|
||||
name: "Send to Anki",
|
||||
isActive: () => false,
|
||||
command: async () => {
|
||||
const { from, to } = props.editor.state.selection;
|
||||
const text = props.editor.state.doc.textBetween(from, to, " ");
|
||||
const title = document.title || "Docmost Page";
|
||||
|
||||
try {
|
||||
const response = await fetch("http://localhost:8765", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
action: "addNote",
|
||||
version: 6,
|
||||
params: {
|
||||
note: {
|
||||
deckName: "Docmost",
|
||||
modelName: "Basic",
|
||||
fields: {
|
||||
Front: title,
|
||||
Back: text,
|
||||
},
|
||||
tags: ["docmost"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.error) {
|
||||
alert(`AnkiConnect Error: ${result.error}`);
|
||||
} else {
|
||||
alert("Successfully sent to Anki!");
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Failed to connect to AnkiConnect. Make sure Anki is running with AnkiConnect plugin installed.");
|
||||
}
|
||||
},
|
||||
icon: IconCards,
|
||||
},
|
||||
];
|
||||
|
||||
const commentItem: BubbleMenuItem = {
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
Columns,
|
||||
Column,
|
||||
Status,
|
||||
Cloze,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@@ -375,6 +376,7 @@ export const mainExtensions = [
|
||||
}).configure(),
|
||||
Columns,
|
||||
Column,
|
||||
Cloze,
|
||||
AutoJoiner.configure({
|
||||
elementsToJoin: [],
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
.docmost-cloze {
|
||||
background-color: var(--mantine-color-black);
|
||||
color: var(--mantine-color-black);
|
||||
border-radius: 4px;
|
||||
padding: 0 2px;
|
||||
transition: all 0.2s ease;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.docmost-cloze:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .docmost-cloze {
|
||||
background-color: var(--mantine-color-white);
|
||||
color: var(--mantine-color-white);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .docmost-cloze:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
color: inherit;
|
||||
}
|
||||
@@ -15,3 +15,4 @@
|
||||
@import "./highlight.css";
|
||||
@import "./columns.css";
|
||||
@import "./status.css";
|
||||
@import "./cloze.css";
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Text, Tabs, Space } from "@mantine/core";
|
||||
import { IconClockHour3, IconStar, IconUser } from "@tabler/icons-react";
|
||||
import { IconClockHour3, IconStar, IconUser, IconSchool, IconCalendarStats } from "@tabler/icons-react";
|
||||
import RecentChanges from "@/components/common/recent-changes";
|
||||
import FavoritesPages from "./favorites-pages";
|
||||
import CreatedByMe from "./created-by-me";
|
||||
import NeedToReview from "./need-to-review";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { homeTabAtom } from "@/features/home/atoms/home-tab-atom";
|
||||
@@ -35,6 +36,16 @@ export default function HomeTabs() {
|
||||
{t("Created by me")}
|
||||
</Text>
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="review" leftSection={<IconSchool size={18} />}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("Need to Review")}
|
||||
</Text>
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="plan" leftSection={<IconCalendarStats size={18} />}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("Plan")}
|
||||
</Text>
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Space my="md" />
|
||||
@@ -48,6 +59,12 @@ export default function HomeTabs() {
|
||||
<Tabs.Panel value="created">
|
||||
<CreatedByMe />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="review">
|
||||
<NeedToReview />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="plan">
|
||||
<NeedToReview onlyDue={false} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
Text,
|
||||
Group,
|
||||
UnstyledButton,
|
||||
Badge,
|
||||
Table,
|
||||
ActionIcon,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import PageListSkeleton from "@/components/ui/page-list-skeleton";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import { useReviewListQuery } from "@/features/page/queries/page-query";
|
||||
import { IconFileDescription, IconSchool } from "@tabler/icons-react";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { getSpaceUrl } from "@/lib/config";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getInitialsColor } from "@/lib/get-initials-color";
|
||||
|
||||
export default function NeedToReview({ spaceId, onlyDue = true }: { spaceId?: string; onlyDue?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useReviewListQuery(spaceId, onlyDue);
|
||||
|
||||
const pages = data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return <PageListSkeleton />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <Text>{t("Failed to fetch pages")}</Text>;
|
||||
}
|
||||
|
||||
return pages.length > 0 ? (
|
||||
<>
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Tbody>
|
||||
{pages.map((page) => (
|
||||
<Table.Tr key={page.id}>
|
||||
<Table.Td>
|
||||
<UnstyledButton
|
||||
component={Link}
|
||||
to={buildPageUrl(
|
||||
page?.space?.slug,
|
||||
page.slugId,
|
||||
page.title,
|
||||
)}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
{page.icon || (
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
size={18}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{page.title || t("Untitled")}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Table.Td>
|
||||
{!spaceId && (
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={getInitialsColor(page?.space?.name || "default")}
|
||||
variant="light"
|
||||
component={Link}
|
||||
to={getSpaceUrl(page?.space?.slug || "")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{page?.space?.name || t("Unknown")}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td>
|
||||
<Text
|
||||
c="dimmed"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
size="xs"
|
||||
fw={500}
|
||||
>
|
||||
{onlyDue ? t("Due") : t("Next review")}: {formattedDate(page.nextReviewDate)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
{hasNextPage && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
fullWidth
|
||||
mt="sm"
|
||||
mb="xl"
|
||||
onClick={() => fetchNextPage()}
|
||||
loading={isFetchingNextPage}
|
||||
>
|
||||
{t("Load more")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={IconSchool}
|
||||
title={onlyDue ? t("All caught up!") : t("No learning plan yet")}
|
||||
description={onlyDue ? t("No pages need review today. Good job!") : t("Try adding some cloze deletions to your notes to start learning.")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Button, Group, Paper, Text, Stack } from "@mantine/core";
|
||||
import { useReviewMutation } from "@/features/page/queries/page-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconBrain } from "@tabler/icons-react";
|
||||
|
||||
type Props = {
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export default function ReviewButtons({ pageId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { mutate, isPending } = useReviewMutation();
|
||||
|
||||
const handleReview = (quality: number) => {
|
||||
mutate({ pageId, quality });
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper withBorder p="md" mb="xl" radius="md" bg="gray.0">
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
<IconBrain size={20} color="var(--mantine-color-blue-filled)" />
|
||||
<Text fw={600} size="sm">
|
||||
{t("How well do you know this page?")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="compact-sm"
|
||||
onClick={() => handleReview(1)}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t("Again")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="orange"
|
||||
size="compact-sm"
|
||||
onClick={() => handleReview(2)}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t("Hard")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="compact-sm"
|
||||
onClick={() => handleReview(3)}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t("Good")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="green"
|
||||
size="compact-sm"
|
||||
onClick={() => handleReview(4)}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t("Easy")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
getAllSidebarPages,
|
||||
getDeletedPages,
|
||||
restorePage,
|
||||
reviewPage,
|
||||
getPagesToReview,
|
||||
} from "@/features/page/services/page-service";
|
||||
import {
|
||||
IMovePage,
|
||||
@@ -607,7 +609,33 @@ export function invalidateOnDeletePage(pageId: string) {
|
||||
});
|
||||
|
||||
//update recent changes
|
||||
queryClient.invalidateQueries({
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["recent-changes"],
|
||||
});
|
||||
}
|
||||
|
||||
export function useReviewListQuery(spaceId?: string, onlyDue?: boolean) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["review-list", spaceId, onlyDue],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getPagesToReview({ spaceId, onlyDue, cursor: pageParam, limit: 15 }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function useReviewMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IPage, Error, { pageId: string; quality: number }>({
|
||||
mutationFn: ({ pageId, quality }) => reviewPage(pageId, quality),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Review updated") });
|
||||
queryClient.invalidateQueries({ queryKey: ["review-list"] });
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({ message: t("Failed to update review"), color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -194,3 +194,13 @@ export async function uploadFile(
|
||||
|
||||
return req as unknown as IAttachment;
|
||||
}
|
||||
|
||||
export async function reviewPage(pageId: string, quality: number): Promise<IPage> {
|
||||
const req = await api.post<IPage>("/pages/review", { pageId, quality });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getPagesToReview(params?: QueryParams & { spaceId?: string; onlyDue?: boolean }): Promise<IPagination<IPage>> {
|
||||
const req = await api.post("/pages/review-list", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,15 @@ export interface IPage {
|
||||
canEdit: boolean;
|
||||
hasRestriction: boolean;
|
||||
};
|
||||
nextReviewDate?: Date;
|
||||
easeFactor?: number;
|
||||
repetitionCount?: number;
|
||||
reviewInterval?: number;
|
||||
stability?: number;
|
||||
difficulty?: number;
|
||||
lapses?: number;
|
||||
state?: number;
|
||||
lastReview?: Date;
|
||||
}
|
||||
|
||||
export interface IContributor {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Text, Tabs, Space } from "@mantine/core";
|
||||
import { IconClockHour3, IconStar, IconUser } from "@tabler/icons-react";
|
||||
import { IconClockHour3, IconSchool, IconStar, IconUser, IconCalendarStats } from "@tabler/icons-react";
|
||||
import RecentChanges from "@/components/common/recent-changes";
|
||||
import FavoritesPages from "@/features/home/components/favorites-pages";
|
||||
import CreatedByMe from "@/features/home/components/created-by-me";
|
||||
import NeedToReview from "@/features/home/components/need-to-review";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -39,6 +40,16 @@ export default function SpaceHomeTabs() {
|
||||
{t("Created by me")}
|
||||
</Text>
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="review" leftSection={<IconSchool size={18} />}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("Need to Review")}
|
||||
</Text>
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="plan" leftSection={<IconCalendarStats size={18} />}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("Plan")}
|
||||
</Text>
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Space my="md" />
|
||||
@@ -52,6 +63,12 @@ export default function SpaceHomeTabs() {
|
||||
<Tabs.Panel value="created">
|
||||
{space?.id && <CreatedByMe spaceId={space.id} />}
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="review">
|
||||
{space?.id && <NeedToReview spaceId={space.id} />}
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="plan">
|
||||
{space?.id && <NeedToReview spaceId={space.id} onlyDue={false} />}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ import { useTranslation } from "react-i18next";
|
||||
import React from "react";
|
||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||
import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { Button, Container } from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import ReviewButtons from "@/features/page/components/review-buttons";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
const MemoizedFullEditor = React.memo(FullEditor);
|
||||
const MemoizedPageHeader = React.memo(PageHeader);
|
||||
@@ -99,6 +100,10 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
||||
|
||||
<MemoizedPageHeader readOnly={!canEdit} />
|
||||
|
||||
<Container size="900" px="xs" pt="50">
|
||||
<ReviewButtons pageId={page.id} />
|
||||
</Container>
|
||||
|
||||
<MemoizedFullEditor
|
||||
key={page.id}
|
||||
pageId={page.id}
|
||||
|
||||
Reference in New Issue
Block a user