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:
parent
d33ad59cc4
commit
aed5fc8ef0
|
|
@ -24,6 +24,7 @@ files:
|
|||
- "out"
|
||||
- "resources"
|
||||
- "!node_modules"
|
||||
- "node_modules/font-list/**/*"
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
win:
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}, [])
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
export interface SettingPageConfig {
|
||||
iconName: string
|
||||
name: string
|
||||
priority: number
|
||||
|
||||
}
|
||||
export const defineSettingPage = (config: SettingPageConfig) => () => ({
|
||||
...config,
|
||||
})
|
||||
|
|
@ -23,7 +23,7 @@ export function Component() {
|
|||
initial: entryColWidth,
|
||||
containerRef,
|
||||
onResizeEnd({ position }) {
|
||||
uiActions.setEntryColWidth(position)
|
||||
uiActions.set("entryColWidth", position)
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 })
|
||||
},
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}()`)
|
||||
Loading…
Reference in New Issue