diff options
| author | Mohamed Bassem <me@mbassem.com> | 2024-12-28 12:30:24 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2024-12-28 12:30:24 +0000 |
| commit | 7956e9fa6772ab57a0794fb7cba7e9d9c0bd7878 (patch) | |
| tree | 10d6e47afed8be007361cd40ec8b05fc52d6d4d9 | |
| parent | 7dd5b2bc8751911f86448e6c4619b2583d9a4b53 (diff) | |
| download | karakeep-7956e9fa6772ab57a0794fb7cba7e9d9c0bd7878.tar.zst | |
feat: Implement the all highlights page. Fixes #620
| -rw-r--r-- | apps/web/app/dashboard/highlights/page.tsx | 20 | ||||
| -rw-r--r-- | apps/web/components/dashboard/highlights/AllHighlights.tsx | 98 | ||||
| -rw-r--r-- | apps/web/components/dashboard/highlights/HighlightCard.tsx | 76 | ||||
| -rw-r--r-- | apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx | 6 | ||||
| -rw-r--r-- | apps/web/components/dashboard/preview/BookmarkPreview.tsx | 15 | ||||
| -rw-r--r-- | apps/web/components/dashboard/preview/HighlightsBox.tsx | 64 | ||||
| -rw-r--r-- | apps/web/components/dashboard/sidebar/ModileSidebar.tsx | 6 | ||||
| -rw-r--r-- | apps/web/components/dashboard/sidebar/Sidebar.tsx | 7 | ||||
| -rw-r--r-- | apps/web/lib/hooks/relative-time.ts | 22 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/en/translation.json | 1 | ||||
| -rw-r--r-- | packages/shared-react/hooks/highlights.ts | 3 | ||||
| -rw-r--r-- | packages/shared/types/highlights.ts | 10 | ||||
| -rw-r--r-- | packages/trpc/routers/highlights.ts | 12 |
13 files changed, 255 insertions, 85 deletions
diff --git a/apps/web/app/dashboard/highlights/page.tsx b/apps/web/app/dashboard/highlights/page.tsx new file mode 100644 index 00000000..646b1c41 --- /dev/null +++ b/apps/web/app/dashboard/highlights/page.tsx @@ -0,0 +1,20 @@ +import AllHighlights from "@/components/dashboard/highlights/AllHighlights"; +import { Separator } from "@/components/ui/separator"; +import { useTranslation } from "@/lib/i18n/server"; +import { api } from "@/server/api/client"; +import { Highlighter } from "lucide-react"; + +export default async function HighlightsPage() { + const { t } = await useTranslation(); + const highlights = await api.highlights.getAll({}); + return ( + <div className="flex flex-col gap-8 rounded-md border bg-background p-4"> + <span className="flex items-center gap-1 text-2xl"> + <Highlighter className="size-6" /> + {t("common.highlights")} + </span> + <Separator /> + <AllHighlights highlights={highlights} /> + </div> + ); +} diff --git a/apps/web/components/dashboard/highlights/AllHighlights.tsx b/apps/web/components/dashboard/highlights/AllHighlights.tsx new file mode 100644 index 00000000..27dda6ff --- /dev/null +++ b/apps/web/components/dashboard/highlights/AllHighlights.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useEffect } from "react"; +import Link from "next/link"; +import { ActionButton } from "@/components/ui/action-button"; +import useRelativeTime from "@/lib/hooks/relative-time"; +import { api } from "@/lib/trpc"; +import { Separator } from "@radix-ui/react-dropdown-menu"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { Dot, LinkIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useInView } from "react-intersection-observer"; + +import { + ZGetAllHighlightsResponse, + ZHighlight, +} from "@hoarder/shared/types/highlights"; + +import HighlightCard from "./HighlightCard"; + +dayjs.extend(relativeTime); + +function Highlight({ highlight }: { highlight: ZHighlight }) { + const { fromNow, localCreatedAt } = useRelativeTime(highlight.createdAt); + const { t } = useTranslation(); + return ( + <div className="flex flex-col gap-2"> + <HighlightCard highlight={highlight} clickable={false} /> + <span className="flex items-center gap-0.5 text-xs italic text-gray-400"> + <span title={localCreatedAt}>{fromNow}</span> + <Dot /> + <Link + href={`/dashboard/preview/${highlight.bookmarkId}`} + className="flex items-center gap-0.5" + > + <LinkIcon className="size-3 italic" /> + {t("common.source")} + </Link> + </span> + </div> + ); +} + +export default function AllHighlights({ + highlights: initialHighlights, +}: { + highlights: ZGetAllHighlightsResponse; +}) { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = + api.highlights.getAll.useInfiniteQuery( + {}, + { + initialData: () => ({ + pages: [initialHighlights], + pageParams: [null], + }), + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ); + + const { ref: loadMoreRef, inView: loadMoreButtonInView } = useInView(); + useEffect(() => { + if (loadMoreButtonInView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [loadMoreButtonInView]); + + return ( + <div className="flex flex-col gap-2"> + {data?.pages + .flatMap((p) => p.highlights) + .map((h) => ( + <> + <Highlight key={h.id} highlight={h} /> + <Separator + key={`sep-${h.id}`} + className="m-2 h-0.5 bg-gray-100 last:hidden" + /> + </> + ))} + {hasNextPage && ( + <div className="flex justify-center"> + <ActionButton + ref={loadMoreRef} + ignoreDemoMode={true} + loading={isFetchingNextPage} + onClick={() => fetchNextPage()} + variant="ghost" + > + Load More + </ActionButton> + </div> + )} + </div> + ); +} diff --git a/apps/web/components/dashboard/highlights/HighlightCard.tsx b/apps/web/components/dashboard/highlights/HighlightCard.tsx new file mode 100644 index 00000000..63ff9423 --- /dev/null +++ b/apps/web/components/dashboard/highlights/HighlightCard.tsx @@ -0,0 +1,76 @@ +import { ActionButton } from "@/components/ui/action-button"; +import { toast } from "@/components/ui/use-toast"; +import { cn } from "@/lib/utils"; +import { Trash2 } from "lucide-react"; + +import { useDeleteHighlight } from "@hoarder/shared-react/hooks/highlights"; +import { ZHighlight } from "@hoarder/shared/types/highlights"; + +import { HIGHLIGHT_COLOR_MAP } from "../preview/highlights"; + +export default function HighlightCard({ + highlight, + clickable, + className, +}: { + highlight: ZHighlight; + clickable: boolean; + className?: string; +}) { + const { mutate: deleteHighlight, isPending: isDeleting } = useDeleteHighlight( + { + onSuccess: () => { + toast({ + description: "Highlight has been deleted!", + }); + }, + onError: () => { + toast({ + description: "Something went wrong", + variant: "destructive", + }); + }, + }, + ); + + const onBookmarkClick = () => { + document + .querySelector(`[data-highlight-id="${highlight.id}"]`) + ?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + }; + + const Wrapper = ({ children }: { children: React.ReactNode }) => + clickable ? ( + <button onClick={onBookmarkClick}>{children}</button> + ) : ( + <div>{children}</div> + ); + + return ( + <div className={cn("flex items-center justify-between", className)}> + <Wrapper> + <blockquote + cite={highlight.bookmarkId} + className={cn( + "prose border-l-[6px] p-2 pl-6 italic dark:prose-invert prose-p:text-sm", + HIGHLIGHT_COLOR_MAP["border-l"][highlight.color], + )} + > + <p>{highlight.text}</p> + </blockquote> + </Wrapper> + <div className="flex gap-2"> + <ActionButton + loading={isDeleting} + variant="ghost" + onClick={() => deleteHighlight({ highlightId: highlight.id })} + > + <Trash2 className="size-4 text-destructive" /> + </ActionButton> + </div> + </div> + ); +} diff --git a/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx b/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx index b99a1a56..89472184 100644 --- a/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx +++ b/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useRef, useState } from "react"; +import { ActionButton } from "@/components/ui/action-button"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; @@ -61,14 +62,15 @@ const ColorPickerMenu: React.FC<ColorPickerMenuProps> = ({ </Button> ))} {selectedHighlight && ( - <Button + <ActionButton + loading={false} size="none" className="size-8 rounded-full" onClick={onDelete} variant="ghost" > <Trash2 className="size-5 text-destructive" /> - </Button> + </ActionButton> )} </PopoverContent> </Popover> diff --git a/apps/web/components/dashboard/preview/BookmarkPreview.tsx b/apps/web/components/dashboard/preview/BookmarkPreview.tsx index b854146c..c257d902 100644 --- a/apps/web/components/dashboard/preview/BookmarkPreview.tsx +++ b/apps/web/components/dashboard/preview/BookmarkPreview.tsx @@ -1,6 +1,5 @@ "use client"; -import { useEffect, useState } from "react"; import Link from "next/link"; import { BookmarkTagsEditor } from "@/components/dashboard/bookmarks/BookmarkTagsEditor"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; @@ -12,10 +11,9 @@ import { TooltipPortal, TooltipTrigger, } from "@/components/ui/tooltip"; +import useRelativeTime from "@/lib/hooks/relative-time"; import { useTranslation } from "@/lib/i18n/client"; import { api } from "@/lib/trpc"; -import dayjs from "dayjs"; -import relativeTime from "dayjs/plugin/relativeTime"; import { CalendarDays, ExternalLink } from "lucide-react"; import { @@ -35,8 +33,6 @@ import LinkContentSection from "./LinkContentSection"; import { NoteEditor } from "./NoteEditor"; import { TextContentSection } from "./TextContentSection"; -dayjs.extend(relativeTime); - function ContentLoading() { return ( <div className="flex w-full flex-col gap-2"> @@ -48,14 +44,7 @@ function ContentLoading() { } function CreationTime({ createdAt }: { createdAt: Date }) { - const [fromNow, setFromNow] = useState(""); - const [localCreatedAt, setLocalCreatedAt] = useState(""); - - // This is to avoid hydration errors when server and clients are in different timezones - useEffect(() => { - setFromNow(dayjs(createdAt).fromNow()); - setLocalCreatedAt(createdAt.toLocaleString()); - }, [createdAt]); + const { fromNow, localCreatedAt } = useRelativeTime(createdAt); return ( <Tooltip delayDuration={0}> <TooltipTrigger asChild> diff --git a/apps/web/components/dashboard/preview/HighlightsBox.tsx b/apps/web/components/dashboard/preview/HighlightsBox.tsx index 3c873f3d..af065a9d 100644 --- a/apps/web/components/dashboard/preview/HighlightsBox.tsx +++ b/apps/web/components/dashboard/preview/HighlightsBox.tsx @@ -1,72 +1,14 @@ -import { ActionButton } from "@/components/ui/action-button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; import { api } from "@/lib/trpc"; -import { cn } from "@/lib/utils"; import { Separator } from "@radix-ui/react-dropdown-menu"; -import { ChevronsDownUp, Trash2 } from "lucide-react"; +import { ChevronsDownUp } from "lucide-react"; -import { useDeleteHighlight } from "@hoarder/shared-react/hooks/highlights"; -import { ZHighlight } from "@hoarder/shared/types/highlights"; - -import { HIGHLIGHT_COLOR_MAP } from "./highlights"; - -function HighlightCard({ highlight }: { highlight: ZHighlight }) { - const { mutate: deleteHighlight, isPending: isDeleting } = useDeleteHighlight( - { - onSuccess: () => { - toast({ - description: "Highlight has been deleted!", - }); - }, - onError: () => { - toast({ - description: "Something went wrong", - variant: "destructive", - }); - }, - }, - ); - - const onBookmarkClick = () => { - document - .querySelector(`[data-highlight-id="${highlight.id}"]`) - ?.scrollIntoView({ - behavior: "smooth", - block: "center", - }); - }; - return ( - <div className="flex items-center justify-between"> - <div className="flex items-center gap-3"> - <button onClick={onBookmarkClick}> - <blockquote - className={cn( - "prose border-l-[6px] p-2 pl-6 italic dark:prose-invert prose-p:text-sm", - HIGHLIGHT_COLOR_MAP["border-l"][highlight.color], - )} - > - <p>{highlight.text}</p> - </blockquote> - </button> - </div> - <div className="flex gap-2"> - <ActionButton - loading={isDeleting} - variant="ghost" - onClick={() => deleteHighlight({ highlightId: highlight.id })} - > - <Trash2 className="size-4 text-destructive" /> - </ActionButton> - </div> - </div> - ); -} +import HighlightCard from "../highlights/HighlightCard"; export default function HighlightsBox({ bookmarkId }: { bookmarkId: string }) { const { t } = useTranslation(); @@ -87,7 +29,7 @@ export default function HighlightsBox({ bookmarkId }: { bookmarkId: string }) { <CollapsibleContent className="group flex flex-col py-3 text-sm"> {highlights.highlights.map((highlight) => ( <> - <HighlightCard key={highlight.id} highlight={highlight} /> + <HighlightCard key={highlight.id} highlight={highlight} clickable /> <Separator className="m-2 h-0.5 bg-gray-200 last:hidden" /> </> ))} diff --git a/apps/web/components/dashboard/sidebar/ModileSidebar.tsx b/apps/web/components/dashboard/sidebar/ModileSidebar.tsx index bfa91afa..777877bf 100644 --- a/apps/web/components/dashboard/sidebar/ModileSidebar.tsx +++ b/apps/web/components/dashboard/sidebar/ModileSidebar.tsx @@ -1,6 +1,6 @@ import MobileSidebarItem from "@/components/shared/sidebar/ModileSidebarItem"; import HoarderLogoIcon from "@/public/icons/logo-icon.svg"; -import { ClipboardList, Search, Tag } from "lucide-react"; +import { ClipboardList, Highlighter, Search, Tag } from "lucide-react"; export default async function MobileSidebar() { return ( @@ -13,6 +13,10 @@ export default async function MobileSidebar() { <MobileSidebarItem logo={<Search />} path="/dashboard/search" /> <MobileSidebarItem logo={<ClipboardList />} path="/dashboard/lists" /> <MobileSidebarItem logo={<Tag />} path="/dashboard/tags" /> + <MobileSidebarItem + logo={<Highlighter />} + path="/dashboard/highlights" + /> </ul> </aside> ); diff --git a/apps/web/components/dashboard/sidebar/Sidebar.tsx b/apps/web/components/dashboard/sidebar/Sidebar.tsx index 8891d9bc..0f805a09 100644 --- a/apps/web/components/dashboard/sidebar/Sidebar.tsx +++ b/apps/web/components/dashboard/sidebar/Sidebar.tsx @@ -4,7 +4,7 @@ import { Separator } from "@/components/ui/separator"; import { useTranslation } from "@/lib/i18n/server"; import { api } from "@/server/api/client"; import { getServerAuthSession } from "@/server/auth"; -import { Archive, Home, Search, Tag } from "lucide-react"; +import { Archive, Highlighter, Home, Search, Tag } from "lucide-react"; import serverConfig from "@hoarder/shared/config"; @@ -46,6 +46,11 @@ export default async function Sidebar() { path: "/dashboard/tags", }, { + name: t("common.highlights"), + icon: <Highlighter size={18} />, + path: "/dashboard/highlights", + }, + { name: t("common.archive"), icon: <Archive size={18} />, path: "/dashboard/archive", diff --git a/apps/web/lib/hooks/relative-time.ts b/apps/web/lib/hooks/relative-time.ts new file mode 100644 index 00000000..f7c38497 --- /dev/null +++ b/apps/web/lib/hooks/relative-time.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from "react"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; + +dayjs.extend(relativeTime); + +export default function useRelativeTime(date: Date) { + const [state, setState] = useState({ + fromNow: "", + localCreatedAt: "", + }); + + // This is to avoid hydration errors when server and clients are in different timezones + useEffect(() => { + setState({ + fromNow: dayjs(date).fromNow(), + localCreatedAt: date.toLocaleString(), + }); + }, [date]); + + return state; +} diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 9caad519..2812df88 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -20,6 +20,7 @@ "note": "Note", "attachments": "Attachments", "highlights": "Highlights", + "source": "Source", "screenshot": "Screenshot", "video": "Video", "archive": "Archive", diff --git a/packages/shared-react/hooks/highlights.ts b/packages/shared-react/hooks/highlights.ts index 299ed697..257a1ed4 100644 --- a/packages/shared-react/hooks/highlights.ts +++ b/packages/shared-react/hooks/highlights.ts @@ -10,6 +10,7 @@ export function useCreateHighlight( apiUtils.highlights.getForBookmark.invalidate({ bookmarkId: req.bookmarkId, }); + apiUtils.highlights.getAll.invalidate(); return opts[0]?.onSuccess?.(res, req, meta); }, }); @@ -25,6 +26,7 @@ export function useUpdateHighlight( apiUtils.highlights.getForBookmark.invalidate({ bookmarkId: res.bookmarkId, }); + apiUtils.highlights.getAll.invalidate(); return opts[0]?.onSuccess?.(res, req, meta); }, }); @@ -40,6 +42,7 @@ export function useDeleteHighlight( apiUtils.highlights.getForBookmark.invalidate({ bookmarkId: res.bookmarkId, }); + apiUtils.highlights.getAll.invalidate(); return opts[0]?.onSuccess?.(res, req, meta); }, }); diff --git a/packages/shared/types/highlights.ts b/packages/shared/types/highlights.ts index 9bda6029..ff4b7e7f 100644 --- a/packages/shared/types/highlights.ts +++ b/packages/shared/types/highlights.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { zCursorV2 } from "./pagination"; + export const DEFAULT_NUM_HIGHLIGHTS_PER_PAGE = 20; const zHighlightColorSchema = z.enum(["yellow", "red", "green", "blue"]); @@ -31,3 +33,11 @@ export const zUpdateHighlightSchema = z.object({ highlightId: z.string(), color: zHighlightColorSchema.optional(), }); + +export const zGetAllHighlightsResponseSchema = z.object({ + highlights: z.array(zHighlightSchema), + nextCursor: zCursorV2.nullable(), +}); +export type ZGetAllHighlightsResponse = z.infer< + typeof zGetAllHighlightsResponseSchema +>; diff --git a/packages/trpc/routers/highlights.ts b/packages/trpc/routers/highlights.ts index e4446679..86da560b 100644 --- a/packages/trpc/routers/highlights.ts +++ b/packages/trpc/routers/highlights.ts @@ -1,10 +1,11 @@ import { experimental_trpcMiddleware, TRPCError } from "@trpc/server"; -import { and, eq, lt, lte, or } from "drizzle-orm"; +import { and, desc, eq, lt, lte, or } from "drizzle-orm"; import { z } from "zod"; import { highlights } from "@hoarder/db/schema"; import { DEFAULT_NUM_HIGHLIGHTS_PER_PAGE, + zGetAllHighlightsResponseSchema, zHighlightSchema, zNewHighlightSchema, zUpdateHighlightSchema, @@ -76,6 +77,7 @@ export const highlightsAppRouter = router({ eq(highlights.bookmarkId, input.bookmarkId), eq(highlights.userId, ctx.user.id), ), + orderBy: [desc(highlights.createdAt), desc(highlights.id)], }); return { highlights: results }; }), @@ -102,12 +104,7 @@ export const highlightsAppRouter = router({ limit: z.number().optional().default(DEFAULT_NUM_HIGHLIGHTS_PER_PAGE), }), ) - .output( - z.object({ - highlights: z.array(zHighlightSchema), - nextCursor: zCursorV2.nullable(), - }), - ) + .output(zGetAllHighlightsResponseSchema) .query(async ({ input, ctx }) => { const results = await ctx.db.query.highlights.findMany({ where: and( @@ -123,6 +120,7 @@ export const highlightsAppRouter = router({ : undefined, ), limit: input.limit + 1, + orderBy: [desc(highlights.createdAt), desc(highlights.id)], }); let nextCursor: z.infer<typeof zCursorV2> | null = null; if (results.length > input.limit) { |
