Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2025 Werther

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
43 changes: 43 additions & 0 deletions app/api/health/db/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server"

import { getConnectionStatus } from "@/lib/db-utils"

export async function GET() {
try {
const status = await getConnectionStatus()

return NextResponse.json(
{
status: status.connected ? "healthy" : "unhealthy",
database: status,
timestamp: new Date().toISOString(),
},
{
status: status.connected ? 200 : 503,
headers: {
"Cache-Control": "no-cache, no-store, must-revalidate",
Pragma: "no-cache",
Expires: "0",
},
}
)
} catch (error) {
console.error("Health check failed:", error)

return NextResponse.json(
{
status: "unhealthy",
error: error instanceof Error ? error.message : "Unknown error",
timestamp: new Date().toISOString(),
},
{
status: 503,
headers: {
"Cache-Control": "no-cache, no-store, must-revalidate",
Pragma: "no-cache",
Expires: "0",
},
}
)
}
}
56 changes: 41 additions & 15 deletions app/api/topics/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { NextRequest, NextResponse } from "next/server"

import { FALLBACK_TOPICS, shouldUseFallback } from "@/lib/fallback-data"
import { getTrendingTopics } from "@/lib/topic-extraction"
import { getDiverseTopics } from "@/lib/topic-search"

// Cache for 15 minutes
export const revalidate = 900
// Cache for 5 minutes
export const revalidate = 300

