Skip to content

Commit

Permalink
🐛 Fix cursor-based media queries (#487)
Browse files Browse the repository at this point in the history
* rewrite horizontal scroller

* fix library stat query

* print startup text

* fix a few warning lints

* make list smarter wrt start/end disabled state
  • Loading branch information
aaronleopold authored Oct 21, 2024
1 parent 95e7da2 commit 87471d7
Show file tree
Hide file tree
Showing 34 changed files with 446 additions and 333 deletions.
2 changes: 1 addition & 1 deletion apps/server/src/http_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ pub async fn run_http_server(config: StumpConfig) -> ServerResult<()> {
let app_state = server_ctx.arced();
let cors_layer = cors::get_cors_layer(config.clone());

tracing::info!("{}", core.get_shadow_text());
println!("{}", core.get_shadow_text());

let app = Router::new()
.merge(routers::mount(app_state.clone()))
Expand Down
9 changes: 6 additions & 3 deletions apps/server/src/routers/api/v1/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1728,18 +1728,21 @@ async fn get_library_stats(
media m
LEFT JOIN finished_reading_sessions frs ON frs.media_id = m.id
LEFT JOIN reading_sessions rs ON rs.media_id = m.id
WHERE {} IS TRUE OR (rs.user_id = {} OR frs.user_id = {})
WHERE {} IS TRUE OR (rs.user_id = {} OR frs.user_id = {}) AND m.series_id IN (
SELECT id FROM series WHERE library_id = {}
)
)
SELECT
*
FROM
base_counts
INNER JOIN progress_counts;
",
PrismaValue::String(id),
PrismaValue::String(id.clone()),
PrismaValue::Boolean(params.all_users),
PrismaValue::String(user.id.clone()),
PrismaValue::String(user.id.clone())
PrismaValue::String(user.id.clone()),
PrismaValue::String(id)
))
.exec()
.await?
Expand Down
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export default [
semi: false,
},
],
...pluginReactHooks.configs.recommended.rules,
},
},
{ languageOptions: { globals: globals.node } },
Expand Down
2 changes: 1 addition & 1 deletion packages/browser/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ function RouterContainer(props: StumpClientProps) {
}

setMounted(true)
}, [baseUrl])
}, [baseUrl, props.baseUrl, setBaseUrl])

