mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-01 15:56:55 +00:00
refactor(frontend): delete RegisterPassword and UiTest routes
This commit is contained in:
@@ -1,550 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
import { _ } from '../lib/i18n'
|
||||
import {
|
||||
createRegistrationFlow,
|
||||
restoreRegistrationFlow,
|
||||
VerificationStep,
|
||||
KeyChoiceStep,
|
||||
DidDocStep,
|
||||
} from '../lib/registration'
|
||||
import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
|
||||
import HandleInput from '../components/HandleInput.svelte'
|
||||
import { ensureRequestUri, getRequestUriFromUrl } from '../lib/oauth'
|
||||
|
||||
let serverInfo = $state<{
|
||||
availableUserDomains: string[]
|
||||
inviteCodeRequired: boolean
|
||||
availableCommsChannels?: string[]
|
||||
selfHostedDidWebEnabled?: boolean
|
||||
} | null>(null)
|
||||
let loadingServerInfo = $state(true)
|
||||
let serverInfoLoaded = false
|
||||
let ssoAvailable = $state(false)
|
||||
|
||||
let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
|
||||
let confirmPassword = $state('')
|
||||
let clientName = $state<string | null>(null)
|
||||
let selectedDomain = $state('')
|
||||
let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
$effect(() => {
|
||||
if (!flow) return
|
||||
const handle = flow.info.handle
|
||||
if (checkHandleTimeout) {
|
||||
clearTimeout(checkHandleTimeout)
|
||||
}
|
||||
if (handle.length >= 3 && !handle.includes('.')) {
|
||||
checkHandleTimeout = setTimeout(() => flow?.checkHandleAvailability(handle), 400)
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!serverInfoLoaded) {
|
||||
serverInfoLoaded = true
|
||||
ensureRequestUri().then((requestUri) => {
|
||||
if (!requestUri) return
|
||||
loadServerInfo()
|
||||
checkSsoAvailable()
|
||||
fetchClientName()
|
||||
}).catch((err) => {
|
||||
console.error('Failed to ensure OAuth request URI:', err)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchClientName() {
|
||||
const requestUri = getRequestUriFromUrl()
|
||||
if (!requestUri) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/oauth/authorize?request_uri=${encodeURIComponent(requestUri)}`, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
clientName = data.client_name || null
|
||||
}
|
||||
} catch {
|
||||
clientName = null
|
||||
}
|
||||
}
|
||||
|
||||
async function checkSsoAvailable() {
|
||||
try {
|
||||
const response = await fetch('/oauth/sso/providers')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
ssoAvailable = (data.providers?.length ?? 0) > 0
|
||||
}
|
||||
} catch {
|
||||
ssoAvailable = false
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (flow?.state.step === 'redirect-to-dashboard') {
|
||||
completeOAuthRegistration()
|
||||
}
|
||||
})
|
||||
|
||||
let creatingStarted = false
|
||||
$effect(() => {
|
||||
if (flow?.state.step === 'creating' && !creatingStarted) {
|
||||
creatingStarted = true
|
||||
flow.createPasswordAccount()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadServerInfo() {
|
||||
try {
|
||||
const restored = restoreRegistrationFlow()
|
||||
if (restored && restored.state.mode === 'password') {
|
||||
flow = restored
|
||||
serverInfo = await api.describeServer()
|
||||
} else {
|
||||
serverInfo = await api.describeServer()
|
||||
const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
|
||||
flow = createRegistrationFlow('password', hostname)
|
||||
}
|
||||
selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname
|
||||
if (flow) flow.setSelectedDomain(selectedDomain)
|
||||
} catch (e) {
|
||||
console.error('Failed to load server info:', e)
|
||||
} finally {
|
||||
loadingServerInfo = false
|
||||
}
|
||||
}
|
||||
|
||||
function validateInfoStep(): string | null {
|
||||
if (!flow) return 'Flow not initialized'
|
||||
const info = flow.info
|
||||
if (!info.handle.trim()) return $_('register.validation.handleRequired')
|
||||
if (info.handle.includes('.')) return $_('register.validation.handleNoDots')
|
||||
if (!info.password) return $_('register.validation.passwordRequired')
|
||||
if (info.password.length < 8) return $_('register.validation.passwordLength')
|
||||
if (info.password !== confirmPassword) return $_('register.validation.passwordsMismatch')
|
||||
if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
|
||||
return $_('register.validation.inviteCodeRequired')
|
||||
}
|
||||
if (info.didType === 'web-external') {
|
||||
if (!info.externalDid?.trim()) return $_('register.validation.externalDidRequired')
|
||||
if (!info.externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat')
|
||||
}
|
||||
switch (info.verificationChannel) {
|
||||
case 'email':
|
||||
if (!info.email.trim()) return $_('register.validation.emailRequired')
|
||||
break
|
||||
case 'discord':
|
||||
if (!info.discordUsername?.trim()) return $_('register.validation.discordUsernameRequired')
|
||||
break
|
||||
case 'telegram':
|
||||
if (!info.telegramUsername?.trim()) return $_('register.validation.telegramRequired')
|
||||
break
|
||||
case 'signal':
|
||||
if (!info.signalUsername?.trim()) return $_('register.validation.signalRequired')
|
||||
break
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleInfoSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!flow) return
|
||||
|
||||
const validationError = validateInfoStep()
|
||||
if (validationError) {
|
||||
flow.setError(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
flow.clearError()
|
||||
flow.proceedFromInfo()
|
||||
}
|
||||
|
||||
async function handleCreateAccount() {
|
||||
if (!flow) return
|
||||
await flow.createPasswordAccount()
|
||||
}
|
||||
|
||||
async function handleComplete() {
|
||||
if (flow) {
|
||||
await flow.finalizeSession()
|
||||
}
|
||||
navigate(routes.dashboard)
|
||||
}
|
||||
|
||||
async function completeOAuthRegistration() {
|
||||
const requestUri = getRequestUriFromUrl()
|
||||
if (!requestUri || !flow?.account) {
|
||||
navigate(routes.dashboard)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/oauth/register/complete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
request_uri: requestUri,
|
||||
did: flow.account.did,
|
||||
app_password: flow.account.appPassword || flow.info.password,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
flow.setError(data.error_description || data.error || $_('common.error'))
|
||||
return
|
||||
}
|
||||
|
||||
if (data.redirect_uri) {
|
||||
window.location.href = data.redirect_uri
|
||||
return
|
||||
}
|
||||
|
||||
navigate(routes.dashboard)
|
||||
} catch (err) {
|
||||
console.error('OAuth registration completion failed:', err)
|
||||
flow.setError(err instanceof Error ? err.message : $_('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
function isChannelAvailable(ch: string): boolean {
|
||||
const available = serverInfo?.availableCommsChannels ?? ['email']
|
||||
return available.includes(ch)
|
||||
}
|
||||
|
||||
function channelLabel(ch: string): string {
|
||||
switch (ch) {
|
||||
case 'email': return $_('register.email')
|
||||
case 'discord': return $_('register.discord')
|
||||
case 'telegram': return $_('register.telegram')
|
||||
case 'signal': return $_('register.signal')
|
||||
default: return ch
|
||||
}
|
||||
}
|
||||
|
||||
let fullHandle = $derived(() => {
|
||||
if (!flow?.info.handle.trim()) return ''
|
||||
if (flow.info.handle.includes('.')) return flow.info.handle.trim()
|
||||
return selectedDomain ? `${flow.info.handle.trim()}.${selectedDomain}` : flow.info.handle.trim()
|
||||
})
|
||||
|
||||
function extractDomain(did: string): string {
|
||||
return did.replace('did:web:', '').replace(/%3A/g, ':')
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
const requestUri = getRequestUriFromUrl()
|
||||
if (!requestUri) {
|
||||
window.history.back()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/oauth/authorize/deny', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ request_uri: requestUri })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
window.history.back()
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data.redirect_uri) {
|
||||
window.location.href = data.redirect_uri
|
||||
} else {
|
||||
window.history.back()
|
||||
}
|
||||
} catch {
|
||||
window.history.back()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<h1>{$_('register.title')}</h1>
|
||||
{#if clientName}
|
||||
<p class="subtitle">{$_('oauth.register.subtitle')} <strong>{clientName}</strong></p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if flow?.state.error}
|
||||
<div class="message error">{flow.state.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if loadingServerInfo || !flow}
|
||||
<div class="loading"></div>
|
||||
{:else if flow.state.step === 'info'}
|
||||
<div class="migrate-callout">
|
||||
<div class="migrate-icon">↗</div>
|
||||
<div class="migrate-content">
|
||||
<strong>{$_('register.migrateTitle')}</strong>
|
||||
<p>{$_('register.migrateDescription')}</p>
|
||||
<a href={getFullUrl(routes.migrate)} class="migrate-link">
|
||||
{$_('register.migrateLink')} →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AccountTypeSwitcher active="password" {ssoAvailable} oauthRequestUri={getRequestUriFromUrl()} />
|
||||
|
||||
<form class="register-form" onsubmit={handleInfoSubmit}>
|
||||
<div>
|
||||
<label for="handle">{$_('register.handle')}</label>
|
||||
<HandleInput
|
||||
value={flow.info.handle}
|
||||
domains={serverInfo?.availableUserDomains ?? []}
|
||||
{selectedDomain}
|
||||
placeholder={$_('register.handlePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
onInput={(v) => { flow!.info.handle = v }}
|
||||
onDomainChange={(d) => { selectedDomain = d; flow!.setSelectedDomain(d) }}
|
||||
/>
|
||||
{#if flow.info.handle.includes('.')}
|
||||
<p class="hint warning">{$_('register.handleDotWarning')}</p>
|
||||
{:else if flow.state.checkingHandle}
|
||||
<p class="hint">{$_('common.checking')}</p>
|
||||
{:else if flow.state.handleAvailable === false}
|
||||
<p class="hint warning">{$_('register.handleTaken')}</p>
|
||||
{:else if flow.state.handleAvailable === true && fullHandle()}
|
||||
<p class="hint success">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
|
||||
{:else if fullHandle()}
|
||||
<p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password">{$_('register.password')}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={flow.info.password}
|
||||
placeholder={$_('register.passwordPlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirm-password">{$_('register.confirmPassword')}</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
placeholder={$_('register.confirmPasswordPlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="verification-channel">{$_('register.verificationMethod')}</label>
|
||||
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
|
||||
<option value="email">{$_('register.email')}</option>
|
||||
{#if isChannelAvailable('discord')}
|
||||
<option value="discord">{$_('register.discord')}</option>
|
||||
{/if}
|
||||
{#if isChannelAvailable('telegram')}
|
||||
<option value="telegram">{$_('register.telegram')}</option>
|
||||
{/if}
|
||||
{#if isChannelAvailable('signal')}
|
||||
<option value="signal">{$_('register.signal')}</option>
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if flow.info.verificationChannel === 'email'}
|
||||
<div>
|
||||
<label for="email">{$_('register.emailAddress')}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={flow.info.email}
|
||||
placeholder={$_('register.emailPlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{:else if flow.info.verificationChannel === 'discord'}
|
||||
<div>
|
||||
<label for="discord-username">{$_('register.discordUsername')}</label>
|
||||
<input
|
||||
id="discord-username"
|
||||
type="text"
|
||||
bind:value={flow.info.discordUsername}
|
||||
onblur={() => flow?.checkCommsChannelInUse('discord', flow.info.discordUsername ?? '')}
|
||||
placeholder={$_('register.discordUsernamePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
{#if flow.state.discordInUse}
|
||||
<p class="hint warning">{$_('register.discordInUseWarning')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if flow.info.verificationChannel === 'telegram'}
|
||||
<div>
|
||||
<label for="telegram-username">{$_('register.telegramUsername')}</label>
|
||||
<input
|
||||
id="telegram-username"
|
||||
type="text"
|
||||
bind:value={flow.info.telegramUsername}
|
||||
onblur={() => flow?.checkCommsChannelInUse('telegram', flow.info.telegramUsername ?? '')}
|
||||
placeholder={$_('register.telegramUsernamePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
{#if flow.state.telegramInUse}
|
||||
<p class="hint warning">{$_('register.telegramInUseWarning')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if flow.info.verificationChannel === 'signal'}
|
||||
<div>
|
||||
<label for="signal-number">{$_('register.signalUsername')}</label>
|
||||
<input
|
||||
id="signal-number"
|
||||
type="tel"
|
||||
bind:value={flow.info.signalUsername}
|
||||
onblur={() => flow?.checkCommsChannelInUse('signal', flow.info.signalUsername ?? '')}
|
||||
placeholder={$_('register.signalUsernamePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.signalUsernameHint')}</p>
|
||||
{#if flow.state.signalInUse}
|
||||
<p class="hint warning">{$_('register.signalInUseWarning')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<fieldset class="identity-section">
|
||||
<legend>{$_('register.identityType')}</legend>
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('register.didPlc')}</strong>
|
||||
<span class="radio-hint">{$_('register.didPlcHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
|
||||
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('register.didWeb')}</strong>
|
||||
{#if serverInfo?.selfHostedDidWebEnabled === false}
|
||||
<span class="radio-hint disabled-hint">{$_('register.didWebDisabledHint')}</span>
|
||||
{:else}
|
||||
<span class="radio-hint">{$_('register.didWebHint')}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('register.didWebBYOD')}</strong>
|
||||
<span class="radio-hint">{$_('register.didWebBYODHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{#if flow.info.didType === 'web'}
|
||||
<div class="warning-box">
|
||||
<strong>{$_('register.didWebWarningTitle')}</strong>
|
||||
<ul>
|
||||
<li><strong>{$_('register.didWebWarning1')}</strong> {$_('register.didWebWarning1Detail', { values: { did: `did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}` } })}</li>
|
||||
<li><strong>{$_('register.didWebWarning2')}</strong> {$_('register.didWebWarning2Detail')}</li>
|
||||
{#if $_('register.didWebWarning3')}
|
||||
<li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if flow.info.didType === 'web-external'}
|
||||
<div>
|
||||
<label for="external-did">{$_('register.externalDid')}</label>
|
||||
<input
|
||||
id="external-did"
|
||||
type="text"
|
||||
bind:value={flow.info.externalDid}
|
||||
placeholder={$_('register.externalDidPlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.externalDidHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if serverInfo?.inviteCodeRequired}
|
||||
<div>
|
||||
<label for="invite-code">{$_('register.inviteCode')}</label>
|
||||
<input
|
||||
id="invite-code"
|
||||
type="text"
|
||||
bind:value={flow.info.inviteCode}
|
||||
placeholder={$_('register.inviteCodePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="secondary" onclick={handleCancel} disabled={flow.state.submitting}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" class="primary" disabled={flow.state.submitting || flow.state.handleAvailable === false || flow.state.checkingHandle}>
|
||||
{flow.state.submitting ? $_('common.loading') : $_('common.continue')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{:else if flow.state.step === 'key-choice'}
|
||||
<KeyChoiceStep {flow} />
|
||||
|
||||
{:else if flow.state.step === 'initial-did-doc'}
|
||||
<DidDocStep
|
||||
{flow}
|
||||
type="initial"
|
||||
onConfirm={handleCreateAccount}
|
||||
onBack={() => flow?.goBack()}
|
||||
/>
|
||||
|
||||
{:else if flow.state.step === 'creating'}
|
||||
<div class="loading">
|
||||
<p>{$_('common.creating')}</p>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'verify'}
|
||||
<VerificationStep {flow} />
|
||||
|
||||
{:else if flow.state.step === 'updated-did-doc'}
|
||||
<DidDocStep
|
||||
{flow}
|
||||
type="updated"
|
||||
onConfirm={() => flow?.activateAccount()}
|
||||
/>
|
||||
|
||||
{:else if flow.state.step === 'redirect-to-dashboard'}
|
||||
<div class="loading">
|
||||
<p>{$_('register.redirecting')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,534 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button, Card, Input, Message, Page, Section } from '../components/ui'
|
||||
import Skeleton from '../components/Skeleton.svelte'
|
||||
import { toast } from '../lib/toast.svelte'
|
||||
import { getServerConfigState } from '../lib/serverConfig.svelte'
|
||||
import { _, locale, getSupportedLocales, localeNames, type SupportedLocale } from '../lib/i18n'
|
||||
|
||||
let inputValue = $state('')
|
||||
let inputError = $state('')
|
||||
let inputDisabled = $state('')
|
||||
|
||||
const serverConfig = getServerConfigState()
|
||||
|
||||
const LIGHT_ACCENT_DEFAULT = '#1a1d1d'
|
||||
const DARK_ACCENT_DEFAULT = '#e6e8e8'
|
||||
const LIGHT_SECONDARY_DEFAULT = '#1a1d1d'
|
||||
const DARK_SECONDARY_DEFAULT = '#e6e8e8'
|
||||
|
||||
let accentLight = $state(LIGHT_ACCENT_DEFAULT)
|
||||
let accentDark = $state(DARK_ACCENT_DEFAULT)
|
||||
let secondaryLight = $state(LIGHT_SECONDARY_DEFAULT)
|
||||
let secondaryDark = $state(DARK_SECONDARY_DEFAULT)
|
||||
|
||||
$effect(() => {
|
||||
accentLight = serverConfig.primaryColor || LIGHT_ACCENT_DEFAULT
|
||||
accentDark = serverConfig.primaryColorDark || DARK_ACCENT_DEFAULT
|
||||
secondaryLight = serverConfig.secondaryColor || LIGHT_SECONDARY_DEFAULT
|
||||
secondaryDark = serverConfig.secondaryColorDark || DARK_SECONDARY_DEFAULT
|
||||
})
|
||||
|
||||
const isDark = $derived(
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
)
|
||||
|
||||
function applyColor(prop: string, value: string): void {
|
||||
document.documentElement.style.setProperty(prop, value)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
applyColor('--accent', isDark ? accentDark : accentLight)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
applyColor('--secondary', isDark ? secondaryDark : secondaryLight)
|
||||
})
|
||||
</script>
|
||||
|
||||
<Page title="UI Test" size="lg">
|
||||
<Section title="Theme">
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label for="accent-light">Accent (light)</label>
|
||||
<div class="color-pair">
|
||||
<input type="color" bind:value={accentLight} />
|
||||
<input id="accent-light" type="text" class="mono" bind:value={accentLight} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="accent-dark">Accent (dark)</label>
|
||||
<div class="color-pair">
|
||||
<input type="color" bind:value={accentDark} />
|
||||
<input id="accent-dark" type="text" class="mono" bind:value={accentDark} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="secondary-light">Secondary (light)</label>
|
||||
<div class="color-pair">
|
||||
<input type="color" bind:value={secondaryLight} />
|
||||
<input id="secondary-light" type="text" class="mono" bind:value={secondaryLight} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="secondary-dark">Secondary (dark)</label>
|
||||
<div class="color-pair">
|
||||
<input type="color" bind:value={secondaryDark} />
|
||||
<input id="secondary-dark" type="text" class="mono" bind:value={secondaryDark} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="locale-picker">Locale</label>
|
||||
<select id="locale-picker" value={$locale} onchange={(e) => locale.set(e.currentTarget.value)}>
|
||||
{#each getSupportedLocales() as loc}
|
||||
<option value={loc}>{localeNames[loc]} ({loc})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Typography">
|
||||
<p style="font-size: var(--text-4xl)">4xl (2.5rem)</p>
|
||||
<p style="font-size: var(--text-3xl)">3xl (2rem)</p>
|
||||
<p style="font-size: var(--text-2xl)">2xl (1.5rem)</p>
|
||||
<p style="font-size: var(--text-xl)">xl (1.25rem)</p>
|
||||
<p style="font-size: var(--text-lg)">lg (1.125rem)</p>
|
||||
<p style="font-size: var(--text-base)">base (1rem)</p>
|
||||
<p style="font-size: var(--text-sm)">sm (0.875rem)</p>
|
||||
<p style="font-size: var(--text-xs)">xs (0.75rem)</p>
|
||||
<hr />
|
||||
<p style="font-weight: var(--font-normal)">Normal (400)</p>
|
||||
<p style="font-weight: var(--font-medium)">Medium (500)</p>
|
||||
<p style="font-weight: var(--font-semibold)">Semibold (600)</p>
|
||||
<p style="font-weight: var(--font-bold)">Bold (700)</p>
|
||||
<hr />
|
||||
<code>Monospace text</code>
|
||||
<pre>Pre block
|
||||
indented</pre>
|
||||
</Section>
|
||||
|
||||
<Section title="Colors">
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<h4>Backgrounds</h4>
|
||||
<div class="swatch" style="background: var(--bg-primary)">bg-primary</div>
|
||||
<div class="swatch" style="background: var(--bg-secondary)">bg-secondary</div>
|
||||
<div class="swatch" style="background: var(--bg-tertiary)">bg-tertiary</div>
|
||||
<div class="swatch" style="background: var(--bg-card)">bg-card</div>
|
||||
<div class="swatch" style="background: var(--bg-input)">bg-input</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Text</h4>
|
||||
<p style="color: var(--text-primary)">text-primary</p>
|
||||
<p style="color: var(--text-secondary)">text-secondary</p>
|
||||
<p style="color: var(--text-muted)">text-muted</p>
|
||||
<div class="swatch" style="background: var(--accent); color: var(--text-inverse)">text-inverse</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Accent</h4>
|
||||
<div class="swatch" style="background: var(--accent); color: var(--text-inverse)">accent</div>
|
||||
<div class="swatch" style="background: var(--accent-hover); color: var(--text-inverse)">accent-hover</div>
|
||||
<div class="swatch" style="background: var(--accent-muted)">accent-muted</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Status</h4>
|
||||
<div class="swatch" style="background: var(--success-bg); color: var(--success-text)">success</div>
|
||||
<div class="swatch" style="background: var(--error-bg); color: var(--error-text)">error</div>
|
||||
<div class="swatch" style="background: var(--warning-bg); color: var(--warning-text)">warning</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Spacing">
|
||||
<div class="spacing-row">
|
||||
{#each [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] as i}
|
||||
<div class="spacing-item">
|
||||
<div class="spacing-box" style="width: var(--space-{i}); height: var(--space-{i})"></div>
|
||||
<span class="text-xs text-muted">--space-{i}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Buttons">
|
||||
<p>
|
||||
<Button variant="primary">{$_('common.save')}</Button>
|
||||
<Button variant="secondary">{$_('common.cancel')}</Button>
|
||||
<Button variant="tertiary">{$_('common.back')}</Button>
|
||||
<Button variant="danger">{$_('common.delete')}</Button>
|
||||
<Button variant="ghost">{$_('common.refresh')}</Button>
|
||||
</p>
|
||||
<p class="mt-5">
|
||||
<Button size="sm">{$_('common.verify')}</Button>
|
||||
<Button size="md">{$_('common.continue')}</Button>
|
||||
<Button size="lg">{$_('common.signIn')}</Button>
|
||||
</p>
|
||||
<p class="mt-5">
|
||||
<Button disabled>{$_('common.save')}</Button>
|
||||
<Button loading>{$_('common.saving')}</Button>
|
||||
<Button variant="secondary" disabled>{$_('common.cancel')}</Button>
|
||||
<Button variant="danger" loading>{$_('common.delete')}</Button>
|
||||
</p>
|
||||
<p class="mt-5">
|
||||
<button class="danger-outline">{$_('common.revoke')}</button>
|
||||
<button class="link">{$_('login.forgotPassword')}</button>
|
||||
<button class="sm">{$_('common.done')}</button>
|
||||
</p>
|
||||
<div class="mt-5">
|
||||
<Button fullWidth>{$_('common.signIn')}</Button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Inputs">
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<Input label={$_('settings.newEmail')} placeholder={$_('settings.newEmailPlaceholder')} bind:value={inputValue} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<Input label={$_('security.passkeyName')} placeholder={$_('security.passkeyNamePlaceholder')} hint={$_('appPasswords.createdMessage')} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<Input label={$_('verification.codeLabel')} placeholder={$_('verification.codePlaceholder')} error={$_('common.error')} bind:value={inputError} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<Input label={$_('settings.yourDomain')} placeholder={$_('settings.yourDomainPlaceholder')} disabled bind:value={inputDisabled} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row mt-5">
|
||||
<div class="field">
|
||||
<label for="demo-select">{$_('settings.language')}</label>
|
||||
<select id="demo-select">
|
||||
{#each getSupportedLocales() as loc}
|
||||
<option>{localeNames[loc]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="demo-textarea">{$_('settings.exportData')}</label>
|
||||
<textarea id="demo-textarea" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Cards">
|
||||
<Card>
|
||||
<h4>{$_('settings.exportData')}</h4>
|
||||
<p class="text-secondary text-sm">{$_('settings.downloadRepo')}</p>
|
||||
</Card>
|
||||
<div class="mt-4">
|
||||
<Card variant="interactive">
|
||||
<h4>{$_('sessions.session')}</h4>
|
||||
<p class="text-secondary text-sm">{$_('sessions.current')}</p>
|
||||
</Card>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<Card variant="danger">
|
||||
<h4>{$_('security.removePassword')}</h4>
|
||||
<p class="text-secondary text-sm">{$_('security.removePasswordWarning')}</p>
|
||||
</Card>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Sections">
|
||||
<Section title="Default section" description="With a description">
|
||||
<p>Section content</p>
|
||||
</Section>
|
||||
<div class="mt-5">
|
||||
<Section title="Danger section" variant="danger">
|
||||
<p>Destructive operations</p>
|
||||
</Section>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Messages">
|
||||
<Message variant="success">{$_('appPasswords.deleted')}</Message>
|
||||
<div class="mt-4"><Message variant="error">{$_('appPasswords.createFailed')}</Message></div>
|
||||
<div class="mt-4"><Message variant="warning">{$_('security.legacyLoginWarning')}</Message></div>
|
||||
<div class="mt-4"><Message variant="info">{$_('appPasswords.createdMessage')}</Message></div>
|
||||
</Section>
|
||||
|
||||
<Section title="Badges">
|
||||
<p>
|
||||
<span class="badge success">{$_('inviteCodes.available')}</span>
|
||||
<span class="badge warning">{$_('inviteCodes.spent')}</span>
|
||||
<span class="badge error">{$_('inviteCodes.disabled')}</span>
|
||||
<span class="badge accent">{$_('sessions.current')}</span>
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Toasts">
|
||||
<p>
|
||||
<Button variant="secondary" onclick={() => toast.success($_('appPasswords.deleted'))}>{$_('appPasswords.deleted')}</Button>
|
||||
<Button variant="secondary" onclick={() => toast.error($_('appPasswords.createFailed'))}>{$_('appPasswords.createFailed')}</Button>
|
||||
<Button variant="secondary" onclick={() => toast.warning($_('security.disableTotpWarning'))}>{$_('security.disableTotpWarning')}</Button>
|
||||
<Button variant="secondary" onclick={() => toast.info($_('appPasswords.createdMessage'))}>{$_('appPasswords.createdMessage')}</Button>
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Skeleton loading">
|
||||
<Skeleton variant="line" size="full" />
|
||||
<Skeleton variant="line" size="medium" />
|
||||
<Skeleton variant="line" size="short" />
|
||||
<Skeleton variant="line" size="tiny" />
|
||||
<div class="mt-5">
|
||||
<Skeleton variant="line" lines={3} />
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<Skeleton variant="card" lines={2} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Fieldset">
|
||||
<fieldset>
|
||||
<legend>Account settings</legend>
|
||||
<div class="field">
|
||||
<label for="demo-display-name">Display name</label>
|
||||
<input id="demo-display-name" type="text" placeholder="Name" />
|
||||
</div>
|
||||
</fieldset>
|
||||
</Section>
|
||||
|
||||
<Section title="Form layouts">
|
||||
<h4>Two column</h4>
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label for="demo-fname">First name</label>
|
||||
<input id="demo-fname" type="text" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="demo-lname">Last name</label>
|
||||
<input id="demo-lname" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
<h4 class="mt-5">Three column</h4>
|
||||
<div class="form-row thirds">
|
||||
<div class="field">
|
||||
<label for="demo-city">City</label>
|
||||
<input id="demo-city" type="text" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="demo-state">State</label>
|
||||
<input id="demo-state" type="text" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="demo-zip">ZIP</label>
|
||||
<input id="demo-zip" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
<h4 class="mt-5">Full width in row</h4>
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label for="demo-handle">Handle</label>
|
||||
<input id="demo-handle" type="text" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="demo-domain">Domain</label>
|
||||
<select id="demo-domain"><option>example.com</option></select>
|
||||
</div>
|
||||
<div class="field full-width">
|
||||
<label for="demo-bio">Bio</label>
|
||||
<textarea id="demo-bio" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Hints">
|
||||
<div class="field">
|
||||
<label for="demo-hint">With hints</label>
|
||||
<input id="demo-hint" type="text" />
|
||||
<span class="hint">Default hint</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<input type="text" />
|
||||
<span class="hint warning">Warning hint</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<input type="text" />
|
||||
<span class="hint error">Error hint</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<input type="text" />
|
||||
<span class="hint success">Success hint</span>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Radio group">
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="demo-radio" checked />
|
||||
<div class="radio-content">
|
||||
<span>Option A</span>
|
||||
<span class="radio-hint">First choice</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="demo-radio" />
|
||||
<div class="radio-content">
|
||||
<span>Option B</span>
|
||||
<span class="radio-hint">Second choice</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="radio-label disabled">
|
||||
<input type="radio" name="demo-radio" disabled />
|
||||
<div class="radio-content">
|
||||
<span>Option C</span>
|
||||
<span class="radio-hint disabled-hint">Unavailable</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Checkbox">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" />
|
||||
<span>{$_('appPasswords.acknowledgeLabel')}</span>
|
||||
</label>
|
||||
</Section>
|
||||
|
||||
<Section title="Warning box">
|
||||
<div class="warning-box">
|
||||
<strong>{$_('appPasswords.saveWarningTitle')}</strong>
|
||||
<p>{$_('appPasswords.saveWarningMessage')}</p>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Split layout">
|
||||
<div class="split-layout">
|
||||
<Card>
|
||||
<h4>Main content</h4>
|
||||
<p class="text-secondary">Primary area with a form or data</p>
|
||||
<div class="field mt-5">
|
||||
<label for="demo-example">Example field</label>
|
||||
<input id="demo-example" type="text" placeholder="Value" />
|
||||
</div>
|
||||
</Card>
|
||||
<div class="info-panel">
|
||||
<h3>Sidebar</h3>
|
||||
<p>Supplementary information placed alongside the main content area.</p>
|
||||
<ul class="info-list">
|
||||
<li>Supports DID methods: did:web, did:plc</li>
|
||||
<li>Maximum blob size: 10MB</li>
|
||||
<li>Rate limit: 100 requests per minute</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Composite: card with form">
|
||||
<Card>
|
||||
<h4>{$_('appPasswords.create')}</h4>
|
||||
<p class="text-secondary text-sm mb-5">{$_('appPasswords.permissions')}</p>
|
||||
<div class="field">
|
||||
<Input label={$_('appPasswords.name')} placeholder={$_('appPasswords.namePlaceholder')} />
|
||||
</div>
|
||||
<div class="mt-5" style="display: flex; gap: var(--space-3); justify-content: flex-end">
|
||||
<Button variant="tertiary">{$_('common.cancel')}</Button>
|
||||
<Button>{$_('appPasswords.create')}</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<Section title="Composite: section with actions">
|
||||
<Section title={$_('security.passkeys')}>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center">
|
||||
<div>
|
||||
<strong>{$_('security.totp')}</strong>
|
||||
<p class="text-sm text-secondary">{$_('security.totpEnabled')}</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm">{$_('security.disableTotp')}</Button>
|
||||
</div>
|
||||
<hr />
|
||||
<div style="display: flex; justify-content: space-between; align-items: center">
|
||||
<div>
|
||||
<strong>{$_('security.passkeys')}</strong>
|
||||
<p class="text-sm text-secondary">{$_('security.noPasskeys')}</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm">{$_('security.addPasskey')}</Button>
|
||||
</div>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Section title="Composite: error state">
|
||||
<div class="error-container">
|
||||
<div class="error-icon">!</div>
|
||||
<h2>Authorization failed</h2>
|
||||
<p>The requested scope exceeds the granted permissions.</p>
|
||||
<Button variant="secondary">Back</Button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Item list">
|
||||
<div class="item">
|
||||
<div class="item-info">
|
||||
<strong>Primary passkey</strong>
|
||||
<span class="text-sm text-secondary">Created 2024-01-15</span>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button class="sm">Rename</button>
|
||||
<button class="sm danger-outline">Revoke</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="item-info">
|
||||
<strong>Backup passkey</strong>
|
||||
<span class="text-sm text-secondary">Created 2024-03-20</span>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button class="sm">Rename</button>
|
||||
<button class="sm danger-outline">Revoke</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="item-info">
|
||||
<strong>Work laptop</strong>
|
||||
<span class="text-sm text-secondary">Created 2024-06-01</span>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button class="sm danger-outline">Revoke</button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Definition list">
|
||||
<dl class="definition-list">
|
||||
<dt>Handle</dt>
|
||||
<dd>@alice.example.com</dd>
|
||||
<dt>DID</dt>
|
||||
<dd class="mono">did:web:alice.example.com</dd>
|
||||
<dt>Email</dt>
|
||||
<dd>alice@example.com</dd>
|
||||
<dt>Created</dt>
|
||||
<dd>2024-01-15</dd>
|
||||
<dt>Status</dt>
|
||||
<dd><span class="badge success">Verified</span></dd>
|
||||
</dl>
|
||||
</Section>
|
||||
|
||||
<Section title="Tabs">
|
||||
<div class="tabs">
|
||||
<button class="tab active">PDS handle</button>
|
||||
<button class="tab">Custom domain</button>
|
||||
</div>
|
||||
<p class="text-secondary">Tab content appears here</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Inline form">
|
||||
<div class="inline-form">
|
||||
<h4>Change password</h4>
|
||||
<div class="field">
|
||||
<label for="demo-current-pw">Current password</label>
|
||||
<input id="demo-current-pw" type="password" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="demo-new-pw">New password</label>
|
||||
<input id="demo-new-pw" type="password" />
|
||||
</div>
|
||||
<div style="display: flex; gap: var(--space-3); justify-content: flex-end">
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
<Button>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</Page>
|
||||
Reference in New Issue
Block a user