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
212 changes: 107 additions & 105 deletions app/app/dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
'use client'

import Link from 'next/link'
import { useState } from 'react'
import { Plus, ArrowDownToLine, RefreshCw } from 'lucide-react'
import { Plus, ArrowDownToLine, WifiOff } from 'lucide-react'
import { RequireWallet } from '@/components/layout/require-wallet'
import { TestnetFaucetBanner } from '@/components/layout/testnet-faucet-banner'
import { DashboardStats, DashboardStatsSkeleton } from '@/components/streams/dashboard-stats'
import { StreamCard, StreamCardSkeleton } from '@/components/streams/stream-card'
import { StreamCardSkeleton } from '@/components/streams/stream-card'
import { VirtualStreamList } from '@/components/streams/virtual-stream-list'
import { EmptyStreams } from '@/components/streams/empty-state'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
Expand All @@ -20,6 +21,13 @@ import { getWithdrawableAmount } from '@/lib/stream-utils'
import type { StreamData } from '@/types/stream'

export function Dashboard() {
const { sent, received, all, loading, isRefreshingAfterHidden } = useStreams()
const { withdrawAll, pending } = useContract()
const now = useNow(1000)
const [withdrawProgress, setWithdrawProgress] = useState<{
current: number
total: number
} | null>(null)
const { sent, received: allReceived, all: allStreams, loading, stale, lastUpdated } = useStreams()
const { withdrawAll, pending } = useContract()
const now = useNow(1000)
Expand Down Expand Up @@ -47,45 +55,59 @@ export function Dashboard() {
}

return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="mt-1 text-sm text-muted-foreground">
Your active and historical token streams.
</p>
</div>
<div className="flex items-center gap-2">
{withdrawableStreams.length > 0 && (
<Button
variant="outline"
className="gap-1.5"
onClick={handleWithdrawAll}
disabled={pending || isWithdrawingAll}
>
<ArrowDownToLine className="size-4" />
<span className="hidden sm:inline">
<RequireWallet>
<div className="space-y-6">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground text-sm">
Your active and historical token streams.
</p>
</div>
<div className="flex items-center gap-2">
{/* "Refreshing…" indicator — shown briefly on tab re-focus after a
long hidden period so the user knows fresh data is being loaded. */}
{isRefreshingAfterHidden && (
<span
className="text-muted-foreground flex items-center gap-1 text-xs"
aria-live="polite"
aria-label="Refreshing stream data"
>
<RefreshCw className="h-3 w-3 animate-spin" aria-hidden />
Refreshing…
</span>
)}
{withdrawableStreams.length > 0 && (
<Button
size="sm"
variant="outline"
onClick={handleWithdrawAll}
disabled={isWithdrawingAll || pending}
data-testid="withdraw-all-btn"
>
<ArrowDownToLine className="mr-2 h-4 w-4" />
{isWithdrawingAll
? `Withdrawing ${withdrawProgress.current}/${withdrawProgress.total}…`
: `Withdraw all (${withdrawableStreams.length})`}
</span>
<span className="sm:hidden">
{isWithdrawingAll
? `${withdrawProgress.current}/${withdrawProgress.total}`
: withdrawableStreams.length}
</span>
<span className="sr-only">
{isWithdrawingAll
? `${withdrawProgress.current}/${withdrawProgress.total}`
: withdrawableStreams.length}
</span>
</Button>
)}
<Button size="sm" asChild>
<Link href="/app/create">
<Plus className="mr-2 h-4 w-4" />
New stream
</Link>
</Button>
)}
<Button asChild className="gap-1.5">
<Link href="/app/create">
<Plus className="size-4" />
<span className="hidden sm:inline">New stream</span>
</Link>
</Button>
</div>
</div>
</div>

{/* Testnet faucet banner */}
<TestnetFaucetBanner />
{/* Offline / stale-data banner */}
{stale && (
<div
Expand All @@ -104,88 +126,68 @@ export function Dashboard() {
{/* Testnet faucet banner */}
<TestnetFaucetBanner />

{/* Stats */}
<SectionErrorBoundary sectionName="Dashboard stats">
{loading ? <DashboardStatsSkeleton /> : <DashboardStats sent={sent} received={received} />}
</SectionErrorBoundary>
{/* Stats */}
<SectionErrorBoundary>
{loading ? <DashboardStatsSkeleton /> : <DashboardStats streams={all} />}
</SectionErrorBoundary>

{/* Stream list */}
<SectionErrorBoundary sectionName="Stream list">
{/* Stream list */}
<Tabs defaultValue="all">
<TabsList>
<TabsTrigger value="all">All ({all.length})</TabsTrigger>
<TabsTrigger value="received">Receiving ({received.length})</TabsTrigger>
<TabsTrigger value="sent">Sending ({sent.length})</TabsTrigger>
<TabsTrigger value="receiving">Receiving ({received.length})</TabsTrigger>
<TabsTrigger value="sending">Sending ({sent.length})</TabsTrigger>
</TabsList>

<TabsContent value="all" className="mt-4">
{loading ? (
<div className="grid gap-3 sm:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<StreamCardSkeleton key={i} />
))}
</div>
) : all.length === 0 ? (
<EmptyStreams />
) : (
<div className="grid gap-3 sm:grid-cols-2">
{all.map((s) => (
<ComponentErrorBoundary key={s.id} label="stream card">
<StreamCard stream={s} />
</ComponentErrorBoundary>
))}
</div>
)}
<ComponentErrorBoundary>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 2 }).map((_, i) => (
<StreamCardSkeleton key={i} />
))}
</div>
) : all.length === 0 ? (
<EmptyStreams />
) : (
<VirtualStreamList streams={all} />
)}
</ComponentErrorBoundary>
</TabsContent>

<TabsContent value="received" className="mt-4">
{loading ? (
<div className="grid gap-3 sm:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<StreamCardSkeleton key={i} />
))}
</div>
) : received.length === 0 ? (
<EmptyStreams
title="No incoming streams"
description="You haven't received any streams yet."
showCreate={false}
/>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{received.map((s) => (
<ComponentErrorBoundary key={s.id} label="stream card">
<StreamCard stream={s} />
</ComponentErrorBoundary>
))}
</div>
)}
<TabsContent value="receiving" className="mt-4">
<ComponentErrorBoundary>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 2 }).map((_, i) => (
<StreamCardSkeleton key={i} />
))}
</div>
) : received.length === 0 ? (
<EmptyStreams />
) : (
<VirtualStreamList streams={received} />
)}
</ComponentErrorBoundary>
</TabsContent>

<TabsContent value="sent" className="mt-4">
{loading ? (
<div className="grid gap-3 sm:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<StreamCardSkeleton key={i} />
))}
</div>
) : sent.length === 0 ? (
<EmptyStreams
title="No outgoing streams"
description="Create a stream to start sending tokens that unlock over time."
/>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{sent.map((s) => (
<ComponentErrorBoundary key={s.id} label="stream card">
<StreamCard stream={s} />
</ComponentErrorBoundary>
))}
</div>
)}
<TabsContent value="sending" className="mt-4">
<ComponentErrorBoundary>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 2 }).map((_, i) => (
<StreamCardSkeleton key={i} />
))}
</div>
) : sent.length === 0 ? (
<EmptyStreams />
) : (
<VirtualStreamList streams={sent} />
)}
</ComponentErrorBoundary>
</TabsContent>
</Tabs>
</SectionErrorBoundary>
</div>
</div>
</RequireWallet>
)
}
Loading
Loading