Initial changes

This commit is contained in:
Tickbase
2025-05-18 08:06:56 +02:00
parent 19087c00da
commit 0be15f83e7
82 changed files with 4636 additions and 3237 deletions

View File

@@ -1,41 +0,0 @@
import React from 'react'
export type ActionType = 'install_cream' | 'uninstall_cream' | 'install_smoke' | 'uninstall_smoke'
interface ActionButtonProps {
action: ActionType
isInstalled: boolean
isWorking: boolean
onClick: () => void
disabled?: boolean
}
const ActionButton: React.FC<ActionButtonProps> = ({
action,
isInstalled,
isWorking,
onClick,
disabled = false,
}) => {
const getButtonText = () => {
if (isWorking) return 'Working...'
const isCream = action.includes('cream')
const product = isCream ? 'CreamLinux' : 'SmokeAPI'
return isInstalled ? `Uninstall ${product}` : `Install ${product}`
}
const getButtonClass = () => {
const baseClass = 'action-button'
return `${baseClass} ${isInstalled ? 'uninstall' : 'install'}`
}
return (
<button className={getButtonClass()} onClick={onClick} disabled={disabled || isWorking}>
{getButtonText()}
</button>
)
}
export default ActionButton

View File

@@ -1,239 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react'
import AnimatedCheckbox from './AnimatedCheckbox'
interface DlcInfo {
appid: string
name: string
enabled: boolean
}
interface DlcSelectionDialogProps {
visible: boolean
gameTitle: string
dlcs: DlcInfo[]
onClose: () => void
onConfirm: (selectedDlcs: DlcInfo[]) => void
isLoading: boolean
isEditMode?: boolean
loadingProgress?: number
estimatedTimeLeft?: string
}
const DlcSelectionDialog: React.FC<DlcSelectionDialogProps> = ({
visible,
gameTitle,
dlcs,
onClose,
onConfirm,
isLoading,
isEditMode = false,
loadingProgress = 0,
estimatedTimeLeft = '',
}) => {
const [selectedDlcs, setSelectedDlcs] = useState<DlcInfo[]>([])
const [showContent, setShowContent] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [selectAll, setSelectAll] = useState(true)
const [initialized, setInitialized] = useState(false)
// Initialize selected DLCs when DLC list changes
useEffect(() => {
if (visible && dlcs.length > 0 && !initialized) {
setSelectedDlcs(dlcs)
// Determine initial selectAll state based on if all DLCs are enabled
const allSelected = dlcs.every((dlc) => dlc.enabled)
setSelectAll(allSelected)
// Mark as initialized so we don't reset selections on subsequent DLC additions
setInitialized(true)
}
}, [visible, dlcs, initialized])
// Handle visibility changes
useEffect(() => {
if (visible) {
// Show content immediately for better UX
const timer = setTimeout(() => {
setShowContent(true)
}, 50)
return () => clearTimeout(timer)
} else {
setShowContent(false)
setInitialized(false) // Reset initialized state when dialog closes
}
}, [visible])
// Memoize filtered DLCs to avoid unnecessary recalculations
const filteredDlcs = useMemo(() => {
return searchQuery.trim() === ''
? selectedDlcs
: selectedDlcs.filter(
(dlc) =>
dlc.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
dlc.appid.includes(searchQuery)
)
}, [selectedDlcs, searchQuery])
// Update DLC selection status
const handleToggleDlc = (appid: string) => {
setSelectedDlcs((prev) =>
prev.map((dlc) => (dlc.appid === appid ? { ...dlc, enabled: !dlc.enabled } : dlc))
)
}
// Update selectAll state when individual DLC selections change
useEffect(() => {
const allSelected = selectedDlcs.every((dlc) => dlc.enabled)
setSelectAll(allSelected)
}, [selectedDlcs])
// Handle new DLCs being added while dialog is already open
useEffect(() => {
if (initialized && dlcs.length > selectedDlcs.length) {
// Find new DLCs that aren't in our current selection
const currentAppIds = new Set(selectedDlcs.map((dlc) => dlc.appid))
const newDlcs = dlcs.filter((dlc) => !currentAppIds.has(dlc.appid))
// Add new DLCs to our selection, maintaining their enabled state
if (newDlcs.length > 0) {
setSelectedDlcs((prev) => [...prev, ...newDlcs])
}
}
}, [dlcs, selectedDlcs, initialized])
const handleToggleSelectAll = () => {
const newSelectAllState = !selectAll
setSelectAll(newSelectAllState)
setSelectedDlcs((prev) =>
prev.map((dlc) => ({
...dlc,
enabled: newSelectAllState,
}))
)
}
const handleConfirm = () => {
onConfirm(selectedDlcs)
}
// Modified to prevent closing when loading
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
// Prevent clicks from propagating through the overlay
e.stopPropagation()
// Only allow closing via overlay click if not loading
if (e.target === e.currentTarget && !isLoading) {
onClose()
}
}
// Count selected DLCs
const selectedCount = selectedDlcs.filter((dlc) => dlc.enabled).length
// Format loading message to show total number of DLCs found
const getLoadingInfoText = () => {
if (isLoading && loadingProgress < 100) {
return ` (Loading more DLCs...)`
} else if (dlcs.length > 0) {
return ` (Total DLCs: ${dlcs.length})`
}
return ''
}
if (!visible) return null
return (
<div
className={`dlc-dialog-overlay ${showContent ? 'visible' : ''}`}
onClick={handleOverlayClick}
>
<div className={`dlc-selection-dialog ${showContent ? 'dialog-visible' : ''}`}>
<div className="dlc-dialog-header">
<h3>{isEditMode ? 'Edit DLCs' : 'Select DLCs to Enable'}</h3>
<div className="dlc-game-info">
<span className="game-title">{gameTitle}</span>
<span className="dlc-count">
{selectedCount} of {selectedDlcs.length} DLCs selected
{getLoadingInfoText()}
</span>
</div>
</div>
<div className="dlc-dialog-search">
<input
type="text"
placeholder="Search DLCs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="dlc-search-input"
/>
<div className="select-all-container">
<AnimatedCheckbox
checked={selectAll}
onChange={handleToggleSelectAll}
label="Select All"
/>
</div>
</div>
{isLoading && (
<div className="dlc-loading-progress">
<div className="progress-bar-container">
<div className="progress-bar" style={{ width: `${loadingProgress}%` }} />
</div>
<div className="loading-details">
<span>Loading DLCs: {loadingProgress}%</span>
{estimatedTimeLeft && (
<span className="time-left">Est. time left: {estimatedTimeLeft}</span>
)}
</div>
</div>
)}
<div className="dlc-list-container">
{selectedDlcs.length > 0 ? (
<ul className="dlc-list">
{filteredDlcs.map((dlc) => (
<li key={dlc.appid} className="dlc-item">
<AnimatedCheckbox
checked={dlc.enabled}
onChange={() => handleToggleDlc(dlc.appid)}
label={dlc.name}
sublabel={`ID: ${dlc.appid}`}
/>
</li>
))}
{isLoading && (
<li className="dlc-item dlc-item-loading">
<div className="loading-pulse"></div>
</li>
)}
</ul>
) : (
<div className="dlc-loading">
<div className="loading-spinner"></div>
<p>Loading DLC information...</p>
</div>
)}
</div>
<div className="dlc-dialog-actions">
<button
className="cancel-button"
onClick={onClose}
disabled={isLoading && loadingProgress < 10} // Briefly disable to prevent accidental closing at start
>
Cancel
</button>
<button className="confirm-button" onClick={handleConfirm} disabled={isLoading}>
{isEditMode ? 'Save Changes' : 'Install with Selected DLCs'}
</button>
</div>
</div>
</div>
)
}
export default DlcSelectionDialog

View File

@@ -1,41 +0,0 @@
import React, { useEffect } from 'react'
import { findBestGameImage } from '../services/ImageService'
interface ImagePreloaderProps {
gameIds: string[]
onComplete?: () => void
}
const ImagePreloader: React.FC<ImagePreloaderProps> = ({ gameIds, onComplete }) => {
useEffect(() => {
const preloadImages = async () => {
try {
// Only preload the first batch for performance (10 images max)
const batchToPreload = gameIds.slice(0, 10)
// Load images in parallel
await Promise.allSettled(batchToPreload.map((id) => findBestGameImage(id)))
if (onComplete) {
onComplete()
}
} catch (error) {
console.error('Error preloading images:', error)
// Continue even if there's an error
if (onComplete) {
onComplete()
}
}
}
if (gameIds.length > 0) {
preloadImages()
} else if (onComplete) {
onComplete()
}
}, [gameIds, onComplete])
return <div className="image-preloader">{/* Hidden element, just used for preloading */}</div>
}
export default ImagePreloader

View File

@@ -1,33 +0,0 @@
import React from 'react'
interface SidebarProps {
setFilter: (filter: string) => void
currentFilter: string
}
const Sidebar: React.FC<SidebarProps> = ({ setFilter, currentFilter }) => {
return (
<div className="sidebar">
<h2>Library</h2>
<ul className="filter-list">
<li className={currentFilter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>
All Games
</li>
<li
className={currentFilter === 'native' ? 'active' : ''}
onClick={() => setFilter('native')}
>
Native
</li>
<li
className={currentFilter === 'proton' ? 'active' : ''}
onClick={() => setFilter('proton')}
>
Proton Required
</li>
</ul>
</div>
)
}
export default Sidebar

View File

@@ -0,0 +1,59 @@
import { FC } from 'react'
import Button, { ButtonVariant } from '../buttons/Button'
// Define available action types
export type ActionType = 'install_cream' | 'uninstall_cream' | 'install_smoke' | 'uninstall_smoke'
interface ActionButtonProps {
action: ActionType
isInstalled: boolean
isWorking: boolean
onClick: () => void
disabled?: boolean
className?: string
}
/**
* Specialized button for game installation actions
*/
const ActionButton: FC<ActionButtonProps> = ({
action,
isInstalled,
isWorking,
onClick,
disabled = false,
className = '',
}) => {
// Determine button text based on state
const getButtonText = () => {
if (isWorking) return 'Working...'
const isCream = action.includes('cream')
const product = isCream ? 'CreamLinux' : 'SmokeAPI'
return isInstalled ? `Uninstall ${product}` : `Install ${product}`
}
// Map to our button variant
const getButtonVariant = (): ButtonVariant => {
// For uninstall actions, use danger variant
if (isInstalled) return 'danger'
// For install actions, use success variant
return 'success'
}
return (
<Button
variant={getButtonVariant()}
isLoading={isWorking}
onClick={onClick}
disabled={disabled || isWorking}
fullWidth
className={`action-button ${className}`}
>
{getButtonText()}
</Button>
)
}
export default ActionButton

View File

@@ -1,25 +1,32 @@
import React from 'react'
interface AnimatedCheckboxProps {
checked: boolean
onChange: () => void
label?: string
sublabel?: string
className?: string
checked: boolean;
onChange: () => void;
label?: string;
sublabel?: string;
className?: string;
}
const AnimatedCheckbox: React.FC<AnimatedCheckboxProps> = ({
/**
* Animated checkbox component with optional label and sublabel
*/
const AnimatedCheckbox = ({
checked,
onChange,
label,
sublabel,
className = '',
}) => {
}: AnimatedCheckboxProps) => {
return (
<label className={`animated-checkbox ${className}`}>
<input type="checkbox" checked={checked} onChange={onChange} className="checkbox-original" />
<input
type="checkbox"
checked={checked}
onChange={onChange}
className="checkbox-original"
/>
<span className={`checkbox-custom ${checked ? 'checked' : ''}`}>
<svg viewBox="0 0 24 24" className="checkmark-icon">
<svg viewBox="0 0 24 24" className="checkmark-icon" aria-hidden="true">
<path
className={`checkmark ${checked ? 'checked' : ''}`}
d="M5 12l5 5L20 7"
@@ -31,6 +38,7 @@ const AnimatedCheckbox: React.FC<AnimatedCheckboxProps> = ({
/>
</svg>
</span>
{(label || sublabel) && (
<div className="checkbox-content">
{label && <span className="checkbox-label">{label}</span>}
@@ -41,4 +49,4 @@ const AnimatedCheckbox: React.FC<AnimatedCheckboxProps> = ({
)
}
export default AnimatedCheckbox
export default AnimatedCheckbox

View File

@@ -0,0 +1,67 @@
import { FC, ButtonHTMLAttributes } from 'react';
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'success' | 'warning';
export type ButtonSize = 'small' | 'medium' | 'large';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
isLoading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
fullWidth?: boolean;
}
/**
* Button component with different variants, sizes and states
*/
const Button: FC<ButtonProps> = ({
children,
variant = 'primary',
size = 'medium',
isLoading = false,
leftIcon,
rightIcon,
fullWidth = false,
className = '',
disabled,
...props
}) => {
// Size class mapping
const sizeClass = {
small: 'btn-sm',
medium: 'btn-md',
large: 'btn-lg',
}[size];
// Variant class mapping
const variantClass = {
primary: 'btn-primary',
secondary: 'btn-secondary',
danger: 'btn-danger',
success: 'btn-success',
warning: 'btn-warning',
}[variant];
return (
<button
className={`btn ${variantClass} ${sizeClass} ${fullWidth ? 'btn-full' : ''} ${
isLoading ? 'btn-loading' : ''
} ${className}`}
disabled={disabled || isLoading}
{...props}
>
{isLoading && (
<span className="btn-spinner">
<span className="spinner"></span>
</span>
)}
{leftIcon && !isLoading && <span className="btn-icon btn-icon-left">{leftIcon}</span>}
<span className="btn-text">{children}</span>
{rightIcon && !isLoading && <span className="btn-icon btn-icon-right">{rightIcon}</span>}
</button>
);
};
export default Button;

View File

@@ -0,0 +1,8 @@
// Export all button components
export { default as Button } from './Button';
export { default as ActionButton } from './ActionButton';
export { default as AnimatedCheckbox } from './AnimatedCheckbox';
// Export types
export type { ButtonVariant, ButtonSize } from './Button';
export type { ActionType } from './ActionButton';

View File

@@ -0,0 +1,75 @@
import { ReactNode } from 'react'
export type LoadingType = 'spinner' | 'dots' | 'progress'
export type LoadingSize = 'small' | 'medium' | 'large'
interface LoadingIndicatorProps {
size?: LoadingSize;
type?: LoadingType;
message?: string;
progress?: number;
className?: string;
}
/**
* Versatile loading indicator component
* Supports multiple visual styles and sizes
*/
const LoadingIndicator = ({
size = 'medium',
type = 'spinner',
message,
progress = 0,
className = '',
}: LoadingIndicatorProps) => {
// Size class mapping
const sizeClass = {
small: 'loading-small',
medium: 'loading-medium',
large: 'loading-large',
}[size]
// Render loading indicator based on type
const renderLoadingIndicator = (): ReactNode => {
switch (type) {
case 'spinner':
return <div className="loading-spinner"></div>
case 'dots':
return (
<div className="loading-dots">
<div className="dot dot-1"></div>
<div className="dot dot-2"></div>
<div className="dot dot-3"></div>
</div>
)
case 'progress':
return (
<div className="loading-progress">
<div className="progress-bar-container">
<div
className="progress-bar"
style={{ width: `${Math.min(Math.max(progress, 0), 100)}%` }}
></div>
</div>
{progress > 0 && (
<div className="progress-percentage">{Math.round(progress)}%</div>
)}
</div>
)
default:
return <div className="loading-spinner"></div>
}
}
return (
<div className={`loading-indicator ${sizeClass} ${className}`}>
{renderLoadingIndicator()}
{message && <p className="loading-message">{message}</p>}
</div>
)
}
export default LoadingIndicator

View File

@@ -0,0 +1,3 @@
export { default as LoadingIndicator } from './LoadingIndicator';
export type { LoadingSize, LoadingType } from './LoadingIndicator';

View File

@@ -0,0 +1,82 @@
import { ReactNode, useEffect, useState } from 'react'
export interface DialogProps {
visible: boolean;
onClose?: () => void;
className?: string;
preventBackdropClose?: boolean;
children: ReactNode;
size?: 'small' | 'medium' | 'large';
showAnimationOnUnmount?: boolean;
}
/**
* Base Dialog component that serves as a container for dialog content
* Used with DialogHeader, DialogBody, and DialogFooter components
*/
const Dialog = ({
visible,
onClose,
className = '',
preventBackdropClose = false,
children,
size = 'medium',
showAnimationOnUnmount = true,
}: DialogProps) => {
const [showContent, setShowContent] = useState(false)
const [shouldRender, setShouldRender] = useState(visible)
// Handle visibility changes with animations
useEffect(() => {
if (visible) {
setShouldRender(true)
// Small delay to trigger entrance animation after component is mounted
const timer = setTimeout(() => {
setShowContent(true)
}, 50)
return () => clearTimeout(timer)
} else if (showAnimationOnUnmount) {
// First hide content with animation
setShowContent(false)
// Then unmount after animation completes
const timer = setTimeout(() => {
setShouldRender(false)
}, 300) // Match this with your CSS transition duration
return () => clearTimeout(timer)
} else {
// Immediately unmount without animation
setShowContent(false)
setShouldRender(false)
}
}, [visible, showAnimationOnUnmount])
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget && !preventBackdropClose && onClose) {
onClose()
}
}
// Don't render anything if dialog shouldn't be shown
if (!shouldRender) return null
const sizeClass = {
small: 'dialog-small',
medium: 'dialog-medium',
large: 'dialog-large',
}[size]
return (
<div
className={`dialog-overlay ${showContent ? 'visible' : ''}`}
onClick={handleBackdropClick}
>
<div
className={`dialog ${sizeClass} ${className} ${showContent ? 'dialog-visible' : ''}`}
>
{children}
</div>
</div>
)
}
export default Dialog

View File

@@ -0,0 +1,31 @@
import { ReactNode } from 'react'
export interface DialogActionsProps {
children: ReactNode;
className?: string;
align?: 'start' | 'center' | 'end';
}
/**
* Actions container for dialog footers
* Provides consistent spacing and alignment for action buttons
*/
const DialogActions = ({
children,
className = '',
align = 'end'
}: DialogActionsProps) => {
const alignClass = {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end'
}[align];
return (
<div className={`dialog-actions ${alignClass} ${className}`}>
{children}
</div>
)
}
export default DialogActions

View File

@@ -0,0 +1,20 @@
import { ReactNode } from 'react'
export interface DialogBodyProps {
children: ReactNode;
className?: string;
}
/**
* Body component for dialogs
* Contains the main content with scrolling capability
*/
const DialogBody = ({ children, className = '' }: DialogBodyProps) => {
return (
<div className={`dialog-body ${className}`}>
{children}
</div>
)
}
export default DialogBody

View File

@@ -0,0 +1,20 @@
import { ReactNode } from 'react'
export interface DialogFooterProps {
children: ReactNode;
className?: string;
}
/**
* Footer component for dialogs
* Contains action buttons and optional status information
*/
const DialogFooter = ({ children, className = '' }: DialogFooterProps) => {
return (
<div className={`dialog-footer ${className}`}>
{children}
</div>
)
}
export default DialogFooter

View File

@@ -0,0 +1,30 @@
import { ReactNode } from 'react'
export interface DialogHeaderProps {
children: ReactNode;
className?: string;
onClose?: () => void;
}
/**
* Header component for dialogs
* Contains the title and optional close button
*/
const DialogHeader = ({ children, className = '', onClose }: DialogHeaderProps) => {
return (
<div className={`dialog-header ${className}`}>
{children}
{onClose && (
<button
className="dialog-close-button"
onClick={onClose}
aria-label="Close dialog"
>
×
</button>
)}
</div>
)
}
export default DialogHeader

View File

@@ -0,0 +1,221 @@
import React, { useState, useEffect } from 'react'
import Dialog from './Dialog'
import DialogHeader from './DialogHeader'
import DialogBody from './DialogBody'
import DialogFooter from './DialogFooter'
import DialogActions from './DialogActions'
import { Button, AnimatedCheckbox } from '@/components/buttons'
import { DlcInfo } from '@/types'
export interface DlcSelectionDialogProps {
visible: boolean;
gameTitle: string;
dlcs: DlcInfo[];
onClose: () => void;
onConfirm: (selectedDlcs: DlcInfo[]) => void;
isLoading: boolean;
isEditMode?: boolean;
loadingProgress?: number;
estimatedTimeLeft?: string;
}
/**
* DLC Selection Dialog component
* Allows users to select which DLCs they want to enable
*/
const DlcSelectionDialog = ({
visible,
gameTitle,
dlcs,
onClose,
onConfirm,
isLoading,
isEditMode = false,
loadingProgress = 0,
estimatedTimeLeft = '',
}: DlcSelectionDialogProps) => {
const [selectedDlcs, setSelectedDlcs] = useState<DlcInfo[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [selectAll, setSelectAll] = useState(true)
const [initialized, setInitialized] = useState(false)
// Initialize selected DLCs when DLC list changes
useEffect(() => {
if (dlcs.length > 0 && !initialized) {
setSelectedDlcs(dlcs)
// Determine initial selectAll state based on if all DLCs are enabled
const allSelected = dlcs.every((dlc) => dlc.enabled)
setSelectAll(allSelected)
// Mark as initialized so we don't reset selections on subsequent DLC additions
setInitialized(true)
}
}, [dlcs, initialized])
// Memoize filtered DLCs to avoid unnecessary recalculations
const filteredDlcs = React.useMemo(() => {
return searchQuery.trim() === ''
? selectedDlcs
: selectedDlcs.filter(
(dlc) =>
dlc.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
dlc.appid.includes(searchQuery)
)
}, [selectedDlcs, searchQuery])
// Update DLC selection status
const handleToggleDlc = (appid: string) => {
setSelectedDlcs((prev) =>
prev.map((dlc) => (dlc.appid === appid ? { ...dlc, enabled: !dlc.enabled } : dlc))
)
}
// Update selectAll state when individual DLC selections change
useEffect(() => {
const allSelected = selectedDlcs.every((dlc) => dlc.enabled)
setSelectAll(allSelected)
}, [selectedDlcs])
// Handle new DLCs being added while dialog is already open
useEffect(() => {
if (initialized && dlcs.length > selectedDlcs.length) {
// Find new DLCs that aren't in our current selection
const currentAppIds = new Set(selectedDlcs.map((dlc) => dlc.appid))
const newDlcs = dlcs.filter((dlc) => !currentAppIds.has(dlc.appid))
// Add new DLCs to our selection, maintaining their enabled state
if (newDlcs.length > 0) {
setSelectedDlcs((prev) => [...prev, ...newDlcs])
}
}
}, [dlcs, selectedDlcs, initialized])
const handleToggleSelectAll = () => {
const newSelectAllState = !selectAll
setSelectAll(newSelectAllState)
setSelectedDlcs((prev) =>
prev.map((dlc) => ({
...dlc,
enabled: newSelectAllState,
}))
)
}
const handleConfirm = () => {
onConfirm(selectedDlcs)
}
// Count selected DLCs
const selectedCount = selectedDlcs.filter((dlc) => dlc.enabled).length
// Format loading message to show total number of DLCs found
const getLoadingInfoText = () => {
if (isLoading && loadingProgress < 100) {
return ` (Loading more DLCs...)`
} else if (dlcs.length > 0) {
return ` (Total DLCs: ${dlcs.length})`
}
return ''
}
return (
<Dialog
visible={visible}
onClose={onClose}
size="large"
preventBackdropClose={isLoading}
>
<DialogHeader onClose={onClose}>
<h3>{isEditMode ? 'Edit DLCs' : 'Select DLCs to Enable'}</h3>
<div className="dlc-game-info">
<span className="game-title">{gameTitle}</span>
<span className="dlc-count">
{selectedCount} of {selectedDlcs.length} DLCs selected
{getLoadingInfoText()}
</span>
</div>
</DialogHeader>
<div className="dlc-dialog-search">
<input
type="text"
placeholder="Search DLCs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="dlc-search-input"
/>
<div className="select-all-container">
<AnimatedCheckbox
checked={selectAll}
onChange={handleToggleSelectAll}
label="Select All"
/>
</div>
</div>
{isLoading && (
<div className="dlc-loading-progress">
<div className="progress-bar-container">
<div className="progress-bar" style={{ width: `${loadingProgress}%` }} />
</div>
<div className="loading-details">
<span>Loading DLCs: {loadingProgress}%</span>
{estimatedTimeLeft && (
<span className="time-left">Est. time left: {estimatedTimeLeft}</span>
)}
</div>
</div>
)}
<DialogBody className="dlc-list-container">
{selectedDlcs.length > 0 ? (
<ul className="dlc-list">
{filteredDlcs.map((dlc) => (
<li key={dlc.appid} className="dlc-item">
<AnimatedCheckbox
checked={dlc.enabled}
onChange={() => handleToggleDlc(dlc.appid)}
label={dlc.name}
sublabel={`ID: ${dlc.appid}`}
/>
</li>
))}
{isLoading && (
<li className="dlc-item dlc-item-loading">
<div className="loading-pulse"></div>
</li>
)}
</ul>
) : (
<div className="dlc-loading">
<div className="loading-spinner"></div>
<p>Loading DLC information...</p>
</div>
)}
</DialogBody>
<DialogFooter>
<DialogActions>
<Button
variant="secondary"
onClick={onClose}
disabled={isLoading && loadingProgress < 10} // Briefly disable to prevent accidental closing at start
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleConfirm}
disabled={isLoading}
>
{isEditMode ? 'Save Changes' : 'Install with Selected DLCs'}
</Button>
</DialogActions>
</DialogFooter>
</Dialog>
)
}
export default DlcSelectionDialog

View File

@@ -1,49 +1,42 @@
import React, { useState, useEffect } from 'react'
import { useState } from 'react'
import Dialog from './Dialog'
import DialogHeader from './DialogHeader'
import DialogBody from './DialogBody'
import DialogFooter from './DialogFooter'
import DialogActions from './DialogActions'
import { Button } from '@/components/buttons'
interface InstructionInfo {
type: string
command: string
game_title: string
dlc_count?: number
export interface InstallationInstructions {
type: string;
command: string;
game_title: string;
dlc_count?: number;
}
interface ProgressDialogProps {
title: string
message: string
progress: number // 0-100
visible: boolean
showInstructions?: boolean
instructions?: InstructionInfo
onClose?: () => void
export interface ProgressDialogProps {
visible: boolean;
title: string;
message: string;
progress: number;
showInstructions?: boolean;
instructions?: InstallationInstructions;
onClose?: () => void;
}
const ProgressDialog: React.FC<ProgressDialogProps> = ({
/**
* ProgressDialog component
* Shows installation progress with a progress bar and optional instructions
*/
const ProgressDialog = ({
visible,
title,
message,
progress,
visible,
showInstructions = false,
instructions,
onClose,
}) => {
}: ProgressDialogProps) => {
const [copySuccess, setCopySuccess] = useState(false)
const [showContent, setShowContent] = useState(false)
// Reset copy state when dialog visibility changes
useEffect(() => {
if (!visible) {
setCopySuccess(false)
setShowContent(false)
} else {
// Add a small delay to trigger the entrance animation
const timer = setTimeout(() => {
setShowContent(true)
}, 50)
return () => clearTimeout(timer)
}
}, [visible])
if (!visible) return null
const handleCopyCommand = () => {
if (instructions?.command) {
@@ -57,29 +50,6 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
}
}
const handleClose = () => {
setShowContent(false)
// Delay closing to allow exit animation
setTimeout(() => {
if (onClose) {
onClose()
}
}, 300)
}
// Prevent closing when in progress
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
// Always prevent propagation
e.stopPropagation()
// Only allow clicking outside to close if we're done processing (100%)
// and showing instructions or if explicitly allowed via a prop
if (e.target === e.currentTarget && progress >= 100 && showInstructions) {
handleClose()
}
// Otherwise, do nothing - require using the close button
}
// Determine if we should show the copy button (for CreamLinux but not SmokeAPI)
const showCopyButton =
instructions?.type === 'cream_install' || instructions?.type === 'cream_uninstall'
@@ -147,14 +117,17 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
const isCloseButtonEnabled = showInstructions || progress >= 100
return (
<div
className={`progress-dialog-overlay ${showContent ? 'visible' : ''}`}
onClick={handleOverlayClick}
<Dialog
visible={visible}
onClose={isCloseButtonEnabled ? onClose : undefined}
size="medium"
preventBackdropClose={!isCloseButtonEnabled}
>
<div
className={`progress-dialog ${showInstructions ? 'with-instructions' : ''} ${showContent ? 'dialog-visible' : ''}`}
>
<DialogHeader>
<h3>{title}</h3>
</DialogHeader>
<DialogBody>
<p>{message}</p>
<div className="progress-bar-container">
@@ -174,36 +147,34 @@ const ProgressDialog: React.FC<ProgressDialogProps> = ({
<div className={getCommandBoxClass()}>
<pre className="selectable-text">{instructions.command}</pre>
</div>
<div className="action-buttons">
{showCopyButton && (
<button className="copy-button" onClick={handleCopyCommand}>
{copySuccess ? 'Copied!' : 'Copy to Clipboard'}
</button>
)}
<button
className="close-button"
onClick={handleClose}
disabled={!isCloseButtonEnabled}
>
Close
</button>
</div>
</div>
)}
</DialogBody>
<DialogFooter>
<DialogActions>
{showInstructions && showCopyButton && (
<Button
variant="primary"
onClick={handleCopyCommand}
>
{copySuccess ? 'Copied!' : 'Copy to Clipboard'}
</Button>
)}
{/* Show close button even if no instructions */}
{!showInstructions && progress >= 100 && (
<div className="action-buttons" style={{ marginTop: '1rem' }}>
<button className="close-button" onClick={handleClose}>
{isCloseButtonEnabled && (
<Button
variant="secondary"
onClick={onClose}
disabled={!isCloseButtonEnabled}
>
Close
</button>
</div>
)}
</div>
</div>
</Button>
)}
</DialogActions>
</DialogFooter>
</Dialog>
)
}
export default ProgressDialog
export default ProgressDialog

View File

@@ -0,0 +1,17 @@
// Export all dialog components
export { default as Dialog } from './Dialog';
export { default as DialogHeader } from './DialogHeader';
export { default as DialogBody } from './DialogBody';
export { default as DialogFooter } from './DialogFooter';
export { default as DialogActions } from './DialogActions';
export { default as ProgressDialog } from './ProgressDialog';
export { default as DlcSelectionDialog } from './DlcSelectionDialog';
// Export types
export type { DialogProps } from './Dialog';
export type { DialogHeaderProps } from './DialogHeader';
export type { DialogBodyProps } from './DialogBody';
export type { DialogFooterProps } from './DialogFooter';
export type { DialogActionsProps } from './DialogActions';
export type { ProgressDialogProps, InstallationInstructions } from './ProgressDialog';
export type { DlcSelectionDialogProps } from './DlcSelectionDialog';

View File

@@ -1,18 +1,7 @@
import React, { useState, useEffect } from 'react'
import { findBestGameImage } from '../services/ImageService'
import { ActionType } from './ActionButton'
interface Game {
id: string
title: string
path: string
platform?: string
native: boolean
api_files: string[]
cream_installed?: boolean
smoke_installed?: boolean
installing?: boolean
}
import { useState, useEffect } from 'react'
import { findBestGameImage } from '@/services/ImageService'
import { Game } from '@/types'
import { ActionButton, ActionType, Button } from '@/components/buttons'
interface GameItemProps {
game: Game
@@ -20,7 +9,11 @@ interface GameItemProps {
onEdit?: (gameId: string) => void
}
const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
/**
* Individual game card component
* Displays game information and action buttons
*/
const GameItem = ({ game, onAction, onEdit }: GameItemProps) => {
const [imageUrl, setImageUrl] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [hasError, setHasError] = useState(false)
@@ -116,58 +109,51 @@ const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
<div className="game-actions">
{/* Show CreamLinux button only for native games */}
{shouldShowCream && (
<button
className={`action-button ${game.cream_installed ? 'uninstall' : 'install'}`}
<ActionButton
action={game.cream_installed ? 'uninstall_cream' : 'install_cream'}
isInstalled={!!game.cream_installed}
isWorking={!!game.installing}
onClick={handleCreamAction}
disabled={!!game.installing}
>
{game.installing
? 'Working...'
: game.cream_installed
? 'Uninstall CreamLinux'
: 'Install CreamLinux'}
</button>
/>
)}
{/* Show SmokeAPI button only for Proton/Windows games with API files */}
{shouldShowSmoke && (
<button
className={`action-button ${game.smoke_installed ? 'uninstall' : 'install'}`}
<ActionButton
action={game.smoke_installed ? 'uninstall_smoke' : 'install_smoke'}
isInstalled={!!game.smoke_installed}
isWorking={!!game.installing}
onClick={handleSmokeAction}
disabled={!!game.installing}
>
{game.installing
? 'Working...'
: game.smoke_installed
? 'Uninstall SmokeAPI'
: 'Install SmokeAPI'}
</button>
/>
)}
{/* Show message for Proton games without API files */}
{isProtonNoApi && (
<div className="api-not-found-message">
<span>Steam API DLL not found</span>
<button
className="rescan-button"
<Button
variant="warning"
size="small"
onClick={() => onAction(game.id, 'install_smoke')}
title="Attempt to scan again"
>
Rescan
</button>
</Button>
</div>
)}
{/* Edit button - only enabled if CreamLinux is installed */}
{game.cream_installed && (
<button
className="edit-button"
<Button
variant="secondary"
size="small"
onClick={handleEdit}
disabled={!game.cream_installed || !!game.installing}
title="Manage DLCs"
className="edit-button"
>
Manage DLCs
</button>
</Button>
)}
</div>
</div>
@@ -175,4 +161,4 @@ const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
)
}
export default GameItem
export default GameItem

View File

@@ -1,19 +1,8 @@
import React, { useState, useEffect, useMemo } from 'react'
import GameItem from './GameItem'
import ImagePreloader from './ImagePreloader'
import { ActionType } from './ActionButton'
interface Game {
id: string
title: string
path: string
platform?: string
native: boolean
api_files: string[]
cream_installed?: boolean
smoke_installed?: boolean
installing?: boolean
}
import { useState, useEffect, useMemo } from 'react'
import {GameItem, ImagePreloader} from '@/components/games'
import { ActionType } from '@/components/buttons'
import { Game } from '@/types'
import LoadingIndicator from '../common/LoadingIndicator'
interface GameListProps {
games: Game[]
@@ -22,10 +11,14 @@ interface GameListProps {
onEdit?: (gameId: string) => void
}
const GameList: React.FC<GameListProps> = ({ games, isLoading, onAction, onEdit }) => {
/**
* Main game list component
* Displays games in a grid with search and filtering applied
*/
const GameList = ({ games, isLoading, onAction, onEdit }: GameListProps) => {
const [imagesPreloaded, setImagesPreloaded] = useState(false)
// Sort games alphabetically by title using useMemo to avoid re-sorting on each render
// Sort games alphabetically by title
const sortedGames = useMemo(() => {
return [...games].sort((a, b) => a.title.localeCompare(b.title))
}, [games])
@@ -35,25 +28,22 @@ const GameList: React.FC<GameListProps> = ({ games, isLoading, onAction, onEdit
setImagesPreloaded(false)
}, [games])
// Debug log to help diagnose game states
useEffect(() => {
if (games.length > 0) {
console.log('Games state in GameList:', games.length, 'games')
}
}, [games])
const handlePreloadComplete = () => {
setImagesPreloaded(true)
}
if (isLoading) {
return (
<div className="game-list">
<div className="loading-indicator">Scanning for games...</div>
<LoadingIndicator
type="spinner"
size="large"
message="Scanning for games..."
/>
</div>
)
}
const handlePreloadComplete = () => {
setImagesPreloaded(true)
}
return (
<div className="game-list">
<h2>Games ({games.length})</h2>
@@ -78,4 +68,4 @@ const GameList: React.FC<GameListProps> = ({ games, isLoading, onAction, onEdit
)
}
export default GameList
export default GameList

View File

@@ -0,0 +1,61 @@
import { useEffect } from 'react'
import { findBestGameImage } from '@/services/ImageService'
interface ImagePreloaderProps {
gameIds: string[]
onComplete?: () => void
}
/**
* Preloads game images to prevent flickering
* Only used internally by GameList component
*/
const ImagePreloader = ({ gameIds, onComplete }: ImagePreloaderProps) => {
useEffect(() => {
const preloadImages = async () => {
try {
// Only preload the first batch for performance (10 images max)
const batchToPreload = gameIds.slice(0, 10)
// Track loading progress
let loadedCount = 0
const totalImages = batchToPreload.length
// Load images in parallel
await Promise.allSettled(
batchToPreload.map(async (id) => {
await findBestGameImage(id)
loadedCount++
// If all images are loaded, call onComplete
if (loadedCount === totalImages && onComplete) {
onComplete()
}
})
)
// Fallback if Promise.allSettled doesn't trigger onComplete
if (onComplete) {
onComplete()
}
} catch (error) {
console.error('Error preloading images:', error)
// Continue even if there's an error
if (onComplete) {
onComplete()
}
}
}
if (gameIds.length > 0) {
preloadImages()
} else if (onComplete) {
onComplete()
}
}, [gameIds, onComplete])
// Invisible component that just handles preloading
return null
}
export default ImagePreloader

View File

@@ -0,0 +1,4 @@
// Export all game components
export { default as GameList } from './GameList';
export { default as GameItem } from './GameItem';
export { default as ImagePreloader } from './ImagePreloader';

View File

@@ -1,6 +1,9 @@
import React, { useEffect, useRef } from 'react'
import { useEffect, useRef } from 'react'
const AnimatedBackground: React.FC = () => {
/**
* Animated background component that draws an interactive particle effect
*/
const AnimatedBackground = () => {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
@@ -33,7 +36,7 @@ const AnimatedBackground: React.FC = () => {
color: string
}
// Color palette
// Color palette matching our theme
const colors = [
'rgba(74, 118, 196, 0.5)', // primary blue
'rgba(155, 125, 255, 0.5)', // purple
@@ -77,7 +80,7 @@ const AnimatedBackground: React.FC = () => {
ctx.fillStyle = particle.color.replace('0.5', `${particle.opacity}`)
ctx.fill()
// Connect particles
// Connect particles that are close to each other
particles.forEach((otherParticle) => {
const dx = particle.x - otherParticle.x
const dy = particle.y - otherParticle.y
@@ -100,6 +103,7 @@ const AnimatedBackground: React.FC = () => {
// Start animation
animate()
// Cleanup
return () => {
window.removeEventListener('resize', setCanvasSize)
}
@@ -109,18 +113,9 @@ const AnimatedBackground: React.FC = () => {
<canvas
ref={canvasRef}
className="animated-background"
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 0,
opacity: 0.4,
}}
aria-hidden="true"
/>
)
}
export default AnimatedBackground
export default AnimatedBackground

View File

@@ -0,0 +1,81 @@
import { Component, ErrorInfo, ReactNode } from 'react'
import { Button } from '@/components/buttons'
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
/**
* Error boundary component to catch and display runtime errors
*/
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = {
hasError: false,
error: null,
}
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
// Update state so the next render will show the fallback UI
return {
hasError: true,
error,
}
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
// Log the error
console.error('ErrorBoundary caught an error:', error, errorInfo)
// Call the onError callback if provided
if (this.props.onError) {
this.props.onError(error, errorInfo)
}
}
handleReset = () => {
this.setState({ hasError: false, error: null })
}
render(): ReactNode {
if (this.state.hasError) {
// Use custom fallback if provided
if (this.props.fallback) {
return this.props.fallback
}
// Default error UI
return (
<div className="error-container">
<h2>Something went wrong</h2>
<details>
<summary>Error details</summary>
<p>{this.state.error?.toString()}</p>
</details>
<Button
variant="primary"
onClick={this.handleReset}
className="error-retry-button"
>
Try again
</Button>
</div>
)
}
return this.props.children
}
}
export default ErrorBoundary

View File

@@ -1,4 +1,4 @@
import React from 'react'
import { Button } from '@/components/buttons'
interface HeaderProps {
onRefresh: () => void
@@ -7,19 +7,28 @@ interface HeaderProps {
searchQuery: string
}
const Header: React.FC<HeaderProps> = ({
/**
* Application header component
* Contains the app title, search input, and refresh button
*/
const Header = ({
onRefresh,
refreshDisabled = false,
onSearch,
searchQuery,
}) => {
}: HeaderProps) => {
return (
<header className="app-header">
<h1>CreamLinux</h1>
<div className="header-controls">
<button className="refresh-button" onClick={onRefresh} disabled={refreshDisabled}>
<Button
variant="primary"
onClick={onRefresh}
disabled={refreshDisabled}
className="refresh-button"
>
Refresh
</button>
</Button>
<input
type="text"
placeholder="Search games..."
@@ -32,4 +41,4 @@ const Header: React.FC<HeaderProps> = ({
)
}
export default Header
export default Header

View File

@@ -1,15 +1,35 @@
import React from 'react'
import { useEffect } from 'react'
interface InitialLoadingScreenProps {
message: string
progress: number
message: string;
progress: number;
onComplete?: () => void;
}
const InitialLoadingScreen: React.FC<InitialLoadingScreenProps> = ({ message, progress }) => {
/**
* Initial loading screen displayed when the app first loads
*/
const InitialLoadingScreen = ({
message,
progress,
onComplete
}: InitialLoadingScreenProps) => {
// Call onComplete when progress reaches 100%
useEffect(() => {
if (progress >= 100 && onComplete) {
const timer = setTimeout(() => {
onComplete();
}, 500); // Small delay to show completion
return () => clearTimeout(timer);
}
}, [progress, onComplete]);
return (
<div className="initial-loading-screen">
<div className="loading-content">
<h1>CreamLinux</h1>
<div className="loading-animation">
<div className="loading-circles">
<div className="circle circle-1"></div>
@@ -17,14 +37,17 @@ const InitialLoadingScreen: React.FC<InitialLoadingScreenProps> = ({ message, pr
<div className="circle circle-3"></div>
</div>
</div>
<p className="loading-message">{message}</p>
<div className="progress-bar-container">
<div className="progress-bar" style={{ width: `${progress}%` }} />
</div>
<div className="progress-percentage">{Math.round(progress)}%</div>
</div>
</div>
)
}
export default InitialLoadingScreen
export default InitialLoadingScreen

View File

@@ -0,0 +1,36 @@
interface SidebarProps {
setFilter: (filter: string) => void
currentFilter: string
}
/**
* Application sidebar component
* Contains filters for game types
*/
const Sidebar = ({ setFilter, currentFilter }: SidebarProps) => {
// Available filter options
const filters = [
{ id: 'all', label: 'All Games' },
{ id: 'native', label: 'Native' },
{ id: 'proton', label: 'Proton Required' }
]
return (
<div className="sidebar">
<h2>Library</h2>
<ul className="filter-list">
{filters.map(filter => (
<li
key={filter.id}
className={currentFilter === filter.id ? 'active' : ''}
onClick={() => setFilter(filter.id)}
>
{filter.label}
</li>
))}
</ul>
</div>
)
}
export default Sidebar

View File

@@ -0,0 +1,6 @@
// Export all layout components
export { default as Header } from './Header';
export { default as Sidebar } from './Sidebar';
export { default as AnimatedBackground } from './AnimatedBackground';
export { default as InitialLoadingScreen } from './InitialLoadingScreen';
export { default as ErrorBoundary } from './ErrorBoundary';

View File

@@ -0,0 +1,83 @@
import { ReactNode, useState, useEffect } from 'react'
export interface ToastProps {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
title?: string;
message: string;
duration?: number;
onDismiss: (id: string) => void;
}
/**
* Individual Toast component
* Displays a notification message with automatic dismissal
*/
const Toast = ({
id,
type,
title,
message,
duration = 5000, // default 5 seconds
onDismiss
}: ToastProps) => {
const [visible, setVisible] = useState(false)
// Handle animation on mount/unmount
useEffect(() => {
// Start the enter animation
const enterTimer = setTimeout(() => {
setVisible(true)
}, 10)
// Auto-dismiss after duration, if not Infinity
let dismissTimer: NodeJS.Timeout | null = null
if (duration !== Infinity) {
dismissTimer = setTimeout(() => {
handleDismiss()
}, duration)
}
return () => {
clearTimeout(enterTimer)
if (dismissTimer) clearTimeout(dismissTimer)
}
}, [duration])
// Get icon based on toast type
const getIcon = (): ReactNode => {
switch (type) {
case 'success':
return '✓'
case 'error':
return '✗'
case 'warning':
return '⚠'
case 'info':
return ''
default:
return ''
}
}
const handleDismiss = () => {
setVisible(false)
// Give time for exit animation
setTimeout(() => onDismiss(id), 300)
}
return (
<div className={`toast toast-${type} ${visible ? 'visible' : ''}`}>
<div className="toast-icon">{getIcon()}</div>
<div className="toast-content">
{title && <h4 className="toast-title">{title}</h4>}
<p className="toast-message">{message}</p>
</div>
<button className="toast-close" onClick={handleDismiss} aria-label="Dismiss">
×
</button>
</div>
)
}
export default Toast

View File

@@ -0,0 +1,47 @@
import Toast, { ToastProps } from './Toast'
export type ToastPosition =
| 'top-right'
| 'top-left'
| 'bottom-right'
| 'bottom-left'
| 'top-center'
| 'bottom-center'
interface ToastContainerProps {
toasts: Omit<ToastProps, 'onDismiss'>[];
onDismiss: (id: string) => void;
position?: ToastPosition;
}
/**
* Container for toast notifications
* Manages positioning and rendering of all toast notifications
*/
const ToastContainer = ({
toasts,
onDismiss,
position = 'bottom-right',
}: ToastContainerProps) => {
if (toasts.length === 0) {
return null
}
return (
<div className={`toast-container ${position}`}>
{toasts.map((toast) => (
<Toast
key={toast.id}
id={toast.id}
type={toast.type}
title={toast.title}
message={toast.message}
duration={toast.duration}
onDismiss={onDismiss}
/>
))}
</div>
)
}
export default ToastContainer

View File

@@ -0,0 +1,5 @@
export { default as Toast } from './Toast';
export { default as ToastContainer } from './ToastContainer';
export type { ToastProps } from './Toast';
export type { ToastPosition } from './ToastContainer';