From 640214ec3839e98c8fd2ec851d8e602b3083484a Mon Sep 17 00:00:00 2001 From: ddidderr Date: Tue, 19 May 2026 20:12:57 +0200 Subject: [PATCH] feat(tauri): implement Steam-style launcher redesign per design handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the previous monolithic 900-line `App.tsx` launcher UI with the Steam-inspired dark redesign specified in `design/README.md` (handoff committed in the previous commit). The new UI is split across small, single-responsibility React modules instead of one file. What changes from the user's perspective ---------------------------------------- - Dark, gradient-tinted background with sticky 64px top bar (glass blur + saturate). Single-row chrome (handoff variant A). - Pill-style filter toggle (`All Games` / `Local` / `Installed`) with an animated thumb that slides between options. - Search field with magnifying-glass icon and a `/` keyboard shortcut to focus it from anywhere outside an input. - Sort menu (Name A–Z / Size / Status) as a dropdown. - Game directory button shows the current path with leading-ellipsis truncation; clicking it opens the native folder picker. - Kebab menu hosts Settings, Refresh library, and Unpack logs (existing companion window). The standalone Unpack-Logs button is removed from the chrome. - Game grid uses CSS `auto-fill` minmax with three density presets (compact / normal / large) and three cover aspects (box / square / banner), persisted via the Settings dialog. - Game cards render with the real thumbnail when the backend has one (via `get_game_thumbnail`) and fall back to a procedurally-generated gradient + accent-blob placeholder with a Bebas Neue title burned in. Each card carries a color-coded state chip (Installed = green, Downloaded = amber, busy = pulsing accent), a peers chip when at least one peer holds the game, the title, size · genre meta line, a status line (errors in red), and a single color-coded primary action button: Play (green gradient), Update / Install (accent), Download (neutral), animated "busy" spinner during in-flight operations, or a disabled "Unavailable" state when no peer has the game. - Clicking anywhere on a card except the action button opens a detail modal: 16:7 hero (uses the thumbnail), state chip, tag pills derived from genre/publisher/release_year, large title, 4-cell meta grid (size, players from `max_players`, version from `local_version` or `eti_game_version` formatted YYYY.MM.DD, status), description, and an action row with the primary action plus an Uninstall ghost-danger button when the game is installed. Esc, scrim click, and the close button all dismiss the modal. - Settings dialog (opened from the kebab menu) lets the user change the accent color (six swatches), background style (flat / gradient / animated), grid density, and cover aspect. Changes apply live and persist immediately to the Tauri store under `launcher-settings.json` (key `ui-settings`); the existing `game-directory` key in the same file is unchanged. - Empty state when no directory is chosen offers a centered prompt with a single CTA. Empty state when filters/search match nothing shows a distinct "Nothing matches" message. Why this approach ----------------- The handoff selected variant A (single-row chrome) explicitly, so only that variant is implemented; variant B underlined tabs and the storage-meter widget are intentionally omitted (no free-space data available from the backend yet). Real cover art from `get_game_thumbnail` is preferred over the placeholder generator. When a thumbnail is present, the Bebas Neue title overlay is suppressed because shipped cover art already has its own title. When the thumbnail is absent, the placeholder gradient (with per-id stable hue/blob/angle) plus the burned-in title takes over — this is the same procedural look as the design reference. Architecture / file layout -------------------------- The previous single-file design is decomposed top-down: ``` src/ main.tsx entry; loads tokens + launcher CSS App.tsx thin router (main vs. unpack-logs view) styles/ tokens.css CSS custom props + body reset launcher.css port of the design reference styles.css (single-row chrome only) windows/ MainWindow.tsx composition root: top bar + grid + modals lib/ types.ts Game, InstallStatus, GameAvailability, ActiveOperationKind, GameFilter / GameSort, DerivedState gameState.ts derive() + isUnavailable + needsUpdate + primaryActionFor + actionLabel + mergeGameUpdate (event reconciliation) + countByFilter + applyFilterAndSort format.ts formatBytes, formatEtiVersion (YYYYMMDD), truncatePath, formatPlayers cover.ts coverColorsFor(id) — stable palette pick + gradient angle + blob position from id hash; titleFontSize store.ts file + key constants for plugin-store hooks/ useSettings.ts UISettings + accent/bg/density/aspect/ sort/filter, persisted via plugin-store useGameDirectory.ts loads + persists the chosen directory and pushes it to update_game_directory useGames.ts owns the games list; listens to every backend event (games-list-updated, game-download-begin/finished/failed/ peers-gone, game-no-peers, game-install- begin/finished/failed, game-uninstall- begin/finished/failed, peer-count-updated); exposes markChecking with a 5s fallback to clear "Checking peers…" when nothing comes back from the backend useGameActions.ts play / install / update / uninstall wrappers around the corresponding invoke commands useThumbnails.ts lazy per-id cache for get_game_thumbnail components/ Icon.tsx inline SVG icon set (currentColor) Brand.tsx brand mark + name + peer-count chip Modal.tsx generic scrim + panel + Esc handler StateChip.tsx corner pill with state-coded dot ActionButton.tsx color-coded primary action; disabled when unavailable; spinner when busy SegmentedRadio.tsx generic 3-way segmented control ColorSwatchPicker.tsx 6-swatch picker with check overlay topbar/ TopBar.tsx chrome composition SegmentedFilters.tsx All / Local / Installed with sliding thumb SearchField.tsx input + `/` shortcut SortMenu.tsx dropdown sort selector DirectoryButton.tsx folder picker trigger KebabMenu.tsx generic dropdown menu grid/ ResultsBar.tsx "Showing N of M games" GameGrid.tsx CSS-grid wrapper GameCard.tsx full card composition GameCover.tsx thumbnail OR placeholder cover art modals/ GameDetailModal.tsx hero + meta grid + actions SettingsDialog.tsx appearance + library preferences empty/ NoDirectoryState.tsx onboarding CTA EmptyResultsState.tsx "scanning" / "nothing matches" ``` `UnpackLogsWindow.tsx` and its CSS are untouched — the unpack-logs companion window is rendered as before via the existing `?view=unpack- logs` route in `App.tsx`. The previous `App.css` is removed entirely (its styles are superseded by `styles/launcher.css`). Bebas Neue is loaded via Google Fonts in `index.html` (preconnect + swap), used for the brand mark and the placeholder cover-art titles. Tradeoffs and intentional omissions ----------------------------------- - Storage meter: omitted. The handoff specifies installed/local/free bytes, but no Tauri command currently provides free-space data. - Variant B (two-row chrome with underline tabs): omitted; the handoff picked variant A. - "View files" action in the detail modal: omitted. The backend doesn't expose per-game install paths and `shell.open` of the user-chosen root directory would be misleading. - "Delete from disk" ghost-danger action for `local` games: omitted. No backend command currently distinguishes "delete downloaded archive" from `uninstall_game`. Only installed games get an Uninstall button. - "Recently Played" sort: omitted (no play-time tracking yet). The sort menu offers Name / Size / Status instead. - Keyboard arrow grid navigation: not yet implemented (out of scope per the handoff). - Per-game progress bar during downloads/installs: not implemented; the action button shows a spinner + "Downloading…" / "Installing…" label instead, matching the existing event-driven status text. Persistence ----------- UI preferences (accent, bg, density, aspect, sort, filter) live in `launcher-settings.json` under a new `ui-settings` key. The existing `game-directory` key in the same file is preserved untouched, so users keep their previously selected directory. Test plan --------- Frontend build verified locally: cd crates/lanspread-tauri-deno-ts && deno task build → `tsc && vite build` completes with no diagnostics; bundle ~228 kB. Manual verification (recommended once the app boots end-to-end): - [ ] Launch with no directory set: only the "Pick a game directory" empty state is visible; clicking the button opens the native folder picker. - [ ] Pick a directory: top bar appears, grid populates as games arrive. - [ ] Click the All / Local / Installed pills: the thumb slides; the count chips reflect the right subset. - [ ] Press `/`: focus moves to the search input; type a substring and confirm the grid filters live. - [ ] Open the Sort menu, switch between sorts; the grid reorders. - [ ] Open the Settings dialog from the kebab: change accent → the thumb, brand mark, search-focus ring, and Install button all switch color live. Change density → grid card size changes. Change cover aspect → cards re-shape (2/3, 1/1, 16/9). Close and reopen: choices are remembered. - [ ] Click anywhere on a card except the action button → detail modal opens with the right metadata; Esc / scrim click / close button all dismiss it. - [ ] Click the action button on an `installed` card → game launches. - [ ] Click the action button on a `local` card → install starts; button shows the spinner + "Installing…". - [ ] Click on a `none` card with peer_count > 0 → download starts; the lifecycle events update the button label correctly. - [ ] Card for a game with peer_count == 0 and not downloaded → button reads "Unavailable" and is disabled. - [ ] Trigger a `game-download-failed` from the backend: the error status line appears under the card title in red. - [ ] Open Unpack Logs from the kebab: the companion window opens exactly as before. Trailer ------- Refs: design/README.md (canonical handoff), design/design_reference/ --- crates/lanspread-tauri-deno-ts/index.html | 5 +- crates/lanspread-tauri-deno-ts/src/App.css | 351 ----- crates/lanspread-tauri-deno-ts/src/App.tsx | 914 +----------- .../src/components/ActionButton.tsx | 44 + .../src/components/Brand.tsx | 19 + .../src/components/ColorSwatchPicker.tsx | 35 + .../src/components/Icon.tsx | 93 ++ .../src/components/Modal.tsx | 36 + .../src/components/SegmentedRadio.tsx | 25 + .../src/components/StateChip.tsx | 27 + .../components/empty/EmptyResultsState.tsx | 14 + .../src/components/empty/NoDirectoryState.tsx | 20 + .../src/components/grid/GameCard.tsx | 68 + .../src/components/grid/GameCover.tsx | 63 + .../src/components/grid/GameGrid.tsx | 27 + .../src/components/grid/ResultsBar.tsx | 12 + .../src/components/modals/GameDetailModal.tsx | 109 ++ .../src/components/modals/SettingsDialog.tsx | 92 ++ .../src/components/topbar/DirectoryButton.tsx | 17 + .../src/components/topbar/KebabMenu.tsx | 58 + .../src/components/topbar/SearchField.tsx | 43 + .../components/topbar/SegmentedFilters.tsx | 51 + .../src/components/topbar/SortMenu.tsx | 60 + .../src/components/topbar/TopBar.tsx | 46 + .../src/hooks/useGameActions.ts | 55 + .../src/hooks/useGameDirectory.ts | 56 + .../src/hooks/useGames.ts | 252 ++++ .../src/hooks/useSettings.ts | 119 ++ .../src/hooks/useThumbnails.ts | 30 + .../lanspread-tauri-deno-ts/src/lib/cover.ts | 58 + .../lanspread-tauri-deno-ts/src/lib/format.ts | 29 + .../src/lib/gameState.ts | 212 +++ .../lanspread-tauri-deno-ts/src/lib/store.ts | 12 + .../lanspread-tauri-deno-ts/src/lib/types.ts | 66 + crates/lanspread-tauri-deno-ts/src/main.tsx | 3 + .../src/styles/launcher.css | 1249 +++++++++++++++++ .../src/styles/tokens.css | 50 + .../src/windows/MainWindow.tsx | 168 +++ 38 files changed, 3329 insertions(+), 1259 deletions(-) delete mode 100644 crates/lanspread-tauri-deno-ts/src/App.css create mode 100644 crates/lanspread-tauri-deno-ts/src/components/ActionButton.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/Brand.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/ColorSwatchPicker.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/Icon.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/Modal.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/SegmentedRadio.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/StateChip.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/empty/EmptyResultsState.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/empty/NoDirectoryState.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/grid/GameCard.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/grid/GameCover.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/grid/GameGrid.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/grid/ResultsBar.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/modals/SettingsDialog.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/topbar/DirectoryButton.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/topbar/KebabMenu.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/topbar/SearchField.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/topbar/SegmentedFilters.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/topbar/SortMenu.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/components/topbar/TopBar.tsx create mode 100644 crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts create mode 100644 crates/lanspread-tauri-deno-ts/src/hooks/useGameDirectory.ts create mode 100644 crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts create mode 100644 crates/lanspread-tauri-deno-ts/src/hooks/useSettings.ts create mode 100644 crates/lanspread-tauri-deno-ts/src/hooks/useThumbnails.ts create mode 100644 crates/lanspread-tauri-deno-ts/src/lib/cover.ts create mode 100644 crates/lanspread-tauri-deno-ts/src/lib/format.ts create mode 100644 crates/lanspread-tauri-deno-ts/src/lib/gameState.ts create mode 100644 crates/lanspread-tauri-deno-ts/src/lib/store.ts create mode 100644 crates/lanspread-tauri-deno-ts/src/lib/types.ts create mode 100644 crates/lanspread-tauri-deno-ts/src/styles/launcher.css create mode 100644 crates/lanspread-tauri-deno-ts/src/styles/tokens.css create mode 100644 crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx diff --git a/crates/lanspread-tauri-deno-ts/index.html b/crates/lanspread-tauri-deno-ts/index.html index ff93803..044113a 100644 --- a/crates/lanspread-tauri-deno-ts/index.html +++ b/crates/lanspread-tauri-deno-ts/index.html @@ -4,7 +4,10 @@ - Tauri + React + Typescript + + + + SoftLAN Launcher diff --git a/crates/lanspread-tauri-deno-ts/src/App.css b/crates/lanspread-tauri-deno-ts/src/App.css deleted file mode 100644 index 99646b5..0000000 --- a/crates/lanspread-tauri-deno-ts/src/App.css +++ /dev/null @@ -1,351 +0,0 @@ -body { - background-color: #000313; - font-family: Arial, sans-serif; - color: #D5DBFE; - margin: 0; - padding: 0; -} - -.fixed-header { - position: fixed; - top: 0; - left: 0; - right: 0; - background-color: #000313; - z-index: 1000; - padding-top: 20px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); -} - -h1.align-center { - margin: 0; - padding: 10px 0; -} - -.main-header { - width: 100%; -} - -.grid-container { - margin-top: 160px; /* Adjust based on your header height */ - padding: 20px; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 20px; -} - -.item { - display: flex; - flex-direction: column; - background: linear-gradient(to bottom, black, #000938); - color: white; - border: 1px solid #444; - border-radius: 8px; - overflow: hidden; - transition: background 0.3s; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); - cursor: pointer; - /* max-width: 280px; */ -} - -.item:hover { - background: linear-gradient(to bottom, black, #3849AB); -} - -.item img { - width: 280px; /* Fixed width */ - height: 200px; /* Fixed height */ - object-fit: cover; - display: block; /* Removes any unwanted spacing */ - margin: 0 auto; /* Centers the image if container is wider */ -} - -.item-name { - text-align: center; - margin: 10px 0; - font-weight: bold; - font-size: 1.1em; -} - -.description { - display: flex; - justify-content: space-between; - padding: 0 10px 10px 10px; - font-size: 0.9em; -} - -.badges { - display: flex; - min-height: 24px; - gap: 6px; - justify-content: center; - align-items: center; - padding: 0 10px 8px; -} - -.badge { - border: 1px solid #4866b9; - border-radius: 4px; - color: #D5DBFE; - font-size: 12px; - line-height: 1; - padding: 5px 7px; -} - -.badge.local-only { - border-color: #8b6f2a; - color: #f1d58a; -} - -.desc-text { - text-align: left; -} - -.size-text { - text-align: right; -} - -.align-center { - text-align: center; -} - -.play-button { - margin-top: auto; - margin-bottom: 2px; - padding: 15px 30px; - background: linear-gradient(45deg, #09305a, #37529c); - font-size: 18px; - font-weight: bold; - text-align: center; - text-decoration: none; - border-radius: 25px; - border: 1px solid transparent; - cursor: pointer; - transition: all 0.3s ease; - box-shadow: 0 8px 15px rgba(0, 191, 255, 0.2); -} - -.play-button:hover { - background: linear-gradient(45deg, #09305a, #4866b9); - box-shadow: 0 20px 25px rgba(0, 191, 255, 0.6); - border: 1px solid rgba(0, 191, 255, 0.6); - animation: flicker 0.2s infinite alternate; - transform: translateY(-2px); -} - -.play-button.unavailable { - background: linear-gradient(45deg, #330000, #550000); - color: #ffb4b4; - border: 1px solid #550000; - box-shadow: none; - cursor: default; - pointer-events: none; -} - -.play-button.unavailable:hover { - background: linear-gradient(45deg, #330000, #550000); - box-shadow: none; - border: 1px solid #550000; - animation: none; - transform: none; -} - -.uninstall-button { - align-self: center; - width: 34px; - height: 34px; - margin: 6px 0 0; - border-radius: 50%; - border: 1px solid #6c2942; - background: #2a0714; - color: #ffb4c8; - font-weight: bold; - cursor: pointer; -} - -.uninstall-button:hover { - border-color: #ff6d9d; - background: #4d1025; -} - -@keyframes flicker { - 0% { opacity: 1; } - 50% { opacity: 0.8; } - 100% { opacity: 1; } -} - -.search-container { - display: flex; - justify-content: center; -} - -.no-directory-container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 40px 20px; - gap: 20px; -} - -.no-directory-message { - color: #8892b0; - font-size: 18px; - text-align: center; -} - -.no-directory-button { - margin-top: 10px; -} - -.search-input { - width: 100%; - max-width: 400px; - padding: 10px 15px; - font-size: 16px; - color: #D5DBFE; - background: #000938; - border: 1px solid #444; - border-radius: 25px; - outline: none; - transition: all 0.3s ease; -} - -.search-input:focus { - border-color: #4866b9; - box-shadow: 0 0 10px rgba(0, 191, 255, 0.2); -} - -.search-input::placeholder { - color: #8892b0; -} - -.search-settings-wrapper { - display: grid; - grid-template-columns: 1fr auto 1fr; - align-items: center; - padding: 20px; -} - -.settings-container { - display: flex; - justify-content: flex-end; - align-items: center; - gap: 15px; -} - -.settings-button { - padding: 8px 16px; - background: linear-gradient(45deg, #09305a, #37529c); - color: #D5DBFE; - border: 1px solid transparent; - border-radius: 20px; - font-size: 14px; - cursor: pointer; - transition: all 0.3s ease; - box-shadow: 0 4px 8px rgba(0, 191, 255, 0.2); -} - -.settings-button:hover { - background: linear-gradient(45deg, #09305a, #4866b9); - box-shadow: 0 8px 12px rgba(0, 191, 255, 0.4); - border: 1px solid rgba(0, 191, 255, 0.6); - transform: translateY(-2px); -} - -.settings-text { - color: #8892b0; - font-size: 14px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 300px; -} - -.no-games-message { - grid-column: 1 / -1; - text-align: center; - color: #8892b0; - font-size: 18px; - padding: 40px 20px; - margin: 20px 0; - background: linear-gradient(to bottom, rgba(0, 9, 56, 0.3), rgba(0, 9, 56, 0.1)); - border: 1px solid #444; - border-radius: 8px; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); -} - -.item-info { - min-height: 18px; - margin: 8px 10px 16px; - font-size: 0.85em; - color: #8892b0; - text-align: center; -} - -.item-info.error { - color: #ff6666; -} - -.filter-container { - display: flex; - justify-content: center; - gap: 10px; - margin: 10px 0; -} - -.filter-button { - padding: 8px 16px; - background: linear-gradient(45deg, #09305a, #37529c); - color: #D5DBFE; - border: 1px solid transparent; - border-radius: 20px; - font-size: 14px; - cursor: pointer; - transition: all 0.3s ease; - box-shadow: 0 4px 8px rgba(0, 191, 255, 0.2); -} - -.filter-button:hover { - background: linear-gradient(45deg, #09305a, #4866b9); - box-shadow: 0 8px 12px rgba(0, 191, 255, 0.4); - transform: translateY(-2px); -} - -.filter-button.active { - background: linear-gradient(45deg, #09305a, #4866b9); - border: 1px solid rgba(0, 191, 255, 0.6); - box-shadow: 0 8px 12px rgba(0, 191, 255, 0.4); -} - -.item-info { - display: flex; - justify-content: space-between; - align-items: center; - min-height: 18px; - margin: 8px 10px 16px; - font-size: 0.85em; - color: #8892b0; - text-align: left; -} - -.status-left { - flex: 1; - text-align: left; -} - -.status-right { - text-align: right; -} - -.peer-count { - font-weight: bold; - color: #4866b9; -} - -.top-left-peer-count { - position: absolute; - top: 20px; - left: 20px; - z-index: 1001; -} diff --git a/crates/lanspread-tauri-deno-ts/src/App.tsx b/crates/lanspread-tauri-deno-ts/src/App.tsx index b7fa5f4..4871ec5 100644 --- a/crates/lanspread-tauri-deno-ts/src/App.tsx +++ b/crates/lanspread-tauri-deno-ts/src/App.tsx @@ -1,911 +1,11 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { invoke } from '@tauri-apps/api/core'; -import { listen } from '@tauri-apps/api/event'; -import { WebviewWindow } from '@tauri-apps/api/webviewWindow'; -import { open } from '@tauri-apps/plugin-dialog'; -import { load } from '@tauri-apps/plugin-store'; - -import "./App.css"; +import { MainWindow } from './windows/MainWindow'; import { UnpackLogsWindow, isUnpackLogsView } from './UnpackLogsWindow'; -const FILE_STORAGE = 'launcher-settings.json'; -const GAME_DIR_KEY = 'game-directory'; -const CHECKING_PEERS_TIMEOUT_MS = 5000; -const FALLBACK_THUMBNAIL = - 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8A8A'; -const STORE_OPTIONS = { - autoSave: true, - defaults: { - [GAME_DIR_KEY]: '', - }, -}; - -// enum with install status -enum InstallStatus { - NotInstalled = 'NotInstalled', - CheckingPeers = 'CheckingPeers', - Downloading = 'Downloading', - Installing = 'Installing', - Uninstalling = 'Uninstalling', - Installed = 'Installed', -} - -type StatusLevel = 'info' | 'error'; - -type GameFilter = 'all' | 'local' | 'installed'; - -interface Game { - id: string; - name: string; - description: string; - size: number; - thumbnail: Uint8Array | number[]; - downloaded: boolean; - installed: boolean; - availability: GameAvailability; - install_status: InstallStatus; - eti_game_version?: string; - local_version?: string; - status_message?: string; - status_level?: StatusLevel; - peer_count: number; -} - -enum GameAvailability { - Ready = 'Ready', - LocalOnly = 'LocalOnly', -} - -enum ActiveOperationKind { - Downloading = 'Downloading', - Installing = 'Installing', - Updating = 'Updating', - Uninstalling = 'Uninstalling', -} - -interface ActiveOperation { - id: string; - operation: ActiveOperationKind; -} - -interface GamesListPayload { - games: Game[]; - active_operations?: ActiveOperation[]; -} - -interface GameThumbnailProps { - gameId: string; - alt: string; - getThumbnailUrl: (gameId: string) => Promise; -} - -const GameThumbnail = ({ gameId, alt, getThumbnailUrl }: GameThumbnailProps) => { - const [thumbnailUrl, setThumbnailUrl] = useState(''); - - useEffect(() => { - let isMounted = true; - - const loadThumbnail = async () => { - const url = await getThumbnailUrl(gameId); - if (isMounted) { - setThumbnailUrl(url); - } - }; - - void loadThumbnail(); - - return () => { - isMounted = false; - }; - }, [gameId, getThumbnailUrl]); - - if (!thumbnailUrl) { - return null; - } - - return {alt}; -}; - -const IN_PROGRESS_INSTALL_STATUSES = new Set([ - InstallStatus.CheckingPeers, - InstallStatus.Downloading, - InstallStatus.Installing, - InstallStatus.Uninstalling, -]); - -const RECONCILED_OPERATION_STATUSES = new Set([ - InstallStatus.Downloading, - InstallStatus.Installing, - InstallStatus.Uninstalling, -]); - -const isInProgressInstallStatus = (status: InstallStatus): boolean => { - return IN_PROGRESS_INSTALL_STATUSES.has(status); -}; - -const isReconciledOperationStatus = (status: InstallStatus): boolean => { - return RECONCILED_OPERATION_STATUSES.has(status); -}; - -const installStatusFromActiveOperation = (operation: ActiveOperationKind): InstallStatus => { - switch (operation) { - case ActiveOperationKind.Downloading: - return InstallStatus.Downloading; - case ActiveOperationKind.Installing: - case ActiveOperationKind.Updating: - return InstallStatus.Installing; - case ActiveOperationKind.Uninstalling: - return InstallStatus.Uninstalling; - } -}; - -const activeStatusById = (activeOperations: ActiveOperation[] = []): Map => { - return new Map(activeOperations.map(operation => [ - operation.id, - installStatusFromActiveOperation(operation.operation), - ])); -}; - -const normalizeGamesListPayload = (payload: GamesListPayload | Game[]): GamesListPayload => { - if (Array.isArray(payload)) { - return { games: payload }; - } - return payload; -}; - -const mergeGameUpdate = ( - game: Game, - previous?: Game, - activeStatus?: InstallStatus, - hasAuthoritativeSnapshot = false, -): Game => { - let installStatus = InstallStatus.NotInstalled; - if (activeStatus !== undefined) { - installStatus = activeStatus; - } else if (game.installed) { - installStatus = InstallStatus.Installed; - } else if ( - previous - && isInProgressInstallStatus(previous.install_status) - && (!hasAuthoritativeSnapshot || previous.install_status === InstallStatus.CheckingPeers) - ) { - installStatus = previous.install_status; - } - - const localStateChanged = previous !== undefined - && (previous.installed !== game.installed || previous.downloaded !== game.downloaded); - const activeStateReconciled = hasAuthoritativeSnapshot - && (activeStatus !== undefined - || (previous !== undefined && isReconciledOperationStatus(previous.install_status))); - const clearStatus = localStateChanged || activeStateReconciled; - - return { - ...game, - availability: game.availability ?? (game.downloaded ? GameAvailability.Ready : GameAvailability.LocalOnly), - install_status: installStatus, - status_message: clearStatus ? undefined : previous?.status_message, - status_level: clearStatus ? undefined : previous?.status_level, - peer_count: game.peer_count ?? 0, - }; -}; - -const MainWindow = () => { - const [gameItems, setGameItems] = useState([]); - const [searchTerm, setSearchTerm] = useState(''); - const [gameDir, setGameDir] = useState(''); - const [currentFilter, setCurrentFilter] = useState('local'); - const [totalPeerCount, setTotalPeerCount] = useState(0); - const checkingPeersTimeouts = useRef>>({}); - const [thumbnails, setThumbnails] = useState>(new Map()); - - const getThumbnailUrl = useCallback(async (gameId: string): Promise => { - // Check cache first - if (thumbnails.has(gameId)) { - return thumbnails.get(gameId)!; - } - - try { - const thumbnailUrl = await invoke('get_game_thumbnail', { gameId }); - setThumbnails(prev => new Map(prev).set(gameId, thumbnailUrl)); - return thumbnailUrl; - } catch { - // Return a small placeholder for missing images - setThumbnails(prev => new Map(prev).set(gameId, FALLBACK_THUMBNAIL)); - return FALLBACK_THUMBNAIL; - } - }, [thumbnails]); - - const getFilteredGames = (games: Game[], filter: GameFilter): Game[] => { - switch (filter) { - case 'local': - // Games present on this machine, whether the archive is downloaded or already installed. - return games.filter(game => game.installed || game.downloaded); - case 'installed': - return games.filter(game => game.installed); - case 'all': - default: - // Games reachable on the LAN: held on this machine or advertised by another peer. - return games.filter(game => game.installed || game.downloaded || game.peer_count > 0); - } - }; - - const filteredAndSearchedGames = getFilteredGames(gameItems, currentFilter).filter(item => - item.name.toLowerCase().includes(searchTerm.toLowerCase()) - ); - - const clearCheckingPeersTimeout = (gameId: string) => { - const timeoutId = checkingPeersTimeouts.current[gameId]; - if (timeoutId !== undefined) { - clearTimeout(timeoutId); - delete checkingPeersTimeouts.current[gameId]; - } - }; - - const scheduleCheckingPeersFallback = (gameId: string, fallbackMessage?: string, fallbackLevel?: StatusLevel) => { - clearCheckingPeersTimeout(gameId); - checkingPeersTimeouts.current[gameId] = setTimeout(() => { - setGameItems(prev => prev.map(item => { - if (item.id !== gameId || item.install_status !== InstallStatus.CheckingPeers) { - return item; - } - return { - ...item, - install_status: item.installed ? InstallStatus.Installed : InstallStatus.NotInstalled, - status_message: fallbackMessage ?? 'No peers currently have this game.', - status_level: fallbackLevel ?? 'error', - }; - })); - delete checkingPeersTimeouts.current[gameId]; - }, CHECKING_PEERS_TIMEOUT_MS); - }; - - useEffect(() => { - return () => { - Object.values(checkingPeersTimeouts.current).forEach(clearTimeout); - checkingPeersTimeouts.current = {}; - }; - }, []); - - const getInitialGameDir = useCallback(async () => { - await new Promise(resolve => setTimeout(resolve, 1000)); - const store = await load(FILE_STORAGE, STORE_OPTIONS); - const savedGameDir = await store.get(GAME_DIR_KEY); - if (savedGameDir) { - setGameDir(savedGameDir); - } - }, []); - - useEffect(() => { - void getInitialGameDir(); - }, [getInitialGameDir]); - - useEffect(() => { - // Listen for game-download-failed events specifically - const setupDownloadFailedListener = async () => { - const unlisten = await listen('game-download-failed', (event) => { - const game_id = event.payload as string; - console.log(`❌ game-download-failed ${game_id} event received`); - clearCheckingPeersTimeout(game_id); - setGameItems(prev => prev.map(item => item.id === game_id - ? { - ...item, - install_status: item.installed ? InstallStatus.Installed : InstallStatus.NotInstalled, - status_message: 'Download failed. Please try again.', - status_level: 'error', - } - : item)); - - // Convert to string explicitly and verify it's not empty - const pathString = String(gameDir); - if (!pathString) { - console.error('gameDir is empty before invoke!'); - return; - } - invoke('update_game_directory', { path: pathString }) - .catch(error => console.error('❌ Error updating game directory:', error)); - }); - return unlisten; - }; - - const setupPeersGoneListener = async () => { - const unlisten = await listen('game-download-peers-gone', (event) => { - const game_id = event.payload as string; - console.log(`❌ game-download-peers-gone ${game_id} event received`); - clearCheckingPeersTimeout(game_id); - setGameItems(prev => prev.map(item => item.id === game_id - ? { - ...item, - install_status: item.installed ? InstallStatus.Installed : InstallStatus.NotInstalled, - status_message: 'Failed: All Peers gone', - status_level: 'error', - } - : item)); - - const pathString = String(gameDir); - if (!pathString) { - console.error('gameDir is empty before invoke!'); - return; - } - invoke('update_game_directory', { path: pathString }) - .catch(error => console.error('❌ Error updating game directory:', error)); - }); - return unlisten; - }; - - const setupNoPeersListener = async () => { - const unlisten = await listen('game-no-peers', (event) => { - const game_id = event.payload as string; - console.log(`⚠️ game-no-peers ${game_id} event received`); - clearCheckingPeersTimeout(game_id); - setGameItems(prev => prev.map(item => item.id === game_id - ? { - ...item, - install_status: item.installed ? InstallStatus.Installed : InstallStatus.NotInstalled, - status_message: 'No peers currently have this game.', - status_level: 'error', - } - : item)); - }); - return unlisten; - }; - - setupDownloadFailedListener(); - setupPeersGoneListener(); - setupNoPeersListener(); - - const setupPeerCountListener = async () => { - const unlisten = await listen('peer-count-updated', (event) => { - const count = event.payload as number; - console.log(`🗲 peer-count-updated ${count} event received`); - setTotalPeerCount(count); - }); - return unlisten; - }; - - setupPeerCountListener(); - }, [gameDir]); - - useEffect(() => { - // Listen for game-install-finished events specifically - const setupInstallFinishedListener = async () => { - const unlisten = await listen('game-install-finished', (event) => { - const game_id = event.payload as string; - console.log(`🗲 game-install-finished ${game_id} event received`); - clearCheckingPeersTimeout(game_id); - setGameItems(prev => prev.map(item => item.id === game_id - ? { - ...item, - install_status: InstallStatus.Installed, - status_message: undefined, - status_level: undefined, - } - : item)); - - // Convert to string explicitly and verify it's not empty - const pathString = String(gameDir); - if (!pathString) { - console.error('gameDir is empty before invoke!'); - return; - } - invoke('update_game_directory', { path: pathString }) - .catch(error => console.error('❌ Error updating game directory:', error)); - }); - return unlisten; - }; - - setupInstallFinishedListener(); - }, [gameDir]); - - useEffect(() => { - if (gameDir) { - // store game directory in persistent storage - const updateStorage = async (game_dir: string) => { - try { - const store = await load(FILE_STORAGE, STORE_OPTIONS); - await store.set(GAME_DIR_KEY, game_dir); - console.info(`📦 Storage updated with game directory: ${game_dir}`); - } catch (error) { - console.error('❌ Error updating storage:', error); - } - }; - - updateStorage(gameDir); - - console.log(`📂 Game directory changed to: ${gameDir}`); - invoke('update_game_directory', { path: gameDir }) - .catch(error => console.error('❌ Error updating game directory:', error)); - } - }, [gameDir]); - - useEffect(() => { - console.log('🔵 Effect starting - setting up listener and requesting games'); - - const setupEventListener = async () => { - try { - // Listen for games-list-updated events - const unlisten_games = await listen('games-list-updated', (event) => { - console.log('🗲 Received games-list-updated event'); - const payload = normalizeGamesListPayload(event.payload as GamesListPayload | Game[]); - const games = payload.games; - const activeStatuses = activeStatusById(payload.active_operations); - const hasAuthoritativeSnapshot = payload.active_operations !== undefined; - console.log(`🎮 ${games.length} Games received`); - setGameItems(prev => { - const previousById = new Map(prev.map(item => [item.id, item])); - return games.map(game => mergeGameUpdate( - game, - previousById.get(game.id), - activeStatuses.get(game.id), - hasAuthoritativeSnapshot, - )); - }); - void getInitialGameDir(); - }); - - // Listen for game-download-begin events - const unlisten_game_download_begin = await listen('game-download-begin', (event) => { - const game_id = event.payload as string; - console.log(`🗲 game-download-begin ${game_id} event received`); - clearCheckingPeersTimeout(game_id); - setGameItems(prev => prev.map(item => item.id === game_id - ? { - ...item, - install_status: InstallStatus.Downloading, - status_message: undefined, - status_level: undefined, - } - : item)); - }); - - // Listen for game-download-finished events - const unlisten_game_download_finished = await listen('game-download-finished', (event) => { - const game_id = event.payload as string; - console.log(`🗲 game-download-finished ${game_id} event received`); - clearCheckingPeersTimeout(game_id); - setGameItems(prev => prev.map(item => item.id === game_id - ? { - ...item, - install_status: InstallStatus.Installing, - status_message: undefined, - status_level: undefined, - } - : item)); - }); - - const unlisten_game_install_begin = await listen('game-install-begin', (event) => { - const game_id = event.payload as string; - console.log(`🗲 game-install-begin ${game_id} event received`); - clearCheckingPeersTimeout(game_id); - setGameItems(prev => prev.map(item => item.id === game_id - ? { - ...item, - install_status: InstallStatus.Installing, - status_message: undefined, - status_level: undefined, - } - : item)); - }); - - const unlisten_game_install_failed = await listen('game-install-failed', (event) => { - const game_id = event.payload as string; - console.log(`❌ game-install-failed ${game_id} event received`); - clearCheckingPeersTimeout(game_id); - setGameItems(prev => prev.map(item => item.id === game_id - ? { - ...item, - install_status: item.installed ? InstallStatus.Installed : InstallStatus.NotInstalled, - status_message: 'Install failed. Please try again.', - status_level: 'error', - } - : item)); - }); - - const unlisten_game_uninstall_begin = await listen('game-uninstall-begin', (event) => { - const game_id = event.payload as string; - console.log(`🗲 game-uninstall-begin ${game_id} event received`); - setGameItems(prev => prev.map(item => item.id === game_id - ? { - ...item, - install_status: InstallStatus.Uninstalling, - status_message: undefined, - status_level: undefined, - } - : item)); - }); - - const unlisten_game_uninstall_finished = await listen('game-uninstall-finished', (event) => { - const game_id = event.payload as string; - console.log(`🗲 game-uninstall-finished ${game_id} event received`); - setGameItems(prev => prev.map(item => item.id === game_id - ? { - ...item, - installed: false, - install_status: InstallStatus.NotInstalled, - status_message: undefined, - status_level: undefined, - } - : item)); - - }); - - const unlisten_game_uninstall_failed = await listen('game-uninstall-failed', (event) => { - const game_id = event.payload as string; - console.log(`❌ game-uninstall-failed ${game_id} event received`); - setGameItems(prev => prev.map(item => item.id === game_id - ? { - ...item, - install_status: item.installed ? InstallStatus.Installed : InstallStatus.NotInstalled, - status_message: 'Uninstall failed. Please try again.', - status_level: 'error', - } - : item)); - }); - - // Initial request for games - console.log('📤 Requesting initial games list'); - await invoke('request_games'); - - // Cleanup function - return () => { - console.log('🧹 Cleaning up - removing listener'); - unlisten_games(); - unlisten_game_download_begin(); - unlisten_game_download_finished(); - unlisten_game_install_begin(); - unlisten_game_install_failed(); - unlisten_game_uninstall_begin(); - unlisten_game_uninstall_finished(); - unlisten_game_uninstall_failed(); - }; - } catch (error) { - console.error('❌ Error in setup:', error); - } - }; - - setupEventListener(); - - // Cleanup - return () => { - console.log('🚫 Effect cleanup - component unmounting'); - }; - }, []); // Empty dependency array means this runs once on mount - - const runGame = async (id: string) => { - console.log(`🎯 Running game with id=${id}`); - try { - const result = await invoke('run_game', { id }); - console.log(`✅ Game started, result=${result}`); - } catch (error) { - console.error('❌ Error running game:', error); - } - }; - - const installGame = async (id: string) => { - console.log(`🎯 Installing game with id=${id}`); - try { - const success = await invoke('install_game', { id }); - if (success) { - console.log(`✅ Game install for id=${id} started...`); - let fallbackMessage: string | undefined; - let fallbackLevel: StatusLevel | undefined; - // update install status in gameItems for this game - setGameItems(prev => prev.map(item => { - if (item.id === id) { - fallbackMessage = item.status_message; - fallbackLevel = item.status_level; - return { - ...item, - install_status: InstallStatus.CheckingPeers, - }; - } - return item; - })); - scheduleCheckingPeersFallback(id, fallbackMessage, fallbackLevel); - } else { - // game is already being installed - console.warn(`🚧 Game with id=${id} is already being installed`); - } - } catch (error) { - console.error('❌ Error installing game:', error); - } - }; - - const updateGame = async (id: string) => { - console.log(`🎯 Updating game with id=${id}`); - try { - const success = await invoke('update_game', { id }); - if (success) { - console.log(`✅ Game update for id=${id} started...`); - let fallbackMessage: string | undefined; - let fallbackLevel: StatusLevel | undefined; - // update install status in gameItems for this game - setGameItems(prev => prev.map(item => { - if (item.id === id) { - fallbackMessage = item.status_message; - fallbackLevel = item.status_level; - return { - ...item, - install_status: InstallStatus.CheckingPeers, - }; - } - return item; - })); - scheduleCheckingPeersFallback(id, fallbackMessage, fallbackLevel); - } else { - // game is already being installed/updated - console.warn(`🚧 Game with id=${id} is already being updated`); - } - } catch (error) { - console.error('❌ Error updating game:', error); - } - }; - - const uninstallGame = async (id: string) => { - console.log(`🎯 Uninstalling game with id=${id}`); - try { - const success = await invoke('uninstall_game', { id }); - if (success) { - setGameItems(prev => prev.map(item => item.id === id - ? { - ...item, - install_status: InstallStatus.Uninstalling, - status_message: undefined, - status_level: undefined, - } - : item)); - } - } catch (error) { - console.error('❌ Error uninstalling game:', error); - } - }; - - const needsUpdate = (game: Game): boolean => { - if (!game.installed) return false; - - // Check if peers have a version and we have a local version - const peerVersion = game.eti_game_version; - const localVersion = game.local_version; - - // If we don't have local version but peers have one, we need update - if (!localVersion && peerVersion) { - return true; - } - - // If we have both versions, compare them numerically - if (localVersion && peerVersion) { - const localNum = parseInt(localVersion, 10); - const peerNum = parseInt(peerVersion, 10); - return peerNum > localNum; - } - - return false; - }; - - const getInProgressLabel = (game: Game): string | undefined => { - switch (game.install_status) { - case InstallStatus.CheckingPeers: - return 'Checking peers...'; - case InstallStatus.Downloading: - return 'Downloading...'; - case InstallStatus.Installing: - return 'Installing...'; - case InstallStatus.Uninstalling: - return 'Uninstalling...'; - default: - return undefined; - } - }; - - const isUnavailable = (game: Game): boolean => { - return !game.installed - && !game.downloaded - && game.peer_count === 0 - && game.install_status === InstallStatus.NotInstalled; - }; - - const getActionLabel = (game: Game): string => { - const inProgress = getInProgressLabel(game); - if (inProgress) { - return inProgress; - } - - if (isUnavailable(game)) { - return 'Unavailable'; - } - - if (!game.installed) { - return game.downloaded ? 'Install' : 'Download'; - } - - if (needsUpdate(game)) { - return 'Update'; - } - - return 'Play'; - }; - - const dialogGameDir = async () => { - const file = await open({ - multiple: false, - directory: true, - }); - - if (file) { - setGameDir(file); - } - }; - - const openUnpackLogsWindow = async () => { - try { - const existing = await WebviewWindow.getByLabel('unpack-logs'); - if (existing) { - await existing.setFocus(); - return; - } - - const logWindow = new WebviewWindow('unpack-logs', { - url: '/?view=unpack-logs', - title: 'Unpack Logs', - width: 900, - height: 700, - resizable: true, - }); - await logWindow.once('tauri://error', (event) => { - console.error('Error opening unpack logs window:', event.payload); - }); - } catch (error) { - console.error('Error opening unpack logs window:', error); - } - }; - - return ( -
-
-
- {totalPeerCount > 0 && ( - - 👥 {totalPeerCount} - - )} -
-

SoftLAN Launcher

-
- {gameDir ? ( -
-
- - - -
-
-
-
- setSearchTerm(e.target.value)} - className="search-input" - /> -
-
- - - {gameDir} -
-
-
- ) : ( -
-
- Please set a game directory to start scanning for games... -
-
- -
-
- )} -
-
-
- {gameDir && filteredAndSearchedGames.length === 0 && gameItems.length === 0 ? ( -
- Scanning for games in your directory... -
- ) : gameDir && filteredAndSearchedGames.length === 0 && gameItems.length > 0 ? ( -
- No games found matching your search and filters. -
- ) : null} - {filteredAndSearchedGames.map((item) => ( -
- -
{item.name}
-
- {item.description.slice(0, 10)} - {(item.size / 1024 / 1024 / 1024).toFixed(1)} GB -
-
- {item.installed && item.availability === GameAvailability.LocalOnly && ( - LocalOnly - )} - {!item.installed && item.downloaded && item.local_version && ( - v{item.local_version} - )} -
-
{ - if (isUnavailable(item)) { - return; - } - - if (!item.installed) { - installGame(item.id); - } else if (needsUpdate(item)) { - updateGame(item.id); - } else { - runGame(item.id); - } - }}> - {getActionLabel(item)} -
- {item.installed && !isInProgressInstallStatus(item.install_status) && ( - - )} -
-
- {item.status_message && item.peer_count === 0 && !item.installed && !item.downloaded ? item.status_message : ''} -
-
- {item.peer_count > 0 && ( - - 👥 {item.peer_count} - - )} -
-
-
- ))} -
-
- ); -}; - -const App = () => { - return isUnpackLogsView() ? : ; -}; +/** + * Tauri can spawn this bundle in either the main launcher window or the + * unpack-logs companion window. The URL query string disambiguates the two so + * a single Vite build serves both. + */ +const App = () => (isUnpackLogsView() ? : ); export default App; diff --git a/crates/lanspread-tauri-deno-ts/src/components/ActionButton.tsx b/crates/lanspread-tauri-deno-ts/src/components/ActionButton.tsx new file mode 100644 index 0000000..1a44ed8 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/ActionButton.tsx @@ -0,0 +1,44 @@ +import { JSX, MouseEvent } from 'react'; + +import { Icon } from './Icon'; +import { Game } from '../lib/types'; +import { actionLabel, primaryActionFor, PrimaryAction } from '../lib/gameState'; + +interface Props { + game: Game; + size?: 'md' | 'lg'; + full?: boolean; + onClick: () => void; +} + +const ICON_FOR_ACTION: Partial> = { + play: , + install: , + update: , + download: , +}; + +/** Color-coded primary action: Play / Install / Update / Download / busy. */ +export const ActionButton = ({ game, size = 'md', full = false, onClick }: Props) => { + const action = primaryActionFor(game); + const cls = [ + 'act-btn', + `act-${action}`, + size === 'lg' ? 'act-lg' : '', + full ? 'act-full' : '', + ].filter(Boolean).join(' '); + const disabled = action === 'busy' || action === 'disabled'; + + const handle = (e: MouseEvent) => { + e.stopPropagation(); + if (disabled) return; + onClick(); + }; + + return ( + + ); +}; diff --git a/crates/lanspread-tauri-deno-ts/src/components/Brand.tsx b/crates/lanspread-tauri-deno-ts/src/components/Brand.tsx new file mode 100644 index 0000000..9e37b09 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/Brand.tsx @@ -0,0 +1,19 @@ +interface Props { + peerCount: number; +} + +export const Brand = ({ peerCount }: Props) => ( +
+
S
+
SoftLAN
+ {peerCount > 0 && ( + + + {peerCount} + + )} +
+); diff --git a/crates/lanspread-tauri-deno-ts/src/components/ColorSwatchPicker.tsx b/crates/lanspread-tauri-deno-ts/src/components/ColorSwatchPicker.tsx new file mode 100644 index 0000000..cee1446 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/ColorSwatchPicker.tsx @@ -0,0 +1,35 @@ +import { Icon } from './Icon'; + +interface Swatch { + value: string; + label: string; +} + +interface Props { + value: string; + options: ReadonlyArray; + onChange: (value: string) => void; +} + +export const ColorSwatchPicker = ({ value, options, onChange }: Props) => ( +
+ {options.map(o => ( + + ))} +
+); diff --git a/crates/lanspread-tauri-deno-ts/src/components/Icon.tsx b/crates/lanspread-tauri-deno-ts/src/components/Icon.tsx new file mode 100644 index 0000000..c2dede7 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/Icon.tsx @@ -0,0 +1,93 @@ +import { JSX, SVGProps } from 'react'; + +type Props = SVGProps; + +const baseStroke: Partial = { + fill: 'none', + stroke: 'currentColor', + strokeLinecap: 'round', + strokeLinejoin: 'round', +}; + +export const Icon = { + search: (p: Props) => ( + + + + + ), + play: (p: Props) => ( + + + + ), + install: (p: Props) => ( + + + + + + ), + download: (p: Props) => ( + + + + + + ), + folder: (p: Props) => ( + + + + ), + kebab: (p: Props) => ( + + + + + + ), + sort: (p: Props) => ( + + + + + + ), + users: (p: Props) => ( + + + + + + + ), + close: (p: Props) => ( + + + + ), + check: (p: Props) => ( + + + + ), + chevron: (p: Props) => ( + + + + ), + trash: (p: Props) => ( + + + + + + ), + games: (p: Props) => ( + + + + + ), +} satisfies Record JSX.Element>; diff --git a/crates/lanspread-tauri-deno-ts/src/components/Modal.tsx b/crates/lanspread-tauri-deno-ts/src/components/Modal.tsx new file mode 100644 index 0000000..d4f7b10 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/Modal.tsx @@ -0,0 +1,36 @@ +import { ReactNode, useEffect } from 'react'; + +interface Props { + onClose: () => void; + children: ReactNode; + className?: string; +} + +/** + * Generic modal scrim + panel container. Closes on scrim click and Esc. + * Click events inside the panel are stopped so children can decide their own + * dismiss behaviour. + */ +export const Modal = ({ onClose, children, className }: Props) => { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return ( +
+
e.stopPropagation()} + > + {children} +
+
+ ); +}; diff --git a/crates/lanspread-tauri-deno-ts/src/components/SegmentedRadio.tsx b/crates/lanspread-tauri-deno-ts/src/components/SegmentedRadio.tsx new file mode 100644 index 0000000..320387f --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/SegmentedRadio.tsx @@ -0,0 +1,25 @@ +interface Option { + value: T; + label: string; +} + +interface Props { + value: T; + options: ReadonlyArray>; + onChange: (value: T) => void; +} + +export const SegmentedRadio = ({ value, options, onChange }: Props) => ( +
+ {options.map(o => ( + + ))} +
+); diff --git a/crates/lanspread-tauri-deno-ts/src/components/StateChip.tsx b/crates/lanspread-tauri-deno-ts/src/components/StateChip.tsx new file mode 100644 index 0000000..c439cc3 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/StateChip.tsx @@ -0,0 +1,27 @@ +import { Game } from '../lib/types'; +import { deriveState } from '../lib/gameState'; + +const LABELS: Record = { + installed: 'Installed', + local: 'Local', + busy: 'Working', + none: '', +}; + +interface Props { + game: Game; + /** Render even for `none` (used in the detail modal). */ + showNone?: boolean; +} + +export const StateChip = ({ game, showNone = false }: Props) => { + const state = deriveState(game); + const label = LABELS[state] ?? ''; + if (!label && !showNone) return null; + return ( +
+ + {label || 'Not downloaded'} +
+ ); +}; diff --git a/crates/lanspread-tauri-deno-ts/src/components/empty/EmptyResultsState.tsx b/crates/lanspread-tauri-deno-ts/src/components/empty/EmptyResultsState.tsx new file mode 100644 index 0000000..70315a2 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/empty/EmptyResultsState.tsx @@ -0,0 +1,14 @@ +import { Icon } from '../Icon'; + +interface Props { + title: string; + hint: string; +} + +export const EmptyResultsState = ({ title, hint }: Props) => ( +
+
+

{title}

+

{hint}

+
+); diff --git a/crates/lanspread-tauri-deno-ts/src/components/empty/NoDirectoryState.tsx b/crates/lanspread-tauri-deno-ts/src/components/empty/NoDirectoryState.tsx new file mode 100644 index 0000000..0097825 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/empty/NoDirectoryState.tsx @@ -0,0 +1,20 @@ +import { Icon } from '../Icon'; + +interface Props { + onChooseDirectory: () => void; +} + +export const NoDirectoryState = ({ onChooseDirectory }: Props) => ( +
+
+

Pick a game directory

+

+ SoftLAN scans the folder you point it at for installable game bundles + and tracks what your peers on the LAN have available. +

+ +
+); diff --git a/crates/lanspread-tauri-deno-ts/src/components/grid/GameCard.tsx b/crates/lanspread-tauri-deno-ts/src/components/grid/GameCard.tsx new file mode 100644 index 0000000..e153d08 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/grid/GameCard.tsx @@ -0,0 +1,68 @@ +import { JSX, KeyboardEvent } from 'react'; + +import { Game } from '../../lib/types'; +import { CoverAspect } from '../../hooks/useSettings'; +import { formatBytes } from '../../lib/format'; + +import { GameCover } from './GameCover'; +import { StateChip } from '../StateChip'; +import { ActionButton } from '../ActionButton'; +import { Icon } from '../Icon'; + +interface Props { + game: Game; + aspect: CoverAspect; + thumbnailUrl: string | null; + onOpen: (game: Game) => void; + onPrimary: (game: Game) => void; +} + +const metaSeparator = (...parts: Array): JSX.Element[] => { + const filtered = parts.filter(Boolean) as string[]; + const out: JSX.Element[] = []; + filtered.forEach((p, i) => { + if (i > 0) out.push(·); + out.push({p}); + }); + return out; +}; + +export const GameCard = ({ game, aspect, thumbnailUrl, onOpen, onPrimary }: Props) => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onOpen(game); + } + }; + + return ( + + ); +}; diff --git a/crates/lanspread-tauri-deno-ts/src/components/grid/GameCover.tsx b/crates/lanspread-tauri-deno-ts/src/components/grid/GameCover.tsx new file mode 100644 index 0000000..6d01842 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/grid/GameCover.tsx @@ -0,0 +1,63 @@ +import { CSSProperties, useMemo } from 'react'; + +import { Game } from '../../lib/types'; +import { coverColorsFor, titleFontSize } from '../../lib/cover'; +import { CoverAspect } from '../../hooks/useSettings'; + +interface Props { + game: Game; + aspect: CoverAspect; + thumbnailUrl?: string | null; + /** Hide the cover-bottom title overlay (used inside the detail modal hero). */ + hideTitle?: boolean; +} + +/** + * Cover art. When a real thumbnail is available it's rendered as the + * background image with the same gradient/vignette overlays as the + * placeholder; otherwise the design's procedurally-generated gradient stands + * in. The Bebas Neue title overlay is rendered on top of either. + */ +export const GameCover = ({ game, aspect, thumbnailUrl, hideTitle = false }: Props) => { + const colors = useMemo(() => coverColorsFor(game.id), [game.id]); + const hasThumbnail = Boolean(thumbnailUrl); + // Real cover art already contains its own title; only burn the Bebas Neue + // overlay onto the procedurally-generated placeholder. + const showOverlayTitle = !hideTitle && !hasThumbnail; + const titleStyle: CSSProperties = { + fontSize: titleFontSize(game.name, aspect), + textShadow: `0 4px 16px ${colors.c2}aa, 0 1px 0 rgba(0,0,0,.3)`, + }; + + return ( +
+ {hasThumbnail ? ( + + ) : ( + <> +
+
+ + )} +
+ {showOverlayTitle && ( +
+
+ {game.name} +
+
+ )} +
+
+ ); +}; diff --git a/crates/lanspread-tauri-deno-ts/src/components/grid/GameGrid.tsx b/crates/lanspread-tauri-deno-ts/src/components/grid/GameGrid.tsx new file mode 100644 index 0000000..245f960 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/grid/GameGrid.tsx @@ -0,0 +1,27 @@ +import { Game } from '../../lib/types'; +import { CoverAspect } from '../../hooks/useSettings'; + +import { GameCard } from './GameCard'; + +interface Props { + games: Game[]; + aspect: CoverAspect; + getThumbnail: (id: string) => string | null; + onOpen: (game: Game) => void; + onPrimary: (game: Game) => void; +} + +export const GameGrid = ({ games, aspect, getThumbnail, onOpen, onPrimary }: Props) => ( +
+ {games.map(g => ( + + ))} +
+); diff --git a/crates/lanspread-tauri-deno-ts/src/components/grid/ResultsBar.tsx b/crates/lanspread-tauri-deno-ts/src/components/grid/ResultsBar.tsx new file mode 100644 index 0000000..5f86385 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/grid/ResultsBar.tsx @@ -0,0 +1,12 @@ +interface Props { + shown: number; + total: number; +} + +export const ResultsBar = ({ shown, total }: Props) => ( +
+
+ Showing {shown} of {total} games +
+
+); diff --git a/crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx b/crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx new file mode 100644 index 0000000..2332051 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx @@ -0,0 +1,109 @@ +import { Modal } from '../Modal'; +import { Icon } from '../Icon'; +import { GameCover } from '../grid/GameCover'; +import { StateChip } from '../StateChip'; +import { ActionButton } from '../ActionButton'; + +import { Game } from '../../lib/types'; +import { deriveState } from '../../lib/gameState'; +import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format'; + +interface Props { + game: Game; + thumbnailUrl: string | null; + onClose: () => void; + onPrimary: (game: Game) => void; + onUninstall: (game: Game) => void; +} + +const tagsFromGame = (game: Game): string[] => { + const tags: string[] = []; + if (game.genre) tags.push(game.genre); + if (game.publisher) tags.push(game.publisher); + if (game.release_year) tags.push(game.release_year); + return tags; +}; + +const statusLabelFor = (game: Game): string => { + switch (deriveState(game)) { + case 'installed': return 'Installed'; + case 'local': return 'Downloaded'; + case 'busy': return 'Working…'; + case 'none': return 'Not downloaded'; + } +}; + +export const GameDetailModal = ({ game, thumbnailUrl, onClose, onPrimary, onUninstall }: Props) => { + const tags = tagsFromGame(game); + return ( + + +
+ +
+
+ {tags.length > 0 && ( +
+ {tags.map(t => {t})} +
+ )} +

