feat: add embedded web UI

Adds an optional web dashboard served by OpenGFW itself, enabled with a new
`web` section in the config file.

Backend (web package, decoupled from engine/io so it builds on any OS):
- hub.go collects statistics off the engine logger callbacks: atomic counters,
  two ring-buffered time series (10s and 1min buckets), top N hosts/blocked
  destinations/rules/analyzers, and a 512 entry event buffer fanned out to
  connected clients over SSE. Slow clients drop frames instead of blocking
  the engine.
- api.go exposes /api/v1 for info, meta, metrics, events, the SSE stream and
  ruleset read/validate/replace.
- auth.go implements password login with in-memory session tokens and login
  rate limiting. Mutating endpoints require the bearer token (the session
  cookie is only accepted for GET), which makes them CSRF-safe.
- cmd/web.go implements the rule manager: rules are compiled before anything
  is written, the file is replaced atomically and the engine is hot reloaded.
  The SIGHUP handler now shares that same path.
- web/devserver serves the UI with synthetic traffic for frontend work on
  machines where the engine itself cannot be built.

Frontend (web/frontend, Vue 3 + Vite + Tailwind CSS v4 + Reka UI):
dashboard, live event feed with analyzer property inspection, visual and YAML
rule editors, analyzer overview and settings. Responsive down to phone sizes
with a bottom tab bar and bottom-sheet dialogs, plus light/dark themes and
English/Chinese translations.

