2026-01-07 09:31:02 +00:00
|
|
|
import React, { useState } from 'react';
|
|
|
|
|
import { useAudio } from '../../context/AudioContext';
|
|
|
|
|
import { MiniPlayer } from '../player/MiniPlayer';
|
|
|
|
|
import { FullPlayer } from '../player/FullPlayer';
|
2026-01-13 18:47:57 +00:00
|
|
|
import {
|
|
|
|
|
X,
|
|
|
|
|
ListMusic,
|
|
|
|
|
Play,
|
|
|
|
|
GripVertical,
|
|
|
|
|
Trash2,
|
|
|
|
|
ArrowUpToLine,
|
|
|
|
|
ListPlus,
|
|
|
|
|
Clock,
|
|
|
|
|
Heart,
|
|
|
|
|
} from 'lucide-react';
|
2026-01-26 13:12:17 +00:00
|
|
|
import { useToast } from '../../components/feedback/ToastProvider';
|
2026-01-07 09:31:02 +00:00
|
|
|
import { Button } from '../ui/button';
|
|
|
|
|
|
|
|
|
|
export const AudioPlayer: React.FC = () => {
|
2026-01-13 18:47:57 +00:00
|
|
|
const {
|
|
|
|
|
currentTrack,
|
|
|
|
|
queue,
|
|
|
|
|
history,
|
|
|
|
|
reorderQueue,
|
|
|
|
|
playTrack,
|
|
|
|
|
playNext,
|
|
|
|
|
removeFromQueue,
|
|
|
|
|
addToQueue,
|
|
|
|
|
clearQueue,
|
|
|
|
|
} = useAudio();
|
2026-01-07 09:31:02 +00:00
|
|
|
const { addToast } = useToast();
|
|
|
|
|
const [isImmersive, setIsImmersive] = useState(false);
|
|
|
|
|
const [showQueue, setShowQueue] = useState(false);
|
|
|
|
|
const [queueTab, setQueueTab] = useState<'up-next' | 'history'>('up-next');
|
|
|
|
|
const [draggedItemIndex, setDraggedItemIndex] = useState<number | null>(null);
|
|
|
|
|
|
|
|
|
|
if (!currentTrack) return null;
|
|
|
|
|
|
|
|
|
|
// Queue Drag Handlers
|
|
|
|
|
const onDragStart = (e: React.DragEvent, index: number) => {
|
|
|
|
|
setDraggedItemIndex(index);
|
2026-01-13 18:47:57 +00:00
|
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
|
|
|
const ghost = document.createElement('div');
|
|
|
|
|
ghost.style.opacity = '0';
|
2026-01-07 09:31:02 +00:00
|
|
|
document.body.appendChild(ghost);
|
|
|
|
|
e.dataTransfer.setDragImage(ghost, 0, 0);
|
|
|
|
|
setTimeout(() => document.body.removeChild(ghost), 0);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onDragOver = (e: React.DragEvent, index: number) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (draggedItemIndex === null || draggedItemIndex === index) return;
|
|
|
|
|
reorderQueue(draggedItemIndex, index);
|
|
|
|
|
setDraggedItemIndex(index);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onDragEnd = () => setDraggedItemIndex(null);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{/* IMMERSIVE PLAYER OVERLAY */}
|
|
|
|
|
{isImmersive && <FullPlayer onClose={() => setIsImmersive(false)} />}
|
|
|
|
|
|
|
|
|
|
{/* QUEUE DRAWER */}
|
|
|
|
|
{showQueue && !isImmersive && (
|
fix: stabilize builds, tests, and lint across all stacks
Complete stabilization pass bringing all 3 stacks to green:
Frontend (apps/web/):
- Fix TypeScript nullability in useSeason.ts, useTimeOfDay.ts hooks
- Disable no-undef in ESLint config (TypeScript handles it; JSX misidentified)
- Rename 306 story imports from @storybook/react to @storybook/react-vite
- Fix conditional hook call in useMediaQuery.ts useIsTablet
- Move useQuery to top of LoginPage.tsx component
- Remove useless try/catch in GearFormModal.tsx
- Fix stale closure in ResetPasswordPage.tsx handleChange
- Make Storybook decorators (withRouter, withQueryClient, withToast, withAudio)
no-ops since global StorybookDecorator already provides these — prevents
nested Router / duplicate provider crashes in vitest-browser
- Fix nested MemoryRouter in 3 page stories (TrackDetail, PlaylistDetail, UserProfile)
- Update i18n initialization in test setup (await init before changeLanguage)
- Update ~30 test assertions from English to French to match i18n translations
- Update test assertions to match SUMI V3 design changes (shadow vs border)
- Fix remaining story type errors (PlayerError, PlaylistBatchActions,
TrackFilters, VirtualizedChatMessages)
Backend (veza-backend-api/):
- Fix response_test.go RespondWithAppError signature (2 args, not 3)
- Fix TestErrorContractAuthEndpoints expected error codes
(ErrCodeUnauthorized vs ErrCodeInvalidCredentials)
- Fix TestTrackHandler_GetTrackLikes_Success missing auth middleware setup
- Fix TestPlaybackAnalyticsService_GetTrackStats k-anonymity threshold
(needs 5 unique users, not 1)
- Replace NOW() PostgreSQL function with time.Now() parameter in marketplace
service for SQLite test compatibility
- Add missing AutoMigrate entries in marketplace_test.go
(ProductImage, ProductPreview, ProductLicense, ProductReview)
Results:
- Frontend TypeCheck: 617 errors -> 0 errors
- Frontend ESLint: 349 errors -> 0 errors
- Frontend Vitest: 196 failing tests -> 1 skipped (3396/3397 passing)
- Backend go vet: 1 error -> 0 errors
- Backend tests: 5 failing -> all 13 packages passing
- Rust: 150/150 tests passing (unchanged)
- Storybook audit: 0 errors across 1244 stories
Triage report: docs/TRIAGE_REPORT.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:48:07 +00:00
|
|
|
<div className="fixed bottom-24 right-4 w-full md:w-96 bg-card/95 backdrop-blur-xl rounded-xl shadow-2xl z-40 overflow-hidden animate-slideUp max-h-layout-panel flex flex-col ring-1 ring-white/10">
|
2026-02-07 14:25:44 +00:00
|
|
|
<div className="flex items-center justify-between p-4 border-b border-border bg-muted/80">
|
|
|
|
|
<h3 className="font-bold text-foreground text-sm tracking-wide flex items-center gap-2">
|
refactor: Phase 3a — Global color class migration to SUMI semantics
- Replace all kodo-* color classes across ~100 TSX files:
kodo-void → background, kodo-ink → card, kodo-graphite → muted,
kodo-steel → muted-foreground, kodo-cyan → primary, kodo-magenta → destructive,
kodo-lime → success, kodo-red → destructive, kodo-gold → warning
- Replace cyan-500, magenta-500, lime-500 default Tailwind colors with
semantic equivalents (primary, destructive, success)
- Fix WaveformVisualizer hardcoded hex colors to SUMI values
- Delete global-effects.css (conflicting, redundant with index.css)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 00:51:49 +00:00
|
|
|
<ListMusic className="w-4 h-4 text-muted-foreground" /> PLAY QUEUE
|
2026-01-13 18:47:57 +00:00
|
|
|
</h3>
|
|
|
|
|
<X
|
2026-02-07 14:25:44 +00:00
|
|
|
className="w-5 h-5 text-muted-foreground cursor-pointer hover:text-foreground"
|
2026-01-13 18:47:57 +00:00
|
|
|
onClick={() => setShowQueue(false)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-01-07 09:31:02 +00:00
|
|
|
|
2026-01-13 18:47:57 +00:00
|
|
|
<div className="flex-1 flex flex-col min-h-0">
|
2026-02-07 14:25:44 +00:00
|
|
|
<div className="flex border-b border-border bg-muted/30">
|
2026-01-13 18:47:57 +00:00
|
|
|
<button
|
2026-02-07 14:25:44 +00:00
|
|
|
className={`flex-1 py-4 text-xs font-bold uppercase tracking-wider transition-colors ${queueTab === 'up-next' ? 'text-primary border-b-2 border-primary bg-muted/50' : 'text-muted-foreground hover:text-foreground'}`}
|
2026-01-13 18:47:57 +00:00
|
|
|
onClick={() => setQueueTab('up-next')}
|
|
|
|
|
>
|
|
|
|
|
Up Next ({queue.length})
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
2026-02-07 14:25:44 +00:00
|
|
|
className={`flex-1 py-4 text-xs font-bold uppercase tracking-wider transition-colors ${queueTab === 'history' ? 'text-primary border-b-2 border-primary bg-muted/50' : 'text-muted-foreground hover:text-foreground'}`}
|
2026-01-13 18:47:57 +00:00
|
|
|
onClick={() => setQueueTab('history')}
|
|
|
|
|
>
|
|
|
|
|
History
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-01-07 09:31:02 +00:00
|
|
|
|
2026-01-13 18:47:57 +00:00
|
|
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-0">
|
|
|
|
|
{queueTab === 'up-next' && (
|
|
|
|
|
<>
|
2026-02-07 14:25:44 +00:00
|
|
|
<div className="p-4 bg-primary/5 border-b border-border/30">
|
feat(web): UI premium Discord/Spotify-like — tokens, shadows, focus, layout
Plan UI premium 6–8 semaines (design system, shell, Storybook, a11y):
- Design system: DESIGN_TOKENS.md, APP_SHELL.md, FULL_LAYOUT_PAGE.md. Single source
for layout/shell (index.css), shadows (design-system.css), durations/easing.
- Tokens: shadow-cover-depth, shadow-gold-glow, shadow-fab-glow; layout max-height
(max-h-layout-drawer, max-h-layout-panel, max-h-layout-list). All duration-200/300/500
replaced by --duration-fast/normal/slow. Arbitrary shadows replaced by token classes.
- Shell & player: Sidebar, Header, GlobalPlayer, MiniPlayer, PlayerQueue, PlayerControls,
AudioPlayer use tokens; focus-visible on Sidebar, PlayerQueue, DropdownMenuTrigger/Item,
TabsTrigger. Typography: text-[10px]/[9px] → text-xs where applicable.
- ESLint: no-restricted-syntax (warn) for w-/h-/rounded-/shadow-/text-/spacing arbitrary.
- Scripts: report-arbitrary-values.mjs, capture/compare/generate visual; visual-complete.spec.ts.
- Stories full layout: Dashboard, Playlists, Library, Settings, Profile in DashboardLayout.stories.
- .cursorrules + README: DESIGN_TOKENS, APP_SHELL, visual commands, no arbitrary without justification.
- apps/web/.gitignore: e2e test artifacts (test-results-visual, playwright-report-visual).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 16:15:58 +00:00
|
|
|
<div className="text-xs font-bold text-muted-foreground uppercase tracking-wider mb-2">
|
2026-01-13 18:47:57 +00:00
|
|
|
Now Playing
|
|
|
|
|
</div>
|
aesthetic-improvements: align spacing to 8px grid (Action 11.2.1.3)
- Created automated script (scripts/align-8px-grid.py) to align all spacing to 8px grid
- Replaced non-8px-aligned spacing: gap-3/p-3/m-3 (12px) → gap-4/p-4/m-4 (16px), gap-5/p-5/m-5 (20px) → gap-6/p-6/m-6 (24px), gap-10/p-10/m-10 (40px) → gap-12/p-12/m-12 (48px), gap-20/p-20/m-20 (80px) → gap-24/p-24/m-24 (96px)
- Preserved: 4px values (gap-1, p-1, m-1) as they may be intentional fine-tuning, responsive breakpoints (sm:, md:, lg:), test files, documentation
- Modified files across all components to ensure consistent 8px grid alignment
- Action 11.2.1.3: Align all elements to 8px grid - COMPLETE
2026-01-16 10:50:46 +00:00
|
|
|
<div className="flex items-center gap-4 group relative">
|
2026-01-13 18:47:57 +00:00
|
|
|
<img
|
|
|
|
|
src={currentTrack.coverUrl}
|
|
|
|
|
alt={`Cover art for ${currentTrack.title} by ${currentTrack.artist}`}
|
|
|
|
|
className="w-12 h-12 rounded shadow-lg"
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex-1 min-w-0">
|
2026-02-12 01:09:29 +00:00
|
|
|
<div className="text-sm font-bold text-foreground truncate">
|
2026-01-13 18:47:57 +00:00
|
|
|
{currentTrack.title}
|
|
|
|
|
</div>
|
2026-02-07 14:25:44 +00:00
|
|
|
<div className="text-xs text-muted-foreground truncate">
|
2026-01-13 18:47:57 +00:00
|
|
|
{currentTrack.artist}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
refactor: Phase 3a — Global color class migration to SUMI semantics
- Replace all kodo-* color classes across ~100 TSX files:
kodo-void → background, kodo-ink → card, kodo-graphite → muted,
kodo-steel → muted-foreground, kodo-cyan → primary, kodo-magenta → destructive,
kodo-lime → success, kodo-red → destructive, kodo-gold → warning
- Replace cyan-500, magenta-500, lime-500 default Tailwind colors with
semantic equivalents (primary, destructive, success)
- Fix WaveformVisualizer hardcoded hex colors to SUMI values
- Delete global-effects.css (conflicting, redundant with index.css)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 00:51:49 +00:00
|
|
|
className="p-2 hover:bg-white/10 rounded-full text-muted-foreground hover:text-destructive"
|
2026-01-13 18:47:57 +00:00
|
|
|
onClick={() => addToast('Saved to Library', 'success')}
|
|
|
|
|
>
|
|
|
|
|
<Heart className="w-4 h-4" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="p-2 space-y-1">
|
|
|
|
|
{queue.length === 0 && (
|
2026-02-07 14:25:44 +00:00
|
|
|
<div className="text-center text-muted-foreground py-12 flex flex-col items-center">
|
2026-01-13 18:47:57 +00:00
|
|
|
<ListMusic className="w-8 h-8 mb-2 opacity-50" />
|
|
|
|
|
<p className="text-sm italic">Queue is empty</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-01-07 09:31:02 +00:00
|
|
|
|
2026-01-13 18:47:57 +00:00
|
|
|
{queue.map((track, i) => (
|
|
|
|
|
<div
|
|
|
|
|
key={track.id}
|
|
|
|
|
draggable
|
|
|
|
|
onDragStart={(e) => onDragStart(e, i)}
|
|
|
|
|
onDragOver={(e) => onDragOver(e, i)}
|
|
|
|
|
onDragEnd={onDragEnd}
|
2026-02-07 14:25:44 +00:00
|
|
|
className={`flex items-center gap-4 p-2 rounded-lg group transition-colors border border-transparent ${draggedItemIndex === i ? 'bg-primary/10 border-primary/50' : 'hover:bg-white/5 hover:border-white/5'}`}
|
2026-01-13 18:47:57 +00:00
|
|
|
>
|
2026-02-12 01:09:29 +00:00
|
|
|
<div className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground p-1">
|
2026-01-13 18:47:57 +00:00
|
|
|
<GripVertical className="w-4 h-4" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="relative w-8 h-8 rounded overflow-hidden flex-shrink-0">
|
|
|
|
|
<img
|
|
|
|
|
src={track.coverUrl}
|
|
|
|
|
alt={`Cover art for ${track.title} by ${track.artist}`}
|
|
|
|
|
className="w-full h-full object-cover"
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
className="absolute inset-0 bg-black/50 hidden group-hover:flex items-center justify-center cursor-pointer"
|
|
|
|
|
onClick={() => playTrack(track)}
|
|
|
|
|
>
|
2026-02-12 01:09:29 +00:00
|
|
|
<Play className="w-3 h-3 text-foreground fill-current" />
|
2026-01-07 09:31:02 +00:00
|
|
|
</div>
|
2026-01-13 18:47:57 +00:00
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 min-w-0 select-none">
|
2026-02-07 14:25:44 +00:00
|
|
|
<div className="text-sm font-medium text-foreground group-hover:text-foreground truncate">
|
2026-01-13 18:47:57 +00:00
|
|
|
{track.title}
|
|
|
|
|
</div>
|
2026-02-07 14:25:44 +00:00
|
|
|
<div className="text-xs text-muted-foreground truncate">
|
2026-01-13 18:47:57 +00:00
|
|
|
{track.artist}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-01-07 09:31:02 +00:00
|
|
|
|
2026-01-13 18:47:57 +00:00
|
|
|
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity gap-1">
|
|
|
|
|
<button
|
2026-02-12 01:09:29 +00:00
|
|
|
className="p-1.5 hover:text-foreground"
|
2026-01-13 18:47:57 +00:00
|
|
|
title="Play Next"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
playNext(track);
|
|
|
|
|
removeFromQueue(track.id);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<ArrowUpToLine className="w-3.5 h-3.5" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
2026-02-07 14:25:44 +00:00
|
|
|
className="p-1.5 hover:text-destructive"
|
2026-01-13 18:47:57 +00:00
|
|
|
title="Remove"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
removeFromQueue(track.id);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-01-07 09:31:02 +00:00
|
|
|
</div>
|
2026-01-13 18:47:57 +00:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{queueTab === 'history' && (
|
|
|
|
|
<div className="p-2 space-y-1">
|
|
|
|
|
{history.length === 0 && (
|
2026-02-07 14:25:44 +00:00
|
|
|
<div className="text-center text-muted-foreground py-12 flex flex-col items-center">
|
2026-01-13 18:47:57 +00:00
|
|
|
<Clock className="w-8 h-8 mb-2 opacity-50" />
|
|
|
|
|
<p className="text-sm italic">No history yet</p>
|
|
|
|
|
</div>
|
2026-01-07 09:31:02 +00:00
|
|
|
)}
|
2026-01-13 18:47:57 +00:00
|
|
|
{[...history].reverse().map((track, i) => (
|
|
|
|
|
<div
|
|
|
|
|
key={`${track.id}-${i}`}
|
aesthetic-improvements: align spacing to 8px grid (Action 11.2.1.3)
- Created automated script (scripts/align-8px-grid.py) to align all spacing to 8px grid
- Replaced non-8px-aligned spacing: gap-3/p-3/m-3 (12px) → gap-4/p-4/m-4 (16px), gap-5/p-5/m-5 (20px) → gap-6/p-6/m-6 (24px), gap-10/p-10/m-10 (40px) → gap-12/p-12/m-12 (48px), gap-20/p-20/m-20 (80px) → gap-24/p-24/m-24 (96px)
- Preserved: 4px values (gap-1, p-1, m-1) as they may be intentional fine-tuning, responsive breakpoints (sm:, md:, lg:), test files, documentation
- Modified files across all components to ensure consistent 8px grid alignment
- Action 11.2.1.3: Align all elements to 8px grid - COMPLETE
2026-01-16 10:50:46 +00:00
|
|
|
className="flex items-center gap-4 p-2 rounded-lg hover:bg-white/5 group opacity-70 hover:opacity-100 transition-opacity"
|
2026-01-13 18:47:57 +00:00
|
|
|
>
|
|
|
|
|
<div className="w-8 h-8 rounded overflow-hidden flex-shrink-0 grayscale group-hover:grayscale-0 transition-all">
|
|
|
|
|
<img
|
|
|
|
|
src={track.coverUrl}
|
|
|
|
|
alt={`Cover art for ${track.title} by ${track.artist}`}
|
|
|
|
|
className="w-full h-full object-cover"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 min-w-0">
|
2026-02-07 14:25:44 +00:00
|
|
|
<div className="text-sm font-medium text-foreground group-hover:text-foreground truncate">
|
2026-01-13 18:47:57 +00:00
|
|
|
{track.title}
|
|
|
|
|
</div>
|
2026-02-07 14:25:44 +00:00
|
|
|
<div className="text-xs text-muted-foreground truncate">
|
2026-01-13 18:47:57 +00:00
|
|
|
{track.artist}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
2026-02-12 01:09:29 +00:00
|
|
|
className="p-1.5 text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity"
|
2026-01-13 18:47:57 +00:00
|
|
|
onClick={() => {
|
|
|
|
|
addToQueue(track);
|
|
|
|
|
addToast('Added back to Queue');
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<ListPlus className="w-4 h-4" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
2026-01-07 09:31:02 +00:00
|
|
|
</div>
|
2026-01-13 18:47:57 +00:00
|
|
|
)}
|
|
|
|
|
</div>
|
2026-01-07 09:31:02 +00:00
|
|
|
|
2026-01-13 18:47:57 +00:00
|
|
|
{queueTab === 'up-next' && queue.length > 0 && (
|
2026-02-07 14:25:44 +00:00
|
|
|
<div className="p-4 border-t border-border bg-muted">
|
2026-01-13 18:47:57 +00:00
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
2026-02-07 14:25:44 +00:00
|
|
|
className="w-full text-xs text-muted-foreground hover:text-destructive"
|
2026-01-13 18:47:57 +00:00
|
|
|
onClick={() => {
|
|
|
|
|
clearQueue();
|
|
|
|
|
addToast('Queue Cleared');
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Clear Queue
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-01-07 09:31:02 +00:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* MINI PLAYER BAR */}
|
2026-01-13 18:47:57 +00:00
|
|
|
<MiniPlayer
|
|
|
|
|
onExpand={() => setIsImmersive(true)}
|
|
|
|
|
onToggleQueue={() => setShowQueue(!showQueue)}
|
|
|
|
|
isQueueOpen={showQueue}
|
2026-01-07 09:31:02 +00:00
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
};
|