mirror of
https://github.com/Novattz/creamlinux-installer.git
synced 2026-01-28 22:32:49 -05:00
Initial changes
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
59
src/components/buttons/ActionButton.tsx
Normal file
59
src/components/buttons/ActionButton.tsx
Normal 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
|
||||
@@ -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
|
||||
67
src/components/buttons/Button.tsx
Normal file
67
src/components/buttons/Button.tsx
Normal 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;
|
||||
8
src/components/buttons/index.ts
Normal file
8
src/components/buttons/index.ts
Normal 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';
|
||||
75
src/components/common/LoadingIndicator.tsx
Normal file
75
src/components/common/LoadingIndicator.tsx
Normal 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
|
||||
3
src/components/common/index.ts
Normal file
3
src/components/common/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as LoadingIndicator } from './LoadingIndicator';
|
||||
|
||||
export type { LoadingSize, LoadingType } from './LoadingIndicator';
|
||||
82
src/components/dialogs/Dialog.tsx
Normal file
82
src/components/dialogs/Dialog.tsx
Normal 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
|
||||
31
src/components/dialogs/DialogActions.tsx
Normal file
31
src/components/dialogs/DialogActions.tsx
Normal 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
|
||||
20
src/components/dialogs/DialogBody.tsx
Normal file
20
src/components/dialogs/DialogBody.tsx
Normal 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
|
||||
20
src/components/dialogs/DialogFooter.tsx
Normal file
20
src/components/dialogs/DialogFooter.tsx
Normal 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
|
||||
30
src/components/dialogs/DialogHeader.tsx
Normal file
30
src/components/dialogs/DialogHeader.tsx
Normal 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
|
||||
221
src/components/dialogs/DlcSelectionDialog.tsx
Normal file
221
src/components/dialogs/DlcSelectionDialog.tsx
Normal 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
|
||||
@@ -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
|
||||
17
src/components/dialogs/index.ts
Normal file
17
src/components/dialogs/index.ts
Normal 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';
|
||||
@@ -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
|
||||
@@ -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
|
||||
61
src/components/games/ImagePreloader.tsx
Normal file
61
src/components/games/ImagePreloader.tsx
Normal 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
|
||||
4
src/components/games/index.ts
Normal file
4
src/components/games/index.ts
Normal 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';
|
||||
@@ -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
|
||||
81
src/components/layout/ErrorBoundary.tsx
Normal file
81
src/components/layout/ErrorBoundary.tsx
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
36
src/components/layout/Sidebar.tsx
Normal file
36
src/components/layout/Sidebar.tsx
Normal 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
|
||||
6
src/components/layout/index.ts
Normal file
6
src/components/layout/index.ts
Normal 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';
|
||||
83
src/components/notifications/Toast.tsx
Normal file
83
src/components/notifications/Toast.tsx
Normal 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
|
||||
47
src/components/notifications/ToastContainer.tsx
Normal file
47
src/components/notifications/ToastContainer.tsx
Normal 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
|
||||
5
src/components/notifications/index.ts
Normal file
5
src/components/notifications/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user