mirror of
https://github.com/Novattz/creamlinux-installer.git
synced 2026-08-01 19:18:25 -04:00
pages
This commit is contained in:
@@ -0,0 +1,176 @@
|
|||||||
|
import { useState, useEffect, ReactNode } from 'react'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { useAppContext } from '@/contexts/useAppContext'
|
||||||
|
import { Icon, IconName } from '@/components/icons'
|
||||||
|
|
||||||
|
interface LocalReport {
|
||||||
|
game_id: string
|
||||||
|
unlocker: string
|
||||||
|
worked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SystemInfo {
|
||||||
|
os_name: string
|
||||||
|
cpu_model: string
|
||||||
|
cpu_cores: number
|
||||||
|
gpu_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatChipProps {
|
||||||
|
label: string
|
||||||
|
value: ReactNode
|
||||||
|
icon: IconName | string
|
||||||
|
variant?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatChip = ({ label, value, icon, variant }: StatChipProps) => (
|
||||||
|
<div className="stat-chip">
|
||||||
|
<Icon name={icon} variant={variant} size="md" className="stat-chip-icon" />
|
||||||
|
<span className="stat-chip-value">{value}</span>
|
||||||
|
<span className="stat-chip-label">{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overview page - the default landing view. Leads with the two library
|
||||||
|
* totals as hero numbers, then a Native/Proton composition bar for the
|
||||||
|
* Steam library, then secondary stats as compact chips, plus app info.
|
||||||
|
*/
|
||||||
|
const OverviewPage = () => {
|
||||||
|
const { games, epicGames, epicLoading, loadEpicGames, activityFeed } = useAppContext()
|
||||||
|
const [localReports, setLocalReports] = useState<LocalReport[]>([])
|
||||||
|
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invoke<LocalReport[]>('get_local_reports')
|
||||||
|
.then(setLocalReports)
|
||||||
|
.catch(() => setLocalReports([]))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invoke<SystemInfo>('get_system_info')
|
||||||
|
.then(setSystemInfo)
|
||||||
|
.catch(() => setSystemInfo(null))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Overview is the default landing page, so kick off the Epic scan here too
|
||||||
|
// otherwise the Epic count would show 0 until the user visits that tab.
|
||||||
|
useEffect(() => {
|
||||||
|
if (epicGames.length === 0 && !epicLoading) {
|
||||||
|
loadEpicGames()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const nativeCount = games.filter((g) => g.native).length
|
||||||
|
const protonCount = games.length - nativeCount
|
||||||
|
const creamCount = games.filter((g) => g.cream_installed).length
|
||||||
|
const smokeCount = games.filter((g) => g.smoke_installed).length
|
||||||
|
const nativePct = games.length ? (nativeCount / games.length) * 100 : 0
|
||||||
|
const protonPct = games.length ? (protonCount / games.length) * 100 : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overview-page">
|
||||||
|
<h2>Overview</h2>
|
||||||
|
|
||||||
|
<div className="stat-hero">
|
||||||
|
<div className="stat-hero-item">
|
||||||
|
<span className="stat-hero-value">{games.length}</span>
|
||||||
|
<span className="stat-hero-label">Steam Games</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-hero-divider" />
|
||||||
|
<div className="stat-hero-item">
|
||||||
|
<span className="stat-hero-value">{epicLoading ? '—' : epicGames.length}</span>
|
||||||
|
<span className="stat-hero-label">Epic Games</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{games.length > 0 && (
|
||||||
|
<div className="library-composition">
|
||||||
|
<span className="overview-section-label">Steam Library Composition</span>
|
||||||
|
<div className="composition-bar">
|
||||||
|
<div className="composition-segment native" style={{ width: `${nativePct}%` }} />
|
||||||
|
<div className="composition-segment proton" style={{ width: `${protonPct}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="composition-legend">
|
||||||
|
<div className="legend-item">
|
||||||
|
<span className="legend-dot native" />
|
||||||
|
<span className="legend-label">Native</span>
|
||||||
|
<span className="legend-value">{nativeCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="legend-item">
|
||||||
|
<span className="legend-dot proton" />
|
||||||
|
<span className="legend-label">Proton</span>
|
||||||
|
<span className="legend-value">{protonCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="stat-chips">
|
||||||
|
<StatChip label="CreamLinux Installed" value={creamCount} icon="Linux" />
|
||||||
|
<StatChip label="SmokeAPI Installed" value={smokeCount} icon="Windows" />
|
||||||
|
<StatChip label="Compatibility Reports" value={localReports.length} icon="Star" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overview-columns">
|
||||||
|
<div className="page-section">
|
||||||
|
<h4>Recent Activity</h4>
|
||||||
|
|
||||||
|
{activityFeed.length === 0 ? (
|
||||||
|
<p className="page-section-description">
|
||||||
|
Nothing yet this session - install or uninstall something and it'll show up here.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="activity-feed">
|
||||||
|
{activityFeed.map((item) => (
|
||||||
|
<li key={item.id} className="activity-item">
|
||||||
|
<span className={`activity-item-dot activity-item-dot--${item.type}`} />
|
||||||
|
<span className="activity-item-message">{item.message}</span>
|
||||||
|
<span className="activity-item-time">
|
||||||
|
{new Date(item.timestamp).toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="page-section">
|
||||||
|
<h4>System</h4>
|
||||||
|
|
||||||
|
{systemInfo ? (
|
||||||
|
<div className="system-specs">
|
||||||
|
<div className="system-spec system-spec--os">
|
||||||
|
<span className="system-spec-label">Operating System</span>
|
||||||
|
<span className="system-spec-value" title={systemInfo.os_name}>
|
||||||
|
{systemInfo.os_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="system-spec system-spec--cpu">
|
||||||
|
<span className="system-spec-label">Processor</span>
|
||||||
|
<span className="system-spec-value" title={systemInfo.cpu_model}>
|
||||||
|
{systemInfo.cpu_model}
|
||||||
|
</span>
|
||||||
|
<span className="system-spec-sub">{systemInfo.cpu_cores} threads</span>
|
||||||
|
</div>
|
||||||
|
<div className="system-spec system-spec--gpu">
|
||||||
|
<span className="system-spec-label">Graphics</span>
|
||||||
|
<span className="system-spec-value" title={systemInfo.gpu_name}>
|
||||||
|
{systemInfo.gpu_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="page-section-description">Reading system info...</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OverviewPage
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { open } from '@tauri-apps/plugin-dialog'
|
||||||
|
import { EntryList } from '@/components/common'
|
||||||
|
import { AnimatedCheckbox, Button } from '@/components/buttons'
|
||||||
|
import { useAppContext } from '@/contexts/useAppContext'
|
||||||
|
import { Config } from '@/types/Config'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings page - Steam library paths, the compatibility reporting toggle, and a danger
|
||||||
|
* zone for resetting app data on disk.
|
||||||
|
*/
|
||||||
|
const SettingsPage = () => {
|
||||||
|
const [libraryPaths, setLibraryPaths] = useState<string[]>([])
|
||||||
|
const [reportingOptedIn, setReportingOptedIn] = useState(false)
|
||||||
|
const [isBusy, setIsBusy] = useState(false)
|
||||||
|
const [pathError, setPathError] = useState<string | null>(null)
|
||||||
|
const [isResetting, setIsResetting] = useState(false)
|
||||||
|
const [isClearingCache, setIsClearingCache] = useState(false)
|
||||||
|
|
||||||
|
const { loadGames, showToast } = useAppContext()
|
||||||
|
|
||||||
|
const loadSettingsConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const config = await invoke<Config>('load_config')
|
||||||
|
setLibraryPaths(config.custom_steam_paths)
|
||||||
|
setReportingOptedIn(config.reporting_opted_in)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load config:', error)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSettingsConfig()
|
||||||
|
}, [loadSettingsConfig])
|
||||||
|
|
||||||
|
const addPath = async (path: string) => {
|
||||||
|
setPathError(null)
|
||||||
|
setIsBusy(true)
|
||||||
|
try {
|
||||||
|
const config = await invoke<Config>('add_custom_steam_path', { path })
|
||||||
|
setLibraryPaths(config.custom_steam_paths)
|
||||||
|
loadGames()
|
||||||
|
showToast('Library path added - rescanning for games...', 'info')
|
||||||
|
} catch (error) {
|
||||||
|
setPathError(`${error}`)
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBrowse = async () => {
|
||||||
|
setPathError(null)
|
||||||
|
|
||||||
|
let selected: string | null
|
||||||
|
try {
|
||||||
|
selected = await open({ directory: true, multiple: false, title: 'Select Steam Library Folder' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to open folder picker:', error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!selected) return
|
||||||
|
|
||||||
|
addPath(selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemovePath = async (path: string) => {
|
||||||
|
setIsBusy(true)
|
||||||
|
setPathError(null)
|
||||||
|
try {
|
||||||
|
const config = await invoke<Config>('remove_custom_steam_path', { path })
|
||||||
|
setLibraryPaths(config.custom_steam_paths)
|
||||||
|
loadGames()
|
||||||
|
showToast('Library path removed - rescanning for games...', 'info')
|
||||||
|
} catch (error) {
|
||||||
|
setPathError(`${error}`)
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleReporting = async () => {
|
||||||
|
const next = !reportingOptedIn
|
||||||
|
setReportingOptedIn(next)
|
||||||
|
try {
|
||||||
|
await invoke('set_reporting_opt_in', { optedIn: next })
|
||||||
|
showToast(
|
||||||
|
next ? 'Compatibility reporting enabled' : 'Compatibility reporting disabled',
|
||||||
|
'info'
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
setReportingOptedIn(!next)
|
||||||
|
showToast(`Failed to update reporting preference: ${error}`, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetConfig = async () => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
'Reset all settings to their defaults? This clears your custom Steam library paths and reporting preference, and brings back the startup disclaimer.'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsResetting(true)
|
||||||
|
try {
|
||||||
|
const config = await invoke<Config>('reset_config')
|
||||||
|
setLibraryPaths(config.custom_steam_paths)
|
||||||
|
setReportingOptedIn(config.reporting_opted_in)
|
||||||
|
loadGames()
|
||||||
|
showToast('Settings reset to defaults', 'success')
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`Failed to reset settings: ${error}`, 'error')
|
||||||
|
} finally {
|
||||||
|
setIsResetting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearCache = async () => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
"Clear cached unlocker downloads? They'll be re-downloaded automatically the next time they're needed."
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsClearingCache(true)
|
||||||
|
try {
|
||||||
|
await invoke('clear_caches')
|
||||||
|
showToast('Unlocker cache cleared', 'success')
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`Failed to clear cache: ${error}`, 'error')
|
||||||
|
} finally {
|
||||||
|
setIsClearingCache(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenConfigFolder = async () => {
|
||||||
|
try {
|
||||||
|
await invoke('open_config_folder')
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`Failed to open config folder: ${error}`, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-page">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
|
||||||
|
<div className="page-section">
|
||||||
|
<h4>Steam Library Paths</h4>
|
||||||
|
<p className="page-section-description">
|
||||||
|
Add extra folders to scan for Steam games - useful if a library isn't auto-detected.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{pathError && <p className="entry-list-error">{pathError}</p>}
|
||||||
|
|
||||||
|
<EntryList
|
||||||
|
items={libraryPaths}
|
||||||
|
onAddManual={addPath}
|
||||||
|
onBrowse={handleBrowse}
|
||||||
|
onRemove={handleRemovePath}
|
||||||
|
placeholder="Type an absolute path and press Enter, or browse..."
|
||||||
|
emptyLabel="No custom library paths added yet."
|
||||||
|
disabled={isBusy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="page-section">
|
||||||
|
<h4>Compatibility Reporting</h4>
|
||||||
|
<p className="page-section-description">
|
||||||
|
Anonymously report whether an unlocker worked for a game, and see how it's worked for
|
||||||
|
others before you install.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<AnimatedCheckbox
|
||||||
|
checked={reportingOptedIn}
|
||||||
|
onChange={handleToggleReporting}
|
||||||
|
label="Enable compatibility reporting"
|
||||||
|
sublabel="You can change this at any time"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="page-section danger-zone">
|
||||||
|
<h4>Danger Zone</h4>
|
||||||
|
<p className="page-section-description">
|
||||||
|
These affect app data on disk - not your games or any unlockers already installed on
|
||||||
|
them.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="danger-zone-row">
|
||||||
|
<div className="danger-zone-row-text">
|
||||||
|
<span className="danger-zone-row-label">Reset all settings</span>
|
||||||
|
<span className="danger-zone-row-description">
|
||||||
|
Restores defaults, including custom library paths and the startup disclaimer.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="danger" size="small" onClick={handleResetConfig} disabled={isResetting}>
|
||||||
|
{isResetting ? 'Resetting...' : 'Reset'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="danger-zone-row">
|
||||||
|
<div className="danger-zone-row-text">
|
||||||
|
<span className="danger-zone-row-label">Clear unlocker cache</span>
|
||||||
|
<span className="danger-zone-row-description">
|
||||||
|
Deletes downloaded CreamLinux/SmokeAPI/ScreamAPI/Koaloader files.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
onClick={handleClearCache}
|
||||||
|
disabled={isClearingCache}
|
||||||
|
>
|
||||||
|
{isClearingCache ? 'Clearing...' : 'Clear'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="danger-zone-row">
|
||||||
|
<div className="danger-zone-row-text">
|
||||||
|
<span className="danger-zone-row-label">Open config folder</span>
|
||||||
|
<span className="danger-zone-row-description">~/.config/creamlinux</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" size="small" onClick={handleOpenConfigFolder}>
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettingsPage
|
||||||
Reference in New Issue
Block a user