diff --git a/src/components/modals/BoardModal.tsx b/src/components/modals/BoardModal.tsx index dc8c09d..3f5ec27 100644 --- a/src/components/modals/BoardModal.tsx +++ b/src/components/modals/BoardModal.tsx @@ -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>({}); - const [imagesReady, setImagesReady] = useState(false); + const [showSkeleton, setShowSkeleton] = useState(false); const loadedSlugsRef = useRef>(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 ( - - {error ? ( - - ) : ( -
- {filteredBoards.map((board) => ( - - ))} - {filteredBoards.length === 0 && ( -
-

{t('modal.noBoards')}

+ ) : ( +
+ )}
- )} -
- )} - +
+ {boardsReady ? ( +
{board.name}
+ ) : ( +
+ )} +
+ {boardsReady ? ( + <> + {board.has_platinum_support && ( + + + Platinum + + )} + {board.has_standard_support && !board.has_platinum_support && ( + + + Standard + + )} + {board.has_community_support && ( + + + Community + + )} + {board.has_eos_support && ( + + + EOS + + )} + {board.has_tvb_support && ( + + + TV Box + + )} + {board.has_wip_support && ( + + + WIP + + )} + + ) : ( + <> +
+
+ + )} +
+
+ + ))} + {filteredBoards.length === 0 && !showSkeleton && ( +
+

{t('modal.noBoards')}

+
+ )} +
+ )} ); } diff --git a/src/components/modals/DeviceModal.tsx b/src/components/modals/DeviceModal.tsx index d1bd27f..f038b31 100644 --- a/src/components/modals/DeviceModal.tsx +++ b/src/components/modals/DeviceModal.tsx @@ -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(null); const [showConfirm, setShowConfirm] = useState(false); + const [showSkeleton, setShowSkeleton] = useState(false); // Track previous devices for change detection const prevDevicesRef = useRef(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) { {t('flash.dataWarning')}
- {loading ? ( -
-
-

{t('modal.scanningDevices')}

-
- ) : error ? ( + {error ? ( - ) : devices.length === 0 ? ( -
- -

{t('modal.noDevices')}

-

- {t('modal.insertDevice')} -

- -
) : ( <> -
- {devices.map((device) => { + {showSkeleton && } + {devices.length === 0 && !showSkeleton && ( +
+ +

{t('modal.noDevices')}

+

+ {t('modal.insertDevice')} +

+ +
+ )} +
+ {!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) { ); })}
-
- -
+ {!showSkeleton && devices.length > 0 && ( +
+ +
+ )} )} diff --git a/src/components/modals/ImageModal.tsx b/src/components/modals/ImageModal.tsx index f6b1b9a..5d11f65 100644 --- a/src/components/modals/ImageModal.tsx +++ b/src/components/modals/ImageModal.tsx @@ -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('all'); + const [showSkeleton, setShowSkeleton] = useState(false); // Use hook for async data fetching const { data: allImages, loading, error, reload } = useAsyncDataWhen( @@ -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 )}
- - {error ? ( - - ) : filteredImages.length === 0 ? ( -
- -

{t('modal.noImages')}

- -
- ) : ( + {error ? ( + + ) : ( + <> + {showSkeleton && } + {filteredImages.length === 0 && !showSkeleton && ( +
+ +

{t('modal.noImages')}

+ +
+ )}
- {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 ); })}
- )} -
+ + )} ); } diff --git a/src/components/modals/ManufacturerModal.tsx b/src/components/modals/ManufacturerModal.tsx index fccbb11..f4615cd 100644 --- a/src/components/modals/ManufacturerModal.tsx +++ b/src/components/modals/ManufacturerModal.tsx @@ -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( @@ -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 = ( - - {error ? ( - - ) : ( + {error ? ( + + ) : ( + <> + {showSkeleton && }
- {manufacturers.map((mfr) => ( + {!showSkeleton && manufacturers.map((mfr) => ( ))} - {manufacturers.length === 0 && ( + {!showSkeleton && manufacturers.length === 0 && (

{t('modal.noManufacturers')}

)}
- )} -
+ + )} ); } diff --git a/src/components/modals/Modal.tsx b/src/components/modals/Modal.tsx index 66c597d..4ccd476 100644 --- a/src/components/modals/Modal.tsx +++ b/src/components/modals/Modal.tsx @@ -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 ( -
-
e.stopPropagation()}> +
+
e.stopPropagation()}>

{title}

-
diff --git a/src/components/shared/SkeletonCard.tsx b/src/components/shared/SkeletonCard.tsx new file mode 100644 index 0000000..0f35809 --- /dev/null +++ b/src/components/shared/SkeletonCard.tsx @@ -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) => ( +
+
+
+
+
+
+
+
+
+
+ ))} + + ); +} + +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) => ( +
+
+
+
+
+
+
+ ))} + + ); +} diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts index 91703d0..0b5bc31 100644 --- a/src/components/shared/index.ts +++ b/src/components/shared/index.ts @@ -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'; diff --git a/src/locales/de.json b/src/locales/de.json index 194a78e..447237e 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -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", diff --git a/src/locales/en.json b/src/locales/en.json index 95b352c..86dd0db 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -47,7 +47,6 @@ "noBoards": "No boards found", "noImages": "No images found", "noDevices": "No devices found", - "loading": "Loading...", "promoted": "Recommended", "stable": "Stable", "nightly": "Nightly", diff --git a/src/locales/es.json b/src/locales/es.json index 12f294e..9ec3e9e 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -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", diff --git a/src/locales/fr.json b/src/locales/fr.json index bc1f8c5..cfccfec 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -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", diff --git a/src/locales/it.json b/src/locales/it.json index 3fce4dc..d0f080b 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -47,7 +47,6 @@ "noBoards": "Nessuna scheda trovata", "noImages": "Nessuna immagine trovata", "noDevices": "Nessun dispositivo trovato", - "loading": "Caricamento...", "promoted": "Raccomandato", "stable": "Stabile", "nightly": "Nightly", diff --git a/src/locales/ja.json b/src/locales/ja.json index 0f5669d..7759d6b 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -47,7 +47,6 @@ "noBoards": "ボードが見つかりません", "noImages": "イメージが見つかりません", "noDevices": "デバイスが見つかりません", - "loading": "読み込み中...", "promoted": "おすすめ", "stable": "安定版", "nightly": "ナイトリー", diff --git a/src/locales/ko.json b/src/locales/ko.json index 3c3090b..3be1f7b 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -47,7 +47,6 @@ "noBoards": "보드를 찾을 수 없습니다", "noImages": "이미지를 찾을 수 없습니다", "noDevices": "장치를 찾을 수 없습니다", - "loading": "로딩 중...", "promoted": "추천", "stable": "안정 버전", "nightly": "나이틀리", diff --git a/src/locales/nl.json b/src/locales/nl.json index db5d07f..ed41ddd 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -47,7 +47,6 @@ "noBoards": "Geen boards gevonden", "noImages": "Geen images gevonden", "noDevices": "Geen apparaten gevonden", - "loading": "Laden...", "promoted": "Aanbevolen", "stable": "Stabiel", "nightly": "Nightly", diff --git a/src/locales/pl.json b/src/locales/pl.json index b3dd20b..53ddb6f 100644 --- a/src/locales/pl.json +++ b/src/locales/pl.json @@ -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", diff --git a/src/locales/pt.json b/src/locales/pt.json index a6b4fe7..f39afd5 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -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", diff --git a/src/locales/ru.json b/src/locales/ru.json index 54af5dc..696f648 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -47,7 +47,6 @@ "noBoards": "Платы не найдены", "noImages": "Образы не найдены", "noDevices": "Устройства не найдены", - "loading": "Загрузка...", "promoted": "Рекомендуемые", "stable": "Стабильная", "nightly": "Nightly", diff --git a/src/locales/sl.json b/src/locales/sl.json index f1a1c4b..5735fc6 100644 --- a/src/locales/sl.json +++ b/src/locales/sl.json @@ -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", diff --git a/src/locales/tr.json b/src/locales/tr.json index d4b852c..eeba719 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -47,7 +47,6 @@ "noBoards": "Kart bulunamadı", "noImages": "İmaj bulunamadı", "noDevices": "Cihaz bulunamadı", - "loading": "Yükleniyor...", "promoted": "Önerilen", "stable": "Kararlı", "nightly": "Nightly", diff --git a/src/locales/uk.json b/src/locales/uk.json index 728be93..8b91c13 100644 --- a/src/locales/uk.json +++ b/src/locales/uk.json @@ -47,7 +47,6 @@ "noBoards": "Плат не знайдено", "noImages": "Образів не знайдено", "noDevices": "Пристроїв не знайдено", - "loading": "Завантаження...", "promoted": "Рекомендовані", "stable": "Стабільна", "nightly": "Nightly", diff --git a/src/locales/zh.json b/src/locales/zh.json index 4c3075e..52247ca 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -47,7 +47,6 @@ "noBoards": "未找到开发板", "noImages": "未找到镜像", "noDevices": "未找到设备", - "loading": "加载中...", "promoted": "推荐", "stable": "稳定版", "nightly": "每日构建", diff --git a/src/styles/components.css b/src/styles/components.css index 0b6b3a9..b770a0d 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -84,6 +84,153 @@ color: var(--text-muted); } +/* ======================================== + SKELETON LOADING + ======================================== */ + +@keyframes skeletonShimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +.skeleton { + background: linear-gradient( + 90deg, + var(--bg-hover) 0%, + var(--bg-secondary) 20%, + var(--bg-hover) 40%, + var(--bg-hover) 100% + ); + background-size: 200% 100%; + animation: skeletonShimmer 1.5s ease-in-out infinite; + border-radius: 4px; +} + +/* Board Card Skeleton */ +.board-card-skeleton { + display: flex; + flex-direction: column; + align-items: center; + padding: 12px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; + cursor: default; + opacity: 0.6; + pointer-events: none; +} + +.board-card-skeleton-image { + width: 100px; + height: 100px; + border-radius: 8px; + margin-bottom: 10px; +} + +.board-card-skeleton-info { + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; +} + +.board-card-skeleton-name { + width: 80%; + height: 14px; +} + +.board-card-skeleton-badges { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: center; +} + +.board-card-skeleton-badge { + width: 50px; + height: 18px; +} + +/* List Item Skeleton */ +.list-item-skeleton { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 20px; + border-bottom: 1px solid var(--border-light); + opacity: 0.6; + pointer-events: none; +} + +.list-item-skeleton-icon { + width: 56px; + height: 56px; + border-radius: 12px; + flex-shrink: 0; +} + +.list-item-skeleton-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; +} + +.list-item-skeleton-title { + width: 60%; + height: 14px; +} + +.list-item-skeleton-subtitle { + width: 40%; + height: 12px; +} + +.board-card-skeleton-image { + width: 100px; + height: 100px; + border-radius: 8px; + margin-bottom: 10px; +} + +.board-card-skeleton-info { + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; +} + +.board-card-skeleton-name { + width: 80%; + height: 14px; +} + +.board-card-skeleton-badges { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: center; +} + +.board-card-skeleton-badge { + width: 50px; + height: 18px; +} + +/* Respect user motion preferences */ +@media (prefers-reduced-motion: reduce) { + .skeleton { + animation: none; + background: var(--bg-hover); + } +} + /* ======================================== LIST ITEMS ======================================== */ @@ -96,13 +243,15 @@ border: none; border-bottom: 1px solid var(--border-light); cursor: pointer; - transition: background 0.1s ease; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); width: 100%; text-align: left; + position: relative; } .list-item:hover { background: var(--bg-hover); + transform: translateX(4px); } .list-item:last-child { @@ -160,15 +309,17 @@ border: 1px solid var(--border-color); border-radius: 12px; cursor: pointer; - transition: all 0.15s ease; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); text-align: center; position: relative; + overflow: hidden; } .board-grid-item:hover { background: var(--bg-hover); border-color: var(--accent); - transform: translateY(-2px); + transform: translateY(-4px) scale(1.02); + box-shadow: var(--shadow-md); } .board-grid-image { diff --git a/src/styles/layout.css b/src/styles/layout.css index b29bff3..faad355 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -143,6 +143,42 @@ justify-content: center; } +/* Entrance animations with staggered delays */ +@keyframes buttonEntrance { + from { + opacity: 0; + transform: translateY(24px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.home-button-group:nth-child(1) .home-button { + animation: buttonEntrance 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + animation-delay: 0ms; + opacity: 0; +} + +.home-button-group:nth-child(2) .home-button { + animation: buttonEntrance 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + animation-delay: 100ms; + opacity: 0; +} + +.home-button-group:nth-child(3) .home-button { + animation: buttonEntrance 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + animation-delay: 200ms; + opacity: 0; +} + +.home-button-group:nth-child(4) .home-button { + animation: buttonEntrance 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + animation-delay: 300ms; + opacity: 0; +} + .home-write-section { display: flex; justify-content: center; @@ -151,19 +187,25 @@ .home-button-group { display: flex; flex-direction: column; - gap: 8px; + gap: 10px; flex: 1; min-width: 200px; max-width: 280px; } .home-button-label { - font-size: 14px; - font-weight: 600; - color: var(--text-secondary); + font-size: 11px; + font-weight: 700; + color: var(--text-muted); text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.8px; text-align: center; + margin-bottom: 2px; + transition: color 0.2s ease; +} + +.home-button-group:hover .home-button-label { + color: var(--accent); } .home-button { @@ -171,13 +213,15 @@ flex-direction: column; align-items: center; justify-content: center; - gap: 12px; + gap: 14px; padding: 28px 24px; background: var(--bg-card); border: 2px solid var(--border-color); border-radius: 12px; cursor: pointer; - transition: all 0.15s ease; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; width: 100%; height: 140px; box-shadow: var(--shadow-sm); @@ -187,6 +231,7 @@ border-color: var(--accent); background: var(--bg-hover); box-shadow: var(--shadow-md); + transform: translateY(-3px) scale(1.02); } .home-button:disabled { @@ -197,6 +242,46 @@ .home-button.selected { border-color: var(--accent); background: var(--bg-hover); + box-shadow: 0 0 0 3px rgba(242, 101, 34, 0.15); + transform: translateY(-1px); +} + +@media (prefers-color-scheme: dark) { + .home-button.selected { + box-shadow: 0 0 0 3px rgba(242, 101, 34, 0.25); + } +} + +/* Active state for tactile feedback */ +.home-button:active:not(:disabled) { + transform: translateY(-1px) scale(0.98); + box-shadow: var(--shadow-sm); + border-color: var(--accent-hover); + transition: all 0.1s ease; +} + +.home-button:active:not(:disabled) svg { + transform: scale(0.95) rotate(0deg); + transition: transform 0.1s ease; +} + +/* Keyboard navigation focus */ +.home-button:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(242, 101, 34, 0.3); + transform: translateY(-1px); +} + +@media (prefers-color-scheme: dark) { + .home-button:focus-visible { + box-shadow: 0 0 0 3px rgba(242, 101, 34, 0.4); + } +} + +.home-button:focus:not(:focus-visible) { + outline: none; + box-shadow: var(--shadow-sm); } .home-button svg { @@ -204,15 +289,27 @@ flex-shrink: 0; width: 36px; height: 36px; + transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1)); +} + +.home-button:hover:not(:disabled) svg { + transform: scale(1.1) rotate(-3deg); +} + +@media (prefers-color-scheme: dark) { + .home-button svg { + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3)); + } } .home-button-text { font-size: 15px; - font-weight: 600; + font-weight: 700; color: var(--accent); text-align: center; text-transform: uppercase; - letter-spacing: 0.3px; + letter-spacing: 0.4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -223,18 +320,19 @@ display: flex; flex-direction: column; align-items: center; - gap: 5px; + gap: 6px; max-width: 180px; } /* Home button title styling - uses shared MarqueeText component */ .home-button-title { - font-size: 14px; - font-weight: 600; + font-size: 15px; + font-weight: 700; color: var(--accent); text-align: center; text-transform: uppercase; - letter-spacing: 0.3px; + letter-spacing: 0.4px; + line-height: 1.3; } .home-button-subtitle { @@ -246,6 +344,13 @@ text-overflow: ellipsis; white-space: nowrap; max-width: 180px; + opacity: 0.9; + transition: opacity 0.2s ease; +} + +.home-button:hover:not(:disabled) .home-button-subtitle { + opacity: 1; + color: var(--text-primary); } .home-button:disabled .home-button-text, @@ -253,6 +358,25 @@ color: var(--text-muted); } +/* Respect user motion preferences */ +@media (prefers-reduced-motion: reduce) { + .home-button { + transition: background 0.15s ease, border-color 0.15s ease; + animation: none !important; + opacity: 1 !important; + transform: none !important; + } + + .home-button:hover:not(:disabled), + .home-button:active:not(:disabled) { + transform: none; + } + + .home-button svg { + transition: none; + } +} + .home-write-button { display: flex; align-items: center; diff --git a/src/styles/modal.css b/src/styles/modal.css index 1cfdf9f..b03ba7b 100644 --- a/src/styles/modal.css +++ b/src/styles/modal.css @@ -1,3 +1,148 @@ +/* ======================================== + MODAL ANIMATIONS + ======================================== */ + +/* Overlay fade animation */ +@keyframes modalOverlayFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes modalOverlayFadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +/* Modal content slide and scale animation */ +@keyframes modalContentSlideIn { + from { + opacity: 0; + transform: translateY(32px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes modalContentSlideOut { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(16px) scale(0.98); + } +} + +/* Staggered list item animations */ +@keyframes listItemFadeIn { + from { + opacity: 0; + transform: translateX(-12px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +/* Staggered board grid animations */ +@keyframes boardGridFadeIn { + from { + opacity: 0; + transform: translateY(12px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* Apply staggered animations to list items in modal */ +.modal-list .list-item { + opacity: 0; + animation: listItemFadeIn 0.3s ease-out forwards; +} + +/* Disable animations for dynamically updated lists (DeviceModal) */ +.modal-list.no-animations .list-item { + opacity: 1 !important; + animation: none !important; +} + +.modal-list .list-item:nth-child(1) { animation-delay: 0.15s; } +.modal-list .list-item:nth-child(2) { animation-delay: 0.175s; } +.modal-list .list-item:nth-child(3) { animation-delay: 0.2s; } +.modal-list .list-item:nth-child(4) { animation-delay: 0.225s; } +.modal-list .list-item:nth-child(5) { animation-delay: 0.25s; } +.modal-list .list-item:nth-child(6) { animation-delay: 0.275s; } +.modal-list .list-item:nth-child(7) { animation-delay: 0.3s; } +.modal-list .list-item:nth-child(8) { animation-delay: 0.325s; } +.modal-list .list-item:nth-child(9) { animation-delay: 0.35s; } +.modal-list .list-item:nth-child(10) { animation-delay: 0.375s; } + +/* For items beyond 10, use a formula */ +.modal-list .list-item:nth-child(n+11) { + animation-delay: calc(0.15s + (0.025s * (10 - 1))); +} + +/* Apply staggered animations to board grid items in modal */ +.board-grid-container .board-grid-item { + opacity: 0; + animation: boardGridFadeIn 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} + +.board-grid-container .board-grid-item:nth-child(1) { animation-delay: 0.15s; } +.board-grid-container .board-grid-item:nth-child(2) { animation-delay: 0.175s; } +.board-grid-container .board-grid-item:nth-child(3) { animation-delay: 0.2s; } +.board-grid-container .board-grid-item:nth-child(4) { animation-delay: 0.225s; } +.board-grid-container .board-grid-item:nth-child(5) { animation-delay: 0.25s; } +.board-grid-container .board-grid-item:nth-child(6) { animation-delay: 0.275s; } +.board-grid-container .board-grid-item:nth-child(7) { animation-delay: 0.3s; } +.board-grid-container .board-grid-item:nth-child(8) { animation-delay: 0.325s; } +.board-grid-container .board-grid-item:nth-child(9) { animation-delay: 0.35s; } +.board-grid-container .board-grid-item:nth-child(10) { animation-delay: 0.375s; } +.board-grid-container .board-grid-item:nth-child(11) { animation-delay: 0.4s; } +.board-grid-container .board-grid-item:nth-child(12) { animation-delay: 0.425s; } + +.board-grid-container .board-grid-item:nth-child(n+13) { + animation-delay: calc(0.15s + (0.025s * (12 - 1))); +} + +/* Respect user motion preferences */ +@media (prefers-reduced-motion: reduce) { + .modal-overlay, + .modal { + animation: none !important; + } + + .modal-list .list-item, + .board-grid-container .board-grid-item { + animation: none !important; + opacity: 1 !important; + } + + .list-item, + .board-grid-item { + transition: background 0.15s ease; + } + + .list-item:hover, + .board-grid-item:hover { + transform: none; + } +} + /* ======================================== MODAL / DIALOG ======================================== */ @@ -12,6 +157,15 @@ padding: 24px; } +/* Modal overlay animations */ +.modal-overlay.modal-entering { + animation: modalOverlayFadeIn 0.25s ease-out forwards; +} + +.modal-overlay.modal-exiting { + animation: modalOverlayFadeOut 0.2s ease-in forwards; +} + .modal { background: var(--bg-modal); border-radius: 12px; @@ -24,6 +178,15 @@ overflow: hidden; } +/* Modal content animations */ +.modal.modal-entering { + animation: modalContentSlideIn 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} + +.modal.modal-exiting { + animation: modalContentSlideOut 0.2s ease-in forwards; +} + .modal-header { display: flex; align-items: center; diff --git a/src/styles/responsive.css b/src/styles/responsive.css index 27aa9be..985a4be 100644 --- a/src/styles/responsive.css +++ b/src/styles/responsive.css @@ -108,6 +108,12 @@ max-width: 300px; } + /* Faster stagger for mobile */ + .home-button-group:nth-child(1) .home-button { animation-delay: 0ms; } + .home-button-group:nth-child(2) .home-button { animation-delay: 80ms; } + .home-button-group:nth-child(3) .home-button { animation-delay: 160ms; } + .home-button-group:nth-child(4) .home-button { animation-delay: 240ms; } + .home-button { min-width: auto; width: 100%;