diff --git a/apps/renderer/src/api/trending.ts b/apps/renderer/src/api/trending.ts index 8f3e51a300..18c9d57774 100644 --- a/apps/renderer/src/api/trending.ts +++ b/apps/renderer/src/api/trending.ts @@ -4,8 +4,7 @@ import { apiFetch } from "~/lib/api-fetch" import type { Models } from "~/models" const v1ApiPrefix = "/v1" -export const getTrendingAggregates = () => { - return apiFetch(`${v1ApiPrefix}/trendings`).then((data) => - camelcaseKeys(data as any, { deep: true }), - ) +export const getTrendingAggregates = async () => { + const data = await apiFetch(`${v1ApiPrefix}/trendings`) + return camelcaseKeys(data as any, { deep: true }) as Models.TrendingAggregates } diff --git a/apps/renderer/src/assets/rsshub-icon.png b/apps/renderer/src/assets/rsshub-icon.png new file mode 100644 index 0000000000..3ca374f04a Binary files /dev/null and b/apps/renderer/src/assets/rsshub-icon.png differ diff --git a/apps/renderer/src/components/ui/button/variants.tsx b/apps/renderer/src/components/ui/button/variants.tsx index e956fa4426..70938ee123 100644 --- a/apps/renderer/src/components/ui/button/variants.tsx +++ b/apps/renderer/src/components/ui/button/variants.tsx @@ -54,7 +54,7 @@ export const styledButtonVariant = cva( ), outline: cn( - "bg-theme-background font-semibold transition-colors duration-200", + "bg-theme-background font-semibold duration-200", "border border-border hover:bg-zinc-50 dark:bg-neutral-900/30 dark:hover:bg-neutral-900/80", "focus:border-accent/80", ), diff --git a/apps/renderer/src/components/ui/radio-group/Radio.tsx b/apps/renderer/src/components/ui/radio-group/Radio.tsx index c53835b886..b8a0ef285b 100644 --- a/apps/renderer/src/components/ui/radio-group/Radio.tsx +++ b/apps/renderer/src/components/ui/radio-group/Radio.tsx @@ -23,7 +23,7 @@ export const Radio: FC< onChange?.(e) }) return ( -
+ -
+ ) } diff --git a/apps/renderer/src/models/index.ts b/apps/renderer/src/models/index.ts index 9a907fee85..550b2b870b 100644 --- a/apps/renderer/src/models/index.ts +++ b/apps/renderer/src/models/index.ts @@ -1,6 +1,6 @@ import type { User } from "@auth/core/types" -import type { FeedModel, ListModelPoplutedFeeds } from "./types" +import type { FeedModel } from "./types" export * from "./types" @@ -20,7 +20,7 @@ export namespace Models { export interface TrendingAggregates { trendingFeeds: FeedModel[] - trendingLists: ListModelPoplutedFeeds[] + trendingLists: TrendingList[] trendingEntries: TrendingEntry[] trendingUsers: User[] } diff --git a/apps/renderer/src/modules/new-user-guide/atoms.ts b/apps/renderer/src/modules/new-user-guide/atoms.ts new file mode 100644 index 0000000000..2cc5fbd75e --- /dev/null +++ b/apps/renderer/src/modules/new-user-guide/atoms.ts @@ -0,0 +1,12 @@ +import { atom } from "jotai" + +import { createAtomHooks } from "~/lib/jotai" + +export const [ + , + useHaveUsedOtherRSSReaderAtom, + useHaveUsedOtherRSSReader, + , + getHaveUsedOtherRSSReader, + setHaveUsedOtherRSSReader, +] = createAtomHooks(atom(false)) diff --git a/apps/renderer/src/modules/new-user-guide/guide-modal-content.tsx b/apps/renderer/src/modules/new-user-guide/guide-modal-content.tsx new file mode 100644 index 0000000000..782b21b1a6 --- /dev/null +++ b/apps/renderer/src/modules/new-user-guide/guide-modal-content.tsx @@ -0,0 +1,356 @@ +import clsx from "clsx" +import { AnimatePresence, m } from "framer-motion" +import type { ComponentProps, FunctionComponentElement } from "react" +import { createElement, useCallback, useMemo, useState } from "react" +import { Trans, useTranslation } from "react-i18next" + +import RSSHubIcon from "~/assets/rsshub-icon.png" +import { Logo } from "~/components/icons/logo" +import { Button } from "~/components/ui/button" +import { Kbd } from "~/components/ui/kbd/Kbd" +import { mountLottie } from "~/components/ui/lottie-container" +import { Markdown } from "~/components/ui/markdown/Markdown" +import { useI18n } from "~/hooks/common" +import confettiUrl from "~/lottie/confetti.lottie?url" +import { settings } from "~/queries/settings" + +import { DiscoverImport } from "../discover/import" +import { settingSyncQueue } from "../settings/helper/sync-queue" +import { LanguageSelector } from "../settings/tabs/general" +import { useHaveUsedOtherRSSReader } from "./atoms" +import { BehaviorGuide } from "./steps/behavior" +import { TrendingFeeds } from "./steps/feeds" +import { Introduction } from "./steps/introduction" +import { RookieCheck } from "./steps/rookie" +import { RSSHubGuide } from "./steps/rsshub" + +const containerWidth = 600 +const variants = { + enter: (direction: number) => { + return { + x: direction > 0 ? containerWidth : -containerWidth, + opacity: 0, + } + }, + center: { + zIndex: 1, + x: 0, + opacity: 1, + }, + exit: (direction: number) => { + return { + zIndex: 0, + x: direction < 0 ? containerWidth : -containerWidth, + opacity: 0, + } + }, +} + +function Intro() { + const { t } = useTranslation("app") + return ( +
+ +

{t("new_user_guide.intro.title")}

+

{t("new_user_guide.intro.description")}

+ +
+ ) +} + +function Outtro() { + const { t } = useTranslation("app") + return ( +
+ +

{t("new_user_guide.outro.title")}

+

{t("new_user_guide.outro.description")}

+
+ ) +} +const absoluteConfettiUrl = new URL(confettiUrl, import.meta.url).href +export function GuideModalContent({ onClose }: { onClose: () => void }) { + const t = useI18n() + const [step, setStep] = useState(0) + const [direction, setDirection] = useState(1) + const haveUsedOtherRSSReader = useHaveUsedOtherRSSReader() + + const guideSteps = useMemo( + () => + [ + { + icon: "i-mgc-grid-cute-re", + title: "Features", + content: createElement(Introduction, { + features: [ + { + icon: "i-mgc-magic-2-cute-re", + title: t.settings("titles.actions"), + description: t.app("new_user_guide.step.features.actions.description"), + }, + { + icon: "i-mgc-department-cute-re", + title: t.settings("titles.integration"), + description: t.app("new_user_guide.step.features.integration.description"), + }, + { + icon: "i-mgc-rada-cute-re", + title: t.settings("titles.lists"), + description: t.settings("lists.info"), + }, + { + icon: "i-mgc-hotkey-cute-re", + title: t.settings("titles.shortcuts"), + description: ( +

+ {t.app("new_user_guide.step.features.shortcuts.description1")}{" "} + H, + }} + /> +

+ ), + }, + ], + }), + }, + { + title: "Power", + icon: "i-mgc-power", + content: ( + + {t.app("new_user_guide.step.power.description")} + + ), + }, + { + title: t.app("new_user_guide.step.start_question.title"), + content: createElement(RookieCheck), + icon: "i-mgc-question-cute-re", + }, + haveUsedOtherRSSReader && { + title: t.app("new_user_guide.step.behavior.title"), + content: createElement(BehaviorGuide), + icon: tw`i-mingcute-cursor-3-line`, + }, + { + title: t.app("new_user_guide.step.rsshub.title"), + content: createElement(RSSHubGuide), + icon: , + }, + haveUsedOtherRSSReader + ? { + title: t.app("new_user_guide.step.import.title"), + content: createElement(DiscoverImport), + icon: "i-mingcute-file-import-line", + } + : { + title: t.app("new_user_guide.step.trending.title"), + content: createElement(TrendingFeeds), + icon: "i-mgc-trending-up-cute-re", + }, + ].filter((i) => !!i) as { + title: string + icon: React.ReactNode + content: FunctionComponentElement + }[], + [haveUsedOtherRSSReader, t], + ) + + const totalSteps = useMemo(() => guideSteps.length, [guideSteps]) + + const status = useMemo( + () => (step === 0 ? "initial" : step > 0 && step <= totalSteps ? "active" : "complete"), + [step, totalSteps], + ) + + const title = useMemo(() => guideSteps[step - 1]?.title, [guideSteps, step]) + + const [isLottieAnimating, setIsLottieAnimating] = useState(false) + + const finishGuide = useCallback(() => { + settingSyncQueue.replaceRemote().then(() => { + settings.get().invalidate() + }) + }, []) + + return ( + + {!!title && ( +

+ {typeof guideSteps[step - 1].icon === "string" ? ( + + ) : ( + guideSteps[step - 1].icon + )} + {title} +

+ )} + +
+ + + {status === "initial" ? ( + + ) : status === "active" ? ( + guideSteps[step - 1].content + ) : status === "complete" ? ( + + ) : null} + + +
+ +
+
+ {Array.from({ length: totalSteps }, (_, i) => i + 1).map((i) => ( + + ))} +
+
+ {step !== 0 && step <= totalSteps && ( + + )} + +
+
+
+ ) +} + +function Step({ step, currentStep }: { step: number; currentStep: number }) { + const status = currentStep === step ? "active" : currentStep < step ? "inactive" : "complete" + + return ( + + + + +
+ {status === "complete" ? ( + + ) : ( + {step} + )} +
+
+
+ ) +} + +function AnimatedCheckIcon(props: ComponentProps<"svg">) { + return ( + + + + ) +} diff --git a/apps/renderer/src/modules/new-user-guide/modal.tsx b/apps/renderer/src/modules/new-user-guide/modal.tsx new file mode 100644 index 0000000000..054c2b0626 --- /dev/null +++ b/apps/renderer/src/modules/new-user-guide/modal.tsx @@ -0,0 +1,27 @@ +import { useState } from "react" + +import { PlainModal } from "~/components/ui/modal/stacked/custom-modal" +import { DeclarativeModal } from "~/components/ui/modal/stacked/declarative-modal" +import { RootPortal } from "~/components/ui/portal" + +import { GuideModalContent } from "./guide-modal-content" + +export const NewUserGuideModal = () => { + const [open, setOpen] = useState(true) + return ( + + + setOpen(false)} /> + + + ) +} diff --git a/apps/renderer/src/modules/new-user-guide/steps/behavior.tsx b/apps/renderer/src/modules/new-user-guide/steps/behavior.tsx new file mode 100644 index 0000000000..4913187da7 --- /dev/null +++ b/apps/renderer/src/modules/new-user-guide/steps/behavior.tsx @@ -0,0 +1,45 @@ +import { useState } from "react" +import { useTranslation } from "react-i18next" + +import { setGeneralSetting } from "~/atoms/settings/general" +import { Radio, RadioGroup } from "~/components/ui/radio-group" + +type Behavior = "default" | "disabled" | "" + +export function BehaviorGuide() { + const [value, setValue] = useState("") + const { t } = useTranslation("app") + + const updateSettings = (behavior: Behavior) => { + setGeneralSetting("hoverMarkUnread", behavior === "default") + setGeneralSetting("scrollMarkUnread", behavior === "default") + } + + return ( +
+

+ {t("new_user_guide.step.behavior.unread_question.content")} +

+
+ { + setValue(value as Behavior) + updateSettings(value as Behavior) + }} + > + + + +
+
+ ) +} diff --git a/apps/renderer/src/modules/new-user-guide/steps/feeds.tsx b/apps/renderer/src/modules/new-user-guide/steps/feeds.tsx new file mode 100644 index 0000000000..124de8bc4e --- /dev/null +++ b/apps/renderer/src/modules/new-user-guide/steps/feeds.tsx @@ -0,0 +1,75 @@ +import { useQuery } from "@tanstack/react-query" +import { useTranslation } from "react-i18next" + +import { getTrendingAggregates } from "~/api/trending" +import { FeedIcon } from "~/components/feed-icon" +import { PhUsersBold } from "~/components/icons/users" +import { Button } from "~/components/ui/button" +import { LoadingWithIcon } from "~/components/ui/loading" +import { useFollow } from "~/hooks/biz/useFollow" +import { cn } from "~/lib/utils" + +export function TrendingFeeds() { + const { data } = useQuery({ + queryKey: ["trending"], + queryFn: () => { + return getTrendingAggregates() + }, + }) + + const follow = useFollow() + const { t } = useTranslation() + + return ( +
    + {data ? ( + data.trendingFeeds.map((feed) => { + return ( +
  • + +
    + +
    +
    +
    {feed.title}
    +
    +
    + +
    + + + {(feed as any).subscriberCount} + + + +
    +
  • + ) + }) + ) : ( + } + size="large" + /> + )} +
+ ) +} diff --git a/apps/renderer/src/modules/new-user-guide/steps/introduction.tsx b/apps/renderer/src/modules/new-user-guide/steps/introduction.tsx new file mode 100644 index 0000000000..4d45494fb6 --- /dev/null +++ b/apps/renderer/src/modules/new-user-guide/steps/introduction.tsx @@ -0,0 +1,42 @@ +import { Markdown } from "~/components/ui/markdown/Markdown" +import { cn } from "~/lib/utils" + +type Feature = { + title: string + icon: string + description: React.ReactNode +} + +export function Introduction({ features }: { features: Feature[] }) { + return ( +
+ {features.map((feature, index) => ( +
+
+
+ +

{feature.title}

+
+ {typeof feature.description === "string" ? ( + {feature.description} + ) : ( +
{feature.description}
+ )} +
+
+ ))} +
+ ) +} diff --git a/apps/renderer/src/modules/new-user-guide/steps/rookie.tsx b/apps/renderer/src/modules/new-user-guide/steps/rookie.tsx new file mode 100644 index 0000000000..2297c2a3d2 --- /dev/null +++ b/apps/renderer/src/modules/new-user-guide/steps/rookie.tsx @@ -0,0 +1,37 @@ +import { useTranslation } from "react-i18next" + +import { Radio, RadioGroup } from "~/components/ui/radio-group" + +import { useHaveUsedOtherRSSReaderAtom } from "../atoms" + +export function RookieCheck() { + const [value, setValue] = useHaveUsedOtherRSSReaderAtom() + const { t } = useTranslation("app") + + return ( +
+

+ {t("new_user_guide.step.start_question.content")} +

+
+ { + setValue(value === "yes") + }} + > + + + +
+
+ ) +} diff --git a/apps/renderer/src/modules/new-user-guide/steps/rsshub.tsx b/apps/renderer/src/modules/new-user-guide/steps/rsshub.tsx new file mode 100644 index 0000000000..1be2d98e82 --- /dev/null +++ b/apps/renderer/src/modules/new-user-guide/steps/rsshub.tsx @@ -0,0 +1,83 @@ +import { useMemo } from "react" +import { useTranslation } from "react-i18next" + +import { Markdown } from "~/components/ui/markdown/Markdown" +import { useAuthQuery } from "~/hooks/common" +import { isASCII } from "~/lib/utils" +import { RecommendationCard } from "~/modules/discover/recommendations-card" +import { Queries } from "~/queries" + +const pickedRoutes = new Set([ + "weibo", + "bilibili", + "twitter", + "telegram", + "youtube", + "github", + "pixiv", + "jike", + "xiaoyuzhou", +]) + +export function RSSHubGuide() { + const { t } = useTranslation("app") + + const rsshubPopular = useAuthQuery( + Queries.discover.rsshubCategory({ + category: "popular", + }), + { + meta: { + persist: true, + }, + }, + ) + + const { data } = rsshubPopular + + const keys = useMemo(() => { + if (data) { + return Object.keys(data).sort((a, b) => { + const aname = data[a].name + const bname = data[b].name + + const aRouteName = data[a].routes[Object.keys(data[a].routes)[0]].name + const bRouteName = data[b].routes[Object.keys(data[b].routes)[0]].name + + const ia = isASCII(aname) && isASCII(aRouteName) + const ib = isASCII(bname) && isASCII(bRouteName) + + if (ia && ib) { + return aname.toLowerCase() < bname.toLowerCase() ? -1 : 1 + } else if (ia || ib) { + return ia > ib ? -1 : 1 + } else { + return 0 + } + }) + } else { + return [] + } + }, [data]) + + if (rsshubPopular.isLoading) { + return null + } + + if (!data) { + return null + } + + return ( +
+ {t("new_user_guide.step.rsshub.info")} +
+ {keys + .filter((key) => pickedRoutes.has(key)) + .map((key) => ( + + ))} +
+
+ ) +} diff --git a/apps/renderer/src/modules/settings/tabs/apperance.tsx b/apps/renderer/src/modules/settings/tabs/apperance.tsx index 430da222b0..11e19ceab2 100644 --- a/apps/renderer/src/modules/settings/tabs/apperance.tsx +++ b/apps/renderer/src/modules/settings/tabs/apperance.tsx @@ -148,7 +148,7 @@ const textSizeMap = { large: 20, } -const TextSize = () => { +export const TextSize = () => { const { t } = useTranslation("settings") const uiTextSize = useUISettingSelector((state) => state.uiTextSize) @@ -177,7 +177,7 @@ const TextSize = () => { ) } -const AppThemeSegment = () => { +export const AppThemeSegment = () => { const { t } = useTranslation("settings") const theme = useThemeAtomValue() const setTheme = useSetTheme() diff --git a/apps/renderer/src/modules/settings/tabs/general.tsx b/apps/renderer/src/modules/settings/tabs/general.tsx index f57011257f..b05909ac0c 100644 --- a/apps/renderer/src/modules/settings/tabs/general.tsx +++ b/apps/renderer/src/modules/settings/tabs/general.tsx @@ -217,7 +217,13 @@ export const VoiceSelector = () => { ) } -export const LanguageSelector = () => { +export const LanguageSelector = ({ + containerClassName, + contentClassName, +}: { + containerClassName?: string + contentClassName?: string +}) => { const { t } = useTranslation("settings") const { t: langT } = useTranslation("lang") const language = useGeneralSettingSelector((state) => state.language) @@ -229,7 +235,7 @@ export const LanguageSelector = () => { const [loadingLanguageLockMap] = useAtom(langLoadingLockMapAtom) return ( -
+
{t("general.language")}