feat: redesign image preview and optimize some ux/ui, add modal rc (#47)

* feat: optimize some ux

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

* fix: align

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

* fix: github login button

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

* fix: align

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

* feat: dialog init

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

* feat: image swiper preview

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

* feat: open in new window

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

* feat: optimize spring

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

* fix: sidebar bar tooltip bug

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

* fix: lint

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

* fix: remark image

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

* fix: unread reset after re-fetch

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

* fix: layout and vibrancy

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

* feat(add): default view

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

* fix: follow mutation

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

* feat: preview

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

* fix: update

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

---------

Signed-off-by: Innei <i@innei.in>
This commit is contained in:
Innei 2024-06-07 19:48:07 +08:00 committed by GitHub
parent 4088d9c774
commit c46c90b5ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 1113 additions and 306 deletions

View File

@ -22,6 +22,14 @@ export default defineConfig(
"unicorn/prefer-module": "off",
"@typescript-eslint/no-floating-promises": "off",
},
settings: {
tailwindcss: {
callees: ["cn"],
whitelist: [
"center",
],
},
},
},
{
files: GLOB_TS_SRC,

View File

@ -1,5 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
"tailwindcss": {},
"tailwindcss/nesting": {},
},
}

View File

@ -106,6 +106,9 @@ export const router = {
handlers.invalidateQuery.send(input)
}),
/**
* @deprecated
*/
previewImage: t.procedure
.input<{
realUrl: string

View File

@ -3,6 +3,7 @@ import { useEffect } from "react"
import { Outlet } from "react-router-dom"
import { useDark } from "./hooks/useDark"
import { RootProviders } from "./providers/root-providers"
import { handlers } from "./tipc"
function App() {
@ -25,8 +26,9 @@ function App() {
aria-hidden
/>
)}
<Outlet />
<RootProviders>
<Outlet />
</RootProviders>
</>
)
}

View File

