feat: macos docker badge and unread count setting (#87)
* fix: keep entry param route when reload page Signed-off-by: Innei <i@innei.in> * fix: setting modal hmr lost state Signed-off-by: Innei <i@innei.in> * fix: radix warning Signed-off-by: Innei <i@innei.in> * feat: show unread count setting Signed-off-by: Innei <i@innei.in> * fix: setting modal only can present once Signed-off-by: Innei <i@innei.in> --------- Signed-off-by: Innei <i@innei.in>
This commit is contained in:
parent
61677cf20a
commit
54d28fdf29
|
|
@ -156,13 +156,21 @@ export const router = {
|
|||
|
||||
openSettingWindow: t.procedure.action(async () => createSettingWindow()),
|
||||
|
||||
getSystemFonts: t.procedure.action(async (): Promise<string[]> => new Promise((resolve) => {
|
||||
// NOTE: should external font-list deps
|
||||
// use `require` to avoid bundling, vite behavior
|
||||
require("font-list").getFonts().then((fonts) => {
|
||||
resolve(fonts.map((font) => font.replaceAll("\"", "")))
|
||||
})
|
||||
})),
|
||||
getSystemFonts: t.procedure.action(
|
||||
async (): Promise<string[]> =>
|
||||
new Promise((resolve) => {
|
||||
// NOTE: should external font-list deps
|
||||
// use `require` to avoid bundling, vite behavior
|
||||
require("font-list")
|
||||
.getFonts()
|
||||
.then((fonts) => {
|
||||
resolve(fonts.map((font) => font.replaceAll("\"", "")))
|
||||
})
|
||||
}),
|
||||
),
|
||||
setMacOSBadge: t.procedure.input<number>().action(async ({ input }) => {
|
||||
app.setBadgeCount(input)
|
||||
}),
|
||||
}
|
||||
|
||||
export type Router = typeof router
|
||||
|
|
|
|||
|
|
@ -2,17 +2,25 @@ import { createAtomHooks } from "@renderer/lib/jotai"
|
|||
import { atom, useAtomValue } from "jotai"
|
||||
import { selectAtom } from "jotai/utils"
|
||||
import { useMemo } from "react"
|
||||
import type { NavigateFunction, Params } from "react-router-dom"
|
||||
import type { Location, NavigateFunction, Params } from "react-router-dom"
|
||||
|
||||
interface RouteAtom {
|
||||
params: Readonly<Params<string>>
|
||||
searchParams: URLSearchParams
|
||||
location: Location<any>
|
||||
}
|
||||
|
||||
export const [routeAtom, , , , getReadonlyRoute, setRoute] = createAtomHooks(
|
||||
atom<RouteAtom>({
|
||||
params: {},
|
||||
searchParams: new URLSearchParams(),
|
||||
location: {
|
||||
pathname: "",
|
||||
search: "",
|
||||
hash: "",
|
||||
state: null,
|
||||
key: "",
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -25,7 +33,7 @@ export const useReadonlyRouteSelector = <T>(
|
|||
useMemo(() => selectAtom(routeAtom, (route) => selector(route)), deps),
|
||||
)
|
||||
|
||||
// VITE HMR will create new router instance, but RouterProvider always stable
|
||||
// Vite HMR will create new router instance, but RouterProvider always stable
|
||||
|
||||
const [, , , , navigate, setNavigate] = createAtomHooks(
|
||||
atom<{ fn: NavigateFunction | null }>({ fn() {} }),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import type { FC, ReactNode } from "react"
|
||||
import {
|
||||
createElement,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
import { LoadingCircle } from "../ui/loading"
|
||||
|
||||
export const LoadRemixAsyncComponent: FC<{
|
||||
loader: () => Promise<any>
|
||||
Header: FC<{ loader: () => any, [key: string]: any }>
|
||||
}> = ({ loader, Header }) => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const [Component, setComponent] = useState<{ c: () => ReactNode }>({
|
||||
c: () => null,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let isUnmounted = false
|
||||
setLoading(true)
|
||||
loader()
|
||||
.then((module) => {
|
||||
if (!module.Component) {
|
||||
return
|
||||
}
|
||||
if (isUnmounted) return
|
||||
|
||||
const { loader } = module
|
||||
setComponent({
|
||||
c: () => (
|
||||
<>
|
||||
<Header loader={loader} />
|
||||
<module.Component />
|
||||
</>
|
||||
),
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
isUnmounted = true
|
||||
}
|
||||
}, [Header, loader])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="center h-full">
|
||||
<LoadingCircle size="large" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return createElement(Component.c)
|
||||
}
|
||||
|
|
@ -12,23 +12,36 @@ export const useModalStack = (options?: ModalStackOptions) => {
|
|||
return {
|
||||
present: useCallback(
|
||||
(props: ModalProps & { id?: string }) => {
|
||||
const modalId = `${id}-${++currentCount.current}`
|
||||
jotaiStore.set(modalStackAtom, (p) => {
|
||||
const modalProps = {
|
||||
...props,
|
||||
id: props.id ?? modalId,
|
||||
wrapper,
|
||||
}
|
||||
modalIdToPropsMap[modalProps.id] = modalProps
|
||||
return p.concat(modalProps)
|
||||
})
|
||||
const fallbackModelId = `${id}-${++currentCount.current}`
|
||||
const modalId = props.id ?? fallbackModelId
|
||||
|
||||
const currentStack = jotaiStore.get(modalStackAtom)
|
||||
|
||||
const existingModal = currentStack.find((item) => item.id === modalId)
|
||||
if (existingModal) {
|
||||
// Move to top
|
||||
jotaiStore.set(modalStackAtom, (p) => {
|
||||
const index = p.indexOf(existingModal)
|
||||
return [...p.slice(0, index), ...p.slice(index + 1), existingModal]
|
||||
})
|
||||
} else {
|
||||
jotaiStore.set(modalStackAtom, (p) => {
|
||||
const modalProps = {
|
||||
...props,
|
||||
id: modalId,
|
||||
wrapper,
|
||||
}
|
||||
modalIdToPropsMap[modalProps.id] = modalProps
|
||||
return p.concat(modalProps)
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
jotaiStore.set(modalStackAtom, (p) =>
|
||||
p.filter((item) => item.id !== modalId))
|
||||
}
|
||||
},
|
||||
[id],
|
||||
[id, wrapper],
|
||||
),
|
||||
|
||||
...actions,
|
||||
|
|
|
|||
|
|
@ -157,6 +157,9 @@ export const ModalInternal: Component<{
|
|||
<Dialog.Root open onOpenChange={onClose}>
|
||||
<Dialog.Portal>
|
||||
<DialogOverlay zIndex={20} />
|
||||
<Dialog.DialogTitle className="sr-only">
|
||||
{title}
|
||||
</Dialog.DialogTitle>
|
||||
<Dialog.Content asChild>
|
||||
<div
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ const SelectContent = React.forwardRef<
|
|||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"shadow-perfect relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -6,3 +6,9 @@ export enum FeedViewType {
|
|||
Audios = 4,
|
||||
Notifications = 5,
|
||||
}
|
||||
|
||||
export enum Routes {
|
||||
Feeds = "/feeds",
|
||||
Discover = "/discover",
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,15 +18,19 @@ import { CategoryRemoveDialogContent } from "./category-remove-dialog"
|
|||
import { CategoryRenameContent } from "./category-rename-dialog"
|
||||
import { FeedItem } from "./item"
|
||||
|
||||
interface FeedCategoryProps {
|
||||
data: FeedListModel["list"][number]
|
||||
view?: number
|
||||
expansion: boolean
|
||||
showUnreadCount?: boolean
|
||||
}
|
||||
|
||||
function FeedCategoryImpl({
|
||||
data,
|
||||
view,
|
||||
expansion,
|
||||
}: {
|
||||
data: FeedListModel["list"][number]
|
||||
view?: number
|
||||
expansion: boolean
|
||||
}) {
|
||||
showUnreadCount = true,
|
||||
}: FeedCategoryProps) {
|
||||
const [open, setOpen] = useState(!data.name)
|
||||
|
||||
const feedIdList = data.list.map((feed) => feed.feedId)
|
||||
|
|
@ -62,9 +66,13 @@ function FeedCategoryImpl({
|
|||
),
|
||||
)
|
||||
|
||||
const isActive = useRouteParamsSelector((routerParams) => routerParams?.level === levels.folder &&
|
||||
routerParams.feedId === data.list.map((feed) => feed.feedId).join(","))
|
||||
const isActive = useRouteParamsSelector(
|
||||
(routerParams) =>
|
||||
routerParams?.level === levels.folder &&
|
||||
routerParams.feedId === data.list.map((feed) => feed.feedId).join(","),
|
||||
)
|
||||
const { present } = useModalStack()
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={open}
|
||||
|
|
@ -128,9 +136,11 @@ function FeedCategoryImpl({
|
|||
>
|
||||
<i className="i-mgc-right-cute-fi mr-2 transition-transform" />
|
||||
</CollapsibleTrigger>
|
||||
<span className="truncate">{data.name}</span>
|
||||
<span className={cn("truncate", !showUnreadCount && (unread ? "font-bold" : "font-medium opacity-70"))}>
|
||||
{data.name}
|
||||
</span>
|
||||
</div>
|
||||
{!!unread && (
|
||||
{!!unread && showUnreadCount && (
|
||||
<div className="ml-2 text-xs text-zinc-500">{unread}</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -154,6 +164,7 @@ function FeedCategoryImpl({
|
|||
>
|
||||
{sortByUnreadFeedList.map((feed) => (
|
||||
<FeedItem
|
||||
showUnreadCount={showUnreadCount}
|
||||
key={feed.feedId}
|
||||
subscription={feed}
|
||||
view={view}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import { getReadonlyRoute } from "@renderer/atoms"
|
||||
import { Logo } from "@renderer/components/icons/logo"
|
||||
import { ActionButton } from "@renderer/components/ui/button"
|
||||
import { ProfileButton } from "@renderer/components/user-button"
|
||||
import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry"
|
||||
import { APP_NAME, levels, views } from "@renderer/lib/constants"
|
||||
import { stopPropagation } from "@renderer/lib/dom"
|
||||
import { Routes } from "@renderer/lib/enum"
|
||||
import { shortcuts } from "@renderer/lib/shortcuts"
|
||||
import { clamp, cn } from "@renderer/lib/utils"
|
||||
import { useWheel } from "@use-gesture/react"
|
||||
import { m, useSpring } from "framer-motion"
|
||||
import { Lethargy } from "lethargy"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useCallback, useRef, useState } from "react"
|
||||
import { isHotkeyPressed, useHotkeys } from "react-hotkeys-hook"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
|
|
@ -18,28 +20,57 @@ import { FeedList } from "./list"
|
|||
|
||||
const lethargy = new Lethargy()
|
||||
|
||||
const useBackHome = (active: number) => {
|
||||
const navigate = useNavigateEntry()
|
||||
|
||||
return useCallback((overvideActive?: number) => {
|
||||
navigate({
|
||||
feedId: null,
|
||||
entryId: null,
|
||||
view: overvideActive ?? active,
|
||||
level: levels.view,
|
||||
})
|
||||
}, [active, navigate])
|
||||
}
|
||||
export function FeedColumn() {
|
||||
const carouselRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [active, setActive] = useState(0)
|
||||
const [active, setActive_] = useState(0)
|
||||
const spring = useSpring(0, {
|
||||
stiffness: 700,
|
||||
damping: 40,
|
||||
})
|
||||
const navigateBackHome = useBackHome(active)
|
||||
const setActive: typeof setActive_ = useCallback(
|
||||
(args) => {
|
||||
const nextActive = typeof args === "function" ? args(active) : args
|
||||
setActive_(args)
|
||||
|
||||
useHotkeys(shortcuts.feeds.switchBetweenViews.key, () => {
|
||||
if (isHotkeyPressed("Left")) {
|
||||
setActive((i) => {
|
||||
if (i === 0) {
|
||||
return views.length - 1
|
||||
} else {
|
||||
return i - 1
|
||||
}
|
||||
})
|
||||
} else {
|
||||
setActive((i) => (i + 1) % views.length)
|
||||
}
|
||||
}, { scopes: ["home"] })
|
||||
if (getReadonlyRoute().location.pathname.startsWith(Routes.Feeds)) {
|
||||
navigateBackHome(nextActive)
|
||||
}
|
||||
spring.set(-nextActive * 256)
|
||||
},
|
||||
[active, navigateBackHome, spring],
|
||||
)
|
||||
|
||||
useHotkeys(
|
||||
shortcuts.feeds.switchBetweenViews.key,
|
||||
() => {
|
||||
if (isHotkeyPressed("Left")) {
|
||||
setActive((i) => {
|
||||
if (i === 0) {
|
||||
return views.length - 1
|
||||
} else {
|
||||
return i - 1
|
||||
}
|
||||
})
|
||||
} else {
|
||||
setActive((i) => (i + 1) % views.length)
|
||||
}
|
||||
},
|
||||
{ scopes: ["home"] },
|
||||
)
|
||||
|
||||
useWheel(
|
||||
({ event, last, memo: wait = false, direction: [dx], delta: [dex] }) => {
|
||||
|
|
@ -67,25 +98,10 @@ export function FeedColumn() {
|
|||
const normalStyle =
|
||||
!window.electron || window.electron.process.platform !== "darwin"
|
||||
|
||||
const navigate = useNavigateEntry()
|
||||
|
||||
useEffect(() => {
|
||||
spring.set(-active * 256)
|
||||
navigateBackHome()
|
||||
}, [active])
|
||||
|
||||
const navigateBackHome = useCallback(() => {
|
||||
navigate({
|
||||
feedId: null,
|
||||
entryId: null,
|
||||
view: active,
|
||||
level: levels.view,
|
||||
})
|
||||
}, [active, navigate])
|
||||
return (
|
||||
<Vibrancy
|
||||
className="flex h-full flex-col gap-3 pt-2.5"
|
||||
onClick={navigateBackHome}
|
||||
onClick={useCallback(() => navigateBackHome(), [navigateBackHome])}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -28,15 +28,18 @@ import { useFeedClaimModal } from "../claim/hooks"
|
|||
import { FeedForm } from "../discover/feed-form"
|
||||
|
||||
type FeedItemData = SubscriptionPlainModel
|
||||
interface FeedItemProps {
|
||||
subscription: FeedItemData
|
||||
view?: number
|
||||
className?: string
|
||||
showUnreadCount?: boolean
|
||||
}
|
||||
const FeedItemImpl = ({
|
||||
subscription,
|
||||
view,
|
||||
className,
|
||||
}: {
|
||||
subscription: FeedItemData
|
||||
view?: number
|
||||
className?: string
|
||||
}) => {
|
||||
showUnreadCount = true,
|
||||
}: FeedItemProps) => {
|
||||
const navigate = useNavigateEntry()
|
||||
const handleNavigate: React.MouseEventHandler<HTMLDivElement> = useCallback(
|
||||
(e) => {
|
||||
|
|
@ -213,7 +216,14 @@ const FeedItemImpl = ({
|
|||
)}
|
||||
>
|
||||
<FeedIcon feed={feed} className="size-4" />
|
||||
<div className="truncate">{feed.title}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"truncate",
|
||||
!showUnreadCount && (feedUnread ? "font-bold" : "font-medium opacity-70"),
|
||||
)}
|
||||
>
|
||||
{feed.title}
|
||||
</div>
|
||||
{feed.errorAt && (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
|
|
@ -250,7 +260,7 @@ const FeedItemImpl = ({
|
|||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
{!!feedUnread && (
|
||||
{showUnreadCount && !!feedUnread && (
|
||||
<div className="ml-2 text-xs text-zinc-500">{feedUnread}</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,14 +5,13 @@ import { FEED_COLLECTION_LIST, levels, views } from "@renderer/lib/constants"
|
|||
import { stopPropagation } from "@renderer/lib/dom"
|
||||
import type { FeedViewType } from "@renderer/lib/enum"
|
||||
import { cn } from "@renderer/lib/utils"
|
||||
import type {
|
||||
FeedListModel,
|
||||
} from "@renderer/models"
|
||||
import type { FeedListModel } from "@renderer/models"
|
||||
import { Queries } from "@renderer/queries"
|
||||
import type { SubscriptionPlainModel } from "@renderer/store"
|
||||
import {
|
||||
getFeedById,
|
||||
useSubscriptionByView,
|
||||
useUIStore,
|
||||
useUnreadStore,
|
||||
} from "@renderer/store"
|
||||
import { useMemo, useState } from "react"
|
||||
|
|
@ -124,6 +123,7 @@ export function FeedList({
|
|||
|
||||
const feedId = useRouteFeedId()
|
||||
const navigate = useNavigateEntry()
|
||||
const showUnreadCount = useUIStore((state) => state.sidebarShowUnreadCount)
|
||||
|
||||
return (
|
||||
<div className={cn(className, "font-medium")}>
|
||||
|
|
@ -187,6 +187,7 @@ export function FeedList({
|
|||
{data?.list?.length ?
|
||||
sortedByUnread?.map((category) => (
|
||||
<FeedCategory
|
||||
showUnreadCount={showUnreadCount}
|
||||
key={category.name}
|
||||
data={category}
|
||||
view={view}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,12 @@
|
|||
import { LoadRemixAsyncComponent } from "@renderer/components/common/LoadRemixAsyncComponent"
|
||||
import { MotionButtonBase } from "@renderer/components/ui/button"
|
||||
import { LoadingCircle } from "@renderer/components/ui/loading"
|
||||
import { useCurrentModal } from "@renderer/components/ui/modal"
|
||||
import { preventDefault } from "@renderer/lib/dom"
|
||||
import {
|
||||
SettingsSidebarTitle,
|
||||
SettingsTitle,
|
||||
} from "@renderer/modules/settings/title"
|
||||
import { createContextState } from "foxact/context-state"
|
||||
import { m } from "framer-motion"
|
||||
import type { FC, PropsWithChildren, ReactNode } from "react"
|
||||
import { createElement, useEffect, useState } from "react"
|
||||
|
||||
import { settings } from "../constants"
|
||||
|
||||
const [SettingTabProvider, useSettingTab, useSetSettingTab] =
|
||||
createContextState("")
|
||||
import { SettingTabProvider, useSettingTab } from "./context"
|
||||
import { SettingModalLayout } from "./layout"
|
||||
|
||||
const pages = (() => {
|
||||
const map = import.meta.glob("@renderer/pages/settings/*.tsx")
|
||||
|
|
@ -27,60 +19,14 @@ const pages = (() => {
|
|||
}
|
||||
return pages
|
||||
})()
|
||||
|
||||
function Layout(props: PropsWithChildren) {
|
||||
const { children } = props
|
||||
const setTab = useSetSettingTab()
|
||||
const tab = useSettingTab()
|
||||
|
||||
useEffect(() => {
|
||||
if (!tab) setTab(settings[0].path)
|
||||
}, [])
|
||||
return (
|
||||
<m.div
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0.96,
|
||||
}}
|
||||
className="flex h-[500px] max-h-[80vh] w-[660px] max-w-full flex-col overflow-hidden rounded-xl border border-border"
|
||||
onContextMenu={preventDefault}
|
||||
>
|
||||
<div className="flex h-0 flex-1 bg-theme-tooltip-background">
|
||||
<div className="w-44 border-r px-2 py-6">
|
||||
{settings.map((t) => (
|
||||
<button
|
||||
key={t.path}
|
||||
className={`my-1 flex w-full items-center rounded-lg px-2.5 py-0.5 leading-loose text-theme-foreground/70 transition-colors ${
|
||||
tab === t.path ?
|
||||
"bg-theme-item-active text-theme-foreground/90" :
|
||||
""
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => setTab(t.path)}
|
||||
>
|
||||
<SettingsSidebarTitle
|
||||
path={t.path}
|
||||
className="text-[0.94rem] font-medium"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative h-full flex-1 bg-theme-background p-8 pt-0">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</m.div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingModalContent = () => (
|
||||
<SettingTabProvider>
|
||||
<Layout>
|
||||
<SettingModalLayout>
|
||||
<div className="h-full flex-1 shrink-0 overflow-auto">
|
||||
<Content />
|
||||
<Close />
|
||||
</div>
|
||||
</Layout>
|
||||
</SettingModalLayout>
|
||||
</SettingTabProvider>
|
||||
)
|
||||
|
||||
|
|
@ -100,48 +46,5 @@ const Content = () => {
|
|||
|
||||
if (!Component) return null
|
||||
|
||||
return <LoadRemixAsyncComponent loader={Component} />
|
||||
}
|
||||
|
||||
const LoadRemixAsyncComponent: FC<{
|
||||
loader: () => Promise<any>
|
||||
}> = ({ loader }) => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const [Component, setComponent] = useState<{ c: () => ReactNode }>({
|
||||
c: () => null,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
loader()
|
||||
.then((module) => {
|
||||
if (!module.Component) {
|
||||
return
|
||||
}
|
||||
|
||||
const { loader } = module
|
||||
setComponent({
|
||||
c: () => (
|
||||
<>
|
||||
<SettingsTitle loader={loader} />
|
||||
<module.Component />
|
||||
</>
|
||||
),
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}, [loader])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="center h-full">
|
||||
<LoadingCircle size="large" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return createElement(Component.c)
|
||||
return <LoadRemixAsyncComponent Header={SettingsTitle} loader={Component} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
import { createContextState } from "foxact/context-state"
|
||||
|
||||
export const [SettingTabProvider, useSettingTab, useSetSettingTab] = createContextState("")
|
||||
|
|
@ -8,6 +8,7 @@ export const useSettingModal = () => {
|
|||
|
||||
return useCallback(() => present({
|
||||
title: "Setting",
|
||||
id: "setting",
|
||||
content: SettingModalContent,
|
||||
CustomModalComponent: (props) => createElement("div", {
|
||||
className: "center h-full center",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import { Logo } from "@renderer/components/icons/logo"
|
||||
import { APP_NAME } from "@renderer/lib/constants"
|
||||
import { preventDefault } from "@renderer/lib/dom"
|
||||
import { m } from "framer-motion"
|
||||
import type { PropsWithChildren } from "react"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { settings } from "../constants"
|
||||
import { SettingsSidebarTitle } from "../title"
|
||||
import { useSetSettingTab, useSettingTab } from "./context"
|
||||
|
||||
export function SettingModalLayout(props: PropsWithChildren) {
|
||||
const { children } = props
|
||||
const setTab = useSetSettingTab()
|
||||
const tab = useSettingTab()
|
||||
|
||||
useEffect(() => {
|
||||
if (!tab) setTab(settings[0].path)
|
||||
}, [])
|
||||
return (
|
||||
<m.div
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0.96,
|
||||
}}
|
||||
className="flex h-[500px] max-h-[80vh] w-[660px] max-w-full flex-col overflow-hidden rounded-xl border border-border"
|
||||
onContextMenu={preventDefault}
|
||||
>
|
||||
<div className="flex h-0 flex-1 bg-theme-tooltip-background">
|
||||
<div className="w-44 border-r px-2 py-5">
|
||||
<div className="mb-4 flex h-8 items-center gap-2 px-4 font-bold">
|
||||
<Logo className="size-6" />
|
||||
{APP_NAME}
|
||||
</div>
|
||||
{settings.map((t) => (
|
||||
<button
|
||||
key={t.path}
|
||||
className={`my-1 flex w-full items-center rounded-lg px-2.5 py-0.5 leading-loose text-theme-foreground/70 transition-colors ${
|
||||
tab === t.path ?
|
||||
"bg-theme-item-active text-theme-foreground/90" :
|
||||
""
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => setTab(t.path)}
|
||||
>
|
||||
<SettingsSidebarTitle
|
||||
path={t.path}
|
||||
className="text-[0.94rem] font-medium"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative h-full flex-1 bg-theme-background p-8 pt-0">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</m.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ export const SettingAppearance = () => {
|
|||
}, [])
|
||||
|
||||
const state = useUIStore()
|
||||
const onlyMacos = window.electron && getOS() === "macOS"
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -33,7 +34,7 @@ export const SettingAppearance = () => {
|
|||
checked={isDark}
|
||||
onCheckedChange={saveDarkSetting}
|
||||
/>
|
||||
{window.electron && getOS() === "macOS" && (
|
||||
{onlyMacos && (
|
||||
<SettingSwitch
|
||||
label="Opaque Sidebars"
|
||||
checked={state.opaqueSidebar}
|
||||
|
|
@ -46,6 +47,23 @@ export const SettingAppearance = () => {
|
|||
<SettingSectionTitle title="Text" />
|
||||
{window.electron && <Fonts />}
|
||||
<TextSize />
|
||||
<SettingSectionTitle title="Display counts" />
|
||||
{onlyMacos && (
|
||||
<SettingSwitch
|
||||
label="Dock Badge"
|
||||
checked={state.showDockBadge}
|
||||
onCheckedChange={(c) => {
|
||||
uiActions.set("showDockBadge", c)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SettingSwitch
|
||||
label="Show sidebar unread count"
|
||||
checked={state.sidebarShowUnreadCount}
|
||||
onCheckedChange={(c) => {
|
||||
uiActions.set("sidebarShowUnreadCount", c)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -108,12 +126,16 @@ const TextSize = () => {
|
|||
)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-24">
|
||||
<SelectTrigger className="h-8 w-24 capitalize">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(textSizeMap).map(([size, value]) => (
|
||||
<SelectItem key={size} value={value.toString()}>
|
||||
<SelectItem
|
||||
className="capitalize"
|
||||
key={size}
|
||||
value={value.toString()}
|
||||
>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,25 @@
|
|||
import { setNavigate, setRoute } from "@renderer/atoms"
|
||||
import { useLayoutEffect } from "react"
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom"
|
||||
|
||||
export const BizRouterProvider = () => {
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom"
|
||||
/**
|
||||
* Why this.
|
||||
* Remix router always update immutable object when the router has any changes, lead to the component which uses router hooks re-render.
|
||||
* This provider is hold a empty component, to store the router hooks value.
|
||||
* And use our router hooks will not re-render the component when the router has any changes.
|
||||
* Also it can access values outside of the component and provide a value selector
|
||||
*/
|
||||
export const StableRouterProvider = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const params = useParams()
|
||||
const nav = useNavigate()
|
||||
const location = useLocation()
|
||||
useLayoutEffect(() => {
|
||||
setRoute({
|
||||
params,
|
||||
searchParams,
|
||||
location,
|
||||
})
|
||||
setNavigate({ fn: nav })
|
||||
}, [searchParams, params, nav])
|
||||
}, [searchParams, params, location, nav])
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { Provider } from "jotai"
|
|||
import type { FC, PropsWithChildren } from "react"
|
||||
import { HelmetProvider } from "react-helmet-async"
|
||||
|
||||
import { BizRouterProvider } from "./biz-router-provider"
|
||||
import { StableRouterProvider } from "./biz-router-provider"
|
||||
import { ContextMenuProvider } from "./context-menu-provider"
|
||||
import { UISettingInitialize } from "./ui-setting-Initialize"
|
||||
import { UserProvider } from "./user-provider"
|
||||
|
|
@ -36,7 +36,7 @@ export const RootProviders: FC<PropsWithChildren> = ({ children }) => (
|
|||
<ModalStackProvider />
|
||||
<ContextMenuProvider />
|
||||
<HelmetProvider>{children}</HelmetProvider>
|
||||
<BizRouterProvider />
|
||||
<StableRouterProvider />
|
||||
</Provider>
|
||||
</TooltipProvider>
|
||||
</PersistQueryClientProvider>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useUIStore } from "@renderer/store"
|
||||
import { useInsertionEffect } from "react"
|
||||
import { tipcClient } from "@renderer/lib/client"
|
||||
import { unreadActions, useUIStore } from "@renderer/store"
|
||||
import { useEffect, useInsertionEffect } from "react"
|
||||
|
||||
export const UISettingInitialize = () => {
|
||||
const state = useUIStore()
|
||||
|
|
@ -8,5 +9,14 @@ export const UISettingInitialize = () => {
|
|||
const root = document.documentElement
|
||||
root.style.fontSize = `${state.uiTextSize}px`
|
||||
}, [state.uiTextSize])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.showDockBadge) {
|
||||
return unreadActions.subscribeUnreadCount((count) => tipcClient?.setMacOSBadge(count), true)
|
||||
} else {
|
||||
tipcClient?.setMacOSBadge(0)
|
||||
}
|
||||
return
|
||||
}, [state.showDockBadge])
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,21 @@
|
|||
import { createZustandStore, getStoreActions } from "./utils/helper"
|
||||
import { buildStorageNS } from "@renderer/lib/ns"
|
||||
|
||||
import {
|
||||
createZustandStore,
|
||||
getStoreActions,
|
||||
localStorage,
|
||||
} from "./utils/helper"
|
||||
|
||||
interface UIState {
|
||||
entryColWidth: number
|
||||
|
||||
opaqueSidebar: boolean
|
||||
readerFontFamily: string
|
||||
uiTextSize: number
|
||||
|
||||
// Display counts
|
||||
/** macOS only */
|
||||
showDockBadge: boolean
|
||||
sidebarShowUnreadCount: boolean
|
||||
}
|
||||
|
||||
const createDefaultUIState = (): UIState => ({
|
||||
|
|
@ -13,13 +23,18 @@ const createDefaultUIState = (): UIState => ({
|
|||
opaqueSidebar: false,
|
||||
readerFontFamily: "SN Pro",
|
||||
uiTextSize: 16,
|
||||
|
||||
showDockBadge: true,
|
||||
sidebarShowUnreadCount: true,
|
||||
})
|
||||
interface UIActions {
|
||||
clear: () => void
|
||||
set: <T extends keyof UIState>(key: T, value: UIState[T]) => void
|
||||
}
|
||||
export const useUIStore = createZustandStore<UIState & UIActions>("ui", {
|
||||
const storageKey = buildStorageNS("ui")
|
||||
export const useUIStore = createZustandStore<UIState & UIActions>(storageKey, {
|
||||
version: 1,
|
||||
storage: localStorage,
|
||||
})((set) => ({
|
||||
...createDefaultUIState(),
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ interface UnreadActions {
|
|||
fetchUnreadAll: () => Promise<Record<string, number>>
|
||||
incrementByFeedId: (feedId: string, inc: number) => void
|
||||
|
||||
subscribeUnreadCount: (fn: (count: number) => void, immediately?: boolean) => () => void
|
||||
|
||||
internal_reset: () => void
|
||||
|
||||
clear: () => void
|
||||
|
|
@ -25,7 +27,7 @@ export const useUnreadStore = createZustandStore<UnreadState & UnreadActions>(
|
|||
{
|
||||
version: 1,
|
||||
},
|
||||
)((set) => ({
|
||||
)((set, get) => ({
|
||||
data: {},
|
||||
|
||||
internal_reset() {
|
||||
|
|
@ -89,6 +91,21 @@ export const useUnreadStore = createZustandStore<UnreadState & UnreadActions>(
|
|||
}),
|
||||
)
|
||||
},
|
||||
|
||||
subscribeUnreadCount(fn, immediately) {
|
||||
const handler = (state: UnreadState & UnreadActions): void => {
|
||||
let unread = 0
|
||||
for (const key in state.data) {
|
||||
unread += state.data[key]
|
||||
}
|
||||
|
||||
fn(unread)
|
||||
}
|
||||
if (immediately) {
|
||||
handler(get())
|
||||
}
|
||||
return useUnreadStore.subscribe(handler)
|
||||
},
|
||||
}))
|
||||
|
||||
export const unreadActions = getStoreActions(useUnreadStore)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,23 @@ export const dbStorage: PersistStorage<any> = {
|
|||
await del(name)
|
||||
},
|
||||
}
|
||||
export const localStorage: PersistStorage<any> = {
|
||||
getItem: (name: string) => {
|
||||
const data = window.localStorage.getItem(name)
|
||||
|
||||
if (data === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return JSON.parse(data)
|
||||
},
|
||||
setItem: (name, value) => {
|
||||
window.localStorage.setItem(name, JSON.stringify(value))
|
||||
},
|
||||
removeItem: (name: string) => {
|
||||
window.localStorage.removeItem(name)
|
||||
},
|
||||
}
|
||||
enableMapSet()
|
||||
export const zustandStorage = dbStorage
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue