Skip to content

Commit a0f16fe

Browse files
author
StellarStream Dev
committed
feat: complete stream detail page redesign with moon theme
- Redesigned stream/[id]/page.tsx with moon theme nebula backgrounds - Created StreamHeader component with org avatar, status badge, sender/receiver - Created FlowRateGauge with animated SVG semi-circle gauge (green to cyan) - Created BalanceProjectionChart using Recharts with green-yellow-red color shift - Created ProgressBar with animated progress and days/weeks remaining display - Created TransactionHistory with pagination (20 per page), type icons, status badges - Created ActionButtons with 5 action buttons, ripple animation, and modals - Created StreamSkeleton for loading states with shimmer effects - Created StreamError with retry button - Updated ViewStreamClient with WebSocket real-time updates (socket.io) - All components use Stellar Glass design system with glass-card aesthetic - Mobile-first single column layout with responsive grid - Loading skeleton and error state with retry functionality - Accessibility: aria-labels, keyboard navigation, semantic structure
1 parent 86508ce commit a0f16fe

10 files changed

Lines changed: 1603 additions & 141 deletions

frontend/app/stream/[id]/page.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/**
2-
* Public stream preview page — Server Component with ISR.
2+
* Stream detail page — Server Component with ISR.
3+
* Complete redesign with moon theme, enhanced data visualization, and action buttons.
34
*/
45

