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
27 changes: 10 additions & 17 deletions admin-web/app/(dashboard)/blogs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ const LOCALE_COLORS: Record<BlogLocale, string> = {
export default function BlogsPage() {
const router = useRouter()
const [posts, setPosts] = useState<BlogPostListItem[]>([])
const [filtered, setFiltered] = useState<BlogPostListItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [localeFilter, setLocaleFilter] = useState<string>('all')
const [statusFilter, setStatusFilter] = useState<string>('all')
const [search, setSearch] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const [deletingId, setDeletingId] = useState<string | null>(null)
const [togglingId, setTogglingId] = useState<string | null>(null)
const [triggeringCron, setTriggeringCron] = useState(false)
Expand All @@ -47,6 +47,7 @@ export default function BlogsPage() {
limit: PAGE_SIZE,
locale: localeFilter !== 'all' ? localeFilter : undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
search: searchQuery || undefined,
})
setPosts(data.posts)
setTotal(data.total)
Expand All @@ -56,7 +57,7 @@ export default function BlogsPage() {
} finally {
setIsLoading(false)
}
}, [page, localeFilter, statusFilter])
}, [page, localeFilter, statusFilter, searchQuery])

const loadStats = useCallback(async () => {
try {
Expand All @@ -75,21 +76,13 @@ export default function BlogsPage() {
useEffect(() => { loadStats() }, [loadStats])

// Reset to first page when filters change
useEffect(() => { setPage(1) }, [localeFilter, statusFilter])
useEffect(() => { setPage(1) }, [localeFilter, statusFilter, searchQuery])

// Search filters the currently loaded page client-side
// Debounce typing, then let the API search every post in the database
useEffect(() => {
let result = posts
if (search.trim()) {
const q = search.toLowerCase()
result = result.filter(p =>
p.title.toLowerCase().includes(q) ||
p.excerpt.toLowerCase().includes(q) ||
p.tags.some(t => t.toLowerCase().includes(q))
)
}
setFiltered(result)
}, [posts, search])
const timer = setTimeout(() => setSearchQuery(search.trim()), 300)
return () => clearTimeout(timer)
}, [search])

const handleToggleStatus = async (post: BlogPostListItem) => {
setTogglingId(post.id)
Expand Down Expand Up @@ -217,7 +210,7 @@ export default function BlogsPage() {
{error}
<button onClick={loadPosts} className="ml-3 underline">Retry</button>
</div>
) : filtered.length === 0 ? (
) : posts.length === 0 ? (
<div className="rounded-xl border border-white/10 bg-white/5 py-16 text-center text-sm text-indigo-400/60">
No posts found.
</div>
Expand All @@ -235,7 +228,7 @@ export default function BlogsPage() {
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{filtered.map(post => (
{posts.map(post => (
<tr key={post.id} className="group transition-colors hover:bg-white/5">
<td className="px-4 py-3">
<div className="flex items-start gap-2">
Expand Down
58 changes: 48 additions & 10 deletions admin-web/app/(dashboard)/content-management/page.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,37 @@
'use client'

import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'
import { toast } from 'sonner'
import { StatsCard } from '@/components/ui/stats-card'
import DailyVersesTable from '@/components/tables/daily-verses-table'
import { PageHeader } from '@/components/ui/page-header'

const PAGE_SIZE = 50

export default function ContentManagementPage() {
const queryClient = useQueryClient()

const [filters, setFilters] = useState({
language: '',
is_active: '',
})
const [page, setPage] = useState(0)

const { data, isLoading } = useQuery({
queryKey: ['content-daily-verses', filters],
const { data, isLoading, isFetching } = useQuery({
queryKey: ['content-daily-verses', filters, page],
queryFn: async () => {
const params = new URLSearchParams()
if (filters.language) params.set('language', filters.language)
if (filters.is_active) params.set('is_active', filters.is_active)
params.set('limit', String(PAGE_SIZE))
params.set('offset', String(page * PAGE_SIZE))

const res = await fetch(`/api/admin/content/daily-verses?${params}`)
if (!res.ok) throw new Error('Failed to fetch daily verses')
return res.json()
},
placeholderData: keepPreviousData,
})

const toggleActive = useMutation({
Expand Down Expand Up @@ -64,7 +70,10 @@ export default function ContentManagementPage() {
},
})

// Counted by Postgres over every row matching the filters, not this page
const stats = data?.stats
const total = data?.total ?? 0
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))

return (
<div className="space-y-6">
Expand All @@ -87,7 +96,10 @@ export default function ContentManagementPage() {
<div className="flex flex-wrap gap-4">
<select
value={filters.language}
onChange={(e) => setFilters({ ...filters, language: e.target.value })}
onChange={(e) => {
setFilters({ ...filters, language: e.target.value })
setPage(0)
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="">All Languages</option>
Expand All @@ -98,7 +110,10 @@ export default function ContentManagementPage() {

<select
value={filters.is_active}
onChange={(e) => setFilters({ ...filters, is_active: e.target.value })}
onChange={(e) => {
setFilters({ ...filters, is_active: e.target.value })
setPage(0)
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="">All Status</option>
Expand Down Expand Up @@ -142,11 +157,34 @@ export default function ContentManagementPage() {
<p className="text-gray-500 dark:text-gray-400">Loading...</p>
</div>
) : (
<DailyVersesTable
verses={data?.daily_verses || []}
onToggleActive={(id, is_active) => toggleActive.mutate({ id, is_active })}
onDelete={(id) => deleteVerse.mutate(id)}
/>
<>
<DailyVersesTable
verses={data?.daily_verses || []}
onToggleActive={(id, is_active) => toggleActive.mutate({ id, is_active })}
onDelete={(id) => deleteVerse.mutate(id)}
/>
<div className="flex items-center justify-between border-t border-gray-200 px-4 py-3 dark:border-gray-700">
<p className="text-sm text-gray-600 dark:text-gray-400">
Page {page + 1} of {totalPages} ({total} matching verses)
</p>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0 || isFetching}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
Previous
</button>
<button
onClick={() => setPage((p) => p + 1)}
disabled={page + 1 >= totalPages || isFetching}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
Next
</button>
</div>
</div>
</>
)}
</div>
</div>
Expand Down
89 changes: 80 additions & 9 deletions admin-web/app/(dashboard)/gamification/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useState } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'
import { StatsCard } from '@/components/ui/stats-card'
import AchievementsTable from '@/components/tables/achievements-table'
import UserAchievementsTable from '@/components/tables/user-achievements-table'
Expand All @@ -21,6 +21,48 @@ const GAMIFICATION_TABS = [
{ value: 'streak-analytics', label: 'Streak Analytics', icon: '🔥' },
]

const PAGE_SIZE = 50

/** Prev/next controls for a server-paged table. */
function TablePagination({
page,
total,
isBusy,
onChange,
noun,
}: {
page: number
total: number
isBusy: boolean
onChange: (page: number) => void
noun: string
}) {
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
return (
<div className="flex items-center justify-between border-t border-gray-200 px-4 py-3 dark:border-gray-700">
<p className="text-sm text-gray-600 dark:text-gray-400">
Page {page + 1} of {totalPages} ({total} {noun})
</p>
<div className="flex gap-2">
<button
onClick={() => onChange(Math.max(0, page - 1))}
disabled={page === 0 || isBusy}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
Previous
</button>
<button
onClick={() => onChange(page + 1)}
disabled={page + 1 >= totalPages || isBusy}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
Next
</button>
</div>
</div>
)
}

export default function GamificationPage() {
const [activeTab, setActiveTab] = useState<TabType>('achievements-catalog')
const queryClient = useQueryClient()
Expand All @@ -38,6 +80,8 @@ export default function GamificationPage() {
})

const [streaksSort, setStreaksSort] = useState('current_streak')
const [userAchievementsPage, setUserAchievementsPage] = useState(0)
const [streaksPage, setStreaksPage] = useState(0)

// Fetch Achievements Catalog
const { data: achievementsData, isLoading: achievementsLoading } = useQuery({
Expand All @@ -55,30 +99,36 @@ export default function GamificationPage() {

// Fetch User Achievements
const { data: userAchievementsData, isLoading: userAchievementsLoading } = useQuery({
queryKey: ['gamification-user-achievements', userAchievementsFilters],
queryKey: ['gamification-user-achievements', userAchievementsFilters, userAchievementsPage],
queryFn: async () => {
const params = new URLSearchParams()
if (userAchievementsFilters.user_id) params.set('user_id', userAchievementsFilters.user_id)
if (userAchievementsFilters.achievement_id) params.set('achievement_id', userAchievementsFilters.achievement_id)
params.set('limit', String(PAGE_SIZE))
params.set('offset', String(userAchievementsPage * PAGE_SIZE))

const res = await fetch(`/api/admin/gamification/user-achievements?${params}`)
if (!res.ok) throw new Error('Failed to fetch user achievements')
return res.json()
},
placeholderData: keepPreviousData,
enabled: activeTab === 'user-achievements'
})

// Fetch Streak Analytics
const { data: streaksData, isLoading: streaksLoading } = useQuery({
queryKey: ['gamification-streaks', streaksSort],
queryKey: ['gamification-streaks', streaksSort, streaksPage],
queryFn: async () => {
const params = new URLSearchParams()
params.set('sort_by', streaksSort)
params.set('limit', String(PAGE_SIZE))
params.set('offset', String(streaksPage * PAGE_SIZE))

const res = await fetch(`/api/admin/gamification/streaks?${params}`)
if (!res.ok) throw new Error('Failed to fetch streaks')
return res.json()
},
placeholderData: keepPreviousData,
enabled: activeTab === 'streak-analytics'
})

Expand Down Expand Up @@ -288,10 +338,19 @@ export default function GamificationPage() {
<p className="text-gray-500 dark:text-gray-400">Loading...</p>
</div>
) : (
<UserAchievementsTable
userAchievements={userAchievementsData?.user_achievements || []}
onRevoke={(id) => revokeUserAchievement.mutate(id)}
/>
<>
<UserAchievementsTable
userAchievements={userAchievementsData?.user_achievements || []}
onRevoke={(id) => revokeUserAchievement.mutate(id)}
/>
<TablePagination
page={userAchievementsPage}
total={userAchievementsData?.total ?? 0}
isBusy={userAchievementsLoading}
onChange={setUserAchievementsPage}
noun="matching unlocks"
/>
</>
)}
</div>
</div>
Expand Down Expand Up @@ -468,7 +527,10 @@ export default function GamificationPage() {
</label>
<select
value={streaksSort}
onChange={(e) => setStreaksSort(e.target.value)}
onChange={(e) => {
setStreaksSort(e.target.value)
setStreaksPage(0)
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="current_streak">Current Streak</option>
Expand All @@ -485,7 +547,16 @@ export default function GamificationPage() {
<p className="text-gray-500 dark:text-gray-400">Loading...</p>
</div>
) : (
<StreaksTable streaks={streaksData?.streaks || []} />
<>
<StreaksTable streaks={streaksData?.streaks || []} />
<TablePagination
page={streaksPage}
total={streaksData?.total ?? 0}
isBusy={streaksLoading}
onChange={setStreaksPage}
noun="users with a study streak"
/>
</>
)}
</div>
</div>
Expand Down
3 changes: 2 additions & 1 deletion admin-web/app/(dashboard)/issues/[feedbackId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ export default function FeedbackDetailPage() {
credentials: 'include',
})
if (!response.ok) throw new Error('Failed to fetch feedback')
return response.json()
const data = await response.json()
return data.feedback ?? []
},
})

Expand Down
Loading