diff options
Diffstat (limited to 'apps')
24 files changed, 1247 insertions, 560 deletions
diff --git a/apps/landing/package.json b/apps/landing/package.json index be96b848..ca5c7a98 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -19,7 +19,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "lucide-react": "^0.330.0", - "next": "14.2.13", + "next": "14.2.15", "react": "^18.3.1", "react-dom": "^18.3.1", "react-select": "^5.8.0", diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 8a6e9aaf..4772c25b 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -30,7 +30,7 @@ "NSAllowsLocalNetworking": true } }, - "buildNumber": "19" + "buildNumber": "20" }, "android": { "adaptiveIcon": { @@ -48,7 +48,7 @@ } }, "package": "app.hoarder.hoardermobile", - "versionCode": 19 + "versionCode": 20 }, "plugins": [ "expo-router", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index e4d69d9f..5d4b6089 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -65,7 +65,7 @@ "ajv": "latest", "eslint": "^8.57.0", "eslint-config-universe": "^12.0.0", - "prettier": "^3.2.5", + "prettier": "^3.4.2", "tailwindcss": "^3.4.1", "typescript": "^5.3.3" }, diff --git a/apps/web/app/dashboard/admin/page.tsx b/apps/web/app/dashboard/admin/page.tsx index 18efc889..cf97698b 100644 --- a/apps/web/app/dashboard/admin/page.tsx +++ b/apps/web/app/dashboard/admin/page.tsx @@ -1,5 +1,7 @@ import { redirect } from "next/navigation"; import AdminActions from "@/components/dashboard/admin/AdminActions"; +import { AdminCard } from "@/components/dashboard/admin/AdminCard"; +import { AdminNotices } from "@/components/dashboard/admin/AdminNotices"; import ServerStats from "@/components/dashboard/admin/ServerStats"; import UserList from "@/components/dashboard/admin/UserList"; import { getServerAuthSession } from "@/server/auth"; @@ -10,14 +12,15 @@ export default async function AdminPage() { redirect("/"); } return ( - <> - <div className="rounded-md border bg-background p-4"> + <div className="flex flex-col gap-4"> + <AdminNotices /> + <AdminCard> <ServerStats /> <AdminActions /> - </div> - <div className="mt-4 rounded-md border bg-background p-4"> + </AdminCard> + <AdminCard> <UserList /> - </div> - </> + </AdminCard> + </div> ); } diff --git a/apps/web/components/dashboard/admin/AdminCard.tsx b/apps/web/components/dashboard/admin/AdminCard.tsx new file mode 100644 index 00000000..3a52b5e5 --- /dev/null +++ b/apps/web/components/dashboard/admin/AdminCard.tsx @@ -0,0 +1,3 @@ +export function AdminCard({ children }: { children: React.ReactNode }) { + return <div className="rounded-md border bg-background p-4">{children}</div>; +} diff --git a/apps/web/components/dashboard/admin/AdminNotices.tsx b/apps/web/components/dashboard/admin/AdminNotices.tsx new file mode 100644 index 00000000..4977736f --- /dev/null +++ b/apps/web/components/dashboard/admin/AdminNotices.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { api } from "@/lib/trpc"; +import { AlertCircle } from "lucide-react"; + +import { AdminCard } from "./AdminCard"; + +interface AdminNotice { + level: "info" | "warning" | "error"; + message: React.ReactNode; + title: string; +} + +function useAdminNotices() { + const { data } = api.admin.getAdminNoticies.useQuery(); + if (!data) { + return []; + } + const ret: AdminNotice[] = []; + if (data.legacyContainersNotice) { + ret.push({ + level: "warning", + message: ( + <p> + You're using the legacy docker container images. Those will stop + getting supported soon. Please follow{" "} + <a + href="https://docs.hoarder.app/next/Guides/legacy-container-upgrade" + className="underline" + > + this guide + </a>{" "} + to upgrade. + </p> + ), + title: "Legacy Container Images", + }); + } + return ret; +} + +export function AdminNotices() { + const notices = useAdminNotices(); + + if (notices.length === 0) { + return null; + } + return ( + <AdminCard> + <div className="flex flex-col gap-2"> + {notices.map((n, i) => ( + <Alert key={i} variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertTitle>{n.title}</AlertTitle> + <AlertDescription>{n.message}</AlertDescription> + </Alert> + ))} + </div> + </AdminCard> + ); +} + +export function AdminNoticeBadge() { + const notices = useAdminNotices(); + if (notices.length === 0) { + return null; + } + return <Badge variant="destructive">{notices.length}</Badge>; +} diff --git a/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx new file mode 100644 index 00000000..74eb0868 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx @@ -0,0 +1,43 @@ +import MarkdownEditor from "@/components/ui/markdown/markdown-editor";
+import { MarkdownReadonly } from "@/components/ui/markdown/markdown-readonly";
+import { toast } from "@/components/ui/use-toast";
+
+import type { ZBookmarkTypeText } from "@hoarder/shared/types/bookmarks";
+import { useUpdateBookmarkText } from "@hoarder/shared-react/hooks/bookmarks";
+
+export function BookmarkMarkdownComponent({
+ children: bookmark,
+ readOnly = true,
+}: {
+ children: ZBookmarkTypeText;
+ readOnly?: boolean;
+}) {
+ const { mutate: updateBookmarkMutator, isPending } = useUpdateBookmarkText({
+ onSuccess: () => {
+ toast({
+ description: "Note updated!",
+ });
+ },
+ onError: () => {
+ toast({ description: "Something went wrong", variant: "destructive" });
+ },
+ });
+
+ const onSave = (text: string) => {
+ updateBookmarkMutator({
+ bookmarkId: bookmark.id,
+ text,
+ });
+ };
+ return (
+ <div className="h-full overflow-hidden">
+ {readOnly ? (
+ <MarkdownReadonly>{bookmark.content.text}</MarkdownReadonly>
+ ) : (
+ <MarkdownEditor onSave={onSave} isSaving={isPending}>
+ {bookmark.content.text}
+ </MarkdownEditor>
+ )}
+ </div>
+ );
+}
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx b/apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx index e0434943..b2c27c7e 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx @@ -1,20 +1,12 @@ -import { useState } from "react"; -import { ActionButton } from "@/components/ui/action-button"; -import { Button } from "@/components/ui/button"; +import { BookmarkMarkdownComponent } from "@/components/dashboard/bookmarks/BookmarkMarkdownComponent"; import { Dialog, - DialogClose, DialogContent, - DialogDescription, - DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Textarea } from "@/components/ui/textarea"; -import { toast } from "@/components/ui/use-toast"; -import { useUpdateBookmarkText } from "@hoarder/shared-react/hooks/bookmarks"; -import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks"; +import { ZBookmark, ZBookmarkTypeText } from "@hoarder/shared/types/bookmarks"; export function BookmarkedTextEditor({ bookmark, @@ -26,55 +18,20 @@ export function BookmarkedTextEditor({ setOpen: (open: boolean) => void; }) { const isNewBookmark = bookmark === undefined; - const [noteText, setNoteText] = useState( - bookmark && bookmark.content.type == BookmarkTypes.TEXT - ? bookmark.content.text - : "", - ); - - const { mutate: updateBookmarkMutator, isPending } = useUpdateBookmarkText({ - onSuccess: () => { - toast({ - description: "Note updated!", - }); - setOpen(false); - }, - onError: () => { - toast({ description: "Something went wrong", variant: "destructive" }); - }, - }); - - const onSave = () => { - updateBookmarkMutator({ - bookmarkId: bookmark.id, - text: noteText, - }); - }; return ( <Dialog open={open} onOpenChange={setOpen}> - <DialogContent> - <DialogHeader> - <DialogTitle>{isNewBookmark ? "New Note" : "Edit Note"}</DialogTitle> - <DialogDescription> - Write your note with markdown support - </DialogDescription> + <DialogContent className="max-w-[80%]"> + <DialogHeader className="flex"> + <DialogTitle className="w-fit"> + {isNewBookmark ? "New Note" : "Edit Note"} + </DialogTitle> </DialogHeader> - <Textarea - value={noteText} - onChange={(e) => setNoteText(e.target.value)} - className="h-52 grow" - /> - <DialogFooter className="flex-shrink gap-1 sm:justify-end"> - <DialogClose asChild> - <Button type="button" variant="secondary"> - Close - </Button> - </DialogClose> - <ActionButton type="button" loading={isPending} onClick={onSave}> - Save - </ActionButton> - </DialogFooter> + <div className="h-[80vh]"> + <BookmarkMarkdownComponent readOnly={false}> + {bookmark as ZBookmarkTypeText} + </BookmarkMarkdownComponent> + </div> </DialogContent> </Dialog> ); diff --git a/apps/web/components/dashboard/bookmarks/TextCard.tsx b/apps/web/components/dashboard/bookmarks/TextCard.tsx index 14a4f905..9d168910 100644 --- a/apps/web/components/dashboard/bookmarks/TextCard.tsx +++ b/apps/web/components/dashboard/bookmarks/TextCard.tsx @@ -2,7 +2,7 @@ import Image from "next/image"; import Link from "next/link"; -import { MarkdownComponent } from "@/components/ui/markdown-component"; +import { BookmarkMarkdownComponent } from "@/components/dashboard/bookmarks/BookmarkMarkdownComponent"; import { bookmarkLayoutSwitch } from "@/lib/userLocalSettings/bookmarksLayout"; import { cn } from "@/lib/utils"; @@ -20,15 +20,16 @@ export default function TextCard({ bookmark: ZBookmarkTypeText; className?: string; }) { - const bookmarkedText = bookmark.content; - const banner = bookmark.assets.find((a) => a.assetType == "bannerImage"); - return ( <> <BookmarkLayoutAdaptingCard title={bookmark.title} - content={<MarkdownComponent>{bookmarkedText.text}</MarkdownComponent>} + content={ + <BookmarkMarkdownComponent readOnly={true}> + {bookmark} + </BookmarkMarkdownComponent> + } footer={ getSourceUrl(bookmark) && ( <FooterLinkURL url={getSourceUrl(bookmark)} /> diff --git a/apps/web/components/dashboard/header/ProfileOptions.tsx b/apps/web/components/dashboard/header/ProfileOptions.tsx index 3dbfcf04..fc18e9d2 100644 --- a/apps/web/components/dashboard/header/ProfileOptions.tsx +++ b/apps/web/components/dashboard/header/ProfileOptions.tsx @@ -16,6 +16,8 @@ import { LogOut, Moon, Paintbrush, Settings, Shield, Sun } from "lucide-react"; import { signOut, useSession } from "next-auth/react"; import { useTheme } from "next-themes"; +import { AdminNoticeBadge } from "../admin/AdminNotices"; + function DarkModeToggle() { const { t } = useTranslation(); const { theme } = useTheme(); @@ -72,9 +74,12 @@ export default function SidebarProfileOptions() { </DropdownMenuItem> {session.user.role == "admin" && ( <DropdownMenuItem asChild> - <Link href="/dashboard/admin"> - <Shield className="mr-2 size-4" /> - {t("admin.admin_settings")} + <Link href="/dashboard/admin" className="flex justify-between"> + <div className="items-cente flex gap-2"> + <Shield className="size-4" /> + {t("admin.admin_settings")} + </div> + <AdminNoticeBadge /> </Link> </DropdownMenuItem> )} diff --git a/apps/web/components/dashboard/preview/TextContentSection.tsx b/apps/web/components/dashboard/preview/TextContentSection.tsx index 327436c6..a58bc717 100644 --- a/apps/web/components/dashboard/preview/TextContentSection.tsx +++ b/apps/web/components/dashboard/preview/TextContentSection.tsx @@ -1,7 +1,8 @@ import Image from "next/image"; -import { MarkdownComponent } from "@/components/ui/markdown-component"; +import { BookmarkMarkdownComponent } from "@/components/dashboard/bookmarks/BookmarkMarkdownComponent"; import { ScrollArea } from "@radix-ui/react-scroll-area"; +import type { ZBookmarkTypeText } from "@hoarder/shared/types/bookmarks"; import { getAssetUrl } from "@hoarder/shared-react/utils/assetUtils"; import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks"; @@ -27,7 +28,9 @@ export function TextContentSection({ bookmark }: { bookmark: ZBookmark }) { /> </div> )} - <MarkdownComponent>{bookmark.content.text}</MarkdownComponent> + <BookmarkMarkdownComponent> + {bookmark as ZBookmarkTypeText} + </BookmarkMarkdownComponent> </ScrollArea> ); } diff --git a/apps/web/components/ui/alert.tsx b/apps/web/components/ui/alert.tsx new file mode 100644 index 00000000..706b711e --- /dev/null +++ b/apps/web/components/ui/alert.tsx @@ -0,0 +1,60 @@ +import type { VariantProps } from "class-variance-authority"; +import * as React from "react"; +import { cn } from "@/lib/utils"; +import { cva } from "class-variance-authority"; + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants> +>(({ className, variant, ...props }, ref) => ( + <div + ref={ref} + role="alert" + className={cn(alertVariants({ variant }), className)} + {...props} + /> +)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLHeadingElement> +>(({ className, ...props }, ref) => ( + // eslint-disable-next-line jsx-a11y/heading-has-content + <h5 + ref={ref} + className={cn("mb-1 font-medium leading-none tracking-tight", className)} + {...props} + /> +)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("text-sm [&_p]:leading-relaxed", className)} + {...props} + /> +)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/apps/web/components/ui/markdown/markdown-editor.tsx b/apps/web/components/ui/markdown/markdown-editor.tsx new file mode 100644 index 00000000..85f0c878 --- /dev/null +++ b/apps/web/components/ui/markdown/markdown-editor.tsx @@ -0,0 +1,124 @@ +import { memo, useMemo, useState } from "react"; +import ToolbarPlugin from "@/components/ui/markdown/plugins/toolbar-plugin"; +import { MarkdownEditorTheme } from "@/components/ui/markdown/theme"; +import { + CodeHighlightNode, + CodeNode, + registerCodeHighlighting, +} from "@lexical/code"; +import { LinkNode } from "@lexical/link"; +import { ListItemNode, ListNode } from "@lexical/list"; +import { + $convertFromMarkdownString, + $convertToMarkdownString, + TRANSFORMERS, +} from "@lexical/markdown"; +import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"; +import { + InitialConfigType, + LexicalComposer, +} from "@lexical/react/LexicalComposer"; +import { ContentEditable } from "@lexical/react/LexicalContentEditable"; +import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; +import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; +import { HorizontalRuleNode } from "@lexical/react/LexicalHorizontalRuleNode"; +import { ListPlugin } from "@lexical/react/LexicalListPlugin"; +import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; +import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; +import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin"; +import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; +import { TabIndentationPlugin } from "@lexical/react/LexicalTabIndentationPlugin"; +import { HeadingNode, QuoteNode } from "@lexical/rich-text"; +import { $getRoot, EditorState, LexicalEditor } from "lexical"; + +function onError(error: Error) { + console.error(error); +} + +const EDITOR_NODES = [ + HeadingNode, + ListNode, + ListItemNode, + QuoteNode, + LinkNode, + CodeNode, + HorizontalRuleNode, + CodeHighlightNode, +]; + +interface MarkdownEditorProps { + children: string; + onSave?: (markdown: string) => void; + isSaving?: boolean; +} + +const MarkdownEditor = memo( + ({ children: initialMarkdown, onSave, isSaving }: MarkdownEditorProps) => { + const [isRawMarkdownMode, setIsRawMarkdownMode] = useState(false); + const [rawMarkdown, setRawMarkdown] = useState(initialMarkdown); + + const initialConfig: InitialConfigType = useMemo( + () => ({ + namespace: "editor", + onError, + theme: MarkdownEditorTheme, + nodes: EDITOR_NODES, + editorState: (editor: LexicalEditor) => { + registerCodeHighlighting(editor); + $convertFromMarkdownString(initialMarkdown, TRANSFORMERS); + }, + }), + [initialMarkdown], + ); + + const handleOnChange = (editorState: EditorState) => { + editorState.read(() => { + let markdownString; + if (isRawMarkdownMode) { + markdownString = $getRoot()?.getFirstChild()?.getTextContent() ?? ""; + } else { + markdownString = $convertToMarkdownString(TRANSFORMERS); + } + setRawMarkdown(markdownString); + }); + }; + + return ( + <LexicalComposer initialConfig={initialConfig}> + <div className="flex h-full flex-col justify-stretch"> + <ToolbarPlugin + isRawMarkdownMode={isRawMarkdownMode} + setIsRawMarkdownMode={setIsRawMarkdownMode} + onSave={onSave && (() => onSave(rawMarkdown))} + isSaving={!!isSaving} + /> + {isRawMarkdownMode ? ( + <PlainTextPlugin + contentEditable={ + <ContentEditable className="h-full w-full min-w-full overflow-auto p-2" /> + } + ErrorBoundary={LexicalErrorBoundary} + /> + ) : ( + <RichTextPlugin + contentEditable={ + <ContentEditable className="prose h-full w-full min-w-full overflow-auto p-2 dark:prose-invert prose-p:m-0" /> + } + ErrorBoundary={LexicalErrorBoundary} + /> + )} + </div> + <HistoryPlugin /> + <AutoFocusPlugin /> + <TabIndentationPlugin /> + <MarkdownShortcutPlugin transformers={TRANSFORMERS} /> + <OnChangePlugin onChange={handleOnChange} /> + <ListPlugin /> + </LexicalComposer> + ); + }, +); +// needed for linter because of memo +MarkdownEditor.displayName = "MarkdownEditor"; + +export default MarkdownEditor; diff --git a/apps/web/components/ui/markdown-component.tsx b/apps/web/components/ui/markdown/markdown-readonly.tsx index d3c832ac..29077480 100644 --- a/apps/web/components/ui/markdown-component.tsx +++ b/apps/web/components/ui/markdown/markdown-readonly.tsx @@ -1,61 +1,57 @@ -import React from "react";
-import CopyBtn from "@/components/ui/copy-button";
-import { cn } from "@/lib/utils";
-import Markdown from "react-markdown";
-import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
-import { dracula } from "react-syntax-highlighter/dist/cjs/styles/prism";
-import remarkBreaks from "remark-breaks";
-import remarkGfm from "remark-gfm";
-
-function PreWithCopyBtn({ className, ...props }: React.ComponentProps<"pre">) {
- const ref = React.useRef<HTMLPreElement>(null);
- return (
- <span className="group relative">
- <CopyBtn
- className="absolute right-1 top-1 m-1 hidden text-white group-hover:block"
- getStringToCopy={() => {
- return ref.current?.textContent ?? "";
- }}
- />
- <pre ref={ref} className={cn(className, "")} {...props} />
- </span>
- );
-}
-
-export function MarkdownComponent({
- children: markdown,
-}: {
- children: string;
-}) {
- return (
- <Markdown
- remarkPlugins={[remarkGfm, remarkBreaks]}
- className="prose dark:prose-invert"
- components={{
- pre({ ...props }) {
- return <PreWithCopyBtn {...props} />;
- },
- code({ className, children, ...props }) {
- const match = /language-(\w+)/.exec(className ?? "");
- return match ? (
- // @ts-expect-error -- Refs are not compatible for some reason
- <SyntaxHighlighter
- PreTag="div"
- language={match[1]}
- {...props}
- style={dracula}
- >
- {String(children).replace(/\n$/, "")}
- </SyntaxHighlighter>
- ) : (
- <code className={className} {...props}>
- {children}
- </code>
- );
- },
- }}
- >
- {markdown}
- </Markdown>
- );
-}
+import React from "react"; +import CopyBtn from "@/components/ui/copy-button"; +import { cn } from "@/lib/utils"; +import Markdown from "react-markdown"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { dracula } from "react-syntax-highlighter/dist/cjs/styles/prism"; +import remarkBreaks from "remark-breaks"; +import remarkGfm from "remark-gfm"; + +function PreWithCopyBtn({ className, ...props }: React.ComponentProps<"pre">) { + const ref = React.useRef<HTMLPreElement>(null); + return ( + <span className="group relative"> + <CopyBtn + className="absolute right-1 top-1 m-1 hidden text-white group-hover:block" + getStringToCopy={() => { + return ref.current?.textContent ?? ""; + }} + /> + <pre ref={ref} className={cn(className, "")} {...props} /> + </span> + ); +} + +export function MarkdownReadonly({ children: markdown }: { children: string }) { + return ( + <Markdown + remarkPlugins={[remarkGfm, remarkBreaks]} + className="prose dark:prose-invert" + components={{ + pre({ ...props }) { + return <PreWithCopyBtn {...props} />; + }, + code({ className, children, ...props }) { + const match = /language-(\w+)/.exec(className ?? ""); + return match ? ( + // @ts-expect-error -- Refs are not compatible for some reason + <SyntaxHighlighter + PreTag="div" + language={match[1]} + {...props} + style={dracula} + > + {String(children).replace(/\n$/, "")} + </SyntaxHighlighter> + ) : ( + <code className={className} {...props}> + {children} + </code> + ); + }, + }} + > + {markdown} + </Markdown> + ); +} diff --git a/apps/web/components/ui/markdown/plugins/toolbar-plugin.tsx b/apps/web/components/ui/markdown/plugins/toolbar-plugin.tsx new file mode 100644 index 00000000..28e265e2 --- /dev/null +++ b/apps/web/components/ui/markdown/plugins/toolbar-plugin.tsx @@ -0,0 +1,290 @@ +import { useCallback, useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { useTranslation } from "@/lib/i18n/client"; +import { + $convertFromMarkdownString, + $convertToMarkdownString, + TRANSFORMERS, +} from "@lexical/markdown"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { mergeRegister } from "@lexical/utils"; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isRangeSelection, + FORMAT_TEXT_COMMAND, + LexicalCommand, + SELECTION_CHANGE_COMMAND, + TextFormatType, +} from "lexical"; +import { + Bold, + Code, + Highlighter, + Italic, + LucideIcon, + Save, + Strikethrough, +} from "lucide-react"; + +import { ActionButton } from "../../action-button"; +import InfoTooltip from "../../info-tooltip"; +import { Label } from "../../label"; +import { Switch } from "../../switch"; + +const LowPriority = 1; + +function MarkdownToolTip() { + const { t } = useTranslation(); + return ( + <InfoTooltip size={15}> + <table className="w-full table-auto text-left text-sm"> + <thead> + <tr> + <th>{t("editor.text_toolbar.markdown_shortcuts.label")}</th> + </tr> + </thead> + <tbody> + <tr className="border-b"> + <td className="py-2"> + {t("editor.text_toolbar.markdown_shortcuts.heading.label")} + </td> + <td className="py-2"> + {t("editor.text_toolbar.markdown_shortcuts.heading.example")} + </td> + </tr> + <tr className="border-b"> + <td className="py-2"> + {t("editor.text_toolbar.markdown_shortcuts.bold.label")} + </td> + <td className="py-2"> + {t("editor.text_toolbar.markdown_shortcuts.bold.example")} + </td> + </tr> + <tr className="border-b"> + <td className="py-2"> + {t("editor.text_toolbar.markdown_shortcuts.italic.label")} + </td> + <td className="py-2"> + {t("editor.text_toolbar.markdown_shortcuts.italic.example")} + </td> + </tr> + <tr className="border-b"> + <td className="py-2"> + {t("editor.text_toolbar.markdown_shortcuts.blockquote.label")} + </td> + <td className="py-2"> + {t("editor.text_toolbar.markdown_shortcuts.blockquote.example")} + </td> + </tr> + <tr className="border-b"> + <td className="py-2"> + {t("editor.text_toolbar.markdown_shortcuts.ordered_list.label")} + </td> + <td className="py-2"> + {t("editor.text_toolbar.markdown_shortcuts.ordered_list.example")} + </td> + </tr> + <tr className="border-b"> + <td className="py-2"> + {t("editor.text_toolbar.markdown_shortcuts.unordered_list.label")} + </td> + <td className="py-2"> + {t( + "editor.text_toolbar.markdown_shortcuts.unordered_list.example", + )} + </td> + </tr> + <tr className="border-b"> + <td className="py-2"> + {t("editor.text_toolbar.markdown_shortcuts.inline_code.label")} + </td> + <td className="py-2"> + {t("editor.text_toolbar.markdown_shortcuts.inline_code.example")} + </td> + </tr> + <tr className="border-b"> + <td className="py-2"> + {t("editor.text_toolbar.markdown_shortcuts.block_code.label")} + </td> + <td className="py-2"> + {t("editor.text_toolbar.markdown_shortcuts.block_code.example")} + </td> + </tr> + </tbody> + </table> + </InfoTooltip> + ); +} + +export default function ToolbarPlugin({ + isRawMarkdownMode = false, + setIsRawMarkdownMode, + onSave, + isSaving, +}: { + isRawMarkdownMode: boolean; + setIsRawMarkdownMode: (value: boolean) => void; + onSave?: () => void; + isSaving: boolean; +}) { + const { t } = useTranslation(); + const [editor] = useLexicalComposerContext(); + const [editorToolbarState, setEditorToolbarState] = useState<{ + isBold: boolean; + isItalic: boolean; + isStrikethrough: boolean; + isHighlight: boolean; + isCode: boolean; + }>({ + isBold: false, + isItalic: false, + isStrikethrough: false, + isHighlight: false, + isCode: false, + }); + + const $updateToolbar = useCallback(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + setEditorToolbarState({ + isBold: selection.hasFormat("bold"), + isItalic: selection.hasFormat("italic"), + isStrikethrough: selection.hasFormat("strikethrough"), + isHighlight: selection.hasFormat("highlight"), + isCode: selection.hasFormat("code"), + }); + } + }, []); + + useEffect(() => { + return mergeRegister( + editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + $updateToolbar(); + }); + }), + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + (_payload, _newEditor) => { + $updateToolbar(); + return false; + }, + LowPriority, + ), + ); + }, [editor, $updateToolbar]); + + const formatButtons: { + command: LexicalCommand<TextFormatType>; + format: TextFormatType; + isActive?: boolean; + icon: LucideIcon; + label: string; + }[] = [ + { + command: FORMAT_TEXT_COMMAND, + format: "bold", + icon: Bold, + isActive: editorToolbarState.isBold, + label: t("editor.text_toolbar.bold"), + }, + { + command: FORMAT_TEXT_COMMAND, + format: "italic", + icon: Italic, + isActive: editorToolbarState.isItalic, + label: t("editor.text_toolbar.italic"), + }, + { + command: FORMAT_TEXT_COMMAND, + format: "strikethrough", + icon: Strikethrough, + isActive: editorToolbarState.isStrikethrough, + label: t("editor.text_toolbar.strikethrough"), + }, + { + command: FORMAT_TEXT_COMMAND, + format: "code", + icon: Code, + isActive: editorToolbarState.isCode, + label: t("editor.text_toolbar.code"), + }, + { + command: FORMAT_TEXT_COMMAND, + format: "highlight", + icon: Highlighter, + isActive: editorToolbarState.isHighlight, + label: t("editor.text_toolbar.highlight"), + }, + ]; + + const handleRawMarkdownToggle = useCallback(() => { + editor.update(() => { + console.log(isRawMarkdownMode); + const root = $getRoot(); + const firstChild = root.getFirstChild(); + if (isRawMarkdownMode) { + if (firstChild) { + $convertFromMarkdownString(firstChild.getTextContent(), TRANSFORMERS); + } + setIsRawMarkdownMode(false); + } else { + const markdown = $convertToMarkdownString(TRANSFORMERS); + const pNode = $createParagraphNode(); + pNode.append($createTextNode(markdown)); + root.clear().append(pNode); + setIsRawMarkdownMode(true); + } + }); + }, [editor, isRawMarkdownMode]); + + return ( + <div className="mb-1 flex items-center justify-between rounded-t-lg p-1"> + <div className="flex"> + {formatButtons.map( + ({ command, format, icon: Icon, isActive, label }) => ( + <Button + key={format} + disabled={isRawMarkdownMode} + size={"sm"} + onClick={() => { + editor.dispatchCommand(command, format); + }} + variant={isActive ? "default" : "ghost"} + aria-label={label} + > + <Icon className="h-4 w-4" /> + </Button> + ), + )} + </div> + <div className="flex items-center gap-2"> + <div className="flex items-center space-x-2"> + <Switch + id="editor-raw-markdown" + onCheckedChange={handleRawMarkdownToggle} + checked={isRawMarkdownMode} + /> + <Label htmlFor="editor-raw-markdown">Raw Markdown</Label> + </div> + {onSave && ( + <ActionButton + loading={isSaving} + className="flex items-center gap-2" + size={"sm"} + onClick={() => { + onSave?.(); + }} + > + <Save className="size-4" /> + Save + </ActionButton> + )} + <MarkdownToolTip /> + </div> + </div> + ); +} diff --git a/apps/web/components/ui/markdown/theme.ts b/apps/web/components/ui/markdown/theme.ts new file mode 100644 index 00000000..ff088e32 --- /dev/null +++ b/apps/web/components/ui/markdown/theme.ts @@ -0,0 +1,35 @@ +export const MarkdownEditorTheme = { + code: "bg-[#282A36] text-[#F8F8F2] font-mono block px-4 py-2 my-2 text-sm overflow-x-auto relative rounded-md shadow-sm", + codeHighlight: { + atrule: "text-[#8BE9FD]", + attr: "text-[#8BE9FD]", + boolean: "text-[#FF79C6]", + builtin: "text-[#50FA7B]", + cdata: "text-[#6272A4]", + char: "text-[#50FA7B]", + class: "text-[#FF79C6]", + "class-name": "text-[#FF79C6]", + comment: "text-[#6272A4]", + constant: "text-[#FF79C6]", + deleted: "text-[#FF5555]", + doctype: "text-[#6272A4]", + entity: "text-[#FFB86C]", + function: "text-[#50FA7B]", + important: "text-[#F1FA8C]", + inserted: "text-[#50FA7B]", + keyword: "text-[#FF79C6]", + namespace: "text-[#F1FA8C]", + number: "text-[#BD93F9]", + operator: "text-[#FFB86C]", + prolog: "text-[#6272A4]", + property: "text-[#FFB86C]", + punctuation: "text-[#F8F8F2]", + regex: "text-[#FF5555]", + selector: "text-[#50FA7B]", + string: "text-[#F1FA8C]", + symbol: "text-[#FF79C6]", + tag: "text-[#FF79C6]", + url: "text-[#8BE9FD]", + variable: "text-[#F1FA8C]", + }, +}; diff --git a/apps/web/components/ui/switch.tsx b/apps/web/components/ui/switch.tsx new file mode 100644 index 00000000..2438dc86 --- /dev/null +++ b/apps/web/components/ui/switch.tsx @@ -0,0 +1,28 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; +import * as SwitchPrimitives from "@radix-ui/react-switch"; + +const Switch = React.forwardRef< + React.ElementRef<typeof SwitchPrimitives.Root>, + React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> +>(({ className, ...props }, ref) => ( + <SwitchPrimitives.Root + className={cn( + "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", + className, + )} + {...props} + ref={ref} + > + <SwitchPrimitives.Thumb + className={cn( + "pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0", + )} + /> + </SwitchPrimitives.Root> +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; diff --git a/apps/web/lib/exportBookmarks.ts b/apps/web/lib/exportBookmarks.ts index dd1913fb..f651b897 100644 --- a/apps/web/lib/exportBookmarks.ts +++ b/apps/web/lib/exportBookmarks.ts @@ -51,7 +51,7 @@ export function toExportFormat( title: bookmark.title ?? (bookmark.content.type === BookmarkTypes.LINK - ? bookmark.content.title ?? null + ? (bookmark.content.title ?? null) : null), tags: bookmark.tags.map((t) => t.name), content, diff --git a/apps/web/lib/i18n/locales/de/translation.json b/apps/web/lib/i18n/locales/de/translation.json index 73e8839e..cb07914a 100644 --- a/apps/web/lib/i18n/locales/de/translation.json +++ b/apps/web/lib/i18n/locales/de/translation.json @@ -1,213 +1,213 @@ { - "common": { - "url": "URL", - "name": "Name", - "email": "E-Mail", - "password": "Passwort", - "action": "Aktion", - "actions": "Aktionen", - "created_at": "Erstellt am", - "key": "Schlüssel", - "role": "Rolle", - "roles": { - "user": "Benutzer", - "admin": "Administrator" - }, - "something_went_wrong": "Etwas ist schief gelaufen", - "experimental": "Experimentell", - "search": "Suche", - "tags": "Tags", - "note": "Notiz", - "attachments": "Anhänge", - "screenshot": "Screenshot", - "video": "Video", - "archive": "Archiv", - "home": "Startseite" + "common": { + "url": "URL", + "name": "Name", + "email": "E-Mail", + "password": "Passwort", + "action": "Aktion", + "actions": "Aktionen", + "created_at": "Erstellt am", + "key": "Schlüssel", + "role": "Rolle", + "roles": { + "user": "Benutzer", + "admin": "Administrator" }, - "layouts": { - "masonry": "Mauerwerk", - "grid": "Raster", - "list": "Liste", - "compact": "Kompakt" + "something_went_wrong": "Etwas ist schief gelaufen", + "experimental": "Experimentell", + "search": "Suche", + "tags": "Tags", + "note": "Notiz", + "attachments": "Anhänge", + "screenshot": "Screenshot", + "video": "Video", + "archive": "Archiv", + "home": "Startseite" + }, + "layouts": { + "masonry": "Mauerwerk", + "grid": "Raster", + "list": "Liste", + "compact": "Kompakt" + }, + "actions": { + "change_layout": "Layout ändern", + "archive": "Archivieren", + "unarchive": "Archivierung aufheben", + "favorite": "Favorit", + "unfavorite": "Favorit entfernen", + "delete": "Löschen", + "refresh": "Aktualisieren", + "download_full_page_archive": "Vollständiges Seitenarchiv herunterladen", + "edit_tags": "Tags bearbeiten", + "add_to_list": "Zur Liste hinzufügen", + "select_all": "Alle auswählen", + "unselect_all": "Auswahl aufheben", + "copy_link": "Link kopieren", + "close_bulk_edit": "Massenbearbeitung schließen", + "bulk_edit": "Massenbearbeitung", + "manage_lists": "Listen verwalten", + "remove_from_list": "Aus Liste entfernen", + "save": "Speichern", + "add": "Hinzufügen", + "edit": "Bearbeiten", + "create": "Erstellen", + "fetch_now": "Jetzt abrufen", + "summarize_with_ai": "Mit KI zusammenfassen", + "edit_title": "Titel bearbeiten", + "sign_out": "Abmelden", + "close": "Schließen", + "merge": "Zusammenführen", + "cancel": "Abbrechen", + "apply_all": "Alle anwenden", + "ignore": "Ignorieren", + "recrawl": "Erneutes Crawlen" + }, + "settings": { + "back_to_app": "Zurück zur App", + "user_settings": "Benutzereinstellungen", + "info": { + "user_info": "Benutzerinformationen", + "basic_details": "Grundlegende Details", + "change_password": "Passwort ändern", + "current_password": "Aktuelles Passwort", + "new_password": "Neues Passwort", + "confirm_new_password": "Neues Passwort bestätigen", + "options": "Optionen", + "interface_lang": "Oberflächensprache" }, - "actions": { - "change_layout": "Layout ändern", - "archive": "Archivieren", - "unarchive": "Archivierung aufheben", - "favorite": "Favorit", - "unfavorite": "Favorit entfernen", - "delete": "Löschen", - "refresh": "Aktualisieren", - "download_full_page_archive": "Vollständiges Seitenarchiv herunterladen", - "edit_tags": "Tags bearbeiten", - "add_to_list": "Zur Liste hinzufügen", - "select_all": "Alle auswählen", - "unselect_all": "Auswahl aufheben", - "copy_link": "Link kopieren", - "close_bulk_edit": "Massenbearbeitung schließen", - "bulk_edit": "Massenbearbeitung", - "manage_lists": "Listen verwalten", - "remove_from_list": "Aus Liste entfernen", - "save": "Speichern", - "add": "Hinzufügen", - "edit": "Bearbeiten", - "create": "Erstellen", - "fetch_now": "Jetzt abrufen", - "summarize_with_ai": "Mit KI zusammenfassen", - "edit_title": "Titel bearbeiten", - "sign_out": "Abmelden", - "close": "Schließen", - "merge": "Zusammenführen", - "cancel": "Abbrechen", - "apply_all": "Alle anwenden", - "ignore": "Ignorieren", - "recrawl": "Erneutes Crawlen" - }, - "settings": { - "back_to_app": "Zurück zur App", - "user_settings": "Benutzereinstellungen", - "info": { - "user_info": "Benutzerinformationen", - "basic_details": "Grundlegende Details", - "change_password": "Passwort ändern", - "current_password": "Aktuelles Passwort", - "new_password": "Neues Passwort", - "confirm_new_password": "Neues Passwort bestätigen", - "options": "Optionen", - "interface_lang": "Oberflächensprache" - }, - "ai": { - "ai_settings": "KI-Einstellungen", - "tagging_rules": "Tagging-Regeln", - "tagging_rule_description": "Eingabeaufforderungen, die Sie hier hinzufügen, werden als Regeln für das Modell bei der Tag-Generierung verwendet. Sie können die endgültigen Aufforderungen im Bereich Vorschau anzeigen.", - "prompt_preview": "Aufforderungsvorschau", - "text_prompt": "Text-Aufforderung", - "images_prompt": "Bild-Aufforderung" - }, - "feeds": { - "rss_subscriptions": "RSS-Abonnements", - "add_a_subscription": "Ein Abonnement hinzufügen" - }, - "import": { - "import_export": "Import / Export", - "import_export_bookmarks": "Lesezeichen importieren / exportieren", - "import_bookmarks_from_html_file": "Lesezeichen aus HTML-Datei importieren", - "import_bookmarks_from_pocket_export": "Lesezeichen aus Pocket-Export importieren", - "import_bookmarks_from_omnivore_export": "Lesezeichen aus Omnivore-Export importieren", - "import_bookmarks_from_hoarder_export": "Lesezeichen aus Hoarder-Export importieren", - "export_links_and_notes": "Links und Notizen exportieren", - "imported_bookmarks": "Importierte Lesezeichen" - }, - "api_keys": { - "api_keys": "API-Schlüssel", - "new_api_key": "Neuer API-Schlüssel", - "new_api_key_desc": "Geben Sie Ihrem API-Schlüssel einen eindeutigen Namen", - "key_success": "Schlüssel wurde erfolgreich erstellt", - "key_success_please_copy": "Bitte kopieren Sie den Schlüssel und speichern Sie ihn an einem sicheren Ort. Sobald Sie das Dialogfeld schließen, können Sie nicht mehr darauf zugreifen." - }, - "broken_links": { - "broken_links": "Defekter Link", - "last_crawled_at": "Zuletzt gecrawlt am", - "crawling_status": "Crawling-Status", - "crawling_failed": "Crawling fehlgeschlagen" - } + "ai": { + "ai_settings": "KI-Einstellungen", + "tagging_rules": "Tagging-Regeln", + "tagging_rule_description": "Eingabeaufforderungen, die Sie hier hinzufügen, werden als Regeln für das Modell bei der Tag-Generierung verwendet. Sie können die endgültigen Aufforderungen im Bereich Vorschau anzeigen.", + "prompt_preview": "Aufforderungsvorschau", + "text_prompt": "Text-Aufforderung", + "images_prompt": "Bild-Aufforderung" }, - "admin": { - "admin_settings": "Admin-Einstellungen", - "server_stats": { - "server_stats": "Serverstatistiken", - "total_users": "Gesamte Benutzer", - "total_bookmarks": "Gesamte Lesezeichen", - "server_version": "Serverversion" - }, - "background_jobs": { - "background_jobs": "Hintergrundaufgaben", - "crawler_jobs": "Crawler-Aufgaben", - "indexing_jobs": "Indexierungsaufgaben", - "inference_jobs": "Inferenzaufgaben", - "tidy_assets_jobs": "Aufräumaufgaben", - "job": "Aufgabe", - "queued": "Warteschlange", - "pending": "Ausstehend", - "failed": "Fehlgeschlagen" - }, - "actions": { - "recrawl_failed_links_only": "Nur fehlgeschlagene Links erneut durchsuchen", - "recrawl_all_links": "Alle Links erneut durchsuchen", - "without_inference": "Ohne Inferenz", - "regenerate_ai_tags_for_failed_bookmarks_only": "KI-Tags nur für fehlgeschlagene Lesezeichen neu generieren", - "regenerate_ai_tags_for_all_bookmarks": "KI-Tags für alle Lesezeichen neu generieren", - "reindex_all_bookmarks": "Alle Lesezeichen neu indizieren", - "compact_assets": "Assets komprimieren" - }, - "users_list": { - "users_list": "Benutzerliste", - "create_user": "Benutzer erstellen", - "change_role": "Rolle ändern", - "reset_password": "Passwort zurücksetzen", - "delete_user": "Benutzer löschen", - "num_bookmarks": "Anzahl der Lesezeichen", - "asset_sizes": "Asset-Größen", - "local_user": "Lokaler Benutzer", - "confirm_password": "Passwort bestätigen" - } + "feeds": { + "rss_subscriptions": "RSS-Abonnements", + "add_a_subscription": "Ein Abonnement hinzufügen" }, - "options": { - "dark_mode": "Dunkelmodus", - "light_mode": "Hellmodus" + "import": { + "import_export": "Import / Export", + "import_export_bookmarks": "Lesezeichen importieren / exportieren", + "import_bookmarks_from_html_file": "Lesezeichen aus HTML-Datei importieren", + "import_bookmarks_from_pocket_export": "Lesezeichen aus Pocket-Export importieren", + "import_bookmarks_from_omnivore_export": "Lesezeichen aus Omnivore-Export importieren", + "import_bookmarks_from_hoarder_export": "Lesezeichen aus Hoarder-Export importieren", + "export_links_and_notes": "Links und Notizen exportieren", + "imported_bookmarks": "Importierte Lesezeichen" }, - "lists": { - "all_lists": "Alle Listen", - "favourites": "Favoriten", - "new_list": "Neue Liste", - "new_nested_list": "Neue verschachtelte Liste" + "api_keys": { + "api_keys": "API-Schlüssel", + "new_api_key": "Neuer API-Schlüssel", + "new_api_key_desc": "Geben Sie Ihrem API-Schlüssel einen eindeutigen Namen", + "key_success": "Schlüssel wurde erfolgreich erstellt", + "key_success_please_copy": "Bitte kopieren Sie den Schlüssel und speichern Sie ihn an einem sicheren Ort. Sobald Sie das Dialogfeld schließen, können Sie nicht mehr darauf zugreifen." }, - "tags": { - "all_tags": "Alle Tags", - "your_tags": "Ihre Tags", - "your_tags_info": "Tags, die Sie mindestens einmal angehängt haben", - "ai_tags": "KI-Tags", - "ai_tags_info": "Tags, die nur automatisch (von der KI) angehängt wurden", - "unused_tags": "Ungeliebte Tags", - "unused_tags_info": "Tags, die an keine Lesezeichen angehängt sind", - "delete_all_unused_tags": "Alle ungeliebten Tags löschen", - "drag_and_drop_merging": "Ziehen & Ablegen zum Zusammenführen", - "drag_and_drop_merging_info": "Ziehen und ablegen, um Tags zusammenzuführen", - "sort_by_name": "Nach Name sortieren" + "broken_links": { + "broken_links": "Defekter Link", + "last_crawled_at": "Zuletzt gecrawlt am", + "crawling_status": "Crawling-Status", + "crawling_failed": "Crawling fehlgeschlagen" + } + }, + "admin": { + "admin_settings": "Admin-Einstellungen", + "server_stats": { + "server_stats": "Serverstatistiken", + "total_users": "Gesamte Benutzer", + "total_bookmarks": "Gesamte Lesezeichen", + "server_version": "Serverversion" }, - "preview": { - "view_original": "Original anzeigen", - "cached_content": "Gecachte Inhalte" + "background_jobs": { + "background_jobs": "Hintergrundaufgaben", + "crawler_jobs": "Crawler-Aufgaben", + "indexing_jobs": "Indexierungsaufgaben", + "inference_jobs": "Inferenzaufgaben", + "tidy_assets_jobs": "Aufräumaufgaben", + "job": "Aufgabe", + "queued": "Warteschlange", + "pending": "Ausstehend", + "failed": "Fehlgeschlagen" }, - "editor": { - "quickly_focus": "Sie können schnell auf dieses Feld fokussieren, indem Sie ⌘ + E drücken", - "multiple_urls_dialog_title": "URLs als separate Lesezeichen importieren?", - "multiple_urls_dialog_desc": "Die Eingabe enthält mehrere URLs in separaten Zeilen. Möchten Sie sie als separate Lesezeichen importieren?", - "import_as_text": "Als Text-Lesezeichen importieren", - "import_as_separate_bookmarks": "Als separate Lesezeichen importieren", - "placeholder": "Fügen Sie einen Link oder ein Bild ein, schreiben Sie eine Notiz oder ziehen Sie ein Bild hierher ...", - "new_item": "NEUER EINTRAG", - "disabled_submissions": "Einsendungen sind deaktiviert" + "actions": { + "recrawl_failed_links_only": "Nur fehlgeschlagene Links erneut durchsuchen", + "recrawl_all_links": "Alle Links erneut durchsuchen", + "without_inference": "Ohne Inferenz", + "regenerate_ai_tags_for_failed_bookmarks_only": "KI-Tags nur für fehlgeschlagene Lesezeichen neu generieren", + "regenerate_ai_tags_for_all_bookmarks": "KI-Tags für alle Lesezeichen neu generieren", + "reindex_all_bookmarks": "Alle Lesezeichen neu indizieren", + "compact_assets": "Assets komprimieren" }, - "toasts": { - "bookmarks": { - "updated": "Das Lesezeichen wurde aktualisiert!", - "deleted": "Das Lesezeichen wurde gelöscht!", - "refetch": "Neuabruf wurde in die Warteschlange gestellt!", - "full_page_archive": "Erstellung des vollständigen Seitenarchivs wurde ausgelöst", - "delete_from_list": "Das Lesezeichen wurde aus der Liste gelöscht", - "clipboard_copied": "Link wurde in Ihre Zwischenablage kopiert!" - }, - "lists": { - "created": "Liste wurde erstellt!", - "updated": "Liste wurde aktualisiert!" - } + "users_list": { + "users_list": "Benutzerliste", + "create_user": "Benutzer erstellen", + "change_role": "Rolle ändern", + "reset_password": "Passwort zurücksetzen", + "delete_user": "Benutzer löschen", + "num_bookmarks": "Anzahl der Lesezeichen", + "asset_sizes": "Asset-Größen", + "local_user": "Lokaler Benutzer", + "confirm_password": "Passwort bestätigen" + } + }, + "options": { + "dark_mode": "Dunkelmodus", + "light_mode": "Hellmodus" + }, + "lists": { + "all_lists": "Alle Listen", + "favourites": "Favoriten", + "new_list": "Neue Liste", + "new_nested_list": "Neue verschachtelte Liste" + }, + "tags": { + "all_tags": "Alle Tags", + "your_tags": "Ihre Tags", + "your_tags_info": "Tags, die Sie mindestens einmal angehängt haben", + "ai_tags": "KI-Tags", + "ai_tags_info": "Tags, die nur automatisch (von der KI) angehängt wurden", + "unused_tags": "Ungeliebte Tags", + "unused_tags_info": "Tags, die an keine Lesezeichen angehängt sind", + "delete_all_unused_tags": "Alle ungeliebten Tags löschen", + "drag_and_drop_merging": "Ziehen & Ablegen zum Zusammenführen", + "drag_and_drop_merging_info": "Ziehen und ablegen, um Tags zusammenzuführen", + "sort_by_name": "Nach Name sortieren" + }, + "preview": { + "view_original": "Original anzeigen", + "cached_content": "Gecachte Inhalte" + }, + "editor": { + "quickly_focus": "Sie können schnell auf dieses Feld fokussieren, indem Sie ⌘ + E drücken", + "multiple_urls_dialog_title": "URLs als separate Lesezeichen importieren?", + "multiple_urls_dialog_desc": "Die Eingabe enthält mehrere URLs in separaten Zeilen. Möchten Sie sie als separate Lesezeichen importieren?", + "import_as_text": "Als Text-Lesezeichen importieren", + "import_as_separate_bookmarks": "Als separate Lesezeichen importieren", + "placeholder": "Fügen Sie einen Link oder ein Bild ein, schreiben Sie eine Notiz oder ziehen Sie ein Bild hierher ...", + "new_item": "NEUER EINTRAG", + "disabled_submissions": "Einsendungen sind deaktiviert" + }, + "toasts": { + "bookmarks": { + "updated": "Das Lesezeichen wurde aktualisiert!", + "deleted": "Das Lesezeichen wurde gelöscht!", + "refetch": "Neuabruf wurde in die Warteschlange gestellt!", + "full_page_archive": "Erstellung des vollständigen Seitenarchivs wurde ausgelöst", + "delete_from_list": "Das Lesezeichen wurde aus der Liste gelöscht", + "clipboard_copied": "Link wurde in Ihre Zwischenablage kopiert!" }, - "cleanups": { - "cleanups": "Bereinigungen", - "duplicate_tags": { - "title": "Doppelte Tags", - "merge_all_suggestions": "Alle Vorschläge zusammenführen?" - } + "lists": { + "created": "Liste wurde erstellt!", + "updated": "Liste wurde aktualisiert!" + } + }, + "cleanups": { + "cleanups": "Bereinigungen", + "duplicate_tags": { + "title": "Doppelte Tags", + "merge_all_suggestions": "Alle Vorschläge zusammenführen?" } + } } diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 9f12487f..a871489a 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -187,7 +187,55 @@ "import_as_separate_bookmarks": "Import as separate Bookmarks", "placeholder": "Paste a link or an image, write a note or drag and drop an image in here ...", "new_item": "NEW ITEM", - "disabled_submissions": "Submissions are disabled" + "disabled_submissions": "Submissions are disabled", + "text_toolbar": { + "undo": "Undo", + "redo": "Redo", + "bold": "Bold", + "italic": "Italic", + "underline": "Underline", + "strikethrough": "Strikethrough", + "code": "Code", + "highlight": "Highlight", + "align_left": "Left Align", + "align_center": "Center Align", + "align_right": "Right Align", + "markdown_shortcuts": { + "label": "Markdown shortcuts", + "heading": { + "label": "Heading", + "example": "# H1, ## H2, ### H3" + }, + "bold": { + "label": "Bold", + "example": "**text** or CTRL+b" + }, + "italic": { + "label": "Italic", + "example": "*Italic* or _Italic_ or CTRL+i" + }, + "blockquote": { + "label": "Blockquote", + "example": "> Blockquote" + }, + "ordered_list": { + "label": "Ordered List", + "example": "1. List item" + }, + "unordered_list": { + "label": "Unordered List", + "example": "- List item" + }, + "inline_code": { + "label": "Inline Code", + "example": "`Code`" + }, + "block_code": { + "label": "Block Code", + "example": "``` + space" + } + } + } }, "toasts": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/fr/translation.json b/apps/web/lib/i18n/locales/fr/translation.json index d369ad44..142e9148 100644 --- a/apps/web/lib/i18n/locales/fr/translation.json +++ b/apps/web/lib/i18n/locales/fr/translation.json @@ -180,7 +180,20 @@ "import_as_separate_bookmarks": "Importer comme favoris séparés", "placeholder": "Collez un lien ou une image, écrivez une note ou glissez-déposez une image ici ...", "new_item": "NOUVEL ÉLÉMENT", - "disabled_submissions": "Les soumissions sont désactivées" + "disabled_submissions": "Les soumissions sont désactivées", + "text_toolbar": { + "undo": "Annuler", + "redo": "Rétablir", + "bold": "Gras", + "italic": "Italique", + "underline": "Souligné", + "strikethrough": "Barré", + "code": "Code", + "highlight": "Surligner", + "align_left": "Aligner à gauche", + "align_center": "Aligner au centre", + "align_right": "Aligner à droite" + } }, "toasts": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/nl/translation.json b/apps/web/lib/i18n/locales/nl/translation.json index 664bbdc3..c835fdcd 100644 --- a/apps/web/lib/i18n/locales/nl/translation.json +++ b/apps/web/lib/i18n/locales/nl/translation.json @@ -1,58 +1,58 @@ { - "common": { - "created_at": "Gemaakt op", - "archive": "Archiveer", - "url": "URL", - "name": "Naam", - "email": "Email", - "actions": "Acties", - "role": "Rol", - "roles": { - "user": "Gebruiker", - "admin": "Admin" - }, - "something_went_wrong": "Er is iets fout gegaagn", - "experimental": "Experimenteel", - "search": "Zoeken", - "tags": "Tags", - "note": "Notitie", - "attachments": "Bijlagen", - "screenshot": "Schermopname", - "video": "Video", - "home": "Home", - "password": "Wachtwoord", - "action": "Actie", - "key": "Sleutel" + "common": { + "created_at": "Gemaakt op", + "archive": "Archiveer", + "url": "URL", + "name": "Naam", + "email": "Email", + "actions": "Acties", + "role": "Rol", + "roles": { + "user": "Gebruiker", + "admin": "Admin" }, - "layouts": { - "list": "Lijst", - "masonry": "Metselwerk", - "grid": "Rooster", - "compact": "Compact" - }, - "actions": { - "close_bulk_edit": "Bulkbewerking sluiten", - "delete": "Wissen", - "change_layout": "Verander Layout", - "archive": "Archiveer", - "unarchive": "Verwijderen uit het archief", - "unfavorite": "Verwijder uit favoriet", - "refresh": "Verversen", - "download_full_page_archive": "Volledige pagina archief downloaden", - "edit_tags": "Labels bewerken", - "add_to_list": "Toevoegen aan lijst", - "select_all": "Selecteer alles", - "unselect_all": "Deselecteer alles", - "bulk_edit": "Bulkbewerking", - "remove_from_list": "Verwijderen uit lijst", - "add": "Toevoegen", - "edit": "Wijzigen", - "create": "Creëer", - "fetch_now": "Nu ophalen", - "summarize_with_ai": "Samenvatten met AI", - "manage_lists": "Lijsten beheren", - "favorite": "Favoriet", - "copy_link": "Kopieer link", - "save": "Bewaar" - } + "something_went_wrong": "Er is iets fout gegaagn", + "experimental": "Experimenteel", + "search": "Zoeken", + "tags": "Tags", + "note": "Notitie", + "attachments": "Bijlagen", + "screenshot": "Schermopname", + "video": "Video", + "home": "Home", + "password": "Wachtwoord", + "action": "Actie", + "key": "Sleutel" + }, + "layouts": { + "list": "Lijst", + "masonry": "Metselwerk", + "grid": "Rooster", + "compact": "Compact" + }, + "actions": { + "close_bulk_edit": "Bulkbewerking sluiten", + "delete": "Wissen", + "change_layout": "Verander Layout", + "archive": "Archiveer", + "unarchive": "Verwijderen uit het archief", + "unfavorite": "Verwijder uit favoriet", + "refresh": "Verversen", + "download_full_page_archive": "Volledige pagina archief downloaden", + "edit_tags": "Labels bewerken", + "add_to_list": "Toevoegen aan lijst", + "select_all": "Selecteer alles", + "unselect_all": "Deselecteer alles", + "bulk_edit": "Bulkbewerking", + "remove_from_list": "Verwijderen uit lijst", + "add": "Toevoegen", + "edit": "Wijzigen", + "create": "Creëer", + "fetch_now": "Nu ophalen", + "summarize_with_ai": "Samenvatten met AI", + "manage_lists": "Lijsten beheren", + "favorite": "Favoriet", + "copy_link": "Kopieer link", + "save": "Bewaar" + } } diff --git a/apps/web/lib/i18n/locales/sv/translation.json b/apps/web/lib/i18n/locales/sv/translation.json index 52e3bb68..9adf3bec 100644 --- a/apps/web/lib/i18n/locales/sv/translation.json +++ b/apps/web/lib/i18n/locales/sv/translation.json @@ -1,177 +1,177 @@ { - "common": { - "url": "URL", - "email": "Email", - "password": "Lösenord", - "created_at": "Skapad den", - "key": "Nyckel", - "action": "Åtgärd", - "actions": "Åtgärder", - "experimental": "Experimentell", - "attachments": "Bilagor", - "screenshot": "Skärmdump", - "roles": { - "admin": "Admin", - "user": "Användare" - }, - "note": "Anteckning", - "video": "Video", - "archive": "Arkiv", - "name": "Namn", - "role": "Roll", - "something_went_wrong": "Något gick fel", - "search": "Sök", - "tags": "Taggar", - "home": "Hem" + "common": { + "url": "URL", + "email": "Email", + "password": "Lösenord", + "created_at": "Skapad den", + "key": "Nyckel", + "action": "Åtgärd", + "actions": "Åtgärder", + "experimental": "Experimentell", + "attachments": "Bilagor", + "screenshot": "Skärmdump", + "roles": { + "admin": "Admin", + "user": "Användare" }, - "layouts": { - "grid": "Rutnät", - "compact": "Kompakt", - "list": "Lista" + "note": "Anteckning", + "video": "Video", + "archive": "Arkiv", + "name": "Namn", + "role": "Roll", + "something_went_wrong": "Något gick fel", + "search": "Sök", + "tags": "Taggar", + "home": "Hem" + }, + "layouts": { + "grid": "Rutnät", + "compact": "Kompakt", + "list": "Lista" + }, + "actions": { + "delete": "Radera", + "archive": "Arkivera", + "unarchive": "Avarkivera", + "add_to_list": "Lägg till i lista", + "download_full_page_archive": "Ladda ner fullsidesarkiv", + "select_all": "Markera allt", + "manage_lists": "Hantera listor", + "remove_from_list": "Ta bort från lista", + "unselect_all": "Avmarkera allt", + "add": "Lägg till", + "fetch_now": "Hämta nu", + "edit": "Ändra", + "sign_out": "Logga ut", + "close": "Stäng", + "edit_title": "Ändra titel", + "cancel": "Avbryt", + "ignore": "Ignorera", + "refresh": "Uppdatera", + "favorite": "Favorit", + "copy_link": "Kopiera länk", + "create": "Skapa", + "change_layout": "Andra layout", + "edit_tags": "Ändra taggar", + "summarize_with_ai": "Sammanfatta med AI", + "save": "Spara", + "merge": "Sammanfoga" + }, + "settings": { + "back_to_app": "Tillbaka till app", + "info": { + "user_info": "Användarinfo", + "basic_details": "Grundläggande detaljer", + "interface_lang": "Gränsnittsspråk", + "current_password": "Nuvarande lösenord", + "confirm_new_password": "Bekräfta nytt lösenord", + "change_password": "Ändra lösenord", + "new_password": "Nytt lösenord" }, - "actions": { - "delete": "Radera", - "archive": "Arkivera", - "unarchive": "Avarkivera", - "add_to_list": "Lägg till i lista", - "download_full_page_archive": "Ladda ner fullsidesarkiv", - "select_all": "Markera allt", - "manage_lists": "Hantera listor", - "remove_from_list": "Ta bort från lista", - "unselect_all": "Avmarkera allt", - "add": "Lägg till", - "fetch_now": "Hämta nu", - "edit": "Ändra", - "sign_out": "Logga ut", - "close": "Stäng", - "edit_title": "Ändra titel", - "cancel": "Avbryt", - "ignore": "Ignorera", - "refresh": "Uppdatera", - "favorite": "Favorit", - "copy_link": "Kopiera länk", - "create": "Skapa", - "change_layout": "Andra layout", - "edit_tags": "Ändra taggar", - "summarize_with_ai": "Sammanfatta med AI", - "save": "Spara", - "merge": "Sammanfoga" - }, - "settings": { - "back_to_app": "Tillbaka till app", - "info": { - "user_info": "Användarinfo", - "basic_details": "Grundläggande detaljer", - "interface_lang": "Gränsnittsspråk", - "current_password": "Nuvarande lösenord", - "confirm_new_password": "Bekräfta nytt lösenord", - "change_password": "Ändra lösenord", - "new_password": "Nytt lösenord" - }, - "feeds": { - "rss_subscriptions": "RSS-prenumerationer", - "add_a_subscription": "Lägg till en prenumeration" - }, - "ai": { - "images_prompt": "Bildprompt", - "text_prompt": "Textprompt", - "tagging_rules": "Taggningsregler", - "ai_settings": "AI-inställningar" - }, - "import": { - "import_export": "Importera / exportera", - "import_export_bookmarks": "Exportera bokmärken", - "import_bookmarks_from_omnivore_export": "Importera bokmärken från Omnivore-export", - "imported_bookmarks": "Importerade bokmärken", - "import_bookmarks_from_hoarder_export": "Importera bokmärken från Hoarder-export", - "import_bookmarks_from_html_file": "Importera bokmärken från HTML-fil", - "import_bookmarks_from_pocket_export": "Importera bokmärken från Pocket-export", - "export_links_and_notes": "Exportera länkar och anteckningar" - }, - "api_keys": { - "api_keys": "API-nycklar", - "new_api_key": "Ny API-nyckel", - "new_api_key_desc": "Ge din API-nyckel ett unikt namn", - "key_success_please_copy": "Kopiera nyckeln och lagra den på en saker plats. Efter att du stängt denna dialogruta kommer su inte att kunna nå den igen." - }, - "user_settings": "Användarinställningar" + "feeds": { + "rss_subscriptions": "RSS-prenumerationer", + "add_a_subscription": "Lägg till en prenumeration" }, - "admin": { - "server_stats": { - "server_stats": "Serverstatistik", - "server_version": "Serverversion" - }, - "admin_settings": "Admin-inställningar", - "background_jobs": { - "background_jobs": "Bakgrundsjobb", - "job": "Jobb", - "pending": "Pågående", - "indexing_jobs": "Indexeringsjobb", - "inference_jobs": "Inferensjobb", - "queued": "Köade" - }, - "actions": { - "reindex_all_bookmarks": "Återindexera alla bokmärken", - "without_inference": "Utan inferens" - }, - "users_list": { - "reset_password": "Återställ lösenord", - "delete_user": "Radera användare", - "users_list": "Användarlista", - "local_user": "Lokal användare", - "confirm_password": "Bekräfta lösenord", - "create_user": "Skapa användare", - "change_role": "Ändra roll" - } + "ai": { + "images_prompt": "Bildprompt", + "text_prompt": "Textprompt", + "tagging_rules": "Taggningsregler", + "ai_settings": "AI-inställningar" }, - "options": { - "light_mode": "Ljust läge", - "dark_mode": "Mörkt läge" + "import": { + "import_export": "Importera / exportera", + "import_export_bookmarks": "Exportera bokmärken", + "import_bookmarks_from_omnivore_export": "Importera bokmärken från Omnivore-export", + "imported_bookmarks": "Importerade bokmärken", + "import_bookmarks_from_hoarder_export": "Importera bokmärken från Hoarder-export", + "import_bookmarks_from_html_file": "Importera bokmärken från HTML-fil", + "import_bookmarks_from_pocket_export": "Importera bokmärken från Pocket-export", + "export_links_and_notes": "Exportera länkar och anteckningar" }, - "lists": { - "new_nested_list": "Ny nästlad lista", - "all_lists": "Alla listor", - "favourites": "Favoriter", - "new_list": "Ny lista" + "api_keys": { + "api_keys": "API-nycklar", + "new_api_key": "Ny API-nyckel", + "new_api_key_desc": "Ge din API-nyckel ett unikt namn", + "key_success_please_copy": "Kopiera nyckeln och lagra den på en saker plats. Efter att du stängt denna dialogruta kommer su inte att kunna nå den igen." }, - "tags": { - "drag_and_drop_merging_info": "Dra och släpp taggar på varandra för att sammanfoga dem", - "delete_all_unused_tags": "Radera alla oanvända taggar", - "all_tags": "Alla taggar", - "unused_tags": "Oanvända taggar", - "sort_by_name": "Sortera efter namn", - "your_tags": "Dina taggar", - "ai_tags": "AI-taggar" + "user_settings": "Användarinställningar" + }, + "admin": { + "server_stats": { + "server_stats": "Serverstatistik", + "server_version": "Serverversion" }, - "editor": { - "import_as_separate_bookmarks": "Importera som separata bokmärken", - "import_as_text": "Importera som textbokmärke", - "placeholder": "Klistra in en länk eller bild, skriv en anteckning eller dra och släpp en bild här...", - "new_item": "NY POST", - "multiple_urls_dialog_title": "Importera URL:er som separata bokmärken?", - "multiple_urls_dialog_desc": "Inmatningen innehåller flera URL:er på separata rader. Vill du importera dem som separata bokmärken?" + "admin_settings": "Admin-inställningar", + "background_jobs": { + "background_jobs": "Bakgrundsjobb", + "job": "Jobb", + "pending": "Pågående", + "indexing_jobs": "Indexeringsjobb", + "inference_jobs": "Inferensjobb", + "queued": "Köade" }, - "toasts": { - "bookmarks": { - "updated": "Bokmärket har uppdaters!", - "full_page_archive": "Skapande av fullsidesarkiv har startats", - "deleted": "Bokmärket har raderats!", - "delete_from_list": "Bokmärket har raderats från listan", - "clipboard_copied": "Länken har lags till i ditt urklipp!" - }, - "lists": { - "created": "Listan har skapats!", - "updated": "Listan har uppdaterats!" - } + "actions": { + "reindex_all_bookmarks": "Återindexera alla bokmärken", + "without_inference": "Utan inferens" }, - "cleanups": { - "duplicate_tags": { - "title": "Dublettaggar", - "merge_all_suggestions": "Slå ihop alla förslag?" - }, - "cleanups": "Rensningar" + "users_list": { + "reset_password": "Återställ lösenord", + "delete_user": "Radera användare", + "users_list": "Användarlista", + "local_user": "Lokal användare", + "confirm_password": "Bekräfta lösenord", + "create_user": "Skapa användare", + "change_role": "Ändra roll" + } + }, + "options": { + "light_mode": "Ljust läge", + "dark_mode": "Mörkt läge" + }, + "lists": { + "new_nested_list": "Ny nästlad lista", + "all_lists": "Alla listor", + "favourites": "Favoriter", + "new_list": "Ny lista" + }, + "tags": { + "drag_and_drop_merging_info": "Dra och släpp taggar på varandra för att sammanfoga dem", + "delete_all_unused_tags": "Radera alla oanvända taggar", + "all_tags": "Alla taggar", + "unused_tags": "Oanvända taggar", + "sort_by_name": "Sortera efter namn", + "your_tags": "Dina taggar", + "ai_tags": "AI-taggar" + }, + "editor": { + "import_as_separate_bookmarks": "Importera som separata bokmärken", + "import_as_text": "Importera som textbokmärke", + "placeholder": "Klistra in en länk eller bild, skriv en anteckning eller dra och släpp en bild här...", + "new_item": "NY POST", + "multiple_urls_dialog_title": "Importera URL:er som separata bokmärken?", + "multiple_urls_dialog_desc": "Inmatningen innehåller flera URL:er på separata rader. Vill du importera dem som separata bokmärken?" + }, + "toasts": { + "bookmarks": { + "updated": "Bokmärket har uppdaters!", + "full_page_archive": "Skapande av fullsidesarkiv har startats", + "deleted": "Bokmärket har raderats!", + "delete_from_list": "Bokmärket har raderats från listan", + "clipboard_copied": "Länken har lags till i ditt urklipp!" }, - "preview": { - "view_original": "Visa orginal" + "lists": { + "created": "Listan har skapats!", + "updated": "Listan har uppdaterats!" } + }, + "cleanups": { + "duplicate_tags": { + "title": "Dublettaggar", + "merge_all_suggestions": "Slå ihop alla förslag?" + }, + "cleanups": "Rensningar" + }, + "preview": { + "view_original": "Visa orginal" + } } diff --git a/apps/web/package.json b/apps/web/package.json index 3affe516..98b3a434 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,11 @@ "@hoarder/shared-react": "workspace:^0.1.0", "@hoarder/trpc": "workspace:^0.1.0", "@hookform/resolvers": "^3.3.4", + "@lexical/list": "^0.20.2", + "@lexical/markdown": "^0.20.2", + "@lexical/plain-text": "^0.20.2", + "@lexical/react": "^0.20.2", + "@lexical/rich-text": "^0.20.2", "@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", @@ -33,6 +38,7 @@ "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-toggle": "^1.0.3", @@ -53,13 +59,14 @@ "fastest-levenshtein": "^1.0.16", "i18next": "^23.16.5", "i18next-resources-to-backend": "^1.2.1", + "lexical": "^0.20.2", "lucide-react": "^0.330.0", - "next": "14.2.13", + "next": "14.2.15", "next-auth": "^4.24.5", "next-i18next": "^15.3.1", "next-pwa": "^5.6.0", "next-themes": "^0.3.0", - "prettier": "^3.2.5", + "prettier": "^3.4.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-draggable": "^4.4.6", |
