feat: add configurable server URL for standalone web hosting

Adds support for hosting the web UI separately from the hapi server on static hosts (GitHub Pages, Cloudflare Pages). Users can now set a custom server origin via a dialog on the login screen, with the ability to return to same-origin behavior.

Changes include:
- New useServerUrl hook for managing server URL configuration and storage
- Updated API client to support baseUrl parameter for all requests
- Enhanced login UI with server picker dialog (top-right button)
- Auth system now keys tokens per baseUrl to support multiple servers
- SSE connection updated to use configured baseUrl
- Documentation updates for standalone hosting setup
This commit is contained in:
weishu
2025-12-25 17:42:30 +08:00
parent 73f5a51548
commit 0be023b23b
9 changed files with 318 additions and 35 deletions
+99 -3
View File
@@ -1,9 +1,16 @@
import { useCallback, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { ApiClient } from '@/api/client'
import { Spinner } from '@/components/Spinner'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import type { ServerUrlResult } from '@/hooks/useServerUrl'
type LoginPromptProps = {
onLogin: (token: string) => void
baseUrl: string
serverUrl: string | null
setServerUrl: (input: string) => ServerUrlResult
clearServerUrl: () => void
error?: string | null
}
@@ -11,6 +18,9 @@ export function LoginPrompt(props: LoginPromptProps) {
const [accessToken, setAccessToken] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isServerDialogOpen, setIsServerDialogOpen] = useState(false)
const [serverInput, setServerInput] = useState(props.serverUrl ?? '')
const [serverError, setServerError] = useState<string | null>(null)
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
@@ -26,7 +36,7 @@ export function LoginPrompt(props: LoginPromptProps) {
try {
// Validate the token by attempting to authenticate
const client = new ApiClient('')
const client = new ApiClient('', { baseUrl: props.baseUrl })
await client.authenticate({ accessToken: trimmedToken })
// If successful, pass the token to parent
props.onLogin(trimmedToken)
@@ -37,10 +47,96 @@ export function LoginPrompt(props: LoginPromptProps) {
}
}, [accessToken, props])
useEffect(() => {
if (!isServerDialogOpen) {
return
}
setServerInput(props.serverUrl ?? '')
setServerError(null)
}, [isServerDialogOpen, props.serverUrl])
const handleSaveServer = useCallback((e: React.FormEvent) => {
e.preventDefault()
const result = props.setServerUrl(serverInput)
if (!result.ok) {
setServerError(result.error)
return
}
setServerError(null)
setServerInput(result.value)
setIsServerDialogOpen(false)
}, [props, serverInput])
const handleClearServer = useCallback(() => {
props.clearServerUrl()
setServerInput('')
setServerError(null)
setIsServerDialogOpen(false)
}, [props])
const displayError = error || props.error
const serverSummary = props.serverUrl ?? `${props.baseUrl} (same origin)`
return (
<div className="h-full flex items-center justify-center p-4">
<div className="relative h-full flex items-center justify-center p-4">
<div className="absolute right-4 top-4 z-10">
<Dialog open={isServerDialogOpen} onOpenChange={setIsServerDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
Server
<span className="text-[10px] uppercase tracking-wide text-[var(--app-hint)]">
{props.serverUrl ? 'Custom' : 'Default'}
</span>
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Server URL</DialogTitle>
<DialogDescription>
Set the hapi server origin for API and live updates.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSaveServer} className="space-y-4">
<div className="text-xs text-[var(--app-hint)]">
Current: {serverSummary}
</div>
<div className="space-y-2">
<label className="text-xs font-medium">Server origin</label>
<input
type="url"
value={serverInput}
onChange={(e) => {
setServerInput(e.target.value)
setServerError(null)
}}
placeholder="https://hapi.example.com"
className="w-full px-3 py-2.5 rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none focus:ring-2 focus:ring-[var(--app-button)] focus:border-transparent"
/>
<div className="text-[11px] text-[var(--app-hint)]">
Use http(s) only. Any path is ignored.
</div>
</div>
{serverError && (
<div className="text-sm text-red-500">
{serverError}
</div>
)}
<div className="flex items-center justify-end gap-2">
{props.serverUrl && (
<Button type="button" variant="outline" onClick={handleClearServer}>
Use same origin
</Button>
)}
<Button type="submit">
Save server
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
<div className="w-full max-w-sm space-y-6">
{/* Header */}
<div className="text-center space-y-2">