refactor(web): split LiveView into live-view module

- types.ts: LiveViewProps, LiveViewChatMessage; mockData: FEATURED_STREAM, CHAT_MESSAGES
- useLiveView: stream, chatMessages, msgInput, handleSend, addToast
- LiveViewPlayer, LiveViewStreamInfo, LiveViewRecommended, LiveViewChat, LiveViewSkeleton
- Layout h-[calc(100vh-120px)] -> min-h-layout-main; text-[10px] -> text-xs
- Stories: Default, Loading (Skeleton); decorator min-h-layout-page
- Re-export from LiveView.tsx

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
senke 2026-02-06 18:15:41 +01:00
parent 11611749df
commit 30df8c99ea
12 changed files with 482 additions and 282 deletions

View file

@ -1,21 +1,26 @@
import type { Meta, StoryObj } from '@storybook/react';
import { LiveView } from './LiveView';
import { LiveView, LiveViewSkeleton } from './live-view';
const meta: Meta<typeof LiveView> = {
title: 'Components/Features/Views/LiveView',
component: LiveView,
parameters: { layout: 'fullscreen' },
tags: ['autodocs'],
decorators: [
(Story) => (
<div className="bg-kodo-background min-h-screen">
<Story />
</div>
),
],
title: 'Components/Features/Views/LiveView',
component: LiveView,
parameters: { layout: 'fullscreen' },
tags: ['autodocs'],
decorators: [
(Story) => (
<div className="bg-kodo-background min-h-layout-page">
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Loading: Story = {
name: 'Loading',
render: () => <LiveViewSkeleton />,
};
export const Default: Story = { name: 'Par défaut' };

View file

@ -1,270 +1 @@
import React, { useState } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import {
Users,
Heart,
Share2,
DollarSign,
MessageSquare,
Send,
Radio,
Settings,
Maximize2,
} from 'lucide-react';
import { LiveStream } from '../../types';
import { useToast } from '../../components/feedback/ToastProvider';
const featuredStream: LiveStream = {
id: '1',
title: 'Late Night DnB Production 🎧 | Feedback Session',
streamer: 'Neuro_Glitch',
viewers: 1240,
thumbnailUrl: 'https://picsum.photos/id/140/800/450',
tags: ['Production', 'Ableton', 'DnB'],
isLive: true,
category: 'Production',
};
const chatMessages = [
{
user: 'BassHead99',
text: 'That Reese bass is filthy! 🤮🔥',
color: 'text-kodo-steel',
},
{ user: 'Studio_Rat', text: 'What VST is that?', color: 'text-kodo-content-dim' },
{
user: 'Neuro_Glitch',
text: "It's Phase Plant, just initializing now.",
color: 'text-kodo-gold font-bold',
},
{
user: 'VocalChops',
text: 'Sent a $5 dono! Check my track?',
color: 'text-kodo-lime',
},
];
export const LiveView: React.FC = () => {
const { addToast } = useToast();
const [msgInput, setMsgInput] = useState('');
const handleSend = () => {
if (!msgInput) return;
addToast('Message sent to chat', 'success');
setMsgInput('');
};
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 h-[calc(100vh-120px)] animate-fadeIn">
{/* Main Stream Area */}
<div className="lg:col-span-9 flex flex-col gap-4">
<div className="relative aspect-video bg-black rounded-xl overflow-hidden shadow-2xl border border-kodo-steel group">
{/* Mock Video Feed */}
<img
src={featuredStream.thumbnailUrl}
className="w-full h-full object-cover opacity-80"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"></div>
{/* Live Indicator */}
<div className="absolute top-4 left-4 flex gap-2">
<span className="bg-kodo-red text-white px-2 py-1 text-xs font-bold rounded flex items-center gap-1 animate-pulse">
<Radio className="w-3 h-3" /> LIVE
</span>
<span className="bg-black/50 backdrop-blur text-white px-2 py-1 text-xs font-mono rounded flex items-center gap-1">
<Users className="w-3 h-3" /> {featuredStream.viewers}
</span>
</div>
{/* Stream Controls Overlay */}
<div className="absolute bottom-0 left-0 right-0 p-4 flex justify-between items-end opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div className="flex gap-4">
<Button
variant="ghost"
size="sm"
className="text-white hover:bg-white/10"
onClick={() => addToast('Chat hidden')}
>
<MessageSquare className="w-5 h-5" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-white hover:bg-white/10"
onClick={() => addToast('Stream Settings')}
>
<Settings className="w-5 h-5" />
</Button>
</div>
<div className="flex gap-4">
<Button
variant="ghost"
size="sm"
className="text-white hover:bg-white/10"
onClick={() => addToast('Entering Fullscreen')}
>
<Maximize2 className="w-5 h-5" />
</Button>
</div>
</div>
</div>
{/* Stream Info */}
<div className="flex justify-between items-start">
<div className="flex gap-4">
<div className="w-12 h-12 rounded-full bg-gradient-neon p-0.5">
<img
src="https://picsum.photos/100/100"
className="w-full h-full rounded-full object-cover border-2 border-kodo-void"
/>
</div>
<div>
<h1 className="text-3xl font-bold text-white">
{featuredStream.title}
</h1>
<p
className="text-kodo-cyan font-medium cursor-pointer hover:underline"
onClick={() => addToast('Opening Streamer Profile')}
>
{featuredStream.streamer}
</p>
<div className="flex gap-2 mt-2">
{featuredStream.tags.map((tag) => (
<Badge key={tag} label={tag} variant="terminal" />
))}
</div>
</div>
</div>
<div className="flex gap-4">
<Button
variant="secondary"
icon={<Heart className="w-4 h-4" />}
onClick={() => addToast('Followed Streamer', 'success')}
>
FOLLOW
</Button>
<Button
variant="primary"
icon={<DollarSign className="w-4 h-4" />}
onClick={() => addToast('Donation modal opening...', 'info')}
>
DONATE
</Button>
<Button
variant="ghost"
icon={<Share2 className="w-4 h-4" />}
onClick={() => addToast('Stream link copied!')}
>
SHARE
</Button>
</div>
</div>
{/* Suggested Streams */}
<div className="mt-4">
<h3 className="font-bold text-kodo-content-dim mb-4 uppercase text-sm tracking-wider">
Recommended Channels
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<Card
key={i}
variant="default"
className="p-0 overflow-hidden group cursor-pointer"
onClick={() => addToast('Switching stream...')}
>
<div className="aspect-video relative">
<img
src={`https://picsum.photos/300/200?random=${i}`}
className="w-full h-full object-cover"
/>
<div className="absolute bottom-2 left-2 bg-kodo-void/80 px-2 py-0.5 rounded text-[10px] text-white">
DJ Set
</div>
</div>
<div className="p-4 flex gap-2">
<div className="w-8 h-8 rounded-full bg-kodo-steel"></div>
<div>
<div className="font-bold text-sm text-white truncate">
Techno Bunker 24/7
</div>
<div className="text-xs text-kodo-content-dim">
Underground_Radio
</div>
</div>
</div>
</Card>
))}
</div>
</div>
</div>
{/* Live Chat */}
<Card
variant="glass"
className="lg:col-span-3 flex flex-col p-0 overflow-hidden h-full max-h-[calc(100vh-120px)]"
>
<div className="p-4 border-b border-kodo-steel/50 flex justify-between items-center bg-kodo-ink">
<span className="font-mono text-sm font-bold text-white">
STREAM CHAT
</span>
<div className="w-2 h-2 bg-kodo-lime rounded-full animate-pulse"></div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4 font-mono text-sm">
{chatMessages.map((msg, i) => (
<div key={i} className="break-words">
<span
className={`font-bold ${msg.color} mr-2 cursor-pointer hover:underline`}
>
{msg.user}:
</span>
<span className="text-kodo-text-main">{msg.text}</span>
</div>
))}
<div className="text-center py-2">
<span className="text-[10px] text-kodo-content-dim bg-kodo-slate px-2 py-1 rounded-full">
Welcome to the chat room!
</span>
</div>
</div>
<div className="p-4 bg-kodo-slate border-t border-kodo-steel">
<div className="flex gap-2">
<div className="relative flex-1">
<input
value={msgInput}
onChange={(e) => setMsgInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
className="w-full bg-kodo-ink border border-kodo-steel rounded px-4 py-2 text-sm text-white focus:border-kodo-steel outline-none"
placeholder="Say something..."
/>
<DollarSign className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-kodo-gold cursor-pointer hover:text-kodo-gold/80 transition-colors" />
</div>
<Button
variant="primary"
size="sm"
className="px-4"
onClick={handleSend}
>
<Send className="w-4 h-4" />
</Button>
</div>
<div className="flex justify-between mt-2 px-1">
<span className="text-[10px] text-kodo-content-dim">
Balance: 420 $VEZA
</span>
<span
className="text-[10px] text-kodo-steel cursor-pointer"
onClick={() => addToast('Opening Wallet...')}
>
Get Coins
</span>
</div>
</div>
</Card>
</div>
);
};
export { LiveView } from './live-view';

