diff --git a/src-tauri/src/commands/migration.rs b/src-tauri/src/commands/migration.rs new file mode 100644 index 0000000..254d586 --- /dev/null +++ b/src-tauri/src/commands/migration.rs @@ -0,0 +1,38 @@ +use crate::error::{AppResult, IpcResult, MutexResultExt}; +use crate::mods::{BulkInstallResult, CslolModInfo, ModLibraryState}; +use crate::patcher::PatcherState; +use crate::state::SettingsState; +use std::path::PathBuf; +use tauri::State; + +use super::mods::reject_if_patcher_running; + +/// Scan a cslol-manager directory for importable mods. +#[tauri::command] +pub fn scan_cslol_mods(directory: String) -> IpcResult> { + let result: AppResult> = + crate::mods::scan_cslol_directory(&PathBuf::from(directory)); + result.into() +} + +/// Import selected mods from a cslol-manager installation. +#[tauri::command] +pub fn import_cslol_mods( + directory: String, + selected_folders: Vec, + library: State, + settings: State, + patcher: State, +) -> IpcResult { + let result: AppResult = (|| { + reject_if_patcher_running(&patcher)?; + let settings = settings.0.lock().mutex_err()?.clone(); + crate::mods::import_cslol_mods( + &library.0, + &settings, + &PathBuf::from(&directory), + &selected_folders, + ) + })(); + result.into() +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f028b18..3de988c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -17,6 +17,7 @@ //! See `docs/ERROR_HANDLING.md` for details. mod app; +mod migration; mod mods; mod patcher; mod profiles; @@ -25,6 +26,7 @@ mod shell; mod workshop; pub use app::*; +pub use migration::*; pub use mods::*; pub use patcher::*; pub use profiles::*; diff --git a/src-tauri/src/commands/mods.rs b/src-tauri/src/commands/mods.rs index baccc46..be0e951 100644 --- a/src-tauri/src/commands/mods.rs +++ b/src-tauri/src/commands/mods.rs @@ -136,7 +136,7 @@ pub fn get_storage_directory( } /// Reject the operation if the patcher is currently running. -fn reject_if_patcher_running(patcher: &State) -> AppResult<()> { +pub(super) fn reject_if_patcher_running(patcher: &State) -> AppResult<()> { let state = patcher.0.lock().mutex_err()?; if state.is_running() { return Err(AppError::PatcherRunning); diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 9dfe95f..abf46c4 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -38,6 +38,8 @@ pub enum ErrorCode { Wad, /// Operation blocked because the patcher is running PatcherRunning, + /// ZIP error + Zip, } /// Structured error response sent over IPC. @@ -190,6 +192,9 @@ pub enum AppError { #[error("Cannot modify mods while the patcher is running")] PatcherRunning, + + #[error("ZIP error: {0}")] + ZipError(#[from] zip::result::ZipError), } impl From for AppErrorResponse { @@ -256,6 +261,8 @@ impl From for AppErrorResponse { ErrorCode::PatcherRunning, "Stop the patcher before modifying mods", ), + + AppError::ZipError(e) => AppErrorResponse::new(ErrorCode::Zip, e.to_string()), } } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 16b015b..eb8258d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -130,6 +130,9 @@ fn main() { commands::get_mod_thumbnail, commands::get_storage_directory, commands::reorder_mods, + // Migration + commands::scan_cslol_mods, + commands::import_cslol_mods, // Patcher commands::start_patcher, commands::stop_patcher, diff --git a/src-tauri/src/mods/migration.rs b/src-tauri/src/mods/migration.rs new file mode 100644 index 0000000..15588e0 --- /dev/null +++ b/src-tauri/src/mods/migration.rs @@ -0,0 +1,171 @@ +use crate::error::{AppError, AppResult}; +use crate::state::Settings; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::{Read, Write}; +use std::path::Path; + +use super::{BulkInstallResult, ModLibrary}; + +/// Metadata for a discovered cslol-manager mod, shown in the UI selection step. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CslolModInfo { + pub folder_name: String, + pub name: String, + pub author: String, + pub version: String, + pub description: String, +} + +/// Scan a cslol-manager directory for installed mods. +/// +/// Expects `dir` to contain an `installed/` subdirectory where each child folder +/// has the standard fantome layout (`META/info.json`, `WAD/`, etc.). +pub fn scan_cslol_directory(dir: &Path) -> AppResult> { + let installed_dir = dir.join("installed"); + if !installed_dir.is_dir() { + return Err(AppError::InvalidPath(format!( + "No 'installed' directory found in {}", + dir.display() + ))); + } + + let mut mods = Vec::new(); + + for entry in fs::read_dir(&installed_dir)? { + let entry = entry?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let folder_name = match path.file_name().and_then(|n| n.to_str()) { + Some(n) => n.to_string(), + None => continue, + }; + + let info_path = path.join("META").join("info.json"); + if !info_path.exists() { + tracing::warn!( + "Skipping cslol mod '{}': missing META/info.json", + folder_name + ); + continue; + } + + match read_cslol_info(&info_path) { + Ok(info) => { + mods.push(CslolModInfo { + folder_name, + name: info.name, + author: info.author, + version: info.version, + description: info.description, + }); + } + Err(e) => { + tracing::warn!("Skipping cslol mod '{}': {}", folder_name, e); + } + } + } + + mods.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + Ok(mods) +} + +/// Import selected cslol-manager mods by creating temporary `.fantome` ZIPs +/// and piping them through the existing install pipeline. +pub fn import_cslol_mods( + library: &ModLibrary, + settings: &Settings, + cslol_dir: &Path, + folders: &[String], +) -> AppResult { + let installed_dir = cslol_dir.join("installed"); + let temp_dir = std::env::temp_dir().join("ltk-migration"); + fs::create_dir_all(&temp_dir)?; + + let mut temp_paths: Vec = Vec::new(); + + for folder in folders { + let mod_dir = installed_dir.join(folder); + if !mod_dir.is_dir() { + tracing::warn!("Skipping missing folder: {}", folder); + continue; + } + + let output_path = temp_dir.join(format!("{}.fantome", folder)); + match create_fantome_zip(&mod_dir, &output_path) { + Ok(()) => { + temp_paths.push(output_path.display().to_string()); + } + Err(e) => { + tracing::warn!("Failed to create fantome zip for '{}': {}", folder, e); + } + } + } + + let result = library.install_mods_from_packages(settings, &temp_paths); + + // Clean up temp files + for path in &temp_paths { + let _ = fs::remove_file(path); + } + let _ = fs::remove_dir(&temp_dir); + + result +} + +/// Create a `.fantome` ZIP archive from a cslol mod directory. +fn create_fantome_zip(mod_dir: &Path, output_path: &Path) -> AppResult<()> { + let file = fs::File::create(output_path)?; + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + + add_dir_to_zip(&mut zip, mod_dir, mod_dir, options)?; + zip.finish().map_err(AppError::ZipError)?; + + Ok(()) +} + +/// Recursively add directory contents to a ZIP archive. +fn add_dir_to_zip( + zip: &mut zip::ZipWriter, + base_dir: &Path, + current_dir: &Path, + options: zip::write::SimpleFileOptions, +) -> AppResult<()> { + for entry in fs::read_dir(current_dir)? { + let entry = entry?; + let path = entry.path(); + + let relative = path + .strip_prefix(base_dir) + .map_err(|e| AppError::Other(e.to_string()))?; + let name = relative.to_string_lossy().replace('\\', "/"); + + if path.is_dir() { + zip.add_directory(format!("{}/", name), options) + .map_err(AppError::ZipError)?; + add_dir_to_zip(zip, base_dir, &path, options)?; + } else { + zip.start_file(&name, options).map_err(AppError::ZipError)?; + let mut f = fs::File::open(&path)?; + let mut buffer = Vec::new(); + f.read_to_end(&mut buffer)?; + zip.write_all(&buffer)?; + } + } + + Ok(()) +} + +/// Read and parse a cslol-manager `META/info.json` file. +fn read_cslol_info(path: &Path) -> AppResult { + let content = fs::read_to_string(path)?; + let content = content.trim_start_matches('\u{feff}').trim(); + + serde_json::from_str(content).map_err(AppError::Serialization) +} diff --git a/src-tauri/src/mods/mod.rs b/src-tauri/src/mods/mod.rs index ad720ce..fc5bbfb 100644 --- a/src-tauri/src/mods/mod.rs +++ b/src-tauri/src/mods/mod.rs @@ -1,7 +1,10 @@ mod inspect; mod library; +mod migration; mod profiles; +pub use migration::*; + pub use inspect::{inspect_modpkg_file, ModpkgInfo}; use crate::error::{AppError, AppResult}; diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index a6354a2..c3e93a0 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -125,4 +125,7 @@ pub struct Settings { /// Whether to patch TFT game files (Map22.wad.client). Default: false. #[serde(default)] pub patch_tft: bool, + /// Whether the user has dismissed the cslol-manager migration banner. + #[serde(default)] + pub migration_dismissed: bool, } diff --git a/src/components/AlertBox.tsx b/src/components/AlertBox.tsx new file mode 100644 index 0000000..6a73aad --- /dev/null +++ b/src/components/AlertBox.tsx @@ -0,0 +1,87 @@ +import { type ReactNode } from "react"; +import { LuCircleAlert, LuCircleCheck, LuCircleX, LuInfo, LuX } from "react-icons/lu"; +import { twMerge } from "tailwind-merge"; + +export type AlertBoxVariant = "info" | "success" | "warning" | "error"; + +export interface AlertBoxProps { + variant?: AlertBoxVariant; + title?: ReactNode; + children?: ReactNode; + icon?: ReactNode; + actions?: ReactNode; + onDismiss?: () => void; + className?: string; +} + +const variantStyles: Record = { + info: { + border: "border-blue-800/50", + bg: "bg-blue-950/30", + icon: "text-blue-400", + }, + success: { + border: "border-green-800/50", + bg: "bg-green-950/30", + icon: "text-green-400", + }, + warning: { + border: "border-amber-800/50", + bg: "bg-amber-950/30", + icon: "text-amber-400", + }, + error: { + border: "border-red-800/50", + bg: "bg-red-950/30", + icon: "text-red-400", + }, +}; + +const defaultIcons: Record = { + info: , + success: , + warning: , + error: , +}; + +export function AlertBox({ + variant = "info", + title, + children, + icon, + actions, + onDismiss, + className, +}: AlertBoxProps) { + const styles = variantStyles[variant]; + const resolvedIcon = icon ?? defaultIcons[variant]; + + return ( +
+
{resolvedIcon}
+
+ {title &&

{title}

} + {children &&
{children}
} +
+ {actions &&
{actions}
} + {onDismiss && ( + + )} +
+ ); +} diff --git a/src/components/SectionCard.tsx b/src/components/SectionCard.tsx new file mode 100644 index 0000000..9c6c90d --- /dev/null +++ b/src/components/SectionCard.tsx @@ -0,0 +1,22 @@ +import { type ReactNode } from "react"; +import { twMerge } from "tailwind-merge"; + +interface SectionCardProps { + title: string; + children: ReactNode; + className?: string; +} + +export function SectionCard({ title, children, className }: SectionCardProps) { + return ( +
+

{title}

+ {children} +
+ ); +} diff --git a/src/components/index.ts b/src/components/index.ts index 0efe898..f8197ae 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,3 +1,4 @@ +export * from "./AlertBox"; export * from "./Button"; export * from "./Checkbox"; export * from "./Combobox"; @@ -9,6 +10,7 @@ export * from "./NavTabs"; export * from "./Popover"; export * from "./Progress"; export * from "./RadioGroup"; +export * from "./SectionCard"; export * from "./Select"; export * from "./Switch"; export * from "./Tabs"; diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 79ac236..c5dab46 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -33,6 +33,8 @@ export interface Settings { libraryViewMode: string | null; /** Whether to patch TFT game files (Map22.wad.client). Default: false. */ patchTft: boolean; + /** Whether the user has dismissed the cslol-manager migration banner. */ + migrationDismissed: boolean; } export interface InstalledMod { @@ -92,6 +94,14 @@ export interface InstallProgress { currentFile: string; } +export interface CslolModInfo { + folderName: string; + name: string; + author: string; + version: string; + description: string; +} + export interface PatcherConfig { logFile?: string | null; timeoutMs?: number | null; @@ -234,6 +244,12 @@ export const api = { getStorageDirectory: () => invokeResult("get_storage_directory"), reorderMods: (modIds: string[]) => invokeResult("reorder_mods", { modIds }), + // Migration + scanCslolMods: (directory: string) => + invokeResult("scan_cslol_mods", { directory }), + importCslolMods: (directory: string, selectedFolders: string[]) => + invokeResult("import_cslol_mods", { directory, selectedFolders }), + // Inspector inspectModpkg: (filePath: string) => invokeResult("inspect_modpkg", { filePath }), diff --git a/src/modules/library/components/BulkInstallProgress.tsx b/src/modules/library/components/BulkInstallProgress.tsx new file mode 100644 index 0000000..8626d9f --- /dev/null +++ b/src/modules/library/components/BulkInstallProgress.tsx @@ -0,0 +1,32 @@ +import { Progress } from "@/components"; +import type { InstallProgress } from "@/lib/tauri"; + +interface BulkInstallProgressProps { + progress: InstallProgress | null; +} + +export function BulkInstallProgress({ progress }: BulkInstallProgressProps) { + if (!progress) { + return ( + + + + + + ); + } + + return ( + <> + 0 ? (progress.current / progress.total) * 100 : 0} + label={`${progress.current} / ${progress.total}`} + > + + + + +

{progress.currentFile}

+ + ); +} diff --git a/src/modules/library/components/BulkInstallResults.tsx b/src/modules/library/components/BulkInstallResults.tsx new file mode 100644 index 0000000..3de4b65 --- /dev/null +++ b/src/modules/library/components/BulkInstallResults.tsx @@ -0,0 +1,42 @@ +import { LuCircleCheck, LuCircleX } from "react-icons/lu"; + +import type { BulkInstallResult } from "@/lib/tauri"; + +interface BulkInstallResultsProps { + result: BulkInstallResult; + /** Verb used in the success message (e.g. "imported", "installed"). */ + verb?: string; +} + +export function BulkInstallResults({ result, verb = "installed" }: BulkInstallResultsProps) { + return ( +
+ {result.installed.length > 0 && ( +
+ + + {result.installed.length} mod{result.installed.length !== 1 ? "s" : ""} {verb} + +
+ )} + + {result.failed.length > 0 && ( +
+
+ + {result.failed.length} failed +
+
    + {result.failed.map((err) => ( +
  • + {err.fileName} + {" — "} + {err.message} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/src/modules/library/components/ImportProgressDialog.tsx b/src/modules/library/components/ImportProgressDialog.tsx index d764c23..e2c8ddd 100644 --- a/src/modules/library/components/ImportProgressDialog.tsx +++ b/src/modules/library/components/ImportProgressDialog.tsx @@ -1,8 +1,9 @@ -import { LuCircleCheck, LuCircleX } from "react-icons/lu"; - -import { Button, Dialog, Progress } from "@/components"; +import { Button, Dialog } from "@/components"; import type { BulkInstallResult, InstallProgress } from "@/lib/tauri"; +import { BulkInstallProgress } from "./BulkInstallProgress"; +import { BulkInstallResults } from "./BulkInstallResults"; + interface ImportProgressDialogProps { open: boolean; onClose: () => void; @@ -28,61 +29,8 @@ export function ImportProgressDialog({ - {!isComplete && ( - <> - {progress ? ( - <> - 0 ? (progress.current / progress.total) * 100 : 0} - label={`${progress.current} / ${progress.total}`} - > - - - - -

{progress.currentFile}

- - ) : ( - - - - - - )} - - )} - - {isComplete && result && ( -
- {result.installed.length > 0 && ( -
- - - {result.installed.length} mod{result.installed.length !== 1 ? "s" : ""}{" "} - installed - -
- )} - - {result.failed.length > 0 && ( -
-
- - {result.failed.length} failed -
-
    - {result.failed.map((err) => ( -
  • - {err.fileName} - {" — "} - {err.message} -
  • - ))} -
-
- )} -
- )} + {!isComplete && } + {isComplete && result && }
diff --git a/src/modules/library/components/index.ts b/src/modules/library/components/index.ts index fd861b2..189d211 100644 --- a/src/modules/library/components/index.ts +++ b/src/modules/library/components/index.ts @@ -1,4 +1,6 @@ export * from "./ActiveFilterChips"; +export * from "./BulkInstallProgress"; +export * from "./BulkInstallResults"; export * from "./DragDropOverlay"; export * from "./FilterBar"; export * from "./FilterPopover"; diff --git a/src/modules/migration/api/index.ts b/src/modules/migration/api/index.ts new file mode 100644 index 0000000..b588247 --- /dev/null +++ b/src/modules/migration/api/index.ts @@ -0,0 +1,3 @@ +export { useImportCslolMods } from "./useImportCslolMods"; +export { useMigrationWizard, type WizardStep } from "./useMigrationWizard"; +export { useScanCslolMods } from "./useScanCslolMods"; diff --git a/src/modules/migration/api/useImportCslolMods.ts b/src/modules/migration/api/useImportCslolMods.ts new file mode 100644 index 0000000..ab70206 --- /dev/null +++ b/src/modules/migration/api/useImportCslolMods.ts @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { api, type AppError, type BulkInstallResult, type InstalledMod } from "@/lib/tauri"; +import { libraryKeys } from "@/modules/library"; +import { unwrapForQuery } from "@/utils/query"; + +interface ImportCslolModsArgs { + directory: string; + selectedFolders: string[]; +} + +export function useImportCslolMods() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ directory, selectedFolders }) => { + const result = await api.importCslolMods(directory, selectedFolders); + return unwrapForQuery(result); + }, + onSuccess: (result) => { + if (result.installed.length > 0) { + queryClient.setQueryData(libraryKeys.mods(), (old) => + old ? [...old, ...result.installed] : result.installed, + ); + } + }, + }); +} diff --git a/src/modules/migration/api/useMigrationWizard.ts b/src/modules/migration/api/useMigrationWizard.ts new file mode 100644 index 0000000..392ef9e --- /dev/null +++ b/src/modules/migration/api/useMigrationWizard.ts @@ -0,0 +1,112 @@ +import { open } from "@tauri-apps/plugin-dialog"; +import { useState } from "react"; + +import type { BulkInstallResult, CslolModInfo } from "@/lib/tauri"; +import { useInstallProgress } from "@/modules/library"; + +import { useImportCslolMods } from "./useImportCslolMods"; +import { useScanCslolMods } from "./useScanCslolMods"; + +export type WizardStep = "browse" | "select" | "importing" | "results"; + +export function useMigrationWizard(onClose: () => void) { + const [step, setStep] = useState("browse"); + const [directory, setDirectory] = useState(""); + const [mods, setMods] = useState([]); + const [selectedFolders, setSelectedFolders] = useState>(new Set()); + const [importResult, setImportResult] = useState(null); + + const scanMods = useScanCslolMods(); + const importMods = useImportCslolMods(); + const { progress, reset: resetProgress } = useInstallProgress(); + + function reset() { + setStep("browse"); + setDirectory(""); + setMods([]); + setSelectedFolders(new Set()); + setImportResult(null); + resetProgress(); + } + + function handleClose() { + if (step === "importing") return; + reset(); + onClose(); + } + + async function handleBrowse() { + const selected = await open({ + directory: true, + title: "Select cslol-manager Directory", + }); + + if (!selected) return; + const dir = selected as string; + setDirectory(dir); + + scanMods.mutate(dir, { + onSuccess: (discovered) => { + setMods(discovered); + setSelectedFolders(new Set(discovered.map((m) => m.folderName))); + setStep("select"); + }, + }); + } + + function handleToggleMod(folderName: string) { + setSelectedFolders((prev) => { + const next = new Set(prev); + if (next.has(folderName)) { + next.delete(folderName); + } else { + next.add(folderName); + } + return next; + }); + } + + function handleSelectAll() { + setSelectedFolders(new Set(mods.map((m) => m.folderName))); + } + + function handleSelectNone() { + setSelectedFolders(new Set()); + } + + function handleImport() { + setImportResult(null); + resetProgress(); + setStep("importing"); + + importMods.mutate( + { directory, selectedFolders: Array.from(selectedFolders) }, + { + onSuccess: (result) => { + setImportResult(result); + setStep("results"); + }, + onError: () => { + setStep("select"); + }, + }, + ); + } + + return { + step, + setStep, + mods, + selectedFolders, + importResult, + progress, + scanError: scanMods.error?.message, + isScanning: scanMods.isPending, + handleClose, + handleBrowse, + handleToggleMod, + handleSelectAll, + handleSelectNone, + handleImport, + }; +} diff --git a/src/modules/migration/api/useScanCslolMods.ts b/src/modules/migration/api/useScanCslolMods.ts new file mode 100644 index 0000000..eb548a2 --- /dev/null +++ b/src/modules/migration/api/useScanCslolMods.ts @@ -0,0 +1,13 @@ +import { useMutation } from "@tanstack/react-query"; + +import { api, type AppError, type CslolModInfo } from "@/lib/tauri"; +import { unwrapForQuery } from "@/utils/query"; + +export function useScanCslolMods() { + return useMutation({ + mutationFn: async (directory) => { + const result = await api.scanCslolMods(directory); + return unwrapForQuery(result); + }, + }); +} diff --git a/src/modules/migration/components/MigrationBanner.tsx b/src/modules/migration/components/MigrationBanner.tsx new file mode 100644 index 0000000..15a99e4 --- /dev/null +++ b/src/modules/migration/components/MigrationBanner.tsx @@ -0,0 +1,29 @@ +import { LuArrowRight } from "react-icons/lu"; + +import { AlertBox, Button } from "@/components"; + +interface MigrationBannerProps { + onImport: () => void; + onDismiss: () => void; +} + +export function MigrationBanner({ onImport, onDismiss }: MigrationBannerProps) { + return ( + + + Import Mods + + + + } + className="rounded-none border-x-0 border-t-0 border-b border-b-surface-600" + > + Import your existing mods to get started quickly. + + ); +} diff --git a/src/modules/migration/components/MigrationSection.tsx b/src/modules/migration/components/MigrationSection.tsx new file mode 100644 index 0000000..f10977e --- /dev/null +++ b/src/modules/migration/components/MigrationSection.tsx @@ -0,0 +1,25 @@ +import { LuDownload } from "react-icons/lu"; + +import { Button, SectionCard } from "@/components"; + +interface MigrationSectionProps { + onImport: () => void; +} + +export function MigrationSection({ onImport }: MigrationSectionProps) { + return ( + +
+

+ If you previously used cslol-manager, you can import your installed mods into LTK Manager. +

+ +
+
+ ); +} diff --git a/src/modules/migration/components/MigrationWizardDialog.tsx b/src/modules/migration/components/MigrationWizardDialog.tsx new file mode 100644 index 0000000..f40fc8d --- /dev/null +++ b/src/modules/migration/components/MigrationWizardDialog.tsx @@ -0,0 +1,220 @@ +import { useMemo, useState } from "react"; +import { LuFolderOpen, LuLoader, LuSearch } from "react-icons/lu"; + +import { Button, Checkbox, Dialog } from "@/components"; +import type { CslolModInfo } from "@/lib/tauri"; +import { BulkInstallProgress, BulkInstallResults } from "@/modules/library"; + +import { useMigrationWizard, type WizardStep } from "../api"; + +interface MigrationWizardDialogProps { + open: boolean; + onClose: () => void; +} + +export function MigrationWizardDialog({ open: isOpen, onClose }: MigrationWizardDialogProps) { + const wizard = useMigrationWizard(onClose); + + return ( + !open && wizard.handleClose()}> + + + + + {getStepTitle(wizard.step)} + {wizard.step !== "importing" && } + + + + {wizard.step === "browse" && ( + + )} + {wizard.step === "select" && ( + + )} + {wizard.step === "importing" && } + {wizard.step === "results" && wizard.importResult && ( + + )} + + + + {wizard.step === "browse" && ( + + )} + {wizard.step === "select" && ( + <> + + + + )} + {wizard.step === "results" && ( + + )} + + + + + ); +} + +function getStepTitle(step: WizardStep): string { + switch (step) { + case "browse": + return "Import from cslol-manager"; + case "select": + return "Select Mods to Import"; + case "importing": + return "Importing Mods..."; + case "results": + return "Import Complete"; + } +} + +// Step sub-components + +interface BrowseStepProps { + onBrowse: () => void; + isScanning: boolean; + error?: string; +} + +function BrowseStep({ onBrowse, isScanning, error }: BrowseStepProps) { + return ( +
+

+ Select your cslol-manager installation directory. LTK Manager will scan for installed mods + and let you choose which ones to import. +

+

+ The directory should contain an{" "} + installed{" "} + folder with your mods. +

+ + {error &&

{error}

} +
+ ); +} + +interface SelectStepProps { + mods: CslolModInfo[]; + selectedFolders: Set; + onToggle: (folderName: string) => void; + onSelectAll: () => void; + onSelectNone: () => void; +} + +function SelectStep({ + mods, + selectedFolders, + onToggle, + onSelectAll, + onSelectNone, +}: SelectStepProps) { + const [search, setSearch] = useState(""); + + const filteredMods = useMemo(() => { + const query = search.trim().toLowerCase(); + if (!query) return mods; + return mods.filter( + (mod) => mod.name.toLowerCase().includes(query) || mod.author?.toLowerCase().includes(query), + ); + }, [mods, search]); + + return ( +
+
+

+ {selectedFolders.size} of {mods.length} mod{mods.length !== 1 ? "s" : ""} selected + {search.trim() && ` \u00b7 Showing ${filteredMods.length} of ${mods.length}`} +

+
+ + +
+
+ +
+ + setSearch(e.target.value)} + className="w-full rounded-lg border border-surface-600 bg-surface-800 py-2 pr-4 pl-10 text-sm text-surface-100 placeholder:text-surface-500 focus:border-transparent focus:ring-2 focus:ring-brand-500 focus:outline-none" + /> +
+ +
+ {filteredMods.length === 0 && ( +

+ No mods matching “{search.trim()}” +

+ )} + {filteredMods.map((mod) => ( + + ))} +
+
+ ); +} diff --git a/src/modules/migration/components/index.ts b/src/modules/migration/components/index.ts new file mode 100644 index 0000000..750f5dd --- /dev/null +++ b/src/modules/migration/components/index.ts @@ -0,0 +1,3 @@ +export { MigrationBanner } from "./MigrationBanner"; +export { MigrationSection } from "./MigrationSection"; +export { MigrationWizardDialog } from "./MigrationWizardDialog"; diff --git a/src/modules/migration/index.ts b/src/modules/migration/index.ts new file mode 100644 index 0000000..088895d --- /dev/null +++ b/src/modules/migration/index.ts @@ -0,0 +1,2 @@ +export * from "./api"; +export * from "./components"; diff --git a/src/modules/settings/components/AboutSection.tsx b/src/modules/settings/components/AboutSection.tsx index 0edea92..3a7ab84 100644 --- a/src/modules/settings/components/AboutSection.tsx +++ b/src/modules/settings/components/AboutSection.tsx @@ -1,6 +1,6 @@ import { LuFileText } from "react-icons/lu"; -import { Button } from "@/components"; +import { Button, SectionCard } from "@/components"; import { api, type AppInfo } from "@/lib/tauri"; interface AboutSectionProps { @@ -9,9 +9,8 @@ interface AboutSectionProps { export function AboutSection({ appInfo }: AboutSectionProps) { return ( -
-

About

-
+ +

LTK Manager

@@ -51,6 +50,6 @@ export function AboutSection({ appInfo }: AboutSectionProps) {
-
+ ); } diff --git a/src/modules/settings/components/AppearanceSection/AppearanceSection.tsx b/src/modules/settings/components/AppearanceSection/AppearanceSection.tsx index 1b97e28..e971a22 100644 --- a/src/modules/settings/components/AppearanceSection/AppearanceSection.tsx +++ b/src/modules/settings/components/AppearanceSection/AppearanceSection.tsx @@ -1,3 +1,4 @@ +import { SectionCard } from "@/components"; import type { Settings } from "@/lib/tauri"; import { AccentColorPicker } from "./AccentColorPicker"; @@ -11,11 +12,10 @@ interface AppearanceSectionProps { export function AppearanceSection({ settings, onSave }: AppearanceSectionProps) { return ( -
-

Appearance

+ -
+ ); } diff --git a/src/modules/settings/components/GeneralSection.tsx b/src/modules/settings/components/GeneralSection.tsx index 06cf644..ddfa167 100644 --- a/src/modules/settings/components/GeneralSection.tsx +++ b/src/modules/settings/components/GeneralSection.tsx @@ -1,4 +1,7 @@ +import { useState } from "react"; + import type { Settings } from "@/lib/tauri"; +import { MigrationSection, MigrationWizardDialog } from "@/modules/migration"; import { LeaguePathSection } from "./LeaguePathSection"; import { ModStorageSection } from "./ModStorageSection"; @@ -11,12 +14,16 @@ interface GeneralSectionProps { } export function GeneralSection({ settings, onSave }: GeneralSectionProps) { + const [migrationOpen, setMigrationOpen] = useState(false); + return ( -
+
+ setMigrationOpen(true)} /> + setMigrationOpen(false)} />
); } diff --git a/src/modules/settings/components/LeaguePathSection.tsx b/src/modules/settings/components/LeaguePathSection.tsx index 9c13985..38ffc2d 100644 --- a/src/modules/settings/components/LeaguePathSection.tsx +++ b/src/modules/settings/components/LeaguePathSection.tsx @@ -2,7 +2,7 @@ import { open } from "@tauri-apps/plugin-dialog"; import { useEffect, useState } from "react"; import { LuCircleAlert, LuCircleCheck, LuFolderOpen, LuLoader } from "react-icons/lu"; -import { Button, Field, IconButton } from "@/components"; +import { Button, Field, IconButton, SectionCard } from "@/components"; import { api, type Settings } from "@/lib/tauri"; import { unwrapForQuery } from "@/utils/query"; @@ -63,8 +63,7 @@ export function LeaguePathSection({ settings, onSave }: SettingsSectionProps) { } return ( -
-

League of Legends

+
Installation Path
@@ -106,6 +105,6 @@ export function LeaguePathSection({ settings, onSave }: SettingsSectionProps) {

)}
-
+ ); } diff --git a/src/modules/settings/components/ModStorageSection.tsx b/src/modules/settings/components/ModStorageSection.tsx index f46550e..c454076 100644 --- a/src/modules/settings/components/ModStorageSection.tsx +++ b/src/modules/settings/components/ModStorageSection.tsx @@ -1,7 +1,7 @@ import { open } from "@tauri-apps/plugin-dialog"; import { LuFolderOpen } from "react-icons/lu"; -import { Field, IconButton } from "@/components"; +import { Field, IconButton, SectionCard } from "@/components"; import type { Settings } from "@/lib/tauri"; interface ModStorageSectionProps { @@ -26,8 +26,7 @@ export function ModStorageSection({ settings, onSave }: ModStorageSectionProps) } return ( -
-

Mod Storage

+
Storage Location
@@ -45,10 +44,10 @@ export function ModStorageSection({ settings, onSave }: ModStorageSectionProps) onClick={handleBrowse} />
-

+

Choose where your installed mods will be stored. Leave empty to use the default location.

-
+ ); } diff --git a/src/modules/settings/components/PatchingSection.tsx b/src/modules/settings/components/PatchingSection.tsx index fefbe4f..8c94029 100644 --- a/src/modules/settings/components/PatchingSection.tsx +++ b/src/modules/settings/components/PatchingSection.tsx @@ -1,4 +1,4 @@ -import { Switch } from "@/components"; +import { SectionCard, Switch } from "@/components"; import type { Settings } from "@/lib/tauri"; interface PatchingSectionProps { @@ -8,8 +8,7 @@ interface PatchingSectionProps { export function PatchingSection({ settings, onSave }: PatchingSectionProps) { return ( -
-

Patching

+
+ ); } diff --git a/src/modules/settings/components/WorkshopSection.tsx b/src/modules/settings/components/WorkshopSection.tsx index 74f26fc..8615703 100644 --- a/src/modules/settings/components/WorkshopSection.tsx +++ b/src/modules/settings/components/WorkshopSection.tsx @@ -1,7 +1,7 @@ import { open } from "@tauri-apps/plugin-dialog"; import { LuFolderOpen } from "react-icons/lu"; -import { Field, IconButton } from "@/components"; +import { Field, IconButton, SectionCard } from "@/components"; import type { Settings } from "@/lib/tauri"; interface WorkshopSectionProps { @@ -26,8 +26,7 @@ export function WorkshopSection({ settings, onSave }: WorkshopSectionProps) { } return ( -
-

Workshop

+
Workshop Directory
@@ -45,11 +44,11 @@ export function WorkshopSection({ settings, onSave }: WorkshopSectionProps) { onClick={handleBrowse} />
-

+

Choose where your mod projects will be stored for the Creator Workshop. This directory will contain all your project folders.

-
+ ); } diff --git a/src/pages/Library.tsx b/src/pages/Library.tsx index cd7a6de..f87440d 100644 --- a/src/pages/Library.tsx +++ b/src/pages/Library.tsx @@ -11,15 +11,21 @@ import { useLibraryActions, useModFileDrop, } from "@/modules/library"; +import { MigrationBanner, MigrationWizardDialog } from "@/modules/migration"; import { usePatcherStatus, useStartPatcher, useStopPatcher } from "@/modules/patcher"; +import { useSaveSettings, useSettings } from "@/modules/settings"; export function Library() { const [searchQuery, setSearchQuery] = useState(""); + const [migrationOpen, setMigrationOpen] = useState(false); const { data: mods = [], isLoading, error } = useInstalledMods(); const actions = useLibraryActions(); const isDragOver = useModFileDrop(actions.handleBulkInstallFiles); + const { data: settings } = useSettings(); + const saveSettings = useSaveSettings(); + const { data: patcherStatus } = usePatcherStatus(); const startPatcher = useStartPatcher(); const stopPatcher = useStopPatcher(); @@ -49,9 +55,20 @@ export function Library() { }); } + function handleDismissMigration() { + if (!settings) return; + saveSettings.mutate({ ...settings, migrationDismissed: true }); + } + return (
+ {settings && !settings.migrationDismissed && ( + setMigrationOpen(true)} + onDismiss={handleDismissMigration} + /> + )} + setMigrationOpen(false)} />
); } diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index d583503..2e5aaca 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -40,21 +40,29 @@ export function Settings() { - + General Appearance - + About @@ -63,7 +71,7 @@ export function Settings() {
{firstRun && !settings.leaguePath && ( -
+

Welcome to LTK Manager!