feat: setting appearance - font (#85)

* feat: setting appearance

Signed-off-by: Innei <i@innei.in>

* feat: font family and text size

Signed-off-by: Innei <i@innei.in>

---------

Signed-off-by: Innei <i@innei.in>
This commit is contained in:
Innei 2024-06-23 21:52:52 +08:00 committed by GitHub
parent d33ad59cc4
commit aed5fc8ef0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 512 additions and 126 deletions

View File

@ -24,6 +24,7 @@ files:
- "out"
- "resources"
- "!node_modules"
- "node_modules/font-list/**/*"
asarUnpack:
- resources/**
win:

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#fff" fill-opacity=".01" d="M24 0v24H0V0z"/><path fill="#10161F" d="M8 12.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0M10 8.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0M15 8.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0"/><path stroke="#10161F" stroke-width="2" d="M17.902 15.484c1.322.22 2.682-.458 2.936-1.773a9 9 0 1 0-8.469 7.282c1.292-.053 1.891-1.472 1.313-2.63a2.115 2.115 0 0 1 .396-2.44l.089-.09a2.29 2.29 0 0 1 1.995-.64z"/><path stroke="#10161F" stroke-width="2" d="M8 12.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0ZM10 8.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0ZM15 8.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0Z"/></svg>

After

Width:  |  Height:  |  Size: 647 B

View File

@ -60,6 +60,7 @@
"dayjs": "1.11.11",
"dotenv": "16.4.5",
"electron-updater": "^6.2.1",
"font-list": "1.5.1",
"foxact": "0.2.35",
"framer-motion": "11.2.11",
"franc-min": "6.2.0",

View File

@ -121,6 +121,9 @@ importers:
electron-updater:
specifier: ^6.2.1
version: 6.2.1
font-list:
specifier: 1.5.1
version: 1.5.1
foxact:
specifier: 0.2.35
version: 0.2.35(react@18.3.1)
@ -3480,6 +3483,9 @@ packages:
debug:
optional: true
font-list@1.5.1:
resolution: {integrity: sha512-Hr5V0dsSv91wH3FgirXd7qh1PydqA/vMQyWjFFWn+lUPJtC+3i2tzgVqbLRcvQh87TGdbTGbAR3mEo4VlwC1jw==}
foreground-child@3.1.1:
resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==}
engines: {node: '>=14'}
@ -9515,6 +9521,8 @@ snapshots:
follow-redirects@1.15.6: {}
font-list@1.5.1: {}
foreground-child@3.1.1:
dependencies:
cross-spawn: 7.0.3

View File

@ -155,6 +155,14 @@ 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("\"", "")))
})
})),
}
export type Router = typeof router

View File

@ -1,12 +1,18 @@
import path from "node:path"
import { is } from "@electron-toolkit/utils"
import { callGlobalContextMethod } from "@shared/bridge"
import type { BrowserWindowConstructorOptions } from "electron"
import { BrowserWindow, shell } from "electron"
import icon from "../../resources/icon.png?asset"
import { store } from "./store"
const windows = {
settingWindow: null as BrowserWindow | null,
mainWindow: null as BrowserWindow | null,
}
export function createWindow(
options: {
extraPath?: string
@ -123,14 +129,21 @@ export const createMainWindow = () => {
x: bounds.x,
y: bounds.y,
})
windows.mainWindow = null
})
windows.mainWindow = window
return window
}
const windows = {
settingWindow: null as BrowserWindow | null,
}
export const createSettingWindow = () => {
// We need to open the setting modal in the main window when the main window exists,
// if we open a new window then the state between the two windows will be out of sync.
if (windows.mainWindow) {
windows.mainWindow.show()
callGlobalContextMethod(windows.mainWindow, "showSetting")
return
}
if (windows.settingWindow) {
windows.settingWindow.show()
return

View File

@ -1,13 +1,16 @@
import { queryClient } from "@renderer/lib/query-client"
import { registerGlobalContext } from "@shared/bridge"
import { useEffect } from "react"
import { Outlet } from "react-router-dom"
import { useDark } from "./hooks/common/useDark"
import { useSettingModal } from "./modules/settings/modal/hooks"
import { RootProviders } from "./providers/root-providers"
import { handlers } from "./tipc"
function App() {
useDark()
const showSetting = useSettingModal()
useEffect(() => {
const cleanup = handlers?.invalidateQuery.listen((queryKey) => {
queryClient.invalidateQueries({
@ -15,6 +18,9 @@ function App() {
})
})
registerGlobalContext({
showSetting,
})
return cleanup
}, [])

View File

@ -1,13 +1,16 @@
import { useDark } from "@renderer/hooks"
import { cn } from "@renderer/lib/utils"
import { useUIStore } from "@renderer/store"
import { useMediaQuery } from "usehooks-ts"
export const Vibrancy: Component<
React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
> = ({ className, children, ...rest }) => {
const opaqueSidebar = useUIStore((s) => s.opaqueSidebar)
const canVibrancy =
window.electron && window.electron.process.platform === "darwin"
window.electron &&
window.electron.process.platform === "darwin" &&
!opaqueSidebar
const systemDark = useMediaQuery("(prefers-color-scheme: dark)")
const { isDark } = useDark()

View File

@ -1,6 +1,5 @@
import * as SelectPrimitive from "@radix-ui/react-select"
import { cn } from "@renderer/lib/utils"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import * as React from "react"
const Select = SelectPrimitive.Root
@ -25,7 +24,7 @@ const SelectTrigger = React.forwardRef<
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="size-4 opacity-50" />
<i className="i-mingcute-down-line size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
@ -43,7 +42,7 @@ const SelectScrollUpButton = React.forwardRef<
)}
{...props}
>
<ChevronUp />
<i className="i-mingcute-up-line" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
@ -60,7 +59,7 @@ const SelectScrollDownButton = React.forwardRef<
)}
{...props}
>
<ChevronDown />
<i className="i-mingcute-down-line" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
@ -124,7 +123,7 @@ const SelectItem = React.forwardRef<
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="size-4" />
<i className="i-mingcute-check-line size-3" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>

View File

@ -8,7 +8,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
"peer inline-flex h-5 w-9 shrink-0 items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
"duration-200 data-[state=checked]:bg-theme-accent data-[state=unchecked]:bg-theme-inactive dark:data-[state=unchecked]:bg-theme-accent-100/10",
className,
)}
@ -17,7 +17,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block size-5 rounded-full shadow-lg ring-0 !transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
"pointer-events-none block size-4 rounded-full ring-0 !transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
"bg-theme-background dark:bg-neutral-300",
)}
/>

View File

@ -5,7 +5,6 @@ import {
} from "@renderer/components/ui/avatar"
import { useSignOut } from "@renderer/hooks"
import { loginHandler } from "@renderer/lib/auth"
import { tipcClient } from "@renderer/lib/client"
import { APP_NAME } from "@renderer/lib/constants"
import { nextFrame, stopPropagation } from "@renderer/lib/dom"
import { cn } from "@renderer/lib/utils"
@ -134,14 +133,10 @@ export const ProfileButton: FC<LoginProps> = memo((props) => {
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
if (window.electron) {
tipcClient?.openSettingWindow()
} else {
// Here we need to delay one frame, so it's two raf,
// in order to have `point-event: none` recorded by RadixOverlay after modal is invoked in a certain scenario,
// and the page freezes after modal is turned off.
nextFrame(settingModalPresent)
}
// Here we need to delay one frame, so it's two raf,
// in order to have `point-event: none` recorded by RadixOverlay after modal is invoked in a certain scenario,
// and the page freezes after modal is turned off.
nextFrame(settingModalPresent)
}}
>
Preferences

View File

@ -324,7 +324,7 @@ const ListHeader: FC<{
<i className="i-mgc-check-circle-cute-re" />
</ActionButton>
</PopoverTrigger>
<PopoverContent className="flex w-fit flex-col items-center justify-center gap-3 text-[15px] font-medium">
<PopoverContent className="flex w-fit flex-col items-center justify-center gap-3 text-[0.94rem] font-medium">
<div>Mark all as read?</div>
<div className="space-x-4">
<PopoverClose>

View File

@ -9,7 +9,7 @@ import {
WrappedElementProvider,
} from "@renderer/providers/wrapped-element-provider"
import { Queries } from "@renderer/queries"
import { useEntry, useFeedHeaderTitle } from "@renderer/store"
import { useEntry, useFeedHeaderTitle, useUIStore } from "@renderer/store"
import { m } from "framer-motion"
import { useEffect, useState } from "react"
@ -76,6 +76,8 @@ function EntryContentRender({ entryId }: { entryId: string }) {
},
)
const readerFontFamily = useUIStore((state) => state.readerFontFamily)
if (!entry) return null
return (
@ -83,13 +85,19 @@ function EntryContentRender({ entryId }: { entryId: string }) {
<EntryHeader entryId={entry.entries.id} view={0} />
<div className="h-[calc(100%-3.5rem)] min-w-0 overflow-y-auto @container">
<m.div
style={{
fontFamily: readerFontFamily,
}}
className="p-5"
initial={{ opacity: 0.01, y: 100 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0.01, y: -100 }}
key={entry.entries.id}
>
<article onContextMenu={stopPropagation} className="relative m-auto min-w-0 max-w-[550px] @4xl:max-w-[70ch]">
<article
onContextMenu={stopPropagation}
className="relative m-auto min-w-0 max-w-[550px] @4xl:max-w-[70ch]"
>
<a
href={entry.entries.url || void 0}
target="_blank"
@ -112,10 +120,10 @@ function EntryContentRender({ entryId }: { entryId: string }) {
</a>
<WrappedElementProvider boundingDetection>
<TitleMetaHandler entryId={entry.entries.id} />
<div className="prose prose-zinc mx-auto mb-32 mt-8 max-w-full cursor-auto select-text break-all text-[15px] dark:prose-invert">
<div className="prose prose-zinc mx-auto mb-32 mt-8 max-w-full cursor-auto select-text break-all text-[0.94rem] dark:prose-invert">
{(summary.isLoading || summary.data) && (
<div className="my-8 space-y-1 rounded-lg border px-4 py-3">
<div className="flex items-center gap-2 font-medium text-zinc-800">
<div className="flex items-center gap-2 font-medium text-zinc-800 dark:text-neutral-400">
<i className="i-mgc-magic-2-cute-re align-middle" />
<span>AI summary</span>
</div>

View File

@ -0,0 +1,34 @@
import type { SettingPageConfig } from "./utils"
function getSettings() {
const map = import.meta.glob("../../pages/settings/*.tsx", { eager: true })
const settings = [] as {
name: string
iconName: string
path: string
Component: () => JSX.Element
priority: number
}[]
for (const path in map) {
const p = path.split("/").pop()?.replace(".tsx", "")!
if (p === "index" || p === "layout") continue
const Module = map[path] as {
Component: () => JSX.Element
loader: () => SettingPageConfig
}
if (!Module.loader) continue
settings.push({
...Module.loader(),
Component: Module.Component,
path: p,
})
}
return settings.sort((a, b) => a.priority - b.priority)
}
export const settings = /* #__PURE__ */ getSettings()

View File

@ -0,0 +1,41 @@
import { Checkbox } from "@renderer/components/ui/checkbox"
import { Label } from "@renderer/components/ui/label"
import { Switch } from "@renderer/components/ui/switch"
import { useId } from "react"
export const SettingCheckbox: Component<{
label: string
checked: boolean
onCheckedChange: (checked: boolean) => void
}> = ({ checked, label, onCheckedChange }) => {
const id = useId()
return (
<div className="mb-2 flex items-center gap-4">
<Checkbox
id={id}
checked={checked}
onCheckedChange={onCheckedChange}
className="cursor-auto"
/>
<Label htmlFor={id}>{label}</Label>
</div>
)
}
export const SettingSwitch: Component<{
label: string
checked: boolean
onCheckedChange: (checked: boolean) => void
}> = ({ checked, label, onCheckedChange }) => {
const id = useId()
return (
<div className="mb-3 flex items-center justify-between gap-4">
<Label htmlFor={id}>{label}</Label>
<Switch
checked={checked}
onCheckedChange={onCheckedChange}
className="cursor-auto"
/>
</div>
)
}

View File

@ -1,14 +1,18 @@
import { MotionButtonBase } from "@renderer/components/ui/button"
import { LoadingCircle } from "@renderer/components/ui/loading"
import { useCurrentModal } from "@renderer/components/ui/modal"
import { settingTabs } from "@renderer/lib/constants"
import { preventDefault } from "@renderer/lib/dom"
import { SettingsTitle } from "@renderer/modules/settings/title"
import {
SettingsSidebarTitle,
SettingsTitle,
} from "@renderer/modules/settings/title"
import { createContextState } from "foxact/context-state"
import { m } from "framer-motion"
import type { FC, PropsWithChildren } from "react"
import type { FC, PropsWithChildren, ReactNode } from "react"
import { createElement, useEffect, useState } from "react"
import { settings } from "../constants"
const [SettingTabProvider, useSettingTab, useSetSettingTab] =
createContextState("")
@ -28,6 +32,10 @@ function Layout(props: PropsWithChildren) {
const { children } = props
const setTab = useSetSettingTab()
const tab = useSettingTab()
useEffect(() => {
if (!tab) setTab(settings[0].path)
}, [])
return (
<m.div
exit={{
@ -39,7 +47,7 @@ function Layout(props: PropsWithChildren) {
>
<div className="flex h-0 flex-1 bg-theme-tooltip-background">
<div className="w-44 border-r px-2 py-6">
{settingTabs.map((t) => (
{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 ${
@ -50,14 +58,14 @@ function Layout(props: PropsWithChildren) {
type="button"
onClick={() => setTab(t.path)}
>
<SettingsTitle
<SettingsSidebarTitle
path={t.path}
className="text-[15px] font-medium"
className="text-[0.94rem] font-medium"
/>
</button>
))}
</div>
<div className="relative h-full flex-1 bg-theme-background p-8">
<div className="relative h-full flex-1 bg-theme-background p-8 pt-0">
{children}
</div>
</div>
@ -87,7 +95,7 @@ const Close = () => {
}
const Content = () => {
const key = useSettingTab() || "index"
const key = useSettingTab()
const Component = pages[key]
if (!Component) return null
@ -100,7 +108,7 @@ const LoadRemixAsyncComponent: FC<{
}> = ({ loader }) => {
const [loading, setLoading] = useState(true)
const [Component, setComponent] = useState({
const [Component, setComponent] = useState<{ c: () => ReactNode }>({
c: () => null,
})
@ -111,8 +119,15 @@ const LoadRemixAsyncComponent: FC<{
if (!module.Component) {
return
}
const { loader } = module
setComponent({
c: module.Component,
c: () => (
<>
<SettingsTitle loader={loader} />
<module.Component />
</>
),
})
})
.finally(() => {

View File

@ -0,0 +1,6 @@
import type { FC } from "react"
export const SettingSectionTitle: FC<{
title: string
}> = ({ title }) =>
<div className="mb-4 mt-6 text-sm font-medium capitalize text-theme-disabled">{title}</div>

View File

@ -0,0 +1,124 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@renderer/components/ui/select"
import { useDark } from "@renderer/hooks"
import { tipcClient } from "@renderer/lib/client"
import { getOS } from "@renderer/lib/utils"
import { uiActions, useUIStore } from "@renderer/store"
import { useQuery } from "@tanstack/react-query"
import { useCallback } from "react"
import { SettingSwitch } from "../control"
import { SettingSectionTitle } from "../section"
import { SettingsTitle } from "../title"
export const SettingAppearance = () => {
const { isDark, toggleDark } = useDark()
const saveDarkSetting = useCallback(() => {
toggleDark()
}, [])
const state = useUIStore()
return (
<div>
<SettingsTitle />
<SettingSectionTitle title="General" />
<SettingSwitch
label="Dark Mode"
checked={isDark}
onCheckedChange={saveDarkSetting}
/>
{window.electron && getOS() === "macOS" && (
<SettingSwitch
label="Opaque Sidebars"
checked={state.opaqueSidebar}
onCheckedChange={(checked) => {
uiActions.set("opaqueSidebar", checked)
}}
/>
)}
<SettingSectionTitle title="Text" />
{window.electron && <Fonts />}
<TextSize />
</div>
)
}
const Fonts = () => {
const { data } = useQuery({
queryFn: () => tipcClient?.getSystemFonts(),
queryKey: ["systemFonts"],
})
const readerFontFamily = useUIStore(
(state) => state.readerFontFamily || "SN Pro",
)
return (
<div className="-mt-1 flex items-center justify-between">
<span className="shrink-0 text-sm font-medium">Font Family</span>
<Select
defaultValue="SN Pro"
value={readerFontFamily}
onValueChange={(value) => {
uiActions.set("readerFontFamily", value)
}}
>
<SelectTrigger className="h-8 w-48">
<SelectValue />
</SelectTrigger>
<SelectContent className="h-64">
<SelectItem value="SN Pro">SN Pro</SelectItem>
{data?.map((font) => (
<SelectItem key={font} value={font}>
{font}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
const textSizeMap = {
tiny: 12,
smaller: 14,
default: 16,
medium: 18,
large: 20,
}
const TextSize = () => {
const uiTextSize = useUIStore((state) => state.uiTextSize)
return (
<div className="mt-1 flex items-center justify-between">
<span className="shrink-0 text-sm font-medium">Text Size</span>
<Select
defaultValue={textSizeMap.default.toString()}
value={uiTextSize.toString() || textSizeMap.default.toString()}
onValueChange={(value) => {
uiActions.set(
"uiTextSize",
Number.parseInt(value) || textSizeMap.default,
)
}}
>
<SelectTrigger className="h-8 w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(textSizeMap).map(([size, value]) => (
<SelectItem key={size} value={value.toString()}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View File

@ -1,16 +1,17 @@
import { settingTabs } from "@renderer/lib/constants"
import { cn } from "@renderer/lib/utils"
import { useLoaderData } from "react-router-dom"
export const SettingsTitle = ({
import { settings } from "./constants"
import type { SettingPageConfig } from "./utils"
export const SettingsSidebarTitle = ({
path,
className,
sticky,
}: {
path: string
className?: string
sticky?: boolean
}) => {
const tab = settingTabs.find((t) => t.path === path)
const tab = settings.find((t) => t.path === path)
if (!tab) {
return null
@ -19,13 +20,40 @@ export const SettingsTitle = ({
return (
<div
className={cn(
"flex items-center gap-2 text-xl font-bold",
sticky && "sticky top-0 bg-background",
"flex items-center gap-2 text-[0.94rem] font-medium",
className,
)}
>
<i className={tab.className} />
<i className={tab.iconName} />
<span>{tab.name}</span>
</div>
)
}
export const SettingsTitle = ({
className,
loader,
}: {
className?: string
loader?: () => SettingPageConfig
}) => {
const { iconName, name: title } = (useLoaderData() ||
loader?.() ||
{}) as SettingPageConfig
if (!title) {
return null
}
return (
<div
className={cn(
"flex items-center gap-2 pb-2 pt-6 text-xl font-bold",
"sticky top-0 mb-4 bg-background",
className,
)}
>
<i className={iconName} />
<span>{title}</span>
</div>
)
}

View File

@ -0,0 +1,9 @@
export interface SettingPageConfig {
iconName: string
name: string
priority: number
}
export const defineSettingPage = (config: SettingPageConfig) => () => ({
...config,
})

View File

@ -23,7 +23,7 @@ export function Component() {
initial: entryColWidth,
containerRef,
onResizeEnd({ position }) {
uiActions.setEntryColWidth(position)
uiActions.set("entryColWidth", position)
},
})

View File

@ -4,11 +4,22 @@ import { apiClient } from "@renderer/lib/api-fetch"
import type { ActionsResponse } from "@renderer/models"
import { ActionCard } from "@renderer/modules/settings/action-card"
import { SettingsTitle } from "@renderer/modules/settings/title"
import { defineSettingPage } from "@renderer/modules/settings/utils"
import { Queries } from "@renderer/queries"
import { useMutation } from "@tanstack/react-query"
import { useEffect, useState } from "react"
import { toast } from "sonner"
const iconName = "i-mgc-magic-2-cute-re"
const name = "Actions"
const priority = 1020
export const loader = defineSettingPage({
iconName,
name,
priority,
})
type Operation =
| "contains"
| "not_contains"
@ -79,7 +90,7 @@ export function Component() {
return (
<>
<SettingsTitle path="actions" sticky className="mb-4" />
<SettingsTitle />
<div className="space-y-4">
{actionsData.map((action, actionIdx) => (
<ActionCard

View File

@ -0,0 +1,16 @@
import { SettingAppearance } from "@renderer/modules/settings/tabs/apperance"
import { defineSettingPage } from "@renderer/modules/settings/utils"
const iconName = "i-mgc-palette-cute-re"
const name = "Appearance"
const priority = 1010
export const loader = defineSettingPage({
iconName,
name,
priority,
})
export function Component() {
return <SettingAppearance />
}

View File

@ -0,0 +1,46 @@
import { tipcClient } from "@renderer/lib/client"
import { SettingSwitch } from "@renderer/modules/settings/control"
import {
SettingsTitle,
} from "@renderer/modules/settings/title"
import { defineSettingPage } from "@renderer/modules/settings/utils"
import { useCallback, useEffect, useState } from "react"
const iconName = "i-mgc-settings-7-cute-re"
const name = "General"
const priority = 1000
export const loader = defineSettingPage({
iconName,
name,
priority,
})
export function Component() {
const [loginSetting, setLoginSetting] = useState(false)
useEffect(() => {
tipcClient?.getLoginItemSettings().then((settings) => {
setLoginSetting(settings.openAtLogin)
})
}, [])
const saveLoginSetting = useCallback((checked: boolean) => {
tipcClient?.setLoginItemSettings(checked)
setLoginSetting(checked)
}, [])
return (
<>
<SettingsTitle />
<div className="mt-6 space-y-6">
{window.electron && (
<SettingSwitch
label="Launch Follow at Login"
checked={loginSetting}
onCheckedChange={saveLoginSetting}
/>
)}
</div>
</>
)
}

View File

@ -1,52 +1,8 @@
import { Checkbox } from "@renderer/components/ui/checkbox"
import { Label } from "@renderer/components/ui/label"
import { useDark } from "@renderer/hooks"
import { tipcClient } from "@renderer/lib/client"
import { SettingsTitle } from "@renderer/modules/settings/title"
import { useCallback, useEffect, useState } from "react"
import { redirect } from "react-router-dom"
export function Component() {
const { isDark, toggleDark } = useDark()
export const Component = () => null
const [loginSetting, setLoginSetting] = useState(false)
useEffect(() => {
tipcClient?.getLoginItemSettings().then((settings) => {
setLoginSetting(settings.openAtLogin)
})
}, [])
const saveLoginSetting = useCallback((checked: boolean) => {
tipcClient?.setLoginItemSettings(checked)
setLoginSetting(checked)
}, [])
const saveDarkSetting = useCallback(() => {
toggleDark()
}, [])
return (
<>
<SettingsTitle path="" sticky className="mb-4" />
<div className="mt-6 space-y-6">
{window.electron && (
<div className="flex items-center gap-4">
<Checkbox
id="launch"
checked={loginSetting}
onCheckedChange={saveLoginSetting}
/>
<Label htmlFor="launch">Launch Follow at Login</Label>
</div>
)}
<div className="flex items-center gap-4">
<Checkbox
id="dark"
checked={isDark}
onCheckedChange={saveDarkSetting}
/>
<Label htmlFor="dark">Dark Mode</Label>
</div>
</div>
</>
)
export const loader = () => {
redirect("/settings/general")
return null
}

View File

@ -1,8 +1,11 @@
import { Logo } from "@renderer/components/icons/logo"
import { Vibrancy } from "@renderer/components/ui/background"
import { APP_NAME, settingTabs } from "@renderer/lib/constants"
import { APP_NAME } from "@renderer/lib/constants"
import { preventDefault } from "@renderer/lib/dom"
import { SettingsTitle } from "@renderer/modules/settings/title"
import { settings } from "@renderer/modules/settings/constants"
import {
SettingsSidebarTitle,
} from "@renderer/modules/settings/title"
import { Link, Outlet, useLocation } from "react-router-dom"
// TODO Web UI
@ -15,32 +18,27 @@ export function Component() {
<div className="flex flex-1">
<Vibrancy className="flex h-full w-44 flex-col border-r px-2.5 py-3 pt-3.5">
<div className="grow pt-8">
{settingTabs.map((t) => (
{settings.map((t) => (
<Link
key={t.path}
className={`my-1 flex items-center rounded-md px-2.5 py-0.5 leading-loose text-theme-foreground/70 transition-colors ${
tab === t.path ?
"bg-native-active text-theme-foreground/90" :
""
}`}
className={`my-1 flex items-center rounded-md px-2.5 py-0.5 leading-loose text-theme-foreground/70 transition-colors dark:text-theme-foreground/90 ${
tab === t.path ?
"bg-native-active text-theme-foreground/90" :
""
}`}
to={`/settings/${t.path}`}
>
<SettingsTitle
path={t.path}
className="text-[15px] font-medium"
/>
<SettingsSidebarTitle path={t.path} />
</Link>
))}
</div>
<div className="center my-3 flex">
<Logo className="size-6" />
<span className="ml-2 font-semibold">
{APP_NAME}
</span>
<span className="ml-2 font-semibold">{APP_NAME}</span>
</div>
</Vibrancy>
<div className="h-screen flex-1 overflow-y-auto bg-theme-background p-8">
<div className="h-screen flex-1 overflow-y-auto bg-theme-background p-8 pt-0">
<Outlet />
</div>
</div>

View File

@ -13,6 +13,7 @@ import {
import { Input } from "@renderer/components/ui/input"
import { apiClient } from "@renderer/lib/api-fetch"
import { SettingsTitle } from "@renderer/modules/settings/title"
import { defineSettingPage } from "@renderer/modules/settings/utils"
import { useMutation } from "@tanstack/react-query"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
@ -24,6 +25,15 @@ const formSchema = z.object({
avatar: z.string().url(),
})
const iconName = "i-mgc-user-setting-cute-re"
const name = "Profile"
const priority = 1030
export const loader = defineSettingPage({
iconName,
name,
priority,
})
export function Component() {
const user = useUser()
@ -54,7 +64,7 @@ export function Component() {
return (
<>
<SettingsTitle path="profile" sticky className="mb-4" />
<SettingsTitle />
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField

View File

@ -1,24 +1,40 @@
import { shortcuts } from "@renderer/lib/shortcuts"
import { cn } from "@renderer/lib/utils"
import { SettingsTitle } from "@renderer/modules/settings/title"
import { defineSettingPage } from "@renderer/modules/settings/utils"
const iconName = "i-mgc-hotkey-cute-re"
const name = "Shortcuts"
const priority = 1040
export const loader = defineSettingPage({
iconName,
name,
priority,
})
export function Component() {
return (
<>
<SettingsTitle path="shortcuts" sticky className="mb-4" />
<SettingsTitle />
<div className="space-y-6">
{Object.keys(shortcuts).map((type) => (
<section key={type}>
<div className="mb-2 text-sm capitalize">{type}</div>
<div className="rounded-md border text-[13px] text-zinc-600">
<div className="mb-2 text-sm font-medium capitalize">{type}</div>
<div className="rounded-md border text-[13px] text-zinc-600 dark:text-zinc-300">
{Object.keys(shortcuts[type]).map((action, index) => (
<div key={`${type}-${action}`} className={cn("flex items-center justify-between px-3 py-1.5", index % 2 && "bg-native/40")}>
<div
key={`${type}-${action}`}
className={cn(
"flex items-center justify-between px-3 py-1.5",
index % 2 && "bg-native/40",
)}
>
<div>{shortcuts[type][action].name}</div>
<div>
{shortcuts[type][action].key.split(
",",
).map((keys) => (
<kbd key={keys} className="kbd ml-1">{keys}</kbd>
{shortcuts[type][action].key.split(",").map((keys) => (
<kbd key={keys} className="kbd ml-1">
{keys}
</kbd>
))}
</div>
</div>

View File

@ -11,6 +11,7 @@ import { HelmetProvider } from "react-helmet-async"
import { BizRouterProvider } from "./biz-router-provider"
import { ContextMenuProvider } from "./context-menu-provider"
import { UISettingInitialize } from "./ui-setting-Initialize"
import { UserProvider } from "./user-provider"
const loadFeatures = () =>
@ -31,6 +32,7 @@ export const RootProviders: FC<PropsWithChildren> = ({ children }) => (
<TooltipProvider>
<Provider store={jotaiStore}>
<UserProvider />
<UISettingInitialize />
<ModalStackProvider />
<ContextMenuProvider />
<HelmetProvider>{children}</HelmetProvider>

View File

@ -0,0 +1,12 @@
import { useUIStore } from "@renderer/store"
import { useInsertionEffect } from "react"
export const UISettingInitialize = () => {
const state = useUIStore()
useInsertionEffect(() => {
const root = document.documentElement
root.style.fontSize = `${state.uiTextSize}px`
}, [state.uiTextSize])
return null
}

View File

@ -2,15 +2,21 @@ import { createZustandStore, getStoreActions } from "./utils/helper"
interface UIState {
entryColWidth: number
opaqueSidebar: boolean
readerFontFamily: string
uiTextSize: number
}
const createDefaultUIState = (): UIState => ({
entryColWidth: 340,
opaqueSidebar: false,
readerFontFamily: "SN Pro",
uiTextSize: 16,
})
interface UIActions {
setEntryColWidth: (width: number) => void
clear: () => void
set: <T extends keyof UIState>(key: T, value: UIState[T]) => void
}
export const useUIStore = createZustandStore<UIState & UIActions>("ui", {
version: 1,
@ -20,9 +26,8 @@ export const useUIStore = createZustandStore<UIState & UIActions>("ui", {
clear() {
set(createDefaultUIState())
},
setEntryColWidth(width) {
set({ entryColWidth: width })
set(key, value) {
set({ [key]: value })
},
}))

View File

@ -159,7 +159,7 @@
appearance: none;
border-radius: 5px;
border-width: 1px;
border-color: theme(colors.zinc.500);
border-color: theme(colors.theme.inactive);
--tw-border-opacity: 0.2;
}

14
src/shared/src/bridge.ts Normal file
View File

@ -0,0 +1,14 @@
import type { BrowserWindow } from "electron"
const PREFIX = "__follow"
interface RenderGlobalContext {
showSetting: () => void
}
export const registerGlobalContext = (context: RenderGlobalContext) => {
globalThis[PREFIX] = context
}
export const callGlobalContextMethod = (
window: BrowserWindow,
method: keyof RenderGlobalContext,
) => window.webContents.executeJavaScript(`globalThis.${PREFIX}.${method}()`)