View file

@ -0,0 +1,50 @@
import React from 'react';
import { useLiveView } from './useLiveView';
import { LiveViewPlayer } from './LiveViewPlayer';
import { LiveViewStreamInfo } from './LiveViewStreamInfo';
import { LiveViewRecommended } from './LiveViewRecommended';
import { LiveViewChat } from './LiveViewChat';
import type { LiveViewProps } from './types';
export function LiveView({ stream: streamOverride, chatMessages: chatOverride }: LiveViewProps = {}) {
const {
stream,
chatMessages,
msgInput,
setMsgInput,
handleSend,
addToast,
} = useLiveView({
stream: streamOverride ?? undefined,
chatMessages: chatOverride,
});
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 min-h-layout-main animate-fadeIn">
<div className="lg:col-span-9 flex flex-col gap-4">
<LiveViewPlayer
stream={stream}
onToggleChat={() => addToast('Chat hidden')}
onSettings={() => addToast('Stream Settings')}
onFullscreen={() => addToast('Entering Fullscreen')}
/>
<LiveViewStreamInfo
stream={stream}
onStreamerClick={() => addToast('Opening Streamer Profile')}
onFollow={() => addToast('Followed Streamer', 'success')}
onDonate={() => addToast('Donation modal opening...', 'info')}
onShare={() => addToast('Stream link copied!')}
/>
<LiveViewRecommended onChannelClick={() => addToast('Switching stream...')} />
</div>
<LiveViewChat
messages={chatMessages}
msgInput={msgInput}
onMsgInputChange={setMsgInput}
onSend={handleSend}
onWalletClick={() => addToast('Opening Wallet...')}
/>
</div>
);
}

