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:
Innei 2024-06-05 16:55:23 +08:00 committed by GitHub
parent 5e666b0848
commit 66adbcc5bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 681 additions and 359 deletions

View File

@ -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",

View File

@ -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

View File

@ -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", () => {

View File

@ -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">

View File

@ -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 />
</>

View File

@ -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}

View File

@ -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
})

View File

@ -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>
)

View File

@ -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>

View File

@ -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",
)
}}

View File

@ -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>
)

View File

@ -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>
)

View File

@ -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>
)

View File

@ -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

View File

@ -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>
))

View File

@ -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",
),
},
},
},
)

View File

@ -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>
</>
)
}

View File

@ -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}

View File

@ -0,0 +1,6 @@
import { signOut } from "@hono/auth-js/react"
import { useCallback } from "react"
export const useSignOut = () => useCallback(() => {
signOut()
}, [])

View File

@ -42,3 +42,5 @@ export const views = [
className: "text-yellow-600",
},
]
export const APP_NAME = "Follow"

View File

@ -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={{

View File

@ -0,0 +1,11 @@
import { Header } from "@renderer/components/header"
import { Outlet } from "react-router-dom"
export function Component() {
return (
<>
<Header />
<Outlet />
</>
)
}

View File

@ -1,11 +1,7 @@
import { Header } from "@renderer/components/header"
import { Outlet } from "react-router-dom"
export function Component() {
return (
<>
<Header />
<Outlet />
</>
<Outlet />
)
}

View File

@ -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>

View File

@ -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>
)

View File

@ -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>
)

View File

@ -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">

View File

@ -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">

View File

@ -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>

View File

@ -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"),
},
],
},
],
},

View File

@ -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()
},
})

View File

@ -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()
},
})

View File

@ -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()
},
})

View File

@ -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
}

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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"),
],