feat(ui): Zone 10 - SocialView SaaS polish (glass, glow, motion, error/empty)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a3a3dd6546
commit
c1ce0c4b5a
10 changed files with 114 additions and 26 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { SocialView, SocialViewSkeleton } from './social-view';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { SocialView, SocialViewSkeleton, SocialViewError } from './social-view';
|
||||
|
||||
const meta: Meta<typeof SocialView> = {
|
||||
title: 'Components/Features/Views/SocialView',
|
||||
|
|
@ -8,7 +9,7 @@ const meta: Meta<typeof SocialView> = {
|
|||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="bg-background min-h-layout-page">
|
||||
<div className="bg-background min-h-layout-page p-4 sm:p-6 lg:p-8">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
|
|
@ -18,12 +19,42 @@ const meta: Meta<typeof SocialView> = {
|
|||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
name: 'Par défaut',
|
||||
args: { onViewProfile: () => {} },
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
name: 'Loading',
|
||||
render: () => <SocialViewSkeleton />,
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
name: 'Par défaut',
|
||||
args: { onViewProfile: (userId: string | null) => console.log('View profile', userId) },
|
||||
export const Error: Story = {
|
||||
name: 'Error',
|
||||
render: () => <SocialViewError onRetry={() => {}} />,
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
name: 'Empty',
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('*/api/v1/tracks', () =>
|
||||
HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
args: { onViewProfile: () => {} },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,18 +4,23 @@ import { SocialViewSidebar } from './SocialViewSidebar';
|
|||
import { SocialViewFeed } from './SocialViewFeed';
|
||||
import { SocialViewTrending } from './SocialViewTrending';
|
||||
import { SocialViewSkeleton } from './SocialViewSkeleton';
|
||||
import { SocialViewError } from './SocialViewError';
|
||||
import type { SocialViewProps } from './types';
|
||||
import type { SocialTabKey } from './types';
|
||||
|
||||
export function SocialView({ onViewProfile }: SocialViewProps) {
|
||||
const { activeTab, setActiveTab, feedTracks, loading, playTrack } = useSocialView();
|
||||
const { activeTab, setActiveTab, feedTracks, loading, error, retry, playTrack } = useSocialView();
|
||||
|
||||
if (loading) {
|
||||
return <SocialViewSkeleton />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <SocialViewError onRetry={retry} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 animate-fadeIn pb-20">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 animate-fadeIn pb-20 min-h-layout-page">
|
||||
<SocialViewSidebar
|
||||
activeTab={activeTab}
|
||||
onTabChange={(t) => setActiveTab(t as SocialTabKey)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface SocialViewErrorProps {
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export function SocialViewError({ onRetry }: SocialViewErrorProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-layout-page-sm text-center px-4">
|
||||
<p className="text-destructive font-medium mb-2">Failed to load feed</p>
|
||||
<p className="text-muted-foreground text-sm mb-4">
|
||||
We couldn't load the community feed. Please try again.
|
||||
</p>
|
||||
{onRetry != null && (
|
||||
<Button variant="outline" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,19 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { SocialViewFeedItem } from './SocialViewFeedItem';
|
||||
import type { Track } from '@/types/api';
|
||||
|
||||
const listVariants = {
|
||||
visible: {
|
||||
transition: { staggerChildren: 0.05, delayChildren: 0.02 },
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 8 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
};
|
||||
|
||||
interface SocialViewFeedProps {
|
||||
tracks: Track[];
|
||||
loading: boolean;
|
||||
|
|
@ -18,14 +30,20 @@ export function SocialViewFeed({ tracks, loading, onPlayTrack }: SocialViewFeedP
|
|||
|
||||
{loading ? null : (
|
||||
<>
|
||||
{tracks.map((track) => (
|
||||
<SocialViewFeedItem
|
||||
key={track.id}
|
||||
track={track}
|
||||
onPlay={onPlayTrack}
|
||||
/>
|
||||
))}
|
||||
{tracks.length === 0 && (
|
||||
{tracks.length > 0 ? (
|
||||
<motion.div
|
||||
className="space-y-4"
|
||||
variants={listVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{tracks.map((track) => (
|
||||
<motion.div key={track.id} variants={itemVariants}>
|
||||
<SocialViewFeedItem track={track} onPlay={onPlayTrack} />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="text-center py-24 text-muted-foreground">
|
||||
No recent activity.
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MoreHorizontal, Play } from 'lucide-react';
|
||||
|
|
@ -11,9 +12,14 @@ interface SocialViewFeedItemProps {
|
|||
|
||||
export function SocialViewFeedItem({ track, onPlay }: SocialViewFeedItemProps) {
|
||||
return (
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="mb-4"
|
||||
>
|
||||
<Card
|
||||
variant="default"
|
||||
className="p-0 overflow-hidden mb-4 border-transparent hover:border-border"
|
||||
variant="glass"
|
||||
className="p-0 overflow-hidden border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-all duration-300"
|
||||
>
|
||||
<div className="p-4 flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-muted overflow-hidden">
|
||||
|
|
@ -74,5 +80,6 @@ export function SocialViewFeedItem({ track, onPlay }: SocialViewFeedItemProps) {
|
|||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export function SocialViewSidebar({
|
|||
}: SocialViewSidebarProps) {
|
||||
return (
|
||||
<div className="hidden lg:block lg:col-span-3 space-y-8">
|
||||
<Card variant="glass" className="p-0 overflow-hidden">
|
||||
<Card variant="glass" className="p-0 overflow-hidden border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-shadow duration-300">
|
||||
<div className="h-20 bg-gradient-gaming" />
|
||||
<div className="px-4 pb-4">
|
||||
<div
|
||||
|
|
@ -40,7 +40,7 @@ export function SocialViewSidebar({
|
|||
</div>
|
||||
</Card>
|
||||
|
||||
<Card variant="default" className="p-2">
|
||||
<Card variant="glass" className="p-2 border-white/5 bg-black/20 backdrop-blur-xl">
|
||||
<nav className="space-y-1">
|
||||
<Button
|
||||
variant={activeTab === 'feed' ? 'outline' : 'ghost'}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||
|
||||
export function SocialViewSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 animate-fadeIn pb-20">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 animate-fadeIn pb-20 min-h-layout-page">
|
||||
<div className="hidden lg:block lg:col-span-3 space-y-8">
|
||||
<Skeleton className="h-48 rounded-xl" />
|
||||
<Skeleton className="h-32 rounded-xl" />
|
||||
<Skeleton className="h-48 rounded-[var(--radius-xl)]" />
|
||||
<Skeleton className="h-32 rounded-[var(--radius-xl)]" />
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 lg:col-span-6 space-y-8">
|
||||
|
|
@ -15,12 +15,12 @@ export function SocialViewSkeleton() {
|
|||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-44 rounded-xl" />
|
||||
<Skeleton key={i} className="h-44 rounded-[var(--radius-xl)]" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block lg:col-span-3">
|
||||
<Skeleton className="h-40 rounded-xl" />
|
||||
<Skeleton className="h-40 rounded-[var(--radius-xl)]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ const TRENDING_TAGS = ['#Techno', '#Synthwave', '#NewGear', '#Tutorial'];
|
|||
export function SocialViewTrending() {
|
||||
return (
|
||||
<div className="hidden lg:block lg:col-span-3 space-y-8">
|
||||
<Card variant="glass">
|
||||
<Card variant="glass" className="border-white/5 bg-black/20 backdrop-blur-xl hover-glow-cyan transition-shadow duration-300">
|
||||
<h3 className="font-bold text-sm text-foreground uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||
<Hash className="w-4 h-4 text-primary" /> Trending Tags
|
||||
</h3>
|
||||
|
|
@ -15,7 +15,7 @@ export function SocialViewTrending() {
|
|||
{TRENDING_TAGS.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="text-xs bg-muted px-2 py-1 rounded text-muted-foreground cursor-pointer hover:text-foreground hover:bg-muted/80"
|
||||
className="text-xs bg-muted px-2 py-1 rounded text-muted-foreground cursor-pointer hover:text-foreground hover:bg-muted/80 transition-all duration-200"
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export type { SocialViewProps, SocialTabKey } from './types';
|
||||
export { SocialView } from './SocialView';
|
||||
export { SocialViewSkeleton } from './SocialViewSkeleton';
|
||||
export { SocialViewError } from './SocialViewError';
|
||||
export { useSocialView } from './useSocialView';
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@ export function useSocialView() {
|
|||
const [activeTab, setActiveTab] = useState<SocialTabKey>('feed');
|
||||
const [feedTracks, setFeedTracks] = useState<Track[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const loadFeed = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
try {
|
||||
const res = await trackService.list({
|
||||
limit: 10,
|
||||
|
|
@ -24,6 +26,7 @@ export function useSocialView() {
|
|||
error: e instanceof Error ? e.message : String(e),
|
||||
stack: e instanceof Error ? e.stack : undefined,
|
||||
});
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -38,6 +41,8 @@ export function useSocialView() {
|
|||
setActiveTab,
|
||||
feedTracks,
|
||||
loading,
|
||||
error,
|
||||
retry: loadFeed,
|
||||
playTrack,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue