mirror of
https://github.com/Novattz/creamlinux-installer.git
synced 2026-01-28 22:32:49 -05:00
Initial commit
This commit is contained in:
46
src/components/ActionButton.tsx
Normal file
46
src/components/ActionButton.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
// src/components/ActionButton.tsx
|
||||
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;
|
||||
127
src/components/AnimatedBackground.tsx
Normal file
127
src/components/AnimatedBackground.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
// src/components/AnimatedBackground.tsx
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
const AnimatedBackground: React.FC = () => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size to match window
|
||||
const setCanvasSize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
};
|
||||
|
||||
setCanvasSize();
|
||||
window.addEventListener('resize', setCanvasSize);
|
||||
|
||||
// Create particles
|
||||
const particles: Particle[] = [];
|
||||
const particleCount = 30;
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
speedX: number;
|
||||
speedY: number;
|
||||
opacity: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// Color palette
|
||||
const colors = [
|
||||
'rgba(74, 118, 196, 0.5)', // primary blue
|
||||
'rgba(155, 125, 255, 0.5)', // purple
|
||||
'rgba(251, 177, 60, 0.5)', // gold
|
||||
];
|
||||
|
||||
// Create initial particles
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
particles.push({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
size: Math.random() * 3 + 1,
|
||||
speedX: Math.random() * 0.2 - 0.1,
|
||||
speedY: Math.random() * 0.2 - 0.1,
|
||||
opacity: Math.random() * 0.07 + 0.03,
|
||||
color: colors[Math.floor(Math.random() * colors.length)]
|
||||
});
|
||||
}
|
||||
|
||||
// Animation loop
|
||||
const animate = () => {
|
||||
// Clear canvas with transparent black to create fade effect
|
||||
ctx.fillStyle = 'rgba(15, 15, 15, 0.1)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Update and draw particles
|
||||
particles.forEach(particle => {
|
||||
// Update position
|
||||
particle.x += particle.speedX;
|
||||
particle.y += particle.speedY;
|
||||
|
||||
// Wrap around edges
|
||||
if (particle.x < 0) particle.x = canvas.width;
|
||||
if (particle.x > canvas.width) particle.x = 0;
|
||||
if (particle.y < 0) particle.y = canvas.height;
|
||||
if (particle.y > canvas.height) particle.y = 0;
|
||||
|
||||
// Draw particle
|
||||
ctx.beginPath();
|
||||
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = particle.color.replace('0.5', `${particle.opacity}`);
|
||||
ctx.fill();
|
||||
|
||||
// Connect particles
|
||||
particles.forEach(otherParticle => {
|
||||
const dx = particle.x - otherParticle.x;
|
||||
const dy = particle.y - otherParticle.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance < 100) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = particle.color.replace('0.5', `${particle.opacity * 0.5}`);
|
||||
ctx.lineWidth = 0.2;
|
||||
ctx.moveTo(particle.x, particle.y);
|
||||
ctx.lineTo(otherParticle.x, otherParticle.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
// Start animation
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', setCanvasSize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="animated-background"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
opacity: 0.4
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimatedBackground;
|
||||
50
src/components/AnimatedCheckbox.tsx
Normal file
50
src/components/AnimatedCheckbox.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
// src/components/AnimatedCheckbox.tsx
|
||||
import React from 'react';
|
||||
|
||||
interface AnimatedCheckboxProps {
|
||||
checked: boolean;
|
||||
onChange: () => void;
|
||||
label?: string;
|
||||
sublabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AnimatedCheckbox: React.FC<AnimatedCheckboxProps> = ({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
sublabel,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<label className={`animated-checkbox ${className}`}>
|
||||
<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">
|
||||
<path
|
||||
className={`checkmark ${checked ? 'checked' : ''}`}
|
||||
d="M5 12l5 5L20 7"
|
||||
stroke="#fff"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{(label || sublabel) && (
|
||||
<div className="checkbox-content">
|
||||
{label && <span className="checkbox-label">{label}</span>}
|
||||
{sublabel && <span className="checkbox-sublabel">{sublabel}</span>}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimatedCheckbox;
|
||||
242
src/components/DlcSelectionDialog.tsx
Normal file
242
src/components/DlcSelectionDialog.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
// src/components/DlcSelectionDialog.tsx
|
||||
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;
|
||||
172
src/components/GameItem.tsx
Normal file
172
src/components/GameItem.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
// src/components/GameItem.tsx
|
||||
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;
|
||||
}
|
||||
|
||||
interface GameItemProps {
|
||||
game: Game;
|
||||
onAction: (gameId: string, action: ActionType) => Promise<void>;
|
||||
onEdit?: (gameId: string) => void;
|
||||
}
|
||||
|
||||
const GameItem: React.FC<GameItemProps> = ({ game, onAction, onEdit }) => {
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Function to fetch the game cover/image
|
||||
const fetchGameImage = async () => {
|
||||
// First check if we already have it (to prevent flickering on re-renders)
|
||||
if (imageUrl) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Try to find the best available image for this game
|
||||
const bestImageUrl = await findBestGameImage(game.id);
|
||||
|
||||
if (bestImageUrl) {
|
||||
setImageUrl(bestImageUrl);
|
||||
setHasError(false);
|
||||
} else {
|
||||
setHasError(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching game image:', error);
|
||||
setHasError(true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (game.id) {
|
||||
fetchGameImage();
|
||||
}
|
||||
}, [game.id, imageUrl]);
|
||||
|
||||
// Determine if we should show CreamLinux buttons (only for native games)
|
||||
const shouldShowCream = game.native === true;
|
||||
|
||||
// Determine if we should show SmokeAPI buttons (only for non-native games with API files)
|
||||
const shouldShowSmoke = !game.native && game.api_files && game.api_files.length > 0;
|
||||
|
||||
// Check if this is a Proton game without API files
|
||||
const isProtonNoApi = !game.native && (!game.api_files || game.api_files.length === 0);
|
||||
|
||||
const handleCreamAction = () => {
|
||||
if (game.installing) return;
|
||||
const action: ActionType = game.cream_installed ? 'uninstall_cream' : 'install_cream';
|
||||
onAction(game.id, action);
|
||||
};
|
||||
|
||||
const handleSmokeAction = () => {
|
||||
if (game.installing) return;
|
||||
const action: ActionType = game.smoke_installed ? 'uninstall_smoke' : 'install_smoke';
|
||||
onAction(game.id, action);
|
||||
};
|
||||
|
||||
// Handle edit button click
|
||||
const handleEdit = () => {
|
||||
if (onEdit && game.cream_installed) {
|
||||
onEdit(game.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine background image
|
||||
const backgroundImage = !isLoading && imageUrl ?
|
||||
`url(${imageUrl})` :
|
||||
hasError ? 'linear-gradient(135deg, #232323, #1A1A1A)' : 'linear-gradient(135deg, #232323, #1A1A1A)';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="game-item-card"
|
||||
style={{
|
||||
backgroundImage,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
<div className="game-item-overlay">
|
||||
<div className="game-badges">
|
||||
<span className={`status-badge ${game.native ? 'native' : 'proton'}`}>
|
||||
{game.native ? 'Native' : 'Proton'}
|
||||
</span>
|
||||
{game.cream_installed && (
|
||||
<span className="status-badge cream">CreamLinux</span>
|
||||
)}
|
||||
{game.smoke_installed && (
|
||||
<span className="status-badge smoke">SmokeAPI</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="game-title">
|
||||
<h3>{game.title}</h3>
|
||||
</div>
|
||||
|
||||
<div className="game-actions">
|
||||
{/* Show CreamLinux button only for native games */}
|
||||
{shouldShowCream && (
|
||||
<button
|
||||
className={`action-button ${game.cream_installed ? 'uninstall' : 'install'}`}
|
||||
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'}`}
|
||||
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"
|
||||
onClick={() => onAction(game.id, 'install_smoke')}
|
||||
title="Attempt to scan again"
|
||||
>
|
||||
Rescan
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit button - only enabled if CreamLinux is installed */}
|
||||
{game.cream_installed && (
|
||||
<button
|
||||
className="edit-button"
|
||||
onClick={handleEdit}
|
||||
disabled={!game.cream_installed || !!game.installing}
|
||||
title="Manage DLCs"
|
||||
>
|
||||
Manage DLCs
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GameItem;
|
||||
92
src/components/GameList.tsx
Normal file
92
src/components/GameList.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
// src/components/GameList.tsx
|
||||
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;
|
||||
}
|
||||
|
||||
interface GameListProps {
|
||||
games: Game[];
|
||||
isLoading: boolean;
|
||||
onAction: (gameId: string, action: ActionType) => Promise<void>;
|
||||
onEdit?: (gameId: string) => void;
|
||||
}
|
||||
|
||||
const GameList: React.FC<GameListProps> = ({
|
||||
games,
|
||||
isLoading,
|
||||
onAction,
|
||||
onEdit
|
||||
}) => {
|
||||
const [imagesPreloaded, setImagesPreloaded] = useState(false);
|
||||
|
||||
// Sort games alphabetically by title - using useMemo to avoid re-sorting on each render
|
||||
const sortedGames = useMemo(() => {
|
||||
return [...games].sort((a, b) => a.title.localeCompare(b.title));
|
||||
}, [games]);
|
||||
|
||||
// Reset preloaded state when games change
|
||||
useEffect(() => {
|
||||
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]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="game-list">
|
||||
<div className="loading-indicator">Scanning for games...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handlePreloadComplete = () => {
|
||||
setImagesPreloaded(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="game-list">
|
||||
<h2>Games ({games.length})</h2>
|
||||
|
||||
{!imagesPreloaded && games.length > 0 && (
|
||||
<ImagePreloader
|
||||
gameIds={sortedGames.map(game => game.id)}
|
||||
onComplete={handlePreloadComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{games.length === 0 ? (
|
||||
<div className="no-games-message">No games found</div>
|
||||
) : (
|
||||
<div className="game-grid">
|
||||
{sortedGames.map(game => (
|
||||
<GameItem
|
||||
key={game.id}
|
||||
game={game}
|
||||
onAction={onAction}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GameList;
|
||||
40
src/components/Header.tsx
Normal file
40
src/components/Header.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
// src/components/Header.tsx
|
||||
import React from 'react';
|
||||
|
||||
interface HeaderProps {
|
||||
onRefresh: () => void;
|
||||
refreshDisabled?: boolean;
|
||||
onSearch: (query: string) => void;
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({
|
||||
onRefresh,
|
||||
refreshDisabled = false,
|
||||
onSearch,
|
||||
searchQuery
|
||||
}) => {
|
||||
return (
|
||||
<header className="app-header">
|
||||
<h1>CreamLinux</h1>
|
||||
<div className="header-controls">
|
||||
<button
|
||||
className="refresh-button"
|
||||
onClick={onRefresh}
|
||||
disabled={refreshDisabled}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search games..."
|
||||
className="search-input"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
48
src/components/ImagePreloader.tsx
Normal file
48
src/components/ImagePreloader.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
// src/components/ImagePreloader.tsx
|
||||
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;
|
||||
36
src/components/InitialLoadingScreen.tsx
Normal file
36
src/components/InitialLoadingScreen.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
interface InitialLoadingScreenProps {
|
||||
message: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
const InitialLoadingScreen: React.FC<InitialLoadingScreenProps> = ({
|
||||
message,
|
||||
progress
|
||||
}) => {
|
||||
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>
|
||||
<div className="circle circle-2"></div>
|
||||
<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;
|
||||
215
src/components/ProgressDialog.tsx
Normal file
215
src/components/ProgressDialog.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
// src/components/ProgressDialog.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface InstructionInfo {
|
||||
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;
|
||||
}
|
||||
|
||||
const ProgressDialog: React.FC<ProgressDialogProps> = ({
|
||||
title,
|
||||
message,
|
||||
progress,
|
||||
visible,
|
||||
showInstructions = false,
|
||||
instructions,
|
||||
onClose
|
||||
}) => {
|
||||
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) {
|
||||
navigator.clipboard.writeText(instructions.command);
|
||||
setCopySuccess(true);
|
||||
|
||||
// Reset the success message after 2 seconds
|
||||
setTimeout(() => {
|
||||
setCopySuccess(false);
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowContent(false);
|
||||
// Delay closing to allow exit animation
|
||||
setTimeout(() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Modified to 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';
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
className={`progress-dialog-overlay ${showContent ? 'visible' : ''}`}
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
<div className={`progress-dialog ${showInstructions ? 'with-instructions' : ''} ${showContent ? 'dialog-visible' : ''}`}>
|
||||
<h3>{title}</h3>
|
||||
<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 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>
|
||||
)}
|
||||
|
||||
{/* Show close button even if no instructions */}
|
||||
{!showInstructions && progress >= 100 && (
|
||||
<div className="action-buttons" style={{ marginTop: '1rem' }}>
|
||||
<button
|
||||
className="close-button"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProgressDialog;
|
||||
37
src/components/Sidebar.tsx
Normal file
37
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
// src/components/Sidebar.tsx
|
||||
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;
|
||||
Reference in New Issue
Block a user