Introduce SubPopover (#1620)

* Introduce SubPopover

* add truncation to sub description popover

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
Felipe Bueno 2024-11-29 22:58:18 -03:00 committed by GitHub
parent bd5db1b62e
commit 55d1f2c952
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 105 additions and 8 deletions

View File

@ -27,6 +27,7 @@ import { useRetryCreateItem } from './use-item-submit'
import { useToast } from './toast'
import { useShowModal } from './modal'
import classNames from 'classnames'
import SubPopover from './sub-popover'
export default function ItemInfo ({
item, full, commentsText = 'comments',
@ -134,9 +135,11 @@ export default function ItemInfo ({
</>}
</span>
{item.subName &&
<Link href={`/~${item.subName}`}>
{' '}<Badge className={styles.newComment} bg={null}>{item.subName}</Badge>
</Link>}
<SubPopover sub={item.subName}>
<Link href={`/~${item.subName}`}>
{' '}<Badge className={styles.newComment} bg={null}>{item.subName}</Badge>
</Link>
</SubPopover>}
{sub?.nsfw &&
<Badge className={styles.newComment} bg={null}>nsfw</Badge>}
{(item.outlawed && !item.mine &&

View File

@ -12,6 +12,7 @@ import Badges from './badge'
import { MEDIA_URL } from '@/lib/constants'
import { abbrNum } from '@/lib/format'
import { Badge } from 'react-bootstrap'
import SubPopover from './sub-popover'
export default function ItemJob ({ item, toc, rank, children }) {
const isEmail = string().email().isValidSync(item.url)
@ -62,9 +63,11 @@ export default function ItemJob ({ item, toc, rank, children }) {
</Link>
</span>
{item.subName &&
<Link href={`/~${item.subName}`}>
{' '}<Badge className={styles.newComment} bg={null}>{item.subName}</Badge>
</Link>}
<SubPopover sub={item.subName}>
<Link href={`/~${item.subName}`}>
{' '}<Badge className={styles.newComment} bg={null}>{item.subName}</Badge>
</Link>
</SubPopover>}
{item.status === 'STOPPED' &&
<>{' '}<Badge bg='info' className={styles.badge}>stopped</Badge></>}
{item.mine && !item.deletedAt &&

29
components/sub-popover.js Normal file
View File

@ -0,0 +1,29 @@
import { SUB_FULL } from '@/fragments/subs'
import errorStyles from '@/styles/error.module.css'
import { useLazyQuery } from '@apollo/client'
import classNames from 'classnames'
import HoverablePopover from './hoverable-popover'
import { TerritoryInfo, TerritoryInfoSkeleton } from './territory-header'
import { truncateString } from '@/lib/format'
export default function SubPopover ({ sub, children }) {
const [getSub, { loading, data }] = useLazyQuery(
SUB_FULL,
{
variables: { sub },
fetchPolicy: 'cache-first'
}
)
return (
<HoverablePopover
onShow={getSub}
trigger={children}
body={!data || loading
? <TerritoryInfoSkeleton />
: !data.sub
? <h1 className={classNames(errorStyles.status, errorStyles.describe)}>SUB NOT FOUND</h1>
: <TerritoryInfo sub={{ ...data.sub, desc: truncateString(data.sub.desc, 280) }} />}
/>
)
}

View File

@ -31,6 +31,17 @@ export function TerritoryDetails ({ sub, children }) {
)
}
export function TerritoryInfoSkeleton ({ children, className }) {
return (
<div className={`${styles.item} ${styles.skeleton} ${className}`}>
<div className={styles.hunk}>
<div className={`${styles.name} clouds text-reset`} />
{children}
</div>
</div>
)
}
export function TerritoryInfo ({ sub }) {
return (
<>

View File

@ -12,6 +12,7 @@ import { useRouter } from 'next/router'
import Link from 'next/link'
import { UNKNOWN_LINK_REL } from '@/lib/constants'
import isEqual from 'lodash/isEqual'
import SubPopover from './sub-popover'
import UserPopover from './user-popover'
import ItemPopover from './item-popover'
import classNames from 'classnames'
@ -183,8 +184,12 @@ function Mention ({ children, node, href, name, id }) {
)
}
function Sub ({ children, node, href, ...props }) {
return <Link href={href}>{children}</Link>
function Sub ({ children, node, href, name, ...props }) {
return (
<SubPopover sub={name}>
<Link href={href}>{children}</Link>
</SubPopover>
)
}
function Item ({ children, node, href, id }) {

View File

@ -204,3 +204,49 @@ export const toPositive = (x) => {
if (typeof x === 'bigint') return toPositiveBigInt(x)
return toPositiveNumber(x)
}
/**
* Truncates a string intelligently, trying to keep natural breaks
* @param {string} str - The string to truncate
* @param {number} maxLength - Maximum length of the result
* @param {string} [suffix='...'] - String to append when truncated
* @returns {string} Truncated string
*/
export const truncateString = (str, maxLength, suffix = ' ...') => {
if (!str || str.length <= maxLength) return str
const effectiveLength = maxLength - suffix.length
// Split into paragraphs and accumulate until we exceed the limit
const paragraphs = str.split(/\n\n+/)
let result = ''
for (const paragraph of paragraphs) {
if ((result + paragraph).length > effectiveLength) {
// If this is the first paragraph and it's too long,
// fall back to sentence/word breaking
if (!result) {
// Try to break at sentence
const sentenceBreak = paragraph.slice(0, effectiveLength).match(/[.!?]\s+[A-Z]/g)
if (sentenceBreak) {
const lastBreak = paragraph.lastIndexOf(sentenceBreak[sentenceBreak.length - 1], effectiveLength)
if (lastBreak > effectiveLength / 2) {
return paragraph.slice(0, lastBreak + 1) + suffix
}
}
// Try to break at word
const wordBreak = paragraph.lastIndexOf(' ', effectiveLength)
if (wordBreak > 0) {
return paragraph.slice(0, wordBreak) + suffix
}
// Fall back to character break
return paragraph.slice(0, effectiveLength) + suffix
}
return result.trim() + suffix
}
result += (result ? '\n\n' : '') + paragraph
}
return result
}