aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/app/dashboard/highlights/page.tsx20
-rw-r--r--apps/web/components/dashboard/highlights/AllHighlights.tsx98
-rw-r--r--apps/web/components/dashboard/highlights/HighlightCard.tsx76
-rw-r--r--apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx6
-rw-r--r--apps/web/components/dashboard/preview/BookmarkPreview.tsx15
-rw-r--r--apps/web/components/dashboard/preview/HighlightsBox.tsx64
-rw-r--r--apps/web/components/dashboard/sidebar/ModileSidebar.tsx6
-rw-r--r--apps/web/components/dashboard/sidebar/Sidebar.tsx7
-rw-r--r--apps/web/lib/hooks/relative-time.ts22
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json1
-rw-r--r--packages/shared-react/hooks/highlights.ts3
-rw-r--r--packages/shared/types/highlights.ts10
-rw-r--r--packages/trpc/routers/highlights.ts12
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) {