@ -17,6 +17,8 @@ import {
import { Image } from "@renderer/components/ui/image"
import { Input } from "@renderer/components/ui/input"
import { apiClient } from "@renderer/lib/api-fetch"
import { FeedViewType } from "@renderer/lib/enum"
import { useFeedStore } from "@renderer/store"
import { DEEPLINK_SCHEME } from "@shared/constants"
import { openElectronWindow } from "@shared/electron"
import { useMutation } from "@tanstack/react-query"
@ -183,20 +185,28 @@ export function DiscoverForm({ type }: { type: string }) {
</Button>
) : (
<Button
onClick={
() =>
openElectronWindow(
`${DEEPLINK_SCHEME}add${
item.feed.id ?
`?id=${item.feed.id}` :
`?url=${item.feed.url}`
}`,
{
resizable: false,
height: 550,
},
)
}
onClick={() => {
const searchParams = new URLSearchParams()
if (item.feed.id) {
searchParams.set("id", item.feed.id)
} else {
searchParams.set("url", item.feed.url)
}
searchParams.set(
"view",
String(`${useFeedStore.getState().activeList?.view ||
FeedViewType.Articles}`),
)
openElectronWindow(
`${DEEPLINK_SCHEME}add?${
searchParams.toString()
}`,
{
resizable: false,
height: 550,
},
)
}}
>
Follow
</Button>

View File

@ -1,4 +1,4 @@
import { Button, HeaderActionButton } from "@renderer/components/ui/button"
import { ActionButton, Button } from "@renderer/components/ui/button"
import {
Popover,
PopoverClose,
@ -29,6 +29,7 @@ import { Virtuoso, VirtuosoGrid } from "react-virtuoso"
import { useEventCallback } from "usehooks-ts"
import { useShallow } from "zustand/react/shallow"
import { EmptyIcon } from "../icons/empty"
import { ArticleItem } from "./article-item"
import { AudioItem } from "./audio-item"
import { EntryItemWrapper } from "./item-wrapper"
@ -149,17 +150,21 @@ export function EntryColumn() {
return (
<div
className="relative flex h-full flex-1 flex-col"
onClick={() => setActiveEntry?.(null)}
onClick={() => setActiveEntry(null)}
>
<ListHeader />
{activeList?.view && views[activeList.view].gridMode ? (
<VirtuosoGrid
listClassName="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4 px-4"
{...virtuosoOptions}
/>
) : (
<Virtuoso {...virtuosoOptions} />
)}
{virtuosoOptions.totalCount === 0 ? (
<EmptyList />
) : activeList?.view && views[activeList.view].gridMode ?
(
<VirtuosoGrid
listClassName="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4 px-4"
{...virtuosoOptions}
/>
) :
(
<Virtuoso {...virtuosoOptions} />
)}
</div>
)
}
@ -208,7 +213,7 @@ const ListHeader: FC = () => {
<div className="mb-5 flex w-full flex-col pl-11 pr-4 pt-2.5">
<div className="flex w-full justify-end">
<div className="relative z-[1] flex items-center gap-1 text-zinc-500">
<HeaderActionButton
<ActionButton
tooltip={unreadOnly ? "Unread Only" : "All"}
onClick={() => setUnreadOnly(!unreadOnly)}
>
@ -217,12 +222,12 @@ const ListHeader: FC = () => {
) : (
<i className="i-mingcute-round-line" />
)}
</HeaderActionButton>
</ActionButton>
<Popover open={markPopoverOpen} onOpenChange={setMarkPopoverOpen}>
<PopoverTrigger>
<HeaderActionButton onClick={() => {}} tooltip="Mark All as Read">
<ActionButton onClick={() => {}} tooltip="Mark All as Read">
<i className="i-mingcute-check-circle-line" />
</HeaderActionButton>
</ActionButton>
</PopoverTrigger>
<PopoverContent className="flex w-fit flex-col items-center justify-center gap-3 text-[15px] font-medium">
<div>Mark all as read?</div>
@ -274,15 +279,20 @@ const EmptyList = (props, ref) => {
initial={{ opacity: 0.01, y: 100 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0.01, y: -100 }}
className="-mt-10 flex h-full flex-col items-center justify-center gap-2 text-zinc-400"
className="-mt-20 flex h-full flex-col items-center justify-center gap-2 text-zinc-400"
{...props}
ref={ref}
>
{unreadOnly && (
{unreadOnly ? (
<>
<i className="i-mingcute-celebrate-line text-4xl" />
<i className="i-mingcute-celebrate-line -mt-11 text-3xl" />
Zero Unread
</>
) : (
<div className="flex -translate-y-6 flex-col items-center justify-center gap-2">
<EmptyIcon className="size-[30px]" />
Zero Items
</div>
)}
</m.div>
)

View File

@ -1,28 +1,42 @@
import { FeedIcon } from "@renderer/components/feed-icon"
import { SwipeImages } from "@renderer/components/swipe-images"
import { SwipeImages } from "@renderer/components/ui/image/swipe-images"
import { ReactVirtuosoItemPlaceholder } from "@renderer/components/ui/placeholder"
import dayjs from "@renderer/lib/dayjs"
import { cn } from "@renderer/lib/utils"
import { useEntry } from "@renderer/store/entry"
import { usePreviewImages } from "../ui/image/hooks"
import type { UniversalItemProps } from "./types"
export function PictureItem({ entryId, entryPreview }: UniversalItemProps) {
const entry = useEntry(entryId) || entryPreview
const previewImage = usePreviewImages()
if (!entry) return <ReactVirtuosoItemPlaceholder />
return (
<div>
<div>
<div className="relative flex gap-2 overflow-x-auto">
<SwipeImages
images={entry.entries.images}
className="aspect-square w-full shrink-0 rounded-md"
imgClassName="object-cover"
uniqueKey={entryId}
/>
</div>
<div className="relative flex gap-2 overflow-x-auto">
<SwipeImages
images={entry.entries.images}
className="aspect-square w-full shrink-0 rounded-md"
imgClassName="object-cover"
uniqueKey={entryId}
onPreview={(images, i) => {
previewImage(images, i)
}}
/>
</div>
<a
href={entry.entries.url!}
className={entry.entries.url ? "cursor-pointer" : void 0}
target="_blank"
>
<div className="flex-1 px-2 pb-3 pt-1 text-sm">
<div className={cn("relative mb-0.5 mt-1 truncate font-medium", !!entry.collections && "pr-4")}>
<div
className={cn(
"relative mb-0.5 mt-1 truncate font-medium",
!!entry.collections && "pr-4",
)}
>
{entry.entries.title}
{!!entry.collections && (
<i className="i-mingcute-star-fill absolute right-0 top-0.5 text-orange-400" />
@ -46,7 +60,7 @@ export function PictureItem({ entryId, entryPreview }: UniversalItemProps) {
</span>
</div>
</div>
</div>
</a>
</div>
)
}

View File

@ -39,6 +39,7 @@ export function SocialMediaItem({ entryId, entryPreview }: UniversalItemProps) {
<div className="mt-1 flex gap-2 overflow-x-auto">
{entry.entries.images?.map((image) => (
<Image
popper
key={image}
src={image}
className="size-28 shrink-0"

View File

@ -66,7 +66,7 @@ export function EntryContent({ entryId }: { entryId: ActiveEntry }) {
) :
(
<m.div
className="flex size-full flex-col items-center justify-center gap-1 text-lg font-medium text-zinc-400"
className="-mt-2 flex size-full flex-col items-center justify-center gap-1 text-lg font-medium text-zinc-400"
initial={{ opacity: 0.01, y: 300 }}
animate={{ opacity: 1, y: 0 }}
>

View File

@ -1,4 +1,4 @@
import { HeaderActionButton } from "@renderer/components/ui/button"
import { ActionButton } from "@renderer/components/ui/button"
import {
TooltipProvider,
} from "@renderer/components/ui/tooltip"
@ -26,7 +26,7 @@ export function EntryShare({
{items
.filter((item) => !item.disabled)
.map((item) => (
<HeaderActionButton
<ActionButton
icon={
item.icon ?
(

View File

@ -1,9 +1,4 @@
import { Button, HeaderActionButton } from "@renderer/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@renderer/components/ui/tooltip"
import { ActionButton } from "@renderer/components/ui/button"
import { UserButton } from "@renderer/components/user-button"
import { APP_NAME, levels, views } from "@renderer/lib/constants"
import { clamp, cn } from "@renderer/lib/utils"
@ -14,6 +9,7 @@ import { Lethargy } from "lethargy"
import { useEffect, useRef, useState } from "react"
import { Link } from "react-router-dom"
import { Vibrancy } from "../ui/background"
import { FeedList } from "./list"
const lethargy = new Lethargy()
@ -55,21 +51,18 @@ export function FeedColumn() {
},
)
const normalStyle = !window.electron || (window.electron.process.platform !== "darwin")
const normalStyle =
!window.electron || window.electron.process.platform !== "darwin"
return (
<div
className={cn(
"flex h-full flex-col gap-3 pt-2.5",
normalStyle && "bg-native",
)}
<Vibrancy
className="flex h-full flex-col gap-3 pt-2.5"
onClick={() =>
setActiveList({
level: levels.view,
id: active,
name: views[active].name,
view: active,
preventNavigate: true,
})}
>
<div
@ -98,45 +91,40 @@ export function FeedColumn() {
)}
<div className="flex items-center gap-2">
<Link to="/profile">
<HeaderActionButton tooltip="Profile">
<ActionButton tooltip="Profile">
<UserButton className="h-5 p-0" hideName />
</HeaderActionButton>
</ActionButton>
</Link>
<Link to="/discover">
<HeaderActionButton tooltip="Add">
<ActionButton tooltip="Add">
<i className="i-mingcute-add-line size-5 text-theme-vibrancyFg" />
</HeaderActionButton>
</ActionButton>
</Link>
</div>
</div>
<div className="flex w-full justify-between px-3 text-xl text-theme-vibrancyFg">
{views.map((item, index) => (
<Tooltip key={item.name}>
<TooltipTrigger asChild>
<Button
className={cn(
active === index && item.className,
"flex items-center text-xl",
"hover:!bg-theme-vibrancyBg",
)}
variant="ghost"
size="sm"
onClick={(e) => {
setActive(index)
setActiveList?.({
level: "view",
id: index,
name: views[index].name,
view: index,
})
e.stopPropagation()
}}
>
{item.icon}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{item.name}</TooltipContent>
</Tooltip>
<ActionButton
key={item.name}
tooltip={item.name}
className={cn(
active === index && item.className,
"flex items-center text-xl",
"hover:!bg-theme-vibrancyBg",
)}
onClick={(e) => {
setActive(index)
setActiveList?.({
level: "view",
id: index,
name: views[index].name,
view: index,
})
e.stopPropagation()
}}
>
{item.icon}
</ActionButton>
))}
</div>
<div className="size-full overflow-hidden" ref={carouselRef}>
@ -154,6 +142,6 @@ export function FeedColumn() {
))}
</m.div>
</div>
</div>
</Vibrancy>
)
}

View File

@ -0,0 +1,34 @@
/* eslint-disable @stylistic/no-tabs */
import { cn } from "@renderer/lib/utils"
export const EmptyIcon: Component = ({ className }) => (
<svg
version="1.1"
x="0px"
y="0px"
width="59.227px"
height="59.227px"
viewBox="0 0 59.227 59.227"
className={cn("fill-current", className)}
>
<g>
<g>
<path
d="M51.586,10.029c-0.333-0.475-0.897-0.689-1.449-0.607c-0.021-0.005-0.042-0.014-0.063-0.017L27.469,6.087
c-0.247-0.037-0.499-0.01-0.734,0.076L8.63,12.799c-0.008,0.003-0.015,0.008-0.023,0.011c-0.019,0.008-0.037,0.02-0.057,0.027
c-0.099,0.044-0.191,0.096-0.276,0.157c-0.026,0.019-0.051,0.038-0.077,0.059c-0.093,0.076-0.178,0.159-0.249,0.254
c-0.004,0.006-0.01,0.009-0.014,0.015L0.289,23.78c-0.293,0.401-0.369,0.923-0.202,1.391c0.167,0.469,0.556,0.823,1.038,0.947
l6.634,1.713v16.401c0,0.659,0.431,1.242,1.062,1.435l24.29,7.422c0.008,0.004,0.017,0.001,0.025,0.005
c0.13,0.036,0.266,0.059,0.402,0.06c0.003,0,0.007,0.002,0.011,0.002l0,0h0.001c0.143,0,0.283-0.026,0.423-0.067
c0.044-0.014,0.085-0.033,0.13-0.052c0.059-0.022,0.117-0.038,0.175-0.068l17.43-9.673c0.477-0.265,0.772-0.767,0.772-1.312
V25.586l5.896-2.83c0.397-0.19,0.69-0.547,0.802-0.973c0.111-0.427,0.03-0.88-0.223-1.241L51.586,10.029z M27.41,9.111
l17.644,2.59L33.35,17.143l-18.534-3.415L27.41,9.111z M9.801,15.854l21.237,3.914l-6.242,9.364l-20.78-5.365L9.801,15.854z
M10.759,43.122V28.605l14.318,3.697c0.125,0.031,0.25,0.048,0.375,0.048c0.493,0,0.965-0.244,1.248-0.668l5.349-8.023v25.968
L10.759,43.122z M49.479,41.1l-14.431,8.007V25.414l2.635,5.599c0.171,0.361,0.479,0.641,0.854,0.773
c0.163,0.06,0.333,0.087,0.502,0.087c0.223,0,0.444-0.05,0.649-0.146l9.789-4.698L49.479,41.1L49.479,41.1z M39.755,28.368
l-4.207-8.938L49.85,12.78l5.634,8.037L39.755,28.368z"
/>
</g>
</g>
</svg>
)

View File

@ -1,103 +0,0 @@
import "swiper/css"
import "swiper/css/navigation"
import "swiper/css/scrollbar"
import { Image } from "@renderer/components/ui/image"
import { cn } from "@renderer/lib/utils"
import { Mousewheel, Navigation, Scrollbar } from "swiper/modules"
import { Swiper, SwiperSlide } from "swiper/react"
export function SwipeImages({
images,
uniqueKey,
className,
imgClassName,
}: {
images?: string[] | null
uniqueKey?: string
className?: string
imgClassName?: string
}) {
return (
<div
className={cn(
"relative flex w-full items-center overflow-hidden",
className,
)}
>
{(images?.length || 0) > 1 ?
(
<>
<Swiper
loop
navigation={{
prevEl: `#swiper-button-prev-${uniqueKey}`,
nextEl: `#swiper-button-next-${uniqueKey}`,
}}
scrollbar={{
hide: true,
}}
mousewheel={{
forceToAxis: true,
}}
modules={[Navigation, Scrollbar, Mousewheel]}
className="size-full"
>
{images?.slice(0, 5).map((image) => (
<SwiperSlide key={image}>
<Image
className={cn(
imgClassName,
)}
alt="cover"
src={image}
loading="lazy"
proxy={{
width: 600,
height: 600,
}}
disableContextMenu
/>
</SwiperSlide>
))}
</Swiper>
<div
id={`swiper-button-prev-${uniqueKey}`}
className="swiper-button left-2"
onDoubleClick={(e) => e.stopPropagation()}
>
<i className="i-mingcute-left-fill" />
</div>
<div
id={`swiper-button-next-${uniqueKey}`}
className="swiper-button right-2"
onDoubleClick={(e) => e.stopPropagation()}
>
<i className="i-mingcute-right-fill" />
</div>
</>
) :
images?.length === 1 ?
(
<Image
className="size-full object-cover sm:transition-transform sm:duration-300 sm:ease-in-out sm:group-hover:scale-105"
alt="cover"
src={images[0]}
loading="lazy"
proxy={{
width: 600,
height: 600,
}}
disableContextMenu
/>
) :
(
<div className="relative flex aspect-video w-full items-center overflow-hidden rounded-t-2xl border-b">
<div className="flex size-full items-center justify-center p-3 text-center sm:transition-transform sm:duration-500 sm:ease-in-out sm:group-hover:scale-105">
<div className="text-xl font-extrabold text-zinc-600" />
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1 @@
export * from "./vibrancy"

View File

@ -0,0 +1,25 @@
import { useDark } from "@renderer/hooks/useDark"
import { cn } from "@renderer/lib/utils"
import { useMediaQuery } from "usehooks-ts"
export const Vibrancy: Component<
React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
> = ({ className, children, ...rest }) => {
const canVibrancy =
window.electron && window.electron.process.platform === "darwin"
const systemDark = useMediaQuery("(prefers-color-scheme: dark)")
const { isDark } = useDark()
return (
<div
className={cn(
canVibrancy ? "bg-native/10" : "bg-native",
systemDark !== isDark && "bg-native",
className,
)}
{...rest}
>
{children}
</div>
)
}

View File

@ -1,4 +1,5 @@
import { Slot } from "@radix-ui/react-slot"
import { stopPropagation } from "@renderer/lib/dom"
import { cn } from "@renderer/lib/utils"
import type { VariantProps } from "class-variance-authority"
import type { HTMLMotionProps } from "framer-motion"
@ -42,24 +43,26 @@ Button.displayName = "Button"
// BIZ buttons
interface HeaderActionButtonProps {
onClick?: () => void
interface ActionButtonProps {
onClick?: React.MouseEventHandler<HTMLButtonElement>
icon?: React.ReactNode | React.FC<ComponentType>
tooltip: string
tooltipSide?: "top" | "bottom"
active?: boolean
}
export const HeaderActionButton = React.forwardRef<
export const ActionButton = React.forwardRef<
HTMLButtonElement,
ComponentType<HeaderActionButtonProps>
ComponentType<ActionButtonProps>
>(
(
{ icon, onClick, tooltip, className, tooltipSide, children, active },
ref,
) => (
<Tooltip key={tooltip}>
<Tooltip key={tooltip} disableHoverableContent>
<TooltipTrigger asChild ref={ref}>
<Button
// @see https://github.com/radix-ui/primitives/issues/2248#issuecomment-2147056904
onFocusCapture={stopPropagation}
className={cn(
"no-drag-region flex items-center text-xl",
active && "bg-zinc-500/15 hover:bg-zinc-500/20",
@ -112,7 +115,6 @@ export const StyledButton = React.forwardRef<
variant: "primary",
className,
}),
)}
{...props}
>

View File

@ -0,0 +1,32 @@
import type { Spring } from "framer-motion"
export const reboundPreset: Spring = {
type: "spring",
bounce: 10,
stiffness: 140,
damping: 8,
}
export const microDampingPreset: Spring = {
type: "spring",
damping: 24,
}
export const microReboundPreset: Spring = {
type: "spring",
stiffness: 300,
damping: 20,
}
export const softSpringPreset: Spring = {
duration: 0.35,
type: "spring",
stiffness: 120,
damping: 20,
}
export const softBouncePreset: Spring = {
type: "spring",
damping: 10,
stiffness: 100,
}

View File

@ -1,6 +1,6 @@
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cn } from "@renderer/lib/utils"
import { X } from "lucide-react"
import { m } from "framer-motion"
import * as React from "react"
const Dialog = DialogPrimitive.Root
@ -11,19 +11,25 @@ const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
))
export const DialogOverlay = ({
onClick,
zIndex,
}: {
onClick?: () => void
zIndex?: number
}) => (
<DialogPrimitive.Overlay asChild>
<m.div
onClick={onClick}
className="fixed inset-0 z-[11] bg-zinc-50/80 dark:bg-neutral-900/80"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{ zIndex }}
/>
</DialogPrimitive.Overlay>
)
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
@ -35,14 +41,14 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="size-4" />
<i className="i-mingcute-close-line size-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
@ -112,7 +118,6 @@ export {
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,

View File

@ -0,0 +1 @@
export * from "./dialog"

View File

@ -0,0 +1,51 @@
import { cn } from "@renderer/lib/utils"
import type { DetailedHTMLProps, FC, HTMLAttributes } from "react"
export const Divider: FC<
DetailedHTMLProps<HTMLAttributes<HTMLHRElement>, HTMLHRElement>
> = (props) => {
const { className, ...rest } = props
return (
<hr
className={cn(
"my-4 h-[0.5px] border-0 bg-black !bg-opacity-30 dark:bg-white",
className,
)}
{...rest}
/>
)
}
export const DividerVertical: FC<
DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>
> = (props) => {
const { className, ...rest } = props
return (
<span
className={cn(
"mx-4 inline-block h-full w-[0.5px] select-none bg-black !bg-opacity-30 text-transparent dark:bg-white",
className,
)}
{...rest}
>
w
</span>
)
}
export const BreadcrumbDivider: Component = ({ className }) => (
<svg
width="24"
height="24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
color="currentColor"
shapeRendering="geometricPrecision"
viewBox="0 0 24 24"
className={className}
>
<path d="M16.88 3.549L7.12 20.451" />
</svg>
)

View File

@ -0,0 +1 @@
export * from "./Divider"

View File

@ -3,52 +3,73 @@ import { client } from "@renderer/lib/client"
import { getProxyUrl } from "@renderer/lib/img-proxy"
import { showNativeMenu } from "@renderer/lib/native-menu"
import { cn } from "@renderer/lib/utils"
import type { ImgHTMLAttributes } from "react"
import { useState } from "react"
import type { FC, ImgHTMLAttributes } from "react"
import { memo, useState } from "react"
import { useEventCallback } from "usehooks-ts"
import { usePreviewImages } from "./image/hooks"
const failedList = new Set<string | undefined>()
export const Image = ({
className,
proxy,
disableContextMenu,
...props
}: ImgHTMLAttributes<HTMLImageElement> & {
export type ImageProps = ImgHTMLAttributes<HTMLImageElement> & {
proxy?: {
width: number
height: number
}
disableContextMenu?: boolean
popper?: boolean
}
const ImageImpl: FC<ImageProps> = ({
className,
proxy,
disableContextMenu,
popper = false,
...props
}) => {
const [hidden, setHidden] = useState(!props.src)
const { src, ...rest } = props
const [hidden, setHidden] = useState(!src)
const [imgSrc, setImgSrc] = useState(
proxy && props.src && !failedList.has(props.src) ?
proxy && src && !failedList.has(src) ?
getProxyUrl({
url: props.src,
url: src,
width: proxy.width,
height: proxy.height,
}) :
props.src,
src,
)
const errorHandle = (e) => {
if (imgSrc !== props.src) {
setImgSrc(props.src)
failedList.add(props.src)
} else {
setHidden(true)
props.onError?.(e)
}
}
const errorHandle: React.ReactEventHandler<HTMLImageElement> =
useEventCallback((e) => {
if (imgSrc !== props.src) {
setImgSrc(props.src)
failedList.add(props.src)
} else {
setHidden(true)
props.onError?.(e)
}
})
const previewImages = usePreviewImages()
const handleClick = useEventCallback(
(e: React.MouseEvent<HTMLImageElement>) => {
if (popper && src) {
e.stopPropagation()
previewImages([src], 0)
}
props.onClick?.(e)
},
)
const { toast } = useToast()
return (
<div className={cn("overflow-hidden rounded", className)}>
<img
{...props}
{...rest}
onError={errorHandle}
className={cn(hidden && "hidden", "size-full bg-stone-100 object-cover")}
className={cn(
hidden && "hidden",
"size-full bg-stone-100 object-cover",
)}
src={imgSrc}
onClick={handleClick}
{...(!disableContextMenu ?
{
onContextMenu: (e) => {
@ -61,12 +82,7 @@ export const Image = ({
label: "Open Image in New Window",
click: () => {
if (props.src && imgSrc && client) {
client.previewImage({
realUrl: props.src,
url: imgSrc,
width: (e.target as HTMLImageElement).naturalWidth,
height: (e.target as HTMLImageElement).naturalHeight,
})
window.open(props.src, "_blank")
}
},
},
@ -93,3 +109,5 @@ export const Image = ({
</div>
)
}
export const Image = memo(ImageImpl)

View File

@ -0,0 +1,24 @@
import { useCallback } from "react"
import { useModalStack } from "../modal/stacked/hooks"
import { PreviewImageContent } from "./preview-image"
export const usePreviewImages = () => {
const { present } = useModalStack()
return useCallback(
(images: string[], initialIndex = 0) => {
present({
content: () => (
<div className="relative size-full">
<PreviewImageContent initialIndex={initialIndex} images={images} />
</div>
),
title: "Image",
CustomModalComponent: ({ children }) => children,
clickOutsideToDismiss: true,
})
},
[present],
)
}

View File

@ -0,0 +1,20 @@
.swipe-wrapper {
:global {
.swiper {
@apply bg-background;
}
.swiper-button {
@apply absolute top-1/2 -translate-y-1/2 bg-white rounded-full z-[1] w-6 h-6 flex items-center justify-center;
@apply transition-opacity opacity-0;
}
.swiper-wrapper {
@apply flex items-center;
}
}
&:hover :global(.swiper-button) {
@apply opacity-80;
}
}

View File

@ -0,0 +1,94 @@
import { stopPropagation } from "@renderer/lib/dom"
import { m } from "framer-motion"
import type { FC } from "react"
import { useState } from "react"
import { Mousewheel, Scrollbar, Virtual } from "swiper/modules"
import { Swiper, SwiperSlide } from "swiper/react"
import { ActionButton } from "../button"
import {
microReboundPreset,
} from "../constants/spring"
import { useCurrentModal } from "../modal"
const Wrapper: Component<{
src: string
}> = ({ children, src }) => {
const { dismiss } = useCurrentModal()
return (
<div className="center relative size-full p-12" onClick={dismiss}>
<m.div
className="size-full"
initial={{ scale: 0.94, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.94, opacity: 0 }}
transition={microReboundPreset}
>
{children}
</m.div>
<m.div
initial={{ opacity: 0.8 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute bottom-4 right-4"
onClick={stopPropagation}
>
<ActionButton
tooltip="Open in browser"
onClick={() => {
window.open(src)
}}
>
<i className="i-mingcute-external-link-line" />
</ActionButton>
</m.div>
</div>
)
}
export const PreviewImageContent: FC<{
images: string[]
initialIndex?: number
}> = ({ images, initialIndex = 0 }) => {
const [currentSrc, setCurrentSrc] = useState(images[initialIndex])
if (images.length === 0) return null
if (images.length === 1) {
const src = images[0]
return (
<Wrapper src={src}>
<img className="size-full object-contain" alt="cover" src={src} />
</Wrapper>
)
}
return (
<Wrapper src={currentSrc}>
<Swiper
initialSlide={initialIndex}
scrollbar={{
hide: true,
}}
mousewheel={{
forceToAxis: true,
}}
virtual
onSlideChange={({ realIndex }) => {
setCurrentSrc(images[realIndex])
}}
modules={[Scrollbar, Mousewheel, Virtual]}
className="size-full"
>
{images.map((image, index) => (
<SwiperSlide key={image} virtualIndex={index}>
<img
className="size-full object-contain"
alt="cover"
src={image}
loading="lazy"
/>
</SwiperSlide>
))}
</Swiper>
</Wrapper>
)
}

View File

@ -0,0 +1,117 @@
import "swiper/css"
import "swiper/css/navigation"
import "swiper/css/scrollbar"
import { Image } from "@renderer/components/ui/image"
import { cn } from "@renderer/lib/utils"
import { uniq } from "lodash-es"
import { useInView } from "react-intersection-observer"
import { Mousewheel, Navigation, Scrollbar } from "swiper/modules"
import { Swiper, SwiperSlide } from "swiper/react"
import styles from "./index.module.css"
export function SwipeImages({
images,
uniqueKey,
className,
imgClassName,
onPreview,
}: {
images?: string[] | null
uniqueKey?: string
className?: string
imgClassName?: string
onPreview?: (images: string[], index?: number) => void
}) {
const uniqImages = uniq(images)
const { ref, inView } = useInView()
if (!images) return null
return (
<div
ref={ref}
className={cn(
"relative flex w-full items-center overflow-hidden",
styles["swipe-wrapper"],
className,
)}
>
{(uniqImages?.length || 0) > 1 ? (
<>
<Swiper
loop
autoplay={inView ? true : false}
navigation={{
prevEl: `#swiper-button-prev-${uniqueKey}`,
nextEl: `#swiper-button-next-${uniqueKey}`,
}}
scrollbar={{
hide: true,
}}
mousewheel={{
forceToAxis: true,
}}
modules={[Navigation, Scrollbar, Mousewheel]}
className="size-full"
>
{uniqImages?.slice(0, 5).map((image, i) => (
<SwiperSlide key={image}>
<Image
className={cn(imgClassName)}
alt="cover"
src={image}
loading="lazy"
proxy={{
width: 600,
height: 600,
}}
disableContextMenu
onClick={() => {
onPreview?.(images, i)
}}
/>
</SwiperSlide>
))}
</Swiper>
<div
id={`swiper-button-prev-${uniqueKey}`}
className="swiper-button left-2"
onDoubleClick={(e) => e.stopPropagation()}
>
<i className="i-mingcute-left-fill" />
</div>
<div
id={`swiper-button-next-${uniqueKey}`}
className="swiper-button right-2"
onDoubleClick={(e) => e.stopPropagation()}
>
<i className="i-mingcute-right-fill" />
</div>
</>
) : uniqImages?.length === 1 ?
(
<Image
onClick={() => onPreview?.(uniqImages)}
className="size-full object-cover sm:transition-transform sm:duration-300 sm:ease-in-out sm:group-hover:scale-105"
alt="cover"
src={uniqImages[0]}
loading="lazy"
proxy={{
width: 600,
height: 600,
}}
disableContextMenu
/>
) :
(
<div className="relative flex aspect-video w-full items-center overflow-hidden rounded-t-2xl border-b">
<div className="flex size-full items-center justify-center p-3 text-center sm:transition-transform sm:duration-500 sm:ease-in-out sm:group-hover:scale-105">
<div className="text-xl font-extrabold text-zinc-600" />
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1 @@
export * from "./stacked"

View File

@ -0,0 +1,23 @@
import { atom } from "jotai"
import type { FC, RefObject } from "react"
import { createContext, useContext } from "react"
import type { ModalProps } from "./types"
export const modalIdToPropsMap = {} as Record<string, ModalProps>
export type CurrentModalContentProps = ModalContentPropsInternal & {
ref: RefObject<HTMLElement | null>
}
export const CurrentModalContext = createContext<CurrentModalContentProps>(
null as any,
)
export const useCurrentModal = () => useContext(CurrentModalContext)
export type ModalContentComponent<T> = FC<ModalContentPropsInternal & T>
export type ModalContentPropsInternal = {
dismiss: () => void
}
export const modalStackAtom = atom([] as (ModalProps & { id: string })[])

View File

@ -0,0 +1,58 @@
import { jotaiStore } from "@renderer/lib/jotai"
import { cn } from "@renderer/lib/utils"
import { AnimatePresence } from "framer-motion"
import type { FC, ReactNode } from "react"
import { useId, useMemo } from "react"
import { modalStackAtom } from "./context"
import { ModalInternal } from "./modal"
import type { ModalProps } from "./types"
export interface DeclarativeModalProps extends Omit<ModalProps, "content"> {
open?: boolean
onOpenChange?: (open: boolean) => void
children?: ReactNode
id?: string
}
const Noop = () => null
const DeclarativeModalImpl: FC<DeclarativeModalProps> = ({
open,
onOpenChange,
children,
...rest
}) => {
const index = useMemo(() => jotaiStore.get(modalStackAtom).length, [])
const id = useId()
const item = useMemo(
() => ({
...rest,
content: Noop,
id,
}),
[id, rest],
)
return (
<AnimatePresence>
{open && (
<ModalInternal isTop onClose={onOpenChange} index={index} item={item}>
{children}
</ModalInternal>
)}
</AnimatePresence>
)
}
const FooterAction: Component = ({ children, className }) => (
<div
className={cn("mt-4 flex items-center justify-end gap-2", className)}
>
{children}
</div>
)
export const DeclarativeModal = Object.assign(DeclarativeModalImpl, {
FooterAction,
})

View File

@ -0,0 +1,9 @@
import type { Context, PropsWithChildren } from "react"
import { memo, useContext } from "react"
export const InjectContext = (context: Context<any>) => {
const ctxValue = useContext(context)
return memo(({ children }: PropsWithChildren) => (
<context.Provider value={ctxValue}>{children}</context.Provider>
))
}

View File

@ -0,0 +1,54 @@
import { jotaiStore } from "@renderer/lib/jotai"
import { useCallback, useEffect, useId, useRef } from "react"
import { useLocation } from "react-router-dom"
import { modalIdToPropsMap, modalStackAtom } from "./context"
import type { ModalProps, ModalStackOptions } from "./types"
export const useModalStack = (options?: ModalStackOptions) => {
const id = useId()
const currentCount = useRef(0)
const { wrapper } = options || {}
return {
present: useCallback(
(props: ModalProps & { id?: string }) => {
const modalId = `${id}-${++currentCount.current}`
jotaiStore.set(modalStackAtom, (p) => {
const modalProps = {
...props,
id: props.id ?? modalId,
wrapper,
}
modalIdToPropsMap[modalProps.id] = modalProps
return p.concat(modalProps)
})
return () => {
jotaiStore.set(modalStackAtom, (p) =>
p.filter((item) => item.id !== modalId))
}
},
[id],
),
...actions,
}
}
const actions = {
dismiss(id: string) {
jotaiStore.set(modalStackAtom, (p) => p.filter((item) => item.id !== id))
},
dismissTop() {
jotaiStore.set(modalStackAtom, (p) => p.slice(0, -1))
},
dismissAll() {
jotaiStore.set(modalStackAtom, [])
},
}
export const useDismissAllWhenRouterChange = () => {
const { pathname } = useLocation()
useEffect(() => {
actions.dismissAll()
}, [pathname])
}

View File

@ -0,0 +1,5 @@
export * from "./context"
export * from "./helper"
export * from "./modal"
export * from "./provider"
export * from "./types"

View File

@ -0,0 +1,223 @@
import * as Dialog from "@radix-ui/react-dialog"
import { stopPropagation } from "@renderer/lib/dom"
import { cn } from "@renderer/lib/utils"
import type { Target, Transition } from "framer-motion"
import { m, useAnimationControls } from "framer-motion"
import { useSetAtom } from "jotai"
import type { SyntheticEvent } from "react"
import {
createElement,
Fragment,
memo,
useCallback,
useEffect,
useMemo,
useRef,
} from "react"
import { useEventCallback } from "usehooks-ts"
import { microReboundPreset } from "../../constants/spring"
import { DialogOverlay } from "../../dialog"
import { Divider } from "../../divider"
import type {
CurrentModalContentProps,
ModalContentPropsInternal,
} from "./context"
import { CurrentModalContext, modalStackAtom } from "./context"
import type { ModalProps } from "./types"
const enterStyle: Target = {
scale: 1,
opacity: 1,
}
const initialStyle: Target = {
scale: 0.96,
opacity: 0,
}
const modalTransition: Transition = {
...microReboundPreset,
}
export const ModalInternal: Component<{
item: ModalProps & { id: string }
index: number
isTop: boolean
onClose?: (open: boolean) => void
}> = memo(function Modal({
item,
index,
onClose: onPropsClose,
children,
isTop,
}) {
const setStack = useSetAtom(modalStackAtom)
const close = useEventCallback(() => {
setStack((p) => p.filter((modal) => modal.id !== item.id))
onPropsClose?.(false)
})
const onClose = useCallback(
(open: boolean): void => {
if (!open) {
close()
}
},
[close],
)
const {
CustomModalComponent,
modalClassName,
content,
title,
clickOutsideToDismiss,
modalContainerClassName,
wrapper: Wrapper = Fragment,
max,
} = item
const modalStyle = useMemo(() => ({ zIndex: 99 + index }), [index])
const dismiss = useCallback(
(e: SyntheticEvent) => {
e.stopPropagation()
close()
},
[close],
)
const animateController = useAnimationControls()
useEffect(() => {
animateController.start(enterStyle)
}, [animateController])
const noticeModal = useCallback(() => {
animateController
.start({
scale: 1.05,
transition: {
duration: 0.06,
},
})
.then(() => {
animateController.start({
scale: 1,
})
})
}, [animateController])
useEffect(() => {
if (isTop) return
animateController.start({
scale: 0.96,
y: 10,
})
return () => {
try {
animateController.stop()
animateController.start({
scale: 1,
y: 0,
})
} catch {
/* empty */
}
}
}, [isTop])
const modalContentRef = useRef<HTMLDivElement>(null)
const ModalProps: ModalContentPropsInternal = useMemo(
() => ({
dismiss: close,
}),
[close],
)
const ModalContextProps = useMemo<CurrentModalContentProps>(
() => ({
...ModalProps,
ref: modalContentRef,
}),
[ModalProps],
)
const finalChildren = (
<CurrentModalContext.Provider value={ModalContextProps}>
{children ?? createElement(content, ModalProps)}
</CurrentModalContext.Provider>
)
if (CustomModalComponent) {
return (
<Wrapper>
<Dialog.Root open onOpenChange={onClose}>
<Dialog.Portal>
<DialogOverlay zIndex={20} />
<Dialog.Content asChild>
<div
className={cn(
"fixed inset-0 z-20 overflow-auto",
modalContainerClassName,
)}
onClick={clickOutsideToDismiss ? dismiss : undefined}
>
<div className="contents" onClick={stopPropagation}>
<CustomModalComponent>{finalChildren}</CustomModalComponent>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</Wrapper>
)
}
return (
<Wrapper>
<Dialog.Root open onOpenChange={onClose}>
<Dialog.Portal>
<DialogOverlay zIndex={20} />
<Dialog.Content asChild>
<div
className={cn(
"center fixed inset-0 z-20 flex",
modalContainerClassName,
)}
onClick={clickOutsideToDismiss ? dismiss : noticeModal}
>
<m.div
style={modalStyle}
exit={initialStyle}
initial={initialStyle}
animate={animateController}
transition={modalTransition}
className={cn(
"relative flex flex-col overflow-hidden rounded-lg",
"bg-zinc-50/80 dark:bg-neutral-900/80",
"p-2 shadow-2xl shadow-stone-300 backdrop-blur-sm dark:shadow-stone-800",
max ?
"h-[90vh] w-[90vw]" :
"max-h-[70vh] min-w-[300px] max-w-[90vw] lg:max-h-[calc(100vh-20rem)] lg:max-w-[70vw]",
"border border-slate-200 dark:border-neutral-800",
modalClassName,
)}
onClick={stopPropagation}
>
<div className="relative flex items-center">
<Dialog.Title className="shrink-0 grow items-center px-4 py-1 text-lg font-medium">
{title}
</Dialog.Title>
<Dialog.DialogClose className="p-2" onClick={close}>
<i className="i-mingcute-close-line" />
</Dialog.DialogClose>
</div>
<Divider className="my-2 shrink-0 border-slate-200 opacity-80 dark:border-neutral-800" />
<div className="min-h-0 shrink grow overflow-auto px-4 py-2">
{finalChildren}
</div>
</m.div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</Wrapper>
)
})

View File

@ -0,0 +1,33 @@
import { AnimatePresence } from "framer-motion"
import { useAtomValue } from "jotai"
import type { FC, PropsWithChildren } from "react"
import { modalStackAtom } from "./context"
import { useDismissAllWhenRouterChange } from "./hooks"
import { ModalInternal } from "./modal"
export const ModalStackProvider: FC<PropsWithChildren> = ({ children }) => (
<>
{children}
<ModalStack />
</>
)
const ModalStack = () => {
const stack = useAtomValue(modalStackAtom)
useDismissAllWhenRouterChange()
return (
<AnimatePresence mode="popLayout">
{stack.map((item, index) => (
<ModalInternal
key={item.id}
item={item}
index={index}
isTop={index === stack.length - 1}
/>
))}
</AnimatePresence>
)
}

View File

@ -0,0 +1,19 @@
import type { FC, PropsWithChildren, ReactNode } from "react"
import type { ModalContentPropsInternal } from "./context"
export interface ModalProps {
title: ReactNode
CustomModalComponent?: FC<PropsWithChildren>
content: FC<ModalContentPropsInternal>
clickOutsideToDismiss?: boolean
modalClassName?: string
modalContainerClassName?: string
max?: boolean
wrapper?: FC
}
export interface ModalStackOptions {
wrapper?: FC
}

View File

@ -0,0 +1,6 @@
import type { ReactEventHandler } from "react"
export const stopPropagation: ReactEventHandler<any> = (e) =>
e.stopPropagation()
export const preventDefault: ReactEventHandler<any> = (e) => e.preventDefault()

View File

@ -1,5 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Image } from "@renderer/components/ui/image"
import { toJsxRuntime } from "hast-util-to-jsx-runtime"
import { createElement } from "react"
import { Fragment, jsx, jsxs } from "react/jsx-runtime"
import rehypeInferDescriptionMeta from "rehype-infer-description-meta"
import rehypeParse from "rehype-parse"
@ -45,13 +47,11 @@ export const parseHtml = async (content: string) => {
content: toJsxRuntime(hastTree, {
Fragment,
ignoreInvalidStyle: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
jsx: (type, props, key) => jsx(type as any, props, key),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
jsxs: (type, props, key) => jsxs(type as any, props, key),
passNode: true,
components: {
img: Image,
img: (props) => createElement(Image, { ...props, popper: true }),
},
}),
}

View File

@ -6,7 +6,6 @@ import * as React from "react"
import ReactDOM from "react-dom/client"
import { RouterProvider } from "react-router-dom"
import { RootProviders } from "./providers/root-providers"
import { router } from "./router"
authConfigManager.setConfig({
@ -17,9 +16,7 @@ authConfigManager.setConfig({
ReactDOM.createRoot(document.querySelector("#root") as HTMLElement).render(
<React.StrictMode>
<RootProviders>
<RouterProvider router={router} />
<ClickToComponent />
</RootProviders>
<RouterProvider router={router} />
<ClickToComponent />
</React.StrictMode>,
)

View File

@ -10,7 +10,6 @@ export type ActiveList = {
id: string | number
name: string
view: number
preventNavigate?: boolean
} | null
export type FeedResponse = SubscriptionResponse[number]["feeds"]

View File

@ -50,7 +50,7 @@ export function Component() {
) : (
<div className="flex flex-col gap-3">
<Button
className="bg-black text-lg text-white"
className="!bg-black text-lg text-white"
size="xl"
onClick={() => {
loginHandler("github")

View File

@ -1,12 +1,17 @@
import { Link, Outlet } from "react-router-dom"
import { MotionButtonBase } from "@renderer/components/ui/button"
import { Outlet, useNavigate } from "react-router-dom"
export function Component() {
const navigate = useNavigate()
return (
<div className="relative flex size-full">
<Link to="/" className="absolute left-10 top-11 flex items-center gap-1">
<i className="i-mingcute-back-line" />
<MotionButtonBase
onClick={() => navigate("/")}
className="absolute left-10 top-11 flex items-center gap-1"
>
<i className="i-mingcute-arrow-left-circle-line" />
Back
</Link>
</MotionButtonBase>
<Outlet />
</div>
)

View File

@ -3,19 +3,20 @@ import { feedActions, useFeedStore } from "@renderer/store"
import { useEffect } from "react"
import { Outlet, useNavigate } from "react-router-dom"
export function Component() {
function MainLayout() {
const { status } = useSession()
const navigate = useNavigate()
const preventNavigate = useFeedStore(
(state) => state.activeList?.preventNavigate,
const changed = useFeedStore(
(state) => `${state.activeList?.view}-${state.activeList?.id}`,
)
useEffect(() => {
feedActions.setActiveEntry(null)
if (!preventNavigate) {
if (changed) {
navigate("/")
}
}, [navigate, preventNavigate])
}, [changed])
if (status !== "authenticated") {
navigate("/login")
@ -24,3 +25,5 @@ export function Component() {
return <Outlet />
}
export { MainLayout as Component }

View File

@ -25,6 +25,7 @@ import { useBizQuery } from "@renderer/hooks/useBizQuery"
import { apiClient } from "@renderer/lib/api-fetch"
import { client } from "@renderer/lib/client"
import { views } from "@renderer/lib/constants"
import { FeedViewType } from "@renderer/lib/enum"
import { cn } from "@renderer/lib/utils"
import { Queries } from "@renderer/queries"
import { useFeed } from "@renderer/queries/feed"
@ -44,6 +45,7 @@ export function Component() {
const paramUrl = urlSearchParams.get("url")
const url = paramUrl ? decodeURIComponent(paramUrl) : undefined
const id = urlSearchParams.get("id") || undefined
const defaultView = urlSearchParams.get("view") || FeedViewType.Articles
const feed = useFeed({
url,
@ -54,7 +56,7 @@ export function Component() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
view: "0",
view: defaultView.toString(),
},
})
@ -77,7 +79,8 @@ export function Component() {
...(isSubscribed && { feedId: feed.data?.feed.id }),
}
const $method = isSubscribed ? apiClient.subscriptions.$patch : apiClient.subscriptions.$post
$method({
return $method({
// @ts-expect-error
json: body,
})

View File

@ -1,4 +1,5 @@
import { SettingsTitle } from "@renderer/components/settings/title"
import { Vibrancy } from "@renderer/components/ui/background"
import { settingTabs } from "@renderer/lib/constants"
import { Link, Outlet, useLocation } from "react-router-dom"
@ -9,7 +10,7 @@ export function Component() {
return (
<div className="flex h-screen flex-col">
<div className="flex flex-1">
<div className="w-44 border-r px-2.5 py-3 pt-3.5 backdrop-blur">
<Vibrancy className="w-44 border-r px-2.5 py-3 pt-3.5">
<div className="mb-3 flex justify-end">
<img src="./icon.svg" alt="logo" className="size-6" />
</div>
@ -24,7 +25,7 @@ export function Component() {
<SettingsTitle path={t.path} className="text-[15px] font-medium" />
</Link>
))}
</div>
</Vibrancy>
<div className="flex-1 bg-background p-8">
<Outlet />
</div>

View File

@ -1,4 +1,5 @@
import { SessionProvider } from "@hono/auth-js/react"
import { ModalStackProvider } from "@renderer/components/ui/modal"
import { Toaster } from "@renderer/components/ui/toaster"
import { TooltipProvider } from "@renderer/components/ui/tooltip"
import { jotaiStore } from "@renderer/lib/jotai"
@ -24,6 +25,7 @@ export const RootProviders: FC<PropsWithChildren> = ({ children }) => (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<Provider store={jotaiStore}>
<ModalStackProvider />
<HelmetProvider>{children}</HelmetProvider>
</Provider>
</TooltipProvider>

View File

@ -1,4 +1,4 @@
import { createBrowserRouter } from "react-router-dom"
import { createBrowserRouter, Outlet } from "react-router-dom"
import App from "./App"
@ -19,9 +19,10 @@ export const router = createBrowserRouter([
path: "",
lazy: () => import("./pages/(main)/(context)/index"),
},
{
path: "",
lazy: () => import("./pages/(main)/layout"),
Component: Outlet,
children: [
{
path: "",
@ -60,6 +61,7 @@ export const router = createBrowserRouter([
},
],
},
],
},
],
@ -126,15 +128,7 @@ export const router = createBrowserRouter([
},
],
},
{
path: "add",
children: [
{
path: "",
lazy: () => import("./pages/add/index"),
},
],
},
{
path: "preview",
children: [
@ -144,6 +138,15 @@ export const router = createBrowserRouter([
},
],
},
{
path: "add",
children: [
{
path: "",
lazy: () => import("./pages/add/index"),
},
],
},
],
},
])

View File

@ -16,17 +16,21 @@ interface SubscriptionActions {
upsert: (feedId: FeedId, subscription: SubscriptionModel) => void
fetchByView: (view?: FeedViewType) => Promise<SubscriptionModel[]>
markReadByView: (view?: FeedViewType) => void
internal_reset: () => void
}
export const useSubscriptionStore = createZustandStore<
SubscriptionState & SubscriptionActions
>("subscription")((set, get) => ({
data: {},
internal_reset() {
set({ data: {} })
},
async fetchByView(view) {
const res = await apiClient.subscriptions.$get({
query: { view: String(view) },
})
get().internal_reset()
res.data.forEach((subscription) => {
set((state) =>
produce(state, (state) => {

View File

@ -12,26 +12,34 @@ interface UnreadActions {
updateByFeedId: (feedId: string, unread: number) => void
fetchUnreadByView: (view?: FeedViewType) => Promise<Record<string, number>>
incrementByFeedId: (feedId: string, inc: number) => void
internal_reset: () => void
}
export const useUnreadStore = createZustandStore<UnreadState & UnreadActions>(
"unread",
)((set) => ({
)((set, get) => ({
data: {},
internal_reset() {
set({ data: {} })
},
async fetchUnreadByView(view) {
const unread = (
await apiClient.reads.$get({ query: { view: String(view) } })
)
const unread = await apiClient.reads.$get({
query: { view: String(view) },
})
const { data } = unread
for (const feedId in data) {
set((state) =>
produce(state, (state) => {
get().internal_reset()
set((state) =>
produce(state, (state) => {
for (const feedId in data) {
state.data[feedId] = data[feedId]
return state
}),
)
}
}
return state
}),
)
return data
},
incrementByFeedId: (feedId, inc: number) => {

View File

@ -20,15 +20,3 @@ a {
[data-theme="dark"] {
color-scheme: dark;
}
.swiper {
@apply bg-zinc-50;
}
.swiper-button {
@apply absolute top-1/2 -translate-y-1/2 bg-white rounded-full z-[1] w-6 h-6 flex items-center justify-center opacity-80;
}
.swiper-wrapper {
@apply flex items-center;
}

View File

@ -18,4 +18,8 @@
mask-repeat: no-repeat;
mask-position: center;
}
.center {
@apply flex items-center justify-center;
}
}