Initial commit

This commit is contained in:
Tickbase
2025-05-17 21:08:01 +02:00
commit 329e058e1b
63 changed files with 17326 additions and 0 deletions

866
src/App.tsx Normal file
View File

@@ -0,0 +1,866 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import './styles/main.scss';
import GameList from './components/GameList';
import Header from './components/Header';
import Sidebar from './components/Sidebar';
import ProgressDialog from './components/ProgressDialog';
import DlcSelectionDialog from './components/DlcSelectionDialog';
import AnimatedBackground from './components/AnimatedBackground';
import InitialLoadingScreen from './components/InitialLoadingScreen';
import { ActionType } from './components/ActionButton';
// Game interface
interface Game {
id: string;
title: string;
path: string;
native: boolean;
platform?: string;
api_files: string[];
cream_installed?: boolean;
smoke_installed?: boolean;
installing?: boolean;
}
// Interface for installation instructions
interface InstructionInfo {
type: string;
command: string;
game_title: string;
dlc_count?: number;
}
// Interface for DLC information
interface DlcInfo {
appid: string;
name: string;
enabled: boolean;
}
function App() {
const [games, setGames] = useState<Game[]>([]);
const [filter, setFilter] = useState("all");
const [searchQuery, setSearchQuery] = useState(""); // Added search query state
const [isLoading, setIsLoading] = useState(true);
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [scanProgress, setScanProgress] = useState({
message: "Initializing...",
progress: 0
});
const [error, setError] = useState<string | null>(null);
const refreshInProgress = useRef(false);
const [isFetchingDlcs, setIsFetchingDlcs] = useState(false);
const dlcFetchController = useRef<AbortController | null>(null);
const activeDlcFetchId = useRef<string | null>(null);
// Progress dialog state
const [progressDialog, setProgressDialog] = useState({
visible: false,
title: '',
message: '',
progress: 0,
showInstructions: false,
instructions: undefined as InstructionInfo | undefined
});
// DLC selection dialog state
const [dlcDialog, setDlcDialog] = useState({
visible: false,
gameId: '',
gameTitle: '',
dlcs: [] as DlcInfo[],
enabledDlcs: [] as string[],
isLoading: false,
isEditMode: false,
progress: 0,
progressMessage: '',
timeLeft: '',
error: null as string | null
});
// Handle search query changes
const handleSearchChange = (query: string) => {
setSearchQuery(query);
};
// Move the loadGames function outside of the useEffect to make it reusable
const loadGames = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
console.log("Invoking scan_steam_games");
const steamGames = await invoke<Game[]>('scan_steam_games').catch(err => {
console.error('Error from scan_steam_games:', err);
throw err;
});
// Add platform property to match the GameList component's expectation
const gamesWithPlatform = steamGames.map(game => ({
...game,
platform: 'Steam'
}));
console.log(`Loaded ${gamesWithPlatform.length} games`);
setGames(gamesWithPlatform);
setIsInitialLoad(false); // Mark initial load as complete
return true;
} catch (error) {
console.error('Error loading games:', error);
setError(`Failed to load games: ${error}`);
setIsInitialLoad(false); // Mark initial load as complete even on error
return false;
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
// Set up event listeners first
const setupEventListeners = async () => {
try {
console.log("Setting up event listeners");
// Listen for progress updates from the backend
const unlistenProgress = await listen('installation-progress', (event) => {
console.log("Received installation-progress event:", event);
const {
title,
message,
progress,
complete,
show_instructions,
instructions
} = event.payload as {
title: string;
message: string;
progress: number;
complete: boolean;
show_instructions?: boolean;
instructions?: InstructionInfo;
};
if (complete && !show_instructions) {
// Hide dialog when complete if no instructions
setTimeout(() => {
setProgressDialog(prev => ({ ...prev, visible: false }));
// Only refresh games list if dialog is closing without instructions
if (!refreshInProgress.current) {
refreshInProgress.current = true;
setTimeout(() => {
loadGames().then(() => {
refreshInProgress.current = false;
});
}, 100);
}
}, 1000);
} else {
// Update progress dialog
setProgressDialog({
visible: true,
title,
message,
progress,
showInstructions: show_instructions || false,
instructions
});
}
});
// Listen for scan progress events
const unlistenScanProgress = await listen('scan-progress', (event) => {
const { message, progress } = event.payload as {
message: string;
progress: number;
};
console.log("Received scan-progress event:", message, progress);
// Update scan progress state
setScanProgress({
message,
progress
});
});
// Listen for individual game updates
const unlistenGameUpdated = await listen('game-updated', (event) => {
console.log("Received game-updated event:", event);
const updatedGame = event.payload as Game;
// Update only the specific game in the state
setGames(prevGames =>
prevGames.map(game =>
game.id === updatedGame.id ? { ...updatedGame, platform: 'Steam' } : game
)
);
});
return () => {
unlistenProgress();
unlistenScanProgress();
unlistenGameUpdated();
};
} catch (error) {
console.error("Error setting up event listeners:", error);
return () => {};
}
};
// First set up event listeners, then load games
let unlisten: (() => void) | null = null;
setupEventListeners()
.then(unlistenFn => {
unlisten = unlistenFn;
return loadGames();
})
.catch(error => {
console.error("Failed to initialize:", error);
});
return () => {
if (unlisten) {
unlisten();
}
};
}, [loadGames]);
// Debugging for state changes
useEffect(() => {
// Debug state changes
if (games.length > 0) {
// Count native and installed games
const nativeCount = games.filter(g => g.native).length;
const creamInstalledCount = games.filter(g => g.cream_installed).length;
const smokeInstalledCount = games.filter(g => g.smoke_installed).length;
console.log(`Game state updated: ${games.length} total games, ${nativeCount} native, ${creamInstalledCount} with CreamLinux, ${smokeInstalledCount} with SmokeAPI`);
// Log any games with unexpected states
const problematicGames = games.filter(g => {
// Native games that have SmokeAPI installed (shouldn't happen)
if (g.native && g.smoke_installed) return true;
// Non-native games with CreamLinux installed (shouldn't happen)
if (!g.native && g.cream_installed) return true;
// Non-native games without API files but with SmokeAPI installed (shouldn't happen)
if (!g.native && (!g.api_files || g.api_files.length === 0) && g.smoke_installed) return true;
return false;
});
if (problematicGames.length > 0) {
console.warn("Found games with unexpected states:", problematicGames);
}
}
}, [games]);
// Set up event listeners for DLC streaming
useEffect(() => {
// Listen for individual DLC found events
const setupDlcEventListeners = async () => {
try {
// This event is emitted for each DLC as it's found
const unlistenDlcFound = await listen('dlc-found', (event) => {
const dlc = JSON.parse(event.payload as string) as { appid: string, name: string };
// Add the DLC to the current list with enabled=true
setDlcDialog(prev => ({
...prev,
dlcs: [...prev.dlcs, { ...dlc, enabled: true }]
}));
});
// When progress is 100%, mark loading as complete and reset fetch state
const unlistenDlcProgress = await listen('dlc-progress', (event) => {
const { message, progress, timeLeft } = event.payload as {
message: string,
progress: number,
timeLeft?: string
};
// Update the progress indicator
setDlcDialog(prev => ({
...prev,
progress,
progressMessage: message,
timeLeft: timeLeft || ''
}));
// If progress is 100%, mark loading as complete
if (progress === 100) {
setTimeout(() => {
setDlcDialog(prev => ({
...prev,
isLoading: false
}));
// Reset fetch state
setIsFetchingDlcs(false);
activeDlcFetchId.current = null;
}, 500);
}
});
// This event is emitted if there's an error
const unlistenDlcError = await listen('dlc-error', (event) => {
const { error } = event.payload as { error: string };
console.error('DLC streaming error:', error);
// Show error in dialog
setDlcDialog(prev => ({
...prev,
error,
isLoading: false
}));
});
return () => {
unlistenDlcFound();
unlistenDlcProgress();
unlistenDlcError();
};
} catch (error) {
console.error("Error setting up DLC event listeners:", error);
return () => {};
}
};
const unlisten = setupDlcEventListeners();
return () => {
unlisten.then(fn => fn());
};
}, []);
// Listen for scan progress events
useEffect(() => {
const listenToScanProgress = async () => {
try {
const unlistenScanProgress = await listen('scan-progress', (event) => {
const { message, progress } = event.payload as {
message: string;
progress: number;
};
// Update loading message
setProgressDialog(prev => ({
...prev,
visible: true,
title: "Scanning for Games",
message,
progress,
showInstructions: false,
instructions: undefined
}));
// Auto-close when complete
if (progress >= 100) {
setTimeout(() => {
setProgressDialog(prev => ({ ...prev, visible: false }));
}, 1500);
}
});
return unlistenScanProgress;
} catch (error) {
console.error("Error setting up scan progress listener:", error);
return () => {};
}
};
const unlistenPromise = listenToScanProgress();
return () => {
unlistenPromise.then(unlisten => unlisten());
};
}, []);
const handleCloseProgressDialog = () => {
// Just hide the dialog without refreshing game list
setProgressDialog(prev => ({ ...prev, visible: false }));
// Only refresh if we need to (instructions didn't trigger update)
if (progressDialog.showInstructions === false && !refreshInProgress.current) {
refreshInProgress.current = true;
setTimeout(() => {
loadGames().then(() => {
refreshInProgress.current = false;
});
}, 100);
}
};
// Function to fetch DLCs for a game with streaming updates
const streamGameDlcs = async (gameId: string): Promise<void> => {
try {
// Set up flag to indicate we're fetching DLCs
setIsFetchingDlcs(true);
activeDlcFetchId.current = gameId;
// Start streaming DLCs - this won't return DLCs directly
// Instead, it triggers events that we'll listen for
await invoke('stream_game_dlcs', { gameId });
return;
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
console.log('DLC fetching was aborted');
} else {
console.error('Error starting DLC stream:', error);
throw error;
}
} finally {
// Reset state when done or on error
setIsFetchingDlcs(false);
activeDlcFetchId.current = null;
}
};
// Clean up if component unmounts during a fetch
useEffect(() => {
return () => {
// Clean up any ongoing fetch operations
if (dlcFetchController.current) {
dlcFetchController.current.abort();
dlcFetchController.current = null;
}
};
}, []);
// Handle game edit (show DLC management dialog)
const handleGameEdit = async (gameId: string) => {
const game = games.find(g => g.id === gameId);
if (!game || !game.cream_installed) return;
// Check if we're already fetching DLCs for this game
if (isFetchingDlcs && activeDlcFetchId.current === gameId) {
console.log(`Already fetching DLCs for ${gameId}, ignoring duplicate request`);
return;
}
try {
// Show dialog immediately with empty DLC list
setDlcDialog({
visible: true,
gameId,
gameTitle: game.title,
dlcs: [],
enabledDlcs: [] as string[],
isLoading: true,
isEditMode: true,
progress: 0,
progressMessage: 'Reading DLC configuration...',
timeLeft: '',
error: null
});
// Try to read all DLCs from the configuration file first (including disabled ones)
try {
const allDlcs = await invoke<DlcInfo[]>('get_all_dlcs_command', { gamePath: game.path })
.catch(() => [] as DlcInfo[]);
if (allDlcs.length > 0) {
// If we have DLCs from the config file, use them
console.log("Loaded existing DLC configuration:", allDlcs);
setDlcDialog(prev => ({
...prev,
dlcs: allDlcs,
isLoading: false,
progress: 100,
progressMessage: 'Loaded existing DLC configuration'
}));
return;
}
} catch (error) {
console.warn("Could not read existing DLC configuration, falling back to API:", error);
// Continue with API loading if config reading fails
}
// Mark that we're fetching DLCs for this game
setIsFetchingDlcs(true);
activeDlcFetchId.current = gameId;
// Create abort controller for fetch operation
dlcFetchController.current = new AbortController();
// Start streaming DLCs
await streamGameDlcs(gameId).catch(error => {
if (error.name !== 'AbortError') {
console.error('Error streaming DLCs:', error);
setDlcDialog(prev => ({
...prev,
error: `Failed to load DLCs: ${error}`,
isLoading: false
}));
}
});
// In parallel, try to get the enabled DLCs
const enabledDlcs = await invoke<string[]>('get_enabled_dlcs_command', { gamePath: game.path })
.catch(() => [] as string[]);
// We'll update the enabled state of DLCs as they come in
setDlcDialog(prev => ({
...prev,
enabledDlcs
}));
} catch (error) {
console.error('Error preparing DLC edit:', error);
setDlcDialog(prev => ({
...prev,
error: `Failed to prepare DLC editor: ${error}`,
isLoading: false
}));
}
};
// Unified handler for all game actions (install/uninstall cream/smoke)
const handleGameAction = async (gameId: string, action: ActionType) => {
try {
// Find game to get title
const game = games.find(g => g.id === gameId);
if (!game) return;
// If we're installing CreamLinux, show DLC selection first
if (action === 'install_cream') {
try {
// Show dialog immediately with empty DLC list and loading state
setDlcDialog({
visible: true,
gameId,
gameTitle: game.title,
dlcs: [], // Start with an empty array
enabledDlcs: [] as string[],
isLoading: true,
isEditMode: false,
progress: 0,
progressMessage: 'Fetching DLC list...',
timeLeft: '',
error: null
});
// Start streaming DLCs - only once
await streamGameDlcs(gameId).catch(error => {
console.error('Error streaming DLCs:', error);
setDlcDialog(prev => ({
...prev,
error: `Failed to load DLCs: ${error}`,
isLoading: false
}));
});
} catch (error) {
console.error('Error fetching DLCs:', error);
// If DLC fetching fails, close dialog and show error
setDlcDialog(prev => ({
...prev,
visible: false,
isLoading: false
}));
setProgressDialog({
visible: true,
title: `Error fetching DLCs for ${game.title}`,
message: `Failed to fetch DLCs: ${error}`,
progress: 100,
showInstructions: false,
instructions: undefined
});
setTimeout(() => {
setProgressDialog(prev => ({ ...prev, visible: false }));
}, 3000);
}
return;
}
// For other actions, proceed directly
// Update local state to show installation in progress
setGames(prevGames => prevGames.map(g =>
g.id === gameId ? { ...g, installing: true } : g
));
// Get title based on action
const isCream = action.includes('cream');
const isInstall = action.includes('install');
const product = isCream ? "CreamLinux" : "SmokeAPI";
const operation = isInstall ? "Installing" : "Uninstalling";
// Show progress dialog
setProgressDialog({
visible: true,
title: `${operation} ${product} for ${game.title}`,
message: isInstall ? 'Downloading required files...' : 'Removing files...',
progress: isInstall ? 0 : 30,
showInstructions: false,
instructions: undefined
});
console.log(`Invoking process_game_action for game ${gameId} with action ${action}`);
// Call the backend with the unified action
const updatedGame = await invoke('process_game_action', {
gameAction: {
game_id: gameId,
action
}
}).catch(err => {
console.error(`Error from process_game_action:`, err);
throw err;
});
console.log('Game action completed, updated game:', updatedGame);
// Update our local state with the result from the backend
if (updatedGame) {
setGames(prevGames => prevGames.map(g =>
g.id === gameId ? { ...g, installing: false } : g
));
}
} catch (error) {
console.error(`Error processing action ${action} for game ${gameId}:`, error);
// Show error in progress dialog
setProgressDialog(prev => ({
...prev,
message: `Error: ${error}`,
progress: 100
}));
// Reset installing state
setGames(prevGames => prevGames.map(game =>
game.id === gameId ? { ...game, installing: false } : game
));
// Hide dialog after a delay
setTimeout(() => {
setProgressDialog(prev => ({ ...prev, visible: false }));
}, 3000);
}
};
// Handle DLC selection dialog close
const handleDlcDialogClose = () => {
// Cancel any in-progress DLC fetching
if (isFetchingDlcs && activeDlcFetchId.current) {
console.log(`Aborting DLC fetch for game ${activeDlcFetchId.current}`);
// This will signal to the Rust backend that we want to stop the process
// You could implement this on the backend if needed with something like:
invoke('abort_dlc_fetch', { gameId: activeDlcFetchId.current })
.catch(err => console.error('Error aborting DLC fetch:', err));
// Reset state
activeDlcFetchId.current = null;
setIsFetchingDlcs(false);
}
// Clear controller
if (dlcFetchController.current) {
dlcFetchController.current.abort();
dlcFetchController.current = null;
}
// Close dialog
setDlcDialog(prev => ({ ...prev, visible: false }));
};
// Handle DLC selection confirmation
const handleDlcConfirm = async (selectedDlcs: DlcInfo[]) => {
// Close the dialog first
setDlcDialog(prev => ({ ...prev, visible: false }));
const gameId = dlcDialog.gameId;
const game = games.find(g => g.id === gameId);
if (!game) return;
// Update local state to show installation in progress
setGames(prevGames => prevGames.map(g =>
g.id === gameId ? { ...g, installing: true } : g
));
try {
if (dlcDialog.isEditMode) {
// If in edit mode, we're updating existing cream_api.ini
// Show progress dialog for editing
setProgressDialog({
visible: true,
title: `Updating DLCs for ${game.title}`,
message: 'Updating DLC configuration...',
progress: 30,
showInstructions: false,
instructions: undefined
});
// Call the backend to update the DLC configuration
await invoke('update_dlc_configuration_command', {
gamePath: game.path,
dlcs: selectedDlcs
});
// Update progress dialog for completion
setProgressDialog(prev => ({
...prev,
title: `Update Complete: ${game.title}`,
message: 'DLC configuration updated successfully!',
progress: 100
}));
// Hide dialog after a delay
setTimeout(() => {
setProgressDialog(prev => ({ ...prev, visible: false }));
// Reset installing state
setGames(prevGames => prevGames.map(g =>
g.id === gameId ? { ...g, installing: false } : g
));
}, 2000);
} else {
// We're doing a fresh install with selected DLCs
// Show progress dialog for installation right away
setProgressDialog({
visible: true,
title: `Installing CreamLinux for ${game.title}`,
message: 'Processing...',
progress: 0,
showInstructions: false,
instructions: undefined
});
// Invoke the installation with the selected DLCs
await invoke('install_cream_with_dlcs_command', {
gameId,
selectedDlcs
}).catch(err => {
console.error(`Error installing CreamLinux with selected DLCs:`, err);
throw err;
});
// Note: we don't need to manually close the dialog or update the game state
// because the backend will emit progress events that handle this
}
} catch (error) {
console.error('Error processing DLC selection:', error);
// Show error in progress dialog
setProgressDialog(prev => ({
...prev,
message: `Error: ${error}`,
progress: 100
}));
// Reset installing state
setGames(prevGames => prevGames.map(g =>
g.id === gameId ? { ...g, installing: false } : g
));
// Hide dialog after a delay
setTimeout(() => {
setProgressDialog(prev => ({ ...prev, visible: false }));
}, 3000);
}
};
// Update DLCs being streamed with enabled state
useEffect(() => {
if (dlcDialog.enabledDlcs.length > 0) {
setDlcDialog(prev => ({
...prev,
dlcs: prev.dlcs.map(dlc => ({
...dlc,
enabled: prev.enabledDlcs.length === 0 || prev.enabledDlcs.includes(dlc.appid)
}))
}));
}
}, [dlcDialog.dlcs, dlcDialog.enabledDlcs]);
// Filter games based on sidebar filter AND search query
const filteredGames = games.filter(game => {
// First filter by the platform/type
const platformMatch = filter === "all" ||
(filter === "native" && game.native) ||
(filter === "proton" && !game.native);
// Then filter by search query (if any)
const searchMatch = searchQuery.trim() === '' ||
game.title.toLowerCase().includes(searchQuery.toLowerCase());
// Both filters must match
return platformMatch && searchMatch;
});
// Check if we should show the initial loading screen
if (isInitialLoad) {
return (
<InitialLoadingScreen
message={scanProgress.message}
progress={scanProgress.progress}
/>
);
}
return (
<div className="app-container">
{/* Animated background */}
<AnimatedBackground />
<Header
onRefresh={loadGames}
onSearch={handleSearchChange}
searchQuery={searchQuery}
/>
<div className="main-content">
<Sidebar setFilter={setFilter} currentFilter={filter} />
{error ? (
<div className="error-message">
<h3>Error Loading Games</h3>
<p>{error}</p>
<button onClick={loadGames}>Retry</button>
</div>
) : (
<GameList
games={filteredGames}
isLoading={isLoading}
onAction={handleGameAction}
onEdit={handleGameEdit}
/>
)}
</div>
{/* Progress Dialog */}
<ProgressDialog
visible={progressDialog.visible}
title={progressDialog.title}
message={progressDialog.message}
progress={progressDialog.progress}
showInstructions={progressDialog.showInstructions}
instructions={progressDialog.instructions}
onClose={handleCloseProgressDialog}
/>
{/* DLC Selection Dialog */}
<DlcSelectionDialog
visible={dlcDialog.visible}
gameTitle={dlcDialog.gameTitle}
dlcs={dlcDialog.dlcs}
isLoading={dlcDialog.isLoading}
isEditMode={dlcDialog.isEditMode}
loadingProgress={dlcDialog.progress}
estimatedTimeLeft={dlcDialog.timeLeft}
onClose={handleDlcDialogClose}
onConfirm={handleDlcConfirm}
/>
</div>
);
}
export default App;

