feat: implement OPML import functionality with feed selection modal
- Added the DiscoverImport component to handle OPML file uploads and parsing. - Introduced OpmlSelectionModal for users to select feeds to import, including quota management and search functionality. - Enhanced error handling and user feedback during the import process. - Updated translations for new features and improved user experience. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
7b3ec11fd4
commit
de6c2ff72d
|
|
@ -5,7 +5,6 @@ import {
|
|||
AccordionTrigger,
|
||||
} from "@follow/components/ui/accordion/index.js"
|
||||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
import { Card, CardContent, CardHeader } from "@follow/components/ui/card/index.jsx"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
|
|
@ -14,27 +13,34 @@ import {
|
|||
FormMessage,
|
||||
} from "@follow/components/ui/form/index.jsx"
|
||||
import { Input } from "@follow/components/ui/input/index.js"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import type { BizRespose } from "@follow/models"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { Fragment } from "react/jsx-runtime"
|
||||
import { Fragment } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
|
||||
import { DropZone } from "~/components/ui/drop-zone"
|
||||
import { Media } from "~/components/ui/media"
|
||||
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
import { apiFetch } from "~/lib/api-fetch"
|
||||
import { toastFetchError } from "~/lib/error-parser"
|
||||
import { Queries } from "~/queries"
|
||||
|
||||
import { FollowSummary } from "../feed/feed-summary"
|
||||
import { OpmlSelectionModal } from "./OpmlSelectionModal"
|
||||
import type { ParsedOpmlData } from "./types"
|
||||
|
||||
type FeedResponseList = {
|
||||
id: string
|
||||
url: string
|
||||
title: string | null
|
||||
}[]
|
||||
const parseOpmlFile = async (file: File): Promise<ParsedOpmlData> => {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
|
||||
const data = await apiFetch<BizRespose<ParsedOpmlData>>("/subscriptions/parse-opml", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
file: z
|
||||
|
|
@ -47,64 +53,32 @@ const formSchema = z.object({
|
|||
}),
|
||||
})
|
||||
|
||||
const NumberDisplay = ({ value }) => <span className="font-bold text-zinc-800">{value ?? 0}</span>
|
||||
|
||||
const list: {
|
||||
key: string
|
||||
title: I18nKeys
|
||||
className: string
|
||||
}[] = [
|
||||
{
|
||||
key: "parsedErrorItems",
|
||||
title: "discover.import.parsedErrorItems",
|
||||
className: "text-red-500",
|
||||
},
|
||||
{
|
||||
key: "successfulItems",
|
||||
title: "discover.import.successfulItems",
|
||||
className: "text-green-500",
|
||||
},
|
||||
{
|
||||
key: "conflictItems",
|
||||
title: "discover.import.conflictItems",
|
||||
className: "text-yellow-500",
|
||||
},
|
||||
]
|
||||
|
||||
export function DiscoverImport() {
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
// FIXME: if post data is form data, hono hc not support this.
|
||||
const { present } = useModalStack()
|
||||
|
||||
const { data } = await apiFetch<{
|
||||
data: {
|
||||
successfulItems: FeedResponseList
|
||||
conflictItems: FeedResponseList
|
||||
parsedErrorItems: FeedResponseList
|
||||
}
|
||||
}>("/subscriptions/import", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
|
||||
return data
|
||||
},
|
||||
onSuccess: () => {
|
||||
Queries.subscription.all().invalidateRoot()
|
||||
},
|
||||
const parseOpmlMutation = useMutation({
|
||||
mutationFn: parseOpmlFile,
|
||||
async onError(err) {
|
||||
toastFetchError(err)
|
||||
},
|
||||
})
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
mutation.mutate(values.file)
|
||||
parseOpmlMutation.mutate(values.file, {
|
||||
onSuccess: (parsedData) => {
|
||||
present({
|
||||
title: t("discover.import.preview_opml_content"),
|
||||
content: () => <OpmlSelectionModal file={values.file} parsedData={parsedData} />,
|
||||
clickOutsideToDismiss: false,
|
||||
modalClassName: "max-w-2xl w-full h-[80vh]",
|
||||
modalContentClassName: "flex flex-col h-full",
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -180,7 +154,7 @@ export function DiscoverImport() {
|
|||
</AccordionItem>
|
||||
<AccordionItem value="other" className="border-b-0">
|
||||
<AccordionTrigger className="justify-normal gap-2 hover:no-underline">
|
||||
<i className="i-mgc-rss-cute-fi -ml-[0.14rem] size-6 text-orange-500" />
|
||||
<i className="i-mgc-rss-cute-fi ml-[-0.14rem] size-6 text-orange-500" />
|
||||
{t("discover.import.opml_step1_other")}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-1">
|
||||
|
|
@ -230,47 +204,13 @@ export function DiscoverImport() {
|
|||
<Button
|
||||
type="submit"
|
||||
disabled={!form.formState.dirtyFields.file}
|
||||
isLoading={mutation.isPending}
|
||||
isLoading={parseOpmlMutation.isPending}
|
||||
>
|
||||
{t("words.import")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
{mutation.isSuccess && (
|
||||
<div className="mt-8 w-full max-w-lg">
|
||||
<Card>
|
||||
<CardHeader className="block text-zinc-500">
|
||||
<Trans
|
||||
ns="app"
|
||||
i18nKey="discover.import.result"
|
||||
components={{
|
||||
SuccessfulNum: <NumberDisplay value={mutation.data?.successfulItems.length} />,
|
||||
ConflictNum: <NumberDisplay value={mutation.data?.conflictItems.length} />,
|
||||
ErrorNum: <NumberDisplay value={mutation.data?.parsedErrorItems.length} />,
|
||||
}}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{list.map((item) => (
|
||||
<div key={item.key}>
|
||||
<div className={cn("mb-4 text-lg font-medium", item.className)}>
|
||||
{t(item.title)}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{!mutation.data?.[item.key].length && (
|
||||
<div className="text-zinc-500">{t("discover.import.noItems")}</div>
|
||||
)}
|
||||
{mutation.data?.[item.key].map((feed) => (
|
||||
<FollowSummary className="max-w-[462px]" key={feed.id} feed={feed} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,373 @@
|
|||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
import { Checkbox } from "@follow/components/ui/checkbox/index.jsx"
|
||||
import { Input } from "@follow/components/ui/input/index.js"
|
||||
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.jsx"
|
||||
import type { BizRespose } from "@follow/models"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import Fuse from "fuse.js"
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { useCurrentModal } from "~/components/ui/modal/stacked/hooks"
|
||||
import { apiFetch } from "~/lib/api-fetch"
|
||||
import { toastFetchError } from "~/lib/error-parser"
|
||||
import { Queries } from "~/queries"
|
||||
|
||||
import type { ParsedFeedItem, ParsedOpmlData } from "./types"
|
||||
|
||||
type FeedResponseList = {
|
||||
id: string
|
||||
url: string
|
||||
title: string | null
|
||||
}[]
|
||||
export const OpmlSelectionModal = ({
|
||||
parsedData,
|
||||
|
||||
file,
|
||||
}: {
|
||||
parsedData: ParsedOpmlData
|
||||
|
||||
file: File
|
||||
}) => {
|
||||
const { dismiss } = useCurrentModal()
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (selectedItems: ParsedFeedItem[]) => {
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append("file", file)
|
||||
formData.append("items", JSON.stringify(selectedItems.map((i) => i.url)))
|
||||
|
||||
const { data } = await apiFetch<
|
||||
BizRespose<{
|
||||
successfulItems: FeedResponseList
|
||||
conflictItems: FeedResponseList
|
||||
parsedErrorItems: FeedResponseList
|
||||
}>
|
||||
>("/subscriptions/import", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
|
||||
return data
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
Queries.subscription.all().invalidateRoot()
|
||||
|
||||
const { successfulItems, conflictItems, parsedErrorItems } = data
|
||||
|
||||
if (parsedErrorItems.length > 0) {
|
||||
toast.warning(t("discover.import.import_completed_with_issues"), {
|
||||
description: (
|
||||
<Trans
|
||||
ns="app"
|
||||
i18nKey="discover.import.result"
|
||||
components={{
|
||||
SuccessfulNum: <NumberDisplay value={successfulItems.length} />,
|
||||
ConflictNum: <NumberDisplay value={conflictItems.length} />,
|
||||
ErrorNum: <NumberDisplay value={parsedErrorItems.length} />,
|
||||
br: <br />,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
duration: 5000,
|
||||
})
|
||||
} else {
|
||||
dismiss()
|
||||
// Show success if everything went well
|
||||
toast.success(t("discover.import.import_successful"), {
|
||||
description: (
|
||||
<Trans
|
||||
ns="app"
|
||||
i18nKey="discover.import.result"
|
||||
components={{
|
||||
SuccessfulNum: <NumberDisplay value={successfulItems.length} />,
|
||||
ConflictNum: <NumberDisplay value={conflictItems.length} />,
|
||||
ErrorNum: <NumberDisplay value={parsedErrorItems.length} />,
|
||||
br: <br />,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
duration: 5000,
|
||||
})
|
||||
}
|
||||
},
|
||||
async onError(err) {
|
||||
toastFetchError(err)
|
||||
},
|
||||
})
|
||||
|
||||
const { t } = useTranslation()
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(
|
||||
() => new Set(parsedData.subscriptions.map((_, index) => index.toString())),
|
||||
)
|
||||
|
||||
const fuse = useMemo(() => {
|
||||
return new Fuse(parsedData.subscriptions, {
|
||||
keys: [
|
||||
{ name: "title", weight: 0.7 },
|
||||
{ name: "url", weight: 0.2 },
|
||||
{ name: "category", weight: 0.1 },
|
||||
],
|
||||
threshold: 0.3,
|
||||
includeMatches: true,
|
||||
minMatchCharLength: 2,
|
||||
})
|
||||
}, [parsedData.subscriptions])
|
||||
|
||||
const filteredSubscriptions = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return parsedData.subscriptions.map((item, index) => ({ item, refIndex: index }))
|
||||
}
|
||||
|
||||
return fuse.search(searchQuery).map((result) => ({
|
||||
item: result.item,
|
||||
refIndex: result.refIndex,
|
||||
}))
|
||||
}, [fuse, searchQuery, parsedData.subscriptions])
|
||||
|
||||
const selectedCount = selectedItems.size
|
||||
const isQuotaExceeded = selectedCount > parsedData.remaining
|
||||
const quotaWarningThreshold = Math.max(1, Math.floor(parsedData.remaining * 0.8)) // 80% of quota
|
||||
|
||||
const toggleItem = useCallback((index: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index)
|
||||
} else {
|
||||
newSet.add(index)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleAll = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (checked) {
|
||||
// Select all filtered items, but respect quota
|
||||
const filteredIndices = filteredSubscriptions.map(({ refIndex }) => refIndex.toString())
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
|
||||
// If we would exceed quota, only select up to the remaining limit
|
||||
let addedCount = 0
|
||||
for (const index of filteredIndices) {
|
||||
if (newSet.size + addedCount >= parsedData.remaining) {
|
||||
break
|
||||
}
|
||||
if (!newSet.has(index)) {
|
||||
addedCount++
|
||||
}
|
||||
newSet.add(index)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
} else {
|
||||
// Deselect all filtered items - no quota restrictions for deselection
|
||||
const filteredIndices = new Set(
|
||||
filteredSubscriptions.map(({ refIndex }) => refIndex.toString()),
|
||||
)
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
filteredIndices.forEach((index) => newSet.delete(index))
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
},
|
||||
[filteredSubscriptions, parsedData.remaining],
|
||||
)
|
||||
|
||||
const handleImport = useCallback(() => {
|
||||
const selected = parsedData.subscriptions.filter((_, index) =>
|
||||
selectedItems.has(index.toString()),
|
||||
)
|
||||
importMutation.mutate(selected)
|
||||
}, [parsedData.subscriptions, selectedItems, importMutation])
|
||||
|
||||
// Calculate selection states for filtered items
|
||||
const filteredSelectedCount = filteredSubscriptions.filter(({ refIndex }) =>
|
||||
selectedItems.has(refIndex.toString()),
|
||||
).length
|
||||
|
||||
const allFilteredSelected =
|
||||
filteredSelectedCount === filteredSubscriptions.length && filteredSubscriptions.length > 0
|
||||
const someFilteredSelected =
|
||||
filteredSelectedCount > 0 && filteredSelectedCount < filteredSubscriptions.length
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-[600px] max-w-full flex-col">
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
{t("discover.import.select_feeds_to_import")}
|
||||
</h3>
|
||||
<p className="text-text-secondary text-sm">
|
||||
{t("discover.import.select_feeds_description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quota Status */}
|
||||
<div
|
||||
className={cn(
|
||||
"border-border bg-background mb-4 flex items-center justify-between rounded-lg border px-3 py-2",
|
||||
isQuotaExceeded
|
||||
? "border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950"
|
||||
: selectedCount >= quotaWarningThreshold
|
||||
? "border-yellow-200 bg-yellow-50 dark:border-yellow-800 dark:bg-yellow-950"
|
||||
: "border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-4 items-center justify-center rounded-full",
|
||||
isQuotaExceeded
|
||||
? "text-red"
|
||||
: selectedCount >= quotaWarningThreshold
|
||||
? "text-yellow"
|
||||
: "text-green",
|
||||
)}
|
||||
>
|
||||
<i
|
||||
className={cn(
|
||||
isQuotaExceeded
|
||||
? "i-mgc-close-cute-re"
|
||||
: selectedCount >= quotaWarningThreshold
|
||||
? "i-mgc-warning-cute-re"
|
||||
: "i-mgc-check-circle-cute-re",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isQuotaExceeded ? (
|
||||
<p>{t("discover.import.quota_exceeded_warning")}</p>
|
||||
) : selectedCount >= quotaWarningThreshold ? (
|
||||
<p>
|
||||
{t("discover.import.quota_warning", {
|
||||
remaining: parsedData.remaining - selectedCount,
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{t("discover.import.remaining_quota", {
|
||||
remaining: parsedData.remaining - selectedCount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-sm font-medium">
|
||||
{t("discover.import.quota_status")} {selectedCount}/{parsedData.remaining}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
placeholder={t("discover.import.search_feeds_placeholder", "Search feeds...")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label
|
||||
className="mb-4 flex items-center gap-3 rounded-lg px-3 py-2"
|
||||
htmlFor="select-all-filtered-feeds"
|
||||
>
|
||||
<Checkbox
|
||||
id="select-all-filtered-feeds"
|
||||
checked={allFilteredSelected}
|
||||
onCheckedChange={toggleAll}
|
||||
indeterminate={someFilteredSelected}
|
||||
/>
|
||||
<span className="font-medium">
|
||||
{searchQuery.trim() ? (
|
||||
<>
|
||||
{t("discover.import.select_all_filtered", "Select all filtered")} (
|
||||
{filteredSelectedCount}/{filteredSubscriptions.length})
|
||||
{filteredSubscriptions.length < parsedData.subscriptions.length && (
|
||||
<span className="text-text-secondary ml-1">
|
||||
of {parsedData.subscriptions.length} total
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t("discover.import.select_all_feeds")} ({selectedItems.size}/
|
||||
{parsedData.subscriptions.length})
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<ScrollArea.ScrollArea rootClassName="-mx-2 flex-1 px-2">
|
||||
<div className="space-y-2">
|
||||
{filteredSubscriptions.length === 0 && searchQuery.trim() ? (
|
||||
<div className="text-text-secondary py-8 text-center">
|
||||
{t("discover.import.no_feeds_found", "No feeds found matching your search.")}
|
||||
</div>
|
||||
) : (
|
||||
filteredSubscriptions.map(({ item, refIndex }) => {
|
||||
const isSelected = selectedItems.has(refIndex.toString())
|
||||
const wouldExceedQuota = !isSelected && selectedCount >= parsedData.remaining
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.url}-${refIndex}`}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors",
|
||||
isSelected ? "border-material-thick bg-material-thick" : "border-background",
|
||||
wouldExceedQuota && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
onClick={() => !wouldExceedQuota && toggleItem(refIndex.toString())}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => !wouldExceedQuota && toggleItem(refIndex.toString())}
|
||||
disabled={wouldExceedQuota}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 shrink">
|
||||
<div className="truncate font-medium">{item.title || "Untitled Feed"}</div>
|
||||
<div className="text-text-secondary truncate text-sm">{item.url}</div>
|
||||
{item.category && (
|
||||
<div className="text-text-secondary mt-1 text-xs opacity-80">
|
||||
{item.category}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea.ScrollArea>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-3 pt-4">
|
||||
<Button variant="outline" onClick={dismiss} disabled={importMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={selectedItems.size === 0 || isQuotaExceeded || importMutation.isPending}
|
||||
isLoading={importMutation.isPending}
|
||||
>
|
||||
{t("words.import")} ({selectedItems.size})
|
||||
{isQuotaExceeded && (
|
||||
<span className="ml-1 text-xs">- {t("discover.import.quota_exceeded")}</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const NumberDisplay = ({ value }) => <span className="text-text font-bold">{value ?? 0}</span>
|
||||
|
|
@ -1 +1,19 @@
|
|||
import type { FeedViewType } from "@follow/constants"
|
||||
|
||||
export * from "@follow/models/rsshub"
|
||||
|
||||
export type ParsedFeedItem = {
|
||||
url: string
|
||||
title: string | null
|
||||
category?: string | null
|
||||
}
|
||||
|
||||
export type ParsedOpmlData = {
|
||||
remaining: number
|
||||
subscriptions: {
|
||||
category: string | null
|
||||
title: string
|
||||
url: string
|
||||
view: FeedViewType
|
||||
}[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,23 @@ const useFontDataWeb = () => {
|
|||
return [
|
||||
{ label: t("appearance.content_font.default"), value: "inherit" },
|
||||
{ label: t("appearance.font.system"), value: "system-ui" },
|
||||
...["Arial", "PingFang SC", "Microsoft YaHei", "SF Pro"].map((font) => ({
|
||||
...[
|
||||
// English
|
||||
"SF Pro",
|
||||
"Segoe UI",
|
||||
"Helvetica",
|
||||
"Arial",
|
||||
// Chinese
|
||||
"PingFang SC",
|
||||
"PingFang TC",
|
||||
"PingFang HK",
|
||||
|
||||
"Microsoft YaHei",
|
||||
"Microsoft JhengHei",
|
||||
// Japanese
|
||||
"Yu Gothic",
|
||||
"Hiragino Sans",
|
||||
].map((font) => ({
|
||||
label: font,
|
||||
|
||||
value: font,
|
||||
|
|
|
|||
|
|
@ -44,7 +44,10 @@
|
|||
"discover.feed_maintainers": "This feed is provided by RSSHub, with credit to <maintainers />",
|
||||
"discover.import.click_to_upload": "Click to upload OPML file",
|
||||
"discover.import.conflictItems": "Conflict Items",
|
||||
"discover.import.import_completed_with_issues": "Import completed with some issues",
|
||||
"discover.import.import_successful": "Import completed successfully",
|
||||
"discover.import.noItems": "No items",
|
||||
"discover.import.no_feeds_found": "No feeds found matching your search.",
|
||||
"discover.import.opml": "OPML file",
|
||||
"discover.import.opml_step1": "Export OPML from your RSS reader",
|
||||
"discover.import.opml_step1_feedly": "Export OPML from Feedly",
|
||||
|
|
@ -57,8 +60,21 @@
|
|||
"discover.import.opml_step1_other": "Export OPML from other readers",
|
||||
"discover.import.opml_step1_other_step1": "OPML is a widely supported open format and is essentially the standard for sharing feed subscription lists. Nearly all RSS readers allow for OPML import and export. If you're unsure how to do this, please refer to your RSS reader's help manual or join our community for assistance.",
|
||||
"discover.import.opml_step2": "Import OPML to Folo",
|
||||
"discover.import.parse_opml": "Parse OPML",
|
||||
"discover.import.parsedErrorItems": "Parsed Error Items",
|
||||
"discover.import.result": "<SuccessfulNum /> feeds were successfully imported, <ConflictNum /> were already subscribed to, and <ErrorNum /> failed to import.",
|
||||
"discover.import.preview_opml_content": "Preview OPML Content",
|
||||
"discover.import.quota_exceeded": "Quota exceeded",
|
||||
"discover.import.quota_exceeded_warning": "You have selected more feeds than your remaining quota allows. Please deselect some feeds to continue.",
|
||||
"discover.import.quota_limit_reached": "Quota limit reached",
|
||||
"discover.import.quota_status": "Import quota:",
|
||||
"discover.import.quota_warning": "You have {{remaining}} feeds remaining in your quota.",
|
||||
"discover.import.remaining_quota": "You can import {{remaining}} more feeds",
|
||||
"discover.import.result": "<SuccessfulNum /> feeds were successfully imported.<br /><ConflictNum /> were already subscribed to.<br /><ErrorNum /> failed to import.",
|
||||
"discover.import.search_feeds_placeholder": "Search feeds...",
|
||||
"discover.import.select_all_feeds": "Select all feeds",
|
||||
"discover.import.select_all_filtered": "Select all filtered",
|
||||
"discover.import.select_feeds_description": "Review and select which feeds you want to import. All feeds are selected by default.",
|
||||
"discover.import.select_feeds_to_import": "Select feeds to import",
|
||||
"discover.import.successfulItems": "Successful Items",
|
||||
"discover.inbox.actions": "Actions",
|
||||
"discover.inbox.description": "You can receive information via email and webhooks through the inbox.",
|
||||
|
|
|
|||
|
|
@ -44,9 +44,21 @@
|
|||
"discover.import.click_to_upload": "OPML ファイルをアップロードするにはクリック",
|
||||
"discover.import.conflictItems": "重複アイテム",
|
||||
"discover.import.noItems": "アイテムなし",
|
||||
"discover.import.no_feeds_found": "検索条件に一致するフィードが見つかりませんでした。",
|
||||
"discover.import.opml": "OPML ファイル",
|
||||
"discover.import.parsedErrorItems": "パースエラーのアイテム",
|
||||
"discover.import.quota_exceeded": "枠を超過",
|
||||
"discover.import.quota_exceeded_warning": "選択したフィード数が残り枠を超えています。続行するには一部のフィードの選択を解除してください。",
|
||||
"discover.import.quota_limit_reached": "枠制限に達しました",
|
||||
"discover.import.quota_status": "インポート枠:",
|
||||
"discover.import.quota_warning": "残り {{remaining}} フィードまでインポートできます。",
|
||||
"discover.import.remaining_quota": "あと {{remaining}} フィードをインポートできます",
|
||||
"discover.import.result": "<SuccessfulNum /> フィードのインポートに成功しました、 <ConflictNum /> 件を購読中、インポート失敗は <ErrorNum /> 件。",
|
||||
"discover.import.search_feeds_placeholder": "フィードを検索...",
|
||||
"discover.import.select_all_feeds": "すべてのフィードを選択",
|
||||
"discover.import.select_all_filtered": "フィルタ結果をすべて選択",
|
||||
"discover.import.select_feeds_description": "インポートするフィードを確認して選択してください。デフォルトですべて選択されています。",
|
||||
"discover.import.select_feeds_to_import": "インポートするフィードを選択",
|
||||
"discover.import.successfulItems": "成功したアイテム",
|
||||
"discover.inbox.actions": "アクション",
|
||||
"discover.inbox.description": "情報は email か webhooks を通して受信箱に配信されます。",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,10 @@
|
|||
"discover.feed_maintainers": "由 RSSHub 提供,感谢贡献者 <maintainers />",
|
||||
"discover.import.click_to_upload": "导入 OPML 文件",
|
||||
"discover.import.conflictItems": "冲突项目",
|
||||
"discover.import.import_completed_with_issues": "导入完成,但存在一些问题",
|
||||
"discover.import.import_successful": "导入成功完成",
|
||||
"discover.import.noItems": "没有项目",
|
||||
"discover.import.no_feeds_found": "未找到匹配的订阅源。",
|
||||
"discover.import.opml": "OPML 文件",
|
||||
"discover.import.opml_step1": "从你的 RSS 阅读器导出 OPML 文件",
|
||||
"discover.import.opml_step1_feedly": "从 Feedly 导出 OPML 文件",
|
||||
|
|
@ -57,8 +60,20 @@
|
|||
"discover.import.opml_step1_other": "从其他阅读器导出 OPML 文件",
|
||||
"discover.import.opml_step1_other_step1": "OPML 是一种广泛支持的开放格式,基本上是共享订阅源列表的标准。几乎所有的 RSS 阅读器都允许导入和导出 OPML。如果您不确定如何操作,请参考您的 RSS 阅读器的帮助手册或加入我们的社区以获取帮助。",
|
||||
"discover.import.opml_step2": "导入 OPML 到 Folo",
|
||||
"discover.import.parse_opml": "解析 OPML",
|
||||
"discover.import.parsedErrorItems": "解析错误项目",
|
||||
"discover.import.result": "成功导入了 <SuccessfulNum /> 个订阅源,<ConflictNum /> 个已订阅,<ErrorNum /> 个导入失败。",
|
||||
"discover.import.preview_opml_content": "预览 OPML 内容",
|
||||
"discover.import.quota_exceeded": "超出额度",
|
||||
"discover.import.quota_exceeded_warning": "你选择的订阅源数量超过了剩余额度。请取消选择一些订阅源以继续。",
|
||||
"discover.import.quota_limit_reached": "已达额度限制",
|
||||
"discover.import.quota_status": "导入额度:",
|
||||
"discover.import.quota_warning": "你的额度还剩 {{remaining}} 个订阅源。",
|
||||
"discover.import.remaining_quota": "你还可以导入 {{remaining}} 个订阅源",
|
||||
"discover.import.search_feeds_placeholder": "搜索订阅源...",
|
||||
"discover.import.select_all_feeds": "全选订阅源",
|
||||
"discover.import.select_all_filtered": "全选筛选结果",
|
||||
"discover.import.select_feeds_description": "检查并选择你想要导入的订阅源。默认全部选中。",
|
||||
"discover.import.select_feeds_to_import": "选择要导入的订阅源",
|
||||
"discover.import.successfulItems": "成功项目",
|
||||
"discover.inbox.actions": "操作",
|
||||
"discover.inbox.description": "你可以通过邮件和 Webhook 在收件箱接收信息。",
|
||||
|
|
@ -263,14 +278,14 @@
|
|||
"new_user_guide.step.behavior.unread_question.option1": "主动:显示时自动标记为已读。",
|
||||
"new_user_guide.step.behavior.unread_question.option2": "平衡:悬停或滚出视野时自动标记为已读。",
|
||||
"new_user_guide.step.behavior.unread_question.option3": "被动:仅在点击时标记为已读。",
|
||||
"new_user_guide.step.discover.description": "你也可以稍后在“发现”中找到它们。",
|
||||
"new_user_guide.step.discover.description": "你也可以稍后在\"发现\"中找到它们。",
|
||||
"new_user_guide.step.discover.title": "为你推荐",
|
||||
"new_user_guide.step.features.actions.description": "自动化规则允许你对不同的订阅执行不同的操作。\n- 使用 AI 进行总结或翻译\n- 配置阅读条目的方式\n- 启用新条目的通知或静音\n- 重写或屏蔽特定条目\n- 将新条目发送到 webhook 地址",
|
||||
"new_user_guide.step.features.integration.description": "集成允许你将条目保存到其他服务。目前支持的服务有:\n- Eagle\n- Readwise\n- Instapaper\n- Obsidian\n- Outline\n- Readeck",
|
||||
"new_user_guide.step.migrate.description": "您也可以稍后在“发现”中导入它们。",
|
||||
"new_user_guide.step.migrate.description": "您也可以稍后在\"发现\"中导入它们。",
|
||||
"new_user_guide.step.migrate.title": "从其他 RSS 阅读器迁移",
|
||||
"new_user_guide.step.power.description": "Folo 使用区块链技术作为活跃用户和优秀创作者的激励机制。用户可以通过持有和使用 Power 来获得更多服务和福利。创作者可以通过提供高质量的内容和服务来获得更多奖励。",
|
||||
"new_user_guide.step.profile.description": "你也可以稍后在“设置”中设置它。",
|
||||
"new_user_guide.step.profile.description": "你也可以稍后在\"设置\"中设置它。",
|
||||
"new_user_guide.step.profile.title": "设置你的个人资料",
|
||||
"new_user_guide.step.shortcuts.description1": "快捷键让你更方便、高效地使用 Folo",
|
||||
"new_user_guide.step.shortcuts.description2": "随时按 <kbd /> 快速查看所有快捷键。",
|
||||
|
|
|
|||
|
|
@ -43,10 +43,23 @@
|
|||
"discover.feed_maintainers": "由 RSSHub 提供,感謝 <maintainers /> 的支持",
|
||||
"discover.import.click_to_upload": "點擊上傳 OPML 文件",
|
||||
"discover.import.conflictItems": "衝突項目",
|
||||
"discover.import.import_completed_with_issues": "匯入完成,但存在一些問題",
|
||||
"discover.import.import_successful": "匯入成功完成",
|
||||
"discover.import.noItems": "沒有項目",
|
||||
"discover.import.no_feeds_found": "未找到符合搜尋條件的 RSS 摘要。",
|
||||
"discover.import.opml": "OPML 檔案",
|
||||
"discover.import.parsedErrorItems": "解析錯誤項目",
|
||||
"discover.import.result": "成功匯入了 <SuccessfulNum /> 個 RSS 摘要,<ConflictNum /> 個已經訂閱,<ErrorNum /> 個匯入失敗。",
|
||||
"discover.import.quota_exceeded": "超出額度",
|
||||
"discover.import.quota_exceeded_warning": "您選擇的 RSS 摘要數量超過了剩餘額度。請取消選擇一些 RSS 摘要以繼續。",
|
||||
"discover.import.quota_limit_reached": "已達額度限制",
|
||||
"discover.import.quota_status": "匯入額度:",
|
||||
"discover.import.quota_warning": "您的額度還剩 {{remaining}} 個 RSS 摘要。",
|
||||
"discover.import.remaining_quota": "你還可以匯入 {{remaining}} 個 RSS 摘要",
|
||||
"discover.import.search_feeds_placeholder": "搜尋 RSS 摘要...",
|
||||
"discover.import.select_all_feeds": "全選 RSS 摘要",
|
||||
"discover.import.select_all_filtered": "全選篩選結果",
|
||||
"discover.import.select_feeds_description": "檢視並選擇您想要匯入的 RSS 摘要。預設全部選中。",
|
||||
"discover.import.select_feeds_to_import": "選擇要匯入的 RSS 摘要",
|
||||
"discover.import.successfulItems": "成功項目",
|
||||
"discover.inbox.actions": "操作",
|
||||
"discover.inbox.description": "你可以透過電子信箱和 Webhooks 在收件匣接收資訊。",
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@ import type { HTMLMotionProps } from "motion/react"
|
|||
import { m } from "motion/react"
|
||||
import * as React from "react"
|
||||
|
||||
type CheckboxProps = React.ComponentProps<typeof CheckboxPrimitive.Root> & HTMLMotionProps<"button">
|
||||
type CheckboxProps = React.ComponentProps<typeof CheckboxPrimitive.Root> &
|
||||
HTMLMotionProps<"button"> & {
|
||||
indeterminate?: boolean
|
||||
}
|
||||
|
||||
function Checkbox({ className, onCheckedChange, ...props }: CheckboxProps) {
|
||||
function Checkbox({ className, onCheckedChange, indeterminate, ...props }: CheckboxProps) {
|
||||
const [isChecked, setIsChecked] = React.useState(props?.checked ?? props?.defaultChecked ?? false)
|
||||
|
||||
React.useEffect(() => {
|
||||
|
|
@ -29,6 +32,7 @@ function Checkbox({ className, onCheckedChange, ...props }: CheckboxProps) {
|
|||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"bg-fill cursor-checkbox focus-visible:ring-border data-[state=checked]:bg-accent peer flex size-5 shrink-0 items-center justify-center rounded-sm transition-colors duration-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:text-white",
|
||||
indeterminate && "bg-accent text-white",
|
||||
className,
|
||||
)}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
|
|
@ -36,39 +40,75 @@ function Checkbox({ className, onCheckedChange, ...props }: CheckboxProps) {
|
|||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator forceMount asChild>
|
||||
<m.svg
|
||||
data-slot="checkbox-indicator"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="3.5"
|
||||
stroke="currentColor"
|
||||
className="size-3.5"
|
||||
initial="unchecked"
|
||||
animate={isChecked ? "checked" : "unchecked"}
|
||||
>
|
||||
<m.path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4.5 12.75l6 6 9-13.5"
|
||||
variants={{
|
||||
checked: {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
{indeterminate ? (
|
||||
<m.svg
|
||||
data-slot="checkbox-indicator"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="3.5"
|
||||
stroke="currentColor"
|
||||
className="size-3.5"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<m.path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5 12h14"
|
||||
variants={{
|
||||
visible: {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
},
|
||||
},
|
||||
unchecked: {
|
||||
pathLength: 0,
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
hidden: {
|
||||
pathLength: 0,
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</m.svg>
|
||||
}}
|
||||
/>
|
||||
</m.svg>
|
||||
) : (
|
||||
<m.svg
|
||||
data-slot="checkbox-indicator"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="3.5"
|
||||
stroke="currentColor"
|
||||
className="size-3.5"
|
||||
initial="unchecked"
|
||||
animate={isChecked ? "checked" : "unchecked"}
|
||||
>
|
||||
<m.path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4.5 12.75l6 6 9-13.5"
|
||||
variants={{
|
||||
checked: {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
},
|
||||
unchecked: {
|
||||
pathLength: 0,
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</m.svg>
|
||||
)}
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</m.button>
|
||||
</CheckboxPrimitive.Root>
|
||||
|
|
|
|||
|
|
@ -177,3 +177,8 @@ export type EntryReadHistoriesModel = Optional<
|
|||
> & {
|
||||
entryId: string
|
||||
}
|
||||
|
||||
export type BizRespose<T> = {
|
||||
data: T
|
||||
code: 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19246,6 +19246,27 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({
|
|||
status: 200;
|
||||
};
|
||||
};
|
||||
} & {
|
||||
"/parse-opml": {
|
||||
$post: {
|
||||
input: {};
|
||||
output: {
|
||||
code: 0;
|
||||
data: {
|
||||
subscriptions: {
|
||||
userId: string;
|
||||
title: string | null;
|
||||
view: number;
|
||||
category: string | null;
|
||||
url: string;
|
||||
}[];
|
||||
remaining: number;
|
||||
};
|
||||
};
|
||||
outputFormat: "json";
|
||||
status: 200;
|
||||
};
|
||||
};
|
||||
}, "/subscriptions"> | hono_types.MergeSchemaPath<hono_types.MergeSchemaPath<{
|
||||
"/": {
|
||||
$post: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue