feat(ui): Zone 10 - SocialView SaaS polish (glass, glow, motion, error/empty)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
senke 2026-02-07 16:43:04 +01:00
parent a3a3dd6546
commit c1ce0c4b5a
10 changed files with 114 additions and 26 deletions

View file

@ -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: () => {} },
};

View file

@ -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)}

View file

@ -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&apos;t load the community feed. Please try again.
</p>
{onRetry != null && (
<Button variant="outline" onClick={onRetry}>
Retry
</Button>
)}
</div>
);
}

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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'}

View file

@ -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>
);

View file

@ -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>

View file

@ -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';

View file

@ -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,
};
}