BIN
src/assets/fonts/Roboto.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

BIN
src/assets/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

View 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;

View 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;

View 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;

View 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
View 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;

View 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
View 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;

View 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;

View 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;

View 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;

View 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;

9
src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,96 @@
// src/services/ImageService.ts
/**
* Game image sources from Steam's CDN
*/
export const SteamImageType = {
HEADER: 'header', // 460x215
CAPSULE: 'capsule_616x353', // 616x353
LOGO: 'logo', // Game logo with transparency
LIBRARY_HERO: 'library_hero', // 1920x620
LIBRARY_CAPSULE: 'library_600x900', // 600x900
} as const;
export type SteamImageTypeKey = keyof typeof SteamImageType;
// Cache for images to prevent flickering
const imageCache: Map<string, string> = new Map();
/**
* Builds a Steam CDN URL for game images
* @param appId Steam application ID
* @param type Image type from SteamImageType enum
* @returns URL string for the image
*/
export const getSteamImageUrl = (appId: string, type: typeof SteamImageType[SteamImageTypeKey]) => {
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appId}/${type}.jpg`;
};
/**
* Checks if an image exists by performing a HEAD request
* @param url Image URL to check
* @returns Promise resolving to a boolean indicating if the image exists
*/
export const checkImageExists = async (url: string): Promise<boolean> => {
try {
const response = await fetch(url, { method: 'HEAD' });
return response.ok;
} catch (error) {
console.error('Error checking image existence:', error);
return false;
}
};
/**
* Preloads an image for faster rendering
* @param url URL of image to preload
* @returns Promise that resolves when image is loaded
*/
const preloadImage = (url: string): Promise<string> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(url);
img.onerror = reject;
img.src = url;
});
};
/**
* Attempts to find a valid image for a Steam game, trying different image types
* @param appId Steam application ID
* @returns Promise resolving to a valid image URL or null if none found
*/
export const findBestGameImage = async (appId: string): Promise<string | null> => {
// Check cache first
if (imageCache.has(appId)) {
return imageCache.get(appId) || null;
}
// Try these image types in order of preference
const typesToTry = [
SteamImageType.HEADER,
SteamImageType.CAPSULE,
SteamImageType.LIBRARY_CAPSULE
];
for (const type of typesToTry) {
const url = getSteamImageUrl(appId, type);
const exists = await checkImageExists(url);
if (exists) {
try {
// Preload the image to prevent flickering
const preloadedUrl = await preloadImage(url);
// Store in cache
imageCache.set(appId, preloadedUrl);
return preloadedUrl;
} catch {
// If preloading fails, just return the URL
imageCache.set(appId, url);
return url;
}
}
}
// If we've reached here, no valid image was found
return null;
};

10
src/styles/_fonts.scss Normal file
View File

@@ -0,0 +1,10 @@
@font-face {
font-family: 'Satoshi';
src: url('../assets/fonts/Satoshi.ttf') format('ttf'),
url('../assets/fonts/Roboto.ttf') format('ttf'),
url('../assets/fonts/WorkSans.ttf') format('ttf');
font-weight: 400; // adjust as needed
font-style: normal;
font-display: swap;
}

262
src/styles/_layout.scss Normal file
View File

@@ -0,0 +1,262 @@
// src/styles/_layout.scss
@use './variables' as *;
@use './mixins' as *;
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--primary-bg);
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 20% 30%, rgba(var(--primary-color), 0.05) 0%, transparent 70%),
radial-gradient(circle at 80% 70%, rgba(var(--cream-color), 0.05) 0%, transparent 70%);
pointer-events: none;
z-index: var(--z-bg);
}
}
// Header
.app-header {
@include flex-between;
padding: 1rem 2rem;
background-color: var(--tertiary-bg);
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
position: relative;
z-index: var(--z-header);
height: var(--header-height);
h1 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
letter-spacing: 0.5px;
@include text-shadow;
}
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--cream-color), var(--primary-color), var(--smoke-color));
opacity: 0.7;
}
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
}
}
.header-controls {
display: flex;
gap: 1rem;
align-items: center;
}
// Main content
.main-content {
display: flex;
flex: 1;
overflow: hidden;
width: 100%;
position: relative;
z-index: var(--z-elevate);
}
/* Sidebar */
.sidebar {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
background-color: var(--secondary-bg);
border-right: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: inset -5px 0 15px rgba(0, 0, 0, 0.2);
padding: 1.5rem 1rem;
@include flex-column;
height: 100%;
overflow-y: auto;
z-index: var(--z-elevate) + 1;
h2 {
color: var(--text-primary);
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 1rem;
letter-spacing: 0.5px;
opacity: 0.9;
}
@include custom-scrollbar;
}
// Game list container
.game-list {
padding: 1.5rem;
flex: 1;
overflow-y: auto;
height: 100%;
width: 100%;
@include custom-scrollbar;
position: relative;
h2 {
font-size: 1.4rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: var(--text-primary);
letter-spacing: 0.5px;
position: relative;
display: inline-block;
padding-bottom: 0.5rem;
&:after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 3px;
background: linear-gradient(90deg, var(--primary-color), transparent);
border-radius: 3px;
}
}
}
// Game grid
.game-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 2rem;
width: 100%;
padding: 0.5rem 0.5rem 2rem 0.5rem;
scroll-behavior: smooth;
align-items: stretch;
opacity: 0;
transform: translateY(10px);
animation: fadeIn 0.5s forwards;
}
// Loading and empty state
.loading-indicator, .no-games-message {
@include flex-center;
height: 250px;
width: 100%;
font-size: 1.2rem;
color: var(--text-secondary);
text-align: center;
border-radius: var(--radius-lg);
background-color: rgba(255, 255, 255, 0.03);
box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(5px);
}
.loading-indicator {
position: relative;
overflow: hidden;
&:after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 50%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.05),
transparent
);
animation: loading-shimmer 2s infinite;
}
}
// Responsive adjustments
@include media-sm {
.game-grid {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}
}
@include media-lg {
.game-grid {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
}
@include media-xl {
.game-grid {
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
}
}
// Scroll to top button
.scroll-top-button {
position: fixed;
bottom: 30px;
right: 30px;
width: 44px;
height: 44px;
border-radius: 50%;
@include gradient-bg($primary-color, color-mix(in srgb, black 10%, var(--primary-color)));
color: var(--text-primary);
@include flex-center;
cursor: pointer;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
opacity: 0;
transform: translateY(20px);
@include transition-standard;
z-index: var(--z-header);
&.visible {
opacity: 1;
transform: translateY(0);
}
&:hover {
transform: translateY(-5px);
box-shadow: 0 8px 20px rgba(var(--primary-color), 0.4);
}
&:active {
transform: translateY(0);
}
}
// Animation keyframes
@keyframes fadeIn {
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes loading-shimmer {
to {
left: 100%;
}
}

107
src/styles/_mixins.scss Normal file
View File

@@ -0,0 +1,107 @@
// src/styles/_mixins.scss
@use './variables' as *;
// src/styles/_mixins.scss
// Basic flex helpers
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@mixin flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
@mixin flex-column {
display: flex;
flex-direction: column;
}
// Glass effect for overlay
@mixin glass-overlay($opacity: 0.7) {
background-color: rgba(var(--primary-bg), var(--opacity));
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.05);
}
@mixin gradient-bg($start-color, $end-color, $direction: 135deg) {
background: linear-gradient($direction, $start-color, $end-color);
}
// Basic transition
@mixin transition-standard {
transition: all var(--duration-normal) var(--easing-ease-out);
}
@mixin shadow-standard {
box-shadow: var(--shadow-standard);
}
@mixin shadow-hover {
box-shadow: var(--shadow-hover);;
}
@mixin text-shadow {
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.7);
}
// Simple animation for hover
@mixin hover-lift {
&:hover {
transform: translateY(-5px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5);
}
}
// Responsive mixins
@mixin media-sm {
@media (min-width: 576px) { @content; }
}
@mixin media-md {
@media (min-width: 768px) { @content; }
}
@mixin media-lg {
@media (min-width: 992px) { @content; }
}
@mixin media-xl {
@media (min-width: 1200px) { @content; }
}
// Card base styling
@mixin card {
background-color: var(--secondary-bg);
border-radius: var(--radius-sm);
@include shadow;
overflow: hidden;
position: relative;
}
// Custom scrollbar
@mixin custom-scrollbar {
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: rgba(var(--primary-bg), 0.5);
border-radius: 10px;
}
&::-webkit-scrollbar-thumb {
background: var(--primary-color);
border-radius: 10px;
border: 2px solid var(--primary-bg);
}
&::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, white 10%, var(--primary-color));
}
}

65
src/styles/_reset.scss Normal file
View File

@@ -0,0 +1,65 @@
// src/styles/_reset.scss
@use './variables' as *;
@use './mixins' as *;
@use './fonts' as *;
// src/styles/_reset.scss
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family: 'Roboto';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--primary-bg);
color: var(--text-primary);
/* Prevent text selection by default */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
#root {
height: 100%;
width: 100%;
}
button {
background: none;
border: none;
cursor: pointer;
font-family: inherit;
&:focus {
outline: none;
}
}
a {
color: inherit;
text-decoration: none;
}
ul, ol {
list-style: none;
}
input, button, textarea, select {
font: inherit;
}
h1, h2, h3, h4, h5, h6 {
font-weight: inherit;
font-size: inherit;
}

116
src/styles/_variables.scss Normal file
View File

@@ -0,0 +1,116 @@
// src/styles/_variables.scss
@use './fonts' as *;
// Color palette
:root {
// Primary colors
--primary-color: #ffc896;
--secondary-color: #ffb278;
// Background
--primary-bg: #0f0f0f;
--secondary-bg: #151515;
--tertiary-bg: #121212;
--elevated-bg: #1a1a1a;
--disabled: #5E5E5E;
// Text
--text-primary: #f0f0f0;
--text-secondary: #c8c8c8;
--text-soft: #afafaf;
--text-heavy: #1a1a1a;
--text-muted: #4b4b4b;
// Borders
--border-dark: #1a1a1a;
--border-soft: #282828;
--border: #323232;
// Status colors - more vibrant
--success: #8cc893;
--warning: #ffc896;
--danger: #d96b6b;
--info: #80b4ff;
--success-light: #b0e0a9;
--warning-light: #ffdcb9;
--danger-light: #e69691;
--info-light: #a8d2ff;
--success-soft: rgba(176, 224, 169, 0.15);
--warning-soft: rgba(247, 200, 111, 0.15);
--danger-soft: rgba(230, 150, 145, 0.15);
--info-soft: rgba(168, 210, 255, 0.15);
// Feature colors
--native: #8cc893;
--proton: #ffc896;
--cream: #80b4ff;
--smoke: #fff096;
--modal-backdrop: rgba(30, 30, 30, 0.95);
// Animation durations
--duration-fast: 100ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
// Animation easings
--easing-ease-out: cubic-bezier(0, 0, 0.2, 1);
--easing-ease-in: cubic-bezier(0.4, 0, 1, 1);
--easing-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
// Layout values
--header-height: 64px;
--sidebar-width: 250px;
--card-height: 200px;
// Border radius
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
// Font weights
--thin: 100;
--extralight: 200;
--light: 300;
--normal: 400;
--medium: 500;
--semibold: 600;
--bold: 700;
--extrabold: 800;
--family: 'Satoshi';
// Shadows
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3);
--shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.3);
--shadow-standard: 0 10px 25px rgba(0, 0, 0, 0.5);
--shadow-hover: 0 15px 30px rgba(0, 0, 0, 0.7);
// Z-index levels
//--z-index-bg: 0;
//--z-index-content: 1;
//--z-index-header: 100;
//--z-index-modal: 1000;
//--z-index-tooltip: 1500;
// Z-index levels
--z-bg: 0;
--z-elevate: 1;
--z-header: 100;
--z-modal: 1000;
--z-tooltip: 1500;
}
$success-color: #55e07a;
$danger-color: #ff5252;
$primary-color: #4a76c4;
$cream-color: #9b7dff;
$smoke-color: #fbb13c;
$warning-color: #fbb13c;

View File

@@ -0,0 +1,98 @@
// src/styles/components/_animated_checkbox.scss
@use '../variables' as *;
@use '../mixins' as *;
.animated-checkbox {
display: flex;
align-items: center;
cursor: pointer;
width: 100%;
position: relative;
&:hover .checkbox-custom {
border-color: rgba(255, 255, 255, 0.3);
}
}
.checkbox-original {
position: absolute;
opacity: 0;
height: 0;
width: 0;
}
.checkbox-custom {
width: 22px;
height: 22px;
background-color: rgba(255, 255, 255, 0.05);
border: 2px solid var(--border-soft, #323232);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s var(--easing-bounce);
margin-right: 15px;
flex-shrink: 0;
position: relative;
&.checked {
background-color: var(--primary-color, #ffc896);
border-color: var(--primary-color, #ffc896);
box-shadow: 0 0 10px rgba(255, 200, 150, 0.2);
}
}
.checkmark-icon {
width: 18px;
height: 18px;
}
.checkmark {
stroke-dasharray: 30;
stroke-dashoffset: 30;
opacity: 0;
transition: stroke-dashoffset 0.3s ease;
&.checked {
stroke-dashoffset: 0;
opacity: 1;
animation: checkmarkAnimation 0.3s cubic-bezier(0.65, 0, 0.45, 1) forwards;
}
}
.checkbox-content {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0; // Ensures text-overflow works properly
}
.checkbox-label {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.checkbox-sublabel {
font-size: 12px;
color: var(--text-muted);
}
// Animation for the checkmark
@keyframes checkmarkAnimation {
0% {
stroke-dashoffset: 30;
opacity: 0;
}
40% {
opacity: 1;
}
100% {
stroke-dashoffset: 0;
opacity: 1;
}
}

View File

@@ -0,0 +1,16 @@
// src/styles/_components/_background.scss
@use '../variables' as *;
@use '../mixins' as *;
@use 'sass:color';
.animated-background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: var(--z-bg);
opacity: 0.4;
}

View File

@@ -0,0 +1,249 @@
// src/styles/_components/_dialog.scss
@use '../variables' as *;
@use '../mixins' as *;
/* Progress Dialog */
.progress-dialog-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: var(--modal-backdrop);
backdrop-filter: blur(5px);
@include flex-center;
z-index: var(--z-modal);
opacity: 0;
animation: modal-appear 0.2s ease-out;
cursor: pointer;
&.visible {
opacity: 1;
}
@keyframes modal-appear {
0% { opacity: 0; transform: scale(0.95); }
100% { opacity: 1; transform: scale(1); }
}
}
.progress-dialog {
background-color: var(--elevated-bg);
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.3); /* shadow-glow */
width: 450px;
max-width: 90vw;
border: 1px solid var(--border-soft);
opacity: 0;
cursor: default;
&.dialog-visible {
transform: scale(1);
opacity: 1;
}
&.with-instructions {
width: 500px;
}
h3 {
font-weight: 700;
margin-bottom: 1rem;
color: var(--text-primary);
}
p {
margin-bottom: 1rem;
color: var(--text-secondary);
}
}
// Progress bar
.progress-bar-container {
height: 8px;
background-color: var(--border-soft);
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-bar {
height: 100%;
background-color: var(--primary-color);
border-radius: 4px;
transition: width 0.3s ease;
background: var(--primary-color);
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.3);
}
.progress-percentage {
text-align: right;
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 1rem;
}
/* Instruction container in progress dialog */
.instruction-container {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-soft);
h4 {
font-weight: 700;
margin-bottom: 1rem;
color: var(--text-primary);
}
}
.instruction-text {
line-height: 1.6;
margin-bottom: 1rem;
color: var(--text-secondary);
}
.dlc-count {
display: inline-block;
margin-bottom: 0.75rem;
padding: 0.4rem 0.8rem;
background-color: var(--info-soft);
color: var(--info);
border-radius: 4px;
font-size: 0.8rem;
&::before {
content: '';
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--info);
margin-right: 8px;
}
}
.command-box {
background-color: var(--border-dark);
border: 1px solid var(--border-soft);
border-radius: 4px;
padding: 1rem;
margin-bottom: 1.2rem;
font-family: monospace;
position: relative;
overflow: hidden;
&.command-box-smoke {
font-size: 0.9rem;
overflow-wrap: break-word;
word-break: break-word;
white-space: pre-wrap;
width: 100%;
max-width: 100%;
}
}
.selectable-text {
font-size: 0.9rem;
line-height: 1.5;
user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
cursor: text;
margin: 0;
color: var(--text-primary);
word-break: break-word;
white-space: pre-wrap;
}
.action-buttons {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.copy-button, .close-button {
padding: 0.6rem 1.2rem;
border-radius: var(--radius-sm);
font-weight: 600;
letter-spacing: 0.5px;
@include transition-standard;
border: none;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
}
.copy-button {
background-color: var(--primary-color);
color: white;
&:hover {
background-color: var(--primary-color);
transform: translateY(-2px) scale(1.02); /* hover-lift */
box-shadow: 0 6px 14px var(--info-soft);
}
}
.close-button {
background-color: var(--border-soft);
color: var(--text-primary);
&:hover {
background-color: var(--border);
transform: translateY(-2px) scale(1.02); /* hover-lift */
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.3);
}
}
// Error message styling
.error-message {
@include flex-column;
align-items: center;
justify-content: center;
padding: 2rem;
margin: 2rem auto;
max-width: 600px;
border-radius: var(--radius-lg);
background-color: rgba(var(--danger), 0.05);
border: 1px solid rgb(var(--danger), 0.2);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(5px);
text-align: center;
h3 {
color: var(--danger);
font-weight: 700;
margin-bottom: 1rem;
}
p {
margin-bottom: 1.5rem;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
}
button {
background-color: var(--primary-color);
color: var(--text-primary);
border: none;
padding: 0.7rem 1.5rem;
border-radius: var(--radius-sm);
font-weight: 600;
letter-spacing: 0.5px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
@include transition-standard;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 14px rgba(var(--primary-color), 0.4);
}
}
}
// Animation for progress bar
@keyframes progress-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}

View File

@@ -0,0 +1,314 @@
// src/styles/components/_dlc_dialog.scss
@use '../variables' as *;
@use '../mixins' as *;
.dlc-dialog-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: var(--modal-backdrop);
backdrop-filter: blur(5px);
@include flex-center;
z-index: var(--z-modal);
opacity: 0;
cursor: pointer;
&.visible {
opacity: 1;
animation: modal-appear 0.2s ease-out;
}
}
.dlc-selection-dialog {
background-color: var(--elevated-bg);
border-radius: 8px;
width: 650px;
max-width: 90vw;
max-height: 80vh;
border: 1px solid var(--border-soft);
box-shadow: 0px 10px 25px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
cursor: default;
opacity: 0;
transform: scale(0.95);
&.dialog-visible {
transform: scale(1);
opacity: 1;
transition: transform 0.2s var(--easing-bounce), opacity 0.2s ease-out;
}
}
.dlc-dialog-header {
padding: 1.5rem;
border-bottom: 1px solid var(--border-soft);
h3 {
font-size: 1.2rem;
font-weight: 700;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
}
.dlc-game-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
.game-title {
font-weight: 500;
color: var(--text-secondary);
}
.dlc-count {
font-size: 0.9rem;
padding: 0.3rem 0.6rem;
background-color: var(--info-soft);
color: var(--info);
border-radius: 4px;
}
}
.dlc-dialog-search {
padding: 0.75rem 1.5rem;
background-color: rgba(0, 0, 0, 0.1);
border-bottom: 1px solid var(--border-soft);
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.dlc-search-input {
flex: 1;
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;
@include transition-standard;
&:focus {
border-color: var(--primary-color);
outline: none;
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2);
}
&::placeholder {
color: var(--text-muted);
}
}
.select-all-container {
display: flex;
align-items: center;
min-width: 100px;
// Custom styling for the select all checkbox
:global(.animated-checkbox) {
margin-left: auto;
}
:global(.checkbox-label) {
font-size: 0.9rem;
color: var(--text-secondary);
}
}
.dlc-loading-progress {
padding: 0.75rem 1.5rem;
background-color: rgba(0, 0, 0, 0.05);
border-bottom: 1px solid var(--border-soft);
.progress-bar-container {
height: 6px;
background-color: var(--border-soft);
border-radius: 3px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-bar {
height: 100%;
background-color: var(--primary-color);
border-radius: 3px;
transition: width 0.3s ease;
background: var(--primary-color);
box-shadow: 0px 0px 6px rgba(128, 181, 255, 0.3);
}
.loading-details {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: var(--text-secondary);
.time-left {
color: var(--text-muted);
}
}
}
.dlc-list-container {
flex: 1;
overflow-y: auto;
min-height: 200px;
@include custom-scrollbar;
}
.dlc-list {
padding: 0.5rem 0;
}
.dlc-item {
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--border-soft);
@include transition-standard;
&:hover {
background-color: rgba(255, 255, 255, 0.03);
}
&:last-child {
border-bottom: none;
}
&.dlc-item-loading {
height: 30px;
display: flex;
align-items: center;
justify-content: center;
.loading-pulse {
width: 70%;
height: 20px;
background: linear-gradient(90deg,
var(--border-soft) 0%,
var(--border) 50%,
var(--border-soft) 100%);
background-size: 200% 100%;
border-radius: 4px;
animation: loading-pulse 1.5s infinite;
}
}
// Enhanced styling for the checkbox component inside dlc-item
:global(.animated-checkbox) {
width: 100%;
.checkbox-label {
color: var(--text-primary);
font-weight: 500;
transition: color 0.15s ease;
}
.checkbox-sublabel {
color: var(--text-muted);
}
// Optional hover effect
&:hover {
.checkbox-label {
color: var(--primary-color);
}
.checkbox-custom {
border-color: var(--primary-color, #ffc896);
transform: scale(1.05);
}
}
}
}
.dlc-loading {
height: 200px;
@include flex-center;
flex-direction: column;
gap: 1rem;
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
p {
color: var(--text-secondary);
}
}
.no-dlcs-message {
height: 200px;
@include flex-center;
color: var(--text-secondary);
}
.dlc-dialog-actions {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-soft);
display: flex;
justify-content: flex-end;
gap: 1rem;
}
.cancel-button, .confirm-button {
padding: 0.6rem 1.2rem;
border-radius: var(--radius-sm);
font-weight: 600;
letter-spacing: 0.5px;
@include transition-standard;
border: none;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
}
.cancel-button {
background-color: var(--border-soft);
color: var(--text-primary);
&:hover {
background-color: var(--border);
transform: translateY(-2px);
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.3);
}
}
.confirm-button {
background-color: var(--primary-color);
color: white;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 14px var(--info-soft);
}
&:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes modal-appear {
0% { opacity: 0; transform: scale(0.95); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes loading-pulse {
0% { background-position: 200% 50%; }
100% { background-position: 0% 50%; }
}

View File

@@ -0,0 +1,287 @@
// src/styles/components/_gamecard.scss
@use '../variables' as *;
@use '../mixins' as *;
.game-item-card {
position: relative;
height: var(--card-height);
border-radius: var(--radius-lg);
overflow: hidden;
will-change: opacity, transform;
@include shadow-standard;
@include transition-standard;
transform-origin: center;
// Simple image loading animation
opacity: 0;
animation: fadeIn 0.5s forwards;
}
// Hover effects for the card
.game-item-card:hover {
transform: translateY(-8px) scale(1.02);
@include shadow-hover;
z-index: 5;
.status-badge.native {
box-shadow: 0 0 10px rgba(85, 224, 122, 0.5)
}
.status-badge.proton {
box-shadow: 0 0 10px rgba(255, 201, 150, 0.5);
}
.status-badge.cream {
box-shadow: 0 0 10px rgba(128, 181, 255, 0.5);
}
.status-badge.smoke {
box-shadow: 0 0 10px rgba(255, 239, 150, 0.5);
}
}
// Special styling for cards with different statuses
.game-item-card:has(.status-badge.cream) {
box-shadow: var(--shadow-standard), 0 0 15px rgba(128, 181, 255, 0.15);
}
.game-item-card:has(.status-badge.smoke) {
box-shadow: var(--shadow-standard), 0 0 15px rgba(255, 239, 150, 0.15);
}
// Simple clean overlay
.game-item-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(to bottom,
rgba(0, 0, 0, 0.5) 0%,
rgba(0, 0, 0, 0.6) 50%,
rgba(0, 0, 0, 0.8) 100%
);
@include flex-column;
justify-content: space-between;
padding: 1rem;
box-sizing: border-box;
font-weight: var(--bold);
font-family: var(--family);
-webkit-font-smoothing: subpixel-antialiased;
text-rendering: geometricPrecision;
color: var(--text-heavy);;
z-index: 1;
}
.game-badges {
display: flex;
justify-content: flex-end;
gap: 0.4rem;
margin-bottom: 0.5rem;
position: relative;
z-index: 2;
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: var(--bold);
font-family: var(--family);
-webkit-font-smoothing: subpixel-antialiased;
text-rendering: geometricPrecision;
color: var(--text-heavy);;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
@include transition-standard;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.status-badge.native {
background-color: var(--native);
color: var(--text-heavy);
}
.status-badge.proton {
background-color: var(--proton);
color: var(--text-heavy);
}
.status-badge.cream {
background-color: var(--cream);
color: var(--text-heavy);
}
.status-badge.smoke {
background-color: var(--smoke);
color: var(--text-heavy);
}
.game-title {
padding: 0;
position: relative;
}
.game-title h3 {
color: var(--text-primary);
font-size: 1.6rem;
font-weight: var(--bold);
margin: 0;
-webkit-font-smoothing: subpixel-antialiased;
text-rendering: geometricPrecision;
transform: translateZ(0); // or
will-change: opacity, transform;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-actions {
display: flex;
gap: 0.5rem;
position: relative;
z-index: 3;
}
.action-button {
flex: 1;
padding: 0.5rem;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: var(--bold);
-webkit-font-smoothing: subpixel-antialiased;
text-rendering: geometricPrecision;
color: var(--text-heavy);
min-width: 0;
white-space: nowrap;
@include transition-standard;
}
.action-button.install {
background-color: var(--success);
}
.action-button.install:hover {
background-color: var(--success-light);
transform: translateY(-2px) scale(1.02);
box-shadow: 0px 0px 12px rgba(140, 200, 147, 0.3);
}
.action-button.uninstall {
background-color: var(--danger);
}
.action-button.uninstall:hover {
background-color: var(--danger-light);
transform: translateY(-2px) scale(1.02);
box-shadow: 0px 0px 12px rgba(217, 107, 107, 0.3)
}
.action-button:active {
transform: scale(0.97);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
}
.action-button:disabled {
opacity: 0.7;
cursor: not-allowed;
background-color: var(--disabled);
transform: none;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
position: relative;
overflow: hidden;
}
.action-button:disabled::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 50%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
animation: button-loading 1.5s infinite;
}
.edit-button {
padding: 0 0.7rem;
background-color: rgba(255, 255, 255, 0.2);
font-weight: var(--bold);
-webkit-font-smoothing: subpixel-antialiased;
text-rendering: geometricPrecision;
color: var(--text-primary);
border-radius: var(--radius-sm);
cursor: pointer;
letter-spacing: 1px;
@include transition-standard;
}
.edit-button:hover {
background-color: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
box-shadow: 0 7px 15px rgba(0, 0, 0, 0.3);
}
.edit-button:active {
transform: translateY(0);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
.api-not-found-message {
display: flex;
align-items: center;
justify-content: space-between;
background-color: rgba(255, 100, 100, 0.2);
border: 1px solid rgba(255, 100, 100, 0.3);
border-radius: var(--radius-sm);
padding: 0.4rem 0.8rem;
width: 100%;
font-size: 0.85rem;
color: var(--text-primary);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
span {
flex: 1;
}
.rescan-button {
background-color: var(--warning);
color: var(--text-heavy);
border: none;
border-radius: var(--radius-sm);
padding: 0.2rem 0.6rem;
font-size: 0.75rem;
font-weight: var(--bold);
margin-left: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background-color: var(--warning-light);
transform: translateY(-2px);
}
&:active {
transform: translateY(0);
}
}
}
// Apply staggered delay to cards
@for $i from 1 through 12 {
.game-grid .game-item-card:nth-child(#{$i}) {
animation-delay: #{$i * 0.05}s;
}
}
// Simple animations
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes button-loading {
to { left: 100%; }
}

View File

@@ -0,0 +1,82 @@
// src/styles/_components/_header.scss
@use '../variables' as *;
@use '../mixins' as *;
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--primary-bg);
position: relative;
}
// Header
.app-header {
@include flex-between;
padding: 1rem 2rem;
background-color: var(--tertiary-bg);
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
position: relative;
z-index: var(--z-header);
height: var(--header-height);
h1 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
letter-spacing: 0.5px;
@include text-shadow;
}
}
.header-controls {
display: flex;
gap: 1rem;
align-items: center;
}
.refresh-button {
background-color: var(--primary-color);
color: var(--text-primary);
border: none;
border-radius: 4px;
padding: 0.6rem 1.2rem;
font-weight: var(--bold);
letter-spacing: 0.5px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
transition: all 0.2s ease;
}
.refresh-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 14px rgba(245, 150, 130, 0.3);
background-color: var(--primary-color);
}
.refresh-button:active {
transform: translateY(0);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
}
.search-input {
padding: 0.5rem 1rem;
border: 1px solid var(--border-soft);
border-radius: 4px;
min-width: 200px;
background-color: var(--border-dark);
color: var(--text-primary);
}
.search-input:focus {
border-color: var(--primary-color);
outline: none;
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2);
}

View File

@@ -0,0 +1,100 @@
.initial-loading-screen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: var(--primary-bg);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal) + 1;
.loading-content {
text-align: center;
padding: 2rem;
max-width: 500px;
width: 90%;
h1 {
font-size: 2.5rem;
margin-bottom: 2rem;
font-weight: var(--bold);
color: var(--primary-color);
text-shadow: 0 2px 10px rgba(var(--primary-color), 0.4);
}
.loading-animation {
margin-bottom: 2rem;
}
.loading-circles {
display: flex;
justify-content: center;
gap: 1rem;
margin-bottom: 1rem;
.circle {
width: 20px;
height: 20px;
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out both;
&.circle-1 {
background-color: var(--primary-color);
animation-delay: -0.32s;
}
&.circle-2 {
background-color: var(--cream-color);
animation-delay: -0.16s;
}
&.circle-3 {
background-color: var(--smoke-color);
}
}
}
.loading-message {
font-size: 1.1rem;
color: var(--text-secondary);
margin-bottom: 1.5rem;
min-height: 3rem;
}
.progress-bar-container {
height: 8px;
background-color: var(--border-soft);
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-bar {
height: 100%;
background-color: var(--primary-color);
border-radius: 4px;
transition: width 0.5s ease;
background: linear-gradient(to right, var(--cream-color), var(--primary-color), var(--smoke-color));
box-shadow: 0px 0px 10px rgba(255, 200, 150, 0.4);
}
.progress-percentage {
text-align: right;
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 1rem;
}
}
}
// Animation for the bouncing circles
@keyframes bounce {
0%, 80%, 100% {
transform: scale(0);
}
40% {
transform: scale(1.0);
}
}

View File

@@ -0,0 +1,206 @@
// src/styles/_components/_sidebar.scss
@use '../variables' as *;
@use '../mixins' as *;
.filter-list {
list-style: none;
margin-bottom: 1.5rem;
li {
@include transition-standard;
border-radius: var(--radius-sm);
padding: 0.7rem 1rem;
margin-bottom: 0.3rem;
font-weight: 500;
cursor: pointer;
&:hover {
background-color: rgba(255, 255, 255, 0.07);
}
&.active {
@include gradient-bg($primary-color, color-mix(in srgb, black 10%, var(--primary-color)));
box-shadow: 0 4px 10px rgba(var(--primary-color), 0.3);
}
}
}
// Custom select dropdown styling
.custom-select {
position: relative;
display: inline-block;
.select-selected {
background-color: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-sm);
color: var(--text-primary);
padding: 0.6rem 1rem;
font-size: 0.9rem;
cursor: pointer;
@include transition-standard;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-width: 150px;
&:after {
content: '';
font-size: 0.7rem;
opacity: 0.7;
}
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
.select-items {
position: absolute;
top: 100%;
left: 0;
right: 0;
background-color: var(--secondary-bg);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-sm);
margin-top: 5px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
z-index: 10;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
&.show {
max-height: 300px;
}
.select-item {
padding: 0.5rem 1rem;
cursor: pointer;
@include transition-standard;
&:hover {
background-color: rgba(255, 255, 255, 0.07);
}
&.selected {
background-color: var(--primary-color);
color: var(--text-primary);
}
}
}
}
// App logo styles
.app-logo {
display: flex;
align-items: center;
gap: 10px;
svg {
width: 28px;
height: 28px;
fill: var(--text-primary);
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
}
}
// Tooltip styles
.tooltip {
position: relative;
display: inline-block;
&:hover .tooltip-content {
visibility: visible;
opacity: 1;
transform: translateY(0);
}
.tooltip-content {
visibility: hidden;
width: 200px;
background-color: var(--secondary-bg);
color: var(--text-primary);
text-align: center;
border-radius: var(--radius-sm);
padding: 8px;
position: absolute;
z-index: var(--z-tooltip);
bottom: 125%;
left: 50%;
margin-left: -100px;
opacity: 0;
transform: translateY(10px);
transition: opacity 0.3s, transform 0.3s;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
font-size: 0.8rem;
pointer-events: none;
&::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: var(--secondary-bg) transparent transparent transparent;
}
}
}
// Header controls
.refresh-button {
background-color: var(--primary-color);
color: var(--text-heavy);
border: none;
border-radius: var(--radius-sm);
padding: 0.6rem 1.2rem;
font-weight: var(--bold);
letter-spacing: 0.5px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
@include transition-standard;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 14px rgba(245, 150, 130, 0.3);
background-color: var(--primary-color);
}
&:active {
transform: translateY(0);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
}
}
.search-input:focus {
border-color: var(--primary-color);
outline: none;
box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2);
}
.search-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;
@include transition-standard;
box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.2);
min-width: 200px;
&:focus {
border-color: var(--primary-color);
background-color: rgba(255, 255, 255, 0.1);
outline: none;
box-shadow: 0 0 0 2px rgba(var(--primary-color), 0.3), inset 0 2px 5px rgba(0, 0, 0, 0.2);
}
&::placeholder {
color: rgba(255, 255, 255, 0.4);
}
}

21
src/styles/main.scss Normal file
View File

@@ -0,0 +1,21 @@
// src/styles/main.scss
// Import variables and mixins first
@use './variables' as *;
@use './mixins' as *;
@use './fonts' as *;
// Reset
@use './reset';
// Layout
@use './layout';
// Components
@use './components/gamecard';
@use './components/dialog';
@use './components/background';
@use './components/sidebar';
@use './components/dlc_dialog';
@use './components/loading_screen';
@use './components/animated_checkbox';

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />