Skip to content

Commit

Permalink
feat: add AutoResizeComponent and entry content header for metadata (#72
Browse files Browse the repository at this point in the history
)

* feat: add component

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

* feat: entry title meta

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

* fix: update anchor

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

* fix: ts error

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

---------

Signed-off-by: Innei <i@innei.in>
  • Loading branch information
Innei authored Jun 18, 2024
1 parent 302dc4c commit 7372034
Show file tree
Hide file tree
Showing 12 changed files with 409 additions and 70 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"dotenv": "16.4.5",
"electron-store": "8.2.0",
"electron-updater": "^6.1.7",
"foxact": "0.2.35",
"framer-motion": "11.2.9",
"franc-min": "6.2.0",
"fuse.js": "7.0.0",
Expand Down
28 changes: 28 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions src/renderer/src/components/common/ProviderComposer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"use client"

import type { JSX } from "react"
import { cloneElement } from "react"

export const ProviderComposer: Component<{
contexts: JSX.Element[]
}> = ({ contexts, children }) =>
contexts.reduceRight(
(kids: any, parent: any) => cloneElement(parent, { children: kids }),

Check warning on line 10 in src/renderer/src/components/common/ProviderComposer.tsx

View workflow job for this annotation

GitHub Actions / Lint and Typecheck (18.x)

Unexpected any. Specify a different type

Check warning on line 10 in src/renderer/src/components/common/ProviderComposer.tsx

View workflow job for this annotation

GitHub Actions / Lint and Typecheck (18.x)

Unexpected any. Specify a different type

Check warning on line 10 in src/renderer/src/components/common/ProviderComposer.tsx

View workflow job for this annotation

GitHub Actions / Lint and Typecheck (18.x)

Using 'cloneElement' is uncommon and can lead to fragile code. Use alternatives instead
children,
)
57 changes: 57 additions & 0 deletions src/renderer/src/components/ui/auto-resize-height.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { cn } from "@renderer/lib/utils"
import type { Spring } from "framer-motion"
import { m } from "framer-motion"
import { useEffect, useRef, useState } from "react"

const softSpringPreset: Spring = {
duration: 0.35,
type: "spring",
stiffness: 120,
damping: 20,
}

interface AnimateChangeInHeightProps {
children: React.ReactNode
className?: string
duration?: number

spring?: boolean
}

export const AutoResizeHeight: React.FC<AnimateChangeInHeightProps> = ({
children,
className,
duration = 0.6,
spring = false,
}) => {
const containerRef = useRef<HTMLDivElement | null>(null)
const [height, setHeight] = useState<number | "auto">("auto")

useEffect(() => {
if (!containerRef.current) return
const resizeObserver = new ResizeObserver((entries) => {
// We only have one entry, so we can use entries[0].
const observedHeight = entries[0].contentRect.height
setHeight(observedHeight)
})

resizeObserver.observe(containerRef.current)

return () => {
// Cleanup the observer when the component is unmounted
resizeObserver.disconnect()
}
}, [])

return (
<m.div
className={cn("overflow-hidden", className)}
style={{ height }}
initial={false}
animate={{ height }}
transition={spring ? softSpringPreset : { duration }}
>
<div ref={containerRef}>{children}</div>
</m.div>
)
}
17 changes: 16 additions & 1 deletion src/renderer/src/lib/jotai.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { PrimitiveAtom } from "jotai"
import type { Atom, PrimitiveAtom } from "jotai"
import { createStore, useAtom, useAtomValue, useSetAtom } from "jotai"
import { selectAtom } from "jotai/utils"
import { useCallback } from "react"

export const jotaiStore = createStore()

Expand All @@ -21,3 +23,16 @@ export const createAtomHooks = <T>(atom: PrimitiveAtom<T>) =>
() => useSetAtom(atom),
...createAtomAccessor(atom),
] as const

export const createAtomSelector = <T>(atom: Atom<T>) => {
const useHook = <R>(selector: (a: T) => R, deps: any[] = []) =>
useAtomValue(
selectAtom(
atom,
useCallback((a) => selector(a as T), deps),
),
)

useHook.__atom = atom
return useHook
}
14 changes: 14 additions & 0 deletions src/renderer/src/modules/entry-content/atoms.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/* eslint-disable unicorn/no-unreadable-array-destructuring */

import { createAtomHooks } from "@renderer/lib/jotai"
import { atom } from "jotai"

export const [, , useEntryTitleMeta, , getEntryTitleMeta, setEntryTitleMeta] =
createAtomHooks(
atom(
null as Nullable<{
title: string
description: string
}>,
),
)
70 changes: 70 additions & 0 deletions src/renderer/src/modules/entry-content/header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { ActionButton } from "@renderer/components/ui/button"
import { useEntryActions } from "@renderer/hooks"
import { cn } from "@renderer/lib/utils"
import { useEntry } from "@renderer/store/entry/hooks"
import { AnimatePresence, m } from "framer-motion"

import { useEntryTitleMeta } from "./atoms"

