Skip to content

Commit 985728a

Browse files
committed
settings menu improvements
1 parent 5b64a7a commit 985728a

File tree

5 files changed

+295
-23
lines changed

5 files changed

+295
-23
lines changed

frontend/src/components/settings-sidebar/settings-menu-item.spec.tsx

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Injector } from '@furystack/inject'
2-
import { createComponent, initializeShadeRoot } from '@furystack/shades'
2+
import { createComponent, initializeShadeRoot, LocationService } from '@furystack/shades'
33
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
44

55
import { SettingsMenuItem } from './settings-menu-item.js'
@@ -17,10 +17,13 @@ describe('SettingsMenuItem', () => {
1717
const injector = new Injector()
1818
const rootElement = document.getElementById('root') as HTMLDivElement
1919

20+
history.pushState(null, '', '/other')
21+
injector.getInstance(LocationService).updateState()
22+
2023
initializeShadeRoot({
2124
injector,
2225
rootElement,
23-
jsxElement: <SettingsMenuItem icon={<span>🏠</span>} label="Home" href="/home" />,
26+
jsxElement: <SettingsMenuItem icon="🏠" label="Home" href="/home" />,
2427
})
2528

2629
const menuItem = document.querySelector('settings-menu-item')
@@ -33,14 +36,17 @@ describe('SettingsMenuItem', () => {
3336
expect(link?.textContent).toContain('🏠')
3437
})
3538

36-
it('should render with active state styling', () => {
39+
it('should render with active state when URL matches href', () => {
3740
const injector = new Injector()
3841
const rootElement = document.getElementById('root') as HTMLDivElement
3942

43+
history.pushState(null, '', '/settings')
44+
injector.getInstance(LocationService).updateState()
45+
4046
initializeShadeRoot({
4147
injector,
4248
rootElement,
43-
jsxElement: <SettingsMenuItem icon={<span>⚙️</span>} label="Settings" href="/settings" isActive={true} />,
49+
jsxElement: <SettingsMenuItem icon="⚙️" label="Settings" href="/settings" />,
4450
})
4551

4652
const menuItem = document.querySelector('settings-menu-item')
@@ -52,14 +58,17 @@ describe('SettingsMenuItem', () => {
5258
expect(link?.textContent).toContain('Settings')
5359
})
5460

55-
it('should render with inactive state by default', () => {
61+
it('should render with inactive state when URL does not match href', () => {
5662
const injector = new Injector()
5763
const rootElement = document.getElementById('root') as HTMLDivElement
5864

65+
history.pushState(null, '', '/other-page')
66+
injector.getInstance(LocationService).updateState()
67+
5968
initializeShadeRoot({
6069
injector,
6170
rootElement,
62-
jsxElement: <SettingsMenuItem icon={<span>📁</span>} label="Files" href="/files" />,
71+
jsxElement: <SettingsMenuItem icon="📁" label="Files" href="/files" />,
6372
})
6473

6574
const menuItem = document.querySelector('settings-menu-item')
@@ -70,14 +79,17 @@ describe('SettingsMenuItem', () => {
7079
expect(link?.getAttribute('href')).toBe('/files')
7180
})
7281

73-
it('should render with explicit inactive state', () => {
82+
it('should render correctly with different URLs', () => {
7483
const injector = new Injector()
7584
const rootElement = document.getElementById('root') as HTMLDivElement
7685

86+
history.pushState(null, '', '/something-else')
87+
injector.getInstance(LocationService).updateState()
88+
7789
initializeShadeRoot({
7890
injector,
7991
rootElement,
80-
jsxElement: <SettingsMenuItem icon={<span>🎬</span>} label="Movies" href="/movies" isActive={false} />,
92+
jsxElement: <SettingsMenuItem icon="🎬" label="Movies" href="/movies" />,
8193
})
8294

8395
const menuItem = document.querySelector('settings-menu-item')

frontend/src/components/settings-sidebar/settings-menu-item.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
1-
import { createComponent, RouteLink, Shade } from '@furystack/shades'
1+
import { createComponent, LocationService, RouteLink, Shade } from '@furystack/shades'
22
import { cssVariableTheme } from '@furystack/shades-common-components'
3+
import { match, type MatchOptions } from 'path-to-regexp'
34

45
type SettingsMenuItemProps = {
5-
icon: JSX.Element
6+
icon: string
67
label: string
78
href: string
8-
isActive?: boolean
9+
routingOptions?: MatchOptions
910
}
1011

1112
export const SettingsMenuItem = Shade<SettingsMenuItemProps>({
1213
shadowDomName: 'settings-menu-item',
13-
render: ({ props }) => {
14-
const { icon, label, href, isActive } = props
14+
render: ({ props, injector, useObservable }) => {
15+
const { icon, label, href, routingOptions } = props
16+
17+
const [currentPath] = useObservable(
18+
'locationChange',
19+
injector.getInstance(LocationService).onLocationPathChanged,
20+
)
21+
const isActive = !!match(href, routingOptions)(currentPath)
1522

1623
return (
1724
<RouteLink
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { createComponent, Shade } from '@furystack/shades'
2+
import { Button, Form, Input, NotyService, Paper } from '@furystack/shades-common-components'
3+
import { ObservableValue } from '@furystack/utils'
4+
import type { OllamaConfig } from 'common'
5+
import { ConfigService } from '../../services/config-service.js'
6+
7+
type OllamaFormData = OllamaConfig['value']
8+
9+
export const AiSettingsPage = Shade({
10+
shadowDomName: 'ai-settings-page',
11+
render: ({ injector, useObservable, useDisposable }) => {
12+
const configService = injector.getInstance(ConfigService)
13+
const notyService = injector.getInstance(NotyService)
14+
15+
const [config] = useObservable('ollamaConfig', configService.getConfigAsObservable('OLLAMA_CONFIG'))
16+
17+
const isLoadingObservable = useDisposable('isLoading', () => new ObservableValue(false))
18+
const [isLoading] = useObservable('isLoadingValue', isLoadingObservable)
19+
20+
const handleSubmit = async (formData: Record<string, unknown>) => {
21+
const data: OllamaFormData = {
22+
host: formData.host as string,
23+
}
24+
25+
isLoadingObservable.setValue(true)
26+
try {
27+
await configService.saveConfig('OLLAMA_CONFIG', data)
28+
notyService.emit('onNotyAdded', {
29+
title: 'Success',
30+
body: 'AI settings saved successfully',
31+
type: 'success',
32+
})
33+
} catch (error) {
34+
const errorMessage = error instanceof Error ? error.message : 'Failed to save settings'
35+
notyService.emit('onNotyAdded', {
36+
title: 'Error',
37+
body: errorMessage,
38+
type: 'error',
39+
})
40+
} finally {
41+
isLoadingObservable.setValue(false)
42+
}
43+
}
44+
45+
if (config.status === 'loading' || config.status === 'uninitialized') {
46+
return (
47+
<div>
48+
<h2 style={{ marginBottom: '24px', color: 'var(--theme-text-primary)' }}>🤖 Ollama Integration</h2>
49+
<Paper elevation={1} style={{ padding: '24px' }}>
50+
<p style={{ color: 'var(--theme-text-secondary)' }}>Loading settings...</p>
51+
</Paper>
52+
</div>
53+
)
54+
}
55+
56+
const currentValues: OllamaFormData =
57+
config.status === 'loaded' && config.value
58+
? (config.value.value as OllamaFormData)
59+
: {
60+
host: '',
61+
}
62+
63+
return (
64+
<div>
65+
<h2 style={{ marginBottom: '24px', color: 'var(--theme-text-primary)' }}>🤖 Ollama Integration</h2>
66+
<p style={{ marginBottom: '24px', color: 'var(--theme-text-secondary)' }}>
67+
Configure the connection to your Ollama server for AI-powered features.
68+
</p>
69+
70+
<Paper elevation={1} style={{ padding: '24px' }}>
71+
<Form<Record<string, unknown>>
72+
validate={(data): data is Record<string, unknown> => {
73+
const formData = data as Record<string, unknown>
74+
const host = formData.host as string
75+
return typeof host === 'string'
76+
}}
77+
onSubmit={(data) => void handleSubmit(data)}
78+
>
79+
<div style={{ marginBottom: '24px' }}>
80+
<Input
81+
labelTitle="Ollama Host URL"
82+
name="host"
83+
type="url"
84+
value={currentValues.host}
85+
placeholder="http://localhost:11434"
86+
style={{ maxWidth: '400px' }}
87+
/>
88+
<small style={{ color: 'var(--theme-text-secondary)', display: 'block', marginTop: '4px' }}>
89+
The Ollama server URL including protocol (http or https). Leave empty to disable AI features.
90+
</small>
91+
</div>
92+
93+
<div style={{ borderTop: '1px solid var(--theme-background-default)', paddingTop: '16px' }}>
94+
<Button type="submit" variant="contained" color="primary" disabled={isLoading}>
95+
{isLoading ? 'Saving...' : 'Save Settings'}
96+
</Button>
97+
</div>
98+
</Form>
99+
</Paper>
100+
</div>
101+
)
102+
},
103+
})

frontend/src/pages/admin/app-settings.tsx

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,28 @@ const settingsRoutes = [
2525
/>
2626
),
2727
},
28+
{
29+
url: '/app-settings/iot',
30+
component: () => (
31+
<PiRatLazyLoad
32+
component={async () => {
33+
const { IotSettingsPage } = await import('./iot-settings.js')
34+
return <IotSettingsPage />
35+
}}
36+
/>
37+
),
38+
},
39+
{
40+
url: '/app-settings/ai',
41+
component: () => (
42+
<PiRatLazyLoad
43+
component={async () => {
44+
const { AiSettingsPage } = await import('./ai-settings.js')
45+
return <AiSettingsPage />
46+
}}
47+
/>
48+
),
49+
},
2850
{
2951
url: '/app-settings',
3052
component: () => (
@@ -60,9 +82,6 @@ export const AppSettingsPage = Shade({
6082
})
6183
}
6284

63-
const isOmdbActive = currentPath === '/app-settings/omdb'
64-
const isStreamingActive = currentPath === '/app-settings/streaming'
65-
6685
return (
6786
<div
6887
style={{
@@ -75,13 +94,14 @@ export const AppSettingsPage = Shade({
7594
>
7695
<SettingsSidebar>
7796
<SettingsMenuSection title="Media">
78-
<SettingsMenuItem icon={<>🎬</>} label="OMDB Settings" href="/app-settings/omdb" isActive={isOmdbActive} />
79-
<SettingsMenuItem
80-
icon={<>📺</>}
81-
label="Streaming Settings"
82-
href="/app-settings/streaming"
83-
isActive={isStreamingActive}
84-
/>
97+
<SettingsMenuItem icon="🎬" label="OMDB Settings" href="/app-settings/omdb" />
98+
<SettingsMenuItem icon="📺" label="Streaming Settings" href="/app-settings/streaming" />
99+
</SettingsMenuSection>
100+
<SettingsMenuSection title="IOT">
101+
<SettingsMenuItem icon="📡" label="Device Availability" href="/app-settings/iot" />
102+
</SettingsMenuSection>
103+
<SettingsMenuSection title="AI">
104+
<SettingsMenuItem icon="🤖" label="Ollama Settings" href="/app-settings/ai" />
85105
</SettingsMenuSection>
86106
</SettingsSidebar>
87107

0 commit comments

Comments
 (0)