mirror of
https://github.com/armbian/imager.git
synced 2026-01-06 12:31:28 -08:00
feat: add skeleton loading components and improve modal animations
- Introduced `ListItemSkeleton` and `BoardCardSkeleton` components for loading states in lists and cards. - Enhanced `DeviceModal`, `ImageModal`, and `ManufacturerModal` to utilize skeleton loading during data fetching. - Improved modal exit animations for a smoother user experience. - Updated styles for skeleton loading and modal animations. - Removed loading text from translations across multiple languages.
This commit is contained in:
@@ -2,7 +2,7 @@ import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { Download, Crown, Shield, Users, Clock, Tv, Wrench } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from './Modal';
|
||||
import { ErrorDisplay, LoadingState, SearchBox } from '../shared';
|
||||
import { ErrorDisplay, BoardCardSkeleton, SearchBox } from '../shared';
|
||||
import type { BoardInfo, Manufacturer } from '../../types';
|
||||
import { getBoards, getBoardImageUrl } from '../../hooks/useTauri';
|
||||
import { useAsyncDataWhen } from '../../hooks/useAsyncData';
|
||||
@@ -21,7 +21,7 @@ export function BoardModal({ isOpen, onClose, onSelect, manufacturer }: BoardMod
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [boardImages, setBoardImages] = useState<Record<string, string | null>>({});
|
||||
const [imagesReady, setImagesReady] = useState(false);
|
||||
const [showSkeleton, setShowSkeleton] = useState(false);
|
||||
const loadedSlugsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Use hook for async data fetching
|
||||
@@ -34,14 +34,38 @@ export function BoardModal({ isOpen, onClose, onSelect, manufacturer }: BoardMod
|
||||
// Use shared hook for vendor logo validation
|
||||
const { isLoaded: vendorLogosChecked, getEffectiveVendor } = useVendorLogos(boards, isOpen);
|
||||
|
||||
// Reset state when modal closes or manufacturer changes
|
||||
// Derive boards ready state from data availability
|
||||
const boardsReady = useMemo(() => {
|
||||
return boards && boards.length > 0 && vendorLogosChecked;
|
||||
}, [boards, vendorLogosChecked]);
|
||||
|
||||
// Show skeleton with minimum delay
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset state on modal close
|
||||
setImagesReady(false);
|
||||
let skeletonTimeout: NodeJS.Timeout;
|
||||
|
||||
if (loading) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Show skeleton during loading
|
||||
setShowSkeleton(true);
|
||||
} else if (boardsReady) {
|
||||
// Keep skeleton visible for at least 300ms
|
||||
skeletonTimeout = setTimeout(() => {
|
||||
setShowSkeleton(false);
|
||||
}, 300);
|
||||
}
|
||||
setSearch('');
|
||||
}, [isOpen, manufacturer]);
|
||||
|
||||
return () => {
|
||||
if (skeletonTimeout) {
|
||||
clearTimeout(skeletonTimeout);
|
||||
}
|
||||
};
|
||||
}, [loading, boardsReady]);
|
||||
|
||||
// Reset images when manufacturer changes
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset state when manufacturer changes
|
||||
setBoardImages({});
|
||||
loadedSlugsRef.current.clear();
|
||||
}, [manufacturer?.id]);
|
||||
|
||||
// Pre-load images for current manufacturer
|
||||
useEffect(() => {
|
||||
@@ -52,17 +76,9 @@ export function BoardModal({ isOpen, onClose, onSelect, manufacturer }: BoardMod
|
||||
return getEffectiveVendor(board) === manufacturerId;
|
||||
});
|
||||
|
||||
if (manufacturerBoards.length === 0) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Early return case
|
||||
setImagesReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
if (manufacturerBoards.length === 0) return;
|
||||
|
||||
const loadImages = async () => {
|
||||
setImagesReady(false);
|
||||
|
||||
await Promise.all(manufacturerBoards.map(async (board) => {
|
||||
if (loadedSlugsRef.current.has(board.slug)) return;
|
||||
|
||||
@@ -78,15 +94,9 @@ export function BoardModal({ isOpen, onClose, onSelect, manufacturer }: BoardMod
|
||||
loadedSlugsRef.current.add(board.slug);
|
||||
setBoardImages((prev) => ({ ...prev, [board.slug]: loaded ? url : null }));
|
||||
}));
|
||||
|
||||
if (!cancelled) {
|
||||
setImagesReady(true);
|
||||
}
|
||||
};
|
||||
|
||||
loadImages();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [isOpen, manufacturer?.id, boards, vendorLogosChecked, getEffectiveVendor]);
|
||||
|
||||
const filteredBoards = useMemo(() => {
|
||||
@@ -114,23 +124,25 @@ export function BoardModal({ isOpen, onClose, onSelect, manufacturer }: BoardMod
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title} searchBar={searchBarContent}>
|
||||
<LoadingState isLoading={loading || !imagesReady}>
|
||||
{error ? (
|
||||
<ErrorDisplay error={error} onRetry={reload} compact />
|
||||
) : (
|
||||
<div className="board-grid-container">
|
||||
{filteredBoards.map((board) => (
|
||||
<button
|
||||
key={board.slug}
|
||||
className="board-grid-item"
|
||||
onClick={() => onSelect(board)}
|
||||
>
|
||||
<span className="badge-image-count"><Download size={10} />{board.image_count}</span>
|
||||
<div className="board-grid-image">
|
||||
{error ? (
|
||||
<ErrorDisplay error={error} onRetry={reload} compact />
|
||||
) : (
|
||||
<div className="board-grid-container">
|
||||
{showSkeleton && <BoardCardSkeleton count={12} />}
|
||||
{!showSkeleton && filteredBoards.map((board) => (
|
||||
<button
|
||||
key={board.slug}
|
||||
className="board-grid-item"
|
||||
onClick={() => onSelect(board)}
|
||||
>
|
||||
<span className={`badge-image-count ${!boardsReady ? 'skeleton' : ''}`}>
|
||||
<Download size={10} />{board.image_count}
|
||||
</span>
|
||||
<div className="board-grid-image">
|
||||
{boardsReady && boardImages[board.slug] ? (
|
||||
<img
|
||||
src={boardImages[board.slug] || fallbackImage}
|
||||
src={boardImages[board.slug] ?? undefined}
|
||||
alt={board.name}
|
||||
className={!boardImages[board.slug] ? 'fallback-image' : ''}
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
if (img.src !== fallbackImage) {
|
||||
@@ -139,58 +151,73 @@ export function BoardModal({ isOpen, onClose, onSelect, manufacturer }: BoardMod
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="board-grid-info">
|
||||
<div className="board-grid-name">{board.name}</div>
|
||||
<div className="board-grid-badges">
|
||||
{board.has_platinum_support && (
|
||||
<span className="badge-platinum">
|
||||
<Crown size={10} />
|
||||
<span>Platinum</span>
|
||||
</span>
|
||||
)}
|
||||
{board.has_standard_support && !board.has_platinum_support && (
|
||||
<span className="badge-standard">
|
||||
<Shield size={10} />
|
||||
<span>Standard</span>
|
||||
</span>
|
||||
)}
|
||||
{board.has_community_support && (
|
||||
<span className="badge-community">
|
||||
<Users size={10} />
|
||||
<span>Community</span>
|
||||
</span>
|
||||
)}
|
||||
{board.has_eos_support && (
|
||||
<span className="badge-eos">
|
||||
<Clock size={10} />
|
||||
<span>EOS</span>
|
||||
</span>
|
||||
)}
|
||||
{board.has_tvb_support && (
|
||||
<span className="badge-tvb">
|
||||
<Tv size={10} />
|
||||
<span>TV Box</span>
|
||||
</span>
|
||||
)}
|
||||
{board.has_wip_support && (
|
||||
<span className="badge-wip">
|
||||
<Wrench size={10} />
|
||||
<span>WIP</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{filteredBoards.length === 0 && (
|
||||
<div className="no-results">
|
||||
<p>{t('modal.noBoards')}</p>
|
||||
) : (
|
||||
<div className="skeleton" style={{ width: '100px', height: '100px', borderRadius: '8px' }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</LoadingState>
|
||||
<div className="board-grid-info">
|
||||
{boardsReady ? (
|
||||
<div className="board-grid-name">{board.name}</div>
|
||||
) : (
|
||||
<div className="skeleton" style={{ width: '80%', height: '14px', marginBottom: '8px' }} />
|
||||
)}
|
||||
<div className="board-grid-badges">
|
||||
{boardsReady ? (
|
||||
<>
|
||||
{board.has_platinum_support && (
|
||||
<span className="badge-platinum">
|
||||
<Crown size={10} />
|
||||
<span>Platinum</span>
|
||||
</span>
|
||||
)}
|
||||
{board.has_standard_support && !board.has_platinum_support && (
|
||||
<span className="badge-standard">
|
||||
<Shield size={10} />
|
||||
<span>Standard</span>
|
||||
</span>
|
||||
)}
|
||||
{board.has_community_support && (
|
||||
<span className="badge-community">
|
||||
<Users size={10} />
|
||||
<span>Community</span>
|
||||
</span>
|
||||
)}
|
||||
{board.has_eos_support && (
|
||||
<span className="badge-eos">
|
||||
<Clock size={10} />
|
||||
<span>EOS</span>
|
||||
</span>
|
||||
)}
|
||||
{board.has_tvb_support && (
|
||||
<span className="badge-tvb">
|
||||
<Tv size={10} />
|
||||
<span>TV Box</span>
|
||||
</span>
|
||||
)}
|
||||
{board.has_wip_support && (
|
||||
<span className="badge-wip">
|
||||
<Wrench size={10} />
|
||||
<span>WIP</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="skeleton" style={{ width: '50px', height: '18px' }} />
|
||||
<div className="skeleton" style={{ width: '50px', height: '18px' }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{filteredBoards.length === 0 && !showSkeleton && (
|
||||
<div className="no-results">
|
||||
<p>{t('modal.noBoards')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { HardDrive, RefreshCw, AlertTriangle, Shield, MemoryStick, Usb } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from './Modal';
|
||||
import { ErrorDisplay, ConfirmationDialog } from '../shared';
|
||||
import { ErrorDisplay, ConfirmationDialog, ListItemSkeleton } from '../shared';
|
||||
import type { BlockDevice } from '../../types';
|
||||
import { getBlockDevices } from '../../hooks/useTauri';
|
||||
import { useAsyncDataWhen } from '../../hooks/useAsyncData';
|
||||
@@ -74,6 +74,7 @@ export function DeviceModal({ isOpen, onClose, onSelect }: DeviceModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedDevice, setSelectedDevice] = useState<BlockDevice | null>(null);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [showSkeleton, setShowSkeleton] = useState(false);
|
||||
|
||||
// Track previous devices for change detection
|
||||
const prevDevicesRef = useRef<BlockDevice[] | null>(null);
|
||||
@@ -86,12 +87,37 @@ export function DeviceModal({ isOpen, onClose, onSelect }: DeviceModalProps) {
|
||||
[isOpen]
|
||||
);
|
||||
|
||||
// Derive devices ready state
|
||||
const devicesReady = useMemo(() => {
|
||||
return devices && devices.length > 0;
|
||||
}, [devices]);
|
||||
|
||||
// Show skeleton with minimum delay
|
||||
useEffect(() => {
|
||||
let skeletonTimeout: NodeJS.Timeout;
|
||||
|
||||
if (loading) {
|
||||
setShowSkeleton(true);
|
||||
} else if (devicesReady || (!loading && devices.length === 0)) {
|
||||
// Keep skeleton visible for at least 300ms
|
||||
skeletonTimeout = setTimeout(() => {
|
||||
setShowSkeleton(false);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (skeletonTimeout) {
|
||||
clearTimeout(skeletonTimeout);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- devicesReady already tracks devices changes; adding devices.length causes re-renders during polling
|
||||
}, [loading, devicesReady]);
|
||||
|
||||
// Update devices only when they actually change
|
||||
useEffect(() => {
|
||||
if (!rawDevices) return;
|
||||
if (devicesChanged(prevDevicesRef.current, rawDevices)) {
|
||||
prevDevicesRef.current = rawDevices;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync external data to state
|
||||
setDevices(sortDevices(rawDevices));
|
||||
}
|
||||
}, [rawDevices]);
|
||||
@@ -138,29 +164,26 @@ export function DeviceModal({ isOpen, onClose, onSelect }: DeviceModalProps) {
|
||||
<span>{t('flash.dataWarning')}</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">
|
||||
<div className="spinner" />
|
||||
<p>{t('modal.scanningDevices')}</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
{error ? (
|
||||
<ErrorDisplay error={error} onRetry={reload} compact />
|
||||
) : devices.length === 0 ? (
|
||||
<div className="no-results">
|
||||
<Usb size={40} />
|
||||
<p>{t('modal.noDevices')}</p>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('modal.insertDevice')}
|
||||
</p>
|
||||
<button className="btn btn-secondary" onClick={reload} disabled={loading} style={{ marginTop: 16 }}>
|
||||
<RefreshCw size={14} className={loading ? 'spin' : ''} />
|
||||
{t('device.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="modal-list">
|
||||
{devices.map((device) => {
|
||||
{showSkeleton && <ListItemSkeleton count={4} />}
|
||||
{devices.length === 0 && !showSkeleton && (
|
||||
<div className="no-results">
|
||||
<Usb size={40} />
|
||||
<p>{t('modal.noDevices')}</p>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('modal.insertDevice')}
|
||||
</p>
|
||||
<button className="btn btn-secondary" onClick={reload} disabled={loading} style={{ marginTop: 16 }}>
|
||||
<RefreshCw size={14} className={loading ? 'spin' : ''} />
|
||||
{t('device.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="modal-list no-animations">
|
||||
{!showSkeleton && devices.map((device) => {
|
||||
const deviceType = getDeviceType(device);
|
||||
const badge = getDeviceBadge(deviceType, t);
|
||||
return (
|
||||
@@ -194,12 +217,14 @@ export function DeviceModal({ isOpen, onClose, onSelect }: DeviceModalProps) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="modal-refresh-bottom">
|
||||
<button className="btn btn-secondary" onClick={reload} disabled={loading}>
|
||||
<RefreshCw size={14} className={loading ? 'spin' : ''} />
|
||||
{t('modal.refreshDevices')}
|
||||
</button>
|
||||
</div>
|
||||
{!showSkeleton && devices.length > 0 && (
|
||||
<div className="modal-refresh-bottom">
|
||||
<button className="btn btn-secondary" onClick={reload} disabled={loading}>
|
||||
<RefreshCw size={14} className={loading ? 'spin' : ''} />
|
||||
{t('modal.refreshDevices')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { Download, Package, Monitor, Terminal, Zap, Star, Layers, Shield, FlaskConical, AppWindow, Box } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from './Modal';
|
||||
import { ErrorDisplay, LoadingState } from '../shared';
|
||||
import { ErrorDisplay, ListItemSkeleton } from '../shared';
|
||||
import type { BoardInfo, ImageInfo, ImageFilterType } from '../../types';
|
||||
import { getImagesForBoard } from '../../hooks/useTauri';
|
||||
import { useAsyncDataWhen } from '../../hooks/useAsyncData';
|
||||
@@ -73,6 +73,7 @@ const FILTER_BUTTONS: Array<{
|
||||
export function ImageModal({ isOpen, onClose, onSelect, board }: ImageModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [filterType, setFilterType] = useState<ImageFilterType>('all');
|
||||
const [showSkeleton, setShowSkeleton] = useState(false);
|
||||
|
||||
// Use hook for async data fetching
|
||||
const { data: allImages, loading, error, reload } = useAsyncDataWhen<ImageInfo[]>(
|
||||
@@ -81,6 +82,32 @@ export function ImageModal({ isOpen, onClose, onSelect, board }: ImageModalProps
|
||||
[isOpen, board?.slug]
|
||||
);
|
||||
|
||||
// Derive images ready state
|
||||
const imagesReady = useMemo(() => {
|
||||
return allImages && allImages.length > 0;
|
||||
}, [allImages]);
|
||||
|
||||
// Show skeleton with minimum delay
|
||||
useEffect(() => {
|
||||
let skeletonTimeout: NodeJS.Timeout;
|
||||
|
||||
if (loading) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Show skeleton during loading
|
||||
setShowSkeleton(true);
|
||||
} else if (imagesReady) {
|
||||
// Keep skeleton visible for at least 300ms
|
||||
skeletonTimeout = setTimeout(() => {
|
||||
setShowSkeleton(false);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (skeletonTimeout) {
|
||||
clearTimeout(skeletonTimeout);
|
||||
}
|
||||
};
|
||||
}, [loading, imagesReady]);
|
||||
|
||||
// Calculate available filters based on all images
|
||||
const availableFilters = useMemo(() => {
|
||||
if (!allImages) return { recommended: false, stable: false, nightly: false, apps: false, barebone: false };
|
||||
@@ -125,20 +152,22 @@ export function ImageModal({ isOpen, onClose, onSelect, board }: ImageModalProps
|
||||
)}
|
||||
</div>
|
||||
|
||||
<LoadingState isLoading={loading}>
|
||||
{error ? (
|
||||
<ErrorDisplay error={error} onRetry={reload} compact />
|
||||
) : filteredImages.length === 0 ? (
|
||||
<div className="no-results">
|
||||
<Package size={48} />
|
||||
<p>{t('modal.noImages')}</p>
|
||||
<button onClick={() => setFilterType('all')} className="btn btn-secondary">
|
||||
{t('modal.allImages')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
{error ? (
|
||||
<ErrorDisplay error={error} onRetry={reload} compact />
|
||||
) : (
|
||||
<>
|
||||
{showSkeleton && <ListItemSkeleton count={6} />}
|
||||
{filteredImages.length === 0 && !showSkeleton && (
|
||||
<div className="no-results">
|
||||
<Package size={48} />
|
||||
<p>{t('modal.noImages')}</p>
|
||||
<button onClick={() => setFilterType('all')} className="btn btn-secondary">
|
||||
{t('modal.allImages')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="modal-list">
|
||||
{filteredImages.map((image, index) => {
|
||||
{!showSkeleton && filteredImages.map((image, index) => {
|
||||
const desktopEnv = getDesktopEnv(image.image_variant);
|
||||
const kernelType = getKernelType(image.kernel_branch);
|
||||
const osInfo = getOsInfo(image.distro_release);
|
||||
@@ -205,8 +234,8 @@ export function ImageModal({ isOpen, onClose, onSelect, board }: ImageModalProps
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</LoadingState>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from './Modal';
|
||||
import { ErrorDisplay, LoadingState, SearchBox } from '../shared';
|
||||
import { ErrorDisplay, ListItemSkeleton, SearchBox } from '../shared';
|
||||
import type { BoardInfo, Manufacturer } from '../../types';
|
||||
import { getBoards } from '../../hooks/useTauri';
|
||||
import { useAsyncDataWhen } from '../../hooks/useAsyncData';
|
||||
@@ -39,6 +39,7 @@ interface ManufacturerModalProps {
|
||||
export function ManufacturerModal({ isOpen, onClose, onSelect }: ManufacturerModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [showSkeleton, setShowSkeleton] = useState(false);
|
||||
|
||||
// Use hook for async data fetching
|
||||
const { data: boards, loading, error, reload } = useAsyncDataWhen<BoardInfo[]>(
|
||||
@@ -50,6 +51,32 @@ export function ManufacturerModal({ isOpen, onClose, onSelect }: ManufacturerMod
|
||||
// Use shared hook for manufacturer list with logo validation
|
||||
const { manufacturers, isLoaded: logosLoaded } = useManufacturerList(boards, isOpen, search);
|
||||
|
||||
// Derive ready state
|
||||
const manufacturersReady = useMemo(() => {
|
||||
return manufacturers && manufacturers.length > 0 && logosLoaded;
|
||||
}, [manufacturers, logosLoaded]);
|
||||
|
||||
// Show skeleton with minimum delay
|
||||
useEffect(() => {
|
||||
let skeletonTimeout: NodeJS.Timeout;
|
||||
|
||||
if (loading) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Show skeleton during loading
|
||||
setShowSkeleton(true);
|
||||
} else if (manufacturersReady) {
|
||||
// Keep skeleton visible for at least 300ms
|
||||
skeletonTimeout = setTimeout(() => {
|
||||
setShowSkeleton(false);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (skeletonTimeout) {
|
||||
clearTimeout(skeletonTimeout);
|
||||
}
|
||||
};
|
||||
}, [loading, manufacturersReady]);
|
||||
|
||||
const searchBarContent = (
|
||||
<SearchBox
|
||||
value={search}
|
||||
@@ -60,12 +87,13 @@ export function ManufacturerModal({ isOpen, onClose, onSelect }: ManufacturerMod
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('modal.selectManufacturer')} searchBar={searchBarContent}>
|
||||
<LoadingState isLoading={loading || !logosLoaded}>
|
||||
{error ? (
|
||||
<ErrorDisplay error={error} onRetry={reload} compact />
|
||||
) : (
|
||||
{error ? (
|
||||
<ErrorDisplay error={error} onRetry={reload} compact />
|
||||
) : (
|
||||
<>
|
||||
{showSkeleton && <ListItemSkeleton count={6} />}
|
||||
<div className="modal-list">
|
||||
{manufacturers.map((mfr) => (
|
||||
{!showSkeleton && manufacturers.map((mfr) => (
|
||||
<button
|
||||
key={mfr.id}
|
||||
className="list-item"
|
||||
@@ -78,14 +106,14 @@ export function ManufacturerModal({ isOpen, onClose, onSelect }: ManufacturerMod
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{manufacturers.length === 0 && (
|
||||
{!showSkeleton && manufacturers.length === 0 && (
|
||||
<div className="no-results">
|
||||
<p>{t('modal.noManufacturers')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</LoadingState>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ReactNode, useEffect, useCallback } from 'react';
|
||||
import { type ReactNode, useEffect, useCallback, useState, useRef } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface ModalProps {
|
||||
@@ -10,16 +10,33 @@ interface ModalProps {
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, title, children, searchBar }: ModalProps) {
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const isExitingRef = useRef(false);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (isExitingRef.current) return;
|
||||
isExitingRef.current = true;
|
||||
setIsExiting(true);
|
||||
setTimeout(() => {
|
||||
setIsExiting(false);
|
||||
isExitingRef.current = false;
|
||||
onClose();
|
||||
}, 200); // Match the CSS exit animation duration
|
||||
}, [onClose]);
|
||||
|
||||
const handleEscape = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
handleClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
}, [handleClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
@@ -27,14 +44,16 @@ export function Modal({ isOpen, onClose, title, children, searchBar }: ModalProp
|
||||
};
|
||||
}, [isOpen, handleEscape]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
if (!isOpen && !isExiting) return null;
|
||||
|
||||
const animationClass = isExiting ? 'modal-exiting' : 'modal-entering';
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className={`modal-overlay ${animationClass}`} onClick={handleClose}>
|
||||
<div className={`modal ${animationClass}`} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">{title}</h2>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
<button className="modal-close" onClick={handleClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
55
src/components/shared/SkeletonCard.tsx
Normal file
55
src/components/shared/SkeletonCard.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Skeleton loading components for cards and lists
|
||||
* Displays animated placeholders while content is loading
|
||||
*/
|
||||
|
||||
interface BoardCardSkeletonProps {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton component for board grid cards
|
||||
* Shows placeholders matching the board card layout
|
||||
*/
|
||||
export function BoardCardSkeleton({ count = 8 }: BoardCardSkeletonProps) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<div key={`skeleton-${index}`} className="board-card-skeleton">
|
||||
<div className="board-card-skeleton-image skeleton" />
|
||||
<div className="board-card-skeleton-info">
|
||||
<div className="board-card-skeleton-name skeleton" />
|
||||
<div className="board-card-skeleton-badges">
|
||||
<div className="board-card-skeleton-badge skeleton" />
|
||||
<div className="board-card-skeleton-badge skeleton" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListItemSkeletonProps {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton component for list items
|
||||
* Shows placeholders matching the list item layout
|
||||
*/
|
||||
export function ListItemSkeleton({ count = 6 }: ListItemSkeletonProps) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<div key={`skeleton-${index}`} className="list-item-skeleton">
|
||||
<div className="list-item-skeleton-icon skeleton" />
|
||||
<div className="list-item-skeleton-content">
|
||||
<div className="list-item-skeleton-title skeleton" />
|
||||
<div className="list-item-skeleton-subtitle skeleton" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
export { AppVersion } from './AppVersion';
|
||||
export { BoardCardSkeleton, ListItemSkeleton } from './SkeletonCard';
|
||||
export { ConfirmationDialog } from './ConfirmationDialog';
|
||||
export { ErrorDisplay } from './ErrorDisplay';
|
||||
export { LoadingState } from './LoadingState';
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
"noBoards": "Keine Boards gefunden",
|
||||
"noImages": "Keine Images gefunden",
|
||||
"noDevices": "Keine Geräte gefunden",
|
||||
"loading": "Wird geladen...",
|
||||
"promoted": "Empfohlen",
|
||||
"stable": "Stabil",
|
||||
"nightly": "Nightly",
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
"noBoards": "No boards found",
|
||||
"noImages": "No images found",
|
||||
"noDevices": "No devices found",
|
||||
"loading": "Loading...",
|
||||
"promoted": "Recommended",
|
||||
"stable": "Stable",
|
||||
"nightly": "Nightly",
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
"noBoards": "No se encontraron placas",
|
||||
"noImages": "No se encontraron imágenes",
|
||||
"noDevices": "No se encontraron dispositivos",
|
||||
"loading": "Cargando...",
|
||||
"promoted": "Recomendado",
|
||||
"stable": "Estable",
|
||||
"nightly": "Nightly",
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
"noBoards": "Aucune carte trouvée",
|
||||
"noImages": "Aucune image trouvée",
|
||||
"noDevices": "Aucun périphérique trouvé",
|
||||
"loading": "Chargement...",
|
||||
"promoted": "Recommandé",
|
||||
"stable": "Stable",
|
||||
"nightly": "Nightly",
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
"noBoards": "Nessuna scheda trovata",
|
||||
"noImages": "Nessuna immagine trovata",
|
||||
"noDevices": "Nessun dispositivo trovato",
|
||||
"loading": "Caricamento...",
|
||||
"promoted": "Raccomandato",
|
||||
"stable": "Stabile",
|
||||
"nightly": "Nightly",
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
"noBoards": "ボードが見つかりません",
|
||||
"noImages": "イメージが見つかりません",
|
||||
"noDevices": "デバイスが見つかりません",
|
||||
"loading": "読み込み中...",
|
||||
"promoted": "おすすめ",
|
||||
"stable": "安定版",
|
||||
"nightly": "ナイトリー",
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
"noBoards": "보드를 찾을 수 없습니다",
|
||||
"noImages": "이미지를 찾을 수 없습니다",
|
||||
"noDevices": "장치를 찾을 수 없습니다",
|
||||
"loading": "로딩 중...",
|
||||
"promoted": "추천",
|
||||
"stable": "안정 버전",
|
||||
"nightly": "나이틀리",
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
"noBoards": "Geen boards gevonden",
|
||||
"noImages": "Geen images gevonden",
|
||||
"noDevices": "Geen apparaten gevonden",
|
||||
"loading": "Laden...",
|
||||
"promoted": "Aanbevolen",
|
||||
"stable": "Stabiel",
|
||||
"nightly": "Nightly",
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
"noBoards": "Nie znaleziono płytek",
|
||||
"noImages": "Nie znaleziono obrazów",
|
||||
"noDevices": "Nie znaleziono urządzeń",
|
||||
"loading": "Ładowanie...",
|
||||
"promoted": "Polecane",
|
||||
"stable": "Stabilna",
|
||||
"nightly": "Nightly",
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
"noBoards": "Nenhuma placa encontrada",
|
||||
"noImages": "Nenhuma imagem encontrada",
|
||||
"noDevices": "Nenhum dispositivo encontrado",
|
||||
"loading": "Carregando...",
|
||||
"promoted": "Recomendado",
|
||||
"stable": "Estável",
|
||||
"nightly": "Nightly",
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
"noBoards": "Платы не найдены",
|
||||
"noImages": "Образы не найдены",
|
||||
"noDevices": "Устройства не найдены",
|
||||
"loading": "Загрузка...",
|
||||
"promoted": "Рекомендуемые",
|
||||
"stable": "Стабильная",
|
||||
"nightly": "Nightly",
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
"noBoards": "Plošč ni mogoče najti",
|
||||
"noImages": "Slik ni mogoče najti",
|
||||
"noDevices": "Naprav ni mogoče najti",
|
||||
"loading": "Nalaganje...",
|
||||
"promoted": "Priporočeno",
|
||||
"stable": "Stabilna",
|
||||
"nightly": "Nightly",
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
"noBoards": "Kart bulunamadı",
|
||||
"noImages": "İmaj bulunamadı",
|
||||
"noDevices": "Cihaz bulunamadı",
|
||||
"loading": "Yükleniyor...",
|
||||
"promoted": "Önerilen",
|
||||
"stable": "Kararlı",
|
||||
"nightly": "Nightly",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user