Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Callback, Login } from '@/components/Auth'
import { ContentPage } from '@/pages/content'
import { TypographyPage } from '@/pages/example/TypographyPage'
import { Home } from '@/pages/home'
import { SettingsPage } from '@/pages/settings'
import { GeothermalRoutes, OcotilloRoutes, ST2Routes } from '@/routes'
import { settings } from '@/settings'

Expand Down Expand Up @@ -61,6 +62,7 @@ const App: React.FC = () => (
path="/ogcapi"
element={<ContentPage src="/content/ogcapi.md" />}
/>
<Route path="/settings" element={<SettingsPage />} />
{/* TEMPORARY: example specimen pages */}
<Route path="/example/typography" element={<TypographyPage />} />
<Route path="/ocotillo/*" element={<OcotilloRoutes />} />
Expand Down
37 changes: 28 additions & 9 deletions src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,10 @@ import {
Lock,
LogOut,
Menu,
Monitor,
Moon,
Search,
Settings as SettingsIcon,
Sun,
User,
X,
Expand All @@ -63,11 +65,12 @@ import { ColorModeContext } from '@/contexts'
import SearchBar from '@/components/SearchBar'
import { ReportBugButton } from '@/components/Button'
import { AmpRole, PRIMARY_NAV, RESOURCE_NAV, type NavItem } from '@/config/navigation'
import { useAccessCapabilities } from '@/hooks'
import { useAccessCapabilities, useBooleanPreference } from '@/hooks'
import { useSearch } from '@/providers/search-provider'
import { SupportPanelContext } from '@/components/SupportPanelContext'
import { NewVersionBanner } from '@/components/NewVersionBanner'
import { trackNavItemClicked } from '@/analytics/posthog'
import { PREFERENCE_KEYS } from '@/utils/preferences'
import pkg from '../../package.json'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
Expand Down Expand Up @@ -1100,7 +1103,7 @@ function ShellHeader() {
const { warnWhen, setWarnWhen } = useWarnAboutChange()
const { mutate: logout } = useLogout()
const translate = useTranslate()
const { mode, setMode } = useContext(ColorModeContext)
const { preference, setMode } = useContext(ColorModeContext)
const { openSearch } = useSearch()

const initials = user?.name
Expand Down Expand Up @@ -1175,19 +1178,31 @@ function ShellHeader() {
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/settings" className="no-underline text-foreground">
<SettingsIcon className="mr-2 size-4" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Appearance */}
<DropdownMenuLabel className="text-xs text-muted-foreground/70 px-3 py-1 font-normal">
Appearance
</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setMode('light')}>
<Sun className="mr-2 size-4" />
Light
{mode === 'light' && <Check className="ml-auto size-4" />}
{preference === 'light' && <Check className="ml-auto size-4" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setMode('dark')}>
<Moon className="mr-2 size-4" />
Dark
{mode === 'dark' && <Check className="ml-auto size-4" />}
{preference === 'dark' && <Check className="ml-auto size-4" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setMode('system')}>
<Monitor className="mr-2 size-4" />
System
{preference === 'system' && <Check className="ml-auto size-4" />}
</DropdownMenuItem>
<DropdownMenuSeparator />
{isExistAuthentication && (
Expand All @@ -1209,13 +1224,18 @@ const AUTO_COLLAPSE_PATHS = ['/ocotillo/map']
function SidebarAutoCollapse(): null {
const location = useLocation()
const { setOpen } = useSidebar()
const [autoCollapseEnabled] = useBooleanPreference(
PREFERENCE_KEYS.autoCollapseSidebarOnMap,
true
)
// Track whether the sidebar was collapsed by this component (not by the user)
const autoCollapsed = useRef(false)

// biome-ignore lint/correctness/useExhaustiveDependencies: setOpen is stable from sidebar context.
useEffect(() => {
const isAutoCollapsePage = AUTO_COLLAPSE_PATHS.some((p) =>
location.pathname.startsWith(p)
)
const isAutoCollapsePage =
autoCollapseEnabled &&
AUTO_COLLAPSE_PATHS.some((p) => location.pathname.startsWith(p))

if (isAutoCollapsePage) {
autoCollapsed.current = true
Expand All @@ -1225,8 +1245,7 @@ function SidebarAutoCollapse(): null {
autoCollapsed.current = false
setOpen(true)
}
// biome-ignore lint/correctness/useExhaustiveDependencies: setOpen is stable from sidebar context.
}, [location.pathname])
}, [location.pathname, autoCollapseEnabled])

return null
}
Expand Down
11 changes: 9 additions & 2 deletions src/contexts/ColorModeContext.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { createContext } from 'react'
import type {
ColorModePreference,
ResolvedColorMode,
} from '@/utils/userProfile'

export type ColorModeContextType = {
mode: string
setMode: (mode?: string) => void
/** The mode actually rendering right now — never "system". */
mode: ResolvedColorMode
/** What the user chose, which may be "system". */
preference: ColorModePreference
setMode: (mode?: ColorModePreference) => void
}

export const ColorModeContext = createContext<ColorModeContextType>(
Expand Down
67 changes: 46 additions & 21 deletions src/contexts/ColorModeContextProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,73 @@
import React, { PropsWithChildren, useEffect, useState } from 'react'
import { ThemeProvider } from '@mui/material'
import React, { PropsWithChildren, useEffect, useMemo, useState } from 'react'
import { getTheme } from '@/theme'
import {
COLOR_MODE_STORAGE_KEY,
type ColorModePreference,
isColorModePreference,
resolveColorMode,
} from '@/utils/userProfile'
import { ColorModeContext } from './ColorModeContext'

const DARK_QUERY = '(prefers-color-scheme: dark)'

const storedPreference = (): ColorModePreference => {
const stored = localStorage.getItem(COLOR_MODE_STORAGE_KEY)
// Anything older or unrecognised falls back to following the OS, which is
// what this app did before "system" was an explicit choice.
return isColorModePreference(stored) ? stored : 'system'
}

export const ColorModeContextProvider: React.FC<PropsWithChildren> = ({
children,
}) => {
const colorModeFromLocalStorage = localStorage.getItem('colorMode')
const isSystemPreferenceDark = window?.matchMedia(
'(prefers-color-scheme: dark)'
).matches
const [preference, setPreference] =
useState<ColorModePreference>(storedPreference)
const [systemPrefersDark, setSystemPrefersDark] = useState(
() => window?.matchMedia(DARK_QUERY).matches ?? false
)

const systemPreference = isSystemPreferenceDark ? 'dark' : 'light'
const initialMode = colorModeFromLocalStorage || systemPreference
const mode = resolveColorMode(preference, systemPrefersDark)

// Apply class immediately so shadcn/Tailwind dark styles don't flash on load
document.documentElement.classList.toggle('dark', initialMode === 'dark')
// Apply the class before paint so Tailwind/shadcn dark styles don't flash
document.documentElement.classList.toggle('dark', mode === 'dark')

const [mode, setMode] = useState(initialMode)
// Following the OS means following it as it changes, not only at load.
useEffect(() => {
const query = window.matchMedia(DARK_QUERY)
const onChange = (event: MediaQueryListEvent) =>
setSystemPrefersDark(event.matches)

query.addEventListener('change', onChange)
return () => query.removeEventListener('change', onChange)
}, [])

useEffect(() => {
window.localStorage.setItem('colorMode', mode)
// Sync the .dark class on <html> so Tailwind/shadcn dark variants activate
window.localStorage.setItem(COLOR_MODE_STORAGE_KEY, preference)
document.documentElement.classList.toggle('dark', mode === 'dark')
}, [mode])
}, [preference, mode])

const setColorMode = (next?: string) => {
if (next === 'light' || next === 'dark') {
setMode(next)
const setColorMode = (next?: ColorModePreference) => {
if (isColorModePreference(next)) {
setPreference(next)
} else {
setMode(mode === 'light' ? 'dark' : 'light')
// No argument still means "flip what I'm looking at", which is how the
// header toggle has always called this.
setPreference(mode === 'light' ? 'dark' : 'light')
}
}

const theme = useMemo(() => getTheme(mode), [mode])

return (
<ColorModeContext.Provider
value={{
setMode: setColorMode,
mode,
preference,
setMode: setColorMode,
}}
>
<ThemeProvider theme={getTheme(mode as 'light' | 'dark')}>
{children}
</ThemeProvider>
<ThemeProvider theme={theme}>{children}</ThemeProvider>
</ColorModeContext.Provider>
)
}
2 changes: 2 additions & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from './useAccessCapabilities'
export * from './useSearchHistory'
export * from './useAll'
export * from './useAllNotes'
export * from './useApiKeys'
export * from './useDebounce'
export * from './useElevation'
export * from './useGisArtifacts'
Expand All @@ -23,3 +24,4 @@ export * from './useSearchModalState'
export * from './useSidebarPanelSync'
export * from './useWellDetails'
export * from './useContainerMinWidth'
export * from './useBooleanPreference'
82 changes: 82 additions & 0 deletions src/hooks/useApiKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { axiosCall, fetcher } from '@/providers/ocotillo-data-provider'
import {
type ApiKey,
type NewApiKey,
zApiKeyList,
zNewApiKey,
} from '@/utils/apiKeys'

/**
* Personal API keys (`/api_key`), for the settings page.
*
* The route answers with the caller's own keys and nothing else — ownership is
* the `sub` claim on the token, never a parameter — so there is nothing to
* filter and one query key serves the whole card.
*/
export const useApiKeys = () =>
useQuery<ApiKey[]>({
queryKey: ['api-keys'],
queryFn: async () => {
const response = await fetcher('api_key')
return zApiKeyList.parse(response.data)
},
})

/**
* Every mutation invalidates the list rather than patching the cache. A key's
* rendered status depends on the server's clock, and `last_used_at` moves
* without this client doing anything, so the authoritative row is the one the
* next read returns.
*/
const useApiKeyMutation = <TVariables, TResult>(
mutationFn: (variables: TVariables) => Promise<TResult>
) => {
const queryClient = useQueryClient()

return useMutation({
mutationFn,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
},
})
}

/**
* Issue a key. The response is the only place the token ever appears — the
* server stores a digest — so whatever calls this owns showing it once.
*/
export const useCreateApiKey = () =>
useApiKeyMutation<{ name: string; lifetimeDays?: number }, NewApiKey>(
async ({ name, lifetimeDays }) => {
const response = await axiosCall('api_key', {
method: 'POST',
data: {
name,
// Omitted rather than guessed: the API owns the default and clamps
// anything longer than its maximum.
...(lifetimeDays === undefined
? {}
: { lifetime_days: lifetimeDays }),
},
})
return zNewApiKey.parse(response.data)
}
)

export const useRenameApiKey = () =>
useApiKeyMutation<{ id: number; name: string }, ApiKey>(
async ({ id, name }) => {
const response = await axiosCall(`api_key/${id}`, {
method: 'PATCH',
data: { name },
})
return zApiKeyList.element.parse(response.data)
}
)

/** Revocation answers 204, so there is no body to parse or return. */
export const useRevokeApiKey = () =>
useApiKeyMutation<number, void>(async (id) => {
await axiosCall(`api_key/${id}`, { method: 'DELETE' })
})
30 changes: 30 additions & 0 deletions src/hooks/useBooleanPreference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useCallback, useSyncExternalStore } from 'react'
import {
type PreferenceKey,
readBooleanPreference,
subscribeToPreferences,
writeBooleanPreference,
} from '@/utils/preferences'

/**
* Reads a localStorage-backed preference as React state. Every component using
* the same key re-renders when any of them writes it, so the settings page and
* the shell stay in step without a shared provider.
*/
export const useBooleanPreference = (
key: PreferenceKey,
fallback: boolean
): [boolean, (value: boolean) => void] => {
const value = useSyncExternalStore(
subscribeToPreferences,
() => readBooleanPreference(key, fallback),
() => fallback
)

const setValue = useCallback(
(next: boolean) => writeBooleanPreference(key, next),
[key]
)

return [value, setValue]
}
Loading
Loading