56
import type { Metadata } from "next";
@@ -57,7 +58,7 @@ export default async function ViewStreamPage({
5758
Stream Not Found
5859
</h1>
5960
<p className="font-body text-sm text-white/40">
60-
The stream you&apos;re looking for doesn&apos;t exist or has been removed.
61+
The stream you're looking for doesn't exist or has been removed.
6162
</p>
6263
<a
6364
href="/"
@@ -69,10 +70,17 @@ export default async function ViewStreamPage({
6970
);
7071
}
7172

73+
// Render the stream detail page with all new components
74+
// The skeleton/loading states are handled inside ViewStreamClient
7275
return (
73-
<div className="flex min-h-screen items-start justify-center p-6 pt-12">
74-
{/* Static shell rendered on the server — hydrated by ViewStreamClient */}
75-
<ViewStreamClient stream={stream} />
76+
<div className="relative min-h-screen">
77+
{/* Nebula background blobs */}
78+
<div className="nebula-blob nebula-cyan" />
79+
<div className="nebula-blob nebula-violet" />
80+
81+
<main className="relative z-10 mx-auto flex w-full max-w-4xl items-start justify-center p-4 pb-20 pt-8 sm:p-6 sm:pt-12">
82+
<ViewStreamClient stream={stream} />
83+
</main>
7684
</div>
7785
);
78-
}
86+
}
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
"use client";
2+
3+
import React, { useState, useCallback } from "react";
4+
import { motion, AnimatePresence } from "framer-motion";
5+
import type { StreamData } from "@/components/view-stream-client";
6+
7+
interface ActionButtonsProps {
8+
stream: StreamData;
9+
}
10+
11+
type ActionType = "pause" | "resume" | "cancel" | "withdraw" | "topup";
12+
13+
interface ActionModal {
14+
type: ActionType;
15+
gasEstimate: string;
16+
description: string;
17+
confirmLabel: string;
18+
danger?: boolean;
19+
}
20+
21+
const ACTION_MODALS: Record<ActionType, ActionModal> = {
22+
pause: {
23+
type: "pause",
24+
gasEstimate: "0.0012 XLM",
25+
description: "Pause the stream. Funds will stop flowing until resumed.",
26+
confirmLabel: "Pause Stream",
27+
},
28+
resume: {
29+
type: "resume",
30+
gasEstimate: "0.0011 XLM",
31+
description: "Resume the stream. Funds will continue flowing.",
32+
confirmLabel: "Resume Stream",
33+
},
34+
cancel: {
35+
type: "cancel",
36+
gasEstimate: "0.0015 XLM",
37+
description:
38+
"Cancel the stream permanently. Unstreamed funds return to sender. This action cannot be undone.",
39+
confirmLabel: "Cancel Stream",
40+
danger: true,
41+
},
42+
withdraw: {
43+
type: "withdraw",
44+
gasEstimate: "0.0013 XLM",
45+
description:
46+
"Withdraw streamed funds to your wallet.",
47+
confirmLabel: "Withdraw Funds",
48+
},
49+
topup: {
50+
type: "topup",
51+
gasEstimate: "0.0010 XLM",
52+
description:
53+
"Add more funds to the stream. Extends the stream duration.",
54+
confirmLabel: "Add Funds",
55+
},
56+
};
57+
58+
/**
59+
* Action Buttons — quick action buttons with hover effects, ripple animation,
60+
* and modals showing gas estimates for each operation.
61+
*/
62+
export function ActionButtons({ stream }: ActionButtonsProps) {
63+
const [activeModal, setActiveModal] = useState<ActionType | null>(null);
64+
const [loadingAction, setLoadingAction] = useState<string | null>(null);
65+
const [ripple, setRipple] = useState<{ x: number; y: number; id: string } | null>(null);
66+
67+
const isActive = stream.status === "active";
68+
const isPaused = stream.status === "paused";
69+
70+
const actions: { type: ActionType; label: string; icon: React.ReactNode; available: boolean }[] = [
71+
{
72+
type: "pause",
73+
label: "Pause",
74+
icon: (
75+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
76+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" />
77+
</svg>
78+
),
79+
available: isActive,
80+
},
81+
{
82+
type: "resume",
83+
label: "Resume",
84+
icon: (
85+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
86+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
87+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
88+
</svg>
89+
),
90+
available: isPaused,
91+
},
92+
{
93+
type: "withdraw",
94+
label: "Withdraw",
95+
icon: (
96+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
97+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
98+
</svg>
99+
),
100+
available: true,
101+
},
102+
{
103+
type: "topup",
104+
label: "Top Up",
105+
icon: (
106+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
107+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
108+
</svg>
109+
),
110+
available: isActive || isPaused,
111+
},
112+
{
113+
type: "cancel",
114+
label: "Cancel",
115+
icon: (
116+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
117+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
118+
</svg>
119+
),
120+
available: isActive || isPaused,
121+
},
122+
];
123+
124+
const handleClick = useCallback(
125+
(e: React.MouseEvent<HTMLButtonElement>, type: ActionType) => {
126+
// Ripple effect
127+
const rect = e.currentTarget.getBoundingClientRect();
128+
const x = e.clientX - rect.left;
129+
const y = e.clientY - rect.top;
130+
setRipple({ x, y, id: type });
131+
setTimeout(() => setRipple(null), 600);
132+
133+
// Open modal
134+
setActiveModal(type);
135+
},
136+
[],
137+
);
138+
139+
const handleConfirm = useCallback(async () => {
140+
if (!activeModal) return;
141+
setLoadingAction(activeModal);
142+
// Simulate transaction
143+
await new Promise((resolve) => setTimeout(resolve, 2000));
144+
setLoadingAction(null);
145+
setActiveModal(null);
146+
}, [activeModal]);
147+
148+
return (
149+
<>
150+
{/* Action buttons */}
151+
<motion.div
152+
initial={{ opacity: 0, y: 12 }}
153+
animate={{ opacity: 1, y: 0 }}
154+
transition={{ duration: 0.6, delay: 0.35, ease: "easeOut" }}
155+
className="rounded-2xl border border-white/10 bg-white/[0.03] p-5 backdrop-blur-xl"
156+
>
157+
<p className="mb-4 font-body text-[10px] uppercase tracking-widest text-white/35">
158+
Quick Actions
159+
</p>
160+
161+
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
162+
{actions.map((action) => (
163+
<button
164+
key={action.type}
165+
onClick={(e) => handleClick(e, action.type)}
166+
disabled={!action.available}
167+
className="group relative overflow-hidden rounded-xl border border-white/10 bg-white/[0.02] p-3 text-center transition-all duration-200 hover:border-white/20 hover:bg-white/[0.05] disabled:cursor-not-allowed disabled:opacity-30"
168+
aria-label={`${action.label} stream`}
169+
>
170+
{/* Ripple layer */}
171+
{ripple?.id === action.type && (
172+
<span
173+
className="pointer-events-none absolute h-16 w-16 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white/10"
174+
style={{
175+
left: ripple.x,
176+
top: ripple.y,
177+
animation: "ripple-anim 0.6s ease-out forwards",
178+
}}
179+
/>
180+
)}
181+
182+
{/* Icon */}
183+
<div className="mb-1.5 flex justify-center text-white/40 transition-colors group-hover:text-[#00f5ff] group-hover:drop-shadow-[0_0_6px_rgba(0,245,255,0.3)]">
184+
{action.icon}
185+
</div>
186+
187+
{/* Label */}
188+
<span className="font-body text-[10px] font-medium uppercase tracking-wider text-white/40 transition-colors group-hover:text-white/70">
189+
{action.label}
190+
</span>
191+
192+
{/* Glow border on hover */}
193+
<div className="pointer-events-none absolute inset-0 rounded-xl opacity-0 transition-opacity duration-300 group-hover:opacity-100"
194+
style={{
195+
boxShadow: "inset 0 0 20px rgba(0,245,255,0.05)",
196+
}}
197+
/>
198+
</button>
199+
))}
200+
</div>
201+
</motion.div>
202+
203+
{/* Modal */}
204+
<AnimatePresence>
205+
{activeModal && (
206+
<motion.div
207+
initial={{ opacity: 0 }}
208+
animate={{ opacity: 1 }}
209+
exit={{ opacity: 0 }}
210+
className="fixed inset-0 z-50 flex items-center justify-center p-4"
211+
onClick={() => setActiveModal(null)}
212+
>
213+
{/* Backdrop */}
214+
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
215+
216+
{/* Modal content */}
217+
<motion.div
218+
initial={{ opacity: 0, scale: 0.95, y: 10 }}
219+
animate={{ opacity: 1, scale: 1, y: 0 }}
220+
exit={{ opacity: 0, scale: 0.95, y: 10 }}
221+
transition={{ duration: 0.2, ease: "easeOut" }}
222+
onClick={(e) => e.stopPropagation()}
223+
className="relative w-full max-w-md rounded-2xl border border-white/10 bg-[#0a0a14] p-6 shadow-2xl"
224+
>
225+
{/* Close button */}
226+
<button
227+
onClick={() => setActiveModal(null)}
228+
className="absolute right-4 top-4 text-white/30 transition-colors hover:text-white/60"
229+
aria-label="Close modal"
230+
>
231+
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
232+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
233+
</svg>
234+
</button>
235+
236+
{/* Modal icon */}
237+
<div
238+
className={`mb-4 flex h-12 w-12 items-center justify-center rounded-xl ${
239+
ACTION_MODALS[activeModal].danger
240+
? "bg-red-500/10 text-red-400"
241+
: "bg-[#00f5ff]/10 text-[#00f5ff]"
242+
}`}
243+
>
244+
{actions.find((a) => a.type === activeModal)?.icon}
245+
</div>
246+
247+
{/* Title */}
248+
<h2 className="font-heading text-lg font-bold text-white">
249+
{ACTION_MODALS[activeModal].confirmLabel}
250+
</h2>
251+
252+
{/* Description */}
253+
<p className="mt-2 font-body text-sm text-white/50">
254+
{ACTION_MODALS[activeModal].description}
255+
</p>
256+
257+
{/* Stream info */}
258+
<div className="mt-4 rounded-xl border border-white/5 bg-white/[0.02] p-3">
259+
<div className="flex items-center justify-between text-sm">
260+
<span className="font-body text-white/40">Stream</span>
261+
<span className="font-ticker text-white/70">{stream.name}</span>
262+
</div>
263+
</div>
264+
265+
{/* Gas estimate */}
266+
<div className="mt-3 flex items-center justify-between rounded-xl border border-amber-500/20 bg-amber-500/5 p-3">
267+
<div className="flex items-center gap-2">
268+
<svg className="h-4 w-4 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
269+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
270+
</svg>
271+
<span className="font-body text-xs text-white/50">Estimated Gas Fee</span>
272+
</div>
273+
<span className="font-ticker text-sm font-semibold text-amber-400">
274+
{ACTION_MODALS[activeModal].gasEstimate}
275+
</span>
276+
</div>
277+
278+
{/* Action buttons */}
279+
<div className="mt-6 flex gap-3">
280+
<button
281+
onClick={() => setActiveModal(null)}
282+
className="flex-1 rounded-xl border border-white/10 py-2.5 font-body text-sm text-white/50 transition-colors hover:border-white/20 hover:text-white/70"
283+
>
284+
Cancel
285+
</button>
286+
<button
287+
onClick={handleConfirm}
288+
disabled={loadingAction === activeModal}
289+
className={`flex-1 rounded-xl py-2.5 font-body text-sm font-semibold transition-all ${
290+
ACTION_MODALS[activeModal].danger
291+
? "bg-red-500/80 text-white hover:bg-red-500"
292+
: "bg-[#00f5ff]/80 text-black hover:bg-[#00f5ff]"
293+
} disabled:cursor-not-allowed disabled:opacity-60`}
294+
>
295+
{loadingAction === activeModal ? (
296+
<span className="flex items-center justify-center gap-2">
297+
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
298+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
299+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
300+
</svg>
301+
Processing...
302+
</span>
303+
) : (
304+
ACTION_MODALS[activeModal].confirmLabel
305+
)}
306+
</button>
307+
</div>
308+
</motion.div>
309+
</motion.div>
310+
)}
311+
</AnimatePresence>
312+
313+
{/* Ripple animation keyframes injected once */}
314+
<style>{`
315+
@keyframes ripple-anim {
316+
0% { transform: translate(-50%, -50%) scale(0); opacity: 0.5; }
317+
100% { transform: translate(-50%, -50%) scale(4); opacity: 0; }
318+
}
319+
`}</style>
320+
</>
321+
);
322+
}

0 commit comments

Comments
 (0)