{game.name}

+
+
+ +
+
+ +
+
+
+
Size
+
{formatBytes(game.size)}
+
+
+
Players
+
+ {formatPlayers(game.max_players)} +
+
+
+
Version
+
+ {formatEtiVersion(game.local_version ?? game.eti_game_version)} +
+
+
+
Status
+
{statusLabelFor(game)}
+
+
+ + {game.description && ( +

{game.description}

+ )} + + {game.status_message && ( +

+ {game.status_message} +

+ )} + +
+ onPrimary(game)} /> + {game.installed && ( + + )} +
+
+ + ); +}; diff --git a/crates/lanspread-tauri-deno-ts/src/components/modals/SettingsDialog.tsx b/crates/lanspread-tauri-deno-ts/src/components/modals/SettingsDialog.tsx new file mode 100644 index 0000000..2719b78 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/modals/SettingsDialog.tsx @@ -0,0 +1,92 @@ +import { Modal } from '../Modal'; +import { Icon } from '../Icon'; +import { ColorSwatchPicker } from '../ColorSwatchPicker'; +import { SegmentedRadio } from '../SegmentedRadio'; + +import { + ACCENT_OPTIONS, + ASPECT_OPTIONS, + BG_OPTIONS, + DENSITY_OPTIONS, + UISettings, +} from '../../hooks/useSettings'; + +interface Props { + settings: UISettings; + onChange: (key: K, value: UISettings[K]) => void; + onClose: () => void; +} + +interface RowProps { + label: string; + hint: string; + children: React.ReactNode; +} + +const Row = ({ label, hint, children }: RowProps) => ( +
+
+
{label}
+
{hint}
+
+
{children}
+
+); + +export const SettingsDialog = ({ settings, onChange, onClose }: Props) => ( + +
+

Settings

+ +
+ +
+
+
Appearance
+ + onChange('accent', v)} + /> + + + onChange('bg', v)} + /> + +
+ +
+
Library
+ + onChange('density', v)} + /> + + + onChange('aspect', v)} + /> + +
+
+ +
+ +
+
+); diff --git a/crates/lanspread-tauri-deno-ts/src/components/topbar/DirectoryButton.tsx b/crates/lanspread-tauri-deno-ts/src/components/topbar/DirectoryButton.tsx new file mode 100644 index 0000000..f56978b --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/topbar/DirectoryButton.tsx @@ -0,0 +1,17 @@ +import { Icon } from '../Icon'; +import { truncatePath } from '../../lib/format'; + +interface Props { + path: string; + onClick: () => void; +} + +export const DirectoryButton = ({ path, onClick }: Props) => ( + +); diff --git a/crates/lanspread-tauri-deno-ts/src/components/topbar/KebabMenu.tsx b/crates/lanspread-tauri-deno-ts/src/components/topbar/KebabMenu.tsx new file mode 100644 index 0000000..62838be --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/topbar/KebabMenu.tsx @@ -0,0 +1,58 @@ +import { useEffect, useRef, useState } from 'react'; + +import { Icon } from '../Icon'; + +export type KebabItem = + | { kind: 'item'; label: string; onClick: () => void } + | { kind: 'separator' }; + +interface Props { + items: ReadonlyArray; +} + +export const KebabMenu = ({ items }: Props) => { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const onClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', onClick); + return () => document.removeEventListener('mousedown', onClick); + }, [open]); + + return ( +
+ + {open && ( +
+ {items.map((it, i) => + it.kind === 'separator' ? ( +
+ ) : ( + + ), + )} +
+ )} +
+ ); +}; diff --git a/crates/lanspread-tauri-deno-ts/src/components/topbar/SearchField.tsx b/crates/lanspread-tauri-deno-ts/src/components/topbar/SearchField.tsx new file mode 100644 index 0000000..0a8afbf --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/topbar/SearchField.tsx @@ -0,0 +1,43 @@ +import { useEffect, useRef } from 'react'; + +import { Icon } from '../Icon'; + +interface Props { + value: string; + onChange: (value: string) => void; +} + +/** + * Search input with a `/` keyboard shortcut for focus. Ignores the shortcut + * when the user is already typing into another input or textarea. + */ +export const SearchField = ({ value, onChange }: Props) => { + const inputRef = useRef(null); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== '/') return; + const target = e.target as HTMLElement | null; + if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return; + e.preventDefault(); + inputRef.current?.focus(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + + return ( +
+ + onChange(e.target.value)} + spellCheck={false} + /> + / +
+ ); +}; diff --git a/crates/lanspread-tauri-deno-ts/src/components/topbar/SegmentedFilters.tsx b/crates/lanspread-tauri-deno-ts/src/components/topbar/SegmentedFilters.tsx new file mode 100644 index 0000000..6956033 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/topbar/SegmentedFilters.tsx @@ -0,0 +1,51 @@ +import { useEffect, useRef, useState } from 'react'; + +import { FilterCounts } from '../../lib/gameState'; +import { GameFilter } from '../../lib/types'; + +interface Tab { + key: GameFilter; + label: string; +} + +const TABS: ReadonlyArray = [ + { key: 'all', label: 'All Games' }, + { key: 'local', label: 'Local' }, + { key: 'installed', label: 'Installed' }, +]; + +interface Props { + value: GameFilter; + onChange: (value: GameFilter) => void; + counts: FilterCounts; +} + +/** Pill-style filter with an animated thumb that slides under the active tab. */ +export const SegmentedFilters = ({ value, onChange, counts }: Props) => { + const containerRef = useRef(null); + const [thumb, setThumb] = useState({ left: 0, width: 0 }); + + useEffect(() => { + if (!containerRef.current) return; + const active = containerRef.current.querySelector(`[data-key="${value}"]`); + if (active) setThumb({ left: active.offsetLeft, width: active.offsetWidth }); + }, [value, counts.all, counts.local, counts.installed]); + + return ( +
+
+ {TABS.map(t => ( + + ))} +
+ ); +}; diff --git a/crates/lanspread-tauri-deno-ts/src/components/topbar/SortMenu.tsx b/crates/lanspread-tauri-deno-ts/src/components/topbar/SortMenu.tsx new file mode 100644 index 0000000..0cc9369 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/topbar/SortMenu.tsx @@ -0,0 +1,60 @@ +import { useEffect, useRef, useState } from 'react'; + +import { Icon } from '../Icon'; +import { GameSort } from '../../lib/types'; + +const OPTIONS: ReadonlyArray<{ key: GameSort; label: string }> = [ + { key: 'az', label: 'Name (A–Z)' }, + { key: 'size', label: 'Size (largest)' }, + { key: 'status', label: 'Status' }, +]; + +interface Props { + value: GameSort; + onChange: (value: GameSort) => void; +} + +export const SortMenu = ({ value, onChange }: Props) => { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const onClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', onClick); + return () => document.removeEventListener('mousedown', onClick); + }, [open]); + + const current = OPTIONS.find(o => o.key === value) ?? OPTIONS[0]; + + return ( +
+ + {open && ( +
+ {OPTIONS.map(o => ( + + ))} +
+ )} +
+ ); +}; diff --git a/crates/lanspread-tauri-deno-ts/src/components/topbar/TopBar.tsx b/crates/lanspread-tauri-deno-ts/src/components/topbar/TopBar.tsx new file mode 100644 index 0000000..7b8af6c --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/topbar/TopBar.tsx @@ -0,0 +1,46 @@ +import { Brand } from '../Brand'; +import { SegmentedFilters } from './SegmentedFilters'; +import { SearchField } from './SearchField'; +import { SortMenu } from './SortMenu'; +import { DirectoryButton } from './DirectoryButton'; +import { KebabMenu, KebabItem } from './KebabMenu'; + +import { FilterCounts } from '../../lib/gameState'; +import { GameFilter, GameSort } from '../../lib/types'; + +interface Props { + peerCount: number; + filter: GameFilter; + setFilter: (value: GameFilter) => void; + counts: FilterCounts; + query: string; + setQuery: (value: string) => void; + sort: GameSort; + setSort: (value: GameSort) => void; + gameDir: string; + onPickDirectory: () => void; + kebabItems: ReadonlyArray; +} + +export const TopBar = ({ + peerCount, + filter, + setFilter, + counts, + query, + setQuery, + sort, + setSort, + gameDir, + onPickDirectory, + kebabItems, +}: Props) => ( +
+ + + + + + +
+); diff --git a/crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts b/crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts new file mode 100644 index 0000000..65a369b --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts @@ -0,0 +1,55 @@ +import { useCallback } from 'react'; +import { invoke } from '@tauri-apps/api/core'; + +import { UseGamesResult } from './useGames'; + +export interface GameActions { + play: (id: string) => Promise; + install: (id: string) => Promise; + update: (id: string) => Promise; + uninstall: (id: string) => Promise; +} + +/** + * Thin wrappers over the backend `run_game` / `install_game` / `update_game` + * / `uninstall_game` commands. For install + update we mark the game as + * "checking peers" up-front through the games hook so the UI doesn't have to + * wait for the first backend event. + */ +export const useGameActions = (games: UseGamesResult): GameActions => { + const play = useCallback(async (id: string) => { + try { + await invoke('run_game', { id }); + } catch (err) { + console.error('run_game failed:', err); + } + }, []); + + const install = useCallback(async (id: string) => { + try { + const success = await invoke('install_game', { id }); + if (success) games.markChecking(id); + } catch (err) { + console.error('install_game failed:', err); + } + }, [games]); + + const update = useCallback(async (id: string) => { + try { + const success = await invoke('update_game', { id }); + if (success) games.markChecking(id); + } catch (err) { + console.error('update_game failed:', err); + } + }, [games]); + + const uninstall = useCallback(async (id: string) => { + try { + await invoke('uninstall_game', { id }); + } catch (err) { + console.error('uninstall_game failed:', err); + } + }, []); + + return { play, install, update, uninstall }; +}; diff --git a/crates/lanspread-tauri-deno-ts/src/hooks/useGameDirectory.ts b/crates/lanspread-tauri-deno-ts/src/hooks/useGameDirectory.ts new file mode 100644 index 0000000..80eba16 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/hooks/useGameDirectory.ts @@ -0,0 +1,56 @@ +import { useCallback, useEffect, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { load } from '@tauri-apps/plugin-store'; + +import { GAME_DIR_KEY, SETTINGS_FILE, SETTINGS_FILE_OPTIONS } from '../lib/store'; + +/** + * Owns the user's selected game directory. Hydrates from the persistent store + * on mount, writes back on every change, and pushes the value to the Tauri + * backend so it can scan/rescan. + */ +export const useGameDirectory = () => { + const [gameDir, setGameDir] = useState(''); + + useEffect(() => { + let cancelled = false; + const hydrate = async () => { + try { + const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS); + const saved = await store.get(GAME_DIR_KEY); + if (saved && !cancelled) setGameDir(saved); + } catch (err) { + console.error('Failed to load game directory:', err); + } + }; + void hydrate(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!gameDir) return; + const sync = async () => { + try { + const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS); + await store.set(GAME_DIR_KEY, gameDir); + } catch (err) { + console.error('Failed to persist game directory:', err); + } + }; + void sync(); + invoke('update_game_directory', { path: gameDir }).catch(err => + console.error('Failed to push game directory to backend:', err), + ); + }, [gameDir]); + + const rescan = useCallback(() => { + if (!gameDir) return; + invoke('update_game_directory', { path: gameDir }).catch(err => + console.error('Failed to rescan game directory:', err), + ); + }, [gameDir]); + + return { gameDir, setGameDir, rescan }; +}; diff --git a/crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts b/crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts new file mode 100644 index 0000000..7b3b735 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts @@ -0,0 +1,252 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { listen, UnlistenFn } from '@tauri-apps/api/event'; + +import { + Game, + GamesListPayload, + InstallStatus, + StatusLevel, +} from '../lib/types'; +import { + activeStatusById, + mergeGameUpdate, + normalizeGamesListPayload, +} from '../lib/gameState'; + +const CHECKING_PEERS_TIMEOUT_MS = 5000; + +interface PendingPatch { + install_status?: InstallStatus; + installed?: boolean; + status_message?: string; + status_level?: StatusLevel | undefined; + clearStatus?: boolean; +} + +const applyPatch = (game: Game, patch: PendingPatch): Game => { + let next: Game = { ...game }; + if (patch.install_status !== undefined) next.install_status = patch.install_status; + if (patch.installed !== undefined) next.installed = patch.installed; + if (patch.clearStatus) { + next.status_message = undefined; + next.status_level = undefined; + } + if (patch.status_message !== undefined) { + next.status_message = patch.status_message; + next.status_level = patch.status_level; + } + return next; +}; + +/** + * Owns the games list and reflects every backend event (download/install/ + * uninstall lifecycle, peer count) into local React state. Returns a + * fire-and-forget `markChecking` helper so action calls can immediately show a + * "Checking peers…" state with an automatic fall-back if the backend never + * emits a follow-up event. + */ +export interface UseGamesResult { + games: Game[]; + setGames: React.Dispatch>; + totalPeerCount: number; + requestGames: () => Promise; + markChecking: (id: string) => void; + cancelChecking: (id: string) => void; +} + +export const useGames = (rescanGameDir: () => void): UseGamesResult => { + const [games, setGames] = useState([]); + const [totalPeerCount, setTotalPeerCount] = useState(0); + const checkingTimeouts = useRef>>({}); + const rescanRef = useRef(rescanGameDir); + rescanRef.current = rescanGameDir; + + const cancelChecking = useCallback((id: string) => { + const t = checkingTimeouts.current[id]; + if (t !== undefined) { + clearTimeout(t); + delete checkingTimeouts.current[id]; + } + }, []); + + const markChecking = useCallback((id: string) => { + cancelChecking(id); + setGames(prev => prev.map(item => + item.id === id + ? { ...item, install_status: InstallStatus.CheckingPeers } + : item, + )); + checkingTimeouts.current[id] = setTimeout(() => { + setGames(prev => prev.map(item => { + if (item.id !== id || item.install_status !== InstallStatus.CheckingPeers) { + return item; + } + return { + ...item, + install_status: item.installed + ? InstallStatus.Installed + : InstallStatus.NotInstalled, + status_message: 'No peers currently have this game.', + status_level: 'error', + }; + })); + delete checkingTimeouts.current[id]; + }, CHECKING_PEERS_TIMEOUT_MS); + }, [cancelChecking]); + + const requestGames = useCallback(async () => { + try { + await invoke('request_games'); + } catch (err) { + console.error('request_games failed:', err); + } + }, []); + + useEffect(() => { + const unlisteners: UnlistenFn[] = []; + let cancelled = false; + + const updateById = (id: string, patch: PendingPatch) => { + setGames(prev => prev.map(item => item.id === id ? applyPatch(item, patch) : item)); + }; + + const handleErrorEvent = ( + id: string, + message: string, + { triggerRescan = false }: { triggerRescan?: boolean } = {}, + ) => { + cancelChecking(id); + setGames(prev => prev.map(item => item.id === id + ? { + ...item, + install_status: item.installed + ? InstallStatus.Installed + : InstallStatus.NotInstalled, + status_message: message, + status_level: 'error', + } + : item)); + if (triggerRescan) rescanRef.current(); + }; + + const register = async () => { + try { + unlisteners.push(await listen('games-list-updated', (event) => { + const payload = normalizeGamesListPayload( + event.payload as GamesListPayload | Game[], + ); + const activeStatuses = activeStatusById(payload.active_operations); + const hasAuthoritative = payload.active_operations !== undefined; + setGames(prev => { + const previousById = new Map(prev.map(item => [item.id, item])); + return payload.games.map(game => mergeGameUpdate( + game, + previousById.get(game.id), + activeStatuses.get(game.id), + hasAuthoritative, + )); + }); + })); + + unlisteners.push(await listen('game-download-begin', (e) => { + const id = e.payload as string; + cancelChecking(id); + updateById(id, { install_status: InstallStatus.Downloading, clearStatus: true }); + })); + + unlisteners.push(await listen('game-download-finished', (e) => { + const id = e.payload as string; + cancelChecking(id); + updateById(id, { install_status: InstallStatus.Installing, clearStatus: true }); + })); + + unlisteners.push(await listen('game-download-failed', (e) => { + handleErrorEvent(e.payload as string, 'Download failed. Please try again.', { + triggerRescan: true, + }); + })); + + unlisteners.push(await listen('game-download-peers-gone', (e) => { + handleErrorEvent(e.payload as string, 'Failed: all peers gone.', { + triggerRescan: true, + }); + })); + + unlisteners.push(await listen('game-no-peers', (e) => { + handleErrorEvent(e.payload as string, 'No peers currently have this game.'); + })); + + unlisteners.push(await listen('game-install-begin', (e) => { + const id = e.payload as string; + cancelChecking(id); + updateById(id, { install_status: InstallStatus.Installing, clearStatus: true }); + })); + + unlisteners.push(await listen('game-install-finished', (e) => { + const id = e.payload as string; + cancelChecking(id); + updateById(id, { + install_status: InstallStatus.Installed, + installed: true, + clearStatus: true, + }); + rescanRef.current(); + })); + + unlisteners.push(await listen('game-install-failed', (e) => { + handleErrorEvent(e.payload as string, 'Install failed. Please try again.'); + })); + + unlisteners.push(await listen('game-uninstall-begin', (e) => { + updateById(e.payload as string, { + install_status: InstallStatus.Uninstalling, + clearStatus: true, + }); + })); + + unlisteners.push(await listen('game-uninstall-finished', (e) => { + updateById(e.payload as string, { + install_status: InstallStatus.NotInstalled, + installed: false, + clearStatus: true, + }); + })); + + unlisteners.push(await listen('game-uninstall-failed', (e) => { + handleErrorEvent(e.payload as string, 'Uninstall failed. Please try again.'); + })); + + unlisteners.push(await listen('peer-count-updated', (e) => { + setTotalPeerCount(e.payload as number); + })); + + if (!cancelled) { + await invoke('request_games').catch(err => + console.error('request_games failed:', err), + ); + } + } catch (err) { + console.error('Failed to register game listeners:', err); + } + }; + + void register(); + + return () => { + cancelled = true; + unlisteners.forEach(fn => fn()); + Object.values(checkingTimeouts.current).forEach(clearTimeout); + checkingTimeouts.current = {}; + }; + }, [cancelChecking]); + + return { + games, + setGames, + totalPeerCount, + requestGames, + markChecking, + cancelChecking, + }; +}; diff --git a/crates/lanspread-tauri-deno-ts/src/hooks/useSettings.ts b/crates/lanspread-tauri-deno-ts/src/hooks/useSettings.ts new file mode 100644 index 0000000..e22b558 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/hooks/useSettings.ts @@ -0,0 +1,119 @@ +import { useCallback, useEffect, useState } from 'react'; +import { load } from '@tauri-apps/plugin-store'; + +import { GameFilter, GameSort } from '../lib/types'; +import { SETTINGS_FILE, SETTINGS_FILE_OPTIONS, UI_SETTINGS_KEY } from '../lib/store'; + +export type Density = 'compact' | 'normal' | 'large'; +export type CoverAspect = 'box' | 'square' | 'banner'; +export type BackgroundStyle = 'flat' | 'gradient' | 'animated'; + +export interface UISettings { + accent: string; + bg: BackgroundStyle; + density: Density; + aspect: CoverAspect; + sort: GameSort; + filter: GameFilter; +} + +export const ACCENT_OPTIONS = [ + { value: '#3b82f6', label: 'Blue' }, + { value: '#22d3ee', label: 'Cyan' }, + { value: '#a855f7', label: 'Violet' }, + { value: '#22c55e', label: 'Green' }, + { value: '#f59e0b', label: 'Amber' }, + { value: '#ef4444', label: 'Red' }, +] as const; + +export const BG_OPTIONS: ReadonlyArray<{ value: BackgroundStyle; label: string }> = [ + { value: 'flat', label: 'Flat' }, + { value: 'gradient', label: 'Gradient' }, + { value: 'animated', label: 'Animated' }, +]; + +export const DENSITY_OPTIONS: ReadonlyArray<{ value: Density; label: string }> = [ + { value: 'compact', label: 'Compact' }, + { value: 'normal', label: 'Normal' }, + { value: 'large', label: 'Large' }, +]; + +export const ASPECT_OPTIONS: ReadonlyArray<{ value: CoverAspect; label: string }> = [ + { value: 'box', label: 'Box-art' }, + { value: 'square', label: 'Square' }, + { value: 'banner', label: 'Banner' }, +]; + +export const DEFAULT_SETTINGS: UISettings = { + accent: '#3b82f6', + bg: 'gradient', + density: 'normal', + aspect: 'box', + sort: 'status', + filter: 'local', +}; + +const sanitize = (raw: Partial | undefined): UISettings => ({ + accent: raw?.accent ?? DEFAULT_SETTINGS.accent, + bg: raw?.bg ?? DEFAULT_SETTINGS.bg, + density: raw?.density ?? DEFAULT_SETTINGS.density, + aspect: raw?.aspect ?? DEFAULT_SETTINGS.aspect, + sort: raw?.sort ?? DEFAULT_SETTINGS.sort, + filter: raw?.filter ?? DEFAULT_SETTINGS.filter, +}); + +export interface UseSettings { + settings: UISettings; + set: (key: K, value: UISettings[K]) => void; + ready: boolean; +} + +/** + * Loads UI preferences from the Tauri persistent store once on mount and + * writes every change back through it. Components only see a synchronous + * `settings` snapshot; persistence is fire-and-forget. + */ +export const useSettings = (): UseSettings => { + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [ready, setReady] = useState(false); + + useEffect(() => { + let cancelled = false; + const init = async () => { + try { + const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS); + const saved = await store.get>(UI_SETTINGS_KEY); + if (!cancelled) { + setSettings(sanitize(saved ?? undefined)); + } + } catch (err) { + console.error('Failed to load UI settings:', err); + } finally { + if (!cancelled) setReady(true); + } + }; + void init(); + return () => { + cancelled = true; + }; + }, []); + + const set = useCallback((key: K, value: UISettings[K]) => { + setSettings(prev => { + const next = { ...prev, [key]: value }; + void persist(next); + return next; + }); + }, []); + + return { settings, set, ready }; +}; + +const persist = async (settings: UISettings): Promise => { + try { + const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS); + await store.set(UI_SETTINGS_KEY, settings); + } catch (err) { + console.error('Failed to persist UI settings:', err); + } +}; diff --git a/crates/lanspread-tauri-deno-ts/src/hooks/useThumbnails.ts b/crates/lanspread-tauri-deno-ts/src/hooks/useThumbnails.ts new file mode 100644 index 0000000..bd867ee --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/hooks/useThumbnails.ts @@ -0,0 +1,30 @@ +import { useCallback, useRef, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; + +/** + * Lazy, per-id cache for cover thumbnails. Returns `null` until the value is + * known; returns the empty string when the backend has nothing for the id, so + * callers can fall back to the placeholder cover art. + */ +export const useThumbnails = () => { + const [thumbnails, setThumbnails] = useState>(new Map()); + const pending = useRef>(new Set()); + + const get = useCallback((id: string): string | null => { + if (thumbnails.has(id)) return thumbnails.get(id) ?? ''; + if (pending.current.has(id)) return null; + pending.current.add(id); + invoke('get_game_thumbnail', { gameId: id }) + .then(url => { + pending.current.delete(id); + setThumbnails(prev => new Map(prev).set(id, url)); + }) + .catch(() => { + pending.current.delete(id); + setThumbnails(prev => new Map(prev).set(id, '')); + }); + return null; + }, [thumbnails]); + + return { get }; +}; diff --git a/crates/lanspread-tauri-deno-ts/src/lib/cover.ts b/crates/lanspread-tauri-deno-ts/src/lib/cover.ts new file mode 100644 index 0000000..6939b75 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/lib/cover.ts @@ -0,0 +1,58 @@ +/** + * Stable gradient + accent derived from a game id. Used as cover-art fallback + * when the backend has no thumbnail for a game. + */ +export interface CoverColors { + c1: string; + c2: string; + accent: string; + angle: number; + blobX: number; + blobY: number; +} + +const PALETTE: Array> = [ + { c1: '#7c2d12', c2: '#1c1917', accent: '#fbbf24' }, + { c1: '#1e40af', c2: '#0c1f3a', accent: '#22d3ee' }, + { c1: '#15803d', c2: '#052e16', accent: '#fef08a' }, + { c1: '#7f1d1d', c2: '#0a0a0a', accent: '#f97316' }, + { c1: '#1d4ed8', c2: '#0c1f3a', accent: '#f59e0b' }, + { c1: '#a16207', c2: '#422006', accent: '#fde047' }, + { c1: '#3f3f46', c2: '#0a0a0a', accent: '#22d3ee' }, + { c1: '#ea580c', c2: '#1e293b', accent: '#22d3ee' }, + { c1: '#064e3b', c2: '#020617', accent: '#34d399' }, + { c1: '#1e3a8a', c2: '#172554', accent: '#22d3ee' }, +]; + +const hash = (id: string): number => { + let h = 0; + for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0; + return h; +}; + +export const coverColorsFor = (id: string): CoverColors => { + const h = hash(id); + const base = PALETTE[h % PALETTE.length]; + return { + ...base, + angle: 110 + (h % 60), + blobX: 60 + (h % 30), + blobY: 10 + ((h * 7) % 30), + }; +}; + +export const titleFontSize = ( + title: string, + aspect: 'box' | 'square' | 'banner', +): number => { + const len = title.length; + if (aspect === 'banner' || aspect === 'square') { + if (len > 22) return 18; + if (len > 14) return 22; + return 28; + } + if (len > 26) return 15; + if (len > 20) return 17; + if (len > 14) return 21; + return 26; +}; diff --git a/crates/lanspread-tauri-deno-ts/src/lib/format.ts b/crates/lanspread-tauri-deno-ts/src/lib/format.ts new file mode 100644 index 0000000..63587cb --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/lib/format.ts @@ -0,0 +1,29 @@ +const GB = 1024 * 1024 * 1024; +const MB = 1024 * 1024; + +export const formatBytes = (bytes: number): string => { + if (bytes >= GB) return `${(bytes / GB).toFixed(1)} GB`; + if (bytes >= MB) return `${(bytes / MB).toFixed(0)} MB`; + return `${bytes} B`; +}; + +/** + * Format an ETI version stamp (YYYYMMDD) for display. Falls back to the raw + * string when it doesn't fit the expected shape. + */ +export const formatEtiVersion = (raw: string | undefined): string => { + if (!raw) return '—'; + if (raw.length === 8 && /^\d{8}$/.test(raw)) { + return `${raw.slice(0, 4)}.${raw.slice(4, 6)}.${raw.slice(6, 8)}`; + } + return raw; +}; + +/** Truncate a path with a leading ellipsis when it exceeds the limit. */ +export const truncatePath = (path: string, max = 36): string => + path.length > max ? `…${path.slice(-(max - 1))}` : path; + +export const formatPlayers = (max?: number): string => { + if (!max || max <= 0) return '—'; + return max === 1 ? '1' : `1–${max}`; +}; diff --git a/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts b/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts new file mode 100644 index 0000000..ab50802 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts @@ -0,0 +1,212 @@ +import { + ActiveOperation, + ActiveOperationKind, + DerivedState, + Game, + GameFilter, + GameSort, + GamesListPayload, + InstallStatus, +} from './types'; + +const IN_PROGRESS_INSTALL_STATUSES = new Set([ + InstallStatus.CheckingPeers, + InstallStatus.Downloading, + InstallStatus.Installing, + InstallStatus.Uninstalling, +]); + +const RECONCILED_OPERATION_STATUSES = new Set([ + InstallStatus.Downloading, + InstallStatus.Installing, + InstallStatus.Uninstalling, +]); + +export const isInProgress = (status: InstallStatus): boolean => + IN_PROGRESS_INSTALL_STATUSES.has(status); + +const isReconciledOperationStatus = (status: InstallStatus): boolean => + RECONCILED_OPERATION_STATUSES.has(status); + +export const installStatusFromActiveOperation = (op: ActiveOperationKind): InstallStatus => { + switch (op) { + case ActiveOperationKind.Downloading: + return InstallStatus.Downloading; + case ActiveOperationKind.Installing: + case ActiveOperationKind.Updating: + return InstallStatus.Installing; + case ActiveOperationKind.Uninstalling: + return InstallStatus.Uninstalling; + } +}; + +export const activeStatusById = (ops: ActiveOperation[] = []): Map => + new Map(ops.map(op => [op.id, installStatusFromActiveOperation(op.operation)])); + +export const normalizeGamesListPayload = ( + payload: GamesListPayload | Game[], +): GamesListPayload => Array.isArray(payload) ? { games: payload } : payload; + +/** + * Reconcile a freshly received backend snapshot of a game with our prior + * locally-tracked install status. Keeps in-progress operations visible across + * snapshots that don't yet reflect the running operation. + */ +export const mergeGameUpdate = ( + incoming: Game, + previous?: Game, + activeStatus?: InstallStatus, + hasAuthoritativeSnapshot = false, +): Game => { + let installStatus = InstallStatus.NotInstalled; + if (activeStatus !== undefined) { + installStatus = activeStatus; + } else if (incoming.installed) { + installStatus = InstallStatus.Installed; + } else if ( + previous + && isInProgress(previous.install_status) + && (!hasAuthoritativeSnapshot || previous.install_status === InstallStatus.CheckingPeers) + ) { + installStatus = previous.install_status; + } + + const localStateChanged = previous !== undefined + && (previous.installed !== incoming.installed || previous.downloaded !== incoming.downloaded); + const activeStateReconciled = hasAuthoritativeSnapshot + && (activeStatus !== undefined + || (previous !== undefined && isReconciledOperationStatus(previous.install_status))); + const clearStatus = localStateChanged || activeStateReconciled; + + return { + ...incoming, + availability: incoming.availability, + install_status: installStatus, + status_message: clearStatus ? undefined : previous?.status_message, + status_level: clearStatus ? undefined : previous?.status_level, + peer_count: incoming.peer_count ?? 0, + }; +}; + +/** Visual card state — used for state chip color and action button styling. */ +export const deriveState = (game: Game): DerivedState => { + if (isInProgress(game.install_status)) return 'busy'; + if (game.installed) return 'installed'; + if (game.downloaded) return 'local'; + return 'none'; +}; + +export const isUnavailable = (game: Game): boolean => + !game.installed + && !game.downloaded + && game.peer_count === 0 + && game.install_status === InstallStatus.NotInstalled; + +export const needsUpdate = (game: Game): boolean => { + if (!game.installed) return false; + const peer = game.eti_game_version; + const local = game.local_version; + if (!local && peer) return true; + if (local && peer) { + const l = parseInt(local, 10); + const p = parseInt(peer, 10); + if (!Number.isNaN(l) && !Number.isNaN(p)) return p > l; + } + return false; +}; + +/** What pressing the card's main action button should do, given the state. */ +export type PrimaryAction = 'play' | 'install' | 'update' | 'download' | 'busy' | 'disabled'; + +export const primaryActionFor = (game: Game): PrimaryAction => { + if (isInProgress(game.install_status)) return 'busy'; + if (isUnavailable(game)) return 'disabled'; + if (!game.installed) return game.downloaded ? 'install' : 'download'; + if (needsUpdate(game)) return 'update'; + return 'play'; +}; + +export const inProgressLabel = (status: InstallStatus): string | undefined => { + switch (status) { + case InstallStatus.CheckingPeers: + return 'Checking peers…'; + case InstallStatus.Downloading: + return 'Downloading…'; + case InstallStatus.Installing: + return 'Installing…'; + case InstallStatus.Uninstalling: + return 'Uninstalling…'; + default: + return undefined; + } +}; + +export const actionLabel = (game: Game): string => { + const busy = inProgressLabel(game.install_status); + if (busy) return busy; + if (isUnavailable(game)) return 'Unavailable'; + if (!game.installed) return game.downloaded ? 'Install' : 'Download'; + if (needsUpdate(game)) return 'Update'; + return 'Play'; +}; + +/** Counts shown on filter pills. */ +export interface FilterCounts { + all: number; + local: number; + installed: number; +} + +export const countByFilter = (games: Game[]): FilterCounts => ({ + all: games.length, + local: games.filter(g => g.installed || g.downloaded).length, + installed: games.filter(g => g.installed).length, +}); + +const matchesFilter = (game: Game, filter: GameFilter): boolean => { + switch (filter) { + case 'local': + return game.installed || game.downloaded; + case 'installed': + return game.installed; + case 'all': + return game.installed || game.downloaded || game.peer_count > 0; + } +}; + +const STATE_SORT_ORDER: Record = { + busy: 0, + installed: 1, + local: 2, + none: 3, +}; + +const compareByState = (a: Game, b: Game): number => { + const diff = STATE_SORT_ORDER[deriveState(a)] - STATE_SORT_ORDER[deriveState(b)]; + return diff !== 0 ? diff : a.name.localeCompare(b.name); +}; + +export const applyFilterAndSort = ( + games: Game[], + filter: GameFilter, + sort: GameSort, + query: string, +): Game[] => { + let list = games.filter(g => matchesFilter(g, filter)); + const q = query.trim().toLowerCase(); + if (q) { + list = list.filter(g => + g.name.toLowerCase().includes(q) + || (g.genre?.toLowerCase().includes(q) ?? false) + || (g.publisher?.toLowerCase().includes(q) ?? false), + ); + } + switch (sort) { + case 'az': + return [...list].sort((a, b) => a.name.localeCompare(b.name)); + case 'size': + return [...list].sort((a, b) => b.size - a.size); + case 'status': + return [...list].sort(compareByState); + } +}; diff --git a/crates/lanspread-tauri-deno-ts/src/lib/store.ts b/crates/lanspread-tauri-deno-ts/src/lib/store.ts new file mode 100644 index 0000000..124ee49 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/lib/store.ts @@ -0,0 +1,12 @@ +/** File names + keys for the @tauri-apps/plugin-store-backed persistent state. */ + +export const SETTINGS_FILE = 'launcher-settings.json'; +export const GAME_DIR_KEY = 'game-directory'; +export const UI_SETTINGS_KEY = 'ui-settings'; + +export const SETTINGS_FILE_OPTIONS = { + autoSave: true, + defaults: { + [GAME_DIR_KEY]: '', + }, +}; diff --git a/crates/lanspread-tauri-deno-ts/src/lib/types.ts b/crates/lanspread-tauri-deno-ts/src/lib/types.ts new file mode 100644 index 0000000..d744f1a --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/lib/types.ts @@ -0,0 +1,66 @@ +export enum InstallStatus { + NotInstalled = 'NotInstalled', + CheckingPeers = 'CheckingPeers', + Downloading = 'Downloading', + Installing = 'Installing', + Uninstalling = 'Uninstalling', + Installed = 'Installed', +} + +export enum GameAvailability { + Ready = 'Ready', + LocalOnly = 'LocalOnly', +} + +export enum ActiveOperationKind { + Downloading = 'Downloading', + Installing = 'Installing', + Updating = 'Updating', + Uninstalling = 'Uninstalling', +} + +export type StatusLevel = 'info' | 'error'; + +export interface Game { + id: string; + name: string; + description: string; + /** Bytes. */ + size: number; + /** Raw bytes — unused in UI, kept for parity with backend payload. */ + thumbnail?: Uint8Array | number[]; + downloaded: boolean; + installed: boolean; + availability: GameAvailability; + install_status: InstallStatus; + eti_game_version?: string; + local_version?: string; + /** Optional richer metadata surfaced by the backend. */ + release_year?: string; + publisher?: string; + max_players?: number; + version?: string; + genre?: string; + status_message?: string; + status_level?: StatusLevel; + peer_count: number; +} + +export interface ActiveOperation { + id: string; + operation: ActiveOperationKind; +} + +export interface GamesListPayload { + games: Game[]; + active_operations?: ActiveOperation[]; +} + +/** Library filter chip — what subset of the catalog to show. */ +export type GameFilter = 'all' | 'local' | 'installed'; + +/** Library sort order. */ +export type GameSort = 'az' | 'size' | 'status'; + +/** Visual state of a card. Derived from install/download flags. */ +export type DerivedState = 'installed' | 'local' | 'none' | 'busy'; diff --git a/crates/lanspread-tauri-deno-ts/src/main.tsx b/crates/lanspread-tauri-deno-ts/src/main.tsx index 278dedf..306429e 100644 --- a/crates/lanspread-tauri-deno-ts/src/main.tsx +++ b/crates/lanspread-tauri-deno-ts/src/main.tsx @@ -1,5 +1,8 @@ import ReactDOM from "react-dom/client"; + import App from "./App"; +import "./styles/tokens.css"; +import "./styles/launcher.css"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/crates/lanspread-tauri-deno-ts/src/styles/launcher.css b/crates/lanspread-tauri-deno-ts/src/styles/launcher.css new file mode 100644 index 0000000..2322ffe --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/styles/launcher.css @@ -0,0 +1,1249 @@ +/* Launcher root */ +.launcher { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + background: var(--bg-0); + color: var(--t-1); + overflow: hidden; + position: relative; + isolation: isolate; +} + +.bg-flat { + background: var(--bg-0); +} +.bg-gradient { + background: + radial-gradient( + ellipse 80% 50% at 50% -10%, + color-mix(in srgb, var(--accent) 22%, transparent) 0%, + transparent 60% + ), + linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%); +} +.bg-animated { + background: + radial-gradient( + ellipse 60% 40% at 20% 0%, + color-mix(in srgb, var(--accent) 24%, transparent) 0%, + transparent 55% + ), + radial-gradient( + ellipse 55% 40% at 85% 8%, + color-mix(in srgb, var(--accent) 16%, transparent) 0%, + transparent 55% + ), + linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%); + background-size: 100% 100%; + animation: bgshift 18s ease-in-out infinite alternate; +} +@keyframes bgshift { + 0% { + background-position: 0% 0%, 0% 0%, 0% 0%; + } + 100% { + background-position: 10% 4%, -6% 2%, 0% 0%; + } +} + +/* Top bar */ +.topbar { + position: relative; + z-index: 10; + background: rgba(10, 14, 19, 0.65); + -webkit-backdrop-filter: blur(20px) saturate(140%); + backdrop-filter: blur(20px) saturate(140%); + border-bottom: 1px solid var(--bd-1); + display: flex; + align-items: center; + gap: 18px; + padding: 14px 24px; + flex-wrap: nowrap; + min-height: 64px; +} + +/* Brand */ +.brand { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} +.brand-mark { + width: 28px; + height: 28px; + border-radius: 7px; + display: grid; + place-items: center; + background: var(--accent); + font-family: var(--font-display); + font-size: 20px; + letter-spacing: 0.02em; + color: white; + box-shadow: + 0 6px 20px -6px color-mix(in srgb, var(--accent) 60%, black), + inset 0 1px 0 rgba(255, 255, 255, 0.22); +} +.brand-name { + font-weight: 700; + font-size: 15px; + letter-spacing: -0.005em; + color: var(--t-1); +} + +/* Peer count chip in brand area */ +.brand-peers { + display: inline-flex; + align-items: center; + gap: 6px; + height: 24px; + padding: 0 10px; + margin-left: 6px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--bd-1); + font-size: 11.5px; + font-weight: 600; + color: var(--t-2); + font-variant-numeric: tabular-nums; +} +.brand-peers-dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: var(--ok); + box-shadow: 0 0 6px var(--ok); +} + +/* Segmented filters */ +.seg { + position: relative; + display: inline-flex; + background: var(--bg-2); + border: 1px solid var(--bd-1); + border-radius: 999px; + padding: 4px; + flex-shrink: 0; +} +.seg-thumb { + position: absolute; + top: 4px; + bottom: 4px; + border-radius: 999px; + background: var(--accent); + transition: + left 0.22s cubic-bezier(0.4, 1.2, 0.5, 1), + width 0.22s cubic-bezier(0.4, 1.2, 0.5, 1); + box-shadow: 0 4px 14px -4px color-mix(in srgb, var(--accent) 60%, black); +} +.seg-btn { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: transparent; + color: var(--t-2); + border: 0; + border-radius: 999px; + font: inherit; + font-weight: 600; + font-size: 12.5px; + cursor: pointer; + white-space: nowrap; + transition: color 0.15s; +} +.seg-btn:hover { + color: var(--t-1); +} +.seg-btn.is-active { + color: white; +} +.seg-count { + font-size: 11px; + font-weight: 700; + padding: 1px 6px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: inherit; + font-variant-numeric: tabular-nums; +} +.seg-btn.is-active .seg-count { + background: rgba(0, 0, 0, 0.25); + color: white; +} + +/* Search */ +.search { + position: relative; + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; + background: var(--bg-2); + border: 1px solid var(--bd-1); + border-radius: 8px; + height: 36px; + min-width: 320px; + flex: 0 1 380px; + color: var(--t-3); + transition: + border-color 0.15s, + background 0.15s, + box-shadow 0.15s, + color 0.15s; +} +.search:focus-within { + border-color: color-mix(in srgb, var(--accent) 60%, var(--bd-2)); + background: var(--bg-1); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent); + color: var(--t-1); +} +.search input { + flex: 1; + min-width: 0; + background: transparent; + border: 0; + outline: 0; + color: var(--t-1); + font: inherit; + font-size: 13px; +} +.search input::placeholder { + color: var(--t-3); +} +.search-kbd { + display: inline-grid; + place-items: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--bd-1); + font-size: 11px; + color: var(--t-3); + font-family: var(--font-mono); +} +.search:focus-within .search-kbd { + opacity: 0.4; +} + +/* Sort menu */ +.sort { + position: relative; + flex-shrink: 0; +} +.sort-btn { + display: inline-flex; + align-items: center; + gap: 8px; + height: 36px; + padding: 0 12px; + background: var(--bg-2); + border: 1px solid var(--bd-1); + border-radius: 8px; + color: var(--t-2); + font: inherit; + font-size: 12.5px; + cursor: pointer; + transition: + border-color 0.15s, + color 0.15s; +} +.sort-btn:hover { + color: var(--t-1); + border-color: var(--bd-2); +} +.sort-btn strong { + color: var(--t-1); + font-weight: 600; +} +.sort-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 50; + min-width: 200px; + padding: 4px; + background: var(--bg-3); + border: 1px solid var(--bd-2); + border-radius: 10px; + box-shadow: + 0 16px 40px -8px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(255, 255, 255, 0.04); +} +.sort-menu button { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 10px; + background: transparent; + border: 0; + border-radius: 6px; + color: var(--t-1); + font: inherit; + font-size: 12.5px; + text-align: left; + cursor: pointer; +} +.sort-menu button:hover { + background: rgba(255, 255, 255, 0.06); +} +.sort-check { + width: 14px; + display: inline-grid; + place-items: center; + color: var(--accent); +} + +/* Directory button */ +.dirbtn { + display: inline-flex; + align-items: center; + gap: 8px; + height: 36px; + padding: 0 12px; + background: var(--bg-2); + border: 1px solid var(--bd-1); + border-radius: 8px; + color: var(--t-2); + font: inherit; + font-size: 12.5px; + cursor: pointer; + max-width: 360px; + transition: + border-color 0.15s, + color 0.15s; + flex-shrink: 1; + min-width: 0; +} +.dirbtn:hover { + border-color: var(--bd-2); + color: var(--t-1); +} +.dirbtn-label { + color: var(--t-1); + font-weight: 600; + flex-shrink: 0; +} +.dirbtn-path { + color: var(--t-3); + font-family: var(--font-mono); + font-size: 11.5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +/* Kebab menu */ +.kebab { + position: relative; +} +.kebab-btn { + width: 36px; + height: 36px; + display: grid; + place-items: center; + background: var(--bg-2); + border: 1px solid var(--bd-1); + border-radius: 8px; + color: var(--t-2); + cursor: pointer; + transition: + color 0.15s, + border-color 0.15s; +} +.kebab-btn:hover { + color: var(--t-1); + border-color: var(--bd-2); +} +.kebab-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 50; + min-width: 180px; + padding: 4px; + background: var(--bg-3); + border: 1px solid var(--bd-2); + border-radius: 10px; + box-shadow: 0 16px 40px -8px rgba(0, 0, 0, 0.5); +} +.kebab-menu button { + display: block; + width: 100%; + padding: 8px 10px; + background: transparent; + border: 0; + border-radius: 6px; + color: var(--t-1); + font: inherit; + font-size: 12.5px; + text-align: left; + cursor: pointer; +} +.kebab-menu button:hover { + background: rgba(255, 255, 255, 0.06); +} +.kebab-sep { + height: 1px; + background: var(--bd-1); + margin: 4px 0; +} + +/* Grid wrapper / results bar */ +.grid-wrap { + flex: 1; + overflow: auto; + padding: 18px 24px 32px; + scrollbar-width: thin; + scrollbar-color: var(--bd-3) transparent; +} +.grid-wrap::-webkit-scrollbar { + width: 10px; +} +.grid-wrap::-webkit-scrollbar-thumb { + background: var(--bd-3); + border-radius: 5px; + border: 2px solid transparent; + background-clip: content-box; +} + +.results-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 4px 16px; + gap: 16px; +} +.results-count { + color: var(--t-2); + font-size: 12.5px; +} +.results-count strong { + color: var(--t-1); + font-weight: 700; +} + +/* Grid */ +.grid { + display: grid; + gap: var(--card-gap, 16px); + grid-template-columns: repeat(auto-fill, minmax(var(--card-min, 188px), 1fr)); +} +.density-compact { + --card-min: 148px; + --card-gap: 12px; +} +.density-normal { + --card-min: 188px; + --card-gap: 16px; +} +.density-large { + --card-min: 244px; + --card-gap: 20px; +} + +/* Card */ +.card { + position: relative; + display: flex; + flex-direction: column; + background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%); + border: 1px solid var(--bd-1); + border-radius: var(--radius-md); + cursor: pointer; + overflow: hidden; + transition: + transform 0.18s cubic-bezier(0.4, 1.2, 0.5, 1), + border-color 0.18s, + box-shadow 0.18s; + outline: 0; + text-align: left; + padding: 0; + font: inherit; + color: inherit; +} +.card:hover, +.card:focus-visible { + transform: translateY(-2px); + border-color: color-mix(in srgb, var(--accent) 45%, var(--bd-2)); + box-shadow: + 0 14px 30px -16px color-mix(in srgb, var(--accent) 50%, black), + 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent); +} +.card:focus-visible { + box-shadow: + 0 14px 30px -16px color-mix(in srgb, var(--accent) 50%, black), + 0 0 0 2px var(--accent); +} + +/* Cover */ +.card-cover-wrap { + position: relative; + width: 100%; + overflow: hidden; + background: var(--bg-3); +} +.card-cover-wrap[data-aspect="box"] { + aspect-ratio: 2 / 3; +} +.card-cover-wrap[data-aspect="square"] { + aspect-ratio: 1 / 1; +} +.card-cover-wrap[data-aspect="banner"] { + aspect-ratio: 16 / 9; +} + +.cover { + position: absolute; + inset: 0; + overflow: hidden; + transition: transform 0.35s cubic-bezier(0.4, 1.2, 0.5, 1); +} +.card:hover .cover { + transform: scale(1.03); +} +.cover-image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.cover-base, +.cover-blob, +.cover-grain, +.cover-vignette, +.cover-mark { + position: absolute; + inset: 0; + pointer-events: none; +} +.cover-grain { + background-image: + repeating-linear-gradient( + 0deg, + rgba(255, 255, 255, 0.018) 0 1px, + transparent 1px 3px + ), + repeating-linear-gradient( + 90deg, + rgba(0, 0, 0, 0.04) 0 1px, + transparent 1px 3px + ); + mix-blend-mode: overlay; + opacity: 0.7; +} +.cover-vignette { + background: linear-gradient( + 180deg, + transparent 30%, + rgba(0, 0, 0, 0.62) 100% + ); +} +.cover-mark { + width: 100%; + height: 100%; +} +.cover-titlewrap { + position: absolute; + left: 0; + right: 0; + bottom: 0; + z-index: 2; + padding: 14px; +} +.cover-title { + font-family: var(--font-display); + font-weight: 400; + letter-spacing: 0.018em; + line-height: 1.02; + text-transform: uppercase; + color: white; + overflow-wrap: normal; + word-break: normal; +} + +/* State chip */ +.state-chip { + position: absolute; + top: 10px; + right: 10px; + z-index: 3; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 9px; + border-radius: 999px; + background: rgba(8, 12, 16, 0.78); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.08); + font-size: 10.5px; + font-weight: 600; + color: var(--t-1); + letter-spacing: 0.01em; +} +.state-dot { + width: 6px; + height: 6px; + border-radius: 999px; +} +.state-chip[data-state="installed"] .state-dot { + background: var(--ok); + box-shadow: 0 0 8px var(--ok); +} +.state-chip[data-state="local"] .state-dot { + background: var(--warn); + box-shadow: 0 0 8px var(--warn); +} +.state-chip[data-state="busy"] .state-dot { + background: var(--accent); + box-shadow: 0 0 8px var(--accent); + animation: state-busy 1.2s ease-in-out infinite; +} +@keyframes state-busy { + 0%, + 100% { + opacity: 0.4; + } + 50% { + opacity: 1; + } +} + +/* Multiplayer / peers badge */ +.card-mp { + position: absolute; + top: 10px; + left: 10px; + z-index: 3; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 999px; + background: rgba(8, 12, 16, 0.65); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.06); + font-size: 10.5px; + font-weight: 600; + color: var(--t-1); + font-variant-numeric: tabular-nums; +} + +/* Card body */ +.card-body { + padding: 11px 12px 12px; + display: flex; + flex-direction: column; + gap: 8px; +} +.card-title { + font-weight: 600; + font-size: 13.5px; + color: var(--t-1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: -0.005em; +} +.card-meta { + display: flex; + align-items: center; + gap: 6px; + font-size: 11.5px; + color: var(--t-3); + font-variant-numeric: tabular-nums; + white-space: nowrap; + overflow: hidden; +} +.card-meta .card-dot { + opacity: 0.5; +} +.card-status { + min-height: 14px; + font-size: 11px; + color: var(--t-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.card-status.is-error { + color: #f87171; +} + +.density-compact .card-body { + padding: 9px 10px 10px; + gap: 6px; +} +.density-compact .card-title { + font-size: 12.5px; +} +.density-compact .card-meta { + font-size: 11px; +} +.density-large .card-body { + padding: 14px; + gap: 10px; +} +.density-large .card-title { + font-size: 15px; +} + +/* Action buttons */ +.act-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 32px; + padding: 0 14px; + border-radius: 7px; + border: 0; + font: inherit; + font-weight: 600; + font-size: 12.5px; + letter-spacing: 0.005em; + cursor: pointer; + transition: + transform 0.12s, + filter 0.12s, + background 0.15s; + white-space: nowrap; +} +.act-btn:hover:not(:disabled) { + filter: brightness(1.12); +} +.act-btn:active:not(:disabled) { + transform: scale(0.98); +} +.act-btn:disabled { + cursor: not-allowed; + opacity: 0.7; +} +.act-full { + width: 100%; +} +.act-lg { + height: 44px; + padding: 0 22px; + font-size: 14px; + gap: 8px; + border-radius: 8px; +} +.act-lg svg { + width: 14px; + height: 14px; +} + +.act-play { + color: white; + background: linear-gradient(180deg, #2bd07f 0%, #1aa460 100%); + box-shadow: + 0 6px 16px -8px #1aa460, + inset 0 1px 0 rgba(255, 255, 255, 0.25); +} +.act-install, +.act-update { + color: white; + background: var(--accent); + box-shadow: + 0 6px 16px -8px color-mix(in srgb, var(--accent) 80%, black), + inset 0 1px 0 rgba(255, 255, 255, 0.22); +} +.act-download { + color: var(--t-1); + background: rgba(255, 255, 255, 0.08); + border: 1px solid var(--bd-2); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} +.act-download:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.12); + border-color: var(--bd-3); +} +.act-busy { + color: var(--t-1); + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--bd-1); +} +.act-busy::before { + content: ""; + display: inline-block; + width: 10px; + height: 10px; + border-radius: 999px; + border: 1.6px solid color-mix(in srgb, var(--accent) 60%, transparent); + border-top-color: var(--accent); + animation: spin 0.9s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +.act-disabled { + color: var(--t-3); + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--bd-1); + cursor: not-allowed; +} + +/* Ghost / secondary buttons */ +.ghost-btn { + display: inline-flex; + align-items: center; + gap: 7px; + height: 44px; + padding: 0 18px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--bd-2); + border-radius: 8px; + color: var(--t-1); + font: inherit; + font-size: 13.5px; + font-weight: 600; + cursor: pointer; + transition: + background 0.15s, + border-color 0.15s, + color 0.15s; +} +.ghost-btn:hover { + background: rgba(255, 255, 255, 0.08); + border-color: var(--bd-3); +} +.ghost-danger { + color: #f87171; +} +.ghost-danger:hover { + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.4); + color: #fca5a5; +} + +/* Modal */ +.modal-scrim { + position: absolute; + inset: 0; + z-index: 100; + background: rgba(4, 7, 11, 0.7); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + display: grid; + place-items: center; + padding: 32px; + animation: fadein 0.18s ease; +} +@keyframes fadein { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +.modal { + width: min(880px, 100%); + max-height: 100%; + background: linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%); + border: 1px solid var(--bd-2); + border-radius: 14px; + overflow: hidden; + position: relative; + box-shadow: + 0 30px 80px -10px rgba(0, 0, 0, 0.7), + 0 0 0 1px rgba(255, 255, 255, 0.04); + display: flex; + flex-direction: column; + animation: modalin 0.25s cubic-bezier(0.3, 1.3, 0.4, 1); +} +@keyframes modalin { + from { + transform: scale(0.96) translateY(8px); + opacity: 0; + } + to { + transform: scale(1) translateY(0); + opacity: 1; + } +} +.modal-close { + position: absolute; + top: 14px; + right: 14px; + z-index: 5; + width: 32px; + height: 32px; + display: grid; + place-items: center; + background: rgba(8, 12, 16, 0.7); + border: 1px solid var(--bd-2); + border-radius: 8px; + color: var(--t-1); + cursor: pointer; + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + transition: + background 0.15s, + border-color 0.15s; +} +.modal-close:hover { + background: rgba(255, 255, 255, 0.1); + border-color: var(--bd-3); +} +.modal-hero { + position: relative; + aspect-ratio: 16 / 7; + overflow: hidden; + background: var(--bg-3); +} +.modal-hero .cover { + transform: none !important; +} +.modal-hero-fade { + position: absolute; + inset: 0; + background: linear-gradient(180deg, transparent 40%, var(--bg-2) 100%); + pointer-events: none; +} +.modal-hero-text { + position: absolute; + left: 28px; + right: 28px; + bottom: 22px; + z-index: 2; +} +.modal-hero-text .modal-title { + font-family: var(--font-ui); + font-size: 32px; + font-weight: 700; + letter-spacing: -0.015em; + color: white; + margin: 6px 0 0; + text-shadow: 0 4px 24px rgba(0, 0, 0, 0.6); +} +.modal-tags { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.modal-tag { + display: inline-block; + padding: 3px 8px; + background: rgba(8, 12, 16, 0.6); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + border: 1px solid var(--bd-2); + border-radius: 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--t-1); +} +.modal-state { + position: absolute; + top: 18px; + left: 24px; + z-index: 3; +} +.modal-state .state-chip { + position: static; + font-size: 11.5px; + padding: 5px 11px; +} + +.modal-body { + padding: 22px 28px 26px; + display: flex; + flex-direction: column; + gap: 18px; + overflow: auto; +} +.modal-meta { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; +} +.meta-cell { + padding: 10px 12px; + background: rgba(255, 255, 255, 0.025); + border: 1px solid var(--bd-1); + border-radius: 8px; +} +.meta-label { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--t-3); +} +.meta-value { + margin-top: 4px; + font-size: 14px; + font-weight: 600; + color: var(--t-1); + display: flex; + align-items: center; + gap: 6px; +} +.meta-mono { + font-family: var(--font-mono); + font-size: 13px; +} + +.modal-desc { + margin: 0; + font-size: 14px; + line-height: 1.55; + color: var(--t-2); + text-wrap: pretty; + max-width: 64ch; + white-space: pre-wrap; +} +.modal-status { + margin: 0; + font-size: 13px; + color: var(--t-2); + padding: 10px 12px; + background: rgba(255, 255, 255, 0.025); + border: 1px solid var(--bd-1); + border-radius: 8px; +} +.modal-status.is-error { + color: #f87171; + border-color: rgba(239, 68, 68, 0.4); + background: rgba(239, 68, 68, 0.08); +} +.modal-actions { + display: flex; + align-items: center; + gap: 10px; + padding-top: 4px; + flex-wrap: wrap; +} +.modal-actions-spacer { + flex: 1; +} + +/* Settings dialog */ +.settings-modal { + width: min(640px, 100%); + background: var(--bg-2); +} +.settings-head { + position: relative; + padding: 22px 28px 18px; + border-bottom: 1px solid var(--bd-1); +} +.settings-head h2 { + margin: 0; + font-size: 20px; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--t-1); +} +.settings-close { + position: absolute; + top: 18px; + right: 18px; +} +.settings-body { + padding: 22px 28px 26px; + display: flex; + flex-direction: column; + gap: 26px; + max-height: 70vh; + overflow: auto; +} +.settings-section { + display: flex; + flex-direction: column; + gap: 14px; +} +.settings-section-title { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--t-3); +} +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} +.settings-row-info { + min-width: 0; + flex: 1; +} +.settings-row-label { + font-size: 14px; + font-weight: 600; + color: var(--t-1); +} +.settings-row-hint { + margin-top: 3px; + font-size: 12px; + color: var(--t-3); +} +.settings-row-control { + flex-shrink: 0; +} +.settings-foot { + display: flex; + justify-content: flex-end; + padding: 14px 22px 18px; + border-top: 1px solid var(--bd-1); + gap: 10px; +} +.settings-done { + height: 36px; + padding: 0 22px; + font-size: 13.5px; + background: var(--accent); + color: white; + border: 0; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: filter 0.12s; +} +.settings-done:hover { + filter: brightness(1.1); +} + +/* Settings: color swatches */ +.swatch-row { + display: inline-flex; + gap: 8px; +} +.swatch { + position: relative; + width: 32px; + height: 32px; + padding: 0; + background: transparent; + border: 0; + border-radius: 9px; + cursor: pointer; +} +.swatch-dot { + display: block; + width: 100%; + height: 100%; + border-radius: 8px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); + transition: + transform 0.15s, + box-shadow 0.15s; +} +.swatch:hover .swatch-dot { + transform: scale(1.06); +} +.swatch.is-active .swatch-dot { + box-shadow: + 0 0 0 2px var(--bg-2), + 0 0 0 4px currentColor; +} +.swatch-check { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: white; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5)); + pointer-events: none; +} + +/* Settings: segmented radio */ +.srad { + display: inline-flex; + background: var(--bg-3); + border: 1px solid var(--bd-1); + border-radius: 8px; + padding: 3px; +} +.srad-btn { + display: inline-flex; + align-items: center; + height: 30px; + padding: 0 14px; + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + color: var(--t-2); + font: inherit; + font-weight: 600; + font-size: 12.5px; + cursor: pointer; + transition: + color 0.15s, + background 0.15s; + white-space: nowrap; +} +.srad-btn:hover { + color: var(--t-1); +} +.srad-btn.is-active { + color: white; + background: var(--accent); + border-color: var(--accent); + box-shadow: + 0 2px 8px -2px rgba(0, 0, 0, 0.4), + inset 0 1px 0 rgba(255, 255, 255, 0.18); +} + +/* Empty / placeholder states */ +.empty-state { + display: grid; + place-items: center; + padding: 80px 24px; + text-align: center; + color: var(--t-2); +} +.empty-state-icon { + width: 56px; + height: 56px; + display: grid; + place-items: center; + border-radius: 14px; + margin-bottom: 18px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--bd-1); + color: var(--t-2); +} +.empty-state-title { + font-size: 18px; + font-weight: 700; + color: var(--t-1); + margin: 0 0 6px; + letter-spacing: -0.01em; +} +.empty-state-hint { + font-size: 13px; + color: var(--t-3); + margin: 0 0 20px; + max-width: 44ch; +} +.empty-state .ghost-btn { + background: var(--accent); + color: white; + border-color: transparent; +} +.empty-state .ghost-btn:hover { + filter: brightness(1.1); + background: var(--accent); + border-color: transparent; +} diff --git a/crates/lanspread-tauri-deno-ts/src/styles/tokens.css b/crates/lanspread-tauri-deno-ts/src/styles/tokens.css new file mode 100644 index 0000000..7874065 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/styles/tokens.css @@ -0,0 +1,50 @@ +:root { + --accent: #3b82f6; + + --bg-0: #0a0e13; + --bg-1: #0f151c; + --bg-2: #131b25; + --bg-3: #1a2330; + --bg-4: #232f3e; + + --bd-1: rgba(255, 255, 255, 0.06); + --bd-2: rgba(255, 255, 255, 0.10); + --bd-3: rgba(255, 255, 255, 0.16); + + --t-1: #e6edf3; + --t-2: #9aa6b4; + --t-3: #6b7785; + --t-4: #4a5663; + + --ok: #22c55e; + --warn: #f59e0b; + --danger: #ef4444; + + --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI Variable", + "Segoe UI", Inter, system-ui, sans-serif; + --font-display: "Bebas Neue", "Oswald", Impact, "Arial Narrow Bold", + sans-serif; + --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + background: var(--bg-0); + color: var(--t-1); + font-family: var(--font-ui); + font-size: 13px; + line-height: 1.4; +} diff --git a/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx b/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx new file mode 100644 index 0000000..113a10f --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx @@ -0,0 +1,168 @@ +import { useCallback, useMemo, useState } from 'react'; +import { open } from '@tauri-apps/plugin-dialog'; + +import { TopBar } from '../components/topbar/TopBar'; +import { KebabItem } from '../components/topbar/KebabMenu'; +import { ResultsBar } from '../components/grid/ResultsBar'; +import { GameGrid } from '../components/grid/GameGrid'; +import { GameDetailModal } from '../components/modals/GameDetailModal'; +import { SettingsDialog } from '../components/modals/SettingsDialog'; +import { NoDirectoryState } from '../components/empty/NoDirectoryState'; +import { EmptyResultsState } from '../components/empty/EmptyResultsState'; + +import { useGameDirectory } from '../hooks/useGameDirectory'; +import { useGames } from '../hooks/useGames'; +import { useGameActions } from '../hooks/useGameActions'; +import { useThumbnails } from '../hooks/useThumbnails'; +import { useSettings } from '../hooks/useSettings'; + +import { Game } from '../lib/types'; +import { applyFilterAndSort, countByFilter, needsUpdate } from '../lib/gameState'; + +const openLogsWindow = async () => { + const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow'); + try { + const existing = await WebviewWindow.getByLabel('unpack-logs'); + if (existing) { + await existing.setFocus(); + return; + } + const win = new WebviewWindow('unpack-logs', { + url: '/?view=unpack-logs', + title: 'Unpack Logs', + width: 900, + height: 700, + resizable: true, + }); + await win.once('tauri://error', (event) => { + console.error('Error opening unpack logs window:', event.payload); + }); + } catch (err) { + console.error('Error opening unpack logs window:', err); + } +}; + +export const MainWindow = () => { + const { settings, set: setSetting } = useSettings(); + const { gameDir, setGameDir, rescan } = useGameDirectory(); + const games = useGames(rescan); + const actions = useGameActions(games); + const thumbnails = useThumbnails(); + + const [openGameId, setOpenGameId] = useState(null); + const [settingsOpen, setSettingsOpen] = useState(false); + + const counts = useMemo(() => countByFilter(games.games), [games.games]); + + // Query is local UI state (no need to persist). + const [query, setQuery] = useState(''); + const filteredGames = useMemo( + () => applyFilterAndSort(games.games, settings.filter, settings.sort, query), + [games.games, settings.filter, settings.sort, query], + ); + + const openGame = useMemo( + () => openGameId ? games.games.find(g => g.id === openGameId) ?? null : null, + [openGameId, games.games], + ); + + const pickDirectory = useCallback(async () => { + const picked = await open({ multiple: false, directory: true }); + if (typeof picked === 'string' && picked) setGameDir(picked); + }, [setGameDir]); + + const handlePrimary = useCallback((game: Game) => { + if (game.installed) { + if (needsUpdate(game)) actions.update(game.id); + else actions.play(game.id); + } else { + actions.install(game.id); + } + }, [actions]); + + const handleUninstall = useCallback((game: Game) => { + actions.uninstall(game.id); + }, [actions]); + + const kebabItems: ReadonlyArray = useMemo(() => [ + { kind: 'item', label: 'Settings', onClick: () => setSettingsOpen(true) }, + { kind: 'item', label: 'Refresh library', onClick: () => rescan() }, + { kind: 'separator' }, + { kind: 'item', label: 'Unpack logs', onClick: () => void openLogsWindow() }, + ], [rescan]); + + const rootStyle = { '--accent': settings.accent } as React.CSSProperties; + const className = [ + 'launcher', + `bg-${settings.bg}`, + `density-${settings.density}`, + ].join(' '); + + return ( +
+ {gameDir ? ( + <> + setSetting('filter', v)} + counts={counts} + query={query} + setQuery={setQuery} + sort={settings.sort} + setSort={(v) => setSetting('sort', v)} + gameDir={gameDir} + onPickDirectory={() => void pickDirectory()} + kebabItems={kebabItems} + /> +
+ + {filteredGames.length === 0 ? ( + games.games.length === 0 ? ( + + ) : ( + + ) + ) : ( + setOpenGameId(g.id)} + onPrimary={handlePrimary} + /> + )} +
+ + ) : ( +
+ void pickDirectory()} /> +
+ )} + + {openGame && ( + setOpenGameId(null)} + onPrimary={handlePrimary} + onUninstall={handleUninstall} + /> + )} + + {settingsOpen && ( + setSettingsOpen(false)} + /> + )} +
+ ); +};