diff --git a/package.json b/package.json index 9359716f20..76c0e61ab0 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@radix-ui/react-label": "2.0.2", "@radix-ui/react-navigation-menu": "1.1.4", "@radix-ui/react-popover": "1.0.7", + "@radix-ui/react-radio-group": "1.1.3", "@radix-ui/react-select": "2.0.0", "@radix-ui/react-slot": "1.0.2", "@radix-ui/react-switch": "1.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f85f3b20c..4f86fc8819 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,9 @@ importers: '@radix-ui/react-popover': specifier: 1.0.7 version: 1.0.7(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-radio-group': + specifier: 1.1.3 + version: 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-select': specifier: 2.0.0 version: 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1459,6 +1462,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-radio-group@1.1.3': + resolution: {integrity: sha512-x+yELayyefNeKeTx4fjK6j99Fs6c4qKm3aY38G3swQVTN6xMpsrbigC0uHs2L//g8q4qR7qOcww8430jJmi2ag==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.0.4': resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==} peerDependencies: @@ -6548,6 +6564,25 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@radix-ui/react-radio-group@1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-context': 1.0.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-direction': 1.0.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-previous': 1.0.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-size': 1.0.1(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.24.4 diff --git a/src/renderer/src/components/settings/action-card.tsx b/src/renderer/src/components/settings/action-card.tsx new file mode 100644 index 0000000000..4e1f4ea67c --- /dev/null +++ b/src/renderer/src/components/settings/action-card.tsx @@ -0,0 +1,627 @@ +/* eslint-disable tailwindcss/no-custom-classname */ +import { Button } from "@renderer/components/ui/button" +import { Card, CardHeader } from "@renderer/components/ui/card" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@renderer/components/ui/collapsible" +import { Divider } from "@renderer/components/ui/divider" +import { Input } from "@renderer/components/ui/input" +import { Label } from "@renderer/components/ui/label" +import { + RadioGroup, + RadioGroupItem, +} from "@renderer/components/ui/radio-group" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@renderer/components/ui/select" +import { Switch } from "@renderer/components/ui/switch" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@renderer/components/ui/table" + +type Operation = + | "contains" + | "not_contains" + | "eq" + | "not_eq" + | "gt" + | "lt" + | "regex" +type EntryField = "all" | "title" | "content" | "author" | "link" | "order" +type FeedField = "view" | "title" | "category" | "site_url" | "feed_url" + +// eslint-disable-next-line unused-imports/no-unused-vars +type Actions = { + name: string + condition: { + field: FeedField + operator: Operation + value: string | number + }[] + result: { + translation?: string + summary?: boolean + rewriteRules?: { + from: string + to: string + }[] + blockRules?: { + field: EntryField + operator: Operation + value: string | number + }[] + } +}[] + +type ActionsInput = { + name: string + condition: { + field?: FeedField + operator?: Operation + value?: string | number + }[] + result: { + translation?: string + summary?: boolean + rewriteRules?: { + from: string + to: string + }[] + blockRules?: { + field?: EntryField + operator?: Operation + value?: string | number + }[] + } +}[] + +const OperationOptions = [ + { + name: "contains", + value: "contains", + }, + { + name: "does not contain", + value: "not_contains", + }, + { + name: "is equal to", + value: "eq", + }, + { + name: "is not equal to", + value: "not_eq", + }, + { + name: "is greater than", + value: "gt", + }, + { + name: "is less than", + value: "lt", + }, + { + name: "matches regex", + value: "regex", + }, +] + +const EntryOptions = [ + { + name: "All", + value: "all", + }, + { + name: "Title", + value: "title", + }, + { + name: "Content", + value: "content", + }, + { + name: "Author", + value: "author", + }, + { + name: "Link", + value: "link", + }, + { + name: "Order", + value: "order", + }, +] + +const FeedOptions = [ + { + name: "View", + value: "view", + }, + { + name: "Title", + value: "title", + }, + { + name: "Category", + value: "category", + }, + { + name: "Site URL", + value: "site_url", + }, + { + name: "Feed URL", + value: "feed_url", + }, +] + +const TransitionOptions = [ + { + name: "English", + value: "en", + }, + { + name: "日本語", + value: "ja", + }, + { + name: "简体中文", + value: "zh-CN", + }, + { + name: "繁體中文", + value: "zh-TW", + }, +] + +const FieldTableHeader = () => ( + + + + Field + Operator + Value + + +) + +const DeleteTableCell = ({ + disabled, + onClick, +}: { + disabled?: boolean + onClick?: () => void +}) => ( + + + +) + +const AddTableRow = ({ onClick }: { onClick?: () => void }) => ( + +) + +const OperationTableCell = ({ + value, + onValueChange, +}: { + value?: Operation + onValueChange?: (value: Operation) => void +}) => ( + + + +) + +const SettingCollapsible = ({ + title, + children, + open, + onOpenChange, +}: { + title: string + children: React.ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void +}) => ( + +
+ + + + +
+ {children} +
+) + +export function ActionCard({ + data, + onChange, +}: { + data: ActionsInput[number] + onChange: (data: ActionsInput[number]) => void +}) { + return ( + + + +
+

Rule name

+ { + data.name = e.target.value + onChange(data) + }} + /> + +
{data.name}
+ +
+
+ +
+

When feeds match…

+ 0 ? "filter" : "all"} + onValueChange={(value) => { + if (value === "all") { + data.condition = [] + } else { + data.condition = [{}] + } + onChange(data) + }} + > +
+ + +
+
+ + +
+
+ {data.condition.length > 0 && ( +
+ + + + {data.condition.map((condition, conditionIdx) => { + const change = ( + key: string, + value: string | number, + ) => { + if (!data.condition[conditionIdx]) { + data.condition[conditionIdx] = {} + } + data.condition[conditionIdx][key] = value + onChange(data) + } + return ( + + { + data.condition.splice(conditionIdx, 1) + onChange(data) + }} + /> + + + + + change("operator", value)} + /> + + + change("value", e.target.value)} + /> + + + ) + })} + +
+ { + data.condition.push({}) + onChange(data) + }} + /> +
+ )} +
+
+

