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

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

@@ -0,0 +1,180 @@
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'
export interface InstallationInstructions {
type: string;
command: string;
game_title: string;
dlc_count?: number;
}
export interface ProgressDialogProps {
visible: boolean;
title: string;
message: string;
progress: number;
showInstructions?: boolean;
instructions?: InstallationInstructions;
onClose?: () => void;
}
/**
* ProgressDialog component
* Shows installation progress with a progress bar and optional instructions
*/
const ProgressDialog = ({
visible,
title,
message,
progress,
showInstructions = false,
instructions,
onClose,
}: ProgressDialogProps) => {
const [copySuccess, setCopySuccess] = useState(false)
const handleCopyCommand = () => {
if (instructions?.command) {
navigator.clipboard.writeText(instructions.command)
setCopySuccess(true)
// Reset the success message after 2 seconds
setTimeout(() => {
setCopySuccess(false)
}, 2000)
}
}
// Determine if we should show the copy button (for CreamLinux but not SmokeAPI)
const showCopyButton =
instructions?.type === 'cream_install' || instructions?.type === 'cream_uninstall'
// Format instruction message based on type
const getInstructionText = () => {
if (!instructions) return null
switch (instructions.type) {
case 'cream_install':
return (
<>
<p className="instruction-text">
In Steam, set the following launch options for{' '}
<strong>{instructions.game_title}</strong>:
</p>
{instructions.dlc_count !== undefined && (
<div className="dlc-count">
<strong>{instructions.dlc_count}</strong> DLCs have been enabled!
</div>
)}
</>
)
case 'cream_uninstall':
return (
<p className="instruction-text">
For <strong>{instructions.game_title}</strong>, open Steam properties and remove the
following launch option:
</p>
)
case 'smoke_install':
return (
<>
<p className="instruction-text">
SmokeAPI has been installed for <strong>{instructions.game_title}</strong>
</p>
{instructions.dlc_count !== undefined && (
<div className="dlc-count">
<strong>{instructions.dlc_count}</strong> Steam API files have been patched.
</div>
)}
</>
)
case 'smoke_uninstall':
return (
<p className="instruction-text">
SmokeAPI has been uninstalled from <strong>{instructions.game_title}</strong>
</p>
)
default:
return (
<p className="instruction-text">
Done processing <strong>{instructions.game_title}</strong>
</p>
)
}
}
// Determine the CSS class for the command box based on instruction type
const getCommandBoxClass = () => {
return instructions?.type.includes('smoke') ? 'command-box command-box-smoke' : 'command-box'
}
// Determine if close button should be enabled
const isCloseButtonEnabled = showInstructions || progress >= 100
return (
<Dialog
visible={visible}
onClose={isCloseButtonEnabled ? onClose : undefined}
size="medium"
preventBackdropClose={!isCloseButtonEnabled}
>
<DialogHeader>
<h3>{title}</h3>
</DialogHeader>
<DialogBody>
<p>{message}</p>
<div className="progress-bar-container">
<div className="progress-bar" style={{ width: `${progress}%` }} />
</div>
<div className="progress-percentage">{Math.round(progress)}%</div>
{showInstructions && instructions && (
<div className="instruction-container">
<h4>
{instructions.type.includes('uninstall')
? 'Uninstallation Instructions'
: 'Installation Instructions'}
</h4>
{getInstructionText()}
<div className={getCommandBoxClass()}>
<pre className="selectable-text">{instructions.command}</pre>
</div>
</div>
)}
</DialogBody>
<DialogFooter>
<DialogActions>
{showInstructions && showCopyButton && (
<Button
variant="primary"
onClick={handleCopyCommand}
>
{copySuccess ? 'Copied!' : 'Copy to Clipboard'}
</Button>
)}
{isCloseButtonEnabled && (
<Button
variant="secondary"
onClick={onClose}
disabled={!isCloseButtonEnabled}
>
Close
</Button>
)}
</DialogActions>
</DialogFooter>
</Dialog>
)
}
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';