aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components/dashboard
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/components/dashboard')
-rw-r--r--apps/web/components/dashboard/feeds/FeedSelector.tsx53
-rw-r--r--apps/web/components/dashboard/lists/BookmarkListSelector.tsx11
-rw-r--r--apps/web/components/dashboard/rules/RuleEngineActionBuilder.tsx216
-rw-r--r--apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx322
-rw-r--r--apps/web/components/dashboard/rules/RuleEngineEventSelector.tsx107
-rw-r--r--apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx203
-rw-r--r--apps/web/components/dashboard/rules/RuleEngineRuleList.tsx166
-rw-r--r--apps/web/components/dashboard/tags/TagAutocomplete.tsx126
-rw-r--r--apps/web/components/dashboard/tags/TagSelector.tsx5
9 files changed, 1207 insertions, 2 deletions
diff --git a/apps/web/components/dashboard/feeds/FeedSelector.tsx b/apps/web/components/dashboard/feeds/FeedSelector.tsx
new file mode 100644
index 00000000..db95a042
--- /dev/null
+++ b/apps/web/components/dashboard/feeds/FeedSelector.tsx
@@ -0,0 +1,53 @@
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import LoadingSpinner from "@/components/ui/spinner";
+import { api } from "@/lib/trpc";
+import { cn } from "@/lib/utils";
+
+export function FeedSelector({
+ value,
+ onChange,
+ placeholder = "Select a feed",
+ className,
+}: {
+ className?: string;
+ value?: string | null;
+ onChange: (value: string) => void;
+ placeholder?: string;
+}) {
+ const { data, isPending } = api.feeds.list.useQuery(undefined, {
+ select: (data) => data.feeds,
+ });
+
+ if (isPending) {
+ return <LoadingSpinner />;
+ }
+
+ return (
+ <Select onValueChange={onChange} value={value ?? ""}>
+ <SelectTrigger className={cn("w-full", className)}>
+ <SelectValue placeholder={placeholder} />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ {data?.map((f) => (
+ <SelectItem key={f.id} value={f.id}>
+ {f.name}
+ </SelectItem>
+ ))}
+ {(data ?? []).length == 0 && (
+ <SelectItem value="nofeed" disabled>
+ You don&apos;t currently have any feeds.
+ </SelectItem>
+ )}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ );
+}
diff --git a/apps/web/components/dashboard/lists/BookmarkListSelector.tsx b/apps/web/components/dashboard/lists/BookmarkListSelector.tsx
index db37efc0..9ce56031 100644
--- a/apps/web/components/dashboard/lists/BookmarkListSelector.tsx
+++ b/apps/web/components/dashboard/lists/BookmarkListSelector.tsx
@@ -7,8 +7,10 @@ import {
SelectValue,
} from "@/components/ui/select";
import LoadingSpinner from "@/components/ui/spinner";
+import { cn } from "@/lib/utils";
import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists";
+import { ZBookmarkList } from "@karakeep/shared/types/lists";
export function BookmarkListSelector({
value,
@@ -16,12 +18,16 @@ export function BookmarkListSelector({
hideSubtreeOf,
hideBookmarkIds = [],
placeholder = "Select a list",
+ className,
+ listTypes = ["manual", "smart"],
}: {
+ className?: string;
value?: string | null;
onChange: (value: string) => void;
placeholder?: string;
hideSubtreeOf?: string;
hideBookmarkIds?: string[];
+ listTypes?: ZBookmarkList["type"][];
}) {
const { data, isPending: isFetchingListsPending } = useBookmarkLists();
let { allPaths } = data ?? {};
@@ -34,6 +40,9 @@ export function BookmarkListSelector({
if (hideBookmarkIds.includes(path[path.length - 1].id)) {
return false;
}
+ if (!listTypes.includes(path[path.length - 1].type)) {
+ return false;
+ }
if (!hideSubtreeOf) {
return true;
}
@@ -42,7 +51,7 @@ export function BookmarkListSelector({
return (
<Select onValueChange={onChange} value={value ?? ""}>
- <SelectTrigger className="w-full">
+ <SelectTrigger className={cn("w-full", className)}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
diff --git a/apps/web/components/dashboard/rules/RuleEngineActionBuilder.tsx b/apps/web/components/dashboard/rules/RuleEngineActionBuilder.tsx
new file mode 100644
index 00000000..0354e8ac
--- /dev/null
+++ b/apps/web/components/dashboard/rules/RuleEngineActionBuilder.tsx
@@ -0,0 +1,216 @@
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Archive,
+ Download,
+ List,
+ PlusCircle,
+ Star,
+ Tag,
+ Trash2,
+} from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import type { RuleEngineAction } from "@karakeep/shared/types/rules";
+
+import { BookmarkListSelector } from "../lists/BookmarkListSelector";
+import { TagAutocomplete } from "../tags/TagAutocomplete";
+
+interface ActionBuilderProps {
+ value: RuleEngineAction[];
+ onChange: (actions: RuleEngineAction[]) => void;
+}
+
+export function ActionBuilder({ value, onChange }: ActionBuilderProps) {
+ const { t } = useTranslation();
+ const handleAddAction = () => {
+ onChange([...value, { type: "addTag", tagId: "" }]);
+ };
+
+ const handleRemoveAction = (index: number) => {
+ const newActions = [...value];
+ newActions.splice(index, 1);
+ onChange(newActions);
+ };
+
+ const handleActionTypeChange = (
+ index: number,
+ type: RuleEngineAction["type"],
+ ) => {
+ const newActions = [...value];
+
+ switch (type) {
+ case "addTag":
+ newActions[index] = { type: "addTag", tagId: "" };
+ break;
+ case "removeTag":
+ newActions[index] = { type: "removeTag", tagId: "" };
+ break;
+ case "addToList":
+ newActions[index] = { type: "addToList", listId: "" };
+ break;
+ case "removeFromList":
+ newActions[index] = { type: "removeFromList", listId: "" };
+ break;
+ case "downloadFullPageArchive":
+ newActions[index] = { type: "downloadFullPageArchive" };
+ break;
+ case "favouriteBookmark":
+ newActions[index] = { type: "favouriteBookmark" };
+ break;
+ case "archiveBookmark":
+ newActions[index] = { type: "archiveBookmark" };
+ break;
+ default: {
+ const _exhaustiveCheck: never = type;
+ return null;
+ }
+ }
+
+ onChange(newActions);
+ };
+
+ const handleActionFieldChange = (
+ index: number,
+ selectVal: RuleEngineAction,
+ ) => {
+ const newActions = [...value];
+ newActions[index] = selectVal;
+ onChange(newActions);
+ };
+
+ const renderActionIcon = (type: string) => {
+ switch (type) {
+ case "addTag":
+ case "removeTag":
+ return <Tag className="h-4 w-4" />;
+ case "addToList":
+ case "removeFromList":
+ return <List className="h-4 w-4" />;
+ case "downloadFullPageArchive":
+ return <Download className="h-4 w-4" />;
+ case "favouriteBookmark":
+ return <Star className="h-4 w-4" />;
+ case "archiveBookmark":
+ return <Archive className="h-4 w-4" />;
+ default:
+ return null;
+ }
+ };
+
+ return (
+ <div className="space-y-3">
+ {value.length === 0 ? (
+ <div className="rounded-md border border-dashed p-4 text-center">
+ <p className="text-sm text-muted-foreground">No actions added yet</p>
+ </div>
+ ) : (
+ value.map((action, index) => (
+ <Card key={index}>
+ <CardContent className="p-3">
+ <div className="flex items-center justify-between">
+ <div className="flex flex-1 items-center">
+ {renderActionIcon(action.type)}
+ <Select
+ value={action.type}
+ onValueChange={(value) =>
+ handleActionTypeChange(
+ index,
+ value as RuleEngineAction["type"],
+ )
+ }
+ >
+ <SelectTrigger className="ml-2 h-8 w-auto border-none bg-transparent px-2">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="addTag">
+ {t("settings.rules.actions_types.add_tag")}
+ </SelectItem>
+ <SelectItem value="removeTag">
+ {t("settings.rules.actions_types.remove_tag")}
+ </SelectItem>
+ <SelectItem value="addToList">
+ {t("settings.rules.actions_types.add_to_list")}
+ </SelectItem>
+ <SelectItem value="removeFromList">
+ {t("settings.rules.actions_types.remove_from_list")}
+ </SelectItem>
+ <SelectItem value="downloadFullPageArchive">
+ {t(
+ "settings.rules.actions_types.download_full_page_archive",
+ )}
+ </SelectItem>
+ <SelectItem value="favouriteBookmark">
+ {t("settings.rules.actions_types.favourite_bookmark")}
+ </SelectItem>
+ <SelectItem value="archiveBookmark">
+ {t("settings.rules.actions_types.archive_bookmark")}
+ </SelectItem>
+ </SelectContent>
+ </Select>
+
+ {(action.type === "addTag" ||
+ action.type === "removeTag") && (
+ <TagAutocomplete
+ className="ml-2 h-8 flex-1"
+ tagId={action.tagId}
+ onChange={(t) =>
+ handleActionFieldChange(index, {
+ type: action.type,
+ tagId: t,
+ })
+ }
+ />
+ )}
+
+ {(action.type === "addToList" ||
+ action.type === "removeFromList") && (
+ <BookmarkListSelector
+ className="ml-2 h-8 flex-1"
+ value={action.listId}
+ listTypes={["manual"]}
+ onChange={(e) =>
+ handleActionFieldChange(index, {
+ type: action.type,
+ listId: e,
+ })
+ }
+ />
+ )}
+ </div>
+
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRemoveAction(index)}
+ className="h-7 w-7 p-0"
+ >
+ <Trash2 className="h-4 w-4 text-red-500" />
+ </Button>
+ </div>
+ </CardContent>
+ </Card>
+ ))
+ )}
+
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={handleAddAction}
+ className="w-full"
+ >
+ <PlusCircle className="mr-2 h-4 w-4" />
+ Add Action
+ </Button>
+ </div>
+ );
+}
diff --git a/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx b/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx
new file mode 100644
index 00000000..8faca013
--- /dev/null
+++ b/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx
@@ -0,0 +1,322 @@
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Archive,
+ ChevronDown,
+ ChevronRight,
+ FileType,
+ Link,
+ PlusCircle,
+ Rss,
+ Star,
+ Tag,
+ Trash2,
+} from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import type { RuleEngineCondition } from "@karakeep/shared/types/rules";
+
+import { FeedSelector } from "../feeds/FeedSelector";
+import { TagAutocomplete } from "../tags/TagAutocomplete";
+
+interface ConditionBuilderProps {
+ value: RuleEngineCondition;
+ onChange: (condition: RuleEngineCondition) => void;
+ level?: number;
+ onRemove?: () => void;
+}
+
+export function ConditionBuilder({
+ value,
+ onChange,
+ level = 0,
+ onRemove,
+}: ConditionBuilderProps) {
+ const { t } = useTranslation();
+ const [isOpen, setIsOpen] = useState(true);
+
+ const handleTypeChange = (type: RuleEngineCondition["type"]) => {
+ switch (type) {
+ case "urlContains":
+ onChange({ type: "urlContains", str: "" });
+ break;
+ case "importedFromFeed":
+ onChange({ type: "importedFromFeed", feedId: "" });
+ break;
+ case "bookmarkTypeIs":
+ onChange({ type: "bookmarkTypeIs", bookmarkType: "link" });
+ break;
+ case "hasTag":
+ onChange({ type: "hasTag", tagId: "" });
+ break;
+ case "isFavourited":
+ onChange({ type: "isFavourited" });
+ break;
+ case "isArchived":
+ onChange({ type: "isArchived" });
+ break;
+ case "and":
+ onChange({ type: "and", conditions: [] });
+ break;
+ case "or":
+ onChange({ type: "or", conditions: [] });
+ break;
+ case "alwaysTrue":
+ onChange({ type: "alwaysTrue" });
+ break;
+ default: {
+ const _exhaustiveCheck: never = type;
+ return null;
+ }
+ }
+ };
+
+ const renderConditionIcon = (type: RuleEngineCondition["type"]) => {
+ switch (type) {
+ case "urlContains":
+ return <Link className="h-4 w-4" />;
+ case "importedFromFeed":
+ return <Rss className="h-4 w-4" />;
+ case "bookmarkTypeIs":
+ return <FileType className="h-4 w-4" />;
+ case "hasTag":
+ return <Tag className="h-4 w-4" />;
+ case "isFavourited":
+ return <Star className="h-4 w-4" />;
+ case "isArchived":
+ return <Archive className="h-4 w-4" />;
+ default:
+ return null;
+ }
+ };
+
+ const renderConditionFields = () => {
+ switch (value.type) {
+ case "urlContains":
+ return (
+ <div className="mt-2">
+ <Input
+ value={value.str}
+ onChange={(e) => onChange({ ...value, str: e.target.value })}
+ placeholder="URL contains..."
+ className="w-full"
+ />
+ </div>
+ );
+
+ case "importedFromFeed":
+ return (
+ <div className="mt-2">
+ <FeedSelector
+ value={value.feedId}
+ onChange={(e) => onChange({ ...value, feedId: e })}
+ className="w-full"
+ />
+ </div>
+ );
+
+ case "bookmarkTypeIs":
+ return (
+ <div className="mt-2">
+ <Select
+ value={value.bookmarkType}
+ onValueChange={(bookmarkType) =>
+ onChange({
+ ...value,
+ bookmarkType: bookmarkType as "link" | "text" | "asset",
+ })
+ }
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select bookmark type" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="link">
+ {t("common.bookmark_types.link")}
+ </SelectItem>
+ <SelectItem value="text">
+ {t("common.bookmark_types.text")}
+ </SelectItem>
+ <SelectItem value="asset">
+ {t("common.bookmark_types.media")}
+ </SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ );
+
+ case "hasTag":
+ return (
+ <div className="mt-2">
+ <TagAutocomplete
+ tagId={value.tagId}
+ onChange={(t) => onChange({ type: value.type, tagId: t })}
+ />
+ </div>
+ );
+
+ case "and":
+ case "or":
+ return (
+ <div className="mt-2 space-y-2">
+ {value.conditions.map((condition, index) => (
+ <ConditionBuilder
+ key={index}
+ value={condition}
+ onChange={(newCondition) => {
+ const newConditions = [...value.conditions];
+ newConditions[index] = newCondition;
+ onChange({ ...value, conditions: newConditions });
+ }}
+ level={level + 1}
+ onRemove={() => {
+ const newConditions = [...value.conditions];
+ newConditions.splice(index, 1);
+ onChange({ ...value, conditions: newConditions });
+ }}
+ />
+ ))}
+
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ className="mt-2"
+ onClick={() => {
+ onChange({
+ ...value,
+ conditions: [
+ ...value.conditions,
+ { type: "urlContains", str: "" },
+ ],
+ });
+ }}
+ >
+ <PlusCircle className="mr-2 h-4 w-4" />
+ Add Condition
+ </Button>
+ </div>
+ );
+
+ default:
+ return null;
+ }
+ };
+
+ const ConditionSelector = () => (
+ <Select value={value.type} onValueChange={handleTypeChange}>
+ <SelectTrigger className="ml-2 h-8 border-none bg-transparent px-2">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="alwaysTrue">
+ {t("settings.rules.conditions_types.always")}
+ </SelectItem>
+ <SelectItem value="and">
+ {t("settings.rules.conditions_types.and")}
+ </SelectItem>
+ <SelectItem value="or">
+ {t("settings.rules.conditions_types.or")}
+ </SelectItem>
+ <SelectItem value="urlContains">
+ {t("settings.rules.conditions_types.url_contains")}
+ </SelectItem>
+ <SelectItem value="importedFromFeed">
+ {t("settings.rules.conditions_types.imported_from_feed")}
+ </SelectItem>
+ <SelectItem value="bookmarkTypeIs">
+ {t("settings.rules.conditions_types.bookmark_type_is")}
+ </SelectItem>
+ <SelectItem value="hasTag">
+ {t("settings.rules.conditions_types.has_tag")}
+ </SelectItem>
+ <SelectItem value="isFavourited">
+ {t("settings.rules.conditions_types.is_favourited")}
+ </SelectItem>
+ <SelectItem value="isArchived">
+ {t("settings.rules.conditions_types.is_archived")}
+ </SelectItem>
+ </SelectContent>
+ </Select>
+ );
+
+ return (
+ <Card
+ className={`border-l-4 ${value.type === "and" ? "border-l-emerald-500" : value.type === "or" ? "border-l-amber-500" : "border-l-slate-300"}`}
+ >
+ <CardContent className="p-3">
+ {value.type === "and" || value.type === "or" ? (
+ <Collapsible open={isOpen} onOpenChange={setIsOpen}>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center">
+ <CollapsibleTrigger asChild>
+ <Button variant="ghost" size="sm" className="h-7 w-7 p-1">
+ {isOpen ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <ChevronRight className="h-4 w-4" />
+ )}
+ </Button>
+ </CollapsibleTrigger>
+ <ConditionSelector />
+ <span className="ml-1 text-sm text-muted-foreground">
+ {value.conditions.length} condition
+ {value.conditions.length !== 1 ? "s" : ""}
+ </span>
+ </div>
+
+ {onRemove && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={onRemove}
+ className="h-7 w-7 p-0"
+ >
+ <Trash2 className="h-4 w-4 text-red-500" />
+ </Button>
+ )}
+ </div>
+
+ <CollapsibleContent>{renderConditionFields()}</CollapsibleContent>
+ </Collapsible>
+ ) : (
+ <div>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center">
+ {renderConditionIcon(value.type)}
+ <ConditionSelector />
+ </div>
+
+ {onRemove && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={onRemove}
+ className="h-7 w-7 p-0"
+ >
+ <Trash2 className="h-4 w-4 text-red-500" />
+ </Button>
+ )}
+ </div>
+
+ {renderConditionFields()}
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ );
+}
diff --git a/apps/web/components/dashboard/rules/RuleEngineEventSelector.tsx b/apps/web/components/dashboard/rules/RuleEngineEventSelector.tsx
new file mode 100644
index 00000000..ae37945e
--- /dev/null
+++ b/apps/web/components/dashboard/rules/RuleEngineEventSelector.tsx
@@ -0,0 +1,107 @@
+import { Card, CardContent } from "@/components/ui/card";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useTranslation } from "react-i18next";
+
+import type { RuleEngineEvent } from "@karakeep/shared/types/rules";
+
+import { BookmarkListSelector } from "../lists/BookmarkListSelector";
+import { TagAutocomplete } from "../tags/TagAutocomplete";
+
+interface EventSelectorProps {
+ value: RuleEngineEvent;
+ onChange: (event: RuleEngineEvent) => void;
+}
+
+export function EventSelector({ value, onChange }: EventSelectorProps) {
+ const { t } = useTranslation();
+ const handleTypeChange = (type: RuleEngineEvent["type"]) => {
+ switch (type) {
+ case "bookmarkAdded":
+ onChange({ type: "bookmarkAdded" });
+ break;
+ case "tagAdded":
+ onChange({ type: "tagAdded", tagId: "" });
+ break;
+ case "tagRemoved":
+ onChange({ type: "tagRemoved", tagId: "" });
+ break;
+ case "addedToList":
+ onChange({ type: "addedToList", listId: "" });
+ break;
+ case "removedFromList":
+ onChange({ type: "removedFromList", listId: "" });
+ break;
+ case "favourited":
+ onChange({ type: "favourited" });
+ break;
+ case "archived":
+ onChange({ type: "archived" });
+ break;
+ default: {
+ const _exhaustiveCheck: never = type;
+ return null;
+ }
+ }
+ };
+
+ return (
+ <Card>
+ <CardContent className="p-4">
+ <div className="flex gap-4">
+ <Select value={value.type} onValueChange={handleTypeChange}>
+ <SelectTrigger id="event-type">
+ <SelectValue placeholder="Select event type" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="bookmarkAdded">
+ {t("settings.rules.events_types.bookmark_added")}
+ </SelectItem>
+ <SelectItem value="tagAdded">
+ {t("settings.rules.events_types.tag_added")}
+ </SelectItem>
+ <SelectItem value="tagRemoved">
+ {t("settings.rules.events_types.tag_removed")}
+ </SelectItem>
+ <SelectItem value="addedToList">
+ {t("settings.rules.events_types.added_to_list")}
+ </SelectItem>
+ <SelectItem value="removedFromList">
+ {t("settings.rules.events_types.removed_from_list")}
+ </SelectItem>
+ <SelectItem value="favourited">
+ {t("settings.rules.events_types.favourited")}
+ </SelectItem>
+ <SelectItem value="archived">
+ {t("settings.rules.events_types.archived")}
+ </SelectItem>
+ </SelectContent>
+ </Select>
+
+ {/* Additional fields based on event type */}
+ {(value.type === "tagAdded" || value.type === "tagRemoved") && (
+ <TagAutocomplete
+ className="w-full"
+ tagId={value.tagId}
+ onChange={(t) => onChange({ type: value.type, tagId: t ?? "" })}
+ />
+ )}
+
+ {(value.type === "addedToList" ||
+ value.type === "removedFromList") && (
+ <BookmarkListSelector
+ listTypes={["manual"]}
+ value={value.listId}
+ onChange={(l) => onChange({ type: value.type, listId: l })}
+ />
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ );
+}
diff --git a/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx b/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx
new file mode 100644
index 00000000..da10317a
--- /dev/null
+++ b/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx
@@ -0,0 +1,203 @@
+import type React from "react";
+import { useEffect, useState } from "react";
+import { ActionBuilder } from "@/components/dashboard/rules/RuleEngineActionBuilder";
+import { ConditionBuilder } from "@/components/dashboard/rules/RuleEngineConditionBuilder";
+import { EventSelector } from "@/components/dashboard/rules/RuleEngineEventSelector";
+import { ActionButton } from "@/components/ui/action-button";
+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 { Textarea } from "@/components/ui/textarea";
+import { toast } from "@/components/ui/use-toast";
+import { Save, X } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import type {
+ RuleEngineAction,
+ RuleEngineCondition,
+ RuleEngineEvent,
+ RuleEngineRule,
+} from "@karakeep/shared/types/rules";
+import {
+ useCreateRule,
+ useUpdateRule,
+} from "@karakeep/shared-react/hooks/rules";
+
+interface RuleEditorProps {
+ rule: Omit<RuleEngineRule, "id"> & { id: string | null };
+ onCancel: () => void;
+}
+
+export function RuleEditor({ rule, onCancel }: RuleEditorProps) {
+ const { t } = useTranslation();
+ const { mutate: createRule, isPending: isCreating } = useCreateRule({
+ onSuccess: () => {
+ toast({
+ description: t("settings.rules.rule_has_been_created"),
+ });
+ onCancel();
+ },
+ onError: (e) => {
+ if (e.data?.code == "BAD_REQUEST") {
+ if (e.data.zodError) {
+ toast({
+ variant: "destructive",
+ description: Object.values(e.data.zodError.fieldErrors)
+ .flat()
+ .join("\n"),
+ });
+ } else {
+ toast({
+ variant: "destructive",
+ description: e.message,
+ });
+ }
+ } else {
+ toast({
+ variant: "destructive",
+ title: t("common.something_went_wrong"),
+ });
+ }
+ },
+ });
+ const { mutate: updateRule, isPending: isUpdating } = useUpdateRule({
+ onSuccess: () => {
+ toast({
+ description: t("settings.rules.rule_has_been_updated"),
+ });
+ onCancel();
+ },
+ onError: (e) => {
+ if (e.data?.code == "BAD_REQUEST") {
+ if (e.data.zodError) {
+ toast({
+ variant: "destructive",
+ description: Object.values(e.data.zodError.fieldErrors)
+ .flat()
+ .join("\n"),
+ });
+ } else {
+ toast({
+ variant: "destructive",
+ description: e.message,
+ });
+ }
+ } else {
+ toast({
+ variant: "destructive",
+ title: t("common.something_went_wrong"),
+ });
+ }
+ },
+ });
+
+ const [editedRule, setEditedRule] = useState<typeof rule>({ ...rule });
+
+ useEffect(() => {
+ setEditedRule({ ...rule });
+ }, [rule]);
+
+ const handleEventChange = (event: RuleEngineEvent) => {
+ setEditedRule({ ...editedRule, event });
+ };
+
+ const handleConditionChange = (condition: RuleEngineCondition) => {
+ setEditedRule({ ...editedRule, condition });
+ };
+
+ const handleActionsChange = (actions: RuleEngineAction[]) => {
+ setEditedRule({ ...editedRule, actions });
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ const rule = editedRule;
+ if (rule.id) {
+ updateRule({
+ ...rule,
+ id: rule.id,
+ });
+ } else {
+ createRule(rule);
+ }
+ };
+
+ return (
+ <form onSubmit={handleSubmit}>
+ <Card>
+ <CardHeader>
+ <CardTitle>
+ {rule.id
+ ? t("settings.rules.edit_rule")
+ : t("settings.rules.ceate_rule")}
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="space-y-4">
+ <div>
+ <Label htmlFor="name">{t("settings.rules.rule_name")}</Label>
+ <Input
+ id="name"
+ value={editedRule.name}
+ onChange={(e) =>
+ setEditedRule({ ...editedRule, name: e.target.value })
+ }
+ placeholder={t("settings.rules.enter_rule_name")}
+ required
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="description">Description</Label>
+ <Textarea
+ id="description"
+ value={editedRule.description ?? ""}
+ onChange={(e) =>
+ setEditedRule({ ...editedRule, description: e.target.value })
+ }
+ placeholder={t("settings.rules.describe_what_this_rule_does")}
+ rows={2}
+ />
+ </div>
+ </div>
+
+ <div className="space-y-2">
+ <Label>{t("settings.rules.whenever")}</Label>
+ <EventSelector
+ value={editedRule.event}
+ onChange={handleEventChange}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label>{t("settings.rules.if")}</Label>
+ <ConditionBuilder
+ value={editedRule.condition}
+ onChange={handleConditionChange}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label>{t("common.actions")}</Label>
+ <ActionBuilder
+ value={editedRule.actions}
+ onChange={handleActionsChange}
+ />
+ </div>
+
+ <div className="flex justify-end space-x-2 pt-4">
+ <Button type="button" variant="outline" onClick={onCancel}>
+ <X className="mr-2 h-4 w-4" />
+ {t("actions.cancel")}
+ </Button>
+ <ActionButton loading={isCreating || isUpdating} type="submit">
+ <Save className="mr-2 h-4 w-4" />
+ {t("settings.rules.save_rule")}
+ </ActionButton>
+ </div>
+ </CardContent>
+ </Card>
+ </form>
+ );
+}
diff --git a/apps/web/components/dashboard/rules/RuleEngineRuleList.tsx b/apps/web/components/dashboard/rules/RuleEngineRuleList.tsx
new file mode 100644
index 00000000..206a3550
--- /dev/null
+++ b/apps/web/components/dashboard/rules/RuleEngineRuleList.tsx
@@ -0,0 +1,166 @@
+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 { 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";
+
+import type { RuleEngineRule } from "@karakeep/shared/types/rules";
+import {
+ useDeleteRule,
+ useUpdateRule,
+} from "@karakeep/shared-react/hooks/rules";
+
+export default function RuleList({
+ rules,
+ onEditRule,
+ onDeleteRule,
+}: {
+ rules: RuleEngineRule[];
+ onEditRule: (rule: RuleEngineRule) => void;
+ onDeleteRule?: (ruleId: string) => void;
+}) {
+ const { t } = useTranslation();
+ const { demoMode } = useClientConfig();
+ const { mutate: updateRule, isPending: isUpdating } = useUpdateRule({
+ onSuccess: () => {
+ toast({
+ description: t("settings.rules.rule_has_been_updated"),
+ });
+ },
+ onError: (e) => {
+ toast({
+ description: e.message,
+ variant: "destructive",
+ });
+ },
+ });
+
+ const { mutate: deleteRule, isPending: isDeleting } = useDeleteRule({
+ onSuccess: (_ret, req) => {
+ toast({
+ description: t("settings.rules.rule_has_been_deleted"),
+ });
+ onDeleteRule?.(req.id);
+ },
+ onError: (e) => {
+ toast({
+ description: e.message,
+ variant: "destructive",
+ });
+ },
+ });
+
+ if (rules.length === 0) {
+ return (
+ <div className="rounded-lg border border-dashed p-8 text-center">
+ <h3 className="text-lg font-medium">
+ {t("settings.rules.no_rules_created_yet")}
+ </h3>
+ <p className="mt-2 text-sm text-muted-foreground">
+ {t("settings.rules.create_your_first_rule")}
+ </p>
+ </div>
+ );
+ }
+
+ return (
+ <div className="space-y-4">
+ {rules.map((rule) => (
+ <Card key={rule.id} className={rule.enabled ? "" : "opacity-70"}>
+ <CardContent className="p-4">
+ <div className="flex items-center justify-between">
+ <div className="mr-4 flex-1">
+ <h3 className="font-medium">{rule.name}</h3>
+ <p className="line-clamp-2 text-sm text-muted-foreground">
+ {rule.description}
+ </p>
+ <div className="mt-2 flex items-center text-xs text-muted-foreground">
+ <span className="flex items-center">
+ <span className="mr-1">Trigger:</span>
+ <span className="font-medium">
+ {formatEventType(rule.event.type)}
+ </span>
+ </span>
+ <span className="mx-2">•</span>
+ <span>
+ {rule.actions.length} action
+ {rule.actions.length !== 1 ? "s" : ""}
+ </span>
+ </div>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Switch
+ disabled={!!demoMode || isUpdating}
+ checked={rule.enabled}
+ onCheckedChange={(checked) =>
+ updateRule({
+ ...rule,
+ enabled: checked,
+ })
+ }
+ />
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => onEditRule(rule)}
+ >
+ <Edit className="h-4 w-4" />
+ </Button>
+ <ActionConfirmingDialog
+ title={t("settings.rules.delete_rule")}
+ description={t("settings.rules.delete_rule_confirmation")}
+ actionButton={(setDialogOpen) => (
+ <ActionButton
+ loading={isDeleting}
+ variant="destructive"
+ onClick={() => {
+ deleteRule({ id: rule.id });
+ setDialogOpen(true);
+ }}
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ {t("actions.delete")}
+ </ActionButton>
+ )}
+ >
+ <Button
+ variant="ghost"
+ size="icon"
+ className="text-red-500 hover:text-red-600"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </ActionConfirmingDialog>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ );
+}
+
+function formatEventType(type: string): string {
+ switch (type) {
+ case "bookmarkAdded":
+ return "Bookmark Added";
+ case "tagAdded":
+ return "Tag Added";
+ case "tagRemoved":
+ return "Tag Removed";
+ case "addedToList":
+ return "Added to List";
+ case "removedFromList":
+ return "Removed from List";
+ case "favourited":
+ return "Favourited";
+ case "archived":
+ return "Archived";
+ default:
+ return type;
+ }
+}
diff --git a/apps/web/components/dashboard/tags/TagAutocomplete.tsx b/apps/web/components/dashboard/tags/TagAutocomplete.tsx
new file mode 100644
index 00000000..23054bc7
--- /dev/null
+++ b/apps/web/components/dashboard/tags/TagAutocomplete.tsx
@@ -0,0 +1,126 @@
+import React, { useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import LoadingSpinner from "@/components/ui/spinner";
+import { api } from "@/lib/trpc";
+import { cn } from "@/lib/utils";
+import { Check, ChevronsUpDown, X } from "lucide-react";
+
+interface TagAutocompleteProps {
+ tagId: string;
+ onChange?: (value: string) => void;
+ className?: string;
+}
+
+export function TagAutocomplete({
+ tagId,
+ onChange,
+ className,
+}: TagAutocompleteProps) {
+ const { data: tags, isPending } = api.tags.list.useQuery(undefined, {
+ select: (data) => data.tags,
+ });
+
+ const [open, setOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+
+ // Filter tags based on search query
+ const filteredTags = (tags ?? [])
+ .filter((tag) => tag.name.toLowerCase().includes(searchQuery.toLowerCase()))
+ .slice(0, 10); // Only show first 10 matches for performance
+
+ const handleSelect = (currentValue: string) => {
+ setOpen(false);
+ onChange?.(currentValue);
+ };
+
+ const clearSelection = () => {
+ onChange?.("");
+ };
+
+ const selectedTag = React.useMemo(() => {
+ if (!tagId) return null;
+ return tags?.find((t) => t.id === tagId) ?? null;
+ }, [tags, tagId]);
+
+ if (isPending) {
+ return <LoadingSpinner />;
+ }
+
+ return (
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={open}
+ className={cn("justify-between", className)}
+ >
+ {selectedTag ? (
+ <div className="flex w-full items-center justify-between">
+ <span>{selectedTag.name}</span>
+ <X
+ className="h-4 w-4 shrink-0 cursor-pointer opacity-50 hover:opacity-100"
+ onClick={(e) => {
+ e.stopPropagation();
+ clearSelection();
+ }}
+ />
+ </div>
+ ) : (
+ "Select a tag..."
+ )}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[--radix-popover-trigger-width] p-0">
+ <Command shouldFilter={false}>
+ <CommandInput
+ placeholder="Search tags..."
+ value={searchQuery}
+ onValueChange={setSearchQuery}
+ className={cn("h-9", className)}
+ />
+ <CommandList>
+ <CommandEmpty>No tags found.</CommandEmpty>
+ <CommandGroup className="max-h-60 overflow-y-auto">
+ {filteredTags.map((tag) => (
+ <CommandItem
+ key={tag.id}
+ value={tag.id}
+ onSelect={handleSelect}
+ className="cursor-pointer"
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ selectedTag?.id === tag.id ? "opacity-100" : "opacity-0",
+ )}
+ />
+ {tag.name}
+ </CommandItem>
+ ))}
+ {searchQuery && filteredTags.length >= 10 && (
+ <div className="px-2 py-2 text-center text-xs text-muted-foreground">
+ Showing first 10 results. Keep typing to refine your search.
+ </div>
+ )}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ );
+}
diff --git a/apps/web/components/dashboard/tags/TagSelector.tsx b/apps/web/components/dashboard/tags/TagSelector.tsx
index afc7340b..01690da1 100644
--- a/apps/web/components/dashboard/tags/TagSelector.tsx
+++ b/apps/web/components/dashboard/tags/TagSelector.tsx
@@ -8,15 +8,18 @@ import {
} from "@/components/ui/select";
import LoadingSpinner from "@/components/ui/spinner";
import { api } from "@/lib/trpc";
+import { cn } from "@/lib/utils";
export function TagSelector({
value,
onChange,
placeholder = "Select a tag",
+ className,
}: {
value?: string | null;
onChange: (value: string) => void;
placeholder?: string;
+ className?: string;
}) {
const { data: allTags, isPending } = api.tags.list.useQuery();
@@ -28,7 +31,7 @@ export function TagSelector({
return (
<Select onValueChange={onChange} value={value ?? ""}>
- <SelectTrigger className="w-full">
+ <SelectTrigger className={cn("w-full", className)}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>