feat: optimize feed external page ui and extract create zustand store logic (#38)
* feat: optimize reading ui Signed-off-by: Innei <i@innei.in> * refactor: zustand creattor Signed-off-by: Innei <i@innei.in> * fix: update feed columns Signed-off-by: Innei <i@innei.in> * feat: helmet and feed external page ui Signed-off-by: Innei <i@innei.in> * fix: spacing Signed-off-by: Innei <i@innei.in> * fix: spacing Signed-off-by: Innei <i@innei.in> * fix: ts Signed-off-by: Innei <i@innei.in> * fix: login page laytou Signed-off-by: Innei <i@innei.in> * chore: remove copy line Signed-off-by: Innei <i@innei.in> --------- Signed-off-by: Innei <i@innei.in>
This commit is contained in:
parent
5e666b0848
commit
66adbcc5bc
|
|
@ -65,6 +65,7 @@
|
|||
"lodash-es": "4.17.21",
|
||||
"lucide-react": "0.381.0",
|
||||
"ofetch": "1.3.4",
|
||||
"react-helmet-async": "2.0.5",
|
||||
"react-hook-form": "7.51.5",
|
||||
"react-intersection-observer": "9.10.2",
|
||||
"react-router-dom": "6.23.1",
|
||||
|
|
@ -88,6 +89,7 @@
|
|||
"@egoist/tailwindcss-icons": "1.8.0",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@iconify-json/mingcute": "1.1.17",
|
||||
"@tailwindcss/container-queries": "0.1.1",
|
||||
"@tailwindcss/typography": "0.5.13",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/node": "^20.12.13",
|
||||
|
|
|
|||
|
|
@ -134,6 +134,9 @@ importers:
|
|||
ofetch:
|
||||
specifier: 1.3.4
|
||||
version: 1.3.4
|
||||
react-helmet-async:
|
||||
specifier: 2.0.5
|
||||
version: 2.0.5(react@18.3.1)
|
||||
react-hook-form:
|
||||
specifier: 7.51.5
|
||||
version: 7.51.5(react@18.3.1)
|
||||
|
|
@ -198,6 +201,9 @@ importers:
|
|||
'@iconify-json/mingcute':
|
||||
specifier: 1.1.17
|
||||
version: 1.1.17
|
||||
'@tailwindcss/container-queries':
|
||||
specifier: 0.1.1
|
||||
version: 0.1.1(tailwindcss@3.4.3)
|
||||
'@tailwindcss/typography':
|
||||
specifier: 0.5.13
|
||||
version: 0.5.13(tailwindcss@3.4.3)
|
||||
|
|
@ -1811,6 +1817,11 @@ packages:
|
|||
resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@tailwindcss/container-queries@0.1.1':
|
||||
resolution: {integrity: sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==}
|
||||
peerDependencies:
|
||||
tailwindcss: '>=3.2.0'
|
||||
|
||||
'@tailwindcss/typography@0.5.13':
|
||||
resolution: {integrity: sha512-ADGcJ8dX21dVVHIwTRgzrcunY6YY9uSlAHHGVKvkA+vLc5qLwEszvKts40lx7z0qc4clpjclwLeK5rVCV2P/uw==}
|
||||
peerDependencies:
|
||||
|
|
@ -4374,6 +4385,14 @@ packages:
|
|||
peerDependencies:
|
||||
react: ^18.3.1
|
||||
|
||||
react-fast-compare@3.2.2:
|
||||
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
|
||||
|
||||
react-helmet-async@2.0.5:
|
||||
resolution: {integrity: sha512-rYUYHeus+i27MvFE+Jaa4WsyBKGkL6qVgbJvSBoX8mbsWoABJXdEO0bZyi0F6i+4f0NuIb8AvqPMj3iXFHkMwg==}
|
||||
peerDependencies:
|
||||
react: ^16.6.0 || ^17.0.0 || ^18.0.0
|
||||
|
||||
react-hook-form@7.51.5:
|
||||
resolution: {integrity: sha512-J2ILT5gWx1XUIJRETiA7M19iXHlG74+6O3KApzvqB/w8S5NQR7AbU8HVZrMALdmDgWpRPYiZJl0zx8Z4L2mP6Q==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
|
|
@ -4636,6 +4655,9 @@ packages:
|
|||
set-blocking@2.0.0:
|
||||
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||
|
||||
shallowequal@1.1.0:
|
||||
resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -6901,6 +6923,10 @@ snapshots:
|
|||
dependencies:
|
||||
defer-to-connect: 2.0.1
|
||||
|
||||
'@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.3)':
|
||||
dependencies:
|
||||
tailwindcss: 3.4.3
|
||||
|
||||
'@tailwindcss/typography@0.5.13(tailwindcss@3.4.3)':
|
||||
dependencies:
|
||||
lodash.castarray: 4.4.0
|
||||
|
|
@ -10085,6 +10111,15 @@ snapshots:
|
|||
react: 18.3.1
|
||||
scheduler: 0.23.2
|
||||
|
||||
react-fast-compare@3.2.2: {}
|
||||
|
||||
react-helmet-async@2.0.5(react@18.3.1):
|
||||
dependencies:
|
||||
invariant: 2.2.4
|
||||
react: 18.3.1
|
||||
react-fast-compare: 3.2.2
|
||||
shallowequal: 1.1.0
|
||||
|
||||
react-hook-form@7.51.5(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
|
@ -10380,6 +10415,8 @@ snapshots:
|
|||
|
||||
set-blocking@2.0.0: {}
|
||||
|
||||
shallowequal@1.1.0: {}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export function createWindow(options?: {
|
|||
transparent: true,
|
||||
backgroundColor: "#00000000", // transparent hexadecimal or anything with transparency,
|
||||
vibrancy: "sidebar",
|
||||
visualEffectState: "followWindow",
|
||||
visualEffectState: "active",
|
||||
})
|
||||
|
||||
window.on("ready-to-show", () => {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
(document.documentElement.dataset.theme = "dark");
|
||||
})();
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
|
|
|
|||
|
|
@ -19,10 +19,12 @@ function App() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="drag-region absolute inset-x-0 top-0 h-12 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
{window.electron && (
|
||||
<div
|
||||
className="drag-region absolute inset-x-0 top-0 h-12 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
|
||||
<Outlet />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -241,7 +241,7 @@ const ListHeader: FC = () => {
|
|||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-[18px]">
|
||||
<div>
|
||||
<div className="text-lg font-bold leading-none">{activeList?.name}</div>
|
||||
<div className="text-xs font-medium text-zinc-400">
|
||||
{entries.data?.pages?.[0].total || 0}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ export function EntryItemWrapper({
|
|||
},
|
||||
}),
|
||||
onMutate: () => {
|
||||
entryActions.markRead(entry.feeds.id, entry.entries.id, true)
|
||||
entryActions.optimisticUpdate(entry.entries.id, {
|
||||
read: true,
|
||||
})
|
||||
},
|
||||
// TODO fallback
|
||||
})
|
||||
|
|
|
|||
|
|
@ -30,36 +30,38 @@ export function EntryContent({ entryId }: { entryId: ActiveEntry }) {
|
|||
(
|
||||
<>
|
||||
<EntryShare entryId={entry.data.entries.id} view={0} />
|
||||
<m.div
|
||||
className="h-[calc(100%-3.5rem)] overflow-y-auto p-5"
|
||||
initial={{ opacity: 0.01, y: 100 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0.01, y: -100 }}
|
||||
key={entry.data?.entries.id}
|
||||
>
|
||||
<div>
|
||||
<a
|
||||
href={entry.data?.entries.url || void 0}
|
||||
target="_blank"
|
||||
className="mx-auto block max-w-[598px] rounded-md p-6 transition-colors hover:bg-theme-item-hover"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div className="select-text break-words text-3xl font-bold">
|
||||
{entry.data?.entries.title}
|
||||
<div className="h-[calc(100%-3.5rem)] overflow-y-auto @container">
|
||||
<m.div
|
||||
className="p-5"
|
||||
initial={{ opacity: 0.01, y: 100 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0.01, y: -100 }}
|
||||
key={entry.data?.entries.id}
|
||||
>
|
||||
<article className="relative m-auto min-w-0 max-w-[550px] @4xl:max-w-[70ch]">
|
||||
<a
|
||||
href={entry.data?.entries.url || void 0}
|
||||
target="_blank"
|
||||
className="-mx-6 block rounded-md p-6 transition-colors hover:bg-theme-item-hover"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div className="select-text break-words text-3xl font-bold">
|
||||
{entry.data?.entries.title}
|
||||
</div>
|
||||
<div className="mt-2 text-[13px] font-medium text-zinc-500">
|
||||
{entry.data?.feeds?.title}
|
||||
</div>
|
||||
<div className="text-[13px] text-zinc-500">
|
||||
{entry.data?.entries.publishedAt &&
|
||||
new Date(entry.data?.entries.publishedAt).toUTCString()}
|
||||
</div>
|
||||
</a>
|
||||
<div className="prose prose-zinc mx-auto mb-32 mt-10 max-w-full cursor-auto select-text break-all text-[15px] dark:prose-invert">
|
||||
{content}
|
||||
</div>
|
||||
<div className="mt-2 text-[13px] font-medium text-zinc-500">
|
||||
{entry.data?.feeds?.title}
|
||||
</div>
|
||||
<div className="text-[13px] text-zinc-500">
|
||||
{entry.data?.entries.publishedAt &&
|
||||
new Date(entry.data?.entries.publishedAt).toUTCString()}
|
||||
</div>
|
||||
</a>
|
||||
<div className="prose prose-zinc mx-auto mb-32 mt-10 max-w-[550px] cursor-auto select-text break-all text-[15px] dark:prose-invert">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</m.div>
|
||||
</article>
|
||||
</m.div>
|
||||
</div>
|
||||
</>
|
||||
) :
|
||||
(
|
||||
|
|
@ -68,7 +70,11 @@ export function EntryContent({ entryId }: { entryId: ActiveEntry }) {
|
|||
initial={{ opacity: 0.01, y: 300 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<img src="./icon.svg" alt="logo" className="size-16 opacity-40 grayscale" />
|
||||
<img
|
||||
src="./icon.svg"
|
||||
alt="logo"
|
||||
className="size-16 opacity-40 grayscale"
|
||||
/>
|
||||
{activeList?.name}
|
||||
</m.div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
TooltipTrigger,
|
||||
} from "@renderer/components/ui/tooltip"
|
||||
import { UserButton } from "@renderer/components/user-button"
|
||||
import { levels, views } from "@renderer/lib/constants"
|
||||
import { APP_NAME, levels, views } from "@renderer/lib/constants"
|
||||
import { clamp, cn } from "@renderer/lib/utils"
|
||||
import { feedActions } from "@renderer/store"
|
||||
import { useWheel } from "@use-gesture/react"
|
||||
|
|
@ -55,9 +55,14 @@ export function FeedColumn() {
|
|||
},
|
||||
)
|
||||
|
||||
const normalStyle = !window.electron || (window.electron.process.platform !== "darwin")
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full flex-col gap-3"
|
||||
className={cn(
|
||||
"flex h-full flex-col gap-3 pt-2.5",
|
||||
normalStyle && "bg-native",
|
||||
)}
|
||||
onClick={() =>
|
||||
setActiveList({
|
||||
level: levels.view,
|
||||
|
|
@ -67,7 +72,30 @@ export function FeedColumn() {
|
|||
preventNavigate: true,
|
||||
})}
|
||||
>
|
||||
<div className="ml-5 mr-3 flex items-center justify-end">
|
||||
<div
|
||||
className={cn(
|
||||
"ml-5 mr-3 flex items-center",
|
||||
|
||||
normalStyle ? "ml-4 justify-between" : "justify-end",
|
||||
)}
|
||||
>
|
||||
{normalStyle && (
|
||||
<div
|
||||
className="flex items-center gap-1 text-xl font-bold"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setActiveList({
|
||||
level: levels.view,
|
||||
id: active,
|
||||
name: views[active].name,
|
||||
view: active,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<img src="./icon.svg" alt="logo" className="size-6" />
|
||||
{APP_NAME}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to="/profile">
|
||||
<HeaderActionButton tooltip="Profile">
|
||||
|
|
@ -118,7 +146,10 @@ export function FeedColumn() {
|
|||
key={item.name}
|
||||
className="shrink-0 snap-center overflow-y-auto"
|
||||
>
|
||||
<FeedList className="flex min-h-full w-64 flex-col px-3 pb-6" view={index} />
|
||||
<FeedList
|
||||
className="flex min-h-full w-64 flex-col px-3 pb-6"
|
||||
view={index}
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
</m.div>
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ export function FeedItem({
|
|||
}}
|
||||
onDoubleClick={() => {
|
||||
window.open(
|
||||
`${import.meta.env.VITE_WEB_URL}/feed/${feed.feedId}`,
|
||||
`${import.meta.env.VITE_WEB_URL}/feed/${feed.feedId}?view=${view}`,
|
||||
"_blank",
|
||||
)
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -158,15 +158,17 @@ export function FeedList({
|
|||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-full flex-1 items-center text-zinc-500">
|
||||
<Link
|
||||
to="/discover"
|
||||
className="-mt-36 flex h-full flex-1 flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<i className="i-mingcute-add-line text-3xl " />
|
||||
Add some feeds
|
||||
</Link>
|
||||
</div>
|
||||
!hideTitle && (
|
||||
<div className="flex h-full flex-1 items-center text-zinc-500">
|
||||
<Link
|
||||
to="/discover"
|
||||
className="-mt-36 flex h-full flex-1 flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<i className="i-mingcute-add-line text-3xl " />
|
||||
Add some feeds
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
NavigationMenuList,
|
||||
navigationMenuTriggerStyle,
|
||||
} from "@renderer/components/ui/navigation-menu"
|
||||
import { APP_NAME } from "@renderer/lib/constants"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
import { UserButton } from "./user-button"
|
||||
|
|
@ -21,15 +22,16 @@ const items = [{
|
|||
}]
|
||||
|
||||
export function Header() {
|
||||
// TODO optimize ui in mobile view
|
||||
return (
|
||||
<header className="w-full border-b px-10 py-3">
|
||||
<header className="w-full border-b px-4 py-3 lg:px-10">
|
||||
<div className="mx-auto flex w-full max-w-[1400px] items-center justify-between">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="flex items-center gap-2 text-xl font-bold">
|
||||
<img src="../icon.svg" alt="logo" className="size-8" />
|
||||
Follow
|
||||
{APP_NAME}
|
||||
</div>
|
||||
<NavigationMenu>
|
||||
<NavigationMenu className="hidden lg:block">
|
||||
<NavigationMenuList>
|
||||
{items.map((item) => (
|
||||
<NavigationMenuItem key={item.link}>
|
||||
|
|
@ -43,7 +45,7 @@ export function Header() {
|
|||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
<UserButton className="h-10 bg-transparent px-10 py-0" />
|
||||
<UserButton className="h-10 bg-transparent p-0 lg:px-10" />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
export const FollowIcon: Component = (props) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 344 330"
|
||||
fill="none"
|
||||
{...props}
|
||||
>
|
||||
<g fill="currentColor" clipPath="url(#a)">
|
||||
<path d="M297.639 0H88.19C62.862 0 42.33 20.514 42.33 45.82c0 25.305 20.532 45.819 45.86 45.819H297.64c25.328 0 45.861-20.514 45.861-45.82C343.5 20.514 322.967 0 297.639 0ZM185.96 119.13H46.361C21.033 119.13.5 139.644.5 164.95c0 25.305 20.533 45.819 45.861 45.819H185.96c25.328 0 45.861-20.514 45.861-45.819 0-25.306-20.533-45.82-45.861-45.82ZM199.466 284.181c0-25.306-20.533-45.82-45.861-45.82-25.328 0-45.861 20.514-45.861 45.82 0 25.305 20.533 45.819 45.861 45.819 25.328 0 45.861-20.514 45.861-45.819Z" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path fill="currentColor" d="M.5 0h343v330H.5z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
import { buttonVariants } from "@renderer/components/ui/button"
|
||||
import { cn } from "@renderer/lib/utils"
|
||||
import * as React from "react"
|
||||
|
||||
import { buttonVariants } from "./button/variants"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
|
|
|||
|
|
@ -1,40 +1,12 @@
|
|||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cn } from "@renderer/lib/utils"
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
import type { HTMLMotionProps } from "framer-motion"
|
||||
import { m } from "framer-motion"
|
||||
import * as React from "react"
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-4",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
xl: "h-14 rounded-lg px-10",
|
||||
icon: "size-10",
|
||||
},
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-zinc-500/10 px-1.5",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip"
|
||||
import { buttonVariants, styledButtonVariant } from "./variants"
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
|
|
@ -110,3 +82,40 @@ export const HeaderActionButton = React.forwardRef<
|
|||
</Tooltip>
|
||||
),
|
||||
)
|
||||
|
||||
export const MotionButtonBase = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
HTMLMotionProps<"button">
|
||||
>(({ children, ...rest }, ref) => (
|
||||
<m.button
|
||||
initial
|
||||
whileFocus={{ scale: 1.02 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
{...rest}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</m.button>
|
||||
))
|
||||
|
||||
MotionButtonBase.displayName = "MotionButtonBase"
|
||||
|
||||
export const StyledButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
HTMLMotionProps<"button">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MotionButtonBase
|
||||
ref={ref}
|
||||
className={cn(
|
||||
styledButtonVariant({
|
||||
variant: "primary",
|
||||
className,
|
||||
}),
|
||||
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
</MotionButtonBase>
|
||||
))
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { cn } from "@renderer/lib/utils"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-4",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
xl: "h-14 rounded-lg px-10",
|
||||
icon: "size-10",
|
||||
},
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-zinc-500/10 px-1.5",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
export const styledButtonVariant = cva(
|
||||
"inline-flex select-none cursor-default items-center gap-2 justify-center rounded-lg py-2 px-3 text-sm outline-offset-2 transition active:transition-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: cn(
|
||||
"bg-theme-accent text-zinc-100",
|
||||
"hover:contrast-[1.10] active:contrast-125",
|
||||
"font-semibold",
|
||||
"disabled:cursor-not-allowed disabled:bg-theme-accent/40 disabled:opacity-80 disabled:dark:text-zinc-50",
|
||||
"dark:text-neutral-800",
|
||||
),
|
||||
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
/* eslint-disable unicorn/prefer-add-event-listener */
|
||||
"use client"
|
||||
|
||||
import clsx from "clsx"
|
||||
import { AnimatePresence, m } from "framer-motion"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
export const ListItemHoverOverlay = () => {
|
||||
const [mouseEnter, setMouseEnter] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const $ref = ref.current
|
||||
if (!$ref) return
|
||||
const $parent = $ref.parentElement
|
||||
if (!$parent) return
|
||||
$parent.onfocus = () => {
|
||||
setMouseEnter(true)
|
||||
}
|
||||
|
||||
$parent.onblur = () => {
|
||||
setMouseEnter(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
$parent.onfocus = null
|
||||
$parent.onblur = null
|
||||
}
|
||||
}, [])
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute inset-0 z-10"
|
||||
onMouseEnter={() => {
|
||||
setMouseEnter(true)
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setMouseEnter(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{mouseEnter && (
|
||||
<m.div
|
||||
layout
|
||||
initial={{
|
||||
opacity: 0.2,
|
||||
scale: 0.95,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
}}
|
||||
layoutId="list-item-hover-overlay"
|
||||
className={clsx(
|
||||
"absolute z-[-1] rounded-xl",
|
||||
"bg-theme-accent/10 dark:bg-neutral-800",
|
||||
|
||||
"inset-0",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -16,9 +16,9 @@ const TooltipContent = React.forwardRef<
|
|||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden border border-border bg-theme-tooltip-background px-2 py-1 text-theme-tooltip-foreground backdrop-blur",
|
||||
"z-50 overflow-hidden border border-border bg-theme-tooltip-background px-2 py-1 text-theme-tooltip-foreground backdrop-blur-xl",
|
||||
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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",
|
||||
"rounded-lg text-xs shadow shadow-theme-tooltip-background",
|
||||
"rounded-lg text-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
import { signOut } from "@hono/auth-js/react"
|
||||
import { useCallback } from "react"
|
||||
|
||||
export const useSignOut = () => useCallback(() => {
|
||||
signOut()
|
||||
}, [])
|
||||
|
|
@ -42,3 +42,5 @@ export const views = [
|
|||
className: "text-yellow-600",
|
||||
},
|
||||
]
|
||||
|
||||
export const APP_NAME = "Follow"
|
||||
|
|
|
|||
|
|
@ -5,13 +5,17 @@ import { SocialMediaItem } from "@renderer/components/entry-column/social-media-
|
|||
import type { UniversalItemProps } from "@renderer/components/entry-column/types"
|
||||
import { VideoItem } from "@renderer/components/entry-column/video-item"
|
||||
import { FeedIcon } from "@renderer/components/feed-icon"
|
||||
import { Button } from "@renderer/components/ui/button"
|
||||
import { views } from "@renderer/lib/constants"
|
||||
import { FollowIcon } from "@renderer/components/icons/follow"
|
||||
import { StyledButton } from "@renderer/components/ui/button"
|
||||
import { ListItemHoverOverlay } from "@renderer/components/ui/list-item-hover-overlay"
|
||||
import { APP_NAME, views } from "@renderer/lib/constants"
|
||||
import { FeedViewType } from "@renderer/lib/enum"
|
||||
import { cn } from "@renderer/lib/utils"
|
||||
import { useEntriesPreview } from "@renderer/queries/entries"
|
||||
import { useFeed } from "@renderer/queries/feed"
|
||||
import { DEEPLINK_SCHEME } from "@shared/constants"
|
||||
import type { FC } from "react"
|
||||
import { Helmet } from "react-helmet-async"
|
||||
import { useParams } from "react-router-dom"
|
||||
|
||||
export function Component() {
|
||||
|
|
@ -29,23 +33,23 @@ export function Component() {
|
|||
|
||||
let Item: FC<UniversalItemProps>
|
||||
switch (view) {
|
||||
case 0: {
|
||||
case FeedViewType.Articles: {
|
||||
Item = ArticleItem
|
||||
break
|
||||
}
|
||||
case 1: {
|
||||
case FeedViewType.SocialMedia: {
|
||||
Item = SocialMediaItem
|
||||
break
|
||||
}
|
||||
case 2: {
|
||||
case FeedViewType.Pictures: {
|
||||
Item = PictureItem
|
||||
break
|
||||
}
|
||||
case 3: {
|
||||
case FeedViewType.Videos: {
|
||||
Item = VideoItem
|
||||
break
|
||||
}
|
||||
case 5: {
|
||||
case FeedViewType.Notifications: {
|
||||
Item = NotificationItem
|
||||
break
|
||||
}
|
||||
|
|
@ -57,13 +61,27 @@ export function Component() {
|
|||
return (
|
||||
<>
|
||||
{feed.data?.feed && (
|
||||
<div className="mx-auto flex max-w-5xl flex-col items-center">
|
||||
<div className="mb-2 mt-10 flex items-center text-2xl font-bold">
|
||||
<FeedIcon feed={feed.data.feed} className="size-8 shrink-0" />
|
||||
<h1>{feed.data.feed.title}</h1>
|
||||
</div>
|
||||
<div className="mb-8 text-sm text-zinc-500">
|
||||
{feed.data.feed.description}
|
||||
<div className="mx-auto mt-12 flex max-w-5xl flex-col items-center justify-center p-4 lg:p-0">
|
||||
<Helmet>
|
||||
<title>
|
||||
{feed.data.feed.title}
|
||||
{" "}
|
||||
|
|
||||
{" "}
|
||||
{APP_NAME}
|
||||
</title>
|
||||
</Helmet>
|
||||
<FeedIcon
|
||||
feed={feed.data.feed}
|
||||
className="mask-squircle mask size-16 shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="mb-2 mt-4 flex items-center text-2xl font-bold">
|
||||
<h1>{feed.data.feed.title}</h1>
|
||||
</div>
|
||||
<div className="mb-8 text-sm text-zinc-500">
|
||||
{feed.data.feed.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4 text-sm">
|
||||
<strong>{feed.data.subscriptionCount}</strong>
|
||||
|
|
@ -72,20 +90,34 @@ export function Component() {
|
|||
{" "}
|
||||
<strong>{feed.data.readCount}</strong>
|
||||
{" "}
|
||||
reads on Follow
|
||||
reads on
|
||||
{" "}
|
||||
{APP_NAME}
|
||||
</div>
|
||||
<a className="mb-8" href={`${DEEPLINK_SCHEME}add?id=${id}`}>
|
||||
<Button>Follow on Follow</Button>
|
||||
<StyledButton>
|
||||
<FollowIcon className="size-3" />
|
||||
follow on
|
||||
{" "}
|
||||
{APP_NAME}
|
||||
</StyledButton>
|
||||
</a>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full",
|
||||
views[view].gridMode &&
|
||||
"grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4",
|
||||
"w-full pb-12",
|
||||
views[view].gridMode ?
|
||||
"grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4" :
|
||||
"flex max-w-3xl flex-col gap-6",
|
||||
)}
|
||||
>
|
||||
{entries.data?.map((entry) => (
|
||||
<a href={entry.url || void 0} target="_blank" key={entry.id}>
|
||||
<a
|
||||
className="relative cursor-pointer"
|
||||
href={entry.url || void 0}
|
||||
target="_blank"
|
||||
key={entry.id}
|
||||
>
|
||||
<ListItemHoverOverlay />
|
||||
<Item
|
||||
entryId=""
|
||||
entryPreview={{
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { Header } from "@renderer/components/header"
|
||||
import { Outlet } from "react-router-dom"
|
||||
|
||||
export function Component() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Outlet />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,11 +1,7 @@
|
|||
import { Header } from "@renderer/components/header"
|
||||
import { Outlet } from "react-router-dom"
|
||||
|
||||
export function Component() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Outlet />
|
||||
</>
|
||||
<Outlet />
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { signIn, useSession } from "@hono/auth-js/react"
|
||||
import { Button } from "@renderer/components/ui/button"
|
||||
import { APP_NAME } from "@renderer/lib/constants"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
|
||||
|
|
@ -29,7 +30,12 @@ export function Component() {
|
|||
return (
|
||||
<div className="flex h-screen w-full flex-col items-center justify-center gap-10">
|
||||
<img src="./icon.svg" alt="logo" className="size-20" />
|
||||
<h1 className="text-3xl font-bold">Log in to Follow</h1>
|
||||
<h1 className="text-3xl font-bold">
|
||||
Log in to
|
||||
{" "}
|
||||
|
||||
{APP_NAME}
|
||||
</h1>
|
||||
{redirecting ?
|
||||
(
|
||||
<div>Redirecting</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Button } from "@renderer/components/ui/button"
|
||||
import { UserButton } from "@renderer/components/user-button"
|
||||
import { APP_NAME } from "@renderer/lib/constants"
|
||||
import { apiFetch } from "@renderer/queries/api-fetch"
|
||||
import { DEEPLINK_SCHEME } from "@shared/constants"
|
||||
import { useEffect } from "react"
|
||||
|
|
@ -32,18 +33,32 @@ export function Component() {
|
|||
<img src="./icon.svg" alt="logo" className="size-20" />
|
||||
<UserButton className="bg-stone-100 px-10 py-4 text-2xl" />
|
||||
<h1 className="text-3xl font-bold">
|
||||
Successfully connected to Follow Account
|
||||
Successfully connected to
|
||||
{" "}
|
||||
{APP_NAME}
|
||||
{" "}
|
||||
Account
|
||||
</h1>
|
||||
<h2>
|
||||
You have successfully connected to Follow Account. Now is the time to
|
||||
open Follow and safely close this page.
|
||||
You have successfully connected to
|
||||
{" "}
|
||||
{APP_NAME}
|
||||
{" "}
|
||||
Account. Now is the time to
|
||||
open
|
||||
{" "}
|
||||
{APP_NAME}
|
||||
{" "}
|
||||
and safely close this page.
|
||||
</h2>
|
||||
<Button
|
||||
className="text-lg"
|
||||
size="xl"
|
||||
onClick={async () => window.open(await getCallbackUrl())}
|
||||
>
|
||||
Open Follow
|
||||
Open
|
||||
{" "}
|
||||
{APP_NAME}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ import {
|
|||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@renderer/components/ui/avatar"
|
||||
import { Button } from "@renderer/components/ui/button"
|
||||
import { useSignOut } from "@renderer/hooks/useAuth"
|
||||
import { views } from "@renderer/lib/constants"
|
||||
import { cn } from "@renderer/lib/utils"
|
||||
|
||||
export function Component() {
|
||||
const { data: session } = useSession()
|
||||
|
||||
const signOut = useSignOut()
|
||||
return (
|
||||
<div className="flex w-full gap-10 px-10 py-16">
|
||||
<div className="flex min-w-40 flex-1 flex-col gap-8 pt-20">
|
||||
|
|
@ -20,6 +22,7 @@ export function Component() {
|
|||
</Avatar>
|
||||
<div className="text-4xl font-bold">{session?.user?.name}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-[2] flex-col gap-8">
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<div className="text-2xl font-bold">Subscriptions</div>
|
||||
|
|
@ -33,15 +36,25 @@ export function Component() {
|
|||
<span>{view.name}</span>
|
||||
</div>
|
||||
<FeedList
|
||||
key={index}
|
||||
key={view.name}
|
||||
className="mb-4"
|
||||
view={index}
|
||||
hideTitle={true}
|
||||
hideTitle
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
signOut()
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { FeedColumn } from "@renderer/components/feed-column"
|
||||
import { cn } from "@renderer/lib/utils"
|
||||
import { Outlet } from "react-router-dom"
|
||||
|
||||
export function Component() {
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<div className={cn("w-64 shrink-0 border-r pt-2.5 backdrop-blur")}>
|
||||
<div className="w-64 shrink-0 border-r">
|
||||
<FeedColumn />
|
||||
</div>
|
||||
<div className="flex flex-1 bg-background">
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export function Component() {
|
|||
className="h-10 border-b flex items-center pl-20 text-sm font-medium"
|
||||
aria-hidden
|
||||
>
|
||||
Follow Settings
|
||||
{APP_NAME} Settings
|
||||
</div> */}
|
||||
<div className="flex flex-1">
|
||||
<div className="w-44 border-r p-3 pt-12 backdrop-blur">
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@ import { QueryClientProvider } from "@tanstack/react-query"
|
|||
import { LazyMotion, MotionConfig } from "framer-motion"
|
||||
import { Provider } from "jotai"
|
||||
import type { FC, PropsWithChildren } from "react"
|
||||
import { HelmetProvider } from "react-helmet-async"
|
||||
|
||||
const loadFeatures = () =>
|
||||
import("../framer-lazy-feature").then((res) => res.default)
|
||||
export const RootProviders: FC<PropsWithChildren> = (props) => (
|
||||
export const RootProviders: FC<PropsWithChildren> = ({ children }) => (
|
||||
<LazyMotion features={loadFeatures} strict key="framer">
|
||||
<MotionConfig
|
||||
transition={{
|
||||
|
|
@ -22,7 +23,9 @@ export const RootProviders: FC<PropsWithChildren> = (props) => (
|
|||
<SessionProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<Provider store={jotaiStore}>{props.children}</Provider>
|
||||
<Provider store={jotaiStore}>
|
||||
<HelmetProvider>{children}</HelmetProvider>
|
||||
</Provider>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</SessionProvider>
|
||||
|
|
|
|||
|
|
@ -25,27 +25,38 @@ export const router = createBrowserRouter([
|
|||
children: [
|
||||
{
|
||||
path: "",
|
||||
lazy: () => import("./pages/(main)/(context)/(nonhome)/layout"),
|
||||
lazy: () =>
|
||||
import("./pages/(main)/(context)/(nonhome)/layout"),
|
||||
children: [
|
||||
{
|
||||
path: "/discover",
|
||||
lazy: () => import("./pages/(main)/(context)/(nonhome)/discover/layout"),
|
||||
lazy: () =>
|
||||
import(
|
||||
"./pages/(main)/(context)/(nonhome)/discover/layout"
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
lazy: () =>
|
||||
import("./pages/(main)/(context)/(nonhome)/discover/index"),
|
||||
import(
|
||||
"./pages/(main)/(context)/(nonhome)/discover/index"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/profile",
|
||||
lazy: () => import("./pages/(main)/(context)/(nonhome)/profile/layout"),
|
||||
lazy: () =>
|
||||
import(
|
||||
"./pages/(main)/(context)/(nonhome)/profile/layout"
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
lazy: () =>
|
||||
import("./pages/(main)/(context)/(nonhome)/profile/index"),
|
||||
import(
|
||||
"./pages/(main)/(context)/(nonhome)/profile/index"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -74,12 +85,20 @@ export const router = createBrowserRouter([
|
|||
lazy: () => import("./pages/(external)/debug"),
|
||||
},
|
||||
{
|
||||
path: "/feed/:id",
|
||||
lazy: () => import("./pages/(external)/feed/layout"),
|
||||
path: "",
|
||||
lazy: () => import("./pages/(external)/(with-layout)/layout"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
lazy: () => import("./pages/(external)/feed/index"),
|
||||
path: "/feed/:id",
|
||||
lazy: () =>
|
||||
import("./pages/(external)/(with-layout)/feed/layout"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
lazy: () =>
|
||||
import("./pages/(external)/(with-layout)/feed/index"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,11 +4,9 @@ import { apiClient } from "@renderer/queries/api-fetch"
|
|||
import type { InferResponseType } from "hono/client"
|
||||
import { produce } from "immer"
|
||||
import { omit } from "lodash-es"
|
||||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
|
||||
import { unreadActions } from "./unread"
|
||||
import { zustandStorage } from "./utils/helper"
|
||||
import { createZustandStore } from "./utils/helper"
|
||||
|
||||
type FeedId = string
|
||||
type EntryId = string
|
||||
|
|
@ -41,120 +39,107 @@ interface EntryActions {
|
|||
markReadByFeedId: (feedId: string) => void
|
||||
}
|
||||
|
||||
export const useEntryStore = create(
|
||||
persist<EntryState & EntryActions>(
|
||||
(set, get) => ({
|
||||
entries: {},
|
||||
flatMapEntries: {},
|
||||
export const useEntryStore = createZustandStore<EntryState & EntryActions>(
|
||||
"entry",
|
||||
)((set, get) => ({
|
||||
entries: {},
|
||||
flatMapEntries: {},
|
||||
|
||||
fetchEntries: async ({
|
||||
level,
|
||||
id,
|
||||
view,
|
||||
fetchEntries: async ({
|
||||
level,
|
||||
id,
|
||||
view,
|
||||
read,
|
||||
|
||||
pageParam,
|
||||
}) => {
|
||||
const res = await apiClient.entries.$post({
|
||||
json: {
|
||||
publishedAfter: pageParam as string,
|
||||
read,
|
||||
...getEntriesParams({
|
||||
level,
|
||||
id,
|
||||
view,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
pageParam,
|
||||
}: {
|
||||
level?: string
|
||||
id?: number | string
|
||||
view?: number
|
||||
read?: boolean
|
||||
const data = await res.json()
|
||||
|
||||
pageParam?: string
|
||||
}) => {
|
||||
const res = await apiClient.entries.$post({
|
||||
json: {
|
||||
publishedAfter: pageParam as string,
|
||||
read,
|
||||
...getEntriesParams({
|
||||
level,
|
||||
id,
|
||||
view,
|
||||
}),
|
||||
},
|
||||
if (data.data) {
|
||||
data.data.forEach((entry: EntryModel) => {
|
||||
get().upsert(entry.feeds.id, entry)
|
||||
})
|
||||
}
|
||||
return data
|
||||
},
|
||||
|
||||
getFlattenMapEntries() {
|
||||
return get().flatMapEntries
|
||||
},
|
||||
optimisticUpdate(entryId: string, changed: Partial<EntryModel>) {
|
||||
set((state) =>
|
||||
produce(state, (draft) => {
|
||||
const entry = draft.flatMapEntries[entryId]
|
||||
if (!entry) return
|
||||
Object.assign(entry, changed)
|
||||
return draft
|
||||
}),
|
||||
)
|
||||
},
|
||||
optimisticUpdateManyByFeedId(feedId, changed) {
|
||||
set((state) =>
|
||||
produce(state, (draft) => {
|
||||
const ids = draft.entries[feedId]
|
||||
if (!ids) return
|
||||
|
||||
ids.forEach((entryId) => {
|
||||
Object.assign(draft.flatMapEntries[entryId], changed)
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (data.data) {
|
||||
data.data.forEach((entry: EntryModel) => {
|
||||
get().upsert(entry.feeds.id, entry)
|
||||
})
|
||||
return draft
|
||||
}),
|
||||
)
|
||||
},
|
||||
optimisticUpdateAll(changed) {
|
||||
set((state) =>
|
||||
produce(state, (draft) => {
|
||||
for (const entry of Object.values(draft.flatMapEntries)) {
|
||||
Object.assign(entry, changed)
|
||||
}
|
||||
return data
|
||||
},
|
||||
return draft
|
||||
}),
|
||||
)
|
||||
},
|
||||
upsert(feedId: string, entry: EntryModel) {
|
||||
set((state) =>
|
||||
produce(state, (draft) => {
|
||||
if (!draft.entries[feedId]) {
|
||||
draft.entries[feedId] = []
|
||||
}
|
||||
draft.entries[feedId].push(entry.entries.id)
|
||||
draft.flatMapEntries[entry.entries.id] = entry
|
||||
return draft
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
getFlattenMapEntries() {
|
||||
return get().flatMapEntries
|
||||
},
|
||||
optimisticUpdate(entryId: string, changed: Partial<EntryModel>) {
|
||||
set((state) =>
|
||||
produce(state, (draft) => {
|
||||
const entry = draft.flatMapEntries[entryId]
|
||||
if (!entry) return
|
||||
Object.assign(entry, changed)
|
||||
return draft
|
||||
}),
|
||||
)
|
||||
},
|
||||
optimisticUpdateManyByFeedId(feedId, changed) {
|
||||
set((state) =>
|
||||
produce(state, (draft) => {
|
||||
const ids = draft.entries[feedId]
|
||||
if (!ids) return
|
||||
|
||||
ids.forEach((entryId) => {
|
||||
Object.assign(draft.flatMapEntries[entryId], changed)
|
||||
})
|
||||
|
||||
return draft
|
||||
}),
|
||||
)
|
||||
},
|
||||
optimisticUpdateAll(changed) {
|
||||
set((state) =>
|
||||
produce(state, (draft) => {
|
||||
for (const entry of Object.values(draft.flatMapEntries)) {
|
||||
Object.assign(entry, changed)
|
||||
}
|
||||
return draft
|
||||
}),
|
||||
)
|
||||
},
|
||||
upsert(feedId: string, entry: EntryModel) {
|
||||
set((state) =>
|
||||
produce(state, (draft) => {
|
||||
if (!draft.entries[feedId]) {
|
||||
draft.entries[feedId] = []
|
||||
}
|
||||
draft.entries[feedId].push(entry.entries.id)
|
||||
draft.flatMapEntries[entry.entries.id] = entry
|
||||
return draft
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
markRead: (feedId: string, entryId: string, read: boolean) => {
|
||||
unreadActions.incrementByFeedId(feedId, read ? -1 : 1)
|
||||
entryActions.optimisticUpdate(entryId, {
|
||||
read,
|
||||
})
|
||||
},
|
||||
markReadByFeedId: (feedId: string) => {
|
||||
const state = get()
|
||||
const entries = state.entries[feedId] || []
|
||||
entries.forEach((entryId) => {
|
||||
entryActions.markRead(feedId, entryId, true)
|
||||
})
|
||||
unreadActions.updateByFeedId(feedId, 0)
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "entry",
|
||||
storage: zustandStorage,
|
||||
},
|
||||
),
|
||||
)
|
||||
markRead: (feedId: string, entryId: string, read: boolean) => {
|
||||
unreadActions.incrementByFeedId(feedId, read ? -1 : 1)
|
||||
entryActions.optimisticUpdate(entryId, {
|
||||
read,
|
||||
})
|
||||
},
|
||||
markReadByFeedId: (feedId: string) => {
|
||||
const state = get()
|
||||
const entries = state.entries[feedId] || []
|
||||
entries.forEach((entryId) => {
|
||||
entryActions.markRead(feedId, entryId, true)
|
||||
})
|
||||
unreadActions.updateByFeedId(feedId, 0)
|
||||
},
|
||||
}))
|
||||
|
||||
export const entryActions = {
|
||||
...(omit(useEntryStore.getState(), [
|
||||
|
|
@ -170,9 +155,3 @@ export const useEntriesByFeedId = (feedId: string) =>
|
|||
})
|
||||
export const useEntry = (entryId: string) =>
|
||||
useEntryStore((state) => state.flatMapEntries[entryId])
|
||||
|
||||
Object.assign(window, {
|
||||
__entry() {
|
||||
return useEntryStore.getState()
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,12 +3,10 @@ import type { SubscriptionModel } from "@renderer/models"
|
|||
import { apiClient } from "@renderer/queries/api-fetch"
|
||||
import { produce } from "immer"
|
||||
import { omit } from "lodash-es"
|
||||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
|
||||
import { entryActions } from "./entry"
|
||||
import { unreadActions } from "./unread"
|
||||
import { zustandStorage } from "./utils/helper"
|
||||
import { createZustandStore } from "./utils/helper"
|
||||
|
||||
type FeedId = string
|
||||
interface SubscriptionState {
|
||||
|
|
@ -19,58 +17,46 @@ interface SubscriptionActions {
|
|||
fetchByView: (view?: FeedViewType) => Promise<SubscriptionModel[]>
|
||||
markReadByView: (view?: FeedViewType) => void
|
||||
}
|
||||
export const useSubscriptionStore = create(
|
||||
persist<SubscriptionState & SubscriptionActions>(
|
||||
(set, get) => ({
|
||||
data: {},
|
||||
export const useSubscriptionStore = createZustandStore<
|
||||
SubscriptionState & SubscriptionActions
|
||||
>("subscription")((set, get) => ({
|
||||
data: {},
|
||||
|
||||
async fetchByView(view) {
|
||||
const res = await (
|
||||
await apiClient.subscriptions.$get({ query: { view: String(view) } })
|
||||
).json()
|
||||
async fetchByView(view) {
|
||||
const res = await (
|
||||
await apiClient.subscriptions.$get({ query: { view: String(view) } })
|
||||
).json()
|
||||
|
||||
res.data.forEach((subscription: SubscriptionModel) => {
|
||||
set((state) =>
|
||||
produce(state, (state) => {
|
||||
state.data[subscription.feeds.id] = subscription
|
||||
return state
|
||||
}),
|
||||
)
|
||||
})
|
||||
res.data.forEach((subscription: SubscriptionModel) => {
|
||||
set((state) =>
|
||||
produce(state, (state) => {
|
||||
state.data[subscription.feeds.id] = subscription
|
||||
return state
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return res.data
|
||||
},
|
||||
upsert: (feedId, subscription) => {
|
||||
set((state) =>
|
||||
produce(state, (state) => {
|
||||
state.data[feedId] = subscription
|
||||
return state
|
||||
}),
|
||||
)
|
||||
},
|
||||
markReadByView(view) {
|
||||
const state = get()
|
||||
for (const feedId in state.data) {
|
||||
if (state.data[feedId].view === view) {
|
||||
unreadActions.updateByFeedId(feedId, 0)
|
||||
entryActions.optimisticUpdateManyByFeedId(feedId, { read: true })
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "subscription",
|
||||
storage: zustandStorage,
|
||||
},
|
||||
),
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
upsert: (feedId, subscription) => {
|
||||
set((state) =>
|
||||
produce(state, (state) => {
|
||||
state.data[feedId] = subscription
|
||||
return state
|
||||
}),
|
||||
)
|
||||
},
|
||||
markReadByView(view) {
|
||||
const state = get()
|
||||
for (const feedId in state.data) {
|
||||
if (state.data[feedId].view === view) {
|
||||
unreadActions.updateByFeedId(feedId, 0)
|
||||
entryActions.optimisticUpdateManyByFeedId(feedId, { read: true })
|
||||
}
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
export const subscriptionActions = {
|
||||
...omit(useSubscriptionStore.getState(), ["data"]),
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
__subscription() {
|
||||
return useSubscriptionStore.getState()
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,10 +2,8 @@ import type { FeedViewType } from "@renderer/lib/enum"
|
|||
import { apiClient } from "@renderer/queries/api-fetch"
|
||||
import { produce } from "immer"
|
||||
import { omit } from "lodash-es"
|
||||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
|
||||
import { zustandStorage } from "./utils/helper"
|
||||
import { createZustandStore } from "./utils/helper"
|
||||
|
||||
interface UnreadState {
|
||||
data: Record<string, number>
|
||||
|
|
@ -15,59 +13,47 @@ interface UnreadActions {
|
|||
fetchUnreadByView: (view?: FeedViewType) => Promise<Record<string, number>>
|
||||
incrementByFeedId: (feedId: string, inc: number) => void
|
||||
}
|
||||
export const useUnreadStore = create(
|
||||
persist<UnreadState & UnreadActions>(
|
||||
(set) => ({
|
||||
data: {},
|
||||
export const useUnreadStore = createZustandStore<UnreadState & UnreadActions>(
|
||||
"unread",
|
||||
)((set) => ({
|
||||
data: {},
|
||||
|
||||
async fetchUnreadByView(view) {
|
||||
const unread = await (
|
||||
await apiClient.reads.$get({ query: { view: String(view) } })
|
||||
).json()
|
||||
async fetchUnreadByView(view) {
|
||||
const unread = await (
|
||||
await apiClient.reads.$get({ query: { view: String(view) } })
|
||||
).json()
|
||||
|
||||
const { data } = unread
|
||||
for (const feedId in data) {
|
||||
set((state) =>
|
||||
produce(state, (state) => {
|
||||
state.data[feedId] = data[feedId]
|
||||
return state
|
||||
}),
|
||||
)
|
||||
}
|
||||
return data
|
||||
},
|
||||
incrementByFeedId: (feedId, inc: number) => {
|
||||
set((state) =>
|
||||
produce(state, (state) => {
|
||||
const cur = state.data[feedId]
|
||||
if (cur === undefined) return state
|
||||
state.data[feedId] = (cur || 0) + inc
|
||||
return state
|
||||
}),
|
||||
)
|
||||
},
|
||||
updateByFeedId: (feedId, unread) => {
|
||||
set((state) =>
|
||||
produce(state, (state) => {
|
||||
state.data[feedId] = unread
|
||||
return state
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "unread",
|
||||
storage: zustandStorage,
|
||||
},
|
||||
),
|
||||
)
|
||||
const { data } = unread
|
||||
for (const feedId in data) {
|
||||
set((state) =>
|
||||
produce(state, (state) => {
|
||||
state.data[feedId] = data[feedId]
|
||||
return state
|
||||
}),
|
||||
)
|
||||
}
|
||||
return data
|
||||
},
|
||||
incrementByFeedId: (feedId, inc: number) => {
|
||||
set((state) =>
|
||||
produce(state, (state) => {
|
||||
const cur = state.data[feedId]
|
||||
if (cur === undefined) return state
|
||||
state.data[feedId] = (cur || 0) + inc
|
||||
return state
|
||||
}),
|
||||
)
|
||||
},
|
||||
updateByFeedId: (feedId, unread) => {
|
||||
set((state) =>
|
||||
produce(state, (state) => {
|
||||
state.data[feedId] = unread
|
||||
return state
|
||||
}),
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
export const unreadActions = {
|
||||
...omit(useUnreadStore.getState(), ["data"]),
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
__unread() {
|
||||
return useUnreadStore.getState()
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { del, get, set } from "idb-keyval"
|
||||
import type { StateCreator } from "zustand"
|
||||
import type { StateStorage } from "zustand/middleware"
|
||||
import { createJSONStorage } from "zustand/middleware"
|
||||
import { createJSONStorage, persist } from "zustand/middleware"
|
||||
import { shallow } from "zustand/shallow"
|
||||
import { createWithEqualityFn } from "zustand/traditional"
|
||||
|
||||
export const dbStorage: StateStorage = {
|
||||
getItem: async (name: string): Promise<string | null> =>
|
||||
|
|
@ -14,3 +17,29 @@ export const dbStorage: StateStorage = {
|
|||
}
|
||||
|
||||
export const zustandStorage = createJSONStorage(() => dbStorage) as any
|
||||
|
||||
export const createZustandStore =
|
||||
<
|
||||
S,
|
||||
T extends StateCreator<
|
||||
S,
|
||||
[["zustand/persist", unknown]],
|
||||
[]
|
||||
> = StateCreator<S, [["zustand/persist", unknown]], []>,
|
||||
>(
|
||||
name: string,
|
||||
) =>
|
||||
(store: T) => {
|
||||
const newStore = createWithEqualityFn(
|
||||
persist<S>(store, { name, storage: zustandStorage }),
|
||||
shallow,
|
||||
)
|
||||
|
||||
// const newStore = create(persist(store, { name, storage: zustandStorage }))
|
||||
Object.assign(window, {
|
||||
[`__${name}`]() {
|
||||
return newStore.getState()
|
||||
},
|
||||
})
|
||||
return newStore
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
:root {
|
||||
--fo-vibrancy-foreground: theme(colors.zinc.500);
|
||||
--fo-vibrancy-background: theme(colors.native.active/10);
|
||||
--fo-vibrancy-background: theme(colors.native.active);
|
||||
|
||||
--fo-item-active: theme(colors.native.active/0.4);
|
||||
--fo-item-hover: theme(colors.native.active/0.2);
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
[data-theme="dark"] {
|
||||
--fo-vibrancy-foreground: theme(colors.zinc.400);
|
||||
/* --fo-vibrancy-background: theme(colors.zinc.600/10); */
|
||||
--fo-vibrancy-background: theme(colors.native.active/10);
|
||||
--fo-vibrancy-background: theme(colors.native.active);
|
||||
|
||||
--fo-item-active: theme(colors.neutral.700/0.4);
|
||||
--fo-item-hover: theme(colors.native.active/0.2);
|
||||
|
|
|
|||
|
|
@ -10,4 +10,12 @@
|
|||
.no-drag-region {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
.mask-squircle {
|
||||
mask-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0nMjAwJyBoZWlnaHQ9JzIwMCcgeG1sbnM9J2h0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnJz48cGF0aCBkPSdNMTAwIDBDMjAgMCAwIDIwIDAgMTAwczIwIDEwMCAxMDAgMTAwIDEwMC0yMCAxMDAtMTAwUzE4MCAwIDEwMCAwWicvPjwvc3ZnPg==);
|
||||
}
|
||||
.mask {
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ module.exports = {
|
|||
collections: getIconCollections(["mingcute"]),
|
||||
}),
|
||||
require("tailwindcss-animate"),
|
||||
require("@tailwindcss/container-queries"),
|
||||
require("@tailwindcss/typography"),
|
||||
require("./src/renderer/src/styles/tailwind-extend.css"),
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in New Issue