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:
SuperKali
2025-12-25 00:11:12 +01:00
parent 13dd5f97dd
commit fa68945062
26 changed files with 796 additions and 183 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
))}
</>
);
}

View File

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

View File

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

View File

@@ -47,7 +47,6 @@
"noBoards": "No boards found",
"noImages": "No images found",
"noDevices": "No devices found",
"loading": "Loading...",
"promoted": "Recommended",
"stable": "Stable",
"nightly": "Nightly",

View File

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

View File

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

View File

@@ -47,7 +47,6 @@
"noBoards": "Nessuna scheda trovata",
"noImages": "Nessuna immagine trovata",
"noDevices": "Nessun dispositivo trovato",
"loading": "Caricamento...",
"promoted": "Raccomandato",
"stable": "Stabile",
"nightly": "Nightly",

View File

@@ -47,7 +47,6 @@
"noBoards": "ボードが見つかりません",
"noImages": "イメージが見つかりません",
"noDevices": "デバイスが見つかりません",
"loading": "読み込み中...",
"promoted": "おすすめ",
"stable": "安定版",
"nightly": "ナイトリー",

View File

@@ -47,7 +47,6 @@
"noBoards": "보드를 찾을 수 없습니다",
"noImages": "이미지를 찾을 수 없습니다",
"noDevices": "장치를 찾을 수 없습니다",
"loading": "로딩 중...",
"promoted": "추천",
"stable": "안정 버전",
"nightly": "나이틀리",

View File

@@ -47,7 +47,6 @@
"noBoards": "Geen boards gevonden",
"noImages": "Geen images gevonden",
"noDevices": "Geen apparaten gevonden",
"loading": "Laden...",
"promoted": "Aanbevolen",
"stable": "Stabiel",
"nightly": "Nightly",

View File

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

View File

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

View File

@@ -47,7 +47,6 @@
"noBoards": "Платы не найдены",
"noImages": "Образы не найдены",
"noDevices": "Устройства не найдены",
"loading": "Загрузка...",
"promoted": "Рекомендуемые",
"stable": "Стабильная",
"nightly": "Nightly",

View File

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

View File

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