feat: action page (#2006)
This commit is contained in:
parent
0ae6ec954a
commit
6d9212ef48
|
|
@ -0,0 +1,65 @@
|
|||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
import { Card, CardContent, CardHeader } from "@follow/components/ui/card/index.jsx"
|
||||
import { Input } from "@follow/components/ui/input/index.js"
|
||||
import { Switch } from "@follow/components/ui/switch/index.jsx"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { actionActions, useActionByIndex } from "~/store/action"
|
||||
|
||||
import { FeedFilter } from "./feed-filter"
|
||||
import { TargetActionList } from "./target-action-list"
|
||||
|
||||
export const ActionCard = ({ index }: { index: number }) => {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<ActionCardToolbar index={index} />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap justify-between gap-x-10 gap-y-5">
|
||||
<FeedFilter index={index} />
|
||||
<TargetActionList index={index} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const ActionCardToolbar = ({ index }: { index: number }) => {
|
||||
const { t } = useTranslation("settings")
|
||||
|
||||
const name = useActionByIndex(index, (a) => a.name)
|
||||
const disabled = useActionByIndex(index, (a) => a.result.disabled)
|
||||
|
||||
const onChange = actionActions.updateByIndex.bind(null, index)
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
actionActions.removeByIndex(index)
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-delete-2-cute-re text-zinc-600" />
|
||||
</Button>
|
||||
<p className="shrink-0 font-medium text-zinc-500">{t("actions.action_card.name")}</p>
|
||||
<Input
|
||||
value={name}
|
||||
className="h-8 max-w-64"
|
||||
onChange={(e) => {
|
||||
onChange((data) => {
|
||||
data.name = e.target.value
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<div className="grow" />
|
||||
|
||||
<Switch
|
||||
checked={!disabled}
|
||||
onCheckedChange={(checked) => {
|
||||
onChange((data) => {
|
||||
data.result.disabled = !checked
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
import { LoadingWithIcon } from "@follow/components/ui/loading/index.jsx"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { toastFetchError } from "~/lib/error-parser"
|
||||
import { queryClient } from "~/lib/query-client"
|
||||
import { ActionCard } from "~/modules/action/action-card"
|
||||
import { useActionsQuery } from "~/queries/actions"
|
||||
import { actionActions, useActions, useIsActionDataDirty } from "~/store/action"
|
||||
|
||||
export const ActionSetting = () => {
|
||||
const actionQuery = useActionsQuery()
|
||||
const actionLength = useActions((actions) => actions.length)
|
||||
|
||||
if (actionQuery.isPending) {
|
||||
return <LoadingWithIcon icon={<i className="i-mgc-magic-2-cute-re" />} size="large" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl space-y-4">
|
||||
{Array.from({ length: actionLength }).map((_action, actionIdx) => (
|
||||
<ActionCard key={actionIdx} index={actionIdx} />
|
||||
))}
|
||||
<ActionSettingOperations />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionSettingOperations() {
|
||||
const { t } = useTranslation("settings")
|
||||
|
||||
const actionLength = useActions((actions) => actions.length)
|
||||
const isDirty = useIsActionDataDirty()
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => actionActions.updateRemoteActions(),
|
||||
onSuccess: () => {
|
||||
// apply new action settings
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["entries"],
|
||||
})
|
||||
toast(t("actions.saveSuccess"))
|
||||
},
|
||||
onError: (error) => {
|
||||
toastFetchError(error)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="center w-full gap-1"
|
||||
onClick={() => {
|
||||
actionActions.insertNewEmptyAction(t("actions.actionName", { number: actionLength + 1 }))
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-add-cute-re" />
|
||||
<span>{t("actions.newRule")}</span>
|
||||
</Button>
|
||||
<div className="text-right">
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!isDirty}
|
||||
isLoading={mutation.isPending}
|
||||
onClick={() => mutation.mutate()}
|
||||
>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,349 @@
|
|||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
import { Input } from "@follow/components/ui/input/index.js"
|
||||
import { Radio, RadioGroup } from "@follow/components/ui/radio-group/index.js"
|
||||
import { Select, SelectTrigger, SelectValue } from "@follow/components/ui/select/index.jsx"
|
||||
import { ResponsiveSelect } from "@follow/components/ui/select/responsive.js"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@follow/components/ui/table/index.jsx"
|
||||
import type { ActionFeedField, ActionOperation } from "@follow/models/types"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { Fragment, useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { ViewSelectContent } from "~/modules/feed/view-select-content"
|
||||
import { actionActions, useActionByIndex } from "~/store/action"
|
||||
|
||||
export const FeedFilter = ({ index }: { index: number }) => {
|
||||
const { t } = useTranslation("settings")
|
||||
|
||||
const FeedOptions = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: t("actions.action_card.feed_options.subscription_view"),
|
||||
value: "view",
|
||||
type: "view",
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.feed_options.feed_title"),
|
||||
value: "title",
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.feed_options.feed_category"),
|
||||
value: "category",
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.feed_options.site_url"),
|
||||
value: "site_url",
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.feed_options.feed_url"),
|
||||
value: "feed_url",
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.feed_options.entry_title"),
|
||||
value: "entry_title",
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.feed_options.entry_content"),
|
||||
value: "entry_content",
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.feed_options.entry_url"),
|
||||
value: "entry_url",
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.feed_options.entry_author"),
|
||||
value: "entry_author",
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.feed_options.entry_media_length"),
|
||||
value: "entry_media_length",
|
||||
type: "number",
|
||||
},
|
||||
]
|
||||
}, [t])
|
||||
|
||||
const disabled = useActionByIndex(index, (a) => a.result.disabled)
|
||||
const condition = useActionByIndex(index, (a) => a.condition)
|
||||
|
||||
const onChange = actionActions.updateByIndex.bind(null, index)
|
||||
return (
|
||||
<div className="shrink grow space-y-3 overflow-auto">
|
||||
<p className="font-medium text-zinc-500">{t("actions.action_card.when_feeds_match")}</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<RadioGroup
|
||||
value={condition.length > 0 ? "filter" : "all"}
|
||||
onValueChange={(value) => {
|
||||
onChange((data) => {
|
||||
if (value === "all") {
|
||||
data.condition = []
|
||||
} else {
|
||||
data.condition = [[{}]]
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Radio disabled={disabled} label={t("actions.action_card.all")} value="all" />
|
||||
<Radio
|
||||
disabled={disabled}
|
||||
label={t("actions.action_card.custom_filters")}
|
||||
value="filter"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{condition.length > 0 && (
|
||||
<div>
|
||||
<Table>
|
||||
<FieldTableHeader />
|
||||
<TableBody>
|
||||
{condition.flatMap((orConditions, orConditionIdx) => {
|
||||
return orConditions.map((condition, conditionIdx) => {
|
||||
const change = (key: string, value: string | number) => {
|
||||
onChange((data) => {
|
||||
if (!data.condition[orConditionIdx]) {
|
||||
data.condition[orConditionIdx] = [{}]
|
||||
}
|
||||
data.condition[orConditionIdx][conditionIdx][key] = value
|
||||
})
|
||||
}
|
||||
const type =
|
||||
FeedOptions.find((option) => option.value === condition.field)?.type || "text"
|
||||
return (
|
||||
<Fragment key={`${orConditionIdx}${conditionIdx}`}>
|
||||
{conditionIdx === 0 && orConditionIdx !== 0 && (
|
||||
<TableRow className="flex h-16 items-center">
|
||||
<Button disabled variant="outline">
|
||||
{t("actions.action_card.or")}
|
||||
</Button>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow className="max-sm:flex max-sm:flex-col">
|
||||
<TableCell
|
||||
size="sm"
|
||||
className="max-sm:flex max-sm:items-center max-sm:justify-between max-sm:pr-0"
|
||||
>
|
||||
<span className="sm:hidden">{t("actions.action_card.field")}</span>
|
||||
<ResponsiveSelect
|
||||
disabled={disabled}
|
||||
value={condition.field}
|
||||
onValueChange={(value) => change("field", value as ActionFeedField)}
|
||||
items={FeedOptions}
|
||||
triggerClassName="max-sm:w-fit"
|
||||
/>
|
||||
</TableCell>
|
||||
<OperationTableCell
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
value={condition.operator}
|
||||
onValueChange={(value) => change("operator", value)}
|
||||
/>
|
||||
<TableCell
|
||||
size="sm"
|
||||
className="max-sm:flex max-sm:items-center max-sm:justify-between max-sm:gap-4 max-sm:pr-0"
|
||||
>
|
||||
<span className="sm:hidden">{t("actions.action_card.value")}</span>
|
||||
{type === "view" ? (
|
||||
<Select
|
||||
disabled={disabled}
|
||||
onValueChange={(value) => change("value", value)}
|
||||
value={condition.value}
|
||||
>
|
||||
<CommonSelectTrigger className="max-sm:w-fit" />
|
||||
<ViewSelectContent />
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
disabled={disabled}
|
||||
type={type}
|
||||
value={condition.value}
|
||||
className="h-8"
|
||||
onChange={(e) => change("value", e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<ActionTableCell
|
||||
disabled={disabled}
|
||||
onAnd={() => {
|
||||
onChange((data) => {
|
||||
data.condition[orConditionIdx].push({})
|
||||
})
|
||||
}}
|
||||
onDelete={() => {
|
||||
onChange((data) => {
|
||||
if (data.condition[orConditionIdx].length === 1) {
|
||||
data.condition.splice(orConditionIdx, 1)
|
||||
} else {
|
||||
data.condition[orConditionIdx].splice(conditionIdx, 1)
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</TableRow>
|
||||
{conditionIdx !== orConditions.length - 1 && (
|
||||
<TableRow className="relative flex items-center">
|
||||
<Button disabled variant="outline">
|
||||
{t("actions.action_card.and")}
|
||||
</Button>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<OrTableRow
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onChange((data) => {
|
||||
data.condition.push([{}])
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const OperationTableCell = ({
|
||||
type,
|
||||
value,
|
||||
onValueChange,
|
||||
disabled,
|
||||
}: {
|
||||
type: string
|
||||
value?: ActionOperation
|
||||
onValueChange?: (value: ActionOperation) => void
|
||||
disabled?: boolean
|
||||
}) => {
|
||||
const { t } = useTranslation("settings")
|
||||
|
||||
const OperationOptions = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: t("actions.action_card.operation_options.contains"),
|
||||
value: "contains",
|
||||
types: ["text"],
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.operation_options.does_not_contain"),
|
||||
value: "not_contains",
|
||||
types: ["text"],
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.operation_options.is_equal_to"),
|
||||
value: "eq",
|
||||
types: ["number", "text", "view"],
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.operation_options.is_not_equal_to"),
|
||||
value: "not_eq",
|
||||
types: ["number", "text", "view"],
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.operation_options.is_greater_than"),
|
||||
value: "gt",
|
||||
types: ["number"],
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.operation_options.is_less_than"),
|
||||
value: "lt",
|
||||
types: ["number"],
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.operation_options.matches_regex"),
|
||||
value: "regex",
|
||||
types: ["text"],
|
||||
},
|
||||
]
|
||||
}, [t])
|
||||
|
||||
const options = OperationOptions.filter((option) => option.types.includes(type))
|
||||
return (
|
||||
<TableCell
|
||||
size="sm"
|
||||
className="max-sm:flex max-sm:items-center max-sm:justify-between max-sm:pr-0"
|
||||
>
|
||||
<span className="sm:hidden">{t("actions.action_card.operator")}</span>
|
||||
<ResponsiveSelect
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onValueChange={(value) => onValueChange?.(value as ActionOperation)}
|
||||
items={options}
|
||||
triggerClassName="h-8 max-sm:w-fit"
|
||||
/>
|
||||
</TableCell>
|
||||
)
|
||||
}
|
||||
|
||||
const CommonSelectTrigger = ({ className }: { className?: string }) => (
|
||||
<SelectTrigger className={cn("h-8", className)}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
)
|
||||
|
||||
const ActionTableCell = ({
|
||||
disabled,
|
||||
onAnd,
|
||||
onDelete,
|
||||
}: {
|
||||
disabled?: boolean
|
||||
onAnd?: () => void
|
||||
onDelete?: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation("settings")
|
||||
return (
|
||||
<>
|
||||
<TableCell size="sm" className="max-sm:flex max-sm:space-x-2 max-sm:pr-0">
|
||||
<Button variant="ghost" className="sm:w-full" disabled={disabled} onClick={onDelete}>
|
||||
<i className="i-mgc-delete-2-cute-re text-zinc-600" />
|
||||
</Button>
|
||||
<Button className="w-full sm:hidden" variant="outline" disabled={disabled} onClick={onAnd}>
|
||||
{t("actions.action_card.and")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell size="sm" className="max-sm:hidden">
|
||||
<Button variant="outline" className="w-full" disabled={disabled} onClick={onAnd}>
|
||||
{t("actions.action_card.and")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const OrTableRow = ({ onClick, disabled }: { onClick?: () => void; disabled?: boolean }) => {
|
||||
const { t } = useTranslation("settings")
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-1 w-full gap-1"
|
||||
buttonClassName="py-1"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t("actions.action_card.or")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const FieldTableHeader = () => {
|
||||
const { t } = useTranslation("settings")
|
||||
return (
|
||||
<TableHeader className="max-sm:hidden">
|
||||
<TableRow>
|
||||
<TableHead size="sm">{t("actions.action_card.field")}</TableHead>
|
||||
<TableHead size="sm">{t("actions.action_card.operator")}</TableHead>
|
||||
<TableHead size="sm">{t("actions.action_card.value")}</TableHead>
|
||||
<TableHead size="sm" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,399 @@
|
|||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
import { Divider } from "@follow/components/ui/divider/index.js"
|
||||
import { Input } from "@follow/components/ui/input/index.js"
|
||||
import { ResponsiveSelect } from "@follow/components/ui/select/responsive.js"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@follow/components/ui/table/index.jsx"
|
||||
import type { ActionModel, SupportedLanguages } from "@follow/models/types"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { Fragment, useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu/dropdown-menu"
|
||||
import { actionActions, useActionByIndex } from "~/store/action"
|
||||
|
||||
type Action = {
|
||||
title: string
|
||||
config?: () => React.ReactNode
|
||||
configInline?: boolean
|
||||
enabled: boolean
|
||||
onInit: (data: ActionModel) => void
|
||||
onRemove: (data: ActionModel) => void
|
||||
}
|
||||
|
||||
const TransitionOptions: {
|
||||
label: string
|
||||
value: SupportedLanguages
|
||||
}[] = [
|
||||
{
|
||||
label: "English",
|
||||
value: "en",
|
||||
},
|
||||
{
|
||||
label: "日本語",
|
||||
value: "ja",
|
||||
},
|
||||
{
|
||||
label: "简体中文",
|
||||
value: "zh-CN",
|
||||
},
|
||||
{
|
||||
label: "繁體中文",
|
||||
value: "zh-TW",
|
||||
},
|
||||
]
|
||||
|
||||
const AddTableRow = ({ onClick, disabled }: { onClick?: () => void; disabled?: boolean }) => {
|
||||
const { t } = useTranslation("settings")
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-1 w-full gap-1"
|
||||
buttonClassName="py-1"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<i className="i-mgc-add-cute-re" /> {t("actions.action_card.add")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const DeleteTableCell = ({ disabled, onClick }: { disabled?: boolean; onClick?: () => void }) => (
|
||||
<TableCell size="sm" className="flex h-10 items-center">
|
||||
<Button variant="ghost" className="w-full" disabled={disabled} onClick={onClick}>
|
||||
<i className="i-mgc-delete-2-cute-re text-zinc-600" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)
|
||||
|
||||
export const TargetActionList = ({ index }: { index: number }) => {
|
||||
const summary = useActionByIndex(index, (a) => a.result.summary)
|
||||
const translation = useActionByIndex(index, (a) => a.result.translation)
|
||||
const readability = useActionByIndex(index, (a) => a.result.readability)
|
||||
const sourceContent = useActionByIndex(index, (a) => a.result.sourceContent)
|
||||
const newEntryNotification = useActionByIndex(index, (a) => a.result.newEntryNotification)
|
||||
const silence = useActionByIndex(index, (a) => a.result.silence)
|
||||
const block = useActionByIndex(index, (a) => a.result.block)
|
||||
const rewriteRules = useActionByIndex(index, (a) => a.result.rewriteRules)
|
||||
const webhooks = useActionByIndex(index, (a) => a.result.webhooks)
|
||||
|
||||
const disabled = useActionByIndex(index, (a) => a.result.disabled)
|
||||
const { t } = useTranslation("settings")
|
||||
|
||||
const onChange = actionActions.updateByIndex.bind(null, index)
|
||||
|
||||
const availableActions: Action[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t("actions.action_card.generate_summary"),
|
||||
enabled: !!summary,
|
||||
onInit: (data) => {
|
||||
data.result.summary = true
|
||||
},
|
||||
onRemove: (data) => {
|
||||
delete data.result.summary
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("actions.action_card.translate_into"),
|
||||
enabled: !!translation,
|
||||
onInit: (data) => {
|
||||
data.result.translation = "zh-CN"
|
||||
},
|
||||
onRemove: (data) => {
|
||||
delete data.result.translation
|
||||
},
|
||||
config: () => (
|
||||
<ResponsiveSelect
|
||||
disabled={disabled}
|
||||
value={translation}
|
||||
onValueChange={(value) => {
|
||||
onChange((data) => {
|
||||
data.result.translation = value
|
||||
})
|
||||
}}
|
||||
items={TransitionOptions}
|
||||
triggerClassName="w-fit max-w-44"
|
||||
/>
|
||||
),
|
||||
configInline: true,
|
||||
},
|
||||
{
|
||||
title: t("actions.action_card.enable_readability"),
|
||||
enabled: !!readability,
|
||||
onInit: (data) => {
|
||||
data.result.readability = true
|
||||
},
|
||||
onRemove: (data) => {
|
||||
delete data.result.readability
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("actions.action_card.source_content"),
|
||||
enabled: !!sourceContent,
|
||||
onInit: (data) => {
|
||||
data.result.sourceContent = true
|
||||
},
|
||||
onRemove: (data) => {
|
||||
delete data.result.sourceContent
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("actions.action_card.new_entry_notification"),
|
||||
enabled: !!newEntryNotification,
|
||||
onInit: (data) => {
|
||||
data.result.newEntryNotification = true
|
||||
},
|
||||
onRemove: (data) => {
|
||||
delete data.result.newEntryNotification
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("actions.action_card.silence"),
|
||||
enabled: !!silence,
|
||||
onInit: (data) => {
|
||||
data.result.silence = true
|
||||
},
|
||||
onRemove: (data) => {
|
||||
delete data.result.silence
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("actions.action_card.block"),
|
||||
enabled: !!block,
|
||||
onInit: (data) => {
|
||||
data.result.block = true
|
||||
},
|
||||
onRemove: (data) => {
|
||||
delete data.result.block
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("actions.action_card.rewrite_rules"),
|
||||
enabled: !!rewriteRules,
|
||||
onInit: (data) => {
|
||||
data.result.rewriteRules = [
|
||||
{
|
||||
from: "",
|
||||
to: "",
|
||||
},
|
||||
]
|
||||
},
|
||||
onRemove: (data) => {
|
||||
delete data.result.rewriteRules
|
||||
},
|
||||
config: () => (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead size="sm" />
|
||||
<TableHead size="sm">{t("actions.action_card.from")}</TableHead>
|
||||
<TableHead size="sm">{t("actions.action_card.to")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rewriteRules?.map((rule, rewriteIdx) => {
|
||||
const change = (key: string, value: string) => {
|
||||
onChange((data) => {
|
||||
data.result.rewriteRules![rewriteIdx][key] = value
|
||||
})
|
||||
}
|
||||
return (
|
||||
<TableRow key={rewriteIdx}>
|
||||
<DeleteTableCell
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onChange((data) => {
|
||||
if (data.result.rewriteRules?.length === 1) {
|
||||
delete data.result.rewriteRules
|
||||
} else {
|
||||
data.result.rewriteRules?.splice(rewriteIdx, 1)
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<TableCell size="sm">
|
||||
<Input
|
||||
disabled={disabled}
|
||||
value={rule.from}
|
||||
className="h-8"
|
||||
onChange={(e) => change("from", e.target.value)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell size="sm">
|
||||
<Input
|
||||
disabled={disabled}
|
||||
value={rule.to}
|
||||
className="h-8"
|
||||
onChange={(e) => change("to", e.target.value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<AddTableRow
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onChange((data) => {
|
||||
if (!data.result.rewriteRules) {
|
||||
data.result.rewriteRules = []
|
||||
}
|
||||
data.result.rewriteRules!.push({
|
||||
from: "",
|
||||
to: "",
|
||||
})
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("actions.action_card.webhooks"),
|
||||
enabled: !!webhooks,
|
||||
onInit: (data) => {
|
||||
data.result.webhooks = [""]
|
||||
},
|
||||
onRemove: (data) => {
|
||||
delete data.result.webhooks
|
||||
},
|
||||
config: () => (
|
||||
<>
|
||||
{webhooks?.map((webhook, rewriteIdx) => {
|
||||
return (
|
||||
<div key={rewriteIdx} className="flex items-center gap-2">
|
||||
<DeleteTableCell
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onChange((data) => {
|
||||
if (data.result.webhooks?.length === 1) {
|
||||
delete data.result.webhooks
|
||||
} else {
|
||||
data.result.webhooks?.splice(rewriteIdx, 1)
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
value={webhook}
|
||||
className="h-8"
|
||||
placeholder="https://"
|
||||
onChange={(e) => {
|
||||
onChange((data) => {
|
||||
data.result.webhooks![rewriteIdx] = e.target.value
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<AddTableRow
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onChange((data) => {
|
||||
if (!data.result.webhooks) {
|
||||
data.result.webhooks = []
|
||||
}
|
||||
data.result.webhooks!.push("")
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
block,
|
||||
disabled,
|
||||
newEntryNotification,
|
||||
onChange,
|
||||
readability,
|
||||
rewriteRules,
|
||||
silence,
|
||||
sourceContent,
|
||||
summary,
|
||||
t,
|
||||
translation,
|
||||
webhooks,
|
||||
],
|
||||
)
|
||||
const enabledActions = useMemo(
|
||||
() => availableActions.filter((action) => action.enabled),
|
||||
[availableActions],
|
||||
)
|
||||
const notEnabledActions = useMemo(
|
||||
() => availableActions.filter((action) => !action.enabled),
|
||||
[availableActions],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-w-[270px] shrink grow space-y-4">
|
||||
<p className="font-medium text-zinc-500">{t("actions.action_card.then_do")}</p>
|
||||
<div className="w-full space-y-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild disabled={disabled}>
|
||||
<Button variant="outline" className={cn(notEnabledActions.length === 0 && "hidden")}>
|
||||
{t("actions.action_card.add_action")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{notEnabledActions.map((action) => {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={action.title}
|
||||
onClick={() => {
|
||||
onChange((data) => {
|
||||
action.onInit(data)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{action.title}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<section>
|
||||
{enabledActions
|
||||
.filter((action) => action.enabled)
|
||||
.map((action, index) => {
|
||||
return (
|
||||
<Fragment key={action.title}>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="w-0 shrink grow truncate">{action.title}</span>
|
||||
{action.configInline && action.config && action.config()}
|
||||
<DeleteTableCell
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onChange((data) => {
|
||||
action.onRemove(data)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!action.configInline && action.config && action.config()}
|
||||
{index !== enabledActions.length - 1 && <Divider className="my-2" />}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,738 +0,0 @@
|
|||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
import { Card, CardHeader } from "@follow/components/ui/card/index.jsx"
|
||||
import { Divider } from "@follow/components/ui/divider/index.js"
|
||||
import { Input } from "@follow/components/ui/input/index.js"
|
||||
import { Radio, RadioGroup } from "@follow/components/ui/radio-group/index.js"
|
||||
import { Select, SelectTrigger, SelectValue } from "@follow/components/ui/select/index.jsx"
|
||||
import { ResponsiveSelect } from "@follow/components/ui/select/responsive.js"
|
||||
import { Switch } from "@follow/components/ui/switch/index.jsx"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@follow/components/ui/table/index.jsx"
|
||||
import type {
|
||||
ActionFeedField,
|
||||
ActionOperation,
|
||||
ActionsInput,
|
||||
SupportedLanguages,
|
||||
} from "@follow/models/types"
|
||||
import { stopPropagation } from "@follow/utils/dom"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { Fragment, useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { Collapse, CollapseControlled } from "~/components/ui/collapse"
|
||||
import { ViewSelectContent } from "~/modules/feed/view-select-content"
|
||||
|
||||
const FieldTableHeader = () => {
|
||||
const { t } = useTranslation("settings")
|
||||
return (
|
||||
<TableHeader className="max-sm:hidden">
|
||||
<TableRow>
|
||||
<TableHead size="sm">{t("actions.action_card.field")}</TableHead>
|
||||
<TableHead size="sm">{t("actions.action_card.operator")}</TableHead>
|
||||
<TableHead size="sm">{t("actions.action_card.value")}</TableHead>
|
||||
<TableHead size="sm" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
)
|
||||
}
|
||||
|
||||
const DeleteTableCell = ({ disabled, onClick }: { disabled?: boolean; onClick?: () => void }) => (
|
||||
<TableCell size="sm" className="flex h-10 items-center">
|
||||
<Button variant="ghost" className="w-full" disabled={disabled} onClick={onClick}>
|
||||
<i className="i-mgc-delete-2-cute-re text-zinc-600" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)
|
||||
|
||||
const ActionTableCell = ({
|
||||
disabled,
|
||||
onAnd,
|
||||
onDelete,
|
||||
}: {
|
||||
disabled?: boolean
|
||||
onAnd?: () => void
|
||||
onDelete?: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation("settings")
|
||||
return (
|
||||
<>
|
||||
<TableCell size="sm" className="max-sm:flex max-sm:space-x-2 max-sm:pr-0">
|
||||
<Button variant="ghost" className="sm:w-full" disabled={disabled} onClick={onDelete}>
|
||||
<i className="i-mgc-delete-2-cute-re text-zinc-600" />
|
||||
</Button>
|
||||
<Button className="w-full sm:hidden" variant="outline" disabled={disabled} onClick={onAnd}>
|
||||
{t("actions.action_card.and")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell size="sm" className="max-sm:hidden">
|
||||
<Button variant="outline" className="w-full" disabled={disabled} onClick={onAnd}>
|
||||
{t("actions.action_card.and")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const OrTableRow = ({ onClick, disabled }: { onClick?: () => void; disabled?: boolean }) => {
|
||||
const { t } = useTranslation("settings")
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-1 w-full gap-1"
|
||||
buttonClassName="py-1"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t("actions.action_card.or")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const AddTableRow = ({ onClick, disabled }: { onClick?: () => void; disabled?: boolean }) => {
|
||||
const { t } = useTranslation("settings")
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-1 w-full gap-1"
|
||||
buttonClassName="py-1"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<i className="i-mgc-add-cute-re" /> {t("actions.action_card.add")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const OperationTableCell = ({
|
||||
type,
|
||||
value,
|
||||
onValueChange,
|
||||
disabled,
|
||||
}: {
|
||||
type: string
|
||||
value?: ActionOperation
|
||||
onValueChange?: (value: ActionOperation) => void
|
||||
disabled?: boolean
|
||||
}) => {
|
||||
const { t } = useTranslation("settings")
|
||||
|
||||
const OperationOptions = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: t("actions.action_card.operation_options.contains"),
|
||||
value: "contains",
|
||||
types: ["text"],
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.operation_options.does_not_contain"),
|
||||
value: "not_contains",
|
||||
types: ["text"],
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.operation_options.is_equal_to"),
|
||||
value: "eq",
|
||||
types: ["number", "text", "view"],
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.operation_options.is_not_equal_to"),
|
||||
value: "not_eq",
|
||||
types: ["number", "text", "view"],
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.operation_options.is_greater_than"),
|
||||
value: "gt",
|
||||
types: ["number"],
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.operation_options.is_less_than"),
|
||||
value: "lt",
|
||||
types: ["number"],
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.operation_options.matches_regex"),
|
||||
value: "regex",
|
||||
types: ["text"],
|
||||
},
|
||||
]
|
||||
}, [t])
|
||||
|
||||
const options = OperationOptions.filter((option) => option.types.includes(type))
|
||||
return (
|
||||
<TableCell
|
||||
size="sm"
|
||||
className="max-sm:flex max-sm:items-center max-sm:justify-between max-sm:pr-0"
|
||||
>
|
||||
<span className="sm:hidden">{t("actions.action_card.operator")}</span>
|
||||
<ResponsiveSelect
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onValueChange={(value) => onValueChange?.(value as ActionOperation)}
|
||||
items={options}
|
||||
triggerClassName="h-8 max-sm:w-fit"
|
||||
/>
|
||||
</TableCell>
|
||||
)
|
||||
}
|
||||
|
||||
const SettingCollapsible = ({
|
||||
title,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const toggleOpen = () => {
|
||||
setOpen((pre) => !pre)
|
||||
onOpenChange && onOpenChange(!open)
|
||||
}
|
||||
|
||||
if (typeof open === "boolean" && typeof onOpenChange === "function") {
|
||||
return (
|
||||
<CollapseControlled isOpened={open} onOpenChange={toggleOpen} title={title}>
|
||||
<div className="px-1 pt-2">{children}</div>
|
||||
</CollapseControlled>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapse title={title}>
|
||||
<div className="pt-2">{children}</div>
|
||||
</Collapse>
|
||||
)
|
||||
}
|
||||
|
||||
const CommonSelectTrigger = ({ className }: { className?: string }) => (
|
||||
<SelectTrigger className={cn("h-8", className)}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
)
|
||||
|
||||
export function ActionCard({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: ActionsInput[number]
|
||||
onChange: (data: ActionsInput[number] | null) => void
|
||||
}) {
|
||||
const { t } = useTranslation("settings")
|
||||
|
||||
const FeedOptions = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: t("actions.action_card.feed_options.subscription_view"),
|
||||
value: "view",
|
||||
type: "view",
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.feed_options.feed_title"),
|
||||
value: "title",
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.feed_options.feed_category"),
|
||||
value: "category",
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.feed_options.site_url"),
|
||||
value: "site_url",
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.feed_options.feed_url"),
|
||||
value: "feed_url",
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.feed_options.entry_title"),
|
||||
value: "entry_title",
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.feed_options.entry_content"),
|
||||
value: "entry_content",
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.feed_options.entry_url"),
|
||||
value: "entry_url",
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.feed_options.entry_author"),
|
||||
value: "entry_author",
|
||||
},
|
||||
{
|
||||
label: t("actions.action_card.feed_options.entry_media_length"),
|
||||
value: "entry_media_length",
|
||||
type: "number",
|
||||
},
|
||||
]
|
||||
}, [t])
|
||||
|
||||
const TransitionOptions: {
|
||||
label: string
|
||||
value: SupportedLanguages
|
||||
}[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: t("actions.action_card.no_translation"),
|
||||
value: "none" as SupportedLanguages,
|
||||
},
|
||||
{
|
||||
label: "English",
|
||||
value: "en",
|
||||
},
|
||||
{
|
||||
label: "日本語",
|
||||
value: "ja",
|
||||
},
|
||||
{
|
||||
label: "简体中文",
|
||||
value: "zh-CN",
|
||||
},
|
||||
{
|
||||
label: "繁體中文",
|
||||
value: "zh-TW",
|
||||
},
|
||||
]
|
||||
}, [t])
|
||||
|
||||
const { disabled } = data.result
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-4 px-6 py-4">
|
||||
<Collapse
|
||||
className="[&_.name-placeholder]:data-[state=open]:hidden [&_input.name-input]:data-[state=open]:block"
|
||||
title={
|
||||
<div className="flex w-full items-center gap-2 pr-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
onChange(null)
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-delete-2-cute-re text-zinc-600" />
|
||||
</Button>
|
||||
<p className="shrink-0 font-medium text-zinc-500">{t("actions.action_card.name")}</p>
|
||||
<Input
|
||||
value={data.name}
|
||||
className="name-input hidden h-8"
|
||||
onClick={stopPropagation}
|
||||
onChange={(e) => {
|
||||
data.name = e.target.value
|
||||
onChange(data)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="name-placeholder flex-1 text-sm">{data.name}</div>
|
||||
<Switch
|
||||
checked={!data.result.disabled}
|
||||
onCheckedChange={(checked) => {
|
||||
data.result.disabled = !checked
|
||||
onChange(data)
|
||||
}}
|
||||
onClick={stopPropagation}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="mt-4 space-y-4 px-1">
|
||||
<div className="space-y-3">
|
||||
<p className="font-medium text-zinc-500">
|
||||
{t("actions.action_card.when_feeds_match")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<RadioGroup
|
||||
value={data.condition.length > 0 ? "filter" : "all"}
|
||||
onValueChange={(value) => {
|
||||
if (value === "all") {
|
||||
data.condition = []
|
||||
} else {
|
||||
data.condition = [[{}]]
|
||||
}
|
||||
onChange(data)
|
||||
}}
|
||||
>
|
||||
<Radio disabled={disabled} label={t("actions.action_card.all")} value="all" />
|
||||
<Radio
|
||||
disabled={disabled}
|
||||
label={t("actions.action_card.custom_filters")}
|
||||
value="filter"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{data.condition.length > 0 && (
|
||||
<div>
|
||||
<Table>
|
||||
<FieldTableHeader />
|
||||
<TableBody>
|
||||
{data.condition.flatMap((orConditions, orConditionIdx) => {
|
||||
return orConditions.map((condition, conditionIdx) => {
|
||||
const change = (key: string, value: string | number) => {
|
||||
if (!data.condition[orConditionIdx]) {
|
||||
data.condition[orConditionIdx] = [{}]
|
||||
}
|
||||
data.condition[orConditionIdx][conditionIdx][key] = value
|
||||
onChange(data)
|
||||
}
|
||||
const type =
|
||||
FeedOptions.find((option) => option.value === condition.field)?.type ||
|
||||
"text"
|
||||
return (
|
||||
<Fragment key={`${orConditionIdx}${conditionIdx}`}>
|
||||
{conditionIdx === 0 && orConditionIdx !== 0 && (
|
||||
<TableRow className="flex h-16 items-center">
|
||||
<Button disabled variant="outline">
|
||||
{t("actions.action_card.or")}
|
||||
</Button>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow className="max-sm:flex max-sm:flex-col">
|
||||
<TableCell
|
||||
size="sm"
|
||||
className="max-sm:flex max-sm:items-center max-sm:justify-between max-sm:pr-0"
|
||||
>
|
||||
<span className="sm:hidden">
|
||||
{t("actions.action_card.field")}
|
||||
</span>
|
||||
<ResponsiveSelect
|
||||
disabled={disabled}
|
||||
value={condition.field}
|
||||
onValueChange={(value) =>
|
||||
change("field", value as ActionFeedField)
|
||||
}
|
||||
items={FeedOptions}
|
||||
triggerClassName="max-sm:w-fit"
|
||||
/>
|
||||
</TableCell>
|
||||
<OperationTableCell
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
value={condition.operator}
|
||||
onValueChange={(value) => change("operator", value)}
|
||||
/>
|
||||
<TableCell
|
||||
size="sm"
|
||||
className="max-sm:flex max-sm:items-center max-sm:justify-between max-sm:gap-4 max-sm:pr-0"
|
||||
>
|
||||
<span className="sm:hidden">
|
||||
{t("actions.action_card.value")}
|
||||
</span>
|
||||
{type === "view" ? (
|
||||
<Select
|
||||
disabled={disabled}
|
||||
onValueChange={(value) => change("value", value)}
|
||||
value={condition.value}
|
||||
>
|
||||
<CommonSelectTrigger className="max-sm:w-fit" />
|
||||
<ViewSelectContent />
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
disabled={disabled}
|
||||
type={type}
|
||||
value={condition.value}
|
||||
className="h-8"
|
||||
onChange={(e) => change("value", e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<ActionTableCell
|
||||
disabled={disabled}
|
||||
onAnd={() => {
|
||||
data.condition[orConditionIdx].push({})
|
||||
onChange(data)
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (data.condition[orConditionIdx].length === 1) {
|
||||
data.condition.splice(orConditionIdx, 1)
|
||||
} else {
|
||||
data.condition[orConditionIdx].splice(conditionIdx, 1)
|
||||
}
|
||||
onChange(data)
|
||||
}}
|
||||
/>
|
||||
</TableRow>
|
||||
{conditionIdx !== orConditions.length - 1 && (
|
||||
<TableRow className="relative flex items-center">
|
||||
<Button disabled variant="outline">
|
||||
{t("actions.action_card.and")}
|
||||
</Button>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<OrTableRow
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
data.condition.push([{}])
|
||||
onChange(data)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<p className="font-medium text-zinc-500">{t("actions.action_card.then_do")}</p>
|
||||
<div className="w-full space-y-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="w-0 shrink grow truncate">
|
||||
{t("actions.action_card.generate_summary")}
|
||||
</span>
|
||||
<Switch
|
||||
disabled={disabled}
|
||||
checked={data.result.summary}
|
||||
onCheckedChange={(checked) => {
|
||||
data.result.summary = checked
|
||||
onChange(data)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="w-0 shrink grow truncate">
|
||||
{t("actions.action_card.translate_into")}
|
||||
</span>
|
||||
<ResponsiveSelect
|
||||
disabled={disabled}
|
||||
value={data.result.translation}
|
||||
onValueChange={(value) => {
|
||||
if (value === "none") {
|
||||
delete data.result.translation
|
||||
} else {
|
||||
data.result.translation = value
|
||||
}
|
||||
onChange(data)
|
||||
}}
|
||||
items={TransitionOptions}
|
||||
triggerClassName="max-w-44"
|
||||
/>
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="w-0 shrink grow truncate">
|
||||
{t("actions.action_card.enable_readability")}
|
||||
</span>
|
||||
<Switch
|
||||
disabled={disabled}
|
||||
checked={data.result.readability}
|
||||
onCheckedChange={(checked) => {
|
||||
data.result.readability = checked
|
||||
onChange(data)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="w-0 shrink grow truncate">
|
||||
{t("actions.action_card.source_content")}
|
||||
</span>
|
||||
<Switch
|
||||
disabled={disabled}
|
||||
checked={data.result.sourceContent}
|
||||
onCheckedChange={(checked) => {
|
||||
data.result.sourceContent = checked
|
||||
onChange(data)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="w-0 shrink grow truncate">
|
||||
{t("actions.action_card.new_entry_notification")}
|
||||
</span>
|
||||
<Switch
|
||||
disabled={disabled}
|
||||
checked={data.result.newEntryNotification}
|
||||
onCheckedChange={(checked) => {
|
||||
data.result.newEntryNotification = checked
|
||||
onChange(data)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="w-0 shrink grow truncate">
|
||||
{t("actions.action_card.silence")}
|
||||
</span>
|
||||
<Switch
|
||||
disabled={disabled}
|
||||
checked={data.result.silence}
|
||||
onCheckedChange={(checked) => {
|
||||
data.result.silence = checked
|
||||
onChange(data)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="w-0 shrink grow truncate">{t("actions.action_card.block")}</span>
|
||||
<Switch
|
||||
disabled={disabled}
|
||||
checked={data.result.block}
|
||||
onCheckedChange={(checked) => {
|
||||
data.result.block = checked
|
||||
onChange(data)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
<SettingCollapsible
|
||||
title={t("actions.action_card.rewrite_rules")}
|
||||
onOpenChange={(open) => {
|
||||
if (
|
||||
open &&
|
||||
(!data.result.rewriteRules || data.result.rewriteRules?.length === 0)
|
||||
) {
|
||||
data.result.rewriteRules = [
|
||||
{
|
||||
from: "",
|
||||
to: "",
|
||||
},
|
||||
]
|
||||
}
|
||||
onChange(data)
|
||||
}}
|
||||
>
|
||||
{data.result.rewriteRules && data.result.rewriteRules.length > 0 && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead size="sm" />
|
||||
<TableHead size="sm">{t("actions.action_card.from")}</TableHead>
|
||||
<TableHead size="sm">{t("actions.action_card.to")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.result.rewriteRules.map((rule, rewriteIdx) => {
|
||||
const change = (key: string, value: string) => {
|
||||
data.result.rewriteRules![rewriteIdx][key] = value
|
||||
onChange(data)
|
||||
}
|
||||
return (
|
||||
<TableRow key={rewriteIdx}>
|
||||
<DeleteTableCell
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (data.result.rewriteRules?.length === 1) {
|
||||
delete data.result.rewriteRules
|
||||
} else {
|
||||
data.result.rewriteRules?.splice(rewriteIdx, 1)
|
||||
}
|
||||
onChange(data)
|
||||
}}
|
||||
/>
|
||||
<TableCell size="sm">
|
||||
<Input
|
||||
disabled={disabled}
|
||||
value={rule.from}
|
||||
className="h-8"
|
||||
onChange={(e) => change("from", e.target.value)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell size="sm">
|
||||
<Input
|
||||
disabled={disabled}
|
||||
value={rule.to}
|
||||
className="h-8"
|
||||
onChange={(e) => change("to", e.target.value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<AddTableRow
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!data.result.rewriteRules) {
|
||||
data.result.rewriteRules = []
|
||||
}
|
||||
data.result.rewriteRules!.push({
|
||||
from: "",
|
||||
to: "",
|
||||
})
|
||||
onChange(data)
|
||||
}}
|
||||
/>
|
||||
</SettingCollapsible>
|
||||
<Divider />
|
||||
<SettingCollapsible
|
||||
title={t("actions.action_card.webhooks")}
|
||||
onOpenChange={(open) => {
|
||||
if (open && (!data.result.webhooks || data.result.webhooks?.length === 0)) {
|
||||
data.result.webhooks = [""]
|
||||
}
|
||||
onChange(data)
|
||||
}}
|
||||
>
|
||||
{data.result.webhooks && data.result.webhooks.length > 0 && (
|
||||
<>
|
||||
{data.result.webhooks.map((webhook, rewriteIdx) => {
|
||||
return (
|
||||
<div key={rewriteIdx} className="flex items-center gap-2">
|
||||
<DeleteTableCell
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (data.result.webhooks?.length === 1) {
|
||||
delete data.result.webhooks
|
||||
} else {
|
||||
data.result.webhooks?.splice(rewriteIdx, 1)
|
||||
}
|
||||
onChange(data)
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
value={webhook}
|
||||
className="h-8"
|
||||
placeholder="https://"
|
||||
onChange={(e) => {
|
||||
data.result.webhooks![rewriteIdx] = e.target.value
|
||||
onChange(data)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
<AddTableRow
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!data.result.webhooks) {
|
||||
data.result.webhooks = []
|
||||
}
|
||||
data.result.webhooks!.push("")
|
||||
onChange(data)
|
||||
}}
|
||||
/>
|
||||
</SettingCollapsible>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Collapse>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
import { LoadingWithIcon } from "@follow/components/ui/loading/index.jsx"
|
||||
import type { ActionsInput, ActionsResponse } from "@follow/models/types"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { useAuthQuery } from "~/hooks/common"
|
||||
import { apiClient } from "~/lib/api-fetch"
|
||||
import { toastFetchError } from "~/lib/error-parser"
|
||||
import { queryClient } from "~/lib/query-client"
|
||||
import { ActionCard } from "~/modules/settings/action-card"
|
||||
import { Queries } from "~/queries"
|
||||
|
||||
export const ActionSetting = () => {
|
||||
const { t } = useTranslation("settings")
|
||||
const actions = useAuthQuery(Queries.action.getAll())
|
||||
const [actionsData, setActionsData] = useState<ActionsInput>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (actions.data?.rules) {
|
||||
setActionsData(
|
||||
actions.data.rules.map((rule) => {
|
||||
const { condition } = rule
|
||||
// fix old data
|
||||
const finalCondition =
|
||||
condition.length === 0 || Array.isArray(condition[0]) ? condition : [condition]
|
||||
|
||||
return {
|
||||
...rule,
|
||||
condition: finalCondition as any,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}, [actions.data?.rules])
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
actionsData.forEach((action) => {
|
||||
action.condition = action.condition
|
||||
.map((condition) => {
|
||||
return condition.filter((c) => c.field && c.operator && c.value)
|
||||
})
|
||||
.filter((c) => c.length > 0)
|
||||
action.result.rewriteRules = action.result.rewriteRules?.filter((r) => r.from && r.to)
|
||||
action.result.blockRules = action.result.blockRules?.filter(
|
||||
(r) => r.field && r.operator && r.value,
|
||||
)
|
||||
action.result.webhooks = action.result.webhooks?.filter((w) => w)
|
||||
})
|
||||
await apiClient.actions.$put({
|
||||
json: {
|
||||
rules: actionsData as ActionsResponse,
|
||||
},
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
Queries.action.getAll().invalidate()
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["entries"],
|
||||
})
|
||||
toast(t("actions.saveSuccess"))
|
||||
},
|
||||
onError: (error) => {
|
||||
toastFetchError(error)
|
||||
},
|
||||
})
|
||||
|
||||
if (actions.isPending) {
|
||||
return (
|
||||
<div className="center flex lg:absolute lg:inset-0">
|
||||
<LoadingWithIcon
|
||||
icon={<i className="i-mgc-magic-2-cute-re" />}
|
||||
size="large"
|
||||
className="lg:-translate-y-full"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
{actionsData.map((action, actionIdx) => (
|
||||
<ActionCard
|
||||
key={actionIdx}
|
||||
data={action}
|
||||
onChange={(newAction) => {
|
||||
if (!newAction) {
|
||||
setActionsData(actionsData.filter((_, idx) => idx !== actionIdx))
|
||||
} else {
|
||||
setActionsData(actionsData.map((a, idx) => (idx === actionIdx ? newAction : a)))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="center w-full gap-1"
|
||||
onClick={() => {
|
||||
setActionsData([
|
||||
...actionsData,
|
||||
{
|
||||
name: t("actions.actionName", { number: actionsData.length + 1 }),
|
||||
condition: [],
|
||||
result: {},
|
||||
},
|
||||
])
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-add-cute-re" />
|
||||
<span>{t("actions.newRule")}</span>
|
||||
</Button>
|
||||
<div className="text-right">
|
||||
<Button variant="primary" isLoading={mutation.isPending} onClick={() => mutation.mutate()}>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -155,6 +155,15 @@ export const ProfileButton: FC<ProfileButtonProps> = memo((props) => {
|
|||
{t("user_button.zen_mode")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="pl-3"
|
||||
onClick={() => {
|
||||
navigate("/action")
|
||||
}}
|
||||
icon={<i className="i-mgc-magic-2-cute-re" />}
|
||||
>
|
||||
{t("words.actions")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="pl-3"
|
||||
onClick={() => {
|
||||
|
|
|
|||
|
|
@ -96,6 +96,11 @@ export const ProfileButton: FC<ProfileButtonProps> = () => {
|
|||
|
||||
<Divider className="mx-auto h-px w-[50px] !bg-border/80" />
|
||||
|
||||
<Item
|
||||
label={t("user_button.actions")}
|
||||
link="/action"
|
||||
icon={<i className="i-mgc-magic-2-cute-re" />}
|
||||
/>
|
||||
<Item
|
||||
label={t("user_button.preferences")}
|
||||
onClick={() => {
|
||||
|
|
@ -117,26 +122,39 @@ export const ProfileButton: FC<ProfileButtonProps> = () => {
|
|||
)
|
||||
}
|
||||
|
||||
const Item: FC<{ icon: React.ReactNode; label: string; onClick: () => void }> = ({
|
||||
const Item: FC<{ icon: React.ReactNode; label: string; onClick?: () => void; link?: string }> = ({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
link,
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
"relative flex w-full select-none items-center rounded-sm px-4 py-1.5 outline-none transition-colors focus:bg-theme-item-hover",
|
||||
"text-base font-medium",
|
||||
"focus-within:!outline-transparent",
|
||||
)}
|
||||
>
|
||||
const containerClassName = clsx(
|
||||
"relative flex w-full select-none items-center rounded-sm px-4 py-1.5 outline-none transition-colors focus:bg-theme-item-hover",
|
||||
"text-base font-medium",
|
||||
"focus-within:!outline-transparent",
|
||||
)
|
||||
|
||||
const children = (
|
||||
<>
|
||||
<span className="mr-1.5 inline-flex size-4 items-center justify-center">{icon}</span>
|
||||
|
||||
{label}
|
||||
{/* Justify Fill */}
|
||||
<span className="ml-1.5 size-4" />
|
||||
</>
|
||||
)
|
||||
|
||||
if (link) {
|
||||
return (
|
||||
<Link to={link} className={containerClassName}>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={onClick} className={containerClassName}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { ActionSetting } from "~/modules/action/action-setting"
|
||||
import { useSubViewTitle } from "~/modules/app-layout/subview/hooks"
|
||||
|
||||
export function Component() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
useSubViewTitle("words.actions")
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full flex-col items-center gap-8 px-4 pb-8 lg:pb-4">
|
||||
<div className="pt-12 text-2xl font-bold">{t("words.actions")}</div>
|
||||
<ActionSetting />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { UserRole } from "@follow/constants"
|
||||
|
||||
import { ActionSetting } from "~/modules/settings/tabs/actions"
|
||||
import { SettingsTitle } from "~/modules/settings/title"
|
||||
import { defineSettingPageData, DisableWhy } from "~/modules/settings/utils"
|
||||
|
||||
const iconName = "i-mgc-magic-2-cute-re"
|
||||
const priority = 1020
|
||||
|
||||
export const loader = defineSettingPageData({
|
||||
icon: iconName,
|
||||
name: "titles.actions",
|
||||
priority,
|
||||
disableIf: (ctx) => [ctx.role === UserRole.Trial, DisableWhy.NotActivation],
|
||||
})
|
||||
|
||||
export function Component() {
|
||||
return (
|
||||
<>
|
||||
<SettingsTitle />
|
||||
<ActionSetting />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { apiClient } from "~/lib/api-fetch"
|
||||
import { useAuthQuery } from "~/hooks/common/useBizQuery"
|
||||
import { defineQuery } from "~/lib/defineQuery"
|
||||
import { actionActions } from "~/store/action"
|
||||
|
||||
export const action = {
|
||||
getAll: () =>
|
||||
defineQuery(["actions"], async () => {
|
||||
const res = await apiClient.actions.$get()
|
||||
return res.data
|
||||
}),
|
||||
getAll: () => defineQuery(["actions"], () => actionActions.fillRemoteActions()),
|
||||
}
|
||||
|
||||
export const useActionsQuery = () =>
|
||||
useAuthQuery(action.getAll(), {
|
||||
staleTime: Infinity,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import type { ActionModel } from "@follow/models/types"
|
||||
|
||||
import { useActionStore } from "./store"
|
||||
|
||||
export function useActions(): ActionModel[]
|
||||
export function useActions<T>(selector: (actions: ActionModel[]) => T): T
|
||||
export function useActions<T>(selector?: (actions: ActionModel[]) => T) {
|
||||
return useActionStore((state) => (selector ? selector(state.actions) : state.actions))
|
||||
}
|
||||
|
||||
export function useActionByIndex(index: number): ActionModel
|
||||
export function useActionByIndex<T>(index: number, selector: (action: ActionModel) => T): T
|
||||
export function useActionByIndex<T>(index: number, selector?: (action: ActionModel) => T) {
|
||||
return useActionStore((state) =>
|
||||
selector ? selector(state.actions[index]) : state.actions[index],
|
||||
)
|
||||
}
|
||||
|
||||
export function useIsActionDataDirty() {
|
||||
return useActionStore((state) => state.isDirty)
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./hooks"
|
||||
export * from "./store"
|
||||
export * from "./types"
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import type { ActionModel, ActionsResponse } from "@follow/models/types"
|
||||
|
||||
import { apiClient } from "~/lib/api-fetch"
|
||||
|
||||
import { createImmerSetter, createZustandStore } from "../utils/helper"
|
||||
import type { ActionState } from "./types"
|
||||
|
||||
export const useActionStore = createZustandStore<ActionState>("action")(() => ({
|
||||
actions: [],
|
||||
isDirty: false,
|
||||
}))
|
||||
|
||||
const set = createImmerSetter(useActionStore)
|
||||
const get = useActionStore.getState
|
||||
|
||||
class ActionActionStatic {
|
||||
init(actions: ActionModel[]) {
|
||||
set((state) => {
|
||||
state.actions = actions
|
||||
})
|
||||
}
|
||||
|
||||
insertNewEmptyAction(name: string) {
|
||||
set((state) => {
|
||||
state.actions.push({
|
||||
name,
|
||||
condition: [],
|
||||
result: {},
|
||||
})
|
||||
state.isDirty = true
|
||||
})
|
||||
}
|
||||
|
||||
removeByIndex(index: number) {
|
||||
set((state) => {
|
||||
state.actions.splice(index, 1)
|
||||
state.isDirty = true
|
||||
})
|
||||
}
|
||||
|
||||
updateByIndex(index: number, update: (action: ActionModel) => void) {
|
||||
set((state) => {
|
||||
update(state.actions[index])
|
||||
state.isDirty = true
|
||||
})
|
||||
}
|
||||
|
||||
async fillRemoteActions() {
|
||||
if (get().actions.length > 0) {
|
||||
return get().actions
|
||||
}
|
||||
|
||||
const res = await apiClient.actions.$get()
|
||||
const rules = (res.data?.rules?.map((rule) => {
|
||||
const { condition } = rule
|
||||
// fix old data
|
||||
const finalCondition =
|
||||
condition.length === 0 || Array.isArray(condition[0]) ? condition : [condition]
|
||||
|
||||
return {
|
||||
...rule,
|
||||
condition: finalCondition as any,
|
||||
}
|
||||
}) || []) as ActionModel[]
|
||||
this.init(rules)
|
||||
return rules
|
||||
}
|
||||
|
||||
async updateRemoteActions() {
|
||||
if (!get().isDirty) {
|
||||
return
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
state.actions.forEach((action) => {
|
||||
action.condition = action.condition
|
||||
.map((condition) => {
|
||||
return condition.filter((c) => c.field && c.operator && c.value)
|
||||
})
|
||||
.filter((c) => c.length > 0)
|
||||
action.result.rewriteRules = action.result.rewriteRules?.filter((r) => r.from && r.to)
|
||||
action.result.blockRules = action.result.blockRules?.filter(
|
||||
(r) => r.field && r.operator && r.value,
|
||||
)
|
||||
action.result.webhooks = action.result.webhooks?.filter((w) => w)
|
||||
})
|
||||
})
|
||||
|
||||
await apiClient.actions.$put({
|
||||
json: {
|
||||
rules: get().actions as ActionsResponse,
|
||||
},
|
||||
})
|
||||
set((state) => {
|
||||
state.isDirty = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const actionActions = new ActionActionStatic()
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import type { ActionModel } from "@follow/models/types"
|
||||
|
||||
export interface ActionState {
|
||||
actions: ActionModel[]
|
||||
isDirty: boolean
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
- New `Move to Category` operation in feed subscription context menu
|
||||
- New `Expand long social media` setting to automatically expand social media entries containing long text.
|
||||
- New `Back Top` button and read progress indicator in entry content
|
||||
- Independent Action page
|
||||
|
||||
## Improvements
|
||||
|
||||
|
|
|
|||
|
|
@ -373,6 +373,7 @@
|
|||
"trending.user": "Trending Users",
|
||||
"user_button.account": "Account",
|
||||
"user_button.achievement": "Achievements",
|
||||
"user_button.actions": "Actions",
|
||||
"user_button.download_desktop_app": "Download Desktop app",
|
||||
"user_button.log_out": "Log out",
|
||||
"user_button.power": "Power",
|
||||
|
|
@ -385,6 +386,7 @@
|
|||
"user_profile.share": "Share",
|
||||
"user_profile.toggle_item_style": "Toggle Item Style",
|
||||
"words.achievement": "Achievements",
|
||||
"words.actions": "Actions",
|
||||
"words.add": "Add",
|
||||
"words.boost": "Boost",
|
||||
"words.browser": "Browser",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"about.socialMedia": "Social Media",
|
||||
"actions.actionName": "Action {{number}}",
|
||||
"actions.action_card.add": "Add",
|
||||
"actions.action_card.add_action": "Add Action",
|
||||
"actions.action_card.all": "All",
|
||||
"actions.action_card.and": "And",
|
||||
"actions.action_card.block": "Block",
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ export type MediaModel = Exclude<
|
|||
undefined
|
||||
>["entries"]["media"]
|
||||
|
||||
export type ActionsInput = {
|
||||
export type ActionModel = {
|
||||
name: string
|
||||
condition: {
|
||||
field?: ActionFeedField
|
||||
|
|
@ -137,7 +137,9 @@ export type ActionsInput = {
|
|||
}[]
|
||||
webhooks?: string[]
|
||||
}
|
||||
}[]
|
||||
}
|
||||
|
||||
export type ActionsInput = ActionModel[]
|
||||
|
||||
export const TransactionTypes = ["mint", "purchase", "tip", "withdraw", "airdrop"] as const
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue