Manually add DLC dialog #99

This commit is contained in:
Novattz
2026-03-13 14:51:33 +00:00
parent b9beb0d704
commit b42086ca27
4 changed files with 270 additions and 106 deletions

View File

@@ -0,0 +1,93 @@
import { 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 } from '@/components/buttons'
import { DlcInfo } from '@/types'
export interface AddDlcDialogProps {
visible: boolean
onClose: () => void
onAdd: (dlc: DlcInfo) => void
existingIds: Set<string>
}
/**
* Add DLC Manually dialog
* Allows users to manually enter a DLC ID and name when it is
* missing from the Steam API and cannot be fetched automatically
*/
const AddDlcDialog = ({ visible, onClose, onAdd, existingIds }: AddDlcDialogProps) => {
const [id, setId] = useState('')
const [name, setName] = useState('')
const [error, setError] = useState('')
// Reset form state when dialog closes
useEffect(() => {
if (!visible) {
setId('')
setName('')
setError('')
}
}, [visible])
// Validate inputs and add the DLC to the list
const handleSubmit = () => {
const trimmedId = id.trim()
const trimmedName = name.trim()
if (!trimmedId) return setError('DLC ID is required.')
if (!/^\d+$/.test(trimmedId)) return setError('DLC ID must be a number.')
if (existingIds.has(trimmedId)) return setError('A DLC with this ID already exists.')
if (!trimmedName) return setError('DLC name is required.')
onAdd({ appid: trimmedId, name: trimmedName, enabled: true })
onClose()
}
return (
<Dialog visible={visible} onClose={onClose} size="small">
<DialogHeader onClose={onClose}>
<h3>Add DLC Manually</h3>
</DialogHeader>
<DialogBody>
<div className="add-dlc-form">
<div className="add-dlc-field">
<label className="add-dlc-label">DLC ID</label>
<input
type="text"
className="add-dlc-input"
placeholder="e.g. 1234560"
value={id}
onChange={(e) => { setId(e.target.value); setError('') }}
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
autoFocus
/>
</div>
<div className="add-dlc-field">
<label className="add-dlc-label">DLC Name</label>
<input
type="text"
className="add-dlc-input"
placeholder="e.g. Expansion - My DLC"
value={name}
onChange={(e) => { setName(e.target.value); setError('') }}
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
/>
</div>
{error && <p className="add-dlc-error">{error}</p>}
</div>
</DialogBody>
<DialogFooter>
<DialogActions>
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={handleSubmit}>Add DLC</Button>
</DialogActions>
</DialogFooter>
</Dialog>
)
}
export default AddDlcDialog

View File

@@ -4,6 +4,7 @@ import DialogHeader from './DialogHeader'
import DialogBody from './DialogBody'
import DialogFooter from './DialogFooter'
import DialogActions from './DialogActions'
import AddDlcDialog from './AddDlcDialog'
import { Button, AnimatedCheckbox } from '@/components/buttons'
import { DlcInfo } from '@/types'
import { Icon, check, info } from '@/components/icons'
@@ -51,6 +52,7 @@ const DlcSelectionDialog = ({
const [searchQuery, setSearchQuery] = useState('')
const [selectAll, setSelectAll] = useState(true)
const [initialized, setInitialized] = useState(false)
const [showAddDlc, setShowAddDlc] = useState(false)
// Reset dialog state when it opens or closes
useEffect(() => {
@@ -126,6 +128,11 @@ const DlcSelectionDialog = ({
)
}, [selectAll])
// Add a manually-entered DLC to the list
const handleAddDlc = useCallback((dlc: DlcInfo) => {
setSelectedDlcs((prev) => [...prev, dlc])
}, [])
// Submit selected DLCs to parent component
const handleConfirm = useCallback(() => {
// Create a deep copy to prevent reference issues
@@ -151,123 +158,140 @@ const DlcSelectionDialog = ({
}
return (
<Dialog visible={visible} onClose={onClose} size="large" preventBackdropClose={isLoading}>
<DialogHeader onClose={onClose} hideCloseButton={true}>
<h3>{dialogTitle}</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>
<>
<Dialog visible={visible} onClose={onClose} size="large" preventBackdropClose={isLoading}>
<DialogHeader onClose={onClose} hideCloseButton={true}>
<h3>{dialogTitle}</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 className="dlc-dialog-search">
<input
type="text"
placeholder="Search DLCs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="dlc-search-input"
/>
</div>
</div>
{(isLoading || isUpdating) && loadingProgress > 0 && (
<div className="dlc-loading-progress">
<div className="progress-bar-container">
<div className="progress-bar" style={{ width: `${loadingProgress}%` }} />
</div>
<div className="loading-details">
<span>{isUpdating ? 'Updating DLC list' : 'Loading DLCs'}: {loadingProgress}%</span>
{estimatedTimeLeft && (
<span className="time-left">Est. time left: {estimatedTimeLeft}</span>
)}
<div className="select-all-container">
<AnimatedCheckbox
checked={selectAll}
onChange={handleToggleSelectAll}
label="Select All"
/>
</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>
{(isLoading || isUpdating) && loadingProgress > 0 && (
<div className="dlc-loading-progress">
<div className="progress-bar-container">
<div className="progress-bar" style={{ width: `${loadingProgress}%` }} />
</div>
<div className="loading-details">
<span>{isUpdating ? 'Updating DLC list' : 'Loading DLCs'}: {loadingProgress}%</span>
{estimatedTimeLeft && (
<span className="time-left">Est. time left: {estimatedTimeLeft}</span>
)}
</div>
</div>
)}
</DialogBody>
<DialogFooter>
{/* Show update results */}
{!isUpdating && !isLoading && isEditMode && updateAttempted && (
<>
{newDlcsCount > 0 && (
<div className="dlc-update-results dlc-update-success">
<span className="update-message">
<Icon name={check} size="md" variant="solid" className="dlc-update-icon-success"/> Found {newDlcsCount} new DLC{newDlcsCount > 1 ? 's' : ''}!
</span>
</div>
)}
{newDlcsCount === 0 && (
<div className="dlc-update-results dlc-update-info">
<span className="update-message">
<Icon name={info} size="md" variant="solid" className="dlc-update-icon-info"/> No new DLCs found. Your list is up to date!
</span>
</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>
<DialogActions>
<Button
variant="secondary"
onClick={onClose}
disabled={(isLoading || isUpdating) && loadingProgress < 10}
>
Cancel
</Button>
{/* Update button - only show in edit mode */}
{isEditMode && onUpdate && (
<Button
variant="warning"
onClick={() => onUpdate(gameId)}
disabled={isLoading || isUpdating}
>
{isUpdating ? 'Updating...' : 'Update DLC List'}
</Button>
<DialogFooter>
{/* Show update results */}
{!isUpdating && !isLoading && isEditMode && updateAttempted && (
<>
{newDlcsCount > 0 && (
<div className="dlc-update-results dlc-update-success">
<span className="update-message">
<Icon name={check} size="md" variant="solid" className="dlc-update-icon-success"/> Found {newDlcsCount} new DLC{newDlcsCount > 1 ? 's' : ''}!
</span>
</div>
)}
{newDlcsCount === 0 && (
<div className="dlc-update-results dlc-update-info">
<span className="update-message">
<Icon name={info} size="md" variant="solid" className="dlc-update-icon-info"/> No new DLCs found. Your list is up to date!
</span>
</div>
)}
</>
)}
<Button variant="primary" onClick={handleConfirm} disabled={isLoading || isUpdating}>
{actionButtonText}
</Button>
</DialogActions>
</DialogFooter>
</Dialog>
<DialogActions>
<Button
variant="secondary"
onClick={onClose}
disabled={(isLoading || isUpdating) && loadingProgress < 10}
>
Cancel
</Button>
<Button
variant="secondary"
onClick={() => setShowAddDlc(true)}
disabled={isLoading || isUpdating}
>
Add DLC Manually
</Button>
{/* Update button - only show in edit mode */}
{isEditMode && onUpdate && (
<Button
variant="warning"
onClick={() => onUpdate(gameId)}
disabled={isLoading || isUpdating}
>
{isUpdating ? 'Updating...' : 'Update DLC List'}
</Button>
)}
<Button variant="primary" onClick={handleConfirm} disabled={isLoading || isUpdating}>
{actionButtonText}
</Button>
</DialogActions>
</DialogFooter>
</Dialog>
<AddDlcDialog
visible={showAddDlc}
onClose={() => setShowAddDlc(false)}
onAdd={handleAddDlc}
existingIds={new Set(selectedDlcs.map((d) => d.appid))}
/>
</>
)
}

View File

@@ -6,6 +6,7 @@ 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 { default as AddDlcDialog } from './AddDlcDialog'
export { default as SettingsDialog } from './SettingsDialog'
export { default as SmokeAPISettingsDialog } from './SmokeAPISettingsDialog'
export { default as ConflictDialog } from './ConflictDialog'
@@ -20,5 +21,6 @@ export type { DialogFooterProps } from './DialogFooter'
export type { DialogActionsProps } from './DialogActions'
export type { ProgressDialogProps, InstallationInstructions } from './ProgressDialog'
export type { DlcSelectionDialogProps } from './DlcSelectionDialog'
export type { AddDlcDialogProps } from './AddDlcDialog'
export type { ConflictDialogProps, Conflict } from './ConflictDialog'
export type { UnlockerSelectionDialogProps } from './UnlockerSelectionDialog'

View File

@@ -209,6 +209,51 @@
}
}
// Add DLC manually form
.add-dlc-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.add-dlc-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.add-dlc-label {
font-size: 0.85rem;
color: var(--text-secondary);
font-weight: 500;
}
.add-dlc-input {
background-color: var(--border-dark);
border: 1px solid var(--border-soft);
border-radius: 4px;
color: var(--text-primary);
padding: 0.6rem 1rem;
font-size: 0.9rem;
transition: all var(--duration-normal) var(--easing-ease-out);
&:focus {
border-color: var(--primary-color);
outline: none;
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2);
}
&::placeholder {
color: var(--text-muted);
}
}
.add-dlc-error {
font-size: 0.82rem;
color: var(--error);
margin: 0;
}
// Loading animations
@keyframes spin {
0% {