+ Then the settings are… +

+
+ { + if (open) { + data.result.translation = TransitionOptions[0].value + } else { + delete data.result.translation + } + onChange(data) + }} + > + + + + { + if (open) { + data.result.summary = true + } else { + delete data.result.summary + } + onChange(data) + }} + > + { + data.result.summary = checked + onChange(data) + }} + /> + + + { + if (open) { + data.result.rewriteRules = [{ + from: "", + to: "", + }] + } else { + delete data.result.rewriteRules + } + onChange(data) + }} + > + {data.result.rewriteRules && data.result.rewriteRules.length > 0 && ( + <> + + + + + From + To + + + + {data.result.rewriteRules.map((rule, rewriteIdx) => { + const change = (key: string, value: string) => { + data.result.rewriteRules![rewriteIdx][key] = value + onChange(data) + } + return ( + + { + if ( + data.result.rewriteRules?.length === 1 + ) { + delete data.result.rewriteRules + } else { + data.result.rewriteRules?.splice( + rewriteIdx, + 1, + ) + } + onChange(data) + }} + /> + + change("from", e.target.value)} + /> + + + change("to", e.target.value)} + /> + + + ) + })} + +
+ { + data.result.rewriteRules!.push({ + from: "", + to: "", + }) + onChange(data) + }} + /> + + )} +
+ + { + if (open) { + data.result.blockRules = [{}] + } else { + delete data.result.blockRules + } + onChange(data) + }} + > + {data.result.blockRules && data.result.blockRules.length > 0 && ( + <> + + + + {data.result.blockRules.map((rule, index) => { + const change = ( + key: string, + value: string | number, + ) => { + data.result.blockRules![index][key] = value + onChange(data) + } + return ( + + { + if (data.result.blockRules?.length === 1) { + delete data.result.blockRules + } else { + data.result.blockRules?.splice(index, 1) + } + onChange(data) + }} + /> + + + + change("operator", value)} + /> + + change("value", e.target.value)} + /> + + + ) + })} + +
+ { + data.result.blockRules!.push({}) + onChange(data) + }} + /> + + )} +
+
+
+
+
+
+
+ ) +} diff --git a/src/renderer/src/components/ui/radio-group.tsx b/src/renderer/src/components/ui/radio-group.tsx new file mode 100644 index 0000000000..31596b10d0 --- /dev/null +++ b/src/renderer/src/components/ui/radio-group.tsx @@ -0,0 +1,37 @@ +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" +import { cn } from "@renderer/lib/utils" +import { Circle } from "lucide-react" +import * as React from "react" + +const RadioGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName + +export { RadioGroup, RadioGroupItem } diff --git a/src/renderer/src/components/ui/table/index.tsx b/src/renderer/src/components/ui/table/index.tsx new file mode 100644 index 0000000000..6777c98e6e --- /dev/null +++ b/src/renderer/src/components/ui/table/index.tsx @@ -0,0 +1,129 @@ +import { cn } from "@renderer/lib/utils" +import type { VariantProps } from "class-variance-authority" +import * as React from "react" + +import { tableCellVariants, tableHeadVariants } from "./variants" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className, + )} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +export interface TableHeadProps + extends React.ThHTMLAttributes, + VariantProps { +} + +const TableHead = React.forwardRef< + HTMLTableCellElement, + TableHeadProps +>(({ className, size, ...props }, ref) => ( +
+)) +TableHead.displayName = "TableHead" + +export interface TableCellProps + extends React.TdHTMLAttributes, + VariantProps { +} + +const TableCell = React.forwardRef< + HTMLTableCellElement, + TableCellProps +>(({ className, size, ...props }, ref) => ( + +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableBody, + TableCaption, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} diff --git a/src/renderer/src/components/ui/table/variants.tsx b/src/renderer/src/components/ui/table/variants.tsx new file mode 100644 index 0000000000..e9b4d5c8eb --- /dev/null +++ b/src/renderer/src/components/ui/table/variants.tsx @@ -0,0 +1,31 @@ +import { cva } from "class-variance-authority" + +export const tableHeadVariants = cva( + "", + { + variants: { + size: { + default: "h-12 px-4", + sm: "h-6 px-3 font-normal text-zinc-800", + }, + }, + defaultVariants: { + size: "default", + }, + }, +) + +export const tableCellVariants = cva( + "", + { + variants: { + size: { + default: "p-4", + sm: "py-1 pr-2 [&:last-child]:pr-0", + }, + }, + defaultVariants: { + size: "default", + }, + }, +) diff --git a/src/renderer/src/pages/settings/actions.tsx b/src/renderer/src/pages/settings/actions.tsx index 771b3b81f5..ad9dd066b3 100644 --- a/src/renderer/src/pages/settings/actions.tsx +++ b/src/renderer/src/pages/settings/actions.tsx @@ -1,9 +1,95 @@ +import { ActionCard } from "@renderer/components/settings/action-card" import { SettingsTitle } from "@renderer/components/settings/title" +import { Button } from "@renderer/components/ui/button" +import { useState } from "react" + +type Operation = "contains" | "not_contains" | "eq" | "not_eq" | "gt" | "lt" | "regex" +type EntryField = "all" | "title" | "content" | "author" | "link" | "order" +type FeedField = "view" | "title" | "category" | "site_url" | "feed_url" + +type ActionsInput = { + name: string + condition: { + field?: FeedField + operator?: Operation + value?: string | number + }[] + result: { + translation?: string + summary?: boolean + rewriteRules?: { + from: string + to: string + }[] + blockRules?: { + field?: EntryField + operator?: Operation + value?: string | number + }[] + } +}[] export function Component() { + const [testActions, setTestActions] = useState([{ + name: "Twitter.com to x.com", + condition: [{ + field: "view" as const, + operator: "eq" as const, + value: 1, + }, { + field: "title" as const, + operator: "contains" as const, + value: "Twitter", + }], + result: { + translation: "zh-CN", + summary: true, + rewriteRules: [{ + from: "twitter.com", + to: "x.com", + }], + blockRules: [{ + field: "content" as const, + operator: "contains" as const, + value: "next.js", + }, { + field: "author" as const, + operator: "eq" as const, + value: "elonmusk", + }], + }, + }]) + return ( <> +
+ {testActions.map((action, actionIdx) => ( + { + setTestActions(testActions.map((a, idx) => idx === actionIdx ? newAction : a)) + }} + /> + ))} + +
) } diff --git a/src/renderer/src/pages/settings/layout.tsx b/src/renderer/src/pages/settings/layout.tsx index 5d76ab55c3..2476dd5be5 100644 --- a/src/renderer/src/pages/settings/layout.tsx +++ b/src/renderer/src/pages/settings/layout.tsx @@ -26,7 +26,7 @@ export function Component() { ))} -
+