The built UI in web/dist is committed and embedded with go:embed so that
`go build` works without Node; CI builds the frontend and checks that the
committed output is up to date.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mei
2026-07-27 08:09:11 +08:00
co-authored by Claude Opus 5
parent 393c29bd2d
commit 5a7722d1d2
94 changed files with 8534 additions and 23 deletions
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Badge } from '@/components/ui'
import { t } from '@/i18n'
import type { LiveEvent } from '@/lib/types'
const props = defineProps<{ event: LiveEvent }>()
const variant = computed(() => {
if (props.event.kind === 'error') return 'block'
if (props.event.kind === 'log') return 'log'
switch (props.event.action) {
case 'allow':
return 'allow'
case 'block':
return 'block'
case 'drop':
return 'drop'
case 'modify':
return 'modify'
default:
return 'muted'
}
})
const label = computed(() => {
if (props.event.kind === 'error') return t('action.error')
if (props.event.kind === 'log') return t('action.log')
return t(`action.${props.event.action ?? 'maybe'}`)
})
</script>
<template>
<Badge :variant="variant" class="uppercase">{{ label }}</Badge>
</template>
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { Activity, LayoutDashboard, ListFilter, Radio, Settings2 } from 'lucide-vue-next'
import { computed, onMounted, onUnmounted } from 'vue'
import { RouterLink, RouterView, useRoute } from 'vue-router'
import StatusPill from '@/components/StatusPill.vue'
import { startLive, stopLive } from '@/composables/useLive'
import { useTheme } from '@/composables/useTheme'
import { t } from '@/i18n'
import { cn } from '@/lib/utils'
const route = useRoute()
useTheme()
const items = computed(() => [
{ to: '/', label: t('nav.dashboard'), icon: LayoutDashboard },
{ to: '/events', label: t('nav.events'), icon: Radio },
{ to: '/rules', label: t('nav.rules'), icon: ListFilter },
{ to: '/analyzers', label: t('nav.analyzers'), icon: Activity },
{ to: '/settings', label: t('nav.settings'), icon: Settings2 },
])
const title = computed(() => {
const item = items.value.find((i) => i.to === route.path)
return item?.label ?? t('app.name')
})
onMounted(startLive)
onUnmounted(stopLive)
</script>
<template>
<div class="bg-background min-h-dvh">
<!-- Desktop sidebar -->
<aside
class="bg-card/40 fixed inset-y-0 left-0 z-40 hidden w-56 flex-col border-r px-3 py-4 md:flex lg:w-60"
>
<div class="flex items-center gap-2 px-2 pb-4">
<div
class="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-lg font-bold"
>
G
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold">{{ t('app.name') }}</p>
<p class="text-muted-foreground truncate text-[11px]">{{ t('app.tagline') }}</p>
</div>
</div>
<nav class="flex flex-1 flex-col gap-0.5">
<RouterLink
v-for="item in items"
:key="item.to"
:to="item.to"
:class="
cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
route.path === item.to
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
)
"
>
<component :is="item.icon" class="size-4 shrink-0" />
{{ item.label }}
</RouterLink>
</nav>
<div class="px-2 pt-3">
<StatusPill />
</div>
</aside>
<div class="flex min-h-dvh flex-col md:pl-56 lg:pl-60">
<!-- Mobile top bar -->
<header
class="bg-background/85 safe-top sticky top-0 z-30 border-b backdrop-blur-md md:hidden"
>
<div class="flex h-12 items-center justify-between gap-3 px-4">
<span class="truncate text-sm font-semibold">{{ title }}</span>
<StatusPill />
</div>
</header>
<main class="flex-1 px-4 pt-4 pb-24 sm:px-6 sm:pt-6 md:pb-8">
<RouterView v-slot="{ Component }">
<component :is="Component" class="animate-fade-in" />
</RouterView>
</main>
</div>
<!-- Mobile bottom navigation -->
<nav
class="bg-background/90 safe-bottom fixed inset-x-0 bottom-0 z-40 border-t backdrop-blur-md md:hidden"
>
<div class="grid grid-cols-5">
<RouterLink
v-for="item in items"
:key="item.to"
:to="item.to"
:class="
cn(
'flex flex-col items-center gap-0.5 py-2 text-[10px] font-medium transition-colors',
route.path === item.to ? 'text-foreground' : 'text-muted-foreground',
)
"
>
<component
:is="item.icon"
:class="cn('size-5', route.path === item.to && 'text-primary')"
/>
<span class="truncate">{{ item.label }}</span>
</RouterLink>
</div>
</nav>
</div>
</template>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue'
import { compact } from '@/lib/format'
import type { NameCount } from '@/lib/types'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<{ items: NameCount[]; tone?: 'primary' | 'block' | 'allow'; empty?: string }>(),
{ tone: 'primary' },
)
const max = computed(() => Math.max(1, ...props.items.map((i) => i.count)))
const bars: Record<string, string> = {
primary: 'bg-primary/15',
block: 'bg-block/15',
allow: 'bg-allow/15',
}
</script>
<template>
<div v-if="props.items.length" class="flex flex-col gap-1">
<div
v-for="item in props.items"
:key="item.name"
class="relative flex items-center justify-between gap-3 overflow-hidden rounded-md px-2 py-1.5"
>
<div
:class="cn('absolute inset-y-0 left-0 rounded-md', bars[props.tone])"
:style="{ width: `${Math.max(4, (item.count / max) * 100)}%` }"
/>
<span class="relative truncate font-mono text-xs sm:text-[13px]">{{ item.name }}</span>
<span class="text-muted-foreground relative shrink-0 text-xs tabular-nums">{{
compact(item.count)
}}</span>
</div>
</div>
<p v-else class="text-muted-foreground py-6 text-center text-xs">
{{ props.empty ?? '—' }}
</p>
</template>
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { ArrowRight } from 'lucide-vue-next'
import ActionBadge from '@/components/ActionBadge.vue'
import { Badge } from '@/components/ui'
import { endpoint, time } from '@/lib/format'
import type { LiveEvent } from '@/lib/types'
const props = defineProps<{ event: LiveEvent }>()
defineEmits<{ select: [event: LiveEvent] }>()
</script>
<template>
<button
type="button"
class="hover:bg-accent/60 focus-visible:ring-ring/40 flex w-full flex-col gap-1.5 rounded-lg px-2.5 py-2 text-left transition-colors outline-none focus-visible:ring-[3px] sm:flex-row sm:items-center sm:gap-3"
@click="$emit('select', props.event)"
>
<div class="flex items-center gap-2">
<span class="text-muted-foreground shrink-0 font-mono text-[11px] tabular-nums">
{{ time(props.event.time) }}
</span>
<ActionBadge :event="props.event" />
<Badge :variant="props.event.proto === 'udp' ? 'udp' : 'tcp'" class="uppercase">
{{ props.event.proto }}
</Badge>
<Badge v-if="props.event.rule" variant="outline" class="max-w-32 truncate sm:hidden">
{{ props.event.rule }}
</Badge>
</div>
<div class="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs">
<span class="text-muted-foreground truncate">
{{ endpoint(props.event.srcIP, props.event.srcPort) }}
</span>
<ArrowRight class="text-muted-foreground/60 size-3 shrink-0" />
<span class="truncate">
{{ props.event.host || endpoint(props.event.dstIP, props.event.dstPort) }}
</span>
</div>
<Badge v-if="props.event.rule" variant="outline" class="hidden max-w-40 truncate sm:inline-flex">
{{ props.event.rule }}
</Badge>
</button>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
const props = defineProps<{ title: string; description?: string }>()
</script>
<template>
<div class="mb-4 flex flex-wrap items-end justify-between gap-3 sm:mb-6">
<div class="min-w-0">
<h1 class="text-lg font-semibold tracking-tight sm:text-2xl">{{ props.title }}</h1>
<p v-if="props.description" class="text-muted-foreground mt-0.5 text-xs sm:text-sm">
{{ props.description }}
</p>
</div>
<div v-if="$slots.actions" class="flex shrink-0 items-center gap-2">
<slot name="actions" />
</div>
</div>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { Card } from '@/components/ui'
import { cn } from '@/lib/utils'
const props = defineProps<{
label: string
value: string
hint?: string
icon?: Component
tone?: 'default' | 'allow' | 'block' | 'drop' | 'modify'
}>()
const tones: Record<string, string> = {
default: 'text-muted-foreground',
allow: 'text-allow',
block: 'text-block',
drop: 'text-drop',
modify: 'text-modify',
}
</script>
<template>
<Card class="p-3.5 sm:p-4">
<div class="flex items-start justify-between gap-2">
<span class="text-muted-foreground text-xs font-medium sm:text-sm">{{ props.label }}</span>
<component
:is="props.icon"
v-if="props.icon"
:class="cn('size-4 shrink-0', tones[props.tone ?? 'default'])"
/>
</div>
<div class="mt-1.5 flex items-baseline gap-1.5">
<span
:class="
cn(
'text-xl font-semibold tracking-tight tabular-nums sm:text-2xl',
props.tone && props.tone !== 'default' ? tones[props.tone] : '',
)
"
>{{ props.value }}</span
>
<span v-if="props.hint" class="text-muted-foreground truncate text-xs">{{
props.hint
}}</span>
</div>
</Card>
</template>
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useLive } from '@/composables/useLive'
import { t } from '@/i18n'
import { cn } from '@/lib/utils'
const { status } = useLive()
const label = computed(() => {
switch (status.value) {
case 'open':
return t('common.live')
case 'connecting':
return t('common.connecting')
default:
return t('common.offline')
}
})
</script>
<template>
<span
:class="
cn(
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium',
status === 'open'
? 'border-allow/30 bg-allow/10 text-allow'
: status === 'connecting'
? 'border-drop/30 bg-drop/10 text-drop'
: 'border-block/30 bg-block/10 text-block',
)
"
>
<span
:class="
cn(
'size-1.5 rounded-full',
status === 'open'
? 'bg-allow animate-pulse-dot'
: status === 'connecting'
? 'bg-drop animate-pulse-dot'
: 'bg-block',
)
"
/>
{{ label }}
</span>
</template>
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { CheckCircle2, TriangleAlert, X } from 'lucide-vue-next'
import { dismiss, useToast } from '@/composables/useToast'
import { cn } from '@/lib/utils'
const { toasts } = useToast()
</script>
<template>
<div
class="pointer-events-none fixed inset-x-0 top-0 z-100 flex flex-col items-center gap-2 p-3 sm:top-auto sm:right-0 sm:bottom-0 sm:left-auto sm:items-end sm:p-4"
>
<TransitionGroup
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2 sm:translate-y-2"
leave-active-class="transition duration-150 ease-in absolute"
leave-to-class="opacity-0 scale-95"
>
<div
v-for="item in toasts"
:key="item.id"
:class="
cn(
'bg-popover text-popover-foreground pointer-events-auto flex w-full max-w-sm items-start gap-2.5 rounded-lg border p-3 shadow-lg',
item.variant === 'error' && 'border-block/40',
item.variant === 'success' && 'border-allow/40',
)
"
>
<CheckCircle2 v-if="item.variant === 'success'" class="text-allow mt-0.5 size-4 shrink-0" />
<TriangleAlert
v-else-if="item.variant === 'error'"
class="text-block mt-0.5 size-4 shrink-0"
/>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium">{{ item.title }}</p>
<p v-if="item.description" class="text-muted-foreground mt-0.5 text-xs break-words">
{{ item.description }}
</p>
</div>
<button
type="button"
class="text-muted-foreground hover:text-foreground -m-1 p-1"
@click="dismiss(item.id)"
>
<X class="size-3.5" />
</button>
</div>
</TransitionGroup>
</div>
</template>
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { t } from '@/i18n'
import { compact, time } from '@/lib/format'
import type { Bucket } from '@/lib/types'
const props = defineProps<{ buckets: Bucket[] }>()
const W = 600
const H = 160
const PAD_TOP = 8
const PAD_BOTTOM = 16
const hover = ref<number | null>(null)
const points = computed(() => props.buckets ?? [])
const max = computed(() => Math.max(1, ...points.value.map((b) => b.tcp + b.udp)))
const hasData = computed(() => points.value.some((b) => b.tcp + b.udp + b.blocked > 0))
function x(i: number): number {
const n = points.value.length
return n <= 1 ? 0 : (i / (n - 1)) * W
}
function y(value: number): number {
const usable = H - PAD_TOP - PAD_BOTTOM
return PAD_TOP + usable - (value / max.value) * usable
}
/** Builds a closed area path for a series of stacked values. */
function area(values: number[]): string {
if (values.length === 0) return ''
const top = values.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(v).toFixed(1)}`)
const base = `L${W},${y(0)} L0,${y(0)} Z`
return top.join(' ') + ' ' + base
}
function line(values: number[]): string {
return values
.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(v).toFixed(1)}`)
.join(' ')
}
const totalPath = computed(() => area(points.value.map((b) => b.tcp + b.udp)))
const tcpPath = computed(() => area(points.value.map((b) => b.tcp)))
const blockedPath = computed(() => line(points.value.map((b) => b.blocked)))
const active = computed(() => (hover.value === null ? null : points.value[hover.value]))
function onMove(event: PointerEvent) {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const ratio = (event.clientX - rect.left) / rect.width
const index = Math.round(ratio * (points.value.length - 1))
hover.value = Math.min(points.value.length - 1, Math.max(0, index))
}
const legend = computed(() => {
const sum = (key: 'tcp' | 'udp' | 'blocked') =>
points.value.reduce((acc, b) => acc + b[key], 0)
return [
{ label: t('dashboard.tcp'), color: 'bg-tcp', value: sum('tcp') },
{ label: t('dashboard.udp'), color: 'bg-udp', value: sum('udp') },
{ label: t('dashboard.blocked'), color: 'bg-block', value: sum('blocked') },
]
})
</script>
<template>
<div>
<div
class="relative touch-pan-y"
@pointermove="onMove"
@pointerdown="onMove"
@pointerleave="hover = null"
>
<svg
:viewBox="`0 0 ${W} ${H}`"
preserveAspectRatio="none"
class="h-36 w-full sm:h-44"
role="img"
>
<line
v-for="frac in [0, 0.5, 1]"
:key="frac"
:x1="0"
:x2="W"
:y1="y(max * frac)"
:y2="y(max * frac)"
class="stroke-border"
stroke-width="1"
vector-effect="non-scaling-stroke"
stroke-dasharray="3 4"
/>
<path :d="totalPath" class="fill-udp/25" />
<path :d="tcpPath" class="fill-tcp/45" />
<path
:d="blockedPath"
class="stroke-block"
fill="none"
stroke-width="1.75"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
<template v-if="hover !== null">
<line
:x1="x(hover)"
:x2="x(hover)"
:y1="PAD_TOP"
:y2="y(0)"
class="stroke-foreground/40"
stroke-width="1"
vector-effect="non-scaling-stroke"
/>
</template>
</svg>
<div
class="text-muted-foreground pointer-events-none absolute top-0 left-0 text-[10px] tabular-nums"
>
{{ compact(max) }}
</div>
<div
v-if="active"
class="bg-popover pointer-events-none absolute top-1 z-10 rounded-md border px-2 py-1.5 text-[11px] shadow-md"
:style="{
left: `${(hover! / Math.max(1, points.length - 1)) * 100}%`,
transform:
hover! / Math.max(1, points.length - 1) > 0.6
? 'translateX(-105%)'
: 'translateX(8px)',
}"
>
<div class="text-muted-foreground mb-0.5">{{ time(active.time) }}</div>
<div class="flex items-center gap-2 tabular-nums">
<span class="bg-tcp size-2 rounded-full" />{{ t('dashboard.tcp') }}
<span class="ml-auto font-medium">{{ active.tcp }}</span>
</div>
<div class="flex items-center gap-2 tabular-nums">
<span class="bg-udp size-2 rounded-full" />{{ t('dashboard.udp') }}
<span class="ml-auto font-medium">{{ active.udp }}</span>
</div>
<div class="flex items-center gap-2 tabular-nums">
<span class="bg-block size-2 rounded-full" />{{ t('dashboard.blocked') }}
<span class="ml-auto font-medium">{{ active.blocked }}</span>
</div>
</div>
<div
v-if="!hasData"
class="text-muted-foreground absolute inset-0 flex items-center justify-center text-xs"
>
{{ t('dashboard.noTraffic') }}
</div>
</div>
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1">
<div v-for="item in legend" :key="item.label" class="flex items-center gap-1.5 text-xs">
<span :class="['size-2 rounded-full', item.color]" />
<span class="text-muted-foreground">{{ item.label }}</span>
<span class="font-medium tabular-nums">{{ compact(item.value) }}</span>
</div>
</div>
</div>
</template>
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import { cva, type VariantProps } from 'class-variance-authority'
export const badgeVariants = cva(
'inline-flex items-center justify-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium whitespace-nowrap tabular-nums',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
outline: 'text-foreground',
muted: 'border-transparent bg-muted text-muted-foreground',
allow: 'border-allow/25 bg-allow/12 text-allow',
block: 'border-block/25 bg-block/12 text-block',
drop: 'border-drop/25 bg-drop/12 text-drop',
modify: 'border-modify/25 bg-modify/12 text-modify',
log: 'border-log/25 bg-log/12 text-log',
tcp: 'border-tcp/25 bg-tcp/12 text-tcp',
udp: 'border-udp/25 bg-udp/12 text-udp',
},
},
defaultVariants: { variant: 'default' },
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
</script>
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ variant?: BadgeVariants['variant']; class?: string }>()
</script>
<template>
<span :class="cn(badgeVariants({ variant: props.variant }), props.class)">
<slot />
</span>
</template>
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
import { cva, type VariantProps } from 'class-variance-authority'
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow,background-color] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 focus-visible:border-ring active:scale-[0.98] select-none",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/30',
outline:
'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5 text-[13px]',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8 rounded-md',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
</script>
<script setup lang="ts">
import { Primitive, type PrimitiveProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<
PrimitiveProps & {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: string
}
>(),
{ as: 'button' },
)
</script>
<template>
<Primitive
:as="props.as"
:as-child="props.asChild"
:class="cn(buttonVariants({ variant: props.variant, size: props.size }), props.class)"
>
<slot />
</Primitive>
</template>
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<div
:class="
cn(
'bg-card text-card-foreground rounded-xl border shadow-sm transition-colors',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<div :class="cn('px-4 pb-4 sm:px-5 sm:pb-5', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<p :class="cn('text-muted-foreground text-xs sm:text-sm', props.class)">
<slot />
</p>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<div :class="cn('flex flex-col gap-1 px-4 pt-4 pb-3 sm:px-5 sm:pt-5', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<h3 :class="cn('text-sm leading-none font-semibold tracking-tight sm:text-base', props.class)">
<slot />
</h3>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; modelValue?: string | number }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<input
:value="props.modelValue"
:class="
cn(
'border-input bg-background flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none sm:text-sm',
'placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground',
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-[3px]',
'disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</template>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { Label } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; for?: string }>()
</script>
<template>
<Label
:for="props.for"
:class="
cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none',
props.class,
)
"
>
<slot />
</Label>
</template>
+71
View File
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { X } from 'lucide-vue-next'
import {
DialogClose,
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
} from 'reka-ui'
import { cn } from '@/lib/utils'
/**
* A dialog that turns into a bottom drawer on small screens, which is much
* easier to reach with a thumb than a centered modal.
*/
const props = defineProps<{
open: boolean
title: string
description?: string
class?: string
}>()
const emit = defineEmits<{ 'update:open': [value: boolean] }>()
</script>
<template>
<DialogRoot :open="props.open" @update:open="emit('update:open', $event)">
<DialogPortal>
<DialogOverlay
class="animate-fade-in fixed inset-0 z-50 bg-black/60 backdrop-blur-[2px]"
/>
<DialogContent
:class="
cn(
'bg-background fixed z-50 flex flex-col gap-4 border shadow-lg outline-none',
// Mobile: bottom drawer
'animate-slide-up inset-x-0 bottom-0 max-h-[92vh] rounded-t-2xl border-b-0 p-4 pb-[max(1rem,env(safe-area-inset-bottom))]',
// Desktop: centered dialog
'sm:top-1/2 sm:bottom-auto sm:left-1/2 sm:max-h-[85vh] sm:w-full sm:max-w-lg sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-xl sm:border-b sm:p-6',
props.class,
)
"
>
<div
class="bg-muted-foreground/25 mx-auto -mt-1 mb-1 h-1 w-10 shrink-0 rounded-full sm:hidden"
/>
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-1">
<DialogTitle class="text-base font-semibold">{{ props.title }}</DialogTitle>
<DialogDescription v-if="props.description" class="text-muted-foreground text-sm">
{{ props.description }}
</DialogDescription>
</div>
<DialogClose
class="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/40 -mt-1 -mr-1 rounded-md p-1.5 transition-colors outline-none focus-visible:ring-[3px]"
>
<X class="size-4" />
</DialogClose>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
<slot />
</div>
<div v-if="$slots.footer" class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<slot name="footer" />
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { Check, ChevronDown } from 'lucide-vue-next'
import {
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectValue,
SelectViewport,
} from 'reka-ui'
import type { SelectOption } from '@/lib/types'
import { cn } from '@/lib/utils'
const props = defineProps<{
modelValue: string
options: SelectOption[]
placeholder?: string
ariaLabel?: string
class?: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<SelectRoot
:model-value="props.modelValue"
@update:model-value="emit('update:modelValue', String($event))"
>
<SelectTrigger
:aria-label="props.ariaLabel"
:class="
cn(
'border-input bg-background flex h-9 w-full items-center justify-between gap-2 rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none',
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-[3px]',
'data-[placeholder]:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
>
<SelectValue :placeholder="props.placeholder ?? ''" class="truncate" />
<ChevronDown class="size-4 shrink-0 opacity-50" />
</SelectTrigger>
<SelectPortal>
<SelectContent
position="popper"
:side-offset="4"
class="bg-popover text-popover-foreground animate-fade-in relative z-50 max-h-72 min-w-[var(--reka-select-trigger-width)] overflow-hidden rounded-md border shadow-md"
>
<SelectViewport class="p-1">
<SelectItem
v-for="option in props.options"
:key="option.value"
:value="option.value"
class="focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
<SelectItemText>{{ option.label }}</SelectItemText>
<SelectItemIndicator class="absolute right-2 flex items-center">
<Check class="size-4" />
</SelectItemIndicator>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<{ class?: string; orientation?: 'horizontal' | 'vertical' }>(),
{ orientation: 'horizontal' },
)
</script>
<template>
<div
role="separator"
:class="
cn(
'bg-border shrink-0',
props.orientation === 'vertical' ? 'h-full w-px' : 'h-px w-full',
props.class,
)
"
/>
</template>
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<div :class="cn('bg-muted animate-pulse rounded-md', props.class)" />
</template>
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { SwitchRoot, SwitchThumb } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; modelValue?: boolean; id?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
</script>
<template>
<SwitchRoot
:id="props.id"
:model-value="props.modelValue"
:class="
cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
@update:model-value="emit('update:modelValue', $event)"
>
<SwitchThumb
class="bg-background pointer-events-none block size-4 rounded-full shadow-sm ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5"
/>
</SwitchRoot>
</template>
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { TabsRoot } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; modelValue?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<TabsRoot
:model-value="props.modelValue"
:class="cn('flex flex-col gap-3', props.class)"
@update:model-value="emit('update:modelValue', String($event))"
>
<slot />
</TabsRoot>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { TabsContent } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; value: string }>()
</script>
<template>
<TabsContent :value="props.value" :class="cn('outline-none', props.class)">
<slot />
</TabsContent>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { TabsList } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<TabsList
:class="
cn(
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-1',
props.class,
)
"
>
<slot />
</TabsList>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { TabsTrigger } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; value: string }>()
</script>
<template>
<TabsTrigger
:value="props.value"
:class="
cn(
'inline-flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1 text-sm font-medium whitespace-nowrap transition-all outline-none',
'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
'focus-visible:ring-ring/40 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:size-3.5',
props.class,
)
"
>
<slot />
</TabsTrigger>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; modelValue?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<textarea
:value="props.modelValue"
:class="
cn(
'border-input bg-background flex min-h-20 w-full rounded-md border px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none sm:text-sm',
'placeholder:text-muted-foreground field-sizing-content',
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-[3px]',
'disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
/>
</template>
+19
View File
@@ -0,0 +1,19 @@
export { default as Badge, badgeVariants } from './Badge.vue'
export { default as Button, buttonVariants } from './Button.vue'
export { default as Card } from './Card.vue'
export { default as CardContent } from './CardContent.vue'
export { default as CardDescription } from './CardDescription.vue'
export { default as CardHeader } from './CardHeader.vue'
export { default as CardTitle } from './CardTitle.vue'
export { default as Input } from './Input.vue'
export { default as Label } from './Label.vue'
export { default as Modal } from './Modal.vue'
export { default as Select } from './Select.vue'
export { default as Separator } from './Separator.vue'
export { default as Skeleton } from './Skeleton.vue'
export { default as Switch } from './Switch.vue'
export { default as Tabs } from './Tabs.vue'
export { default as TabsContent } from './TabsContent.vue'
export { default as TabsList } from './TabsList.vue'
export { default as TabsTrigger } from './TabsTrigger.vue'
export { default as Textarea } from './Textarea.vue'