export async function GET(request: NextRequest) {
try {
Expand Down Expand Up @@ -35,16 +36,41 @@ export async function GET(request: NextRequest) {
)
}

// Get topics (diverse or regular)
const topics = diverse
? await getDiverseTopics({
timeWindow,
limit,
entityType,
randomize,
topicTypes,
})
: await getTrendingTopics({ timeWindow, limit, entityType, topicTypes })
// Get topics (diverse or regular) with fallback
let topics
try {
topics = diverse
? await getDiverseTopics({
timeWindow,
limit,
entityType,
randomize,
topicTypes,
})
: await getTrendingTopics({ timeWindow, limit, entityType, topicTypes })

// If we got no topics and database might be having issues, use fallback
if (topics.length === 0) {
console.log("No topics found, checking database connectivity...")
const { getConnectionStatus } = await import("@/lib/db-utils")
const dbStatus = await getConnectionStatus()

if (!dbStatus.connected) {
console.log("Database is not connected, using fallback topics")
topics = FALLBACK_TOPICS.slice(0, limit)
}
}
} catch (error) {
console.error("Error fetching topics from database:", error)

// Use fallback data if it's a network/database error
if (shouldUseFallback(error)) {
console.log("Using fallback topics due to database connectivity issues")
topics = FALLBACK_TOPICS.slice(0, limit)
} else {
throw error
}
}

// Transform to API response format
const response = {
Expand Down Expand Up @@ -81,9 +107,9 @@ export async function GET(request: NextRequest) {

return NextResponse.json(response, {
headers: {
"Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800",
"CDN-Cache-Control": "public, s-maxage=900",
"Vercel-CDN-Cache-Control": "public, s-maxage=900",
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=600",
"CDN-Cache-Control": "public, s-maxage=300",
"Vercel-CDN-Cache-Control": "public, s-maxage=300",
},
})
} catch (error) {
Expand Down
4 changes: 2 additions & 2 deletions app/discover/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ export default function DiscoverPage() {
)}

<Badge variant="outline">
<Clock className="mr-1 size-3" />
<Clock className="mr-1 w-3 h-3" />
{
TIME_FILTER_OPTIONS.find(
(opt) => opt.value === searchState.timeFilter
Expand All @@ -386,7 +386,7 @@ export default function DiscoverPage() {
</Badge>

<Badge variant="outline">
<Filter className="mr-1 size-3" />
<Filter className="mr-1 w-3 h-3" />
{searchState.sortBy === "score"
? "Most Relevant"
: "Most Recent"}
Expand Down
11 changes: 7 additions & 4 deletions components/article-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ export function ArticleList({
onClick={() => onSortChange("time")}
className="flex-1 sm:flex-none"
>
<Calendar className="mr-1 size-3 w-4 sm:h-4" />
<Calendar className="mr-1 h-4 w-4 sm:h-4" />
<span className="xs:inline hidden">Most Recent</span>
<span className="xs:hidden">Recent</span>
</Button>
Expand All @@ -213,11 +213,14 @@ export function ArticleList({
className="border-primary/20 hover:border-primary/40 transition-colors"
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<CardTitle className="text-lg leading-tight">
{article.title}
</CardTitle>
<Badge variant="secondary" className="shrink-0">
<Badge
variant="secondary"
className="shrink-0 self-start sm:self-auto"
>
{article.source}
</Badge>
</div>
Expand Down Expand Up @@ -262,7 +265,7 @@ export function ArticleList({
variant="outline"
className="border-blue-200 bg-blue-100 text-xs text-blue-800 hover:bg-blue-200 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-300 dark:hover:bg-blue-900/30"
>
<Hash className="mr-1 size-3" />
<Hash className="mr-1 w-3 h-3" />
{topic}
</Badge>
))}
Expand Down
2 changes: 1 addition & 1 deletion components/fact-page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export function FactPageClient({ fact }: FactPageClientProps) {
rel="noopener noreferrer"
className="hover:text-primary/80 inline-flex items-center gap-1 text-primary transition-colors"
>
<ExternalLink className="size-3" />
<ExternalLink className="w-3 h-3" />
Read original article
</a>
)}
Expand Down
2 changes: 1 addition & 1 deletion components/real-time-fact-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ export function RealTimeFactSection({ className }: RealTimeFactProps) {
rel="noopener noreferrer"
className="hover:text-primary/80 inline-flex items-center gap-1 text-primary transition-colors"
>
<ExternalLink className="size-3" />
<ExternalLink className="w-3 h-3" />
Read original article
</a>
)}
Expand Down
92 changes: 75 additions & 17 deletions components/topic-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,9 @@ export function TopicSelector({
"LAW_OR_POLICY",
]

// Fetch trending topics
// Fetch trending topics with retry logic
useEffect(() => {
const fetchTopics = async () => {
const fetchTopics = async (retryCount = 0) => {
try {
setIsLoading(true)
setError("")
Expand All @@ -154,23 +154,44 @@ export function TopicSelector({
? `&topicTypes=${selectedTopicTypes.join(",")}`
: ""

// Add cache-busting parameter to prevent stale cache issues
const timestamp = Date.now()
const url = enableDiversity
? `/api/topics?limit=20&timeWindow=48&diverse=true${topicTypesParam}`
: `/api/topics?limit=20&timeWindow=48${topicTypesParam}`
? `/api/topics?limit=20&timeWindow=48&diverse=true&cache_bust=${timestamp}${topicTypesParam}`
: `/api/topics?limit=20&timeWindow=48&cache_bust=${timestamp}${topicTypesParam}`

const response = await fetch(url)
const response = await fetch(url, {
cache: "no-store",
headers: {
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
})

if (!response.ok) {
throw new Error(`Failed to fetch topics: ${response.status}`)
}

const data = await response.json()
setTopics(data.topics || [])
setError("") // Clear any previous errors
setIsLoading(false) // Always clear loading on successful response
} catch (err) {
console.error("Error fetching topics:", err)
setError(err instanceof Error ? err.message : "Failed to load topics")
} finally {
setIsLoading(false)

// Retry logic: retry up to 3 times with exponential backoff
if (retryCount < 3) {
const delay = Math.pow(2, retryCount) * 1000 // 1s, 2s, 4s
console.log(
`Retrying fetch topics in ${delay}ms (attempt ${retryCount + 1}/3)`
)
setTimeout(() => {
fetchTopics(retryCount + 1)
}, delay)
} else {
setError(err instanceof Error ? err.message : "Failed to load topics")
setIsLoading(false) // Clear loading on final failure
}
}
}

Expand Down Expand Up @@ -334,7 +355,49 @@ export function TopicSelector({
<Button
variant="outline"
size="sm"
onClick={() => window.location.reload()}
onClick={() => {
setError("")
setIsLoading(true)
// Trigger a new fetch by updating a dependency
const timestamp = Date.now()
const topicTypesParam =
selectedTopicTypes.length > 0
? `&topicTypes=${selectedTopicTypes.join(",")}`
: ""
const url = enableDiversity
? `/api/topics?limit=20&timeWindow=48&diverse=true&cache_bust=${timestamp}${topicTypesParam}`
: `/api/topics?limit=20&timeWindow=48&cache_bust=${timestamp}${topicTypesParam}`

fetch(url, {
cache: "no-store",
headers: {
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
})
.then((response) => {
if (!response.ok)
throw new Error(
`Failed to fetch topics: ${response.status}`
)
return response.json()
})
.then((data) => {
setTopics(data.topics || [])
setError("")
})
.catch((err) => {
console.error("Error fetching topics:", err)
setError(
err instanceof Error
? err.message
: "Failed to load topics"
)
})
.finally(() => {
setIsLoading(false)
})
}}
>
Try Again
</Button>
Expand Down Expand Up @@ -362,12 +425,12 @@ export function TopicSelector({
<Card className={`border-primary/20 ${className}`}>
<CardContent className="p-3">
<div className="mb-3">
<div className="mb-2">
<h3 className="mb-2 flex items-center gap-2 text-base font-semibold sm:mb-0">
<div className="mb-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h3 className="flex items-center gap-2 text-base font-semibold">
<TrendingUp className="h-4 w-4" />
{showSearchResults ? "Search Results" : "Trending Topics"}
</h3>
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
<div className="flex flex-wrap items-center gap-2">
{!showSearchResults && (
<Button
variant="ghost"
Expand Down Expand Up @@ -424,11 +487,6 @@ export function TopicSelector({
{/* Topic Type Filter */}
{enableTopicTypeFilter && (
<div className="mb-3">
<div className="mb-1">
<span className="text-xs font-medium text-muted-foreground">
Filter by type:
</span>
</div>
<div className="flex flex-wrap gap-1">
{availableTopicTypes.map((type) => {
const isSelected = selectedTopicTypes.includes(type)
Expand Down
Loading