diff --git a/electron/trpc/data/games.ts b/electron/trpc/data/games.ts index b35df51..235c3c5 100644 --- a/electron/trpc/data/games.ts +++ b/electron/trpc/data/games.ts @@ -74,10 +74,10 @@ export const dataGamesRouter = t.router({ .input(z.object({ gameId: z.string().min(1).nullable() })) .mutation(async ({ input }) => { const db = getDb() - await db.transaction(async (tx) => { - await tx.update(game).set({ isDefault: false }).where(eq(game.isDefault, true)) + db.transaction((tx) => { + tx.update(game).set({ isDefault: false }).where(eq(game.isDefault, true)).run() if (input.gameId != null) { - await tx.update(game).set({ isDefault: true }).where(eq(game.id, input.gameId)) + tx.update(game).set({ isDefault: true }).where(eq(game.id, input.gameId)).run() } }) }), diff --git a/electron/trpc/data/profiles.ts b/electron/trpc/data/profiles.ts index 583e101..992143b 100644 --- a/electron/trpc/data/profiles.ts +++ b/electron/trpc/data/profiles.ts @@ -55,33 +55,35 @@ export const dataProfilesRouter = t.router({ const db = getDb() const defaultId = `${input.gameId}-default` - return await db.transaction(async (tx) => { + return db.transaction((tx) => { // Check if default profile exists - const existing = await tx + const existing = tx .select() .from(profile) .where(eq(profile.id, defaultId)) .limit(1) + .all() if (!existing[0]) { - await tx.insert(profile).values({ + tx.insert(profile).values({ id: defaultId, gameId: input.gameId, name: "Default", isDefault: true, isActive: false, - }) + }).run() } // If no active profile for this game, set default as active - const active = await tx + const active = tx .select() .from(profile) .where(and(eq(profile.gameId, input.gameId), eq(profile.isActive, true))) .limit(1) + .all() if (!active[0]) { - await tx.update(profile).set({ isActive: true }).where(eq(profile.id, defaultId)) + tx.update(profile).set({ isActive: true }).where(eq(profile.id, defaultId)).run() } return defaultId @@ -98,23 +100,24 @@ export const dataProfilesRouter = t.router({ const db = getDb() const newId = `${input.gameId}-${randomUUID()}` - return await db.transaction(async (tx) => { + return db.transaction((tx) => { // Deactivate current active profile - await tx + tx .update(profile) .set({ isActive: false }) .where(and(eq(profile.gameId, input.gameId), eq(profile.isActive, true))) + .run() // Insert new profile as active const now = new Date().toISOString() - await tx.insert(profile).values({ + tx.insert(profile).values({ id: newId, gameId: input.gameId, name: input.name, isDefault: false, isActive: true, createdAt: now, - }) + }).run() return { id: newId, @@ -148,13 +151,14 @@ export const dataProfilesRouter = t.router({ .mutation(async ({ input }) => { const db = getDb() - return await db.transaction(async (tx) => { + return db.transaction((tx) => { // Check if it's the default profile - const target = await tx + const target = tx .select() .from(profile) .where(and(eq(profile.id, input.profileId), eq(profile.gameId, input.gameId))) .limit(1) + .all() if (!target[0]) { return { deleted: false as boolean, reason: "Profile not found" as string | undefined } @@ -168,17 +172,18 @@ export const dataProfilesRouter = t.router({ if (target[0].isActive) { const defaultId = `${input.gameId}-default` // Try default profile first - const defaultProfile = await tx + const defaultProfile = tx .select() .from(profile) .where(eq(profile.id, defaultId)) .limit(1) + .all() if (defaultProfile[0]) { - await tx.update(profile).set({ isActive: true }).where(eq(profile.id, defaultId)) + tx.update(profile).set({ isActive: true }).where(eq(profile.id, defaultId)).run() } else { // Fall back to any other profile - const other = await tx + const other = tx .select() .from(profile) .where(and( @@ -186,14 +191,15 @@ export const dataProfilesRouter = t.router({ eq(profile.isActive, false), )) .limit(1) + .all() if (other[0]) { - await tx.update(profile).set({ isActive: true }).where(eq(profile.id, other[0].id)) + tx.update(profile).set({ isActive: true }).where(eq(profile.id, other[0].id)).run() } } } // Delete the profile (cascade deletes profileMod rows) - await tx.delete(profile).where(eq(profile.id, input.profileId)) + tx.delete(profile).where(eq(profile.id, input.profileId)).run() return { deleted: true as boolean, reason: undefined as string | undefined } }) }), @@ -206,17 +212,19 @@ export const dataProfilesRouter = t.router({ })) .mutation(async ({ input }) => { const db = getDb() - await db.transaction(async (tx) => { + db.transaction((tx) => { // Deactivate all profiles for this game - await tx + tx .update(profile) .set({ isActive: false }) .where(and(eq(profile.gameId, input.gameId), eq(profile.isActive, true))) + .run() // Activate target - await tx + tx .update(profile) .set({ isActive: true }) .where(eq(profile.id, input.profileId)) + .run() }) }), @@ -227,29 +235,31 @@ export const dataProfilesRouter = t.router({ const db = getDb() const defaultId = `${input.gameId}-default` - return await db.transaction(async (tx) => { + return db.transaction((tx) => { // Delete all non-default profiles (cascade cleans profileMod) - await tx + tx .delete(profile) .where(and(eq(profile.gameId, input.gameId), eq(profile.isDefault, false))) + .run() // Ensure default profile exists - const existing = await tx + const existing = tx .select() .from(profile) .where(eq(profile.id, defaultId)) .limit(1) + .all() if (!existing[0]) { - await tx.insert(profile).values({ + tx.insert(profile).values({ id: defaultId, gameId: input.gameId, name: "Default", isDefault: true, isActive: true, - }) + }).run() } else { - await tx.update(profile).set({ isActive: true }).where(eq(profile.id, defaultId)) + tx.update(profile).set({ isActive: true }).where(eq(profile.id, defaultId)).run() } return defaultId diff --git a/src/components/app-bootstrap.tsx b/src/components/app-bootstrap.tsx index 75086b0..1249878 100644 --- a/src/components/app-bootstrap.tsx +++ b/src/components/app-bootstrap.tsx @@ -1,11 +1,11 @@ import { useEffect, useRef } from "react" import { useAppStore } from "@/store/app-store" import { - useGameManagementData, - useGameManagementActions, - useProfileActions, - useSettingsData, - useSettingsActions, + useGames, + useAddGame, + useEnsureDefaultProfile, + useAllSettings, + useUpdateGlobalSettings, } from "@/data" import { DownloadBridge } from "@/components/download-bridge" import { trpc, hasElectronTRPC } from "@/lib/trpc" @@ -13,12 +13,12 @@ import { i18n } from "@/lib/i18n" export function AppBootstrap() { const hasInitialized = useRef(false) - const { defaultGameId } = useGameManagementData() - const gameMut = useGameManagementActions() - const profileMut = useProfileActions() + const { defaultGameId } = useGames() + const addGame = useAddGame() + const ensureDefaultProfile = useEnsureDefaultProfile() const selectGame = useAppStore((s) => s.selectGame) - const { global: globalSettings, getPerGame } = useSettingsData() - const { updateGlobal } = useSettingsActions() + const { global: globalSettings, getPerGame } = useAllSettings() + const updateGlobal = useUpdateGlobalSettings() useEffect(() => { void i18n.changeLanguage(globalSettings.language) @@ -49,21 +49,21 @@ export function AppBootstrap() { // Apply updates if any if (Object.keys(updates).length > 0) { - updateGlobal(updates) + updateGlobal.mutate(updates) } - }, [defaultPathsQuery.data, globalSettings.dataFolder, globalSettings.steamFolder, updateGlobal]) + }, [defaultPathsQuery.data, globalSettings.dataFolder, globalSettings.steamFolder, updateGlobal.mutate]) useEffect(() => { if (hasInitialized.current || !defaultGameId) return hasInitialized.current = true // Ensure game is managed - gameMut.addManagedGame(defaultGameId) + addGame.mutate(defaultGameId) // Only ensure profile if game has install folder set const installFolder = getPerGame(defaultGameId).gameInstallFolder if (installFolder?.trim()) { - profileMut.ensureDefaultProfile(defaultGameId) + ensureDefaultProfile.mutate(defaultGameId) } // Select the game diff --git a/src/components/download-bridge.tsx b/src/components/download-bridge.tsx index fc13642..39c2fec 100644 --- a/src/components/download-bridge.tsx +++ b/src/components/download-bridge.tsx @@ -9,12 +9,9 @@ */ import { useEffect, useRef } from "react" import { toast } from "sonner" +import { useQueryClient } from "@tanstack/react-query" import { useDownloadStore } from "@/store/download-store" -// Keep store imports for imperative getState() inside IPC callbacks -import { useSettingsStore } from "@/store/settings-store" -import { useProfileStore } from "@/store/profile-store" -import { useModManagementStore } from "@/store/mod-management-store" -import { useSettingsData } from "@/data" +import { useAllSettings, getClient, queryKeys } from "@/data" import { trpc } from "@/lib/trpc" type DownloadUpdateEvent = { @@ -35,9 +32,10 @@ type DownloadProgressEvent = { export function DownloadBridge() { const updateTask = useDownloadStore((s) => s._updateTask) + const qc = useQueryClient() // Reactive settings via data hooks (for syncing to main process) - const { global: globalSettings, perGame } = useSettingsData() + const { global: globalSettings, perGame } = useAllSettings() const { maxConcurrentDownloads: maxConcurrent, speedLimitEnabled, speedLimitBps, dataFolder, modDownloadFolder, cacheFolder } = globalSettings const updateSettingsMutation = trpc.downloads.updateSettings.useMutation() @@ -132,57 +130,71 @@ export function DownloadBridge() { const task = useDownloadStore.getState().getTask(event.downloadId) if (!task) return - // Check if auto-install is enabled (read from store directly to get latest value) - const autoInstallEnabled = useSettingsStore.getState().global.autoInstallMods - if (autoInstallEnabled && event.result?.extractedPath) { - // Get active profile for this game - const activeProfileId = useProfileStore.getState().activeProfileIdByGame[task.gameId] - - if (activeProfileId) { - // Check if mod is already installed - const isAlreadyInstalled = useModManagementStore.getState().isModInstalled(activeProfileId, task.modId) - - if (!isAlreadyInstalled) { - try { - // Auto-install the mod - const result = await installModMutation.mutateAsync({ - gameId: task.gameId, - profileId: activeProfileId, - modId: task.modId, - author: task.modAuthor, - name: task.modName, - version: task.modVersion, - extractedPath: event.result.extractedPath, - }) - - // Mark as installed in state - useModManagementStore.getState().installMod(activeProfileId, task.modId, task.modVersion) - - // Show success toast - toast.success(`${task.modName} installed`, { - description: `v${task.modVersion} - ${result.filesCopied} files copied to profile`, - }) - } catch (error) { - // Show error toast if auto-install fails - const message = error instanceof Error ? error.message : "Unknown error" - toast.error(`Auto-install failed: ${task.modName}`, { - description: message, + const client = getClient() + + // Check if auto-install is enabled + try { + const globalSettings = await client.data.settings.getGlobal.query() + const autoInstallEnabled = globalSettings.autoInstallMods + + if (autoInstallEnabled && event.result?.extractedPath) { + // Get active profile for this game + const activeProfile = await client.data.profiles.getActive.query({ gameId: task.gameId }) + const activeProfileId = activeProfile?.id ?? null + + if (activeProfileId) { + // Check if mod is already installed + const installedMods = await client.data.mods.listInstalled.query({ profileId: activeProfileId }) + const isAlreadyInstalled = installedMods.some(m => m.modId === task.modId) + + if (!isAlreadyInstalled) { + try { + // Auto-install the mod + const result = await installModMutation.mutateAsync({ + gameId: task.gameId, + profileId: activeProfileId, + modId: task.modId, + author: task.modAuthor, + name: task.modName, + version: task.modVersion, + extractedPath: event.result.extractedPath, + }) + + // Mark as installed in DB + await client.data.mods.install.mutate({ + profileId: activeProfileId, + modId: task.modId, + version: task.modVersion, + }) + // Invalidate cache + qc.invalidateQueries({ queryKey: queryKeys.mods.root }) + + toast.success(`${task.modName} installed`, { + description: `v${task.modVersion} - ${result.filesCopied} files copied to profile`, + }) + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error" + toast.error(`Auto-install failed: ${task.modName}`, { + description: message, + }) + } + } else { + toast.success(`${task.modName} downloaded`, { + description: `v${task.modVersion} - already installed`, }) } } else { - // Mod already installed, just show download success toast.success(`${task.modName} downloaded`, { - description: `v${task.modVersion} - already installed`, + description: `v${task.modVersion} - no active profile`, }) } } else { - // No active profile, show download success toast.success(`${task.modName} downloaded`, { - description: `v${task.modVersion} - no active profile`, + description: `v${task.modVersion} is ready to install`, }) } - } else { - // Auto-install disabled or no extracted path, show regular download success + } catch { + // If settings fetch fails, just show download success toast.success(`${task.modName} downloaded`, { description: `v${task.modVersion} is ready to install`, }) @@ -221,7 +233,7 @@ export function DownloadBridge() { unsubCompleted() unsubFailed() } - }, [updateTask]) + }, [updateTask, qc]) // This is an invisible bridge component return null diff --git a/src/components/features/add-game-dialog.tsx b/src/components/features/add-game-dialog.tsx index 940b4b1..5a60881 100644 --- a/src/components/features/add-game-dialog.tsx +++ b/src/components/features/add-game-dialog.tsx @@ -1,6 +1,7 @@ import { useState } from "react" import { useTranslation } from "react-i18next" -import { List, ChevronLeft, FolderOpen } from "lucide-react" +import { List, ChevronLeft, FolderOpen, Loader2 } from "lucide-react" +import { useQueryClient } from "@tanstack/react-query" import { AlertDialog, @@ -17,7 +18,16 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import { useAppStore } from "@/store/app-store" -import { useGameManagementActions, useProfileActions, useSettingsActions } from "@/data" +import { + useAddGame, + useTouchGame, + useSetDefaultGame, + useEnsureDefaultProfile, + useCreateProfile, + useSetActiveProfile, + useUpdateGameSettings, + queryKeys, +} from "@/data" import { ECOSYSTEM_GAMES, type EcosystemGame } from "@/lib/ecosystem-games" import { selectFolder } from "@/lib/desktop" import { CreateProfileDialog } from "./create-profile-dialog" @@ -40,16 +50,22 @@ export function AddGameDialog({ open, onOpenChange, forceOpen = false }: AddGame const [profileChoice, setProfileChoice] = useState<"default" | "create" | "import">("default") const [createProfileOpen, setCreateProfileOpen] = useState(false) const [selectedProfileId, setSelectedProfileId] = useState(null) + const [isAddingGame, setIsAddingGame] = useState(false) - const { addManagedGame, appendRecentManagedGame, setDefaultGameId } = useGameManagementActions() - const { ensureDefaultProfile, createProfile, setActiveProfile } = useProfileActions() + const queryClient = useQueryClient() + const addGame = useAddGame() + const touchGame = useTouchGame() + const setDefaultGame = useSetDefaultGame() + const ensureDefaultProfile = useEnsureDefaultProfile() + const createProfile = useCreateProfile() + const setActiveProfile = useSetActiveProfile() + const updateGameSettings = useUpdateGameSettings() const selectGame = useAppStore((s) => s.selectGame) - const { updatePerGame: updatePerGameSettings } = useSettingsActions() const filteredGames = ECOSYSTEM_GAMES.filter((game) => game.name.toLowerCase().includes(query.toLowerCase()) ) - + const isValidPath = installFolder.trim().length > 0 const handleGameClick = (game: EcosystemGame) => { @@ -64,58 +80,76 @@ export function AddGameDialog({ open, onOpenChange, forceOpen = false }: AddGame } } - const handleCreateProfile = (profileName: string) => { + const handleCreateProfile = async (profileName: string) => { if (!pickedGame) return - const profile = createProfile(pickedGame.id, profileName) + const profile = await createProfile.mutateAsync({ gameId: pickedGame.id, name: profileName }) setSelectedProfileId(profile.id) setProfileChoice("create") setCreateProfileOpen(false) } - const handleAddGame = () => { + const handleAddGame = async () => { if (!pickedGame) return - - const isValidPath = installFolder.trim().length > 0 + setIsAddingGame(true) - // Add to managed games - addManagedGame(pickedGame.id) - appendRecentManagedGame(pickedGame.id) + try { + const isValidPath = installFolder.trim().length > 0 - // Set as default game (latest added becomes default) - setDefaultGameId(pickedGame.id) + // Add to managed games + await addGame.mutateAsync(pickedGame.id) + await touchGame.mutateAsync(pickedGame.id) - // Only create profiles if install folder is valid (non-empty) - if (isValidPath) { - if (installFolder.trim()) { - updatePerGameSettings(pickedGame.id, { gameInstallFolder: installFolder }) - } - - // Ensure default profile exists - const defaultProfileId = ensureDefaultProfile(pickedGame.id) - - // Set active profile based on user choice - if (profileChoice === "create" && selectedProfileId) { - setActiveProfile(pickedGame.id, selectedProfileId) - } else if (profileChoice === "import") { - toast.info(t("add_game_profile_import_toast")) - setActiveProfile(pickedGame.id, defaultProfileId) - } else { - setActiveProfile(pickedGame.id, defaultProfileId) + // Set as default game (latest added becomes default) + await setDefaultGame.mutateAsync(pickedGame.id) + + // Only create profiles if install folder is valid (non-empty) + if (isValidPath) { + if (installFolder.trim()) { + await updateGameSettings.mutateAsync({ gameId: pickedGame.id, updates: { gameInstallFolder: installFolder } }) + } + + // Ensure default profile exists + const defaultProfileId = await ensureDefaultProfile.mutateAsync(pickedGame.id) + + // Set active profile based on user choice + if (profileChoice === "create" && selectedProfileId) { + await setActiveProfile.mutateAsync({ gameId: pickedGame.id, profileId: selectedProfileId }) + } else if (profileChoice === "import") { + toast.info(t("add_game_profile_import_toast")) + await setActiveProfile.mutateAsync({ gameId: pickedGame.id, profileId: defaultProfileId }) + } else { + await setActiveProfile.mutateAsync({ gameId: pickedGame.id, profileId: defaultProfileId }) + } } - } - // If !isValidPath: don't call ensureDefaultProfile or setActiveProfile + // If !isValidPath: don't call ensureDefaultProfile or setActiveProfile - // Select the game - selectGame(pickedGame.id) + // Select the game + selectGame(pickedGame.id) - // Close dialog and reset state - onOpenChange(false) - setStep("select") - setPickedGame(null) - setInstallFolder("") - setQuery("") - setProfileChoice("default") - setSelectedProfileId(null) + // Wait for cache to update before closing dialog + // This prevents the auto-open logic in global-rail from triggering + await queryClient.refetchQueries({ + queryKey: queryKeys.games.root, + type: 'active' + }) + + // Close dialog and reset state + onOpenChange(false) + setStep("select") + setPickedGame(null) + setInstallFolder("") + setQuery("") + setProfileChoice("default") + setSelectedProfileId(null) + } catch (error) { + // Show error toast and keep dialog open + toast.error(t("add_game_error_title") || "Failed to add game", { + description: error instanceof Error ? error.message : "Unknown error" + }) + return + } finally { + setIsAddingGame(false) + } } const handleBack = () => { @@ -364,7 +398,8 @@ export function AddGameDialog({ open, onOpenChange, forceOpen = false }: AddGame - diff --git a/src/components/features/config-editor/config-editor-center.tsx b/src/components/features/config-editor/config-editor-center.tsx index 9eabff3..5e9f7a0 100644 --- a/src/components/features/config-editor/config-editor-center.tsx +++ b/src/components/features/config-editor/config-editor-center.tsx @@ -12,7 +12,7 @@ import { parseBepInExConfig, parseIniConfig, updateConfigValue, updateIniValue, import { logger } from "@/lib/logger" import { trpc } from "@/lib/trpc" import { useAppStore } from "@/store/app-store" -import { useProfileData } from "@/data" +import { useActiveProfileId } from "@/data" import { toast } from "sonner" // Lazy load Monaco editor @@ -33,8 +33,7 @@ type FileFormat = "cfg" | "yaml" | "yml" | "json" | "ini" | "txt" export function ConfigEditorCenter() { const { t } = useTranslation() const selectedGameId = useAppStore((s) => s.selectedGameId) - const { activeProfileIdByGame } = useProfileData() - const activeProfileId = selectedGameId ? activeProfileIdByGame[selectedGameId] ?? null : null + const activeProfileId = useActiveProfileId(selectedGameId) const [selectedRelativePath, setSelectedRelativePath] = useState(null) const [mode, setMode] = useState<"gui" | "raw">("gui") diff --git a/src/components/features/delete-profile-dialog.tsx b/src/components/features/delete-profile-dialog.tsx index 98f4623..089f0e2 100644 --- a/src/components/features/delete-profile-dialog.tsx +++ b/src/components/features/delete-profile-dialog.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next" +import { Loader2 } from "lucide-react" import { AlertDialog, AlertDialogContent, @@ -17,6 +18,7 @@ type DeleteProfileDialogProps = { onConfirm: () => void disabled?: boolean disabledReason?: string + loading?: boolean } export function DeleteProfileDialog({ @@ -26,10 +28,11 @@ export function DeleteProfileDialog({ onConfirm, disabled = false, disabledReason, + loading = false, }: DeleteProfileDialogProps) { const { t } = useTranslation() return ( - + {t("dialog_delete_profile_title")} @@ -45,12 +48,13 @@ export function DeleteProfileDialog({ )} - {t("common_cancel")} + {t("common_cancel")} + {loading && } Delete Profile diff --git a/src/components/features/dependencies/dependency-download-dialog.tsx b/src/components/features/dependencies/dependency-download-dialog.tsx index a7e4844..2006df9 100644 --- a/src/components/features/dependencies/dependency-download-dialog.tsx +++ b/src/components/features/dependencies/dependency-download-dialog.tsx @@ -16,7 +16,7 @@ import { Badge } from "@/components/ui/badge" import { Separator } from "@/components/ui/separator" import { Skeleton } from "@/components/ui/skeleton" import { analyzeModDependencies, type DependencyStatus } from "@/lib/dependency-utils" -import { useModManagementData, useModManagementActions, useProfileData, useSettingsData } from "@/data" +import { useActiveProfileId, useInstalledMods, useSetDependencyWarnings, useGlobalSettings } from "@/data" import { useDownloadActions } from "@/hooks/use-download-actions" import { useOnlineDependenciesRecursive } from "@/lib/queries/useOnlineMods" import { MODS } from "@/mocks/mods" @@ -100,11 +100,10 @@ function getStatusVariant(status: DependencyStatus): "default" | "secondary" | " export const DependencyDownloadDialog = memo(function DependencyDownloadDialog({ mod, requestedVersion, open, onOpenChange }: DependencyDownloadDialogProps) { const { t } = useTranslation() - const { activeProfileIdByGame } = useProfileData() - const activeProfileId = mod ? activeProfileIdByGame[mod.gameId] : undefined - const { installedModVersionsByProfile, installedModsByProfile } = useModManagementData() - const { setDependencyWarnings } = useModManagementActions() - const { global: { enforceDependencyVersions } } = useSettingsData() + const activeProfileId = useActiveProfileId(mod?.gameId ?? null) + const { mods: installedModsList, isModInstalled } = useInstalledMods(activeProfileId) + const setDependencyWarnings = useSetDependencyWarnings() + const { enforceDependencyVersions } = useGlobalSettings() const { startDownload } = useDownloadActions() // null means "auto-select everything that needs downloading" @@ -115,8 +114,14 @@ export const DependencyDownloadDialog = memo(function DependencyDownloadDialog({ // Check if this is a Thunderstore online mod (UUID format: 36 chars with hyphens) const isThunderstoreMod = mod ? (mod.id.length === 36 && mod.id.includes("-")) : false - // Get installed versions for the active profile - const installedVersionsForProfile = activeProfileId ? installedModVersionsByProfile[activeProfileId] : undefined + // Build installed versions map for the active profile + const installedVersionsForProfile = useMemo(() => { + const map: Record = {} + for (const m of installedModsList) { + map[m.modId] = m.installedVersion + } + return map + }, [installedModsList]) // Use recursive online dependency resolution for Thunderstore mods in Electron const recursiveDepsQuery = useOnlineDependenciesRecursive({ @@ -202,7 +207,7 @@ export const DependencyDownloadDialog = memo(function DependencyDownloadDialog({ } // Fallback to non-recursive mock catalog analysis - const installedVersions = installedModVersionsByProfile[activeProfileId] || {} + const installedVersions = installedVersionsForProfile const depInfos = analyzeModDependencies({ mod, mods: MODS, @@ -246,7 +251,7 @@ export const DependencyDownloadDialog = memo(function DependencyDownloadDialog({ childrenByKey: {} as Record, isLoadingDeps: false, } - }, [mod, activeProfileId, isThunderstoreMod, recursiveDepsQuery.isElectron, recursiveDepsQuery.isLoading, recursiveDepsQuery.data, installedModVersionsByProfile, enforceDependencyVersions]) + }, [mod, activeProfileId, isThunderstoreMod, recursiveDepsQuery.isElectron, recursiveDepsQuery.isLoading, recursiveDepsQuery.data, installedVersionsForProfile, enforceDependencyVersions]) // Flatten all deps for easier access const allDeps = useMemo(() => { @@ -337,8 +342,7 @@ export const DependencyDownloadDialog = memo(function DependencyDownloadDialog({ if (!activeProfileId) return // Only download the target mod - const installed = installedModsByProfile[activeProfileId] - const isTargetInstalled = installed ? installed.has(mod.id) : false + const isTargetInstalled = isModInstalled(mod.id) if (!isTargetInstalled) { // Find the download URL for the requested version @@ -362,18 +366,17 @@ export const DependencyDownloadDialog = memo(function DependencyDownloadDialog({ .map(dep => `${dep.author}-${dep.name}${dep.requiredVersion ? `-${dep.requiredVersion}` : ""}`) if (unresolvedDeps.length > 0) { - setDependencyWarnings(activeProfileId, mod.id, unresolvedDeps) + setDependencyWarnings.mutate({ profileId: activeProfileId, modId: mod.id, warnings: unresolvedDeps }) } - + onOpenChange(false) } - + const handleDownloadSelected = () => { if (!activeProfileId) return - const installed = installedModsByProfile[activeProfileId] - const isTargetInstalled = installed ? installed.has(mod.id) : false - + const isTargetInstalled = isModInstalled(mod.id) + // Download target mod if not already installed if (!isTargetInstalled) { // Find the download URL for the requested version @@ -423,18 +426,18 @@ export const DependencyDownloadDialog = memo(function DependencyDownloadDialog({ .map(dep => `${dep.author}-${dep.name}${dep.requiredVersion ? `-${dep.requiredVersion}` : ""}`) if (unresolvedDeps.length > 0) { - setDependencyWarnings(activeProfileId, mod.id, unresolvedDeps) + setDependencyWarnings.mutate({ profileId: activeProfileId, modId: mod.id, warnings: unresolvedDeps }) } - + onOpenChange(false) } - + const handleCancel = () => { onOpenChange(false) } - const targetInstalled = activeProfileId ? (installedModsByProfile[activeProfileId]?.has(mod.id) || false) : false + const targetInstalled = isModInstalled(mod.id) const handleOpenChange = (nextOpen: boolean) => { onOpenChange(nextOpen) diff --git a/src/components/features/downloads-page.tsx b/src/components/features/downloads-page.tsx index d18e828..98060dc 100644 --- a/src/components/features/downloads-page.tsx +++ b/src/components/features/downloads-page.tsx @@ -4,10 +4,11 @@ import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { Progress } from "@/components/ui/progress" import { cn } from "@/lib/utils" +import { useState, useEffect } from "react" import { useDownloadStore, type DownloadTask } from "@/store/download-store" import { useDownloadActions } from "@/hooks/use-download-actions" import { useModInstaller } from "@/hooks/use-mod-installer" -import { useModManagementData, useProfileData } from "@/data" +import { getClient } from "@/data" import { ECOSYSTEM_GAMES } from "@/lib/ecosystem-games" import { openFolder } from "@/lib/desktop" @@ -66,6 +67,79 @@ const DOWNLOAD_STATUS_KEYS: Record = { cancelled: "downloads_status_cancelled", } +/** Install button that async-checks profile/mod status via tRPC client */ +function DownloadInstallButton({ + task, + installDownloadedMod, + isInstalling, +}: { + task: DownloadTask + installDownloadedMod: (params: { + gameId: string + profileId: string + modId: string + author: string + name: string + version: string + extractedPath: string + }) => void + isInstalling: boolean +}) { + const { t } = useTranslation() + const [profileId, setProfileId] = useState(null) + const [alreadyInstalled, setAlreadyInstalled] = useState(false) + const [checked, setChecked] = useState(false) + + useEffect(() => { + let cancelled = false + const check = async () => { + try { + const client = getClient() + const profile = await client.data.profiles.getActive.query({ gameId: task.gameId }) + if (cancelled) return + if (!profile) { + setChecked(true) + return + } + setProfileId(profile.id) + const mods = await client.data.mods.listInstalled.query({ profileId: profile.id }) + if (cancelled) return + setAlreadyInstalled(mods.some(m => m.modId === task.modId)) + setChecked(true) + } catch { + if (!cancelled) setChecked(true) + } + } + check() + return () => { cancelled = true } + }, [task.gameId, task.modId]) + + if (!checked || !profileId || alreadyInstalled) return null + + return ( + + ) +} + export function DownloadsPage() { const { t } = useTranslation() const tasks = useDownloadStore((s) => s.tasks) @@ -82,8 +156,6 @@ export function DownloadsPage() { } = useDownloadActions() const { installDownloadedMod, isInstalling } = useModInstaller() - const { activeProfileIdByGame } = useProfileData() - const { isModInstalled } = useModManagementData() const allTasks = Object.values(tasks) const activeTasks = getAllActiveTasks() @@ -279,36 +351,13 @@ export function DownloadsPage() {
{/* Install button */} - {task.extractedPath && (() => { - const profileId = activeProfileIdByGame[task.gameId] - const alreadyInstalled = profileId && isModInstalled(profileId, task.modId) - - if (!alreadyInstalled && profileId) { - return ( - - ) - } - return null - })()} + {task.extractedPath && ( + + )} {/* Show folder buttons */} {task.archivePath && ( diff --git a/src/components/features/game-dashboard.tsx b/src/components/features/game-dashboard.tsx index 9c3892b..376142d 100644 --- a/src/components/features/game-dashboard.tsx +++ b/src/components/features/game-dashboard.tsx @@ -3,8 +3,12 @@ import { useTranslation } from "react-i18next" import { Plus, Upload, Download as DownloadIcon, ChevronDown, Settings, FolderOpen, FileCode, FileDown, Edit, Trash2 } from "lucide-react" import { useAppStore } from "@/store/app-store" -import { useProfileData, useProfileActions, useModManagementData, useModManagementActions, useSettingsData } from "@/data" -import type { Profile } from "@/store/profile-store" +import { + useProfiles, useActiveProfileId, useProfileModCounts, useInstalledMods, + useEnsureDefaultProfile, useSetActiveProfile, useCreateProfile, useRenameProfile, useDeleteProfile, + useMarkModInstalled, useUninstallAllMods, useDeleteProfileModState, useAllSettings, + type Profile, +} from "@/data" import { trpc } from "@/lib/trpc" import { getExeNames, getEcosystemEntry, getModloaderPackageForGame } from "@/lib/ecosystem" import { useCatalogStatus } from "@/lib/queries/useOnlineMods" @@ -28,9 +32,6 @@ import { UninstallAllModsDialog } from "./uninstall-all-mods-dialog" import { InstallBaseDependenciesDialog } from "./install-base-dependencies-dialog" import { toast } from "sonner" -// Stable fallback constant to avoid creating new [] in selectors -const EMPTY_PROFILES: readonly Profile[] = [] - export function GameDashboard() { const { t } = useTranslation() const [createProfileOpen, setCreateProfileOpen] = useState(false) @@ -40,26 +41,33 @@ export function GameDashboard() { const [installDepsOpen, setInstallDepsOpen] = useState(false) const [depsMissing, setDepsMissing] = useState([]) const [isInstallingDeps, setIsInstallingDeps] = useState(false) + const [isDeletingProfile, setIsDeletingProfile] = useState(false) + const [isUninstallingAll, setIsUninstallingAll] = useState(false) const selectedGameId = useAppStore((s) => s.selectedGameId) const openSettingsToGame = useAppStore((s) => s.openSettingsToGame) - const { activeProfileIdByGame, profilesByGame } = useProfileData() - const profileMut = useProfileActions() - const { installedModsByProfile } = useModManagementData() - const modMut = useModManagementActions() + const profiles = useProfiles(selectedGameId) + const activeProfileId = useActiveProfileId(selectedGameId) + const profileModCounts = useProfileModCounts(selectedGameId) + const { modIds } = useInstalledMods(activeProfileId) + const installedModCount = modIds.length + + const ensureDefaultProfile = useEnsureDefaultProfile() + const setActiveProfile = useSetActiveProfile() + const createProfileMut = useCreateProfile() + const renameProfileMut = useRenameProfile() + const deleteProfileMut = useDeleteProfile() + const markModInstalled = useMarkModInstalled() + const uninstallAllModsMut = useUninstallAllMods() + const deleteProfileState = useDeleteProfileModState() + const { getPerGame } = useAllSettings() - const activeProfileId = selectedGameId ? activeProfileIdByGame[selectedGameId] : undefined - const profiles = (selectedGameId ? profilesByGame[selectedGameId] : undefined) ?? EMPTY_PROFILES - const resetProfileMutation = trpc.profiles.resetProfile.useMutation() const launchMutation = trpc.launch.start.useMutation() const installDepsMutation = trpc.launch.installBaseDependencies.useMutation() const trpcUtils = trpc.useUtils() - const installedModsSet = activeProfileId ? installedModsByProfile[activeProfileId] : undefined - const installedModCount = installedModsSet?.size ?? 0 - + // Check if game binary can be found - const { getPerGame } = useSettingsData() const installFolder = selectedGameId ? getPerGame(selectedGameId).gameInstallFolder : "" const profilesEnabled = installFolder?.trim().length > 0 const exeNames = selectedGameId ? getExeNames(selectedGameId) : [] @@ -155,9 +163,10 @@ export function GameDashboard() { // Auto-ensure default profile when install folder becomes valid useEffect(() => { if (profilesEnabled && selectedGameId) { - profileMut.ensureDefaultProfile(selectedGameId) + ensureDefaultProfile.mutate(selectedGameId) } - }, [profilesEnabled, selectedGameId, profileMut]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [profilesEnabled, selectedGameId]) // Early return if no game selected - MUST be after all hooks if (!selectedGameId) { @@ -282,9 +291,9 @@ export function GameDashboard() { // Use UUID so metadata loads; fallback to owner-name if UUID is missing. const installedId = installResult.packageUuid4 || installResult.packageId if (installedId && installResult.version) { - modMut.installMod(activeProfileId, installedId, installResult.version) + markModInstalled.mutate({ profileId: activeProfileId, modId: installedId, version: installResult.version }) } - + toast.success("Base dependencies installed", { description: `${installResult.filesInstalled || 0} components installed successfully`, }) @@ -350,7 +359,7 @@ export function GameDashboard() { // Use UUID so metadata loads; fallback to owner-name if UUID is missing. const installedId = installResult.packageUuid4 || installResult.packageId if (installedId && installResult.version) { - modMut.installMod(activeProfileId, installedId, installResult.version) + markModInstalled.mutate({ profileId: activeProfileId, modId: installedId, version: installResult.version }) } toast.success("Base dependencies installed", { @@ -403,36 +412,38 @@ export function GameDashboard() { } const handleCreateProfile = (profileName: string) => { - profileMut.createProfile(selectedGameId, profileName) + createProfileMut.mutate({ gameId: selectedGameId, name: profileName }) toast.success("Profile created") } const handleRenameProfile = (newName: string) => { if (!activeProfileId) return - profileMut.renameProfile(selectedGameId, activeProfileId, newName) + renameProfileMut.mutate({ gameId: selectedGameId, profileId: activeProfileId, newName }) toast.success("Profile renamed") } const handleDeleteProfile = async () => { if (!activeProfileId) return - - const result = await profileMut.deleteProfile(selectedGameId, activeProfileId) - if (!result.deleted) { - toast.error("Cannot delete default profile") - setDeleteProfileOpen(false) - return - } - + setIsDeletingProfile(true) + try { + const result = await deleteProfileMut.mutateAsync({ gameId: selectedGameId, profileId: activeProfileId }) + if (!result.deleted) { + toast.error("Cannot delete default profile") + setDeleteProfileOpen(false) + setIsDeletingProfile(false) + return + } + // Delete profile BepInEx folder from disk const resetResult = await resetProfileMutation.mutateAsync({ gameId: selectedGameId, profileId: activeProfileId, }) - + // Clear state - modMut.deleteProfileState(activeProfileId) - + deleteProfileState.mutate(activeProfileId) + toast.success("Profile deleted", { description: `${resetResult.filesRemoved} files removed from disk`, }) @@ -441,24 +452,26 @@ export function GameDashboard() { toast.error("Failed to delete profile files", { description: message, }) + } finally { + setIsDeletingProfile(false) + setDeleteProfileOpen(false) } - - setDeleteProfileOpen(false) } const handleUninstallAll = async () => { if (!activeProfileId) return - + setIsUninstallingAll(true) + try { // Delete profile BepInEx folder (all installed mods) const result = await resetProfileMutation.mutateAsync({ gameId: selectedGameId, profileId: activeProfileId, }) - + // Clear mod state for this profile (but keep the profile itself) - modMut.uninstallAllMods(activeProfileId) - + uninstallAllModsMut.mutate(activeProfileId) + toast.success("All mods uninstalled", { description: `${result.filesRemoved} files removed from profile`, }) @@ -467,14 +480,15 @@ export function GameDashboard() { toast.error("Failed to uninstall mods", { description: message, }) + } finally { + setIsUninstallingAll(false) + setUninstallAllOpen(false) } - - setUninstallAllOpen(false) } const gameProfiles = profiles.map(profile => ({ ...profile, - modCount: installedModsByProfile[profile.id]?.size ?? 0 + modCount: profileModCounts[profile.id] ?? 0 })) const currentProfile = gameProfiles.find((p) => p.id === activeProfileId) @@ -498,12 +512,14 @@ export function GameDashboard() { onConfirm={handleDeleteProfile} disabled={activeProfileId === `${selectedGameId}-default`} disabledReason="Cannot delete the default profile" + loading={isDeletingProfile} /> {t("common_all_profiles")} profileMut.setActiveProfile(selectedGameId, profileId)} + onValueChange={(profileId) => setActiveProfile.mutate({ gameId: selectedGameId, profileId })} > {gameProfiles.map((profile) => ( s.selectedGameId) - const { activeProfileIdByGame } = useProfileData() - const activeProfileId = selectedGameId ? activeProfileIdByGame[selectedGameId] : undefined + const activeProfileId = useActiveProfileId(selectedGameId) + const { mods: installedModsList, isModInstalled, isModEnabled, getInstalledVersion } = useInstalledMods(activeProfileId) + const { enforceDependencyVersions } = useGlobalSettings() const setSearchQuery = useAppStore((s) => s.setSearchQuery) const setModLibraryTab = useAppStore((s) => s.setModLibraryTab) const selectMod = useAppStore((s) => s.selectMod) @@ -267,8 +265,7 @@ export function ModInspectorContent({ mod, onBack }: ModInspectorContentProps) { // Use lazy initialization to set installed version by default (rerender-lazy-state-init) const [selectedVersion, setSelectedVersion] = useState(() => { - const installedVersion = activeProfileId ? installedVersionsByProfile[activeProfileId]?.[mod.id] : undefined - return installedVersion || mod.version + return getInstalledVersion(mod.id) || mod.version }) // Fetch readme from Thunderstore API @@ -280,21 +277,21 @@ export function ModInspectorContent({ mod, onBack }: ModInspectorContentProps) { // Subscribe to the specific task so component re-renders on changes const downloadTask = useDownloadStore((s) => s.tasks[mod.id]) - // Derive Sets from data hooks - const installedSet = activeProfileId ? installedModsByProfile[activeProfileId] : undefined - const enabledSet = activeProfileId ? enabledModsByProfile[activeProfileId] : undefined - const uninstallingSet = uninstallingMods - - // Derive booleans from Sets - const installed = installedSet ? installedSet.has(mod.id) : false - const enabled = enabledSet ? enabledSet.has(mod.id) : false - const isUninstalling = uninstallingSet.has(mod.id) + const installed = isModInstalled(mod.id) + const enabled = isModEnabled(mod.id) + const isUninstalling = false // Get the actually installed version (not just mod.version which is the latest) - const installedVersion = activeProfileId ? installedVersionsByProfile[activeProfileId]?.[mod.id] : undefined + const installedVersion = getInstalledVersion(mod.id) - // Extract primitive dependencies for useMemo (rerender-dependencies) - const installedVersionsForProfile = activeProfileId ? installedVersionsByProfile[activeProfileId] : undefined + // Build installed versions map for dependency analysis + const installedVersionsForProfile = useMemo(() => { + const map: Record = {} + for (const m of installedModsList) { + map[m.modId] = m.installedVersion + } + return map + }, [installedModsList]) // Derive version state for CTA (Call-to-Action) logic const hasKnownInstalledVersion = installed && typeof installedVersion === "string" && installedVersion.length > 0 @@ -453,7 +450,7 @@ export function ModInspectorContent({ mod, onBack }: ModInspectorContentProps) { const handleToggleEnabled = () => { if (activeProfileId) { - toggleMod(activeProfileId, mod.id) + toggleMod.mutate({ profileId: activeProfileId, modId: mod.id }) } } @@ -1007,9 +1004,8 @@ export function ModInspector() { const selectedGameId = useAppStore((s) => s.selectedGameId) const selectMod = useAppStore((s) => s.selectMod) - const { activeProfileIdByGame } = useProfileData() - const activeProfileId = selectedGameId ? activeProfileIdByGame[selectedGameId] : undefined - const { installedModVersionsByProfile: installedVersionsByProfile } = useModManagementData() + const activeProfileId = useActiveProfileId(selectedGameId) + const { mods: installedModsList } = useInstalledMods(activeProfileId) const { uninstallMod } = useModActions() // Check if this is a Thunderstore mod (UUID format: 36 chars with hyphens) @@ -1052,7 +1048,7 @@ export function ModInspector() { // If mod not found and selectedModId exists: show fallback for unknown installed mods if (!mod && selectedModId) { - const installedVersion = activeProfileId ? installedVersionsByProfile[activeProfileId]?.[selectedModId] : undefined + const installedVersion = installedModsList.find(m => m.modId === selectedModId)?.installedVersion // Show minimal "Unknown mod" inspector return ( diff --git a/src/components/features/mod-list-item.tsx b/src/components/features/mod-list-item.tsx index 8398e15..b09402b 100644 --- a/src/components/features/mod-list-item.tsx +++ b/src/components/features/mod-list-item.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next" import { Download, Trash2, Loader2, Pause, AlertTriangle } from "lucide-react" import { useAppStore } from "@/store/app-store" import { useDownloadStore } from "@/store/download-store" -import { useProfileData, useModManagementData, useModManagementActions } from "@/data" +import { useActiveProfileId, useInstalledMods, useToggleMod } from "@/data" import { useDownloadActions } from "@/hooks/use-download-actions" import { useModActions } from "@/hooks/use-mod-actions" import { cn } from "@/lib/utils" @@ -25,32 +25,24 @@ export const ModListItem = memo(function ModListItem({ mod, onOpenDependencyDial const selectedModId = useAppStore((s) => s.selectedModId) const selectedGameId = useAppStore((s) => s.selectedGameId) - const { toggleMod } = useModManagementActions() + const toggleMod = useToggleMod() const { uninstallMod } = useModActions() - const { installedModsByProfile, enabledModsByProfile, uninstallingMods, getDependencyWarnings, installedModVersionsByProfile: installedVersionsByProfile } = useModManagementData() - - const { activeProfileIdByGame } = useProfileData() - const activeProfileId = selectedGameId ? activeProfileIdByGame[selectedGameId] : undefined + const activeProfileId = useActiveProfileId(selectedGameId) + const { isModInstalled, isModEnabled, getInstalledVersion, getDependencyWarnings } = useInstalledMods(activeProfileId) const { startDownload } = useDownloadActions() const isSelected = selectedModId === mod.id - // Derive Sets from data hooks - const installedSet = activeProfileId ? installedModsByProfile[activeProfileId] : undefined - const enabledSet = activeProfileId ? enabledModsByProfile[activeProfileId] : undefined - const uninstallingSet = uninstallingMods - // Subscribe to download task const downloadTask = useDownloadStore((s) => s.tasks[mod.id]) - - // Derive booleans from Sets - const isInstalled = installedSet ? installedSet.has(mod.id) : false - const isEnabled = enabledSet ? enabledSet.has(mod.id) : false - const isUninstalling = uninstallingSet.has(mod.id) + + const isInstalled = isModInstalled(mod.id) + const isEnabled = isModEnabled(mod.id) + const isUninstalling = false // Check for dependency warnings - const depWarnings = activeProfileId ? getDependencyWarnings(activeProfileId, mod.id) : [] + const depWarnings = getDependencyWarnings(mod.id) const hasWarnings = isInstalled && depWarnings.length > 0 // Check download states @@ -59,8 +51,7 @@ export const ModListItem = memo(function ModListItem({ mod, onOpenDependencyDial const isPaused = downloadTask?.status === "paused" const hasDownloadTask = isDownloading || isQueued || isPaused - // Get the actually installed version - const installedVersion = activeProfileId ? installedVersionsByProfile[activeProfileId]?.[mod.id] : undefined + const installedVersion = getInstalledVersion(mod.id) const hasUpdate = isInstalled && installedVersion && isVersionGreater(mod.version, installedVersion) // Early return if no game selected (shouldn't happen, but type-safe) @@ -164,7 +155,7 @@ export const ModListItem = memo(function ModListItem({ mod, onOpenDependencyDial {t("common_enabled")} activeProfileId && toggleMod(activeProfileId, mod.id)} + onCheckedChange={() => activeProfileId && toggleMod.mutate({ profileId: activeProfileId, modId: mod.id })} />
) : null} diff --git a/src/components/features/mod-tile.tsx b/src/components/features/mod-tile.tsx index d1de35d..2858771 100644 --- a/src/components/features/mod-tile.tsx +++ b/src/components/features/mod-tile.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next" import { Download, Trash2, Loader2, Pause, AlertTriangle } from "lucide-react" import { useAppStore } from "@/store/app-store" import { useDownloadStore } from "@/store/download-store" -import { useProfileData, useModManagementData, useModManagementActions } from "@/data" +import { useActiveProfileId, useInstalledMods, useToggleMod } from "@/data" import { useDownloadActions } from "@/hooks/use-download-actions" import { useModActions } from "@/hooks/use-mod-actions" import { cn } from "@/lib/utils" @@ -25,32 +25,24 @@ export const ModTile = memo(function ModTile({ mod, onOpenDependencyDialog }: Mo const selectedModId = useAppStore((s) => s.selectedModId) const selectedGameId = useAppStore((s) => s.selectedGameId) - const { toggleMod } = useModManagementActions() + const toggleMod = useToggleMod() const { uninstallMod } = useModActions() - const { installedModsByProfile, enabledModsByProfile, uninstallingMods, getDependencyWarnings, installedModVersionsByProfile: installedVersionsByProfile } = useModManagementData() - - const { activeProfileIdByGame } = useProfileData() - const activeProfileId = selectedGameId ? activeProfileIdByGame[selectedGameId] : undefined + const activeProfileId = useActiveProfileId(selectedGameId) + const { isModInstalled, isModEnabled, getInstalledVersion, getDependencyWarnings } = useInstalledMods(activeProfileId) const { startDownload } = useDownloadActions() const isSelected = selectedModId === mod.id - // Derive Sets from data hooks - const installedSet = activeProfileId ? installedModsByProfile[activeProfileId] : undefined - const enabledSet = activeProfileId ? enabledModsByProfile[activeProfileId] : undefined - const uninstallingSet = uninstallingMods - // Subscribe to download task const downloadTask = useDownloadStore((s) => s.tasks[mod.id]) - - // Derive booleans from Sets - const isInstalled = installedSet ? installedSet.has(mod.id) : false - const isEnabled = enabledSet ? enabledSet.has(mod.id) : false - const isUninstalling = uninstallingSet.has(mod.id) + + const isInstalled = isModInstalled(mod.id) + const isEnabled = isModEnabled(mod.id) + const isUninstalling = false // Check for dependency warnings - const depWarnings = activeProfileId ? getDependencyWarnings(activeProfileId, mod.id) : [] + const depWarnings = getDependencyWarnings(mod.id) const hasWarnings = isInstalled && depWarnings.length > 0 // Check download states @@ -59,8 +51,7 @@ export const ModTile = memo(function ModTile({ mod, onOpenDependencyDialog }: Mo const isPaused = downloadTask?.status === "paused" const hasDownloadTask = isDownloading || isQueued || isPaused - // Get the actually installed version - const installedVersion = activeProfileId ? installedVersionsByProfile[activeProfileId]?.[mod.id] : undefined + const installedVersion = getInstalledVersion(mod.id) const hasUpdate = isInstalled && installedVersion && isVersionGreater(mod.version, installedVersion) const handleActionClick = (e: React.MouseEvent) => { @@ -144,7 +135,7 @@ export const ModTile = memo(function ModTile({ mod, onOpenDependencyDialog }: Mo {t("common_enabled")} activeProfileId && toggleMod(activeProfileId, mod.id)} + onCheckedChange={() => activeProfileId && toggleMod.mutate({ profileId: activeProfileId, modId: mod.id })} /> ) : null} diff --git a/src/components/features/mods-library.tsx b/src/components/features/mods-library.tsx index 2f121ae..b61c40f 100644 --- a/src/components/features/mods-library.tsx +++ b/src/components/features/mods-library.tsx @@ -4,8 +4,11 @@ import { Search, SlidersHorizontal, MoreVertical, ChevronDown, Plus, Grid3x3, Li import { useVirtualizer } from "@tanstack/react-virtual" import { useAppStore } from "@/store/app-store" -import type { Profile } from "@/store/profile-store" -import { useProfileData, useProfileActions, useModManagementData, useModManagementActions, useSettingsData } from "@/data" +import { + useActiveProfileId, useProfiles, useProfileModCounts, useInstalledMods, + useCreateProfile, useSetActiveProfile, useMarkModInstalled, useAllSettings, + type Profile, +} from "@/data" import { MODS } from "@/mocks/mods" import { ECOSYSTEM_GAMES } from "@/lib/ecosystem-games" import { MOD_CATEGORIES } from "@/mocks/mod-categories" @@ -18,8 +21,6 @@ import type { Mod } from "@/types/mod" import { toast } from "sonner" import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip" -// Stable fallback constants to avoid creating new references in selectors -const EMPTY_PROFILES: readonly Profile[] = [] const EMPTY_SET = new Set() // Helper: Check if a modId is a Thunderstore UUID (36 chars with hyphens) @@ -501,23 +502,32 @@ export function ModsLibrary() { const tab = useAppStore((s) => s.modLibraryTab) const setTab = useAppStore((s) => s.setModLibraryTab) - // Subscribe to the installed mods Set directly for real-time updates - const { activeProfileIdByGame, profilesByGame: profilesByGameFromData } = useProfileData() - const activeProfileId = selectedGameId ? activeProfileIdByGame[selectedGameId] ?? null : null - const { installedModsByProfile, installedModVersionsByProfile } = useModManagementData() - const { installMod } = useModManagementActions() - // Use stable fallback to avoid new Set() every render - const installedModsSet = activeProfileId ? installedModsByProfile[activeProfileId] : undefined - const installedModsSetOrEmpty = installedModsSet ?? EMPTY_SET - const installedVersionsMap = activeProfileId ? installedModVersionsByProfile[activeProfileId] : undefined - - // Avoid returning new [] in selector - return undefined and default outside - const profilesFromStore = selectedGameId ? profilesByGameFromData[selectedGameId] ?? undefined : undefined - const profiles = profilesFromStore ?? EMPTY_PROFILES - const { createProfile, setActiveProfile } = useProfileActions() - + // Subscribe to installed mods for real-time updates + const activeProfileId = useActiveProfileId(selectedGameId) + const { mods: installedModsList, modIds: installedModIds } = useInstalledMods(activeProfileId) + const markModInstalled = useMarkModInstalled() + const profileModCounts = useProfileModCounts(selectedGameId) + + // Build sets/maps from installed mods data + const installedModsSetOrEmpty = useMemo( + () => (installedModIds.length > 0 ? new Set(installedModIds) : EMPTY_SET), + [installedModIds], + ) + const installedVersionsMap = useMemo(() => { + if (installedModsList.length === 0) return undefined + const map: Record = {} + for (const m of installedModsList) { + map[m.modId] = m.installedVersion + } + return map + }, [installedModsList]) + + const profiles = useProfiles(selectedGameId) + const createProfile = useCreateProfile() + const setActiveProfile = useSetActiveProfile() + // Check if profiles are enabled (requires install folder) - const { getPerGame: getPerGameSettings } = useSettingsData() + const { getPerGame: getPerGameSettings } = useAllSettings() const installFolder = selectedGameId ? getPerGameSettings(selectedGameId).gameInstallFolder : "" const profilesEnabled = installFolder?.trim().length > 0 const exeNames = selectedGameId ? getExeNames(selectedGameId) : [] @@ -859,13 +869,13 @@ export function ModsLibrary() { const currentGame = ECOSYSTEM_GAMES.find((g) => g.id === selectedGameId) const gameProfiles = profiles.map(profile => ({ ...profile, - modCount: installedModsByProfile[profile.id]?.size ?? 0 + modCount: profileModCounts[profile.id] ?? 0 })) const currentProfile = gameProfiles.find((p) => p.id === activeProfileId) const handleCreateProfile = (profileName: string) => { if (!selectedGameId) return - createProfile(selectedGameId, profileName) + createProfile.mutate({ gameId: selectedGameId, name: profileName }) } const handleToggleCategory = useCallback((category: string) => { @@ -1018,9 +1028,9 @@ export function ModsLibrary() { // Use UUID so metadata loads; fallback to owner-name if UUID is missing. const installedId = installResult.packageUuid4 || installResult.packageId if (installedId && installResult.version) { - installMod(activeProfileId, installedId, installResult.version) + markModInstalled.mutate({ profileId: activeProfileId, modId: installedId, version: installResult.version }) } - + toast.success("Base dependencies installed", { description: `${installResult.filesInstalled || 0} components installed successfully`, }) @@ -1085,9 +1095,9 @@ export function ModsLibrary() { // Use UUID so metadata loads; fallback to owner-name if UUID is missing. const installedId = installResult.packageUuid4 || installResult.packageId if (installedId && installResult.version) { - installMod(activeProfileId, installedId, installResult.version) + markModInstalled.mutate({ profileId: activeProfileId, modId: installedId, version: installResult.version }) } - + toast.success("Base dependencies installed") } } catch (error) { @@ -1300,7 +1310,7 @@ export function ModsLibrary() { {t("common_all_profiles")} setActiveProfile(selectedGameId, profileId)} + onValueChange={(profileId) => setActiveProfile.mutate({ gameId: selectedGameId, profileId })} > {gameProfiles.map((profile) => ( strin export function DownloadsPanel(_props: PanelProps) { const { t } = useTranslation() - const { speedLimitEnabled, speedLimitBps, speedUnit, maxConcurrentDownloads, downloadCacheEnabled, autoInstallMods } = useSettingsData().global - const { updateGlobal } = useSettingsActions() + const { speedLimitEnabled, speedLimitBps, speedUnit, maxConcurrentDownloads, downloadCacheEnabled, autoInstallMods } = useGlobalSettings() + const updateGlobal = useUpdateGlobalSettings() // Logarithmic slider mapping (10 KB/s to 200 MB/s) const minBps = 10 * 1024 // 10 KB/s @@ -63,21 +63,21 @@ export function DownloadsPanel(_props: PanelProps) { } const handleSpeedLimitChange = (enabled: boolean) => { - updateGlobal({ speedLimitEnabled: enabled }) + updateGlobal.mutate({ speedLimitEnabled: enabled }) } const handleSpeedValueChange = (value: number | number[]) => { const numValue = Array.isArray(value) ? value[0] : value const bps = sliderToBps(numValue) - updateGlobal({ speedLimitBps: bps }) + updateGlobal.mutate({ speedLimitBps: bps }) } const handleUnitChange = (value: string) => { - updateGlobal({ speedUnit: value as "Bps" | "bps" }) + updateGlobal.mutate({ speedUnit: value as "Bps" | "bps" }) } const handleConcurrencyChange = (value: string) => { - updateGlobal({ maxConcurrentDownloads: parseInt(value, 10) }) + updateGlobal.mutate({ maxConcurrentDownloads: parseInt(value, 10) }) } return ( @@ -158,7 +158,7 @@ export function DownloadsPanel(_props: PanelProps) { rightContent={ updateGlobal({ downloadCacheEnabled: checked })} + onCheckedChange={(checked) => updateGlobal.mutate({ downloadCacheEnabled: checked })} /> } /> @@ -169,7 +169,7 @@ export function DownloadsPanel(_props: PanelProps) { rightContent={ updateGlobal({ autoInstallMods: checked })} + onCheckedChange={(checked) => updateGlobal.mutate({ autoInstallMods: checked })} /> } /> diff --git a/src/components/features/settings/panels/game-settings-panel.tsx b/src/components/features/settings/panels/game-settings-panel.tsx index cba1cae..2e6342d 100644 --- a/src/components/features/settings/panels/game-settings-panel.tsx +++ b/src/components/features/settings/panels/game-settings-panel.tsx @@ -1,15 +1,20 @@ import { useTranslation } from "react-i18next" +import { Loader2 } from "lucide-react" import { SettingsRow } from "../settings-row" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { FolderPathControl } from "../folder-path-control" import { - useSettingsData, - useSettingsActions, - useProfileData, - useProfileActions, - useModManagementActions, - useGameManagementActions, + useGlobalSettings, + useGameSettings, + useUpdateGameSettings, + useDeleteGameSettings, + useProfiles, + useActiveProfileId, + useResetGameProfiles, + useRemoveGameProfiles, + useDeleteProfileModState, + useRemoveGame, } from "@/data" import { useAppStore } from "@/store/app-store" import { openFolder } from "@/lib/desktop" @@ -24,19 +29,21 @@ interface GameSettingsPanelProps { export function GameSettingsPanel({ gameId }: GameSettingsPanelProps) { const { t } = useTranslation() - const { profilesByGame, activeProfileIdByGame } = useProfileData() - const profileMut = useProfileActions() + const profiles = useProfiles(gameId ?? null) + const activeProfileId = useActiveProfileId(gameId ?? null) + const globalSettings = useGlobalSettings() + const perGameSettings = useGameSettings(gameId ?? null) + const updateGameSettings = useUpdateGameSettings() + const deleteGameSettings = useDeleteGameSettings() + const resetGameProfiles = useResetGameProfiles() + const removeGameProfiles = useRemoveGameProfiles() + const deleteProfileModState = useDeleteProfileModState() + const removeGame = useRemoveGame() const resetProfileMutation = trpc.profiles.resetProfile.useMutation() const unmanageGameMutation = trpc.games.unmanageGameCleanup.useMutation() const cleanupInjectedMutation = trpc.launch.cleanupInjected.useMutation() - const { global: globalSettings, getPerGame } = useSettingsData() - const settingsMut = useSettingsActions() - - const modMut = useModManagementActions() - const gameMut = useGameManagementActions() - const selectGame = useAppStore((s) => s.selectGame) const setSettingsOpen = useAppStore((s) => s.setSettingsOpen) @@ -54,7 +61,6 @@ export function GameSettingsPanel({ gameId }: GameSettingsPanelProps) { } const game = ECOSYSTEM_GAMES.find((g) => g.id === gameId) - const perGameSettings = getPerGame(gameId) if (!game) { return ( @@ -70,13 +76,12 @@ export function GameSettingsPanel({ gameId }: GameSettingsPanelProps) { } const handleLaunchParametersChange = (value: string) => { - settingsMut.updatePerGame(gameId, { launchParameters: value }) + updateGameSettings.mutate({ gameId, updates: { launchParameters: value } }) } const handleBrowseProfileFolder = () => { - const profileId = activeProfileIdByGame[gameId] - if (profileId) { - const profilePath = `${globalSettings.dataFolder}/${gameId}/profiles/${profileId}` + if (activeProfileId) { + const profilePath = `${globalSettings.dataFolder}/${gameId}/profiles/${activeProfileId}` openFolder(profilePath) } } @@ -87,8 +92,6 @@ export function GameSettingsPanel({ gameId }: GameSettingsPanelProps) { ) if (confirmed) { try { - const profiles = profilesByGame[gameId] || [] - let totalFilesRemoved = 0 for (const profile of profiles) { @@ -97,10 +100,10 @@ export function GameSettingsPanel({ gameId }: GameSettingsPanelProps) { profileId: profile.id, }) totalFilesRemoved += result.filesRemoved - await modMut.deleteProfileState(profile.id) + await deleteProfileModState.mutateAsync(profile.id) } - await profileMut.resetGameProfilesToDefault(gameId) + await resetGameProfiles.mutateAsync(gameId) toast.success(`${game.name} installation reset`, { description: `${totalFilesRemoved} files removed, reset to Default profile`, @@ -128,15 +131,13 @@ export function GameSettingsPanel({ gameId }: GameSettingsPanelProps) { gameId, }) - const profiles = profilesByGame[gameId] || [] - for (const profile of profiles) { - await modMut.deleteProfileState(profile.id) + await deleteProfileModState.mutateAsync(profile.id) } - await profileMut.removeGameProfiles(gameId) - await settingsMut.deletePerGame(gameId) - const nextDefaultGameId = await gameMut.removeManagedGame(gameId) + await removeGameProfiles.mutateAsync(gameId) + await deleteGameSettings.mutateAsync(gameId) + const nextDefaultGameId = await removeGame.mutateAsync(gameId) selectGame(nextDefaultGameId) @@ -195,7 +196,7 @@ export function GameSettingsPanel({ gameId }: GameSettingsPanelProps) { settingsMut.updatePerGame(gameId, { gameInstallFolder: nextPath })} + onChangePath={(nextPath) => updateGameSettings.mutate({ gameId, updates: { gameInstallFolder: nextPath } })} className="w-full" /> } @@ -208,7 +209,7 @@ export function GameSettingsPanel({ gameId }: GameSettingsPanelProps) { settingsMut.updatePerGame(gameId, { modDownloadFolder: nextPath })} + onChangePath={(nextPath) => updateGameSettings.mutate({ gameId, updates: { modDownloadFolder: nextPath } })} className="w-full" /> } @@ -221,7 +222,7 @@ export function GameSettingsPanel({ gameId }: GameSettingsPanelProps) { settingsMut.updatePerGame(gameId, { modCacheFolder: nextPath })} + onChangePath={(nextPath) => updateGameSettings.mutate({ gameId, updates: { modCacheFolder: nextPath } })} className="w-full" /> } @@ -243,13 +244,13 @@ export function GameSettingsPanel({ gameId }: GameSettingsPanelProps) { {t("settings_game_browse_profile_folder")} @@ -275,7 +276,9 @@ export function GameSettingsPanel({ gameId }: GameSettingsPanelProps) { variant="destructive" size="sm" onClick={handleCleanupInjected} + disabled={cleanupInjectedMutation.isPending} > + {cleanupInjectedMutation.isPending && } {t("settings_game_clean_injected_button")} } @@ -289,7 +292,9 @@ export function GameSettingsPanel({ gameId }: GameSettingsPanelProps) { variant="destructive" size="sm" onClick={handleResetGameInstallation} + disabled={resetProfileMutation.isPending || deleteProfileModState.isPending || resetGameProfiles.isPending} > + {(resetProfileMutation.isPending || deleteProfileModState.isPending || resetGameProfiles.isPending) && } {t("settings_game_reset_button")} } @@ -303,7 +308,9 @@ export function GameSettingsPanel({ gameId }: GameSettingsPanelProps) { variant="destructive" size="sm" onClick={handleRemoveManagement} + disabled={unmanageGameMutation.isPending || cleanupInjectedMutation.isPending || removeGame.isPending} > + {(unmanageGameMutation.isPending || cleanupInjectedMutation.isPending || removeGame.isPending) && } {t("settings_game_remove_button")} } diff --git a/src/components/features/settings/panels/locations-panel.tsx b/src/components/features/settings/panels/locations-panel.tsx index 11ea074..a149099 100644 --- a/src/components/features/settings/panels/locations-panel.tsx +++ b/src/components/features/settings/panels/locations-panel.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next" -import { useSettingsData, useSettingsActions } from "@/data" +import { useGlobalSettings, useUpdateGlobalSettings } from "@/data" import { SettingsRow } from "../settings-row" import { FolderPathControl } from "../folder-path-control" @@ -10,8 +10,8 @@ interface PanelProps { export function LocationsPanel(_props: PanelProps) { void _props const { t } = useTranslation() - const { dataFolder, steamFolder, modDownloadFolder, cacheFolder } = useSettingsData().global - const { updateGlobal } = useSettingsActions() + const { dataFolder, steamFolder, modDownloadFolder, cacheFolder } = useGlobalSettings() + const updateGlobal = useUpdateGlobalSettings() return (
@@ -29,7 +29,7 @@ export function LocationsPanel(_props: PanelProps) { belowContent={ updateGlobal({ dataFolder: nextPath })} + onChangePath={(nextPath) => updateGlobal.mutate({ dataFolder: nextPath })} className="w-full" /> } @@ -41,7 +41,7 @@ export function LocationsPanel(_props: PanelProps) { belowContent={ updateGlobal({ steamFolder: nextPath })} + onChangePath={(nextPath) => updateGlobal.mutate({ steamFolder: nextPath })} className="w-full" /> } @@ -54,7 +54,7 @@ export function LocationsPanel(_props: PanelProps) { updateGlobal({ modDownloadFolder: nextPath })} + onChangePath={(nextPath) => updateGlobal.mutate({ modDownloadFolder: nextPath })} className="w-full" /> } @@ -67,7 +67,7 @@ export function LocationsPanel(_props: PanelProps) { updateGlobal({ cacheFolder: nextPath })} + onChangePath={(nextPath) => updateGlobal.mutate({ cacheFolder: nextPath })} className="w-full" /> } diff --git a/src/components/features/settings/panels/other-panel.tsx b/src/components/features/settings/panels/other-panel.tsx index f499ac2..6d640f9 100644 --- a/src/components/features/settings/panels/other-panel.tsx +++ b/src/components/features/settings/panels/other-panel.tsx @@ -2,7 +2,7 @@ import { useMemo } from "react" import { SettingsRow } from "../settings-row" import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" -import { useSettingsData, useSettingsActions } from "@/data" +import { useGlobalSettings, useUpdateGlobalSettings } from "@/data" import { useTranslation } from "react-i18next" import { logger } from "@/lib/logger" import { @@ -26,8 +26,8 @@ export function OtherPanel(_props: PanelProps) { void _props const { t, i18n } = useTranslation() - const { theme, language, cardDisplayType, funkyMode, enforceDependencyVersions } = useSettingsData().global - const { updateGlobal } = useSettingsActions() + const { theme, language, cardDisplayType, funkyMode, enforceDependencyVersions } = useGlobalSettings() + const updateGlobal = useUpdateGlobalSettings() const availableLanguages = useMemo( () => Object.keys(i18n.options.resources ?? {}) as string[], @@ -60,7 +60,7 @@ export function OtherPanel(_props: PanelProps) { key={mode} variant={theme === mode ? "default" : "outline"} size="sm" - onClick={() => updateGlobal({ theme: mode })} + onClick={() => updateGlobal.mutate({ theme: mode })} className="capitalize" > {mode} @@ -77,7 +77,7 @@ export function OtherPanel(_props: PanelProps) { { - if (value) updateGlobal({ cardDisplayType: value }) + if (value) updateGlobal.mutate({ cardDisplayType: value }) }} > @@ -121,7 +121,7 @@ export function OtherPanel(_props: PanelProps) { rightContent={ updateGlobal({ funkyMode: checked })} + onCheckedChange={(checked) => updateGlobal.mutate({ funkyMode: checked })} /> } /> @@ -151,7 +151,7 @@ export function OtherPanel(_props: PanelProps) { rightContent={ updateGlobal({ enforceDependencyVersions: checked })} + onCheckedChange={(checked) => updateGlobal.mutate({ enforceDependencyVersions: checked })} /> } /> diff --git a/src/components/features/settings/settings-dialog.tsx b/src/components/features/settings/settings-dialog.tsx index 7d128c9..1b0f9e9 100644 --- a/src/components/features/settings/settings-dialog.tsx +++ b/src/components/features/settings/settings-dialog.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react" import { useTranslation } from "react-i18next" import { useAppStore } from "@/store/app-store" -import { useGameManagementData } from "@/data" +import { useGames } from "@/data" import { Dialog, DialogContent, DialogClose } from "@/components/ui/dialog" import { XIcon, PlusIcon } from "lucide-react" import { LocationsPanel } from "./panels/locations-panel" @@ -44,7 +44,7 @@ export function SettingsDialog() { const settingsOpen = useAppStore((s) => s.settingsOpen) const setSettingsOpen = useAppStore((s) => s.setSettingsOpen) const settingsActiveSection = useAppStore((s) => s.settingsActiveSection) - const { managedGameIds } = useGameManagementData() + const { managedGameIds } = useGames() const [activeSection, setActiveSection] = useState(settingsActiveSection || "other") const [searchQuery] = useState("") const [addGameOpen, setAddGameOpen] = useState(false) diff --git a/src/components/features/uninstall-all-mods-dialog.tsx b/src/components/features/uninstall-all-mods-dialog.tsx index 3c66f72..cc9872b 100644 --- a/src/components/features/uninstall-all-mods-dialog.tsx +++ b/src/components/features/uninstall-all-mods-dialog.tsx @@ -10,13 +10,14 @@ import { AlertDialogMedia, AlertDialogTitle, } from "@/components/ui/alert-dialog" -import { AlertTriangle } from "lucide-react" +import { AlertTriangle, Loader2 } from "lucide-react" type UninstallAllModsDialogProps = { open: boolean onOpenChange: (open: boolean) => void modCount: number onConfirm: () => void + loading?: boolean } export function UninstallAllModsDialog({ @@ -24,15 +25,12 @@ export function UninstallAllModsDialog({ onOpenChange, modCount, onConfirm, + loading = false, }: UninstallAllModsDialogProps) { const { t } = useTranslation() - const handleConfirm = () => { - onConfirm() - onOpenChange(false) - } return ( - + @@ -45,8 +43,9 @@ export function UninstallAllModsDialog({ - {t("common_cancel")} - + {t("common_cancel")} + + {loading && } Uninstall All diff --git a/src/components/layout/global-rail.tsx b/src/components/layout/global-rail.tsx index 05d255d..49e1e76 100644 --- a/src/components/layout/global-rail.tsx +++ b/src/components/layout/global-rail.tsx @@ -5,9 +5,10 @@ import { Link, useRouterState } from "@tanstack/react-router" import { useAppStore } from "@/store/app-store" import { - useProfileData, - useGameManagementData, - useGameManagementActions, + useProfiles, + useActiveProfileId, + useGames, + useSetDefaultGame, } from "@/data" import { ECOSYSTEM_GAMES } from "@/lib/ecosystem-games" import { Button } from "@/components/ui/button" @@ -41,15 +42,14 @@ export function GlobalRailContent({ onNavigate }: GlobalRailContentProps) { const setModLibraryTab = useAppStore((s) => s.setModLibraryTab) const pathname = useRouterState({ select: (s) => s.location.pathname }) - const { activeProfileIdByGame, profilesByGame } = useProfileData() - const { recentManagedGameIds, defaultGameId, managedGameIds } = useGameManagementData() - const { setDefaultGameId } = useGameManagementActions() - - const activeProfileId = selectedGameId ? activeProfileIdByGame[selectedGameId] ?? null : null + const profiles = useProfiles(selectedGameId) + const activeProfileId = useActiveProfileId(selectedGameId) + const { recentManagedGameIds, defaultGameId, managedGameIds } = useGames() + const setDefaultGame = useSetDefaultGame() // Get active profile name const activeProfile = selectedGameId && activeProfileId - ? profilesByGame[selectedGameId]?.find(p => p.id === activeProfileId) + ? profiles.find(p => p.id === activeProfileId) : null const activeProfileName = activeProfile?.name ?? t("rail_no_profile") @@ -152,7 +152,7 @@ export function GlobalRailContent({ onNavigate }: GlobalRailContentProps) { value={selectedGameId ?? undefined} onValueChange={(nextId) => { selectGame(nextId) - setDefaultGameId(nextId) + setDefaultGame.mutate(nextId) setMenuOpen(false) onNavigate?.() }} @@ -289,7 +289,7 @@ export function GlobalRailContent({ onNavigate }: GlobalRailContentProps) { key={`recent-${game.id}`} onClick={() => { selectGame(game.id) - setDefaultGameId(game.id) + setDefaultGame.mutate(game.id) onNavigate?.() }} className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" diff --git a/src/data/datasource.ts b/src/data/datasource.ts deleted file mode 100644 index c7a88f1..0000000 --- a/src/data/datasource.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Resolved datasource mode. Read once at module load time — Vite statically - * replaces `import.meta.env.VITE_DATASOURCE` at build time so this is - * effectively a compile-time constant and allows tree-shaking of unused paths. - */ -export const DATASOURCE: "db" | "zustand" = - (import.meta.env.VITE_DATASOURCE as "db" | "zustand" | undefined) ?? "db" - -export const isDbMode = DATASOURCE === "db" -export const isZustandMode = DATASOURCE === "zustand" diff --git a/src/data/hooks.ts b/src/data/hooks.ts deleted file mode 100644 index 0721d71..0000000 --- a/src/data/hooks.ts +++ /dev/null @@ -1,658 +0,0 @@ -/** - * React hooks – the stable API that components import. - * - * Data hooks: useSuspenseQuery → return type is always T (never undefined). - * Hook return shapes are identical regardless of VITE_DATASOURCE. - * Action hooks: call async service functions directly. - * In Zustand mode, DataBridge handles cache invalidation. - * In DB mode, action hooks explicitly invalidate after mutations. - * - * Pattern for component migration: - * Before: const x = useProfileStore((s) => s.profilesByGame) - * After: const { profilesByGame } = useProfileData() - */ - -import { useEffect, useMemo, useCallback } from "react" -import { useSuspenseQuery, useQueryClient } from "@tanstack/react-query" -import { useGameManagementStore } from "@/store/game-management-store" -import { useSettingsStore } from "@/store/settings-store" -import { useProfileStore } from "@/store/profile-store" -import type { Profile } from "@/store/profile-store" -import { useModManagementStore } from "@/store/mod-management-store" -import { - gameService, - settingsService, - profileService, - modService, -} from "./services" -import type { GlobalSettings, GameSettings } from "./interfaces" -import { isDbMode, isZustandMode } from "./datasource" - -// --------------------------------------------------------------------------- -// Query keys -// --------------------------------------------------------------------------- - -export const dataKeys = { - gameManagement: ["store", "gameManagement"] as const, - settings: ["store", "settings"] as const, - profiles: ["store", "profiles"] as const, - modManagement: ["store", "modManagement"] as const, -} - -// --------------------------------------------------------------------------- -// DataBridge – subscribe to Zustand stores and invalidate React Query cache -// --------------------------------------------------------------------------- - -export function DataBridge() { - const queryClient = useQueryClient() - - useEffect(() => { - // In DB mode, data store subscriptions are not needed — mutations - // explicitly invalidate queries. No-op. - if (isDbMode) return - - const unsubs = [ - useGameManagementStore.subscribe(() => { - queryClient.invalidateQueries({ queryKey: dataKeys.gameManagement }) - }), - useSettingsStore.subscribe(() => { - queryClient.invalidateQueries({ queryKey: dataKeys.settings }) - }), - useProfileStore.subscribe(() => { - queryClient.invalidateQueries({ queryKey: dataKeys.profiles }) - }), - useModManagementStore.subscribe(() => { - queryClient.invalidateQueries({ queryKey: dataKeys.modManagement }) - }), - ] - return () => unsubs.forEach((fn) => fn()) - }, [queryClient]) - - return null -} - -// =========================================================================== -// Game Management -// =========================================================================== - -type GameManagementData = { - managedGameIds: string[] - recentManagedGameIds: string[] - defaultGameId: string | null -} - -export function useGameManagementData(): GameManagementData { - const { data } = useSuspenseQuery({ - queryKey: dataKeys.gameManagement, - queryFn: async (): Promise => { - if (isDbMode) { - const [games, recentGames, defaultGame] = await Promise.all([ - gameService.list(), - gameService.getRecent(), - gameService.getDefault(), - ]) - return { - managedGameIds: games.map((g) => g.id), - recentManagedGameIds: recentGames.map((g) => g.id), - defaultGameId: defaultGame?.id ?? null, - } - } - const s = useGameManagementStore.getState() - return { - managedGameIds: s.managedGameIds, - recentManagedGameIds: s.recentManagedGameIds, - defaultGameId: s.defaultGameId, - } - }, - ...(isZustandMode && { - initialData: (): GameManagementData => { - const s = useGameManagementStore.getState() - return { - managedGameIds: s.managedGameIds, - recentManagedGameIds: s.recentManagedGameIds, - defaultGameId: s.defaultGameId, - } - }, - }), - staleTime: Infinity, - }) - return data -} - -export function useGameManagementActions() { - const queryClient = useQueryClient() - - return useMemo( - () => ({ - addManagedGame: async (gameId: string) => { - await gameService.add(gameId) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.gameManagement, - }) - }, - removeManagedGame: async (gameId: string) => { - const result = await gameService.remove(gameId) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.gameManagement, - }) - return result - }, - setDefaultGameId: async (gameId: string | null) => { - await gameService.setDefault(gameId) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.gameManagement, - }) - }, - appendRecentManagedGame: async (gameId: string) => { - await gameService.touch(gameId) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.gameManagement, - }) - }, - }), - [queryClient], - ) -} - -// =========================================================================== -// Settings -// =========================================================================== - -type SettingsData = { - global: GlobalSettings - perGame: Record - /** Derived helper matching store's getPerGame(). */ - getPerGame: (gameId: string) => GameSettings -} - -const defaultGameSettings: GameSettings = { - gameInstallFolder: "", - modDownloadFolder: "", - cacheFolder: "", - modCacheFolder: "", - launchParameters: "", - onlineModListCacheDate: null, -} - -export function useSettingsData(): SettingsData { - const { data } = useSuspenseQuery({ - queryKey: dataKeys.settings, - queryFn: async () => { - if (isDbMode) { - const [global, games] = await Promise.all([ - settingsService.getGlobal(), - gameService.list(), - ]) - const perGameEntries = await Promise.all( - games.map( - async (g) => - [g.id, await settingsService.getForGame(g.id)] as const, - ), - ) - return { - global, - perGame: Object.fromEntries(perGameEntries) as Record< - string, - GameSettings - >, - } - } - const s = useSettingsStore.getState() - return { - global: { ...s.global }, - perGame: { ...s.perGame } as Record, - } - }, - ...(isZustandMode && { - initialData: () => { - const s = useSettingsStore.getState() - return { - global: { ...s.global }, - perGame: { ...s.perGame } as Record, - } - }, - }), - staleTime: Infinity, - }) - - const getPerGame = useCallback( - (gameId: string): GameSettings => ({ - ...defaultGameSettings, - ...data.perGame[gameId], - }), - [data.perGame], - ) - - return { global: data.global, perGame: data.perGame, getPerGame } -} - -export function useSettingsActions() { - const queryClient = useQueryClient() - - return useMemo( - () => ({ - updateGlobal: async (updates: Partial) => { - await settingsService.updateGlobal(updates) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.settings, - }) - }, - updatePerGame: async ( - gameId: string, - updates: Partial, - ) => { - await settingsService.updateForGame(gameId, updates) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.settings, - }) - }, - resetPerGame: async (gameId: string) => { - await settingsService.resetForGame(gameId) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.settings, - }) - }, - deletePerGame: async (gameId: string) => { - await settingsService.deleteForGame(gameId) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.settings, - }) - }, - }), - [queryClient], - ) -} - -// =========================================================================== -// Profiles -// =========================================================================== - -type ProfileData = { - profilesByGame: Record - activeProfileIdByGame: Record -} - -export function useProfileData(): ProfileData { - const { data } = useSuspenseQuery({ - queryKey: dataKeys.profiles, - queryFn: async (): Promise => { - if (isDbMode) { - const games = await gameService.list() - const entries = await Promise.all( - games.map(async (g) => { - const [profiles, active] = await Promise.all([ - profileService.list(g.id), - profileService.getActive(g.id), - ]) - return { gameId: g.id, profiles, activeId: active?.id ?? "" } - }), - ) - return { - profilesByGame: Object.fromEntries( - entries.map((e) => [e.gameId, e.profiles]), - ), - activeProfileIdByGame: Object.fromEntries( - entries - .filter((e) => e.activeId) - .map((e) => [e.gameId, e.activeId]), - ), - } - } - const s = useProfileStore.getState() - return { - profilesByGame: s.profilesByGame, - activeProfileIdByGame: s.activeProfileIdByGame, - } - }, - ...(isZustandMode && { - initialData: (): ProfileData => { - const s = useProfileStore.getState() - return { - profilesByGame: s.profilesByGame, - activeProfileIdByGame: s.activeProfileIdByGame, - } - }, - }), - staleTime: Infinity, - }) - return data -} - -export function useProfileActions() { - const queryClient = useQueryClient() - - return useMemo( - () => ({ - ensureDefaultProfile: async (gameId: string) => { - const result = await profileService.ensureDefault(gameId) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.profiles, - }) - return result - }, - setActiveProfile: async (gameId: string, profileId: string) => { - await profileService.setActive(gameId, profileId) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.profiles, - }) - }, - createProfile: async (gameId: string, name: string) => { - const result = await profileService.create(gameId, name) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.profiles, - }) - return result - }, - renameProfile: async ( - gameId: string, - profileId: string, - newName: string, - ) => { - await profileService.rename(gameId, profileId, newName) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.profiles, - }) - }, - deleteProfile: async (gameId: string, profileId: string) => { - const result = await profileService.remove(gameId, profileId) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.profiles, - }) - return result - }, - resetGameProfilesToDefault: async (gameId: string) => { - const result = await profileService.reset(gameId) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.profiles, - }) - return result - }, - removeGameProfiles: async (gameId: string) => { - await profileService.removeAll(gameId) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.profiles, - }) - }, - }), - [queryClient], - ) -} - -// =========================================================================== -// Mod Management -// =========================================================================== - -type ModManagementQueryData = { - installedModsByProfile: Record> - enabledModsByProfile: Record> - installedModVersionsByProfile: Record> - dependencyWarningsByProfile: Record> -} - -type ModManagementData = ModManagementQueryData & { - uninstallingMods: Set - // Derived helpers (matching store method signatures) - isModInstalled: (profileId: string, modId: string) => boolean - isModEnabled: (profileId: string, modId: string) => boolean - getInstalledModIds: (profileId: string) => string[] - getInstalledVersion: ( - profileId: string, - modId: string, - ) => string | undefined - getDependencyWarnings: (profileId: string, modId: string) => string[] -} - -export function useModManagementData(): ModManagementData { - // uninstallingMods is UI state — always from Zustand regardless of VITE_DATASOURCE. - // Subscribe directly so it doesn't trigger a full DB re-fetch. - const uninstallingMods = useModManagementStore((s) => s.uninstallingMods) - - const { data } = useSuspenseQuery({ - queryKey: dataKeys.modManagement, - queryFn: async (): Promise => { - if (isDbMode) { - const games = await gameService.list() - const allProfiles = ( - await Promise.all(games.map((g) => profileService.list(g.id))) - ).flat() - - const profileMods = await Promise.all( - allProfiles.map(async (p) => ({ - profileId: p.id, - mods: await modService.listInstalled(p.id), - })), - ) - - const installedModsByProfile: Record> = {} - const enabledModsByProfile: Record> = {} - const installedModVersionsByProfile: Record< - string, - Record - > = {} - const dependencyWarningsByProfile: Record< - string, - Record - > = {} - - for (const { profileId, mods } of profileMods) { - const installed = new Set() - const enabled = new Set() - const versions: Record = {} - const warnings: Record = {} - - for (const mod of mods) { - installed.add(mod.modId) - if (mod.enabled) enabled.add(mod.modId) - versions[mod.modId] = mod.installedVersion - if (mod.dependencyWarnings.length > 0) { - warnings[mod.modId] = mod.dependencyWarnings - } - } - - installedModsByProfile[profileId] = installed - enabledModsByProfile[profileId] = enabled - installedModVersionsByProfile[profileId] = versions - dependencyWarningsByProfile[profileId] = warnings - } - - return { - installedModsByProfile, - enabledModsByProfile, - installedModVersionsByProfile, - dependencyWarningsByProfile, - } - } - const s = useModManagementStore.getState() - return { - installedModsByProfile: s.installedModsByProfile, - enabledModsByProfile: s.enabledModsByProfile, - installedModVersionsByProfile: s.installedModVersionsByProfile, - dependencyWarningsByProfile: s.dependencyWarningsByProfile, - } - }, - ...(isZustandMode && { - initialData: (): ModManagementQueryData => { - const s = useModManagementStore.getState() - return { - installedModsByProfile: s.installedModsByProfile, - enabledModsByProfile: s.enabledModsByProfile, - installedModVersionsByProfile: s.installedModVersionsByProfile, - dependencyWarningsByProfile: s.dependencyWarningsByProfile, - } - }, - }), - staleTime: Infinity, - structuralSharing: false, // Sets don't survive structural sharing - }) - - // Derived helpers matching store methods - const isModInstalled = useCallback( - (profileId: string, modId: string) => { - const set = data.installedModsByProfile[profileId] - return set ? set.has(modId) : false - }, - [data.installedModsByProfile], - ) - - const isModEnabled = useCallback( - (profileId: string, modId: string) => { - const set = data.enabledModsByProfile[profileId] - return set ? set.has(modId) : false - }, - [data.enabledModsByProfile], - ) - - const getInstalledModIds = useCallback( - (profileId: string) => { - const set = data.installedModsByProfile[profileId] - return set ? Array.from(set) : [] - }, - [data.installedModsByProfile], - ) - - const getInstalledVersion = useCallback( - (profileId: string, modId: string) => { - const map = data.installedModVersionsByProfile[profileId] - return map ? map[modId] : undefined - }, - [data.installedModVersionsByProfile], - ) - - const getDependencyWarnings = useCallback( - (profileId: string, modId: string) => { - const map = data.dependencyWarningsByProfile[profileId] - return map ? map[modId] || [] : [] - }, - [data.dependencyWarningsByProfile], - ) - - return { - ...data, - uninstallingMods, - isModInstalled, - isModEnabled, - getInstalledModIds, - getInstalledVersion, - getDependencyWarnings, - } -} - -export function useModManagementActions() { - const queryClient = useQueryClient() - - return useMemo( - () => ({ - installMod: async ( - profileId: string, - modId: string, - version: string, - ) => { - await modService.install(profileId, modId, version) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.modManagement, - }) - }, - uninstallMod: async (profileId: string, modId: string) => { - await modService.uninstall(profileId, modId) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.modManagement, - }) - }, - uninstallAllMods: async (profileId: string) => { - const result = await modService.uninstallAll(profileId) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.modManagement, - }) - return result - }, - enableMod: async (profileId: string, modId: string) => { - await modService.enable(profileId, modId) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.modManagement, - }) - }, - disableMod: async (profileId: string, modId: string) => { - await modService.disable(profileId, modId) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.modManagement, - }) - }, - toggleMod: async (profileId: string, modId: string) => { - await modService.toggle(profileId, modId) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.modManagement, - }) - }, - setDependencyWarnings: async ( - profileId: string, - modId: string, - warnings: string[], - ) => { - await modService.setDependencyWarnings(profileId, modId, warnings) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.modManagement, - }) - }, - clearDependencyWarnings: async (profileId: string, modId: string) => { - await modService.clearDependencyWarnings(profileId, modId) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.modManagement, - }) - }, - deleteProfileState: async (profileId: string) => { - await modService.deleteProfileState(profileId) - if (isDbMode) - await queryClient.invalidateQueries({ - queryKey: dataKeys.modManagement, - }) - }, - }), - [queryClient], - ) -} - -// =========================================================================== -// Convenience: combined mutation for "unmanage game" flow -// =========================================================================== - -export function useUnmanageGame() { - const gameMut = useGameManagementActions() - const settingsMut = useSettingsActions() - const profileMut = useProfileActions() - const modMut = useModManagementActions() - - return useCallback( - async (gameId: string) => { - // Use service to get profiles (works in both Zustand and DB mode) - const profiles = await profileService.list(gameId) - await Promise.all(profiles.map((p) => modMut.deleteProfileState(p.id))) - await profileMut.removeGameProfiles(gameId) - await settingsMut.deletePerGame(gameId) - return gameMut.removeManagedGame(gameId) - }, - [gameMut, settingsMut, profileMut, modMut], - ) -} diff --git a/src/data/index.ts b/src/data/index.ts index 64fe7d1..a0bd04b 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -1,19 +1,11 @@ /** * Data layer entry point. * - * Swap the service implementations in services.ts when migrating from - * Zustand to DB/tRPC. + * All database access goes through tRPC → Electron main process → SQLite. + * Query hooks fetch data; mutation hooks modify data and invalidate caches. */ -// Re-export service singletons (for imperative access in non-hook code) -export { - gameService, - settingsService, - profileService, - modService, -} from "./services" - -// Re-export types & interfaces for convenience +// Types export type { ManagedGame, Profile, @@ -21,29 +13,56 @@ export type { GlobalSettings, GameSettings, EffectiveGameSettings, - IGameService, - ISettingsService, - IProfileService, - IModService, -} from "./interfaces" +} from "./types" + +// Query key registry (for advanced cache control) +export { queryKeys } from "./query-keys" + +// Vanilla tRPC client (for imperative / non-React code) +export { getClient } from "./trpc-client" + +// Query hooks +export { + useGames, + useGlobalSettings, + useGameSettings, + useAllSettings, + useProfiles, + useActiveProfileId, + useProfileModCounts, + useInstalledMods, +} from "./queries" -// Re-export hooks + DataBridge +// Mutation hooks export { - // Bridge (mount once near app root) - DataBridge, - dataKeys, - // Game Management - useGameManagementData, - useGameManagementActions, + // Games + useAddGame, + useRemoveGame, + useSetDefaultGame, + useTouchGame, // Settings - useSettingsData, - useSettingsActions, + useUpdateGlobalSettings, + useUpdateGameSettings, + useResetGameSettings, + useDeleteGameSettings, // Profiles - useProfileData, - useProfileActions, - // Mod Management - useModManagementData, - useModManagementActions, + useEnsureDefaultProfile, + useSetActiveProfile, + useCreateProfile, + useRenameProfile, + useDeleteProfile, + useResetGameProfiles, + useRemoveGameProfiles, + // Mods + useMarkModInstalled, + useMarkModUninstalled, + useUninstallAllMods, + useEnableMod, + useDisableMod, + useToggleMod, + useSetDependencyWarnings, + useClearDependencyWarnings, + useDeleteProfileModState, // Compound useUnmanageGame, -} from "./hooks" +} from "./mutations" diff --git a/src/data/interfaces.ts b/src/data/interfaces.ts deleted file mode 100644 index 838c882..0000000 --- a/src/data/interfaces.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Data service interfaces. - * - * All methods are async – current Zustand implementation wraps synchronous - * calls in Promises; future DB/tRPC implementation will be natively async. - * - * Components should NOT import these interfaces directly – use the hooks - * from `@/data/hooks` instead. - */ - -// --------------------------------------------------------------------------- -// Domain types -// --------------------------------------------------------------------------- - -export type ManagedGame = { - id: string - isDefault: boolean - lastAccessedAt: number | null -} - -export type { Profile } from "@/store/profile-store" - -export type InstalledMod = { - modId: string - installedVersion: string - enabled: boolean - dependencyWarnings: string[] -} - -export type GlobalSettings = { - // Paths - dataFolder: string - steamFolder: string - modDownloadFolder: string - cacheFolder: string - - // Downloads - speedLimitEnabled: boolean - speedLimitBps: number - speedUnit: "Bps" | "bps" - maxConcurrentDownloads: number - downloadCacheEnabled: boolean - preferredThunderstoreCdn: string - autoInstallMods: boolean - - // Mods - enforceDependencyVersions: boolean - - // UI - cardDisplayType: "collapsed" | "expanded" - theme: "dark" | "light" | "system" - language: string - funkyMode: boolean -} - -export type GameSettings = { - gameInstallFolder: string - modDownloadFolder: string - cacheFolder: string - modCacheFolder: string - launchParameters: string - onlineModListCacheDate: number | null -} - -/** - * GameSettings after merging with GlobalSettings defaults. - * Every field is guaranteed non-null. - */ -export type EffectiveGameSettings = GameSettings & { - /** Resolved from per-game → global → default */ - modDownloadFolder: string - /** Resolved from per-game → global → default */ - cacheFolder: string -} - -// --------------------------------------------------------------------------- -// Service interfaces -// --------------------------------------------------------------------------- - -export interface IGameService { - // Queries - list(): Promise - getDefault(): Promise - getRecent(limit?: number): Promise - - // Mutations - add(gameId: string): Promise - remove(gameId: string): Promise - setDefault(gameId: string | null): Promise - touch(gameId: string): Promise -} - -export interface ISettingsService { - // Queries - getGlobal(): Promise - getForGame(gameId: string): Promise - getEffective(gameId: string): Promise - - // Mutations - updateGlobal(updates: Partial): Promise - updateForGame(gameId: string, updates: Partial): Promise - resetForGame(gameId: string): Promise - deleteForGame(gameId: string): Promise -} - -export interface IProfileService { - // Queries - list(gameId: string): Promise - getActive(gameId: string): Promise - - // Mutations - ensureDefault(gameId: string): Promise - create(gameId: string, name: string): Promise - rename(gameId: string, profileId: string, newName: string): Promise - remove( - gameId: string, - profileId: string, - ): Promise<{ deleted: boolean; reason?: string }> - setActive(gameId: string, profileId: string): Promise - reset(gameId: string): Promise - removeAll(gameId: string): Promise -} - -export interface IModService { - // Queries - listInstalled(profileId: string): Promise - isInstalled(profileId: string, modId: string): Promise - isEnabled(profileId: string, modId: string): Promise - getInstalledVersion( - profileId: string, - modId: string, - ): Promise - getDependencyWarnings( - profileId: string, - modId: string, - ): Promise - - // Mutations - install(profileId: string, modId: string, version: string): Promise - uninstall(profileId: string, modId: string): Promise - uninstallAll(profileId: string): Promise - enable(profileId: string, modId: string): Promise - disable(profileId: string, modId: string): Promise - toggle(profileId: string, modId: string): Promise - setDependencyWarnings( - profileId: string, - modId: string, - warnings: string[], - ): Promise - clearDependencyWarnings( - profileId: string, - modId: string, - ): Promise - deleteProfileState(profileId: string): Promise -} diff --git a/src/data/mutations.ts b/src/data/mutations.ts new file mode 100644 index 0000000..26fe73f --- /dev/null +++ b/src/data/mutations.ts @@ -0,0 +1,367 @@ +/** + * Mutation hooks – one hook per operation, each with built-in cache invalidation. + * + * Each hook returns a standard React Query UseMutationResult. + * Components use: `hook.mutate(args)` or `await hook.mutateAsync(args)`. + */ + +import { useCallback } from "react" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { getClient } from "./trpc-client" +import { queryKeys } from "./query-keys" +import type { GlobalSettings, GameSettings } from "./types" + +// --------------------------------------------------------------------------- +// Shared invalidation helper +// --------------------------------------------------------------------------- + +function useInvalidate() { + const qc = useQueryClient() + return useCallback( + (...keys: readonly (readonly unknown[])[]) => + Promise.all( + keys.map((k) => qc.invalidateQueries({ queryKey: k as readonly unknown[] })), + ), + [qc], + ) +} + +// =========================================================================== +// Game Mutations +// =========================================================================== + +export function useAddGame() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: (gameId: string) => + getClient().data.games.add.mutate({ gameId }), + onSuccess: () => + invalidate(queryKeys.games.root, queryKeys.profiles.root, queryKeys.settings.root), + }) +} + +export function useRemoveGame() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: (gameId: string) => + getClient().data.games.remove.mutate({ gameId }), + onSuccess: () => + invalidate( + queryKeys.games.root, + queryKeys.profiles.root, + queryKeys.mods.root, + queryKeys.settings.root, + ), + }) +} + +export function useSetDefaultGame() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: (gameId: string | null) => + getClient().data.games.setDefault.mutate({ gameId }), + onSuccess: () => invalidate(queryKeys.games.root), + }) +} + +export function useTouchGame() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: (gameId: string) => + getClient().data.games.touch.mutate({ gameId }), + onSuccess: () => invalidate(queryKeys.games.root), + }) +} + +// =========================================================================== +// Settings Mutations +// =========================================================================== + +export function useUpdateGlobalSettings() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: (updates: Partial) => + getClient().data.settings.updateGlobal.mutate({ updates }), + onSuccess: () => invalidate(queryKeys.settings.root), + }) +} + +export function useUpdateGameSettings() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: ({ + gameId, + updates, + }: { + gameId: string + updates: Partial + }) => getClient().data.settings.updateForGame.mutate({ gameId, updates }), + onSuccess: () => invalidate(queryKeys.settings.root), + }) +} + +export function useResetGameSettings() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: (gameId: string) => + getClient().data.settings.resetForGame.mutate({ gameId }), + onSuccess: () => invalidate(queryKeys.settings.root), + }) +} + +export function useDeleteGameSettings() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: (gameId: string) => + getClient().data.settings.deleteForGame.mutate({ gameId }), + onSuccess: () => invalidate(queryKeys.settings.root), + }) +} + +// =========================================================================== +// Profile Mutations +// =========================================================================== + +export function useEnsureDefaultProfile() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: (gameId: string) => + getClient().data.profiles.ensureDefault.mutate({ gameId }), + onSuccess: () => invalidate(queryKeys.profiles.root, queryKeys.mods.root), + }) +} + +export function useSetActiveProfile() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: ({ + gameId, + profileId, + }: { + gameId: string + profileId: string + }) => getClient().data.profiles.setActive.mutate({ gameId, profileId }), + onSuccess: () => invalidate(queryKeys.profiles.root), + }) +} + +export function useCreateProfile() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: ({ gameId, name }: { gameId: string; name: string }) => + getClient().data.profiles.create.mutate({ gameId, name }), + onSuccess: () => invalidate(queryKeys.profiles.root, queryKeys.mods.root), + }) +} + +export function useRenameProfile() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: ({ + gameId, + profileId, + newName, + }: { + gameId: string + profileId: string + newName: string + }) => + getClient().data.profiles.rename.mutate({ gameId, profileId, newName }), + onSuccess: () => invalidate(queryKeys.profiles.root), + }) +} + +export function useDeleteProfile() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: ({ + gameId, + profileId, + }: { + gameId: string + profileId: string + }) => getClient().data.profiles.remove.mutate({ gameId, profileId }), + onSuccess: () => invalidate(queryKeys.profiles.root, queryKeys.mods.root), + }) +} + +export function useResetGameProfiles() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: (gameId: string) => + getClient().data.profiles.reset.mutate({ gameId }), + onSuccess: () => invalidate(queryKeys.profiles.root, queryKeys.mods.root), + }) +} + +export function useRemoveGameProfiles() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: (gameId: string) => + getClient().data.profiles.removeAll.mutate({ gameId }), + onSuccess: () => invalidate(queryKeys.profiles.root, queryKeys.mods.root), + }) +} + +// =========================================================================== +// Mod Mutations +// =========================================================================== + +export function useMarkModInstalled() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: ({ + profileId, + modId, + version, + }: { + profileId: string + modId: string + version: string + }) => getClient().data.mods.install.mutate({ profileId, modId, version }), + onSuccess: () => invalidate(queryKeys.mods.root), + }) +} + +export function useMarkModUninstalled() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: ({ + profileId, + modId, + }: { + profileId: string + modId: string + }) => getClient().data.mods.uninstall.mutate({ profileId, modId }), + onSuccess: () => invalidate(queryKeys.mods.root), + }) +} + +export function useUninstallAllMods() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: (profileId: string) => + getClient().data.mods.uninstallAll.mutate({ profileId }), + onSuccess: () => invalidate(queryKeys.mods.root), + }) +} + +export function useEnableMod() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: ({ + profileId, + modId, + }: { + profileId: string + modId: string + }) => getClient().data.mods.enable.mutate({ profileId, modId }), + onSuccess: () => invalidate(queryKeys.mods.root), + }) +} + +export function useDisableMod() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: ({ + profileId, + modId, + }: { + profileId: string + modId: string + }) => getClient().data.mods.disable.mutate({ profileId, modId }), + onSuccess: () => invalidate(queryKeys.mods.root), + }) +} + +export function useToggleMod() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: ({ + profileId, + modId, + }: { + profileId: string + modId: string + }) => getClient().data.mods.toggle.mutate({ profileId, modId }), + onSuccess: () => invalidate(queryKeys.mods.root), + }) +} + +export function useSetDependencyWarnings() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: ({ + profileId, + modId, + warnings, + }: { + profileId: string + modId: string + warnings: string[] + }) => + getClient().data.mods.setDependencyWarnings.mutate({ + profileId, + modId, + warnings, + }), + onSuccess: () => invalidate(queryKeys.mods.root), + }) +} + +export function useClearDependencyWarnings() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: ({ + profileId, + modId, + }: { + profileId: string + modId: string + }) => + getClient().data.mods.clearDependencyWarnings.mutate({ + profileId, + modId, + }), + onSuccess: () => invalidate(queryKeys.mods.root), + }) +} + +export function useDeleteProfileModState() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: (profileId: string) => + getClient().data.mods.deleteProfileState.mutate({ profileId }), + onSuccess: () => invalidate(queryKeys.mods.root), + }) +} + +// =========================================================================== +// Compound: Unmanage Game +// =========================================================================== + +export function useUnmanageGame() { + const invalidate = useInvalidate() + + return useMutation({ + mutationFn: async (gameId: string) => { + const client = getClient() + const profiles = await client.data.profiles.list.query({ gameId }) + await Promise.all( + profiles.map((p) => + client.data.mods.deleteProfileState.mutate({ profileId: p.id }), + ), + ) + await client.data.profiles.removeAll.mutate({ gameId }) + await client.data.settings.deleteForGame.mutate({ gameId }) + return client.data.games.remove.mutate({ gameId }) + }, + onSuccess: () => + invalidate( + queryKeys.games.root, + queryKeys.profiles.root, + queryKeys.mods.root, + queryKeys.settings.root, + ), + }) +} diff --git a/src/data/queries.ts b/src/data/queries.ts new file mode 100644 index 0000000..9b6db20 --- /dev/null +++ b/src/data/queries.ts @@ -0,0 +1,214 @@ +/** + * Query hooks – pure data fetching via useSuspenseQuery. + * + * Each hook returns data directly (no loading/error states – Suspense handles that). + * Parameterized hooks accept `null` and return safe defaults so components + * don't need conditional hook calls. + */ + +import { useMemo, useCallback } from "react" +import { useSuspenseQuery } from "@tanstack/react-query" +import { getClient } from "./trpc-client" +import { queryKeys } from "./query-keys" +import type { GlobalSettings, GameSettings, Profile, InstalledMod } from "./types" + +// --------------------------------------------------------------------------- +// Games +// --------------------------------------------------------------------------- + +export function useGames() { + const { data: games } = useSuspenseQuery({ + queryKey: queryKeys.games.list, + queryFn: () => getClient().data.games.list.query(), + staleTime: Infinity, + }) + + return useMemo( + () => ({ + managedGameIds: games.map((g) => g.id), + recentManagedGameIds: games + .filter((g) => g.lastAccessedAt != null) + .sort((a, b) => (b.lastAccessedAt ?? 0) - (a.lastAccessedAt ?? 0)) + .slice(0, 10) + .map((g) => g.id), + defaultGameId: games.find((g) => g.isDefault)?.id ?? null, + }), + [games], + ) +} + +// --------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------- + +const defaultGameSettings: GameSettings = { + gameInstallFolder: "", + modDownloadFolder: "", + cacheFolder: "", + modCacheFolder: "", + launchParameters: "", + onlineModListCacheDate: null, +} + +export function useGlobalSettings(): GlobalSettings { + const { data } = useSuspenseQuery({ + queryKey: queryKeys.settings.global, + queryFn: () => getClient().data.settings.getGlobal.query(), + staleTime: Infinity, + }) + return data +} + +export function useGameSettings(gameId: string | null): GameSettings { + const { data } = useSuspenseQuery({ + queryKey: gameId ? queryKeys.settings.game(gameId) : queryKeys.settings.gameDisabled, + queryFn: () => + gameId + ? getClient().data.settings.getForGame.query({ gameId }) + : defaultGameSettings, + staleTime: Infinity, + }) + return useMemo(() => ({ ...defaultGameSettings, ...data }), [data]) +} + +/** + * Combined settings hook – returns global + all per-game settings. + * Used by DownloadBridge which needs to sync everything to main process. + */ +export function useAllSettings() { + const { data } = useSuspenseQuery({ + queryKey: queryKeys.settings.all, + queryFn: async () => { + const client = getClient() + const [global, games] = await Promise.all([ + client.data.settings.getGlobal.query(), + client.data.games.list.query(), + ]) + const entries = await Promise.all( + games.map( + async (g) => + [g.id, await client.data.settings.getForGame.query({ gameId: g.id })] as const, + ), + ) + return { + global: global as GlobalSettings, + perGame: Object.fromEntries(entries) as Record, + } + }, + staleTime: Infinity, + }) + + const getPerGame = useCallback( + (gameId: string): GameSettings => ({ + ...defaultGameSettings, + ...data.perGame[gameId], + }), + [data.perGame], + ) + + return { global: data.global, perGame: data.perGame, getPerGame } +} + +// --------------------------------------------------------------------------- +// Profiles +// --------------------------------------------------------------------------- + +export function useProfiles(gameId: string | null): Profile[] { + const { data } = useSuspenseQuery({ + queryKey: gameId ? queryKeys.profiles.list(gameId) : queryKeys.profiles.listDisabled, + queryFn: () => + gameId ? getClient().data.profiles.list.query({ gameId }) : [], + staleTime: Infinity, + }) + return data +} + +export function useActiveProfileId(gameId: string | null): string | null { + const { data } = useSuspenseQuery({ + queryKey: gameId ? queryKeys.profiles.active(gameId) : queryKeys.profiles.activeDisabled, + queryFn: async () => { + if (!gameId) return null + const profile = await getClient().data.profiles.getActive.query({ gameId }) + return profile?.id ?? null + }, + staleTime: Infinity, + }) + return data +} + +// --------------------------------------------------------------------------- +// Installed Mods +// --------------------------------------------------------------------------- + +/** + * Per-profile mod counts for every profile of a game. + * Used by profile dropdowns that show "N mods" next to each profile. + */ +export function useProfileModCounts(gameId: string | null): Record { + const { data } = useSuspenseQuery({ + queryKey: gameId ? queryKeys.mods.counts(gameId) : queryKeys.mods.countsDisabled, + queryFn: async () => { + if (!gameId) return {} as Record + const client = getClient() + const profiles = await client.data.profiles.list.query({ gameId }) + const entries = await Promise.all( + profiles.map(async (p) => { + const mods = await client.data.mods.listInstalled.query({ profileId: p.id }) + return [p.id, mods.length] as const + }), + ) + return Object.fromEntries(entries) as Record + }, + staleTime: Infinity, + }) + return data +} + +// --------------------------------------------------------------------------- +// Installed Mods +// --------------------------------------------------------------------------- + +const EMPTY_MODS: InstalledMod[] = [] + +export function useInstalledMods(profileId: string | null) { + const { data: mods } = useSuspenseQuery({ + queryKey: profileId + ? queryKeys.mods.installed(profileId) + : queryKeys.mods.installedDisabled, + queryFn: () => + profileId + ? getClient().data.mods.listInstalled.query({ profileId }) + : EMPTY_MODS, + staleTime: Infinity, + }) + + const isModInstalled = useCallback( + (modId: string) => mods.some((m) => m.modId === modId), + [mods], + ) + + const isModEnabled = useCallback( + (modId: string) => mods.find((m) => m.modId === modId)?.enabled ?? false, + [mods], + ) + + const getInstalledVersion = useCallback( + (modId: string) => mods.find((m) => m.modId === modId)?.installedVersion, + [mods], + ) + + const getDependencyWarnings = useCallback( + (modId: string) => + mods.find((m) => m.modId === modId)?.dependencyWarnings ?? [], + [mods], + ) + + return { + mods, + modIds: useMemo(() => mods.map((m) => m.modId), [mods]), + isModInstalled, + isModEnabled, + getInstalledVersion, + getDependencyWarnings, + } +} diff --git a/src/data/query-keys.ts b/src/data/query-keys.ts new file mode 100644 index 0000000..160f481 --- /dev/null +++ b/src/data/query-keys.ts @@ -0,0 +1,36 @@ +/** + * Centralized query key definitions. + * + * Invalidating a "root" key invalidates all queries that start with that prefix + * (React Query matches by prefix). + * + * "disabled" keys are used when a hook receives `null` — they must NOT collide + * with any real key so that invalidation of `root` doesn't trigger useless fetches. + */ +export const queryKeys = { + games: { + root: ["games"] as const, + list: ["games", "list"] as const, + }, + settings: { + root: ["settings"] as const, + global: ["settings", "global"] as const, + game: (gameId: string) => ["settings", "game", gameId] as const, + gameDisabled: ["settings", "game", "__disabled__"] as const, + all: ["settings", "all"] as const, + }, + profiles: { + root: ["profiles"] as const, + list: (gameId: string) => ["profiles", "list", gameId] as const, + listDisabled: ["profiles", "list", "__disabled__"] as const, + active: (gameId: string) => ["profiles", "active", gameId] as const, + activeDisabled: ["profiles", "active", "__disabled__"] as const, + }, + mods: { + root: ["mods"] as const, + counts: (gameId: string) => ["mods", "counts", gameId] as const, + countsDisabled: ["mods", "counts", "__disabled__"] as const, + installed: (profileId: string) => ["mods", "installed", profileId] as const, + installedDisabled: ["mods", "installed", "__disabled__"] as const, + }, +} diff --git a/src/data/services.ts b/src/data/services.ts deleted file mode 100644 index 433b298..0000000 --- a/src/data/services.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Service singletons – conditionally backed by Zustand or tRPC/DB based on - * the VITE_DATASOURCE build flag. - * - * Vite replaces `import.meta.env.VITE_DATASOURCE` at compile time, so the - * unused branch is tree-shaken in production builds. - */ - -import { isDbMode } from "./datasource" - -import { - createZustandGameService, - createZustandSettingsService, - createZustandProfileService, - createZustandModService, -} from "./zustand" - -import { - createTRPCGameService, - createTRPCSettingsService, - createTRPCProfileService, - createTRPCModService, -} from "./trpc-services" - -export const gameService = isDbMode - ? createTRPCGameService() - : createZustandGameService() - -export const settingsService = isDbMode - ? createTRPCSettingsService() - : createZustandSettingsService() - -export const profileService = isDbMode - ? createTRPCProfileService() - : createZustandProfileService() - -export const modService = isDbMode - ? createTRPCModService() - : createZustandModService() diff --git a/src/data/trpc-client.ts b/src/data/trpc-client.ts new file mode 100644 index 0000000..09f4138 --- /dev/null +++ b/src/data/trpc-client.ts @@ -0,0 +1,21 @@ +/** + * Lazy vanilla (non-React) tRPC client singleton. + * Used by query/mutation hooks to call the Electron main process over IPC. + */ +import { createVanillaTRPCClient } from "@/lib/trpc" +import type { AppRouter } from "../../electron/trpc/router" +import type { TRPCClient } from "@trpc/client" + +let _client: TRPCClient | null = null + +export function getClient(): TRPCClient { + if (!_client) { + _client = createVanillaTRPCClient() + if (!_client) { + throw new Error( + "electronTRPC is not available. DB mode requires running inside Electron.", + ) + } + } + return _client +} diff --git a/src/data/trpc-services.ts b/src/data/trpc-services.ts deleted file mode 100644 index 3d4fc4e..0000000 --- a/src/data/trpc-services.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * tRPC-backed implementations of the data service interfaces. - * - * Each method delegates to the vanilla tRPC client which communicates with - * the Electron main process over IPC → SQLite via Drizzle ORM. - * - * The client is lazily initialized on first use so the module can be safely - * imported even if `window.electronTRPC` is not yet available. - */ - -import type { - IGameService, - ISettingsService, - IProfileService, - IModService, - ManagedGame, - EffectiveGameSettings, -} from "./interfaces" -import { createVanillaTRPCClient } from "@/lib/trpc" -import type { AppRouter } from "../../electron/trpc/router" -import type { TRPCClient } from "@trpc/client" - -// --------------------------------------------------------------------------- -// Lazy client singleton -// --------------------------------------------------------------------------- - -let _client: TRPCClient | null = null - -function getClient(): TRPCClient { - if (!_client) { - _client = createVanillaTRPCClient() - if (!_client) { - throw new Error( - "VITE_DATASOURCE=db but electronTRPC is not available. " + - "DB mode requires running inside Electron.", - ) - } - } - return _client -} - -// --------------------------------------------------------------------------- -// Game service -// --------------------------------------------------------------------------- - -export function createTRPCGameService(): IGameService { - return { - async list(): Promise { - return getClient().data.games.list.query() - }, - - async getDefault(): Promise { - return getClient().data.games.getDefault.query() - }, - - async getRecent(limit = 10): Promise { - return getClient().data.games.getRecent.query({ limit }) - }, - - async add(gameId: string): Promise { - await getClient().data.games.add.mutate({ gameId }) - }, - - async remove(gameId: string): Promise { - return getClient().data.games.remove.mutate({ gameId }) - }, - - async setDefault(gameId: string | null): Promise { - await getClient().data.games.setDefault.mutate({ gameId }) - }, - - async touch(gameId: string): Promise { - await getClient().data.games.touch.mutate({ gameId }) - }, - } -} - -// --------------------------------------------------------------------------- -// Settings service -// --------------------------------------------------------------------------- - -export function createTRPCSettingsService(): ISettingsService { - return { - async getGlobal() { - return getClient().data.settings.getGlobal.query() - }, - - async getForGame(gameId: string) { - return getClient().data.settings.getForGame.query({ gameId }) - }, - - async getEffective(gameId: string): Promise { - return getClient().data.settings.getEffective.query({ - gameId, - }) as Promise - }, - - async updateGlobal(updates) { - await getClient().data.settings.updateGlobal.mutate({ updates }) - }, - - async updateForGame(gameId, updates) { - await getClient().data.settings.updateForGame.mutate({ gameId, updates }) - }, - - async resetForGame(gameId) { - await getClient().data.settings.resetForGame.mutate({ gameId }) - }, - - async deleteForGame(gameId) { - await getClient().data.settings.deleteForGame.mutate({ gameId }) - }, - } -} - -// --------------------------------------------------------------------------- -// Profile service -// --------------------------------------------------------------------------- - -export function createTRPCProfileService(): IProfileService { - return { - async list(gameId) { - return getClient().data.profiles.list.query({ gameId }) - }, - - async getActive(gameId) { - return getClient().data.profiles.getActive.query({ gameId }) - }, - - async ensureDefault(gameId) { - return getClient().data.profiles.ensureDefault.mutate({ gameId }) - }, - - async create(gameId, name) { - return getClient().data.profiles.create.mutate({ gameId, name }) - }, - - async rename(gameId, profileId, newName) { - await getClient().data.profiles.rename.mutate({ - gameId, - profileId, - newName, - }) - }, - - async remove(gameId, profileId) { - return getClient().data.profiles.remove.mutate({ gameId, profileId }) - }, - - async setActive(gameId, profileId) { - await getClient().data.profiles.setActive.mutate({ gameId, profileId }) - }, - - async reset(gameId) { - return getClient().data.profiles.reset.mutate({ gameId }) - }, - - async removeAll(gameId) { - await getClient().data.profiles.removeAll.mutate({ gameId }) - }, - } -} - -// --------------------------------------------------------------------------- -// Mod service -// --------------------------------------------------------------------------- - -export function createTRPCModService(): IModService { - return { - async listInstalled(profileId) { - return getClient().data.mods.listInstalled.query({ profileId }) - }, - - async isInstalled(profileId, modId) { - return getClient().data.mods.isInstalled.query({ profileId, modId }) - }, - - async isEnabled(profileId, modId) { - return getClient().data.mods.isEnabled.query({ profileId, modId }) - }, - - async getInstalledVersion(profileId, modId) { - return getClient().data.mods.getInstalledVersion.query({ - profileId, - modId, - }) - }, - - async getDependencyWarnings(profileId, modId) { - return getClient().data.mods.getDependencyWarnings.query({ - profileId, - modId, - }) - }, - - async install(profileId, modId, version) { - await getClient().data.mods.install.mutate({ profileId, modId, version }) - }, - - async uninstall(profileId, modId) { - await getClient().data.mods.uninstall.mutate({ profileId, modId }) - }, - - async uninstallAll(profileId) { - return getClient().data.mods.uninstallAll.mutate({ profileId }) - }, - - async enable(profileId, modId) { - await getClient().data.mods.enable.mutate({ profileId, modId }) - }, - - async disable(profileId, modId) { - await getClient().data.mods.disable.mutate({ profileId, modId }) - }, - - async toggle(profileId, modId) { - await getClient().data.mods.toggle.mutate({ profileId, modId }) - }, - - async setDependencyWarnings(profileId, modId, warnings) { - await getClient().data.mods.setDependencyWarnings.mutate({ - profileId, - modId, - warnings, - }) - }, - - async clearDependencyWarnings(profileId, modId) { - await getClient().data.mods.clearDependencyWarnings.mutate({ - profileId, - modId, - }) - }, - - async deleteProfileState(profileId) { - await getClient().data.mods.deleteProfileState.mutate({ profileId }) - }, - } -} diff --git a/src/data/types.ts b/src/data/types.ts new file mode 100644 index 0000000..23afcce --- /dev/null +++ b/src/data/types.ts @@ -0,0 +1,53 @@ +// Domain types – the canonical frontend shapes. + +export type ManagedGame = { + id: string + isDefault: boolean + lastAccessedAt: number | null +} + +export type Profile = { + id: string + name: string + createdAt: number +} + +export type InstalledMod = { + modId: string + installedVersion: string + enabled: boolean + dependencyWarnings: string[] +} + +export type GlobalSettings = { + dataFolder: string + steamFolder: string + modDownloadFolder: string + cacheFolder: string + speedLimitEnabled: boolean + speedLimitBps: number + speedUnit: "Bps" | "bps" + maxConcurrentDownloads: number + downloadCacheEnabled: boolean + preferredThunderstoreCdn: string + autoInstallMods: boolean + enforceDependencyVersions: boolean + cardDisplayType: "collapsed" | "expanded" + theme: "dark" | "light" | "system" + language: string + funkyMode: boolean +} + +export type GameSettings = { + gameInstallFolder: string + modDownloadFolder: string + cacheFolder: string + modCacheFolder: string + launchParameters: string + onlineModListCacheDate: number | null +} + +export type EffectiveGameSettings = GameSettings & { + modDownloadFolder: string + cacheFolder: string +} diff --git a/src/data/zustand.ts b/src/data/zustand.ts deleted file mode 100644 index e295775..0000000 --- a/src/data/zustand.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * Zustand-backed implementations of the data service interfaces. - * - * Every method wraps synchronous Zustand store access in a Promise so the - * call-site contract is always async. When we migrate to a real DB backend - * these functions get replaced – nothing else changes. - */ - -import { useGameManagementStore } from "@/store/game-management-store" -import { useSettingsStore } from "@/store/settings-store" -import { useProfileStore } from "@/store/profile-store" -import { useModManagementStore } from "@/store/mod-management-store" -import type { - IGameService, - ISettingsService, - IProfileService, - IModService, - ManagedGame, - InstalledMod, - EffectiveGameSettings, -} from "./interfaces" - -// --------------------------------------------------------------------------- -// Game service -// --------------------------------------------------------------------------- - -export function createZustandGameService(): IGameService { - const store = () => useGameManagementStore.getState() - - return { - async list() { - const s = store() - return s.managedGameIds.map( - (id): ManagedGame => ({ - id, - isDefault: s.defaultGameId === id, - lastAccessedAt: null, // current store has no per-game timestamp - }), - ) - }, - - async getDefault() { - const s = store() - if (!s.defaultGameId) return null - return { - id: s.defaultGameId, - isDefault: true, - lastAccessedAt: null, - } - }, - - async getRecent(limit = 10) { - const s = store() - return s.recentManagedGameIds - .slice(-limit) - .reverse() - .map( - (id): ManagedGame => ({ - id, - isDefault: s.defaultGameId === id, - lastAccessedAt: null, - }), - ) - }, - - async add(gameId) { - store().addManagedGame(gameId) - }, - - async remove(gameId) { - return store().removeManagedGame(gameId) - }, - - async setDefault(gameId) { - store().setDefaultGameId(gameId) - }, - - async touch(gameId) { - store().appendRecentManagedGame(gameId) - }, - } -} - -// --------------------------------------------------------------------------- -// Settings service -// --------------------------------------------------------------------------- - -export function createZustandSettingsService(): ISettingsService { - const store = () => useSettingsStore.getState() - - return { - async getGlobal() { - return { ...store().global } - }, - - async getForGame(gameId) { - return store().getPerGame(gameId) - }, - - async getEffective(gameId): Promise { - const s = store() - const global = s.global - const perGame = s.getPerGame(gameId) - - return { - ...perGame, - modDownloadFolder: perGame.modDownloadFolder || global.modDownloadFolder, - cacheFolder: perGame.cacheFolder || global.cacheFolder, - } - }, - - async updateGlobal(updates) { - store().updateGlobal(updates) - }, - - async updateForGame(gameId, updates) { - store().updatePerGame(gameId, updates) - }, - - async resetForGame(gameId) { - store().resetPerGame(gameId) - }, - - async deleteForGame(gameId) { - store().deletePerGame(gameId) - }, - } -} - -// --------------------------------------------------------------------------- -// Profile service -// --------------------------------------------------------------------------- - -export function createZustandProfileService(): IProfileService { - const store = () => useProfileStore.getState() - - return { - async list(gameId) { - const s = store() - return s.profilesByGame[gameId] ?? [] - }, - - async getActive(gameId) { - const s = store() - const activeId = s.activeProfileIdByGame[gameId] - if (!activeId) return null - const profiles = s.profilesByGame[gameId] ?? [] - return profiles.find((p) => p.id === activeId) ?? null - }, - - async ensureDefault(gameId) { - return store().ensureDefaultProfile(gameId) - }, - - async create(gameId, name) { - return store().createProfile(gameId, name) - }, - - async rename(gameId, profileId, newName) { - store().renameProfile(gameId, profileId, newName) - }, - - async remove(gameId, profileId) { - return store().deleteProfile(gameId, profileId) - }, - - async setActive(gameId, profileId) { - store().setActiveProfile(gameId, profileId) - }, - - async reset(gameId) { - return store().resetGameProfilesToDefault(gameId) - }, - - async removeAll(gameId) { - store().removeGameProfiles(gameId) - }, - } -} - -// --------------------------------------------------------------------------- -// Mod service -// --------------------------------------------------------------------------- - -export function createZustandModService(): IModService { - const store = () => useModManagementStore.getState() - - return { - async listInstalled(profileId) { - const s = store() - const modIds = s.getInstalledModIds(profileId) - return modIds.map( - (modId): InstalledMod => ({ - modId, - installedVersion: s.getInstalledVersion(profileId, modId) ?? "", - enabled: s.isModEnabled(profileId, modId), - dependencyWarnings: s.getDependencyWarnings(profileId, modId), - }), - ) - }, - - async isInstalled(profileId, modId) { - return store().isModInstalled(profileId, modId) - }, - - async isEnabled(profileId, modId) { - return store().isModEnabled(profileId, modId) - }, - - async getInstalledVersion(profileId, modId) { - return store().getInstalledVersion(profileId, modId) - }, - - async getDependencyWarnings(profileId, modId) { - return store().getDependencyWarnings(profileId, modId) - }, - - async install(profileId, modId, version) { - store().installMod(profileId, modId, version) - }, - - async uninstall(profileId, modId) { - await store().uninstallMod(profileId, modId) - }, - - async uninstallAll(profileId) { - return store().uninstallAllMods(profileId) - }, - - async enable(profileId, modId) { - store().enableMod(profileId, modId) - }, - - async disable(profileId, modId) { - store().disableMod(profileId, modId) - }, - - async toggle(profileId, modId) { - store().toggleMod(profileId, modId) - }, - - async setDependencyWarnings(profileId, modId, warnings) { - store().setDependencyWarnings(profileId, modId, warnings) - }, - - async clearDependencyWarnings(profileId, modId) { - store().clearDependencyWarnings(profileId, modId) - }, - - async deleteProfileState(profileId) { - store().deleteProfileState(profileId) - }, - } -} diff --git a/src/hooks/use-download-actions.ts b/src/hooks/use-download-actions.ts index 682dcb5..53cacdc 100644 --- a/src/hooks/use-download-actions.ts +++ b/src/hooks/use-download-actions.ts @@ -6,7 +6,7 @@ import { useCallback } from "react" import { toast } from "sonner" import { trpc } from "@/lib/trpc" import { useDownloadStore } from "@/store/download-store" -import { useSettingsStore } from "@/store/settings-store" +import { useGlobalSettings } from "@/data" export function useDownloadActions() { const enqueueMutation = trpc.downloads.enqueue.useMutation() @@ -14,6 +14,8 @@ export function useDownloadActions() { const pauseMutation = trpc.downloads.pause.useMutation() const resumeMutation = trpc.downloads.resume.useMutation() + const globalSettings = useGlobalSettings() + const startDownload = useCallback( (params: { gameId: string @@ -25,11 +27,9 @@ export function useDownloadActions() { downloadUrl: string }) => { const downloadId = `${params.gameId}:${params.modId}:${params.modVersion}` - - // Read settings at the moment of action (no effect needed) - const settings = useSettingsStore.getState().global - const preferredCdn = settings.preferredThunderstoreCdn - const downloadCacheEnabled = settings.downloadCacheEnabled + + const preferredCdn = globalSettings.preferredThunderstoreCdn + const downloadCacheEnabled = globalSettings.downloadCacheEnabled // Optimistically add task to store useDownloadStore.getState()._addTask({ @@ -64,7 +64,7 @@ export function useDownloadActions() { ignoreCache: !downloadCacheEnabled, }) }, - [enqueueMutation] + [enqueueMutation, globalSettings] ) const pauseDownload = useCallback( diff --git a/src/hooks/use-mod-actions.ts b/src/hooks/use-mod-actions.ts index 33800bd..c27b932 100644 --- a/src/hooks/use-mod-actions.ts +++ b/src/hooks/use-mod-actions.ts @@ -5,14 +5,17 @@ import { useCallback } from "react" import { toast } from "sonner" import { trpc } from "@/lib/trpc" -import { useModManagementActions } from "@/data" +import { useMarkModUninstalled } from "@/data" import { useAppStore } from "@/store/app-store" export function useModActions() { const uninstallModMutation = trpc.profiles.uninstallMod.useMutation() - const { uninstallMod: markUninstalled } = useModManagementActions() + const markUninstalled = useMarkModUninstalled() const selectedGameId = useAppStore((s) => s.selectedGameId) - + + // Extract stable .mutateAsync reference (React Query guarantees referential stability) + const markUninstalledAsync = markUninstalled.mutateAsync + /** * Uninstalls a mod from a profile * Removes files from profile folder AND updates state @@ -27,13 +30,13 @@ export function useModActions() { toast.error("No game selected") return } - + // If we don't have author/name, we can only update state (legacy behavior) if (!modMeta) { - await markUninstalled(profileId, modId) + await markUninstalledAsync({ profileId, modId }) return } - + try { const result = await uninstallModMutation.mutateAsync({ gameId: selectedGameId, @@ -42,14 +45,14 @@ export function useModActions() { author: modMeta.author, name: modMeta.name, }) - + // Mark as uninstalled in state only after successful file removal - await markUninstalled(profileId, modId) - + await markUninstalledAsync({ profileId, modId }) + toast.success(`${modMeta.name} uninstalled`, { description: `${result.filesRemoved} files removed from profile`, }) - + return result } catch (error) { const message = error instanceof Error ? error.message : "Unknown error" @@ -59,9 +62,9 @@ export function useModActions() { throw error } }, - [uninstallModMutation, markUninstalled, selectedGameId] + [uninstallModMutation, markUninstalledAsync, selectedGameId] ) - + /** * Uninstalls all mods from a profile */ @@ -71,10 +74,10 @@ export function useModActions() { toast.error("No game selected") return } - + let successCount = 0 let failCount = 0 - + for (const mod of mods) { try { await uninstallModMutation.mutateAsync({ @@ -84,26 +87,26 @@ export function useModActions() { author: mod.author, name: mod.name, }) - - await markUninstalled(profileId, mod.id) + + await markUninstalledAsync({ profileId, modId: mod.id }) successCount++ } catch (error) { console.error(`Failed to uninstall ${mod.name}:`, error) failCount++ } } - + if (successCount > 0) { toast.success(`Uninstalled ${successCount} mods`, { description: failCount > 0 ? `${failCount} failed` : undefined, }) } - + if (failCount > 0 && successCount === 0) { toast.error(`Failed to uninstall ${failCount} mods`) } }, - [uninstallModMutation, markUninstalled, selectedGameId] + [uninstallModMutation, markUninstalledAsync, selectedGameId] ) return { diff --git a/src/hooks/use-mod-installer.ts b/src/hooks/use-mod-installer.ts index 01af61a..f01e61f 100644 --- a/src/hooks/use-mod-installer.ts +++ b/src/hooks/use-mod-installer.ts @@ -7,14 +7,19 @@ import { toast } from "sonner" import { trpc } from "@/lib/trpc" import { useDownloadStore } from "@/store/download-store" import { useDownloadActions } from "./use-download-actions" -import { useModManagementActions } from "@/data" +import { useMarkModInstalled, useMarkModUninstalled } from "@/data" export function useModInstaller() { const { startDownload } = useDownloadActions() const installModMutation = trpc.profiles.installMod.useMutation() const uninstallModMutation = trpc.profiles.uninstallMod.useMutation() - const { installMod: markInstalled, uninstallMod: markUninstalled } = useModManagementActions() - + const markInstalled = useMarkModInstalled() + const markUninstalled = useMarkModUninstalled() + + // Extract stable references (React Query's .mutate/.mutateAsync are referentially stable) + const markInstalledFire = markInstalled.mutate + const markUninstalledAsync = markUninstalled.mutateAsync + /** * Installs a downloaded mod to a profile * Requires the mod to already be downloaded (extractedPath must exist) @@ -39,14 +44,14 @@ export function useModInstaller() { version: params.version, extractedPath: params.extractedPath, }) - + // Mark as installed in state only after successful file copy - markInstalled(params.profileId, params.modId, params.version) - + markInstalledFire({ profileId: params.profileId, modId: params.modId, version: params.version }) + toast.success(`${params.name} installed`, { description: `${result.filesCopied} files copied to profile`, }) - + return result } catch (error) { const message = error instanceof Error ? error.message : "Unknown error" @@ -56,9 +61,9 @@ export function useModInstaller() { throw error } }, - [installModMutation, markInstalled] + [installModMutation, markInstalledFire] ) - + /** * Uninstalls a mod from a profile * Removes files from profile folder @@ -79,14 +84,14 @@ export function useModInstaller() { author: params.author, name: params.name, }) - + // Mark as uninstalled in state only after successful file removal - await markUninstalled(params.profileId, params.modId) - + await markUninstalledAsync({ profileId: params.profileId, modId: params.modId }) + toast.success(`${params.name} uninstalled`, { description: `${result.filesRemoved} files removed from profile`, }) - + return result } catch (error) { const message = error instanceof Error ? error.message : "Unknown error" @@ -96,7 +101,7 @@ export function useModInstaller() { throw error } }, - [uninstallModMutation, markUninstalled] + [uninstallModMutation, markUninstalledAsync] ) /** diff --git a/src/main.tsx b/src/main.tsx index daed72c..48027c3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,7 +7,6 @@ import "./index.css" import "./lib/i18n" import { createRouter } from "./router" import { AppBootstrap } from "./components/app-bootstrap" -import { DataBridge } from "./data" import { queryClient } from "./lib/query-client" import { TRPCProvider } from "./lib/trpc" @@ -35,7 +34,6 @@ createRoot(rootEl).render( - {import.meta.env.DEV && ( diff --git a/src/store/game-management-store.ts b/src/store/game-management-store.ts deleted file mode 100644 index 4f4b5e2..0000000 --- a/src/store/game-management-store.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { create } from "zustand" -import { persist } from "zustand/middleware" - -type GameManagementState = { - // All games the user has added to manage - managedGameIds: string[] - - // Recently selected games (for quick access) - recentManagedGameIds: string[] - - // Default game to load on app start - defaultGameId: string | null - - // Actions - addManagedGame: (gameId: string) => void - appendRecentManagedGame: (gameId: string) => void - setDefaultGameId: (gameId: string | null) => void - removeManagedGame: (gameId: string) => string | null -} - -export const useGameManagementStore = create()( - persist( - (set, get) => ({ - // Initial state - managedGameIds: [], - recentManagedGameIds: [], - defaultGameId: null, - - // Actions - addManagedGame: (gameId) => - set((state) => { - if (state.managedGameIds.includes(gameId)) { - return state - } - return { - managedGameIds: [...state.managedGameIds, gameId], - } - }), - - appendRecentManagedGame: (gameId) => - set((state) => { - // Remove existing occurrence - const filtered = state.recentManagedGameIds.filter((id) => id !== gameId) - // Append to end - const updated = [...filtered, gameId] - // Cap to 10 - const capped = updated.slice(-10) - return { - recentManagedGameIds: capped, - } - }), - - setDefaultGameId: (gameId) => - set({ - defaultGameId: gameId, - }), - - removeManagedGame: (gameId) => { - const state = get() - - // Remove from managed games - const updatedManagedGameIds = state.managedGameIds.filter((id) => id !== gameId) - - // Remove from recent games - const updatedRecentGameIds = state.recentManagedGameIds.filter((id) => id !== gameId) - - // Determine next default - let nextDefaultGameId: string | null = null - if (state.defaultGameId === gameId) { - // Pick the most recent remaining game, or the last managed game - const candidatesFromRecent = updatedRecentGameIds.filter((id) => updatedManagedGameIds.includes(id)) - if (candidatesFromRecent.length > 0) { - nextDefaultGameId = candidatesFromRecent[candidatesFromRecent.length - 1] - } else if (updatedManagedGameIds.length > 0) { - nextDefaultGameId = updatedManagedGameIds[updatedManagedGameIds.length - 1] - } - } else { - nextDefaultGameId = state.defaultGameId - } - - set({ - managedGameIds: updatedManagedGameIds, - recentManagedGameIds: updatedRecentGameIds, - defaultGameId: nextDefaultGameId, - }) - - return nextDefaultGameId - }, - }), - { - name: "r2modman.gameManagement", - } - ) -) diff --git a/src/store/mod-management-store.ts b/src/store/mod-management-store.ts deleted file mode 100644 index bab7651..0000000 --- a/src/store/mod-management-store.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { create } from "zustand" -import { persist } from "zustand/middleware" -import { toast } from "sonner" - -type ModManagementState = { - // Map of profileId -> Set of installed mod IDs - installedModsByProfile: Record> - // Map of profileId -> Set of enabled mod IDs - enabledModsByProfile: Record> - // Map of profileId -> Map of modId -> installed version - installedModVersionsByProfile: Record> - // Map of profileId -> Map of modId -> unresolved dependency strings - dependencyWarningsByProfile: Record> - // Set of mod IDs currently being uninstalled - uninstallingMods: Set - - // Actions - installMod: (profileId: string, modId: string, version: string) => void - uninstallMod: (profileId: string, modId: string) => Promise - uninstallAllMods: (profileId: string) => number - isModInstalled: (profileId: string, modId: string) => boolean - getInstalledModIds: (profileId: string) => string[] - getInstalledVersion: (profileId: string, modId: string) => string | undefined - - enableMod: (profileId: string, modId: string) => void - disableMod: (profileId: string, modId: string) => void - toggleMod: (profileId: string, modId: string) => void - isModEnabled: (profileId: string, modId: string) => boolean - - setDependencyWarnings: (profileId: string, modId: string, warnings: string[]) => void - clearDependencyWarnings: (profileId: string, modId: string) => void - getDependencyWarnings: (profileId: string, modId: string) => string[] - - deleteProfileState: (profileId: string) => void -} - -export const useModManagementStore = create()( - persist( - (set, get) => ({ - // Initial state - empty (no seeded mods) - installedModsByProfile: {}, - enabledModsByProfile: {}, - installedModVersionsByProfile: {}, - dependencyWarningsByProfile: {}, - uninstallingMods: new Set(), - - installMod: (profileId, modId, version) => { - const currentInstalled = get().installedModsByProfile - const currentEnabled = get().enabledModsByProfile - const currentVersions = get().installedModVersionsByProfile - const installedSet = currentInstalled[profileId] || new Set() - const enabledSet = currentEnabled[profileId] || new Set() - const versionsMap = currentVersions[profileId] || {} - - installedSet.add(modId) - enabledSet.add(modId) // Default to enabled when installed - versionsMap[modId] = version - - set({ - installedModsByProfile: { - ...currentInstalled, - [profileId]: new Set(installedSet), - }, - enabledModsByProfile: { - ...currentEnabled, - [profileId]: new Set(enabledSet), - }, - installedModVersionsByProfile: { - ...currentVersions, - [profileId]: { ...versionsMap }, - }, - }) - }, - - uninstallMod: async (profileId, modId) => { - // Add to uninstalling set - const currentUninstalling = get().uninstallingMods - currentUninstalling.add(modId) - set({ - uninstallingMods: new Set(currentUninstalling), - }) - - // Simulate uninstall delay (1-2 seconds) - await new Promise((resolve) => setTimeout(resolve, 1000 + Math.random() * 1000)) - - // Remove from installed and enabled - const currentInstalled = get().installedModsByProfile - const currentEnabled = get().enabledModsByProfile - const currentVersions = get().installedModVersionsByProfile - const currentWarnings = get().dependencyWarningsByProfile - const installedSet = currentInstalled[profileId] || new Set() - const enabledSet = currentEnabled[profileId] || new Set() - const versionsMap = { ...(currentVersions[profileId] || {}) } - const warningsMap = { ...(currentWarnings[profileId] || {}) } - - installedSet.delete(modId) - enabledSet.delete(modId) - delete versionsMap[modId] - delete warningsMap[modId] - - // Remove from uninstalling set - const updatedUninstalling = get().uninstallingMods - updatedUninstalling.delete(modId) - - set({ - installedModsByProfile: { - ...currentInstalled, - [profileId]: new Set(installedSet), - }, - enabledModsByProfile: { - ...currentEnabled, - [profileId]: new Set(enabledSet), - }, - installedModVersionsByProfile: { - ...currentVersions, - [profileId]: versionsMap, - }, - dependencyWarningsByProfile: { - ...currentWarnings, - [profileId]: warningsMap, - }, - uninstallingMods: new Set(updatedUninstalling), - }) - - // Show success toast - toast.success("Uninstalled successfully") - }, - - uninstallAllMods: (profileId) => { - const currentInstalled = get().installedModsByProfile - const currentEnabled = get().enabledModsByProfile - const currentVersions = get().installedModVersionsByProfile - const currentWarnings = get().dependencyWarningsByProfile - - // Get count before clearing - const installedSet = currentInstalled[profileId] || new Set() - const modCount = installedSet.size - - // Clear all mods for this profile - set({ - installedModsByProfile: { - ...currentInstalled, - [profileId]: new Set(), - }, - enabledModsByProfile: { - ...currentEnabled, - [profileId]: new Set(), - }, - installedModVersionsByProfile: { - ...currentVersions, - [profileId]: {}, - }, - dependencyWarningsByProfile: { - ...currentWarnings, - [profileId]: {}, - }, - }) - - return modCount - }, - - isModInstalled: (profileId, modId) => { - const profileSet = get().installedModsByProfile[profileId] - return profileSet ? profileSet.has(modId) : false - }, - - getInstalledModIds: (profileId) => { - const profileSet = get().installedModsByProfile[profileId] - return profileSet ? Array.from(profileSet) : [] - }, - - getInstalledVersion: (profileId, modId) => { - const versionsMap = get().installedModVersionsByProfile[profileId] - return versionsMap ? versionsMap[modId] : undefined - }, - - enableMod: (profileId, modId) => { - const current = get().enabledModsByProfile - const profileSet = current[profileId] || new Set() - profileSet.add(modId) - set({ - enabledModsByProfile: { - ...current, - [profileId]: new Set(profileSet), - }, - }) - }, - - disableMod: (profileId, modId) => { - const current = get().enabledModsByProfile - const profileSet = current[profileId] || new Set() - profileSet.delete(modId) - set({ - enabledModsByProfile: { - ...current, - [profileId]: new Set(profileSet), - }, - }) - }, - - toggleMod: (profileId, modId) => { - const isEnabled = get().isModEnabled(profileId, modId) - if (isEnabled) { - get().disableMod(profileId, modId) - } else { - get().enableMod(profileId, modId) - } - }, - - isModEnabled: (profileId, modId) => { - const profileSet = get().enabledModsByProfile[profileId] - return profileSet ? profileSet.has(modId) : false - }, - - setDependencyWarnings: (profileId, modId, warnings) => { - const current = get().dependencyWarningsByProfile - const profileWarnings = current[profileId] || {} - set({ - dependencyWarningsByProfile: { - ...current, - [profileId]: { - ...profileWarnings, - [modId]: warnings, - }, - }, - }) - }, - - clearDependencyWarnings: (profileId, modId) => { - const current = get().dependencyWarningsByProfile - const profileWarnings = { ...(current[profileId] || {}) } - delete profileWarnings[modId] - set({ - dependencyWarningsByProfile: { - ...current, - [profileId]: profileWarnings, - }, - }) - }, - - getDependencyWarnings: (profileId, modId) => { - const profileWarnings = get().dependencyWarningsByProfile[profileId] - return profileWarnings ? profileWarnings[modId] || [] : [] - }, - - deleteProfileState: (profileId) => { - const currentInstalled = get().installedModsByProfile - const currentEnabled = get().enabledModsByProfile - const currentVersions = get().installedModVersionsByProfile - const currentWarnings = get().dependencyWarningsByProfile - - // Delete all state for this profile - const newInstalled = { ...currentInstalled } - const newEnabled = { ...currentEnabled } - const newVersions = { ...currentVersions } - const newWarnings = { ...currentWarnings } - - delete newInstalled[profileId] - delete newEnabled[profileId] - delete newVersions[profileId] - delete newWarnings[profileId] - - set({ - installedModsByProfile: newInstalled, - enabledModsByProfile: newEnabled, - installedModVersionsByProfile: newVersions, - dependencyWarningsByProfile: newWarnings, - }) - }, - }), - { - name: "mod-management-storage.v2", - // Custom serialization for Set - partialize: (state) => ({ - installedModsByProfile: Object.fromEntries( - Object.entries(state.installedModsByProfile).map(([profileId, modSet]) => [ - profileId, - Array.from(modSet), - ]) - ), - enabledModsByProfile: Object.fromEntries( - Object.entries(state.enabledModsByProfile).map(([profileId, modSet]) => [ - profileId, - Array.from(modSet), - ]) - ), - installedModVersionsByProfile: state.installedModVersionsByProfile, - dependencyWarningsByProfile: state.dependencyWarningsByProfile, - }), - // Custom deserialization for Set - merge: (persistedState: any, currentState) => ({ - ...currentState, - installedModsByProfile: Object.fromEntries( - Object.entries(persistedState.installedModsByProfile || {}).map( - ([profileId, modIds]: [string, any]) => [profileId, new Set(modIds)] - ) - ), - enabledModsByProfile: Object.fromEntries( - Object.entries(persistedState.enabledModsByProfile || {}).map( - ([profileId, modIds]: [string, any]) => [profileId, new Set(modIds)] - ) - ), - installedModVersionsByProfile: persistedState.installedModVersionsByProfile || {}, - dependencyWarningsByProfile: persistedState.dependencyWarningsByProfile || {}, - }), - } - ) -) diff --git a/src/store/profile-store.ts b/src/store/profile-store.ts deleted file mode 100644 index 6bd8e1b..0000000 --- a/src/store/profile-store.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { create } from "zustand" -import { persist } from "zustand/middleware" - -export type Profile = { - id: string - name: string - createdAt: number -} - -type ProfileState = { - // Profiles per game - profilesByGame: Record - // Active profile per game - activeProfileIdByGame: Record - - // Actions - ensureDefaultProfile: (gameId: string) => string - setActiveProfile: (gameId: string, profileId: string) => void - createProfile: (gameId: string, name: string) => Profile - renameProfile: (gameId: string, profileId: string, newName: string) => void - deleteProfile: (gameId: string, profileId: string) => { deleted: boolean; reason?: string } - resetGameProfilesToDefault: (gameId: string) => string - removeGameProfiles: (gameId: string) => void -} - -function getDefaultProfileId(gameId: string): string { - return `${gameId}-default` -} - -export const useProfileStore = create()( - persist( - (set, get) => ({ - // Initial state - empty (profiles only created when game has valid path) - profilesByGame: {}, - activeProfileIdByGame: {}, - - // Actions - ensureDefaultProfile: (gameId) => { - const state = get() - const defaultProfileId = getDefaultProfileId(gameId) - const profiles = state.profilesByGame[gameId] || [] - const hasActiveProfile = !!state.activeProfileIdByGame[gameId] - - // Check if default profile already exists - const defaultExists = profiles.some(p => p.id === defaultProfileId) - - // Only update state if something needs to change - if (!defaultExists || !hasActiveProfile) { - set((state) => { - const updates: Partial = {} - - // Add default profile if it doesn't exist - if (!defaultExists) { - const defaultProfile: Profile = { - id: defaultProfileId, - name: "Default", - createdAt: Date.now(), - } - - updates.profilesByGame = { - ...state.profilesByGame, - [gameId]: [...(state.profilesByGame[gameId] || []), defaultProfile], - } - } - - // Set as active if no active profile for this game - if (!state.activeProfileIdByGame[gameId]) { - updates.activeProfileIdByGame = { - ...state.activeProfileIdByGame, - [gameId]: defaultProfileId, - } - } - - return updates - }) - } - - return defaultProfileId - }, - - setActiveProfile: (gameId, profileId) => - set((state) => ({ - activeProfileIdByGame: { - ...state.activeProfileIdByGame, - [gameId]: profileId, - }, - })), - - createProfile: (gameId, name) => { - const newProfile: Profile = { - id: `${gameId}-${crypto.randomUUID()}`, - name, - createdAt: Date.now(), - } - - set((state) => ({ - profilesByGame: { - ...state.profilesByGame, - [gameId]: [...(state.profilesByGame[gameId] || []), newProfile], - }, - activeProfileIdByGame: { - ...state.activeProfileIdByGame, - [gameId]: newProfile.id, - }, - })) - - return newProfile - }, - - renameProfile: (gameId, profileId, newName) => { - set((state) => { - const profiles = state.profilesByGame[gameId] || [] - const updatedProfiles = profiles.map(p => - p.id === profileId ? { ...p, name: newName } : p - ) - - return { - profilesByGame: { - ...state.profilesByGame, - [gameId]: updatedProfiles, - }, - } - }) - }, - - deleteProfile: (gameId, profileId) => { - const state = get() - const defaultProfileId = getDefaultProfileId(gameId) - - // Block deletion of default profile - if (profileId === defaultProfileId) { - return { deleted: false, reason: "default" } - } - - const profiles = state.profilesByGame[gameId] || [] - const deletedIndex = profiles.findIndex(p => p.id === profileId) - const updatedProfiles = profiles.filter(p => p.id !== profileId) - - // Determine next active profile if we're deleting the active one - let nextActiveProfileId: string | undefined - if (state.activeProfileIdByGame[gameId] === profileId) { - // Try previous profile (index - 1) - if (deletedIndex > 0 && updatedProfiles[deletedIndex - 1]) { - nextActiveProfileId = updatedProfiles[deletedIndex - 1].id - } - // Else try next profile (same index after deletion) - else if (updatedProfiles[deletedIndex]) { - nextActiveProfileId = updatedProfiles[deletedIndex].id - } - // Else fall back to default - else { - nextActiveProfileId = defaultProfileId - } - } - - set((state) => ({ - profilesByGame: { - ...state.profilesByGame, - [gameId]: updatedProfiles, - }, - ...(nextActiveProfileId && { - activeProfileIdByGame: { - ...state.activeProfileIdByGame, - [gameId]: nextActiveProfileId, - }, - }), - })) - - // Ensure default profile exists if we're switching to it - if (nextActiveProfileId === defaultProfileId) { - get().ensureDefaultProfile(gameId) - } - - return { deleted: true } - }, - - resetGameProfilesToDefault: (gameId) => { - const defaultProfileId = getDefaultProfileId(gameId) - const defaultProfile: Profile = { - id: defaultProfileId, - name: "Default", - createdAt: Date.now(), - } - - set((state) => ({ - profilesByGame: { - ...state.profilesByGame, - [gameId]: [defaultProfile], - }, - activeProfileIdByGame: { - ...state.activeProfileIdByGame, - [gameId]: defaultProfileId, - }, - })) - - return defaultProfileId - }, - - removeGameProfiles: (gameId) => { - set((state) => { - const { [gameId]: _removed, ...remainingProfiles } = state.profilesByGame - const { [gameId]: _removedActive, ...remainingActive } = state.activeProfileIdByGame - - return { - profilesByGame: remainingProfiles, - activeProfileIdByGame: remainingActive, - } - }) - }, - }), - { - name: "r2modman.profiles", - } - ) -) diff --git a/src/store/settings-store.ts b/src/store/settings-store.ts deleted file mode 100644 index 07350ad..0000000 --- a/src/store/settings-store.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { create } from "zustand" -import { persist } from "zustand/middleware" - -type GlobalSettings = { - // Locations - dataFolder: string - steamFolder: string - modDownloadFolder: string - cacheFolder: string - - // Downloads - speedLimitEnabled: boolean - speedLimitBps: number // Internal: bytes per second - speedUnit: "Bps" | "bps" // Display unit preference - maxConcurrentDownloads: number - downloadCacheEnabled: boolean - preferredThunderstoreCdn: string - autoInstallMods: boolean // Auto-install mods after download completes - - // Mods - enforceDependencyVersions: boolean - - // UI / Other - cardDisplayType: "collapsed" | "expanded" - theme: "dark" | "light" | "system" - language: string - funkyMode: boolean -} - -type PerGameSettings = { - gameInstallFolder: string - modDownloadFolder: string - cacheFolder: string - modCacheFolder: string - launchParameters: string - onlineModListCacheDate: number | null -} - -type SettingsState = { - global: GlobalSettings - perGame: Record - - // Actions - updateGlobal: (updates: Partial) => void - updatePerGame: (gameId: string, updates: Partial) => void - getPerGame: (gameId: string) => PerGameSettings - resetPerGame: (gameId: string) => void - deletePerGame: (gameId: string) => void -} - -const defaultGlobalSettings: GlobalSettings = { - dataFolder: "", - steamFolder: "", - modDownloadFolder: "", - cacheFolder: "", - speedLimitEnabled: false, - speedLimitBps: 0, - speedUnit: "Bps", - maxConcurrentDownloads: 3, - downloadCacheEnabled: true, - preferredThunderstoreCdn: "main", - autoInstallMods: true, // Default to auto-install enabled - enforceDependencyVersions: true, - cardDisplayType: "collapsed", - theme: "dark", - language: "en", - funkyMode: false, -} - -const defaultPerGameSettings: PerGameSettings = { - gameInstallFolder: "", - modDownloadFolder: "", - cacheFolder: "", - modCacheFolder: "", - launchParameters: "", - onlineModListCacheDate: null, -} - -export const useSettingsStore = create()( - persist( - (set, get) => ({ - global: defaultGlobalSettings, - perGame: {}, - - updateGlobal: (updates) => - set((state) => ({ - global: { ...state.global, ...updates }, - })), - - updatePerGame: (gameId, updates) => - set((state) => ({ - perGame: { - ...state.perGame, - [gameId]: { - ...defaultPerGameSettings, - ...state.perGame[gameId], - ...updates, - }, - }, - })), - - getPerGame: (gameId) => { - const state = get() - return { - ...defaultPerGameSettings, - ...state.perGame[gameId], - } - }, - - resetPerGame: (gameId) => - set((state) => ({ - perGame: { - ...state.perGame, - [gameId]: { ...defaultPerGameSettings }, - }, - })), - - deletePerGame: (gameId) => - set((state) => { - const nextPerGame = { ...state.perGame } - delete nextPerGame[gameId] - return { perGame: nextPerGame } - }), - }), - { - name: "r2modman.settings", - } - ) -)