feat: migrate UI and add modules manager
This commit is contained in:
parent
fdf7f9e3b4
commit
131a81970f
18 changed files with 1075 additions and 2141 deletions
3
webui/.vscode/extensions.json
vendored
Normal file
3
webui/.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
|
|
@ -1,73 +1 @@
|
|||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
# Magic Mount Webui
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
@ -4,10 +4,10 @@
|
|||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Magic Mount</title>
|
||||
<title>Magic Mount Webui</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
33
webui/jsconfig.json
Normal file
33
webui/jsconfig.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "bundler",
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
/**
|
||||
* svelte-preprocess cannot figure out whether you have
|
||||
* a value or a type, so tell TypeScript to enforce using
|
||||
* `import type` instead of `import` for Types.
|
||||
*/
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
/**
|
||||
* To have warnings / errors of the Svelte compiler at the
|
||||
* correct position, enable source maps by default.
|
||||
*/
|
||||
"sourceMap": true,
|
||||
"esModuleInterop": true,
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
/**
|
||||
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||
* Disable this if you'd like to use dynamic types.
|
||||
*/
|
||||
"checkJs": true
|
||||
},
|
||||
/**
|
||||
* Use global.d.ts instead of compilerOptions.types
|
||||
* to avoid limiting type declarations.
|
||||
*/
|
||||
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
|
||||
}
|
||||
|
|
@ -1,31 +1,19 @@
|
|||
{
|
||||
"name": "mm-webui",
|
||||
"name": "magic-mount-webui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"build": "vite build --emptyOutDir",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"kernelsu": "^2.1.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"svelte": "^5.43.8",
|
||||
"vite": "^7.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"kernelsu": "^2.1.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1556
webui/pnpm-lock.yaml
generated
1556
webui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
673
webui/src/App.svelte
Normal file
673
webui/src/App.svelte
Normal file
|
|
@ -0,0 +1,673 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { exec } from 'kernelsu';
|
||||
import locate from './locate.json';
|
||||
import './app.css';
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
moduledir: '/data/adb/modules',
|
||||
tempdir: '',
|
||||
mountsource: 'KSU',
|
||||
logfile: '/data/adb/magic_mount/mm.log',
|
||||
verbose: false,
|
||||
partitions: []
|
||||
};
|
||||
|
||||
const CONFIG_PATH = '/data/adb/magic_mount/mm.conf';
|
||||
|
||||
// i18n
|
||||
let lang = 'en';
|
||||
$: L = locate[lang];
|
||||
|
||||
// Autoload lang
|
||||
const availableLanguages = Object.keys(locate).map(code => ({
|
||||
code,
|
||||
name: locate[code]?.lang?.display || code.toUpperCase()
|
||||
}));
|
||||
|
||||
let langDropdownOpen = false;
|
||||
|
||||
function toggleLangDropdown() {
|
||||
langDropdownOpen = !langDropdownOpen;
|
||||
}
|
||||
|
||||
function selectLanguage(newLang) {
|
||||
lang = newLang;
|
||||
langDropdownOpen = false;
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem('mm-lang', newLang);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(event) {
|
||||
const dropdown = event.target.closest('.lang-dropdown');
|
||||
if (!dropdown && langDropdownOpen) {
|
||||
langDropdownOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
// tabs
|
||||
let activeTab = 'config';
|
||||
let isSwitching = false;
|
||||
|
||||
function switchTab(tab) {
|
||||
if (tab === activeTab || isSwitching) return;
|
||||
|
||||
isSwitching = true;
|
||||
|
||||
setTimeout(() => {
|
||||
activeTab = tab;
|
||||
isSwitching = false;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
|
||||
// config state
|
||||
let config = { ...DEFAULT_CONFIG };
|
||||
let configLoading = false;
|
||||
let configSaving = false;
|
||||
let configMessage = null;
|
||||
|
||||
// log state
|
||||
let logSelection = 'current';
|
||||
let logContent = '';
|
||||
let logLoading = false;
|
||||
let logError = null;
|
||||
|
||||
// partitions input
|
||||
let partitionInput = '';
|
||||
|
||||
// modules state
|
||||
let modules = [];
|
||||
let modulesLoading = false;
|
||||
let modulesError = null;
|
||||
|
||||
// helpers
|
||||
function isTrueValue(v) {
|
||||
const s = v.trim().toLowerCase();
|
||||
return s === '1' || s === 'true' || s === 'yes' || s === 'on';
|
||||
}
|
||||
|
||||
function parseKvConfig(text) {
|
||||
try {
|
||||
const result = { ...DEFAULT_CONFIG };
|
||||
const lines = text.split('\n');
|
||||
|
||||
for (let line of lines) {
|
||||
line = line.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
|
||||
const eqIndex = line.indexOf('=');
|
||||
if (eqIndex < 0) continue;
|
||||
|
||||
let key = line.slice(0, eqIndex).trim();
|
||||
let value = line.slice(eqIndex + 1).trim();
|
||||
|
||||
if (!key || !value) continue;
|
||||
|
||||
switch (key) {
|
||||
case 'module_dir':
|
||||
result.moduledir = value;
|
||||
break;
|
||||
case 'temp_dir':
|
||||
result.tempdir = value;
|
||||
break;
|
||||
case 'mount_source':
|
||||
result.mountsource = value;
|
||||
break;
|
||||
case 'log_file':
|
||||
result.logfile = value;
|
||||
break;
|
||||
case 'debug':
|
||||
result.verbose = isTrueValue(value);
|
||||
break;
|
||||
case 'partitions':
|
||||
result.partitions = value
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error('Failed to parse config:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function serializeKvConfig(cfg) {
|
||||
const lines = ['# Magic Mount Configuration File', '# Generated by Web UI', ''];
|
||||
|
||||
const moduleDir = cfg.moduledir || DEFAULT_CONFIG.moduledir;
|
||||
const mountSource = cfg.mountsource || DEFAULT_CONFIG.mountsource;
|
||||
const logFile = cfg.logfile || DEFAULT_CONFIG.logfile;
|
||||
|
||||
lines.push(`module_dir=${moduleDir}`);
|
||||
|
||||
if (cfg.tempdir) {
|
||||
lines.push(`temp_dir=${cfg.tempdir}`);
|
||||
}
|
||||
|
||||
lines.push(`mount_source=${mountSource}`);
|
||||
|
||||
if (logFile) {
|
||||
lines.push(`log_file=${logFile}`);
|
||||
}
|
||||
|
||||
lines.push(`debug=${cfg.verbose ? 'true' : 'false'}`);
|
||||
|
||||
if (cfg.partitions.length > 0) {
|
||||
lines.push(`partitions=${cfg.partitions.join(',')}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// load config
|
||||
async function loadConfig() {
|
||||
configLoading = true;
|
||||
configMessage = null;
|
||||
|
||||
try {
|
||||
const { errno, stdout, stderr } = await exec(
|
||||
`[ -f "${CONFIG_PATH}" ] && cat "${CONFIG_PATH}" || echo ""`
|
||||
);
|
||||
|
||||
if (errno !== 0) {
|
||||
console.error(stderr);
|
||||
config = { ...DEFAULT_CONFIG };
|
||||
partitionInput = '';
|
||||
configMessage = L.config.loadError;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stdout.trim()) {
|
||||
config = { ...DEFAULT_CONFIG };
|
||||
partitionInput = '';
|
||||
configMessage = L.config.loadDefault;
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseKvConfig(stdout);
|
||||
if (parsed) {
|
||||
config = parsed;
|
||||
partitionInput = parsed.partitions.join(', ');
|
||||
configMessage = L.config.loadSuccess;
|
||||
} else {
|
||||
config = { ...DEFAULT_CONFIG };
|
||||
partitionInput = '';
|
||||
configMessage = L.config.loadError;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
config = { ...DEFAULT_CONFIG };
|
||||
partitionInput = '';
|
||||
configMessage = L.config.loadError;
|
||||
} finally {
|
||||
configLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// save config
|
||||
async function saveConfig() {
|
||||
configSaving = true;
|
||||
configMessage = null;
|
||||
|
||||
try {
|
||||
const partitions = partitionInput
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s);
|
||||
|
||||
const configToSave = { ...config, partitions };
|
||||
|
||||
const content = serializeKvConfig(configToSave);
|
||||
const escapedContent = content.replace(/'/g, "'\\''");
|
||||
|
||||
const shell = `
|
||||
mkdir -p "$(dirname "${CONFIG_PATH}")" && \
|
||||
printf '%s\n' '${escapedContent}' > "${CONFIG_PATH}"
|
||||
`;
|
||||
|
||||
const { errno, stderr } = await exec(shell);
|
||||
|
||||
if (errno !== 0) {
|
||||
console.error(stderr);
|
||||
configMessage = L.config.saveFailed;
|
||||
} else {
|
||||
config = configToSave;
|
||||
configMessage = L.config.saveSuccess;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
configMessage = L.config.saveError;
|
||||
} finally {
|
||||
configSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// load log
|
||||
async function loadLog() {
|
||||
logLoading = true;
|
||||
logError = null;
|
||||
logContent = '';
|
||||
try {
|
||||
const basePath = config.logfile || DEFAULT_CONFIG.logfile;
|
||||
const fullPath = logSelection === 'current' ? basePath : `${basePath}.old`;
|
||||
|
||||
const { errno, stdout, stderr } = await exec(
|
||||
`[ -f "${fullPath}" ] && cat "${fullPath}" || echo "Log file not found"`
|
||||
);
|
||||
|
||||
if (errno !== 0) {
|
||||
console.error(stderr);
|
||||
logError = L.logs.readFailed + ` (errno=${errno})`;
|
||||
} else {
|
||||
logContent = stdout || '';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
logError = L.logs.readException;
|
||||
} finally {
|
||||
logLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// load modules
|
||||
async function loadModules() {
|
||||
modulesLoading = true;
|
||||
modulesError = null;
|
||||
modules = [];
|
||||
try {
|
||||
const moduleDir = config.moduledir || DEFAULT_CONFIG.moduledir;
|
||||
|
||||
const shell = `
|
||||
MOD_DIR="${moduleDir}"
|
||||
[ -d "$MOD_DIR" ] || exit 0
|
||||
for m in "$MOD_DIR"/*; do
|
||||
[ -d "$m" ] || continue
|
||||
[ -d "$m/system" ] || continue
|
||||
name="$(basename "$m")"
|
||||
disabled=0
|
||||
skip=0
|
||||
if [ -e "$m/disable" ] || [ -e "$m/remove" ]; then
|
||||
disabled=1
|
||||
fi
|
||||
if [ -e "$m/skip_mount" ]; then
|
||||
skip=1
|
||||
fi
|
||||
printf '%s|%s|%s\n' "$name" "$disabled" "$skip"
|
||||
done
|
||||
`;
|
||||
|
||||
const { errno, stdout, stderr } = await exec(shell);
|
||||
|
||||
if (errno !== 0) {
|
||||
console.error(stderr);
|
||||
modulesError = L.modules.loadError;
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = stdout
|
||||
.split('\n')
|
||||
.map(l => l.trim())
|
||||
.filter(l => l.length > 0);
|
||||
|
||||
modules = lines.map(line => {
|
||||
const [name, disabledStr, skipStr] = line.split('|');
|
||||
return {
|
||||
name: name || '(unknown)',
|
||||
disabledByFlag: disabledStr === '1',
|
||||
skipMount: skipStr === '1',
|
||||
toggling: false
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
modulesError = L.modules.loadError;
|
||||
} finally {
|
||||
modulesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSkipMount(mod) {
|
||||
if (mod.disabledByFlag) return;
|
||||
|
||||
const moduleDir = config.moduledir || DEFAULT_CONFIG.moduledir;
|
||||
const modulePath = `${moduleDir}/${mod.name}`;
|
||||
|
||||
modules = modules.map(m =>
|
||||
m.name === mod.name ? { ...m, toggling: true, error: undefined } : m
|
||||
);
|
||||
|
||||
try {
|
||||
const shell = mod.skipMount
|
||||
? `rm -f "${modulePath}/skip_mount"`
|
||||
: `touch "${modulePath}/skip_mount"`;
|
||||
|
||||
const { errno, stderr } = await exec(shell);
|
||||
|
||||
if (errno !== 0) {
|
||||
console.error(stderr);
|
||||
modules = modules.map(m =>
|
||||
m.name === mod.name ? { ...m, toggling: false, error: L.modules.toggleError } : m
|
||||
);
|
||||
} else {
|
||||
modules = modules.map(m =>
|
||||
m.name === mod.name
|
||||
? { ...m, skipMount: !m.skipMount, toggling: false, error: undefined }
|
||||
: m
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
modules = modules.map(m =>
|
||||
m.name === mod.name ? { ...m, toggling: false, error: L.modules.toggleError } : m
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = window.localStorage.getItem('mm-lang');
|
||||
if (saved && locate[saved]) {
|
||||
lang = saved;
|
||||
}
|
||||
|
||||
// 添加全局点击监听
|
||||
window.addEventListener('click', handleClickOutside);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
}
|
||||
loadConfig();
|
||||
});
|
||||
// reactive statements for tab changes
|
||||
let configLoaded = false;
|
||||
$: if (activeTab === 'config' && !configLoaded ) {
|
||||
loadConfig().then(() => {
|
||||
configLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
$: if (activeTab === 'log') {
|
||||
loadLog();
|
||||
}
|
||||
|
||||
$: if (activeTab === 'module') {
|
||||
loadModules();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = window.localStorage.getItem('mm-lang');
|
||||
if (saved && locate[saved]) {
|
||||
lang = saved;
|
||||
}
|
||||
|
||||
window.addEventListener('click', handleClickOutside);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
$: effectiveModuleDir = config.moduledir || DEFAULT_CONFIG.moduledir;
|
||||
</script>
|
||||
|
||||
<div class="app-root">
|
||||
<div class="top-bar">
|
||||
<div class="title">Magic Mount</div>
|
||||
|
||||
<div class="lang-dropdown">
|
||||
<button
|
||||
type="button"
|
||||
class="lang-select"
|
||||
on:click|stopPropagation={toggleLangDropdown}
|
||||
>
|
||||
{availableLanguages.find(l => l.code === lang)?.name || lang}
|
||||
<span class="arrow {langDropdownOpen ? 'open' : ''}">▼</span>
|
||||
</button>
|
||||
|
||||
{#if langDropdownOpen}
|
||||
<div class="lang-menu">
|
||||
{#each availableLanguages as { code, name }}
|
||||
<button
|
||||
type="button"
|
||||
class="lang-option {code === lang ? 'active' : ''}"
|
||||
on:click={() => selectLanguage(code)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-main">
|
||||
{#if isSwitching}
|
||||
<div class="tab-overlay">
|
||||
<div class="tab-spinner"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#key activeTab}
|
||||
{#if activeTab === 'config'}
|
||||
<div
|
||||
class="card"
|
||||
in:fade={{ duration: 180 }}
|
||||
out:fade={{ duration: 180 }}
|
||||
>
|
||||
<h2>{L.config.title}</h2>
|
||||
|
||||
{#if configLoading}
|
||||
<p class="hint">Loading...</p>
|
||||
{/if}
|
||||
{#if configMessage}
|
||||
<p class="hint">{configMessage}</p>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="verbose-setting">{L.config.verboseLabel}</label>
|
||||
<div class="loglevel-switch" id="verbose-setting" role="group">
|
||||
<button
|
||||
type="button"
|
||||
class="lv-btn {!config.verbose ? 'active' : ''}"
|
||||
on:click|preventDefault={() => (config.verbose = false)}
|
||||
>
|
||||
{L.config.verboseOff}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="lv-btn {config.verbose ? 'active' : ''}"
|
||||
on:click|preventDefault={() => (config.verbose = true)}
|
||||
>
|
||||
{L.config.verboseOn}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="moduledir-input">{L.config.moduleDir}</label>
|
||||
<input
|
||||
id="moduledir-input"
|
||||
type="text"
|
||||
bind:value={config.moduledir}
|
||||
placeholder={DEFAULT_CONFIG.moduledir}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="tempdir-input">{L.config.tempDir}</label>
|
||||
<input
|
||||
id="tempdir-input"
|
||||
type="text"
|
||||
bind:value={config.tempdir}
|
||||
placeholder="(auto-select if empty)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="mountsource-input">{L.config.mountSource}</label>
|
||||
<input
|
||||
id="mountsource-input"
|
||||
type="text"
|
||||
bind:value={config.mountsource}
|
||||
placeholder={DEFAULT_CONFIG.mountsource}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="logfile-input">{L.config.logFile}</label>
|
||||
<input
|
||||
id="logfile-input"
|
||||
type="text"
|
||||
bind:value={config.logfile}
|
||||
placeholder={DEFAULT_CONFIG.logfile}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="partitions-input">{L.config.partitions}</label>
|
||||
<input
|
||||
id="partitions-input"
|
||||
type="text"
|
||||
bind:value={partitionInput}
|
||||
placeholder="mi_ext,my_stock"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" on:click|preventDefault={loadConfig} disabled={configLoading || configSaving}>
|
||||
{L.config.reload}
|
||||
</button>
|
||||
<button type="button" class="primary" on:click|preventDefault={saveConfig} disabled={configSaving || configLoading}>
|
||||
{configSaving ? 'Saving...' : L.config.save}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="path">{L.config.pathLabel}: {CONFIG_PATH}</p>
|
||||
</div>
|
||||
|
||||
{:else if activeTab === 'module'}
|
||||
<div
|
||||
class="card"
|
||||
in:fade={{ duration: 180 }}
|
||||
out:fade={{ duration: 180 }}
|
||||
>
|
||||
<h2>{L.modules.title}</h2>
|
||||
|
||||
<p class="path">{L.modules.basePath}: {effectiveModuleDir}</p>
|
||||
|
||||
{#if modulesError}
|
||||
<p class="error">{modulesError}</p>
|
||||
{/if}
|
||||
{#if modulesLoading && modules.length === 0}
|
||||
<p class="hint">Loading...</p>
|
||||
{/if}
|
||||
|
||||
{#if !modulesLoading && modules.length === 0 && !modulesError}
|
||||
<p class="hint">{L.modules.empty}</p>
|
||||
{/if}
|
||||
|
||||
{#if !modulesLoading && modules.length > 0}
|
||||
<div class="module-list">
|
||||
{#each modules as m (m.name)}
|
||||
<div class="module-row">
|
||||
<div class="module-name">{m.name}</div>
|
||||
|
||||
<label class="switch {m.disabledByFlag ? 'disabled' : ''}">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={m.disabledByFlag ? false : !m.skipMount}
|
||||
disabled={m.disabledByFlag || m.toggling}
|
||||
on:change={() => {
|
||||
if (!m.disabledByFlag) toggleSkipMount(m);
|
||||
}}
|
||||
/>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
|
||||
{#if m.error}
|
||||
<p class="error small">{m.error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="actions" style="margin-top: 20px">
|
||||
<button type="button" on:click|preventDefault={loadModules} disabled={modulesLoading}>
|
||||
{modulesLoading ? 'Loading...' : L.modules.reload}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div
|
||||
class="card"
|
||||
in:fade={{ duration: 180 }}
|
||||
out:fade={{ duration: 180 }}
|
||||
>
|
||||
<h2>{L.logs.title}</h2>
|
||||
|
||||
<div class="field">
|
||||
<label for="log-select">{L.logs.select}</label>
|
||||
<div class="log-select-row">
|
||||
<select id="log-select" bind:value={logSelection}>
|
||||
<option value="current">{L.logs.current}</option>
|
||||
<option value="old">{L.logs.old}</option>
|
||||
</select>
|
||||
|
||||
<button type="button" class="refresh-btn" on:click|preventDefault={loadLog} disabled={logLoading}>
|
||||
{logLoading ? 'Loading...' : L.logs.refresh}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if logError}
|
||||
<p class="error">{logError}</p>
|
||||
{/if}
|
||||
|
||||
<pre class="log-view">{logLoading && !logContent ? 'Loading...' : logContent || L.logs.empty}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<div class="bottom-bar">
|
||||
<button
|
||||
type="button"
|
||||
class="tab-btn {activeTab === 'config' ? 'active' : ''}"
|
||||
on:click|preventDefault={() => switchTab('config')}
|
||||
>
|
||||
{L.tabs.config}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="tab-btn {activeTab === 'module' ? 'active' : ''}"
|
||||
on:click|preventDefault={() => switchTab('module')}
|
||||
>
|
||||
{L.tabs.modules}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="tab-btn {activeTab === 'log' ? 'active' : ''}"
|
||||
on:click|preventDefault={() => switchTab('log')}
|
||||
>
|
||||
{L.tabs.logs}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Component-specific styles only if needed */
|
||||
</style>
|
||||
|
|
@ -1,469 +0,0 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { exec } from "kernelsu";
|
||||
import locate from "./locate.json";
|
||||
import "./index.css";
|
||||
|
||||
type Lang = keyof typeof locate; // "en" | "zh"
|
||||
type Tab = "config" | "log";
|
||||
|
||||
type ConfigState = {
|
||||
moduledir: string;
|
||||
tempdir: string;
|
||||
mountsource: string;
|
||||
logfile: string;
|
||||
verbose: boolean;
|
||||
partitions: string[];
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: ConfigState = {
|
||||
moduledir: "/data/adb/modules",
|
||||
tempdir: "",
|
||||
mountsource: "KSU",
|
||||
logfile: "/data/adb/magic_mount/mm.log",
|
||||
verbose: false,
|
||||
partitions: [],
|
||||
};
|
||||
|
||||
const CONFIG_PATH = "/data/adb/magic_mount/mm.conf";
|
||||
|
||||
const App: React.FC = () => {
|
||||
// ---------- i18n ----------
|
||||
const [lang, setLang] = useState<Lang>(() => {
|
||||
if (typeof window === "undefined") return "en";
|
||||
const saved = window.localStorage.getItem("mm-lang");
|
||||
return saved === "zh" || saved === "en" ? (saved as Lang) : "en";
|
||||
});
|
||||
const L = locate[lang];
|
||||
|
||||
const handleLangChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = e.target.value as Lang;
|
||||
setLang(value);
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem("mm-lang", value);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- tabs ----------
|
||||
const [activeTab, setActiveTab] = useState<Tab>("config");
|
||||
|
||||
// ---------- config state ----------
|
||||
const [config, setConfig] = useState<ConfigState>(DEFAULT_CONFIG);
|
||||
const [configLoading, setConfigLoading] = useState(false);
|
||||
const [configSaving, setConfigSaving] = useState(false);
|
||||
const [configMessage, setConfigMessage] = useState<string | null>(null);
|
||||
|
||||
// ---------- log state ----------
|
||||
type LogSelection = "current" | "old";
|
||||
const [logSelection, setLogSelection] = useState<LogSelection>("current");
|
||||
const [logContent, setLogContent] = useState("");
|
||||
const [logLoading, setLogLoading] = useState(false);
|
||||
const [logError, setLogError] = useState<string | null>(null);
|
||||
|
||||
// ---------- partitions input ----------
|
||||
const [partitionInput, setPartitionInput] = useState("");
|
||||
|
||||
// ---------- helpers ----------
|
||||
const isTrueValue = (v: string): boolean => {
|
||||
const s = v.trim().toLowerCase();
|
||||
return s === "1" || s === "true" || s === "yes" || s === "on";
|
||||
};
|
||||
|
||||
const parseKvConfig = (text: string): ConfigState | null => {
|
||||
try {
|
||||
const result: ConfigState = { ...DEFAULT_CONFIG };
|
||||
const lines = text.split("\n");
|
||||
|
||||
for (let line of lines) {
|
||||
line = line.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
|
||||
const eqIndex = line.indexOf("=");
|
||||
if (eqIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let key = line.slice(0, eqIndex).trim();
|
||||
let value = line.slice(eqIndex + 1).trim();
|
||||
|
||||
if (!key || !value) continue;
|
||||
|
||||
switch (key) {
|
||||
case "module_dir":
|
||||
result.moduledir = value;
|
||||
break;
|
||||
case "temp_dir":
|
||||
result.tempdir = value;
|
||||
break;
|
||||
case "mount_source":
|
||||
result.mountsource = value;
|
||||
break;
|
||||
case "log_file":
|
||||
result.logfile = value;
|
||||
break;
|
||||
case "debug":
|
||||
result.verbose = isTrueValue(value);
|
||||
break;
|
||||
case "partitions":
|
||||
result.partitions = value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
break;
|
||||
default:
|
||||
// unknown key: ignore
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error("Failed to parse config:", e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const serializeKvConfig = (cfg: ConfigState): string => {
|
||||
const lines: string[] = ["# Magic Mount Configuration File", "# Generated by Web UI", ""];
|
||||
|
||||
const moduleDir = cfg.moduledir || DEFAULT_CONFIG.moduledir;
|
||||
const mountSource = cfg.mountsource || DEFAULT_CONFIG.mountsource;
|
||||
const logFile = cfg.logfile || DEFAULT_CONFIG.logfile;
|
||||
|
||||
lines.push(`module_dir=${moduleDir}`);
|
||||
|
||||
if (cfg.tempdir) {
|
||||
lines.push(`temp_dir=${cfg.tempdir}`);
|
||||
}
|
||||
|
||||
lines.push(`mount_source=${mountSource}`);
|
||||
|
||||
if (logFile) {
|
||||
lines.push(`log_file=${logFile}`);
|
||||
}
|
||||
|
||||
lines.push(`debug=${cfg.verbose ? "true" : "false"}`);
|
||||
|
||||
if (cfg.partitions.length > 0) {
|
||||
lines.push(`partitions=${cfg.partitions.join(",")}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
// ---------- read config ----------
|
||||
const loadConfig = async () => {
|
||||
setConfigLoading(true);
|
||||
setConfigMessage(null);
|
||||
|
||||
try {
|
||||
const { errno, stdout, stderr } = await exec(
|
||||
`[ -f "${CONFIG_PATH}" ] && cat "${CONFIG_PATH}" || echo ""`,
|
||||
);
|
||||
|
||||
if (errno !== 0) {
|
||||
console.error(stderr);
|
||||
setConfig(DEFAULT_CONFIG);
|
||||
setPartitionInput("");
|
||||
setConfigMessage(L.config.loadError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stdout.trim()) {
|
||||
setConfig(DEFAULT_CONFIG);
|
||||
setPartitionInput("");
|
||||
setConfigMessage(L.config.loadDefault);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseKvConfig(stdout);
|
||||
if (parsed) {
|
||||
setConfig(parsed);
|
||||
setPartitionInput(parsed.partitions.join(", "));
|
||||
setConfigMessage(L.config.loadSuccess);
|
||||
} else {
|
||||
setConfig(DEFAULT_CONFIG);
|
||||
setPartitionInput("");
|
||||
setConfigMessage(L.config.loadError);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setConfig(DEFAULT_CONFIG);
|
||||
setPartitionInput("");
|
||||
setConfigMessage(L.config.loadError);
|
||||
} finally {
|
||||
setConfigLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- save config ----------
|
||||
const saveConfig = async () => {
|
||||
setConfigSaving(true);
|
||||
setConfigMessage(null);
|
||||
|
||||
try {
|
||||
const partitions = partitionInput
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s);
|
||||
|
||||
const configToSave: ConfigState = { ...config, partitions };
|
||||
|
||||
const content = serializeKvConfig(configToSave);
|
||||
const escapedContent = content.replace(/'/g, "'\\''");
|
||||
|
||||
const shell = `
|
||||
mkdir -p "$(dirname "${CONFIG_PATH}")" && \
|
||||
printf '%s\n' '${escapedContent}' > "${CONFIG_PATH}"
|
||||
`;
|
||||
|
||||
const { errno, stderr } = await exec(shell);
|
||||
|
||||
if (errno !== 0) {
|
||||
console.error(stderr);
|
||||
setConfigMessage(L.config.saveFailed);
|
||||
} else {
|
||||
setConfig(configToSave);
|
||||
setConfigMessage(L.config.saveSuccess);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setConfigMessage(L.config.saveError);
|
||||
} finally {
|
||||
setConfigSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- read log ----------
|
||||
const loadLog = async () => {
|
||||
setLogLoading(true);
|
||||
setLogError(null);
|
||||
setLogContent("");
|
||||
try {
|
||||
const basePath = config.logfile || DEFAULT_CONFIG.logfile;
|
||||
const fullPath = logSelection === "current" ? basePath : `${basePath}.old`;
|
||||
|
||||
const { errno, stdout, stderr } = await exec(
|
||||
`[ -f "${fullPath}" ] && cat "${fullPath}" || echo "Log file not found"`,
|
||||
);
|
||||
|
||||
if (errno !== 0) {
|
||||
console.error(stderr);
|
||||
setLogError(L.logs.readFailed + ` (errno=${errno})`);
|
||||
} else {
|
||||
setLogContent(stdout || "");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setLogError(L.logs.readException);
|
||||
} finally {
|
||||
setLogLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// initial load config
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// reload log when tab or file changed
|
||||
useEffect(() => {
|
||||
if (activeTab === "log") {
|
||||
loadLog();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab, logSelection, config.logfile]);
|
||||
|
||||
// ---------- render tabs ----------
|
||||
|
||||
const renderConfigTab = () => (
|
||||
<div className="card">
|
||||
<h2>{L.config.title}</h2>
|
||||
|
||||
{configLoading && <p className="hint">Loading...</p>}
|
||||
{configMessage && <p className="hint">{configMessage}</p>}
|
||||
|
||||
{/* Verbose switch */}
|
||||
<div className="field">
|
||||
<label>{L.config.verboseLabel}</label>
|
||||
<div className="loglevel-switch">
|
||||
<button
|
||||
className={!config.verbose ? "lv-btn active" : "lv-btn"}
|
||||
onClick={() =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
verbose: false,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{L.config.verboseOff}
|
||||
</button>
|
||||
<button
|
||||
className={config.verbose ? "lv-btn active" : "lv-btn"}
|
||||
onClick={() =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
verbose: true,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{L.config.verboseOn}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Module Directory */}
|
||||
<div className="field">
|
||||
<label>{L.config.moduleDir}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.moduledir}
|
||||
placeholder={DEFAULT_CONFIG.moduledir}
|
||||
onChange={(e) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
moduledir: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Temp Directory */}
|
||||
<div className="field">
|
||||
<label>{L.config.tempDir}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.tempdir}
|
||||
placeholder="(auto-select if empty)"
|
||||
onChange={(e) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
tempdir: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mount Source */}
|
||||
<div className="field">
|
||||
<label>{L.config.mountSource}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.mountsource}
|
||||
placeholder={DEFAULT_CONFIG.mountsource}
|
||||
onChange={(e) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
mountsource: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Log File */}
|
||||
<div className="field">
|
||||
<label>{L.config.logFile}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.logfile}
|
||||
placeholder={DEFAULT_CONFIG.logfile}
|
||||
onChange={(e) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
logfile: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Partitions */}
|
||||
<div className="field">
|
||||
<label>{L.config.partitions}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={partitionInput}
|
||||
placeholder="mi_ext,my_stock"
|
||||
onChange={(e) => setPartitionInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="actions">
|
||||
<button onClick={loadConfig} disabled={configLoading || configSaving}>
|
||||
{L.config.reload}
|
||||
</button>
|
||||
<button className="primary" onClick={saveConfig} disabled={configSaving || configLoading}>
|
||||
{configSaving ? "Saving..." : L.config.save}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="path">
|
||||
{L.config.pathLabel}: {CONFIG_PATH}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderLogTab = () => {
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>{L.logs.title}</h2>
|
||||
|
||||
<div className="field">
|
||||
<label>{L.logs.select}</label>
|
||||
<div className="log-select-row">
|
||||
<select
|
||||
value={logSelection}
|
||||
onChange={(e) => setLogSelection(e.target.value as LogSelection)}
|
||||
>
|
||||
<option value="current">{L.logs.current}</option>
|
||||
<option value="old">{L.logs.old}</option>
|
||||
</select>
|
||||
|
||||
<button className="refresh-btn" onClick={loadLog} disabled={logLoading}>
|
||||
{logLoading ? "Loading..." : L.logs.refresh}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{logError && <p className="error">{logError}</p>}
|
||||
|
||||
<pre className="log-view">
|
||||
{logLoading && !logContent ? "Loading..." : logContent || L.logs.empty}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-root">
|
||||
{/* top bar with language selector */}
|
||||
<div className="top-bar">
|
||||
<div className="title">Magic Mount</div>
|
||||
|
||||
<select className="lang-select" value={lang} onChange={handleLangChange}>
|
||||
<option value="en">English</option>
|
||||
<option value="zh">中文</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="app-main">{activeTab === "config" ? renderConfigTab() : renderLogTab()}</div>
|
||||
|
||||
{/* bottom tabs */}
|
||||
<div className="bottom-bar">
|
||||
<button
|
||||
className={activeTab === "config" ? "tab-btn active" : "tab-btn"}
|
||||
onClick={() => setActiveTab("config")}
|
||||
>
|
||||
{L.tabs.config}
|
||||
</button>
|
||||
<button
|
||||
className={activeTab === "log" ? "tab-btn active" : "tab-btn"}
|
||||
onClick={() => setActiveTab("log")}
|
||||
>
|
||||
{L.tabs.logs}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
448
webui/src/app.css
Normal file
448
webui/src/app.css
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
/* For KernelSU WebUI */
|
||||
@import url("https://mui.kernelsu.org/internal/insets.css");
|
||||
|
||||
/* ------- Nord Base Colors ------- */
|
||||
:root {
|
||||
--nord0: #2e3440;
|
||||
--nord1: #3b4252;
|
||||
--nord2: #434c5e;
|
||||
--nord3: #4c566a;
|
||||
|
||||
--nord4: #d8dee9;
|
||||
--nord5: #e5e9f0;
|
||||
--nord6: #eceff4;
|
||||
|
||||
--nord7: #8fbcbb;
|
||||
--nord8: #88c0d0;
|
||||
--nord9: #81a1c1;
|
||||
--nord10: #5e81ac;
|
||||
|
||||
--nord11: #bf616a;
|
||||
--nord12: #d08770;
|
||||
--nord13: #ebcb8b;
|
||||
--nord14: #a3be8c;
|
||||
--nord15: #b48ead;
|
||||
}
|
||||
|
||||
/* ---------- Base Layout ---------- */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
padding-top: var(--window-inset-top, 0px);
|
||||
padding-bottom: var(--window-inset-bottom, 0px);
|
||||
margin: 0;
|
||||
background: var(--nord0);
|
||||
color: var(--nord6);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "SF Pro Text", Inter, "Segoe UI", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.app-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - var(--window-inset-top, 0px) - var(--window-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
/* Top bar for language selector */
|
||||
.top-bar {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
background: var(--nord1);
|
||||
border-bottom: 1px solid var(--nord2);
|
||||
}
|
||||
|
||||
/* Language dropdown container */
|
||||
.lang-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lang-select {
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--nord2);
|
||||
background: var(--nord0);
|
||||
color: var(--nord6);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 100px;
|
||||
justify-content: space-between;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.lang-select:hover {
|
||||
background: var(--nord2);
|
||||
}
|
||||
|
||||
.lang-select .arrow {
|
||||
font-size: 10px;
|
||||
transition: transform 0.2s;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.lang-select .arrow.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.lang-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
background: var(--nord0);
|
||||
border: 1px solid var(--nord2);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
min-width: 100px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.lang-option {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--nord6);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: 0.15s;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.lang-option:hover {
|
||||
background: var(--nord2);
|
||||
}
|
||||
|
||||
.lang-option.active {
|
||||
background: var(--nord8);
|
||||
color: var(--nord0);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.app-main {
|
||||
flex: 1;
|
||||
padding: 16px 16px 64px;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ---------- Card ---------- */
|
||||
.card {
|
||||
background: var(--nord1);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
color: var(--nord6);
|
||||
}
|
||||
|
||||
/* ---------- Form fields ---------- */
|
||||
.field {
|
||||
margin-bottom: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 13px;
|
||||
color: var(--nord4);
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--nord2);
|
||||
background: var(--nord0);
|
||||
color: var(--nord6);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
border-color: var(--nord8);
|
||||
box-shadow: 0 0 0 2px rgba(136, 192, 208, 0.2);
|
||||
}
|
||||
|
||||
/* ---------- Buttons ---------- */
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--nord2);
|
||||
color: var(--nord6);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--nord3);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--nord8);
|
||||
color: var(--nord0);
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: var(--nord9);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---------- Actions (centered) ---------- */
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ---------- Text helpers ---------- */
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--nord4);
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 12px;
|
||||
color: var(--nord11);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.path {
|
||||
font-size: 11px;
|
||||
color: var(--nord4);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ---------- Log viewer ---------- */
|
||||
.log-view {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: var(--nord0);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--nord2);
|
||||
color: var(--nord6);
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* ---------- Bottom Tabs ---------- */
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
bottom: var(--window-inset-bottom, 0px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 52px;
|
||||
background: var(--nord1);
|
||||
display: flex;
|
||||
border-top: 1px solid var(--nord2);
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
color: var(--nord6);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--nord2);
|
||||
}
|
||||
|
||||
/* ---------- loglevel Switch ---------- */
|
||||
.loglevel-switch {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--nord2);
|
||||
background: var(--nord0);
|
||||
}
|
||||
|
||||
.lv-btn {
|
||||
flex: 1;
|
||||
padding: 10px 0;
|
||||
text-align: center;
|
||||
background: var(--nord1);
|
||||
color: var(--nord6);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
font-size: 14px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.lv-btn:first-child {
|
||||
border-top-left-radius: 10px;
|
||||
border-bottom-left-radius: 10px;
|
||||
}
|
||||
|
||||
.lv-btn:last-child {
|
||||
border-top-right-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
}
|
||||
|
||||
.lv-btn:hover {
|
||||
background: var(--nord2);
|
||||
}
|
||||
|
||||
.lv-btn.active {
|
||||
background: var(--nord8);
|
||||
color: var(--nord0);
|
||||
}
|
||||
|
||||
/* ---------- Log select row ---------- */
|
||||
.log-select-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.log-select-row select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
padding: 8px 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* top bar title */
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--nord6);
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* module row */
|
||||
.module-list {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.module-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 8px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.module-name {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* switch slider */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0; left: 0;
|
||||
right: 0; bottom: 0;
|
||||
background-color: #666;
|
||||
transition: 0.3s;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: #4caf50;
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* disabled */
|
||||
.switch.disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* smaller error text */
|
||||
.error.small {
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
color: #f66;
|
||||
}
|
||||
|
||||
/* ---------- Tab switching overlay ---------- */
|
||||
.tab-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.tab-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(255,255,255,0.25);
|
||||
border-top-color: var(--nord8);
|
||||
animation: tab-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes tab-spin {
|
||||
from {
|
||||
transform: rotate(0deg) scale(0.9);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg) scale(1);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,77 +1,98 @@
|
|||
{
|
||||
"en": {
|
||||
"lang": {
|
||||
"display": "English"
|
||||
},
|
||||
"tabs": {
|
||||
"config": "Config",
|
||||
"modules": "Modules",
|
||||
"logs": "Logs"
|
||||
},
|
||||
"config": {
|
||||
"title": "Configuration",
|
||||
"reload": "Reload",
|
||||
"save": "Save",
|
||||
"loadSuccess": "Configuration loaded.",
|
||||
"loadDefault": "No config file found, using defaults.",
|
||||
"loadError": "Failed to read config, using default values.",
|
||||
"saveSuccess": "Configuration saved.",
|
||||
"saveFailed": "Failed to save configuration.",
|
||||
"saveError": "An error occurred while saving.",
|
||||
"pathLabel": "Config file path",
|
||||
|
||||
"verboseLabel": "Log Level",
|
||||
"verboseOff": "Normal",
|
||||
"verboseOn": "Verbose",
|
||||
"moduleDir": "Module Directory",
|
||||
"tempDir": "Temporary Directory",
|
||||
"tempDir": "Temp Directory",
|
||||
"mountSource": "Mount Source",
|
||||
"logFile": "Log File",
|
||||
"partitions": "Partitions",
|
||||
"verboseLabel": "Log Level",
|
||||
"verboseOff": "Info",
|
||||
"verboseOn": "Debug"
|
||||
"reload": "Reload",
|
||||
"save": "Save",
|
||||
"pathLabel": "Config Path",
|
||||
"loadError": "Failed to load config",
|
||||
"loadDefault": "Using default config",
|
||||
"loadSuccess": "Config loaded",
|
||||
"saveFailed": "Failed to save config",
|
||||
"saveSuccess": "Config saved successfully",
|
||||
"saveError": "Error saving config"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Log Viewer",
|
||||
"select": "Log File",
|
||||
"refresh": "Refresh",
|
||||
"empty": "(empty)",
|
||||
"readFailed": "Failed to read log file.",
|
||||
"readException": "An exception occurred while reading log.",
|
||||
"title": "Logs",
|
||||
"select": "Select Log File",
|
||||
"current": "Current Log",
|
||||
"old": "Old Log"
|
||||
"old": "Old Log",
|
||||
"refresh": "Refresh",
|
||||
"empty": "No logs available",
|
||||
"readFailed": "Failed to read log",
|
||||
"readException": "Error reading log"
|
||||
},
|
||||
"modules": {
|
||||
"title": "Modules",
|
||||
"basePath": "Module Directory",
|
||||
"empty": "No modules found",
|
||||
"reload": "Reload",
|
||||
"loadError": "Failed to load modules",
|
||||
"toggleError": "Failed to toggle module"
|
||||
}
|
||||
},
|
||||
|
||||
"zh": {
|
||||
"lang": {
|
||||
"display": "中文"
|
||||
},
|
||||
"tabs": {
|
||||
"config": "配置",
|
||||
"modules": "模块",
|
||||
"logs": "日志"
|
||||
},
|
||||
"config": {
|
||||
"title": "配置",
|
||||
"reload": "重载",
|
||||
"save": "保存",
|
||||
"loadSuccess": "已从配置文件加载。",
|
||||
"loadDefault": "未找到配置文件,已使用默认配置。",
|
||||
"loadError": "读取配置失败,已使用默认配置。",
|
||||
"saveSuccess": "配置已保存。",
|
||||
"saveFailed": "保存失败,请检查权限或日志。",
|
||||
"saveError": "保存配置时发生错误。",
|
||||
"pathLabel": "配置文件路径",
|
||||
|
||||
"verboseLabel": "日志级别",
|
||||
"verboseOff": "普通",
|
||||
"verboseOn": "详细",
|
||||
"moduleDir": "模块目录",
|
||||
"tempDir": "临时目录",
|
||||
"mountSource": "挂载源",
|
||||
"logFile": "日志文件",
|
||||
"partitions": "分区列表",
|
||||
"verboseLabel": "日志级别",
|
||||
"verboseOff": "信息",
|
||||
"verboseOn": "调试"
|
||||
"partitions": "分区",
|
||||
"reload": "重新加载",
|
||||
"save": "保存",
|
||||
"pathLabel": "配置路径",
|
||||
"loadError": "加载配置失败",
|
||||
"loadDefault": "使用默认配置",
|
||||
"loadSuccess": "配置已加载",
|
||||
"saveFailed": "保存配置失败",
|
||||
"saveSuccess": "配置保存成功",
|
||||
"saveError": "保存配置出错"
|
||||
},
|
||||
"logs": {
|
||||
"title": "日志查看",
|
||||
"select": "日志文件",
|
||||
"refresh": "刷新",
|
||||
"empty": "(空日志)",
|
||||
"readFailed": "读取日志失败。",
|
||||
"readException": "读取日志时发生异常。",
|
||||
"title": "日志",
|
||||
"select": "选择日志文件",
|
||||
"current": "当前日志",
|
||||
"old": "旧日志"
|
||||
"old": "旧日志",
|
||||
"refresh": "刷新",
|
||||
"empty": "暂无日志",
|
||||
"readFailed": "读取日志失败",
|
||||
"readException": "读取日志出错"
|
||||
},
|
||||
"modules": {
|
||||
"title": "模块",
|
||||
"basePath": "模块目录",
|
||||
"empty": "未找到模块",
|
||||
"reload": "重新加载",
|
||||
"loadError": "加载模块失败",
|
||||
"toggleError": "切换模块失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
webui/src/main.js
Normal file
8
webui/src/main.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { mount } from 'svelte'
|
||||
import App from './App.svelte'
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app'),
|
||||
})
|
||||
|
||||
export default app
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
8
webui/svelte.config.js
Normal file
8
webui/svelte.config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
|
||||
export default {
|
||||
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
11
webui/vite.config.js
Normal file
11
webui/vite.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
build: {
|
||||
outDir: '../template/webroot',
|
||||
},
|
||||
plugins: [svelte()],
|
||||
})
|
||||
Loading…
Reference in a new issue