feat: action page (#2006)

This commit is contained in:
Stephen Zhou 2024-12-12 11:16:30 +08:00 committed by GitHub
parent 0ae6ec954a
commit 6d9212ef48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1089 additions and 903 deletions

View File

@ -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>
)
}

View File

@ -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>
</>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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={() => {

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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 />
</>
)
}

View File

@ -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,
})

View File

@ -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)
}

View File

@ -0,0 +1,3 @@
export * from "./hooks"
export * from "./store"
export * from "./types"

View File

@ -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()

View File

@ -0,0 +1,6 @@
import type { ActionModel } from "@follow/models/types"
export interface ActionState {
actions: ActionModel[]
isDirty: boolean
}

View File

@ -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

View File

@ -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",

View File

@ -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",

View File

@ -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