export function EntryHeader({
view,
entryId,
}: {
view: number
entryId: string
}) {
const entry = useEntry(entryId)

const { items } = useEntryActions({
view,
entry,
})
const entryTitleMeta = useEntryTitleMeta()
if (!entry?.entries.url) return null

return (
<div
className={cn(
"flex h-[55px] min-w-0 items-center justify-between gap-3 overflow-hidden px-5 text-lg text-zinc-500",
entryTitleMeta && "border-b border-border",
)}
>
<div className="flex min-w-0 shrink">
<AnimatePresence>
{entryTitleMeta && (
<m.div
initial={{ opacity: 0.01, y: 30 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0.01, y: 30 }}
className="flex min-w-0 shrink items-end gap-2 truncate text-sm leading-tight text-theme-foreground"
>
<span className="min-w-0 shrink truncate font-bold">{entryTitleMeta.title}</span>
<i className="i-mingcute-line-line size-[10px] shrink-0 translate-y-[-3px] rotate-[-25deg]" />
<span className="shrink-0 truncate text-xs opacity-80">
{entryTitleMeta.description}
</span>
</m.div>
)}
</AnimatePresence>
</div>
<div className="flex items-center gap-3">
{items
.filter((item) => !item.disabled)
.map((item) => (
<ActionButton
icon={
item.icon ? (
<img className="size-4 grayscale" src={item.icon} />
) : (
<i className={item.className} />
)
}
onClick={item.onClick}
tooltip={item.name}
key={item.name}
/>
))}
</div>
</div>
)
}
76 changes: 58 additions & 18 deletions src/renderer/src/modules/entry-content/index.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
import { Logo } from "@renderer/components/icons/logo"
import { AutoResizeHeight } from "@renderer/components/ui/auto-resize-height"
import { useBizQuery } from "@renderer/hooks"
import { parseHtml } from "@renderer/lib/parse-html"
import type { ActiveEntryId } from "@renderer/models"
import {
useIsSoFWrappedElement,
WrappedElementProvider,
} from "@renderer/providers/wrapped-element-provider"
import { Queries } from "@renderer/queries"
import { useEntry, useFeedStore } from "@renderer/store"
import { m } from "framer-motion"
import { useEffect, useState } from "react"

import { LoadingCircle } from "../../components/ui/loading"
import { EntryTranslation } from "../entry-column/translation"
import { EntryShare } from "./share"
import { setEntryTitleMeta } from "./atoms"
import { EntryHeader } from "./header"

export const EntryContent = ({ entry }: { entry: ActiveEntryId }) => {
const activeList = useFeedStore((state) => state.activeList)

if (!entry) {
return (
<m.div
className="-mt-2 flex size-full flex-col items-center justify-center gap-1 text-lg font-medium text-zinc-400"
className="-mt-2 flex size-full min-w-0 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 }}
>
Expand Down Expand Up @@ -74,8 +80,8 @@ function EntryContentRender({ entryId }: { entryId: string }) {

return (
<>
<EntryShare entryId={entry.entries.id} view={0} />
<div className="h-[calc(100%-3.5rem)] overflow-y-auto @container">
<EntryHeader entryId={entry.entries.id} view={0} />
<div className="h-[calc(100%-3.5rem)] min-w-0 overflow-y-auto @container">
<m.div
className="p-5"
initial={{ opacity: 0.01, y: 100 }}
Expand All @@ -101,23 +107,31 @@ function EntryContentRender({ entryId }: { entryId: string }) {
</div>
<div className="text-[13px] text-zinc-500">
{entry.entries.publishedAt &&
new Date(entry.entries.publishedAt).toUTCString()}
new Date(entry.entries.publishedAt).toLocaleString()}
</div>
</a>
<div className="prose prose-zinc mx-auto mb-32 mt-8 max-w-full cursor-auto select-text break-all text-[15px] dark:prose-invert">
{(summary.isLoading || summary.data) && (
<div className="my-8 space-y-1 rounded-lg border px-4 py-3">
<div className="flex items-center gap-2 font-medium text-zinc-800">
<i className="i-mingcute-bling-line align-middle" />
<span>AI summary</span>
<WrappedElementProvider boundingDetection>
<TitleMetaHandler entryId={entry.entries.id} />
<div className="prose prose-zinc mx-auto mb-32 mt-8 max-w-full cursor-auto select-text break-all text-[15px] dark:prose-invert">
{(summary.isLoading || summary.data) && (
<div className="my-8 space-y-1 rounded-lg border px-4 py-3">
<div className="flex items-center gap-2 font-medium text-zinc-800">
<i className="i-mingcute-bling-line align-middle" />
<span>AI summary</span>
</div>
<AutoResizeHeight
spring
className="text-sm leading-relaxed"
>
{summary.isLoading ?
SummaryLoadingSkeleton :
summary.data}
</AutoResizeHeight>
</div>
<div className="text-sm leading-relaxed">
{summary.isLoading ? "Loading..." : summary.data}
</div>
</div>
)}
{content}
</div>
)}
{content}
</div>
</WrappedElementProvider>
{!content && (
<div className="center mt-16">
{!error ? (
Expand All @@ -136,3 +150,29 @@ function EntryContentRender({ entryId }: { entryId: string }) {
</>
)
}

const SummaryLoadingSkeleton = (
<div className="space-y-2">
<span className="block h-3 w-full animate-pulse rounded-xl bg-zinc-200 dark:bg-neutral-800" />
<span className="block h-3 w-full animate-pulse rounded-xl bg-zinc-200 dark:bg-neutral-800" />
<span className="block h-3 w-full animate-pulse rounded-xl bg-zinc-200 dark:bg-neutral-800" />
</div>
)

const TitleMetaHandler: Component<{
entryId: string
}> = ({ entryId }) => {
const isAtTop = useIsSoFWrappedElement()
const {
entries: { title: entryTitle },
feeds: { title: feedTitle },
} = useEntry(entryId)!

useEffect(() => {
if (!isAtTop && entryTitle && feedTitle) { setEntryTitleMeta({ title: entryTitle, description: feedTitle }) }
return () => {
setEntryTitleMeta(null)
}
}, [entryId, entryTitle, feedTitle, isAtTop])
return null
}
Loading

0 comments on commit 7372034

Please sign in to comment.