settings, updater, license, autostart (wip), styling
This commit is contained in:
+1
-1
@@ -7,7 +7,7 @@
|
||||
<title>Rclone UI</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body class="select-none">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { fetch } from '@tauri-apps/plugin-http'
|
||||
import { usePersistedStore } from './store'
|
||||
|
||||
export async function validateLicense(licenseKey: string) {
|
||||
let id
|
||||
|
||||
try {
|
||||
id = await invoke('get_uid')
|
||||
} catch (e) {
|
||||
console.error(JSON.stringify(e))
|
||||
throw new Error('Failed to build unique identifier. Please try again later.')
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
throw new Error('Failed to build unique identifier. Please try again later.')
|
||||
}
|
||||
|
||||
const validationResponse = await fetch('https://rcloneui.com/api/v1/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
licenseKey,
|
||||
id,
|
||||
}),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.catch((e) => {
|
||||
console.error(JSON.stringify(e))
|
||||
throw new Error('Failed to validate license. Are you connected to the internet?')
|
||||
})
|
||||
|
||||
// console.log(JSON.stringify(validationResponse))
|
||||
|
||||
if (validationResponse.error) {
|
||||
throw new Error(validationResponse.error)
|
||||
}
|
||||
|
||||
if (!validationResponse.valid) {
|
||||
throw new Error('Invalid license key. Please check your license key and try again.')
|
||||
}
|
||||
|
||||
usePersistedStore.setState({ licenseKey, licenseValid: true })
|
||||
}
|
||||
|
||||
export async function revokeLicense(licenseKey: string) {
|
||||
let id
|
||||
|
||||
try {
|
||||
id = await invoke('get_uid')
|
||||
} catch (e) {
|
||||
console.error(JSON.stringify(e))
|
||||
throw new Error('Failed to build unique identifier. Please try again later.')
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
throw new Error('Failed to build unique identifier. Please try again later.')
|
||||
}
|
||||
|
||||
const revocationResponse = await fetch('https://rcloneui.com/api/v1/revoke', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
licenseKey,
|
||||
id,
|
||||
}),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.catch((e) => {
|
||||
console.error(JSON.stringify(e))
|
||||
throw new Error('Failed to revoke license. Are you connected to the internet?')
|
||||
})
|
||||
|
||||
// console.log(JSON.stringify(revocationResponse))
|
||||
|
||||
if (revocationResponse.error) {
|
||||
throw new Error(revocationResponse.error)
|
||||
}
|
||||
|
||||
if (!revocationResponse.revoked) {
|
||||
throw new Error('Failed to revoke license. Please check your license key and try again.')
|
||||
}
|
||||
|
||||
usePersistedStore.setState({ licenseKey: undefined, licenseValid: false })
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { confirm, message } from '@tauri-apps/plugin-dialog'
|
||||
import {} from '@tauri-apps/plugin-fs'
|
||||
import { ask, message } from '@tauri-apps/plugin-dialog'
|
||||
import { debug, error, info, trace, warn } from '@tauri-apps/plugin-log'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { exit } from '@tauri-apps/plugin-process'
|
||||
import { validateLicense } from './lib/license'
|
||||
import { listRemotes } from './lib/rclone/api'
|
||||
import { initRclone } from './lib/rclone/init'
|
||||
import { useStore } from './lib/store'
|
||||
import { usePersistedStore, useStore } from './lib/store'
|
||||
import { initLoadingTray, initTray, rebuildTrayMenu } from './lib/tray'
|
||||
|
||||
// forward console logs in webviews to the tauri logger, so they show up in terminal
|
||||
@@ -29,6 +29,61 @@ forwardConsole('info', info)
|
||||
forwardConsole('warn', warn)
|
||||
forwardConsole('error', error)
|
||||
|
||||
async function waitForHydration() {
|
||||
console.log('waiting for store hydration')
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
if (!usePersistedStore.persist.hasHydrated()) {
|
||||
await waitForHydration()
|
||||
}
|
||||
console.log('store hydrated')
|
||||
}
|
||||
|
||||
async function validateInstance() {
|
||||
const isOnline = navigator.onLine
|
||||
|
||||
if (!isOnline) {
|
||||
await ask(
|
||||
'You are not connected to the internet. Please check your internet connection and try again.',
|
||||
{
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
okLabel: 'Exit',
|
||||
cancelLabel: '',
|
||||
}
|
||||
)
|
||||
return await exit(0)
|
||||
}
|
||||
|
||||
const licenseKey = usePersistedStore.getState().licenseKey
|
||||
if (!licenseKey) {
|
||||
usePersistedStore.setState({ licenseValid: false })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await validateLicense(licenseKey)
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
await ask(e.message, {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
okLabel: 'Exit',
|
||||
cancelLabel: '',
|
||||
})
|
||||
await exit(0)
|
||||
return
|
||||
}
|
||||
|
||||
await ask('An error occurred while validating your license. Please try again.', {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
okLabel: 'Exit',
|
||||
cancelLabel: '',
|
||||
})
|
||||
await exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
async function startRclone() {
|
||||
try {
|
||||
const remotes = await listRemotes()
|
||||
@@ -43,9 +98,11 @@ async function startRclone() {
|
||||
try {
|
||||
rclone = await initRclone()
|
||||
} catch (error) {
|
||||
await confirm(error.message || 'Failed to provision rclone, please try again later.', {
|
||||
await ask(error.message || 'Failed to provision rclone, please try again later.', {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
okLabel: 'Exit',
|
||||
cancelLabel: '',
|
||||
})
|
||||
return await exit(0)
|
||||
}
|
||||
@@ -110,10 +167,24 @@ getCurrentWindow().listen('tauri://close-requested', async (e) => {
|
||||
|
||||
getCurrentWindow().listen('rebuild-tray', async (e) => {
|
||||
console.log('(main) window rebuild-tray requested')
|
||||
|
||||
// wait for store to be updated
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
|
||||
await rebuildTrayMenu()
|
||||
})
|
||||
|
||||
// function handleNetworkStatusChange() {
|
||||
// console.log('Network status changed. Online:', navigator.onLine)
|
||||
// // rebuildTrayMenu().catch(console.error)
|
||||
// }
|
||||
|
||||
// window.addEventListener('online', handleNetworkStatusChange)
|
||||
// window.addEventListener('offline', handleNetworkStatusChange)
|
||||
|
||||
initLoadingTray()
|
||||
.then(() => waitForHydration())
|
||||
.then(() => validateInstance())
|
||||
.then(() => startRclone())
|
||||
.then(() => initTray())
|
||||
.catch(console.error)
|
||||
|
||||
Generated
+18
-9
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@nextui-org/react": "^2.6.11",
|
||||
"@tauri-apps/api": "~2.2.0",
|
||||
"@tauri-apps/plugin-autostart": "^2.2.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.0",
|
||||
"@tauri-apps/plugin-fs": "^2.2.0",
|
||||
"@tauri-apps/plugin-http": "^2.2.0",
|
||||
@@ -17,10 +18,10 @@
|
||||
"@tauri-apps/plugin-notification": "^2.2.1",
|
||||
"@tauri-apps/plugin-opener": "^2.2.5",
|
||||
"@tauri-apps/plugin-os": "^2.2.0",
|
||||
"@tauri-apps/plugin-positioner": "^2.2.0",
|
||||
"@tauri-apps/plugin-process": "^2.2.0",
|
||||
"@tauri-apps/plugin-shell": "^2.2.0",
|
||||
"@tauri-apps/plugin-store": "^2.2.0",
|
||||
"@tauri-apps/plugin-updater": "^2.4.0",
|
||||
"@tauri-apps/plugin-window-state": "^2.2.0",
|
||||
"framer-motion": "^11.17.0",
|
||||
"lucide-react": "^0.471.0",
|
||||
@@ -4185,6 +4186,14 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-autostart": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-autostart/-/plugin-autostart-2.2.0.tgz",
|
||||
"integrity": "sha512-TzVcDZdOvdot0avkpstUWJKKEl4cyxLpFB9DZZRW5zH8k+Bv8IVJmO0zyYuw+7oKlGdHOINbD/7Je7GHMViw5w==",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.2.0.tgz",
|
||||
@@ -4241,14 +4250,6 @@
|
||||
"@tauri-apps/api": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-positioner": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-positioner/-/plugin-positioner-2.2.0.tgz",
|
||||
"integrity": "sha512-3JIWqV4U1US4nmM4PGmAHODq0ltkJ91MyANR033elKSSpX3AqKnVQnLz76xThBFHjChJrZwsXASpc2Inpu+cTg==",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-process": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.2.0.tgz",
|
||||
@@ -4273,6 +4274,14 @@
|
||||
"@tauri-apps/api": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-updater": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.4.0.tgz",
|
||||
"integrity": "sha512-BkeKN2WObAjobf2G77HyW/DxAfI0In+VSqWGnw/0cVPlM+VmA7fw9dKUnSunryZOG7ys9y07tj7FQa1ABMXGZQ==",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-window-state": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-window-state/-/plugin-window-state-2.2.0.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"dependencies": {
|
||||
"@nextui-org/react": "^2.6.11",
|
||||
"@tauri-apps/api": "~2.2.0",
|
||||
"@tauri-apps/plugin-autostart": "^2.2.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.0",
|
||||
"@tauri-apps/plugin-fs": "^2.2.0",
|
||||
"@tauri-apps/plugin-http": "^2.2.0",
|
||||
@@ -31,6 +32,7 @@
|
||||
"@tauri-apps/plugin-process": "^2.2.0",
|
||||
"@tauri-apps/plugin-shell": "^2.2.0",
|
||||
"@tauri-apps/plugin-store": "^2.2.0",
|
||||
"@tauri-apps/plugin-updater": "^2.4.0",
|
||||
"@tauri-apps/plugin-window-state": "^2.2.0",
|
||||
"framer-motion": "^11.17.0",
|
||||
"lucide-react": "^0.471.0",
|
||||
|
||||
Generated
+552
-292
File diff suppressed because it is too large
Load Diff
@@ -35,9 +35,12 @@ tauri-plugin-notification = { version = "2.0.0", features = [ "windows7-compat"
|
||||
tauri-plugin-os = "2"
|
||||
fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs" }
|
||||
zip = "0.6.6"
|
||||
machine-uid = "0.5.3"
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-single-instance = "2.2.0"
|
||||
tauri-plugin-updater = "2"
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
cocoa = "0.26"
|
||||
|
||||
@@ -242,6 +242,15 @@
|
||||
},
|
||||
"opener:default",
|
||||
|
||||
"updater:allow-download",
|
||||
"updater:allow-download-and-install",
|
||||
"updater:allow-check",
|
||||
"updater:allow-install",
|
||||
|
||||
"autostart:allow-enable",
|
||||
"autostart:allow-disable",
|
||||
"autostart:allow-is-enabled",
|
||||
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
@@ -253,6 +262,9 @@
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/**"
|
||||
},
|
||||
{
|
||||
"url": "https://rcloneui.com/**"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+46
-41
@@ -6,71 +6,76 @@
|
||||
// cwd: String,
|
||||
// }
|
||||
|
||||
|
||||
|
||||
use machine_uid;
|
||||
use std::fs::{self, File};
|
||||
use std::path::Path;
|
||||
use zip::ZipArchive;
|
||||
|
||||
#[tauri::command]
|
||||
fn unzip_file(zip_path: &str, output_folder: &str) -> Result<(), String> {
|
||||
// Open the zip file
|
||||
let file = File::open(zip_path).map_err(|e| e.to_string())?;
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
fs::create_dir_all(output_folder).map_err(|e| e.to_string())?;
|
||||
|
||||
// Create ZIP archive reader
|
||||
let mut archive = ZipArchive::new(file).map_err(|e| e.to_string())?;
|
||||
|
||||
// Extract everything
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).map_err(|e| e.to_string())?;
|
||||
let outpath = Path::new(output_folder).join(file.name());
|
||||
|
||||
if file.name().ends_with('/') {
|
||||
fs::create_dir_all(&outpath).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
if let Some(p) = outpath.parent() {
|
||||
fs::create_dir_all(p).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let mut outfile = File::create(&outpath).map_err(|e| e.to_string())?;
|
||||
std::io::copy(&mut file, &mut outfile).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// Get and set permissions (Unix only)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Some(mode) = file.unix_mode() {
|
||||
fs::set_permissions(&outpath, fs::Permissions::from_mode(mode))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
// Open the zip file
|
||||
let file = File::open(zip_path).map_err(|e| e.to_string())?;
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
fs::create_dir_all(output_folder).map_err(|e| e.to_string())?;
|
||||
|
||||
// Create ZIP archive reader
|
||||
let mut archive = ZipArchive::new(file).map_err(|e| e.to_string())?;
|
||||
|
||||
// Extract everything
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).map_err(|e| e.to_string())?;
|
||||
let outpath = Path::new(output_folder).join(file.name());
|
||||
|
||||
if file.name().ends_with('/') {
|
||||
fs::create_dir_all(&outpath).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
if let Some(p) = outpath.parent() {
|
||||
fs::create_dir_all(p).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let mut outfile = File::create(&outpath).map_err(|e| e.to_string())?;
|
||||
std::io::copy(&mut file, &mut outfile).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// Get and set permissions (Unix only)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Some(mode) = file.unix_mode() {
|
||||
fs::set_permissions(&outpath, fs::Permissions::from_mode(mode))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_arch() -> String {
|
||||
let arch = std::env::consts::ARCH;
|
||||
|
||||
|
||||
match arch {
|
||||
"aarch64" => "arm64".to_string(),
|
||||
"x86_64" => "amd64".to_string(),
|
||||
"i386" => "386".to_string(),
|
||||
"i386" => "386".to_string(),
|
||||
_ => "unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_uid() -> String {
|
||||
return machine_uid::get().unwrap();
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let mut app = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
// .plugin(tauri_plugin_autostart::init(tauri_plugin_autostart::MacosLauncher::LaunchAgent, Some(vec![])))
|
||||
// .plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {
|
||||
// println!("{}, {argv:?}, {cwd}", app.package_info().name);
|
||||
// // app.emit("single-instance", Payload { args: argv, cwd }).unwrap();
|
||||
@@ -88,7 +93,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_log::Builder::new().build())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![unzip_file, get_arch])
|
||||
.invoke_handler(tauri::generate_handler![unzip_file, get_arch, get_uid])
|
||||
.setup(|_app| Ok(()))
|
||||
// .setup(|app| {
|
||||
// if cfg!(debug_assertions) {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"targets": ["nsis", "dmg", "app", "appimage", "deb", "rpm"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
@@ -79,6 +79,19 @@
|
||||
},
|
||||
"hardenedRuntime": true,
|
||||
"minimumSystemVersion": "10.13"
|
||||
},
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installMode": "both"
|
||||
},
|
||||
"signCommand": "trusted-signing-cli %1 -e https://eus.codesigning.azure.net -a sign-1 -c Sign1"
|
||||
},
|
||||
"createUpdaterArtifacts": true
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIyNDFENEZGNjFDNTBGOEYKUldTUEQ4VmgvOVJCSWhVZmw0enhmcW1kWFk3TS9mMzBDRjVEZWdxKzQ5ZmRhTlYvT2gvdFNMbE8K",
|
||||
"endpoints": ["https://github.com/FTCHD/rclone-ui/releases/latest/download/latest.json"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,3 +20,10 @@ html {
|
||||
body {
|
||||
/* background-color: red; */
|
||||
}
|
||||
|
||||
/* * {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
} */
|
||||
|
||||
+670
-29
@@ -1,23 +1,607 @@
|
||||
import { Button, Card, CardBody } from '@nextui-org/react'
|
||||
import { confirm } from '@tauri-apps/plugin-dialog'
|
||||
import { CableIcon, PencilIcon, Plus, Trash2Icon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Button, Card, CardBody, Checkbox, Chip, Input, Tab, Tabs } from '@nextui-org/react'
|
||||
import { ask, message } from '@tauri-apps/plugin-dialog'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { relaunch } from '@tauri-apps/plugin-process'
|
||||
import { type Update, check } from '@tauri-apps/plugin-updater'
|
||||
import {
|
||||
CheckIcon,
|
||||
CogIcon,
|
||||
EyeIcon,
|
||||
MedalIcon,
|
||||
PlusIcon,
|
||||
ServerIcon,
|
||||
Trash2Icon,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { revokeLicense, validateLicense } from '../../lib/license'
|
||||
import { deleteRemote } from '../../lib/rclone/api'
|
||||
import { useStore } from '../../lib/store'
|
||||
import { usePersistedStore, useStore } from '../../lib/store'
|
||||
import { triggerTrayRebuild } from '../../lib/tray'
|
||||
import RemoteCreateDrawer from '../components/RemoteCreateDrawer'
|
||||
import RemoteDefaultsDrawer from '../components/RemoteDefaultsDrawer'
|
||||
import RemoteEditDrawer from '../components/RemoteEditDrawer'
|
||||
|
||||
function Settings() {
|
||||
const settingsPass = usePersistedStore((state) => state.settingsPass)
|
||||
|
||||
const [passwordCheckInput, setPasswordCheckInput] = useState('')
|
||||
const [passwordCheckPassed, setPasswordCheckPassed] = useState(false)
|
||||
const [passwordVisible, setPasswordVisible] = useState(false)
|
||||
|
||||
if (settingsPass && !passwordCheckPassed) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-screen h-screen gap-4 overflow-hidden animate-fade-in">
|
||||
<Input
|
||||
placeholder="Enter pin or password"
|
||||
value={passwordCheckInput}
|
||||
onChange={(e) => setPasswordCheckInput(e.target.value)}
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
type={passwordVisible ? 'text' : 'password'}
|
||||
fullWidth={false}
|
||||
size="lg"
|
||||
endContent={
|
||||
<Button
|
||||
onPress={() => setPasswordVisible(!passwordVisible)}
|
||||
isIconOnly={true}
|
||||
variant="light"
|
||||
data-focus-visible="false"
|
||||
>
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
onPress={async () => {
|
||||
if (passwordCheckInput === settingsPass) {
|
||||
setPasswordCheckPassed(true)
|
||||
return
|
||||
}
|
||||
|
||||
await message('The password you entered is incorrect.', {
|
||||
title: 'Login failed',
|
||||
kind: 'error',
|
||||
})
|
||||
}}
|
||||
data-focus-visible="false"
|
||||
color="primary"
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<RemotesSection />
|
||||
<div className="flex flex-col w-screen h-screen gap-0 overflow-hidden animate-fade-in">
|
||||
<Tabs
|
||||
aria-label="Options"
|
||||
isVertical={true}
|
||||
variant="light"
|
||||
destroyInactiveTabPanel={false}
|
||||
disableAnimation={true}
|
||||
className="flex-shrink-0 w-3/12 h-screen px-2 py-4 border-r border-neutral-700"
|
||||
classNames={{
|
||||
tabList: 'w-full gap-3',
|
||||
}}
|
||||
size="lg"
|
||||
defaultSelectedKey="general"
|
||||
color="secondary"
|
||||
>
|
||||
<Tab
|
||||
key="general"
|
||||
title={
|
||||
<div className="flex items-center space-x-2">
|
||||
<CogIcon className="w-5 h-5" />
|
||||
<span>General</span>
|
||||
</div>
|
||||
}
|
||||
data-focus-visible="false"
|
||||
className="w-full max-h-screen p-0 overflow-scroll overscroll-none"
|
||||
>
|
||||
<GeneralSection />
|
||||
</Tab>
|
||||
<Tab
|
||||
key="remotes"
|
||||
title={
|
||||
<div className="flex items-center space-x-2">
|
||||
<ServerIcon className="w-5 h-5" />
|
||||
<span>Remotes</span>
|
||||
</div>
|
||||
}
|
||||
data-focus-visible="false"
|
||||
className="w-full max-h-screen p-0 overflow-scroll overscroll-none"
|
||||
>
|
||||
<RemotesSection />
|
||||
</Tab>
|
||||
<Tab
|
||||
key="license"
|
||||
title={
|
||||
<div className="flex items-center space-x-2">
|
||||
<MedalIcon className="w-5 h-5" />
|
||||
<span>License</span>
|
||||
</div>
|
||||
}
|
||||
data-focus-visible="false"
|
||||
className="w-full max-h-screen p-0 overflow-scroll overscroll-none"
|
||||
>
|
||||
<LicenseSection />
|
||||
</Tab>
|
||||
{/* <Tab
|
||||
key="hosts"
|
||||
title={
|
||||
<div className="flex items-center space-x-2">
|
||||
<SwatchBookIcon className="w-5 h-5" />
|
||||
<span>Hosts</span>
|
||||
</div>
|
||||
}
|
||||
data-focus-visible="false"
|
||||
className="w-full max-h-screen p-0 overflow-scroll overscroll-none"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<BaseHeader title="Hosts" />
|
||||
</div>
|
||||
</Tab> */}
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RemotesSection = () => {
|
||||
function GeneralSection() {
|
||||
const settingsPass = usePersistedStore((state) => state.settingsPass)
|
||||
const setSettingsPass = usePersistedStore((state) => state.setSettingsPass)
|
||||
const [passwordInput, setPasswordInput] = useState('')
|
||||
const [passwordVisible, setPasswordVisible] = useState(false)
|
||||
|
||||
const disabledActions = usePersistedStore((state) => state.disabledActions)
|
||||
const setDisabledActions = usePersistedStore((state) => state.setDisabledActions)
|
||||
|
||||
const [update, setUpdate] = useState<Update | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// needed since the first value from the persisted store is undefined
|
||||
setPasswordInput(settingsPass || '')
|
||||
}, [settingsPass])
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
|
||||
useEffect(() => {
|
||||
triggerTrayRebuild()
|
||||
}, [disabledActions])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<BaseHeader title="General" />
|
||||
|
||||
<div className="flex flex-row justify-center w-full gap-8 px-8">
|
||||
<div className="flex flex-col items-end flex-1 gap-2 bg-transparent-500">
|
||||
<h3 className="font-medium">Password</h3>
|
||||
|
||||
<p className="text-xs text-neutral-500 text-end">
|
||||
Set a password to protect this settings panel
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-3/5 gap-2 bg-transparent-500">
|
||||
<Input
|
||||
placeholder="Enter password"
|
||||
value={passwordInput}
|
||||
onChange={(e) => setPasswordInput(e.target.value)}
|
||||
size="lg"
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
type={passwordVisible ? 'text' : 'password'}
|
||||
endContent={
|
||||
passwordInput && (
|
||||
<Button
|
||||
onPress={() => setPasswordVisible(!passwordVisible)}
|
||||
isIconOnly={true}
|
||||
variant="light"
|
||||
data-focus-visible="false"
|
||||
>
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
data-focus-visible="false"
|
||||
/>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
fullWidth={true}
|
||||
onPress={async () => {
|
||||
setSettingsPass(passwordInput)
|
||||
}}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
Change password
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
color="danger"
|
||||
fullWidth={true}
|
||||
onPress={async () => {
|
||||
setPasswordInput('')
|
||||
setSettingsPass(undefined)
|
||||
}}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
Remove password
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-center w-full gap-8 px-8">
|
||||
<div className="flex flex-col items-end flex-grow gap-2 bg-transparent-500">
|
||||
<h3 className="font-medium">Options</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-3/5 gap-3 bg-transparent-500">
|
||||
<Checkbox isDisabled={true}>
|
||||
<div className="flex flex-row gap-2">
|
||||
<p>Start on boot</p>
|
||||
<Chip size="sm" color="primary">
|
||||
Coming soon
|
||||
</Chip>
|
||||
</div>
|
||||
</Checkbox>
|
||||
|
||||
<Checkbox
|
||||
isSelected={!disabledActions?.includes('tray-mount')}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setDisabledActions(
|
||||
disabledActions?.filter((action) => action !== 'tray-mount') ||
|
||||
[]
|
||||
)
|
||||
} else {
|
||||
setDisabledActions([...(disabledActions || []), 'tray-mount'])
|
||||
}
|
||||
}}
|
||||
>
|
||||
Show <span className="font-mono text-blue-300">Mount</span> option
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
isSelected={!disabledActions?.includes('tray-sync')}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setDisabledActions(
|
||||
disabledActions?.filter((action) => action !== 'tray-sync') ||
|
||||
[]
|
||||
)
|
||||
} else {
|
||||
setDisabledActions([...(disabledActions || []), 'tray-sync'])
|
||||
}
|
||||
}}
|
||||
>
|
||||
Show <span className="font-mono text-blue-300">Sync</span> option
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
isSelected={!disabledActions?.includes('tray-copy')}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setDisabledActions(
|
||||
disabledActions?.filter((action) => action !== 'tray-copy') ||
|
||||
[]
|
||||
)
|
||||
} else {
|
||||
setDisabledActions([...(disabledActions || []), 'tray-copy'])
|
||||
}
|
||||
}}
|
||||
>
|
||||
Show <span className="font-mono text-blue-300">Copy</span> option
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
{/* <Button
|
||||
onPress={async () => {
|
||||
const enabled = await isEnabled()
|
||||
if (enabled) {
|
||||
await disable()
|
||||
} else {
|
||||
await enable()
|
||||
}
|
||||
}}
|
||||
>
|
||||
Start on boot
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onPress={async () => {
|
||||
const enabled = await isEnabled()
|
||||
alert(enabled ? 'Enabled' : 'Disabled')
|
||||
}}
|
||||
>
|
||||
Get status
|
||||
</Button> */}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-center w-full gap-8 px-8">
|
||||
<div className="flex flex-col items-end flex-grow gap-2 bg-transparent-500">
|
||||
<h3 className="font-medium">Update</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-3/5 gap-3 bg-transparent-500">
|
||||
{update ? (
|
||||
<Button
|
||||
onPress={async () => {
|
||||
let downloaded = 0
|
||||
let contentLength = 0
|
||||
|
||||
await update.downloadAndInstall((event) => {
|
||||
// biome-ignore lint/style/useDefaultSwitchClause: <explanation>
|
||||
switch (event.event) {
|
||||
case 'Started': {
|
||||
contentLength = event.data.contentLength || 0
|
||||
console.log(
|
||||
`started downloading ${event.data.contentLength} bytes`
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'Progress': {
|
||||
downloaded += event.data.chunkLength
|
||||
console.log(
|
||||
`downloaded ${downloaded} from ${contentLength}`
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'Finished':
|
||||
console.log('download finished')
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
const answer = await ask('Update installed. Ready to restart?', {
|
||||
title: 'Update',
|
||||
kind: 'info',
|
||||
okLabel: 'Restart',
|
||||
cancelLabel: '',
|
||||
})
|
||||
|
||||
if (!answer) {
|
||||
return
|
||||
}
|
||||
|
||||
await relaunch()
|
||||
}}
|
||||
>
|
||||
Check for updates
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onPress={async () => {
|
||||
const receivedUpdate = await check()
|
||||
if (!receivedUpdate) {
|
||||
return
|
||||
}
|
||||
console.log(
|
||||
`found update ${receivedUpdate.version} from ${receivedUpdate.date} with notes ${receivedUpdate.body}`
|
||||
)
|
||||
setUpdate(receivedUpdate)
|
||||
}}
|
||||
>
|
||||
Tap to update
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LicenseSection() {
|
||||
const [isLicenseEditable, setIsLicenseEditable] = useState(false)
|
||||
const licenseKey = usePersistedStore((state) => state.licenseKey)
|
||||
const licenseValid = usePersistedStore((state) => state.licenseValid)
|
||||
|
||||
const [isRevoking, setIsRevoking] = useState(false)
|
||||
const [isActivating, setIsActivating] = useState(false)
|
||||
const [licenseKeyInput, setLicenseKeyInput] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setLicenseKeyInput(licenseKey || '')
|
||||
setIsLicenseEditable(!licenseKey)
|
||||
}, [licenseKey])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<BaseHeader title="License" />
|
||||
|
||||
<div className="flex flex-row justify-center w-full gap-8 px-8">
|
||||
<div className="flex flex-col items-end w-2/6 gap-2 bg-transparent-500">
|
||||
<h3 className="font-medium">Activate</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-4/6 gap-2 bg-transparent-500">
|
||||
<Input
|
||||
placeholder="Enter license key"
|
||||
value={licenseKeyInput}
|
||||
onChange={(e) => setLicenseKeyInput(e.target.value)}
|
||||
size="lg"
|
||||
isDisabled={!isLicenseEditable || isActivating}
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
endContent={
|
||||
licenseValid && <CheckIcon className="w-5 h-5 text-green-500" />
|
||||
}
|
||||
data-focus-visible="false"
|
||||
/>
|
||||
|
||||
{!licenseValid && (
|
||||
<Button
|
||||
fullWidth={true}
|
||||
isLoading={isActivating}
|
||||
onPress={async () => {
|
||||
setIsActivating(true)
|
||||
try {
|
||||
await validateLicense(licenseKeyInput)
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
await ask(e.message, {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
okLabel: 'Ok',
|
||||
cancelLabel: '',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await ask('An error occurred. Please try again.', {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
okLabel: 'Ok',
|
||||
cancelLabel: '',
|
||||
})
|
||||
} finally {
|
||||
setIsActivating(false)
|
||||
}
|
||||
|
||||
await message('Your license has been activated successfully.', {
|
||||
title: 'Congrats!',
|
||||
kind: 'info',
|
||||
})
|
||||
}}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
Activate
|
||||
</Button>
|
||||
)}
|
||||
{licenseValid && (
|
||||
<Button
|
||||
fullWidth={true}
|
||||
isLoading={isRevoking}
|
||||
color="danger"
|
||||
variant="ghost"
|
||||
onPress={async () => {
|
||||
// usePersistedStore.setState({
|
||||
// licenseKey: undefined,
|
||||
// licenseValid: false,
|
||||
// })
|
||||
// return
|
||||
|
||||
const answer = await ask(
|
||||
'Are you sure you want to deactivate your license? You can always activate it again later.',
|
||||
{
|
||||
title: 'Deactivate License',
|
||||
kind: 'warning',
|
||||
}
|
||||
)
|
||||
|
||||
if (!answer) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsRevoking(true)
|
||||
try {
|
||||
await revokeLicense(licenseKeyInput)
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
await ask(e.message, {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
okLabel: 'Ok',
|
||||
cancelLabel: '',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await ask('An error occurred. Please try again.', {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
okLabel: 'Ok',
|
||||
cancelLabel: '',
|
||||
})
|
||||
} finally {
|
||||
setIsRevoking(false)
|
||||
}
|
||||
|
||||
await message('Your license has been deactivated.', {
|
||||
title: 'License deactivated',
|
||||
kind: 'info',
|
||||
})
|
||||
}}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
Deactivate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-center w-full gap-8 px-8">
|
||||
<div className="flex flex-col items-end w-2/6 gap-2 bg-transparent-500">
|
||||
<h3 className="font-medium">Buy</h3>
|
||||
|
||||
<p className="text-xs text-neutral-500 text-end">
|
||||
Includes access to future features and updates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-4/6 gap-3 bg-transparent-500">
|
||||
<Button
|
||||
size="lg"
|
||||
fullWidth={true}
|
||||
color="primary"
|
||||
variant="shadow"
|
||||
onPress={async () => {
|
||||
await openUrl('https://buy.stripe.com/test_dR67uygoIcYJ6hG4gg')
|
||||
}}
|
||||
>
|
||||
Lifetime License — $7
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <div className="flex flex-row justify-center w-full gap-8 px-8">
|
||||
<div className="flex flex-col items-end w-2/6 gap-2 bg-transparent-500">
|
||||
<h3 className="font-medium">Features</h3>
|
||||
<p className="text-xs text-neutral-500 text-end">What you get with a license</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-4/6 gap-2 bg-transparent-500">
|
||||
<Card className="border-none bg-background/60 dark:bg-default-100/60">
|
||||
<CardBody>
|
||||
<ul className="flex flex-col gap-3">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="w-5 h-5 text-green-500" />
|
||||
<span>Work with more than 3 remotes</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="w-5 h-5 text-green-500" />
|
||||
<span>Runs on up to 5 devices</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="w-5 h-5 text-green-500" />
|
||||
<span>File Commander</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="w-5 h-5 text-green-500" />
|
||||
<span>Mobile Client</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="w-5 h-5 text-green-500" />
|
||||
<span>Supporting Open Source</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RemotesSection() {
|
||||
const licenseValid = usePersistedStore((state) => state.licenseValid)
|
||||
|
||||
const remotes = useStore((state) => state.remotes)
|
||||
const removeRemote = useStore((state) => state.removeRemote)
|
||||
|
||||
@@ -35,6 +619,7 @@ const RemotesSection = () => {
|
||||
onPress={() => setCreatingDrawerOpen(true)}
|
||||
color="primary"
|
||||
data-focus-visible="false"
|
||||
variant="shadow"
|
||||
>
|
||||
Create Remote
|
||||
</Button>
|
||||
@@ -44,54 +629,79 @@ const RemotesSection = () => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="sticky top-0 z-50 flex items-center justify-between p-4 bg-neutral-900/50 backdrop-blur-lg">
|
||||
<h2 className="text-xl font-semibold">Remotes</h2>
|
||||
<Button
|
||||
onPress={() => setCreatingDrawerOpen(true)}
|
||||
isIconOnly={true}
|
||||
variant="light"
|
||||
data-focus-visible="false"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<BaseHeader
|
||||
title="Remotes"
|
||||
endContent={
|
||||
<Button
|
||||
onPress={async () => {
|
||||
if (!licenseValid) {
|
||||
await message(
|
||||
'Community version does not support adding more than 3 remotes.',
|
||||
{
|
||||
title: 'Missing license',
|
||||
kind: 'error',
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setCreatingDrawerOpen(true)
|
||||
}}
|
||||
isIconOnly={true}
|
||||
variant="faded"
|
||||
color="primary"
|
||||
data-focus-visible="false"
|
||||
size="sm"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
{remotes.map((remote) => (
|
||||
<Card key={remote} shadow="sm">
|
||||
<Card
|
||||
key={remote}
|
||||
shadow="sm"
|
||||
isBlurred={true}
|
||||
className="border-none bg-background/60 dark:bg-default-100/60"
|
||||
>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{remote}</span>
|
||||
<div className="space-x-2">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Button
|
||||
onPress={() => {
|
||||
setPickedRemote(remote)
|
||||
setDefaultsDrawerOpen(true)
|
||||
}}
|
||||
isIconOnly={true}
|
||||
// isIconOnly={true}
|
||||
color="primary"
|
||||
variant="light"
|
||||
variant="flat"
|
||||
data-focus-visible="false"
|
||||
>
|
||||
<CableIcon className="w-4 h-4" />
|
||||
{/* <CableIcon className="w-4 h-4" /> */}
|
||||
Edit Defaults
|
||||
</Button>
|
||||
<Button
|
||||
onPress={() => {
|
||||
setPickedRemote(remote)
|
||||
setEditingDrawerOpen(true)
|
||||
}}
|
||||
isIconOnly={true}
|
||||
color="primary"
|
||||
variant="light"
|
||||
// isIconOnly={true}
|
||||
// color="primary"
|
||||
variant="faded"
|
||||
data-focus-visible="false"
|
||||
>
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
{/* <PencilIcon className="w-4 h-4" /> */}
|
||||
Config
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
color="danger"
|
||||
variant="light"
|
||||
size="sm"
|
||||
onPress={async () => {
|
||||
const confirmation = await confirm(
|
||||
const confirmation = await ask(
|
||||
`Are you sure you want to remove ${remote}? This action cannot be reverted.`,
|
||||
{ title: `Removing ${remote}`, kind: 'warning' }
|
||||
)
|
||||
@@ -153,4 +763,35 @@ const RemotesSection = () => {
|
||||
)
|
||||
}
|
||||
|
||||
function BaseHeader({ title, endContent }: { title: string; endContent?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="sticky top-0 z-50 flex items-center justify-between p-4 h-14 bg-neutral-900/50 backdrop-blur-lg">
|
||||
<h2 className="text-xl font-semibold">{title}</h2>
|
||||
{endContent}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Settings
|
||||
|
||||
// {/* <Button
|
||||
// onPress={async () => {
|
||||
// const enabled = await isEnabled()
|
||||
// if (enabled) {
|
||||
// await disable()
|
||||
// } else {
|
||||
// await enable()
|
||||
// }
|
||||
// }}
|
||||
// >
|
||||
// Start on boot
|
||||
// </Button>
|
||||
|
||||
// <Button
|
||||
// onPress={async () => {
|
||||
// const enabled = await isEnabled()
|
||||
// alert(enabled ? 'Enabled' : 'Disabled')
|
||||
// }}
|
||||
// >
|
||||
// Get status
|
||||
// </Button> */}
|
||||
|
||||
Reference in New Issue
Block a user