View file

@ -0,0 +1,82 @@
import React from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Send, DollarSign } from 'lucide-react';
import type { LiveViewChatMessage } from './types';
interface LiveViewChatProps {
messages: LiveViewChatMessage[];
msgInput: string;
onMsgInputChange: (value: string) => void;
onSend: () => void;
onWalletClick?: () => void;
}
export function LiveViewChat({
messages,
msgInput,
onMsgInputChange,
onSend,
onWalletClick,
}: LiveViewChatProps) {
return (
<Card
variant="glass"
className="lg:col-span-3 flex flex-col p-0 overflow-hidden h-full min-h-0"
>
<div className="p-4 border-b border-kodo-steel/50 flex justify-between items-center bg-kodo-ink">
<span className="font-mono text-sm font-bold text-white">
STREAM CHAT
</span>
<div className="w-2 h-2 bg-kodo-lime rounded-full animate-pulse" />
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4 font-mono text-sm">
{messages.map((msg, i) => (
<div key={i} className="break-words">
<span
className={`font-bold ${msg.color} mr-2 cursor-pointer hover:underline`}
>
{msg.user}:
</span>
<span className="text-kodo-text-main">{msg.text}</span>
</div>
))}
<div className="text-center py-2">
<span className="text-xs text-kodo-content-dim bg-kodo-slate px-2 py-1 rounded-full">
Welcome to the chat room!
</span>
</div>
</div>
<div className="p-4 bg-kodo-slate border-t border-kodo-steel">
<div className="flex gap-2">
<div className="relative flex-1">
<input
value={msgInput}
onChange={(e) => onMsgInputChange(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onSend()}
className="w-full bg-kodo-ink border border-kodo-steel rounded px-4 py-2 text-sm text-white focus:border-kodo-steel outline-none"
placeholder="Say something..."
/>
<DollarSign className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-kodo-gold cursor-pointer hover:text-kodo-gold/80 transition-colors" />
</div>
<Button variant="primary" size="sm" className="px-4" onClick={onSend}>
<Send className="w-4 h-4" />
</Button>
</div>
<div className="flex justify-between mt-2 px-1">
<span className="text-xs text-kodo-content-dim">
Balance: 420 $VEZA
</span>
<span
className="text-xs text-kodo-steel cursor-pointer"
onClick={onWalletClick}
>
Get Coins
</span>
</div>
</div>
</Card>
);
}

View file

@ -0,0 +1,69 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Users, Radio, MessageSquare, Settings, Maximize2 } from 'lucide-react';
import type { LiveStream } from '@/types';
interface LiveViewPlayerProps {
stream: LiveStream;
onToggleChat?: () => void;
onSettings?: () => void;
onFullscreen?: () => void;
}
export function LiveViewPlayer({
stream,
onToggleChat,
onSettings,
onFullscreen,
}: LiveViewPlayerProps) {
return (
<div className="relative aspect-video bg-black rounded-xl overflow-hidden shadow-2xl border border-kodo-steel group">
<img
src={stream.thumbnailUrl}
alt=""
className="w-full h-full object-cover opacity-80"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
<div className="absolute top-4 left-4 flex gap-2">
<span className="bg-kodo-red text-white px-2 py-1 text-xs font-bold rounded flex items-center gap-1 animate-pulse">
<Radio className="w-3 h-3" /> LIVE
</span>
<span className="bg-black/50 backdrop-blur text-white px-2 py-1 text-xs font-mono rounded flex items-center gap-1">
<Users className="w-3 h-3" /> {stream.viewers}
</span>
</div>
<div className="absolute bottom-0 left-0 right-0 p-4 flex justify-between items-end opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div className="flex gap-4">
<Button
variant="ghost"
size="sm"
className="text-white hover:bg-white/10"
onClick={onToggleChat}
>
<MessageSquare className="w-5 h-5" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-white hover:bg-white/10"
onClick={onSettings}
>
<Settings className="w-5 h-5" />
</Button>
</div>
<div className="flex gap-4">
<Button
variant="ghost"
size="sm"
className="text-white hover:bg-white/10"
onClick={onFullscreen}
>
<Maximize2 className="w-5 h-5" />
</Button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,48 @@
import React from 'react';
import { Card } from '@/components/ui/card';
interface LiveViewRecommendedProps {
onChannelClick?: (index: number) => void;
}
export function LiveViewRecommended({ onChannelClick }: LiveViewRecommendedProps) {
return (
<div className="mt-4">
<h3 className="font-bold text-kodo-content-dim mb-4 uppercase text-sm tracking-wider">
Recommended Channels
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<Card
key={i}
variant="default"
className="p-0 overflow-hidden group cursor-pointer"
onClick={() => onChannelClick?.(i)}
>
<div className="aspect-video relative">
<img
src={`https://picsum.photos/300/200?random=${i}`}
alt=""
className="w-full h-full object-cover"
/>
<div className="absolute bottom-2 left-2 bg-kodo-void/80 px-2 py-0.5 rounded text-xs text-white">
DJ Set
</div>
</div>
<div className="p-4 flex gap-2">
<div className="w-8 h-8 rounded-full bg-kodo-steel" />
<div>
<div className="font-bold text-sm text-white truncate">
Techno Bunker 24/7
</div>
<div className="text-xs text-kodo-content-dim">
Underground_Radio
</div>
</div>
</div>
</Card>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,48 @@
import React from 'react';
import { Skeleton } from '@/components/ui/skeleton';
export function LiveViewSkeleton() {
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 min-h-layout-main animate-fadeIn">
<div className="lg:col-span-9 flex flex-col gap-4">
<Skeleton className="aspect-video w-full rounded-xl" />
<div className="flex justify-between items-start gap-4">
<div className="flex gap-4">
<Skeleton className="w-12 h-12 rounded-full shrink-0" />
<div className="space-y-2 min-w-0">
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-4 w-32" />
<div className="flex gap-2">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-20" />
<Skeleton className="h-5 w-14" />
</div>
</div>
</div>
<div className="flex gap-2 shrink-0">
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-20" />
</div>
</div>
<div className="mt-4">
<Skeleton className="h-4 w-48 mb-4" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="aspect-video w-full rounded-xl" />
))}
</div>
</div>
</div>
<div className="lg:col-span-3 flex flex-col min-h-0">
<Skeleton className="h-12 rounded-t-xl mb-4" />
<div className="flex-1 space-y-4">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
<Skeleton className="h-24 rounded-b-xl mt-4" />
</div>
</div>
);
}

