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:
parent
4088d9c774
commit
c46c90b5ef
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
"tailwindcss": {},
|
||||
"tailwindcss/nesting": {},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,9 @@ export const router = {
|
|||
handlers.invalidateQuery.send(input)
|
||||
}),
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
previewImage: t.procedure
|
||||
.input<{
|
||||
realUrl: string
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 ?
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "./vibrancy"
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "./dialog"
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "./Divider"
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "./stacked"
|
||||
|
|
@ -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 })[])
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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>
|
||||
))
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export * from "./context"
|
||||
export * from "./helper"
|
||||
export * from "./modal"
|
||||
export * from "./provider"
|
||||
export * from "./types"
|
||||
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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 }),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ export type ActiveList = {
|
|||
id: string | number
|
||||
name: string
|
||||
view: number
|
||||
preventNavigate?: boolean
|
||||
} | null
|
||||
|
||||
export type FeedResponse = SubscriptionResponse[number]["feeds"]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,4 +18,8 @@
|
|||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
}
|
||||
|
||||
.center {
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue