feat: 完善 signals 热度筛选与展示交互
This commit is contained in:
@@ -21,17 +21,18 @@ export default async function SignalsPage({ params }: SignalsPageProps) {
|
||||
const t = await getTranslations({ locale, namespace: 'signals' })
|
||||
|
||||
return (
|
||||
<main className="container mx-auto max-w-7xl px-4 py-12 md:py-16">
|
||||
<section className="neo-card p-7 md:p-10 mb-8 relative overflow-hidden">
|
||||
<div className="absolute -right-6 -top-6 h-24 w-24 rotate-12 border-2 border-black opacity-20 dark:border-gray-500" />
|
||||
<div className="absolute -bottom-6 -left-6 h-14 w-28 -rotate-12 border-2 border-black opacity-20 dark:border-gray-500" />
|
||||
<p className="inline-flex items-center gap-2 border-2 border-black bg-primary px-3 py-2 font-display text-xs font-bold uppercase text-black shadow-neo-sm">
|
||||
{t('heroEyebrow')}
|
||||
</p>
|
||||
<h1 className="mt-5 font-display text-4xl font-bold leading-tight tracking-tight md:text-5xl">
|
||||
{t('heroTitle')}
|
||||
</h1>
|
||||
<p className="mt-4 max-w-4xl text-base leading-relaxed text-gray-700 dark:text-gray-300 md:text-lg">
|
||||
<main className="container mx-auto max-w-7xl px-4 py-6 md:py-8">
|
||||
<section className="neo-card mb-4 p-4 md:p-5 relative overflow-hidden">
|
||||
<div className="absolute -right-4 -top-4 h-16 w-16 rotate-12 border-2 border-black opacity-15 dark:border-gray-500" />
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<p className="inline-flex items-center gap-2 border-2 border-black bg-primary px-2.5 py-1.5 font-display text-[10px] font-bold uppercase text-black shadow-neo-sm">
|
||||
{t('heroEyebrow')}
|
||||
</p>
|
||||
<h1 className="font-display text-2xl font-bold leading-tight tracking-tight md:text-3xl">
|
||||
{t('heroTitle')}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-relaxed text-gray-700 dark:text-gray-300 md:text-base">
|
||||
{t('heroDescription')}
|
||||
</p>
|
||||
</section>
|
||||
@@ -54,6 +55,9 @@ export default async function SignalsPage({ params }: SignalsPageProps) {
|
||||
sourceHuggingFace: t('sourceHuggingFace'),
|
||||
sourceReddit: t('sourceReddit'),
|
||||
sourceProductHunt: t('sourceProductHunt'),
|
||||
hotBadge: t('hotBadge'),
|
||||
totalCountHint: t('totalCountHint'),
|
||||
hotCountHint: t('hotCountHint'),
|
||||
loadMore: t('loadMore'),
|
||||
loading: t('loading'),
|
||||
loadFailed: t('loadFailed'),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { ZodError } from 'zod'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { computeSignalHotness, isSignalHotColumnMissingError } from '@/lib/signal-hotness'
|
||||
import {
|
||||
SignalQuerySchema,
|
||||
type SignalQuery,
|
||||
@@ -24,6 +25,8 @@ interface SignalView {
|
||||
source: SignalSource
|
||||
sourceUrl: string
|
||||
engagement: number
|
||||
hotScore: number
|
||||
isHot: boolean
|
||||
publishedAt: string
|
||||
topic: string
|
||||
tags: string[]
|
||||
@@ -143,6 +146,8 @@ function toSignalView(row: {
|
||||
tags: Prisma.JsonValue
|
||||
sections: Prisma.JsonValue
|
||||
engagement: number
|
||||
hotScore?: number | null
|
||||
isHot?: boolean | null
|
||||
publishedAt: Date
|
||||
}, locale: SignalQuery['locale']): SignalView | null {
|
||||
if (!isSignalSource(row.source)) {
|
||||
@@ -151,6 +156,13 @@ function toSignalView(row: {
|
||||
|
||||
const title = pickLocalizedText(locale, row.title, row.titleEn)
|
||||
const summary = pickLocalizedText(locale, row.summary, row.summaryEn)
|
||||
const fallbackHotness = computeSignalHotness({
|
||||
source: row.source,
|
||||
engagement: row.engagement,
|
||||
publishedAt: row.publishedAt,
|
||||
})
|
||||
const hotScore = typeof row.hotScore === 'number' && row.hotScore > 0 ? row.hotScore : fallbackHotness.hotScore
|
||||
const isHot = typeof row.isHot === 'boolean' ? row.isHot || fallbackHotness.isHot : fallbackHotness.isHot
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -160,6 +172,8 @@ function toSignalView(row: {
|
||||
source: row.source,
|
||||
sourceUrl: row.sourceUrl,
|
||||
engagement: row.engagement,
|
||||
hotScore,
|
||||
isHot,
|
||||
publishedAt: row.publishedAt.toISOString(),
|
||||
topic: pickLocalizedText(locale, row.topic || '', row.topicEn || undefined),
|
||||
tags: parseTags(row.tags),
|
||||
@@ -215,35 +229,88 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const orderBy: Prisma.SignalOrderByWithRelationInput[] =
|
||||
parsed.sort === 'hot'
|
||||
? [{ engagement: 'desc' }, { publishedAt: 'desc' }, { id: 'desc' }]
|
||||
? [{ isHot: 'desc' }, { hotScore: 'desc' }, { engagement: 'desc' }, { publishedAt: 'desc' }, { id: 'desc' }]
|
||||
: [{ publishedAt: 'desc' }, { id: 'desc' }]
|
||||
|
||||
const rows = await prisma.signal.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
take: parsed.limit + 1,
|
||||
...(parsed.cursor
|
||||
? {
|
||||
cursor: { id: parsed.cursor },
|
||||
skip: 1,
|
||||
}
|
||||
: {}),
|
||||
select: {
|
||||
id: true,
|
||||
source: true,
|
||||
sourceUrl: true,
|
||||
title: true,
|
||||
titleEn: true,
|
||||
summary: true,
|
||||
summaryEn: true,
|
||||
topic: true,
|
||||
topicEn: true,
|
||||
tags: true,
|
||||
sections: true,
|
||||
engagement: true,
|
||||
publishedAt: true,
|
||||
},
|
||||
})
|
||||
const cursorArgs = parsed.cursor
|
||||
? {
|
||||
cursor: { id: parsed.cursor },
|
||||
skip: 1 as const,
|
||||
}
|
||||
: {}
|
||||
|
||||
let rows: Array<{
|
||||
id: string
|
||||
source: string
|
||||
sourceUrl: string
|
||||
title: string
|
||||
titleEn: string | null
|
||||
summary: string
|
||||
summaryEn: string | null
|
||||
topic: string | null
|
||||
topicEn: string | null
|
||||
tags: Prisma.JsonValue
|
||||
sections: Prisma.JsonValue
|
||||
engagement: number
|
||||
hotScore?: number | null
|
||||
isHot?: boolean | null
|
||||
publishedAt: Date
|
||||
}>
|
||||
|
||||
try {
|
||||
rows = await prisma.signal.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
take: parsed.limit + 1,
|
||||
...cursorArgs,
|
||||
select: {
|
||||
id: true,
|
||||
source: true,
|
||||
sourceUrl: true,
|
||||
title: true,
|
||||
titleEn: true,
|
||||
summary: true,
|
||||
summaryEn: true,
|
||||
topic: true,
|
||||
topicEn: true,
|
||||
tags: true,
|
||||
sections: true,
|
||||
engagement: true,
|
||||
hotScore: true,
|
||||
isHot: true,
|
||||
publishedAt: true,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (!isSignalHotColumnMissingError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const fallbackOrderBy: Prisma.SignalOrderByWithRelationInput[] =
|
||||
parsed.sort === 'hot' ? [{ engagement: 'desc' }, { publishedAt: 'desc' }, { id: 'desc' }] : [{ publishedAt: 'desc' }, { id: 'desc' }]
|
||||
|
||||
rows = await prisma.signal.findMany({
|
||||
where,
|
||||
orderBy: fallbackOrderBy,
|
||||
take: parsed.limit + 1,
|
||||
...cursorArgs,
|
||||
select: {
|
||||
id: true,
|
||||
source: true,
|
||||
sourceUrl: true,
|
||||
title: true,
|
||||
titleEn: true,
|
||||
summary: true,
|
||||
summaryEn: true,
|
||||
topic: true,
|
||||
topicEn: true,
|
||||
tags: true,
|
||||
sections: true,
|
||||
engagement: true,
|
||||
publishedAt: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const hasMore = rows.length > parsed.limit
|
||||
const pageRows = hasMore ? rows.slice(0, parsed.limit) : rows
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { isValidApiKey } from '@/lib/auth'
|
||||
import { computeSignalHotness, isSignalHotColumnMissingError } from '@/lib/signal-hotness'
|
||||
import {
|
||||
SignalIngestionInputSchema,
|
||||
SignalWebhookPayloadSchema,
|
||||
@@ -84,6 +85,27 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const validSignal = itemValidation.data as SignalIngestionInput
|
||||
const computedHotness = computeSignalHotness({
|
||||
source: validSignal.source,
|
||||
engagement: validSignal.engagement,
|
||||
publishedAt: validSignal.publishedAt,
|
||||
})
|
||||
const hotScore =
|
||||
typeof validSignal.hotScore === 'number' ? validSignal.hotScore : computedHotness.hotScore
|
||||
const isHot = typeof validSignal.isHot === 'boolean' ? validSignal.isHot : computedHotness.isHot
|
||||
const baseData = {
|
||||
title: validSignal.title,
|
||||
titleEn: validSignal.titleEn || null,
|
||||
summary: validSignal.summary,
|
||||
summaryEn: validSignal.summaryEn || null,
|
||||
topic: validSignal.topic || null,
|
||||
topicEn: validSignal.topicEn || null,
|
||||
tags: toTagsJson(validSignal.tags),
|
||||
sections: toSectionsJson(validSignal.sections),
|
||||
engagement: validSignal.engagement,
|
||||
publishedAt: new Date(validSignal.publishedAt),
|
||||
isActive: validSignal.isActive,
|
||||
}
|
||||
|
||||
const existing = await prisma.signal.findUnique({
|
||||
where: {
|
||||
@@ -103,33 +125,36 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
},
|
||||
update: {
|
||||
title: validSignal.title,
|
||||
titleEn: validSignal.titleEn || null,
|
||||
summary: validSignal.summary,
|
||||
summaryEn: validSignal.summaryEn || null,
|
||||
topic: validSignal.topic || null,
|
||||
topicEn: validSignal.topicEn || null,
|
||||
tags: toTagsJson(validSignal.tags),
|
||||
sections: toSectionsJson(validSignal.sections),
|
||||
engagement: validSignal.engagement,
|
||||
publishedAt: new Date(validSignal.publishedAt),
|
||||
isActive: validSignal.isActive,
|
||||
...baseData,
|
||||
hotScore,
|
||||
isHot,
|
||||
},
|
||||
create: {
|
||||
source: validSignal.source,
|
||||
sourceUrl: validSignal.sourceUrl,
|
||||
title: validSignal.title,
|
||||
titleEn: validSignal.titleEn || null,
|
||||
summary: validSignal.summary,
|
||||
summaryEn: validSignal.summaryEn || null,
|
||||
topic: validSignal.topic || null,
|
||||
topicEn: validSignal.topicEn || null,
|
||||
tags: toTagsJson(validSignal.tags),
|
||||
sections: toSectionsJson(validSignal.sections),
|
||||
engagement: validSignal.engagement,
|
||||
publishedAt: new Date(validSignal.publishedAt),
|
||||
isActive: validSignal.isActive,
|
||||
...baseData,
|
||||
hotScore,
|
||||
isHot,
|
||||
},
|
||||
}).catch(async (error) => {
|
||||
if (!isSignalHotColumnMissingError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
await prisma.signal.upsert({
|
||||
where: {
|
||||
source_sourceUrl: {
|
||||
source: validSignal.source,
|
||||
sourceUrl: validSignal.sourceUrl,
|
||||
},
|
||||
},
|
||||
update: baseData,
|
||||
create: {
|
||||
source: validSignal.source,
|
||||
sourceUrl: validSignal.sourceUrl,
|
||||
...baseData,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ChevronDown,
|
||||
Flame,
|
||||
FlaskConical,
|
||||
Globe2,
|
||||
Github,
|
||||
List,
|
||||
MessageCircle,
|
||||
Newspaper,
|
||||
Rocket,
|
||||
@@ -41,6 +44,8 @@ export interface IdeaSignal {
|
||||
source: SignalSource
|
||||
sourceUrl: string
|
||||
engagement: number
|
||||
hotScore: number
|
||||
isHot: boolean
|
||||
publishedAt: string
|
||||
topic: string
|
||||
tags: string[]
|
||||
@@ -76,6 +81,9 @@ interface SignalFeedClientProps {
|
||||
sourceHuggingFace: string
|
||||
sourceReddit: string
|
||||
sourceProductHunt: string
|
||||
hotBadge: string
|
||||
totalCountHint: string
|
||||
hotCountHint: string
|
||||
loadMore: string
|
||||
loading: string
|
||||
loadFailed: string
|
||||
@@ -216,7 +224,9 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
||||
const [search, setSearch] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [source, setSource] = useState<SourceFilter>('all')
|
||||
const [sort, setSort] = useState<SortKey>('latest')
|
||||
const [sort, setSort] = useState<SortKey>('hot')
|
||||
const [isSortMenuOpen, setIsSortMenuOpen] = useState(false)
|
||||
const sortMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const [signals, setSignals] = useState<IdeaSignal[]>([])
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
||||
@@ -283,6 +293,35 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
||||
}
|
||||
}, [debouncedSearch, locale, sort, source])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSortMenuOpen) {
|
||||
return
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!sortMenuRef.current) {
|
||||
return
|
||||
}
|
||||
if (event.target instanceof Node && !sortMenuRef.current.contains(event.target)) {
|
||||
setIsSortMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
setIsSortMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('pointerdown', handlePointerDown)
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', handlePointerDown)
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isSortMenuOpen])
|
||||
|
||||
async function handleLoadMore() {
|
||||
if (!hasMore || !nextCursor || isLoadingMore) {
|
||||
return
|
||||
@@ -344,64 +383,120 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
||||
'data-[active=true]:bg-black data-[active=true]:text-white dark:data-[active=true]:bg-primary dark:data-[active=true]:text-black',
|
||||
}
|
||||
|
||||
const activeSortLabel = sortOptions.find((option) => option.key === sort)?.label || translations.sortLatest
|
||||
const hotCount = signals.filter((item) => item.isHot).length
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<article className="neo-card p-3.5 md:p-4">
|
||||
<div className="grid grid-cols-1 gap-2.5 lg:grid-cols-2">
|
||||
<label className="flex items-center gap-3 neo-input p-3">
|
||||
<Search className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">{translations.searchLabel}</span>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={translations.searchPlaceholder}
|
||||
className="w-full bg-transparent outline-none font-sans text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2 neo-input p-2">
|
||||
<TrendingUp className="h-4 w-4 ml-1" aria-hidden="true" />
|
||||
<label htmlFor="signal-sort" className="font-display text-xs font-bold uppercase">
|
||||
{translations.sortLabel}
|
||||
<div className="space-y-3">
|
||||
<article className="neo-card bg-white/95 p-2.5 backdrop-blur dark:bg-surface-dark/95 md:p-3">
|
||||
<div className="border-2 border-black bg-orange-50/70 p-2 dark:border-gray-600 dark:bg-gray-900/50 md:p-2.5">
|
||||
<div className="grid grid-cols-1 gap-2 xl:grid-cols-[minmax(0,1fr)_220px_auto]">
|
||||
<label className="flex h-10 items-center gap-2.5 border-2 border-black bg-white px-3 dark:border-gray-500 dark:bg-surface-dark">
|
||||
<Search className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span className="sr-only">{translations.searchLabel}</span>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={translations.searchPlaceholder}
|
||||
className="w-full bg-transparent outline-none font-sans text-sm"
|
||||
/>
|
||||
</label>
|
||||
<select
|
||||
id="signal-sort"
|
||||
value={sort}
|
||||
onChange={(event) => setSort(event.target.value as SortKey)}
|
||||
className="ml-auto bg-transparent outline-none text-sm font-display font-bold cursor-pointer"
|
||||
>
|
||||
{sortOptions.map((option) => (
|
||||
<option key={option.key} value={option.key}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5">
|
||||
<p className="mb-2 font-display text-xs font-bold uppercase">{translations.sourceLabel}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sourceOptions.map((option) => {
|
||||
const active = source === option
|
||||
const meta = option === 'all' ? allSourcesMeta : sourceMeta[option]
|
||||
const SourceIcon = meta.icon
|
||||
<div ref={sortMenuRef} className="relative">
|
||||
<button
|
||||
id="signal-sort"
|
||||
type="button"
|
||||
aria-label={translations.sortLabel}
|
||||
aria-expanded={isSortMenuOpen}
|
||||
aria-haspopup="listbox"
|
||||
onClick={() => setIsSortMenuOpen((previous) => !previous)}
|
||||
className="neo-btn flex h-10 w-full items-center justify-between bg-white px-2.5 dark:bg-surface-dark"
|
||||
>
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 font-display text-xs font-bold uppercase text-black dark:text-gray-100">
|
||||
{sort === 'hot' ? <TrendingUp className="h-3.5 w-3.5 shrink-0" aria-hidden="true" /> : null}
|
||||
<span className="truncate">{activeSortLabel}</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 shrink-0 transition-transform ${isSortMenuOpen ? 'rotate-180' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
data-active={active}
|
||||
onClick={() => setSource(option)}
|
||||
className={`min-h-10 border-2 border-black bg-white px-3 py-1.5 font-display text-xs font-bold uppercase transition-colors cursor-pointer hover:bg-gray-100 dark:border-gray-500 dark:bg-surface-dark dark:hover:bg-gray-800 ${meta.filterClass}`}
|
||||
{isSortMenuOpen ? (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-labelledby="signal-sort"
|
||||
className="neo-card absolute left-0 right-0 top-[calc(100%+6px)] z-30 overflow-hidden"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<SourceIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{sourceLabels[option]}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{sortOptions.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={sort === option.key}
|
||||
onClick={() => {
|
||||
setSort(option.key)
|
||||
setIsSortMenuOpen(false)
|
||||
}}
|
||||
className={`flex w-full items-center border-b-2 border-black px-2.5 py-2 text-left font-display text-xs font-bold uppercase last:border-b-0 dark:border-gray-600 ${
|
||||
sort === option.key
|
||||
? 'bg-primary text-black'
|
||||
: 'bg-white text-black hover:bg-gray-100 dark:bg-surface-dark dark:text-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{option.key === 'hot' ? <TrendingUp className="h-3.5 w-3.5 shrink-0" aria-hidden="true" /> : null}
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-start gap-1.5 xl:justify-end">
|
||||
<span
|
||||
title={translations.totalCountHint}
|
||||
aria-label={`${translations.totalCountHint}: ${signals.length}`}
|
||||
className="inline-flex h-8 items-center gap-1 border-2 border-black bg-primary px-2 py-1 font-display text-[10px] font-bold uppercase text-black"
|
||||
>
|
||||
<List className="h-3 w-3" aria-hidden="true" />
|
||||
{signals.length}
|
||||
</span>
|
||||
<span
|
||||
title={translations.hotCountHint}
|
||||
aria-label={`${translations.hotCountHint}: ${hotCount}`}
|
||||
className="inline-flex h-8 items-center gap-1 border-2 border-black bg-red-200 px-2 py-1 font-display text-[10px] font-bold uppercase text-black"
|
||||
>
|
||||
<Flame className="h-3 w-3" aria-hidden="true" />
|
||||
{hotCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 min-w-0 overflow-x-auto pb-1">
|
||||
<div className="flex min-w-max gap-1.5">
|
||||
{sourceOptions.map((option) => {
|
||||
const active = source === option
|
||||
const meta = option === 'all' ? allSourcesMeta : sourceMeta[option]
|
||||
const SourceIcon = meta.icon
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
data-active={active}
|
||||
onClick={() => setSource(option)}
|
||||
className={`h-8 border-2 border-black bg-white px-2.5 font-display text-[11px] font-bold uppercase transition-colors cursor-pointer hover:bg-gray-100 dark:border-gray-500 dark:bg-surface-dark dark:hover:bg-gray-800 ${meta.filterClass}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<SourceIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{sourceLabels[option]}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -414,20 +509,25 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
||||
<article className="neo-card p-8 text-center font-display font-bold">{translations.noResults}</article>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-2.5 lg:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-2.5">
|
||||
{signals.map((signal) => {
|
||||
const sourceInfo = sourceMeta[signal.source]
|
||||
const SourceIcon = sourceInfo.icon
|
||||
const visibleTags = signal.tags.slice(0, 2)
|
||||
const hiddenTagCount = Math.max(0, signal.tags.length - visibleTags.length)
|
||||
|
||||
return (
|
||||
<article key={signal.id} className="neo-card relative p-3.5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 className="line-clamp-2 font-display text-lg font-bold leading-tight md:text-xl">{signal.title}</h2>
|
||||
<span className="font-display text-xs font-bold uppercase text-gray-500 dark:text-gray-400 shrink-0">
|
||||
{formatDate(signal.publishedAt, locale)}
|
||||
</span>
|
||||
<div className="shrink-0 text-right">
|
||||
{signal.isHot ? (
|
||||
<span className="inline-flex border-2 border-black bg-red-300 px-1.5 py-0.5 font-display text-[10px] font-bold uppercase text-black">
|
||||
{translations.hotBadge}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="mt-1 font-display text-xs font-bold uppercase text-gray-500 dark:text-gray-400">
|
||||
{formatDate(signal.publishedAt, locale)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-1.5 line-clamp-3 text-sm leading-relaxed text-gray-700 dark:text-gray-300">
|
||||
@@ -442,29 +542,23 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
||||
>
|
||||
<p className="mb-1 font-display text-[10px] font-bold uppercase">{section.title}</p>
|
||||
<div className="space-y-1">
|
||||
{section.items.slice(0, 2).map((item) => (
|
||||
{section.items.map((item) => (
|
||||
<p key={item} className="line-clamp-2 text-xs leading-relaxed text-gray-800 dark:text-gray-200">
|
||||
{item}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
{section.items.length > 2 ? (
|
||||
<p className="mt-1 font-display text-[10px] font-bold uppercase text-gray-600 dark:text-gray-400">
|
||||
+{section.items.length - 2}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 border-t-2 border-black pt-2 dark:border-gray-600">
|
||||
<div className="flex min-h-8 flex-wrap items-center justify-end gap-1.5 pl-28">
|
||||
{visibleTags.map((tag) => (
|
||||
{signal.tags.map((tag) => (
|
||||
<span key={tag} className="neo-tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{hiddenTagCount > 0 ? <span className="neo-tag">+{hiddenTagCount}</span> : null}
|
||||
<Link
|
||||
href={signal.sourceUrl}
|
||||
target="_blank"
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { SignalSource } from '@/lib/validations'
|
||||
import { Prisma } from '@prisma/client'
|
||||
|
||||
interface HotnessInput {
|
||||
source: SignalSource
|
||||
engagement: number
|
||||
publishedAt: Date | string
|
||||
}
|
||||
|
||||
interface HotnessResult {
|
||||
hotScore: number
|
||||
isHot: boolean
|
||||
}
|
||||
|
||||
interface HotConfig {
|
||||
engagementBaseline: number
|
||||
minEngagement: number
|
||||
threshold: number
|
||||
}
|
||||
|
||||
const HOT_CONFIG: Record<SignalSource, HotConfig> = {
|
||||
hacker_news: { engagementBaseline: 140, minEngagement: 45, threshold: 64 },
|
||||
github: { engagementBaseline: 1200, minEngagement: 120, threshold: 66 },
|
||||
arxiv: { engagementBaseline: 20, minEngagement: 12, threshold: 72 },
|
||||
hugging_face: { engagementBaseline: 55, minEngagement: 20, threshold: 68 },
|
||||
reddit: { engagementBaseline: 160, minEngagement: 50, threshold: 65 },
|
||||
product_hunt: { engagementBaseline: 120, minEngagement: 35, threshold: 65 },
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function normalizeEngagement(engagement: number, baseline: number): number {
|
||||
if (engagement <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const capped = Math.min(engagement, baseline * 3)
|
||||
const score = (Math.log1p(capped) / Math.log1p(baseline)) * 100
|
||||
return clamp(score, 0, 100)
|
||||
}
|
||||
|
||||
function recencyScore(publishedAt: Date | string): number {
|
||||
const date = publishedAt instanceof Date ? publishedAt : new Date(publishedAt)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const ageHours = (Date.now() - date.getTime()) / (1000 * 60 * 60)
|
||||
if (ageHours <= 0) {
|
||||
return 100
|
||||
}
|
||||
|
||||
const score = 100 - ageHours * 2.4
|
||||
return clamp(score, 0, 100)
|
||||
}
|
||||
|
||||
export function computeSignalHotness(input: HotnessInput): HotnessResult {
|
||||
const safeEngagement = Number.isFinite(input.engagement) && input.engagement > 0 ? Math.floor(input.engagement) : 0
|
||||
const config = HOT_CONFIG[input.source]
|
||||
|
||||
const engagementPart = normalizeEngagement(safeEngagement, config.engagementBaseline)
|
||||
const recencyPart = recencyScore(input.publishedAt)
|
||||
const hotScore = Math.round(engagementPart * 0.72 + recencyPart * 0.28)
|
||||
|
||||
const isHot = safeEngagement >= config.minEngagement && hotScore >= config.threshold
|
||||
|
||||
return {
|
||||
hotScore: clamp(hotScore, 0, 100),
|
||||
isHot,
|
||||
}
|
||||
}
|
||||
|
||||
export function isSignalHotColumnMissingError(error: unknown): boolean {
|
||||
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (error.code !== 'P2022') {
|
||||
return false
|
||||
}
|
||||
|
||||
const column = String(error.meta?.column || '')
|
||||
return column.includes('hotScore') || column.includes('isHot')
|
||||
}
|
||||
@@ -197,6 +197,8 @@ export const SignalIngestionInputSchema = z.object({
|
||||
tags: z.array(z.string().min(1).max(40)).max(20).default([]),
|
||||
sections: z.array(SignalSectionSchema).min(1).max(6),
|
||||
engagement: z.coerce.number().int().nonnegative().default(0),
|
||||
hotScore: z.coerce.number().int().min(0).max(100).optional(),
|
||||
isHot: z.boolean().optional(),
|
||||
publishedAt: z.string().datetime({ offset: true }),
|
||||
isActive: z.boolean().default(true),
|
||||
});
|
||||
|
||||
@@ -183,6 +183,9 @@
|
||||
"discussionAnglesTitle": "Disagreements",
|
||||
"sortLatest": "Latest First",
|
||||
"sortHot": "Most Discussed",
|
||||
"hotBadge": "HOT",
|
||||
"totalCountHint": "Total discussions under current filters",
|
||||
"hotCountHint": "High-heat discussions under current filters",
|
||||
"sourceAll": "All Sources",
|
||||
"sourceHackerNews": "Hacker News",
|
||||
"sourceGithub": "GitHub",
|
||||
|
||||
@@ -183,6 +183,9 @@
|
||||
"discussionAnglesTitle": "争议与分歧",
|
||||
"sortLatest": "最新优先",
|
||||
"sortHot": "热度优先",
|
||||
"hotBadge": "HOT",
|
||||
"totalCountHint": "当前筛选条件下的讨论总数",
|
||||
"hotCountHint": "当前筛选条件下的高热度讨论数量",
|
||||
"sourceAll": "全部来源",
|
||||
"sourceHackerNews": "Hacker News",
|
||||
"sourceGithub": "GitHub",
|
||||
|
||||
Reference in New Issue
Block a user