diff options
Diffstat (limited to '')
118 files changed, 6784 insertions, 1768 deletions
diff --git a/apps/web/components/admin/AddUserDialog.tsx b/apps/web/components/admin/AddUserDialog.tsx index 67c38501..b5843eab 100644 --- a/apps/web/components/admin/AddUserDialog.tsx +++ b/apps/web/components/admin/AddUserDialog.tsx @@ -1,213 +1,217 @@ -import { useEffect, useState } from "react";
-import { ActionButton } from "@/components/ui/action-button";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { toast } from "@/components/ui/use-toast";
-import { api } from "@/lib/trpc";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { TRPCClientError } from "@trpc/client";
-import { useForm } from "react-hook-form";
-import { z } from "zod";
-
-import { zAdminCreateUserSchema } from "@karakeep/shared/types/admin";
-
-type AdminCreateUserSchema = z.infer<typeof zAdminCreateUserSchema>;
-
-export default function AddUserDialog({
- children,
-}: {
- children?: React.ReactNode;
-}) {
- const apiUtils = api.useUtils();
- const [isOpen, onOpenChange] = useState(false);
- const form = useForm<AdminCreateUserSchema>({
- resolver: zodResolver(zAdminCreateUserSchema),
- defaultValues: {
- name: "",
- email: "",
- password: "",
- confirmPassword: "",
- role: "user",
- },
- });
- const { mutate, isPending } = api.admin.createUser.useMutation({
- onSuccess: () => {
- toast({
- description: "User created successfully",
- });
- onOpenChange(false);
- apiUtils.users.list.invalidate();
- apiUtils.admin.userStats.invalidate();
- },
- onError: (error) => {
- if (error instanceof TRPCClientError) {
- toast({
- variant: "destructive",
- description: error.message,
- });
- } else {
- toast({
- variant: "destructive",
- description: "Failed to create user",
- });
- }
- },
- });
-
- useEffect(() => {
- if (!isOpen) {
- form.reset();
- }
- }, [isOpen, form]);
-
- return (
- <Dialog open={isOpen} onOpenChange={onOpenChange}>
- <DialogTrigger asChild>{children}</DialogTrigger>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Add User</DialogTitle>
- </DialogHeader>
- <Form {...form}>
- <form onSubmit={form.handleSubmit((val) => mutate(val))}>
- <div className="flex w-full flex-col space-y-2">
- <FormField
- control={form.control}
- name="name"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Name</FormLabel>
- <FormControl>
- <Input
- type="text"
- placeholder="Name"
- {...field}
- className="w-full rounded border p-2"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="email"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Email</FormLabel>
- <FormControl>
- <Input
- type="email"
- placeholder="Email"
- {...field}
- className="w-full rounded border p-2"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="password"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Password</FormLabel>
- <FormControl>
- <Input
- type="password"
- placeholder="Password"
- {...field}
- className="w-full rounded border p-2"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="confirmPassword"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Confirm Password</FormLabel>
- <FormControl>
- <Input
- type="password"
- placeholder="Confirm Password"
- {...field}
- className="w-full rounded border p-2"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="role"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Role</FormLabel>
- <FormControl>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- >
- <SelectTrigger className="w-full">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="user">User</SelectItem>
- <SelectItem value="admin">Admin</SelectItem>
- </SelectContent>
- </Select>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <DialogFooter className="sm:justify-end">
- <DialogClose asChild>
- <Button type="button" variant="secondary">
- Close
- </Button>
- </DialogClose>
- <ActionButton
- type="submit"
- loading={isPending}
- disabled={isPending}
- >
- Create
- </ActionButton>
- </DialogFooter>
- </div>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- );
-}
+import { useEffect, useState } from "react"; +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "@/components/ui/sonner"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { TRPCClientError } from "@trpc/client"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; +import { zAdminCreateUserSchema } from "@karakeep/shared/types/admin"; + +type AdminCreateUserSchema = z.infer<typeof zAdminCreateUserSchema>; + +export default function AddUserDialog({ + children, +}: { + children?: React.ReactNode; +}) { + const api = useTRPC(); + const queryClient = useQueryClient(); + const [isOpen, onOpenChange] = useState(false); + const form = useForm<AdminCreateUserSchema>({ + resolver: zodResolver(zAdminCreateUserSchema), + defaultValues: { + name: "", + email: "", + password: "", + confirmPassword: "", + role: "user", + }, + }); + const { mutate, isPending } = useMutation( + api.admin.createUser.mutationOptions({ + onSuccess: () => { + toast({ + description: "User created successfully", + }); + onOpenChange(false); + queryClient.invalidateQueries(api.users.list.pathFilter()); + queryClient.invalidateQueries(api.admin.userStats.pathFilter()); + }, + onError: (error) => { + if (error instanceof TRPCClientError) { + toast({ + variant: "destructive", + description: error.message, + }); + } else { + toast({ + variant: "destructive", + description: "Failed to create user", + }); + } + }, + }), + ); + + useEffect(() => { + if (!isOpen) { + form.reset(); + } + }, [isOpen, form]); + + return ( + <Dialog open={isOpen} onOpenChange={onOpenChange}> + <DialogTrigger asChild>{children}</DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>Add User</DialogTitle> + </DialogHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit((val) => mutate(val))}> + <div className="flex w-full flex-col space-y-2"> + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>Name</FormLabel> + <FormControl> + <Input + type="text" + placeholder="Name" + {...field} + className="w-full rounded border p-2" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input + type="email" + placeholder="Email" + {...field} + className="w-full rounded border p-2" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="password" + render={({ field }) => ( + <FormItem> + <FormLabel>Password</FormLabel> + <FormControl> + <Input + type="password" + placeholder="Password" + {...field} + className="w-full rounded border p-2" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="confirmPassword" + render={({ field }) => ( + <FormItem> + <FormLabel>Confirm Password</FormLabel> + <FormControl> + <Input + type="password" + placeholder="Confirm Password" + {...field} + className="w-full rounded border p-2" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="role" + render={({ field }) => ( + <FormItem> + <FormLabel>Role</FormLabel> + <FormControl> + <Select + value={field.value} + onValueChange={field.onChange} + > + <SelectTrigger className="w-full"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="user">User</SelectItem> + <SelectItem value="admin">Admin</SelectItem> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton + type="submit" + loading={isPending} + disabled={isPending} + > + Create + </ActionButton> + </DialogFooter> + </div> + </form> + </Form> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/components/admin/AdminNotices.tsx b/apps/web/components/admin/AdminNotices.tsx index 77b1b481..76c3df04 100644 --- a/apps/web/components/admin/AdminNotices.tsx +++ b/apps/web/components/admin/AdminNotices.tsx @@ -2,9 +2,11 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; import { AlertCircle } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import { AdminCard } from "./AdminCard"; interface AdminNotice { @@ -14,7 +16,8 @@ interface AdminNotice { } function useAdminNotices() { - const { data } = api.admin.getAdminNoticies.useQuery(); + const api = useTRPC(); + const { data } = useQuery(api.admin.getAdminNoticies.queryOptions()); if (!data) { return []; } diff --git a/apps/web/components/admin/BackgroundJobs.tsx b/apps/web/components/admin/BackgroundJobs.tsx index ba73db2e..0df34cc4 100644 --- a/apps/web/components/admin/BackgroundJobs.tsx +++ b/apps/web/components/admin/BackgroundJobs.tsx @@ -11,10 +11,9 @@ import { CardTitle, } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; -import { keepPreviousData } from "@tanstack/react-query"; +import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; import { Activity, AlertTriangle, @@ -31,6 +30,8 @@ import { Webhook, } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import { Button } from "../ui/button"; import { AdminCard } from "./AdminCard"; @@ -254,13 +255,51 @@ function JobCard({ } function useJobActions() { + const api = useTRPC(); const { t } = useTranslation(); const { mutateAsync: recrawlLinks, isPending: isRecrawlPending } = - api.admin.recrawlLinks.useMutation({ + useMutation( + api.admin.recrawlLinks.mutationOptions({ + onSuccess: () => { + toast({ + description: "Recrawl enqueued", + }); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: e.message, + }); + }, + }), + ); + + const { mutateAsync: reindexBookmarks, isPending: isReindexPending } = + useMutation( + api.admin.reindexAllBookmarks.mutationOptions({ + onSuccess: () => { + toast({ + description: "Reindex enqueued", + }); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: e.message, + }); + }, + }), + ); + + const { + mutateAsync: reprocessAssetsFixMode, + isPending: isReprocessingPending, + } = useMutation( + api.admin.reprocessAssetsFixMode.mutationOptions({ onSuccess: () => { toast({ - description: "Recrawl enqueued", + description: "Reprocessing enqueued", }); }, onError: (e) => { @@ -269,13 +308,17 @@ function useJobActions() { description: e.message, }); }, - }); + }), + ); - const { mutateAsync: reindexBookmarks, isPending: isReindexPending } = - api.admin.reindexAllBookmarks.useMutation({ + const { + mutateAsync: reRunInferenceOnAllBookmarks, + isPending: isInferencePending, + } = useMutation( + api.admin.reRunInferenceOnAllBookmarks.mutationOptions({ onSuccess: () => { toast({ - description: "Reindex enqueued", + description: "Inference jobs enqueued", }); }, onError: (e) => { @@ -284,62 +327,38 @@ function useJobActions() { description: e.message, }); }, - }); - - const { - mutateAsync: reprocessAssetsFixMode, - isPending: isReprocessingPending, - } = api.admin.reprocessAssetsFixMode.useMutation({ - onSuccess: () => { - toast({ - description: "Reprocessing enqueued", - }); - }, - onError: (e) => { - toast({ - variant: "destructive", - description: e.message, - }); - }, - }); - - const { - mutateAsync: reRunInferenceOnAllBookmarks, - isPending: isInferencePending, - } = api.admin.reRunInferenceOnAllBookmarks.useMutation({ - onSuccess: () => { - toast({ - description: "Inference jobs enqueued", - }); - }, - onError: (e) => { - toast({ - variant: "destructive", - description: e.message, - }); - }, - }); + }), + ); const { mutateAsync: runAdminMaintenanceTask, isPending: isAdminMaintenancePending, - } = api.admin.runAdminMaintenanceTask.useMutation({ - onSuccess: () => { - toast({ - description: "Admin maintenance request has been enqueued!", - }); - }, - onError: (e) => { - toast({ - variant: "destructive", - description: e.message, - }); - }, - }); + } = useMutation( + api.admin.runAdminMaintenanceTask.mutationOptions({ + onSuccess: () => { + toast({ + description: "Admin maintenance request has been enqueued!", + }); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: e.message, + }); + }, + }), + ); return { crawlActions: [ { + label: t("admin.background_jobs.actions.recrawl_pending_links_only"), + onClick: () => + recrawlLinks({ crawlStatus: "pending", runInference: true }), + variant: "secondary" as const, + loading: isRecrawlPending, + }, + { label: t("admin.background_jobs.actions.recrawl_failed_links_only"), onClick: () => recrawlLinks({ crawlStatus: "failure", runInference: true }), @@ -361,6 +380,15 @@ function useJobActions() { inferenceActions: [ { label: t( + "admin.background_jobs.actions.regenerate_ai_tags_for_pending_bookmarks_only", + ), + onClick: () => + reRunInferenceOnAllBookmarks({ type: "tag", status: "pending" }), + variant: "secondary" as const, + loading: isInferencePending, + }, + { + label: t( "admin.background_jobs.actions.regenerate_ai_tags_for_failed_bookmarks_only", ), onClick: () => @@ -378,6 +406,18 @@ function useJobActions() { }, { label: t( + "admin.background_jobs.actions.regenerate_ai_summaries_for_pending_bookmarks_only", + ), + onClick: () => + reRunInferenceOnAllBookmarks({ + type: "summarize", + status: "pending", + }), + variant: "secondary" as const, + loading: isInferencePending, + }, + { + label: t( "admin.background_jobs.actions.regenerate_ai_summaries_for_failed_bookmarks_only", ), onClick: () => @@ -438,13 +478,13 @@ function useJobActions() { } export default function BackgroundJobs() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: serverStats } = api.admin.backgroundJobsStats.useQuery( - undefined, - { + const { data: serverStats } = useQuery( + api.admin.backgroundJobsStats.queryOptions(undefined, { refetchInterval: 1000, placeholderData: keepPreviousData, - }, + }), ); const actions = useJobActions(); diff --git a/apps/web/components/admin/BasicStats.tsx b/apps/web/components/admin/BasicStats.tsx index 67352f66..ec2b73a9 100644 --- a/apps/web/components/admin/BasicStats.tsx +++ b/apps/web/components/admin/BasicStats.tsx @@ -3,9 +3,10 @@ import { AdminCard } from "@/components/admin/AdminCard"; import { useClientConfig } from "@/lib/clientConfig"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { useQuery } from "@tanstack/react-query"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + const REPO_LATEST_RELEASE_API = "https://api.github.com/repos/karakeep-app/karakeep/releases/latest"; const REPO_RELEASE_PAGE = "https://github.com/karakeep-app/karakeep/releases"; @@ -42,7 +43,7 @@ function ReleaseInfo() { rel="noreferrer" title="Update available" > - ({latestRelease} ⬆️) + ({latestRelease}⬆️) </a> ); } @@ -71,10 +72,13 @@ function StatsSkeleton() { } export default function BasicStats() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: serverStats } = api.admin.stats.useQuery(undefined, { - refetchInterval: 5000, - }); + const { data: serverStats } = useQuery( + api.admin.stats.queryOptions(undefined, { + refetchInterval: 5000, + }), + ); if (!serverStats) { return <StatsSkeleton />; diff --git a/apps/web/components/admin/BookmarkDebugger.tsx b/apps/web/components/admin/BookmarkDebugger.tsx new file mode 100644 index 00000000..7e15262f --- /dev/null +++ b/apps/web/components/admin/BookmarkDebugger.tsx @@ -0,0 +1,661 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { AdminCard } from "@/components/admin/AdminCard"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import InfoTooltip from "@/components/ui/info-tooltip"; +import { Input } from "@/components/ui/input"; +import { useTranslation } from "@/lib/i18n/client"; +import { formatBytes } from "@/lib/utils"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { formatDistanceToNow } from "date-fns"; +import { + AlertCircle, + CheckCircle2, + ChevronDown, + ChevronRight, + Clock, + Database, + ExternalLink, + FileText, + FileType, + Image as ImageIcon, + Link as LinkIcon, + Loader2, + RefreshCw, + Search, + Sparkles, + Tag, + User, + XCircle, +} from "lucide-react"; +import { parseAsString, useQueryState } from "nuqs"; +import { toast } from "sonner"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; +import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; + +export default function BookmarkDebugger() { + const api = useTRPC(); + const { t } = useTranslation(); + const [inputValue, setInputValue] = useState(""); + const [bookmarkId, setBookmarkId] = useQueryState( + "bookmarkId", + parseAsString.withDefault(""), + ); + const [showHtmlPreview, setShowHtmlPreview] = useState(false); + + // Sync input value with URL on mount/change + useEffect(() => { + if (bookmarkId) { + setInputValue(bookmarkId); + } + }, [bookmarkId]); + + const { + data: debugInfo, + isLoading, + error, + } = useQuery( + api.admin.getBookmarkDebugInfo.queryOptions( + { bookmarkId: bookmarkId }, + { enabled: !!bookmarkId && bookmarkId.length > 0 }, + ), + ); + + const handleLookup = () => { + if (inputValue.trim()) { + setBookmarkId(inputValue.trim()); + } + }; + + const recrawlMutation = useMutation( + api.admin.adminRecrawlBookmark.mutationOptions({ + onSuccess: () => { + toast.success(t("admin.admin_tools.action_success"), { + description: t("admin.admin_tools.recrawl_queued"), + }); + }, + onError: (error) => { + toast.error(t("admin.admin_tools.action_failed"), { + description: error.message, + }); + }, + }), + ); + + const reindexMutation = useMutation( + api.admin.adminReindexBookmark.mutationOptions({ + onSuccess: () => { + toast.success(t("admin.admin_tools.action_success"), { + description: t("admin.admin_tools.reindex_queued"), + }); + }, + onError: (error) => { + toast.error(t("admin.admin_tools.action_failed"), { + description: error.message, + }); + }, + }), + ); + + const retagMutation = useMutation( + api.admin.adminRetagBookmark.mutationOptions({ + onSuccess: () => { + toast.success(t("admin.admin_tools.action_success"), { + description: t("admin.admin_tools.retag_queued"), + }); + }, + onError: (error) => { + toast.error(t("admin.admin_tools.action_failed"), { + description: error.message, + }); + }, + }), + ); + + const resummarizeMutation = useMutation( + api.admin.adminResummarizeBookmark.mutationOptions({ + onSuccess: () => { + toast.success(t("admin.admin_tools.action_success"), { + description: t("admin.admin_tools.resummarize_queued"), + }); + }, + onError: (error) => { + toast.error(t("admin.admin_tools.action_failed"), { + description: error.message, + }); + }, + }), + ); + + const handleRecrawl = () => { + if (bookmarkId) { + recrawlMutation.mutate({ bookmarkId }); + } + }; + + const handleReindex = () => { + if (bookmarkId) { + reindexMutation.mutate({ bookmarkId }); + } + }; + + const handleRetag = () => { + if (bookmarkId) { + retagMutation.mutate({ bookmarkId }); + } + }; + + const handleResummarize = () => { + if (bookmarkId) { + resummarizeMutation.mutate({ bookmarkId }); + } + }; + + const getStatusBadge = (status: "pending" | "failure" | "success" | null) => { + if (!status) return null; + + const config = { + success: { + variant: "default" as const, + icon: CheckCircle2, + }, + failure: { + variant: "destructive" as const, + icon: XCircle, + }, + pending: { + variant: "secondary" as const, + icon: AlertCircle, + }, + }; + + const { variant, icon: Icon } = config[status]; + + return ( + <Badge variant={variant}> + <Icon className="mr-1 h-3 w-3" /> + {status} + </Badge> + ); + }; + + return ( + <div className="flex flex-col gap-4"> + {/* Input Section */} + <AdminCard> + <div className="mb-3 flex items-center gap-2"> + <Search className="h-5 w-5 text-muted-foreground" /> + <h2 className="text-lg font-semibold"> + {t("admin.admin_tools.bookmark_debugger")} + </h2> + <InfoTooltip className="text-muted-foreground" size={16}> + Some data will be redacted for privacy. + </InfoTooltip> + </div> + <div className="flex gap-2"> + <div className="relative max-w-md flex-1"> + <Database className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> + <Input + placeholder={t("admin.admin_tools.bookmark_id_placeholder")} + value={inputValue} + onChange={(e) => setInputValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleLookup(); + } + }} + className="pl-9" + /> + </div> + <Button onClick={handleLookup} disabled={!inputValue.trim()}> + <Search className="mr-2 h-4 w-4" /> + {t("admin.admin_tools.lookup")} + </Button> + </div> + </AdminCard> + + {/* Loading State */} + {isLoading && ( + <AdminCard> + <div className="flex items-center justify-center py-8"> + <Loader2 className="h-8 w-8 animate-spin text-gray-400" /> + </div> + </AdminCard> + )} + + {/* Error State */} + {!isLoading && error && ( + <AdminCard> + <div className="flex items-center gap-3 rounded-lg border border-destructive/50 bg-destructive/10 p-4"> + <XCircle className="h-5 w-5 flex-shrink-0 text-destructive" /> + <div className="flex-1"> + <h3 className="text-sm font-semibold text-destructive"> + {t("admin.admin_tools.fetch_error")} + </h3> + <p className="mt-1 text-sm text-muted-foreground"> + {error.message} + </p> + </div> + </div> + </AdminCard> + )} + + {/* Debug Info Display */} + {!isLoading && !error && debugInfo && ( + <AdminCard> + <div className="space-y-4"> + {/* Basic Info & Status */} + <div className="grid gap-4 md:grid-cols-2"> + {/* Basic Info */} + <div className="rounded-lg border bg-muted/30 p-4"> + <div className="mb-3 flex items-center gap-2"> + <Database className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold"> + {t("admin.admin_tools.basic_info")} + </h3> + </div> + <div className="space-y-2.5 text-sm"> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Database className="h-3.5 w-3.5" /> + {t("common.id")} + </span> + <span className="font-mono text-xs">{debugInfo.id}</span> + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <FileType className="h-3.5 w-3.5" /> + {t("common.type")} + </span> + <Badge variant="secondary">{debugInfo.type}</Badge> + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <LinkIcon className="h-3.5 w-3.5" /> + {t("common.source")} + </span> + <span>{debugInfo.source || "N/A"}</span> + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <User className="h-3.5 w-3.5" /> + {t("admin.admin_tools.owner_user_id")} + </span> + <span className="font-mono text-xs"> + {debugInfo.userId} + </span> + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Clock className="h-3.5 w-3.5" /> + {t("common.created_at")} + </span> + <span className="text-xs"> + {formatDistanceToNow(new Date(debugInfo.createdAt), { + addSuffix: true, + })} + </span> + </div> + {debugInfo.modifiedAt && ( + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Clock className="h-3.5 w-3.5" /> + {t("common.updated_at")} + </span> + <span className="text-xs"> + {formatDistanceToNow(new Date(debugInfo.modifiedAt), { + addSuffix: true, + })} + </span> + </div> + )} + </div> + </div> + + {/* Status */} + <div className="rounded-lg border bg-muted/30 p-4"> + <div className="mb-3 flex items-center gap-2"> + <AlertCircle className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold"> + {t("admin.admin_tools.status")} + </h3> + </div> + <div className="space-y-2.5 text-sm"> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Tag className="h-3.5 w-3.5" /> + {t("admin.admin_tools.tagging_status")} + </span> + {getStatusBadge(debugInfo.taggingStatus)} + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Sparkles className="h-3.5 w-3.5" /> + {t("admin.admin_tools.summarization_status")} + </span> + {getStatusBadge(debugInfo.summarizationStatus)} + </div> + {debugInfo.linkInfo && ( + <> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <RefreshCw className="h-3.5 w-3.5" /> + {t("admin.admin_tools.crawl_status")} + </span> + {getStatusBadge(debugInfo.linkInfo.crawlStatus)} + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <LinkIcon className="h-3.5 w-3.5" /> + {t("admin.admin_tools.crawl_status_code")} + </span> + <Badge + variant={ + debugInfo.linkInfo.crawlStatusCode === null || + (debugInfo.linkInfo.crawlStatusCode >= 200 && + debugInfo.linkInfo.crawlStatusCode < 300) + ? "default" + : "destructive" + } + > + {debugInfo.linkInfo.crawlStatusCode} + </Badge> + </div> + {debugInfo.linkInfo.crawledAt && ( + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Clock className="h-3.5 w-3.5" /> + {t("admin.admin_tools.crawled_at")} + </span> + <span className="text-xs"> + {formatDistanceToNow( + new Date(debugInfo.linkInfo.crawledAt), + { + addSuffix: true, + }, + )} + </span> + </div> + )} + </> + )} + </div> + </div> + </div> + + {/* Content */} + {(debugInfo.title || + debugInfo.summary || + debugInfo.linkInfo || + debugInfo.textInfo?.sourceUrl || + debugInfo.assetInfo) && ( + <div className="rounded-lg border bg-muted/30 p-4"> + <div className="mb-3 flex items-center gap-2"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold"> + {t("admin.admin_tools.content")} + </h3> + </div> + <div className="space-y-3 text-sm"> + {debugInfo.title && ( + <div> + <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> + <FileText className="h-3.5 w-3.5" /> + {t("common.title")} + </div> + <div className="rounded border bg-background px-3 py-2 font-medium"> + {debugInfo.title} + </div> + </div> + )} + {debugInfo.summary && ( + <div> + <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> + <Sparkles className="h-3.5 w-3.5" /> + {t("admin.admin_tools.summary")} + </div> + <div className="rounded border bg-background px-3 py-2"> + {debugInfo.summary} + </div> + </div> + )} + {debugInfo.linkInfo && ( + <div> + <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> + <LinkIcon className="h-3.5 w-3.5" /> + {t("admin.admin_tools.url")} + </div> + <Link + prefetch={false} + href={debugInfo.linkInfo.url} + target="_blank" + rel="noopener noreferrer" + className="flex items-center gap-1.5 rounded border bg-background px-3 py-2 text-primary hover:underline" + > + <span className="break-all"> + {debugInfo.linkInfo.url} + </span> + <ExternalLink className="h-3.5 w-3.5 flex-shrink-0" /> + </Link> + </div> + )} + {debugInfo.textInfo?.sourceUrl && ( + <div> + <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> + <LinkIcon className="h-3.5 w-3.5" /> + {t("admin.admin_tools.source_url")} + </div> + <Link + prefetch={false} + href={debugInfo.textInfo.sourceUrl} + target="_blank" + rel="noopener noreferrer" + className="flex items-center gap-1.5 rounded border bg-background px-3 py-2 text-primary hover:underline" + > + <span className="break-all"> + {debugInfo.textInfo.sourceUrl} + </span> + <ExternalLink className="h-3.5 w-3.5 flex-shrink-0" /> + </Link> + </div> + )} + {debugInfo.assetInfo && ( + <div> + <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> + <ImageIcon className="h-3.5 w-3.5" /> + {t("admin.admin_tools.asset_type")} + </div> + <div className="rounded border bg-background px-3 py-2"> + <Badge variant="secondary" className="mb-1"> + {debugInfo.assetInfo.assetType} + </Badge> + {debugInfo.assetInfo.fileName && ( + <div className="mt-1 font-mono text-xs text-muted-foreground"> + {debugInfo.assetInfo.fileName} + </div> + )} + </div> + </div> + )} + </div> + </div> + )} + + {/* HTML Preview */} + {debugInfo.linkInfo && debugInfo.linkInfo.htmlContentPreview && ( + <div className="rounded-lg border bg-muted/30 p-4"> + <button + onClick={() => setShowHtmlPreview(!showHtmlPreview)} + className="flex w-full items-center gap-2 text-sm font-semibold hover:opacity-70" + > + {showHtmlPreview ? ( + <ChevronDown className="h-4 w-4 text-muted-foreground" /> + ) : ( + <ChevronRight className="h-4 w-4 text-muted-foreground" /> + )} + <FileText className="h-4 w-4 text-muted-foreground" /> + {t("admin.admin_tools.html_preview")} + </button> + {showHtmlPreview && ( + <pre className="mt-3 max-h-60 overflow-auto rounded-md border bg-muted p-3 text-xs"> + {debugInfo.linkInfo.htmlContentPreview} + </pre> + )} + </div> + )} + + {/* Tags */} + {debugInfo.tags.length > 0 && ( + <div className="rounded-lg border bg-muted/30 p-4"> + <div className="mb-3 flex items-center gap-2"> + <Tag className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold"> + {t("common.tags")}{" "} + <span className="text-muted-foreground"> + ({debugInfo.tags.length}) + </span> + </h3> + </div> + <div className="flex flex-wrap gap-2"> + {debugInfo.tags.map((tag) => ( + <Badge + key={tag.id} + variant={ + tag.attachedBy === "ai" ? "default" : "secondary" + } + className="gap-1.5" + > + {tag.attachedBy === "ai" && ( + <Sparkles className="h-3 w-3" /> + )} + <span>{tag.name}</span> + </Badge> + ))} + </div> + </div> + )} + + {/* Assets */} + {debugInfo.assets.length > 0 && ( + <div className="rounded-lg border bg-muted/30 p-4"> + <div className="mb-3 flex items-center gap-2"> + <ImageIcon className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold"> + {t("common.attachments")}{" "} + <span className="text-muted-foreground"> + ({debugInfo.assets.length}) + </span> + </h3> + </div> + <div className="space-y-2 text-sm"> + {debugInfo.assets.map((asset) => ( + <div + key={asset.id} + className="flex items-center justify-between rounded-md border bg-background p-3" + > + <div className="flex items-center gap-3"> + <ImageIcon className="h-4 w-4 text-muted-foreground" /> + <div> + <Badge variant="secondary" className="text-xs"> + {asset.assetType} + </Badge> + <div className="mt-1 text-xs text-muted-foreground"> + {formatBytes(asset.size)} + </div> + </div> + </div> + {asset.url && ( + <Link + prefetch={false} + href={asset.url} + target="_blank" + rel="noopener noreferrer" + className="flex items-center gap-1.5 text-primary hover:underline" + > + {t("admin.admin_tools.view")} + <ExternalLink className="h-3.5 w-3.5" /> + </Link> + )} + </div> + ))} + </div> + </div> + )} + + {/* Actions */} + <div className="rounded-lg border border-dashed bg-muted/20 p-4"> + <div className="mb-3 flex items-center gap-2"> + <RefreshCw className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold">{t("common.actions")}</h3> + </div> + <div className="flex flex-wrap gap-2"> + <Button + onClick={handleRecrawl} + disabled={ + debugInfo.type !== BookmarkTypes.LINK || + recrawlMutation.isPending + } + size="sm" + variant="outline" + > + {recrawlMutation.isPending ? ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <RefreshCw className="mr-2 h-4 w-4" /> + )} + {t("admin.admin_tools.recrawl")} + </Button> + <Button + onClick={handleReindex} + disabled={reindexMutation.isPending} + size="sm" + variant="outline" + > + {reindexMutation.isPending ? ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <Search className="mr-2 h-4 w-4" /> + )} + {t("admin.admin_tools.reindex")} + </Button> + <Button + onClick={handleRetag} + disabled={retagMutation.isPending} + size="sm" + variant="outline" + > + {retagMutation.isPending ? ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <Tag className="mr-2 h-4 w-4" /> + )} + {t("admin.admin_tools.retag")} + </Button> + <Button + onClick={handleResummarize} + disabled={ + debugInfo.type !== BookmarkTypes.LINK || + resummarizeMutation.isPending + } + size="sm" + variant="outline" + > + {resummarizeMutation.isPending ? ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <Sparkles className="mr-2 h-4 w-4" /> + )} + {t("admin.admin_tools.resummarize")} + </Button> + </div> + </div> + </div> + </AdminCard> + )} + </div> + ); +} diff --git a/apps/web/components/admin/CreateInviteDialog.tsx b/apps/web/components/admin/CreateInviteDialog.tsx index 84f5c60f..e9930b1e 100644 --- a/apps/web/components/admin/CreateInviteDialog.tsx +++ b/apps/web/components/admin/CreateInviteDialog.tsx @@ -19,13 +19,15 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { toast } from "@/components/ui/use-toast"; -import { api } from "@/lib/trpc"; +import { toast } from "@/components/ui/sonner"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { TRPCClientError } from "@trpc/client"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + const createInviteSchema = z.object({ email: z.string().email("Please enter a valid email address"), }); @@ -37,6 +39,8 @@ interface CreateInviteDialogProps { export default function CreateInviteDialog({ children, }: CreateInviteDialogProps) { + const api = useTRPC(); + const queryClient = useQueryClient(); const [open, setOpen] = useState(false); const [errorMessage, setErrorMessage] = useState(""); @@ -47,25 +51,26 @@ export default function CreateInviteDialog({ }, }); - const invalidateInvitesList = api.useUtils().invites.list.invalidate; - const createInviteMutation = api.invites.create.useMutation({ - onSuccess: () => { - toast({ - description: "Invite sent successfully", - }); - invalidateInvitesList(); - setOpen(false); - form.reset(); - setErrorMessage(""); - }, - onError: (e) => { - if (e instanceof TRPCClientError) { - setErrorMessage(e.message); - } else { - setErrorMessage("Failed to send invite"); - } - }, - }); + const createInviteMutation = useMutation( + api.invites.create.mutationOptions({ + onSuccess: () => { + toast({ + description: "Invite sent successfully", + }); + queryClient.invalidateQueries(api.invites.list.pathFilter()); + setOpen(false); + form.reset(); + setErrorMessage(""); + }, + onError: (e) => { + if (e instanceof TRPCClientError) { + setErrorMessage(e.message); + } else { + setErrorMessage("Failed to send invite"); + } + }, + }), + ); return ( <Dialog open={open} onOpenChange={setOpen}> diff --git a/apps/web/components/admin/InvitesList.tsx b/apps/web/components/admin/InvitesList.tsx index 1418c9bb..d4dc1793 100644 --- a/apps/web/components/admin/InvitesList.tsx +++ b/apps/web/components/admin/InvitesList.tsx @@ -2,7 +2,7 @@ import { ActionButton } from "@/components/ui/action-button"; import { ButtonWithTooltip } from "@/components/ui/button"; -import LoadingSpinner from "@/components/ui/spinner"; +import { toast } from "@/components/ui/sonner"; import { Table, TableBody, @@ -11,25 +11,32 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { toast } from "@/components/ui/use-toast"; -import { api } from "@/lib/trpc"; +import { + useMutation, + useQueryClient, + useSuspenseQuery, +} from "@tanstack/react-query"; import { formatDistanceToNow } from "date-fns"; import { Mail, MailX, UserPlus } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import ActionConfirmingDialog from "../ui/action-confirming-dialog"; +import { AdminCard } from "./AdminCard"; import CreateInviteDialog from "./CreateInviteDialog"; export default function InvitesList() { - const invalidateInvitesList = api.useUtils().invites.list.invalidate; - const { data: invites, isLoading } = api.invites.list.useQuery(); + const api = useTRPC(); + const queryClient = useQueryClient(); + const { data: invites } = useSuspenseQuery(api.invites.list.queryOptions()); - const { mutateAsync: revokeInvite, isPending: isRevokePending } = - api.invites.revoke.useMutation({ + const { mutateAsync: revokeInvite, isPending: isRevokePending } = useMutation( + api.invites.revoke.mutationOptions({ onSuccess: () => { toast({ description: "Invite revoked successfully", }); - invalidateInvitesList(); + queryClient.invalidateQueries(api.invites.list.pathFilter()); }, onError: (e) => { toast({ @@ -37,15 +44,16 @@ export default function InvitesList() { description: `Failed to revoke invite: ${e.message}`, }); }, - }); + }), + ); - const { mutateAsync: resendInvite, isPending: isResendPending } = - api.invites.resend.useMutation({ + const { mutateAsync: resendInvite, isPending: isResendPending } = useMutation( + api.invites.resend.mutationOptions({ onSuccess: () => { toast({ description: "Invite resent successfully", }); - invalidateInvitesList(); + queryClient.invalidateQueries(api.invites.list.pathFilter()); }, onError: (e) => { toast({ @@ -53,11 +61,8 @@ export default function InvitesList() { description: `Failed to resend invite: ${e.message}`, }); }, - }); - - if (isLoading) { - return <LoadingSpinner />; - } + }), + ); const activeInvites = invites?.invites || []; @@ -139,17 +144,19 @@ export default function InvitesList() { ); return ( - <div className="flex flex-col gap-4"> - <div className="mb-2 flex items-center justify-between text-xl font-medium"> - <span>User Invitations ({activeInvites.length})</span> - <CreateInviteDialog> - <ButtonWithTooltip tooltip="Send Invite" variant="outline"> - <UserPlus size={16} /> - </ButtonWithTooltip> - </CreateInviteDialog> - </div> + <AdminCard> + <div className="flex flex-col gap-4"> + <div className="mb-2 flex items-center justify-between text-xl font-medium"> + <span>User Invitations ({activeInvites.length})</span> + <CreateInviteDialog> + <ButtonWithTooltip tooltip="Send Invite" variant="outline"> + <UserPlus size={16} /> + </ButtonWithTooltip> + </CreateInviteDialog> + </div> - <InviteTable invites={activeInvites} title="Invites" /> - </div> + <InviteTable invites={activeInvites} title="Invites" /> + </div> + </AdminCard> ); } diff --git a/apps/web/components/admin/InvitesListSkeleton.tsx b/apps/web/components/admin/InvitesListSkeleton.tsx new file mode 100644 index 00000000..19e8088d --- /dev/null +++ b/apps/web/components/admin/InvitesListSkeleton.tsx @@ -0,0 +1,55 @@ +import { AdminCard } from "@/components/admin/AdminCard"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +const headerWidths = ["w-40", "w-28", "w-20", "w-20"]; + +export default function InvitesListSkeleton() { + return ( + <AdminCard> + <div className="flex flex-col gap-4"> + <div className="mb-2 flex items-center justify-between"> + <Skeleton className="h-6 w-48" /> + <Skeleton className="h-9 w-9" /> + </div> + + <Table> + <TableHeader> + <TableRow> + {headerWidths.map((width, index) => ( + <TableHead key={`invite-list-header-${index}`}> + <Skeleton className={`h-4 ${width}`} /> + </TableHead> + ))} + </TableRow> + </TableHeader> + <TableBody> + {Array.from({ length: 2 }).map((_, rowIndex) => ( + <TableRow key={`invite-list-row-${rowIndex}`}> + {headerWidths.map((width, cellIndex) => ( + <TableCell key={`invite-list-cell-${rowIndex}-${cellIndex}`}> + {cellIndex === headerWidths.length - 1 ? ( + <div className="flex gap-2"> + <Skeleton className="h-6 w-6" /> + <Skeleton className="h-6 w-6" /> + </div> + ) : ( + <Skeleton className={`h-4 ${width}`} /> + )} + </TableCell> + ))} + </TableRow> + ))} + </TableBody> + </Table> + </div> + </AdminCard> + ); +} diff --git a/apps/web/components/admin/ResetPasswordDialog.tsx b/apps/web/components/admin/ResetPasswordDialog.tsx index cc2a95f5..f195395a 100644 --- a/apps/web/components/admin/ResetPasswordDialog.tsx +++ b/apps/web/components/admin/ResetPasswordDialog.tsx @@ -1,145 +1,150 @@ -import { useEffect, useState } from "react";
-import { ActionButton } from "@/components/ui/action-button";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
-import { toast } from "@/components/ui/use-toast";
-import { api } from "@/lib/trpc"; // Adjust the import path as needed
-import { zodResolver } from "@hookform/resolvers/zod";
-import { TRPCClientError } from "@trpc/client";
-import { useForm } from "react-hook-form";
-import { z } from "zod";
-
-import { resetPasswordSchema } from "@karakeep/shared/types/admin";
-
-interface ResetPasswordDialogProps {
- userId: string;
- children?: React.ReactNode;
-}
-
-type ResetPasswordSchema = z.infer<typeof resetPasswordSchema>;
-
-export default function ResetPasswordDialog({
- children,
- userId,
-}: ResetPasswordDialogProps) {
- const [isOpen, onOpenChange] = useState(false);
- const form = useForm<ResetPasswordSchema>({
- resolver: zodResolver(resetPasswordSchema),
- defaultValues: {
- userId,
- newPassword: "",
- newPasswordConfirm: "",
- },
- });
- const { mutate, isPending } = api.admin.resetPassword.useMutation({
- onSuccess: () => {
- toast({
- description: "Password reset successfully",
- });
- onOpenChange(false);
- },
- onError: (error) => {
- if (error instanceof TRPCClientError) {
- toast({
- variant: "destructive",
- description: error.message,
- });
- } else {
- toast({
- variant: "destructive",
- description: "Failed to reset password",
- });
- }
- },
- });
-
- useEffect(() => {
- if (isOpen) {
- form.reset();
- }
- }, [isOpen, form]);
-
- return (
- <Dialog open={isOpen} onOpenChange={onOpenChange}>
- <DialogTrigger asChild>{children}</DialogTrigger>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Reset Password</DialogTitle>
- </DialogHeader>
- <Form {...form}>
- <form onSubmit={form.handleSubmit((val) => mutate(val))}>
- <div className="flex w-full flex-col space-y-2">
- <FormField
- control={form.control}
- name="newPassword"
- render={({ field }) => (
- <FormItem>
- <FormLabel>New Password</FormLabel>
- <FormControl>
- <Input
- type="password"
- placeholder="New Password"
- {...field}
- className="w-full rounded border p-2"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="newPasswordConfirm"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Confirm New Password</FormLabel>
- <FormControl>
- <Input
- type="password"
- placeholder="Confirm New Password"
- {...field}
- className="w-full rounded border p-2"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <DialogFooter className="sm:justify-end">
- <DialogClose asChild>
- <Button type="button" variant="secondary">
- Close
- </Button>
- </DialogClose>
- <ActionButton
- type="submit"
- loading={isPending}
- disabled={isPending}
- >
- Reset
- </ActionButton>
- </DialogFooter>
- </div>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- );
-}
+import { useEffect, useState } from "react"; +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/sonner"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { TRPCClientError } from "@trpc/client"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; // Adjust the import path as needed + +import { resetPasswordSchema } from "@karakeep/shared/types/admin"; + +interface ResetPasswordDialogProps { + userId: string; + children?: React.ReactNode; +} + +type ResetPasswordSchema = z.infer<typeof resetPasswordSchema>; + +export default function ResetPasswordDialog({ + children, + userId, +}: ResetPasswordDialogProps) { + const api = useTRPC(); + const [isOpen, onOpenChange] = useState(false); + const form = useForm<ResetPasswordSchema>({ + resolver: zodResolver(resetPasswordSchema), + defaultValues: { + userId, + newPassword: "", + newPasswordConfirm: "", + }, + }); + const { mutate, isPending } = useMutation( + api.admin.resetPassword.mutationOptions({ + onSuccess: () => { + toast({ + description: "Password reset successfully", + }); + onOpenChange(false); + }, + onError: (error) => { + if (error instanceof TRPCClientError) { + toast({ + variant: "destructive", + description: error.message, + }); + } else { + toast({ + variant: "destructive", + description: "Failed to reset password", + }); + } + }, + }), + ); + + useEffect(() => { + if (isOpen) { + form.reset(); + } + }, [isOpen, form]); + + return ( + <Dialog open={isOpen} onOpenChange={onOpenChange}> + <DialogTrigger asChild>{children}</DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>Reset Password</DialogTitle> + </DialogHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit((val) => mutate(val))}> + <div className="flex w-full flex-col space-y-2"> + <FormField + control={form.control} + name="newPassword" + render={({ field }) => ( + <FormItem> + <FormLabel>New Password</FormLabel> + <FormControl> + <Input + type="password" + placeholder="New Password" + {...field} + className="w-full rounded border p-2" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="newPasswordConfirm" + render={({ field }) => ( + <FormItem> + <FormLabel>Confirm New Password</FormLabel> + <FormControl> + <Input + type="password" + placeholder="Confirm New Password" + {...field} + className="w-full rounded border p-2" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton + type="submit" + loading={isPending} + disabled={isPending} + > + Reset + </ActionButton> + </DialogFooter> + </div> + </form> + </Form> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/components/admin/ServiceConnections.tsx b/apps/web/components/admin/ServiceConnections.tsx index 8d79d8bb..5cdab46a 100644 --- a/apps/web/components/admin/ServiceConnections.tsx +++ b/apps/web/components/admin/ServiceConnections.tsx @@ -2,7 +2,9 @@ import { AdminCard } from "@/components/admin/AdminCard"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; function ConnectionStatus({ label, @@ -105,10 +107,13 @@ function ConnectionsSkeleton() { } export default function ServiceConnections() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: connections } = api.admin.checkConnections.useQuery(undefined, { - refetchInterval: 10000, - }); + const { data: connections } = useQuery( + api.admin.checkConnections.queryOptions(undefined, { + refetchInterval: 10000, + }), + ); if (!connections) { return <ConnectionsSkeleton />; diff --git a/apps/web/components/admin/UpdateUserDialog.tsx b/apps/web/components/admin/UpdateUserDialog.tsx index 7093ccda..95ccb6fd 100644 --- a/apps/web/components/admin/UpdateUserDialog.tsx +++ b/apps/web/components/admin/UpdateUserDialog.tsx @@ -26,13 +26,14 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { toast } from "@/components/ui/use-toast"; -import { api } from "@/lib/trpc"; +import { toast } from "@/components/ui/sonner"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { TRPCClientError } from "@trpc/client"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { updateUserSchema } from "@karakeep/shared/types/admin"; type UpdateUserSchema = z.infer<typeof updateUserSchema>; @@ -51,7 +52,8 @@ export default function UpdateUserDialog({ currentStorageQuota, children, }: UpdateUserDialogProps) { - const apiUtils = api.useUtils(); + const api = useTRPC(); + const queryClient = useQueryClient(); const [isOpen, onOpenChange] = useState(false); const defaultValues = { userId, @@ -63,28 +65,30 @@ export default function UpdateUserDialog({ resolver: zodResolver(updateUserSchema), defaultValues, }); - const { mutate, isPending } = api.admin.updateUser.useMutation({ - onSuccess: () => { - toast({ - description: "User updated successfully", - }); - apiUtils.users.list.invalidate(); - onOpenChange(false); - }, - onError: (error) => { - if (error instanceof TRPCClientError) { + const { mutate, isPending } = useMutation( + api.admin.updateUser.mutationOptions({ + onSuccess: () => { toast({ - variant: "destructive", - description: error.message, + description: "User updated successfully", }); - } else { - toast({ - variant: "destructive", - description: "Failed to update user", - }); - } - }, - }); + queryClient.invalidateQueries(api.users.list.pathFilter()); + onOpenChange(false); + }, + onError: (error) => { + if (error instanceof TRPCClientError) { + toast({ + variant: "destructive", + description: error.message, + }); + } else { + toast({ + variant: "destructive", + description: "Failed to update user", + }); + } + }, + }), + ); useEffect(() => { if (isOpen) { diff --git a/apps/web/components/admin/UserList.tsx b/apps/web/components/admin/UserList.tsx index f386a8cd..6789f66a 100644 --- a/apps/web/components/admin/UserList.tsx +++ b/apps/web/components/admin/UserList.tsx @@ -2,7 +2,7 @@ import { ActionButton } from "@/components/ui/action-button"; import { ButtonWithTooltip } from "@/components/ui/button"; -import LoadingSpinner from "@/components/ui/spinner"; +import { toast } from "@/components/ui/sonner"; import { Table, TableBody, @@ -11,16 +11,20 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { toast } from "@/components/ui/use-toast"; +import { useSession } from "@/lib/auth/client"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { + useMutation, + useQueryClient, + useSuspenseQuery, +} from "@tanstack/react-query"; import { Check, KeyRound, Pencil, Trash, UserPlus, X } from "lucide-react"; -import { useSession } from "next-auth/react"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; import ActionConfirmingDialog from "../ui/action-confirming-dialog"; import AddUserDialog from "./AddUserDialog"; import { AdminCard } from "./AdminCard"; -import InvitesList from "./InvitesList"; import ResetPasswordDialog from "./ResetPasswordDialog"; import UpdateUserDialog from "./UpdateUserDialog"; @@ -32,18 +36,23 @@ function toHumanReadableSize(size: number) { } export default function UsersSection() { + const api = useTRPC(); + const queryClient = useQueryClient(); const { t } = useTranslation(); const { data: session } = useSession(); - const invalidateUserList = api.useUtils().users.list.invalidate; - const { data: users } = api.users.list.useQuery(); - const { data: userStats } = api.admin.userStats.useQuery(); - const { mutateAsync: deleteUser, isPending: isDeletionPending } = - api.users.delete.useMutation({ + const { + data: { users }, + } = useSuspenseQuery(api.users.list.queryOptions()); + const { data: userStats } = useSuspenseQuery( + api.admin.userStats.queryOptions(), + ); + const { mutateAsync: deleteUser, isPending: isDeletionPending } = useMutation( + api.users.delete.mutationOptions({ onSuccess: () => { toast({ description: "User deleted", }); - invalidateUserList(); + queryClient.invalidateQueries(api.users.list.pathFilter()); }, onError: (e) => { toast({ @@ -51,122 +60,113 @@ export default function UsersSection() { description: `Something went wrong: ${e.message}`, }); }, - }); - - if (!users || !userStats) { - return <LoadingSpinner />; - } + }), + ); return ( - <div className="flex flex-col gap-4"> - <AdminCard> - <div className="flex flex-col gap-4"> - <div className="mb-2 flex items-center justify-between text-xl font-medium"> - <span>{t("admin.users_list.users_list")}</span> - <AddUserDialog> - <ButtonWithTooltip tooltip="Create User" variant="outline"> - <UserPlus size={16} /> - </ButtonWithTooltip> - </AddUserDialog> - </div> + <AdminCard> + <div className="flex flex-col gap-4"> + <div className="mb-2 flex items-center justify-between text-xl font-medium"> + <span>{t("admin.users_list.users_list")}</span> + <AddUserDialog> + <ButtonWithTooltip tooltip="Create User" variant="outline"> + <UserPlus size={16} /> + </ButtonWithTooltip> + </AddUserDialog> + </div> - <Table> - <TableHeader className="bg-gray-200"> - <TableRow> - <TableHead>{t("common.name")}</TableHead> - <TableHead>{t("common.email")}</TableHead> - <TableHead>{t("admin.users_list.num_bookmarks")}</TableHead> - <TableHead>{t("admin.users_list.asset_sizes")}</TableHead> - <TableHead>{t("common.role")}</TableHead> - <TableHead>{t("admin.users_list.local_user")}</TableHead> - <TableHead>{t("common.actions")}</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {users.users.map((u) => ( - <TableRow key={u.id}> - <TableCell className="py-1">{u.name}</TableCell> - <TableCell className="py-1">{u.email}</TableCell> - <TableCell className="py-1"> - {userStats[u.id].numBookmarks} /{" "} - {u.bookmarkQuota ?? t("admin.users_list.unlimited")} - </TableCell> - <TableCell className="py-1"> - {toHumanReadableSize(userStats[u.id].assetSizes)} /{" "} - {u.storageQuota - ? toHumanReadableSize(u.storageQuota) - : t("admin.users_list.unlimited")} - </TableCell> - <TableCell className="py-1"> - {u.role && t(`common.roles.${u.role}`)} - </TableCell> - <TableCell className="py-1"> - {u.localUser ? <Check /> : <X />} - </TableCell> - <TableCell className="flex gap-1 py-1"> - <ActionConfirmingDialog - title={t("admin.users_list.delete_user")} - description={t( - "admin.users_list.delete_user_confirm_description", - { - name: u.name ?? "this user", - }, - )} - actionButton={(setDialogOpen) => ( - <ActionButton - variant="destructive" - loading={isDeletionPending} - onClick={async () => { - await deleteUser({ userId: u.id }); - setDialogOpen(false); - }} - > - Delete - </ActionButton> - )} - > - <ButtonWithTooltip - tooltip={t("admin.users_list.delete_user")} - variant="outline" - disabled={session!.user.id == u.id} + <Table> + <TableHeader className="bg-gray-200"> + <TableRow> + <TableHead>{t("common.name")}</TableHead> + <TableHead>{t("common.email")}</TableHead> + <TableHead>{t("admin.users_list.num_bookmarks")}</TableHead> + <TableHead>{t("admin.users_list.asset_sizes")}</TableHead> + <TableHead>{t("common.role")}</TableHead> + <TableHead>{t("admin.users_list.local_user")}</TableHead> + <TableHead>{t("common.actions")}</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {users.map((u) => ( + <TableRow key={u.id}> + <TableCell className="py-1">{u.name}</TableCell> + <TableCell className="py-1">{u.email}</TableCell> + <TableCell className="py-1"> + {userStats[u.id].numBookmarks} /{" "} + {u.bookmarkQuota ?? t("admin.users_list.unlimited")} + </TableCell> + <TableCell className="py-1"> + {toHumanReadableSize(userStats[u.id].assetSizes)} /{" "} + {u.storageQuota + ? toHumanReadableSize(u.storageQuota) + : t("admin.users_list.unlimited")} + </TableCell> + <TableCell className="py-1"> + {u.role && t(`common.roles.${u.role}`)} + </TableCell> + <TableCell className="py-1"> + {u.localUser ? <Check /> : <X />} + </TableCell> + <TableCell className="flex gap-1 py-1"> + <ActionConfirmingDialog + title={t("admin.users_list.delete_user")} + description={t( + "admin.users_list.delete_user_confirm_description", + { + name: u.name ?? "this user", + }, + )} + actionButton={(setDialogOpen) => ( + <ActionButton + variant="destructive" + loading={isDeletionPending} + onClick={async () => { + await deleteUser({ userId: u.id }); + setDialogOpen(false); + }} > - <Trash size={16} color="red" /> - </ButtonWithTooltip> - </ActionConfirmingDialog> - <ResetPasswordDialog userId={u.id}> - <ButtonWithTooltip - tooltip={t("admin.users_list.reset_password")} - variant="outline" - disabled={session!.user.id == u.id || !u.localUser} - > - <KeyRound size={16} color="red" /> - </ButtonWithTooltip> - </ResetPasswordDialog> - <UpdateUserDialog - userId={u.id} - currentRole={u.role!} - currentQuota={u.bookmarkQuota} - currentStorageQuota={u.storageQuota} + Delete + </ActionButton> + )} + > + <ButtonWithTooltip + tooltip={t("admin.users_list.delete_user")} + variant="outline" + disabled={session!.user.id == u.id} > - <ButtonWithTooltip - tooltip="Edit User" - variant="outline" - disabled={session!.user.id == u.id} - > - <Pencil size={16} color="red" /> - </ButtonWithTooltip> - </UpdateUserDialog> - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </div> - </AdminCard> - - <AdminCard> - <InvitesList /> - </AdminCard> - </div> + <Trash size={16} color="red" /> + </ButtonWithTooltip> + </ActionConfirmingDialog> + <ResetPasswordDialog userId={u.id}> + <ButtonWithTooltip + tooltip={t("admin.users_list.reset_password")} + variant="outline" + disabled={session!.user.id == u.id || !u.localUser} + > + <KeyRound size={16} color="red" /> + </ButtonWithTooltip> + </ResetPasswordDialog> + <UpdateUserDialog + userId={u.id} + currentRole={u.role!} + currentQuota={u.bookmarkQuota} + currentStorageQuota={u.storageQuota} + > + <ButtonWithTooltip + tooltip="Edit User" + variant="outline" + disabled={session!.user.id == u.id} + > + <Pencil size={16} color="red" /> + </ButtonWithTooltip> + </UpdateUserDialog> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + </AdminCard> ); } diff --git a/apps/web/components/admin/UserListSkeleton.tsx b/apps/web/components/admin/UserListSkeleton.tsx new file mode 100644 index 00000000..3da80aa1 --- /dev/null +++ b/apps/web/components/admin/UserListSkeleton.tsx @@ -0,0 +1,56 @@ +import { AdminCard } from "@/components/admin/AdminCard"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +const headerWidths = ["w-24", "w-32", "w-28", "w-28", "w-20", "w-16", "w-24"]; + +export default function UserListSkeleton() { + return ( + <AdminCard> + <div className="flex flex-col gap-4"> + <div className="mb-2 flex items-center justify-between"> + <Skeleton className="h-6 w-40" /> + <Skeleton className="h-9 w-9" /> + </div> + + <Table> + <TableHeader> + <TableRow> + {headerWidths.map((width, index) => ( + <TableHead key={`user-list-header-${index}`}> + <Skeleton className={`h-4 ${width}`} /> + </TableHead> + ))} + </TableRow> + </TableHeader> + <TableBody> + {Array.from({ length: 4 }).map((_, rowIndex) => ( + <TableRow key={`user-list-row-${rowIndex}`}> + {headerWidths.map((width, cellIndex) => ( + <TableCell key={`user-list-cell-${rowIndex}-${cellIndex}`}> + {cellIndex === headerWidths.length - 1 ? ( + <div className="flex gap-2"> + <Skeleton className="h-6 w-6" /> + <Skeleton className="h-6 w-6" /> + <Skeleton className="h-6 w-6" /> + </div> + ) : ( + <Skeleton className={`h-4 ${width}`} /> + )} + </TableCell> + ))} + </TableRow> + ))} + </TableBody> + </Table> + </div> + </AdminCard> + ); +} diff --git a/apps/web/components/dashboard/BulkBookmarksAction.tsx b/apps/web/components/dashboard/BulkBookmarksAction.tsx index 817521ff..0e74b985 100644 --- a/apps/web/components/dashboard/BulkBookmarksAction.tsx +++ b/apps/web/components/dashboard/BulkBookmarksAction.tsx @@ -7,7 +7,7 @@ import { ActionButtonWithTooltip, } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; -import { useToast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import useBulkActionsStore from "@/lib/bulkActions"; import { useTranslation } from "@/lib/i18n/client"; import { @@ -16,6 +16,7 @@ import { Hash, Link, List, + ListMinus, Pencil, RotateCw, Trash2, @@ -27,6 +28,7 @@ import { useRecrawlBookmark, useUpdateBookmark, } from "@karakeep/shared-react/hooks/bookmarks"; +import { useRemoveBookmarkFromList } from "@karakeep/shared-react/hooks/lists"; import { limitConcurrency } from "@karakeep/shared/concurrency"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; @@ -38,7 +40,11 @@ const MAX_CONCURRENT_BULK_ACTIONS = 50; export default function BulkBookmarksAction() { const { t } = useTranslation(); - const { selectedBookmarks, isBulkEditEnabled } = useBulkActionsStore(); + const { + selectedBookmarks, + isBulkEditEnabled, + listContext: withinListContext, + } = useBulkActionsStore(); const setIsBulkEditEnabled = useBulkActionsStore( (state) => state.setIsBulkEditEnabled, ); @@ -49,8 +55,9 @@ export default function BulkBookmarksAction() { const isEverythingSelected = useBulkActionsStore( (state) => state.isEverythingSelected, ); - const { toast } = useToast(); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isRemoveFromListDialogOpen, setIsRemoveFromListDialogOpen] = + useState(false); const [manageListsModal, setManageListsModalOpen] = useState(false); const [bulkTagModal, setBulkTagModalOpen] = useState(false); const pathname = usePathname(); @@ -93,6 +100,13 @@ export default function BulkBookmarksAction() { onError, }); + const removeBookmarkFromListMutator = useRemoveBookmarkFromList({ + onSuccess: () => { + setIsBulkEditEnabled(false); + }, + onError, + }); + interface UpdateBookmarkProps { favourited?: boolean; archived?: boolean; @@ -185,6 +199,31 @@ export default function BulkBookmarksAction() { setIsDeleteDialogOpen(false); }; + const removeBookmarksFromList = async () => { + if (!withinListContext) return; + + const results = await Promise.allSettled( + limitConcurrency( + selectedBookmarks.map( + (item) => () => + removeBookmarkFromListMutator.mutateAsync({ + bookmarkId: item.id, + listId: withinListContext.id, + }), + ), + MAX_CONCURRENT_BULK_ACTIONS, + ), + ); + + const successes = results.filter((r) => r.status === "fulfilled").length; + if (successes > 0) { + toast({ + description: `${successes} bookmarks have been removed from the list!`, + }); + } + setIsRemoveFromListDialogOpen(false); + }; + const alreadyFavourited = selectedBookmarks.length && selectedBookmarks.every((item) => item.favourited === true); @@ -204,6 +243,18 @@ export default function BulkBookmarksAction() { hidden: !isBulkEditEnabled, }, { + name: t("actions.remove_from_list"), + icon: <ListMinus size={18} />, + action: () => setIsRemoveFromListDialogOpen(true), + isPending: removeBookmarkFromListMutator.isPending, + hidden: + !isBulkEditEnabled || + !withinListContext || + withinListContext.type !== "manual" || + (withinListContext.userRole !== "editor" && + withinListContext.userRole !== "owner"), + }, + { name: t("actions.add_to_list"), icon: <List size={18} />, action: () => setManageListsModalOpen(true), @@ -232,7 +283,7 @@ export default function BulkBookmarksAction() { hidden: !isBulkEditEnabled, }, { - name: t("actions.download_full_page_archive"), + name: t("actions.preserve_offline_archive"), icon: <FileDown size={18} />, action: () => recrawlBookmarks(true), isPending: recrawlBookmarkMutator.isPending, @@ -299,6 +350,27 @@ export default function BulkBookmarksAction() { </ActionButton> )} /> + <ActionConfirmingDialog + open={isRemoveFromListDialogOpen} + setOpen={setIsRemoveFromListDialogOpen} + title={"Remove Bookmarks from List"} + description={ + <p> + Are you sure you want to remove {selectedBookmarks.length} bookmarks + from this list? + </p> + } + actionButton={() => ( + <ActionButton + type="button" + variant="destructive" + loading={removeBookmarkFromListMutator.isPending} + onClick={() => removeBookmarksFromList()} + > + {t("actions.remove")} + </ActionButton> + )} + /> <BulkManageListsModal bookmarkIds={selectedBookmarks.map((b) => b.id)} open={manageListsModal} diff --git a/apps/web/components/dashboard/ErrorFallback.tsx b/apps/web/components/dashboard/ErrorFallback.tsx new file mode 100644 index 00000000..7e4ce0d6 --- /dev/null +++ b/apps/web/components/dashboard/ErrorFallback.tsx @@ -0,0 +1,43 @@ +"use client"; + +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { AlertTriangle, Home, RefreshCw } from "lucide-react"; + +export default function ErrorFallback() { + return ( + <div className="flex flex-1 items-center justify-center rounded-lg bg-slate-50 p-8 shadow-sm dark:bg-slate-700/50 dark:shadow-md"> + <div className="w-full max-w-md space-y-8 text-center"> + <div className="flex justify-center"> + <div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted"> + <AlertTriangle className="h-10 w-10 text-muted-foreground" /> + </div> + </div> + + <div className="space-y-4"> + <h1 className="text-balance text-2xl font-semibold text-foreground"> + Oops! Something went wrong + </h1> + <p className="text-pretty leading-relaxed text-muted-foreground"> + We're sorry, but an unexpected error occurred. Please try again + or contact support if the issue persists. + </p> + </div> + + <div className="space-y-3"> + <Button className="w-full" onClick={() => window.location.reload()}> + <RefreshCw className="mr-2 h-4 w-4" /> + Try Again + </Button> + + <Link href="/" className="block"> + <Button variant="outline" className="w-full"> + <Home className="mr-2 h-4 w-4" /> + Go Home + </Button> + </Link> + </div> + </div> + </div> + ); +} diff --git a/apps/web/components/dashboard/UploadDropzone.tsx b/apps/web/components/dashboard/UploadDropzone.tsx index 8d119467..c76da523 100644 --- a/apps/web/components/dashboard/UploadDropzone.tsx +++ b/apps/web/components/dashboard/UploadDropzone.tsx @@ -1,6 +1,8 @@ "use client"; import React, { useCallback, useState } from "react"; +import { toast } from "@/components/ui/sonner"; +import { BOOKMARK_DRAG_MIME } from "@/lib/bookmark-drag"; import useUpload from "@/lib/hooks/upload-file"; import { cn } from "@/lib/utils"; import { TRPCClientError } from "@trpc/client"; @@ -10,7 +12,6 @@ import { useCreateBookmarkWithPostHook } from "@karakeep/shared-react/hooks/book import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; import LoadingSpinner from "../ui/spinner"; -import { toast } from "../ui/use-toast"; import BookmarkAlreadyExistsToast from "../utils/BookmarkAlreadyExistsToast"; export function useUploadAsset() { @@ -136,7 +137,12 @@ export default function UploadDropzone({ <DropZone noClick onDrop={onDrop} - onDragEnter={() => setDragging(true)} + onDragEnter={(e) => { + // Don't show overlay for internal bookmark card drags + if (!e.dataTransfer.types.includes(BOOKMARK_DRAG_MIME)) { + setDragging(true); + } + }} onDragLeave={() => setDragging(false)} > {({ getRootProps, getInputProps }) => ( diff --git a/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx index 595a9e00..b120e0b1 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx @@ -1,5 +1,6 @@ -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; import { getBookmarkRefreshInterval } from "@karakeep/shared/utils/bookmarkUtils"; @@ -15,20 +16,23 @@ export default function BookmarkCard({ bookmark: ZBookmark; className?: string; }) { - const { data: bookmark } = api.bookmarks.getBookmark.useQuery( - { - bookmarkId: initialData.id, - }, - { - initialData, - refetchInterval: (query) => { - const data = query.state.data; - if (!data) { - return false; - } - return getBookmarkRefreshInterval(data); + const api = useTRPC(); + const { data: bookmark } = useQuery( + api.bookmarks.getBookmark.queryOptions( + { + bookmarkId: initialData.id, }, - }, + { + initialData, + refetchInterval: (query) => { + const data = query.state.data; + if (!data) { + return false; + } + return getBookmarkRefreshInterval(data); + }, + }, + ), ); switch (bookmark.content.type) { diff --git a/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx b/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx index a3e5d3b3..7c254336 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx @@ -1,8 +1,8 @@ -import dayjs from "dayjs"; +import { format, isAfter, subYears } from "date-fns"; export default function BookmarkFormattedCreatedAt(prop: { createdAt: Date }) { - const createdAt = dayjs(prop.createdAt); - const oneYearAgo = dayjs().subtract(1, "year"); - const formatString = createdAt.isAfter(oneYearAgo) ? "MMM D" : "MMM D, YYYY"; - return createdAt.format(formatString); + const createdAt = prop.createdAt; + const oneYearAgo = subYears(new Date(), 1); + const formatString = isAfter(createdAt, oneYearAgo) ? "MMM d" : "MMM d, yyyy"; + return format(createdAt, formatString); } diff --git a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx index e8520b1a..f164b275 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx @@ -2,9 +2,11 @@ import type { BookmarksLayoutTypes } from "@/lib/userLocalSettings/types"; import type { ReactNode } from "react"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import Image from "next/image"; import Link from "next/link"; +import { useSession } from "@/lib/auth/client"; +import { BOOKMARK_DRAG_MIME } from "@/lib/bookmark-drag"; import useBulkActionsStore from "@/lib/bulkActions"; import { bookmarkLayoutSwitch, @@ -12,17 +14,28 @@ import { useBookmarkLayout, } from "@/lib/userLocalSettings/bookmarksLayout"; import { cn } from "@/lib/utils"; -import { Check, Image as ImageIcon, NotebookPen } from "lucide-react"; -import { useSession } from "next-auth/react"; +import { useQuery } from "@tanstack/react-query"; +import { + Check, + GripVertical, + Image as ImageIcon, + NotebookPen, +} from "lucide-react"; import { useTheme } from "next-themes"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; +import { useBookmarkListContext } from "@karakeep/shared-react/hooks/bookmark-list-context"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; -import { isBookmarkStillTagging } from "@karakeep/shared/utils/bookmarkUtils"; +import { + getBookmarkTitle, + isBookmarkStillTagging, +} from "@karakeep/shared/utils/bookmarkUtils"; import { switchCase } from "@karakeep/shared/utils/switch"; import BookmarkActionBar from "./BookmarkActionBar"; import BookmarkFormattedCreatedAt from "./BookmarkFormattedCreatedAt"; +import BookmarkOwnerIcon from "./BookmarkOwnerIcon"; import { NotePreview } from "./NotePreview"; import TagList from "./TagList"; @@ -60,6 +73,43 @@ function BottomRow({ ); } +function OwnerIndicator({ bookmark }: { bookmark: ZBookmark }) { + const api = useTRPC(); + const listContext = useBookmarkListContext(); + const collaborators = useQuery( + api.lists.getCollaborators.queryOptions( + { + listId: listContext?.id ?? "", + }, + { + refetchOnWindowFocus: false, + enabled: !!listContext?.hasCollaborators, + }, + ), + ); + + if (!listContext || listContext.userRole === "owner" || !collaborators.data) { + return null; + } + + let owner = undefined; + if (bookmark.userId === collaborators.data.owner?.id) { + owner = collaborators.data.owner; + } else { + owner = collaborators.data.collaborators.find( + (c) => c.userId === bookmark.userId, + )?.user; + } + + if (!owner) return null; + + return ( + <div className="absolute right-2 top-2 z-40 opacity-0 transition-opacity duration-200 group-hover:opacity-100"> + <BookmarkOwnerIcon ownerName={owner.name} ownerAvatar={owner.image} /> + </div> + ); +} + function MultiBookmarkSelector({ bookmark }: { bookmark: ZBookmark }) { const { selectedBookmarks, isBulkEditEnabled } = useBulkActionsStore(); const toggleBookmark = useBulkActionsStore((state) => state.toggleBookmark); @@ -114,6 +164,65 @@ function MultiBookmarkSelector({ bookmark }: { bookmark: ZBookmark }) { ); } +function DragHandle({ + bookmark, + className, +}: { + bookmark: ZBookmark; + className?: string; +}) { + const { isBulkEditEnabled } = useBulkActionsStore(); + const handleDragStart = useCallback( + (e: React.DragEvent) => { + e.stopPropagation(); + e.dataTransfer.setData(BOOKMARK_DRAG_MIME, bookmark.id); + e.dataTransfer.effectAllowed = "copy"; + + // Create a small pill element as the drag preview + const pill = document.createElement("div"); + const title = getBookmarkTitle(bookmark) ?? "Untitled"; + pill.textContent = + title.length > 40 ? title.substring(0, 40) + "\u2026" : title; + Object.assign(pill.style, { + position: "fixed", + left: "-9999px", + top: "-9999px", + padding: "6px 12px", + borderRadius: "8px", + backgroundColor: "hsl(var(--card))", + border: "1px solid hsl(var(--border))", + boxShadow: "0 4px 12px rgba(0,0,0,0.15)", + fontSize: "13px", + fontFamily: "inherit", + color: "hsl(var(--foreground))", + maxWidth: "240px", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }); + document.body.appendChild(pill); + e.dataTransfer.setDragImage(pill, 0, 0); + requestAnimationFrame(() => pill.remove()); + }, + [bookmark], + ); + + if (isBulkEditEnabled) return null; + + return ( + <div + draggable + onDragStart={handleDragStart} + className={cn( + "absolute z-40 cursor-grab rounded bg-background/70 p-0.5 opacity-0 shadow-sm transition-opacity duration-200 group-hover:opacity-100", + className, + )} + > + <GripVertical className="size-4 text-muted-foreground" /> + </div> + ); +} + function ListView({ bookmark, image, @@ -133,11 +242,16 @@ function ListView({ return ( <div className={cn( - "relative flex max-h-96 gap-4 overflow-hidden rounded-lg p-2", + "group relative flex max-h-96 gap-4 overflow-hidden rounded-lg p-2", className, )} > <MultiBookmarkSelector bookmark={bookmark} /> + <OwnerIndicator bookmark={bookmark} /> + <DragHandle + bookmark={bookmark} + className="left-1 top-1/2 -translate-y-1/2" + /> <div className="flex size-32 items-center justify-center overflow-hidden"> {image("list", cn("size-32 rounded-lg", imgFitClass))} </div> @@ -191,12 +305,14 @@ function GridView({ return ( <div className={cn( - "relative flex flex-col overflow-hidden rounded-lg", + "group relative flex flex-col overflow-hidden rounded-lg", className, fitHeight && layout != "grid" ? "max-h-96" : "h-96", )} > <MultiBookmarkSelector bookmark={bookmark} /> + <OwnerIndicator bookmark={bookmark} /> + <DragHandle bookmark={bookmark} className="left-2 top-2" /> {img && <div className="h-56 w-full shrink-0 overflow-hidden">{img}</div>} <div className="flex h-full flex-col justify-between gap-2 overflow-hidden p-2"> <div className="grow-1 flex flex-col gap-2 overflow-hidden"> @@ -228,12 +344,17 @@ function CompactView({ bookmark, title, footer, className }: Props) { return ( <div className={cn( - "relative flex flex-col overflow-hidden rounded-lg", + "group relative flex flex-col overflow-hidden rounded-lg", className, "max-h-96", )} > <MultiBookmarkSelector bookmark={bookmark} /> + <OwnerIndicator bookmark={bookmark} /> + <DragHandle + bookmark={bookmark} + className="left-0.5 top-1/2 -translate-y-1/2" + /> <div className="flex h-full justify-between gap-2 overflow-hidden p-2"> <div className="flex items-center gap-2"> {bookmark.content.type === BookmarkTypes.LINK && diff --git a/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx index e7fea2c3..a1eab830 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx @@ -1,6 +1,6 @@ import MarkdownEditor from "@/components/ui/markdown/markdown-editor";
import { MarkdownReadonly } from "@/components/ui/markdown/markdown-readonly";
-import { toast } from "@/components/ui/use-toast";
+import { toast } from "@/components/ui/sonner";
import { useUpdateBookmark } from "@karakeep/shared-react/hooks/bookmarks";
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx index 66de6156..c161853d 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx @@ -1,18 +1,26 @@ "use client"; -import { useEffect, useState } from "react"; +import { ChangeEvent, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { useToast } from "@/components/ui/use-toast"; +import { useSession } from "@/lib/auth/client"; import { useClientConfig } from "@/lib/clientConfig"; +import useUpload from "@/lib/hooks/upload-file"; import { useTranslation } from "@/lib/i18n/client"; import { + Archive, + Download, FileDown, + FileText, + ImagePlus, Link, List, ListX, @@ -22,20 +30,25 @@ import { SquarePen, Trash2, } from "lucide-react"; -import { useSession } from "next-auth/react"; +import { toast } from "sonner"; import type { ZBookmark, ZBookmarkedLink, } from "@karakeep/shared/types/bookmarks"; import { - useRecrawlBookmark, - useUpdateBookmark, -} from "@karakeep/shared-react/hooks//bookmarks"; -import { useRemoveBookmarkFromList } from "@karakeep/shared-react/hooks//lists"; + useAttachBookmarkAsset, + useReplaceBookmarkAsset, +} from "@karakeep/shared-react/hooks/assets"; import { useBookmarkGridContext } from "@karakeep/shared-react/hooks/bookmark-grid-context"; import { useBookmarkListContext } from "@karakeep/shared-react/hooks/bookmark-list-context"; +import { + useRecrawlBookmark, + useUpdateBookmark, +} from "@karakeep/shared-react/hooks/bookmarks"; +import { useRemoveBookmarkFromList } from "@karakeep/shared-react/hooks/lists"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; +import { getAssetUrl } from "@karakeep/shared/utils/assetUtils"; import { BookmarkedTextEditor } from "./BookmarkedTextEditor"; import DeleteBookmarkConfirmationDialog from "./DeleteBookmarkConfirmationDialog"; @@ -43,9 +56,35 @@ import { EditBookmarkDialog } from "./EditBookmarkDialog"; import { ArchivedActionIcon, FavouritedActionIcon } from "./icons"; import { useManageListsModal } from "./ManageListsModal"; +interface ActionItem { + id: string; + title: string; + icon: React.ReactNode; + visible: boolean; + disabled: boolean; + className?: string; + onClick: () => void; +} + +interface SubsectionItem { + id: string; + title: string; + icon: React.ReactNode; + visible: boolean; + items: ActionItem[]; +} + +const getBannerSonnerId = (bookmarkId: string) => + `replace-banner-${bookmarkId}`; + +type ActionItemType = ActionItem | SubsectionItem; + +function isSubsectionItem(item: ActionItemType): item is SubsectionItem { + return "items" in item; +} + export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const { t } = useTranslation(); - const { toast } = useToast(); const linkId = bookmark.id; const { data: session } = useSession(); @@ -73,54 +112,122 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const [isTextEditorOpen, setTextEditorOpen] = useState(false); const [isEditBookmarkDialogOpen, setEditBookmarkDialogOpen] = useState(false); + const bannerFileInputRef = useRef<HTMLInputElement>(null); + + const { mutate: uploadBannerAsset } = useUpload({ + onError: (e) => { + toast.error(e.error, { id: getBannerSonnerId(bookmark.id) }); + }, + }); + + const { mutate: attachAsset, isPending: isAttaching } = + useAttachBookmarkAsset({ + onSuccess: () => { + toast.success(t("toasts.bookmarks.update_banner"), { + id: getBannerSonnerId(bookmark.id), + }); + }, + onError: (e) => { + toast.error(e.message, { id: getBannerSonnerId(bookmark.id) }); + }, + }); + + const { mutate: replaceAsset, isPending: isReplacing } = + useReplaceBookmarkAsset({ + onSuccess: () => { + toast.success(t("toasts.bookmarks.update_banner"), { + id: getBannerSonnerId(bookmark.id), + }); + }, + onError: (e) => { + toast.error(e.message, { id: getBannerSonnerId(bookmark.id) }); + }, + }); + const { listId } = useBookmarkGridContext() ?? {}; const withinListContext = useBookmarkListContext(); const onError = () => { - toast({ - variant: "destructive", - title: t("common.something_went_wrong"), - }); + toast.error(t("common.something_went_wrong")); }; const updateBookmarkMutator = useUpdateBookmark({ onSuccess: () => { - toast({ - description: t("toasts.bookmarks.updated"), - }); + toast.success(t("toasts.bookmarks.updated")); }, onError, }); const crawlBookmarkMutator = useRecrawlBookmark({ onSuccess: () => { - toast({ - description: t("toasts.bookmarks.refetch"), - }); + toast.success(t("toasts.bookmarks.refetch")); }, onError, }); const fullPageArchiveBookmarkMutator = useRecrawlBookmark({ onSuccess: () => { - toast({ - description: t("toasts.bookmarks.full_page_archive"), - }); + toast.success(t("toasts.bookmarks.full_page_archive")); + }, + onError, + }); + + const preservePdfMutator = useRecrawlBookmark({ + onSuccess: () => { + toast.success(t("toasts.bookmarks.preserve_pdf")); }, onError, }); const removeFromListMutator = useRemoveBookmarkFromList({ onSuccess: () => { - toast({ - description: t("toasts.bookmarks.delete_from_list"), - }); + toast.success(t("toasts.bookmarks.delete_from_list")); }, onError, }); + const handleBannerFileChange = (event: ChangeEvent<HTMLInputElement>) => { + const files = event.target.files; + if (files && files.length > 0) { + const file = files[0]; + const existingBanner = bookmark.assets.find( + (asset) => asset.assetType === "bannerImage", + ); + + if (existingBanner) { + toast.loading(t("toasts.bookmarks.uploading_banner"), { + id: getBannerSonnerId(bookmark.id), + }); + uploadBannerAsset(file, { + onSuccess: (resp) => { + replaceAsset({ + bookmarkId: bookmark.id, + oldAssetId: existingBanner.id, + newAssetId: resp.assetId, + }); + }, + }); + } else { + toast.loading(t("toasts.bookmarks.uploading_banner"), { + id: getBannerSonnerId(bookmark.id), + }); + uploadBannerAsset(file, { + onSuccess: (resp) => { + attachAsset({ + bookmarkId: bookmark.id, + asset: { + id: resp.assetId, + assetType: "bannerImage", + }, + }); + }, + }); + } + } + }; + // Define action items array - const actionItems = [ + const actionItems: ActionItemType[] = [ { id: "edit", title: t("actions.edit"), @@ -174,19 +281,6 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { }), }, { - id: "download-full-page", - title: t("actions.download_full_page_archive"), - icon: <FileDown className="mr-2 size-4" />, - visible: isOwner && bookmark.content.type === BookmarkTypes.LINK, - disabled: false, - onClick: () => { - fullPageArchiveBookmarkMutator.mutate({ - bookmarkId: bookmark.id, - archiveFullPage: true, - }); - }, - }, - { id: "copy-link", title: t("actions.copy_link"), icon: <Link className="mr-2 size-4" />, @@ -196,9 +290,7 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { navigator.clipboard.writeText( (bookmark.content as ZBookmarkedLink).url, ); - toast({ - description: t("toasts.bookmarks.clipboard_copied"), - }); + toast.success(t("toasts.bookmarks.clipboard_copied")); }, }, { @@ -213,14 +305,15 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { id: "remove-from-list", title: t("actions.remove_from_list"), icon: <ListX className="mr-2 size-4" />, - visible: + visible: Boolean( (isOwner || (withinListContext && (withinListContext.userRole === "editor" || withinListContext.userRole === "owner"))) && - !!listId && - !!withinListContext && - withinListContext.type === "manual", + !!listId && + !!withinListContext && + withinListContext.type === "manual", + ), disabled: demoMode, onClick: () => removeFromListMutator.mutate({ @@ -229,12 +322,98 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { }), }, { - id: "refresh", - title: t("actions.refresh"), - icon: <RotateCw className="mr-2 size-4" />, + id: "offline-copies", + title: t("actions.offline_copies"), + icon: <Archive className="mr-2 size-4" />, visible: isOwner && bookmark.content.type === BookmarkTypes.LINK, - disabled: demoMode, - onClick: () => crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }), + items: [ + { + id: "download-full-page", + title: t("actions.preserve_offline_archive"), + icon: <FileDown className="mr-2 size-4" />, + visible: true, + disabled: demoMode, + onClick: () => { + fullPageArchiveBookmarkMutator.mutate({ + bookmarkId: bookmark.id, + archiveFullPage: true, + }); + }, + }, + { + id: "preserve-pdf", + title: t("actions.preserve_as_pdf"), + icon: <FileText className="mr-2 size-4" />, + visible: true, + disabled: demoMode, + onClick: () => { + preservePdfMutator.mutate({ + bookmarkId: bookmark.id, + storePdf: true, + }); + }, + }, + { + id: "download-full-page-archive", + title: t("actions.download_full_page_archive_file"), + icon: <Download className="mr-2 size-4" />, + visible: + bookmark.content.type === BookmarkTypes.LINK && + !!( + bookmark.content.fullPageArchiveAssetId || + bookmark.content.precrawledArchiveAssetId + ), + disabled: false, + onClick: () => { + const link = bookmark.content as ZBookmarkedLink; + const archiveAssetId = + link.fullPageArchiveAssetId ?? link.precrawledArchiveAssetId; + if (archiveAssetId) { + window.open(getAssetUrl(archiveAssetId), "_blank"); + } + }, + }, + { + id: "download-pdf", + title: t("actions.download_pdf_file"), + icon: <Download className="mr-2 size-4" />, + visible: !!(bookmark.content as ZBookmarkedLink).pdfAssetId, + disabled: false, + onClick: () => { + const link = bookmark.content as ZBookmarkedLink; + if (link.pdfAssetId) { + window.open(getAssetUrl(link.pdfAssetId), "_blank"); + } + }, + }, + ], + }, + { + id: "more", + title: t("actions.more"), + icon: <MoreHorizontal className="mr-2 size-4" />, + visible: isOwner, + items: [ + { + id: "refresh", + title: t("actions.refresh"), + icon: <RotateCw className="mr-2 size-4" />, + visible: bookmark.content.type === BookmarkTypes.LINK, + disabled: demoMode, + onClick: () => + crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }), + }, + { + id: "replace-banner", + title: bookmark.assets.find((a) => a.assetType === "bannerImage") + ? t("actions.replace_banner") + : t("actions.add_banner"), + icon: <ImagePlus className="mr-2 size-4" />, + visible: true, + disabled: demoMode || isAttaching || isReplacing, + onClick: () => bannerFileInputRef.current?.click(), + }, + ], }, { id: "delete", @@ -248,7 +427,12 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { ]; // Filter visible items - const visibleItems = actionItems.filter((item) => item.visible); + const visibleItems: ActionItemType[] = actionItems.filter((item) => { + if (isSubsectionItem(item)) { + return item.visible && item.items.some((subItem) => subItem.visible); + } + return item.visible; + }); // If no items are visible, don't render the dropdown if (visibleItems.length === 0) { @@ -283,19 +467,56 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { </Button> </DropdownMenuTrigger> <DropdownMenuContent className="w-fit"> - {visibleItems.map((item) => ( - <DropdownMenuItem - key={item.id} - disabled={item.disabled} - className={item.className} - onClick={item.onClick} - > - {item.icon} - <span>{item.title}</span> - </DropdownMenuItem> - ))} + {visibleItems.map((item) => { + if (isSubsectionItem(item)) { + const visibleSubItems = item.items.filter( + (subItem) => subItem.visible, + ); + if (visibleSubItems.length === 0) { + return null; + } + return ( + <DropdownMenuSub key={item.id}> + <DropdownMenuSubTrigger> + {item.icon} + <span>{item.title}</span> + </DropdownMenuSubTrigger> + <DropdownMenuSubContent> + {visibleSubItems.map((subItem) => ( + <DropdownMenuItem + key={subItem.id} + disabled={subItem.disabled} + onClick={subItem.onClick} + > + {subItem.icon} + <span>{subItem.title}</span> + </DropdownMenuItem> + ))} + </DropdownMenuSubContent> + </DropdownMenuSub> + ); + } + return ( + <DropdownMenuItem + key={item.id} + disabled={item.disabled} + className={item.className} + onClick={item.onClick} + > + {item.icon} + <span>{item.title}</span> + </DropdownMenuItem> + ); + })} </DropdownMenuContent> </DropdownMenu> + <input + type="file" + ref={bannerFileInputRef} + onChange={handleBannerFileChange} + className="hidden" + accept=".jpg,.jpeg,.png,.webp" + /> </> ); } diff --git a/apps/web/components/dashboard/bookmarks/BookmarkOwnerIcon.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOwnerIcon.tsx new file mode 100644 index 00000000..57770547 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/BookmarkOwnerIcon.tsx @@ -0,0 +1,31 @@ +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { UserAvatar } from "@/components/ui/user-avatar"; + +interface BookmarkOwnerIconProps { + ownerName: string; + ownerAvatar: string | null; +} + +export default function BookmarkOwnerIcon({ + ownerName, + ownerAvatar, +}: BookmarkOwnerIconProps) { + return ( + <Tooltip> + <TooltipTrigger> + <UserAvatar + name={ownerName} + image={ownerAvatar} + className="size-5 shrink-0 rounded-full ring-1 ring-border" + /> + </TooltipTrigger> + <TooltipContent className="font-sm"> + <p className="font-medium">{ownerName}</p> + </TooltipContent> + </Tooltip> + ); +} diff --git a/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx b/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx index 22b5408e..09843bce 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx @@ -1,4 +1,4 @@ -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; import { useUpdateBookmarkTags } from "@karakeep/shared-react/hooks/bookmarks"; diff --git a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx index f726c703..b3a1881a 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx @@ -16,6 +16,7 @@ import Masonry from "react-masonry-css"; import resolveConfig from "tailwindcss/resolveConfig"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; +import { useBookmarkListContext } from "@karakeep/shared-react/hooks/bookmark-list-context"; import BookmarkCard from "./BookmarkCard"; import EditorCard from "./EditorCard"; @@ -64,6 +65,7 @@ export default function BookmarksGrid({ const gridColumns = useGridColumns(); const bulkActionsStore = useBulkActionsStore(); const inBookmarkGrid = useInBookmarkGridStore(); + const withinListContext = useBookmarkListContext(); const breakpointConfig = useMemo( () => getBreakpointConfig(gridColumns), [gridColumns], @@ -72,10 +74,13 @@ export default function BookmarksGrid({ useEffect(() => { bulkActionsStore.setVisibleBookmarks(bookmarks); + bulkActionsStore.setListContext(withinListContext); + return () => { bulkActionsStore.setVisibleBookmarks([]); + bulkActionsStore.setListContext(undefined); }; - }, [bookmarks]); + }, [bookmarks, withinListContext?.id]); useEffect(() => { inBookmarkGrid.setInBookmarkGrid(true); @@ -112,12 +117,20 @@ export default function BookmarksGrid({ <> {bookmarkLayoutSwitch(layout, { masonry: ( - <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + <Masonry + className="-ml-4 flex w-auto" + columnClassName="pl-4" + breakpointCols={breakpointConfig} + > {children} </Masonry> ), grid: ( - <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + <Masonry + className="-ml-4 flex w-auto" + columnClassName="pl-4" + breakpointCols={breakpointConfig} + > {children} </Masonry> ), diff --git a/apps/web/components/dashboard/bookmarks/BookmarksGridSkeleton.tsx b/apps/web/components/dashboard/bookmarks/BookmarksGridSkeleton.tsx index b592919b..9adc7b7a 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarksGridSkeleton.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarksGridSkeleton.tsx @@ -69,12 +69,20 @@ export default function BookmarksGridSkeleton({ return bookmarkLayoutSwitch(layout, { masonry: ( - <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + <Masonry + className="-ml-4 flex w-auto" + columnClassName="pl-4" + breakpointCols={breakpointConfig} + > {children} </Masonry> ), grid: ( - <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + <Masonry + className="-ml-4 flex w-auto" + columnClassName="pl-4" + breakpointCols={breakpointConfig} + > {children} </Masonry> ), diff --git a/apps/web/components/dashboard/bookmarks/BulkManageListsModal.tsx b/apps/web/components/dashboard/bookmarks/BulkManageListsModal.tsx index 23afa7d2..1d4f5814 100644 --- a/apps/web/components/dashboard/bookmarks/BulkManageListsModal.tsx +++ b/apps/web/components/dashboard/bookmarks/BulkManageListsModal.tsx @@ -15,7 +15,7 @@ import { FormItem, FormMessage, } from "@/components/ui/form"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; diff --git a/apps/web/components/dashboard/bookmarks/BulkTagModal.tsx b/apps/web/components/dashboard/bookmarks/BulkTagModal.tsx index 431f0fcd..c790a5fe 100644 --- a/apps/web/components/dashboard/bookmarks/BulkTagModal.tsx +++ b/apps/web/components/dashboard/bookmarks/BulkTagModal.tsx @@ -7,10 +7,11 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; +import { useQueries } from "@tanstack/react-query"; import { useUpdateBookmarkTags } from "@karakeep/shared-react/hooks/bookmarks"; -import { api } from "@karakeep/shared-react/trpc"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { limitConcurrency } from "@karakeep/shared/concurrency"; import { ZBookmark } from "@karakeep/shared/types/bookmarks"; @@ -25,9 +26,12 @@ export default function BulkTagModal({ open: boolean; setOpen: (open: boolean) => void; }) { - const results = api.useQueries((t) => - bookmarkIds.map((id) => t.bookmarks.getBookmark({ bookmarkId: id })), - ); + const api = useTRPC(); + const results = useQueries({ + queries: bookmarkIds.map((id) => + api.bookmarks.getBookmark.queryOptions({ bookmarkId: id }), + ), + }); const bookmarks = results .map((r) => r.data) diff --git a/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx b/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx index 7e680706..8e7a4d34 100644 --- a/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx +++ b/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx @@ -1,7 +1,7 @@ import { usePathname, useRouter } from "next/navigation"; import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; import { useDeleteBookmark } from "@karakeep/shared-react/hooks//bookmarks"; diff --git a/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx b/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx index 76208158..8b77365c 100644 --- a/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx +++ b/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx @@ -25,18 +25,19 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { toast } from "@/components/ui/sonner"; import { Textarea } from "@/components/ui/textarea"; -import { toast } from "@/components/ui/use-toast"; import { useDialogFormReset } from "@/lib/hooks/useDialogFormReset"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; import { format } from "date-fns"; import { CalendarIcon } from "lucide-react"; import { useForm } from "react-hook-form"; import { useUpdateBookmark } from "@karakeep/shared-react/hooks/bookmarks"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes, ZBookmark, @@ -60,10 +61,11 @@ export function EditBookmarkDialog({ open: boolean; setOpen: (v: boolean) => void; }) { + const api = useTRPC(); const { t } = useTranslation(); - const { data: assetContent, isLoading: isAssetContentLoading } = - api.bookmarks.getBookmark.useQuery( + const { data: assetContent, isLoading: isAssetContentLoading } = useQuery( + api.bookmarks.getBookmark.queryOptions( { bookmarkId: bookmark.id, includeContent: true, @@ -73,11 +75,13 @@ export function EditBookmarkDialog({ select: (b) => b.content.type == BookmarkTypes.ASSET ? b.content.content : null, }, - ); + ), + ); const bookmarkToDefault = (bookmark: ZBookmark) => ({ bookmarkId: bookmark.id, summary: bookmark.summary, + note: bookmark.note === null ? undefined : bookmark.note, title: getBookmarkTitle(bookmark), createdAt: bookmark.createdAt ?? new Date(), // Link specific defaults (only if bookmark is a link) @@ -196,6 +200,26 @@ export function EditBookmarkDialog({ /> )} + { + <FormField + control={form.control} + name="note" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("common.note")}</FormLabel> + <FormControl> + <Textarea + placeholder="Bookmark notes" + {...field} + value={field.value ?? ""} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + } + {isLink && ( <FormField control={form.control} diff --git a/apps/web/components/dashboard/bookmarks/EditorCard.tsx b/apps/web/components/dashboard/bookmarks/EditorCard.tsx index fa752c5f..4636bcb9 100644 --- a/apps/web/components/dashboard/bookmarks/EditorCard.tsx +++ b/apps/web/components/dashboard/bookmarks/EditorCard.tsx @@ -5,8 +5,8 @@ import { Form, FormControl, FormItem } from "@/components/ui/form"; import { Kbd } from "@/components/ui/kbd"; import MultipleChoiceDialog from "@/components/ui/multiple-choice-dialog"; import { Separator } from "@/components/ui/separator"; +import { toast } from "@/components/ui/sonner"; import { Textarea } from "@/components/ui/textarea"; -import { toast } from "@/components/ui/use-toast"; import BookmarkAlreadyExistsToast from "@/components/utils/BookmarkAlreadyExistsToast"; import { useClientConfig } from "@/lib/clientConfig"; import { useTranslation } from "@/lib/i18n/client"; diff --git a/apps/web/components/dashboard/bookmarks/ManageListsModal.tsx b/apps/web/components/dashboard/bookmarks/ManageListsModal.tsx index 7c3827ab..1fee0505 100644 --- a/apps/web/components/dashboard/bookmarks/ManageListsModal.tsx +++ b/apps/web/components/dashboard/bookmarks/ManageListsModal.tsx @@ -16,11 +16,11 @@ import { FormItem, FormMessage, } from "@/components/ui/form"; +import { toast } from "@/components/ui/sonner"; import LoadingSpinner from "@/components/ui/spinner"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; import { Archive, X } from "lucide-react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -30,6 +30,7 @@ import { useBookmarkLists, useRemoveBookmarkFromList, } from "@karakeep/shared-react/hooks/lists"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkListSelector } from "../lists/BookmarkListSelector"; import ArchiveBookmarkButton from "./action-buttons/ArchiveBookmarkButton"; @@ -43,6 +44,7 @@ export default function ManageListsModal({ open: boolean; setOpen: (open: boolean) => void; }) { + const api = useTRPC(); const { t } = useTranslation(); const formSchema = z.object({ listId: z.string({ @@ -61,13 +63,14 @@ export default function ManageListsModal({ { enabled: open }, ); - const { data: alreadyInList, isPending: isAlreadyInListPending } = - api.lists.getListsOfBookmark.useQuery( + const { data: alreadyInList, isPending: isAlreadyInListPending } = useQuery( + api.lists.getListsOfBookmark.queryOptions( { bookmarkId, }, { enabled: open }, - ); + ), + ); const isLoading = isAllListsPending || isAlreadyInListPending; diff --git a/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx b/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx index b2cf118e..5f107663 100644 --- a/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx +++ b/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx @@ -1,8 +1,8 @@ import React from "react"; import { ActionButton } from "@/components/ui/action-button"; import { MarkdownReadonly } from "@/components/ui/markdown/markdown-readonly"; +import { toast } from "@/components/ui/sonner"; import LoadingSpinner from "@/components/ui/spinner"; -import { toast } from "@/components/ui/use-toast"; import { useClientConfig } from "@/lib/clientConfig"; import { useTranslation } from "@/lib/i18n/client"; import { cn } from "@/lib/utils"; diff --git a/apps/web/components/dashboard/bookmarks/TagList.tsx b/apps/web/components/dashboard/bookmarks/TagList.tsx index f1c319ea..88611c52 100644 --- a/apps/web/components/dashboard/bookmarks/TagList.tsx +++ b/apps/web/components/dashboard/bookmarks/TagList.tsx @@ -1,8 +1,8 @@ import Link from "next/link"; import { badgeVariants } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; +import { useSession } from "@/lib/auth/client"; import { cn } from "@/lib/utils"; -import { useSession } from "next-auth/react"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; diff --git a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx index bc06c647..ec4a9d8a 100644 --- a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx +++ b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx @@ -13,25 +13,32 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { useClientConfig } from "@/lib/clientConfig"; -import { api } from "@/lib/trpc"; +import { useTranslation } from "@/lib/i18n/client"; import { cn } from "@/lib/utils"; -import { keepPreviousData } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { Command as CommandPrimitive } from "cmdk"; import { Check, Loader2, Plus, Sparkles, X } from "lucide-react"; import type { ZBookmarkTags } from "@karakeep/shared/types/tags"; +import { useTRPC } from "@karakeep/shared-react/trpc"; export function TagsEditor({ tags: _tags, onAttach, onDetach, disabled, + allowCreation = true, + placeholder, }: { tags: ZBookmarkTags[]; onAttach: (tag: { tagName: string; tagId?: string }) => void; onDetach: (tag: { tagName: string; tagId: string }) => void; disabled?: boolean; + allowCreation?: boolean; + placeholder?: string; }) { + const api = useTRPC(); + const { t } = useTranslation(); const demoMode = !!useClientConfig().demoMode; const isDisabled = demoMode || disabled; const inputRef = React.useRef<HTMLInputElement>(null); @@ -40,6 +47,7 @@ export function TagsEditor({ const [inputValue, setInputValue] = React.useState(""); const [optimisticTags, setOptimisticTags] = useState<ZBookmarkTags[]>(_tags); const tempIdCounter = React.useRef(0); + const hasInitializedRef = React.useRef(_tags.length > 0); const generateTempId = React.useCallback(() => { tempIdCounter.current += 1; @@ -54,25 +62,42 @@ export function TagsEditor({ }, []); React.useEffect(() => { + // When allowCreation is false, only sync on initial load + // After that, rely on optimistic updates to avoid re-ordering + if (!allowCreation) { + if (!hasInitializedRef.current && _tags.length > 0) { + hasInitializedRef.current = true; + setOptimisticTags(_tags); + } + return; + } + + // For allowCreation mode, sync server state with optimistic state setOptimisticTags((prev) => { - let results = prev; + // Start with a copy to avoid mutating the previous state + const results = [...prev]; + let changed = false; + for (const tag of _tags) { const idx = results.findIndex((t) => t.name === tag.name); if (idx == -1) { results.push(tag); + changed = true; continue; } if (results[idx].id.startsWith("temp-")) { results[idx] = tag; + changed = true; continue; } } - return results; + + return changed ? results : prev; }); - }, [_tags]); + }, [_tags, allowCreation]); - const { data: filteredOptions, isLoading: isExistingTagsLoading } = - api.tags.list.useQuery( + const { data: filteredOptions, isLoading: isExistingTagsLoading } = useQuery( + api.tags.list.queryOptions( { nameContains: inputValue, limit: 50, @@ -91,7 +116,8 @@ export function TagsEditor({ placeholderData: keepPreviousData, gcTime: inputValue.length > 0 ? 60_000 : 3_600_000, }, - ); + ), + ); const selectedValues = optimisticTags.map((tag) => tag.id); @@ -122,7 +148,7 @@ export function TagsEditor({ (opt) => opt.name.toLowerCase() === trimmedInputValue.toLowerCase(), ); - if (!exactMatch) { + if (!exactMatch && allowCreation) { return [ { id: "create-new", @@ -136,7 +162,7 @@ export function TagsEditor({ } return baseOptions; - }, [filteredOptions, trimmedInputValue]); + }, [filteredOptions, trimmedInputValue, allowCreation]); const onChange = ( actionMeta: @@ -256,6 +282,24 @@ export function TagsEditor({ } }; + const inputPlaceholder = + placeholder ?? + (allowCreation + ? t("tags.search_or_create_placeholder", { + defaultValue: "Search or create tags...", + }) + : t("tags.search_placeholder", { + defaultValue: "Search tags...", + })); + const visiblePlaceholder = + optimisticTags.length === 0 ? inputPlaceholder : undefined; + const inputWidth = Math.max( + inputValue.length > 0 + ? inputValue.length + : Math.min(visiblePlaceholder?.length ?? 1, 24), + 1, + ); + return ( <div ref={containerRef} className="w-full"> <Popover open={open && !isDisabled} onOpenChange={handleOpenChange}> @@ -311,8 +355,9 @@ export function TagsEditor({ value={inputValue} onKeyDown={handleKeyDown} onValueChange={(v) => setInputValue(v)} + placeholder={visiblePlaceholder} className="bg-transparent outline-none placeholder:text-muted-foreground" - style={{ width: `${Math.max(inputValue.length, 1)}ch` }} + style={{ width: `${inputWidth}ch` }} disabled={isDisabled} /> {isExistingTagsLoading && ( @@ -329,7 +374,7 @@ export function TagsEditor({ <CommandList className="max-h-64"> {displayedOptions.length === 0 ? ( <CommandEmpty> - {trimmedInputValue ? ( + {trimmedInputValue && allowCreation ? ( <div className="flex items-center justify-between px-2 py-1.5"> <span>Create "{trimmedInputValue}"</span> <Button diff --git a/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx b/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx index 968d0326..e9bee653 100644 --- a/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx +++ b/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx @@ -3,13 +3,14 @@ import { useEffect } from "react"; import UploadDropzone from "@/components/dashboard/UploadDropzone"; import { useSortOrderStore } from "@/lib/store/useSortOrderStore"; -import { api } from "@/lib/trpc"; +import { useInfiniteQuery } from "@tanstack/react-query"; import type { ZGetBookmarksRequest, ZGetBookmarksResponse, } from "@karakeep/shared/types/bookmarks"; import { BookmarkGridContextProvider } from "@karakeep/shared-react/hooks/bookmark-grid-context"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import BookmarksGrid from "./BookmarksGrid"; @@ -23,6 +24,7 @@ export default function UpdatableBookmarksGrid({ showEditorCard?: boolean; itemsPerPage?: number; }) { + const api = useTRPC(); let sortOrder = useSortOrderStore((state) => state.sortOrder); if (sortOrder === "relevance") { // Relevance is not supported in the `getBookmarks` endpoint. @@ -32,17 +34,19 @@ export default function UpdatableBookmarksGrid({ const finalQuery = { ...query, sortOrder, includeContent: false }; const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } = - api.bookmarks.getBookmarks.useInfiniteQuery( - { ...finalQuery, useCursorV2: true }, - { - initialData: () => ({ - pages: [initialBookmarks], - pageParams: [query.cursor], - }), - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - refetchOnMount: true, - }, + useInfiniteQuery( + api.bookmarks.getBookmarks.infiniteQueryOptions( + { ...finalQuery, useCursorV2: true }, + { + initialData: () => ({ + pages: [initialBookmarks], + pageParams: [query.cursor ?? null], + }), + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + refetchOnMount: true, + }, + ), ); useEffect(() => { diff --git a/apps/web/components/dashboard/bookmarks/action-buttons/ArchiveBookmarkButton.tsx b/apps/web/components/dashboard/bookmarks/action-buttons/ArchiveBookmarkButton.tsx index d45cfc82..48d3c7ac 100644 --- a/apps/web/components/dashboard/bookmarks/action-buttons/ArchiveBookmarkButton.tsx +++ b/apps/web/components/dashboard/bookmarks/action-buttons/ArchiveBookmarkButton.tsx @@ -1,9 +1,10 @@ import React from "react"; import { ActionButton, ActionButtonProps } from "@/components/ui/action-button"; -import { toast } from "@/components/ui/use-toast"; -import { api } from "@/lib/trpc"; +import { toast } from "@/components/ui/sonner"; +import { useQuery } from "@tanstack/react-query"; import { useUpdateBookmark } from "@karakeep/shared-react/hooks/bookmarks"; +import { useTRPC } from "@karakeep/shared-react/trpc"; interface ArchiveBookmarkButtonProps extends Omit<ActionButtonProps, "loading" | "disabled"> { @@ -15,13 +16,16 @@ const ArchiveBookmarkButton = React.forwardRef< HTMLButtonElement, ArchiveBookmarkButtonProps >(({ bookmarkId, onDone, ...props }, ref) => { - const { data } = api.bookmarks.getBookmark.useQuery( - { bookmarkId }, - { - select: (data) => ({ - archived: data.archived, - }), - }, + const api = useTRPC(); + const { data } = useQuery( + api.bookmarks.getBookmark.queryOptions( + { bookmarkId }, + { + select: (data) => ({ + archived: data.archived, + }), + }, + ), ); const { mutate: updateBookmark, isPending: isArchivingBookmark } = diff --git a/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx b/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx index 52a9ab0c..b1870644 100644 --- a/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx +++ b/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx @@ -11,6 +11,7 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; +import { toast } from "@/components/ui/sonner"; import LoadingSpinner from "@/components/ui/spinner"; import { Table, @@ -20,14 +21,14 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; import { distance } from "fastest-levenshtein"; import { Check, Combine, X } from "lucide-react"; import { useMergeTag } from "@karakeep/shared-react/hooks/tags"; +import { useTRPC } from "@karakeep/shared-react/trpc"; interface Suggestion { mergeIntoId: string; @@ -199,12 +200,15 @@ function SuggestionRow({ } export function TagDuplicationDetection() { + const api = useTRPC(); const [expanded, setExpanded] = useState(false); - let { data: allTags } = api.tags.list.useQuery( - {}, - { - refetchOnWindowFocus: false, - }, + let { data: allTags } = useQuery( + api.tags.list.queryOptions( + {}, + { + refetchOnWindowFocus: false, + }, + ), ); const { suggestions, updateMergeInto, setSuggestions, deleteSuggestion } = diff --git a/apps/web/components/dashboard/feeds/FeedSelector.tsx b/apps/web/components/dashboard/feeds/FeedSelector.tsx index db95a042..58fae503 100644 --- a/apps/web/components/dashboard/feeds/FeedSelector.tsx +++ b/apps/web/components/dashboard/feeds/FeedSelector.tsx @@ -7,8 +7,10 @@ import { SelectValue, } from "@/components/ui/select"; import LoadingSpinner from "@/components/ui/spinner"; -import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; export function FeedSelector({ value, @@ -21,9 +23,12 @@ export function FeedSelector({ onChange: (value: string) => void; placeholder?: string; }) { - const { data, isPending } = api.feeds.list.useQuery(undefined, { - select: (data) => data.feeds, - }); + const api = useTRPC(); + const { data, isPending } = useQuery( + api.feeds.list.queryOptions(undefined, { + select: (data) => data.feeds, + }), + ); if (isPending) { return <LoadingSpinner />; diff --git a/apps/web/components/dashboard/header/ProfileOptions.tsx b/apps/web/components/dashboard/header/ProfileOptions.tsx index 7ccc0078..8a2b0165 100644 --- a/apps/web/components/dashboard/header/ProfileOptions.tsx +++ b/apps/web/components/dashboard/header/ProfileOptions.tsx @@ -1,5 +1,6 @@ "use client"; +import { useMemo } from "react"; import Link from "next/link"; import { redirect, useRouter } from "next/navigation"; import { useToggleTheme } from "@/components/theme-provider"; @@ -11,11 +12,24 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Separator } from "@/components/ui/separator"; +import { UserAvatar } from "@/components/ui/user-avatar"; +import { useSession } from "@/lib/auth/client"; import { useTranslation } from "@/lib/i18n/client"; -import { LogOut, Moon, Paintbrush, Settings, Shield, Sun } from "lucide-react"; -import { useSession } from "next-auth/react"; +import { + BookOpen, + LogOut, + Moon, + Paintbrush, + Puzzle, + Settings, + Shield, + Sun, + Twitter, +} from "lucide-react"; import { useTheme } from "next-themes"; +import { useWhoAmI } from "@karakeep/shared-react/hooks/users"; + import { AdminNoticeBadge } from "../../admin/AdminNotices"; function DarkModeToggle() { @@ -43,7 +57,12 @@ export default function SidebarProfileOptions() { const { t } = useTranslation(); const toggleTheme = useToggleTheme(); const { data: session } = useSession(); + const { data: whoami } = useWhoAmI(); const router = useRouter(); + + const avatarImage = whoami?.image ?? null; + const avatarUrl = useMemo(() => avatarImage ?? null, [avatarImage]); + if (!session) return redirect("/"); return ( @@ -53,13 +72,21 @@ export default function SidebarProfileOptions() { className="border-new-gray-200 aspect-square rounded-full border-4 bg-black p-0 text-white" variant="ghost" > - {session.user.name?.charAt(0) ?? "U"} + <UserAvatar + image={avatarUrl} + name={session.user.name} + className="h-full w-full rounded-full" + /> </Button> </DropdownMenuTrigger> <DropdownMenuContent className="mr-2 min-w-64 p-2"> <div className="flex gap-2"> - <div className="border-new-gray-200 flex aspect-square size-11 items-center justify-center rounded-full border-4 bg-black p-0 text-white"> - {session.user.name?.charAt(0) ?? "U"} + <div className="border-new-gray-200 flex aspect-square size-11 items-center justify-center overflow-hidden rounded-full border-4 bg-black p-0 text-white"> + <UserAvatar + image={avatarUrl} + name={session.user.name} + className="h-full w-full" + /> </div> <div className="flex flex-col"> <p>{session.user.name}</p> @@ -95,6 +122,25 @@ export default function SidebarProfileOptions() { <DarkModeToggle /> </DropdownMenuItem> <Separator className="my-2" /> + <DropdownMenuItem asChild> + <a href="https://karakeep.app/apps" target="_blank" rel="noreferrer"> + <Puzzle className="mr-2 size-4" /> + {t("options.apps_extensions")} + </a> + </DropdownMenuItem> + <DropdownMenuItem asChild> + <a href="https://docs.karakeep.app" target="_blank" rel="noreferrer"> + <BookOpen className="mr-2 size-4" /> + {t("options.documentation")} + </a> + </DropdownMenuItem> + <DropdownMenuItem asChild> + <a href="https://x.com/karakeep_app" target="_blank" rel="noreferrer"> + <Twitter className="mr-2 size-4" /> + {t("options.follow_us_on_x")} + </a> + </DropdownMenuItem> + <Separator className="my-2" /> <DropdownMenuItem onClick={() => router.push("/logout")}> <LogOut className="mr-2 size-4" /> <span>{t("actions.sign_out")}</span> diff --git a/apps/web/components/dashboard/highlights/AllHighlights.tsx b/apps/web/components/dashboard/highlights/AllHighlights.tsx index 928f4e05..c7e809ec 100644 --- a/apps/web/components/dashboard/highlights/AllHighlights.tsx +++ b/apps/web/components/dashboard/highlights/AllHighlights.tsx @@ -5,15 +5,14 @@ import Link from "next/link"; import { ActionButton } from "@/components/ui/action-button"; import { Input } from "@/components/ui/input"; 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 { useInfiniteQuery } from "@tanstack/react-query"; import { Dot, LinkIcon, Search, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useInView } from "react-intersection-observer"; import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { ZGetAllHighlightsResponse, ZHighlight, @@ -21,8 +20,6 @@ import { import HighlightCard from "./HighlightCard"; -dayjs.extend(relativeTime); - function Highlight({ highlight }: { highlight: ZHighlight }) { const { fromNow, localCreatedAt } = useRelativeTime(highlight.createdAt); const { t } = useTranslation(); @@ -49,6 +46,7 @@ export default function AllHighlights({ }: { highlights: ZGetAllHighlightsResponse; }) { + const api = useTRPC(); const { t } = useTranslation(); const [searchInput, setSearchInput] = useState(""); const debouncedSearch = useDebounce(searchInput, 300); @@ -56,28 +54,32 @@ export default function AllHighlights({ // Use search endpoint if searchQuery is provided, otherwise use getAll const useSearchQuery = debouncedSearch.trim().length > 0; - const getAllQuery = api.highlights.getAll.useInfiniteQuery( - {}, - { - enabled: !useSearchQuery, - initialData: !useSearchQuery - ? () => ({ - pages: [initialHighlights], - pageParams: [null], - }) - : undefined, - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - }, + const getAllQuery = useInfiniteQuery( + api.highlights.getAll.infiniteQueryOptions( + {}, + { + enabled: !useSearchQuery, + initialData: !useSearchQuery + ? () => ({ + pages: [initialHighlights], + pageParams: [null], + }) + : undefined, + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ), ); - const searchQueryResult = api.highlights.search.useInfiniteQuery( - { text: debouncedSearch }, - { - enabled: useSearchQuery, - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - }, + const searchQueryResult = useInfiniteQuery( + api.highlights.search.infiniteQueryOptions( + { text: debouncedSearch }, + { + enabled: useSearchQuery, + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ), ); const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = diff --git a/apps/web/components/dashboard/highlights/HighlightCard.tsx b/apps/web/components/dashboard/highlights/HighlightCard.tsx index 51421e0f..e7e7c519 100644 --- a/apps/web/components/dashboard/highlights/HighlightCard.tsx +++ b/apps/web/components/dashboard/highlights/HighlightCard.tsx @@ -1,5 +1,5 @@ import { ActionButton } from "@/components/ui/action-button"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { cn } from "@/lib/utils"; import { Trash2 } from "lucide-react"; diff --git a/apps/web/components/dashboard/lists/AllListsView.tsx b/apps/web/components/dashboard/lists/AllListsView.tsx index 7a7c9504..52d65756 100644 --- a/apps/web/components/dashboard/lists/AllListsView.tsx +++ b/apps/web/components/dashboard/lists/AllListsView.tsx @@ -2,7 +2,6 @@ import { useMemo, useState } from "react"; import Link from "next/link"; -import { EditListModal } from "@/components/dashboard/lists/EditListModal"; import { Button } from "@/components/ui/button"; import { Collapsible, @@ -10,7 +9,7 @@ import { CollapsibleTriggerChevron, } from "@/components/ui/collapsible"; import { useTranslation } from "@/lib/i18n/client"; -import { MoreHorizontal, Plus } from "lucide-react"; +import { MoreHorizontal } from "lucide-react"; import type { ZBookmarkList } from "@karakeep/shared/types/lists"; import { @@ -89,12 +88,6 @@ export default function AllListsView({ return ( <ul> - <EditListModal> - <Button className="mb-2 flex h-full w-full items-center"> - <Plus /> - <span>{t("lists.new_list")}</span> - </Button> - </EditListModal> <ListItem collapsible={false} name={t("lists.favourites")} diff --git a/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx b/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx index 2bb5f41b..0070b827 100644 --- a/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx +++ b/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx @@ -1,10 +1,10 @@ import { useEffect, useState } from "react"; import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; -import { api } from "@/lib/trpc"; -import { keepPreviousData } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { ZBookmarkList } from "@karakeep/shared/types/lists"; import { ZBookmarkListTreeNode } from "@karakeep/shared/utils/listUtils"; @@ -101,6 +101,7 @@ export function CollapsibleBookmarkLists({ filter?: (node: ZBookmarkListTreeNode) => boolean; indentOffset?: number; }) { + const api = useTRPC(); // If listsData is provided, use it directly. Otherwise, fetch it. let { data: fetchedData } = useBookmarkLists(undefined, { initialData: initialData ? { lists: initialData } : undefined, @@ -108,9 +109,11 @@ export function CollapsibleBookmarkLists({ }); const data = listsData || fetchedData; - const { data: listStats } = api.lists.stats.useQuery(undefined, { - placeholderData: keepPreviousData, - }); + const { data: listStats } = useQuery( + api.lists.stats.queryOptions(undefined, { + placeholderData: keepPreviousData, + }), + ); if (!data) { return <FullPageSpinner />; diff --git a/apps/web/components/dashboard/lists/DeleteListConfirmationDialog.tsx b/apps/web/components/dashboard/lists/DeleteListConfirmationDialog.tsx index 4996ddf1..6c091d7a 100644 --- a/apps/web/components/dashboard/lists/DeleteListConfirmationDialog.tsx +++ b/apps/web/components/dashboard/lists/DeleteListConfirmationDialog.tsx @@ -3,8 +3,8 @@ import { usePathname, useRouter } from "next/navigation"; import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; import { Label } from "@/components/ui/label"; +import { toast } from "@/components/ui/sonner"; import { Switch } from "@/components/ui/switch"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; import type { ZBookmarkList } from "@karakeep/shared/types/lists"; diff --git a/apps/web/components/dashboard/lists/EditListModal.tsx b/apps/web/components/dashboard/lists/EditListModal.tsx index 5febf88c..21a61d65 100644 --- a/apps/web/components/dashboard/lists/EditListModal.tsx +++ b/apps/web/components/dashboard/lists/EditListModal.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -34,7 +36,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; import data from "@emoji-mart/data"; import Picker from "@emoji-mart/react"; diff --git a/apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx b/apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx index 62dbbcef..859f4c83 100644 --- a/apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx +++ b/apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx @@ -2,11 +2,12 @@ import React from "react"; import { usePathname, useRouter } from "next/navigation"; import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { ZBookmarkList } from "@karakeep/shared/types/lists"; +import { useTRPC } from "@karakeep/shared-react/trpc"; export default function LeaveListConfirmationDialog({ list, @@ -19,34 +20,37 @@ export default function LeaveListConfirmationDialog({ open: boolean; setOpen: (v: boolean) => void; }) { + const api = useTRPC(); const { t } = useTranslation(); const currentPath = usePathname(); const router = useRouter(); - const utils = api.useUtils(); + const queryClient = useQueryClient(); - const { mutate: leaveList, isPending } = api.lists.leaveList.useMutation({ - onSuccess: () => { - toast({ - description: t("lists.leave_list.success", { - icon: list.icon, - name: list.name, - }), - }); - setOpen(false); - // Invalidate the lists cache - utils.lists.list.invalidate(); - // If currently viewing this list, redirect to lists page - if (currentPath.includes(list.id)) { - router.push("/dashboard/lists"); - } - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("common.something_went_wrong"), - }); - }, - }); + const { mutate: leaveList, isPending } = useMutation( + api.lists.leaveList.mutationOptions({ + onSuccess: () => { + toast({ + description: t("lists.leave_list.success", { + icon: list.icon, + name: list.name, + }), + }); + setOpen(false); + // Invalidate the lists cache + queryClient.invalidateQueries(api.lists.list.pathFilter()); + // If currently viewing this list, redirect to lists page + if (currentPath.includes(list.id)) { + router.push("/dashboard/lists"); + } + }, + onError: (error) => { + toast({ + variant: "destructive", + description: error.message || t("common.something_went_wrong"), + }); + }, + }), + ); return ( <ActionConfirmingDialog diff --git a/apps/web/components/dashboard/lists/ListHeader.tsx b/apps/web/components/dashboard/lists/ListHeader.tsx index 8e014e2a..4176a80e 100644 --- a/apps/web/components/dashboard/lists/ListHeader.tsx +++ b/apps/web/components/dashboard/lists/ListHeader.tsx @@ -6,13 +6,14 @@ import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, - TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { UserAvatar } from "@/components/ui/user-avatar"; import { useTranslation } from "@/lib/i18n/client"; -import { MoreHorizontal, SearchIcon, Users } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { MoreHorizontal, SearchIcon } from "lucide-react"; -import { api } from "@karakeep/shared-react/trpc"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { parseSearchQuery } from "@karakeep/shared/searchQueryParser"; import { ZBookmarkList } from "@karakeep/shared/types/lists"; @@ -24,15 +25,30 @@ export default function ListHeader({ }: { initialData: ZBookmarkList; }) { + const api = useTRPC(); const { t } = useTranslation(); const router = useRouter(); - const { data: list, error } = api.lists.get.useQuery( - { - listId: initialData.id, - }, - { - initialData, - }, + const { data: list, error } = useQuery( + api.lists.get.queryOptions( + { + listId: initialData.id, + }, + { + initialData, + }, + ), + ); + + const { data: collaboratorsData } = useQuery( + api.lists.getCollaborators.queryOptions( + { + listId: initialData.id, + }, + { + refetchOnWindowFocus: false, + enabled: list.hasCollaborators, + }, + ), ); const parsedQuery = useMemo(() => { @@ -55,22 +71,44 @@ export default function ListHeader({ <span className="text-2xl"> {list.icon} {list.name} </span> - {list.hasCollaborators && ( - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Users className="size-5 text-primary" /> - </TooltipTrigger> - <TooltipContent> - <p>{t("lists.shared")}</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> + {list.hasCollaborators && collaboratorsData && ( + <div className="group flex"> + {collaboratorsData.owner && ( + <Tooltip> + <TooltipTrigger> + <div className="-mr-2 transition-all duration-300 ease-out group-hover:mr-1"> + <UserAvatar + name={collaboratorsData.owner.name} + image={collaboratorsData.owner.image} + className="size-5 shrink-0 rounded-full ring-2 ring-background" + /> + </div> + </TooltipTrigger> + <TooltipContent> + <p>{collaboratorsData.owner.name}</p> + </TooltipContent> + </Tooltip> + )} + {collaboratorsData.collaborators.map((collab) => ( + <Tooltip key={collab.userId}> + <TooltipTrigger> + <div className="-mr-2 transition-all duration-300 ease-out group-hover:mr-1"> + <UserAvatar + name={collab.user.name} + image={collab.user.image} + className="size-5 shrink-0 rounded-full ring-2 ring-background" + /> + </div> + </TooltipTrigger> + <TooltipContent> + <p>{collab.user.name}</p> + </TooltipContent> + </Tooltip> + ))} + </div> )} {list.description && ( - <span className="text-lg text-gray-400"> - {`(${list.description})`} - </span> + <span className="text-lg text-gray-400">{`(${list.description})`}</span> )} </div> <div className="flex items-center"> diff --git a/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx b/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx index 0a55c5fe..518e6440 100644 --- a/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx +++ b/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx @@ -22,11 +22,13 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; +import { UserAvatar } from "@/components/ui/user-avatar"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, Trash2, UserPlus, Users } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { ZBookmarkList } from "@karakeep/shared/types/lists"; export function ManageCollaboratorsModal({ @@ -42,6 +44,7 @@ export function ManageCollaboratorsModal({ children?: React.ReactNode; readOnly?: boolean; }) { + const api = useTRPC(); if ( (userOpen !== undefined && !userSetOpen) || (userOpen === undefined && userSetOpen) @@ -60,82 +63,102 @@ export function ManageCollaboratorsModal({ >("viewer"); const { t } = useTranslation(); - const utils = api.useUtils(); + const queryClient = useQueryClient(); const invalidateListCaches = () => Promise.all([ - utils.lists.getCollaborators.invalidate({ listId: list.id }), - utils.lists.get.invalidate({ listId: list.id }), - utils.lists.list.invalidate(), - utils.bookmarks.getBookmarks.invalidate({ listId: list.id }), + queryClient.invalidateQueries( + api.lists.getCollaborators.queryFilter({ listId: list.id }), + ), + queryClient.invalidateQueries( + api.lists.get.queryFilter({ listId: list.id }), + ), + queryClient.invalidateQueries(api.lists.list.pathFilter()), + queryClient.invalidateQueries( + api.bookmarks.getBookmarks.queryFilter({ listId: list.id }), + ), ]); // Fetch collaborators - const { data: collaboratorsData, isLoading } = - api.lists.getCollaborators.useQuery({ listId: list.id }, { enabled: open }); + const { data: collaboratorsData, isLoading } = useQuery( + api.lists.getCollaborators.queryOptions( + { listId: list.id }, + { enabled: open }, + ), + ); // Mutations - const addCollaborator = api.lists.addCollaborator.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.collaborators.invitation_sent"), - }); - setNewCollaboratorEmail(""); - await invalidateListCaches(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("lists.collaborators.failed_to_add"), - }); - }, - }); + const addCollaborator = useMutation( + api.lists.addCollaborator.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.collaborators.invitation_sent"), + }); + setNewCollaboratorEmail(""); + await invalidateListCaches(); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: error.message || t("lists.collaborators.failed_to_add"), + }); + }, + }), + ); - const removeCollaborator = api.lists.removeCollaborator.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.collaborators.removed"), - }); - await invalidateListCaches(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("lists.collaborators.failed_to_remove"), - }); - }, - }); + const removeCollaborator = useMutation( + api.lists.removeCollaborator.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.collaborators.removed"), + }); + await invalidateListCaches(); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: + error.message || t("lists.collaborators.failed_to_remove"), + }); + }, + }), + ); - const updateCollaboratorRole = api.lists.updateCollaboratorRole.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.collaborators.role_updated"), - }); - await invalidateListCaches(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: - error.message || t("lists.collaborators.failed_to_update_role"), - }); - }, - }); + const updateCollaboratorRole = useMutation( + api.lists.updateCollaboratorRole.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.collaborators.role_updated"), + }); + await invalidateListCaches(); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: + error.message || t("lists.collaborators.failed_to_update_role"), + }); + }, + }), + ); - const revokeInvitation = api.lists.revokeInvitation.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.collaborators.invitation_revoked"), - }); - await invalidateListCaches(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("lists.collaborators.failed_to_revoke"), - }); - }, - }); + const revokeInvitation = useMutation( + api.lists.revokeInvitation.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.collaborators.invitation_revoked"), + }); + await invalidateListCaches(); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: + error.message || t("lists.collaborators.failed_to_revoke"), + }); + }, + }), + ); const handleAddCollaborator = () => { if (!newCollaboratorEmail.trim()) { @@ -256,15 +279,22 @@ export function ManageCollaboratorsModal({ key={`owner-${collaboratorsData.owner.id}`} className="flex items-center justify-between rounded-lg border p-3" > - <div className="flex-1"> - <div className="font-medium"> - {collaboratorsData.owner.name} - </div> - {collaboratorsData.owner.email && ( - <div className="text-sm text-muted-foreground"> - {collaboratorsData.owner.email} + <div className="flex flex-1 items-center gap-3"> + <UserAvatar + name={collaboratorsData.owner.name} + image={collaboratorsData.owner.image} + className="size-10 ring-1 ring-border" + /> + <div className="flex-1"> + <div className="font-medium"> + {collaboratorsData.owner.name} </div> - )} + {collaboratorsData.owner.email && ( + <div className="text-sm text-muted-foreground"> + {collaboratorsData.owner.email} + </div> + )} + </div> </div> <div className="text-sm capitalize text-muted-foreground"> {t("lists.collaborators.owner")} @@ -278,27 +308,34 @@ export function ManageCollaboratorsModal({ key={collaborator.id} className="flex items-center justify-between rounded-lg border p-3" > - <div className="flex-1"> - <div className="flex items-center gap-2"> - <div className="font-medium"> - {collaborator.user.name} + <div className="flex flex-1 items-center gap-3"> + <UserAvatar + name={collaborator.user.name} + image={collaborator.user.image} + className="size-10 ring-1 ring-border" + /> + <div className="flex-1"> + <div className="flex items-center gap-2"> + <div className="font-medium"> + {collaborator.user.name} + </div> + {collaborator.status === "pending" && ( + <Badge variant="outline" className="text-xs"> + {t("lists.collaborators.pending")} + </Badge> + )} + {collaborator.status === "declined" && ( + <Badge variant="destructive" className="text-xs"> + {t("lists.collaborators.declined")} + </Badge> + )} </div> - {collaborator.status === "pending" && ( - <Badge variant="outline" className="text-xs"> - {t("lists.collaborators.pending")} - </Badge> - )} - {collaborator.status === "declined" && ( - <Badge variant="destructive" className="text-xs"> - {t("lists.collaborators.declined")} - </Badge> + {collaborator.user.email && ( + <div className="text-sm text-muted-foreground"> + {collaborator.user.email} + </div> )} </div> - {collaborator.user.email && ( - <div className="text-sm text-muted-foreground"> - {collaborator.user.email} - </div> - )} </div> {readOnly ? ( <div className="text-sm capitalize text-muted-foreground"> diff --git a/apps/web/components/dashboard/lists/MergeListModal.tsx b/apps/web/components/dashboard/lists/MergeListModal.tsx index 0b7d362a..b22cd1a2 100644 --- a/apps/web/components/dashboard/lists/MergeListModal.tsx +++ b/apps/web/components/dashboard/lists/MergeListModal.tsx @@ -19,8 +19,8 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/sonner"; import { Switch } from "@/components/ui/switch"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { X } from "lucide-react"; diff --git a/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx b/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx index c453a91f..7c13dbeb 100644 --- a/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx +++ b/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx @@ -8,11 +8,13 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Loader2, Mail, X } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + interface Invitation { id: string; role: string; @@ -27,41 +29,51 @@ interface Invitation { } function InvitationRow({ invitation }: { invitation: Invitation }) { + const api = useTRPC(); const { t } = useTranslation(); - const utils = api.useUtils(); + const queryClient = useQueryClient(); - const acceptInvitation = api.lists.acceptInvitation.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.invitations.accepted"), - }); - await Promise.all([ - utils.lists.getPendingInvitations.invalidate(), - utils.lists.list.invalidate(), - ]); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("lists.invitations.failed_to_accept"), - }); - }, - }); + const acceptInvitation = useMutation( + api.lists.acceptInvitation.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.invitations.accepted"), + }); + await Promise.all([ + queryClient.invalidateQueries( + api.lists.getPendingInvitations.pathFilter(), + ), + queryClient.invalidateQueries(api.lists.list.pathFilter()), + ]); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: error.message || t("lists.invitations.failed_to_accept"), + }); + }, + }), + ); - const declineInvitation = api.lists.declineInvitation.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.invitations.declined"), - }); - await utils.lists.getPendingInvitations.invalidate(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("lists.invitations.failed_to_decline"), - }); - }, - }); + const declineInvitation = useMutation( + api.lists.declineInvitation.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.invitations.declined"), + }); + await queryClient.invalidateQueries( + api.lists.getPendingInvitations.pathFilter(), + ); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: + error.message || t("lists.invitations.failed_to_decline"), + }); + }, + }), + ); return ( <div className="flex items-center justify-between rounded-lg border p-4"> @@ -126,10 +138,12 @@ function InvitationRow({ invitation }: { invitation: Invitation }) { } export function PendingInvitationsCard() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: invitations, isLoading } = - api.lists.getPendingInvitations.useQuery(); + const { data: invitations, isLoading } = useQuery( + api.lists.getPendingInvitations.queryOptions(), + ); if (isLoading) { return null; @@ -142,9 +156,13 @@ export function PendingInvitationsCard() { return ( <Card> <CardHeader> - <CardTitle className="flex items-center gap-2"> + <CardTitle className="flex items-center gap-2 font-normal"> <Mail className="h-5 w-5" /> - {t("lists.invitations.pending")} ({invitations.length}) + {t("lists.invitations.pending")} + + <span className="rounded bg-secondary p-1 text-sm text-secondary-foreground"> + {invitations.length} + </span> </CardTitle> <CardDescription>{t("lists.invitations.description")}</CardDescription> </CardHeader> diff --git a/apps/web/components/dashboard/lists/RssLink.tsx b/apps/web/components/dashboard/lists/RssLink.tsx index 1be48681..2ac53c93 100644 --- a/apps/web/components/dashboard/lists/RssLink.tsx +++ b/apps/web/components/dashboard/lists/RssLink.tsx @@ -7,29 +7,39 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { useClientConfig } from "@/lib/clientConfig"; -import { api } from "@/lib/trpc"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, RotateCcw } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + export default function RssLink({ listId }: { listId: string }) { + const api = useTRPC(); const { t } = useTranslation(); const clientConfig = useClientConfig(); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); - const { mutate: regenRssToken, isPending: isRegenPending } = - api.lists.regenRssToken.useMutation({ + const { mutate: regenRssToken, isPending: isRegenPending } = useMutation( + api.lists.regenRssToken.mutationOptions({ onSuccess: () => { - apiUtils.lists.getRssToken.invalidate({ listId }); + queryClient.invalidateQueries( + api.lists.getRssToken.queryFilter({ listId }), + ); }, - }); - const { mutate: clearRssToken, isPending: isClearPending } = - api.lists.clearRssToken.useMutation({ + }), + ); + const { mutate: clearRssToken, isPending: isClearPending } = useMutation( + api.lists.clearRssToken.mutationOptions({ onSuccess: () => { - apiUtils.lists.getRssToken.invalidate({ listId }); + queryClient.invalidateQueries( + api.lists.getRssToken.queryFilter({ listId }), + ); }, - }); - const { data: rssToken, isLoading: isTokenLoading } = - api.lists.getRssToken.useQuery({ listId }); + }), + ); + const { data: rssToken, isLoading: isTokenLoading } = useQuery( + api.lists.getRssToken.queryOptions({ listId }), + ); const rssUrl = useMemo(() => { if (!rssToken || !rssToken.token) { diff --git a/apps/web/components/dashboard/preview/ActionBar.tsx b/apps/web/components/dashboard/preview/ActionBar.tsx index 6e4cd5a2..9603465e 100644 --- a/apps/web/components/dashboard/preview/ActionBar.tsx +++ b/apps/web/components/dashboard/preview/ActionBar.tsx @@ -1,12 +1,12 @@ import { useState } from "react"; import { ActionButton } from "@/components/ui/action-button"; import { Button } from "@/components/ui/button"; +import { toast } from "@/components/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; import { Pencil, Trash2 } from "lucide-react"; diff --git a/apps/web/components/dashboard/preview/AttachmentBox.tsx b/apps/web/components/dashboard/preview/AttachmentBox.tsx index 73eea640..654f3211 100644 --- a/apps/web/components/dashboard/preview/AttachmentBox.tsx +++ b/apps/web/components/dashboard/preview/AttachmentBox.tsx @@ -8,7 +8,7 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import FilePickerButton from "@/components/ui/file-picker-button"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { ASSET_TYPE_TO_ICON } from "@/lib/attachments"; import useUpload from "@/lib/hooks/upload-file"; import { useTranslation } from "@/lib/i18n/client"; diff --git a/apps/web/components/dashboard/preview/BookmarkPreview.tsx b/apps/web/components/dashboard/preview/BookmarkPreview.tsx index 7e6bf814..719cdff8 100644 --- a/apps/web/components/dashboard/preview/BookmarkPreview.tsx +++ b/apps/web/components/dashboard/preview/BookmarkPreview.tsx @@ -13,12 +13,13 @@ import { TooltipPortal, TooltipTrigger, } from "@/components/ui/tooltip"; +import { useSession } from "@/lib/auth/client"; import useRelativeTime from "@/lib/hooks/relative-time"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; import { Building, CalendarDays, ExternalLink, User } from "lucide-react"; -import { useSession } from "next-auth/react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; import { getBookmarkRefreshInterval, @@ -116,24 +117,27 @@ export default function BookmarkPreview({ bookmarkId: string; initialData?: ZBookmark; }) { + const api = useTRPC(); const { t } = useTranslation(); const [activeTab, setActiveTab] = useState<string>("content"); const { data: session } = useSession(); - const { data: bookmark } = api.bookmarks.getBookmark.useQuery( - { - bookmarkId, - }, - { - initialData, - refetchInterval: (query) => { - const data = query.state.data; - if (!data) { - return false; - } - return getBookmarkRefreshInterval(data); + const { data: bookmark } = useQuery( + api.bookmarks.getBookmark.queryOptions( + { + bookmarkId, }, - }, + { + initialData, + refetchInterval: (query) => { + const data = query.state.data; + if (!data) { + return false; + } + return getBookmarkRefreshInterval(data); + }, + }, + ), ); if (!bookmark) { diff --git a/apps/web/components/dashboard/preview/HighlightsBox.tsx b/apps/web/components/dashboard/preview/HighlightsBox.tsx index 41ab7d74..e8503fd9 100644 --- a/apps/web/components/dashboard/preview/HighlightsBox.tsx +++ b/apps/web/components/dashboard/preview/HighlightsBox.tsx @@ -5,10 +5,12 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { Separator } from "@radix-ui/react-dropdown-menu"; +import { useQuery } from "@tanstack/react-query"; import { ChevronsDownUp } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import HighlightCard from "../highlights/HighlightCard"; export default function HighlightsBox({ @@ -18,10 +20,12 @@ export default function HighlightsBox({ bookmarkId: string; readOnly: boolean; }) { + const api = useTRPC(); const { t } = useTranslation(); - const { data: highlights, isPending: isLoading } = - api.highlights.getForBookmark.useQuery({ bookmarkId }); + const { data: highlights, isPending: isLoading } = useQuery( + api.highlights.getForBookmark.queryOptions({ bookmarkId }), + ); if (isLoading || !highlights || highlights?.highlights.length === 0) { return null; diff --git a/apps/web/components/dashboard/preview/LinkContentSection.tsx b/apps/web/components/dashboard/preview/LinkContentSection.tsx index 64b62df6..f4e344ac 100644 --- a/apps/web/components/dashboard/preview/LinkContentSection.tsx +++ b/apps/web/components/dashboard/preview/LinkContentSection.tsx @@ -16,16 +16,19 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { useTranslation } from "@/lib/i18n/client"; +import { useSession } from "@/lib/auth/client"; +import { Trans, useTranslation } from "@/lib/i18n/client"; +import { useReaderSettings } from "@/lib/readerSettings"; import { AlertTriangle, Archive, BookOpen, Camera, ExpandIcon, + FileText, + Info, Video, } from "lucide-react"; -import { useSession } from "next-auth/react"; import { useQueryState } from "nuqs"; import { ErrorBoundary } from "react-error-boundary"; @@ -34,8 +37,10 @@ import { ZBookmark, ZBookmarkedLink, } from "@karakeep/shared/types/bookmarks"; +import { READER_FONT_FAMILIES } from "@karakeep/shared/types/readers"; import { contentRendererRegistry } from "./content-renderers"; +import ReaderSettingsPopover from "./ReaderSettingsPopover"; import ReaderView from "./ReaderView"; function CustomRendererErrorFallback({ error }: { error: Error }) { @@ -100,12 +105,23 @@ function VideoSection({ link }: { link: ZBookmarkedLink }) { ); } +function PDFSection({ link }: { link: ZBookmarkedLink }) { + return ( + <iframe + title="PDF Viewer" + src={`/api/assets/${link.pdfAssetId}`} + className="relative h-full min-w-full" + /> + ); +} + export default function LinkContentSection({ bookmark, }: { bookmark: ZBookmark; }) { const { t } = useTranslation(); + const { settings } = useReaderSettings(); const availableRenderers = contentRendererRegistry.getRenderers(bookmark); const defaultSection = availableRenderers.length > 0 ? availableRenderers[0].id : "cached"; @@ -135,6 +151,11 @@ export default function LinkContentSection({ <ScrollArea className="h-full"> <ReaderView className="prose mx-auto dark:prose-invert" + style={{ + fontFamily: READER_FONT_FAMILIES[settings.fontFamily], + fontSize: `${settings.fontSize}px`, + lineHeight: settings.lineHeight, + }} bookmarkId={bookmark.id} readOnly={!isOwner} /> @@ -144,6 +165,8 @@ export default function LinkContentSection({ content = <FullPageArchiveSection link={bookmark.content} />; } else if (section === "video") { content = <VideoSection link={bookmark.content} />; + } else if (section === "pdf") { + content = <PDFSection link={bookmark.content} />; } else { content = <ScreenshotSection link={bookmark.content} />; } @@ -188,6 +211,12 @@ export default function LinkContentSection({ {t("common.screenshot")} </div> </SelectItem> + <SelectItem value="pdf" disabled={!bookmark.content.pdfAssetId}> + <div className="flex items-center"> + <FileText className="mr-2 h-4 w-4" /> + {t("common.pdf")} + </div> + </SelectItem> <SelectItem value="archive" disabled={ @@ -213,16 +242,47 @@ export default function LinkContentSection({ </SelectContent> </Select> {section === "cached" && ( + <> + <ReaderSettingsPopover /> + <Tooltip> + <TooltipTrigger> + <Link + href={`/reader/${bookmark.id}`} + className={buttonVariants({ variant: "outline" })} + > + <ExpandIcon className="h-4 w-4" /> + </Link> + </TooltipTrigger> + <TooltipContent side="bottom">FullScreen</TooltipContent> + </Tooltip> + </> + )} + {section === "archive" && ( <Tooltip> - <TooltipTrigger> - <Link - href={`/reader/${bookmark.id}`} - className={buttonVariants({ variant: "outline" })} - > - <ExpandIcon className="h-4 w-4" /> - </Link> + <TooltipTrigger asChild> + <div className="flex h-10 items-center gap-1 rounded-md border border-blue-500/50 bg-blue-50 px-3 text-blue-700 dark:bg-blue-950 dark:text-blue-300"> + <Info className="h-4 w-4" /> + </div> </TooltipTrigger> - <TooltipContent side="bottom">FullScreen</TooltipContent> + <TooltipContent side="bottom" className="max-w-sm"> + <p className="text-sm"> + <Trans + i18nKey="preview.archive_info" + components={{ + 1: ( + <Link + prefetch={false} + href={`/api/assets/${bookmark.content.fullPageArchiveAssetId ?? bookmark.content.precrawledArchiveAssetId}`} + download + className="font-medium underline" + > + link + </Link> + ), + }} + /> + </p> + </TooltipContent> </Tooltip> )} </div> diff --git a/apps/web/components/dashboard/preview/NoteEditor.tsx b/apps/web/components/dashboard/preview/NoteEditor.tsx index 538aff2e..86807569 100644 --- a/apps/web/components/dashboard/preview/NoteEditor.tsx +++ b/apps/web/components/dashboard/preview/NoteEditor.tsx @@ -1,5 +1,5 @@ +import { toast } from "@/components/ui/sonner"; import { Textarea } from "@/components/ui/textarea"; -import { toast } from "@/components/ui/use-toast"; import { useClientConfig } from "@/lib/clientConfig"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; diff --git a/apps/web/components/dashboard/preview/ReaderSettingsPopover.tsx b/apps/web/components/dashboard/preview/ReaderSettingsPopover.tsx new file mode 100644 index 00000000..f37b8263 --- /dev/null +++ b/apps/web/components/dashboard/preview/ReaderSettingsPopover.tsx @@ -0,0 +1,457 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Slider } from "@/components/ui/slider"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useTranslation } from "@/lib/i18n/client"; +import { useReaderSettings } from "@/lib/readerSettings"; +import { + Globe, + Laptop, + Minus, + Plus, + RotateCcw, + Settings, + Type, + X, +} from "lucide-react"; + +import { + formatFontSize, + formatLineHeight, + READER_DEFAULTS, + READER_SETTING_CONSTRAINTS, +} from "@karakeep/shared/types/readers"; + +interface ReaderSettingsPopoverProps { + open?: boolean; + onOpenChange?: (open: boolean) => void; + variant?: "outline" | "ghost"; +} + +export default function ReaderSettingsPopover({ + open, + onOpenChange, + variant = "outline", +}: ReaderSettingsPopoverProps) { + const { t } = useTranslation(); + const { + settings, + serverSettings, + localOverrides, + sessionOverrides, + hasSessionChanges, + hasLocalOverrides, + isSaving, + updateSession, + clearSession, + saveToDevice, + clearLocalOverride, + saveToServer, + } = useReaderSettings(); + + // Helper to get the effective server value (server setting or default) + const getServerValue = <K extends keyof typeof serverSettings>(key: K) => { + return serverSettings[key] ?? READER_DEFAULTS[key]; + }; + + // Helper to check if a setting has a local override + const hasLocalOverride = (key: keyof typeof localOverrides) => { + return localOverrides[key] !== undefined; + }; + + // Build tooltip message for the settings button + const getSettingsTooltip = () => { + if (hasSessionChanges && hasLocalOverrides) { + return t("settings.info.reader_settings.tooltip_preview_and_local"); + } + if (hasSessionChanges) { + return t("settings.info.reader_settings.tooltip_preview"); + } + if (hasLocalOverrides) { + return t("settings.info.reader_settings.tooltip_local"); + } + return t("settings.info.reader_settings.tooltip_default"); + }; + + return ( + <Popover open={open} onOpenChange={onOpenChange}> + <Tooltip> + <TooltipTrigger asChild> + <PopoverTrigger asChild> + <Button variant={variant} size="icon" className="relative"> + <Settings className="h-4 w-4" /> + {(hasSessionChanges || hasLocalOverrides) && ( + <span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-primary" /> + )} + </Button> + </PopoverTrigger> + </TooltipTrigger> + <TooltipContent side="bottom"> + <p>{getSettingsTooltip()}</p> + </TooltipContent> + </Tooltip> + <PopoverContent + side="bottom" + align="center" + collisionPadding={32} + className="flex w-80 flex-col overflow-hidden p-0" + style={{ + maxHeight: "var(--radix-popover-content-available-height)", + }} + > + <div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4"> + <div className="flex items-center justify-between pb-2"> + <div className="flex items-center gap-2"> + <Type className="h-4 w-4" /> + <h3 className="font-semibold"> + {t("settings.info.reader_settings.title")} + </h3> + </div> + {hasSessionChanges && ( + <span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary"> + {t("settings.info.reader_settings.preview")} + </span> + )} + </div> + + <div className="space-y-4"> + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <label className="text-sm font-medium"> + {t("settings.info.reader_settings.font_family")} + </label> + <div className="flex items-center gap-1"> + {sessionOverrides.fontFamily !== undefined && ( + <span className="text-xs text-muted-foreground"> + {t("settings.info.reader_settings.preview_inline")} + </span> + )} + {hasLocalOverride("fontFamily") && + sessionOverrides.fontFamily === undefined && ( + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="h-5 w-5 text-muted-foreground hover:text-foreground" + onClick={() => clearLocalOverride("fontFamily")} + > + <X className="h-3 w-3" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p> + {t( + "settings.info.reader_settings.clear_override_hint", + { + value: t( + `settings.info.reader_settings.${getServerValue("fontFamily")}` as const, + ), + }, + )} + </p> + </TooltipContent> + </Tooltip> + )} + </div> + </div> + <Select + value={settings.fontFamily} + onValueChange={(value) => + updateSession({ + fontFamily: value as "serif" | "sans" | "mono", + }) + } + > + <SelectTrigger + className={ + hasLocalOverride("fontFamily") && + sessionOverrides.fontFamily === undefined + ? "border-primary/50" + : "" + } + > + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="serif"> + {t("settings.info.reader_settings.serif")} + </SelectItem> + <SelectItem value="sans"> + {t("settings.info.reader_settings.sans")} + </SelectItem> + <SelectItem value="mono"> + {t("settings.info.reader_settings.mono")} + </SelectItem> + </SelectContent> + </Select> + </div> + + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <label className="text-sm font-medium"> + {t("settings.info.reader_settings.font_size")} + </label> + <div className="flex items-center gap-1"> + <span className="text-sm text-muted-foreground"> + {formatFontSize(settings.fontSize)} + {sessionOverrides.fontSize !== undefined && ( + <span className="ml-1 text-xs"> + {t("settings.info.reader_settings.preview_inline")} + </span> + )} + </span> + {hasLocalOverride("fontSize") && + sessionOverrides.fontSize === undefined && ( + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="h-5 w-5 text-muted-foreground hover:text-foreground" + onClick={() => clearLocalOverride("fontSize")} + > + <X className="h-3 w-3" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p> + {t( + "settings.info.reader_settings.clear_override_hint", + { + value: formatFontSize( + getServerValue("fontSize"), + ), + }, + )} + </p> + </TooltipContent> + </Tooltip> + )} + </div> + </div> + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="icon" + className="h-7 w-7 bg-transparent" + onClick={() => + updateSession({ + fontSize: Math.max( + READER_SETTING_CONSTRAINTS.fontSize.min, + settings.fontSize - + READER_SETTING_CONSTRAINTS.fontSize.step, + ), + }) + } + > + <Minus className="h-3 w-3" /> + </Button> + <Slider + value={[settings.fontSize]} + onValueChange={([value]) => + updateSession({ fontSize: value }) + } + max={READER_SETTING_CONSTRAINTS.fontSize.max} + min={READER_SETTING_CONSTRAINTS.fontSize.min} + step={READER_SETTING_CONSTRAINTS.fontSize.step} + className={`flex-1 ${ + hasLocalOverride("fontSize") && + sessionOverrides.fontSize === undefined + ? "[&_[role=slider]]:border-primary/50" + : "" + }`} + /> + <Button + variant="outline" + size="icon" + className="h-7 w-7 bg-transparent" + onClick={() => + updateSession({ + fontSize: Math.min( + READER_SETTING_CONSTRAINTS.fontSize.max, + settings.fontSize + + READER_SETTING_CONSTRAINTS.fontSize.step, + ), + }) + } + > + <Plus className="h-3 w-3" /> + </Button> + </div> + </div> + + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <label className="text-sm font-medium"> + {t("settings.info.reader_settings.line_height")} + </label> + <div className="flex items-center gap-1"> + <span className="text-sm text-muted-foreground"> + {formatLineHeight(settings.lineHeight)} + {sessionOverrides.lineHeight !== undefined && ( + <span className="ml-1 text-xs"> + {t("settings.info.reader_settings.preview_inline")} + </span> + )} + </span> + {hasLocalOverride("lineHeight") && + sessionOverrides.lineHeight === undefined && ( + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="h-5 w-5 text-muted-foreground hover:text-foreground" + onClick={() => clearLocalOverride("lineHeight")} + > + <X className="h-3 w-3" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p> + {t( + "settings.info.reader_settings.clear_override_hint", + { + value: formatLineHeight( + getServerValue("lineHeight"), + ), + }, + )} + </p> + </TooltipContent> + </Tooltip> + )} + </div> + </div> + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="icon" + className="h-7 w-7 bg-transparent" + onClick={() => + updateSession({ + lineHeight: Math.max( + READER_SETTING_CONSTRAINTS.lineHeight.min, + Math.round( + (settings.lineHeight - + READER_SETTING_CONSTRAINTS.lineHeight.step) * + 10, + ) / 10, + ), + }) + } + > + <Minus className="h-3 w-3" /> + </Button> + <Slider + value={[settings.lineHeight]} + onValueChange={([value]) => + updateSession({ lineHeight: value }) + } + max={READER_SETTING_CONSTRAINTS.lineHeight.max} + min={READER_SETTING_CONSTRAINTS.lineHeight.min} + step={READER_SETTING_CONSTRAINTS.lineHeight.step} + className={`flex-1 ${ + hasLocalOverride("lineHeight") && + sessionOverrides.lineHeight === undefined + ? "[&_[role=slider]]:border-primary/50" + : "" + }`} + /> + <Button + variant="outline" + size="icon" + className="h-7 w-7 bg-transparent" + onClick={() => + updateSession({ + lineHeight: Math.min( + READER_SETTING_CONSTRAINTS.lineHeight.max, + Math.round( + (settings.lineHeight + + READER_SETTING_CONSTRAINTS.lineHeight.step) * + 10, + ) / 10, + ), + }) + } + > + <Plus className="h-3 w-3" /> + </Button> + </div> + </div> + + {hasSessionChanges && ( + <> + <Separator /> + + <div className="space-y-2"> + <Button + variant="outline" + size="sm" + className="w-full" + onClick={() => clearSession()} + > + <RotateCcw className="mr-2 h-4 w-4" /> + {t("settings.info.reader_settings.reset_preview")} + </Button> + + <div className="flex gap-2"> + <Button + variant="outline" + size="sm" + className="flex-1" + disabled={isSaving} + onClick={() => saveToDevice()} + > + <Laptop className="mr-2 h-4 w-4" /> + {t("settings.info.reader_settings.save_to_device")} + </Button> + <Button + variant="default" + size="sm" + className="flex-1" + disabled={isSaving} + onClick={() => saveToServer()} + > + <Globe className="mr-2 h-4 w-4" /> + {t("settings.info.reader_settings.save_to_all_devices")} + </Button> + </div> + + <p className="text-center text-xs text-muted-foreground"> + {t("settings.info.reader_settings.save_hint")} + </p> + </div> + </> + )} + + {!hasSessionChanges && ( + <p className="text-center text-xs text-muted-foreground"> + {t("settings.info.reader_settings.adjust_hint")} + </p> + )} + </div> + </div> + </PopoverContent> + </Popover> + ); +} diff --git a/apps/web/components/dashboard/preview/ReaderView.tsx b/apps/web/components/dashboard/preview/ReaderView.tsx index f2f843ee..76070534 100644 --- a/apps/web/components/dashboard/preview/ReaderView.tsx +++ b/apps/web/components/dashboard/preview/ReaderView.tsx @@ -1,12 +1,15 @@ import { FullPageSpinner } from "@/components/ui/full-page-spinner"; -import { toast } from "@/components/ui/use-toast"; -import { api } from "@/lib/trpc"; +import { toast } from "@/components/ui/sonner"; +import { useTranslation } from "@/lib/i18n/client"; +import { useQuery } from "@tanstack/react-query"; +import { FileX } from "lucide-react"; import { useCreateHighlight, useDeleteHighlight, useUpdateHighlight, } from "@karakeep/shared-react/hooks/highlights"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; import BookmarkHTMLHighlighter from "./BookmarkHtmlHighlighter"; @@ -22,11 +25,15 @@ export default function ReaderView({ style?: React.CSSProperties; readOnly: boolean; }) { - const { data: highlights } = api.highlights.getForBookmark.useQuery({ - bookmarkId, - }); - const { data: cachedContent, isPending: isCachedContentLoading } = - api.bookmarks.getBookmark.useQuery( + const { t } = useTranslation(); + const api = useTRPC(); + const { data: highlights } = useQuery( + api.highlights.getForBookmark.queryOptions({ + bookmarkId, + }), + ); + const { data: cachedContent, isPending: isCachedContentLoading } = useQuery( + api.bookmarks.getBookmark.queryOptions( { bookmarkId, includeContent: true, @@ -37,7 +44,8 @@ export default function ReaderView({ ? data.content.htmlContent : null, }, - ); + ), + ); const { mutate: createHighlight } = useCreateHighlight({ onSuccess: () => { @@ -86,7 +94,23 @@ export default function ReaderView({ content = <FullPageSpinner />; } else if (!cachedContent) { content = ( - <div className="text-destructive">Failed to fetch link content ...</div> + <div className="flex h-full w-full items-center justify-center p-4"> + <div className="max-w-sm space-y-4 text-center"> + <div className="flex justify-center"> + <div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted"> + <FileX className="h-8 w-8 text-muted-foreground" /> + </div> + </div> + <div className="space-y-2"> + <h3 className="text-lg font-medium text-foreground"> + {t("preview.fetch_error_title")} + </h3> + <p className="text-sm leading-relaxed text-muted-foreground"> + {t("preview.fetch_error_description")} + </p> + </div> + </div> + </div> ); } else { content = ( diff --git a/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx b/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx index 8faca013..28bf690d 100644 --- a/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx +++ b/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx @@ -19,6 +19,7 @@ import { ChevronDown, ChevronRight, FileType, + Heading, Link, PlusCircle, Rss, @@ -28,7 +29,10 @@ import { } from "lucide-react"; import { useTranslation } from "react-i18next"; -import type { RuleEngineCondition } from "@karakeep/shared/types/rules"; +import type { + RuleEngineCondition, + RuleEngineEvent, +} from "@karakeep/shared/types/rules"; import { FeedSelector } from "../feeds/FeedSelector"; import { TagAutocomplete } from "../tags/TagAutocomplete"; @@ -36,6 +40,7 @@ import { TagAutocomplete } from "../tags/TagAutocomplete"; interface ConditionBuilderProps { value: RuleEngineCondition; onChange: (condition: RuleEngineCondition) => void; + eventType: RuleEngineEvent["type"]; level?: number; onRemove?: () => void; } @@ -43,6 +48,7 @@ interface ConditionBuilderProps { export function ConditionBuilder({ value, onChange, + eventType, level = 0, onRemove, }: ConditionBuilderProps) { @@ -54,6 +60,15 @@ export function ConditionBuilder({ case "urlContains": onChange({ type: "urlContains", str: "" }); break; + case "urlDoesNotContain": + onChange({ type: "urlDoesNotContain", str: "" }); + break; + case "titleContains": + onChange({ type: "titleContains", str: "" }); + break; + case "titleDoesNotContain": + onChange({ type: "titleDoesNotContain", str: "" }); + break; case "importedFromFeed": onChange({ type: "importedFromFeed", feedId: "" }); break; @@ -88,7 +103,11 @@ export function ConditionBuilder({ const renderConditionIcon = (type: RuleEngineCondition["type"]) => { switch (type) { case "urlContains": + case "urlDoesNotContain": return <Link className="h-4 w-4" />; + case "titleContains": + case "titleDoesNotContain": + return <Heading className="h-4 w-4" />; case "importedFromFeed": return <Rss className="h-4 w-4" />; case "bookmarkTypeIs": @@ -118,6 +137,42 @@ export function ConditionBuilder({ </div> ); + case "urlDoesNotContain": + return ( + <div className="mt-2"> + <Input + value={value.str} + onChange={(e) => onChange({ ...value, str: e.target.value })} + placeholder="URL does not contain..." + className="w-full" + /> + </div> + ); + + case "titleContains": + return ( + <div className="mt-2"> + <Input + value={value.str} + onChange={(e) => onChange({ ...value, str: e.target.value })} + placeholder="Title contains..." + className="w-full" + /> + </div> + ); + + case "titleDoesNotContain": + return ( + <div className="mt-2"> + <Input + value={value.str} + onChange={(e) => onChange({ ...value, str: e.target.value })} + placeholder="Title does not contain..." + className="w-full" + /> + </div> + ); + case "importedFromFeed": return ( <div className="mt-2"> @@ -182,6 +237,7 @@ export function ConditionBuilder({ newConditions[index] = newCondition; onChange({ ...value, conditions: newConditions }); }} + eventType={eventType} level={level + 1} onRemove={() => { const newConditions = [...value.conditions]; @@ -217,6 +273,10 @@ export function ConditionBuilder({ } }; + // Title conditions are hidden for "bookmarkAdded" event because + // titles are not available at bookmark creation time (they're fetched during crawling) + const showTitleConditions = eventType !== "bookmarkAdded"; + const ConditionSelector = () => ( <Select value={value.type} onValueChange={handleTypeChange}> <SelectTrigger className="ml-2 h-8 border-none bg-transparent px-2"> @@ -235,6 +295,19 @@ export function ConditionBuilder({ <SelectItem value="urlContains"> {t("settings.rules.conditions_types.url_contains")} </SelectItem> + <SelectItem value="urlDoesNotContain"> + {t("settings.rules.conditions_types.url_does_not_contain")} + </SelectItem> + {showTitleConditions && ( + <SelectItem value="titleContains"> + {t("settings.rules.conditions_types.title_contains")} + </SelectItem> + )} + {showTitleConditions && ( + <SelectItem value="titleDoesNotContain"> + {t("settings.rules.conditions_types.title_does_not_contain")} + </SelectItem> + )} <SelectItem value="importedFromFeed"> {t("settings.rules.conditions_types.imported_from_feed")} </SelectItem> diff --git a/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx b/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx index da10317a..e4859b4a 100644 --- a/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx +++ b/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx @@ -8,8 +8,8 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { toast } from "@/components/ui/sonner"; import { Textarea } from "@/components/ui/textarea"; -import { toast } from "@/components/ui/use-toast"; import { Save, X } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -175,6 +175,7 @@ export function RuleEditor({ rule, onCancel }: RuleEditorProps) { <ConditionBuilder value={editedRule.condition} onChange={handleConditionChange} + eventType={editedRule.event.type} /> </div> diff --git a/apps/web/components/dashboard/rules/RuleEngineRuleList.tsx b/apps/web/components/dashboard/rules/RuleEngineRuleList.tsx index 206a3550..32262b31 100644 --- a/apps/web/components/dashboard/rules/RuleEngineRuleList.tsx +++ b/apps/web/components/dashboard/rules/RuleEngineRuleList.tsx @@ -2,8 +2,8 @@ import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { toast } from "@/components/ui/sonner"; import { Switch } from "@/components/ui/switch"; -import { toast } from "@/components/ui/use-toast"; import { useClientConfig } from "@/lib/clientConfig"; import { Edit, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx index 15facb2d..4d3a690b 100644 --- a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx +++ b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx @@ -208,6 +208,17 @@ export default function QueryExplainerTooltip({ </TableCell> </TableRow> ); + case "source": + return ( + <TableRow> + <TableCell> + {matcher.inverse + ? t("search.is_not_from_source") + : t("search.is_from_source")} + </TableCell> + <TableCell>{matcher.source}</TableCell> + </TableRow> + ); default: { const _exhaustiveCheck: never = matcher; return null; diff --git a/apps/web/components/dashboard/search/useSearchAutocomplete.ts b/apps/web/components/dashboard/search/useSearchAutocomplete.ts index ba55d51f..c72f4fc5 100644 --- a/apps/web/components/dashboard/search/useSearchAutocomplete.ts +++ b/apps/web/components/dashboard/search/useSearchAutocomplete.ts @@ -2,8 +2,9 @@ import type translation from "@/lib/i18n/locales/en/translation.json"; import type { TFunction } from "i18next"; import type { LucideIcon } from "lucide-react"; import { useCallback, useMemo } from "react"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; import { + Globe, History, ListTree, RssIcon, @@ -14,6 +15,8 @@ import { import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists"; import { useTagAutocomplete } from "@karakeep/shared-react/hooks/tags"; import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; +import { useTRPC } from "@karakeep/shared-react/trpc"; +import { zBookmarkSourceSchema } from "@karakeep/shared/types/bookmarks"; const MAX_DISPLAY_SUGGESTIONS = 5; @@ -97,10 +100,14 @@ const QUALIFIER_DEFINITIONS = [ value: "age:", descriptionKey: "search.created_within", }, + { + value: "source:", + descriptionKey: "search.is_from_source", + }, ] satisfies ReadonlyArray<QualifierDefinition>; export interface AutocompleteSuggestionItem { - type: "token" | "tag" | "list" | "feed"; + type: "token" | "tag" | "list" | "feed" | "source"; id: string; label: string; insertText: string; @@ -263,6 +270,7 @@ const useTagSuggestions = ( const { data: tagResults } = useTagAutocomplete({ nameContains: debouncedTagSearchTerm, select: (data) => data.tags, + enabled: parsed.activeToken.length > 0, }); const tagSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => { @@ -292,6 +300,7 @@ const useTagSuggestions = ( const useFeedSuggestions = ( parsed: ParsedSearchState, ): AutocompleteSuggestionItem[] => { + const api = useTRPC(); const shouldSuggestFeeds = parsed.normalizedTokenWithoutMinus.startsWith("feed:"); const feedSearchTermRaw = shouldSuggestFeeds @@ -299,7 +308,11 @@ const useFeedSuggestions = ( : ""; const feedSearchTerm = stripSurroundingQuotes(feedSearchTermRaw); const normalizedFeedSearchTerm = feedSearchTerm.toLowerCase(); - const { data: feedResults } = api.feeds.list.useQuery(); + const { data: feedResults } = useQuery( + api.feeds.list.queryOptions(undefined, { + enabled: parsed.activeToken.length > 0, + }), + ); const feedSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => { if (!shouldSuggestFeeds) { @@ -349,7 +362,9 @@ const useListSuggestions = ( : ""; const listSearchTerm = stripSurroundingQuotes(listSearchTermRaw); const normalizedListSearchTerm = listSearchTerm.toLowerCase(); - const { data: listResults } = useBookmarkLists(); + const { data: listResults } = useBookmarkLists(undefined, { + enabled: parsed.activeToken.length > 0, + }); const listSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => { if (!shouldSuggestLists) { @@ -357,6 +372,7 @@ const useListSuggestions = ( } const lists = listResults?.data ?? []; + const seenListNames = new Set<string>(); return lists .filter((list) => { @@ -365,6 +381,15 @@ const useListSuggestions = ( } return list.name.toLowerCase().includes(normalizedListSearchTerm); }) + .filter((list) => { + const normalizedListName = list.name.trim().toLowerCase(); + if (seenListNames.has(normalizedListName)) { + return false; + } + + seenListNames.add(normalizedListName); + return true; + }) .slice(0, MAX_DISPLAY_SUGGESTIONS) .map((list) => { const formattedName = formatSearchValue(list.name); @@ -389,12 +414,53 @@ const useListSuggestions = ( return listSuggestions; }; +const SOURCE_VALUES = zBookmarkSourceSchema.options; + +const useSourceSuggestions = ( + parsed: ParsedSearchState, +): AutocompleteSuggestionItem[] => { + const shouldSuggestSources = + parsed.normalizedTokenWithoutMinus.startsWith("source:"); + const sourceSearchTerm = shouldSuggestSources + ? parsed.normalizedTokenWithoutMinus.slice("source:".length) + : ""; + + const sourceSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => { + if (!shouldSuggestSources) { + return []; + } + + return SOURCE_VALUES.filter((source) => { + if (sourceSearchTerm.length === 0) { + return true; + } + return source.startsWith(sourceSearchTerm); + }) + .slice(0, MAX_DISPLAY_SUGGESTIONS) + .map((source) => { + const insertText = `${parsed.isTokenNegative ? "-" : ""}source:${source}`; + return { + type: "source" as const, + id: `source-${source}`, + label: insertText, + insertText, + appendSpace: true, + description: undefined, + Icon: Globe, + } satisfies AutocompleteSuggestionItem; + }); + }, [shouldSuggestSources, sourceSearchTerm, parsed.isTokenNegative]); + + return sourceSuggestions; +}; + const useHistorySuggestions = ( value: string, history: string[], ): HistorySuggestionItem[] => { const historyItems = useMemo<HistorySuggestionItem[]>(() => { const trimmedValue = value.trim(); + const seenTerms = new Set<string>(); const results = trimmedValue.length === 0 ? history @@ -402,16 +468,27 @@ const useHistorySuggestions = ( item.toLowerCase().includes(trimmedValue.toLowerCase()), ); - return results.slice(0, MAX_DISPLAY_SUGGESTIONS).map( - (term) => - ({ - type: "history" as const, - id: `history-${term}`, - term, - label: term, - Icon: History, - }) satisfies HistorySuggestionItem, - ); + return results + .filter((term) => { + const normalizedTerm = term.trim().toLowerCase(); + if (seenTerms.has(normalizedTerm)) { + return false; + } + + seenTerms.add(normalizedTerm); + return true; + }) + .slice(0, MAX_DISPLAY_SUGGESTIONS) + .map( + (term) => + ({ + type: "history" as const, + id: `history-${term}`, + term, + label: term, + Icon: History, + }) satisfies HistorySuggestionItem, + ); }, [history, value]); return historyItems; @@ -431,6 +508,7 @@ export const useSearchAutocomplete = ({ const tagSuggestions = useTagSuggestions(parsedState); const listSuggestions = useListSuggestions(parsedState); const feedSuggestions = useFeedSuggestions(parsedState); + const sourceSuggestions = useSourceSuggestions(parsedState); const historyItems = useHistorySuggestions(value, history); const { activeToken, getActiveToken } = parsedState; @@ -461,6 +539,14 @@ export const useSearchAutocomplete = ({ }); } + if (sourceSuggestions.length > 0) { + groups.push({ + id: "sources", + label: t("search.is_from_source"), + items: sourceSuggestions, + }); + } + // Only suggest qualifiers if no other suggestions are available if (groups.length === 0 && qualifierSuggestions.length > 0) { groups.push({ @@ -484,6 +570,7 @@ export const useSearchAutocomplete = ({ tagSuggestions, listSuggestions, feedSuggestions, + sourceSuggestions, historyItems, t, ]); diff --git a/apps/web/components/dashboard/sidebar/AllLists.tsx b/apps/web/components/dashboard/sidebar/AllLists.tsx index 306bf4b4..d1099231 100644 --- a/apps/web/components/dashboard/sidebar/AllLists.tsx +++ b/apps/web/components/dashboard/sidebar/AllLists.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import SidebarItem from "@/components/shared/sidebar/SidebarItem"; @@ -10,6 +10,8 @@ import { CollapsibleContent, CollapsibleTriggerTriangle, } from "@/components/ui/collapsible"; +import { toast } from "@/components/ui/sonner"; +import { BOOKMARK_DRAG_MIME } from "@/lib/bookmark-drag"; import { useTranslation } from "@/lib/i18n/client"; import { cn } from "@/lib/utils"; import { MoreHorizontal, Plus } from "lucide-react"; @@ -17,6 +19,7 @@ import { MoreHorizontal, Plus } from "lucide-react"; import type { ZBookmarkList } from "@karakeep/shared/types/lists"; import { augmentBookmarkListsWithInitialData, + useAddBookmarkToList, useBookmarkLists, } from "@karakeep/shared-react/hooks/lists"; import { ZBookmarkListTreeNode } from "@karakeep/shared/utils/listUtils"; @@ -26,6 +29,146 @@ import { EditListModal } from "../lists/EditListModal"; import { ListOptions } from "../lists/ListOptions"; import { InvitationNotificationBadge } from "./InvitationNotificationBadge"; +function useDropTarget(listId: string, listName: string) { + const { mutateAsync: addToList } = useAddBookmarkToList(); + const [dropHighlight, setDropHighlight] = useState(false); + const dragCounterRef = useRef(0); + const { t } = useTranslation(); + + const onDragOver = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes(BOOKMARK_DRAG_MIME)) { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + } + }, []); + + const onDragEnter = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes(BOOKMARK_DRAG_MIME)) { + e.preventDefault(); + dragCounterRef.current++; + setDropHighlight(true); + } + }, []); + + const onDragLeave = useCallback(() => { + dragCounterRef.current--; + if (dragCounterRef.current <= 0) { + dragCounterRef.current = 0; + setDropHighlight(false); + } + }, []); + + const onDrop = useCallback( + async (e: React.DragEvent) => { + dragCounterRef.current = 0; + setDropHighlight(false); + const bookmarkId = e.dataTransfer.getData(BOOKMARK_DRAG_MIME); + if (!bookmarkId) return; + e.preventDefault(); + try { + await addToList({ bookmarkId, listId }); + toast({ + description: t("lists.add_to_list_success", { + list: listName, + defaultValue: `Added to "${listName}"`, + }), + }); + } catch { + toast({ + description: t("common.something_went_wrong", { + defaultValue: "Something went wrong", + }), + variant: "destructive", + }); + } + }, + [addToList, listId, listName, t], + ); + + return { dropHighlight, onDragOver, onDragEnter, onDragLeave, onDrop }; +} + +function DroppableListSidebarItem({ + node, + level, + open, + numBookmarks, + selectedListId, + setSelectedListId, +}: { + node: ZBookmarkListTreeNode; + level: number; + open: boolean; + numBookmarks?: number; + selectedListId: string | null; + setSelectedListId: (id: string | null) => void; +}) { + const canDrop = + node.item.type === "manual" && + (node.item.userRole === "owner" || node.item.userRole === "editor"); + const { dropHighlight, onDragOver, onDragEnter, onDragLeave, onDrop } = + useDropTarget(node.item.id, node.item.name); + + return ( + <SidebarItem + collapseButton={ + node.children.length > 0 && ( + <CollapsibleTriggerTriangle + className="absolute left-0.5 top-1/2 size-2 -translate-y-1/2" + open={open} + /> + ) + } + logo={ + <span className="flex"> + <span className="text-lg"> {node.item.icon}</span> + </span> + } + name={node.item.name} + path={`/dashboard/lists/${node.item.id}`} + className="group px-0.5" + right={ + <ListOptions + onOpenChange={(isOpen) => { + if (isOpen) { + setSelectedListId(node.item.id); + } else { + setSelectedListId(null); + } + }} + list={node.item} + > + <Button size="none" variant="ghost" className="relative"> + <MoreHorizontal + className={cn( + "absolute inset-0 m-auto size-4 opacity-0 transition-opacity duration-100 group-hover:opacity-100", + selectedListId == node.item.id ? "opacity-100" : "opacity-0", + )} + /> + <span + className={cn( + "px-2.5 text-xs font-light text-muted-foreground opacity-100 transition-opacity duration-100 group-hover:opacity-0", + selectedListId == node.item.id || numBookmarks === undefined + ? "opacity-0" + : "opacity-100", + )} + > + {numBookmarks} + </span> + </Button> + </ListOptions> + } + linkClassName="py-0.5" + style={{ marginLeft: `${level * 1}rem` }} + dropHighlight={canDrop && dropHighlight} + onDragOver={canDrop ? onDragOver : undefined} + onDragEnter={canDrop ? onDragEnter : undefined} + onDragLeave={canDrop ? onDragLeave : undefined} + onDrop={canDrop ? onDrop : undefined} + /> + ); +} + export default function AllLists({ initialData, }: { @@ -71,7 +214,7 @@ export default function AllLists({ }, [isViewingSharedList, sharedListsOpen]); return ( - <ul className="max-h-full gap-y-2 overflow-auto text-sm"> + <ul className="sidebar-scrollbar max-h-full gap-y-2 overflow-auto text-sm"> <li className="flex justify-between pb-3"> <p className="text-xs uppercase tracking-wider text-muted-foreground"> Lists @@ -107,59 +250,13 @@ export default function AllLists({ filter={(node) => node.item.userRole === "owner"} isOpenFunc={isNodeOpen} render={({ node, level, open, numBookmarks }) => ( - <SidebarItem - collapseButton={ - node.children.length > 0 && ( - <CollapsibleTriggerTriangle - className="absolute left-0.5 top-1/2 size-2 -translate-y-1/2" - open={open} - /> - ) - } - logo={ - <span className="flex"> - <span className="text-lg"> {node.item.icon}</span> - </span> - } - name={node.item.name} - path={`/dashboard/lists/${node.item.id}`} - className="group px-0.5" - right={ - <ListOptions - onOpenChange={(isOpen) => { - if (isOpen) { - setSelectedListId(node.item.id); - } else { - setSelectedListId(null); - } - }} - list={node.item} - > - <Button size="none" variant="ghost" className="relative"> - <MoreHorizontal - className={cn( - "absolute inset-0 m-auto size-4 opacity-0 transition-opacity duration-100 group-hover:opacity-100", - selectedListId == node.item.id - ? "opacity-100" - : "opacity-0", - )} - /> - <span - className={cn( - "px-2.5 text-xs font-light text-muted-foreground opacity-100 transition-opacity duration-100 group-hover:opacity-0", - selectedListId == node.item.id || - numBookmarks === undefined - ? "opacity-0" - : "opacity-100", - )} - > - {numBookmarks} - </span> - </Button> - </ListOptions> - } - linkClassName="py-0.5" - style={{ marginLeft: `${level * 1}rem` }} + <DroppableListSidebarItem + node={node} + level={level} + open={open} + numBookmarks={numBookmarks} + selectedListId={selectedListId} + setSelectedListId={setSelectedListId} /> )} /> @@ -187,59 +284,13 @@ export default function AllLists({ isOpenFunc={isNodeOpen} indentOffset={1} render={({ node, level, open, numBookmarks }) => ( - <SidebarItem - collapseButton={ - node.children.length > 0 && ( - <CollapsibleTriggerTriangle - className="absolute left-0.5 top-1/2 size-2 -translate-y-1/2" - open={open} - /> - ) - } - logo={ - <span className="flex"> - <span className="text-lg"> {node.item.icon}</span> - </span> - } - name={node.item.name} - path={`/dashboard/lists/${node.item.id}`} - className="group px-0.5" - right={ - <ListOptions - onOpenChange={(isOpen) => { - if (isOpen) { - setSelectedListId(node.item.id); - } else { - setSelectedListId(null); - } - }} - list={node.item} - > - <Button size="none" variant="ghost" className="relative"> - <MoreHorizontal - className={cn( - "absolute inset-0 m-auto size-4 opacity-0 transition-opacity duration-100 group-hover:opacity-100", - selectedListId == node.item.id - ? "opacity-100" - : "opacity-0", - )} - /> - <span - className={cn( - "px-2.5 text-xs font-light text-muted-foreground opacity-100 transition-opacity duration-100 group-hover:opacity-0", - selectedListId == node.item.id || - numBookmarks === undefined - ? "opacity-0" - : "opacity-100", - )} - > - {numBookmarks} - </span> - </Button> - </ListOptions> - } - linkClassName="py-0.5" - style={{ marginLeft: `${level * 1}rem` }} + <DroppableListSidebarItem + node={node} + level={level} + open={open} + numBookmarks={numBookmarks} + selectedListId={selectedListId} + setSelectedListId={setSelectedListId} /> )} /> diff --git a/apps/web/components/dashboard/sidebar/InvitationNotificationBadge.tsx b/apps/web/components/dashboard/sidebar/InvitationNotificationBadge.tsx index e4d7b39f..e3c65be9 100644 --- a/apps/web/components/dashboard/sidebar/InvitationNotificationBadge.tsx +++ b/apps/web/components/dashboard/sidebar/InvitationNotificationBadge.tsx @@ -1,13 +1,15 @@ "use client"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; export function InvitationNotificationBadge() { - const { data: pendingInvitations } = api.lists.getPendingInvitations.useQuery( - undefined, - { + const api = useTRPC(); + const { data: pendingInvitations } = useQuery( + api.lists.getPendingInvitations.queryOptions(undefined, { refetchInterval: 1000 * 60 * 5, - }, + }), ); const pendingInvitationsCount = pendingInvitations?.length ?? 0; diff --git a/apps/web/components/dashboard/tags/AllTagsView.tsx b/apps/web/components/dashboard/tags/AllTagsView.tsx index c21f9aac..9708c37f 100644 --- a/apps/web/components/dashboard/tags/AllTagsView.tsx +++ b/apps/web/components/dashboard/tags/AllTagsView.tsx @@ -22,9 +22,9 @@ import { import InfoTooltip from "@/components/ui/info-tooltip"; import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; +import { toast } from "@/components/ui/sonner"; import Spinner from "@/components/ui/spinner"; import { Toggle } from "@/components/ui/toggle"; -import { toast } from "@/components/ui/use-toast"; import useBulkTagActionsStore from "@/lib/bulkTagActions"; import { useTranslation } from "@/lib/i18n/client"; import { ArrowDownAZ, ChevronDown, Combine, Search, Tag } from "lucide-react"; diff --git a/apps/web/components/dashboard/tags/BulkTagAction.tsx b/apps/web/components/dashboard/tags/BulkTagAction.tsx index fbd044e0..c8061a1f 100644 --- a/apps/web/components/dashboard/tags/BulkTagAction.tsx +++ b/apps/web/components/dashboard/tags/BulkTagAction.tsx @@ -4,8 +4,8 @@ import { useEffect, useState } from "react"; import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; import { ButtonWithTooltip } from "@/components/ui/button"; +import { toast } from "@/components/ui/sonner"; import { Toggle } from "@/components/ui/toggle"; -import { useToast } from "@/components/ui/use-toast"; import useBulkTagActionsStore from "@/lib/bulkTagActions"; import { useTranslation } from "@/lib/i18n/client"; import { CheckCheck, Pencil, Trash2, X } from "lucide-react"; @@ -17,7 +17,6 @@ const MAX_CONCURRENT_BULK_ACTIONS = 50; export default function BulkTagAction() { const { t } = useTranslation(); - const { toast } = useToast(); const { selectedTagIds, diff --git a/apps/web/components/dashboard/tags/CreateTagModal.tsx b/apps/web/components/dashboard/tags/CreateTagModal.tsx index 3a4c4995..e5cf4a45 100644 --- a/apps/web/components/dashboard/tags/CreateTagModal.tsx +++ b/apps/web/components/dashboard/tags/CreateTagModal.tsx @@ -22,7 +22,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { Plus } from "lucide-react"; diff --git a/apps/web/components/dashboard/tags/DeleteTagConfirmationDialog.tsx b/apps/web/components/dashboard/tags/DeleteTagConfirmationDialog.tsx index 0a589ee6..7df04e20 100644 --- a/apps/web/components/dashboard/tags/DeleteTagConfirmationDialog.tsx +++ b/apps/web/components/dashboard/tags/DeleteTagConfirmationDialog.tsx @@ -1,7 +1,7 @@ import { usePathname, useRouter } from "next/navigation"; import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useDeleteTag } from "@karakeep/shared-react/hooks/tags"; diff --git a/apps/web/components/dashboard/tags/EditableTagName.tsx b/apps/web/components/dashboard/tags/EditableTagName.tsx index 7854be32..e6df5086 100644 --- a/apps/web/components/dashboard/tags/EditableTagName.tsx +++ b/apps/web/components/dashboard/tags/EditableTagName.tsx @@ -1,7 +1,7 @@ "use client"; import { usePathname, useRouter } from "next/navigation"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { cn } from "@/lib/utils"; import { useUpdateTag } from "@karakeep/shared-react/hooks/tags"; diff --git a/apps/web/components/dashboard/tags/MergeTagModal.tsx b/apps/web/components/dashboard/tags/MergeTagModal.tsx index 84dcd478..22b07c98 100644 --- a/apps/web/components/dashboard/tags/MergeTagModal.tsx +++ b/apps/web/components/dashboard/tags/MergeTagModal.tsx @@ -18,7 +18,7 @@ import { FormItem, FormMessage, } from "@/components/ui/form"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; diff --git a/apps/web/components/dashboard/tags/TagAutocomplete.tsx b/apps/web/components/dashboard/tags/TagAutocomplete.tsx index 8164dc81..656d4c5a 100644 --- a/apps/web/components/dashboard/tags/TagAutocomplete.tsx +++ b/apps/web/components/dashboard/tags/TagAutocomplete.tsx @@ -15,11 +15,12 @@ import { } from "@/components/ui/popover"; import LoadingSpinner from "@/components/ui/spinner"; import { cn } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; import { Check, ChevronsUpDown, X } from "lucide-react"; import { useTagAutocomplete } from "@karakeep/shared-react/hooks/tags"; import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; -import { api } from "@karakeep/shared-react/trpc"; +import { useTRPC } from "@karakeep/shared-react/trpc"; interface TagAutocompleteProps { tagId: string; @@ -32,6 +33,7 @@ export function TagAutocomplete({ onChange, className, }: TagAutocompleteProps) { + const api = useTRPC(); const [open, setOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const searchQueryDebounced = useDebounce(searchQuery, 500); @@ -41,8 +43,8 @@ export function TagAutocomplete({ select: (data) => data.tags, }); - const { data: selectedTag, isLoading: isSelectedTagLoading } = - api.tags.get.useQuery( + const { data: selectedTag, isLoading: isSelectedTagLoading } = useQuery( + api.tags.get.queryOptions( { tagId, }, @@ -53,7 +55,8 @@ export function TagAutocomplete({ }), enabled: !!tagId, }, - ); + ), + ); const handleSelect = (currentValue: string) => { setOpen(false); diff --git a/apps/web/components/dashboard/tags/TagPill.tsx b/apps/web/components/dashboard/tags/TagPill.tsx index 65a42e08..09310f9f 100644 --- a/apps/web/components/dashboard/tags/TagPill.tsx +++ b/apps/web/components/dashboard/tags/TagPill.tsx @@ -2,7 +2,7 @@ import React, { useRef, useState } from "react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useDragAndDrop } from "@/lib/drag-and-drop"; import { X } from "lucide-react"; import Draggable from "react-draggable"; diff --git a/apps/web/components/invite/InviteAcceptForm.tsx b/apps/web/components/invite/InviteAcceptForm.tsx index 95a0e1eb..eb1fa5c9 100644 --- a/apps/web/components/invite/InviteAcceptForm.tsx +++ b/apps/web/components/invite/InviteAcceptForm.tsx @@ -21,14 +21,16 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { api } from "@/lib/trpc"; +import { signIn } from "@/lib/auth/client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { TRPCClientError } from "@trpc/client"; import { AlertCircle, Clock, Loader2, Mail, UserPlus } from "lucide-react"; -import { signIn } from "next-auth/react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + const inviteAcceptSchema = z .object({ name: z.string().min(1, "Name is required"), @@ -47,6 +49,7 @@ interface InviteAcceptFormProps { } export default function InviteAcceptForm({ token }: InviteAcceptFormProps) { + const api = useTRPC(); const router = useRouter(); const form = useForm<z.infer<typeof inviteAcceptSchema>>({ @@ -59,7 +62,7 @@ export default function InviteAcceptForm({ token }: InviteAcceptFormProps) { isPending: loading, data: inviteData, error, - } = api.invites.get.useQuery({ token }); + } = useQuery(api.invites.get.queryOptions({ token })); useEffect(() => { if (error) { @@ -67,7 +70,9 @@ export default function InviteAcceptForm({ token }: InviteAcceptFormProps) { } }, [error]); - const acceptInviteMutation = api.invites.accept.useMutation(); + const acceptInviteMutation = useMutation( + api.invites.accept.mutationOptions(), + ); const handleBackToSignIn = () => { router.push("/signin"); diff --git a/apps/web/components/public/lists/PublicBookmarkGrid.tsx b/apps/web/components/public/lists/PublicBookmarkGrid.tsx index d6aa9875..742d7e6e 100644 --- a/apps/web/components/public/lists/PublicBookmarkGrid.tsx +++ b/apps/web/components/public/lists/PublicBookmarkGrid.tsx @@ -9,14 +9,15 @@ import { ActionButton } from "@/components/ui/action-button"; import { badgeVariants } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; -import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; import tailwindConfig from "@/tailwind.config"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { Expand, FileIcon, ImageIcon } from "lucide-react"; import { useInView } from "react-intersection-observer"; import Masonry from "react-masonry-css"; import resolveConfig from "tailwindcss/resolveConfig"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes, ZPublicBookmark, @@ -199,19 +200,22 @@ export default function PublicBookmarkGrid({ bookmarks: ZPublicBookmark[]; nextCursor: ZCursor | null; }) { + const api = useTRPC(); const { ref: loadMoreRef, inView: loadMoreButtonInView } = useInView(); const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = - api.publicBookmarks.getPublicBookmarksInList.useInfiniteQuery( - { listId: list.id }, - { - initialData: () => ({ - pages: [{ bookmarks: initialBookmarks, nextCursor, list }], - pageParams: [null], - }), - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - refetchOnMount: true, - }, + useInfiniteQuery( + api.publicBookmarks.getPublicBookmarksInList.infiniteQueryOptions( + { listId: list.id }, + { + initialData: () => ({ + pages: [{ bookmarks: initialBookmarks, nextCursor, list }], + pageParams: [null], + }), + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + refetchOnMount: true, + }, + ), ); useEffect(() => { @@ -227,7 +231,11 @@ export default function PublicBookmarkGrid({ }, [data]); return ( <> - <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + <Masonry + className="-ml-4 flex w-auto" + columnClassName="pl-4" + breakpointCols={breakpointConfig} + > {bookmarks.map((bookmark) => ( <BookmarkCard key={bookmark.id} bookmark={bookmark} /> ))} diff --git a/apps/web/components/settings/AISettings.tsx b/apps/web/components/settings/AISettings.tsx index beaa93dc..6d28f4f8 100644 --- a/apps/web/components/settings/AISettings.tsx +++ b/apps/web/components/settings/AISettings.tsx @@ -1,6 +1,25 @@ "use client"; +import React from "react"; +import { TagsEditor } from "@/components/dashboard/bookmarks/TagsEditor"; import { ActionButton } from "@/components/ui/action-button"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Field, + FieldContent, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, + FieldTitle, +} from "@/components/ui/field"; import { Form, FormControl, @@ -10,6 +29,7 @@ import { } from "@/components/ui/form"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; import { Input } from "@/components/ui/input"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Select, SelectContent, @@ -18,15 +38,22 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; +import { Switch } from "@/components/ui/switch"; import { useClientConfig } from "@/lib/clientConfig"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useUserSettings } from "@/lib/userSettings"; +import { cn } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Plus, Save, Trash2 } from "lucide-react"; -import { useForm } from "react-hook-form"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Info, Plus, Save, Trash2 } from "lucide-react"; +import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; +import type { ZBookmarkTags } from "@karakeep/shared/types/tags"; +import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; +import { useUpdateUserSettings } from "@karakeep/shared-react/hooks/users"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { buildImagePrompt, buildSummaryPromptUntruncated, @@ -37,10 +64,426 @@ import { ZPrompt, zUpdatePromptSchema, } from "@karakeep/shared/types/prompts"; +import { zUpdateUserSettingsSchema } from "@karakeep/shared/types/users"; + +function SettingsSection({ + title, + description, + children, +}: { + title?: string; + description?: string; + children: React.ReactNode; + className?: string; +}) { + return ( + <Card> + <CardHeader> + {title && <CardTitle>{title}</CardTitle>} + {description && <CardDescription>{description}</CardDescription>} + </CardHeader> + <CardContent>{children}</CardContent> + </Card> + ); +} + +export function AIPreferences() { + const { t } = useTranslation(); + const clientConfig = useClientConfig(); + const settings = useUserSettings(); + + const { mutate: updateSettings, isPending } = useUpdateUserSettings({ + onSuccess: () => { + toast({ + description: "Settings updated successfully!", + }); + }, + onError: () => { + toast({ + description: "Failed to update settings", + variant: "destructive", + }); + }, + }); + + const form = useForm<z.infer<typeof zUpdateUserSettingsSchema>>({ + resolver: zodResolver(zUpdateUserSettingsSchema), + values: settings + ? { + inferredTagLang: settings.inferredTagLang ?? "", + autoTaggingEnabled: settings.autoTaggingEnabled, + autoSummarizationEnabled: settings.autoSummarizationEnabled, + } + : undefined, + }); + + const showAutoTagging = clientConfig.inference.enableAutoTagging; + const showAutoSummarization = clientConfig.inference.enableAutoSummarization; + + const onSubmit = (data: z.infer<typeof zUpdateUserSettingsSchema>) => { + updateSettings(data); + }; + + return ( + <SettingsSection title="AI preferences"> + <form onSubmit={form.handleSubmit(onSubmit)}> + <FieldGroup className="gap-3"> + <Controller + name="inferredTagLang" + control={form.control} + render={({ field, fieldState }) => ( + <Field + className="rounded-lg border p-3" + data-invalid={fieldState.invalid} + > + <FieldContent> + <FieldLabel htmlFor="inferredTagLang"> + {t("settings.ai.inference_language")} + </FieldLabel> + <FieldDescription> + {t("settings.ai.inference_language_description")} + </FieldDescription> + </FieldContent> + <Input + {...field} + id="inferredTagLang" + value={field.value ?? ""} + onChange={(e) => + field.onChange( + e.target.value.length > 0 ? e.target.value : null, + ) + } + aria-invalid={fieldState.invalid} + placeholder={`Default (${clientConfig.inference.inferredTagLang})`} + type="text" + /> + {fieldState.invalid && ( + <FieldError errors={[fieldState.error]} /> + )} + </Field> + )} + /> + + {showAutoTagging && ( + <Controller + name="autoTaggingEnabled" + control={form.control} + render={({ field, fieldState }) => ( + <Field + orientation="horizontal" + className="rounded-lg border p-3" + data-invalid={fieldState.invalid} + > + <FieldContent> + <FieldLabel htmlFor="autoTaggingEnabled"> + {t("settings.ai.auto_tagging")} + </FieldLabel> + <FieldDescription> + {t("settings.ai.auto_tagging_description")} + </FieldDescription> + </FieldContent> + <Switch + id="autoTaggingEnabled" + name={field.name} + checked={field.value ?? true} + onCheckedChange={field.onChange} + aria-invalid={fieldState.invalid} + /> + {fieldState.invalid && ( + <FieldError errors={[fieldState.error]} /> + )} + </Field> + )} + /> + )} + + {showAutoSummarization && ( + <Controller + name="autoSummarizationEnabled" + control={form.control} + render={({ field, fieldState }) => ( + <Field + orientation="horizontal" + className="rounded-lg border p-3" + data-invalid={fieldState.invalid} + > + <FieldContent> + <FieldLabel htmlFor="autoSummarizationEnabled"> + {t("settings.ai.auto_summarization")} + </FieldLabel> + <FieldDescription> + {t("settings.ai.auto_summarization_description")} + </FieldDescription> + </FieldContent> + <Switch + id="autoSummarizationEnabled" + name={field.name} + checked={field.value ?? true} + onCheckedChange={field.onChange} + aria-invalid={fieldState.invalid} + /> + {fieldState.invalid && ( + <FieldError errors={[fieldState.error]} /> + )} + </Field> + )} + /> + )} + + <div className="flex justify-end pt-4"> + <ActionButton type="submit" loading={isPending} variant="default"> + <Save className="mr-2 size-4" /> + {t("actions.save")} + </ActionButton> + </div> + </FieldGroup> + </form> + </SettingsSection> + ); +} + +export function TagStyleSelector() { + const { t } = useTranslation(); + const settings = useUserSettings(); + + const { mutate: updateSettings, isPending: isUpdating } = + useUpdateUserSettings({ + onSuccess: () => { + toast({ + description: "Tag style updated successfully!", + }); + }, + onError: () => { + toast({ + description: "Failed to update tag style", + variant: "destructive", + }); + }, + }); + + const tagStyleOptions = [ + { + value: "lowercase-hyphens", + label: t("settings.ai.lowercase_hyphens"), + examples: ["machine-learning", "web-development"], + }, + { + value: "lowercase-spaces", + label: t("settings.ai.lowercase_spaces"), + examples: ["machine learning", "web development"], + }, + { + value: "lowercase-underscores", + label: t("settings.ai.lowercase_underscores"), + examples: ["machine_learning", "web_development"], + }, + { + value: "titlecase-spaces", + label: t("settings.ai.titlecase_spaces"), + examples: ["Machine Learning", "Web Development"], + }, + { + value: "titlecase-hyphens", + label: t("settings.ai.titlecase_hyphens"), + examples: ["Machine-Learning", "Web-Development"], + }, + { + value: "camelCase", + label: t("settings.ai.camelCase"), + examples: ["machineLearning", "webDevelopment"], + }, + { + value: "as-generated", + label: t("settings.ai.no_preference"), + examples: ["Machine Learning", "web development", "AI_generated"], + }, + ] as const; + + const selectedStyle = settings?.tagStyle ?? "as-generated"; + + return ( + <SettingsSection + title={t("settings.ai.tag_style")} + description={t("settings.ai.tag_style_description")} + > + <RadioGroup + value={selectedStyle} + onValueChange={(value) => { + updateSettings({ tagStyle: value as typeof selectedStyle }); + }} + disabled={isUpdating} + className="grid gap-3 sm:grid-cols-2" + > + {tagStyleOptions.map((option) => ( + <FieldLabel + key={option.value} + htmlFor={option.value} + className={cn(selectedStyle === option.value && "ring-1")} + > + <Field orientation="horizontal"> + <FieldContent> + <FieldTitle>{option.label}</FieldTitle> + <div className="flex flex-wrap gap-1"> + {option.examples.map((example) => ( + <Badge + key={example} + variant="secondary" + className="text-xs font-light" + > + {example} + </Badge> + ))} + </div> + </FieldContent> + <RadioGroupItem value={option.value} id={option.value} /> + </Field> + </FieldLabel> + ))} + </RadioGroup> + </SettingsSection> + ); +} + +export function CuratedTagsSelector() { + const api = useTRPC(); + const { t } = useTranslation(); + const settings = useUserSettings(); + + const { mutate: updateSettings, isPending: isUpdatingCuratedTags } = + useUpdateUserSettings({ + onSuccess: () => { + toast({ + description: t("settings.ai.curated_tags_updated"), + }); + }, + onError: () => { + toast({ + description: t("settings.ai.curated_tags_update_failed"), + variant: "destructive", + }); + }, + }); + + const areTagIdsEqual = React.useCallback((a: string[], b: string[]) => { + return a.length === b.length && a.every((id, index) => id === b[index]); + }, []); + + const curatedTagIds = React.useMemo( + () => settings?.curatedTagIds ?? [], + [settings?.curatedTagIds], + ); + const [localCuratedTagIds, setLocalCuratedTagIds] = + React.useState<string[]>(curatedTagIds); + const debouncedCuratedTagIds = useDebounce(localCuratedTagIds, 300); + const lastServerCuratedTagIdsRef = React.useRef(curatedTagIds); + const lastSubmittedCuratedTagIdsRef = React.useRef<string[] | null>(null); + + React.useEffect(() => { + const hadUnsyncedLocalChanges = !areTagIdsEqual( + localCuratedTagIds, + lastServerCuratedTagIdsRef.current, + ); + + if ( + !hadUnsyncedLocalChanges && + !areTagIdsEqual(localCuratedTagIds, curatedTagIds) + ) { + setLocalCuratedTagIds(curatedTagIds); + } + + lastServerCuratedTagIdsRef.current = curatedTagIds; + }, [areTagIdsEqual, curatedTagIds, localCuratedTagIds]); + + React.useEffect(() => { + if (isUpdatingCuratedTags) { + return; + } + + if (areTagIdsEqual(debouncedCuratedTagIds, curatedTagIds)) { + lastSubmittedCuratedTagIdsRef.current = null; + return; + } + + if ( + lastSubmittedCuratedTagIdsRef.current && + areTagIdsEqual( + lastSubmittedCuratedTagIdsRef.current, + debouncedCuratedTagIds, + ) + ) { + return; + } + + lastSubmittedCuratedTagIdsRef.current = debouncedCuratedTagIds; + updateSettings({ + curatedTagIds: + debouncedCuratedTagIds.length > 0 ? debouncedCuratedTagIds : null, + }); + }, [ + areTagIdsEqual, + curatedTagIds, + debouncedCuratedTagIds, + isUpdatingCuratedTags, + updateSettings, + ]); + + // Fetch selected tags to display their names + const { data: selectedTagsData } = useQuery( + api.tags.list.queryOptions( + { ids: localCuratedTagIds }, + { enabled: localCuratedTagIds.length > 0 }, + ), + ); + + const selectedTags: ZBookmarkTags[] = React.useMemo(() => { + const tagsMap = new Map( + (selectedTagsData?.tags ?? []).map((tag) => [tag.id, tag]), + ); + // Preserve the order from curatedTagIds instead of server sort order + return localCuratedTagIds + .map((id) => tagsMap.get(id)) + .filter((tag): tag is NonNullable<typeof tag> => tag != null) + .map((tag) => ({ + id: tag.id, + name: tag.name, + attachedBy: "human" as const, + })); + }, [selectedTagsData?.tags, localCuratedTagIds]); + + return ( + <SettingsSection + title={t("settings.ai.curated_tags")} + description={t("settings.ai.curated_tags_description")} + > + <TagsEditor + tags={selectedTags} + placeholder="Select curated tags..." + onAttach={(tag) => { + const tagId = tag.tagId; + if (tagId) { + setLocalCuratedTagIds((prev) => { + if (prev.includes(tagId)) { + return prev; + } + return [...prev, tagId]; + }); + } + }} + onDetach={(tag) => { + setLocalCuratedTagIds((prev) => { + return prev.filter((id) => id !== tag.tagId); + }); + }} + allowCreation={false} + /> + </SettingsSection> + ); +} export function PromptEditor() { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); const form = useForm<z.infer<typeof zNewPromptSchema>>({ resolver: zodResolver(zNewPromptSchema), @@ -50,15 +493,16 @@ export function PromptEditor() { }, }); - const { mutateAsync: createPrompt, isPending: isCreating } = - api.prompts.create.useMutation({ + const { mutateAsync: createPrompt, isPending: isCreating } = useMutation( + api.prompts.create.mutationOptions({ onSuccess: () => { toast({ description: "Prompt has been created!", }); - apiUtils.prompts.list.invalidate(); + queryClient.invalidateQueries(api.prompts.list.pathFilter()); }, - }); + }), + ); return ( <Form {...form}> @@ -140,26 +584,29 @@ export function PromptEditor() { } export function PromptRow({ prompt }: { prompt: ZPrompt }) { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); - const { mutateAsync: updatePrompt, isPending: isUpdating } = - api.prompts.update.useMutation({ + const queryClient = useQueryClient(); + const { mutateAsync: updatePrompt, isPending: isUpdating } = useMutation( + api.prompts.update.mutationOptions({ onSuccess: () => { toast({ description: "Prompt has been updated!", }); - apiUtils.prompts.list.invalidate(); + queryClient.invalidateQueries(api.prompts.list.pathFilter()); }, - }); - const { mutate: deletePrompt, isPending: isDeleting } = - api.prompts.delete.useMutation({ + }), + ); + const { mutate: deletePrompt, isPending: isDeleting } = useMutation( + api.prompts.delete.mutationOptions({ onSuccess: () => { toast({ description: "Prompt has been deleted!", }); - apiUtils.prompts.list.invalidate(); + queryClient.invalidateQueries(api.prompts.list.pathFilter()); }, - }); + }), + ); const form = useForm<z.infer<typeof zUpdatePromptSchema>>({ resolver: zodResolver(zUpdatePromptSchema), @@ -273,92 +720,144 @@ export function PromptRow({ prompt }: { prompt: ZPrompt }) { } export function TaggingRules() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: prompts, isLoading } = api.prompts.list.useQuery(); + const { data: prompts, isLoading } = useQuery( + api.prompts.list.queryOptions(), + ); return ( - <div className="mt-2 flex flex-col gap-2"> - <div className="w-full text-xl font-medium sm:w-1/3"> - {t("settings.ai.tagging_rules")} - </div> - <p className="mb-1 text-xs italic text-muted-foreground"> - {t("settings.ai.tagging_rule_description")} - </p> - {isLoading && <FullPageSpinner />} + <SettingsSection + title={t("settings.ai.tagging_rules")} + description={t("settings.ai.tagging_rule_description")} + > {prompts && prompts.length == 0 && ( - <p className="rounded-md bg-muted p-2 text-sm text-muted-foreground"> - You don't have any custom prompts yet. - </p> + <div className="flex items-start gap-2 rounded-md bg-muted p-4 text-sm text-muted-foreground"> + <Info className="size-4 flex-shrink-0" /> + <p>You don't have any custom prompts yet.</p> + </div> )} - {prompts && - prompts.map((prompt) => <PromptRow key={prompt.id} prompt={prompt} />)} - <PromptEditor /> - </div> + <div className="flex flex-col gap-2"> + {isLoading && <FullPageSpinner />} + {prompts && + prompts.map((prompt) => ( + <PromptRow key={prompt.id} prompt={prompt} /> + ))} + <PromptEditor /> + </div> + </SettingsSection> ); } export function PromptDemo() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: prompts } = api.prompts.list.useQuery(); + const { data: prompts } = useQuery(api.prompts.list.queryOptions()); + const settings = useUserSettings(); const clientConfig = useClientConfig(); + const tagStyle = settings?.tagStyle ?? "as-generated"; + const curatedTagIds = settings?.curatedTagIds ?? []; + const { data: tagsData } = useQuery( + api.tags.list.queryOptions( + { ids: curatedTagIds }, + { enabled: curatedTagIds.length > 0 }, + ), + ); + const inferredTagLang = + settings?.inferredTagLang ?? clientConfig.inference.inferredTagLang; + + // Resolve curated tag names for preview + const curatedTagNames = + curatedTagIds.length > 0 && tagsData?.tags + ? curatedTagIds + .map((id) => tagsData.tags.find((tag) => tag.id === id)?.name) + .filter((name): name is string => Boolean(name)) + : undefined; + return ( - <div className="flex flex-col gap-2"> - <div className="mb-4 w-full text-xl font-medium sm:w-1/3"> - {t("settings.ai.prompt_preview")} + <SettingsSection + title={t("settings.ai.prompt_preview")} + description="Preview the actual prompts sent to AI based on your settings" + > + <div className="space-y-4"> + <div> + <p className="mb-2 text-sm font-medium"> + {t("settings.ai.text_prompt")} + </p> + <code className="block whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground"> + {buildTextPromptUntruncated( + inferredTagLang, + (prompts ?? []) + .filter( + (p) => p.appliesTo == "text" || p.appliesTo == "all_tagging", + ) + .map((p) => p.text), + "\n<CONTENT_HERE>\n", + tagStyle, + curatedTagNames, + ).trim()} + </code> + </div> + <div> + <p className="mb-2 text-sm font-medium"> + {t("settings.ai.images_prompt")} + </p> + <code className="block whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground"> + {buildImagePrompt( + inferredTagLang, + (prompts ?? []) + .filter( + (p) => + p.appliesTo == "images" || p.appliesTo == "all_tagging", + ) + .map((p) => p.text), + tagStyle, + curatedTagNames, + ).trim()} + </code> + </div> + <div> + <p className="mb-2 text-sm font-medium"> + {t("settings.ai.summarization_prompt")} + </p> + <code className="block whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground"> + {buildSummaryPromptUntruncated( + inferredTagLang, + (prompts ?? []) + .filter((p) => p.appliesTo == "summary") + .map((p) => p.text), + "\n<CONTENT_HERE>\n", + ).trim()} + </code> + </div> </div> - <p>{t("settings.ai.text_prompt")}</p> - <code className="whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground"> - {buildTextPromptUntruncated( - clientConfig.inference.inferredTagLang, - (prompts ?? []) - .filter( - (p) => p.appliesTo == "text" || p.appliesTo == "all_tagging", - ) - .map((p) => p.text), - "\n<CONTENT_HERE>\n", - ).trim()} - </code> - <p>{t("settings.ai.images_prompt")}</p> - <code className="whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground"> - {buildImagePrompt( - clientConfig.inference.inferredTagLang, - (prompts ?? []) - .filter( - (p) => p.appliesTo == "images" || p.appliesTo == "all_tagging", - ) - .map((p) => p.text), - ).trim()} - </code> - <p>{t("settings.ai.summarization_prompt")}</p> - <code className="whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground"> - {buildSummaryPromptUntruncated( - clientConfig.inference.inferredTagLang, - (prompts ?? []) - .filter((p) => p.appliesTo == "summary") - .map((p) => p.text), - "\n<CONTENT_HERE>\n", - ).trim()} - </code> - </div> + </SettingsSection> ); } export default function AISettings() { const { t } = useTranslation(); return ( - <> - <div className="rounded-md border bg-background p-4"> - <div className="mb-2 flex flex-col gap-3"> - <div className="w-full text-2xl font-medium sm:w-1/3"> - {t("settings.ai.ai_settings")} - </div> - <TaggingRules /> - </div> - </div> - <div className="mt-4 rounded-md border bg-background p-4"> - <PromptDemo /> - </div> - </> + <div className="space-y-6"> + <h2 className="text-3xl font-bold tracking-tight"> + {t("settings.ai.ai_settings")} + </h2> + + {/* AI Preferences */} + <AIPreferences /> + + {/* Tag Style */} + <TagStyleSelector /> + + {/* Curated Tags */} + <CuratedTagsSelector /> + + {/* Tagging Rules */} + <TaggingRules /> + + {/* Prompt Preview */} + <PromptDemo /> + </div> ); } diff --git a/apps/web/components/settings/AddApiKey.tsx b/apps/web/components/settings/AddApiKey.tsx index c8baa626..b6612a51 100644 --- a/apps/web/components/settings/AddApiKey.tsx +++ b/apps/web/components/settings/AddApiKey.tsx @@ -24,34 +24,39 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; import { PlusCircle } from "lucide-react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import ApiKeySuccess from "./ApiKeySuccess"; function AddApiKeyForm({ onSuccess }: { onSuccess: (key: string) => void }) { + const api = useTRPC(); const { t } = useTranslation(); const formSchema = z.object({ name: z.string(), }); const router = useRouter(); - const mutator = api.apiKeys.create.useMutation({ - onSuccess: (resp) => { - onSuccess(resp.key); - router.refresh(); - }, - onError: () => { - toast({ - description: t("common.something_went_wrong"), - variant: "destructive", - }); - }, - }); + const mutator = useMutation( + api.apiKeys.create.mutationOptions({ + onSuccess: (resp) => { + onSuccess(resp.key); + router.refresh(); + }, + onError: () => { + toast({ + description: t("common.something_went_wrong"), + variant: "destructive", + }); + }, + }), + ); const form = useForm<z.infer<typeof formSchema>>({ resolver: zodResolver(formSchema), diff --git a/apps/web/components/settings/ApiKeySettings.tsx b/apps/web/components/settings/ApiKeySettings.tsx index bc4b71c5..fa8b4927 100644 --- a/apps/web/components/settings/ApiKeySettings.tsx +++ b/apps/web/components/settings/ApiKeySettings.tsx @@ -8,6 +8,7 @@ import { } from "@/components/ui/table"; import { useTranslation } from "@/lib/i18n/server"; import { api } from "@/server/api/client"; +import { formatDistanceToNow } from "date-fns"; import AddApiKey from "./AddApiKey"; import DeleteApiKey from "./DeleteApiKey"; @@ -32,23 +33,33 @@ export default async function ApiKeys() { <TableHead>{t("common.name")}</TableHead> <TableHead>{t("common.key")}</TableHead> <TableHead>{t("common.created_at")}</TableHead> + <TableHead>{t("common.last_used")}</TableHead> <TableHead>{t("common.action")}</TableHead> </TableRow> </TableHeader> <TableBody> - {keys.keys.map((k) => ( - <TableRow key={k.id}> - <TableCell>{k.name}</TableCell> - <TableCell>**_{k.keyId}_**</TableCell> - <TableCell>{k.createdAt.toLocaleString()}</TableCell> - <TableCell> - <div className="flex items-center gap-2"> - <RegenerateApiKey name={k.name} id={k.id} /> - <DeleteApiKey name={k.name} id={k.id} /> - </div> - </TableCell> - </TableRow> - ))} + {keys.keys.map((key) => { + return ( + <TableRow key={key.id}> + <TableCell>{key.name}</TableCell> + <TableCell>**_{key.keyId}_**</TableCell> + <TableCell> + {formatDistanceToNow(key.createdAt, { addSuffix: true })} + </TableCell> + <TableCell> + {key.lastUsedAt + ? formatDistanceToNow(key.lastUsedAt, { addSuffix: true }) + : "—"} + </TableCell> + <TableCell> + <div className="flex items-center gap-2"> + <RegenerateApiKey name={key.name} id={key.id} /> + <DeleteApiKey name={key.name} id={key.id} /> + </div> + </TableCell> + </TableRow> + ); + })} <TableRow></TableRow> </TableBody> </Table> diff --git a/apps/web/components/settings/BackupSettings.tsx b/apps/web/components/settings/BackupSettings.tsx index 18a80993..57672fb0 100644 --- a/apps/web/components/settings/BackupSettings.tsx +++ b/apps/web/components/settings/BackupSettings.tsx @@ -21,12 +21,12 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { toast } from "@/components/ui/sonner"; import { Switch } from "@/components/ui/switch"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { useUserSettings } from "@/lib/userSettings"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CheckCircle, Download, @@ -39,6 +39,7 @@ import { useForm } from "react-hook-form"; import { z } from "zod"; import { useUpdateUserSettings } from "@karakeep/shared-react/hooks/users"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { zBackupSchema } from "@karakeep/shared/types/backups"; import { zUpdateBackupSettingsSchema } from "@karakeep/shared/types/users"; import { getAssetUrl } from "@karakeep/shared/utils/assetUtils"; @@ -207,16 +208,17 @@ function BackupConfigurationForm() { } function BackupRow({ backup }: { backup: z.infer<typeof zBackupSchema> }) { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); - const { mutate: deleteBackup, isPending: isDeleting } = - api.backups.delete.useMutation({ + const { mutate: deleteBackup, isPending: isDeleting } = useMutation( + api.backups.delete.mutationOptions({ onSuccess: () => { toast({ description: t("settings.backups.toasts.backup_deleted"), }); - apiUtils.backups.list.invalidate(); + queryClient.invalidateQueries(api.backups.list.pathFilter()); }, onError: (error) => { toast({ @@ -224,7 +226,8 @@ function BackupRow({ backup }: { backup: z.infer<typeof zBackupSchema> }) { variant: "destructive", }); }, - }); + }), + ); const formatSize = (bytes: number) => { if (bytes < 1024) return `${bytes} B`; @@ -330,25 +333,28 @@ function BackupRow({ backup }: { backup: z.infer<typeof zBackupSchema> }) { } function BackupsList() { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); - const { data: backups, isLoading } = api.backups.list.useQuery(undefined, { - refetchInterval: (query) => { - const data = query.state.data; - // Poll every 3 seconds if there's a pending backup, otherwise don't poll - return data?.backups.some((backup) => backup.status === "pending") - ? 3000 - : false; - }, - }); + const queryClient = useQueryClient(); + const { data: backups, isLoading } = useQuery( + api.backups.list.queryOptions(undefined, { + refetchInterval: (query) => { + const data = query.state.data; + // Poll every 3 seconds if there's a pending backup, otherwise don't poll + return data?.backups.some((backup) => backup.status === "pending") + ? 3000 + : false; + }, + }), + ); - const { mutate: triggerBackup, isPending: isTriggering } = - api.backups.triggerBackup.useMutation({ + const { mutate: triggerBackup, isPending: isTriggering } = useMutation( + api.backups.triggerBackup.mutationOptions({ onSuccess: () => { toast({ description: t("settings.backups.toasts.backup_queued"), }); - apiUtils.backups.list.invalidate(); + queryClient.invalidateQueries(api.backups.list.pathFilter()); }, onError: (error) => { toast({ @@ -356,7 +362,8 @@ function BackupsList() { variant: "destructive", }); }, - }); + }), + ); return ( <div className="rounded-md border bg-background p-4"> diff --git a/apps/web/components/settings/ChangePassword.tsx b/apps/web/components/settings/ChangePassword.tsx index a27741d9..481d4b95 100644 --- a/apps/web/components/settings/ChangePassword.tsx +++ b/apps/web/components/settings/ChangePassword.tsx @@ -12,19 +12,21 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; import { Eye, EyeOff, Lock } from "lucide-react"; import { useForm } from "react-hook-form"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { zChangePasswordSchema } from "@karakeep/shared/types/users"; import { Button } from "../ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; export function ChangePassword() { + const api = useTRPC(); const { t } = useTranslation(); const [showCurrentPassword, setShowCurrentPassword] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false); @@ -38,22 +40,27 @@ export function ChangePassword() { }, }); - const mutator = api.users.changePassword.useMutation({ - onSuccess: () => { - toast({ description: "Password changed successfully" }); - form.reset(); - }, - onError: (e) => { - if (e.data?.code == "UNAUTHORIZED") { - toast({ - description: "Your current password is incorrect", - variant: "destructive", - }); - } else { - toast({ description: "Something went wrong", variant: "destructive" }); - } - }, - }); + const mutator = useMutation( + api.users.changePassword.mutationOptions({ + onSuccess: () => { + toast({ description: "Password changed successfully" }); + form.reset(); + }, + onError: (e) => { + if (e.data?.code == "UNAUTHORIZED") { + toast({ + description: "Your current password is incorrect", + variant: "destructive", + }); + } else { + toast({ + description: "Something went wrong", + variant: "destructive", + }); + } + }, + }), + ); async function onSubmit(value: z.infer<typeof zChangePasswordSchema>) { mutator.mutate({ diff --git a/apps/web/components/settings/DeleteAccount.tsx b/apps/web/components/settings/DeleteAccount.tsx index 6ebafff9..5ccbfaf7 100644 --- a/apps/web/components/settings/DeleteAccount.tsx +++ b/apps/web/components/settings/DeleteAccount.tsx @@ -13,7 +13,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { zodResolver } from "@hookform/resolvers/zod"; import { AlertTriangle, Eye, EyeOff, Trash2 } from "lucide-react"; import { useForm } from "react-hook-form"; diff --git a/apps/web/components/settings/DeleteApiKey.tsx b/apps/web/components/settings/DeleteApiKey.tsx index 4efb7ea8..b4cf7eea 100644 --- a/apps/web/components/settings/DeleteApiKey.tsx +++ b/apps/web/components/settings/DeleteApiKey.tsx @@ -4,10 +4,12 @@ import { useRouter } from "next/navigation"; import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; import { Button } from "@/components/ui/button"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useMutation } from "@tanstack/react-query"; import { Trash } from "lucide-react"; +import { toast } from "sonner"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; export default function DeleteApiKey({ name, @@ -16,16 +18,17 @@ export default function DeleteApiKey({ name: string; id: string; }) { + const api = useTRPC(); const { t } = useTranslation(); const router = useRouter(); - const mutator = api.apiKeys.revoke.useMutation({ - onSuccess: () => { - toast({ - description: "Key was successfully deleted", - }); - router.refresh(); - }, - }); + const mutator = useMutation( + api.apiKeys.revoke.mutationOptions({ + onSuccess: () => { + toast.success("Key was successfully deleted"); + router.refresh(); + }, + }), + ); return ( <ActionConfirmingDialog @@ -49,8 +52,8 @@ export default function DeleteApiKey({ </ActionButton> )} > - <Button variant="outline"> - <Trash size={18} color="red" /> + <Button variant="ghost" title={t("actions.delete")}> + <Trash size={18} /> </Button> </ActionConfirmingDialog> ); diff --git a/apps/web/components/settings/FeedSettings.tsx b/apps/web/components/settings/FeedSettings.tsx index 23b639e4..ba1568a7 100644 --- a/apps/web/components/settings/FeedSettings.tsx +++ b/apps/web/components/settings/FeedSettings.tsx @@ -13,12 +13,12 @@ import { } from "@/components/ui/form"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/sonner"; import { Switch } from "@/components/ui/switch"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowDownToLine, CheckCircle, @@ -33,6 +33,7 @@ import { import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { ZFeed, zNewFeedSchema, @@ -61,9 +62,10 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; export function FeedsEditorDialog() { + const api = useTRPC(); const { t } = useTranslation(); const [open, setOpen] = React.useState(false); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); const form = useForm<z.infer<typeof zNewFeedSchema>>({ resolver: zodResolver(zNewFeedSchema), @@ -81,16 +83,17 @@ export function FeedsEditorDialog() { } }, [open]); - const { mutateAsync: createFeed, isPending: isCreating } = - api.feeds.create.useMutation({ + const { mutateAsync: createFeed, isPending: isCreating } = useMutation( + api.feeds.create.mutationOptions({ onSuccess: () => { toast({ description: "Feed has been created!", }); - apiUtils.feeds.list.invalidate(); + queryClient.invalidateQueries(api.feeds.list.pathFilter()); setOpen(false); }, - }); + }), + ); return ( <Dialog open={open} onOpenChange={setOpen}> @@ -191,8 +194,9 @@ export function FeedsEditorDialog() { } export function EditFeedDialog({ feed }: { feed: ZFeed }) { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); const [open, setOpen] = React.useState(false); React.useEffect(() => { if (open) { @@ -204,16 +208,17 @@ export function EditFeedDialog({ feed }: { feed: ZFeed }) { }); } }, [open]); - const { mutateAsync: updateFeed, isPending: isUpdating } = - api.feeds.update.useMutation({ + const { mutateAsync: updateFeed, isPending: isUpdating } = useMutation( + api.feeds.update.mutationOptions({ onSuccess: () => { toast({ description: "Feed has been updated!", }); setOpen(false); - apiUtils.feeds.list.invalidate(); + queryClient.invalidateQueries(api.feeds.list.pathFilter()); }, - }); + }), + ); const form = useForm<z.infer<typeof zUpdateFeedSchema>>({ resolver: zodResolver(zUpdateFeedSchema), defaultValues: { @@ -339,44 +344,49 @@ export function EditFeedDialog({ feed }: { feed: ZFeed }) { } export function FeedRow({ feed }: { feed: ZFeed }) { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); - const { mutate: deleteFeed, isPending: isDeleting } = - api.feeds.delete.useMutation({ + const queryClient = useQueryClient(); + const { mutate: deleteFeed, isPending: isDeleting } = useMutation( + api.feeds.delete.mutationOptions({ onSuccess: () => { toast({ description: "Feed has been deleted!", }); - apiUtils.feeds.list.invalidate(); + queryClient.invalidateQueries(api.feeds.list.pathFilter()); }, - }); + }), + ); - const { mutate: fetchNow, isPending: isFetching } = - api.feeds.fetchNow.useMutation({ + const { mutate: fetchNow, isPending: isFetching } = useMutation( + api.feeds.fetchNow.mutationOptions({ onSuccess: () => { toast({ description: "Feed fetch has been enqueued!", }); - apiUtils.feeds.list.invalidate(); + queryClient.invalidateQueries(api.feeds.list.pathFilter()); }, - }); + }), + ); - const { mutate: updateFeedEnabled } = api.feeds.update.useMutation({ - onSuccess: () => { - toast({ - description: feed.enabled - ? t("settings.feeds.feed_disabled") - : t("settings.feeds.feed_enabled"), - }); - apiUtils.feeds.list.invalidate(); - }, - onError: (error) => { - toast({ - description: `Error: ${error.message}`, - variant: "destructive", - }); - }, - }); + const { mutate: updateFeedEnabled } = useMutation( + api.feeds.update.mutationOptions({ + onSuccess: () => { + toast({ + description: feed.enabled + ? t("settings.feeds.feed_disabled") + : t("settings.feeds.feed_enabled"), + }); + queryClient.invalidateQueries(api.feeds.list.pathFilter()); + }, + onError: (error) => { + toast({ + description: `Error: ${error.message}`, + variant: "destructive", + }); + }, + }), + ); const handleToggle = (checked: boolean) => { updateFeedEnabled({ feedId: feed.id, enabled: checked }); @@ -456,8 +466,9 @@ export function FeedRow({ feed }: { feed: ZFeed }) { } export default function FeedSettings() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: feeds, isLoading } = api.feeds.list.useQuery(); + const { data: feeds, isLoading } = useQuery(api.feeds.list.queryOptions()); return ( <> <div className="rounded-md border bg-background p-4"> diff --git a/apps/web/components/settings/ImportExport.tsx b/apps/web/components/settings/ImportExport.tsx index b6e4da9a..e02297c9 100644 --- a/apps/web/components/settings/ImportExport.tsx +++ b/apps/web/components/settings/ImportExport.tsx @@ -12,6 +12,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { toast } from "@/components/ui/sonner"; import { useBookmarkImport } from "@/lib/hooks/useBookmarkImport"; import { useTranslation } from "@/lib/i18n/client"; import { cn } from "@/lib/utils"; @@ -19,7 +20,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, Download, Loader2, Upload } from "lucide-react"; import { Card, CardContent } from "../ui/card"; -import { toast } from "../ui/use-toast"; import { ImportSessionsSection } from "./ImportSessionsSection"; function ImportCard({ @@ -180,6 +180,23 @@ export function ImportExportRow() { </FilePickerButton> </ImportCard> <ImportCard + text="Matter" + description={t("settings.import.import_bookmarks_from_matter_export")} + > + <FilePickerButton + size={"sm"} + loading={false} + accept=".csv" + multiple={false} + className="flex items-center gap-2" + onFileSelect={(file) => + runUploadBookmarkFile({ file, source: "matter" }) + } + > + <p>Import</p> + </FilePickerButton> + </ImportCard> + <ImportCard text="Omnivore" description={t( "settings.import.import_bookmarks_from_omnivore_export", @@ -254,6 +271,25 @@ export function ImportExportRow() { </FilePickerButton> </ImportCard> <ImportCard + text="Instapaper" + description={t( + "settings.import.import_bookmarks_from_instapaper_export", + )} + > + <FilePickerButton + size={"sm"} + loading={false} + accept=".csv" + multiple={false} + className="flex items-center gap-2" + onFileSelect={(file) => + runUploadBookmarkFile({ file, source: "instapaper" }) + } + > + <p>Import</p> + </FilePickerButton> + </ImportCard> + <ImportCard text="Karakeep" description={t( "settings.import.import_bookmarks_from_karakeep_export", diff --git a/apps/web/components/settings/ImportSessionCard.tsx b/apps/web/components/settings/ImportSessionCard.tsx index 690caaa5..f62a00dd 100644 --- a/apps/web/components/settings/ImportSessionCard.tsx +++ b/apps/web/components/settings/ImportSessionCard.tsx @@ -9,6 +9,8 @@ import { Progress } from "@/components/ui/progress"; import { useDeleteImportSession, useImportSessionStats, + usePauseImportSession, + useResumeImportSession, } from "@/lib/hooks/useImportSessions"; import { useTranslation } from "@/lib/i18n/client"; import { formatDistanceToNow } from "date-fns"; @@ -19,10 +21,17 @@ import { Clock, ExternalLink, Loader2, + Pause, + Play, Trash2, + Upload, } from "lucide-react"; -import type { ZImportSessionWithStats } from "@karakeep/shared/types/importSessions"; +import type { + ZImportSessionStatus, + ZImportSessionWithStats, +} from "@karakeep/shared/types/importSessions"; +import { switchCase } from "@karakeep/shared/utils/switch"; interface ImportSessionCardProps { session: ZImportSessionWithStats; @@ -30,10 +39,14 @@ interface ImportSessionCardProps { function getStatusColor(status: string) { switch (status) { + case "staging": + return "bg-purple-500/10 text-purple-700 dark:text-purple-400"; case "pending": return "bg-muted text-muted-foreground"; - case "in_progress": + case "running": return "bg-blue-500/10 text-blue-700 dark:text-blue-400"; + case "paused": + return "bg-yellow-500/10 text-yellow-700 dark:text-yellow-400"; case "completed": return "bg-green-500/10 text-green-700 dark:text-green-400"; case "failed": @@ -45,10 +58,14 @@ function getStatusColor(status: string) { function getStatusIcon(status: string) { switch (status) { + case "staging": + return <Upload className="h-4 w-4" />; case "pending": return <Clock className="h-4 w-4" />; - case "in_progress": + case "running": return <Loader2 className="h-4 w-4 animate-spin" />; + case "paused": + return <Pause className="h-4 w-4" />; case "completed": return <CheckCircle2 className="h-4 w-4" />; case "failed": @@ -62,13 +79,18 @@ export function ImportSessionCard({ session }: ImportSessionCardProps) { const { t } = useTranslation(); const { data: liveStats } = useImportSessionStats(session.id); const deleteSession = useDeleteImportSession(); + const pauseSession = usePauseImportSession(); + const resumeSession = useResumeImportSession(); - const statusLabels: Record<string, string> = { - pending: t("settings.import_sessions.status.pending"), - in_progress: t("settings.import_sessions.status.in_progress"), - completed: t("settings.import_sessions.status.completed"), - failed: t("settings.import_sessions.status.failed"), - }; + const statusLabels = (s: ZImportSessionStatus) => + switchCase(s, { + staging: t("settings.import_sessions.status.staging"), + pending: t("settings.import_sessions.status.pending"), + running: t("settings.import_sessions.status.running"), + paused: t("settings.import_sessions.status.paused"), + completed: t("settings.import_sessions.status.completed"), + failed: t("settings.import_sessions.status.failed"), + }); // Use live stats if available, otherwise fallback to session stats const stats = liveStats || session; @@ -79,7 +101,14 @@ export function ImportSessionCard({ session }: ImportSessionCardProps) { 100 : 0; - const canDelete = stats.status !== "in_progress"; + const canDelete = + stats.status === "completed" || + stats.status === "failed" || + stats.status === "paused"; + + const canPause = stats.status === "pending" || stats.status === "running"; + + const canResume = stats.status === "paused"; return ( <Card className="transition-all hover:shadow-md"> @@ -101,7 +130,7 @@ export function ImportSessionCard({ session }: ImportSessionCardProps) { > {getStatusIcon(stats.status)} <span className="ml-1 capitalize"> - {statusLabels[stats.status] ?? stats.status.replace("_", " ")} + {statusLabels(stats.status)} </span> </Badge> </div> @@ -213,6 +242,38 @@ export function ImportSessionCard({ session }: ImportSessionCardProps) { {/* Actions */} <div className="flex items-center justify-end pt-2"> <div className="flex items-center gap-2"> + <Button variant="outline" size="sm" asChild> + <Link href={`/settings/import/${session.id}`}> + <ExternalLink className="mr-1 h-4 w-4" /> + {t("settings.import_sessions.view_details")} + </Link> + </Button> + {canPause && ( + <Button + variant="outline" + size="sm" + onClick={() => + pauseSession.mutate({ importSessionId: session.id }) + } + disabled={pauseSession.isPending} + > + <Pause className="mr-1 h-4 w-4" /> + {t("settings.import_sessions.pause_session")} + </Button> + )} + {canResume && ( + <Button + variant="outline" + size="sm" + onClick={() => + resumeSession.mutate({ importSessionId: session.id }) + } + disabled={resumeSession.isPending} + > + <Play className="mr-1 h-4 w-4" /> + {t("settings.import_sessions.resume_session")} + </Button> + )} {canDelete && ( <ActionConfirmingDialog title={t("settings.import_sessions.delete_dialog_title")} diff --git a/apps/web/components/settings/ImportSessionDetail.tsx b/apps/web/components/settings/ImportSessionDetail.tsx new file mode 100644 index 00000000..4b356eda --- /dev/null +++ b/apps/web/components/settings/ImportSessionDetail.tsx @@ -0,0 +1,596 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { ActionButton } from "@/components/ui/action-button"; +import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { FullPageSpinner } from "@/components/ui/full-page-spinner"; +import { Progress } from "@/components/ui/progress"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + useDeleteImportSession, + useImportSessionResults, + useImportSessionStats, + usePauseImportSession, + useResumeImportSession, +} from "@/lib/hooks/useImportSessions"; +import { useTranslation } from "@/lib/i18n/client"; +import { formatDistanceToNow } from "date-fns"; +import { + AlertCircle, + ArrowLeft, + CheckCircle2, + Clock, + ExternalLink, + FileText, + Globe, + Loader2, + Paperclip, + Pause, + Play, + Trash2, + Upload, +} from "lucide-react"; +import { useInView } from "react-intersection-observer"; + +import type { ZImportSessionStatus } from "@karakeep/shared/types/importSessions"; +import { switchCase } from "@karakeep/shared/utils/switch"; + +type FilterType = + | "all" + | "accepted" + | "rejected" + | "skipped_duplicate" + | "pending"; + +type SimpleTFunction = ( + key: string, + options?: Record<string, unknown>, +) => string; + +interface ImportSessionResultItem { + id: string; + title: string | null; + url: string | null; + content: string | null; + type: string; + status: string; + result: string | null; + resultReason: string | null; + resultBookmarkId: string | null; +} + +function getStatusColor(status: string) { + switch (status) { + case "staging": + return "bg-purple-500/10 text-purple-700 dark:text-purple-400"; + case "pending": + return "bg-muted text-muted-foreground"; + case "running": + return "bg-blue-500/10 text-blue-700 dark:text-blue-400"; + case "paused": + return "bg-yellow-500/10 text-yellow-700 dark:text-yellow-400"; + case "completed": + return "bg-green-500/10 text-green-700 dark:text-green-400"; + case "failed": + return "bg-destructive/10 text-destructive"; + default: + return "bg-muted text-muted-foreground"; + } +} + +function getStatusIcon(status: string) { + switch (status) { + case "staging": + return <Upload className="h-4 w-4" />; + case "pending": + return <Clock className="h-4 w-4" />; + case "running": + return <Loader2 className="h-4 w-4 animate-spin" />; + case "paused": + return <Pause className="h-4 w-4" />; + case "completed": + return <CheckCircle2 className="h-4 w-4" />; + case "failed": + return <AlertCircle className="h-4 w-4" />; + default: + return <Clock className="h-4 w-4" />; + } +} + +function getResultBadge( + status: string, + result: string | null, + t: (key: string) => string, +) { + if (status === "pending") { + return ( + <Badge + variant="secondary" + className="bg-muted text-muted-foreground hover:bg-muted" + > + <Clock className="mr-1 h-3 w-3" /> + {t("settings.import_sessions.detail.result_pending")} + </Badge> + ); + } + if (status === "processing") { + return ( + <Badge + variant="secondary" + className="bg-blue-500/10 text-blue-700 hover:bg-blue-500/10 dark:text-blue-400" + > + <Loader2 className="mr-1 h-3 w-3 animate-spin" /> + {t("settings.import_sessions.detail.result_processing")} + </Badge> + ); + } + switch (result) { + case "accepted": + return ( + <Badge + variant="secondary" + className="bg-green-500/10 text-green-700 hover:bg-green-500/10 dark:text-green-400" + > + <CheckCircle2 className="mr-1 h-3 w-3" /> + {t("settings.import_sessions.detail.result_accepted")} + </Badge> + ); + case "rejected": + return ( + <Badge + variant="secondary" + className="bg-destructive/10 text-destructive hover:bg-destructive/10" + > + <AlertCircle className="mr-1 h-3 w-3" /> + {t("settings.import_sessions.detail.result_rejected")} + </Badge> + ); + case "skipped_duplicate": + return ( + <Badge + variant="secondary" + className="bg-amber-500/10 text-amber-700 hover:bg-amber-500/10 dark:text-amber-400" + > + {t("settings.import_sessions.detail.result_skipped_duplicate")} + </Badge> + ); + default: + return ( + <Badge variant="secondary" className="bg-muted hover:bg-muted"> + — + </Badge> + ); + } +} + +function getTypeIcon(type: string) { + switch (type) { + case "link": + return <Globe className="h-3 w-3" />; + case "text": + return <FileText className="h-3 w-3" />; + case "asset": + return <Paperclip className="h-3 w-3" />; + default: + return null; + } +} + +function getTypeLabel(type: string, t: SimpleTFunction) { + switch (type) { + case "link": + return t("common.bookmark_types.link"); + case "text": + return t("common.bookmark_types.text"); + case "asset": + return t("common.bookmark_types.media"); + default: + return type; + } +} + +function getTitleDisplay( + item: { + title: string | null; + url: string | null; + content: string | null; + type: string; + }, + noTitleLabel: string, +) { + if (item.title) { + return item.title; + } + if (item.type === "text" && item.content) { + return item.content.length > 80 + ? item.content.substring(0, 80) + "…" + : item.content; + } + if (item.url) { + try { + const url = new URL(item.url); + const display = url.hostname + url.pathname; + return display.length > 60 ? display.substring(0, 60) + "…" : display; + } catch { + return item.url.length > 60 ? item.url.substring(0, 60) + "…" : item.url; + } + } + return noTitleLabel; +} + +export default function ImportSessionDetail({ + sessionId, +}: { + sessionId: string; +}) { + const { t: tRaw } = useTranslation(); + const t = tRaw as SimpleTFunction; + const router = useRouter(); + const [filter, setFilter] = useState<FilterType>("all"); + + const { data: stats, isLoading: isStatsLoading } = + useImportSessionStats(sessionId); + const { + data: resultsData, + isLoading: isResultsLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useImportSessionResults(sessionId, filter); + + const deleteSession = useDeleteImportSession(); + const pauseSession = usePauseImportSession(); + const resumeSession = useResumeImportSession(); + + const { ref: loadMoreRef, inView: loadMoreInView } = useInView(); + + useEffect(() => { + if (loadMoreInView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [fetchNextPage, hasNextPage, isFetchingNextPage, loadMoreInView]); + + if (isStatsLoading) { + return <FullPageSpinner />; + } + + if (!stats) { + return null; + } + + const items: ImportSessionResultItem[] = + resultsData?.pages.flatMap((page) => page.items) ?? []; + + const progress = + stats.totalBookmarks > 0 + ? ((stats.completedBookmarks + stats.failedBookmarks) / + stats.totalBookmarks) * + 100 + : 0; + + const canDelete = + stats.status === "completed" || + stats.status === "failed" || + stats.status === "paused"; + const canPause = stats.status === "pending" || stats.status === "running"; + const canResume = stats.status === "paused"; + + const statusLabels = (s: ZImportSessionStatus) => + switchCase(s, { + staging: t("settings.import_sessions.status.staging"), + pending: t("settings.import_sessions.status.pending"), + running: t("settings.import_sessions.status.running"), + paused: t("settings.import_sessions.status.paused"), + completed: t("settings.import_sessions.status.completed"), + failed: t("settings.import_sessions.status.failed"), + }); + + const handleDelete = () => { + deleteSession.mutateAsync({ importSessionId: sessionId }).then(() => { + router.push("/settings/import"); + }); + }; + + return ( + <div className="flex flex-col gap-6"> + {/* Back link */} + <Link + href="/settings/import" + className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground" + > + <ArrowLeft className="h-4 w-4" /> + {t("settings.import_sessions.detail.back_to_import")} + </Link> + + {/* Header */} + <div className="rounded-md border bg-background p-4"> + <div className="flex flex-col gap-4"> + <div className="flex items-start justify-between"> + <div className="flex-1"> + <h2 className="text-lg font-medium">{stats.name}</h2> + <p className="mt-1 text-sm text-muted-foreground"> + {t("settings.import_sessions.created_at", { + time: formatDistanceToNow(stats.createdAt, { + addSuffix: true, + }), + })} + </p> + </div> + <Badge + className={`${getStatusColor(stats.status)} hover:bg-inherit`} + > + {getStatusIcon(stats.status)} + <span className="ml-1 capitalize"> + {statusLabels(stats.status)} + </span> + </Badge> + </div> + + {/* Progress bar + stats */} + {stats.totalBookmarks > 0 && ( + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <h4 className="text-sm font-medium text-muted-foreground"> + {t("settings.import_sessions.progress")} + </h4> + <div className="flex items-center gap-2"> + <span className="text-sm font-medium"> + {stats.completedBookmarks + stats.failedBookmarks} /{" "} + {stats.totalBookmarks} + </span> + <Badge variant="outline" className="text-xs"> + {Math.round(progress)}% + </Badge> + </div> + </div> + <Progress value={progress} className="h-3" /> + <div className="flex flex-wrap gap-2"> + {stats.completedBookmarks > 0 && ( + <Badge + variant="secondary" + className="bg-green-500/10 text-green-700 hover:bg-green-500/10 dark:text-green-400" + > + <CheckCircle2 className="mr-1.5 h-3 w-3" /> + {t("settings.import_sessions.badges.completed", { + count: stats.completedBookmarks, + })} + </Badge> + )} + {stats.failedBookmarks > 0 && ( + <Badge + variant="secondary" + className="bg-destructive/10 text-destructive hover:bg-destructive/10" + > + <AlertCircle className="mr-1.5 h-3 w-3" /> + {t("settings.import_sessions.badges.failed", { + count: stats.failedBookmarks, + })} + </Badge> + )} + {stats.pendingBookmarks > 0 && ( + <Badge + variant="secondary" + className="bg-amber-500/10 text-amber-700 hover:bg-amber-500/10 dark:text-amber-400" + > + <Clock className="mr-1.5 h-3 w-3" /> + {t("settings.import_sessions.badges.pending", { + count: stats.pendingBookmarks, + })} + </Badge> + )} + {stats.processingBookmarks > 0 && ( + <Badge + variant="secondary" + className="bg-blue-500/10 text-blue-700 hover:bg-blue-500/10 dark:text-blue-400" + > + <Loader2 className="mr-1.5 h-3 w-3 animate-spin" /> + {t("settings.import_sessions.badges.processing", { + count: stats.processingBookmarks, + })} + </Badge> + )} + </div> + </div> + )} + + {/* Message */} + {stats.message && ( + <div className="rounded-lg border bg-muted/50 p-3 text-sm text-muted-foreground dark:bg-muted/20"> + {stats.message} + </div> + )} + + {/* Action buttons */} + <div className="flex items-center justify-end"> + <div className="flex items-center gap-2"> + {canPause && ( + <Button + variant="outline" + size="sm" + onClick={() => + pauseSession.mutate({ importSessionId: sessionId }) + } + disabled={pauseSession.isPending} + > + <Pause className="mr-1 h-4 w-4" /> + {t("settings.import_sessions.pause_session")} + </Button> + )} + {canResume && ( + <Button + variant="outline" + size="sm" + onClick={() => + resumeSession.mutate({ importSessionId: sessionId }) + } + disabled={resumeSession.isPending} + > + <Play className="mr-1 h-4 w-4" /> + {t("settings.import_sessions.resume_session")} + </Button> + )} + {canDelete && ( + <ActionConfirmingDialog + title={t("settings.import_sessions.delete_dialog_title")} + description={ + <div> + {t("settings.import_sessions.delete_dialog_description", { + name: stats.name, + })} + </div> + } + actionButton={(setDialogOpen) => ( + <Button + variant="destructive" + onClick={() => { + handleDelete(); + setDialogOpen(false); + }} + disabled={deleteSession.isPending} + > + {t("settings.import_sessions.delete_session")} + </Button> + )} + > + <Button + variant="destructive" + size="sm" + disabled={deleteSession.isPending} + > + <Trash2 className="mr-1 h-4 w-4" /> + {t("actions.delete")} + </Button> + </ActionConfirmingDialog> + )} + </div> + </div> + </div> + </div> + + {/* Filter tabs + Results table */} + <div className="rounded-md border bg-background p-4"> + <Tabs + value={filter} + onValueChange={(v) => setFilter(v as FilterType)} + className="w-full" + > + <TabsList className="mb-4 flex w-full flex-wrap"> + <TabsTrigger value="all"> + {t("settings.import_sessions.detail.filter_all")} + </TabsTrigger> + <TabsTrigger value="accepted"> + {t("settings.import_sessions.detail.filter_accepted")} + </TabsTrigger> + <TabsTrigger value="rejected"> + {t("settings.import_sessions.detail.filter_rejected")} + </TabsTrigger> + <TabsTrigger value="skipped_duplicate"> + {t("settings.import_sessions.detail.filter_duplicates")} + </TabsTrigger> + <TabsTrigger value="pending"> + {t("settings.import_sessions.detail.filter_pending")} + </TabsTrigger> + </TabsList> + </Tabs> + + {isResultsLoading ? ( + <FullPageSpinner /> + ) : items.length === 0 ? ( + <p className="rounded-md bg-muted p-4 text-center text-sm text-muted-foreground"> + {t("settings.import_sessions.detail.no_results")} + </p> + ) : ( + <div className="flex flex-col gap-2"> + <Table> + <TableHeader> + <TableRow> + <TableHead> + {t("settings.import_sessions.detail.table_title")} + </TableHead> + <TableHead className="w-[80px]"> + {t("settings.import_sessions.detail.table_type")} + </TableHead> + <TableHead className="w-[120px]"> + {t("settings.import_sessions.detail.table_result")} + </TableHead> + <TableHead> + {t("settings.import_sessions.detail.table_reason")} + </TableHead> + <TableHead className="w-[100px]"> + {t("settings.import_sessions.detail.table_bookmark")} + </TableHead> + </TableRow> + </TableHeader> + <TableBody> + {items.map((item) => ( + <TableRow key={item.id}> + <TableCell className="max-w-[300px] truncate font-medium"> + {getTitleDisplay( + item, + t("settings.import_sessions.detail.no_title"), + )} + </TableCell> + <TableCell> + <Badge + variant="outline" + className="flex w-fit items-center gap-1 text-xs" + > + {getTypeIcon(item.type)} + {getTypeLabel(item.type, t)} + </Badge> + </TableCell> + <TableCell> + {getResultBadge(item.status, item.result, t)} + </TableCell> + <TableCell className="max-w-[200px] truncate text-sm text-muted-foreground"> + {item.resultReason || "—"} + </TableCell> + <TableCell> + {item.resultBookmarkId ? ( + <Link + href={`/dashboard/preview/${item.resultBookmarkId}`} + className="flex items-center gap-1 text-sm text-primary hover:text-primary/80" + prefetch={false} + > + <ExternalLink className="h-3 w-3" /> + {t("settings.import_sessions.detail.view_bookmark")} + </Link> + ) : ( + <span className="text-sm text-muted-foreground">—</span> + )} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + {hasNextPage && ( + <div className="flex justify-center"> + <ActionButton + ref={loadMoreRef} + ignoreDemoMode={true} + loading={isFetchingNextPage} + onClick={() => fetchNextPage()} + variant="ghost" + > + {t("settings.import_sessions.detail.load_more")} + </ActionButton> + </div> + )} + </div> + )} + </div> + </div> + ); +} diff --git a/apps/web/components/settings/ReaderSettings.tsx b/apps/web/components/settings/ReaderSettings.tsx new file mode 100644 index 00000000..d694bf02 --- /dev/null +++ b/apps/web/components/settings/ReaderSettings.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "@/components/ui/sonner"; +import { useClientConfig } from "@/lib/clientConfig"; +import { useTranslation } from "@/lib/i18n/client"; +import { useReaderSettings } from "@/lib/readerSettings"; +import { + AlertTriangle, + BookOpen, + ChevronDown, + Laptop, + RotateCcw, +} from "lucide-react"; + +import { + formatFontSize, + formatLineHeight, + READER_DEFAULTS, + READER_FONT_FAMILIES, + READER_SETTING_CONSTRAINTS, +} from "@karakeep/shared/types/readers"; + +import { Alert, AlertDescription } from "../ui/alert"; +import { Button } from "../ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "../ui/collapsible"; +import { Label } from "../ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { Slider } from "../ui/slider"; + +export default function ReaderSettings() { + const { t } = useTranslation(); + const clientConfig = useClientConfig(); + const { + settings, + serverSettings, + localOverrides, + hasLocalOverrides, + clearServerDefaults, + clearLocalOverrides, + updateServerSetting, + } = useReaderSettings(); + + // Local state for collapsible + const [isOpen, setIsOpen] = useState(false); + + // Local state for slider dragging (null = not dragging, use server value) + const [draggingFontSize, setDraggingFontSize] = useState<number | null>(null); + const [draggingLineHeight, setDraggingLineHeight] = useState<number | null>( + null, + ); + + const hasServerSettings = + serverSettings.fontSize !== null || + serverSettings.lineHeight !== null || + serverSettings.fontFamily !== null; + + const handleClearDefaults = () => { + clearServerDefaults(); + toast({ description: t("settings.info.reader_settings.defaults_cleared") }); + }; + + const handleClearLocalOverrides = () => { + clearLocalOverrides(); + toast({ + description: t("settings.info.reader_settings.local_overrides_cleared"), + }); + }; + + // Format local override for display + const formatLocalOverride = ( + key: "fontSize" | "lineHeight" | "fontFamily", + ) => { + const value = localOverrides[key]; + if (value === undefined) return null; + if (key === "fontSize") return formatFontSize(value as number); + if (key === "lineHeight") return formatLineHeight(value as number); + if (key === "fontFamily") { + switch (value) { + case "serif": + return t("settings.info.reader_settings.serif"); + case "sans": + return t("settings.info.reader_settings.sans"); + case "mono": + return t("settings.info.reader_settings.mono"); + } + } + return String(value); + }; + + return ( + <Collapsible open={isOpen} onOpenChange={setIsOpen}> + <Card> + <CardHeader> + <CollapsibleTrigger className="flex w-full items-center justify-between [&[data-state=open]>svg]:rotate-180"> + <div className="flex flex-col items-start gap-1 text-left"> + <CardTitle className="flex items-center gap-2 text-xl"> + <BookOpen className="h-5 w-5" /> + {t("settings.info.reader_settings.title")} + </CardTitle> + <CardDescription> + {t("settings.info.reader_settings.description")} + </CardDescription> + </div> + <ChevronDown className="h-5 w-5 shrink-0 transition-transform duration-200" /> + </CollapsibleTrigger> + </CardHeader> + <CollapsibleContent> + <CardContent className="space-y-6"> + {/* Local Overrides Warning */} + {hasLocalOverrides && ( + <Alert> + <AlertTriangle className="h-4 w-4" /> + <AlertDescription className="flex flex-col gap-3"> + <div> + <p className="font-medium"> + {t("settings.info.reader_settings.local_overrides_title")} + </p> + <p className="mt-1 text-sm text-muted-foreground"> + {t( + "settings.info.reader_settings.local_overrides_description", + )} + </p> + <ul className="mt-2 text-sm text-muted-foreground"> + {localOverrides.fontFamily !== undefined && ( + <li> + {t("settings.info.reader_settings.font_family")}:{" "} + {formatLocalOverride("fontFamily")} + </li> + )} + {localOverrides.fontSize !== undefined && ( + <li> + {t("settings.info.reader_settings.font_size")}:{" "} + {formatLocalOverride("fontSize")} + </li> + )} + {localOverrides.lineHeight !== undefined && ( + <li> + {t("settings.info.reader_settings.line_height")}:{" "} + {formatLocalOverride("lineHeight")} + </li> + )} + </ul> + </div> + <Button + variant="outline" + size="sm" + onClick={handleClearLocalOverrides} + className="w-fit" + > + <Laptop className="mr-2 h-4 w-4" /> + {t("settings.info.reader_settings.clear_local_overrides")} + </Button> + </AlertDescription> + </Alert> + )} + + {/* Font Family */} + <div className="space-y-2"> + <Label className="text-sm font-medium"> + {t("settings.info.reader_settings.font_family")} + </Label> + <Select + disabled={!!clientConfig.demoMode} + value={serverSettings.fontFamily ?? "not-set"} + onValueChange={(value) => { + if (value !== "not-set") { + updateServerSetting({ + fontFamily: value as "serif" | "sans" | "mono", + }); + } + }} + > + <SelectTrigger className="h-11"> + <SelectValue + placeholder={t("settings.info.reader_settings.not_set")} + /> + </SelectTrigger> + <SelectContent> + <SelectItem value="not-set" disabled> + {t("settings.info.reader_settings.not_set")} ( + {t("common.default")}: {READER_DEFAULTS.fontFamily}) + </SelectItem> + <SelectItem value="serif"> + {t("settings.info.reader_settings.serif")} + </SelectItem> + <SelectItem value="sans"> + {t("settings.info.reader_settings.sans")} + </SelectItem> + <SelectItem value="mono"> + {t("settings.info.reader_settings.mono")} + </SelectItem> + </SelectContent> + </Select> + {serverSettings.fontFamily === null && ( + <p className="text-xs text-muted-foreground"> + {t("settings.info.reader_settings.using_default")}:{" "} + {READER_DEFAULTS.fontFamily} + </p> + )} + </div> + + {/* Font Size */} + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <Label className="text-sm font-medium"> + {t("settings.info.reader_settings.font_size")} + </Label> + <span className="text-sm text-muted-foreground"> + {formatFontSize(draggingFontSize ?? settings.fontSize)} + {serverSettings.fontSize === null && + draggingFontSize === null && + ` (${t("common.default").toLowerCase()})`} + </span> + </div> + <Slider + disabled={!!clientConfig.demoMode} + value={[draggingFontSize ?? settings.fontSize]} + onValueChange={([value]) => setDraggingFontSize(value)} + onValueCommit={([value]) => { + updateServerSetting({ fontSize: value }); + setDraggingFontSize(null); + }} + max={READER_SETTING_CONSTRAINTS.fontSize.max} + min={READER_SETTING_CONSTRAINTS.fontSize.min} + step={READER_SETTING_CONSTRAINTS.fontSize.step} + /> + </div> + + {/* Line Height */} + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <Label className="text-sm font-medium"> + {t("settings.info.reader_settings.line_height")} + </Label> + <span className="text-sm text-muted-foreground"> + {formatLineHeight(draggingLineHeight ?? settings.lineHeight)} + {serverSettings.lineHeight === null && + draggingLineHeight === null && + ` (${t("common.default").toLowerCase()})`} + </span> + </div> + <Slider + disabled={!!clientConfig.demoMode} + value={[draggingLineHeight ?? settings.lineHeight]} + onValueChange={([value]) => setDraggingLineHeight(value)} + onValueCommit={([value]) => { + updateServerSetting({ lineHeight: value }); + setDraggingLineHeight(null); + }} + max={READER_SETTING_CONSTRAINTS.lineHeight.max} + min={READER_SETTING_CONSTRAINTS.lineHeight.min} + step={READER_SETTING_CONSTRAINTS.lineHeight.step} + /> + </div> + + {/* Clear Defaults Button */} + {hasServerSettings && ( + <Button + variant="outline" + onClick={handleClearDefaults} + className="w-full" + disabled={!!clientConfig.demoMode} + > + <RotateCcw className="mr-2 h-4 w-4" /> + {t("settings.info.reader_settings.clear_defaults")} + </Button> + )} + + {/* Preview */} + <div className="rounded-lg border p-4"> + <p className="mb-2 text-sm font-medium text-muted-foreground"> + {t("settings.info.reader_settings.preview")} + </p> + <p + style={{ + fontFamily: READER_FONT_FAMILIES[settings.fontFamily], + fontSize: `${draggingFontSize ?? settings.fontSize}px`, + lineHeight: draggingLineHeight ?? settings.lineHeight, + }} + > + {t("settings.info.reader_settings.preview_text")} + <br /> + {t("settings.info.reader_settings.preview_text")} + <br /> + {t("settings.info.reader_settings.preview_text")} + </p> + </div> + </CardContent> + </CollapsibleContent> + </Card> + </Collapsible> + ); +} diff --git a/apps/web/components/settings/RegenerateApiKey.tsx b/apps/web/components/settings/RegenerateApiKey.tsx index 1c034026..943d21ef 100644 --- a/apps/web/components/settings/RegenerateApiKey.tsx +++ b/apps/web/components/settings/RegenerateApiKey.tsx @@ -14,11 +14,13 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useMutation } from "@tanstack/react-query"; import { RefreshCcw } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import ApiKeySuccess from "./ApiKeySuccess"; export default function RegenerateApiKey({ @@ -28,25 +30,28 @@ export default function RegenerateApiKey({ id: string; name: string; }) { + const api = useTRPC(); const { t } = useTranslation(); const router = useRouter(); const [key, setKey] = useState<string | undefined>(undefined); const [dialogOpen, setDialogOpen] = useState<boolean>(false); - const mutator = api.apiKeys.regenerate.useMutation({ - onSuccess: (resp) => { - setKey(resp.key); - router.refresh(); - }, - onError: () => { - toast({ - description: t("common.something_went_wrong"), - variant: "destructive", - }); - setDialogOpen(false); - }, - }); + const mutator = useMutation( + api.apiKeys.regenerate.mutationOptions({ + onSuccess: (resp) => { + setKey(resp.key); + router.refresh(); + }, + onError: () => { + toast({ + description: t("common.something_went_wrong"), + variant: "destructive", + }); + setDialogOpen(false); + }, + }), + ); const handleRegenerate = () => { mutator.mutate({ id }); diff --git a/apps/web/components/settings/SubscriptionSettings.tsx b/apps/web/components/settings/SubscriptionSettings.tsx index 53f1caf4..48ab1258 100644 --- a/apps/web/components/settings/SubscriptionSettings.tsx +++ b/apps/web/components/settings/SubscriptionSettings.tsx @@ -1,10 +1,13 @@ "use client"; import { useEffect } from "react"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { CreditCard, Loader2 } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import { Alert, AlertDescription } from "../ui/alert"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; @@ -16,27 +19,29 @@ import { CardTitle, } from "../ui/card"; import { Skeleton } from "../ui/skeleton"; -import { toast } from "../ui/use-toast"; export default function SubscriptionSettings() { + const api = useTRPC(); const { t } = useTranslation(); const { data: subscriptionStatus, refetch, isLoading: isQueryLoading, - } = api.subscriptions.getSubscriptionStatus.useQuery(); + } = useQuery(api.subscriptions.getSubscriptionStatus.queryOptions()); - const { data: subscriptionPrice } = - api.subscriptions.getSubscriptionPrice.useQuery(); + const { data: subscriptionPrice } = useQuery( + api.subscriptions.getSubscriptionPrice.queryOptions(), + ); - const { mutate: syncStripeState } = - api.subscriptions.syncWithStripe.useMutation({ + const { mutate: syncStripeState } = useMutation( + api.subscriptions.syncWithStripe.mutationOptions({ onSuccess: () => { refetch(); }, - }); - const createCheckoutSession = - api.subscriptions.createCheckoutSession.useMutation({ + }), + ); + const createCheckoutSession = useMutation( + api.subscriptions.createCheckoutSession.mutationOptions({ onSuccess: (resp) => { if (resp.url) { window.location.href = resp.url; @@ -48,9 +53,10 @@ export default function SubscriptionSettings() { variant: "destructive", }); }, - }); - const createPortalSession = api.subscriptions.createPortalSession.useMutation( - { + }), + ); + const createPortalSession = useMutation( + api.subscriptions.createPortalSession.mutationOptions({ onSuccess: (resp) => { if (resp.url) { window.location.href = resp.url; @@ -62,7 +68,7 @@ export default function SubscriptionSettings() { variant: "destructive", }); }, - }, + }), ); const isLoading = diff --git a/apps/web/components/settings/UserAvatar.tsx b/apps/web/components/settings/UserAvatar.tsx new file mode 100644 index 00000000..6baff7c2 --- /dev/null +++ b/apps/web/components/settings/UserAvatar.tsx @@ -0,0 +1,149 @@ +"use client"; + +import type { ChangeEvent } from "react"; +import { useRef } from "react"; +import { ActionButton } from "@/components/ui/action-button"; +import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; +import { toast } from "@/components/ui/sonner"; +import { UserAvatar as UserAvatarImage } from "@/components/ui/user-avatar"; +import useUpload from "@/lib/hooks/upload-file"; +import { useTranslation } from "@/lib/i18n/client"; +import { Image as ImageIcon, Upload, User, X } from "lucide-react"; + +import { + useUpdateUserAvatar, + useWhoAmI, +} from "@karakeep/shared-react/hooks/users"; + +import { Button } from "../ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; + +export default function UserAvatar() { + const { t } = useTranslation(); + const fileInputRef = useRef<HTMLInputElement>(null); + const whoami = useWhoAmI(); + const image = whoami.data?.image ?? null; + + const updateAvatar = useUpdateUserAvatar({ + onError: () => { + toast({ + description: t("common.something_went_wrong"), + variant: "destructive", + }); + }, + }); + + const upload = useUpload({ + onSuccess: async (resp) => { + try { + await updateAvatar.mutateAsync({ assetId: resp.assetId }); + toast({ + description: t("settings.info.avatar.updated"), + }); + } catch { + // handled in onError + } + }, + onError: (err) => { + toast({ + description: err.error, + variant: "destructive", + }); + }, + }); + + const isBusy = upload.isPending || updateAvatar.isPending; + + const handleSelectFile = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + upload.mutate(file); + event.target.value = ""; + }; + + return ( + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-xl"> + <ImageIcon className="h-5 w-5" /> + {t("settings.info.avatar.title")} + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <p className="text-sm text-muted-foreground"> + {t("settings.info.avatar.description")} + </p> + <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> + <div className="flex items-center gap-4"> + <div className="flex size-16 items-center justify-center overflow-hidden rounded-full border bg-muted"> + <UserAvatarImage + image={image} + name={t("settings.info.avatar.title")} + fallback={<User className="h-7 w-7 text-muted-foreground" />} + className="h-full w-full" + /> + </div> + <input + ref={fileInputRef} + type="file" + accept="image/*" + className="hidden" + onChange={handleFileChange} + /> + <ActionButton + type="button" + variant="secondary" + onClick={handleSelectFile} + loading={upload.isPending} + disabled={isBusy} + > + <Upload className="mr-2 h-4 w-4" /> + {image + ? t("settings.info.avatar.change") + : t("settings.info.avatar.upload")} + </ActionButton> + </div> + <ActionConfirmingDialog + title={t("settings.info.avatar.remove_confirm_title")} + description={ + <p>{t("settings.info.avatar.remove_confirm_description")}</p> + } + actionButton={(setDialogOpen) => ( + <ActionButton + type="button" + variant="destructive" + loading={updateAvatar.isPending} + onClick={() => + updateAvatar.mutate( + { assetId: null }, + { + onSuccess: () => { + toast({ + description: t("settings.info.avatar.removed"), + }); + setDialogOpen(false); + }, + }, + ) + } + > + {t("settings.info.avatar.remove")} + </ActionButton> + )} + > + <Button type="button" variant="outline" disabled={!image || isBusy}> + <X className="mr-2 h-4 w-4" /> + {t("settings.info.avatar.remove")} + </Button> + </ActionConfirmingDialog> + </div> + </CardContent> + </Card> + ); +} diff --git a/apps/web/components/settings/UserOptions.tsx b/apps/web/components/settings/UserOptions.tsx index 0df1085e..763695c5 100644 --- a/apps/web/components/settings/UserOptions.tsx +++ b/apps/web/components/settings/UserOptions.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { toast } from "@/components/ui/sonner"; import { useClientConfig } from "@/lib/clientConfig"; import { useTranslation } from "@/lib/i18n/client"; import { useInterfaceLang } from "@/lib/userLocalSettings/bookmarksLayout"; @@ -28,7 +29,6 @@ import { SelectTrigger, SelectValue, } from "../ui/select"; -import { toast } from "../ui/use-toast"; const LanguageSelect = () => { const lang = useInterfaceLang(); diff --git a/apps/web/components/settings/WebhookSettings.tsx b/apps/web/components/settings/WebhookSettings.tsx index 8efd3ba6..7a05b9e6 100644 --- a/apps/web/components/settings/WebhookSettings.tsx +++ b/apps/web/components/settings/WebhookSettings.tsx @@ -12,10 +12,10 @@ import { } from "@/components/ui/form"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; import { Input } from "@/components/ui/input"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Edit, KeyRound, @@ -28,6 +28,7 @@ import { import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { zNewWebhookSchema, zUpdateWebhookSchema, @@ -56,9 +57,10 @@ import { import { WebhookEventSelector } from "./WebhookEventSelector"; export function WebhooksEditorDialog() { + const api = useTRPC(); const { t } = useTranslation(); const [open, setOpen] = React.useState(false); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); const form = useForm<z.infer<typeof zNewWebhookSchema>>({ resolver: zodResolver(zNewWebhookSchema), @@ -75,16 +77,17 @@ export function WebhooksEditorDialog() { } }, [open]); - const { mutateAsync: createWebhook, isPending: isCreating } = - api.webhooks.create.useMutation({ + const { mutateAsync: createWebhook, isPending: isCreating } = useMutation( + api.webhooks.create.mutationOptions({ onSuccess: () => { toast({ description: "Webhook has been created!", }); - apiUtils.webhooks.list.invalidate(); + queryClient.invalidateQueries(api.webhooks.list.pathFilter()); setOpen(false); }, - }); + }), + ); return ( <Dialog open={open} onOpenChange={setOpen}> @@ -179,8 +182,9 @@ export function WebhooksEditorDialog() { } export function EditWebhookDialog({ webhook }: { webhook: ZWebhook }) { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); const [open, setOpen] = React.useState(false); React.useEffect(() => { if (open) { @@ -191,16 +195,17 @@ export function EditWebhookDialog({ webhook }: { webhook: ZWebhook }) { }); } }, [open]); - const { mutateAsync: updateWebhook, isPending: isUpdating } = - api.webhooks.update.useMutation({ + const { mutateAsync: updateWebhook, isPending: isUpdating } = useMutation( + api.webhooks.update.mutationOptions({ onSuccess: () => { toast({ description: "Webhook has been updated!", }); setOpen(false); - apiUtils.webhooks.list.invalidate(); + queryClient.invalidateQueries(api.webhooks.list.pathFilter()); }, - }); + }), + ); const updateSchema = zUpdateWebhookSchema.required({ events: true, url: true, @@ -302,8 +307,9 @@ export function EditWebhookDialog({ webhook }: { webhook: ZWebhook }) { } export function EditTokenDialog({ webhook }: { webhook: ZWebhook }) { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); const [open, setOpen] = React.useState(false); React.useEffect(() => { if (open) { @@ -331,16 +337,17 @@ export function EditTokenDialog({ webhook }: { webhook: ZWebhook }) { }, }); - const { mutateAsync: updateWebhook, isPending: isUpdating } = - api.webhooks.update.useMutation({ + const { mutateAsync: updateWebhook, isPending: isUpdating } = useMutation( + api.webhooks.update.mutationOptions({ onSuccess: () => { toast({ description: "Webhook token has been updated!", }); setOpen(false); - apiUtils.webhooks.list.invalidate(); + queryClient.invalidateQueries(api.webhooks.list.pathFilter()); }, - }); + }), + ); return ( <Dialog open={open} onOpenChange={setOpen}> @@ -432,17 +439,19 @@ export function EditTokenDialog({ webhook }: { webhook: ZWebhook }) { } export function WebhookRow({ webhook }: { webhook: ZWebhook }) { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); - const { mutate: deleteWebhook, isPending: isDeleting } = - api.webhooks.delete.useMutation({ + const queryClient = useQueryClient(); + const { mutate: deleteWebhook, isPending: isDeleting } = useMutation( + api.webhooks.delete.mutationOptions({ onSuccess: () => { toast({ description: "Webhook has been deleted!", }); - apiUtils.webhooks.list.invalidate(); + queryClient.invalidateQueries(api.webhooks.list.pathFilter()); }, - }); + }), + ); return ( <TableRow> @@ -479,8 +488,11 @@ export function WebhookRow({ webhook }: { webhook: ZWebhook }) { } export default function WebhookSettings() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: webhooks, isLoading } = api.webhooks.list.useQuery(); + const { data: webhooks, isLoading } = useQuery( + api.webhooks.list.queryOptions(), + ); return ( <div className="rounded-md border bg-background p-4"> <div className="flex flex-col gap-2"> diff --git a/apps/web/components/shared/sidebar/Sidebar.tsx b/apps/web/components/shared/sidebar/Sidebar.tsx index bf5a626b..3f4780e7 100644 --- a/apps/web/components/shared/sidebar/Sidebar.tsx +++ b/apps/web/components/shared/sidebar/Sidebar.tsx @@ -32,7 +32,10 @@ export default async function Sidebar({ </ul> </div> {extraSections} - <SidebarVersion serverVersion={serverConfig.serverVersion} /> + <SidebarVersion + serverVersion={serverConfig.serverVersion} + changeLogVersion={serverConfig.changelogVersion} + /> </aside> ); } diff --git a/apps/web/components/shared/sidebar/SidebarItem.tsx b/apps/web/components/shared/sidebar/SidebarItem.tsx index e602a435..eb61d48b 100644 --- a/apps/web/components/shared/sidebar/SidebarItem.tsx +++ b/apps/web/components/shared/sidebar/SidebarItem.tsx @@ -14,6 +14,11 @@ export default function SidebarItem({ style, collapseButton, right = null, + dropHighlight = false, + onDrop, + onDragOver, + onDragEnter, + onDragLeave, }: { name: string; logo: React.ReactNode; @@ -23,6 +28,11 @@ export default function SidebarItem({ linkClassName?: string; right?: React.ReactNode; collapseButton?: React.ReactNode; + dropHighlight?: boolean; + onDrop?: React.DragEventHandler; + onDragOver?: React.DragEventHandler; + onDragEnter?: React.DragEventHandler; + onDragLeave?: React.DragEventHandler; }) { const currentPath = usePathname(); return ( @@ -32,9 +42,14 @@ export default function SidebarItem({ path == currentPath ? "bg-accent/50 text-foreground" : "text-muted-foreground", + dropHighlight && "bg-accent ring-2 ring-primary", className, )} style={style} + onDrop={onDrop} + onDragOver={onDragOver} + onDragEnter={onDragEnter} + onDragLeave={onDragLeave} > <div className="flex-1"> {collapseButton} diff --git a/apps/web/components/shared/sidebar/SidebarLayout.tsx b/apps/web/components/shared/sidebar/SidebarLayout.tsx index 8ea8655e..e1b35634 100644 --- a/apps/web/components/shared/sidebar/SidebarLayout.tsx +++ b/apps/web/components/shared/sidebar/SidebarLayout.tsx @@ -1,7 +1,11 @@ +import { Suspense } from "react"; +import ErrorFallback from "@/components/dashboard/ErrorFallback"; import Header from "@/components/dashboard/header/Header"; import DemoModeBanner from "@/components/DemoModeBanner"; import { Separator } from "@/components/ui/separator"; +import LoadingSpinner from "@/components/ui/spinner"; import ValidAccountCheck from "@/components/utils/ValidAccountCheck"; +import { ErrorBoundary } from "react-error-boundary"; import serverConfig from "@karakeep/shared/config"; @@ -29,7 +33,11 @@ export default function SidebarLayout({ <Separator /> </div> {modal} - <div className="min-h-30 container p-4">{children}</div> + <div className="min-h-30 container p-4"> + <ErrorBoundary fallback={<ErrorFallback />}> + <Suspense fallback={<LoadingSpinner />}>{children}</Suspense> + </ErrorBoundary> + </div> </main> </div> </div> diff --git a/apps/web/components/shared/sidebar/SidebarVersion.tsx b/apps/web/components/shared/sidebar/SidebarVersion.tsx index fc2ec5a3..2d6d3380 100644 --- a/apps/web/components/shared/sidebar/SidebarVersion.tsx +++ b/apps/web/components/shared/sidebar/SidebarVersion.tsx @@ -46,36 +46,50 @@ function isStableRelease(version?: string) { } interface SidebarVersionProps { + // The actual version of the server serverVersion?: string; + // The version that should be displayed in the changelog + changeLogVersion?: string; } -export default function SidebarVersion({ serverVersion }: SidebarVersionProps) { +export default function SidebarVersion({ + serverVersion, + changeLogVersion, +}: SidebarVersionProps) { const { disableNewReleaseCheck } = useClientConfig(); const { t } = useTranslation(); - const stableRelease = isStableRelease(serverVersion); + const effectiveChangelogVersion = changeLogVersion ?? serverVersion; + const stableRelease = isStableRelease(effectiveChangelogVersion); const displayVersion = serverVersion ?? "unknown"; + const changelogDisplayVersion = effectiveChangelogVersion ?? displayVersion; const versionLabel = `Karakeep v${displayVersion}`; const releasePageUrl = useMemo(() => { - if (!serverVersion || !isStableRelease(serverVersion)) { + if ( + !effectiveChangelogVersion || + !isStableRelease(effectiveChangelogVersion) + ) { return GITHUB_REPO_URL; } - return `${GITHUB_RELEASE_URL}v${serverVersion}`; - }, [serverVersion]); + return `${GITHUB_RELEASE_URL}v${effectiveChangelogVersion}`; + }, [effectiveChangelogVersion]); const [open, setOpen] = useState(false); const [shouldNotify, setShouldNotify] = useState(false); const releaseNotesQuery = useQuery<string>({ - queryKey: ["sidebar-release-notes", serverVersion], + queryKey: ["sidebar-release-notes", effectiveChangelogVersion], queryFn: async ({ signal }) => { - if (!serverVersion) { + if (!effectiveChangelogVersion) { return ""; } - const response = await fetch(`${RELEASE_API_URL}v${serverVersion}`, { - signal, - }); + const response = await fetch( + `${RELEASE_API_URL}v${effectiveChangelogVersion}`, + { + signal, + }, + ); if (!response.ok) { throw new Error("Failed to load release notes"); @@ -89,7 +103,7 @@ export default function SidebarVersion({ serverVersion }: SidebarVersionProps) { open && stableRelease && !disableNewReleaseCheck && - Boolean(serverVersion), + Boolean(effectiveChangelogVersion), staleTime: RELEASE_NOTES_STALE_TIME, retry: 1, refetchOnWindowFocus: false, @@ -123,30 +137,34 @@ export default function SidebarVersion({ serverVersion }: SidebarVersionProps) { }, [releaseNotesQuery.error, t]); useEffect(() => { - if (!stableRelease || !serverVersion || disableNewReleaseCheck) { + if ( + !stableRelease || + !effectiveChangelogVersion || + disableNewReleaseCheck + ) { setShouldNotify(false); return; } try { const seenVersion = window.localStorage.getItem(LOCAL_STORAGE_KEY); - setShouldNotify(seenVersion !== serverVersion); + setShouldNotify(seenVersion !== effectiveChangelogVersion); } catch (error) { console.warn("Failed to read localStorage:", error); setShouldNotify(true); } - }, [serverVersion, stableRelease, disableNewReleaseCheck]); + }, [effectiveChangelogVersion, stableRelease, disableNewReleaseCheck]); const markReleaseAsSeen = useCallback(() => { - if (!serverVersion) return; + if (!effectiveChangelogVersion) return; try { - window.localStorage.setItem(LOCAL_STORAGE_KEY, serverVersion); + window.localStorage.setItem(LOCAL_STORAGE_KEY, effectiveChangelogVersion); } catch (error) { console.warn("Failed to write to localStorage:", error); // Ignore failures, we still clear the notification for the session } setShouldNotify(false); - }, [serverVersion]); + }, [effectiveChangelogVersion]); const handleOpenChange = useCallback( (nextOpen: boolean) => { @@ -202,7 +220,9 @@ export default function SidebarVersion({ serverVersion }: SidebarVersionProps) { <DialogContent className="max-w-3xl"> <DialogHeader> <DialogTitle> - {t("version.whats_new_title", { version: displayVersion })} + {t("version.whats_new_title", { + version: changelogDisplayVersion, + })} </DialogTitle> <DialogDescription> {t("version.release_notes_description")} diff --git a/apps/web/components/signin/CredentialsForm.tsx b/apps/web/components/signin/CredentialsForm.tsx index 4a4a0533..0ff5b1d0 100644 --- a/apps/web/components/signin/CredentialsForm.tsx +++ b/apps/web/components/signin/CredentialsForm.tsx @@ -14,10 +14,10 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { signIn } from "@/lib/auth/client"; import { useClientConfig } from "@/lib/clientConfig"; import { zodResolver } from "@hookform/resolvers/zod"; import { AlertCircle, Lock } from "lucide-react"; -import { signIn } from "next-auth/react"; import { useForm } from "react-hook-form"; import { z } from "zod"; diff --git a/apps/web/components/signin/ForgotPasswordForm.tsx b/apps/web/components/signin/ForgotPasswordForm.tsx index 29d55f2b..7ba37553 100644 --- a/apps/web/components/signin/ForgotPasswordForm.tsx +++ b/apps/web/components/signin/ForgotPasswordForm.tsx @@ -20,18 +20,21 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; import { TRPCClientError } from "@trpc/client"; import { AlertCircle, CheckCircle } from "lucide-react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + const forgotPasswordSchema = z.object({ email: z.string().email("Please enter a valid email address"), }); export default function ForgotPasswordForm() { + const api = useTRPC(); const [isSubmitted, setIsSubmitted] = useState(false); const [errorMessage, setErrorMessage] = useState(""); const router = useRouter(); @@ -40,7 +43,9 @@ export default function ForgotPasswordForm() { resolver: zodResolver(forgotPasswordSchema), }); - const forgotPasswordMutation = api.users.forgotPassword.useMutation(); + const forgotPasswordMutation = useMutation( + api.users.forgotPassword.mutationOptions(), + ); const onSubmit = async (values: z.infer<typeof forgotPasswordSchema>) => { try { diff --git a/apps/web/components/signin/ResetPasswordForm.tsx b/apps/web/components/signin/ResetPasswordForm.tsx index d4d8a285..571a09ae 100644 --- a/apps/web/components/signin/ResetPasswordForm.tsx +++ b/apps/web/components/signin/ResetPasswordForm.tsx @@ -20,13 +20,14 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; import { TRPCClientError } from "@trpc/client"; import { AlertCircle, CheckCircle } from "lucide-react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { zResetPasswordSchema } from "@karakeep/shared/types/users"; const resetPasswordSchema = z @@ -44,6 +45,7 @@ interface ResetPasswordFormProps { } export default function ResetPasswordForm({ token }: ResetPasswordFormProps) { + const api = useTRPC(); const [isSuccess, setIsSuccess] = useState(false); const [errorMessage, setErrorMessage] = useState(""); const router = useRouter(); @@ -52,7 +54,9 @@ export default function ResetPasswordForm({ token }: ResetPasswordFormProps) { resolver: zodResolver(resetPasswordSchema), }); - const resetPasswordMutation = api.users.resetPassword.useMutation(); + const resetPasswordMutation = useMutation( + api.users.resetPassword.mutationOptions(), + ); const onSubmit = async (values: z.infer<typeof resetPasswordSchema>) => { try { diff --git a/apps/web/components/signin/SignInProviderButton.tsx b/apps/web/components/signin/SignInProviderButton.tsx index edb411e6..4b218e2a 100644 --- a/apps/web/components/signin/SignInProviderButton.tsx +++ b/apps/web/components/signin/SignInProviderButton.tsx @@ -1,7 +1,7 @@ "use client"; import { Button } from "@/components/ui/button"; -import { signIn } from "next-auth/react"; +import { signIn } from "@/lib/auth/client"; export default function SignInProviderButton({ provider, diff --git a/apps/web/components/signup/SignUpForm.tsx b/apps/web/components/signup/SignUpForm.tsx index 340b461a..15b64fab 100644 --- a/apps/web/components/signup/SignUpForm.tsx +++ b/apps/web/components/signup/SignUpForm.tsx @@ -23,21 +23,28 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { signIn } from "@/lib/auth/client"; import { useClientConfig } from "@/lib/clientConfig"; -import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; import { Turnstile } from "@marsidev/react-turnstile"; +import { useMutation } from "@tanstack/react-query"; import { TRPCClientError } from "@trpc/client"; import { AlertCircle, UserX } from "lucide-react"; -import { signIn } from "next-auth/react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { zSignUpSchema } from "@karakeep/shared/types/users"; +import { isMobileAppRedirect } from "@karakeep/shared/utils/redirectUrl"; const VERIFY_EMAIL_ERROR = "Please verify your email address before signing in"; -export default function SignUpForm() { +interface SignUpFormProps { + redirectUrl: string; +} + +export default function SignUpForm({ redirectUrl }: SignUpFormProps) { + const api = useTRPC(); const form = useForm<z.infer<typeof zSignUpSchema>>({ resolver: zodResolver(zSignUpSchema), defaultValues: { @@ -54,7 +61,7 @@ export default function SignUpForm() { const turnstileSiteKey = clientConfig.turnstile?.siteKey; const turnstileRef = useRef<TurnstileInstance>(null); - const createUserMutation = api.users.create.useMutation(); + const createUserMutation = useMutation(api.users.create.mutationOptions()); if ( clientConfig.auth.disableSignups || @@ -111,7 +118,10 @@ export default function SignUpForm() { } form.clearErrors("turnstileToken"); try { - await createUserMutation.mutateAsync(value); + await createUserMutation.mutateAsync({ + ...value, + redirectUrl, + }); } catch (e) { if (e instanceof TRPCClientError) { setErrorMessage(e.message); @@ -131,7 +141,7 @@ export default function SignUpForm() { if (!resp || !resp.ok || resp.error) { if (resp?.error === VERIFY_EMAIL_ERROR) { router.replace( - `/check-email?email=${encodeURIComponent(value.email.trim())}`, + `/check-email?email=${encodeURIComponent(value.email.trim())}&redirectUrl=${encodeURIComponent(redirectUrl)}`, ); } else { setErrorMessage( @@ -145,7 +155,11 @@ export default function SignUpForm() { } return; } - router.replace("/"); + if (isMobileAppRedirect(redirectUrl)) { + window.location.href = redirectUrl; + } else { + router.replace(redirectUrl); + } })} className="space-y-4" > diff --git a/apps/web/components/subscription/QuotaProgress.tsx b/apps/web/components/subscription/QuotaProgress.tsx index 525eae8f..29bb7fc9 100644 --- a/apps/web/components/subscription/QuotaProgress.tsx +++ b/apps/web/components/subscription/QuotaProgress.tsx @@ -1,9 +1,11 @@ "use client"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; import { Database, HardDrive } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import { Card, CardContent, @@ -110,9 +112,11 @@ function QuotaProgressItem({ } export function QuotaProgress() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: quotaUsage, isLoading } = - api.subscriptions.getQuotaUsage.useQuery(); + const { data: quotaUsage, isLoading } = useQuery( + api.subscriptions.getQuotaUsage.queryOptions(), + ); if (isLoading) { return ( diff --git a/apps/web/components/theme-provider.tsx b/apps/web/components/theme-provider.tsx index 1ab9a49d..1179bdfe 100644 --- a/apps/web/components/theme-provider.tsx +++ b/apps/web/components/theme-provider.tsx @@ -5,7 +5,11 @@ import * as React from "react"; import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes"; export function ThemeProvider({ children, ...props }: ThemeProviderProps) { - return <NextThemesProvider {...props}>{children}</NextThemesProvider>; + return ( + <NextThemesProvider scriptProps={{ "data-cfasync": "false" }} {...props}> + {children} + </NextThemesProvider> + ); } export function useToggleTheme() { diff --git a/apps/web/components/ui/avatar.tsx b/apps/web/components/ui/avatar.tsx new file mode 100644 index 00000000..48ec676b --- /dev/null +++ b/apps/web/components/ui/avatar.tsx @@ -0,0 +1,49 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +const Avatar = React.forwardRef< + React.ElementRef<typeof AvatarPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> +>(({ className, ...props }, ref) => ( + <AvatarPrimitive.Root + ref={ref} + className={cn( + "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", + className, + )} + {...props} + /> +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef<typeof AvatarPrimitive.Image>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> +>(({ className, ...props }, ref) => ( + <AvatarPrimitive.Image + ref={ref} + className={cn("aspect-square h-full w-full", className)} + {...props} + /> +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef<typeof AvatarPrimitive.Fallback>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> +>(({ className, ...props }, ref) => ( + <AvatarPrimitive.Fallback + ref={ref} + className={cn( + "flex h-full w-full items-center justify-center rounded-full bg-black text-white", + className, + )} + {...props} + /> +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/apps/web/components/ui/copy-button.tsx b/apps/web/components/ui/copy-button.tsx index 8d8699f8..fb1f943f 100644 --- a/apps/web/components/ui/copy-button.tsx +++ b/apps/web/components/ui/copy-button.tsx @@ -1,9 +1,9 @@ import React, { useEffect, useState } from "react";
+import { toast } from "@/components/ui/sonner";
import { cn } from "@/lib/utils";
import { Check, Copy } from "lucide-react";
import { Button } from "./button";
-import { toast } from "./use-toast";
export default function CopyBtn({
className,
diff --git a/apps/web/components/ui/field.tsx b/apps/web/components/ui/field.tsx new file mode 100644 index 00000000..a52897f5 --- /dev/null +++ b/apps/web/components/ui/field.tsx @@ -0,0 +1,244 @@ +"use client"; + +import type { VariantProps } from "class-variance-authority"; +import { useMemo } from "react"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; +import { cva } from "class-variance-authority"; + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( + <fieldset + data-slot="field-set" + className={cn( + "flex flex-col gap-6", + "has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className, + )} + {...props} + /> + ); +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + <legend + data-slot="field-legend" + data-variant={variant} + className={cn( + "mb-3 font-medium", + "data-[variant=legend]:text-base", + "data-[variant=label]:text-sm", + className, + )} + {...props} + /> + ); +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="field-group" + className={cn( + "group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4", + className, + )} + {...props} + /> + ); +} + +const fieldVariants = cva( + "group/field flex w-full gap-3 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], + horizontal: [ + "flex-row items-center", + "[&>[data-slot=field-label]]:flex-auto", + "has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px has-[>[data-slot=field-content]]:items-start", + ], + responsive: [ + "@md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto flex-col [&>*]:w-full [&>.sr-only]:w-auto", + "@md/field-group:[&>[data-slot=field-label]]:flex-auto", + "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + }, + }, + defaultVariants: { + orientation: "vertical", + }, + }, +); + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) { + return ( + <div + role="group" + data-slot="field" + data-orientation={orientation} + className={cn(fieldVariants({ orientation }), className)} + {...props} + /> + ); +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="field-content" + className={cn( + "group/field-content flex flex-1 flex-col gap-1.5 leading-snug", + className, + )} + {...props} + /> + ); +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps<typeof Label>) { + return ( + <Label + data-slot="field-label" + className={cn( + "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50", + "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>[data-slot=field]]:p-4", + "has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10", + className, + )} + {...props} + /> + ); +} + +function FieldTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="field-label" + className={cn( + "flex w-fit items-center gap-2 text-sm font-medium leading-snug group-data-[disabled=true]/field:opacity-50", + className, + )} + {...props} + /> + ); +} + +function FieldDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( + <p + data-slot="field-description" + className={cn( + "text-sm font-normal leading-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance", + "nth-last-2:-mt-1 last:mt-0 [[data-variant=legend]+&]:-mt-1.5", + "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", + className, + )} + {...props} + /> + ); +} + +function FieldSeparator({ + children, + className, + ...props +}: React.ComponentProps<"div"> & { + children?: React.ReactNode; +}) { + return ( + <div + data-slot="field-separator" + data-content={!!children} + className={cn( + "relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2", + className, + )} + {...props} + > + <Separator className="absolute inset-0 top-1/2" /> + {children && ( + <span + className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground" + data-slot="field-separator-content" + > + {children} + </span> + )} + </div> + ); +} + +function FieldError({ + className, + children, + errors, + ...props +}: React.ComponentProps<"div"> & { + errors?: ({ message?: string } | undefined)[]; +}) { + const content = useMemo(() => { + if (children) { + return children; + } + + if (!errors) { + return null; + } + + if (errors?.length === 1 && errors[0]?.message) { + return errors[0].message; + } + + return ( + <ul className="ml-4 flex list-disc flex-col gap-1"> + {errors.map( + (error, index) => + error?.message && <li key={index}>{error.message}</li>, + )} + </ul> + ); + }, [children, errors]); + + if (!content) { + return null; + } + + return ( + <div + role="alert" + data-slot="field-error" + className={cn("text-sm font-normal text-destructive", className)} + {...props} + > + {content} + </div> + ); +} + +export { + Field, + FieldLabel, + FieldDescription, + FieldError, + FieldGroup, + FieldLegend, + FieldSeparator, + FieldSet, + FieldContent, + FieldTitle, +}; diff --git a/apps/web/components/ui/info-tooltip.tsx b/apps/web/components/ui/info-tooltip.tsx index 4dd97199..9d525983 100644 --- a/apps/web/components/ui/info-tooltip.tsx +++ b/apps/web/components/ui/info-tooltip.tsx @@ -22,8 +22,7 @@ export default function InfoTooltip({ <TooltipTrigger asChild> {variant === "tip" ? ( <Info - color="#494949" - className={cn("z-10 cursor-pointer", className)} + className={cn("z-10 cursor-pointer text-[#494949]", className)} size={size} /> ) : ( diff --git a/apps/web/components/ui/radio-group.tsx b/apps/web/components/ui/radio-group.tsx new file mode 100644 index 00000000..0da1136e --- /dev/null +++ b/apps/web/components/ui/radio-group.tsx @@ -0,0 +1,43 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import { Circle } from "lucide-react"; + +const RadioGroup = React.forwardRef< + React.ElementRef<typeof RadioGroupPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> +>(({ className, ...props }, ref) => { + return ( + <RadioGroupPrimitive.Root + className={cn("grid gap-2", className)} + {...props} + ref={ref} + /> + ); +}); +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; + +const RadioGroupItem = React.forwardRef< + React.ElementRef<typeof RadioGroupPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> +>(({ className, ...props }, ref) => { + return ( + <RadioGroupPrimitive.Item + ref={ref} + className={cn( + "aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", + className, + )} + {...props} + > + <RadioGroupPrimitive.Indicator className="flex items-center justify-center"> + <Circle className="h-2.5 w-2.5 fill-current text-current" /> + </RadioGroupPrimitive.Indicator> + </RadioGroupPrimitive.Item> + ); +}); +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; + +export { RadioGroup, RadioGroupItem }; diff --git a/apps/web/components/ui/sonner.tsx b/apps/web/components/ui/sonner.tsx new file mode 100644 index 00000000..d281f4ae --- /dev/null +++ b/apps/web/components/ui/sonner.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { + CircleCheck, + Info, + LoaderCircle, + OctagonX, + TriangleAlert, +} from "lucide-react"; +import { useTheme } from "next-themes"; +import { Toaster as Sonner, toast } from "sonner"; + +type ToasterProps = React.ComponentProps<typeof Sonner>; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme(); + + return ( + <Sonner + theme={theme as ToasterProps["theme"]} + className="toaster group" + icons={{ + success: <CircleCheck className="h-4 w-4" />, + info: <Info className="h-4 w-4" />, + warning: <TriangleAlert className="h-4 w-4" />, + error: <OctagonX className="h-4 w-4" />, + loading: <LoaderCircle className="h-4 w-4 animate-spin" />, + }} + toastOptions={{ + classNames: { + toast: + "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", + description: "group-[.toast]:text-muted-foreground", + actionButton: + "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", + cancelButton: + "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", + }, + }} + {...props} + /> + ); +}; + +/** + * Compat layer for migrating from old toaster to sonner + * @deprecated Use sonner's natie toast instead + */ +const legacyToast = ({ + title, + description, + variant, +}: { + title?: React.ReactNode; + description?: React.ReactNode; + variant?: "destructive" | "default"; +}) => { + let toastTitle = title; + let toastDescription: React.ReactNode | undefined = description; + if (!title) { + toastTitle = description; + toastDescription = undefined; + } + if (variant === "destructive") { + toast.error(toastTitle, { description: toastDescription }); + } else { + toast(toastTitle, { description: toastDescription }); + } +}; + +export { Toaster, legacyToast as toast }; diff --git a/apps/web/components/ui/toaster.tsx b/apps/web/components/ui/toaster.tsx deleted file mode 100644 index 7d82ed55..00000000 --- a/apps/web/components/ui/toaster.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -import { - Toast, - ToastClose, - ToastDescription, - ToastProvider, - ToastTitle, - ToastViewport, -} from "@/components/ui/toast"; -import { useToast } from "@/components/ui/use-toast"; - -export function Toaster() { - const { toasts } = useToast(); - - return ( - <ToastProvider> - {toasts.map(function ({ id, title, description, action, ...props }) { - return ( - <Toast key={id} {...props}> - <div className="grid gap-1"> - {title && <ToastTitle>{title}</ToastTitle>} - {description && ( - <ToastDescription>{description}</ToastDescription> - )} - </div> - {action} - <ToastClose /> - </Toast> - ); - })} - <ToastViewport /> - </ToastProvider> - ); -} diff --git a/apps/web/components/ui/use-toast.ts b/apps/web/components/ui/use-toast.ts deleted file mode 100644 index c3e7e884..00000000 --- a/apps/web/components/ui/use-toast.ts +++ /dev/null @@ -1,188 +0,0 @@ -// Inspired by react-hot-toast library -import type { ToastActionElement, ToastProps } from "@/components/ui/toast"; -import * as React from "react"; - -const TOAST_LIMIT = 10; -const TOAST_REMOVE_DELAY = 1000000; - -type ToasterToast = ToastProps & { - id: string; - title?: React.ReactNode; - description?: React.ReactNode; - action?: ToastActionElement; -}; - -const actionTypes = { - ADD_TOAST: "ADD_TOAST", - UPDATE_TOAST: "UPDATE_TOAST", - DISMISS_TOAST: "DISMISS_TOAST", - REMOVE_TOAST: "REMOVE_TOAST", -} as const; - -let count = 0; - -function genId() { - count = (count + 1) % Number.MAX_SAFE_INTEGER; - return count.toString(); -} - -type ActionType = typeof actionTypes; - -type Action = - | { - type: ActionType["ADD_TOAST"]; - toast: ToasterToast; - } - | { - type: ActionType["UPDATE_TOAST"]; - toast: Partial<ToasterToast>; - } - | { - type: ActionType["DISMISS_TOAST"]; - toastId?: ToasterToast["id"]; - } - | { - type: ActionType["REMOVE_TOAST"]; - toastId?: ToasterToast["id"]; - }; - -interface State { - toasts: ToasterToast[]; -} - -const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>(); - -const addToRemoveQueue = (toastId: string) => { - if (toastTimeouts.has(toastId)) { - return; - } - - const timeout = setTimeout(() => { - toastTimeouts.delete(toastId); - dispatch({ - type: "REMOVE_TOAST", - toastId: toastId, - }); - }, TOAST_REMOVE_DELAY); - - toastTimeouts.set(toastId, timeout); -}; - -export const reducer = (state: State, action: Action): State => { - switch (action.type) { - case "ADD_TOAST": - return { - ...state, - toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), - }; - - case "UPDATE_TOAST": - return { - ...state, - toasts: state.toasts.map((t) => - t.id === action.toast.id ? { ...t, ...action.toast } : t, - ), - }; - - case "DISMISS_TOAST": { - const { toastId } = action; - - // ! Side effects ! - This could be extracted into a dismissToast() action, - // but I'll keep it here for simplicity - if (toastId) { - addToRemoveQueue(toastId); - } else { - state.toasts.forEach((toast) => { - addToRemoveQueue(toast.id); - }); - } - - return { - ...state, - toasts: state.toasts.map((t) => - t.id === toastId || toastId === undefined - ? { - ...t, - open: false, - } - : t, - ), - }; - } - case "REMOVE_TOAST": - if (action.toastId === undefined) { - return { - ...state, - toasts: [], - }; - } - return { - ...state, - toasts: state.toasts.filter((t) => t.id !== action.toastId), - }; - } -}; - -const listeners: ((_state: State) => void)[] = []; - -let memoryState: State = { toasts: [] }; - -function dispatch(action: Action) { - memoryState = reducer(memoryState, action); - listeners.forEach((listener) => { - listener(memoryState); - }); -} - -type Toast = Omit<ToasterToast, "id">; - -function toast({ ...props }: Toast) { - const id = genId(); - - const update = (props: ToasterToast) => - dispatch({ - type: "UPDATE_TOAST", - toast: { ...props, id }, - }); - const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); - - dispatch({ - type: "ADD_TOAST", - toast: { - ...props, - id, - open: true, - onOpenChange: (open) => { - if (!open) dismiss(); - }, - }, - }); - - return { - id: id, - dismiss, - update, - }; -} - -function useToast() { - const [state, setState] = React.useState<State>(memoryState); - - React.useEffect(() => { - listeners.push(setState); - return () => { - const index = listeners.indexOf(setState); - if (index > -1) { - listeners.splice(index, 1); - } - }; - }, [state]); - - return { - ...state, - toast, - dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), - }; -} - -export { useToast, toast }; diff --git a/apps/web/components/ui/user-avatar.tsx b/apps/web/components/ui/user-avatar.tsx new file mode 100644 index 00000000..4ebb6ec3 --- /dev/null +++ b/apps/web/components/ui/user-avatar.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useMemo } from "react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { cn } from "@/lib/utils"; + +import { getAssetUrl } from "@karakeep/shared/utils/assetUtils"; + +interface UserAvatarProps { + image?: string | null; + name?: string | null; + className?: string; + imgClassName?: string; + fallbackClassName?: string; + fallback?: React.ReactNode; +} + +const isExternalUrl = (value: string) => + value.startsWith("http://") || value.startsWith("https://"); + +export function UserAvatar({ + image, + name, + className, + imgClassName, + fallbackClassName, + fallback, +}: UserAvatarProps) { + const avatarUrl = useMemo(() => { + if (!image) { + return null; + } + return isExternalUrl(image) ? image : getAssetUrl(image); + }, [image]); + + const fallbackContent = fallback ?? name?.charAt(0) ?? "U"; + + return ( + <Avatar className={className}> + {avatarUrl && ( + <AvatarImage + src={avatarUrl} + alt={name ?? "User"} + className={cn("object-cover", imgClassName)} + /> + )} + <AvatarFallback className={cn("text-sm font-medium", fallbackClassName)}> + {fallbackContent} + </AvatarFallback> + </Avatar> + ); +} diff --git a/apps/web/components/utils/ValidAccountCheck.tsx b/apps/web/components/utils/ValidAccountCheck.tsx index 5ca5fd5c..54d27b34 100644 --- a/apps/web/components/utils/ValidAccountCheck.tsx +++ b/apps/web/components/utils/ValidAccountCheck.tsx @@ -2,22 +2,27 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; /** * This component is used to address a confusion when the JWT token exists but the user no longer exists in the database. * So this component synchronusly checks if the user is still valid and if not, signs out the user. */ export default function ValidAccountCheck() { + const api = useTRPC(); const router = useRouter(); - const { error } = api.users.whoami.useQuery(undefined, { - retry: (_failureCount, error) => { - if (error.data?.code === "UNAUTHORIZED") { - return false; - } - return true; - }, - }); + const { error } = useQuery( + api.users.whoami.queryOptions(undefined, { + retry: (_failureCount, error) => { + if (error.data?.code === "UNAUTHORIZED") { + return false; + } + return true; + }, + }), + ); useEffect(() => { if (error?.data?.code === "UNAUTHORIZED") { router.push("/logout"); diff --git a/apps/web/components/wrapped/ShareButton.tsx b/apps/web/components/wrapped/ShareButton.tsx new file mode 100644 index 00000000..048cafea --- /dev/null +++ b/apps/web/components/wrapped/ShareButton.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { RefObject, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Download, Loader2, Share2 } from "lucide-react"; +import { domToPng } from "modern-screenshot"; + +interface ShareButtonProps { + contentRef: RefObject<HTMLDivElement | null>; + fileName?: string; +} + +export function ShareButton({ + contentRef, + fileName = "karakeep-wrapped-2025.png", +}: ShareButtonProps) { + const [isGenerating, setIsGenerating] = useState(false); + + const handleShare = async () => { + if (!contentRef.current) return; + + setIsGenerating(true); + + try { + // Capture the content as PNG data URL + const dataUrl = await domToPng(contentRef.current, { + scale: 2, // Higher resolution + quality: 1, + debug: false, + width: contentRef.current.scrollWidth, // Capture full width + height: contentRef.current.scrollHeight, // Capture full height including scrolled content + drawImageInterval: 100, // Add delay for rendering + }); + + // Convert data URL to blob + const response = await fetch(dataUrl); + const blob = await response.blob(); + + // Try native share API first (works well on mobile) + if ( + typeof navigator.share !== "undefined" && + typeof navigator.canShare !== "undefined" + ) { + const file = new File([blob], fileName, { type: "image/png" }); + if (navigator.canShare({ files: [file] })) { + await navigator.share({ + files: [file], + title: "My 2025 Karakeep Wrapped", + text: "Check out my 2025 Karakeep Wrapped!", + }); + return; + } + } + + // Fallback: download the image + const a = document.createElement("a"); + a.href = dataUrl; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } catch (error) { + console.error("Failed to capture or share image:", error); + } finally { + setIsGenerating(false); + } + }; + + const isNativeShareAvailable = + typeof navigator.share !== "undefined" && + typeof navigator.canShare !== "undefined"; + + return ( + <Button + onClick={handleShare} + disabled={isGenerating} + size="icon" + variant="ghost" + className="h-10 w-10 rounded-full bg-white/10 text-slate-100 hover:bg-white/20" + aria-label={isNativeShareAvailable ? "Share" : "Download"} + title={isNativeShareAvailable ? "Share" : "Download"} + > + {isGenerating ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : isNativeShareAvailable ? ( + <Share2 className="h-4 w-4" /> + ) : ( + <Download className="h-4 w-4" /> + )} + </Button> + ); +} diff --git a/apps/web/components/wrapped/WrappedContent.tsx b/apps/web/components/wrapped/WrappedContent.tsx new file mode 100644 index 00000000..261aadfd --- /dev/null +++ b/apps/web/components/wrapped/WrappedContent.tsx @@ -0,0 +1,390 @@ +"use client"; + +import { forwardRef } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { + BookOpen, + Calendar, + Chrome, + Clock, + Code, + FileText, + Globe, + Hash, + Heart, + Highlighter, + Link, + Rss, + Smartphone, + Upload, + Zap, +} from "lucide-react"; +import { z } from "zod"; + +import { zBookmarkSourceSchema } from "@karakeep/shared/types/bookmarks"; +import { zWrappedStatsResponseSchema } from "@karakeep/shared/types/users"; + +type WrappedStats = z.infer<typeof zWrappedStatsResponseSchema>; +type BookmarkSource = z.infer<typeof zBookmarkSourceSchema>; + +interface WrappedContentProps { + stats: WrappedStats; + userName?: string; +} + +const dayNames = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +]; +const monthNames = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +function formatSourceName(source: BookmarkSource | null): string { + if (!source) return "Unknown"; + const sourceMap: Record<BookmarkSource, string> = { + api: "API", + web: "Web", + extension: "Browser Extension", + cli: "CLI", + mobile: "Mobile App", + singlefile: "SingleFile", + rss: "RSS Feed", + import: "Import", + }; + return sourceMap[source]; +} + +function getSourceIcon(source: BookmarkSource | null, className = "h-5 w-5") { + const iconProps = { className }; + switch (source) { + case "api": + return <Zap {...iconProps} />; + case "web": + return <Globe {...iconProps} />; + case "extension": + return <Chrome {...iconProps} />; + case "cli": + return <Code {...iconProps} />; + case "mobile": + return <Smartphone {...iconProps} />; + case "singlefile": + return <FileText {...iconProps} />; + case "rss": + return <Rss {...iconProps} />; + case "import": + return <Upload {...iconProps} />; + default: + return <Globe {...iconProps} />; + } +} + +export const WrappedContent = forwardRef<HTMLDivElement, WrappedContentProps>( + ({ stats, userName }, ref) => { + const maxMonthlyCount = Math.max( + ...stats.monthlyActivity.map((m) => m.count), + ); + + return ( + <div + ref={ref} + className="min-h-screen w-full overflow-auto bg-slate-950 bg-[radial-gradient(1200px_600px_at_20%_-10%,rgba(16,185,129,0.18),transparent),radial-gradient(900px_500px_at_90%_10%,rgba(14,116,144,0.2),transparent)] p-6 text-slate-100 md:p-8" + > + <div className="mx-auto max-w-5xl space-y-4"> + {/* Header */} + <div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between"> + <div> + <h1 className="text-2xl font-semibold md:text-3xl"> + Your {stats.year} Wrapped + </h1> + <p className="mt-1 text-xs text-slate-300 md:text-sm"> + A Year in Karakeep + </p> + {userName && ( + <p className="mt-2 text-sm text-slate-400">{userName}</p> + )} + </div> + </div> + + <div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3"> + <Card className="flex flex-col items-center justify-center border border-white/10 bg-white/5 p-4 text-center text-slate-100 backdrop-blur-sm"> + <p className="text-xs text-slate-300">You saved</p> + <p className="my-2 text-3xl font-semibold md:text-4xl"> + {stats.totalBookmarks} + </p> + <p className="text-xs text-slate-300"> + {stats.totalBookmarks === 1 ? "item" : "items"} this year + </p> + </Card> + {/* First Bookmark */} + {stats.firstBookmark && ( + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm"> + <div className="flex h-full flex-col"> + <div className="mb-3 flex items-center gap-2"> + <Calendar className="h-4 w-4 flex-shrink-0 text-emerald-300" /> + <p className="text-[10px] uppercase tracking-wide text-slate-400"> + First Bookmark of {stats.year} + </p> + </div> + <div className="flex-1"> + <p className="text-2xl font-bold text-slate-100"> + {new Date( + stats.firstBookmark.createdAt, + ).toLocaleDateString("en-US", { + month: "long", + day: "numeric", + })} + </p> + {stats.firstBookmark.title && ( + <p className="mt-2 line-clamp-2 text-base leading-relaxed text-slate-300"> + “{stats.firstBookmark.title}” + </p> + )} + </div> + </div> + </Card> + )} + + {/* Activity + Peak */} + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm"> + <h2 className="mb-2 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-300"> + <Clock className="h-4 w-4" /> + Activity Highlights + </h2> + <div className="grid gap-2 text-sm"> + {stats.mostActiveDay && ( + <div> + <p className="text-xs text-slate-400">Most Active Day</p> + <p className="text-base font-semibold"> + {new Date(stats.mostActiveDay.date).toLocaleDateString( + "en-US", + { + month: "short", + day: "numeric", + }, + )} + </p> + <p className="text-xs text-slate-400"> + {stats.mostActiveDay.count}{" "} + {stats.mostActiveDay.count === 1 ? "save" : "saves"} + </p> + </div> + )} + <div className="grid grid-cols-2 gap-2"> + <div> + <p className="text-xs text-slate-400">Peak Hour</p> + <p className="text-base font-semibold"> + {stats.peakHour === 0 + ? "12 AM" + : stats.peakHour < 12 + ? `${stats.peakHour} AM` + : stats.peakHour === 12 + ? "12 PM" + : `${stats.peakHour - 12} PM`} + </p> + </div> + <div> + <p className="text-xs text-slate-400">Peak Day</p> + <p className="text-base font-semibold"> + {dayNames[stats.peakDayOfWeek]} + </p> + </div> + </div> + </div> + </Card> + + {/* Top Lists */} + {(stats.topDomains.length > 0 || stats.topTags.length > 0) && ( + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm md:col-span-2 lg:col-span-2"> + <h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-slate-300"> + Top Lists + </h2> + <div className="grid gap-3 md:grid-cols-2"> + {stats.topDomains.length > 0 && ( + <div> + <h3 className="mb-1 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-slate-400"> + <Globe className="h-3.5 w-3.5" /> + Sites + </h3> + <div className="space-y-1.5 text-sm"> + {stats.topDomains.map((domain, index) => ( + <div + key={domain.domain} + className="flex items-center justify-between" + > + <div className="flex items-center gap-2"> + <div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-white/10 text-[10px] font-semibold text-slate-200"> + {index + 1} + </div> + <span className="text-slate-100"> + {domain.domain} + </span> + </div> + <Badge className="bg-white/10 text-[10px] text-slate-200"> + {domain.count} + </Badge> + </div> + ))} + </div> + </div> + )} + {stats.topTags.length > 0 && ( + <div> + <h3 className="mb-1 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-slate-400"> + <Hash className="h-3.5 w-3.5" /> + Tags + </h3> + <div className="space-y-1.5 text-sm"> + {stats.topTags.map((tag, index) => ( + <div + key={tag.name} + className="flex items-center justify-between" + > + <div className="flex items-center gap-2"> + <div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-white/10 text-[10px] font-semibold text-slate-200"> + {index + 1} + </div> + <span className="text-slate-100">{tag.name}</span> + </div> + <Badge className="bg-white/10 text-[10px] text-slate-200"> + {tag.count} + </Badge> + </div> + ))} + </div> + </div> + )} + </div> + </Card> + )} + + {/* Bookmarks by Source */} + {stats.bookmarksBySource.length > 0 && ( + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm"> + <h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-300"> + How You Save + </h2> + <div className="space-y-1.5 text-sm"> + {stats.bookmarksBySource.map((source) => ( + <div + key={source.source || "unknown"} + className="flex items-center justify-between" + > + <div className="flex items-center gap-2 text-slate-100"> + {getSourceIcon(source.source, "h-4 w-4")} + <span>{formatSourceName(source.source)}</span> + </div> + <Badge className="bg-white/10 text-[10px] text-slate-200"> + {source.count} + </Badge> + </div> + ))} + </div> + </Card> + )} + + {/* Monthly Activity */} + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm md:col-span-2 lg:col-span-3"> + <h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-300"> + <Calendar className="h-4 w-4" /> + Your Year in Saves + </h2> + <div className="grid gap-2 text-xs md:grid-cols-2 lg:grid-cols-3"> + {stats.monthlyActivity.map((month) => ( + <div key={month.month} className="flex items-center gap-2"> + <div className="w-7 text-right text-[10px] text-slate-400"> + {monthNames[month.month - 1]} + </div> + <div className="relative h-2 flex-1 overflow-hidden rounded-full bg-white/10"> + <div + className="h-full rounded-full bg-emerald-300/70" + style={{ + width: `${maxMonthlyCount > 0 ? (month.count / maxMonthlyCount) * 100 : 0}%`, + }} + /> + </div> + <div className="w-7 text-[10px] text-slate-300"> + {month.count} + </div> + </div> + ))} + </div> + </Card> + + {/* Summary Stats */} + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm md:col-span-2 lg:col-span-3"> + <div className="grid gap-3 text-center sm:grid-cols-3"> + <div className="rounded-lg bg-white/5 p-3"> + <Heart className="mx-auto mb-1 h-4 w-4 text-rose-200" /> + <p className="text-lg font-semibold"> + {stats.totalFavorites} + </p> + <p className="text-[10px] text-slate-400">Favorites</p> + </div> + <div className="rounded-lg bg-white/5 p-3"> + <Hash className="mx-auto mb-1 h-4 w-4 text-amber-200" /> + <p className="text-lg font-semibold">{stats.totalTags}</p> + <p className="text-[10px] text-slate-400">Tags Created</p> + </div> + <div className="rounded-lg bg-white/5 p-3"> + <Highlighter className="mx-auto mb-1 h-4 w-4 text-emerald-200" /> + <p className="text-lg font-semibold"> + {stats.totalHighlights} + </p> + <p className="text-[10px] text-slate-400">Highlights</p> + </div> + </div> + <div className="mt-3 grid gap-3 text-center sm:grid-cols-3"> + <div className="rounded-lg bg-white/5 p-3"> + <Link className="mx-auto mb-1 h-4 w-4 text-slate-200" /> + <p className="text-lg font-semibold"> + {stats.bookmarksByType.link} + </p> + <p className="text-[10px] text-slate-400">Links</p> + </div> + <div className="rounded-lg bg-white/5 p-3"> + <FileText className="mx-auto mb-1 h-4 w-4 text-slate-200" /> + <p className="text-lg font-semibold"> + {stats.bookmarksByType.text} + </p> + <p className="text-[10px] text-slate-400">Notes</p> + </div> + <div className="rounded-lg bg-white/5 p-3"> + <BookOpen className="mx-auto mb-1 h-4 w-4 text-slate-200" /> + <p className="text-lg font-semibold"> + {stats.bookmarksByType.asset} + </p> + <p className="text-[10px] text-slate-400">Assets</p> + </div> + </div> + </Card> + </div> + + {/* Footer */} + <div className="pb-4 pt-1 text-center text-[10px] text-slate-500"> + Made with Karakeep + </div> + </div> + </div> + ); + }, +); + +WrappedContent.displayName = "WrappedContent"; diff --git a/apps/web/components/wrapped/WrappedModal.tsx b/apps/web/components/wrapped/WrappedModal.tsx new file mode 100644 index 00000000..b8bf3e25 --- /dev/null +++ b/apps/web/components/wrapped/WrappedModal.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useRef } from "react"; +import { + Dialog, + DialogContent, + DialogOverlay, + DialogTitle, +} from "@/components/ui/dialog"; +import * as VisuallyHidden from "@radix-ui/react-visually-hidden"; +import { useQuery } from "@tanstack/react-query"; +import { Loader2, X } from "lucide-react"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; + +import { ShareButton } from "./ShareButton"; +import { WrappedContent } from "./WrappedContent"; + +interface WrappedModalProps { + open: boolean; + onClose: () => void; +} + +export function WrappedModal({ open, onClose }: WrappedModalProps) { + const api = useTRPC(); + const contentRef = useRef<HTMLDivElement | null>(null); + const { data: stats, isLoading } = useQuery( + api.users.wrapped.queryOptions(undefined, { + enabled: open, + }), + ); + const { data: whoami } = useQuery( + api.users.whoami.queryOptions(undefined, { + enabled: open, + }), + ); + + return ( + <Dialog open={open} onOpenChange={onClose}> + <DialogOverlay className="z-50" /> + <DialogContent + className="max-w-screen h-screen max-h-screen w-screen overflow-hidden rounded-none border-none p-0" + hideCloseBtn={true} + > + <VisuallyHidden.Root> + <DialogTitle>Your 2025 Wrapped</DialogTitle> + </VisuallyHidden.Root> + <div className="fixed right-4 top-4 z-50 flex items-center gap-2"> + {/* Share button overlay */} + {stats && !isLoading && <ShareButton contentRef={contentRef} />} + {/* Close button overlay */} + <button + onClick={onClose} + className="rounded-full bg-white/10 p-2 backdrop-blur-sm transition-colors hover:bg-white/20" + aria-label="Close" + title="Close" + > + <X className="h-5 w-5 text-white" /> + </button> + </div> + + {/* Content */} + {isLoading ? ( + <div className="flex h-full items-center justify-center bg-slate-950 bg-[radial-gradient(1200px_600px_at_20%_-10%,rgba(16,185,129,0.18),transparent),radial-gradient(900px_500px_at_90%_10%,rgba(14,116,144,0.2),transparent)]"> + <div className="text-center text-white"> + <Loader2 className="mx-auto mb-4 h-12 w-12 animate-spin" /> + <p className="text-xl">Loading your Wrapped...</p> + </div> + </div> + ) : stats ? ( + <WrappedContent + ref={contentRef} + stats={stats} + userName={whoami?.name || undefined} + /> + ) : ( + <div className="flex h-full items-center justify-center bg-slate-950 bg-[radial-gradient(1200px_600px_at_20%_-10%,rgba(16,185,129,0.18),transparent),radial-gradient(900px_500px_at_90%_10%,rgba(14,116,144,0.2),transparent)]"> + <div className="text-center text-white"> + <p className="text-xl">Failed to load your Wrapped stats</p> + <button + onClick={onClose} + className="mt-4 rounded-lg bg-white/20 px-6 py-2 backdrop-blur-sm hover:bg-white/30" + > + Close + </button> + </div> + </div> + )} + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/components/wrapped/index.ts b/apps/web/components/wrapped/index.ts new file mode 100644 index 00000000..45d142e1 --- /dev/null +++ b/apps/web/components/wrapped/index.ts @@ -0,0 +1,3 @@ +export { WrappedModal } from "./WrappedModal"; +export { WrappedContent } from "./WrappedContent"; +export { ShareButton } from "./ShareButton"; |