useEffect(() => {
setPlatform(props.platform)
Expand Down
267 changes: 127 additions & 140 deletions packages/browser/src/components/HorizontalCardList.tsx
Original file line number Diff line number Diff line change
@@ -1,182 +1,169 @@
import { cx, Heading, IconButton, Text, ToolTip } from '@stump/components'
import { defaultRangeExtractor, Range, useVirtualizer } from '@tanstack/react-virtual'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useCallback, useEffect, useRef } from 'react'
import { useMediaMatch } from 'rooks'
import { Button, cn, Heading, Text, ToolTip } from '@stump/components'
import { ChevronLeft, ChevronRight, CircleSlash2 } from 'lucide-react'
import { forwardRef, useCallback, useMemo, useRef, useState } from 'react'
import { ScrollerProps, Virtuoso, VirtuosoHandle } from 'react-virtuoso'
import { useInViewRef, useMediaMatch } from 'rooks'

import { usePreferences } from '../hooks'

type Props = {
cards: JSX.Element[]
title?: string
hasMore?: boolean
fetchNext?: () => void
emptyMessage?: string | (() => React.ReactNode)
title: string
items: JSX.Element[]
onFetchMore: () => void
emptyState?: React.ReactNode
}
export default function HorizontalCardList({
cards,
title,
fetchNext,
emptyMessage = 'No items to display',
}: Props) {
const parentRef = useRef<HTMLDivElement>(null)
const visibleRef = useRef([0, 0])

export default function HorizontalCardList_({ title, items, onFetchMore, emptyState }: Props) {
const virtuosoRef = useRef<VirtuosoHandle>(null)

const isAtLeastSmall = useMediaMatch('(min-width: 640px)')
const isAtLeastMedium = useMediaMatch('(min-width: 768px)')

// NOTE: "When this function's memoization changes, the entire list is recalculated"
// Sure doesn't seem like it! >:( I had to create an effect that calls measure() when the
// memoization changes. This feels like a bug TBH.
const estimateSize = useCallback(() => {
if (!isAtLeastSmall) {
return 170
} else if (!isAtLeastMedium) {
return 185
} else {
return 205
}
}, [isAtLeastSmall, isAtLeastMedium])
const height = useMemo(
() => (!isAtLeastSmall ? 325 : !isAtLeastMedium ? 350 : 385),
[isAtLeastSmall, isAtLeastMedium],
)

const columnVirtualizer = useVirtualizer({
count: cards.length,
estimateSize,
getScrollElement: () => parentRef.current,
horizontal: true,
overscan: 15,
rangeExtractor: useCallback((range: Range) => {
visibleRef.current = [range.startIndex, range.endIndex]
return defaultRangeExtractor(range)
}, []),
const [firstCardRef, firstCardIsInView] = useInViewRef({ threshold: 0.5 })
const [lastCardRef, lastCardIsInView] = useInViewRef({ threshold: 0.5 })
const [visibleRange, setVisibleRange] = useState({
endIndex: 0,
startIndex: 0,
})

const virtualItems = columnVirtualizer.getVirtualItems()
const isEmpty = virtualItems.length === 0

const [lowerBound, upperBound] = visibleRef.current

const canSkipBackward = (lowerBound ?? 0) > 0
const canSkipForward = !!cards.length && (upperBound || 0) + 1 < cards.length

useEffect(() => {
columnVirtualizer.measure()
}, [isAtLeastMedium, isAtLeastSmall])

useEffect(() => {
const [lastItem] = [...virtualItems].reverse()
if (!lastItem) {
return
}

// if we are 80% of the way to the end, fetch more
const start = upperBound || 0
const threshold = lastItem.index * 0.8
const closeToEnd = start >= threshold

if (closeToEnd) {
fetchNext?.()
}
}, [virtualItems, fetchNext, upperBound])

const getItemOffset = (index: number) => {
return index * estimateSize()
}

const handleSkipAhead = async (skipValue = 5) => {
const nextIndex = (upperBound || 5) + skipValue || 10
const virtualItem = virtualItems.find((item) => item.index === nextIndex)

if (!virtualItem) {
// NOTE: this is really just a guess, and this should never ~really~ happen
columnVirtualizer.scrollToOffset(getItemOffset(nextIndex), { behavior: 'smooth' })
} else {
columnVirtualizer.scrollToIndex(nextIndex, { behavior: 'smooth' })
}
}

const handleSkipBackward = (skipValue = 5) => {
let nextIndex = (visibleRef?.current[0] ?? 0) - skipValue || 0
if (nextIndex < 0) {
nextIndex = 0
}
const { startIndex: lowerBound, endIndex: upperBound } = visibleRange

const canSkipBackward = upperBound > 0 && !firstCardIsInView
const canSkipForward = items.length && !lastCardIsInView

const handleSkipAhead = useCallback(
(skip = 5) => {
const nextIndex = Math.min(upperBound + skip, items.length - 1)
virtuosoRef.current?.scrollIntoView({
index: nextIndex,
behavior: 'smooth',
align: 'start',
})
},
[upperBound, items.length],
)

columnVirtualizer.scrollToIndex(nextIndex, { behavior: 'smooth' })
}
const handleSkipBackward = useCallback(
(skip = 5) => {
const nextIndex = Math.max(lowerBound - skip, 0)
virtuosoRef.current?.scrollIntoView({
index: nextIndex,
behavior: 'smooth',
align: 'start',
})
},
[lowerBound],
)

const renderContent = () => {
if (!cards.length) {
return typeof emptyMessage === 'string' ? (
<Text size="md">{emptyMessage}</Text>
) : (
emptyMessage()
)
}

return (
<div
ref={parentRef}
className="h-[23rem] overflow-x-auto overflow-y-hidden scrollbar-hide sm:h-[25rem] lg:h-[28rem]"
>
<div
className="relative inline-flex h-full"
style={{
width: `${columnVirtualizer.getTotalSize()}px`,
}}
>
{columnVirtualizer.getVirtualItems().map((virtualItem) => {
const card = cards[virtualItem.index]

return (
<div
key={virtualItem.key}
style={{
height: '100%',
left: 0,
position: 'absolute',
top: 0,
transform: `translateX(${virtualItem.start}px)`,
width: `${estimateSize()}px`,
}}
>
{card}
if (!items.length) {
return (
<div className="flex">
{emptyState || (
<div className="flex items-start justify-start space-x-3 rounded-lg border border-dashed border-edge-subtle px-4 py-4">
<span className="rounded-lg border border-edge bg-background-surface p-2">
<CircleSlash2 className="h-8 w-8 text-foreground-muted" />
</span>
<div>
<Text>Nothing to show</Text>
<Text size="sm" variant="muted">
No results present to display
</Text>
</div>
)
})}
</div>
)}
</div>
</div>
)
)
} else {
return (
<Virtuoso
ref={virtuosoRef}
style={{ height }}
horizontalDirection
data={items}
components={{
Scroller: HorizontalScroller,
}}
itemContent={(idx, card) => (
<div
{...(idx === 0
? { ref: firstCardRef }
: idx === items.length - 1
? { ref: lastCardRef }
: {})}
className="px-1"
>
{card}
</div>
)}
endReached={onFetchMore}
increaseViewportBy={5 * (height / 3)}
rangeChanged={setVisibleRange}
overscan={{ main: 3, reverse: 3 }}
/>
)
}
}

return (
<div className="flex h-full w-full flex-col space-y-2">
<div className="flex flex-col space-y-2">
<div className="flex flex-row items-center justify-between">
<Heading size="sm">{title}</Heading>
<div className={cx('self-end', { hidden: isEmpty })}>
<div className={cn('self-end', { hidden: !items.length })}>
<div className="flex gap-2">
<ToolTip content="Seek backwards" isDisabled={!canSkipBackward}>
<IconButton
<ToolTip content="Seek backwards" isDisabled={!canSkipBackward} align="end">
<Button
variant="ghost"
size="sm"
size="icon"
disabled={!canSkipBackward}
onClick={() => handleSkipBackward()}
onDoubleClick={() => handleSkipBackward(20)}
>
<ChevronLeft className="h-4 w-4" />
</IconButton>
</Button>
</ToolTip>
<ToolTip content="Seek Ahead" isDisabled={!canSkipForward}>
<IconButton
<ToolTip content="Seek Ahead" isDisabled={!canSkipForward} align="end">
<Button
variant="ghost"
size="sm"
size="icon"
disabled={!canSkipForward}
onClick={() => handleSkipAhead()}
onDoubleClick={() => handleSkipAhead(20)}
>
<ChevronRight className="h-4 w-4" />
</IconButton>
</Button>
</ToolTip>
</div>
</div>
</div>

{renderContent()}
</div>
)
}

const HorizontalScroller = forwardRef<HTMLDivElement, ScrollerProps>(
({ children, ...props }, ref) => {
const {
preferences: { enable_hide_scrollbar },
} = usePreferences()

return (
<div
className={cn('flex overflow-y-hidden', {
'scrollbar-hide': enable_hide_scrollbar,
})}
ref={ref}
{...props}
>
{children}
</div>
)
},
)
HorizontalScroller.displayName = 'HorizontalScroller'
4 changes: 2 additions & 2 deletions packages/browser/src/components/entity/EntityCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export default function EntityCard({
const renderTitle = () => {
if (typeof title === 'string') {
return (
<Text size="sm" className="line-clamp-2 h-[40px]">
<Text size="sm" className="line-clamp-2 h-[40px] min-w-0 whitespace-normal">
{title}
</Text>
)
Expand Down Expand Up @@ -150,7 +150,7 @@ export default function EntityCard({
<Container
{...containerProps}
className={cn(
'relative flex flex-1 flex-col space-y-1 overflow-hidden rounded-lg border-[1.5px] border-edge bg-background/80 shadow-sm transition-colors duration-100',
'relative flex flex-1 flex-col space-y-1 overflow-hidden rounded-lg border-[1.5px] border-edge bg-background/80 transition-colors duration-100',
{ 'cursor-pointer hover:border-edge-brand dark:hover:border-edge-brand': hasClickAction },
{ 'max-w-[16rem]': isCover },
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */

// FIXME: this file is a mess
import { UseEpubReturn, useSDK } from '@stump/client'
import { useEffect, useState } from 'react'
Expand Down Expand Up @@ -45,7 +46,7 @@ export default function EpubStreamReader({ epub, actions, ...rest }: UseEpubRetu
// FIXME: don't cast
setContent(rest.correctHtmlUrls(res as string))
})
}, [])
}, [epub, actions, sdk, rest])

function handleClickEvent(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
if (e.target instanceof HTMLAnchorElement && e.target.href) {
Expand Down
Loading

0 comments on commit 87471d7

Please sign in to comment.