diff --git a/src/App.tsx b/src/App.tsx index 586f543..a24975f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,34 +1,17 @@ -import { invoke } from "@tauri-apps/api/core"; +import { useState, useEffect } from "react"; import { listen } from "@tauri-apps/api/event"; -import { openUrl } from "@tauri-apps/plugin-opener"; -import { useState, useEffect, useRef } from "react"; -import "./App.css"; - -const Icons = { - Discord: () => , - Github: () => , - Reddit: () => , - Volume: ({ level }: { level: number }) => ( - {level > 0 && }{level > 0.5 && } - ), - Linux: () => -}; - -interface Runner { - id: string; - name: string; - path: string; - type: string; -} - -interface AppConfig { - username: string; - linuxRunner?: string; -} - -let audioCtx: AudioContext | null = null; -let sfxGain: GainNode | null = null; -const buffers: Record = {}; +import { useAudio } from "./hooks/useAudio"; +import { useSettings } from "./hooks/useSettings"; +import { TauriService } from "./services/tauri"; +import { AppConfig, Runner, InstalledStatus, ReinstallModalData, McNotification } from "./types"; +import { Sidebar } from "./components/layout/Sidebar"; +import { HomeView } from "./components/views/HomeView"; +import { VersionsView } from "./components/views/VersionsView"; +import { SettingsView } from "./components/views/SettingsView"; +import { FirstRunView } from "./components/views/FirstRunView"; +import { ReinstallModal } from "./components/modals/ReinstallModal"; +import { Notification } from "./components/common/Notification"; +import "./index.css"; export default function App() { const [username, setUsername] = useState(""); @@ -37,66 +20,24 @@ export default function App() { const [isRunning, setIsRunning] = useState(false); const [installingInstance, setInstallingInstance] = useState(null); const [downloadProgress, setDownloadProgress] = useState(0); - const [installedStatus, setInstalledStatus] = useState>({ vanilla_tu19: false, vanilla_tu24: false }); + const [installedStatus, setInstalledStatus] = useState({ + vanilla_tu19: false, + vanilla_tu24: false, + }); const [selectedInstance, setSelectedInstance] = useState("vanilla_tu19"); - const [reinstallModal, setReinstallModal] = useState<{ id: string, url: string } | null>(null); - const [mcNotif, setMcNotif] = useState<{ t: string, m: string } | null>(null); + const [reinstallModal, setReinstallModal] = useState(null); + const [mcNotif, setMcNotif] = useState(null); const [availableRunners, setAvailableRunners] = useState([]); const [selectedRunner, setSelectedRunner] = useState(""); const [isLinux, setIsLinux] = useState(false); - const [musicVol, setMusicVol] = useState(parseFloat(localStorage.getItem("musicVol") || "0.4")); - const [sfxVol, setSfxVol] = useState(parseFloat(localStorage.getItem("sfxVol") || "0.7")); - const [isMuted, setIsMuted] = useState(localStorage.getItem("isMuted") === "true"); + const { musicVol, setMusicVol, sfxVol, setSfxVol, isMuted, setIsMuted } = useSettings(); + const { musicRef, playRandomMusic, playSfx, ensureAudio } = useAudio(musicVol, sfxVol, isMuted); - const musicRef = useRef(null); - const lastTrack = useRef(0); - - useEffect(() => { - localStorage.setItem("musicVol", musicVol.toString()); - localStorage.setItem("sfxVol", sfxVol.toString()); - localStorage.setItem("isMuted", isMuted.toString()); - if (musicRef.current) musicRef.current.volume = isMuted ? 0 : musicVol; - }, [musicVol, sfxVol, isMuted]); - - const ensureAudio = async () => { - let currentCtx = audioCtx; - if (!currentCtx) { - const AC = (window as any).AudioContext || (window as any).webkitAudioContext; - if (!AC) return; - const newCtx = new AC() as AudioContext; - const newGain = newCtx.createGain(); - newGain.connect(newCtx.destination); - audioCtx = newCtx; sfxGain = newGain; currentCtx = newCtx; - const sounds = ['click.wav', 'orb.ogg', 'levelup.ogg', 'back.ogg', 'pop.wav', 'wood click.wav']; - sounds.forEach(s => { - fetch(`/sounds/${s}`).then(r => r.arrayBuffer()).then(b => newCtx.decodeAudioData(b)).then(buf => { if (buf) buffers[s] = buf; }); - }); - } - if (currentCtx.state === 'suspended') await currentCtx.resume(); - }; - - const playSfx = async (n: string, multiplier: number = 1.0) => { - await ensureAudio(); - const currentCtx = audioCtx; - const currentGain = sfxGain; - if (!currentCtx || !currentGain || !buffers[n] || isMuted) return; - const s = currentCtx.createBufferSource(); - s.buffer = buffers[n]; - const g = currentCtx.createGain(); - g.gain.value = sfxVol * multiplier; - s.connect(g); g.connect(currentCtx.destination); - s.start(0); - }; - - const playRandomMusic = () => { - if (!musicRef.current) return; - let track = Math.floor(Math.random() * 5) + 1; - if (track === lastTrack.current) track = (track % 5) + 1; - lastTrack.current = track; - musicRef.current.src = `/music/music${track}.ogg`; - musicRef.current.volume = isMuted ? 0 : musicVol; - musicRef.current.play().catch(() => { }); + const updateAllStatus = async () => { + const v19 = await TauriService.checkGameInstalled("vanilla_tu19"); + const v24 = await TauriService.checkGameInstalled("vanilla_tu24"); + setInstalledStatus({ vanilla_tu19: v19, vanilla_tu24: v24 }); }; const fadeAndLaunch = async () => { @@ -108,13 +49,18 @@ export default function App() { let currentStep = 0; const fade = setInterval(() => { currentStep++; - if (musicRef.current) musicRef.current.volume = Math.max(0, startVol * (1 - currentStep / steps)); - if (currentStep >= steps) { clearInterval(fade); if (musicRef.current) musicRef.current.pause(); } + if (musicRef.current) { + musicRef.current.volume = Math.max(0, startVol * (1 - currentStep / steps)); + } + if (currentStep >= steps) { + clearInterval(fade); + if (musicRef.current) musicRef.current.pause(); + } }, 50); } setTimeout(async () => { try { - await invoke("launch_game", { instanceId: selectedInstance }); + await TauriService.launchGame(selectedInstance); } catch (e) { alert(`Failed to launch game: ${e}`); } finally { @@ -128,10 +74,12 @@ export default function App() { }; const executeInstall = async (id: string, url: string) => { - setInstallingInstance(id); setDownloadProgress(0); + setInstallingInstance(id); + setDownloadProgress(0); try { - await invoke("download_and_install", { url, instanceId: id }); - setMcNotif({ t: "Success!", m: "Ready to play." }); playSfx('orb.ogg'); + await TauriService.downloadAndInstall(url, id); + setMcNotif({ t: "Success!", m: "Ready to play." }); + playSfx('orb.ogg'); setTimeout(() => setMcNotif(null), 4000); updateAllStatus(); } catch (e) { @@ -141,14 +89,8 @@ export default function App() { setInstallingInstance(null); }; - const updateAllStatus = async () => { - const v19 = await invoke("check_game_installed", { instanceId: "vanilla_tu19" }); - const v24 = await invoke("check_game_installed", { instanceId: "vanilla_tu24" }); - setInstalledStatus({ vanilla_tu19: v19, vanilla_tu24: v24 }); - }; - useEffect(() => { - invoke("load_config").then((c) => { + TauriService.loadConfig().then((c) => { const config = c as AppConfig; if (config.username && config.username.trim() !== "") { setUsername(config.username); @@ -161,216 +103,111 @@ export default function App() { const platform = window.navigator.platform.toLowerCase(); if (platform.includes("linux")) { setIsLinux(true); - invoke("get_available_runners").then(runners => { + TauriService.getAvailableRunners().then((runners) => { setAvailableRunners(runners); }); } updateAllStatus(); - const unlisten = listen("download-progress", (e) => setDownloadProgress(Math.round(e.payload))); - return () => { unlisten.then(f => f()); }; + const unlisten = listen("download-progress", (e) => + setDownloadProgress(Math.round(e.payload)) + ); + return () => { + unlisten.then((f) => f()); + }; }, []); if (isFirstRun) { return ( -
e.preventDefault()}> - -
-

Welcome to Emerald Legacy!

- setUsername(e.target.value)} className="w-full bg-black border-4 border-emerald-900 p-4 text-3xl text-center mb-8 outline-none" placeholder="Username..." /> - -
-
+ ); } return ( -
e.preventDefault()}> +
e.preventDefault()} + >
diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx new file mode 100644 index 0000000..e384bd3 --- /dev/null +++ b/src/components/Icons.tsx @@ -0,0 +1,29 @@ +export const Icons = { + Discord: () => ( + + + + ), + Github: () => ( + + + + ), + Reddit: () => ( + + + + ), + Volume: ({ level }: { level: number }) => ( + + + {level > 0 && } + {level > 0.5 && } + + ), + Linux: () => ( + + + + ), +}; diff --git a/src/components/common/Notification.tsx b/src/components/common/Notification.tsx new file mode 100644 index 0000000..d47f06e --- /dev/null +++ b/src/components/common/Notification.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +interface NotificationProps { + title: string; + message: string; +} + +export const Notification: React.FC = ({ title, message }) => { + return ( +
+
+ ✓ +
+
+ {title} + {message} +
+
+ ); +}; diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..d7b6ac0 --- /dev/null +++ b/src/components/layout/Sidebar.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { TauriService } from '../../services/tauri'; + +interface SidebarProps { + activeTab: string; + setActiveTab: (tab: string) => void; + playSfx: (name: string, multiplier?: number) => void; + updateAllStatus: () => void; + installingInstance: string | null; + downloadProgress: number; +} + +export const Sidebar: React.FC = ({ + activeTab, + setActiveTab, + playSfx, + updateAllStatus, + installingInstance, + downloadProgress, +}) => { + return ( + + ); +}; diff --git a/src/components/modals/ReinstallModal.tsx b/src/components/modals/ReinstallModal.tsx new file mode 100644 index 0000000..732b85c --- /dev/null +++ b/src/components/modals/ReinstallModal.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { ReinstallModalData } from '../../types'; + +interface ReinstallModalProps { + data: ReinstallModalData; + onCancel: () => void; + onConfirm: (id: string, url: string) => void; + playSfx: (name: string, multiplier?: number) => void; +} + +export const ReinstallModal: React.FC = ({ + data, + onCancel, + onConfirm, + playSfx, +}) => { + return ( +
+
+

+ Warning +

+

+ Reinstalling will delete all data. Continue? +

+
+ + +
+
+
+ ); +}; diff --git a/src/components/views/FirstRunView.tsx b/src/components/views/FirstRunView.tsx new file mode 100644 index 0000000..cdb9261 --- /dev/null +++ b/src/components/views/FirstRunView.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { TauriService } from '../../services/tauri'; +import { Runner } from '../../types'; + +interface FirstRunViewProps { + username: string; + setUsername: (name: string) => void; + isLinux: boolean; + selectedRunner: string; + availableRunners: Runner[]; + setIsFirstRun: (val: boolean) => void; + playRandomMusic: () => void; + playSfx: (name: string, multiplier?: number) => void; + ensureAudio: () => void; +} + +export const FirstRunView: React.FC = ({ + username, + setUsername, + isLinux, + selectedRunner, + availableRunners, + setIsFirstRun, + playRandomMusic, + playSfx, + ensureAudio, +}) => { + return ( +
e.preventDefault()} + > + Menu Title +
+

Welcome to Emerald Legacy!

+ setUsername(e.target.value)} + className="w-full bg-black border-4 border-emerald-900 p-4 text-3xl text-center mb-8 outline-none" + placeholder="Username..." + /> + +
+
+ ); +}; diff --git a/src/components/views/HomeView.tsx b/src/components/views/HomeView.tsx new file mode 100644 index 0000000..102a3f4 --- /dev/null +++ b/src/components/views/HomeView.tsx @@ -0,0 +1,79 @@ +import React from 'react'; + +interface HomeViewProps { + username: string; + selectedInstance: string; + setSelectedInstance: (id: string) => void; + installedStatus: Record; + isRunning: boolean; + installingInstance: string | null; + fadeAndLaunch: () => void; + playSfx: (name: string, multiplier?: number) => void; + setActiveTab: (tab: string) => void; +} + +export const HomeView: React.FC = ({ + username, + selectedInstance, + setSelectedInstance, + installedStatus, + isRunning, + installingInstance, + fadeAndLaunch, + playSfx, + setActiveTab, +}) => { + const hasInstalledInstance = installedStatus.vanilla_tu19 || installedStatus.vanilla_tu24; + + return ( +
+
+ Menu Title +
+ Welcome, {username}! +
+
+
+ {hasInstalledInstance ? ( + <> + + + + ) : ( +
+

Game not installed

+ +
+ )} +
+
+ ); +}; diff --git a/src/components/views/SettingsView.tsx b/src/components/views/SettingsView.tsx new file mode 100644 index 0000000..4cc95d0 --- /dev/null +++ b/src/components/views/SettingsView.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { Icons } from '../Icons'; +import { TauriService } from '../../services/tauri'; +import { Runner } from '../../types'; +import { openUrl } from "@tauri-apps/plugin-opener"; + +interface SettingsViewProps { + username: string; + setUsername: (name: string) => void; + isLinux: boolean; + selectedRunner: string; + setSelectedRunner: (runner: string) => void; + availableRunners: Runner[]; + musicVol: number; + setMusicVol: (vol: number) => void; + sfxVol: number; + setSfxVol: (vol: number) => void; + isMuted: boolean; + setIsMuted: (muted: boolean) => void; + playSfx: (name: string, multiplier?: number) => void; +} + +export const SettingsView: React.FC = ({ + username, + setUsername, + isLinux, + selectedRunner, + setSelectedRunner, + availableRunners, + musicVol, + setMusicVol, + sfxVol, + setSfxVol, + isMuted, + setIsMuted, + playSfx, +}) => { + return ( +
+

Settings

+
+
+ +
+ setUsername(e.target.value)} + className="flex-1 bg-black border-4 border-slate-700 p-4 text-3xl outline-none focus:border-emerald-500" + /> + +
+
+ + {isLinux && ( +
+ +
+ + {availableRunners.length === 0 && ( +

+ No Proton or Wine installations found. Please install Steam or Wine. +

+ )} +
+
+ )} + +
+ +
+
+ + Music {Math.round(musicVol * 100)}% + + setMusicVol(parseFloat(e.target.value))} + className="mc-range" + /> +
+
+ + SFX {Math.round(sfxVol * 100)}% + + setSfxVol(parseFloat(e.target.value))} + className="mc-range" + /> +
+
+ +
+ +
+

+ About the project +

+

+ I'm KayJann, and I absolutely love this project! It's my very first one, + and my goal is to create a central hub for the LCE community to bring us all together. +

+

Social Links

+
+ + + +
+
+
+
+ ); +}; diff --git a/src/components/views/VersionsView.tsx b/src/components/views/VersionsView.tsx new file mode 100644 index 0000000..c2eca9e --- /dev/null +++ b/src/components/views/VersionsView.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { TauriService } from '../../services/tauri'; +import { ReinstallModalData } from '../../types'; + +interface VersionsViewProps { + installedStatus: Record; + installingInstance: string | null; + executeInstall: (id: string, url: string) => void; + setReinstallModal: (data: ReinstallModalData | null) => void; + playSfx: (name: string, multiplier?: number) => void; +} + +export const VersionsView: React.FC = ({ + installedStatus, + installingInstance, + executeInstall, + setReinstallModal, + playSfx, +}) => { + const versions = [ + { + id: "vanilla_tu19", + name: "Vanilla Nightly (TU19)", + desc: "Leaked 4J Studios build.", + url: "https://huggingface.co/datasets/KayJann/emerald-legacy-assets/resolve/main/emerald_tu19_vanilla.zip" + }, + { + id: "vanilla_tu24", + name: "Vanilla TU24", + desc: "Horses and Wither update.", + url: "https://huggingface.co/datasets/KayJann/emerald-legacy-assets/resolve/main/emerald_tu24_vanilla.zip" + } + ]; + + return ( +
+

Instances

+
+ {versions.map(v => ( +
+
+

{v.name}

+

{v.desc}

+
+
+ {installedStatus[v.id] ? ( + <> + + + + ) : ( + + )} +
+
+ ))} + + {['TU75', 'TU9', 'Modded Pack'].map(v => ( +
+
+

Vanilla {v}

+

Legacy version.

+
+ SOON +
+ ))} +
+
+ ); +}; diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts new file mode 100644 index 0000000..6839d6c --- /dev/null +++ b/src/hooks/useAudio.ts @@ -0,0 +1,34 @@ +import { useRef, useEffect } from 'react'; +import { playSfx as playSfxService, ensureAudio } from '../services/audio'; + +export const useAudio = (musicVol: number, sfxVol: number, isMuted: boolean) => { + const musicRef = useRef(null); + const lastTrack = useRef(0); + + useEffect(() => { + if (musicRef.current) { + musicRef.current.volume = isMuted ? 0 : musicVol; + } + }, [musicVol, isMuted]); + + const playRandomMusic = () => { + if (!musicRef.current) return; + let track = Math.floor(Math.random() * 5) + 1; + if (track === lastTrack.current) track = (track % 5) + 1; + lastTrack.current = track; + musicRef.current.src = `/music/music${track}.ogg`; + musicRef.current.volume = isMuted ? 0 : musicVol; + musicRef.current.play().catch(() => {}); + }; + + const playSfx = (name: string, multiplier: number = 1.0) => { + playSfxService(name, sfxVol, isMuted, multiplier); + }; + + return { + musicRef, + playRandomMusic, + playSfx, + ensureAudio, + }; +}; diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts new file mode 100644 index 0000000..6b0b81b --- /dev/null +++ b/src/hooks/useSettings.ts @@ -0,0 +1,22 @@ +import { useState, useEffect } from 'react'; + +export const useSettings = () => { + const [musicVol, setMusicVol] = useState(parseFloat(localStorage.getItem("musicVol") || "0.4")); + const [sfxVol, setSfxVol] = useState(parseFloat(localStorage.getItem("sfxVol") || "0.7")); + const [isMuted, setIsMuted] = useState(localStorage.getItem("isMuted") === "true"); + + useEffect(() => { + localStorage.setItem("musicVol", musicVol.toString()); + localStorage.setItem("sfxVol", sfxVol.toString()); + localStorage.setItem("isMuted", isMuted.toString()); + }, [musicVol, sfxVol, isMuted]); + + return { + musicVol, + setMusicVol, + sfxVol, + setSfxVol, + isMuted, + setIsMuted, + }; +}; diff --git a/src/App.css b/src/index.css similarity index 97% rename from src/App.css rename to src/index.css index 6a17817..85bda6d 100644 --- a/src/App.css +++ b/src/index.css @@ -1,12 +1,13 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Fonts and Globals */ @font-face { font-family: 'Minecraft'; src: url('/fonts/Mojangles.ttf') format('truetype'); } -@tailwind base; -@tailwind components; -@tailwind utilities; - * { border-radius: 0 !important; font-family: 'Minecraft', sans-serif !important; @@ -28,7 +29,6 @@ main::before { content: ""; position: absolute; inset: 0; - /*background: rgba(0, 0, 0, 0.7);*/ z-index: 0; } @@ -41,6 +41,15 @@ main::before { scrollbar-width: none; } +.sidebar-progress { + background: #2a2a2a; + border-top: 4px solid #000; + padding: 20px; + position: relative; + box-shadow: inset 0 4px #444; +} + +/* Legacy UI Components */ .legacy-btn { @apply transition-all duration-75 flex items-center justify-center; background: #bebebe; @@ -83,14 +92,6 @@ main::before { outline-offset: -8px; } -.sidebar-progress { - background: #2a2a2a; - border-top: 4px solid #000; - padding: 20px; - position: relative; - box-shadow: inset 0 4px #444; -} - .mc-progress-container { background: #313131 !important; border: 4px solid #000 !important; @@ -133,6 +134,7 @@ main::before { } .mc-range { + appearance: none; -webkit-appearance: none; background: #000; height: 12px; @@ -167,12 +169,6 @@ main::before { transform: scale(1.1); } -.social-btn svg { - fill: white; - width: 32px; - height: 32px; -} - .btn-discord { background: #5865F2 !important; box-shadow: inset 4px 4px #8ea1e1, inset -4px -4px #313338 !important; @@ -203,8 +199,7 @@ main::before { from { transform: rotate(-20deg) scale(1); } - to { transform: rotate(-20deg) scale(1.05); } -} \ No newline at end of file +} diff --git a/src/main.tsx b/src/main.tsx index 1381b8e..02e3004 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,7 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; -import './App.css'; +import './index.css'; const rootElement = document.getElementById("root"); diff --git a/src/services/audio.ts b/src/services/audio.ts new file mode 100644 index 0000000..4d93c78 --- /dev/null +++ b/src/services/audio.ts @@ -0,0 +1,45 @@ +let audioCtx: AudioContext | null = null; +let sfxGain: GainNode | null = null; +const buffers: Record = {}; + +export const ensureAudio = async () => { + let currentCtx = audioCtx; + if (!currentCtx) { + const AC = (window as any).AudioContext || (window as any).webkitAudioContext; + if (!AC) return null; + const newCtx = new AC() as AudioContext; + const newGain = newCtx.createGain(); + newGain.connect(newCtx.destination); + audioCtx = newCtx; + sfxGain = newGain; + currentCtx = newCtx; + + const sounds = ['click.wav', 'orb.ogg', 'levelup.ogg', 'back.ogg', 'pop.wav', 'wood click.wav']; + sounds.forEach((s) => { + fetch(`/sounds/${s}`) + .then((r) => r.arrayBuffer()) + .then((b) => newCtx.decodeAudioData(b)) + .then((buf) => { + if (buf) buffers[s] = buf; + }); + }); + } + if (currentCtx.state === 'suspended') await currentCtx.resume(); + return { audioCtx, sfxGain, buffers }; +}; + +export const playSfx = async (n: string, sfxVol: number, isMuted: boolean, multiplier: number = 1.0) => { + const audioData = await ensureAudio(); + if (!audioData) return; + const { audioCtx, sfxGain, buffers } = audioData; + + if (!audioCtx || !sfxGain || !buffers[n] || isMuted) return; + + const s = audioCtx.createBufferSource(); + s.buffer = buffers[n]; + const g = audioCtx.createGain(); + g.gain.value = sfxVol * multiplier; + s.connect(g); + g.connect(audioCtx.destination); + s.start(0); +}; diff --git a/src/services/tauri.ts b/src/services/tauri.ts new file mode 100644 index 0000000..f40c149 --- /dev/null +++ b/src/services/tauri.ts @@ -0,0 +1,13 @@ +import { invoke } from "@tauri-apps/api/core"; +import { AppConfig, Runner } from "../types"; + +export const TauriService = { + loadConfig: () => invoke("load_config"), + saveConfig: (config: AppConfig) => invoke("save_config", { config }), + launchGame: (instanceId: string) => invoke("launch_game", { instanceId }), + downloadAndInstall: (url: string, instanceId: string) => invoke("download_and_install", { url, instanceId }), + checkGameInstalled: (instanceId: string) => invoke("check_game_installed", { instanceId }), + getAvailableRunners: () => invoke("get_available_runners"), + openInstanceFolder: (instanceId: string) => invoke("open_instance_folder", { instanceId }), + cancelDownload: () => invoke("cancel_download"), +}; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..5500c77 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,27 @@ +export interface Runner { + id: string; + name: string; + path: string; + type: string; +} + +export interface AppConfig { + username: string; + linuxRunner?: string; +} + +export interface InstalledStatus { + vanilla_tu19: boolean; + vanilla_tu24: boolean; + [key: string]: boolean; +} + +export interface ReinstallModalData { + id: string; + url: string; +} + +export interface McNotification { + t: string; + m: string; +}