View file

@ -0,0 +1,72 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Heart, Share2, DollarSign } from 'lucide-react';
import type { LiveStream } from '@/types';
interface LiveViewStreamInfoProps {
stream: LiveStream;
onStreamerClick?: () => void;
onFollow?: () => void;
onDonate?: () => void;
onShare?: () => void;
}
export function LiveViewStreamInfo({
stream,
onStreamerClick,
onFollow,
onDonate,
onShare,
}: LiveViewStreamInfoProps) {
return (
<div className="flex justify-between items-start">
<div className="flex gap-4">
<div className="w-12 h-12 rounded-full bg-gradient-neon p-0.5">
<img
src="https://picsum.photos/100/100"
alt=""
className="w-full h-full rounded-full object-cover border-2 border-kodo-void"
/>
</div>
<div>
<h1 className="text-3xl font-bold text-white">{stream.title}</h1>
<p
className="text-kodo-cyan font-medium cursor-pointer hover:underline"
onClick={onStreamerClick}
>
{stream.streamer}
</p>
<div className="flex gap-2 mt-2">
{stream.tags.map((tag) => (
<Badge key={tag} label={tag} variant="terminal" />
))}
</div>
</div>
</div>
<div className="flex gap-4">
<Button
variant="secondary"
icon={<Heart className="w-4 h-4" />}
onClick={onFollow}
>
FOLLOW
</Button>
<Button
variant="primary"
icon={<DollarSign className="w-4 h-4" />}
onClick={onDonate}
>
DONATE
</Button>
<Button
variant="ghost"
icon={<Share2 className="w-4 h-4" />}
onClick={onShare}
>
SHARE
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,6 @@
export type { LiveViewProps, LiveViewChatMessage } from './types';
export type { LiveStream } from '@/types';
export { LiveView } from './LiveView';
export { LiveViewSkeleton } from './LiveViewSkeleton';
export { useLiveView } from './useLiveView';
export { FEATURED_STREAM, CHAT_MESSAGES } from './mockData';

View file

@ -0,0 +1,36 @@
import type { LiveStream } from '@/types';
import type { LiveViewChatMessage } from './types';
export const FEATURED_STREAM: LiveStream = {
id: '1',
title: 'Late Night DnB Production 🎧 | Feedback Session',
streamer: 'Neuro_Glitch',
viewers: 1240,
thumbnailUrl: 'https://picsum.photos/id/140/800/450',
tags: ['Production', 'Ableton', 'DnB'],
isLive: true,
category: 'Production',
};
export const CHAT_MESSAGES: LiveViewChatMessage[] = [
{
user: 'BassHead99',
text: 'That Reese bass is filthy! 🤮🔥',
color: 'text-kodo-steel',
},
{
user: 'Studio_Rat',
text: 'What VST is that?',
color: 'text-kodo-content-dim',
},
{
user: 'Neuro_Glitch',
text: "It's Phase Plant, just initializing now.",
color: 'text-kodo-gold font-bold',
},
{
user: 'VocalChops',
text: 'Sent a $5 dono! Check my track?',
color: 'text-kodo-lime',
},
];

View file

@ -0,0 +1,16 @@
import type { LiveStream } from '@/types';
export type { LiveStream };
export interface LiveViewChatMessage {
user: string;
text: string;
color: string;
}
export interface LiveViewProps {
/** Optional stream override for stories */
stream?: LiveStream | null;
/** Optional chat messages override */
chatMessages?: LiveViewChatMessage[];
}

View file

@ -0,0 +1,37 @@
import { useState, useCallback } from 'react';
import { useToast } from '@/components/feedback/ToastProvider';
import { FEATURED_STREAM, CHAT_MESSAGES } from './mockData';
import type { LiveStream } from '@/types';
import type { LiveViewChatMessage } from './types';
export interface UseLiveViewOptions {
stream?: LiveStream | null;
chatMessages?: LiveViewChatMessage[];
onSendMessage?: (text: string) => void;
}
export function useLiveView(options: UseLiveViewOptions = {}) {
const { addToast } = useToast();
const stream = options.stream ?? FEATURED_STREAM;
const chatMessages = options.chatMessages ?? CHAT_MESSAGES;
const [msgInput, setMsgInput] = useState('');
const handleSend = useCallback(() => {
if (!msgInput.trim()) return;
if (options.onSendMessage) {
options.onSendMessage(msgInput);
} else {
addToast('Message sent to chat', 'success');
}
setMsgInput('');
}, [msgInput, options.onSendMessage, addToast]);
return {
stream,
chatMessages,
msgInput,
setMsgInput,
handleSend,
addToast,
};
}