Add Loading component and use across views

Introduce a reusable Loading component (src/components/ui/loading.tsx) that shows a spinning Loader2 icon and an optional message. Replace ad-hoc loading UIs by importing and using Loading in BrowseView and CastView. In App.tsx, add mediaLoading state (set around fetchAllMedia) and pass it to BrowseView; also add local loading states to MediaDetailRoute and CastDetailRoute to show Loading while fetching details. These changes centralize loading UX and remove duplicated spinner markup.
This commit is contained in:
Lars Behrends
2026-04-11 01:26:41 +02:00
parent 555209ed4b
commit 0d530ea99c
4 changed files with 38 additions and 6 deletions

View File

@@ -14,6 +14,7 @@ import CastDetailView from './components/CastDetailView';
import AddMediaView from './components/AddMediaView'; import AddMediaView from './components/AddMediaView';
import ImporterView from './components/ImporterView'; import ImporterView from './components/ImporterView';
import SettingsView from './components/SettingsView'; import SettingsView from './components/SettingsView';
import Loading from './components/ui/loading';
import { MOCK_MEDIA, DETAIL_MEDIA } from './data'; import { MOCK_MEDIA, DETAIL_MEDIA } from './data';
import { Media, Staff, MediaCategory, UserSettings } from './types'; import { Media, Staff, MediaCategory, UserSettings } from './types';
import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff, fetchSettings, updateSettings } from './api'; import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff, fetchSettings, updateSettings } from './api';
@@ -37,6 +38,7 @@ function AppContent() {
// Load media from API on component mount (only when not on cast routes) // Load media from API on component mount (only when not on cast routes)
const [apiMedia, setApiMedia] = useState<Media[]>([]); const [apiMedia, setApiMedia] = useState<Media[]>([]);
const [mediaLoading, setMediaLoading] = useState(true);
useEffect(() => { useEffect(() => {
const loadSettingsFromApi = async () => { const loadSettingsFromApi = async () => {
@@ -72,11 +74,14 @@ function AppContent() {
useEffect(() => { useEffect(() => {
const loadMediaFromApi = async () => { const loadMediaFromApi = async () => {
setMediaLoading(true);
try { try {
const media = await fetchAllMedia(); const media = await fetchAllMedia();
setApiMedia(media); setApiMedia(media);
} catch (error) { } catch (error) {
console.error('Failed to load media from API:', error); console.error('Failed to load media from API:', error);
} finally {
setMediaLoading(false);
} }
}; };
@@ -320,6 +325,7 @@ function AppContent() {
itemsPerPage={settings?.itemsPerPage} itemsPerPage={settings?.itemsPerPage}
gridItemSize={settings?.gridItemSize} gridItemSize={settings?.gridItemSize}
onGridItemSizeChange={handleGridItemSizeChange} onGridItemSizeChange={handleGridItemSizeChange}
loading={mediaLoading}
/> />
} /> } />
<Route path="/media/:id" element={ <Route path="/media/:id" element={
@@ -385,10 +391,12 @@ function AppContent() {
function MediaDetailRoute({ selectedMedia, setSelectedMedia, allMedia, onPersonClick }: any) { function MediaDetailRoute({ selectedMedia, setSelectedMedia, allMedia, onPersonClick }: any) {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const loadMedia = async () => { const loadMedia = async () => {
if (id) { if (id) {
setLoading(true);
try { try {
const fetchedMedia = await fetchMediaById(id); const fetchedMedia = await fetchMediaById(id);
if (fetchedMedia) { if (fetchedMedia) {
@@ -399,12 +407,15 @@ function MediaDetailRoute({ selectedMedia, setSelectedMedia, allMedia, onPersonC
} catch (error) { } catch (error) {
console.error('Failed to fetch media:', error); console.error('Failed to fetch media:', error);
navigate('/'); navigate('/');
} finally {
setLoading(false);
} }
} }
}; };
loadMedia(); loadMedia();
}, [id]); }, [id]);
if (loading) return <Loading message="Loading media details..." />;
if (!selectedMedia) return null; if (!selectedMedia) return null;
return ( return (
@@ -419,10 +430,12 @@ function MediaDetailRoute({ selectedMedia, setSelectedMedia, allMedia, onPersonC
function CastDetailRoute({ selectedPerson, setSelectedPerson }: any) { function CastDetailRoute({ selectedPerson, setSelectedPerson }: any) {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const loadCast = async () => { const loadCast = async () => {
if (id) { if (id) {
setLoading(true);
try { try {
const castData = await fetchCastById(id); const castData = await fetchCastById(id);
if (castData) { if (castData) {
@@ -434,12 +447,15 @@ function CastDetailRoute({ selectedPerson, setSelectedPerson }: any) {
} catch (error) { } catch (error) {
console.error('Failed to load cast:', error); console.error('Failed to load cast:', error);
navigate('/cast'); navigate('/cast');
} finally {
setLoading(false);
} }
} }
}; };
loadCast(); loadCast();
}, [id]); }, [id]);
if (loading) return <Loading message="Loading cast details..." />;
if (!selectedPerson) return null; if (!selectedPerson) return null;
return ( return (

View File

@@ -3,6 +3,7 @@ import MediaCard from './MediaCard';
import MediaListItem from './MediaListItem'; import MediaListItem from './MediaListItem';
import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Search, Monitor, Users, FolderTree, Tag } from 'lucide-react'; import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Search, Monitor, Users, FolderTree, Tag } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import Loading from '@/components/ui/loading';
import React, { useState, useMemo, useEffect } from 'react'; import React, { useState, useMemo, useEffect } from 'react';
import { import {
DropdownMenu, DropdownMenu,
@@ -20,9 +21,10 @@ interface BrowseViewProps {
itemsPerPage?: number; itemsPerPage?: number;
gridItemSize?: number; gridItemSize?: number;
onGridItemSizeChange?: (size: number) => void; onGridItemSizeChange?: (size: number) => void;
loading?: boolean;
} }
export default function BrowseView({ mediaList, onMediaClick, activeCategory, itemsPerPage: initialItemsPerPage = 12, gridItemSize: initialGridItemSize = 5, onGridItemSizeChange }: BrowseViewProps) { export default function BrowseView({ mediaList, onMediaClick, activeCategory, itemsPerPage: initialItemsPerPage = 12, gridItemSize: initialGridItemSize = 5, onGridItemSizeChange, loading = false }: BrowseViewProps) {
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage); const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
@@ -309,7 +311,9 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
</div> </div>
{/* Content */} {/* Content */}
{mediaList.length === 0 ? ( {loading ? (
<Loading message="Loading media..." />
) : mediaList.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground"> <div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4"> <div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
<Search size={32} /> <Search size={32} />

View File

@@ -5,6 +5,7 @@ import { Search, ArrowUpDown, User, ChevronLeft, ChevronRight, X, Filter } from
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import Loading from '@/components/ui/loading';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { fetchAllCast } from '@/api'; import { fetchAllCast } from '@/api';
@@ -319,10 +320,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
)} )}
{loading ? ( {loading ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground"> <Loading message="Loading cast..." />
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#6d28d9] mb-4" />
<p className="text-lg font-bold">Loading cast...</p>
</div>
) : filteredStaff.length === 0 ? ( ) : filteredStaff.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground"> <div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<User size={48} className="mb-4 opacity-20" /> <User size={48} className="mb-4 opacity-20" />

View File

@@ -0,0 +1,14 @@
import { Loader2 } from 'lucide-react';
interface LoadingProps {
message?: string;
}
export default function Loading({ message = 'Loading...' }: LoadingProps) {
return (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Loader2 className="animate-spin h-12 w-12 text-[#6d28d9] mb-4" />
<p className="text-lg font-bold">{message}</p>
</div>
);
}