Skip to content
Open
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
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@stellar/freighter-api": "^6.0.1",
"@stellar/stellar-sdk": "^15.1.0",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-virtual": "^3.14.9",
"lucide-react": "^0.575.0",
"next": "16.2.9",
"next-themes": "^0.4.6",
Expand Down
175 changes: 131 additions & 44 deletions frontend/src/components/dashboard/ActivityHistory.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
"use client";

import React from "react";
import React, { useRef } from "react";
import Link from "next/link";
import { useVirtualizer } from "@tanstack/react-virtual";
import { BackendStreamEvent } from "@/lib/api-types";
import { formatAmount } from "@/lib/amount";
import TransactionTracker from "@/components/TransactionTracker";
import { Download, ExternalLink, Clock } from "lucide-react";
import { Button } from "../ui/Button";

const VIRTUALIZATION_THRESHOLD = 50;

interface ActivityHistoryProps {
events: BackendStreamEvent[];
isLoading?: boolean;
Expand All @@ -17,6 +20,17 @@ export const ActivityHistory: React.FC<ActivityHistoryProps> = ({
events,
isLoading,
}) => {
const parentRef = useRef<HTMLDivElement>(null);
const shouldVirtualize = events.length > VIRTUALIZATION_THRESHOLD;

const virtualizer = useVirtualizer({
count: events.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 120,
overscan: 5,
enabled: shouldVirtualize,
});

const exportToCSV = () => {
const headers = [
"Stream ID",
Expand Down Expand Up @@ -139,55 +153,128 @@ export const ActivityHistory: React.FC<ActivityHistoryProps> = ({
</Button>
</div>

<div className="relative space-y-4 before:absolute before:inset-0 before:ml-5 before:-translate-x-px md:before:mx-auto md:before:translate-x-0 before:h-full before:w-0.5 before:bg-gradient-to-b before:from-transparent before:via-slate-700 before:to-transparent">
{events.map((event, index) => (
{shouldVirtualize ? (
<div
ref={parentRef}
className="relative h-[600px] overflow-auto before:absolute before:inset-0 before:ml-5 before:-translate-x-px md:before:mx-auto md:before:translate-x-0 before:h-full before:w-0.5 before:bg-gradient-to-b before:from-transparent before:via-slate-700 before:to-transparent"
>
<div
key={`${event.id}-${index}`}
className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group"
style={{ height: virtualizer.getTotalSize() }}
className="relative w-full"
>
{/* Dot */}
<div className="flex items-center justify-center w-10 h-10 rounded-full border border-slate-700 bg-slate-900 text-accent shadow shrink-0 md:order-1 md:group-odd:-translate-x-1/2 md:group-even:translate-x-1/2">
<Clock className="h-5 w-5" />
</div>
{/* Content Card */}
<div className="w-[calc(100%-4rem)] md:w-[calc(50%-2.5rem)] p-4 bg-white/5 border border-glass-border rounded-xl hover:bg-white/10 transition-colors shadow-xl">
<div className="flex flex-col sm:flex-row justify-between items-start mb-2 gap-2">
<div>
<p className="text-white font-medium text-sm sm:text-base">
{renderEventMessage(event)}
</p>
<time className="text-xs text-slate-400 flex items-center gap-1 mt-1">
{new Date(event.timestamp * 1000).toLocaleString()}
</time>
{virtualizer.getVirtualItems().map((virtualRow) => {
const event = events[virtualRow.index];
if (!event) return null;
return (
<div
key={`${event.id}-${virtualRow.index}`}
ref={virtualizer.measureElement}
data-index={virtualRow.index}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group mb-4"
>
{/* Dot */}
<div className="flex items-center justify-center w-10 h-10 rounded-full border border-slate-700 bg-slate-900 text-accent shadow shrink-0 md:order-1 md:group-odd:-translate-x-1/2 md:group-even:translate-x-1/2">
<Clock className="h-5 w-5" />
</div>
{/* Content Card */}
<div className="w-[calc(100%-4rem)] md:w-[calc(50%-2.5rem)] p-4 bg-white/5 border border-glass-border rounded-xl hover:bg-white/10 transition-colors shadow-xl">
<div className="flex flex-col sm:flex-row justify-between items-start mb-2 gap-2">
<div>
<p className="text-white font-medium text-sm sm:text-base">
{renderEventMessage(event)}
</p>
<time className="text-xs text-slate-400 flex items-center gap-1 mt-1">
{new Date(event.timestamp * 1000).toLocaleString()}
</time>
</div>
<span className="text-[10px] uppercase tracking-wider px-2 py-1 rounded bg-accent/10 text-accent font-bold border border-accent/20">
{event.eventType}
</span>
</div>

{event.transactionHash && (
<div className="mt-4 pt-4 border-t border-white/5 flex items-center justify-between">
<TransactionTracker
status="confirmed"
action="withdraw"
txHash={event.transactionHash}
streamId={event.streamId.toString()}
/>
<a
href={`https://stellar.expert/explorer/testnet/tx/${event.transactionHash}`}
target="_blank"
rel="noopener noreferrer"
className="text-slate-500 hover:text-white transition-colors"
title="View on Explorer"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
)}
</div>
</div>
<span className="text-[10px] uppercase tracking-wider px-2 py-1 rounded bg-accent/10 text-accent font-bold border border-accent/20">
{event.eventType}
</span>
);
})}
</div>
</div>
) : (
<div className="relative space-y-4 before:absolute before:inset-0 before:ml-5 before:-translate-x-px md:before:mx-auto md:before:translate-x-0 before:h-full before:w-0.5 before:bg-gradient-to-b before:from-transparent before:via-slate-700 before:to-transparent">
{events.map((event, index) => (
<div
key={`${event.id}-${index}`}
className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group"
>
{/* Dot */}
<div className="flex items-center justify-center w-10 h-10 rounded-full border border-slate-700 bg-slate-900 text-accent shadow shrink-0 md:order-1 md:group-odd:-translate-x-1/2 md:group-even:translate-x-1/2">
<Clock className="h-5 w-5" />
</div>

{event.transactionHash && (
<div className="mt-4 pt-4 border-t border-white/5 flex items-center justify-between">
<TransactionTracker
status="confirmed"
action="withdraw"
txHash={event.transactionHash}
streamId={event.streamId.toString()}
/>
<a
href={`https://stellar.expert/explorer/testnet/tx/${event.transactionHash}`}
target="_blank"
rel="noopener noreferrer"
className="text-slate-500 hover:text-white transition-colors"
title="View on Explorer"
>
<ExternalLink className="h-4 w-4" />
</a>
{/* Content Card */}
<div className="w-[calc(100%-4rem)] md:w-[calc(50%-2.5rem)] p-4 bg-white/5 border border-glass-border rounded-xl hover:bg-white/10 transition-colors shadow-xl">
<div className="flex flex-col sm:flex-row justify-between items-start mb-2 gap-2">
<div>
<p className="text-white font-medium text-sm sm:text-base">
{renderEventMessage(event)}
</p>
<time className="text-xs text-slate-400 flex items-center gap-1 mt-1">
{new Date(event.timestamp * 1000).toLocaleString()}
</time>
</div>
<span className="text-[10px] uppercase tracking-wider px-2 py-1 rounded bg-accent/10 text-accent font-bold border border-accent/20">
{event.eventType}
</span>
</div>
)}

{event.transactionHash && (
<div className="mt-4 pt-4 border-t border-white/5 flex items-center justify-between">
<TransactionTracker
status="confirmed"
action="withdraw"
txHash={event.transactionHash}
streamId={event.streamId.toString()}
/>
<a
href={`https://stellar.expert/explorer/testnet/tx/${event.transactionHash}`}
target="_blank"
rel="noopener noreferrer"
className="text-slate-500 hover:text-white transition-colors"
title="View on Explorer"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
)}
</div>
</div>
</div>
))}
</div>
))}
</div>
)}

{events.length === 0 && !isLoading && (
<div className="text-center py-12 text-slate-400 bg-white/5 rounded-xl border border-dashed border-slate-700">
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/components/dashboard/SSEStatusIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ export function SSEStatusIndicator({
{/* Disconnect Banner */}
{showDisconnectBanner && isDisconnected && (
<div
className="fixed top-0 left-0 right-0 z-50 bg-red-500/90 text-white p-4 flex items-center justify-between gap-4 animate-in slide-in-from-top"
className={`fixed top-0 left-0 right-0 z-50 ${
isReconnecting
? "bg-yellow-500/90"
: "bg-red-500/90"
} text-white p-4 flex items-center justify-between gap-4 animate-in slide-in-from-top`}
role="alert"
>
<div className="flex items-center gap-3 max-w-2xl">
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/dashboard/dashboard-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import toast from "react-hot-toast";
import {
getDashboardAnalytics,
fetchDashboardData,
useDashboard,
dashboardQueryKey,
type DashboardSnapshot,
type Stream,
Expand Down Expand Up @@ -48,6 +47,7 @@ import {
type StreamFormData,
} from "../stream-creation/StreamCreationWizard";
import { TopUpModal } from "../stream-creation/TopUpModal";
import { useQueryClient } from "@tanstack/react-query";
import { CancelConfirmModal } from "../stream-creation/CancelConfirmModal";
import { StreamDetailsModal } from "./StreamDetailsModal";
import { Button } from "../ui/Button";
Expand Down
18 changes: 16 additions & 2 deletions frontend/src/components/streams/IncomingStreamCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import React from "react";
import { Button } from "@/components/ui/Button";
import { useStreamingAmount } from "@/hooks/useStreamingAmount";
import type {
Expand Down Expand Up @@ -32,7 +33,7 @@ function badgeClassName(status: IncomingStreamStatus): string {
}
}

export function IncomingStreamCard({
export const IncomingStreamCard = React.memo(function IncomingStreamCard({
stream,
withdrawing,
onWithdraw,
Expand Down Expand Up @@ -124,4 +125,17 @@ export function IncomingStreamCard({
</div>
</article>
);
}
}, (prevProps, nextProps) => {
return (
prevProps.stream.deposited === nextProps.stream.deposited &&
prevProps.stream.withdrawn === nextProps.stream.withdrawn &&
prevProps.stream.ratePerSecond === nextProps.stream.ratePerSecond &&
prevProps.stream.lastUpdateTime === nextProps.stream.lastUpdateTime &&
prevProps.stream.isActive === nextProps.stream.isActive &&
prevProps.stream.isPaused === nextProps.stream.isPaused &&
prevProps.stream.pausedAt === nextProps.stream.pausedAt &&
prevProps.stream.totalPausedDuration === nextProps.stream.totalPausedDuration &&
prevProps.stream.status === nextProps.stream.status &&
prevProps.withdrawing === nextProps.withdrawing
);
});
49 changes: 47 additions & 2 deletions frontend/src/components/wallet/WalletButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* - "Disconnect" button
*/

import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect, useCallback } from "react";
import { useWallet } from "@/context/wallet-context";
import {
shortenPublicKey,
Expand All @@ -27,8 +27,29 @@ export function WalletButton() {
const [modalOpen, setModalOpen] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [copied, setCopied] = useState(false);
const [walletUnavailable, setWalletUnavailable] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);

const checkWalletAvailability = useCallback(() => {
if (typeof window === "undefined") return true;
const freighterAvailable =
"freighter" in window || "stellar" in window;
return freighterAvailable;
}, []);

useEffect(() => {
if (status !== "connected") return;

const interval = setInterval(() => {
if (!checkWalletAvailability()) {
setWalletUnavailable(true);
disconnect();
}
}, 5000);

return () => clearInterval(interval);
}, [status, checkWalletAvailability, disconnect]);

// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
Expand Down Expand Up @@ -59,7 +80,11 @@ export function WalletButton() {
const handleDisconnect = () => {
setDropdownOpen(false);
setModalOpen(false);
disconnect();
try {
disconnect();
} catch {
setWalletUnavailable(true);
}
};

// Close modal when connection is successful
Expand All @@ -78,6 +103,26 @@ export function WalletButton() {
);
}

if (walletUnavailable) {
return (
<div className="flex items-center gap-2">
<span className="text-xs text-amber-600 dark:text-amber-400">
Wallet extension unavailable
</span>
<button
type="button"
className="wallet-connect-btn"
onClick={() => {
setWalletUnavailable(false);
setModalOpen(true);
}}
>
Reconnect
</button>
</div>
);
}

if (status === "connected" && session) {

const networkOk = isExpectedNetwork(session.network);
Expand Down
24 changes: 11 additions & 13 deletions frontend/src/lib/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,21 +88,19 @@ async function fetchStreams(
for (const endpoint of endpoints) {
try {
const response = await fetch(`${endpoint}?${params.toString()}`, { signal });
if (response.ok) {
const payload = (await response.json()) as
| BackendStream[]
| { data?: BackendStream[] };
return Array.isArray(payload) ? payload : payload.data ?? [];
}

if (response.status === 404) {
lastError = new Error(`Endpoint not found: ${endpoint}`);
continue;
}
if (response.ok) {
const payload = (await response.json()) as
| BackendStream[]
| { data?: BackendStream[] };
return Array.isArray(payload) ? payload : payload.data ?? [];
}

lastError = new Error(`Failed to fetch streams (${response.status}) from ${endpoint}`);
}
if (response.status === 404) {
lastError = new Error(`Endpoint not found: ${endpoint}`);
continue;
}

lastError = new Error(`Failed to fetch streams (${response.status}) from ${endpoint}`);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
throw err;
Expand Down
Loading
Loading