├── .github
└── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── .gitignore
├── .prettierignore
├── .prettierrc
├── CHANGELOG.md
├── LICENSE.md
├── README.md
├── docs
├── adding-icons.md
└── icons.md
├── eslint.config.js
├── index.html
├── package-lock.json
├── package.json
├── public
└── vite.svg
├── scripts
└── optimize-svg.js
├── src-tauri
├── .gitignore
├── Cargo.toml
├── build.rs
├── capabilities
│ ├── default.json
│ └── desktop.json
├── icons
│ ├── 128x128.png
│ ├── 128x128@2x.png
│ └── icon.png
├── src
│ ├── cache.rs
│ ├── dlc_manager.rs
│ ├── installer.rs
│ ├── main.rs
│ └── searcher.rs
└── tauri.conf.json
├── src
├── App.tsx
├── assets
│ ├── fonts
│ │ ├── Roboto.ttf
│ │ ├── Satoshi.ttf
│ │ └── WorkSans.ttf
│ ├── react.svg
│ └── screenshot.png
├── components
│ ├── buttons
│ │ ├── ActionButton.tsx
│ │ ├── AnimatedCheckbox.tsx
│ │ ├── Button.tsx
│ │ └── index.ts
│ ├── common
│ │ ├── LoadingIndicator.tsx
│ │ └── index.ts
│ ├── dialogs
│ │ ├── Dialog.tsx
│ │ ├── DialogActions.tsx
│ │ ├── DialogBody.tsx
│ │ ├── DialogFooter.tsx
│ │ ├── DialogHeader.tsx
│ │ ├── DlcSelectionDialog.tsx
│ │ ├── ProgressDialog.tsx
│ │ └── index.ts
│ ├── games
│ │ ├── GameItem.tsx
│ │ ├── GameList.tsx
│ │ ├── ImagePreloader.tsx
│ │ └── index.ts
│ ├── icons
│ │ ├── Icon.tsx
│ │ ├── IconFactory.ts
│ │ ├── brands
│ │ │ ├── discord.svg
│ │ │ ├── github.svg
│ │ │ ├── index.ts
│ │ │ ├── linux.svg
│ │ │ ├── proton.svg
│ │ │ ├── steam.svg
│ │ │ └── windows.svg
│ │ ├── index.ts
│ │ └── ui
│ │ │ ├── bold
│ │ │ ├── arrow-up.svg
│ │ │ ├── check.svg
│ │ │ ├── close.svg
│ │ │ ├── controller.svg
│ │ │ ├── copy.svg
│ │ │ ├── diamond.svg
│ │ │ ├── download.svg
│ │ │ ├── download1.svg
│ │ │ ├── edit.svg
│ │ │ ├── error.svg
│ │ │ ├── index.ts
│ │ │ ├── info.svg
│ │ │ ├── layers.svg
│ │ │ ├── refresh.svg
│ │ │ ├── search.svg
│ │ │ ├── trash.svg
│ │ │ ├── warning.svg
│ │ │ └── wine.svg
│ │ │ └── outline
│ │ │ ├── arrow-up.svg
│ │ │ ├── check.svg
│ │ │ ├── close.svg
│ │ │ ├── controller.svg
│ │ │ ├── copy.svg
│ │ │ ├── diamond.svg
│ │ │ ├── download.svg
│ │ │ ├── download1.svg
│ │ │ ├── edit.svg
│ │ │ ├── error.svg
│ │ │ ├── index.ts
│ │ │ ├── info.svg
│ │ │ ├── layers.svg
│ │ │ ├── refresh.svg
│ │ │ ├── search.svg
│ │ │ ├── trash.svg
│ │ │ ├── warning.svg
│ │ │ └── wine.svg
│ ├── layout
│ │ ├── AnimatedBackground.tsx
│ │ ├── ErrorBoundary.tsx
│ │ ├── Header.tsx
│ │ ├── InitialLoadingScreen.tsx
│ │ ├── Sidebar.tsx
│ │ └── index.ts
│ ├── notifications
│ │ ├── Toast.tsx
│ │ ├── ToastContainer.tsx
│ │ └── index.ts
│ └── updater
│ │ ├── UpdateNotifier.tsx
│ │ └── index.ts
├── contexts
│ ├── AppContext.tsx
│ ├── AppProvider.tsx
│ ├── index.ts
│ └── useAppContext.ts
├── hooks
│ ├── index.ts
│ ├── useAppLogic.ts
│ ├── useDlcManager.ts
│ ├── useGameActions.ts
│ ├── useGames.ts
│ ├── useToasts.ts
│ └── useUpdateChecker.ts
├── main.tsx
├── services
│ ├── ImageService.ts
│ └── index.ts
├── styles
│ ├── abstracts
│ │ ├── _fonts.scss
│ │ ├── _index.scss
│ │ ├── _layout.scss
│ │ ├── _mixins.scss
│ │ ├── _reset.scss
│ │ └── _variables.scss
│ ├── components
│ │ ├── buttons
│ │ │ ├── _action_button.scss
│ │ │ ├── _animated_checkbox.scss
│ │ │ ├── _button.scss
│ │ │ └── _index.scss
│ │ ├── common
│ │ │ ├── _index.scss
│ │ │ ├── _loading.scss
│ │ │ └── _updater.scss
│ │ ├── dialogs
│ │ │ ├── _dialog.scss
│ │ │ ├── _dlc_dialog.scss
│ │ │ ├── _index.scss
│ │ │ └── _progress_dialog.scss
│ │ ├── games
│ │ │ ├── _gamecard.scss
│ │ │ ├── _gamelist.scss
│ │ │ └── _index.scss
│ │ ├── icons
│ │ │ ├── _icon.scss
│ │ │ └── _index.scss
│ │ ├── layout
│ │ │ ├── _background.scss
│ │ │ ├── _header.scss
│ │ │ ├── _index.scss
│ │ │ ├── _loading_screen.scss
│ │ │ └── _sidebar.scss
│ │ └── notifications
│ │ │ ├── _index.scss
│ │ │ └── _toast.scss
│ ├── main.scss
│ ├── pages
│ │ └── _home.scss
│ └── themes
│ │ ├── _dark.scss
│ │ └── _index.scss
├── types
│ ├── DlcInfo.ts
│ ├── Game.ts
│ ├── index.ts
│ └── svg.d.ts
├── utils
│ ├── helpers.ts
│ └── index.ts
└── vite-env.d.ts
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug Report
3 | about: Create a report to help improve CreamLinux
4 | title: '[BUG] '
5 | labels: bug
6 | assignees: 'Novattz'
7 | ---
8 |
9 | ## Bug Description
10 |
11 | A clear and concise description of what the bug is.
12 |
13 | ## Steps To Reproduce
14 |
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | ## Expected Behavior
21 |
22 | A clear and concise description of what you expected to happen.
23 |
24 | ## Screenshots
25 |
26 | If applicable, add screenshots to help explain your problem.
27 |
28 | ## System Information
29 |
30 | - OS: [e.g. Ubuntu 22.04, Arch Linux, etc.]
31 | - Desktop Environment: [e.g. GNOME, KDE, etc.]
32 | - CreamLinux Version: [e.g. 0.1.0]
33 | - Steam Version: [e.g. latest]
34 |
35 | ## Game Information
36 |
37 | - Game name:
38 | - Game ID (if known):
39 | - Native Linux or Proton:
40 | - Steam installation path:
41 |
42 | ## Additional Context
43 |
44 | Add any other context about the problem here.
45 |
46 | ## Logs
47 |
48 | If possible, include the contents of `~/.cache/creamlinux/creamlinux.log` or attach the file.
49 |
50 | ```
51 | Paste log content here
52 | ```
53 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature Request
3 | about: Suggest an idea for CreamLinux
4 | title: '[FEATURE] '
5 | labels: enhancement
6 | assignees: 'Novattz'
7 | ---
8 |
9 | ## Feature Description
10 |
11 | A clear and concise description of what you want to happen.
12 |
13 | ## Problem This Feature Solves
14 |
15 | Is your feature request related to a problem? Please describe.
16 | Ex. I'm always frustrated when [...]
17 |
18 | ## Alternatives You've Considered
19 |
20 | A clear and concise description of any alternative solutions or features you've considered.
21 |
22 | ## Additional Context
23 |
24 | Add any other context or screenshots about the feature request here.
25 |
26 | ## Implementation Ideas (Optional)
27 |
28 | If you have any ideas on how this feature could be implemented, please share them here.
29 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | docs
14 | *.local
15 | *.lock
16 | .env
17 | CHANGELOG.md
18 | scripts/prepare-release.js
19 | scripts/update-server.js
20 |
21 | # Editor directories and files
22 | .vscode/*
23 | !.vscode/extensions.json
24 | .idea
25 | .DS_Store
26 | *.suo
27 | *.ntvs*
28 | *.njsproj
29 | *.sln
30 | *.sw?
31 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | dist
2 | node_modules
3 | src-tauri/target
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "singleQuote": true,
4 | "printWidth": 100,
5 | "trailingComma": "es5"
6 | }
7 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Novattz/creamlinux-installer/116e2cfea0fdae0f9daffc7b483122d543b9a508/CHANGELOG.md
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Tickbase
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # CreamLinux
2 |
3 | CreamLinux is a GUI application for Linux that simplifies the management of DLC in Steam games. It provides a user-friendly interface to install and configure CreamAPI (for native Linux games) and SmokeAPI (for Windows games running through Proton).
4 |
5 | ## Watch the demo here:
6 | [](https://www.youtube.com/watch?v=ZunhZnKFLlg)
7 | ## Beta Status
8 |
9 | ⚠️ **IMPORTANT**: CreamLinux is currently in BETA. This means:
10 |
11 | - Some features may be incomplete or subject to change
12 | - You might encounter bugs or unexpected behavior
13 | - The application is under active development
14 | - Your feedback and bug reports are invaluable
15 |
16 | While the core functionality is working, please be aware that this is an early release. Im continuously working to improve stability, add features, and enhance the user experience. Please report any issues you encounter on [GitHub Issues page](https://github.com/Novattz/creamlinux-installer/issues).
17 |
18 | ## Features
19 |
20 | - **Auto-discovery**: Automatically finds Steam games installed on your system
21 | - **Native support**: Installs CreamLinux for native Linux games
22 | - **Proton support**: Installs SmokeAPI for Windows games running through Proton
23 | - **DLC management**: Easily select which DLCs to enable
24 | - **Modern UI**: Clean, responsive interface that's easy to use
25 |
26 | ## Installation
27 |
28 | ### AppImage (Recommended)
29 |
30 | 1. Download the latest `CreamLinux.AppImage` from the [Releases](https://github.com/Novattz/creamlinux-installer/releases) page
31 | 2. Make it executable:
32 | ```bash
33 | chmod +x CreamLinux.AppImage
34 | ```
35 | 3. Run it:
36 | ```bash
37 | ./CreamLinux.AppImage
38 | ```
39 |
40 | For Nvidia users use this command:
41 | ```
42 | WEBKIT_DISABLE_DMABUF_RENDERER=1 ./creamlinux.appimage
43 | ```
44 |
45 | ### Building from Source
46 |
47 | #### Prerequisites
48 |
49 | - Rust 1.77.2 or later
50 | - Node.js 18 or later
51 | - npm or yarn
52 |
53 | #### Steps
54 |
55 | 1. Clone the repository:
56 |
57 | ```bash
58 | git clone https://github.com/novattz/creamlinux.git
59 | cd creamlinux
60 | ```
61 |
62 | 2. Install dependencies:
63 |
64 | ```bash
65 | npm install # or yarn
66 | ```
67 |
68 | 3. Build the application:
69 |
70 | ```bash
71 | NO_STRIP=true npm run tauri build
72 | ```
73 |
74 | 4. The compiled binary will be available in `src-tauri/target/release/creamlinux`
75 |
76 | ### Desktop Integration
77 |
78 | If you're using the AppImage version, you can integrate it into your desktop environment:
79 |
80 | 1. Create a desktop entry file:
81 |
82 | ```bash
83 | mkdir -p ~/.local/share/applications
84 | ```
85 |
86 | 2. Create `~/.local/share/applications/creamlinux.desktop` with the following content (adjust the path to your AppImage):
87 |
88 | ```
89 | [Desktop Entry]
90 | Name=Creamlinux
91 | Exec=/absolute/path/to/CreamLinux.AppImage
92 | Icon=/absolute/path/to/creamlinux-icon.png
93 | Type=Application
94 | Categories=Game;Utility;
95 | Comment=DLC Manager for Steam games on Linux
96 | ```
97 |
98 | 3. Update your desktop database so creamlinux appears in your app launcher:
99 |
100 | ```bash
101 | update-desktop-database ~/.local/share/applications
102 | ```
103 |
104 | ## Troubleshooting
105 |
106 | ### Common Issues
107 |
108 | - **Game doesn't load**: Make sure the launch options are correctly set in Steam
109 | - **DLCs not showing up**: Try refreshing the game list and reinstalling
110 | - **Cannot find Steam**: Ensure Steam is installed and you've launched it at least once (Flatpak is not supported yet)
111 |
112 | ### Debug Logs
113 |
114 | Logs are stored at: `~/.cache/creamlinux/creamlinux.log`
115 |
116 | ## License
117 |
118 | This project is licensed under the MIT License - see the [LICENSE](LICENSE.md) file for details.
119 |
120 | ## Credits
121 |
122 | - [Creamlinux](https://github.com/anticitizn/creamlinux) - Native DLC support
123 | - [SmokeAPI](https://github.com/acidicoala/SmokeAPI) - Proton support
124 | - [Tauri](https://tauri.app/) - Framework for building the desktop application
125 | - [React](https://reactjs.org/) - UI library
126 |
--------------------------------------------------------------------------------
/docs/adding-icons.md:
--------------------------------------------------------------------------------
1 | # Adding New Icons to Creamlinux
2 |
3 | This guide explains how to add new icons to the Creamlinux project.
4 |
5 | ## Prerequisites
6 |
7 | - Basic knowledge of SVG files
8 | - Node.js and npm installed
9 | - Creamlinux project set up
10 |
11 | ## Step 1: Find or Create SVG Icons
12 |
13 | You can:
14 |
15 | - Create your own SVG icons using tools like Figma, Sketch, or Illustrator
16 | - Download icons from libraries like Heroicons, Material Icons, or Feather Icons
17 | - Use existing SVG files
18 |
19 | Ideally, icons should:
20 |
21 | - Be 24x24px or have a viewBox of "0 0 24 24"
22 | - Have a consistent style with existing icons
23 | - Use stroke-width of 2 for outline variants
24 | - Use solid fills for bold variants
25 |
26 | ## Step 2: Optimize SVG Files
27 |
28 | We have a script to optimize SVG files for the icon system:
29 |
30 | ```bash
31 | # Install dependencies
32 | npm install
33 |
34 | # Optimize a single SVG
35 | npm run optimize-svg path/to/icon.svg
36 |
37 | # Optimize all SVGs in a directory
38 | npm run optimize-svg src/components/icons/ui/outline
39 | ```
40 |
41 | The optimizer will:
42 |
43 | - Remove unnecessary attributes
44 | - Set the viewBox to "0 0 24 24"
45 | - Add currentColor for fills/strokes for proper color inheritance
46 | - Remove width and height attributes for flexible sizing
47 |
48 | ## Step 3: Add SVG Files to the Project
49 |
50 | 1. Decide if your icon is a "bold" (filled) or "outline" (stroked) variant
51 | 2. Place the file in the appropriate directory:
52 | - For outline variants: `src/components/icons/ui/outline/`
53 | - For bold variants: `src/components/icons/ui/bold/`
54 | 3. Use a descriptive name like `download.svg` or `settings.svg`
55 |
56 | ## Step 4: Export the Icons
57 |
58 | 1. Open the index.ts file in the respective directory:
59 |
60 | - `src/components/icons/ui/outline/index.ts` for outline variants
61 | - `src/components/icons/ui/bold/index.ts` for bold variants
62 |
63 | 2. Add an export statement for your new icon:
64 |
65 | ```typescript
66 | // For outline variant
67 | export { ReactComponent as NewIconOutlineIcon } from './new-icon.svg'
68 |
69 | // For bold variant
70 | export { ReactComponent as NewIconBoldIcon } from './new-icon.svg'
71 | ```
72 |
73 | Use a consistent naming pattern:
74 |
75 | - CamelCase
76 | - Descriptive name
77 | - Suffix with BoldIcon or OutlineIcon based on variant
78 |
79 | ## Step 5: Use the Icon in Your Components
80 |
81 | Now you can use your new icon in any component:
82 |
83 | ```tsx
84 | import { Icon } from '@/components/icons'
85 | import { NewIconOutlineIcon, NewIconBoldIcon } from '@/components/icons'
86 |
87 | // In your component:
88 |
89 |
90 | ```
91 |
92 | ## Best Practices
93 |
94 | 1. **Create both variants**: When possible, create both bold and outline variants for consistency.
95 |
96 | 2. **Use semantic names**: Name icons based on their meaning, not appearance (e.g., "success" instead of "checkmark").
97 |
98 | 3. **Be consistent**: Follow the existing icon style for visual harmony.
99 |
100 | 4. **Test different sizes**: Ensure icons look good at all standard sizes: xs, sm, md, lg, xl.
101 |
102 | 5. **Optimize manually if needed**: Sometimes automatic optimization may not work perfectly. You might need to manually edit SVG files.
103 |
104 | 6. **Add accessibility**: When using icons, provide proper accessibility:
105 |
106 | ```tsx
107 |
108 | ```
109 |
110 | ## Troubleshooting
111 |
112 | **Problem**: Icon doesn't change color with CSS
113 | **Solution**: Make sure your SVG uses `currentColor` for fill or stroke
114 |
115 | **Problem**: Icon looks pixelated
116 | **Solution**: Ensure your SVG has a proper viewBox attribute
117 |
118 | **Problem**: Icon sizing is inconsistent
119 | **Solution**: Use the standard size props (xs, sm, md, lg, xl) instead of custom sizes
120 |
121 | **Problem**: SVG has complex gradients or effects that don't render correctly
122 | **Solution**: Simplify the SVG design; complex effects aren't ideal for UI icons
123 |
124 | ## Additional Resources
125 |
126 | - [SVGR documentation](https://react-svgr.com/docs/what-is-svgr/)
127 | - [SVGO documentation](https://github.com/svg/svgo)
128 | - [SVG MDN documentation](https://developer.mozilla.org/en-US/docs/Web/SVG)
129 |
--------------------------------------------------------------------------------
/docs/icons.md:
--------------------------------------------------------------------------------
1 | # Icon Usage Methods
2 |
3 | There are two ways to use icons in Creamlinux, both fully supported and completely interchangeable.
4 |
5 | ## Method 1: Using Icon component with name prop
6 |
7 | This approach uses the `Icon` component with a `name` prop:
8 |
9 | ```tsx
10 | import { Icon, refresh, check, info, steam } from '@/components/icons'
11 |
12 |
13 |
14 |
15 | {/* Brand icons auto-detect the variant */}
16 | ```
17 |
18 | ## Method 2: Using direct icon components
19 |
20 | This approach imports pre-configured icon components directly:
21 |
22 | ```tsx
23 | import { RefreshIcon, CheckBoldIcon, InfoIcon, SteamIcon } from '@/components/icons'
24 |
25 | {/* Outline variant */}
26 | {/* Bold variant */}
27 |
28 | {/* Brand icon */}
29 | ```
30 |
31 | ## When to use each method
32 |
33 | ### Use Method 1 (Icon + name) when:
34 |
35 | - You have dynamic icon selection based on data or state
36 | - You want to keep your imports list shorter
37 | - You're working with icons in loops or maps
38 | - You want to change variants dynamically
39 |
40 | Example of dynamic icon selection:
41 |
42 | ```tsx
43 | import { Icon } from '@/components/icons'
44 |
45 | function StatusIndicator({ status }) {
46 | const iconName =
47 | status === 'success'
48 | ? 'Check'
49 | : status === 'warning'
50 | ? 'Warning'
51 | : status === 'error'
52 | ? 'Close'
53 | : 'Info'
54 |
55 | return
56 | }
57 | ```
58 |
59 | ### Use Method 2 (direct components) when:
60 |
61 | - You want the most concise syntax
62 | - You're using a fixed set of icons that won't change
63 | - You want specific variants (like InfoBoldIcon vs InfoIcon)
64 | - You prefer more explicit component names in your JSX
65 |
66 | Example of fixed icon usage:
67 |
68 | ```tsx
69 | import { InfoIcon, CloseIcon } from '@/components/icons'
70 |
71 | function ModalHeader({ title, onClose }) {
72 | return (
73 |
74 |
75 |
76 |
{title}
77 |
78 |
79 |
80 |
81 |
82 | )
83 | }
84 | ```
85 |
86 | ## Available Icon Component Exports
87 |
88 | ### UI Icons (Outline variant by default)
89 |
90 | ```tsx
91 | import {
92 | ArrowUpIcon,
93 | CheckIcon,
94 | CloseIcon,
95 | ControllerIcon,
96 | CopyIcon,
97 | DownloadIcon,
98 | EditIcon,
99 | InfoIcon,
100 | LayersIcon,
101 | RefreshIcon,
102 | SearchIcon,
103 | TrashIcon,
104 | WarningIcon,
105 | WineIcon,
106 | } from '@/components/icons'
107 | ```
108 |
109 | ### Bold Variants
110 |
111 | ```tsx
112 | import { CheckBoldIcon, InfoBoldIcon, WarningBoldIcon } from '@/components/icons'
113 | ```
114 |
115 | ### Brand Icons
116 |
117 | ```tsx
118 | import { DiscordIcon, GitHubIcon, LinuxIcon, SteamIcon, WindowsIcon } from '@/components/icons'
119 | ```
120 |
121 | ## Combining Methods
122 |
123 | Both methods work perfectly together and can be mixed in the same component:
124 |
125 | ```tsx
126 | import {
127 | Icon,
128 | refresh, // Method 1
129 | CheckBoldIcon, // Method 2
130 | } from '@/components/icons'
131 |
132 | function MyComponent() {
133 | return (
134 |
135 |
136 |
137 |
138 | )
139 | }
140 | ```
141 |
142 | ## Props are Identical
143 |
144 | Both methods accept the same props:
145 |
146 | ```tsx
147 | // These are equivalent:
148 |
149 |
150 | ```
151 |
152 | Available props in both cases:
153 |
154 | - `size`: "xs" | "sm" | "md" | "lg" | "xl" | number
155 | - `variant`: "outline" | "bold" | "brand" (only for Icon + name method)
156 | - `fillColor`: CSS color string
157 | - `strokeColor`: CSS color string
158 | - `className`: CSS class string
159 | - `title`: Accessibility title
160 | - ...plus all standard SVG attributes
161 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import tseslint from 'typescript-eslint'
6 |
7 | export default tseslint.config(
8 | { ignores: ['dist', 'node_modules', 'src-tauri/target'] },
9 | {
10 | extends: [js.configs.recommended, ...tseslint.configs.recommended],
11 | files: ['**/*.{ts,tsx}'],
12 | languageOptions: {
13 | ecmaVersion: 2020,
14 | globals: globals.browser,
15 | },
16 | plugins: {
17 | 'react-hooks': reactHooks,
18 | 'react-refresh': reactRefresh,
19 | },
20 | rules: {
21 | ...reactHooks.configs.recommended.rules,
22 | 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
23 | },
24 | }
25 | )
26 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Creamlinux
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "creamlinux",
3 | "private": true,
4 | "version": "1.0.0",
5 | "type": "module",
6 | "author": "Tickbase",
7 | "repository": "https://github.com/Novattz/creamlinux-installer",
8 | "license": "MIT",
9 | "scripts": {
10 | "dev": "vite",
11 | "build": "tsc -b && vite build",
12 | "lint": "eslint .",
13 | "preview": "vite preview",
14 | "tauri": "tauri",
15 | "optimize-svg": "node scripts/optimize-svg.js",
16 | "prepare-release": "node scripts/prepare-release.js"
17 | },
18 | "dependencies": {
19 | "@tauri-apps/api": "^2.5.0",
20 | "@tauri-apps/plugin-process": "^2.2.1",
21 | "@tauri-apps/plugin-updater": "^2.7.1",
22 | "react": "^19.0.0",
23 | "react-dom": "^19.0.0",
24 | "sass": "^1.89.0",
25 | "uuid": "^11.1.0"
26 | },
27 | "devDependencies": {
28 | "@eslint/js": "^9.22.0",
29 | "@semantic-release/changelog": "^6.0.3",
30 | "@semantic-release/git": "^10.0.1",
31 | "@semantic-release/github": "^11.0.2",
32 | "@svgr/core": "^8.1.0",
33 | "@svgr/webpack": "^8.1.0",
34 | "@tauri-apps/cli": "^2.5.0",
35 | "@types/node": "^20.10.0",
36 | "@types/react": "^19.0.10",
37 | "@types/react-dom": "^19.0.4",
38 | "@vitejs/plugin-react": "^4.3.4",
39 | "dotenv": "^16.5.0",
40 | "eslint": "^9.22.0",
41 | "eslint-plugin-react-hooks": "^5.2.0",
42 | "eslint-plugin-react-refresh": "^0.4.19",
43 | "glob": "^11.0.2",
44 | "globals": "^16.0.0",
45 | "node-fetch": "^3.3.2",
46 | "sass-embedded": "^1.86.3",
47 | "semantic-release": "^24.2.4",
48 | "typescript": "~5.7.2",
49 | "typescript-eslint": "^8.26.1",
50 | "vite": "^6.3.5",
51 | "vite-plugin-svgr": "^4.3.0"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/scripts/optimize-svg.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * SVG Optimizer for Creamlinux
5 | *
6 | * This script optimizes SVG files for use in the icon system.
7 | * Run it with `node optimize-svg.js path/to/svg`
8 | */
9 |
10 | import fs from 'fs'
11 | import path from 'path'
12 | import optimize from 'svgo'
13 |
14 | // Check if a file path is provided
15 | if (process.argv.length < 3) {
16 | console.error('Please provide a path to an SVG file or directory')
17 | process.exit(1)
18 | }
19 |
20 | const inputPath = process.argv[2]
21 |
22 | // SVGO configuration
23 | const svgoConfig = {
24 | plugins: [
25 | {
26 | name: 'preset-default',
27 | params: {
28 | overrides: {
29 | // Keep viewBox attribute
30 | removeViewBox: false,
31 | // Don't remove IDs
32 | cleanupIDs: false,
33 | // Don't minify colors
34 | convertColors: false,
35 | },
36 | },
37 | },
38 | // Add currentColor for path fill if not specified
39 | {
40 | name: 'addAttributesToSVGElement',
41 | params: {
42 | attributes: [
43 | {
44 | fill: 'currentColor',
45 | },
46 | ],
47 | },
48 | },
49 | // Remove width and height
50 | {
51 | name: 'removeAttrs',
52 | params: {
53 | attrs: ['width', 'height'],
54 | },
55 | },
56 | // Make sure viewBox is 0 0 24 24 for consistent sizing
57 | {
58 | name: 'addAttributesToSVGElement',
59 | params: {
60 | attributes: [
61 | {
62 | viewBox: '0 0 24 24',
63 | },
64 | ],
65 | },
66 | },
67 | ],
68 | }
69 |
70 | // Function to optimize a single SVG file
71 | function optimizeSVG(filePath) {
72 | try {
73 | const svg = fs.readFileSync(filePath, 'utf8')
74 | const result = optimize(svg, svgoConfig)
75 |
76 | // Write the optimized SVG back to the file
77 | fs.writeFileSync(filePath, result.data)
78 | console.log(`✅ Optimized: ${filePath}`)
79 |
80 | return true
81 | } catch (error) {
82 | console.error(`❌ Error optimizing ${filePath}:`, error)
83 | return false
84 | }
85 | }
86 |
87 | // Function to process a directory of SVG files
88 | function processDirectory(dirPath) {
89 | try {
90 | const files = fs.readdirSync(dirPath)
91 | let optimizedCount = 0
92 |
93 | for (const file of files) {
94 | const filePath = path.join(dirPath, file)
95 | const stat = fs.statSync(filePath)
96 |
97 | if (stat.isDirectory()) {
98 | // Recursively process subdirectories
99 | optimizedCount += processDirectory(filePath)
100 | } else if (path.extname(file).toLowerCase() === '.svg') {
101 | // Process SVG files
102 | if (optimizeSVG(filePath)) {
103 | optimizedCount++
104 | }
105 | }
106 | }
107 |
108 | return optimizedCount
109 | } catch (error) {
110 | console.error(`Error processing directory ${dirPath}:`, error)
111 | return 0
112 | }
113 | }
114 |
115 | // Main execution
116 | try {
117 | const stat = fs.statSync(inputPath)
118 |
119 | if (stat.isDirectory()) {
120 | const count = processDirectory(inputPath)
121 | console.log(`\nOptimized ${count} SVG files in ${inputPath}`)
122 | } else if (path.extname(inputPath).toLowerCase() === '.svg') {
123 | optimizeSVG(inputPath)
124 | } else {
125 | console.error('The provided path is not an SVG file or directory')
126 | process.exit(1)
127 | }
128 | } catch (error) {
129 | console.error('Error:', error)
130 | process.exit(1)
131 | }
132 |
--------------------------------------------------------------------------------
/src-tauri/.gitignore:
--------------------------------------------------------------------------------
1 | # Generated by Cargo
2 | # will have compiled files and executables
3 | /target/
4 | /gen/schemas
5 |
--------------------------------------------------------------------------------
/src-tauri/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "app"
3 | version = "1.0.0"
4 | description = "DLC Manager for Steam games on Linux"
5 | authors = ["tickbase"]
6 | license = "MIT"
7 | repository = "https://github.com/Novattz/creamlinux-installer"
8 | edition = "2021"
9 | rust-version = "1.77.2"
10 |
11 | [build-dependencies]
12 | tauri-build = { version = "2.2.0", features = [] }
13 |
14 | [dependencies]
15 | serde_json = { version = "1.0", features = ["raw_value"] }
16 | serde = { version = "1.0", features = ["derive"] }
17 | regex = "1"
18 | xdg = "2"
19 | log = "0.4"
20 | log4rs = "1.2"
21 | reqwest = { version = "0.11", features = ["json"] }
22 | tokio = { version = "1", features = ["full"] }
23 | zip = "0.6"
24 | tempfile = "3.8"
25 | walkdir = "2.3"
26 | parking_lot = "0.12"
27 | tauri = { version = "2.5.0", features = [] }
28 | tauri-plugin-log = "2.0.0-rc"
29 | tauri-plugin-shell = "2.0.0-rc"
30 | tauri-plugin-dialog = "2.0.0-rc"
31 | tauri-plugin-fs = "2.0.0-rc"
32 | num_cpus = "1.16.0"
33 | tauri-plugin-process = "2"
34 |
35 | [features]
36 | custom-protocol = ["tauri/custom-protocol"]
37 |
38 | [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
39 | tauri-plugin-updater = "2"
40 |
--------------------------------------------------------------------------------
/src-tauri/build.rs:
--------------------------------------------------------------------------------
1 | fn main() {
2 | tauri_build::build()
3 | }
4 |
--------------------------------------------------------------------------------
/src-tauri/capabilities/default.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "../gen/schemas/desktop-schema.json",
3 | "identifier": "default",
4 | "description": "enables the default permissions",
5 | "windows": ["main"],
6 | "permissions": ["core:default"]
7 | }
8 |
--------------------------------------------------------------------------------
/src-tauri/capabilities/desktop.json:
--------------------------------------------------------------------------------
1 | {
2 | "identifier": "desktop-capability",
3 | "platforms": [
4 | "macOS",
5 | "windows",
6 | "linux"
7 | ],
8 | "windows": [
9 | "main"
10 | ],
11 | "permissions": [
12 | "updater:default"
13 | ]
14 | }
--------------------------------------------------------------------------------
/src-tauri/icons/128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Novattz/creamlinux-installer/116e2cfea0fdae0f9daffc7b483122d543b9a508/src-tauri/icons/128x128.png
--------------------------------------------------------------------------------
/src-tauri/icons/128x128@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Novattz/creamlinux-installer/116e2cfea0fdae0f9daffc7b483122d543b9a508/src-tauri/icons/128x128@2x.png
--------------------------------------------------------------------------------
/src-tauri/icons/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Novattz/creamlinux-installer/116e2cfea0fdae0f9daffc7b483122d543b9a508/src-tauri/icons/icon.png
--------------------------------------------------------------------------------
/src-tauri/src/cache.rs:
--------------------------------------------------------------------------------
1 | // This is a placeholder file - cache functionality has been removed
2 | // and now only exists in memory within the App state
3 |
4 | pub fn cache_dlcs(_game_id: &str, _dlcs: &[crate::dlc_manager::DlcInfoWithState]) -> std::io::Result<()> {
5 | // This function is kept only for compatibility, but now does nothing
6 | // The DLCs are only cached in memory
7 | log::info!("Cache functionality has been removed - DLCs are only stored in memory");
8 | Ok(())
9 | }
10 |
11 | pub fn load_cached_dlcs(_game_id: &str) -> Option> {
12 | // This function is kept only for compatibility, but now always returns None
13 | log::info!("Cache functionality has been removed - DLCs are only stored in memory");
14 | None
15 | }
16 |
17 | pub fn clear_all_caches() -> std::io::Result<()> {
18 | // This function is kept only for compatibility, but now does nothing
19 | log::info!("Cache functionality has been removed - DLCs are only stored in memory");
20 | Ok(())
21 | }
--------------------------------------------------------------------------------
/src-tauri/tauri.conf.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
3 | "build": {
4 | "frontendDist": "../dist",
5 | "devUrl": "http://localhost:1420",
6 | "beforeDevCommand": "npm run dev",
7 | "beforeBuildCommand": "npm run build"
8 | },
9 | "bundle": {
10 | "active": true,
11 | "targets": "all",
12 | "category": "Utility",
13 | "createUpdaterArtifacts": true,
14 | "icon": ["icons/128x128.png", "icons/128x128@2x.png", "icons/icon.png"]
15 | },
16 | "productName": "Creamlinux",
17 | "mainBinaryName": "creamlinux",
18 | "version": "1.0.0",
19 | "identifier": "com.creamlinux.dev",
20 | "plugins": {
21 | "updater": {
22 | "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDJDNEI1NzBBRDUxODQ3RjEKUldUeFJ4alZDbGRMTE5Vc241NG5yL080UklnaW1iUGdUWElPRXloRGtKZ3M2SWkzK0RGSDh3Q2kK",
23 | "endpoints": [
24 | "https://github.com/Novattz/creamlinux-installer/releases/latest/download/latest.json"
25 | ],
26 | "windows": {
27 | "installMode": "passive"
28 | }
29 | }
30 | },
31 | "app": {
32 | "withGlobalTauri": false,
33 | "windows": [
34 | {
35 | "title": "Creamlinux",
36 | "width": 1000,
37 | "height": 700,
38 | "minWidth": 800,
39 | "minHeight": 600,
40 | "resizable": true,
41 | "fullscreen": false
42 | }
43 | ],
44 | "security": {
45 | "csp": null
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { useAppContext } from '@/contexts/useAppContext'
2 | import { UpdateNotifier } from '@/components/updater'
3 | import { useAppLogic } from '@/hooks'
4 | import './styles/main.scss'
5 |
6 | // Layout components
7 | import { Header, Sidebar, InitialLoadingScreen, ErrorBoundary } from '@/components/layout'
8 | import AnimatedBackground from '@/components/layout/AnimatedBackground'
9 |
10 | // Dialog components
11 | import { ProgressDialog, DlcSelectionDialog } from '@/components/dialogs'
12 |
13 | // Game components
14 | import { GameList } from '@/components/games'
15 |
16 | /**
17 | * Main application component
18 | */
19 | function App() {
20 | // Get application logic from hook
21 | const {
22 | filter,
23 | setFilter,
24 | searchQuery,
25 | handleSearchChange,
26 | isInitialLoad,
27 | scanProgress,
28 | filteredGames,
29 | handleRefresh,
30 | isLoading,
31 | error,
32 | } = useAppLogic({ autoLoad: true })
33 |
34 | // Get action handlers from context
35 | const {
36 | dlcDialog,
37 | handleDlcDialogClose,
38 | handleProgressDialogClose,
39 | progressDialog,
40 | handleGameAction,
41 | handleDlcConfirm,
42 | handleGameEdit,
43 | } = useAppContext()
44 |
45 | // Show loading screen during initial load
46 | if (isInitialLoad) {
47 | return
48 | }
49 |
50 | return (
51 |
52 |
53 | {/* Animated background */}
54 |
55 |
56 | {/* Header with search */}
57 |
63 |
64 |
65 | {/* Sidebar for filtering */}
66 |
67 |
68 | {/* Show error or game list */}
69 | {error ? (
70 |
71 |
Error Loading Games
72 |
{error}
73 |
Retry
74 |
75 | ) : (
76 |
82 | )}
83 |
84 |
85 | {/* Progress Dialog */}
86 |
95 |
96 | {/* DLC Selection Dialog */}
97 |
108 |
109 | {/* Simple update notifier that uses toast - no UI component */}
110 |
111 |
112 |
113 | )
114 | }
115 |
116 | export default App
--------------------------------------------------------------------------------
/src/assets/fonts/Roboto.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Novattz/creamlinux-installer/116e2cfea0fdae0f9daffc7b483122d543b9a508/src/assets/fonts/Roboto.ttf
--------------------------------------------------------------------------------
/src/assets/fonts/Satoshi.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Novattz/creamlinux-installer/116e2cfea0fdae0f9daffc7b483122d543b9a508/src/assets/fonts/Satoshi.ttf
--------------------------------------------------------------------------------
/src/assets/fonts/WorkSans.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Novattz/creamlinux-installer/116e2cfea0fdae0f9daffc7b483122d543b9a508/src/assets/fonts/WorkSans.ttf
--------------------------------------------------------------------------------
/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Novattz/creamlinux-installer/116e2cfea0fdae0f9daffc7b483122d543b9a508/src/assets/screenshot.png
--------------------------------------------------------------------------------
/src/components/buttons/ActionButton.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react'
2 | import Button, { ButtonVariant } from '../buttons/Button'
3 | import { Icon, layers, download } from '@/components/icons'
4 |
5 | // Define available action types
6 | export type ActionType = 'install_cream' | 'uninstall_cream' | 'install_smoke' | 'uninstall_smoke'
7 |
8 | interface ActionButtonProps {
9 | action: ActionType
10 | isInstalled: boolean
11 | isWorking: boolean
12 | onClick: () => void
13 | disabled?: boolean
14 | className?: string
15 | }
16 |
17 | /**
18 | * Specialized button for game installation actions
19 | */
20 | const ActionButton: FC = ({
21 | action,
22 | isInstalled,
23 | isWorking,
24 | onClick,
25 | disabled = false,
26 | className = '',
27 | }) => {
28 | // Determine button text based on state
29 | const getButtonText = () => {
30 | if (isWorking) return 'Working...'
31 |
32 | const isCream = action.includes('cream')
33 | const product = isCream ? 'CreamLinux' : 'SmokeAPI'
34 |
35 | return isInstalled ? `Uninstall ${product}` : `Install ${product}`
36 | }
37 |
38 | // Map to button variant
39 | const getButtonVariant = (): ButtonVariant => {
40 | // For uninstall actions, use danger variant
41 | if (isInstalled) return 'danger'
42 | // For install actions, use success variant
43 | return 'success'
44 | }
45 |
46 | // Select appropriate icon based on action type and state
47 | const getIconInfo = () => {
48 | const isCream = action.includes('cream')
49 |
50 | if (isInstalled) {
51 | // Uninstall actions
52 | return { name: layers, variant: 'bold' }
53 | } else {
54 | // Install actions
55 | return { name: download, variant: isCream ? 'bold' : 'outline' }
56 | }
57 | }
58 |
59 | const iconInfo = getIconInfo()
60 |
61 | return (
62 |
71 | }
72 | >
73 | {getButtonText()}
74 |
75 | )
76 | }
77 |
78 | export default ActionButton
79 |
--------------------------------------------------------------------------------
/src/components/buttons/AnimatedCheckbox.tsx:
--------------------------------------------------------------------------------
1 | import { Icon, check } from '@/components/icons'
2 |
3 | interface AnimatedCheckboxProps {
4 | checked: boolean
5 | onChange: () => void
6 | label?: string
7 | sublabel?: string
8 | className?: string
9 | }
10 |
11 | /**
12 | * Animated checkbox component with optional label and sublabel
13 | */
14 | const AnimatedCheckbox = ({
15 | checked,
16 | onChange,
17 | label,
18 | sublabel,
19 | className = '',
20 | }: AnimatedCheckboxProps) => {
21 | return (
22 |
23 |
24 |
25 |
26 | {checked && }
27 |
28 |
29 | {(label || sublabel) && (
30 |
31 | {label && {label} }
32 | {sublabel && {sublabel} }
33 |
34 | )}
35 |
36 | )
37 | }
38 |
39 | export default AnimatedCheckbox
40 |
--------------------------------------------------------------------------------
/src/components/buttons/Button.tsx:
--------------------------------------------------------------------------------
1 | import { FC, ButtonHTMLAttributes } from 'react'
2 |
3 | export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'success' | 'warning'
4 | export type ButtonSize = 'small' | 'medium' | 'large'
5 |
6 | interface ButtonProps extends ButtonHTMLAttributes {
7 | variant?: ButtonVariant
8 | size?: ButtonSize
9 | isLoading?: boolean
10 | leftIcon?: React.ReactNode
11 | rightIcon?: React.ReactNode
12 | fullWidth?: boolean
13 | }
14 |
15 | /**
16 | * Button component with different variants, sizes and states
17 | */
18 | const Button: FC = ({
19 | children,
20 | variant = 'primary',
21 | size = 'medium',
22 | isLoading = false,
23 | leftIcon,
24 | rightIcon,
25 | fullWidth = false,
26 | className = '',
27 | disabled,
28 | ...props
29 | }) => {
30 | // Size class mapping
31 | const sizeClass = {
32 | small: 'btn-sm',
33 | medium: 'btn-md',
34 | large: 'btn-lg',
35 | }[size]
36 |
37 | // Variant class mapping
38 | const variantClass = {
39 | primary: 'btn-primary',
40 | secondary: 'btn-secondary',
41 | danger: 'btn-danger',
42 | success: 'btn-success',
43 | warning: 'btn-warning',
44 | }[variant]
45 |
46 | return (
47 |
54 | {isLoading && (
55 |
56 |
57 |
58 | )}
59 |
60 | {leftIcon && !isLoading && {leftIcon} }
61 | {children}
62 | {rightIcon && !isLoading && {rightIcon} }
63 |
64 | )
65 | }
66 |
67 | export default Button
68 |
--------------------------------------------------------------------------------
/src/components/buttons/index.ts:
--------------------------------------------------------------------------------
1 | // Export all button components
2 | export { default as Button } from './Button'
3 | export { default as ActionButton } from './ActionButton'
4 | export { default as AnimatedCheckbox } from './AnimatedCheckbox'
5 |
6 | // Export types
7 | export type { ButtonVariant, ButtonSize } from './Button'
8 | export type { ActionType } from './ActionButton'
9 |
--------------------------------------------------------------------------------
/src/components/common/LoadingIndicator.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react'
2 |
3 | export type LoadingType = 'spinner' | 'dots' | 'progress'
4 | export type LoadingSize = 'small' | 'medium' | 'large'
5 |
6 | interface LoadingIndicatorProps {
7 | size?: LoadingSize
8 | type?: LoadingType
9 | message?: string
10 | progress?: number
11 | className?: string
12 | }
13 |
14 | /**
15 | * Versatile loading indicator component
16 | * Supports multiple visual styles and sizes
17 | */
18 | const LoadingIndicator = ({
19 | size = 'medium',
20 | type = 'spinner',
21 | message,
22 | progress = 0,
23 | className = '',
24 | }: LoadingIndicatorProps) => {
25 | // Size class mapping
26 | const sizeClass = {
27 | small: 'loading-small',
28 | medium: 'loading-medium',
29 | large: 'loading-large',
30 | }[size]
31 |
32 | // Render loading indicator based on type
33 | const renderLoadingIndicator = (): ReactNode => {
34 | switch (type) {
35 | case 'spinner':
36 | return
37 |
38 | case 'dots':
39 | return (
40 |
45 | )
46 |
47 | case 'progress':
48 | return (
49 |
50 |
56 | {progress > 0 &&
{Math.round(progress)}%
}
57 |
58 | )
59 |
60 | default:
61 | return
62 | }
63 | }
64 |
65 | return (
66 |
67 | {renderLoadingIndicator()}
68 | {message &&
{message}
}
69 |
70 | )
71 | }
72 |
73 | export default LoadingIndicator
74 |
--------------------------------------------------------------------------------
/src/components/common/index.ts:
--------------------------------------------------------------------------------
1 | export { default as LoadingIndicator } from './LoadingIndicator'
2 |
3 | export type { LoadingSize, LoadingType } from './LoadingIndicator'
4 |
--------------------------------------------------------------------------------
/src/components/dialogs/Dialog.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode, useEffect, useState } from 'react'
2 |
3 | export interface DialogProps {
4 | visible: boolean
5 | onClose?: () => void
6 | className?: string
7 | preventBackdropClose?: boolean
8 | children: ReactNode
9 | size?: 'small' | 'medium' | 'large'
10 | showAnimationOnUnmount?: boolean
11 | }
12 |
13 | /**
14 | * Base Dialog component that serves as a container for dialog content
15 | * Used with DialogHeader, DialogBody, and DialogFooter components
16 | */
17 | const Dialog = ({
18 | visible,
19 | onClose,
20 | className = '',
21 | preventBackdropClose = false,
22 | children,
23 | size = 'medium',
24 | showAnimationOnUnmount = true,
25 | }: DialogProps) => {
26 | const [showContent, setShowContent] = useState(false)
27 | const [shouldRender, setShouldRender] = useState(visible)
28 |
29 | // Handle visibility changes with animations
30 | useEffect(() => {
31 | if (visible) {
32 | setShouldRender(true)
33 | // Small delay to trigger entrance animation after component is mounted
34 | const timer = setTimeout(() => {
35 | setShowContent(true)
36 | }, 50)
37 | return () => clearTimeout(timer)
38 | } else if (showAnimationOnUnmount) {
39 | // First hide content with animation
40 | setShowContent(false)
41 | // Then unmount after animation completes
42 | const timer = setTimeout(() => {
43 | setShouldRender(false)
44 | }, 300) // Match this with your CSS transition duration
45 | return () => clearTimeout(timer)
46 | } else {
47 | // Immediately unmount without animation
48 | setShowContent(false)
49 | setShouldRender(false)
50 | }
51 | }, [visible, showAnimationOnUnmount])
52 |
53 | const handleBackdropClick = (e: React.MouseEvent) => {
54 | if (e.target === e.currentTarget && !preventBackdropClose && onClose) {
55 | onClose()
56 | }
57 | }
58 |
59 | // Don't render anything if dialog shouldn't be shown
60 | if (!shouldRender) return null
61 |
62 | const sizeClass = {
63 | small: 'dialog-small',
64 | medium: 'dialog-medium',
65 | large: 'dialog-large',
66 | }[size]
67 |
68 | return (
69 |
70 |
71 | {children}
72 |
73 |
74 | )
75 | }
76 |
77 | export default Dialog
78 |
--------------------------------------------------------------------------------
/src/components/dialogs/DialogActions.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react'
2 |
3 | export interface DialogActionsProps {
4 | children: ReactNode
5 | className?: string
6 | align?: 'start' | 'center' | 'end'
7 | }
8 |
9 | /**
10 | * Actions container for dialog footers
11 | * Provides consistent spacing and alignment for action buttons
12 | */
13 | const DialogActions = ({ children, className = '', align = 'end' }: DialogActionsProps) => {
14 | const alignClass = {
15 | start: 'justify-start',
16 | center: 'justify-center',
17 | end: 'justify-end',
18 | }[align]
19 |
20 | return {children}
21 | }
22 |
23 | export default DialogActions
24 |
--------------------------------------------------------------------------------
/src/components/dialogs/DialogBody.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react'
2 |
3 | export interface DialogBodyProps {
4 | children: ReactNode
5 | className?: string
6 | }
7 |
8 | /**
9 | * Body component for dialogs
10 | * Contains the main content with scrolling capability
11 | */
12 | const DialogBody = ({ children, className = '' }: DialogBodyProps) => {
13 | return {children}
14 | }
15 |
16 | export default DialogBody
17 |
--------------------------------------------------------------------------------
/src/components/dialogs/DialogFooter.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react'
2 |
3 | export interface DialogFooterProps {
4 | children: ReactNode
5 | className?: string
6 | }
7 |
8 | /**
9 | * Footer component for dialogs
10 | * Contains action buttons and optional status information
11 | */
12 | const DialogFooter = ({ children, className = '' }: DialogFooterProps) => {
13 | return {children}
14 | }
15 |
16 | export default DialogFooter
17 |
--------------------------------------------------------------------------------
/src/components/dialogs/DialogHeader.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react'
2 |
3 | export interface DialogHeaderProps {
4 | children: ReactNode
5 | className?: string
6 | onClose?: () => void
7 | hideCloseButton?: boolean;
8 | }
9 |
10 | /**
11 | * Header component for dialogs
12 | * Contains the title and optional close button
13 | */
14 | const DialogHeader = ({ children, className = '', onClose, hideCloseButton = false }: DialogHeaderProps) => {
15 | return (
16 |
17 | {children}
18 | {onClose && !hideCloseButton && (
19 |
20 | ×
21 |
22 | )}
23 |
24 | )
25 | }
26 |
27 | export default DialogHeader
28 |
--------------------------------------------------------------------------------
/src/components/dialogs/ProgressDialog.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import Dialog from './Dialog'
3 | import DialogHeader from './DialogHeader'
4 | import DialogBody from './DialogBody'
5 | import DialogFooter from './DialogFooter'
6 | import DialogActions from './DialogActions'
7 | import { Button } from '@/components/buttons'
8 |
9 | export interface InstallationInstructions {
10 | type: string
11 | command: string
12 | game_title: string
13 | dlc_count?: number
14 | }
15 |
16 | export interface ProgressDialogProps {
17 | visible: boolean
18 | title: string
19 | message: string
20 | progress: number
21 | showInstructions?: boolean
22 | instructions?: InstallationInstructions
23 | onClose?: () => void
24 | }
25 |
26 | /**
27 | * ProgressDialog component
28 | * Shows installation progress with a progress bar and optional instructions
29 | */
30 | const ProgressDialog = ({
31 | visible,
32 | title,
33 | message,
34 | progress,
35 | showInstructions = false,
36 | instructions,
37 | onClose,
38 | }: ProgressDialogProps) => {
39 | const [copySuccess, setCopySuccess] = useState(false)
40 |
41 | const handleCopyCommand = () => {
42 | if (instructions?.command) {
43 | navigator.clipboard.writeText(instructions.command)
44 | setCopySuccess(true)
45 |
46 | // Reset the success message after 2 seconds
47 | setTimeout(() => {
48 | setCopySuccess(false)
49 | }, 2000)
50 | }
51 | }
52 |
53 | // Determine if we should show the copy button (for CreamLinux but not SmokeAPI)
54 | const showCopyButton =
55 | instructions?.type === 'cream_install' || instructions?.type === 'cream_uninstall'
56 |
57 | // Format instruction message based on type
58 | const getInstructionText = () => {
59 | if (!instructions) return null
60 |
61 | switch (instructions.type) {
62 | case 'cream_install':
63 | return (
64 | <>
65 |
66 | In Steam, set the following launch options for{' '}
67 | {instructions.game_title} :
68 |
69 | {instructions.dlc_count !== undefined && (
70 |
71 | {instructions.dlc_count} DLCs have been enabled!
72 |
73 | )}
74 | >
75 | )
76 | case 'cream_uninstall':
77 | return (
78 |
79 | For {instructions.game_title} , open Steam properties and remove the
80 | following launch option:
81 |
82 | )
83 | case 'smoke_install':
84 | return (
85 | <>
86 |
87 | SmokeAPI has been installed for {instructions.game_title}
88 |
89 | {instructions.dlc_count !== undefined && (
90 |
91 | {instructions.dlc_count} Steam API files have been patched.
92 |
93 | )}
94 | >
95 | )
96 | case 'smoke_uninstall':
97 | return (
98 |
99 | SmokeAPI has been uninstalled from {instructions.game_title}
100 |
101 | )
102 | default:
103 | return (
104 |
105 | Done processing {instructions.game_title}
106 |
107 | )
108 | }
109 | }
110 |
111 | // Determine the CSS class for the command box based on instruction type
112 | const getCommandBoxClass = () => {
113 | return instructions?.type.includes('smoke') ? 'command-box command-box-smoke' : 'command-box'
114 | }
115 |
116 | // Determine if close button should be enabled
117 | const isCloseButtonEnabled = showInstructions || progress >= 100
118 |
119 | return (
120 |
126 |
127 | {title}
128 |
129 |
130 |
131 | {message}
132 |
133 |
136 | {Math.round(progress)}%
137 |
138 | {showInstructions && instructions && (
139 |
140 |
141 | {instructions.type.includes('uninstall')
142 | ? 'Uninstallation Instructions'
143 | : 'Installation Instructions'}
144 |
145 | {getInstructionText()}
146 |
147 |
148 |
{instructions.command}
149 |
150 |
151 | )}
152 |
153 |
154 |
155 |
156 | {showInstructions && showCopyButton && (
157 |
158 | {copySuccess ? 'Copied!' : 'Copy to Clipboard'}
159 |
160 | )}
161 |
162 | {isCloseButtonEnabled && (
163 |
164 | Close
165 |
166 | )}
167 |
168 |
169 |
170 | )
171 | }
172 |
173 | export default ProgressDialog
174 |
--------------------------------------------------------------------------------
/src/components/dialogs/index.ts:
--------------------------------------------------------------------------------
1 | // Export all dialog components
2 | export { default as Dialog } from './Dialog'
3 | export { default as DialogHeader } from './DialogHeader'
4 | export { default as DialogBody } from './DialogBody'
5 | export { default as DialogFooter } from './DialogFooter'
6 | export { default as DialogActions } from './DialogActions'
7 | export { default as ProgressDialog } from './ProgressDialog'
8 | export { default as DlcSelectionDialog } from './DlcSelectionDialog'
9 |
10 | // Export types
11 | export type { DialogProps } from './Dialog'
12 | export type { DialogHeaderProps } from './DialogHeader'
13 | export type { DialogBodyProps } from './DialogBody'
14 | export type { DialogFooterProps } from './DialogFooter'
15 | export type { DialogActionsProps } from './DialogActions'
16 | export type { ProgressDialogProps, InstallationInstructions } from './ProgressDialog'
17 | export type { DlcSelectionDialogProps } from './DlcSelectionDialog'
18 |
--------------------------------------------------------------------------------
/src/components/games/GameItem.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 | import { findBestGameImage } from '@/services/ImageService'
3 | import { Game } from '@/types'
4 | import { ActionButton, ActionType, Button } from '@/components/buttons'
5 |
6 | interface GameItemProps {
7 | game: Game
8 | onAction: (gameId: string, action: ActionType) => Promise
9 | onEdit?: (gameId: string) => void
10 | }
11 |
12 | /**
13 | * Individual game card component
14 | * Displays game information and action buttons
15 | */
16 | const GameItem = ({ game, onAction, onEdit }: GameItemProps) => {
17 | const [imageUrl, setImageUrl] = useState(null)
18 | const [isLoading, setIsLoading] = useState(true)
19 | const [hasError, setHasError] = useState(false)
20 |
21 | useEffect(() => {
22 | // Function to fetch the game cover/image
23 | const fetchGameImage = async () => {
24 | // First check if we already have it (to prevent flickering on re-renders)
25 | if (imageUrl) return
26 |
27 | setIsLoading(true)
28 | try {
29 | // Try to find the best available image for this game
30 | const bestImageUrl = await findBestGameImage(game.id)
31 |
32 | if (bestImageUrl) {
33 | setImageUrl(bestImageUrl)
34 | setHasError(false)
35 | } else {
36 | setHasError(true)
37 | }
38 | } catch (error) {
39 | console.error('Error fetching game image:', error)
40 | setHasError(true)
41 | } finally {
42 | setIsLoading(false)
43 | }
44 | }
45 |
46 | if (game.id) {
47 | fetchGameImage()
48 | }
49 | }, [game.id, imageUrl])
50 |
51 | // Determine if we should show CreamLinux buttons (only for native games)
52 | const shouldShowCream = game.native === true
53 |
54 | // Determine if we should show SmokeAPI buttons (only for non-native games with API files)
55 | const shouldShowSmoke = !game.native && game.api_files && game.api_files.length > 0
56 |
57 | // Check if this is a Proton game without API files
58 | const isProtonNoApi = !game.native && (!game.api_files || game.api_files.length === 0)
59 |
60 | const handleCreamAction = () => {
61 | if (game.installing) return
62 | const action: ActionType = game.cream_installed ? 'uninstall_cream' : 'install_cream'
63 | onAction(game.id, action)
64 | }
65 |
66 | const handleSmokeAction = () => {
67 | if (game.installing) return
68 | const action: ActionType = game.smoke_installed ? 'uninstall_smoke' : 'install_smoke'
69 | onAction(game.id, action)
70 | }
71 |
72 | // Handle edit button click
73 | const handleEdit = () => {
74 | if (onEdit && game.cream_installed) {
75 | onEdit(game.id)
76 | }
77 | }
78 |
79 | // Determine background image
80 | const backgroundImage =
81 | !isLoading && imageUrl
82 | ? `url(${imageUrl})`
83 | : hasError
84 | ? 'linear-gradient(135deg, #232323, #1A1A1A)'
85 | : 'linear-gradient(135deg, #232323, #1A1A1A)'
86 |
87 | return (
88 |
96 |
97 |
98 |
99 | {game.native ? 'Native' : 'Proton'}
100 |
101 | {game.cream_installed && CreamLinux }
102 | {game.smoke_installed && SmokeAPI }
103 |
104 |
105 |
106 |
{game.title}
107 |
108 |
109 |
110 | {/* Show CreamLinux button only for native games */}
111 | {shouldShowCream && (
112 |
118 | )}
119 |
120 | {/* Show SmokeAPI button only for Proton/Windows games with API files */}
121 | {shouldShowSmoke && (
122 |
128 | )}
129 |
130 | {/* Show message for Proton games without API files */}
131 | {isProtonNoApi && (
132 |
133 | Steam API DLL not found
134 | onAction(game.id, 'install_smoke')}
138 | title="Attempt to scan again"
139 | >
140 | Rescan
141 |
142 |
143 | )}
144 |
145 | {/* Edit button - only enabled if CreamLinux is installed */}
146 | {game.cream_installed && (
147 |
155 | Manage DLCs
156 |
157 | )}
158 |
159 |
160 |
161 | )
162 | }
163 |
164 | export default GameItem
165 |
--------------------------------------------------------------------------------
/src/components/games/GameList.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useMemo } from 'react'
2 | import { GameItem, ImagePreloader } from '@/components/games'
3 | import { ActionType } from '@/components/buttons'
4 | import { Game } from '@/types'
5 | import LoadingIndicator from '../common/LoadingIndicator'
6 |
7 | interface GameListProps {
8 | games: Game[]
9 | isLoading: boolean
10 | onAction: (gameId: string, action: ActionType) => Promise
11 | onEdit?: (gameId: string) => void
12 | }
13 |
14 | /**
15 | * Main game list component
16 | * Displays games in a grid with search and filtering applied
17 | */
18 | const GameList = ({ games, isLoading, onAction, onEdit }: GameListProps) => {
19 | const [imagesPreloaded, setImagesPreloaded] = useState(false)
20 |
21 | // Sort games alphabetically by title
22 | const sortedGames = useMemo(() => {
23 | return [...games].sort((a, b) => a.title.localeCompare(b.title))
24 | }, [games])
25 |
26 | // Reset preloaded state when games change
27 | useEffect(() => {
28 | setImagesPreloaded(false)
29 | }, [games])
30 |
31 | const handlePreloadComplete = () => {
32 | setImagesPreloaded(true)
33 | }
34 |
35 | if (isLoading) {
36 | return (
37 |
38 |
39 |
40 | )
41 | }
42 |
43 | return (
44 |
45 |
Games ({games.length})
46 |
47 | {!imagesPreloaded && games.length > 0 && (
48 |
game.id)}
50 | onComplete={handlePreloadComplete}
51 | />
52 | )}
53 |
54 | {games.length === 0 ? (
55 | No games found
56 | ) : (
57 |
58 | {sortedGames.map((game) => (
59 |
60 | ))}
61 |
62 | )}
63 |
64 | )
65 | }
66 |
67 | export default GameList
68 |
--------------------------------------------------------------------------------
/src/components/games/ImagePreloader.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 | import { findBestGameImage } from '@/services/ImageService'
3 |
4 | interface ImagePreloaderProps {
5 | gameIds: string[]
6 | onComplete?: () => void
7 | }
8 |
9 | /**
10 | * Preloads game images to prevent flickering
11 | * Only used internally by GameList component
12 | */
13 | const ImagePreloader = ({ gameIds, onComplete }: ImagePreloaderProps) => {
14 | useEffect(() => {
15 | const preloadImages = async () => {
16 | try {
17 | // Only preload the first batch for performance (10 images max)
18 | const batchToPreload = gameIds.slice(0, 10)
19 |
20 | // Track loading progress
21 | let loadedCount = 0
22 | const totalImages = batchToPreload.length
23 |
24 | // Load images in parallel
25 | await Promise.allSettled(
26 | batchToPreload.map(async (id) => {
27 | await findBestGameImage(id)
28 | loadedCount++
29 |
30 | // If all images are loaded, call onComplete
31 | if (loadedCount === totalImages && onComplete) {
32 | onComplete()
33 | }
34 | })
35 | )
36 |
37 | // Fallback if Promise.allSettled doesn't trigger onComplete
38 | if (onComplete) {
39 | onComplete()
40 | }
41 | } catch (error) {
42 | console.error('Error preloading images:', error)
43 | // Continue even if there's an error
44 | if (onComplete) {
45 | onComplete()
46 | }
47 | }
48 | }
49 |
50 | if (gameIds.length > 0) {
51 | preloadImages()
52 | } else if (onComplete) {
53 | onComplete()
54 | }
55 | }, [gameIds, onComplete])
56 |
57 | // Invisible component that just handles preloading
58 | return null
59 | }
60 |
61 | export default ImagePreloader
62 |
--------------------------------------------------------------------------------
/src/components/games/index.ts:
--------------------------------------------------------------------------------
1 | // Export all game components
2 | export { default as GameList } from './GameList'
3 | export { default as GameItem } from './GameItem'
4 | export { default as ImagePreloader } from './ImagePreloader'
5 |
--------------------------------------------------------------------------------
/src/components/icons/Icon.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Icon component for displaying SVG icons with standardized properties
3 | */
4 | import React from 'react'
5 |
6 | // Import all icon variants
7 | import * as OutlineIcons from './ui/outline'
8 | import * as BoldIcons from './ui/bold'
9 | import * as BrandIcons from './brands'
10 |
11 | export type IconSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | number
12 | export type IconVariant = 'bold' | 'outline' | 'brand' | undefined
13 | export type IconName = keyof typeof OutlineIcons | keyof typeof BoldIcons | keyof typeof BrandIcons
14 |
15 | // Sets of icon names by type for determining default variants
16 | const BRAND_ICON_NAMES = new Set(Object.keys(BrandIcons))
17 | const OUTLINE_ICON_NAMES = new Set(Object.keys(OutlineIcons))
18 | const BOLD_ICON_NAMES = new Set(Object.keys(BoldIcons))
19 |
20 | export interface IconProps extends React.SVGProps {
21 | /** Name of the icon to render */
22 | name: IconName | string
23 | /** Size of the icon */
24 | size?: IconSize
25 | /** Icon variant - bold, outline, or brand */
26 | variant?: IconVariant | string
27 | /** Title for accessibility */
28 | title?: string
29 | /** Fill color (if not specified by the SVG itself) */
30 | fillColor?: string
31 | /** Stroke color (if not specified by the SVG itself) */
32 | strokeColor?: string
33 | /** Additional CSS class names */
34 | className?: string
35 | }
36 |
37 | /**
38 | * Convert size string to pixel value
39 | */
40 | const getSizeValue = (size: IconSize): string => {
41 | if (typeof size === 'number') return `${size}px`
42 |
43 | const sizeMap: Record = {
44 | xs: '12px',
45 | sm: '16px',
46 | md: '24px',
47 | lg: '32px',
48 | xl: '48px',
49 | }
50 |
51 | return sizeMap[size] || sizeMap.md
52 | }
53 |
54 | /**
55 | * Gets the icon component based on name and variant
56 | */
57 | const getIconComponent = (
58 | name: string,
59 | variant: IconVariant | string
60 | ): React.ComponentType> | null => {
61 | // Normalize variant to ensure it's a valid IconVariant
62 | const normalizedVariant =
63 | variant === 'bold' || variant === 'outline' || variant === 'brand'
64 | ? (variant as IconVariant)
65 | : undefined
66 |
67 | // Try to get the icon from the specified variant
68 | switch (normalizedVariant) {
69 | case 'outline':
70 | return OutlineIcons[name as keyof typeof OutlineIcons] || null
71 | case 'bold':
72 | return BoldIcons[name as keyof typeof BoldIcons] || null
73 | case 'brand':
74 | return BrandIcons[name as keyof typeof BrandIcons] || null
75 | default:
76 | // If no variant specified, determine best default
77 | if (BRAND_ICON_NAMES.has(name)) {
78 | return BrandIcons[name as keyof typeof BrandIcons] || null
79 | } else if (OUTLINE_ICON_NAMES.has(name)) {
80 | return OutlineIcons[name as keyof typeof OutlineIcons] || null
81 | } else if (BOLD_ICON_NAMES.has(name)) {
82 | return BoldIcons[name as keyof typeof BoldIcons] || null
83 | }
84 | return null
85 | }
86 | }
87 |
88 | /**
89 | * Icon component
90 | * Renders SVG icons with consistent sizing and styling
91 | */
92 | const Icon: React.FC = ({
93 | name,
94 | size = 'md',
95 | variant,
96 | title,
97 | fillColor,
98 | strokeColor,
99 | className = '',
100 | ...rest
101 | }) => {
102 | // Determine default variant based on icon type if no variant provided
103 | let defaultVariant: IconVariant | string = variant
104 |
105 | if (defaultVariant === undefined) {
106 | if (BRAND_ICON_NAMES.has(name)) {
107 | defaultVariant = 'brand'
108 | } else {
109 | defaultVariant = 'bold' // Default to bold for non-brand icons
110 | }
111 | }
112 |
113 | // Get the icon component based on name and variant
114 | let finalIconComponent = getIconComponent(name, defaultVariant)
115 | let finalVariant = defaultVariant
116 |
117 | // Try fallbacks if the icon doesn't exist in the requested variant
118 | if (!finalIconComponent && defaultVariant !== 'outline') {
119 | finalIconComponent = getIconComponent(name, 'outline')
120 | finalVariant = 'outline'
121 | }
122 |
123 | if (!finalIconComponent && defaultVariant !== 'bold') {
124 | finalIconComponent = getIconComponent(name, 'bold')
125 | finalVariant = 'bold'
126 | }
127 |
128 | if (!finalIconComponent && defaultVariant !== 'brand') {
129 | finalIconComponent = getIconComponent(name, 'brand')
130 | finalVariant = 'brand'
131 | }
132 |
133 | // If still no icon found, return null
134 | if (!finalIconComponent) {
135 | console.warn(`Icon not found: ${name} (${defaultVariant})`)
136 | return null
137 | }
138 |
139 | const sizeValue = getSizeValue(size)
140 | const combinedClassName = `icon icon-${size} icon-${finalVariant} ${className}`.trim()
141 |
142 | const IconComponentToRender = finalIconComponent
143 |
144 | return (
145 |
156 | )
157 | }
158 |
159 | export default Icon
160 |
--------------------------------------------------------------------------------
/src/components/icons/IconFactory.ts:
--------------------------------------------------------------------------------
1 | // BROKEN
2 |
3 | //import React from 'react'
4 | //import Icon from './Icon'
5 | //import type { IconProps, IconVariant } from './Icon'
6 | //
7 | //export const createIconComponent = (
8 | // name: string,
9 | // defaultVariant: IconVariant = 'outline'
10 | //): React.FC> => {
11 | // const IconComponent: React.FC> = (props) => {
12 | // return (
13 | //
18 | // )
19 | // }
20 | //
21 | // IconComponent.displayName = `${name}Icon`
22 | // return IconComponent
23 | //}
24 | //
25 |
--------------------------------------------------------------------------------
/src/components/icons/brands/discord.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/brands/github.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/brands/index.ts:
--------------------------------------------------------------------------------
1 | // Bold variant icons
2 | export { ReactComponent as Linux } from './linux.svg'
3 | export { ReactComponent as Steam } from './steam.svg'
4 | export { ReactComponent as Windows } from './windows.svg'
5 | export { ReactComponent as Github } from './github.svg'
6 | export { ReactComponent as Discord } from './discord.svg'
7 | export { ReactComponent as Proton } from './proton.svg'
8 |
--------------------------------------------------------------------------------
/src/components/icons/brands/linux.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/brands/steam.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/brands/windows.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/index.ts:
--------------------------------------------------------------------------------
1 | // import { createIconComponent } from './IconFactory' <-- Broken atm
2 | export { default as Icon } from './Icon'
3 | export type { IconProps, IconSize, IconVariant, IconName } from './Icon'
4 |
5 | // Re-export all icons by category for convenience
6 | import * as OutlineIcons from './ui/outline'
7 | import * as BoldIcons from './ui/bold'
8 | import * as BrandIcons from './brands'
9 |
10 | export { OutlineIcons, BoldIcons, BrandIcons }
11 |
12 | // Export individual icon names as constants
13 | // UI icons
14 | export const arrowUp = 'ArrowUp'
15 | export const check = 'Check'
16 | export const close = 'Close'
17 | export const controller = 'Controller'
18 | export const copy = 'Copy'
19 | export const download = 'Download'
20 | export const download1 = 'Download1'
21 | export const edit = 'Edit'
22 | export const error = 'Error'
23 | export const info = 'Info'
24 | export const layers = 'Layers'
25 | export const refresh = 'Refresh'
26 | export const search = 'Search'
27 | export const trash = 'Trash'
28 | export const warning = 'Warning'
29 | export const wine = 'Wine'
30 | export const diamond = 'Diamond'
31 |
32 | // Brand icons
33 | export const discord = 'Discord'
34 | export const github = 'GitHub'
35 | export const linux = 'Linux'
36 | export const proton = 'Proton'
37 | export const steam = 'Steam'
38 | export const windows = 'Windows'
39 |
40 | // Keep the IconNames object for backward compatibility and autocompletion
41 | export const IconNames = {
42 | // UI icons
43 | ArrowUp: arrowUp,
44 | Check: check,
45 | Close: close,
46 | Controller: controller,
47 | Copy: copy,
48 | Download: download,
49 | Download1: download1,
50 | Edit: edit,
51 | Error: error,
52 | Info: info,
53 | Layers: layers,
54 | Refresh: refresh,
55 | Search: search,
56 | Trash: trash,
57 | Warning: warning,
58 | Wine: wine,
59 | Diamond: diamond,
60 |
61 | // Brand icons
62 | Discord: discord,
63 | GitHub: github,
64 | Linux: linux,
65 | Proton: proton,
66 | Steam: steam,
67 | Windows: windows,
68 | } as const
69 |
70 | // Export direct icon components using createIconComponent from IconFactory
71 | // UI icons (outline variant by default)
72 | //export const ArrowUpIcon = createIconComponent(arrowUp, 'outline')
73 | //export const CheckIcon = createIconComponent(check, 'outline')
74 | //export const CloseIcon = createIconComponent(close, 'outline')
75 | //export const ControllerIcon = createIconComponent(controller, 'outline')
76 | //export const CopyIcon = createIconComponent(copy, 'outline')
77 | //export const DownloadIcon = createIconComponent(download, 'outline')
78 | //export const Download1Icon = createIconComponent(download1, 'outline')
79 | //export const EditIcon = createIconComponent(edit, 'outline')
80 | //export const ErrorIcon = createIconComponent(error, 'outline')
81 | //export const InfoIcon = createIconComponent(info, 'outline')
82 | //export const LayersIcon = createIconComponent(layers, 'outline')
83 | //export const RefreshIcon = createIconComponent(refresh, 'outline')
84 | //export const SearchIcon = createIconComponent(search, 'outline')
85 | //export const TrashIcon = createIconComponent(trash, 'outline')
86 | //export const WarningIcon = createIconComponent(warning, 'outline')
87 | //export const WineIcon = createIconComponent(wine, 'outline')
88 |
89 | // Brand icons
90 | //export const DiscordIcon = createIconComponent(discord, 'brand')
91 | //export const GitHubIcon = createIconComponent(github, 'brand')
92 | //export const LinuxIcon = createIconComponent(linux, 'brand')
93 | //export const SteamIcon = createIconComponent(steam, 'brand')
94 | //export const WindowsIcon = createIconComponent(windows, 'brand')
95 |
96 | // Bold variants for common icons
97 | //export const CheckBoldIcon = createIconComponent(check, 'bold')
98 | //export const InfoBoldIcon = createIconComponent(info, 'bold')
99 | //export const WarningBoldIcon = createIconComponent(warning, 'bold')
100 | //export const ErrorBoldIcon = createIconComponent(error, 'bold')
101 |
--------------------------------------------------------------------------------
/src/components/icons/ui/bold/arrow-up.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/bold/check.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/bold/close.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/bold/controller.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/bold/copy.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/bold/diamond.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/bold/download.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/bold/download1.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/bold/edit.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/bold/error.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/bold/index.ts:
--------------------------------------------------------------------------------
1 | // Bold variant icons
2 | export { ReactComponent as ArrowUp } from './arrow-up.svg'
3 | export { ReactComponent as Check } from './check.svg'
4 | export { ReactComponent as Close } from './close.svg'
5 | export { ReactComponent as Controller } from './controller.svg'
6 | export { ReactComponent as Copy } from './copy.svg'
7 | export { ReactComponent as Download } from './download.svg'
8 | export { ReactComponent as Download1 } from './download1.svg'
9 | export { ReactComponent as Edit } from './edit.svg'
10 | export { ReactComponent as Error } from './error.svg'
11 | export { ReactComponent as Info } from './info.svg'
12 | export { ReactComponent as Layers } from './layers.svg'
13 | export { ReactComponent as Refresh } from './refresh.svg'
14 | export { ReactComponent as Search } from './search.svg'
15 | export { ReactComponent as Trash } from './trash.svg'
16 | export { ReactComponent as Warning } from './warning.svg'
17 | export { ReactComponent as Wine } from './wine.svg'
18 | export { ReactComponent as Diamond } from './diamond.svg'
19 |
--------------------------------------------------------------------------------
/src/components/icons/ui/bold/info.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/bold/layers.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/bold/refresh.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/bold/search.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/bold/trash.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/bold/warning.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/bold/wine.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/outline/arrow-up.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/outline/check.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/outline/close.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/outline/controller.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/outline/copy.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/outline/diamond.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/outline/download.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/outline/download1.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/outline/edit.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/outline/error.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/outline/index.ts:
--------------------------------------------------------------------------------
1 | // Outline variant icons
2 | export { ReactComponent as ArrowUp } from './arrow-up.svg'
3 | export { ReactComponent as Check } from './check.svg'
4 | export { ReactComponent as Close } from './close.svg'
5 | export { ReactComponent as Controller } from './controller.svg'
6 | export { ReactComponent as Copy } from './copy.svg'
7 | export { ReactComponent as Download } from './download.svg'
8 | export { ReactComponent as Download1 } from './download1.svg'
9 | export { ReactComponent as Edit } from './edit.svg'
10 | export { ReactComponent as Error } from './error.svg'
11 | export { ReactComponent as Info } from './info.svg'
12 | export { ReactComponent as Layers } from './layers.svg'
13 | export { ReactComponent as Refresh } from './refresh.svg'
14 | export { ReactComponent as Search } from './search.svg'
15 | export { ReactComponent as Trash } from './trash.svg'
16 | export { ReactComponent as Warning } from './warning.svg'
17 | export { ReactComponent as Wine } from './wine.svg'
18 | export { ReactComponent as Diamond } from './diamond.svg'
19 |
--------------------------------------------------------------------------------
/src/components/icons/ui/outline/info.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/outline/layers.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/outline/refresh.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/outline/search.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/outline/trash.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/outline/warning.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/icons/ui/outline/wine.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/layout/AnimatedBackground.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react'
2 |
3 | /**
4 | * Animated background component that draws an interactive particle effect
5 | */
6 | const AnimatedBackground = () => {
7 | const canvasRef = useRef(null)
8 |
9 | useEffect(() => {
10 | const canvas = canvasRef.current
11 | if (!canvas) return
12 |
13 | const ctx = canvas.getContext('2d')
14 | if (!ctx) return
15 |
16 | // Set canvas size to match window
17 | const setCanvasSize = () => {
18 | canvas.width = window.innerWidth
19 | canvas.height = window.innerHeight
20 | }
21 |
22 | setCanvasSize()
23 | window.addEventListener('resize', setCanvasSize)
24 |
25 | // Create particles
26 | const particles: Particle[] = []
27 | const particleCount = 30
28 |
29 | interface Particle {
30 | x: number
31 | y: number
32 | size: number
33 | speedX: number
34 | speedY: number
35 | opacity: number
36 | color: string
37 | }
38 |
39 | // Color palette matching theme
40 | const colors = [
41 | 'rgba(74, 118, 196, 0.5)', // primary blue
42 | 'rgba(155, 125, 255, 0.5)', // purple
43 | 'rgba(251, 177, 60, 0.5)', // gold
44 | ]
45 |
46 | // Create initial particles
47 | for (let i = 0; i < particleCount; i++) {
48 | particles.push({
49 | x: Math.random() * canvas.width,
50 | y: Math.random() * canvas.height,
51 | size: Math.random() * 3 + 1,
52 | speedX: Math.random() * 0.2 - 0.1,
53 | speedY: Math.random() * 0.2 - 0.1,
54 | opacity: Math.random() * 0.07 + 0.03,
55 | color: colors[Math.floor(Math.random() * colors.length)],
56 | })
57 | }
58 |
59 | // Animation loop
60 | const animate = () => {
61 | // Clear canvas with transparent black to create fade effect
62 | ctx.fillStyle = 'rgba(15, 15, 15, 0.1)'
63 | ctx.fillRect(0, 0, canvas.width, canvas.height)
64 |
65 | // Update and draw particles
66 | particles.forEach((particle) => {
67 | // Update position
68 | particle.x += particle.speedX
69 | particle.y += particle.speedY
70 |
71 | // Wrap around edges
72 | if (particle.x < 0) particle.x = canvas.width
73 | if (particle.x > canvas.width) particle.x = 0
74 | if (particle.y < 0) particle.y = canvas.height
75 | if (particle.y > canvas.height) particle.y = 0
76 |
77 | // Draw particle
78 | ctx.beginPath()
79 | ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2)
80 | ctx.fillStyle = particle.color.replace('0.5', `${particle.opacity}`)
81 | ctx.fill()
82 |
83 | // Connect particles that are close to each other
84 | particles.forEach((otherParticle) => {
85 | const dx = particle.x - otherParticle.x
86 | const dy = particle.y - otherParticle.y
87 | const distance = Math.sqrt(dx * dx + dy * dy)
88 |
89 | if (distance < 100) {
90 | ctx.beginPath()
91 | ctx.strokeStyle = particle.color.replace('0.5', `${particle.opacity * 0.5}`)
92 | ctx.lineWidth = 0.2
93 | ctx.moveTo(particle.x, particle.y)
94 | ctx.lineTo(otherParticle.x, otherParticle.y)
95 | ctx.stroke()
96 | }
97 | })
98 | })
99 |
100 | requestAnimationFrame(animate)
101 | }
102 |
103 | // Start animation
104 | animate()
105 |
106 | // Cleanup
107 | return () => {
108 | window.removeEventListener('resize', setCanvasSize)
109 | }
110 | }, [])
111 |
112 | return
113 | }
114 |
115 | export default AnimatedBackground
116 |
--------------------------------------------------------------------------------
/src/components/layout/ErrorBoundary.tsx:
--------------------------------------------------------------------------------
1 | import { Component, ErrorInfo, ReactNode } from 'react'
2 | import { Button } from '@/components/buttons'
3 |
4 | interface ErrorBoundaryProps {
5 | children: ReactNode
6 | fallback?: ReactNode
7 | onError?: (error: Error, errorInfo: ErrorInfo) => void
8 | }
9 |
10 | interface ErrorBoundaryState {
11 | hasError: boolean
12 | error: Error | null
13 | }
14 |
15 | /**
16 | * Error boundary component to catch and display runtime errors
17 | */
18 | class ErrorBoundary extends Component {
19 | constructor(props: ErrorBoundaryProps) {
20 | super(props)
21 | this.state = {
22 | hasError: false,
23 | error: null,
24 | }
25 | }
26 |
27 | static getDerivedStateFromError(error: Error): ErrorBoundaryState {
28 | // Update state so the next render will show the fallback UI
29 | return {
30 | hasError: true,
31 | error,
32 | }
33 | }
34 |
35 | componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
36 | // Log the error
37 | console.error('ErrorBoundary caught an error:', error, errorInfo)
38 |
39 | // Call the onError callback if provided
40 | if (this.props.onError) {
41 | this.props.onError(error, errorInfo)
42 | }
43 | }
44 |
45 | handleReset = () => {
46 | this.setState({ hasError: false, error: null })
47 | }
48 |
49 | render(): ReactNode {
50 | if (this.state.hasError) {
51 | // Use custom fallback if provided
52 | if (this.props.fallback) {
53 | return this.props.fallback
54 | }
55 |
56 | // Default error UI
57 | return (
58 |
59 |
Something went wrong
60 |
61 |
62 | Error details
63 | {this.state.error?.toString()}
64 |
65 |
66 |
67 | Try again
68 |
69 |
70 | )
71 | }
72 |
73 | return this.props.children
74 | }
75 | }
76 |
77 | export default ErrorBoundary
78 |
--------------------------------------------------------------------------------
/src/components/layout/Header.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/buttons'
2 | import { Icon, diamond, refresh, search } from '@/components/icons'
3 |
4 | interface HeaderProps {
5 | onRefresh: () => void
6 | refreshDisabled?: boolean
7 | onSearch: (query: string) => void
8 | searchQuery: string
9 | }
10 |
11 | /**
12 | * Application header component
13 | * Contains the app title, search input, and refresh button
14 | */
15 | const Header = ({ onRefresh, refreshDisabled = false, onSearch, searchQuery }: HeaderProps) => {
16 | return (
17 |
44 | )
45 | }
46 |
47 | export default Header
48 |
--------------------------------------------------------------------------------
/src/components/layout/InitialLoadingScreen.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | interface InitialLoadingScreenProps {
4 | message: string
5 | progress: number
6 | onComplete?: () => void
7 | }
8 |
9 | /**
10 | * Initial loading screen displayed when the app first loads
11 | */
12 | const InitialLoadingScreen = ({ message, progress }: InitialLoadingScreenProps) => {
13 | const [detailedStatus, setDetailedStatus] = useState([
14 | 'Initializing application...',
15 | 'Setting up Steam integration...',
16 | 'Preparing DLC management...',
17 | ])
18 |
19 | // Use a sequence of messages based on progress
20 | useEffect(() => {
21 | const messages = [
22 | { threshold: 10, message: 'Checking system requirements...' },
23 | { threshold: 30, message: 'Scanning Steam libraries...' },
24 | { threshold: 50, message: 'Discovering games...' },
25 | { threshold: 70, message: 'Analyzing game configurations...' },
26 | { threshold: 90, message: 'Preparing user interface...' },
27 | { threshold: 100, message: 'Ready to launch!' },
28 | ]
29 |
30 | // Find current status message based on progress
31 | const currentMessage = messages.find((m) => progress <= m.threshold)?.message || 'Loading...'
32 |
33 | // Add new messages to the log as progress increases
34 | if (currentMessage && !detailedStatus.includes(currentMessage)) {
35 | setDetailedStatus((prev) => [...prev, currentMessage])
36 | }
37 | }, [progress, detailedStatus])
38 |
39 | return (
40 |
41 |
42 |
CreamLinux
43 |
44 |
45 | {/* Enhanced animation with SVG or more elaborate CSS animation */}
46 |
51 |
52 |
53 |
{message}
54 |
55 | {/* Add a detailed status log that shows progress steps */}
56 |
57 | {detailedStatus.slice(-4).map((status, index) => (
58 |
59 | ○
60 | {status}
61 |
62 | ))}
63 |
64 |
65 |
68 |
69 |
{Math.round(progress)}%
70 |
71 |
72 | )
73 | }
74 |
75 | export default InitialLoadingScreen
76 |
--------------------------------------------------------------------------------
/src/components/layout/Sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { Icon, layers, linux, proton } from '@/components/icons'
2 |
3 | interface SidebarProps {
4 | setFilter: (filter: string) => void
5 | currentFilter: string
6 | }
7 |
8 | // Define a type for filter items that makes variant optional
9 | type FilterItem = {
10 | id: string
11 | label: string
12 | icon: string
13 | variant?: string
14 | }
15 |
16 | /**
17 | * Application sidebar component
18 | * Contains filters for game types
19 | */
20 | const Sidebar = ({ setFilter, currentFilter }: SidebarProps) => {
21 | // Available filter options with icons
22 | const filters: FilterItem[] = [
23 | { id: 'all', label: 'All Games', icon: layers, variant: 'bold' },
24 | { id: 'native', label: 'Native', icon: linux, variant: 'brand' },
25 | { id: 'proton', label: 'Proton Required', icon: proton, variant: 'brand' },
26 | ]
27 |
28 | return (
29 |
30 |
31 |
Library
32 |
33 |
34 |
48 |
49 | )
50 | }
51 |
52 | export default Sidebar
53 |
--------------------------------------------------------------------------------
/src/components/layout/index.ts:
--------------------------------------------------------------------------------
1 | // Export all layout components
2 | export { default as Header } from './Header'
3 | export { default as Sidebar } from './Sidebar'
4 | export { default as AnimatedBackground } from './AnimatedBackground'
5 | export { default as InitialLoadingScreen } from './InitialLoadingScreen'
6 | export { default as ErrorBoundary } from './ErrorBoundary'
7 |
--------------------------------------------------------------------------------
/src/components/notifications/Toast.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode, useState, useEffect, useCallback } from 'react'
2 | import { Icon, check, info, warning, error } from '@/components/icons'
3 |
4 | export interface ToastProps {
5 | id: string
6 | type: 'success' | 'error' | 'warning' | 'info'
7 | title?: string
8 | message: string
9 | duration?: number
10 | onDismiss: (id: string) => void
11 | }
12 |
13 | /**
14 | * Individual Toast component
15 | * Displays a notification message with automatic dismissal
16 | */
17 | const Toast = ({
18 | id,
19 | type,
20 | title,
21 | message,
22 | duration = 5000, // default 5 seconds
23 | onDismiss,
24 | }: ToastProps) => {
25 | const [visible, setVisible] = useState(false)
26 | const [isClosing, setIsClosing] = useState(false)
27 |
28 | // Use useCallback to memoize the handleDismiss function
29 | const handleDismiss = useCallback(() => {
30 | setIsClosing(true)
31 | // Give time for exit animation
32 | setTimeout(() => {
33 | setVisible(false)
34 | setTimeout(() => onDismiss(id), 50)
35 | }, 300)
36 | }, [id, onDismiss])
37 |
38 | // Handle animation on mount/unmount
39 | useEffect(() => {
40 | // Start the enter animation
41 | const enterTimer = setTimeout(() => {
42 | setVisible(true)
43 | }, 10)
44 |
45 | // Auto-dismiss after duration, if not Infinity
46 | let dismissTimer: NodeJS.Timeout | null = null
47 | if (duration !== Infinity) {
48 | dismissTimer = setTimeout(() => {
49 | handleDismiss()
50 | }, duration)
51 | }
52 |
53 | return () => {
54 | clearTimeout(enterTimer)
55 | if (dismissTimer) clearTimeout(dismissTimer)
56 | }
57 | }, [duration, handleDismiss])
58 |
59 | // Get icon based on toast type
60 | const getIcon = (): ReactNode => {
61 | switch (type) {
62 | case 'success':
63 | return
64 | case 'error':
65 | return
66 | case 'warning':
67 | return
68 | case 'info':
69 | return
70 | default:
71 | return
72 | }
73 | }
74 |
75 | return (
76 |
79 |
{getIcon()}
80 |
81 | {title &&
{title} }
82 |
{message}
83 |
84 |
85 | ×
86 |
87 |
88 | )
89 | }
90 |
91 | export default Toast
92 |
--------------------------------------------------------------------------------
/src/components/notifications/ToastContainer.tsx:
--------------------------------------------------------------------------------
1 | import Toast, { ToastProps } from './Toast'
2 |
3 | export type ToastPosition =
4 | | 'top-right'
5 | | 'top-left'
6 | | 'bottom-right'
7 | | 'bottom-left'
8 | | 'top-center'
9 | | 'bottom-center'
10 |
11 | interface ToastContainerProps {
12 | toasts: Omit[]
13 | onDismiss: (id: string) => void
14 | position?: ToastPosition
15 | }
16 |
17 | /**
18 | * Container for toast notifications
19 | * Manages positioning and rendering of all toast notifications
20 | */
21 | const ToastContainer = ({ toasts, onDismiss, position = 'bottom-right' }: ToastContainerProps) => {
22 | if (toasts.length === 0) {
23 | return null
24 | }
25 |
26 | return (
27 |
28 | {toasts.map((toast) => (
29 |
38 | ))}
39 |
40 | )
41 | }
42 |
43 | export default ToastContainer
44 |
--------------------------------------------------------------------------------
/src/components/notifications/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Toast } from './Toast'
2 | export { default as ToastContainer } from './ToastContainer'
3 |
4 | export type { ToastProps } from './Toast'
5 | export type { ToastPosition } from './ToastContainer'
6 |
--------------------------------------------------------------------------------
/src/components/updater/UpdateNotifier.tsx:
--------------------------------------------------------------------------------
1 | import { useUpdateChecker } from '@/hooks/useUpdateChecker'
2 |
3 | /**
4 | * Simple component that uses the update checker hook
5 | * Can be dropped in anywhere in the app
6 | */
7 | const UpdateNotifier = () => {
8 | useUpdateChecker()
9 |
10 | // This component doesn't render anything
11 | return null
12 | }
13 |
14 | export default UpdateNotifier
--------------------------------------------------------------------------------
/src/components/updater/index.ts:
--------------------------------------------------------------------------------
1 | // Update checker implementation
2 | export { default as useUpdateChecker } from '@/hooks/useUpdateChecker'
3 |
4 | // Simple component for using the checker
5 | export { default as UpdateNotifier } from './UpdateNotifier'
--------------------------------------------------------------------------------
/src/contexts/AppContext.tsx:
--------------------------------------------------------------------------------
1 | import { createContext } from 'react'
2 | import { Game, DlcInfo } from '@/types'
3 | import { ActionType } from '@/components/buttons/ActionButton'
4 |
5 | // Types for context sub-components
6 | export interface InstallationInstructions {
7 | type: string
8 | command: string
9 | game_title: string
10 | dlc_count?: number
11 | }
12 |
13 | export interface DlcDialogState {
14 | visible: boolean
15 | gameId: string
16 | gameTitle: string
17 | dlcs: DlcInfo[]
18 | isLoading: boolean
19 | isEditMode: boolean
20 | progress: number
21 | timeLeft?: string
22 | }
23 |
24 | export interface ProgressDialogState {
25 | visible: boolean
26 | title: string
27 | message: string
28 | progress: number
29 | showInstructions: boolean
30 | instructions?: InstallationInstructions
31 | }
32 |
33 | // Define the context type
34 | export interface AppContextType {
35 | // Game state
36 | games: Game[]
37 | isLoading: boolean
38 | error: string | null
39 | loadGames: () => Promise
40 | handleProgressDialogClose: () => void
41 |
42 | // DLC management
43 | dlcDialog: DlcDialogState
44 | handleGameEdit: (gameId: string) => void
45 | handleDlcDialogClose: () => void
46 |
47 | // Game actions
48 | progressDialog: ProgressDialogState
49 | handleGameAction: (gameId: string, action: ActionType) => Promise
50 | handleDlcConfirm: (selectedDlcs: DlcInfo[]) => void
51 |
52 | // Toast notifications
53 | showToast: (
54 | message: string,
55 | type: 'success' | 'error' | 'warning' | 'info',
56 | options?: Record
57 | ) => void
58 | }
59 |
60 | // Create the context with a default value
61 | export const AppContext = createContext(undefined)
62 |
--------------------------------------------------------------------------------
/src/contexts/index.ts:
--------------------------------------------------------------------------------
1 | export * from './AppContext'
2 | export * from './AppProvider'
3 | export * from './useAppContext'
4 |
--------------------------------------------------------------------------------
/src/contexts/useAppContext.ts:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react'
2 | import { AppContext, AppContextType } from './AppContext'
3 |
4 | /**
5 | * Custom hook to use the application context
6 | * Ensures proper error handling if used outside of AppProvider
7 | */
8 | export const useAppContext = (): AppContextType => {
9 | const context = useContext(AppContext)
10 |
11 | if (context === undefined) {
12 | throw new Error('useAppContext must be used within an AppProvider')
13 | }
14 |
15 | return context
16 | }
17 |
--------------------------------------------------------------------------------
/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | // Export all hooks
2 | export { useGames } from './useGames'
3 | export { useDlcManager } from './useDlcManager'
4 | export { useGameActions } from './useGameActions'
5 | export { useToasts } from './useToasts'
6 | export { useAppLogic } from './useAppLogic'
7 |
8 | // Export types
9 | export type { ToastType, Toast, ToastOptions } from './useToasts'
10 | export type { DlcDialogState } from './useDlcManager'
11 |
--------------------------------------------------------------------------------
/src/hooks/useAppLogic.ts:
--------------------------------------------------------------------------------
1 | import { useState, useCallback, useEffect, useRef } from 'react'
2 | import { useAppContext } from '@/contexts/useAppContext'
3 |
4 | interface UseAppLogicOptions {
5 | autoLoad?: boolean
6 | }
7 |
8 | /**
9 | * Main application logic hook
10 | * Combines various aspects of the app's behavior
11 | */
12 | export function useAppLogic(options: UseAppLogicOptions = {}) {
13 | const { autoLoad = true } = options
14 |
15 | // Get values from app context
16 | const { games, loadGames, isLoading, error, showToast } = useAppContext()
17 |
18 | // Local state for filtering and UI
19 | const [filter, setFilter] = useState('all')
20 | const [searchQuery, setSearchQuery] = useState('')
21 | const [isInitialLoad, setIsInitialLoad] = useState(true)
22 | const isInitializedRef = useRef(false)
23 | const [scanProgress, setScanProgress] = useState({
24 | message: 'Initializing...',
25 | progress: 0,
26 | })
27 |
28 | // Filter games based on current filter and search
29 | const filteredGames = useCallback(() => {
30 | return games.filter((game) => {
31 | // First filter by platform type
32 | const platformMatch =
33 | filter === 'all' ||
34 | (filter === 'native' && game.native) ||
35 | (filter === 'proton' && !game.native)
36 |
37 | // Then filter by search query
38 | const searchMatch =
39 | !searchQuery.trim() || game.title.toLowerCase().includes(searchQuery.toLowerCase())
40 |
41 | return platformMatch && searchMatch
42 | })
43 | }, [games, filter, searchQuery])
44 |
45 | // Handle search changes
46 | const handleSearchChange = useCallback((query: string) => {
47 | setSearchQuery(query)
48 | }, [])
49 |
50 | // Handle initial loading with simulated progress
51 | useEffect(() => {
52 | if (!autoLoad || !isInitialLoad || isInitializedRef.current) return
53 |
54 | isInitializedRef.current = true
55 | console.log('[APP LOGIC #2] Starting initialization')
56 |
57 | const initialLoad = async () => {
58 | try {
59 | setScanProgress({ message: 'Scanning for games...', progress: 20 })
60 | await new Promise((resolve) => setTimeout(resolve, 800))
61 |
62 | setScanProgress({ message: 'Loading game information...', progress: 50 })
63 | await loadGames()
64 |
65 | setScanProgress({ message: 'Finishing up...', progress: 90 })
66 | await new Promise((resolve) => setTimeout(resolve, 500))
67 |
68 | setScanProgress({ message: 'Ready!', progress: 100 })
69 | setTimeout(() => setIsInitialLoad(false), 500)
70 | } catch (error) {
71 | setScanProgress({ message: `Error: ${error}`, progress: 100 })
72 | showToast(`Failed to load: ${error}`, 'error')
73 | setTimeout(() => setIsInitialLoad(false), 2000)
74 | }
75 | }
76 |
77 | initialLoad()
78 | }, [autoLoad, isInitialLoad, loadGames, showToast])
79 |
80 | // Force a refresh
81 | const handleRefresh = useCallback(async () => {
82 | try {
83 | await loadGames()
84 | showToast('Game list refreshed', 'success')
85 | } catch (error) {
86 | showToast(`Failed to refresh: ${error}`, 'error')
87 | }
88 | }, [loadGames, showToast])
89 |
90 | return {
91 | filter,
92 | setFilter,
93 | searchQuery,
94 | handleSearchChange,
95 | isInitialLoad,
96 | setIsInitialLoad,
97 | scanProgress,
98 | filteredGames: filteredGames(),
99 | handleRefresh,
100 | isLoading,
101 | error,
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/src/hooks/useGames.ts:
--------------------------------------------------------------------------------
1 | import { useState, useCallback, useEffect } from 'react'
2 | import { Game } from '@/types'
3 | import { invoke } from '@tauri-apps/api/core'
4 | import { listen } from '@tauri-apps/api/event'
5 |
6 | /**
7 | * Hook for managing games state and operations
8 | * Handles game loading, scanning, and updates
9 | */
10 | export function useGames() {
11 | const [games, setGames] = useState([])
12 | const [isLoading, setIsLoading] = useState(true)
13 | const [isInitialLoad, setIsInitialLoad] = useState(true)
14 | const [scanProgress, setScanProgress] = useState({
15 | message: 'Initializing...',
16 | progress: 0,
17 | })
18 | const [error, setError] = useState(null)
19 |
20 | // LoadGames function outside of the useEffect to make it reusable
21 | const loadGames = useCallback(async () => {
22 | try {
23 | setIsLoading(true)
24 | setError(null)
25 |
26 | console.log('Invoking scan_steam_games')
27 | const steamGames = await invoke('scan_steam_games')
28 |
29 | // Add platform property to match GameList component's expectation
30 | const gamesWithPlatform = steamGames.map((game) => ({
31 | ...game,
32 | platform: 'Steam',
33 | }))
34 |
35 | console.log(`Loaded ${gamesWithPlatform.length} games`)
36 | setGames(gamesWithPlatform)
37 | setIsInitialLoad(false) // Mark initial load as complete
38 | return true
39 | } catch (error) {
40 | console.error('Error loading games:', error)
41 | setError(`Failed to load games: ${error}`)
42 | setIsInitialLoad(false) // Mark initial load as complete even on error
43 | return false
44 | } finally {
45 | setIsLoading(false)
46 | }
47 | }, [])
48 |
49 | // Setup event listeners for game updates
50 | useEffect(() => {
51 | let unlisteners: (() => void)[] = []
52 |
53 | // Set up event listeners
54 | const setupEventListeners = async () => {
55 | try {
56 | console.log('Setting up game event listeners')
57 |
58 | // Listen for individual game updates
59 | const unlistenGameUpdated = await listen('game-updated', (event) => {
60 | console.log('Received game-updated event:', event)
61 |
62 | const updatedGame = event.payload
63 |
64 | // Update only the specific game in the state
65 | setGames((prevGames) =>
66 | prevGames.map((game) =>
67 | game.id === updatedGame.id ? { ...updatedGame, platform: 'Steam' } : game
68 | )
69 | )
70 | })
71 |
72 | // Listen for scan progress events
73 | const unlistenScanProgress = await listen<{
74 | message: string
75 | progress: number
76 | }>('scan-progress', (event) => {
77 | const { message, progress } = event.payload
78 |
79 | console.log('Received scan-progress event:', message, progress)
80 |
81 | // Update scan progress state
82 | setScanProgress({
83 | message,
84 | progress,
85 | })
86 | })
87 |
88 | unlisteners = [unlistenGameUpdated, unlistenScanProgress]
89 | } catch (error) {
90 | console.error('Error setting up event listeners:', error)
91 | }
92 | }
93 |
94 | // Initialize event listeners and then load games
95 | setupEventListeners().then(() => {
96 | if (isInitialLoad) {
97 | loadGames().catch(console.error)
98 | }
99 | })
100 |
101 | // Cleanup function
102 | return () => {
103 | unlisteners.forEach((fn) => fn())
104 | }
105 | }, [loadGames, isInitialLoad])
106 |
107 | // Helper function to update a specific game in state
108 | const updateGame = useCallback((updatedGame: Game) => {
109 | setGames((prevGames) =>
110 | prevGames.map((game) => (game.id === updatedGame.id ? updatedGame : game))
111 | )
112 | }, [])
113 |
114 | return {
115 | games,
116 | isLoading,
117 | isInitialLoad,
118 | scanProgress,
119 | error,
120 | loadGames,
121 | updateGame,
122 | setGames,
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/src/hooks/useToasts.ts:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from 'react'
2 | import { v4 as uuidv4 } from 'uuid'
3 |
4 | /**
5 | * Toast type definition
6 | */
7 | export type ToastType = 'success' | 'error' | 'warning' | 'info'
8 |
9 | /**
10 | * Toast interface
11 | */
12 | export interface Toast {
13 | id: string
14 | message: string
15 | type: ToastType
16 | duration?: number
17 | title?: string
18 | }
19 |
20 | /**
21 | * Toast options interface
22 | */
23 | export interface ToastOptions {
24 | title?: string
25 | duration?: number
26 | }
27 |
28 | /**
29 | * Hook for managing toast notifications
30 | * Provides methods for adding and removing notifications of different types
31 | */
32 | export function useToasts() {
33 | const [toasts, setToasts] = useState([])
34 |
35 | /**
36 | * Removes a toast by ID
37 | */
38 | const removeToast = useCallback((id: string) => {
39 | setToasts((currentToasts) => currentToasts.filter((toast) => toast.id !== id))
40 | }, [])
41 |
42 | /**
43 | * Adds a new toast with the specified type and options
44 | */
45 | const addToast = useCallback(
46 | (toast: Omit) => {
47 | const id = uuidv4()
48 | const newToast = { ...toast, id }
49 |
50 | setToasts((currentToasts) => [...currentToasts, newToast])
51 |
52 | // Auto-remove toast after its duration expires
53 | if (toast.duration !== Infinity) {
54 | setTimeout(() => {
55 | removeToast(id)
56 | }, toast.duration || 5000) // Default 5 seconds
57 | }
58 |
59 | return id
60 | },
61 | [removeToast]
62 | )
63 |
64 | /**
65 | * Shorthand method for success toasts
66 | */
67 | const success = useCallback(
68 | (message: string, options: ToastOptions = {}) =>
69 | addToast({ message, type: 'success', ...options }),
70 | [addToast]
71 | )
72 |
73 | /**
74 | * Shorthand method for error toasts
75 | */
76 | const error = useCallback(
77 | (message: string, options: ToastOptions = {}) =>
78 | addToast({ message, type: 'error', ...options }),
79 | [addToast]
80 | )
81 |
82 | /**
83 | * Shorthand method for warning toasts
84 | */
85 | const warning = useCallback(
86 | (message: string, options: ToastOptions = {}) =>
87 | addToast({ message, type: 'warning', ...options }),
88 | [addToast]
89 | )
90 |
91 | /**
92 | * Shorthand method for info toasts
93 | */
94 | const info = useCallback(
95 | (message: string, options: ToastOptions = {}) =>
96 | addToast({ message, type: 'info', ...options }),
97 | [addToast]
98 | )
99 |
100 | return {
101 | toasts,
102 | addToast,
103 | removeToast,
104 | success,
105 | error,
106 | warning,
107 | info,
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/src/hooks/useUpdateChecker.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 | import { check } from '@tauri-apps/plugin-updater'
3 | import { useToasts } from '@/hooks'
4 |
5 | /**
6 | * Hook that silently checks for updates and shows a toast notification if an update is available
7 | */
8 | export function useUpdateChecker() {
9 | const { success, error } = useToasts()
10 |
11 | useEffect(() => {
12 | // Check for updates on component mount
13 | const checkForUpdates = async () => {
14 | try {
15 | // Check for updates
16 | const update = await check()
17 |
18 | // If update is available, show a toast notification
19 | if (update) {
20 | console.log(`Update available: ${update.version}`)
21 | success(`Update v${update.version} available! Check GitHub for details.`, {
22 | duration: 8000 // Show for 8 seconds
23 | })
24 | }
25 | } catch (err) {
26 | // Log error but don't show to user
27 | console.error('Update check failed:', err)
28 | }
29 | }
30 |
31 | // Small delay to avoid interfering with app startup
32 | const timer = setTimeout(() => {
33 | checkForUpdates()
34 | }, 3000)
35 |
36 | return () => clearTimeout(timer)
37 | }, [success, error])
38 |
39 | // This hook doesn't return anything
40 | return null
41 | }
42 |
43 | export default useUpdateChecker
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import App from './App.tsx'
4 | import { AppProvider } from '@/contexts/index.ts'
5 |
6 | createRoot(document.getElementById('root')!).render(
7 |
8 |
9 |
10 |
11 |
12 | )
13 |
--------------------------------------------------------------------------------
/src/services/ImageService.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Game image sources from Steam's CDN
3 | */
4 | export const SteamImageType = {
5 | HEADER: 'header', // 460x215
6 | CAPSULE: 'capsule_616x353', // 616x353
7 | LOGO: 'logo', // Game logo with transparency
8 | LIBRARY_HERO: 'library_hero', // 1920x620
9 | LIBRARY_CAPSULE: 'library_600x900', // 600x900
10 | } as const
11 |
12 | export type SteamImageTypeKey = keyof typeof SteamImageType
13 |
14 | // Cache for images to prevent flickering during re-renders
15 | const imageCache: Map = new Map()
16 |
17 | /**
18 | * Builds a Steam CDN URL for game images
19 | * @param appId Steam application ID
20 | * @param type Image type from SteamImageType enum
21 | * @returns URL string for the image
22 | */
23 | export const getSteamImageUrl = (
24 | appId: string,
25 | type: (typeof SteamImageType)[SteamImageTypeKey]
26 | ): string => {
27 | return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appId}/${type}.jpg`
28 | }
29 |
30 | /**
31 | * Checks if an image exists by performing a HEAD request
32 | * @param url Image URL to check
33 | * @returns Promise resolving to a boolean indicating if the image exists
34 | */
35 | export const checkImageExists = async (url: string): Promise => {
36 | try {
37 | const response = await fetch(url, { method: 'HEAD' })
38 | return response.ok
39 | } catch (error) {
40 | console.error('Error checking image existence:', error)
41 | return false
42 | }
43 | }
44 |
45 | /**
46 | * Preloads an image for faster rendering
47 | * @param url URL of image to preload
48 | * @returns Promise that resolves when image is loaded
49 | */
50 | const preloadImage = (url: string): Promise => {
51 | return new Promise((resolve, reject) => {
52 | const img = new Image()
53 | img.onload = () => resolve(url)
54 | img.onerror = reject
55 | img.src = url
56 | })
57 | }
58 |
59 | /**
60 | * Attempts to find a valid image for a Steam game, trying different image types
61 | * @param appId Steam application ID
62 | * @returns Promise resolving to a valid image URL or null if none found
63 | */
64 | export const findBestGameImage = async (appId: string): Promise => {
65 | // Check cache first
66 | if (imageCache.has(appId)) {
67 | return imageCache.get(appId) || null
68 | }
69 |
70 | // Try these image types in order of preference
71 | const typesToTry = [SteamImageType.HEADER, SteamImageType.CAPSULE, SteamImageType.LIBRARY_CAPSULE]
72 |
73 | for (const type of typesToTry) {
74 | const url = getSteamImageUrl(appId, type)
75 | const exists = await checkImageExists(url)
76 |
77 | if (exists) {
78 | try {
79 | // Preload the image to prevent flickering
80 | const preloadedUrl = await preloadImage(url)
81 | // Store in cache
82 | imageCache.set(appId, preloadedUrl)
83 | return preloadedUrl
84 | } catch {
85 | // If preloading fails, just return the URL
86 | imageCache.set(appId, url)
87 | return url
88 | }
89 | }
90 | }
91 |
92 | // If no valid image was found
93 | return null
94 | }
95 |
--------------------------------------------------------------------------------
/src/services/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ImageService'
2 |
--------------------------------------------------------------------------------
/src/styles/abstracts/_fonts.scss:
--------------------------------------------------------------------------------
1 | /*
2 | Font definitions
3 | */
4 |
5 | @font-face {
6 | font-family: 'Satoshi';
7 | src:
8 | url('../assets/fonts/Satoshi.ttf') format('ttf'),
9 | url('../assets/fonts/Roboto.ttf') format('ttf'),
10 | url('../assets/fonts/WorkSans.ttf') format('ttf');
11 | font-weight: 400;
12 | font-style: normal;
13 | font-display: swap;
14 | }
15 |
--------------------------------------------------------------------------------
/src/styles/abstracts/_index.scss:
--------------------------------------------------------------------------------
1 | @forward './fonts';
2 | @forward './layout';
3 | @forward './mixins';
4 | @forward './reset';
5 | @forward './variables';
6 |
--------------------------------------------------------------------------------
/src/styles/abstracts/_layout.scss:
--------------------------------------------------------------------------------
1 | /*
2 | Layout styles
3 | Main layout structure for the application
4 | */
5 | .app-container {
6 | display: flex;
7 | flex-direction: column;
8 | height: 100vh;
9 | width: 100vw;
10 | position: absolute;
11 | top: 0;
12 | left: 0;
13 | right: 0;
14 | bottom: 0;
15 | background-color: var(--primary-bg);
16 | position: relative;
17 |
18 | &::before {
19 | content: '';
20 | position: absolute;
21 | top: 0;
22 | left: 0;
23 | right: 0;
24 | bottom: 0;
25 | background-image:
26 | radial-gradient(circle at 20% 30%, rgba(var(--primary-color), 0.05) 0%, transparent 70%),
27 | radial-gradient(circle at 80% 70%, rgba(var(--cream-color), 0.05) 0%, transparent 70%);
28 | pointer-events: none;
29 | z-index: var(--z-bg);
30 | }
31 | }
32 |
33 | // Main content area
34 | .main-content {
35 | display: flex;
36 | flex: 1;
37 | overflow: hidden;
38 | width: 100%;
39 | position: relative;
40 | z-index: var(--z-elevate);
41 | }
42 |
43 | // Error message container
44 | .error-container {
45 | display: flex;
46 | flex-direction: column;
47 | align-items: center;
48 | justify-content: center;
49 | padding: 2rem;
50 | margin: 2rem auto;
51 | max-width: 600px;
52 | border-radius: var(--radius-lg);
53 | background-color: rgba(var(--danger), 0.05);
54 | border: 1px solid rgb(var(--danger), 0.2);
55 | box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
56 | backdrop-filter: blur(5px);
57 | text-align: center;
58 |
59 | h2 {
60 | font-size: 1.5rem;
61 | margin-bottom: 1rem;
62 | }
63 |
64 | details {
65 | margin: 1rem 0;
66 | width: 100%;
67 |
68 | summary {
69 | cursor: pointer;
70 | color: var(--text-secondary);
71 | margin-bottom: 0.5rem;
72 | }
73 |
74 | p {
75 | padding: 1rem;
76 | background-color: rgba(0, 0, 0, 0.2);
77 | border-radius: var(--radius-sm);
78 | color: var(--text-secondary);
79 | font-family: monospace;
80 | overflow-x: auto;
81 | white-space: pre-wrap;
82 | }
83 | }
84 |
85 | button {
86 | background-color: var(--primary-color);
87 | color: var(--text-heavy);
88 | border: none;
89 | padding: 0.7rem 1.5rem;
90 | border-radius: var(--radius-sm);
91 | font-weight: 600;
92 | letter-spacing: 0.5px;
93 | box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
94 | transition: all var(--duration-normal) var(--easing-ease-out);
95 |
96 | &:hover {
97 | transform: translateY(-2px);
98 | box-shadow: 0 6px 14px rgba(var(--primary-color), 0.4);
99 | }
100 | }
101 | }
102 |
103 | // Error message styling in the game list
104 | .error-message {
105 | display: flex;
106 | flex-direction: column;
107 | align-items: center;
108 | justify-content: center;
109 | padding: 2rem;
110 | margin: 2rem auto;
111 | max-width: 600px;
112 | border-radius: var(--radius-lg);
113 | background-color: rgba(var(--danger), 0.05);
114 | border: 1px solid rgb(var(--danger), 0.2);
115 | box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
116 | backdrop-filter: blur(5px);
117 | text-align: center;
118 |
119 | h3 {
120 | color: var(--danger);
121 | font-weight: 700;
122 | margin-bottom: 1rem;
123 | }
124 |
125 | p {
126 | margin-bottom: 1.5rem;
127 | color: var(--text-secondary);
128 | white-space: pre-wrap;
129 | word-break: break-word;
130 | }
131 |
132 | button {
133 | background-color: var(--primary-color);
134 | color: var(--text-primary);
135 | border: none;
136 | padding: 0.7rem 1.5rem;
137 | border-radius: var(--radius-sm);
138 | font-weight: 600;
139 | letter-spacing: 0.5px;
140 | box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
141 | transition: all var(--duration-normal) var(--easing-ease-out);
142 |
143 | &:hover {
144 | transform: translateY(-2px);
145 | box-shadow: 0 6px 14px rgba(var(--primary-color), 0.4);
146 | }
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/src/styles/abstracts/_mixins.scss:
--------------------------------------------------------------------------------
1 | /*
2 | Mixins for reusable style patterns
3 | */
4 |
5 | // Basic flex helpers
6 | @mixin flex-center {
7 | display: flex;
8 | align-items: center;
9 | justify-content: center;
10 | }
11 |
12 | @mixin flex-between {
13 | display: flex;
14 | align-items: center;
15 | justify-content: space-between;
16 | }
17 |
18 | @mixin flex-column {
19 | display: flex;
20 | flex-direction: column;
21 | }
22 |
23 | // Glass effect for overlay
24 | @mixin glass-overlay($opacity: 0.7) {
25 | background-color: rgba(var(--primary-bg), var(--opacity));
26 | backdrop-filter: blur(8px);
27 | border: 1px solid rgba(255, 255, 255, 0.05);
28 | }
29 |
30 | @mixin gradient-bg($start-color, $end-color, $direction: 135deg) {
31 | background: linear-gradient($direction, $start-color, $end-color);
32 | }
33 |
34 | // Basic transition
35 | @mixin transition-standard {
36 | transition: all var(--duration-normal) var(--easing-ease-out);
37 | }
38 |
39 | @mixin shadow-standard {
40 | box-shadow: var(--shadow-standard);
41 | }
42 |
43 | @mixin shadow-hover {
44 | box-shadow: var(--shadow-hover);
45 | }
46 |
47 | @mixin text-shadow {
48 | text-shadow: 0 2px 4px rgba(0, 0, 0, 0.7);
49 | }
50 |
51 | // Simple animation for hover
52 | @mixin hover-lift {
53 | &:hover {
54 | transform: translateY(-5px);
55 | box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5);
56 | }
57 | }
58 |
59 | // Responsive mixins
60 | @mixin media-sm {
61 | @media (min-width: 576px) {
62 | @content;
63 | }
64 | }
65 |
66 | @mixin media-md {
67 | @media (min-width: 768px) {
68 | @content;
69 | }
70 | }
71 |
72 | @mixin media-lg {
73 | @media (min-width: 992px) {
74 | @content;
75 | }
76 | }
77 |
78 | @mixin media-xl {
79 | @media (min-width: 1200px) {
80 | @content;
81 | }
82 | }
83 |
84 | // Card base styling
85 | @mixin card {
86 | background-color: var(--secondary-bg);
87 | border-radius: var(--radius-sm);
88 | @include shadow-standard;
89 | overflow: hidden;
90 | position: relative;
91 | }
92 |
93 | // Custom scrollbar
94 | @mixin custom-scrollbar {
95 | &::-webkit-scrollbar {
96 | width: 8px;
97 | }
98 |
99 | &::-webkit-scrollbar-track {
100 | background: rgba(var(--primary-bg), 0.5);
101 | border-radius: 10px;
102 | }
103 |
104 | &::-webkit-scrollbar-thumb {
105 | background: var(--primary-color);
106 | border-radius: 10px;
107 | border: 2px solid var(--primary-bg);
108 | }
109 |
110 | &::-webkit-scrollbar-thumb:hover {
111 | background: color-mix(in srgb, white 10%, var(--primary-color));
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/src/styles/abstracts/_reset.scss:
--------------------------------------------------------------------------------
1 | /*
2 | CSS Reset and base styles
3 | */
4 |
5 | * {
6 | box-sizing: border-box;
7 | margin: 0;
8 | padding: 0;
9 | }
10 |
11 | html,
12 | body {
13 | height: 100%;
14 | width: 100%;
15 | overflow: hidden;
16 | }
17 |
18 | body {
19 | font-family: 'Roboto';
20 | -webkit-font-smoothing: antialiased;
21 | -moz-osx-font-smoothing: grayscale;
22 | background-color: var(--primary-bg);
23 | color: var(--text-primary);
24 | // Prevent text selection by default
25 | user-select: none;
26 | -webkit-user-select: none;
27 | -moz-user-select: none;
28 | -ms-user-select: none;
29 | }
30 |
31 | #root {
32 | height: 100%;
33 | width: 100%;
34 | }
35 |
36 | button {
37 | background: none;
38 | border: none;
39 | cursor: pointer;
40 | font-family: inherit;
41 |
42 | &:focus {
43 | outline: none;
44 | }
45 | }
46 |
47 | a {
48 | color: inherit;
49 | text-decoration: none;
50 | }
51 |
52 | ul,
53 | ol {
54 | list-style: none;
55 | }
56 |
57 | input,
58 | button,
59 | textarea,
60 | select {
61 | font: inherit;
62 | }
63 |
64 | h1,
65 | h2,
66 | h3,
67 | h4,
68 | h5,
69 | h6 {
70 | font-weight: inherit;
71 | font-size: inherit;
72 | }
73 |
--------------------------------------------------------------------------------
/src/styles/abstracts/_variables.scss:
--------------------------------------------------------------------------------
1 | /*
2 | Variables for consistent styling
3 | */
4 |
5 | :root {
6 | // Animation durations
7 | --duration-fast: 100ms;
8 | --duration-normal: 200ms;
9 | --duration-slow: 300ms;
10 |
11 | // Animation easings
12 | --easing-ease-out: cubic-bezier(0, 0, 0.2, 1);
13 | --easing-ease-in: cubic-bezier(0.4, 0, 1, 1);
14 | --easing-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
15 | --easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
16 |
17 | // Layout values
18 | --header-height: 64px;
19 | --sidebar-width: 250px;
20 | --card-height: 200px;
21 |
22 | // Border radius
23 | --radius-sm: 6px;
24 | --radius-md: 8px;
25 | --radius-lg: 12px;
26 |
27 | // Font weights
28 | --thin: 100;
29 | --extralight: 200;
30 | --light: 300;
31 | --normal: 400;
32 | --medium: 500;
33 | --semibold: 600;
34 | --bold: 700;
35 | --extrabold: 800;
36 |
37 | --family: 'Satoshi';
38 |
39 | // Shadows
40 | --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
41 | --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
42 | --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
43 | --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3);
44 | --shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.3);
45 | --shadow-standard: 0 10px 25px rgba(0, 0, 0, 0.5);
46 | --shadow-hover: 0 15px 30px rgba(0, 0, 0, 0.7);
47 |
48 | // Z-index levels
49 | --z-bg: 0;
50 | --z-elevate: 1;
51 | --z-header: 100;
52 | --z-modal: 1000;
53 | --z-tooltip: 1500;
54 | }
55 |
56 | // Color variables for SCSS usage
57 | $success-color: #55e07a;
58 | $danger-color: #ff5252;
59 | $primary-color: #4a76c4;
60 | $cream-color: #9b7dff;
61 | $smoke-color: #fbb13c;
62 | $warning-color: #fbb13c;
63 |
--------------------------------------------------------------------------------
/src/styles/components/buttons/_action_button.scss:
--------------------------------------------------------------------------------
1 | @use '../../themes/index' as *;
2 | @use '../../abstracts/index' as *;
3 |
4 | /*
5 | Action button styles
6 | Used specifically for game installation/uninstallation
7 | */
8 | .action-button {
9 | flex: 1;
10 | padding: 0.5rem;
11 | border: none;
12 | border-radius: var(--radius-sm);
13 | cursor: pointer;
14 | font-weight: var(--bold);
15 | -webkit-font-smoothing: subpixel-antialiased;
16 | text-rendering: geometricPrecision;
17 | color: var(--text-heavy);
18 | min-width: 0;
19 | white-space: nowrap;
20 | transition: all var(--duration-normal) var(--easing-ease-out);
21 |
22 | &.install {
23 | background-color: var(--success);
24 |
25 | &:hover {
26 | background-color: var(--success-light);
27 | transform: translateY(-2px) scale(1.02);
28 | box-shadow: 0px 0px 12px rgba(140, 200, 147, 0.3);
29 | }
30 | }
31 |
32 | &.uninstall {
33 | background-color: var(--danger);
34 |
35 | &:hover {
36 | background-color: var(--danger-light);
37 | transform: translateY(-2px) scale(1.02);
38 | box-shadow: 0px 0px 12px rgba(217, 107, 107, 0.3);
39 | }
40 | }
41 |
42 | &:active {
43 | transform: scale(0.97);
44 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
45 | }
46 |
47 | &:disabled {
48 | opacity: 0.7;
49 | cursor: not-allowed;
50 | background-color: var(--disabled);
51 | transform: none;
52 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
53 | position: relative;
54 | overflow: hidden;
55 |
56 | &::after {
57 | content: '';
58 | position: absolute;
59 | top: 0;
60 | left: -100%;
61 | width: 50%;
62 | height: 100%;
63 | background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
64 | animation: button-loading 1.5s infinite;
65 | }
66 | }
67 | }
68 |
69 | // Edit button appearing on game cards
70 | .edit-button {
71 | padding: 0 0.7rem;
72 | background-color: rgba(255, 255, 255, 0.2);
73 | font-weight: var(--bold);
74 | -webkit-font-smoothing: subpixel-antialiased;
75 | text-rendering: geometricPrecision;
76 | color: var(--text-primary);
77 | border-radius: var(--radius-sm);
78 | cursor: pointer;
79 | letter-spacing: 1px;
80 | transition: all var(--duration-normal) var(--easing-ease-out);
81 |
82 | &:hover {
83 | background-color: rgba(255, 255, 255, 0.3);
84 | transform: translateY(-2px);
85 | box-shadow: 0 7px 15px rgba(0, 0, 0, 0.3);
86 | }
87 |
88 | &:active {
89 | transform: translateY(0);
90 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
91 | }
92 |
93 | &:disabled {
94 | opacity: 0.5;
95 | cursor: not-allowed;
96 | transform: none;
97 | box-shadow: none;
98 | }
99 | }
100 |
101 | // Animation for loading state
102 | @keyframes button-loading {
103 | to {
104 | left: 100%;
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/src/styles/components/buttons/_animated_checkbox.scss:
--------------------------------------------------------------------------------
1 | @use '../../themes/index' as *;
2 | @use '../../abstracts/index' as *;
3 |
4 | /*
5 | Animated checkbox component styles
6 | */
7 | .animated-checkbox {
8 | display: flex;
9 | align-items: center;
10 | cursor: pointer;
11 | width: 100%;
12 | position: relative;
13 |
14 | &:hover .checkbox-custom {
15 | border-color: rgba(255, 255, 255, 0.3);
16 | }
17 | }
18 |
19 | .checkbox-original {
20 | position: absolute;
21 | opacity: 0;
22 | height: 0;
23 | width: 0;
24 | }
25 |
26 | .checkbox-custom {
27 | width: 22px;
28 | height: 22px;
29 | background-color: rgba(255, 255, 255, 0.05);
30 | border: 2px solid var(--border-soft, #323232);
31 | border-radius: 4px;
32 | display: flex;
33 | align-items: center;
34 | justify-content: center;
35 | transition: all 0.2s var(--easing-bounce);
36 | margin-right: 15px;
37 | flex-shrink: 0;
38 | position: relative;
39 |
40 | &.checked {
41 | background-color: var(--primary-color, #ffc896);
42 | border-color: var(--primary-color, #ffc896);
43 | box-shadow: 0 0 10px rgba(255, 200, 150, 0.2);
44 | }
45 |
46 | .checkbox-icon {
47 | color: var(--text-heavy);
48 | opacity: 0;
49 | transform: scale(0);
50 | transition: all 0.3s var(--easing-bounce);
51 | }
52 |
53 | &.checked .checkbox-icon {
54 | opacity: 1;
55 | transform: scale(1);
56 | animation: checkbox-pop 0.3s var(--easing-bounce) forwards;
57 | }
58 | }
59 |
60 | .checkbox-content {
61 | display: flex;
62 | flex-direction: column;
63 | flex: 1;
64 | min-width: 0; // Ensures text-overflow works properly
65 | }
66 |
67 | .checkbox-label {
68 | font-size: 15px;
69 | font-weight: 500;
70 | color: var(--text-primary);
71 | white-space: nowrap;
72 | overflow: hidden;
73 | text-overflow: ellipsis;
74 | }
75 |
76 | .checkbox-sublabel {
77 | font-size: 12px;
78 | color: var(--text-muted);
79 | }
80 |
81 | // Animation for the checkbox
82 | @keyframes checkbox-pop {
83 | 0% {
84 | transform: scale(0);
85 | opacity: 0;
86 | }
87 | 70% {
88 | transform: scale(1.2);
89 | opacity: 1;
90 | }
91 | 100% {
92 | transform: scale(1);
93 | opacity: 1;
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/styles/components/buttons/_button.scss:
--------------------------------------------------------------------------------
1 | @use '../../themes/index' as *;
2 | @use '../../abstracts/index' as *;
3 |
4 | /*
5 | Button component styles
6 | Core styling for buttons throughout the application
7 | */
8 | .btn {
9 | display: inline-flex;
10 | align-items: center;
11 | justify-content: center;
12 | position: relative;
13 | font-weight: var(--semibold);
14 | white-space: nowrap;
15 | border-radius: var(--radius-sm);
16 | cursor: pointer;
17 | transition: all var(--duration-normal) var(--easing-ease-out);
18 |
19 | // Default states
20 | &:hover {
21 | transform: translateY(-2px);
22 | box-shadow: var(--shadow-hover);
23 | }
24 |
25 | &:active {
26 | transform: translateY(0);
27 | box-shadow: var(--shadow-standard);
28 | }
29 |
30 | &:disabled,
31 | &.disabled {
32 | opacity: 0.7;
33 | cursor: not-allowed;
34 | transform: none !important;
35 | box-shadow: var(--shadow-standard) !important;
36 | }
37 |
38 | // Sizing
39 | &.btn-sm {
40 | font-size: 0.75rem;
41 | padding: 0.4rem 0.8rem;
42 | gap: 0.3rem;
43 | }
44 |
45 | &.btn-md {
46 | font-size: 0.875rem;
47 | padding: 0.6rem 1.2rem;
48 | gap: 0.5rem;
49 | }
50 |
51 | &.btn-lg {
52 | font-size: 1rem;
53 | padding: 0.8rem 1.5rem;
54 | gap: 0.6rem;
55 | }
56 |
57 | // Variants
58 | &.btn-primary {
59 | background-color: var(--primary-color);
60 | color: var(--text-heavy);
61 |
62 | &:hover {
63 | background-color: var(--primary-color);
64 | box-shadow: 0 6px 14px rgba(var(--primary-color), 0.3);
65 | }
66 | }
67 |
68 | &.btn-secondary {
69 | background-color: var(--border-soft);
70 | color: var(--text-primary);
71 |
72 | &:hover {
73 | background-color: var(--border);
74 | box-shadow: 0 6px 14px rgba(0, 0, 0, 0.3);
75 | }
76 | }
77 |
78 | &.btn-success {
79 | background-color: var(--success);
80 | color: var(--text-heavy);
81 |
82 | &:hover {
83 | background-color: var(--success-light);
84 | box-shadow: 0 6px 14px rgba(var(--success), 0.3);
85 | }
86 | }
87 |
88 | &.btn-danger {
89 | background-color: var(--danger);
90 | color: var(--text-heavy);
91 |
92 | &:hover {
93 | background-color: var(--danger-light);
94 | box-shadow: 0 6px 14px rgba(var(--danger), 0.3);
95 | }
96 | }
97 |
98 | &.btn-warning {
99 | background-color: var(--warning);
100 | color: var(--text-heavy);
101 |
102 | &:hover {
103 | background-color: var(--warning-light);
104 | box-shadow: 0 6px 14px rgba(var(--warning), 0.3);
105 | }
106 | }
107 |
108 | // Loading state
109 | &.btn-loading {
110 | position: relative;
111 |
112 | .btn-spinner {
113 | width: 1em;
114 | height: 1em;
115 | position: relative;
116 | margin-right: 0.5rem;
117 |
118 | .spinner {
119 | width: 100%;
120 | height: 100%;
121 | border: 2px solid rgba(255, 255, 255, 0.3);
122 | border-top-color: currentColor;
123 | border-radius: 50%;
124 | animation: spin 0.8s linear infinite;
125 | }
126 | }
127 |
128 | .btn-text {
129 | opacity: 0.7;
130 | }
131 | }
132 |
133 | // Icons
134 | .btn-icon {
135 | display: flex;
136 | align-items: center;
137 | justify-content: center;
138 |
139 | &.btn-icon-left {
140 | margin-right: 0.1rem;
141 | }
142 |
143 | &.btn-icon-right {
144 | margin-left: 0.1rem;
145 | }
146 | }
147 |
148 | // Full width
149 | &.btn-full {
150 | width: 100%;
151 | }
152 | }
153 |
154 | // Animation for spinner
155 | @keyframes spin {
156 | 0% {
157 | transform: rotate(0deg);
158 | }
159 | 100% {
160 | transform: rotate(360deg);
161 | }
162 | }
163 |
--------------------------------------------------------------------------------
/src/styles/components/buttons/_index.scss:
--------------------------------------------------------------------------------
1 | @forward './action_button';
2 | @forward './animated_checkbox';
3 | @forward './button';
4 |
--------------------------------------------------------------------------------
/src/styles/components/common/_index.scss:
--------------------------------------------------------------------------------
1 | @forward './loading';
2 | @forward './updater';
3 |
--------------------------------------------------------------------------------
/src/styles/components/common/_loading.scss:
--------------------------------------------------------------------------------
1 | @use '../../themes/index' as *;
2 | @use '../../abstracts/index' as *;
3 |
4 | /*
5 | Loading indicator component styles
6 | */
7 | .loading-indicator {
8 | display: flex;
9 | align-items: center;
10 | justify-content: center;
11 | flex-direction: column;
12 |
13 | // Size variations
14 | &.loading-small {
15 | .loading-spinner {
16 | width: 20px;
17 | height: 20px;
18 | border-width: 2px;
19 | }
20 |
21 | .loading-dots {
22 | .dot {
23 | width: 8px;
24 | height: 8px;
25 | }
26 | }
27 |
28 | .loading-message {
29 | font-size: 0.8rem;
30 | margin-top: 0.5rem;
31 | }
32 |
33 | .progress-bar-container {
34 | height: 6px;
35 | width: 100px;
36 | }
37 | }
38 |
39 | &.loading-medium {
40 | .loading-spinner {
41 | width: 30px;
42 | height: 30px;
43 | border-width: 3px;
44 | }
45 |
46 | .loading-dots {
47 | .dot {
48 | width: 10px;
49 | height: 10px;
50 | }
51 | }
52 |
53 | .loading-message {
54 | font-size: 0.9rem;
55 | margin-top: 0.75rem;
56 | }
57 |
58 | .progress-bar-container {
59 | height: 8px;
60 | width: 200px;
61 | }
62 | }
63 |
64 | &.loading-large {
65 | .loading-spinner {
66 | width: 50px;
67 | height: 50px;
68 | border-width: 4px;
69 | }
70 |
71 | .loading-dots {
72 | .dot {
73 | width: 14px;
74 | height: 14px;
75 | }
76 | }
77 |
78 | .loading-message {
79 | font-size: 1.1rem;
80 | margin-top: 1rem;
81 | }
82 |
83 | .progress-bar-container {
84 | height: 10px;
85 | width: 300px;
86 | }
87 | }
88 | }
89 |
90 | // Spinner styles
91 | .loading-spinner {
92 | border-radius: 50%;
93 | border: 3px solid rgba(255, 255, 255, 0.1);
94 | border-top-color: var(--primary-color);
95 | animation: spin 1s linear infinite;
96 | }
97 |
98 | // Loading dots animation
99 | .loading-dots {
100 | display: flex;
101 | gap: 0.4rem;
102 |
103 | .dot {
104 | background-color: var(--primary-color);
105 | border-radius: 50%;
106 | animation: bounce 1.4s infinite ease-in-out both;
107 |
108 | &.dot-1 {
109 | animation-delay: -0.32s;
110 | }
111 |
112 | &.dot-2 {
113 | animation-delay: -0.16s;
114 | }
115 | }
116 | }
117 |
118 | // Progress bar styles
119 | .loading-progress {
120 | display: flex;
121 | flex-direction: column;
122 | align-items: center;
123 | width: 100%;
124 | }
125 |
126 | .progress-bar-container {
127 | background-color: var(--border-soft);
128 | border-radius: 4px;
129 | overflow: hidden;
130 | width: 100%;
131 | margin-bottom: 0.5rem;
132 | }
133 |
134 | .progress-bar {
135 | height: 100%;
136 | background-color: var(--primary-color);
137 | border-radius: 4px;
138 | transition: width 0.3s ease;
139 | }
140 |
141 | .progress-percentage {
142 | font-size: 0.8rem;
143 | color: var(--text-secondary);
144 | }
145 |
146 | .loading-message {
147 | color: var(--text-secondary);
148 | text-align: center;
149 | }
150 |
151 | // Animations
152 | @keyframes spin {
153 | 0% {
154 | transform: rotate(0deg);
155 | }
156 | 100% {
157 | transform: rotate(360deg);
158 | }
159 | }
160 |
161 | @keyframes bounce {
162 | 0%,
163 | 80%,
164 | 100% {
165 | transform: scale(0);
166 | }
167 | 40% {
168 | transform: scale(1);
169 | }
170 | }
171 |
--------------------------------------------------------------------------------
/src/styles/components/common/_updater.scss:
--------------------------------------------------------------------------------
1 | @use '../../themes/index' as *;
2 | @use '../../abstracts/index' as *;
3 |
4 | /*
5 | Update checker component styles
6 | */
7 | .update-checker {
8 | border-radius: var(--radius-md);
9 | background-color: var(--elevated-bg);
10 | padding: 1.25rem;
11 | margin: 1rem 0;
12 | border: 1px solid var(--border-soft);
13 | box-shadow: var(--shadow-standard);
14 | max-width: 500px;
15 | position: fixed;
16 | bottom: 20px;
17 | right: 20px;
18 | z-index: var(--z-modal) - 1;
19 |
20 | &.error {
21 | border-color: var(--danger);
22 | background-color: var(--danger-soft);
23 | }
24 |
25 | .update-info {
26 | margin-bottom: 1rem;
27 |
28 | h3 {
29 | font-size: 1.2rem;
30 | color: var(--primary-color);
31 | margin-bottom: 0.5rem;
32 | font-weight: var(--bold);
33 | }
34 |
35 | p {
36 | color: var(--text-secondary);
37 | margin-bottom: 0.5rem;
38 | font-size: 0.9rem;
39 | }
40 |
41 | .update-notes {
42 | font-size: 0.85rem;
43 | color: var(--text-soft);
44 | max-height: 120px;
45 | overflow-y: auto;
46 | padding: 0.5rem;
47 | background-color: rgba(0, 0, 0, 0.1);
48 | border-radius: var(--radius-sm);
49 | white-space: pre-line;
50 | margin-top: 0.5rem;
51 | @include custom-scrollbar;
52 | }
53 | }
54 |
55 | .update-progress {
56 | margin-top: 1rem;
57 |
58 | .progress-bar-container {
59 | height: 6px;
60 | background-color: var(--border-soft);
61 | border-radius: 3px;
62 | margin-bottom: 0.5rem;
63 | overflow: hidden;
64 | }
65 |
66 | .progress-bar {
67 | height: 100%;
68 | background-color: var(--primary-color);
69 | border-radius: 3px;
70 | transition: width 0.3s ease;
71 | }
72 |
73 | p {
74 | font-size: 0.8rem;
75 | color: var(--text-secondary);
76 | text-align: right;
77 | }
78 | }
79 |
80 | .update-actions {
81 | display: flex;
82 | gap: 0.75rem;
83 | margin-top: 1rem;
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/styles/components/dialogs/_dialog.scss:
--------------------------------------------------------------------------------
1 | @use '../../themes/index' as *;
2 | @use '../../abstracts/index' as *;
3 |
4 | /*
5 | Base dialog styles
6 | Used for all dialog components
7 | */
8 |
9 | // Dialog overlay
10 | .dialog-overlay {
11 | position: fixed;
12 | top: 0;
13 | left: 0;
14 | width: 100vw;
15 | height: 100vh;
16 | background-color: var(--modal-backdrop);
17 | backdrop-filter: blur(5px);
18 | display: flex;
19 | align-items: center;
20 | justify-content: center;
21 | z-index: var(--z-modal);
22 | opacity: 0;
23 | cursor: pointer;
24 | transition: opacity 0.2s ease-out;
25 |
26 | &.visible {
27 | opacity: 1;
28 | }
29 | }
30 |
31 | // Dialog container
32 | .dialog {
33 | background-color: var(--elevated-bg);
34 | border-radius: var(--radius-md);
35 | box-shadow: 0px 10px 25px rgba(0, 0, 0, 0.4);
36 | border: 1px solid var(--border-soft);
37 | opacity: 0;
38 | transform: scale(0.95);
39 | transition:
40 | transform 0.2s var(--easing-bounce),
41 | opacity 0.2s ease-out;
42 | cursor: default;
43 | display: flex;
44 | flex-direction: column;
45 | max-height: 90vh;
46 | overflow: hidden;
47 |
48 | &.dialog-visible {
49 | transform: scale(1);
50 | opacity: 1;
51 | }
52 |
53 | // Sizing variants
54 | &.dialog-small {
55 | width: 450px;
56 | max-width: 90vw;
57 | }
58 |
59 | &.dialog-medium {
60 | width: 550px;
61 | max-width: 90vw;
62 | }
63 |
64 | &.dialog-large {
65 | width: 700px;
66 | max-width: 90vw;
67 | }
68 | }
69 |
70 | // Dialog header
71 | .dialog-header {
72 | padding: 1.5rem;
73 | border-bottom: 1px solid var(--border-soft);
74 | position: relative;
75 |
76 | h3 {
77 | font-size: 1.2rem;
78 | font-weight: 700;
79 | margin-bottom: 0.5rem;
80 | color: var(--text-primary);
81 | }
82 |
83 | // Close button
84 | .dialog-close-button {
85 | position: absolute;
86 | top: 1rem;
87 | right: 1rem;
88 | width: 32px;
89 | height: 32px;
90 | background: var(--border-soft);
91 | border-radius: 50%;
92 | color: var(--text-primary);
93 | font-size: 1.5rem;
94 | line-height: 1;
95 | display: flex;
96 | align-items: center;
97 | justify-content: center;
98 | cursor: pointer;
99 | transition: all 0.2s ease;
100 |
101 | &:hover {
102 | background: var(--border);
103 | transform: rotate(90deg);
104 | }
105 | }
106 | }
107 |
108 | // Dialog body
109 | .dialog-body {
110 | padding: 1rem 1.5rem;
111 | overflow-y: auto;
112 | flex: 1;
113 | @include custom-scrollbar;
114 |
115 | p {
116 | margin-bottom: 1rem;
117 | }
118 | }
119 |
120 | // Dialog footer
121 | .dialog-footer {
122 | padding: 1rem 1.5rem;
123 | border-top: 1px solid var(--border-soft);
124 | }
125 |
126 | // Dialog actions
127 | .dialog-actions {
128 | display: flex;
129 | justify-content: flex-end;
130 | gap: 0.75rem;
131 |
132 | &.justify-start {
133 | justify-content: flex-start;
134 | }
135 |
136 | &.justify-center {
137 | justify-content: center;
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/src/styles/components/dialogs/_dlc_dialog.scss:
--------------------------------------------------------------------------------
1 | @use '../../themes/index' as *;
2 | @use '../../abstracts/index' as *;
3 |
4 | /*
5 | DLC Selection Dialog styles
6 | For managing game DLCs
7 | */
8 |
9 | // DLC dialog search bar
10 | .dlc-dialog-search {
11 | padding: 0.75rem 1.5rem;
12 | background-color: rgba(0, 0, 0, 0.1);
13 | border-bottom: 1px solid var(--border-soft);
14 | display: flex;
15 | justify-content: space-between;
16 | align-items: center;
17 | gap: 1rem;
18 | }
19 |
20 | .dlc-search-input {
21 | flex: 1;
22 | background-color: var(--border-dark);
23 | border: 1px solid var(--border-soft);
24 | border-radius: 4px;
25 | color: var(--text-primary);
26 | padding: 0.6rem 1rem;
27 | font-size: 0.9rem;
28 | transition: all var(--duration-normal) var(--easing-ease-out);
29 |
30 | &:focus {
31 | border-color: var(--primary-color);
32 | outline: none;
33 | box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.2);
34 | }
35 |
36 | &::placeholder {
37 | color: var(--text-muted);
38 | }
39 | }
40 |
41 | // Select all container
42 | .select-all-container {
43 | display: flex;
44 | align-items: center;
45 | min-width: 100px;
46 |
47 | .animated-checkbox {
48 | margin-left: auto;
49 | }
50 |
51 | .checkbox-label {
52 | font-size: 0.9rem;
53 | color: var(--text-secondary);
54 | }
55 | }
56 |
57 | // Loading progress indicator
58 | .dlc-loading-progress {
59 | padding: 0.75rem 1.5rem;
60 | background-color: rgba(0, 0, 0, 0.05);
61 | border-bottom: 1px solid var(--border-soft);
62 |
63 | .loading-details {
64 | display: flex;
65 | justify-content: space-between;
66 | font-size: 0.8rem;
67 | color: var(--text-secondary);
68 |
69 | .time-left {
70 | color: var(--text-muted);
71 | }
72 | }
73 | }
74 |
75 | // DLC list container
76 | .dlc-list-container {
77 | flex: 1;
78 | overflow-y: auto;
79 | min-height: 200px;
80 | @include custom-scrollbar;
81 | padding: 0;
82 | }
83 |
84 | .dlc-list {
85 | margin: 0;
86 | padding: 0;
87 | list-style: none;
88 | }
89 |
90 | // DLC item
91 | .dlc-item {
92 | padding: 0.75rem 1.5rem;
93 | border-bottom: 1px solid var(--border-soft);
94 | transition: all var(--duration-normal) var(--easing-ease-out);
95 |
96 | &:hover {
97 | background-color: rgba(255, 255, 255, 0.03);
98 | }
99 |
100 | &:last-child {
101 | border-bottom: none;
102 | }
103 |
104 | &.dlc-item-loading {
105 | height: 30px;
106 | display: flex;
107 | align-items: center;
108 | justify-content: center;
109 |
110 | .loading-pulse {
111 | width: 70%;
112 | height: 20px;
113 | background: linear-gradient(
114 | 90deg,
115 | var(--border-soft) 0%,
116 | var(--border) 50%,
117 | var(--border-soft) 100%
118 | );
119 | background-size: 200% 100%;
120 | border-radius: 4px;
121 | animation: loading-pulse 1.5s infinite;
122 | }
123 | }
124 | }
125 |
126 | // DLC loading state
127 | .dlc-loading {
128 | height: 200px;
129 | display: flex;
130 | align-items: center;
131 | justify-content: center;
132 | flex-direction: column;
133 | gap: 1rem;
134 |
135 | .loading-spinner {
136 | width: 40px;
137 | height: 40px;
138 | border: 3px solid rgba(255, 255, 255, 0.1);
139 | border-top-color: var(--primary-color);
140 | border-radius: 50%;
141 | animation: spin 1s linear infinite;
142 | }
143 |
144 | p {
145 | color: var(--text-secondary);
146 | }
147 | }
148 |
149 | .no-dlcs-message {
150 | height: 200px;
151 | display: flex;
152 | align-items: center;
153 | justify-content: center;
154 | color: var(--text-secondary);
155 | }
156 |
157 | // Game information in DLC dialog
158 | .dlc-game-info {
159 | display: flex;
160 | justify-content: space-between;
161 | align-items: center;
162 | margin-top: 0.5rem;
163 |
164 | .game-title {
165 | font-weight: 500;
166 | color: var(--text-secondary);
167 | }
168 |
169 | .dlc-count {
170 | font-size: 0.9rem;
171 | padding: 0.3rem 0.6rem;
172 | background-color: var(--info-soft);
173 | color: var(--info);
174 | border-radius: 4px;
175 | }
176 | }
177 |
178 | // Loading animations
179 | @keyframes spin {
180 | 0% {
181 | transform: rotate(0deg);
182 | }
183 | 100% {
184 | transform: rotate(360deg);
185 | }
186 | }
187 |
188 | @keyframes loading-pulse {
189 | 0% {
190 | background-position: 200% 50%;
191 | }
192 | 100% {
193 | background-position: 0% 50%;
194 | }
195 | }
196 |
--------------------------------------------------------------------------------
/src/styles/components/dialogs/_index.scss:
--------------------------------------------------------------------------------
1 | @forward './dialog';
2 | @forward './dlc_dialog';
3 | @forward './progress_dialog';
4 |
--------------------------------------------------------------------------------
/src/styles/components/dialogs/_progress_dialog.scss:
--------------------------------------------------------------------------------
1 | @use '../../themes/index' as *;
2 | @use '../../abstracts/index' as *;
3 |
4 | /*
5 | Progress dialog styles
6 | For installation/uninstallation progress display
7 | */
8 |
9 | // Progress bar
10 | .progress-bar-container {
11 | height: 8px;
12 | background-color: var(--border-soft);
13 | border-radius: 4px;
14 | overflow: hidden;
15 | margin-bottom: 0.5rem;
16 | }
17 |
18 | .progress-bar {
19 | height: 100%;
20 | background-color: var(--primary-color);
21 | border-radius: 4px;
22 | transition: width 0.3s ease;
23 | background: var(--primary-color);
24 | box-shadow: 0px 0px 6px rgba(245, 150, 130, 0.3);
25 | }
26 |
27 | .progress-percentage {
28 | text-align: right;
29 | font-size: 0.875rem;
30 | color: var(--text-secondary);
31 | margin-bottom: 1rem;
32 | }
33 |
34 | // Instruction container
35 | .instruction-container {
36 | margin-top: 1rem;
37 | padding-top: 1rem;
38 | border-top: 1px solid var(--border-soft);
39 |
40 | h4 {
41 | font-weight: 700;
42 | margin-bottom: 1rem;
43 | color: var(--text-primary);
44 | }
45 | }
46 |
47 | .instruction-text {
48 | line-height: 1.6;
49 | margin-bottom: 1rem;
50 | color: var(--text-secondary);
51 | }
52 |
53 | .dlc-count {
54 | display: inline-block;
55 | margin-bottom: 0.75rem;
56 | padding: 0.4rem 0.8rem;
57 | background-color: var(--info-soft);
58 | color: var(--info);
59 | border-radius: 4px;
60 | font-size: 0.8rem;
61 |
62 | &::before {
63 | content: '';
64 | display: inline-block;
65 | width: 8px;
66 | height: 8px;
67 | border-radius: 50%;
68 | background-color: var(--info);
69 | margin-right: 8px;
70 | }
71 | }
72 |
73 | // Command box
74 | .command-box {
75 | background-color: var(--border-dark);
76 | border: 1px solid var(--border-soft);
77 | border-radius: 4px;
78 | padding: 1rem;
79 | margin-bottom: 1.2rem;
80 | font-family: monospace;
81 | position: relative;
82 | overflow: hidden;
83 |
84 | &.command-box-smoke {
85 | font-size: 0.9rem;
86 | overflow-wrap: break-word;
87 | word-break: break-word;
88 | white-space: pre-wrap;
89 | width: 100%;
90 | max-width: 100%;
91 | }
92 | }
93 |
94 | // Selectable text
95 | .selectable-text {
96 | font-size: 0.9rem;
97 | line-height: 1.5;
98 | user-select: text;
99 | -webkit-user-select: text;
100 | -moz-user-select: text;
101 | -ms-user-select: text;
102 | cursor: text;
103 | margin: 0;
104 | color: var(--text-primary);
105 | word-break: break-word;
106 | white-space: pre-wrap;
107 | }
108 |
109 | // Animation for progress bar
110 | @keyframes progress-shimmer {
111 | 0% {
112 | transform: translateX(-100%);
113 | }
114 | 100% {
115 | transform: translateX(100%);
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/styles/components/games/_gamecard.scss:
--------------------------------------------------------------------------------
1 | @use '../../themes/index' as *;
2 | @use '../../abstracts/index' as *;
3 |
4 | /*
5 | Game card styles
6 | For game items displayed in the grid
7 | */
8 | .game-item-card {
9 | position: relative;
10 | height: var(--card-height);
11 | border-radius: var(--radius-lg);
12 | overflow: hidden;
13 | will-change: opacity, transform;
14 | box-shadow: var(--shadow-standard);
15 | transition: all var(--duration-normal) var(--easing-ease-out);
16 | transform-origin: center;
17 |
18 | // Simple image loading animation
19 | opacity: 0;
20 | animation: fadeIn 0.5s forwards;
21 | }
22 |
23 | // Hover effects for the card
24 | .game-item-card:hover {
25 | transform: translateY(-8px) scale(1.02);
26 | box-shadow: var(--shadow-hover);
27 | z-index: 5;
28 |
29 | .status-badge.native {
30 | box-shadow: 0 0 10px rgba(85, 224, 122, 0.5);
31 | }
32 |
33 | .status-badge.proton {
34 | box-shadow: 0 0 10px rgba(255, 201, 150, 0.5);
35 | }
36 |
37 | .status-badge.cream {
38 | box-shadow: 0 0 10px rgba(128, 181, 255, 0.5);
39 | }
40 |
41 | .status-badge.smoke {
42 | box-shadow: 0 0 10px rgba(255, 239, 150, 0.5);
43 | }
44 | }
45 |
46 | // Special styling for cards with different statuses
47 | .game-item-card:has(.status-badge.cream) {
48 | box-shadow:
49 | var(--shadow-standard),
50 | 0 0 15px rgba(128, 181, 255, 0.15);
51 | }
52 |
53 | .game-item-card:has(.status-badge.smoke) {
54 | box-shadow:
55 | var(--shadow-standard),
56 | 0 0 15px rgba(255, 239, 150, 0.15);
57 | }
58 |
59 | // Game item overlay
60 | .game-item-overlay {
61 | position: absolute;
62 | top: 0;
63 | left: 0;
64 | width: 100%;
65 | height: 100%;
66 | background: linear-gradient(
67 | to bottom,
68 | rgba(0, 0, 0, 0.5) 0%,
69 | rgba(0, 0, 0, 0.6) 50%,
70 | rgba(0, 0, 0, 0.8) 100%
71 | );
72 | display: flex;
73 | flex-direction: column;
74 | justify-content: space-between;
75 | padding: 1rem;
76 | box-sizing: border-box;
77 | font-weight: var(--bold);
78 | font-family: var(--family);
79 | -webkit-font-smoothing: subpixel-antialiased;
80 | text-rendering: geometricPrecision;
81 | color: var(--text-heavy);
82 | z-index: 1;
83 | }
84 |
85 | // Game badges
86 | .game-badges {
87 | display: flex;
88 | justify-content: flex-end;
89 | gap: 0.4rem;
90 | margin-bottom: 0.5rem;
91 | position: relative;
92 | z-index: 2;
93 | }
94 |
95 | .status-badge {
96 | display: inline-block;
97 | padding: 0.25rem 0.5rem;
98 | border-radius: 4px;
99 | font-size: 0.75rem;
100 | font-weight: var(--bold);
101 | font-family: var(--family);
102 | -webkit-font-smoothing: subpixel-antialiased;
103 | text-rendering: geometricPrecision;
104 | color: var(--text-heavy);
105 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
106 | transition: all var(--duration-normal) var(--easing-ease-out);
107 | border: 1px solid rgba(255, 255, 255, 0.1);
108 | }
109 |
110 | .status-badge.native {
111 | background-color: var(--native);
112 | color: var(--text-heavy);
113 | }
114 | .status-badge.proton {
115 | background-color: var(--proton);
116 | color: var(--text-heavy);
117 | }
118 |
119 | .status-badge.cream {
120 | background-color: var(--cream);
121 | color: var(--text-heavy);
122 | }
123 |
124 | .status-badge.smoke {
125 | background-color: var(--smoke);
126 | color: var(--text-heavy);
127 | }
128 |
129 | // Game title
130 | .game-title {
131 | padding: 0;
132 | position: relative;
133 | }
134 |
135 | .game-title h3 {
136 | color: var(--text-primary);
137 | font-size: 1.6rem;
138 | font-weight: var(--bold);
139 | margin: 0;
140 | -webkit-font-smoothing: subpixel-antialiased;
141 | text-rendering: geometricPrecision;
142 | transform: translateZ(0);
143 | will-change: opacity, transform;
144 | text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
145 | overflow: hidden;
146 | text-overflow: ellipsis;
147 | white-space: nowrap;
148 | }
149 |
150 | // Game actions
151 | .game-actions {
152 | display: flex;
153 | gap: 0.5rem;
154 | position: relative;
155 | z-index: 3;
156 | }
157 |
158 | // API not found message
159 | .api-not-found-message {
160 | display: flex;
161 | align-items: center;
162 | justify-content: space-between;
163 | background-color: rgba(255, 100, 100, 0.2);
164 | border: 1px solid rgba(255, 100, 100, 0.3);
165 | border-radius: var(--radius-sm);
166 | padding: 0.4rem 0.8rem;
167 | width: 100%;
168 | font-size: 0.85rem;
169 | color: var(--text-primary);
170 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
171 |
172 | span {
173 | flex: 1;
174 | }
175 |
176 | .rescan-button {
177 | background-color: var(--warning);
178 | color: var(--text-heavy);
179 | border: none;
180 | border-radius: var(--radius-sm);
181 | padding: 0.2rem 0.6rem;
182 | font-size: 0.75rem;
183 | font-weight: var(--bold);
184 | margin-left: 0.5rem;
185 | cursor: pointer;
186 | transition: all 0.2s ease;
187 |
188 | &:hover {
189 | background-color: var(--warning-light);
190 | transform: translateY(-2px);
191 | }
192 |
193 | &:active {
194 | transform: translateY(0);
195 | }
196 | }
197 | }
198 |
199 | // Apply staggered delay to cards
200 | @for $i from 1 through 12 {
201 | .game-grid .game-item-card:nth-child(#{$i}) {
202 | animation-delay: #{$i * 0.05}s;
203 | }
204 | }
205 |
206 | // Simple animations
207 | @keyframes fadeIn {
208 | from {
209 | opacity: 0;
210 | }
211 | to {
212 | opacity: 1;
213 | }
214 | }
215 |
--------------------------------------------------------------------------------
/src/styles/components/games/_gamelist.scss:
--------------------------------------------------------------------------------
1 | @use '../../themes/index' as *;
2 | @use '../../abstracts/index' as *;
3 |
4 | /*
5 | Game list styles
6 | For game list container and grid
7 | */
8 | .game-list {
9 | padding: 1.5rem;
10 | flex: 1;
11 | overflow-y: auto;
12 | height: 100%;
13 | width: 100%;
14 | @include custom-scrollbar;
15 | position: relative;
16 |
17 | h2 {
18 | font-size: 1.4rem;
19 | font-weight: 700;
20 | margin-bottom: 1.5rem;
21 | color: var(--text-primary);
22 | letter-spacing: 0.5px;
23 | position: relative;
24 | display: inline-block;
25 | padding-bottom: 0.5rem;
26 |
27 | &:after {
28 | content: '';
29 | position: absolute;
30 | bottom: 0;
31 | left: 0;
32 | width: 100%;
33 | height: 3px;
34 | background: linear-gradient(90deg, var(--primary-color), transparent);
35 | border-radius: 3px;
36 | }
37 | }
38 | }
39 |
40 | // Game grid
41 | .game-grid {
42 | display: grid;
43 | grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
44 | gap: 2rem;
45 | width: 100%;
46 | padding: 0.5rem 0.5rem 2rem 0.5rem;
47 | scroll-behavior: smooth;
48 | align-items: stretch;
49 | opacity: 0;
50 | transform: translateY(10px);
51 | animation: fadeIn 0.5s forwards;
52 | }
53 |
54 | // Loading and empty state
55 | .loading-indicator,
56 | .no-games-message {
57 | display: flex;
58 | align-items: center;
59 | justify-content: center;
60 | height: 250px;
61 | width: 100%;
62 | font-size: 1.2rem;
63 | color: var(--text-secondary);
64 | text-align: center;
65 | border-radius: var(--radius-lg);
66 | background-color: rgba(255, 255, 255, 0.03);
67 | box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.2);
68 | backdrop-filter: blur(5px);
69 | }
70 |
71 | .loading-indicator {
72 | position: relative;
73 | overflow: hidden;
74 |
75 | &:after {
76 | content: '';
77 | position: absolute;
78 | top: 0;
79 | left: -100%;
80 | width: 50%;
81 | height: 100%;
82 | background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.05), transparent);
83 | animation: loading-shimmer 2s infinite;
84 | }
85 | }
86 |
87 | // Responsive adjustments
88 | @include media-sm {
89 | .game-grid {
90 | grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
91 | }
92 | }
93 |
94 | @include media-lg {
95 | .game-grid {
96 | grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
97 | }
98 | }
99 |
100 | @include media-xl {
101 | .game-grid {
102 | grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
103 | }
104 | }
105 |
106 | // Scroll to top button
107 | .scroll-top-button {
108 | position: fixed;
109 | bottom: 30px;
110 | right: 30px;
111 | width: 44px;
112 | height: 44px;
113 | border-radius: 50%;
114 | background: linear-gradient(
115 | 135deg,
116 | var(--primary-color),
117 | color-mix(in srgb, black 10%, var(--primary-color))
118 | );
119 | color: var(--text-primary);
120 | display: flex;
121 | align-items: center;
122 | justify-content: center;
123 | cursor: pointer;
124 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
125 | opacity: 0;
126 | transform: translateY(20px);
127 | transition: all var(--duration-normal) var(--easing-ease-out);
128 | z-index: var(--z-header);
129 |
130 | &.visible {
131 | opacity: 1;
132 | transform: translateY(0);
133 | }
134 |
135 | &:hover {
136 | transform: translateY(-5px);
137 | box-shadow: 0 8px 20px rgba(var(--primary-color), 0.4);
138 | }
139 |
140 | &:active {
141 | transform: translateY(0);
142 | }
143 | }
144 |
145 | // Loading shimmer animation
146 | @keyframes loading-shimmer {
147 | to {
148 | left: 100%;
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/src/styles/components/games/_index.scss:
--------------------------------------------------------------------------------
1 | @forward './gamecard';
2 | @forward './gamelist';
3 |
--------------------------------------------------------------------------------
/src/styles/components/icons/_icon.scss:
--------------------------------------------------------------------------------
1 | @use '../../themes/index' as *;
2 | @use '../../abstracts/index' as *;
3 |
4 | /*
5 | Icon component styles
6 | */
7 | .icon {
8 | display: inline-flex;
9 | vertical-align: middle;
10 |
11 | // Base icon styling
12 | &.icon-xs {
13 | width: 12px;
14 | height: 12px;
15 | }
16 |
17 | &.icon-sm {
18 | width: 16px;
19 | height: 16px;
20 | }
21 |
22 | &.icon-md {
23 | width: 24px;
24 | height: 24px;
25 | }
26 |
27 | &.icon-lg {
28 | width: 32px;
29 | height: 32px;
30 | }
31 |
32 | &.icon-xl {
33 | width: 48px;
34 | height: 48px;
35 | }
36 |
37 | // Interactive icons
38 | &.icon-clickable {
39 | cursor: pointer;
40 | transition:
41 | transform 0.2s ease,
42 | opacity 0.2s ease;
43 |
44 | &:hover {
45 | opacity: 0.8;
46 | transform: scale(1.1);
47 | }
48 |
49 | &:active {
50 | transform: scale(0.95);
51 | }
52 | }
53 |
54 | // Icon with background
55 | &.icon-with-bg {
56 | padding: 6px;
57 | border-radius: 50%;
58 | background-color: var(--border-soft);
59 |
60 | &:hover {
61 | background-color: var(--border);
62 | }
63 | }
64 |
65 | // Color variations
66 | &.icon-primary {
67 | color: var(--primary-color);
68 | }
69 |
70 | &.icon-secondary {
71 | color: var(--text-secondary);
72 | }
73 |
74 | &.icon-danger {
75 | color: var(--danger);
76 | }
77 |
78 | &.icon-success {
79 | color: var(--success);
80 | }
81 |
82 | &.icon-warning {
83 | color: var(--warning);
84 | }
85 |
86 | &.icon-info {
87 | color: var(--info);
88 | }
89 | }
90 |
91 | // Icon in button
92 | .btn .icon {
93 | display: flex;
94 | align-items: center;
95 | justify-content: center;
96 | }
97 |
98 | // Animated icons
99 | .icon-spin {
100 | animation: icon-spin 2s linear infinite;
101 | }
102 |
103 | .icon-pulse {
104 | animation: icon-pulse 1.5s ease-in-out infinite;
105 | }
106 |
107 | // Animations
108 | @keyframes icon-spin {
109 | from {
110 | transform: rotate(0deg);
111 | }
112 | to {
113 | transform: rotate(360deg);
114 | }
115 | }
116 |
117 | @keyframes icon-pulse {
118 | 0% {
119 | opacity: 1;
120 | transform: scale(1);
121 | }
122 | 50% {
123 | opacity: 0.5;
124 | transform: scale(0.95);
125 | }
126 | 100% {
127 | opacity: 1;
128 | transform: scale(1);
129 | }
130 | }
131 |
--------------------------------------------------------------------------------
/src/styles/components/icons/_index.scss:
--------------------------------------------------------------------------------
1 | @forward './icon';
2 |
--------------------------------------------------------------------------------
/src/styles/components/layout/_background.scss:
--------------------------------------------------------------------------------
1 | @use '../../themes/index' as *;
2 | @use '../../abstracts/index' as *;
3 |
4 | /*
5 | Animated background styles
6 | */
7 | .animated-background {
8 | position: fixed;
9 | top: 0;
10 | left: 0;
11 | width: 100%;
12 | height: 100%;
13 | pointer-events: none;
14 | z-index: var(--z-bg);
15 | opacity: 0.4;
16 | }
17 |
--------------------------------------------------------------------------------
/src/styles/components/layout/_header.scss:
--------------------------------------------------------------------------------
1 | @use '../../themes/index' as *;
2 | @use '../../abstracts/index' as *;
3 |
4 | /*
5 | Header component styles
6 | */
7 | .app-header {
8 | display: flex;
9 | align-items: center;
10 | justify-content: space-between;
11 | padding: 1rem 2rem;
12 | background-color: var(--tertiary-bg);
13 | border-bottom: 1px solid rgba(255, 255, 255, 0.07);
14 | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
15 | position: relative;
16 | z-index: var(--z-header);
17 | height: var(--header-height);
18 |
19 | .app-title {
20 | display: flex;
21 | align-items: center;
22 | gap: 0.75rem;
23 |
24 | h1 {
25 | font-size: 1.5rem;
26 | font-weight: 600;
27 | color: var(--text-primary);
28 | letter-spacing: 0.5px;
29 | text-shadow: 0 2px 4px rgba(0, 0, 0, 0.7);
30 | }
31 |
32 | .app-logo-icon {
33 | color: var(--primary-color);
34 | filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
35 | }
36 | }
37 |
38 | &::after {
39 | content: '';
40 | position: absolute;
41 | bottom: 0;
42 | left: 0;
43 | right: 0;
44 | height: 3px;
45 | background: linear-gradient(
46 | 90deg,
47 | var(--cream-color),
48 | var(--primary-color),
49 | var(--smoke-color)
50 | );
51 | opacity: 0.7;
52 | }
53 |
54 | &::before {
55 | content: '';
56 | position: absolute;
57 | top: 0;
58 | left: 0;
59 | right: 0;
60 | height: 1px;
61 | background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
62 | }
63 | }
64 |
65 | .header-controls {
66 | display: flex;
67 | gap: 1rem;
68 | align-items: center;
69 | }
70 |
71 | .search-container {
72 | position: relative;
73 | display: flex;
74 | align-items: center;
75 |
76 | .search-icon {
77 | position: absolute;
78 | left: 0.8rem;
79 | color: rgba(255, 255, 255, 0.4);
80 | pointer-events: none;
81 | }
82 | }
83 |
84 | .search-input {
85 | background-color: var(--border-dark);
86 | border: 1px solid var(--border-soft);
87 | border-radius: 4px;
88 | color: var(--text-primary);
89 | padding: 0.6rem 1rem 0.6rem 2.5rem;
90 | font-size: 0.9rem;
91 | transition: all var(--duration-normal) var(--easing-ease-out);
92 | box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.2);
93 | min-width: 200px;
94 |
95 | &:focus {
96 | border-color: var(--primary-color);
97 | background-color: rgba(255, 255, 255, 0.1);
98 | outline: none;
99 | box-shadow:
100 | 0 0 0 2px rgba(var(--primary-color), 0.3),
101 | inset 0 2px 5px rgba(0, 0, 0, 0.2);
102 |
103 | & + .search-icon {
104 | color: var(--primary-color);
105 | }
106 | }
107 |
108 | &::placeholder {
109 | color: rgba(255, 255, 255, 0.4);
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/src/styles/components/layout/_index.scss:
--------------------------------------------------------------------------------
1 | @forward './background';
2 | @forward './header';
3 | @forward './loading_screen';
4 | @forward './sidebar';
5 |
--------------------------------------------------------------------------------
/src/styles/components/layout/_loading_screen.scss:
--------------------------------------------------------------------------------
1 | @use '../../themes/index' as *;
2 | @use '../../abstracts/index' as *;
3 |
4 | /*
5 | Initial loading screen styles
6 | */
7 | .initial-loading-screen {
8 | position: fixed;
9 | top: 0;
10 | left: 0;
11 | width: 100vw;
12 | height: 100vh;
13 | background-color: var(--primary-bg);
14 | display: flex;
15 | align-items: center;
16 | justify-content: center;
17 | z-index: var(--z-modal) + 1;
18 |
19 | .loading-content {
20 | text-align: center;
21 | padding: 2rem;
22 | max-width: 500px;
23 | width: 90%;
24 |
25 | h1 {
26 | font-size: 2.5rem;
27 | margin-bottom: 2rem;
28 | font-weight: var(--bold);
29 | color: var(--primary-color);
30 | text-shadow: 0 2px 10px rgba(var(--primary-color), 0.4);
31 | }
32 |
33 | .loading-animation {
34 | margin-bottom: 2rem;
35 | }
36 |
37 | .loading-circles {
38 | display: flex;
39 | justify-content: center;
40 | gap: 1rem;
41 | margin-bottom: 1rem;
42 |
43 | .circle {
44 | width: 20px;
45 | height: 20px;
46 | border-radius: 50%;
47 | animation: bounce 1.4s infinite ease-in-out both;
48 |
49 | &.circle-1 {
50 | background-color: var(--primary-color);
51 | animation-delay: -0.32s;
52 | }
53 |
54 | &.circle-2 {
55 | background-color: var(--cream-color);
56 | animation-delay: -0.16s;
57 | }
58 |
59 | &.circle-3 {
60 | background-color: var(--smoke-color);
61 | }
62 | }
63 | }
64 |
65 | .loading-message {
66 | font-size: 1.1rem;
67 | color: var(--text-secondary);
68 | margin-bottom: 1.5rem;
69 | min-height: 3rem;
70 | }
71 |
72 | .loading-status-log {
73 | margin: 1rem 0;
74 | text-align: left;
75 | max-height: 100px;
76 | overflow-y: auto;
77 | background-color: rgba(0, 0, 0, 0.2);
78 | border-radius: var(--radius-sm);
79 | padding: 0.5rem;
80 |
81 | .status-line {
82 | margin: 0.5rem 0;
83 | display: flex;
84 | align-items: center;
85 |
86 | .status-indicator {
87 | color: var(--primary-color);
88 | margin-right: 0.5rem;
89 | font-size: 1.2rem;
90 | }
91 |
92 | .status-text {
93 | color: var(--text-secondary);
94 | font-size: 0.9rem;
95 | }
96 |
97 | &:last-child {
98 | .status-indicator {
99 | color: var(--success);
100 | }
101 |
102 | .status-text {
103 | color: var(--text-primary);
104 | font-weight: 600;
105 | }
106 | }
107 | }
108 | }
109 |
110 | .progress-bar-container {
111 | height: 8px;
112 | background-color: var(--border-soft);
113 | border-radius: 4px;
114 | overflow: hidden;
115 | margin-bottom: 0.5rem;
116 | }
117 |
118 | .progress-bar {
119 | height: 100%;
120 | background-color: var(--primary-color);
121 | border-radius: 4px;
122 | transition: width 0.5s ease;
123 | background: linear-gradient(
124 | to right,
125 | var(--cream-color),
126 | var(--primary-color),
127 | var(--smoke-color)
128 | );
129 | box-shadow: 0px 0px 10px rgba(255, 200, 150, 0.4);
130 | }
131 |
132 | .progress-percentage {
133 | text-align: right;
134 | font-size: 0.875rem;
135 | color: var(--text-secondary);
136 | margin-bottom: 1rem;
137 | }
138 | }
139 | }
140 |
141 | // Animation for the bouncing circles
142 | @keyframes bounce {
143 | 0%,
144 | 80%,
145 | 100% {
146 | transform: scale(0);
147 | }
148 | 40% {
149 | transform: scale(1);
150 | }
151 | }
152 |
--------------------------------------------------------------------------------
/src/styles/components/layout/_sidebar.scss:
--------------------------------------------------------------------------------
1 | @use '../../themes/index' as *;
2 | @use '../../abstracts/index' as *;
3 |
4 | /*
5 | Sidebar component styles
6 | */
7 | .sidebar {
8 | width: var(--sidebar-width);
9 | min-width: var(--sidebar-width);
10 | background-color: var(--secondary-bg);
11 | border-right: 1px solid rgba(255, 255, 255, 0.05);
12 | box-shadow: inset -5px 0 15px rgba(0, 0, 0, 0.2);
13 | padding: 1.5rem 1rem;
14 | display: flex;
15 | flex-direction: column;
16 | height: 100%;
17 | overflow-y: auto;
18 | z-index: var(--z-elevate) + 1;
19 | @include custom-scrollbar;
20 | }
21 |
22 | .sidebar-header {
23 | margin-bottom: 1.5rem;
24 |
25 | h2 {
26 | color: var(--text-primary);
27 | font-size: 1.1rem;
28 | font-weight: 600;
29 | letter-spacing: 0.5px;
30 | opacity: 0.9;
31 | }
32 | }
33 |
34 | .filter-list {
35 | list-style: none;
36 | margin-bottom: 1.5rem;
37 |
38 | li {
39 | transition: all var(--duration-normal) var(--easing-ease-out);
40 | border-radius: var(--radius-sm);
41 | padding: 0.7rem 1rem;
42 | margin-bottom: 0.3rem;
43 | font-weight: 500;
44 | cursor: pointer;
45 |
46 | &:hover {
47 | background-color: rgba(255, 255, 255, 0.07);
48 | }
49 |
50 | &.active {
51 | background: linear-gradient(
52 | 135deg,
53 | var(--primary-color),
54 | color-mix(in srgb, black 10%, var(--primary-color))
55 | );
56 | box-shadow: 0 4px 10px rgba(var(--primary-color), 0.3);
57 | color: var(--elevated-bg);
58 |
59 | .filter-icon {
60 | color: var(--elevated-bg);
61 | }
62 | }
63 | }
64 | }
65 |
66 | .filter-item {
67 | display: flex;
68 | align-items: center;
69 | gap: 0.75rem;
70 |
71 | .filter-icon {
72 | flex-shrink: 0;
73 | }
74 | }
75 |
76 | // App logo styles
77 | .app-logo {
78 | display: flex;
79 | align-items: center;
80 | gap: 10px;
81 |
82 | svg {
83 | width: 28px;
84 | height: 28px;
85 | fill: var(--text-primary);
86 | filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/styles/components/notifications/_index.scss:
--------------------------------------------------------------------------------
1 | @forward './toast';
2 |
--------------------------------------------------------------------------------
/src/styles/components/notifications/_toast.scss:
--------------------------------------------------------------------------------
1 | @use '../../themes/index' as *;
2 | @use '../../abstracts/index' as *;
3 |
4 | /*
5 | Toast notification styles
6 | */
7 |
8 | // Toast container positioning
9 | .toast-container {
10 | position: fixed;
11 | z-index: var(--z-tooltip);
12 | display: flex;
13 | flex-direction: column;
14 | gap: 0.5rem;
15 | padding: 1rem;
16 | max-width: 380px;
17 |
18 | // Position variations
19 | &.top-right {
20 | top: 1rem;
21 | right: 1rem;
22 | align-items: flex-end;
23 | }
24 |
25 | &.top-left {
26 | top: 1rem;
27 | left: 1rem;
28 | align-items: flex-start;
29 | }
30 |
31 | &.bottom-right {
32 | bottom: 1rem;
33 | right: 1rem;
34 | align-items: flex-end;
35 | }
36 |
37 | &.bottom-left {
38 | bottom: 1rem;
39 | left: 1rem;
40 | align-items: flex-start;
41 | }
42 |
43 | &.top-center {
44 | top: 1rem;
45 | left: 50%;
46 | transform: translateX(-50%);
47 | align-items: center;
48 | }
49 |
50 | &.bottom-center {
51 | bottom: 1rem;
52 | left: 50%;
53 | transform: translateX(-50%);
54 | align-items: center;
55 | }
56 | }
57 |
58 | // Individual toast styling
59 | .toast {
60 | display: flex;
61 | align-items: flex-start;
62 | background-color: var(--elevated-bg);
63 | border-radius: var(--radius-md);
64 | box-shadow: var(--shadow-lg);
65 | padding: 0.75rem 1rem;
66 | max-width: 100%;
67 | min-width: 280px;
68 | opacity: 0;
69 | transform: translateY(10px);
70 | transition: all 0.3s var(--easing-ease-out);
71 | border-left: 4px solid;
72 | position: relative;
73 | cursor: default;
74 |
75 | &.visible {
76 | opacity: 1;
77 | transform: translateY(0);
78 | }
79 |
80 | &.closing {
81 | opacity: 0;
82 | transform: translateY(-10px);
83 | }
84 |
85 | // Type-specific styling
86 | &.toast-success {
87 | border-color: var(--success);
88 | .toast-icon {
89 | color: var(--success);
90 | }
91 | }
92 |
93 | &.toast-error {
94 | border-color: var(--danger);
95 | .toast-icon {
96 | color: var(--danger);
97 | }
98 | }
99 |
100 | &.toast-warning {
101 | border-color: var(--warning);
102 | .toast-icon {
103 | color: var(--warning);
104 | }
105 | }
106 |
107 | &.toast-info {
108 | border-color: var(--info);
109 | .toast-icon {
110 | color: var(--info);
111 | }
112 | }
113 |
114 | // Toast elements
115 | .toast-icon {
116 | flex-shrink: 0;
117 | font-size: 1.25rem;
118 | margin-right: 0.75rem;
119 | margin-top: 0.125rem;
120 | }
121 |
122 | .toast-content {
123 | flex: 1;
124 | min-width: 0; // Required for proper overflow handling
125 | }
126 |
127 | .toast-title {
128 | font-weight: 600;
129 | font-size: 0.95rem;
130 | margin-bottom: 0.25rem;
131 | color: var(--text-primary);
132 | }
133 |
134 | .toast-message {
135 | font-size: 0.875rem;
136 | color: var(--text-secondary);
137 | margin: 0;
138 | word-break: break-word;
139 | }
140 |
141 | .toast-close {
142 | background: none;
143 | border: none;
144 | color: var(--text-muted);
145 | font-size: 1.25rem;
146 | line-height: 1;
147 | padding: 0;
148 | cursor: pointer;
149 | margin-left: 0.5rem;
150 | transition: color 0.2s ease;
151 |
152 | &:hover {
153 | color: var(--text-primary);
154 | }
155 | }
156 | }
157 |
158 | // Animations for toast
159 | @keyframes toast-in {
160 | from {
161 | opacity: 0;
162 | transform: translateY(20px);
163 | }
164 | to {
165 | opacity: 1;
166 | transform: translateY(0);
167 | }
168 | }
169 |
170 | @keyframes toast-out {
171 | from {
172 | opacity: 1;
173 | transform: translateY(0);
174 | }
175 | to {
176 | opacity: 0;
177 | transform: translateY(-20px);
178 | }
179 | }
180 |
--------------------------------------------------------------------------------
/src/styles/main.scss:
--------------------------------------------------------------------------------
1 | /*
2 | Main SCSS entry point
3 | Import all partials in the correct order
4 | */
5 |
6 | /* Basic variables and mixins */
7 | @use 'abstracts/index' as *;
8 |
9 | @use 'themes/index' as *;
10 |
11 | /* Layout components */
12 | @use 'components/layout/index' as *;
13 |
14 | /* Game components */
15 | @use 'components/games/index' as *;
16 |
17 | /* Button components */
18 | @use 'components/buttons/index' as *;
19 |
20 | /* Dialog components */
21 | @use 'components/dialogs/index' as *;
22 |
23 | /* Notification components */
24 | @use 'components/notifications/index' as *;
25 |
26 | /* Icon components */
27 | @use 'components/icons/index' as *;
28 |
29 | /* Common components */
30 | @use 'components/common/index' as *;
31 |
32 | /* Page-specific styles */
33 | //@use 'pages/home';
34 |
--------------------------------------------------------------------------------
/src/styles/pages/_home.scss:
--------------------------------------------------------------------------------
1 | /*
2 | Home page specific styles
3 | */
4 |
5 | // Currently empty since most styles are component-based
6 | // Will be used for any specific home page layouts or adjustments
7 |
8 | .home-page {
9 | // Page-specific styles can be added here
10 | }
11 |
12 | // Page-specific media queries
13 | @include media-sm {
14 | // Small screen adjustments
15 | }
16 |
17 | @include media-md {
18 | // Medium screen adjustments
19 | }
20 |
21 | @include media-lg {
22 | // Large screen adjustments
23 | }
24 |
25 | @include media-xl {
26 | // Extra large screen adjustments
27 | }
28 |
--------------------------------------------------------------------------------
/src/styles/themes/_dark.scss:
--------------------------------------------------------------------------------
1 | /*
2 | Dark theme styles
3 | Contains variables specific to dark theme
4 | */
5 |
6 | :root {
7 | // Primary colors
8 | --primary-color: #ffc896;
9 | --secondary-color: #ffb278;
10 |
11 | // Background
12 | --primary-bg: #0f0f0f;
13 | --secondary-bg: #151515;
14 | --tertiary-bg: #121212;
15 | --elevated-bg: #1a1a1a;
16 | --disabled: #5e5e5e;
17 |
18 | // Text
19 | --text-primary: #f0f0f0;
20 | --text-secondary: #c8c8c8;
21 | --text-soft: #afafaf;
22 | --text-heavy: #1a1a1a;
23 | --text-muted: #4b4b4b;
24 |
25 | // Borders
26 | --border-dark: #1a1a1a;
27 | --border-soft: #282828;
28 | --border: #323232;
29 |
30 | // Status colors
31 | --success: #8cc893;
32 | --warning: #ffc896;
33 | --danger: #d96b6b;
34 | --info: #80b4ff;
35 |
36 | --success-light: #b0e0a9;
37 | --warning-light: #ffdcb9;
38 | --danger-light: #e69691;
39 | --info-light: #a8d2ff;
40 |
41 | --success-soft: rgba(176, 224, 169, 0.15);
42 | --warning-soft: rgba(247, 200, 111, 0.15);
43 | --danger-soft: rgba(230, 150, 145, 0.15);
44 | --info-soft: rgba(168, 210, 255, 0.15);
45 |
46 | // Feature colors
47 | --native: #8cc893;
48 | --proton: #ffc896;
49 | --cream: #80b4ff;
50 | --smoke: #fff096;
51 |
52 | --modal-backdrop: rgba(30, 30, 30, 0.95);
53 | }
54 |
--------------------------------------------------------------------------------
/src/styles/themes/_index.scss:
--------------------------------------------------------------------------------
1 | @forward './dark';
2 |
--------------------------------------------------------------------------------
/src/types/DlcInfo.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * DLC information interface
3 | */
4 | export interface DlcInfo {
5 | appid: string
6 | name: string
7 | enabled: boolean
8 | }
9 |
--------------------------------------------------------------------------------
/src/types/Game.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Game information interface
3 | */
4 | export interface Game {
5 | id: string
6 | title: string
7 | path: string
8 | platform?: string
9 | native: boolean
10 | api_files: string[]
11 | cream_installed?: boolean
12 | smoke_installed?: boolean
13 | installing?: boolean
14 | }
15 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Game'
2 | export * from './DlcInfo'
3 |
--------------------------------------------------------------------------------
/src/types/svg.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | declare module '*.svg' {
4 | import React from 'react'
5 |
6 | export const ReactComponent: React.FunctionComponent<
7 | React.SVGProps & { title?: string }
8 | >
9 |
10 | const src: string
11 | export default src
12 | }
13 |
14 | declare module '*.png' {
15 | const content: string
16 | export default content
17 | }
18 |
19 | declare module '*.jpg' {
20 | const content: string
21 | export default content
22 | }
23 |
24 | declare module '*.jpeg' {
25 | const content: string
26 | export default content
27 | }
28 |
29 | declare module '*.gif' {
30 | const content: string
31 | export default content
32 | }
33 |
--------------------------------------------------------------------------------
/src/utils/helpers.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * General-purpose utility functions
3 | */
4 |
5 | /**
6 | * Formats a timestamp in seconds to a human-readable string
7 | * @param seconds Number of seconds
8 | * @returns Formatted string (e.g., "5m 30s" or "30s")
9 | */
10 | export function formatTime(seconds: number): string {
11 | if (seconds < 60) {
12 | return `${Math.round(seconds)}s`
13 | }
14 |
15 | const minutes = Math.floor(seconds / 60)
16 | const remainingSeconds = Math.round(seconds % 60)
17 |
18 | if (remainingSeconds === 0) {
19 | return `${minutes}m`
20 | }
21 |
22 | return `${minutes}m ${remainingSeconds}s`
23 | }
24 |
25 | /**
26 | * Truncates a string if it exceeds the specified length
27 | * @param str String to truncate
28 | * @param maxLength Maximum length before truncation
29 | * @param suffix Suffix to append to truncated string (default: "...")
30 | * @returns Truncated string
31 | */
32 | export function truncateString(str: string, maxLength: number, suffix: string = '...'): string {
33 | if (str.length <= maxLength) {
34 | return str
35 | }
36 |
37 | return str.substring(0, maxLength - suffix.length) + suffix
38 | }
39 |
40 | /**
41 | * Debounces a function to limit how often it's called
42 | * @param fn Function to debounce
43 | * @param delay Delay in milliseconds
44 | * @returns Debounced function
45 | */
46 | export function debounce unknown>(
47 | fn: T,
48 | delay: number
49 | ): (...args: Parameters) => void {
50 | let timer: NodeJS.Timeout | null = null
51 |
52 | return function (...args: Parameters) {
53 | if (timer) {
54 | clearTimeout(timer)
55 | }
56 |
57 | timer = setTimeout(() => {
58 | fn(...args)
59 | }, delay)
60 | }
61 | }
62 |
63 | /**
64 | * Creates a throttled function that only invokes the provided function at most once per specified interval
65 | * @param fn Function to throttle
66 | * @param limit Interval in milliseconds
67 | * @returns Throttled function
68 | */
69 | export function throttle unknown>(
70 | fn: T,
71 | limit: number
72 | ): (...args: Parameters) => void {
73 | let lastCall = 0
74 |
75 | return function (...args: Parameters) {
76 | const now = Date.now()
77 |
78 | if (now - lastCall < limit) {
79 | return
80 | }
81 |
82 | lastCall = now
83 | return fn(...args)
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './helpers'
2 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "@/*": ["src/*"]
6 | },
7 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
8 | "target": "ES2020",
9 | "useDefineForClassFields": true,
10 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
11 | "module": "ESNext",
12 | "skipLibCheck": true,
13 |
14 | /* Bundler mode */
15 | "moduleResolution": "bundler",
16 | "allowImportingTsExtensions": true,
17 | "isolatedModules": true,
18 | "moduleDetection": "force",
19 | "noEmit": true,
20 | "jsx": "react-jsx",
21 |
22 | /* Linting */
23 | "strict": true,
24 | "noUnusedLocals": true,
25 | "noUnusedParameters": true,
26 | "noFallthroughCasesInSwitch": true,
27 | "noUncheckedSideEffectImports": true
28 | },
29 | "include": ["src"]
30 | }
31 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
4 | }
5 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 | import path from 'path'
4 | import svgr from 'vite-plugin-svgr'
5 |
6 | // https://vitejs.dev/config/
7 | export default defineConfig({
8 | resolve: {
9 | alias: {
10 | '@': path.resolve(__dirname, 'src'),
11 | },
12 | },
13 |
14 | plugins: [
15 | react(),
16 | svgr({
17 | svgrOptions: {
18 | // SVGR options go here
19 | icon: true,
20 | dimensions: false,
21 | titleProp: true,
22 | exportType: 'named',
23 | },
24 | include: '**/*.svg',
25 | }),
26 | ],
27 |
28 | clearScreen: false,
29 | server: {
30 | port: 1420,
31 | strictPort: true,
32 | },
33 | envPrefix: ['VITE_', 'TAURI_'],
34 | build: {
35 | target: ['es2021', 'chrome105', 'safari13'],
36 | minify: 'esbuild',
37 | sourcemap: true,
38 | },
39 | })
40 |
--------------------------------------------------------------------------------