feat(ui): add unified settings screen
This commit is contained in:
@@ -2,10 +2,8 @@ import { Select } from "@kobalte/core/select"
|
||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
import Kbd from "./kbd"
|
||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
import { useFolderDrop } from "../lib/hooks/use-folder-drop"
|
||||
import VersionPill from "./version-pill"
|
||||
@@ -14,6 +12,7 @@ import { githubStars } from "../stores/github-stars"
|
||||
import { formatCompactCount } from "../lib/formatters"
|
||||
import { useI18n, type Locale } from "../lib/i18n"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
||||
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
@@ -21,15 +20,11 @@ const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).h
|
||||
interface FolderSelectionViewProps {
|
||||
onSelectFolder: (folder: string, binaryPath?: string) => void
|
||||
isLoading?: boolean
|
||||
advancedSettingsOpen?: boolean
|
||||
onAdvancedSettingsOpen?: () => void
|
||||
onAdvancedSettingsClose?: () => void
|
||||
onOpenRemoteAccess?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings, updateLastUsedBinary } = useConfig()
|
||||
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings } = useConfig()
|
||||
const { t, locale } = useI18n()
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||
@@ -196,7 +191,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
})
|
||||
|
||||
function dropTargetBlocked() {
|
||||
return isLoading() || isFolderBrowserOpen() || Boolean(props.advancedSettingsOpen)
|
||||
return isLoading() || isFolderBrowserOpen() || settingsOpen()
|
||||
}
|
||||
|
||||
function showInvalidFolderDropAlert() {
|
||||
@@ -264,11 +259,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
handleFolderSelect(path)
|
||||
}
|
||||
|
||||
function handleBinaryChange(binary: string) {
|
||||
|
||||
setSelectedBinary(binary)
|
||||
}
|
||||
|
||||
function handleRemove(path: string, e?: Event) {
|
||||
if (isLoading()) return
|
||||
e?.stopPropagation()
|
||||
@@ -398,16 +388,24 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</Select>
|
||||
</div>
|
||||
<div class="absolute top-4 right-6 flex items-center gap-2">
|
||||
<ThemeModeToggle class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" />
|
||||
<Show when={props.onOpenRemoteAccess}>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
onClick={() => props.onOpenRemoteAccess?.()}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
onClick={() => openSettings("appearance")}
|
||||
aria-label={t("settings.open.title")}
|
||||
title={t("settings.open.title")}
|
||||
>
|
||||
<Settings class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
onClick={() => openSettings("remote")}
|
||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||
title={t("instanceTabs.remote.title")}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
<Show when={props.onClose}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -595,12 +593,12 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Advanced settings section */}
|
||||
{/* OpenCode settings section */}
|
||||
<div class="panel-section w-full">
|
||||
<button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between">
|
||||
<button onClick={() => openSettings("opencode")} class="panel-section-header w-full justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Settings class="w-4 h-4 icon-muted" />
|
||||
<span class="text-sm font-medium text-secondary">{t("folderSelection.advancedSettings")}</span>
|
||||
<span class="text-sm font-medium text-secondary">{t("folderSelection.opencode")}</span>
|
||||
</div>
|
||||
<ChevronRight class="w-4 h-4 icon-muted" />
|
||||
</button>
|
||||
@@ -661,14 +659,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<AdvancedSettingsModal
|
||||
open={Boolean(props.advancedSettingsOpen)}
|
||||
onClose={() => props.onAdvancedSettingsClose?.()}
|
||||
selectedBinary={selectedBinary()}
|
||||
onBinaryChange={handleBinaryChange}
|
||||
isLoading={props.isLoading}
|
||||
/>
|
||||
|
||||
<DirectoryBrowserDialog
|
||||
open={isFolderBrowserOpen()}
|
||||
title={t("folderSelection.dialog.title")}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||
import { Component, For, Show, createMemo } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { Instance } from "../types/instance"
|
||||
import InstanceTab from "./instance-tab"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import { Plus, MonitorUp, Bell, BellOff } from "lucide-solid"
|
||||
import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
||||
import NotificationsSettingsModal from "./notifications-settings-modal"
|
||||
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { openSettings } from "../stores/settings-screen"
|
||||
|
||||
interface InstanceTabsProps {
|
||||
instances: Map<string, Instance>
|
||||
@@ -17,13 +16,11 @@ interface InstanceTabsProps {
|
||||
onSelect: (instanceId: string) => void
|
||||
onClose: (instanceId: string) => void
|
||||
onNew: () => void
|
||||
onOpenRemoteAccess?: () => void
|
||||
}
|
||||
|
||||
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const { preferences } = useConfig()
|
||||
const [notificationsOpen, setNotificationsOpen] = createSignal(false)
|
||||
|
||||
const notificationsSupported = createMemo(() => isOsNotificationSupportedSync())
|
||||
const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled))
|
||||
@@ -33,8 +30,10 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
})
|
||||
|
||||
const notificationTitle = createMemo(() => {
|
||||
if (!notificationsSupported()) return "Notifications unsupported"
|
||||
return notificationsEnabled() ? "Notifications enabled" : "Notifications disabled"
|
||||
if (!notificationsSupported()) return t("settings.notifications.status.unsupported")
|
||||
return notificationsEnabled()
|
||||
? t("settings.notifications.status.enabled")
|
||||
: t("settings.notifications.status.disabled")
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -72,32 +71,35 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<ThemeModeToggle class="new-tab-button" />
|
||||
<button
|
||||
class="new-tab-button"
|
||||
onClick={() => openSettings("appearance")}
|
||||
title={t("settings.open.title")}
|
||||
aria-label={t("settings.open.ariaLabel")}
|
||||
>
|
||||
<Settings class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
|
||||
onClick={() => setNotificationsOpen(true)}
|
||||
title={notificationTitle()}
|
||||
aria-label={notificationTitle()}
|
||||
>
|
||||
<button
|
||||
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
|
||||
onClick={() => openSettings("notifications")}
|
||||
title={notificationTitle()}
|
||||
aria-label={notificationTitle()}
|
||||
>
|
||||
<Dynamic component={notificationIcon()} class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<Show when={Boolean(props.onOpenRemoteAccess)}>
|
||||
<button
|
||||
class="new-tab-button tab-remote-button"
|
||||
onClick={() => props.onOpenRemoteAccess?.()}
|
||||
title={t("instanceTabs.remote.title")}
|
||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
class="new-tab-button tab-remote-button"
|
||||
onClick={() => openSettings("remote")}
|
||||
title={t("instanceTabs.remote.title")}
|
||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NotificationsSettingsModal open={notificationsOpen()} onClose={() => setNotificationsOpen(false)} />
|
||||
</div>
|
||||
|
||||
)
|
||||
|
||||
109
packages/ui/src/components/settings-screen.tsx
Normal file
109
packages/ui/src/components/settings-screen.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, X } from "lucide-solid"
|
||||
import { createMemo, For, type Component } from "solid-js"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import {
|
||||
activeSettingsSection,
|
||||
closeSettings,
|
||||
settingsOpen,
|
||||
setActiveSettingsSection,
|
||||
type SettingsSectionId,
|
||||
} from "../stores/settings-screen"
|
||||
import { AppearanceSettingsSection } from "./settings/appearance-settings-section"
|
||||
import { NotificationsSettingsSection } from "./settings/notifications-settings-section"
|
||||
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
|
||||
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
|
||||
|
||||
export const SettingsScreen: Component = () => {
|
||||
const { t } = useI18n()
|
||||
|
||||
const sections = createMemo(() => [
|
||||
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
|
||||
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
||||
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
|
||||
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
|
||||
])
|
||||
|
||||
const renderSection = () => {
|
||||
switch (activeSettingsSection()) {
|
||||
case "notifications":
|
||||
return <NotificationsSettingsSection />
|
||||
case "remote":
|
||||
return <RemoteAccessSettingsSection />
|
||||
case "opencode":
|
||||
return <OpenCodeSettingsSection />
|
||||
case "appearance":
|
||||
default:
|
||||
return <AppearanceSettingsSection />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={settingsOpen()} onOpenChange={(open) => !open && closeSettings()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="settings-screen-frame">
|
||||
<Dialog.Content class="modal-surface settings-screen-shell">
|
||||
<Dialog.Title class="sr-only">{t("settings.title")}</Dialog.Title>
|
||||
<Dialog.Description class="sr-only">{t("settings.description")}</Dialog.Description>
|
||||
|
||||
<aside class="settings-screen-nav">
|
||||
<div class="settings-screen-nav-header">
|
||||
<div class="settings-screen-nav-title-row">
|
||||
<span class="settings-screen-nav-icon-wrap">
|
||||
<Settings class="settings-screen-nav-icon" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 class="settings-screen-title">{t("settings.title")}</h2>
|
||||
<p class="settings-screen-subtitle">{t("settings.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="settings-screen-nav-list" aria-label={t("settings.navigationAriaLabel")}>
|
||||
<For each={sections()}>
|
||||
{(section) => {
|
||||
const Icon = section.icon
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="settings-nav-button"
|
||||
data-selected={activeSettingsSection() === section.id ? "true" : "false"}
|
||||
onClick={() => setActiveSettingsSection(section.id)}
|
||||
>
|
||||
<Icon class="settings-nav-button-icon" />
|
||||
<span>{section.label}</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="settings-screen-content">
|
||||
<header class="settings-screen-content-header">
|
||||
<div class="settings-screen-content-header-title-group">
|
||||
<p class="settings-screen-content-eyebrow">{t("settings.content.eyebrow")}</p>
|
||||
<h1 class="settings-screen-content-title">
|
||||
{sections().find((section) => section.id === activeSettingsSection())?.label}
|
||||
</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary settings-screen-close"
|
||||
onClick={closeSettings}
|
||||
aria-label={t("settings.close")}
|
||||
title={t("settings.close")}
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="settings-screen-scroll">{renderSection()}</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Component } from "solid-js"
|
||||
import { Check, Laptop, Moon, Sun } from "lucide-solid"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import { useTheme, type ThemeMode } from "../../lib/theme"
|
||||
|
||||
const themeModeOptions: Array<{ value: ThemeMode; icon: typeof Laptop }> = [
|
||||
{ value: "system", icon: Laptop },
|
||||
{ value: "light", icon: Sun },
|
||||
{ value: "dark", icon: Moon },
|
||||
]
|
||||
|
||||
export const AppearanceSettingsSection: Component = () => {
|
||||
const { t } = useI18n()
|
||||
const { themeMode, setThemeMode } = useTheme()
|
||||
|
||||
const modeLabel = (mode: ThemeMode) => {
|
||||
if (mode === "system") return t("theme.mode.system")
|
||||
if (mode === "light") return t("theme.mode.light")
|
||||
return t("theme.mode.dark")
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="settings-section-stack">
|
||||
<div class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<div>
|
||||
<h3 class="settings-card-title">{t("settings.appearance.theme.title")}</h3>
|
||||
<p class="settings-card-subtitle">{t("settings.appearance.theme.subtitle")}</p>
|
||||
</div>
|
||||
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
|
||||
</div>
|
||||
<div class="settings-choice-grid">
|
||||
{themeModeOptions.map((option) => {
|
||||
const Icon = option.icon
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="settings-choice"
|
||||
data-selected={themeMode() === option.value ? "true" : "false"}
|
||||
onClick={() => setThemeMode(option.value)}
|
||||
>
|
||||
<span class="settings-choice-icon-wrap">
|
||||
<Icon class="settings-choice-icon" />
|
||||
</span>
|
||||
<span class="settings-choice-copy">
|
||||
<span class="settings-choice-label">{modeLabel(option.value)}</span>
|
||||
<span class="settings-choice-description">{t(`settings.appearance.theme.option.${option.value}`)}</span>
|
||||
</span>
|
||||
<span class="settings-choice-check" aria-hidden="true">
|
||||
<Check class="w-4 h-4" />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { Show, createEffect, createResource, type Component } from "solid-js"
|
||||
import { Bell } from "lucide-solid"
|
||||
import { showToastNotification } from "../../lib/notifications"
|
||||
import {
|
||||
getOsNotificationCapability,
|
||||
requestOsNotificationPermission,
|
||||
type OsNotificationPermission,
|
||||
} from "../../lib/os-notifications"
|
||||
import { useConfig } from "../../stores/preferences"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
|
||||
function formatPermissionLabel(permission: OsNotificationPermission, t: ReturnType<typeof useI18n>["t"]): string {
|
||||
switch (permission) {
|
||||
case "granted":
|
||||
return t("settings.notifications.permission.granted")
|
||||
case "denied":
|
||||
return t("settings.notifications.permission.denied")
|
||||
case "default":
|
||||
return t("settings.notifications.permission.default")
|
||||
case "unsupported":
|
||||
return t("settings.notifications.permission.unsupported")
|
||||
default:
|
||||
return String(permission)
|
||||
}
|
||||
}
|
||||
|
||||
export const NotificationsSettingsSection: Component = () => {
|
||||
const { t } = useI18n()
|
||||
const { preferences, updatePreferences } = useConfig()
|
||||
const [capability, { refetch }] = createResource(() => getOsNotificationCapability())
|
||||
|
||||
createEffect(() => {
|
||||
void refetch()
|
||||
})
|
||||
|
||||
const handleEnableToggle = async (enabled: boolean) => {
|
||||
if (!enabled) {
|
||||
updatePreferences({ osNotificationsEnabled: false })
|
||||
return
|
||||
}
|
||||
|
||||
const cap = capability()
|
||||
if (cap && !cap.supported) {
|
||||
showToastNotification({
|
||||
title: t("settings.section.notifications.title"),
|
||||
message: cap.info ?? t("settings.notifications.messages.unsupportedEnvironment"),
|
||||
variant: "warning",
|
||||
})
|
||||
updatePreferences({ osNotificationsEnabled: false })
|
||||
return
|
||||
}
|
||||
|
||||
const permission = await requestOsNotificationPermission()
|
||||
if (permission !== "granted") {
|
||||
showToastNotification({
|
||||
title: t("settings.section.notifications.title"),
|
||||
message:
|
||||
permission === "denied"
|
||||
? t("settings.notifications.messages.permissionDenied")
|
||||
: t("settings.notifications.messages.permissionNotGranted"),
|
||||
variant: "warning",
|
||||
})
|
||||
updatePreferences({ osNotificationsEnabled: false })
|
||||
return
|
||||
}
|
||||
|
||||
updatePreferences({ osNotificationsEnabled: true })
|
||||
void refetch()
|
||||
}
|
||||
|
||||
const handleRequestPermission = async () => {
|
||||
const cap = capability()
|
||||
if (cap && !cap.supported) {
|
||||
showToastNotification({
|
||||
title: t("settings.section.notifications.title"),
|
||||
message: cap.info ?? t("settings.notifications.messages.unsupportedGeneral"),
|
||||
variant: "warning",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const permission = await requestOsNotificationPermission()
|
||||
if (permission === "granted") {
|
||||
showToastNotification({
|
||||
title: t("settings.section.notifications.title"),
|
||||
message: t("settings.notifications.messages.permissionGranted"),
|
||||
variant: "success",
|
||||
duration: 6000,
|
||||
})
|
||||
void refetch()
|
||||
return
|
||||
}
|
||||
|
||||
showToastNotification({
|
||||
title: t("settings.section.notifications.title"),
|
||||
message:
|
||||
permission === "denied"
|
||||
? t("settings.notifications.messages.permissionRequestDenied")
|
||||
: t("settings.notifications.messages.permissionNotGranted"),
|
||||
variant: "warning",
|
||||
})
|
||||
void refetch()
|
||||
}
|
||||
|
||||
const supported = () => capability()?.supported ?? false
|
||||
const permissionLabel = () => formatPermissionLabel(capability()?.permission ?? "unsupported", t)
|
||||
const infoMessage = () => capability()?.info
|
||||
|
||||
return (
|
||||
<div class="settings-section-stack">
|
||||
<div class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<div class="settings-card-heading-with-icon">
|
||||
<Bell class="settings-card-heading-icon" />
|
||||
<div>
|
||||
<h3 class="settings-card-title">{t("settings.notifications.sessionStatus.title")}</h3>
|
||||
<p class="settings-card-subtitle">{t("settings.notifications.sessionStatus.subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
|
||||
</div>
|
||||
|
||||
<div class="settings-stack">
|
||||
<div class="settings-toggle-row">
|
||||
<div>
|
||||
<div class="settings-toggle-title">{t("settings.notifications.enable.title")}</div>
|
||||
<div class="settings-toggle-caption">
|
||||
{t("settings.notifications.enable.permission", { permission: permissionLabel() })}
|
||||
</div>
|
||||
</div>
|
||||
<label class="settings-checkbox-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(preferences().osNotificationsEnabled)}
|
||||
disabled={!supported() && capability.state === "ready"}
|
||||
onChange={(event) => void handleEnableToggle(event.currentTarget.checked)}
|
||||
/>
|
||||
<span>{t("settings.common.enabled")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Show when={supported() && (capability()?.permission ?? "unsupported") !== "granted"}>
|
||||
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||
<div>
|
||||
<div class="settings-toggle-title">{t("settings.notifications.requestPermission.title")}</div>
|
||||
<div class="settings-toggle-caption">{t("settings.notifications.requestPermission.subtitle")}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary w-auto whitespace-nowrap"
|
||||
onClick={() => void handleRequestPermission()}
|
||||
>
|
||||
{t("settings.notifications.requestPermission.action")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="settings-toggle-row">
|
||||
<div>
|
||||
<div class="settings-toggle-title">{t("settings.notifications.allowVisible.title")}</div>
|
||||
<div class="settings-toggle-caption">{t("settings.notifications.allowVisible.subtitle")}</div>
|
||||
</div>
|
||||
<label class="settings-checkbox-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(preferences().osNotificationsAllowWhenVisible)}
|
||||
disabled={!preferences().osNotificationsEnabled}
|
||||
onChange={(event) => updatePreferences({ osNotificationsAllowWhenVisible: event.currentTarget.checked })}
|
||||
/>
|
||||
<span>{t("settings.common.enabled")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Show when={Boolean(infoMessage())}>
|
||||
<div class="settings-inline-note">{infoMessage()}</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!supported() && capability.state === "ready"}>
|
||||
<div class="settings-inline-note">{t("settings.notifications.unsupportedNote")}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<div>
|
||||
<h3 class="settings-card-title">{t("settings.notifications.events.title")}</h3>
|
||||
<p class="settings-card-subtitle">{t("settings.notifications.events.subtitle")}</p>
|
||||
</div>
|
||||
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
|
||||
</div>
|
||||
|
||||
<div class="settings-stack">
|
||||
<div class="settings-toggle-row">
|
||||
<div>
|
||||
<div class="settings-toggle-title">{t("settings.notifications.events.needsInput")}</div>
|
||||
</div>
|
||||
<label class="settings-checkbox-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(preferences().notifyOnNeedsInput)}
|
||||
disabled={!preferences().osNotificationsEnabled}
|
||||
onChange={(event) => updatePreferences({ notifyOnNeedsInput: event.currentTarget.checked })}
|
||||
/>
|
||||
<span>{t("settings.common.enabled")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-toggle-row">
|
||||
<div>
|
||||
<div class="settings-toggle-title">{t("settings.notifications.events.idle")}</div>
|
||||
</div>
|
||||
<label class="settings-checkbox-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(preferences().notifyOnIdle)}
|
||||
disabled={!preferences().osNotificationsEnabled}
|
||||
onChange={(event) => updatePreferences({ notifyOnIdle: event.currentTarget.checked })}
|
||||
/>
|
||||
<span>{t("settings.common.enabled")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createEffect, createSignal, type Component } from "solid-js"
|
||||
import { Terminal } from "lucide-solid"
|
||||
import OpenCodeBinarySelector from "../opencode-binary-selector"
|
||||
import EnvironmentVariablesEditor from "../environment-variables-editor"
|
||||
import { useConfig } from "../../stores/preferences"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
|
||||
export const OpenCodeSettingsSection: Component = () => {
|
||||
const { t } = useI18n()
|
||||
const { serverSettings, updateLastUsedBinary } = useConfig()
|
||||
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
||||
|
||||
createEffect(() => {
|
||||
const binary = serverSettings().opencodeBinary || "opencode"
|
||||
setSelectedBinary((current) => (current === binary ? current : binary))
|
||||
})
|
||||
|
||||
const handleBinaryChange = (binary: string) => {
|
||||
setSelectedBinary(binary)
|
||||
updateLastUsedBinary(binary)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="settings-section-stack">
|
||||
<div class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<div class="settings-card-heading-with-icon">
|
||||
<Terminal class="settings-card-heading-icon" />
|
||||
<div>
|
||||
<h3 class="settings-card-title">{t("settings.opencode.runtime.title")}</h3>
|
||||
<p class="settings-card-subtitle">{t("settings.opencode.runtime.subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||
</div>
|
||||
|
||||
<OpenCodeBinarySelector selectedBinary={selectedBinary()} onBinaryChange={handleBinaryChange} isVisible />
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<div>
|
||||
<h3 class="settings-card-title">{t("advancedSettings.environmentVariables.title")}</h3>
|
||||
<p class="settings-card-subtitle">{t("advancedSettings.environmentVariables.subtitle")}</p>
|
||||
</div>
|
||||
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||
</div>
|
||||
<EnvironmentVariablesEditor />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import { Switch } from "@kobalte/core/switch"
|
||||
import { For, Show, createMemo, createSignal, type Component, onMount } from "solid-js"
|
||||
import { toDataURL } from "qrcode"
|
||||
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
||||
import type { NetworkAddress, ServerMeta } from "../../../../server/src/api-types"
|
||||
import { serverApi } from "../../lib/api-client"
|
||||
import { restartCli } from "../../lib/native/cli"
|
||||
import { serverSettings, setListeningMode } from "../../stores/preferences"
|
||||
import { showConfirmDialog } from "../../stores/alerts"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
export const RemoteAccessSettingsSection: Component = () => {
|
||||
const { t } = useI18n()
|
||||
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
||||
const [authStatus, setAuthStatus] = createSignal<{
|
||||
authenticated: boolean
|
||||
username?: string
|
||||
passwordUserProvided?: boolean
|
||||
} | null>(null)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [applyingListeningMode, setApplyingListeningMode] = createSignal(false)
|
||||
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
|
||||
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [passwordFormOpen, setPasswordFormOpen] = createSignal(false)
|
||||
const [passwordValue, setPasswordValue] = createSignal("")
|
||||
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
||||
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
||||
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||
|
||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||
const displayAddresses = createMemo(() => {
|
||||
const list = addresses()
|
||||
if (!allowExternalConnections()) return []
|
||||
return list.filter((address) => address.scope !== "loopback")
|
||||
})
|
||||
|
||||
const refreshMeta = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setPasswordError(null)
|
||||
try {
|
||||
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
||||
setMeta(metaResult)
|
||||
setAuthStatus(authResult)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void refreshMeta()
|
||||
})
|
||||
|
||||
const toggleExpanded = async (url: string) => {
|
||||
if (expandedUrl() === url) {
|
||||
setExpandedUrl(null)
|
||||
return
|
||||
}
|
||||
setExpandedUrl(url)
|
||||
if (!qrCodes()[url]) {
|
||||
try {
|
||||
const dataUrl = await toDataURL(url, { margin: 1, scale: 4 })
|
||||
setQrCodes((prev) => ({ ...prev, [url]: dataUrl }))
|
||||
} catch (err) {
|
||||
log.error("Failed to generate QR code", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleAllowConnectionsChange = async (checked: boolean) => {
|
||||
const targetMode: "local" | "all" = checked ? "all" : "local"
|
||||
if (targetMode === currentMode() || applyingListeningMode()) return
|
||||
|
||||
const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), {
|
||||
title: checked
|
||||
? t("remoteAccess.listeningMode.restartConfirm.title.all")
|
||||
: t("remoteAccess.listeningMode.restartConfirm.title.local"),
|
||||
variant: "warning",
|
||||
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
|
||||
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
setApplyingListeningMode(true)
|
||||
setError(null)
|
||||
try {
|
||||
await setListeningMode(targetMode)
|
||||
const restarted = await restartCli()
|
||||
if (!restarted) {
|
||||
setError(t("remoteAccess.restart.errorManual"))
|
||||
} else {
|
||||
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setApplyingListeningMode(false)
|
||||
}
|
||||
|
||||
void refreshMeta()
|
||||
}
|
||||
|
||||
const handleOpenUrl = (url: string) => {
|
||||
try {
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
} catch (err) {
|
||||
log.error("Failed to open URL", err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitPassword = async () => {
|
||||
setPasswordError(null)
|
||||
|
||||
const next = passwordValue()
|
||||
const confirm = passwordConfirm()
|
||||
if (next.trim().length < 8) {
|
||||
setPasswordError(t("remoteAccess.password.error.tooShort"))
|
||||
return
|
||||
}
|
||||
if (next !== confirm) {
|
||||
setPasswordError(t("remoteAccess.password.error.mismatch"))
|
||||
return
|
||||
}
|
||||
|
||||
setSavingPassword(true)
|
||||
try {
|
||||
const result = await serverApi.setServerPassword(next)
|
||||
setAuthStatus({
|
||||
authenticated: true,
|
||||
username: result.username,
|
||||
passwordUserProvided: result.passwordUserProvided,
|
||||
})
|
||||
setPasswordValue("")
|
||||
setPasswordConfirm("")
|
||||
setPasswordFormOpen(false)
|
||||
} catch (err) {
|
||||
setPasswordError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSavingPassword(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="settings-section-stack">
|
||||
<div class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<div class="settings-card-heading-with-icon">
|
||||
<Shield class="settings-card-heading-icon" />
|
||||
<div>
|
||||
<h3 class="settings-card-title">{t("remoteAccess.sections.listeningMode.label")}</h3>
|
||||
<p class="settings-card-subtitle">{t("remoteAccess.sections.listeningMode.help")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-toolbar-inline">
|
||||
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||
<button
|
||||
class="selector-button selector-button-secondary w-auto"
|
||||
type="button"
|
||||
onClick={() => void refreshMeta()}
|
||||
disabled={loading()}
|
||||
>
|
||||
<RefreshCw class={`w-4 h-4 ${loading() ? "remote-spin" : ""}`} />
|
||||
<span>{t("remoteAccess.refresh")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
class="remote-toggle"
|
||||
checked={allowExternalConnections()}
|
||||
onChange={(nextChecked) => void handleAllowConnectionsChange(nextChecked)}
|
||||
disabled={loading() || applyingListeningMode()}
|
||||
>
|
||||
<Switch.Input />
|
||||
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>
|
||||
<span class="remote-toggle-state">
|
||||
{allowExternalConnections() ? t("remoteAccess.toggle.on") : t("remoteAccess.toggle.off")}
|
||||
</span>
|
||||
<Switch.Thumb class="remote-toggle-thumb" />
|
||||
</Switch.Control>
|
||||
<div class="remote-toggle-copy">
|
||||
<span class="remote-toggle-title">{t("remoteAccess.toggle.title")}</span>
|
||||
<span class="remote-toggle-caption">
|
||||
{allowExternalConnections()
|
||||
? t("remoteAccess.toggle.caption.all")
|
||||
: t("remoteAccess.toggle.caption.local")}
|
||||
</span>
|
||||
</div>
|
||||
</Switch>
|
||||
|
||||
<p class="remote-toggle-note">{t("remoteAccess.toggle.note")}</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<div class="settings-card-heading-with-icon">
|
||||
<Shield class="settings-card-heading-icon" />
|
||||
<div>
|
||||
<h3 class="settings-card-title">{t("remoteAccess.sections.serverPassword.label")}</h3>
|
||||
<p class="settings-card-subtitle">{t("remoteAccess.sections.serverPassword.help")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={authStatus() && authStatus()!.authenticated}
|
||||
fallback={<div class="settings-card-message">{t("remoteAccess.authStatus.unavailable")}</div>}
|
||||
>
|
||||
<div class="settings-card-content">
|
||||
<p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p>
|
||||
<p class="settings-help-text">
|
||||
{authStatus()!.passwordUserProvided
|
||||
? t("remoteAccess.password.status.set")
|
||||
: t("remoteAccess.password.status.unset")}
|
||||
</p>
|
||||
|
||||
<div class="settings-password-actions">
|
||||
<button
|
||||
class="settings-pill-button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPasswordFormOpen(!passwordFormOpen())
|
||||
setPasswordError(null)
|
||||
}}
|
||||
>
|
||||
{passwordFormOpen()
|
||||
? t("remoteAccess.password.actions.cancel")
|
||||
: authStatus()!.passwordUserProvided
|
||||
? t("remoteAccess.password.actions.change")
|
||||
: t("remoteAccess.password.actions.set")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={passwordFormOpen()}>
|
||||
<div class="settings-form-group">
|
||||
<label class="settings-form-label">{t("remoteAccess.password.form.newPassword")}</label>
|
||||
<input
|
||||
class="selector-input w-full"
|
||||
type="password"
|
||||
value={passwordValue()}
|
||||
onInput={(event) => setPasswordValue(event.currentTarget.value)}
|
||||
placeholder={t("remoteAccess.password.form.placeholder")}
|
||||
/>
|
||||
</div>
|
||||
<div class="settings-form-group">
|
||||
<label class="settings-form-label">{t("remoteAccess.password.form.confirmPassword")}</label>
|
||||
<input
|
||||
class="selector-input w-full"
|
||||
type="password"
|
||||
value={passwordConfirm()}
|
||||
onInput={(event) => setPasswordConfirm(event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when={passwordError()}>
|
||||
{(message) => <div class="settings-error-message">{message()}</div>}
|
||||
</Show>
|
||||
|
||||
<div class="settings-password-actions">
|
||||
<button class="settings-pill-button" type="button" disabled={savingPassword()} onClick={() => void handleSubmitPassword()}>
|
||||
{savingPassword() ? t("remoteAccess.password.save.saving") : t("remoteAccess.password.save.label")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<div class="settings-card-heading-with-icon">
|
||||
<Wifi class="settings-card-heading-icon" />
|
||||
<div>
|
||||
<h3 class="settings-card-title">{t("remoteAccess.sections.addresses.label")}</h3>
|
||||
<p class="settings-card-subtitle">{t("remoteAccess.sections.addresses.help")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||
</div>
|
||||
|
||||
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
||||
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
||||
<Show
|
||||
when={displayAddresses().length > 0 || meta()?.localUrl}
|
||||
fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}
|
||||
>
|
||||
<div class="remote-address-list">
|
||||
<Show when={meta()?.localUrl}>
|
||||
{(url) => {
|
||||
const value = () => url()
|
||||
const expandedState = () => expandedUrl() === value()
|
||||
const qr = () => qrCodes()[value()]
|
||||
return (
|
||||
<div class="remote-address">
|
||||
<div class="remote-address-main">
|
||||
<div>
|
||||
<p class="remote-address-url">{value()}</p>
|
||||
<p class="remote-address-meta">{t("remoteAccess.address.scope.loopback")}</p>
|
||||
</div>
|
||||
<div class="remote-actions">
|
||||
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(value())}>
|
||||
<ExternalLink class="remote-icon" />
|
||||
{t("remoteAccess.address.open")}
|
||||
</button>
|
||||
<button
|
||||
class="remote-pill"
|
||||
type="button"
|
||||
onClick={() => void toggleExpanded(value())}
|
||||
aria-expanded={expandedState()}
|
||||
>
|
||||
<Link2 class="remote-icon" />
|
||||
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={expandedState()}>
|
||||
<div class="remote-qr">
|
||||
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||
{(dataUrl) => (
|
||||
<img
|
||||
src={dataUrl()}
|
||||
alt={t("remoteAccess.address.qrAlt", { url: value() })}
|
||||
class="remote-qr-img"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<For each={displayAddresses()}>
|
||||
{(address) => {
|
||||
const url = address.remoteUrl
|
||||
const expandedState = () => expandedUrl() === url
|
||||
const qr = () => qrCodes()[url]
|
||||
const scopeLabel = () =>
|
||||
address.scope === "external"
|
||||
? t("remoteAccess.address.scope.network")
|
||||
: address.scope === "loopback"
|
||||
? t("remoteAccess.address.scope.loopback")
|
||||
: t("remoteAccess.address.scope.internal")
|
||||
|
||||
return (
|
||||
<div class="remote-address">
|
||||
<div class="remote-address-main">
|
||||
<div>
|
||||
<p class="remote-address-url">{url}</p>
|
||||
<p class="remote-address-meta">
|
||||
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
|
||||
</p>
|
||||
</div>
|
||||
<div class="remote-actions">
|
||||
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
|
||||
<ExternalLink class="remote-icon" />
|
||||
{t("remoteAccess.address.open")}
|
||||
</button>
|
||||
<button
|
||||
class="remote-pill"
|
||||
type="button"
|
||||
onClick={() => void toggleExpanded(url)}
|
||||
aria-expanded={expandedState()}
|
||||
>
|
||||
<Link2 class="remote-icon" />
|
||||
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={expandedState()}>
|
||||
<div class="remote-qr">
|
||||
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||
{(dataUrl) => (
|
||||
<img src={dataUrl()} alt={t("remoteAccess.address.qrAlt", { url })} class="remote-qr-img" />
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user