Admin panel (warp-admin) and the apps/ui design system
apps/ui: Warp's design system for Vue 3 + Tailwind v4. Tokens, fonts, icons and logo come from the brand package, with a Tailwind theme on the tokens and Vue components on the brand component styles. apps/admin: the admin panel as a separate, optional service. warp-admin embeds the built Vue app and proxies only the login endpoints and /_warp/admin/v1 to the homeserver. Pages: dashboard, users, rooms, federation, registration and tokens, server notices, application services, audit log. Also: pnpm workspace, go.work entry, Dockerfile, an `admin` compose profile, Makefile targets, a CI job with a proxy smoke test, and docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
487e0f410b
commit
7e5e38c921
137 files changed
+6895
-15
No files matched your search
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Display name or Matrix ID; the initial is taken from it. */
|
||||
name: string
|
||||
src?: string
|
||||
/** px */
|
||||
size?: number
|
||||
presence?: 'online' | 'away' | 'busy' | 'offline'
|
||||
}>(),
|
||||
{ src: undefined, size: 40, presence: undefined },
|
||||
)
|
||||
|
||||
const initial = computed(() => {
|
||||
const n = props.name.replace(/^[@#!+]/, '').trim()
|
||||
return (Array.from(n)[0] ?? '?').toUpperCase()
|
||||
})
|
||||
const dot = computed(() => Math.max(8, Math.round(props.size / 4)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="wp-avatar"
|
||||
:style="{ width: `${size}px`, height: `${size}px`, fontSize: `${Math.round(size * 0.42)}px` }"
|
||||
:title="name"
|
||||
>
|
||||
<img v-if="src" :src="src" alt="" />
|
||||
<span v-else aria-hidden="true">{{ initial }}</span>
|
||||
<span
|
||||
v-if="presence"
|
||||
:class="['wp-presence', `wp-presence-${presence}`]"
|
||||
:style="{ width: `${dot}px`, height: `${dot}px` }"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import WIcon from './WIcon.vue'
|
||||
import type { IconName } from '../icons'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
tone?: 'neutral' | 'brand' | 'success' | 'warning' | 'danger' | 'info'
|
||||
/** Unread count; renders a round counter, 99+ above 99. */
|
||||
count?: number
|
||||
icon?: IconName
|
||||
}>(),
|
||||
{ tone: 'neutral', count: undefined, icon: undefined },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="['wp-badge', `wp-badge-${tone}`, count !== undefined && 'wp-badge-count']">
|
||||
<template v-if="count !== undefined">{{ count > 99 ? '99+' : count }}</template>
|
||||
<template v-else>
|
||||
<WIcon v-if="icon" :name="icon" :size="14" />
|
||||
<slot />
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import WIcon from './WIcon.vue'
|
||||
import type { IconName } from '../icons'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
tone?: 'info' | 'success' | 'warning' | 'danger'
|
||||
title?: string
|
||||
/** Defaults to the tone's own icon: status is never colour alone. */
|
||||
icon?: IconName
|
||||
}>(),
|
||||
{ tone: 'info', title: undefined, icon: undefined },
|
||||
)
|
||||
|
||||
const toneIcon: Record<'info' | 'success' | 'warning' | 'danger', IconName> = {
|
||||
info: 'server',
|
||||
success: 'shield-check',
|
||||
warning: 'shield-alert',
|
||||
danger: 'shield-alert',
|
||||
}
|
||||
const shown = computed(() => props.icon ?? toneIcon[props.tone])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['wp-banner', `wp-banner-${tone}`]" :role="tone === 'danger' ? 'alert' : 'status'">
|
||||
<WIcon :name="shown" />
|
||||
<div class="wp-banner-body">
|
||||
<p v-if="title" class="wp-banner-title">{{ title }}</p>
|
||||
<p v-if="$slots.default" class="wp-banner-text"><slot /></p>
|
||||
</div>
|
||||
<div v-if="$slots.action" class="wp-banner-action"><slot name="action" /></div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import WIcon from './WIcon.vue'
|
||||
import WSpinner from './WSpinner.vue'
|
||||
import type { IconName } from '../icons'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** One primary action per view. `danger` is the soft destructive style,
|
||||
* `danger-solid` the final confirmation inside a dialog. */
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'danger-solid'
|
||||
size?: 'md' | 'sm'
|
||||
icon?: IconName
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
disabled?: boolean
|
||||
/** Shows a spinner and disables the button while an action runs. */
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{ variant: 'secondary', size: 'md', icon: undefined, type: 'button', disabled: false, loading: false },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
:class="['wp-btn', `wp-btn-${size}`, `wp-btn-${variant}`]"
|
||||
:disabled="disabled || loading"
|
||||
:aria-busy="loading || undefined"
|
||||
>
|
||||
<WSpinner v-if="loading" :size="size === 'sm' ? 14 : 16" />
|
||||
<WIcon v-else-if="icon" :name="icon" :size="size === 'sm' ? 16 : 18" />
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ label: string; disabled?: boolean }>()
|
||||
|
||||
const model = defineModel<boolean>({ default: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="wp-check">
|
||||
<input v-model="model" type="checkbox" :disabled="disabled" />
|
||||
<span>{{ label }}</span>
|
||||
</label>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'
|
||||
import WIconButton from './WIconButton.vue'
|
||||
|
||||
// A modal on the native <dialog>: the browser traps focus, closes it on
|
||||
// Escape and gives focus back to whatever opened it.
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
/** Wider body for tables and long forms. */
|
||||
wide?: boolean
|
||||
/** Keep the dialog open on Escape, e.g. while a request is running. */
|
||||
persistent?: boolean
|
||||
}>(),
|
||||
{ wide: false, persistent: false },
|
||||
)
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
const el = ref<HTMLDialogElement>()
|
||||
const titleId = useId()
|
||||
|
||||
watch(
|
||||
open,
|
||||
async (value) => {
|
||||
await nextTick()
|
||||
const d = el.value
|
||||
if (!d) return
|
||||
if (value && !d.open) d.showModal()
|
||||
if (!value && d.open) d.close()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function onCancel(e: Event) {
|
||||
if (props.persistent) e.preventDefault()
|
||||
else open.value = false
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (el.value?.open) el.value.close()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="el"
|
||||
class="wp-dialog"
|
||||
:style="wide ? { maxWidth: '720px' } : undefined"
|
||||
:aria-labelledby="titleId"
|
||||
@cancel="onCancel"
|
||||
@close="onClose"
|
||||
>
|
||||
<template v-if="open">
|
||||
<div class="wp-dialog-head">
|
||||
<h2 :id="titleId" class="wp-dialog-title">{{ title }}</h2>
|
||||
<WIconButton icon="close" label="Close" :disabled="persistent" @click="open = false" />
|
||||
</div>
|
||||
<slot />
|
||||
<div v-if="$slots.actions" class="wp-dialog-actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</template>
|
||||
</dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { iconBody, type IconName } from '../icons'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name: IconName
|
||||
/** px */
|
||||
size?: number
|
||||
/** Accessible name; leave out for a decorative icon. */
|
||||
label?: string
|
||||
}>(),
|
||||
{ size: 20, label: undefined },
|
||||
)
|
||||
|
||||
const body = computed(() => iconBody(props.name))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="wp-icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
:width="size"
|
||||
:height="size"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
:role="label ? 'img' : undefined"
|
||||
:aria-label="label"
|
||||
:aria-hidden="label ? undefined : 'true'"
|
||||
focusable="false"
|
||||
v-html="body"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import WIcon from './WIcon.vue'
|
||||
import type { IconName } from '../icons'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
icon: IconName
|
||||
/** Required accessible name, also the tooltip. */
|
||||
label: string
|
||||
variant?: 'ghost' | 'solid'
|
||||
type?: 'button' | 'submit'
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{ variant: 'ghost', type: 'button', disabled: false },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
:class="['wp-iconbtn', variant === 'solid' && 'wp-iconbtn-solid']"
|
||||
:aria-label="label"
|
||||
:title="label"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<WIcon :name="icon" />
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { symbolPath, symbolSmallPath, wordmarkPath, wordmarkTransform } from '../logos'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** "horizontal" (symbol + wordmark, the default lockup) or "symbol". */
|
||||
variant?: 'horizontal' | 'symbol'
|
||||
/** Rendered height in px. */
|
||||
height?: number
|
||||
/** One colour: the symbol takes CSS `color` too. */
|
||||
mono?: boolean
|
||||
}>(),
|
||||
{ variant: 'horizontal', height: 32, mono: false },
|
||||
)
|
||||
|
||||
const width = computed(() => (props.variant === 'horizontal' ? props.height * 3 : props.height))
|
||||
// Under 32px the small cut keeps the gap between the frames open.
|
||||
const symbol = computed(() => (props.variant === 'symbol' && props.height < 32 ? symbolSmallPath : symbolPath))
|
||||
const symbolFill = computed(() => (props.mono ? 'currentColor' : 'var(--teal)'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="wp-logo"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
:viewBox="variant === 'horizontal' ? '0 0 768 256' : '0 0 256 256'"
|
||||
:width="width"
|
||||
:height="height"
|
||||
role="img"
|
||||
aria-label="Warp"
|
||||
>
|
||||
<path :fill="symbolFill" fill-rule="evenodd" :d="symbol" />
|
||||
<path v-if="variant === 'horizontal'" fill="currentColor" :transform="wordmarkTransform" :d="wordmarkPath" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
options: { value: string; label: string }[]
|
||||
hint?: string
|
||||
}>()
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
const id = useId()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wp-field">
|
||||
<label class="wp-field-label" :for="id">{{ label }}</label>
|
||||
<select :id="id" v-model="model" class="wp-select" v-bind="$attrs">
|
||||
<option v-for="o in options" :key="o.value" :value="o.value">{{ o.label }}</option>
|
||||
</select>
|
||||
<p v-if="hint" class="wp-field-hint">{{ hint }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number; label?: string }>(), { size: 20, label: undefined })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="wp-spinner"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
:role="label ? 'status' : undefined"
|
||||
:aria-label="label"
|
||||
:aria-hidden="label ? undefined : 'true'"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
/** Accessible name when no visible label is tied to the switch. */
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const model = defineModel<boolean>({ default: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
class="wp-switch"
|
||||
:aria-checked="model ? 'true' : 'false'"
|
||||
:aria-label="label"
|
||||
:disabled="disabled"
|
||||
@click="model = !model"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
import WIcon from './WIcon.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
hint?: string
|
||||
error?: string
|
||||
mono?: boolean
|
||||
}>(),
|
||||
{ hint: undefined, error: undefined, mono: false },
|
||||
)
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
const id = useId()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['wp-field wp-field-wide', error && 'wp-field-error']">
|
||||
<label class="wp-field-label" :for="id">{{ label }}</label>
|
||||
<textarea
|
||||
:id="id"
|
||||
v-model="model"
|
||||
:class="['wp-textarea', mono && 'wp-input-mono']"
|
||||
:aria-invalid="error ? 'true' : undefined"
|
||||
:aria-describedby="error || hint ? `${id}-hint` : undefined"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
<p v-if="error || hint" :id="`${id}-hint`" class="wp-field-hint">
|
||||
<WIcon v-if="error" name="shield-alert" :size="14" />
|
||||
{{ error || hint }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
import WIcon from './WIcon.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
hint?: string
|
||||
error?: string
|
||||
/** Monospace input for Matrix IDs, server names, keys. */
|
||||
mono?: boolean
|
||||
/** Fill the container instead of stopping at 360px. */
|
||||
wide?: boolean
|
||||
}>(),
|
||||
{ hint: undefined, error: undefined, mono: false, wide: false },
|
||||
)
|
||||
|
||||
const model = defineModel<string | number>({ default: '' })
|
||||
const id = useId()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['wp-field', error && 'wp-field-error', wide && 'wp-field-wide']">
|
||||
<label class="wp-field-label" :for="id">{{ label }}</label>
|
||||
<input
|
||||
:id="id"
|
||||
v-model="model"
|
||||
:class="['wp-input', mono && 'wp-input-mono']"
|
||||
:aria-invalid="error ? 'true' : undefined"
|
||||
:aria-describedby="error || hint ? `${id}-hint` : undefined"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
<p v-if="error || hint" :id="`${id}-hint`" class="wp-field-hint">
|
||||
<WIcon v-if="error" name="shield-alert" :size="14" />
|
||||
{{ error || hint }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user