1365 lines
45 KiB
Svelte
1365 lines
45 KiB
Svelte
<script lang="ts">
|
||
import { invoke } from '@tauri-apps/api/core';
|
||
import { onMount } from 'svelte';
|
||
|
||
type ActiveView = 'recommend' | 'shortlist';
|
||
type ModalView = 'setup' | 'settings' | 'about' | null;
|
||
|
||
type Recommendation = {
|
||
appId: number;
|
||
rank: number;
|
||
title: string;
|
||
fit: string;
|
||
reason: string;
|
||
imageUrl: string;
|
||
playtimeMinutes: number;
|
||
genres: string[];
|
||
steamTags: string[];
|
||
categories: string[];
|
||
reviewSummary: string;
|
||
reviewPercent: number | null;
|
||
supportsSingleplayer: boolean;
|
||
supportsMultiplayer: boolean;
|
||
};
|
||
|
||
type SetupSettings = {
|
||
steamProfile: string;
|
||
steamApiKey: string;
|
||
timeToBeatProvider: string;
|
||
defaultSessionLength: string;
|
||
trueRandomEnabled: boolean;
|
||
includeUnplayedGames: boolean;
|
||
includePlayedGames: boolean;
|
||
preferredReviewLevel: string;
|
||
preferredPlayMode: string;
|
||
preferredGenre: string;
|
||
preferredPlaytime: string;
|
||
};
|
||
|
||
let activeView: ActiveView = 'recommend';
|
||
let modalView: ModalView = null;
|
||
let setupStep = 0;
|
||
let steamProfile = '';
|
||
let steamApiKey = '';
|
||
let timeToBeatProvider = '';
|
||
let defaultSessionLength = '90 minutes';
|
||
let trueRandomEnabled = false;
|
||
let includeUnplayedGames = true;
|
||
let includePlayedGames = true;
|
||
let preferredReviewLevel = 'any';
|
||
let preferredPlayMode = 'any';
|
||
let preferredGenre = '';
|
||
let preferredPlaytime = 'any';
|
||
let setupComplete = false;
|
||
let spinCount = 0;
|
||
let wheelRotation = 0;
|
||
let selectedRecommendation: Recommendation | null = null;
|
||
let importedLibrary: Recommendation[] = [];
|
||
let isImportingLibrary = false;
|
||
let isEnrichingMetadata = false;
|
||
let isEnrichingSelectedMetadata = false;
|
||
let isLoadingSettings = true;
|
||
let libraryImportError = '';
|
||
let metadataRevision = 0;
|
||
let isTauriRuntime = false;
|
||
let isDebugWindow = false;
|
||
let browserReady = false;
|
||
let debugSnapshot: ReturnType<typeof createDebugSnapshot> | null = null;
|
||
|
||
const setupSteps = [
|
||
{
|
||
label: 'Steam profile',
|
||
type: 'text',
|
||
placeholder: 'Steam ID, vanity URL, or profile URL',
|
||
title: 'Connect your Steam profile',
|
||
instruction:
|
||
'Enter the public identifier for the Steam account whose backlog should be imported. You can paste a full profile URL, a custom vanity URL, or a 64-bit Steam ID.',
|
||
helper:
|
||
'Your profile and game details must be visible to the Steam Web API. Private libraries are not part of the MVP.'
|
||
},
|
||
{
|
||
label: 'Steam Web API key',
|
||
type: 'password',
|
||
placeholder: 'Stored locally',
|
||
title: 'Add your Steam Web API key',
|
||
instruction:
|
||
'Paste a Steam Web API key from Valve. The app uses it to fetch owned games and playtime for the profile you entered.',
|
||
helper:
|
||
'The key should be stored locally by the desktop app. Do not paste your Steam password; the app should never ask for it.'
|
||
},
|
||
{
|
||
label: 'Time-to-beat provider',
|
||
type: 'text',
|
||
placeholder: 'HowLongToBeat-compatible provider',
|
||
title: 'Choose the duration metadata source',
|
||
instruction:
|
||
'Provide the configured source or key for a HowLongToBeat-compatible provider. This lets the picker match games to the time available for a session.',
|
||
helper:
|
||
'If this is left blank later, recommendations can still work, but time-based ranking will be weaker.'
|
||
},
|
||
{
|
||
label: 'Default session length',
|
||
type: 'select',
|
||
placeholder: '90 minutes',
|
||
title: 'Set the default session length',
|
||
instruction:
|
||
'Choose the starting time window used by the recommendation spinner. You can still change this before each spin once the recommendation flow is implemented.',
|
||
helper: 'Pick the duration that best matches your usual play session.'
|
||
},
|
||
{
|
||
label: 'Random mode',
|
||
type: 'toggle',
|
||
placeholder: 'Enable true random mode',
|
||
title: 'Decide how random the picker should be',
|
||
instruction:
|
||
'Enable true random if you want a mode that ignores scoring and chooses from the eligible library pool directly.',
|
||
helper:
|
||
'Weighted recommendations remain the default because they can explain why a game was selected.'
|
||
}
|
||
];
|
||
|
||
const setupOptions = setupSteps.slice(0, 3);
|
||
|
||
const reviewPreferences = [
|
||
{ value: 'any', label: 'Any review signal', minimum: 0 },
|
||
{ value: 'overwhelmingly_positive', label: 'Overwhelmingly Positive', minimum: 95 },
|
||
{ value: 'very_positive', label: 'Very Positive', minimum: 80 },
|
||
{ value: 'positive', label: 'Positive', minimum: 70 },
|
||
{ value: 'mostly_positive', label: 'Mostly Positive', minimum: 60 },
|
||
{ value: 'mixed', label: 'Mixed', minimum: 40 },
|
||
{ value: 'negative', label: 'Negative or unusual picks', minimum: 0 }
|
||
];
|
||
|
||
const playModePreferences = [
|
||
{ value: 'any', label: 'Any mode' },
|
||
{ value: 'singleplayer', label: 'Prefer single-player' },
|
||
{ value: 'multiplayer', label: 'Prefer multiplayer/co-op' }
|
||
];
|
||
|
||
const playtimePreferences = [
|
||
{ value: 'any', label: 'Any playtime' },
|
||
{ value: 'unplayed', label: 'Prefer unplayed' },
|
||
{ value: 'lightly_played', label: 'Prefer lightly played' },
|
||
{ value: 'started', label: 'Prefer already started' }
|
||
];
|
||
|
||
$: availableGenres = Array.from(
|
||
new Set(importedLibrary.flatMap((game) => [...game.genres, ...game.steamTags]).filter(Boolean))
|
||
).sort((left, right) => left.localeCompare(right));
|
||
$: candidateGames = importedLibrary
|
||
.filter(isEligibleGame)
|
||
.map((game) => ({ ...game, score: scoreGame(game) }))
|
||
.sort((left, right) => right.score - left.score || left.title.localeCompare(right.title));
|
||
$: wheelGames = candidateGames;
|
||
$: shortlistGames = candidateGames.slice(0, 5);
|
||
$: debugGames = importedLibrary
|
||
.map((game) => ({ game, debug: scoreGameDebug(game) }))
|
||
.sort((left, right) => right.debug.total - left.debug.total || left.game.title.localeCompare(right.game.title));
|
||
$: excludedDebugGames = debugGames.filter((entry) => !entry.debug.eligible);
|
||
$: includedDebugGames = debugGames.filter((entry) => entry.debug.eligible);
|
||
$: debugPublishKey = [
|
||
selectedRecommendation?.appId ?? 'none',
|
||
spinCount,
|
||
metadataRevision,
|
||
importedLibrary.length,
|
||
includedDebugGames.length,
|
||
excludedDebugGames.length,
|
||
trueRandomEnabled,
|
||
includeUnplayedGames,
|
||
includePlayedGames,
|
||
preferredReviewLevel,
|
||
preferredPlayMode,
|
||
preferredGenre,
|
||
preferredPlaytime,
|
||
isEnrichingMetadata,
|
||
isEnrichingSelectedMetadata
|
||
].join('|');
|
||
|
||
$: if (browserReady && !isDebugWindow && debugPublishKey) {
|
||
publishDebugSnapshot();
|
||
}
|
||
|
||
onMount(() => {
|
||
browserReady = true;
|
||
isDebugWindow =
|
||
new URLSearchParams(window.location.search).get('debug') === '1' ||
|
||
window.location.hash === '#debug';
|
||
isTauriRuntime = Boolean(window.__TAURI_INTERNALS__);
|
||
|
||
if (isDebugWindow) {
|
||
loadDebugSnapshot();
|
||
const interval = window.setInterval(loadDebugSnapshot, 500);
|
||
window.addEventListener('storage', loadDebugSnapshot);
|
||
|
||
return () => {
|
||
window.clearInterval(interval);
|
||
window.removeEventListener('storage', loadDebugSnapshot);
|
||
};
|
||
}
|
||
|
||
void loadSavedSettings();
|
||
});
|
||
|
||
function openModal(view: Exclude<ModalView, null>) {
|
||
if (view === 'setup') {
|
||
setupStep = 0;
|
||
}
|
||
|
||
modalView = view;
|
||
}
|
||
|
||
function closeModal() {
|
||
modalView = null;
|
||
}
|
||
|
||
function hasRequiredSetup() {
|
||
return steamProfile.trim().length > 0 && steamApiKey.trim().length > 0;
|
||
}
|
||
|
||
async function loadSavedSettings() {
|
||
isLoadingSettings = true;
|
||
|
||
if (!isTauriRuntime) {
|
||
libraryImportError =
|
||
'Desktop integration is unavailable in browser preview. Run the app with Tauri to load saved settings and import your Steam library.';
|
||
modalView = 'setup';
|
||
isLoadingSettings = false;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const savedSettings = await invoke<SetupSettings | null>('load_setup_settings');
|
||
|
||
if (!savedSettings) {
|
||
modalView = 'setup';
|
||
return;
|
||
}
|
||
|
||
steamProfile = savedSettings.steamProfile;
|
||
steamApiKey = savedSettings.steamApiKey;
|
||
timeToBeatProvider = savedSettings.timeToBeatProvider;
|
||
defaultSessionLength = savedSettings.defaultSessionLength || '90 minutes';
|
||
trueRandomEnabled = savedSettings.trueRandomEnabled;
|
||
includeUnplayedGames = savedSettings.includeUnplayedGames ?? true;
|
||
includePlayedGames = savedSettings.includePlayedGames ?? true;
|
||
preferredReviewLevel = savedSettings.preferredReviewLevel || 'any';
|
||
preferredPlayMode = savedSettings.preferredPlayMode || 'any';
|
||
preferredGenre = savedSettings.preferredGenre || '';
|
||
preferredPlaytime = savedSettings.preferredPlaytime || 'any';
|
||
setupComplete = hasRequiredSetup();
|
||
modalView = null;
|
||
|
||
if (setupComplete) {
|
||
isLoadingSettings = false;
|
||
await importSteamLibrary();
|
||
}
|
||
} catch (error) {
|
||
libraryImportError =
|
||
error instanceof Error ? error.message : String(error || 'Could not load saved settings.');
|
||
modalView = 'setup';
|
||
} finally {
|
||
isLoadingSettings = false;
|
||
}
|
||
}
|
||
|
||
async function persistSetupSettings() {
|
||
if (!isTauriRuntime) {
|
||
return;
|
||
}
|
||
|
||
await invoke('save_setup_settings', {
|
||
settings: {
|
||
steamProfile,
|
||
steamApiKey,
|
||
timeToBeatProvider,
|
||
defaultSessionLength,
|
||
trueRandomEnabled,
|
||
includeUnplayedGames,
|
||
includePlayedGames,
|
||
preferredReviewLevel,
|
||
preferredPlayMode,
|
||
preferredGenre,
|
||
preferredPlaytime
|
||
}
|
||
});
|
||
}
|
||
|
||
async function importSteamLibrary() {
|
||
if (!hasRequiredSetup()) {
|
||
importedLibrary = [];
|
||
setupComplete = false;
|
||
return;
|
||
}
|
||
|
||
isImportingLibrary = true;
|
||
libraryImportError = '';
|
||
|
||
if (!isTauriRuntime) {
|
||
importedLibrary = [];
|
||
setupComplete = false;
|
||
libraryImportError =
|
||
'Steam library import requires the Tauri desktop runtime. Browser preview cannot access native commands.';
|
||
isImportingLibrary = false;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
importedLibrary = await invoke<Recommendation[]>('import_steam_library', {
|
||
steamProfile,
|
||
apiKey: steamApiKey
|
||
});
|
||
setupComplete = importedLibrary.length > 0;
|
||
selectedRecommendation = null;
|
||
spinCount = 0;
|
||
metadataRevision += 1;
|
||
|
||
if (setupComplete) {
|
||
void enrichSteamMetadata();
|
||
}
|
||
} catch (error) {
|
||
importedLibrary = [];
|
||
setupComplete = false;
|
||
libraryImportError =
|
||
error instanceof Error ? error.message : String(error || 'Failed to import Steam library.');
|
||
} finally {
|
||
isImportingLibrary = false;
|
||
}
|
||
}
|
||
|
||
async function enrichSteamMetadata() {
|
||
if (!isTauriRuntime || importedLibrary.length === 0) {
|
||
return;
|
||
}
|
||
|
||
isEnrichingMetadata = true;
|
||
|
||
try {
|
||
const enrichedLibrary = await invoke<Recommendation[]>('enrich_steam_metadata', {
|
||
games: importedLibrary
|
||
});
|
||
importedLibrary = enrichedLibrary;
|
||
|
||
if (selectedRecommendation) {
|
||
selectedRecommendation =
|
||
enrichedLibrary.find((game) => game.appId === selectedRecommendation?.appId) ??
|
||
selectedRecommendation;
|
||
}
|
||
|
||
metadataRevision += 1;
|
||
} catch (error) {
|
||
libraryImportError =
|
||
error instanceof Error
|
||
? error.message
|
||
: String(error || 'Failed to enrich Steam metadata.');
|
||
} finally {
|
||
isEnrichingMetadata = false;
|
||
}
|
||
}
|
||
|
||
async function enrichSelectedRecommendation(game: Recommendation) {
|
||
if (!isTauriRuntime) {
|
||
return;
|
||
}
|
||
|
||
isEnrichingSelectedMetadata = true;
|
||
|
||
try {
|
||
const [enrichedGame] = await invoke<Recommendation[]>('enrich_steam_metadata', {
|
||
games: [game]
|
||
});
|
||
|
||
if (!enrichedGame) {
|
||
return;
|
||
}
|
||
|
||
selectedRecommendation = enrichedGame;
|
||
importedLibrary = importedLibrary.map((libraryGame) =>
|
||
libraryGame.appId === enrichedGame.appId ? enrichedGame : libraryGame
|
||
);
|
||
metadataRevision += 1;
|
||
} catch (error) {
|
||
libraryImportError =
|
||
error instanceof Error
|
||
? error.message
|
||
: String(error || 'Failed to enrich selected game metadata.');
|
||
} finally {
|
||
isEnrichingSelectedMetadata = false;
|
||
}
|
||
}
|
||
|
||
async function saveSetup() {
|
||
setupComplete = hasRequiredSetup();
|
||
await persistSetupSettings();
|
||
await importSteamLibrary();
|
||
modalView = null;
|
||
activeView = 'recommend';
|
||
}
|
||
|
||
async function saveSettings() {
|
||
setupComplete = hasRequiredSetup();
|
||
await persistSetupSettings();
|
||
await importSteamLibrary();
|
||
modalView = null;
|
||
}
|
||
|
||
async function openDebugWindow() {
|
||
publishDebugSnapshot();
|
||
|
||
if (!isTauriRuntime) {
|
||
window.open('?debug=1', 'steam-backlog-helper-debug', 'width=1120,height=820');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await invoke('open_debug_window');
|
||
} catch (error) {
|
||
libraryImportError =
|
||
error instanceof Error ? error.message : String(error || 'Could not open debug window.');
|
||
}
|
||
}
|
||
|
||
function spinRecommendation() {
|
||
if (!setupComplete) {
|
||
openModal('setup');
|
||
return;
|
||
}
|
||
|
||
if (candidateGames.length === 0) {
|
||
libraryImportError = 'Import your Steam library before spinning.';
|
||
openModal('setup');
|
||
return;
|
||
}
|
||
|
||
const availableGames =
|
||
candidateGames.length > 1 && selectedRecommendation
|
||
? candidateGames.filter((game) => game.appId !== selectedRecommendation?.appId)
|
||
: candidateGames;
|
||
|
||
const nextRecommendation = trueRandomEnabled
|
||
? availableGames[Math.floor(Math.random() * availableGames.length)]
|
||
: pickWeightedGame(availableGames);
|
||
const visualIndex = Math.max(
|
||
0,
|
||
wheelGames.findIndex((game) => game.appId === nextRecommendation.appId)
|
||
);
|
||
|
||
selectedRecommendation = nextRecommendation;
|
||
wheelRotation += 720 + visualIndex * (360 / wheelGames.length) + 360 / wheelGames.length / 2;
|
||
spinCount += 1;
|
||
void enrichSelectedRecommendation(nextRecommendation);
|
||
}
|
||
|
||
function nextSetupStep() {
|
||
setupStep = Math.min(setupStep + 1, setupSteps.length);
|
||
}
|
||
|
||
function previousSetupStep() {
|
||
setupStep = Math.max(setupStep - 1, 0);
|
||
}
|
||
|
||
function isEligibleGame(game: Recommendation) {
|
||
const isUnplayed = game.playtimeMinutes === 0;
|
||
|
||
if (!includeUnplayedGames && isUnplayed) {
|
||
return false;
|
||
}
|
||
|
||
if (!includePlayedGames && !isUnplayed) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
function scoreGame(game: Recommendation) {
|
||
return scoreGameDebug(game).total;
|
||
}
|
||
|
||
function scoreGameDebug(game: Recommendation) {
|
||
const contributions: Array<{ label: string; value: number; detail: string }> = [
|
||
{ label: 'Base candidate weight', value: trueRandomEnabled ? 1 : 10, detail: 'Every eligible game starts here.' }
|
||
];
|
||
const restrictions: string[] = [];
|
||
const isUnplayed = game.playtimeMinutes === 0;
|
||
|
||
if (!includeUnplayedGames && isUnplayed) {
|
||
restrictions.push('Excluded because unplayed games are disabled.');
|
||
}
|
||
|
||
if (!includePlayedGames && !isUnplayed) {
|
||
restrictions.push('Excluded because games with playtime are disabled.');
|
||
}
|
||
|
||
if (trueRandomEnabled) {
|
||
return {
|
||
eligible: restrictions.length === 0,
|
||
restrictions,
|
||
total: restrictions.length === 0 ? 1 : 0,
|
||
contributions,
|
||
metadata: gameDebugMetadata(game)
|
||
};
|
||
}
|
||
|
||
let score = 10;
|
||
const reviewPreference = reviewPreferences.find(
|
||
(preference) => preference.value === preferredReviewLevel
|
||
);
|
||
|
||
if (reviewPreference && reviewPreference.value !== 'any' && game.reviewPercent !== null) {
|
||
const value = game.reviewPercent >= reviewPreference.minimum ? 16 : -6;
|
||
score += value;
|
||
contributions.push({
|
||
label: 'Review preference',
|
||
value,
|
||
detail: `${game.reviewPercent}% positive vs ${reviewPreference.minimum}% target.`
|
||
});
|
||
} else if (reviewPreference && reviewPreference.value !== 'any') {
|
||
contributions.push({
|
||
label: 'Review preference',
|
||
value: 0,
|
||
detail: 'Review data is pending, so no review weight was applied.'
|
||
});
|
||
}
|
||
|
||
if (preferredReviewLevel === 'negative' && game.reviewPercent !== null) {
|
||
const value = game.reviewPercent < 60 ? 16 : -4;
|
||
score += value;
|
||
contributions.push({
|
||
label: 'Negative review preference',
|
||
value,
|
||
detail: `${game.reviewPercent}% positive.`
|
||
});
|
||
}
|
||
|
||
if (preferredPlayMode === 'singleplayer') {
|
||
const value = game.supportsSingleplayer ? 14 : -4;
|
||
score += value;
|
||
contributions.push({
|
||
label: 'Single-player preference',
|
||
value,
|
||
detail: game.supportsSingleplayer ? 'Single-player category detected.' : 'Single-player category not detected.'
|
||
});
|
||
}
|
||
|
||
if (preferredPlayMode === 'multiplayer') {
|
||
const value = game.supportsMultiplayer ? 14 : -4;
|
||
score += value;
|
||
contributions.push({
|
||
label: 'Multiplayer preference',
|
||
value,
|
||
detail: game.supportsMultiplayer ? 'Multiplayer or co-op category detected.' : 'Multiplayer category not detected.'
|
||
});
|
||
}
|
||
|
||
if (
|
||
preferredGenre &&
|
||
[...game.genres, ...game.steamTags].some((genre) => genre === preferredGenre)
|
||
) {
|
||
score += 18;
|
||
contributions.push({
|
||
label: 'Genre preference',
|
||
value: 18,
|
||
detail: `${preferredGenre} matched.`
|
||
});
|
||
} else if (preferredGenre) {
|
||
contributions.push({
|
||
label: 'Genre preference',
|
||
value: 0,
|
||
detail: `${preferredGenre} not present or metadata is pending.`
|
||
});
|
||
}
|
||
|
||
if (preferredPlaytime === 'unplayed') {
|
||
const value = game.playtimeMinutes === 0 ? 14 : -2;
|
||
score += value;
|
||
contributions.push({
|
||
label: 'Unplayed preference',
|
||
value,
|
||
detail: game.playtimeMinutes === 0 ? 'No recorded playtime.' : `${game.playtimeMinutes} minutes played.`
|
||
});
|
||
}
|
||
|
||
if (preferredPlaytime === 'lightly_played') {
|
||
const value = game.playtimeMinutes > 0 && game.playtimeMinutes <= 300 ? 14 : -2;
|
||
score += value;
|
||
contributions.push({
|
||
label: 'Lightly played preference',
|
||
value,
|
||
detail: `${game.playtimeMinutes} minutes played.`
|
||
});
|
||
}
|
||
|
||
if (preferredPlaytime === 'started') {
|
||
const value = game.playtimeMinutes > 0 ? 12 : -2;
|
||
score += value;
|
||
contributions.push({
|
||
label: 'Started preference',
|
||
value,
|
||
detail: game.playtimeMinutes > 0 ? `${game.playtimeMinutes} minutes played.` : 'No recorded playtime.'
|
||
});
|
||
}
|
||
|
||
const eligible = restrictions.length === 0;
|
||
|
||
return {
|
||
eligible,
|
||
restrictions,
|
||
total: eligible ? Math.max(1, score) : 0,
|
||
contributions,
|
||
metadata: gameDebugMetadata(game)
|
||
};
|
||
}
|
||
|
||
function gameDebugMetadata(game: Recommendation) {
|
||
return {
|
||
reviews: game.reviewPercent === null ? game.reviewSummary : `${game.reviewSummary} (${game.reviewPercent}%)`,
|
||
genres: game.genres.length > 0 ? game.genres.join(', ') : 'Pending',
|
||
steamTags: game.steamTags.length > 0 ? game.steamTags.join(', ') : 'Pending',
|
||
categories: game.categories.length > 0 ? game.categories.join(', ') : 'Pending',
|
||
playMode: [
|
||
game.supportsSingleplayer ? 'Single-player' : null,
|
||
game.supportsMultiplayer ? 'Multiplayer/co-op' : null
|
||
].filter(Boolean).join(', ') || 'Pending',
|
||
playtime: game.playtimeMinutes === 0 ? 'Unplayed' : `${Math.floor(game.playtimeMinutes / 60)}h ${game.playtimeMinutes % 60}m`
|
||
};
|
||
}
|
||
|
||
function pickWeightedGame(games: Array<Recommendation & { score: number }>) {
|
||
const totalWeight = games.reduce((total, game) => total + game.score, 0);
|
||
let cursor = Math.random() * totalWeight;
|
||
|
||
for (const game of games) {
|
||
cursor -= game.score;
|
||
|
||
if (cursor <= 0) {
|
||
return game;
|
||
}
|
||
}
|
||
|
||
return games[games.length - 1];
|
||
}
|
||
|
||
function createDebugSnapshot() {
|
||
return {
|
||
generatedAt: new Date().toLocaleTimeString(),
|
||
selected: selectedRecommendation
|
||
? {
|
||
game: selectedRecommendation,
|
||
debug: scoreGameDebug(selectedRecommendation)
|
||
}
|
||
: null,
|
||
summary: {
|
||
imported: importedLibrary.length,
|
||
eligible: includedDebugGames.length,
|
||
restricted: excludedDebugGames.length,
|
||
mode: trueRandomEnabled ? 'Random' : 'Weighted',
|
||
spinCount,
|
||
metadataEnrichment:
|
||
isEnrichingSelectedMetadata || isEnrichingMetadata ? 'running' : 'idle'
|
||
},
|
||
rules: {
|
||
hardRestrictions: `Unplayed: ${includeUnplayedGames ? 'included' : 'excluded'} · Played: ${
|
||
includePlayedGames ? 'included' : 'excluded'
|
||
}`,
|
||
review:
|
||
reviewPreferences.find((preference) => preference.value === preferredReviewLevel)?.label ??
|
||
'Any review signal',
|
||
playMode:
|
||
playModePreferences.find((preference) => preference.value === preferredPlayMode)?.label ??
|
||
'Any mode',
|
||
genre: preferredGenre || 'Any genre',
|
||
playtime:
|
||
playtimePreferences.find((preference) => preference.value === preferredPlaytime)?.label ??
|
||
'Any playtime'
|
||
},
|
||
games: debugGames.slice(0, 100)
|
||
};
|
||
}
|
||
|
||
function publishDebugSnapshot() {
|
||
if (typeof window === 'undefined') {
|
||
return;
|
||
}
|
||
|
||
window.localStorage.setItem(
|
||
'steam-backlog-helper-debug',
|
||
JSON.stringify(createDebugSnapshot())
|
||
);
|
||
}
|
||
|
||
function loadDebugSnapshot() {
|
||
const rawSnapshot = window.localStorage.getItem('steam-backlog-helper-debug');
|
||
debugSnapshot = rawSnapshot ? JSON.parse(rawSnapshot) : null;
|
||
}
|
||
</script>
|
||
|
||
<svelte:head>
|
||
<title>Steam Backlog Helper</title>
|
||
</svelte:head>
|
||
|
||
{#if isDebugWindow}
|
||
<main class="debug-app" aria-labelledby="debug-title">
|
||
<header class="debug-window-header">
|
||
<div>
|
||
<p class="eyebrow">Debug mode</p>
|
||
<h1 id="debug-title">Recommendation Diagnostics</h1>
|
||
</div>
|
||
<p>Last update: {debugSnapshot?.generatedAt ?? 'waiting for main window'}</p>
|
||
</header>
|
||
|
||
<div class="debug-content">
|
||
{#if !debugSnapshot}
|
||
<article class="empty-state">
|
||
<h2>No debug data yet</h2>
|
||
<p>Open the main window, import a library, and spin a recommendation.</p>
|
||
</article>
|
||
{:else}
|
||
<section class="debug-summary" aria-label="Debug summary">
|
||
<article>
|
||
<span>Imported</span>
|
||
<strong>{debugSnapshot.summary.imported}</strong>
|
||
</article>
|
||
<article>
|
||
<span>Eligible</span>
|
||
<strong>{debugSnapshot.summary.eligible}</strong>
|
||
</article>
|
||
<article>
|
||
<span>Restricted</span>
|
||
<strong>{debugSnapshot.summary.restricted}</strong>
|
||
</article>
|
||
<article>
|
||
<span>Spins</span>
|
||
<strong>{debugSnapshot.summary.spinCount}</strong>
|
||
</article>
|
||
<article>
|
||
<span>Metadata</span>
|
||
<strong>{debugSnapshot.summary.metadataEnrichment}</strong>
|
||
</article>
|
||
</section>
|
||
|
||
<section class="debug-panel selected-debug" aria-labelledby="debug-selected-title">
|
||
<h2 id="debug-selected-title">Current Pick</h2>
|
||
{#if debugSnapshot.selected}
|
||
<article>
|
||
<img
|
||
src={debugSnapshot.selected.game.imageUrl}
|
||
alt={debugSnapshot.selected.game.title}
|
||
/>
|
||
<div>
|
||
<span>App {debugSnapshot.selected.game.appId}</span>
|
||
<h3>{debugSnapshot.selected.game.title}</h3>
|
||
<p>{debugSnapshot.selected.game.fit}</p>
|
||
<strong>Score {debugSnapshot.selected.debug.total}</strong>
|
||
</div>
|
||
</article>
|
||
<dl class="debug-metadata">
|
||
<div>
|
||
<dt>Reviews</dt>
|
||
<dd>{debugSnapshot.selected.debug.metadata.reviews}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Genres</dt>
|
||
<dd>{debugSnapshot.selected.debug.metadata.genres}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Steam tags</dt>
|
||
<dd>{debugSnapshot.selected.debug.metadata.steamTags}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Categories</dt>
|
||
<dd>{debugSnapshot.selected.debug.metadata.categories}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Mode</dt>
|
||
<dd>{debugSnapshot.selected.debug.metadata.playMode}</dd>
|
||
</div>
|
||
</dl>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Weight</th>
|
||
<th>Value</th>
|
||
<th>Reason</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{#each debugSnapshot.selected.debug.contributions as contribution}
|
||
<tr>
|
||
<td>{contribution.label}</td>
|
||
<td>{contribution.value > 0 ? `+${contribution.value}` : contribution.value}</td>
|
||
<td>{contribution.detail}</td>
|
||
</tr>
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
{:else}
|
||
<p class="empty-debug">No recommendation has been spun yet.</p>
|
||
{/if}
|
||
</section>
|
||
|
||
<section class="debug-panel" aria-labelledby="debug-rules-title">
|
||
<h2 id="debug-rules-title">Active Rules</h2>
|
||
<dl class="debug-rules">
|
||
<div>
|
||
<dt>Hard restrictions</dt>
|
||
<dd>{debugSnapshot.rules.hardRestrictions}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Review preference</dt>
|
||
<dd>{debugSnapshot.rules.review}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Play mode preference</dt>
|
||
<dd>{debugSnapshot.rules.playMode}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Genre preference</dt>
|
||
<dd>{debugSnapshot.rules.genre}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Playtime preference</dt>
|
||
<dd>{debugSnapshot.rules.playtime}</dd>
|
||
</div>
|
||
</dl>
|
||
</section>
|
||
|
||
<section class="debug-panel" aria-labelledby="debug-games-title">
|
||
<h2 id="debug-games-title">Game Weighting</h2>
|
||
<div class="debug-table">
|
||
{#each debugSnapshot.games as entry}
|
||
<article class:restricted={!entry.debug.eligible}>
|
||
<header>
|
||
<div>
|
||
<span>App {entry.game.appId}</span>
|
||
<h3>{entry.game.title}</h3>
|
||
</div>
|
||
<strong>{entry.debug.eligible ? entry.debug.total : 'Excluded'}</strong>
|
||
</header>
|
||
|
||
{#if entry.debug.restrictions.length > 0}
|
||
<ul class="debug-restrictions">
|
||
{#each entry.debug.restrictions as restriction}
|
||
<li>{restriction}</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
|
||
<dl class="debug-metadata">
|
||
<div>
|
||
<dt>Playtime</dt>
|
||
<dd>{entry.debug.metadata.playtime}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Reviews</dt>
|
||
<dd>{entry.debug.metadata.reviews}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Genres</dt>
|
||
<dd>{entry.debug.metadata.genres}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Steam tags</dt>
|
||
<dd>{entry.debug.metadata.steamTags}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Categories</dt>
|
||
<dd>{entry.debug.metadata.categories}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Mode</dt>
|
||
<dd>{entry.debug.metadata.playMode}</dd>
|
||
</div>
|
||
</dl>
|
||
</article>
|
||
{/each}
|
||
</div>
|
||
</section>
|
||
{/if}
|
||
</div>
|
||
</main>
|
||
{:else}
|
||
<main class="app-shell" aria-labelledby="page-title">
|
||
<header class="topbar">
|
||
<button type="button" class="brand-button" on:click={() => openModal('about')}>
|
||
<span>Steam Backlog Helper</span>
|
||
<small>Local picker</small>
|
||
</button>
|
||
|
||
<nav class="view-tabs" aria-label="Primary views">
|
||
<button
|
||
type="button"
|
||
class:active={activeView === 'recommend'}
|
||
on:click={() => (activeView = 'recommend')}
|
||
>
|
||
Recommend
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class:active={activeView === 'shortlist'}
|
||
on:click={() => (activeView = 'shortlist')}
|
||
>
|
||
Shortlist
|
||
</button>
|
||
</nav>
|
||
|
||
<button
|
||
type="button"
|
||
class="icon-button"
|
||
aria-label="Open settings"
|
||
title="Settings"
|
||
on:click={() => openModal('settings')}
|
||
>
|
||
⚙
|
||
</button>
|
||
</header>
|
||
|
||
{#if activeView === 'recommend'}
|
||
<section class="recommend-view" aria-label="Recommendation spinner">
|
||
<div class="session-heading">
|
||
<p class="eyebrow">Recommendation</p>
|
||
<h1 id="page-title">Session Picker</h1>
|
||
</div>
|
||
|
||
<div class="spinner-stage">
|
||
<div class="wheel-stage" aria-label="Recommendation wheel">
|
||
<div class="wheel-pointer" aria-hidden="true"></div>
|
||
<div
|
||
class:ready={setupComplete}
|
||
class="game-wheel"
|
||
style={`--wheel-rotation: ${wheelRotation}deg`}
|
||
aria-hidden="true"
|
||
>
|
||
{#each wheelGames as game, index}
|
||
{@const angle = index * (360 / wheelGames.length)}
|
||
{@const segmentSize = 360 / wheelGames.length}
|
||
<div
|
||
class="wheel-segment"
|
||
class:selected={selectedRecommendation?.appId === game.appId}
|
||
style={`--segment-angle: ${angle}deg; --segment-size: ${segmentSize}deg; --segment-image: url('${game.imageUrl}')`}
|
||
>
|
||
<span>{index + 1}</span>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
|
||
<article class="wheel-result">
|
||
{#if selectedRecommendation}
|
||
<img src={selectedRecommendation.imageUrl} alt={selectedRecommendation.title} />
|
||
<div>
|
||
<span>Selected</span>
|
||
<strong>{selectedRecommendation.title}</strong>
|
||
<p>{selectedRecommendation.fit}</p>
|
||
</div>
|
||
{:else}
|
||
<div class="empty-cover" aria-hidden="true">?</div>
|
||
<div>
|
||
<span>
|
||
{isLoadingSettings
|
||
? 'Loading'
|
||
: isImportingLibrary
|
||
? 'Importing'
|
||
: setupComplete
|
||
? 'Ready'
|
||
: 'Setup required'}
|
||
</span>
|
||
<strong>
|
||
{isLoadingSettings
|
||
? 'Checking saved setup'
|
||
: isImportingLibrary
|
||
? 'Fetching library'
|
||
: setupComplete
|
||
? 'Spin the wheel'
|
||
: 'Complete setup'}
|
||
</strong>
|
||
<p>
|
||
{isLoadingSettings
|
||
? 'Reading local SQLite settings'
|
||
: isImportingLibrary
|
||
? 'Steam Web API import in progress'
|
||
: setupComplete
|
||
? `${candidateGames.length} eligible games loaded`
|
||
: 'Profile and API key required'}
|
||
</p>
|
||
</div>
|
||
{/if}
|
||
</article>
|
||
</div>
|
||
|
||
<div class="spin-copy">
|
||
<p class="eyebrow">
|
||
{isLoadingSettings
|
||
? 'Loading settings'
|
||
: isImportingLibrary
|
||
? 'Importing library'
|
||
: setupComplete
|
||
? 'Ready'
|
||
: 'Setup required'}
|
||
</p>
|
||
<h2>
|
||
{selectedRecommendation
|
||
? selectedRecommendation.title
|
||
: isImportingLibrary
|
||
? 'Fetching your Steam library.'
|
||
: setupComplete
|
||
? 'Spin for a recommendation.'
|
||
: 'Import your library to spin a recommendation.'}
|
||
</h2>
|
||
<p>
|
||
{selectedRecommendation
|
||
? selectedRecommendation.reason
|
||
: isImportingLibrary
|
||
? 'Importing owned games. Metadata enrichment will run as a separate cached step.'
|
||
: setupComplete
|
||
? `Configured for ${defaultSessionLength}. The wheel contains ${candidateGames.length} eligible games from ${importedLibrary.length} imported Steam games.`
|
||
: 'The first run setup enables Steam import, metadata enrichment, review signals, and time-to-beat matching.'}
|
||
</p>
|
||
{#if libraryImportError}
|
||
<p class="error-message">{libraryImportError}</p>
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="spin-actions">
|
||
<button
|
||
type="button"
|
||
class="primary-action"
|
||
disabled={isImportingLibrary}
|
||
on:click={spinRecommendation}
|
||
>
|
||
{isLoadingSettings
|
||
? 'Loading Settings'
|
||
: isImportingLibrary
|
||
? 'Importing Library'
|
||
: setupComplete
|
||
? 'Spin Recommendation'
|
||
: 'Run Setup'}
|
||
</button>
|
||
<button type="button" class="secondary-action" on:click={() => openModal('setup')}>
|
||
{setupComplete ? 'Edit Setup' : 'Open Setup'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
{:else}
|
||
<section class="shortlist-view" aria-labelledby="shortlist-title">
|
||
<div class="section-heading">
|
||
<div>
|
||
<p class="eyebrow">Shortlist</p>
|
||
<h1 id="shortlist-title">Top candidate games</h1>
|
||
</div>
|
||
<button type="button" class="secondary-action">Refresh Shortlist</button>
|
||
</div>
|
||
|
||
<div class="shortlist">
|
||
{#if shortlistGames.length === 0}
|
||
<article class="empty-state">
|
||
<h2>No imported library yet</h2>
|
||
<p>Complete setup to import your Steam games and build the shortlist.</p>
|
||
{#if libraryImportError}
|
||
<p class="error-message">{libraryImportError}</p>
|
||
{/if}
|
||
</article>
|
||
{/if}
|
||
{#each shortlistGames as game, index}
|
||
<article class="shortlist-card">
|
||
<b>{index + 1}</b>
|
||
<img src={game.imageUrl} alt={game.title} />
|
||
<div>
|
||
<span>{game.fit} · {game.reviewSummary}</span>
|
||
<h2>{game.title}</h2>
|
||
<p>
|
||
{game.reason}
|
||
{#if game.score}
|
||
Weighted score: {game.score}.
|
||
{/if}
|
||
</p>
|
||
</div>
|
||
</article>
|
||
{/each}
|
||
</div>
|
||
</section>
|
||
{/if}
|
||
|
||
{#if modalView}
|
||
<div class="modal-layer">
|
||
<button
|
||
type="button"
|
||
class="modal-backdrop"
|
||
aria-label="Close modal"
|
||
on:click={closeModal}
|
||
></button>
|
||
<div
|
||
class="modal-panel"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="modal-title"
|
||
>
|
||
<header class="modal-header">
|
||
<div>
|
||
<p class="eyebrow">
|
||
{modalView === 'about'
|
||
? 'About'
|
||
: modalView === 'setup'
|
||
? `First Run · Step ${Math.min(setupStep + 1, setupSteps.length)} of ${setupSteps.length}`
|
||
: 'Settings'}
|
||
</p>
|
||
<h2 id="modal-title">
|
||
{modalView === 'about'
|
||
? 'Steam Backlog Helper'
|
||
: modalView === 'setup'
|
||
? 'Setup Wizard'
|
||
: 'Settings'}
|
||
</h2>
|
||
</div>
|
||
<button type="button" class="close-button" aria-label="Close" on:click={closeModal}>
|
||
×
|
||
</button>
|
||
</header>
|
||
|
||
{#if modalView === 'about'}
|
||
<div class="modal-content">
|
||
<p>
|
||
Steam Backlog Helper is a local-first desktop app for turning a large Steam library
|
||
into a focused recommendation for what to play next.
|
||
</p>
|
||
<dl class="about-list">
|
||
<div>
|
||
<dt>Display name</dt>
|
||
<dd>Steam Backlog Helper</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Package ID</dt>
|
||
<dd>com.boris.backloghelper</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Stack</dt>
|
||
<dd>Tauri, SvelteKit, TypeScript, Rust, SQLite</dd>
|
||
</div>
|
||
</dl>
|
||
</div>
|
||
{:else if modalView === 'setup'}
|
||
<div class="wizard">
|
||
<ol class="wizard-steps" aria-label="Setup progress">
|
||
{#each setupSteps as step, index}
|
||
<li class:active={index === setupStep} class:complete={index < setupStep}>
|
||
<span>{index + 1}</span>
|
||
<p>{step.label}</p>
|
||
</li>
|
||
{/each}
|
||
</ol>
|
||
|
||
{#if setupStep < setupSteps.length}
|
||
{@const step = setupSteps[setupStep]}
|
||
<section class="wizard-step" aria-labelledby="wizard-step-title">
|
||
<div>
|
||
<p class="eyebrow">Configure</p>
|
||
<h3 id="wizard-step-title">{step.title}</h3>
|
||
<p>{step.instruction}</p>
|
||
</div>
|
||
|
||
{#if step.type === 'select'}
|
||
<label>
|
||
<span>{step.label}</span>
|
||
<select bind:value={defaultSessionLength}>
|
||
<option>90 minutes</option>
|
||
<option>30 minutes</option>
|
||
<option>2 hours</option>
|
||
<option>No limit</option>
|
||
</select>
|
||
</label>
|
||
{:else if step.type === 'toggle'}
|
||
<label class="toggle-control">
|
||
<input type="checkbox" bind:checked={trueRandomEnabled} />
|
||
<span>{step.placeholder}</span>
|
||
</label>
|
||
{:else if setupStep === 0}
|
||
<label>
|
||
<span>{step.label}</span>
|
||
<input type="text" placeholder={step.placeholder} bind:value={steamProfile} />
|
||
</label>
|
||
{:else if setupStep === 1}
|
||
<label>
|
||
<span>{step.label}</span>
|
||
<input type="password" placeholder={step.placeholder} bind:value={steamApiKey} />
|
||
</label>
|
||
{:else}
|
||
<label>
|
||
<span>{step.label}</span>
|
||
<input
|
||
type="text"
|
||
placeholder={step.placeholder}
|
||
bind:value={timeToBeatProvider}
|
||
/>
|
||
</label>
|
||
{/if}
|
||
|
||
<p class="field-help">{step.helper}</p>
|
||
</section>
|
||
{:else}
|
||
<section class="wizard-step review-step" aria-labelledby="wizard-step-title">
|
||
<div>
|
||
<p class="eyebrow">Review</p>
|
||
<h3 id="wizard-step-title">Ready to import</h3>
|
||
<p>
|
||
Setup will save these local settings, then the app can import your Steam
|
||
library and enrich it for recommendations.
|
||
</p>
|
||
</div>
|
||
|
||
<ul>
|
||
<li>Steam profile can resolve the target library.</li>
|
||
<li>Steam Web API key is available for owned-games import.</li>
|
||
<li>Time-to-beat metadata can improve session matching.</li>
|
||
<li>Default session length: {defaultSessionLength}.</li>
|
||
<li>True random mode: {trueRandomEnabled ? 'enabled' : 'disabled'}.</li>
|
||
<li>Defaults can be changed later from Settings.</li>
|
||
</ul>
|
||
</section>
|
||
{/if}
|
||
|
||
<footer class="modal-actions">
|
||
<button
|
||
type="button"
|
||
class="secondary-action"
|
||
disabled={setupStep === 0}
|
||
on:click={previousSetupStep}
|
||
>
|
||
Back
|
||
</button>
|
||
{#if setupStep < setupSteps.length}
|
||
<button
|
||
type="button"
|
||
class="primary-action"
|
||
disabled={setupStep === 0 && steamProfile.trim().length === 0}
|
||
on:click={nextSetupStep}
|
||
>
|
||
{setupStep === setupSteps.length - 1 ? 'Review' : 'Next'}
|
||
</button>
|
||
{:else}
|
||
<button
|
||
type="button"
|
||
class="primary-action"
|
||
disabled={!hasRequiredSetup()}
|
||
on:click={saveSetup}
|
||
>
|
||
{isImportingLibrary ? 'Importing' : 'Save And Import'}
|
||
</button>
|
||
{/if}
|
||
</footer>
|
||
</div>
|
||
{:else}
|
||
<form class="settings-form">
|
||
{#each setupOptions as option}
|
||
{#if option.label === 'Steam profile'}
|
||
<label>
|
||
<span>{option.label}</span>
|
||
<input type="text" placeholder={option.placeholder} bind:value={steamProfile} />
|
||
</label>
|
||
{:else if option.label === 'Steam Web API key'}
|
||
<label>
|
||
<span>{option.label}</span>
|
||
<input type="password" placeholder={option.placeholder} bind:value={steamApiKey} />
|
||
</label>
|
||
{:else}
|
||
<label>
|
||
<span>{option.label}</span>
|
||
<input
|
||
type="text"
|
||
placeholder={option.placeholder}
|
||
bind:value={timeToBeatProvider}
|
||
/>
|
||
</label>
|
||
{/if}
|
||
{/each}
|
||
|
||
<label>
|
||
<span>Default session length</span>
|
||
<select bind:value={defaultSessionLength}>
|
||
<option>90 minutes</option>
|
||
<option>30 minutes</option>
|
||
<option>2 hours</option>
|
||
<option>No limit</option>
|
||
</select>
|
||
</label>
|
||
|
||
<label class="toggle-control">
|
||
<input type="checkbox" bind:checked={trueRandomEnabled} />
|
||
<span>Enable true random mode</span>
|
||
</label>
|
||
|
||
<div class="settings-group">
|
||
<h3>Eligibility filters</h3>
|
||
<p>These remove games from the wheel entirely.</p>
|
||
<label class="toggle-control">
|
||
<input type="checkbox" bind:checked={includeUnplayedGames} />
|
||
<span>Include unplayed games</span>
|
||
</label>
|
||
<label class="toggle-control">
|
||
<input type="checkbox" bind:checked={includePlayedGames} />
|
||
<span>Include games with playtime</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="settings-group">
|
||
<h3>Preferred filters</h3>
|
||
<p>These change recommendation weighting without excluding games.</p>
|
||
<label>
|
||
<span>Review preference</span>
|
||
<select bind:value={preferredReviewLevel}>
|
||
{#each reviewPreferences as preference}
|
||
<option value={preference.value}>{preference.label}</option>
|
||
{/each}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>Player mode</span>
|
||
<select bind:value={preferredPlayMode}>
|
||
{#each playModePreferences as preference}
|
||
<option value={preference.value}>{preference.label}</option>
|
||
{/each}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>Genre</span>
|
||
<select bind:value={preferredGenre}>
|
||
<option value="">Any genre</option>
|
||
{#each availableGenres as genre}
|
||
<option value={genre}>{genre}</option>
|
||
{/each}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>Playtime</span>
|
||
<select bind:value={preferredPlaytime}>
|
||
{#each playtimePreferences as preference}
|
||
<option value={preference.value}>{preference.label}</option>
|
||
{/each}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="settings-group">
|
||
<h3>Debug mode</h3>
|
||
<p>Inspect active restrictions, weighting rules, review data, and per-game scores.</p>
|
||
<button
|
||
type="button"
|
||
class="secondary-action"
|
||
on:click={openDebugWindow}
|
||
>
|
||
Open Debug Window
|
||
</button>
|
||
</div>
|
||
|
||
<footer class="modal-actions">
|
||
<button type="button" class="secondary-action" on:click={closeModal}>Cancel</button>
|
||
<button type="button" class="primary-action" disabled={isImportingLibrary} on:click={saveSettings}>
|
||
{isImportingLibrary ? 'Importing' : 'Save Settings'}
|
||
</button>
|
||
</footer>
|
||
</form>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</main>
|
||
{/if}
|