feat(web): replace 3D graph with fleet map, add desktop background patterns, rename Knowledge Base to Fleet
- FleetMap: service-centric host -> container -> service graph replacing the WebGL 3D force graph, with health coloring, hover-to-trace blast radius, and click-to-open - Desktop background: configurable CSS pattern picker in Settings -> Appearance (8 patterns, color/fill/opacity/fade/size/rotation), replacing the hardcoded ambient graph background - Fix missing data-orientation/data-disabled Tailwind custom variants so the shadcn Slider's track actually renders - Rename "Knowledge Base" app to "Fleet"; scope its table to the same fleet entities as the graph (compute-entity descendants + service) instead of all entities - Remove dead code: EntityGraph, GraphBackground, categories.ts, MultiSelectFilter (all superseded by the above) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,9 +3,7 @@
|
||||
import { fetchAllEntities, fetchOntology, fetchGraph, type Entity, type EntityType, type Ontology } from '$lib/api'
|
||||
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||
import EntityTable from '$lib/components/EntityTable.svelte'
|
||||
import EntityGraph, { type GraphInfo } from '$lib/components/EntityGraph.svelte'
|
||||
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte'
|
||||
import { typeToCategory, type Category } from '$lib/categories'
|
||||
import FleetMap from '$lib/components/FleetMap.svelte'
|
||||
import { openEntityWindow, wmState } from '$lib/stores/windows'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Input } from '$lib/components/ui/input'
|
||||
@@ -13,7 +11,6 @@
|
||||
import { Label } from '$lib/components/ui/label'
|
||||
import NetworkIcon from '@lucide/svelte/icons/share-2'
|
||||
import TableIcon from '@lucide/svelte/icons/table-2'
|
||||
import LocateFixedIcon from '@lucide/svelte/icons/locate-fixed'
|
||||
|
||||
type View = 'graph' | 'table'
|
||||
|
||||
@@ -49,22 +46,17 @@
|
||||
if (lastOpened && !$wmState.windows[lastOpened]) lastOpened = null
|
||||
})
|
||||
|
||||
// ─── entities: fetched here (not inside EntityTable) so the search/type
|
||||
// toolbar lives in the shared page toolbar instead of the resizable browse
|
||||
// pane, where its width is at the mercy of the divider and it would
|
||||
// truncate. Both views now share the same full entity set — there's no
|
||||
// more per-category server-side scoping, only the client-side type
|
||||
// multiselect (activeTypes) below, which both the table (row visibility)
|
||||
// and the graph (node visibility) read from.
|
||||
// ─── entities: fetched here (not inside EntityTable) so the search toolbar
|
||||
// lives in the shared page toolbar instead of the resizable browse pane,
|
||||
// where its width is at the mercy of the divider and it would truncate.
|
||||
// Both views show the same set: the *fleet* — the exact scope of the fleet
|
||||
// map (see isFleetType), not a generic browse-everything entity list.
|
||||
let allEntities = $state<Entity[]>([])
|
||||
let entitiesLoading = $state(true)
|
||||
let showInactive = $state(false)
|
||||
// child entity slug -> parent entity slug, derived from the ontology graph
|
||||
// (see loadGrouping). Feeds EntityTable's treegrid grouping, nesting e.g.
|
||||
// host -> lxc -> service, or storage-pool -> volume -> dataset — computed
|
||||
// over the whole entity set so the hierarchy doesn't reshuffle as the type
|
||||
// filter is toggled (EntityTable falls back a filtered-out parent's
|
||||
// children to top-level rather than dropping them).
|
||||
// (see loadGrouping). Feeds EntityTable's treegrid grouping, nesting the
|
||||
// fleet exactly as the fleet map's lanes do: host -> lxc/vm -> service.
|
||||
let childToParent = $state<Map<string, string> | null>(null)
|
||||
|
||||
let ontologyPromise: Promise<Ontology> | null = null
|
||||
@@ -73,9 +65,21 @@
|
||||
return ontologyPromise
|
||||
}
|
||||
|
||||
// type -> browsing category (see categories.ts), used only to seed the
|
||||
// type multiselect's default selection ("fleet") — not to scope any fetch.
|
||||
let typeCategory = $state<Map<string, Category | undefined>>(new Map())
|
||||
// The fleet = the same three kinds the fleet map shows: machines, VMs and
|
||||
// containers (everything under the abstract `compute-entity` root) plus
|
||||
// `service`. Derived from the ontology's own parent chain, not a hardcoded
|
||||
// type list, so any new compute/container subtype is included for free —
|
||||
// and generic software-domain types (config-repo, deploy-pipeline,
|
||||
// cluster, ...) that aren't fleet topology stay out.
|
||||
function isFleetType(byName: Map<string, EntityType>, typeName: string): boolean {
|
||||
if (typeName === 'service') return true
|
||||
let t = byName.get(typeName)
|
||||
for (let i = 0; t && i < 10; i++) {
|
||||
if (t.name === 'compute-entity') return true
|
||||
t = t.parent_type ? byName.get(t.parent_type) : undefined
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Distance from the ontology's abstract root ("entity") down to typeName —
|
||||
// 0 for entity itself, 1 for its direct subtypes, etc. Used as a
|
||||
@@ -99,8 +103,9 @@
|
||||
// parent (e.g. many hosts are located-at one site). many-to-many
|
||||
// relationships (mounts, stores-on, backs-up-to, ...) have no single
|
||||
// parent, so they're excluded from tree nesting. A candidate parent that
|
||||
// isn't actually part of the set being browsed (e.g. `cluster`, filtered
|
||||
// out below) is dropped rather than kept as a dangling pointer — that's
|
||||
// isn't actually part of the set being browsed (e.g. `cluster` or `site`,
|
||||
// outside the fleet scope) is dropped rather than kept as a dangling
|
||||
// pointer — that's
|
||||
// also what lets `located-at` surface as a host's parent instead of
|
||||
// `member-of` without any special-cased priority: with cluster absent,
|
||||
// member-of simply has nothing valid to point at. An entity can still be
|
||||
@@ -142,10 +147,13 @@
|
||||
|
||||
async function loadEntities() {
|
||||
entitiesLoading = true
|
||||
// cluster entities are dropped so a host's `member-of` edge has no valid
|
||||
// parent to point at, leaving `located-at` (site) as the only remaining
|
||||
// tree-parent candidate (see loadGrouping).
|
||||
const fetched = (await fetchAllEntities()).filter((e) => e.type !== 'cluster')
|
||||
const { entityTypes } = await getOntology()
|
||||
const byName = new Map(entityTypes.map((t) => [t.name, t]))
|
||||
// Scope to the fleet up front. This also drops non-fleet parents (site,
|
||||
// cluster) from the grouping input, so a host's `located-at`/`member-of`
|
||||
// edge has no valid parent to point at and the host stays top-level —
|
||||
// which is exactly the fleet map's arrangement (hosts anchor the tree).
|
||||
const fetched = (await fetchAllEntities()).filter((e) => isFleetType(byName, e.type))
|
||||
childToParent = await loadGrouping(fetched)
|
||||
allEntities = fetched
|
||||
entitiesLoading = false
|
||||
@@ -153,9 +161,6 @@
|
||||
|
||||
onMount(() => {
|
||||
loadEntities()
|
||||
getOntology().then((o) => {
|
||||
typeCategory = new Map(o.entityTypes.map((t) => [t.name, typeToCategory(t.name, t.domain)]))
|
||||
})
|
||||
const unsubscribe = subscribeEvents()
|
||||
return unsubscribe
|
||||
})
|
||||
@@ -166,25 +171,9 @@
|
||||
loadEntities()
|
||||
})
|
||||
|
||||
const allTypes = $derived(Array.from(new Set(allEntities.map((e) => e.type))).sort())
|
||||
|
||||
// Shared show/hide-by-type filter — governs both the table's row
|
||||
// visibility and the graph's node visibility. Seeded once (not
|
||||
// re-derived) to "fleet" types as soon as both the entity set and the
|
||||
// ontology's type->category map are loaded, so it doesn't clobber the
|
||||
// user's own toggles on a later reload.
|
||||
let activeTypes = $state<Set<string>>(new Set())
|
||||
let typesSeeded = false
|
||||
$effect(() => {
|
||||
if (typesSeeded || allTypes.length === 0 || typeCategory.size === 0) return
|
||||
activeTypes = new Set(allTypes.filter((t) => typeCategory.get(t) === 'fleet'))
|
||||
typesSeeded = true
|
||||
})
|
||||
|
||||
const filteredEntities = $derived.by(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
return allEntities.filter((e) => {
|
||||
if (!activeTypes.has(e.type)) return false
|
||||
if (q && !e.slug.toLowerCase().includes(q) && !e.name.toLowerCase().includes(q)) return false
|
||||
// entities with no tracked lifecycle state (state is null) aren't
|
||||
// "destroyed or inactive" — only hide ones whose tracked state has
|
||||
@@ -193,41 +182,14 @@
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// ─── graph controls: same reasoning as the table toolbar above — these
|
||||
// live here instead of inside EntityGraph so they render at full toolbar
|
||||
// width instead of being squeezed by the resizable browse pane.
|
||||
let graphRoot = $state('')
|
||||
let graphDepth = $state(2)
|
||||
let graphReloadToken = $state(0)
|
||||
let graphResetToken = $state(0)
|
||||
let graphActiveRelTypes = $state<Set<string>>(new Set())
|
||||
let graphInfo = $state<GraphInfo>({ allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
|
||||
|
||||
function commitGraphQuery() {
|
||||
graphReloadToken++
|
||||
}
|
||||
|
||||
function resetGraph() {
|
||||
graphRoot = ''
|
||||
search = ''
|
||||
graphResetToken++
|
||||
}
|
||||
|
||||
function relColorFor(type: string): string {
|
||||
return graphInfo.relColors.get(type) ?? '#30363d'
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col gap-3 p-4">
|
||||
<!-- single toolbar row: search + type filter are shared by both views
|
||||
(one multiselect instead of a category tab, a single-select "All
|
||||
types" dropdown, and a separate graph node-type toggle), the rest is
|
||||
view-specific, and the graph/table switch sits inline with the rest
|
||||
instead of floating in its own row. -->
|
||||
<!-- single toolbar row: the search box is shared by both views (graph
|
||||
highlights nodes, table filters rows); the Inactive toggle + count are
|
||||
table-only; the graph/table switch sits inline on the right. -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Input placeholder="Filter / highlight by slug or name…" bind:value={search} class="h-8 max-w-xs text-xs" />
|
||||
<MultiSelectFilter label="Types" options={allTypes} bind:selected={activeTypes} />
|
||||
|
||||
{#if view === 'table'}
|
||||
<div class="flex items-center gap-1.5">
|
||||
@@ -235,17 +197,6 @@
|
||||
<Label for="show-inactive" class="text-xs font-normal text-muted-foreground">Inactive</Label>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground">{filteredEntities.length} of {allEntities.length}</span>
|
||||
{:else}
|
||||
<Input placeholder="Root entity…" bind:value={graphRoot} class="h-8 max-w-40 text-xs" onchange={commitGraphQuery} />
|
||||
<Input type="number" min="1" max="5" bind:value={graphDepth} class="h-8 w-14 text-xs" onchange={commitGraphQuery} />
|
||||
<Button variant="outline" size="sm" class="h-8" onclick={resetGraph}>
|
||||
<LocateFixedIcon class="mr-1 size-3.5" />
|
||||
Reset
|
||||
</Button>
|
||||
<MultiSelectFilter label="Edges" options={graphInfo.allRelTypes} bind:selected={graphActiveRelTypes} colorFor={relColorFor} />
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{graphInfo.visibleCount} nodes{graphInfo.truncated ? ' · truncated' : ''} · {graphInfo.zoomPct}%
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<div class="ml-auto inline-flex overflow-hidden rounded-md border">
|
||||
@@ -276,18 +227,7 @@
|
||||
(WindowLayer, mounted globally inside Desktop.svelte) instead of a sidebar. -->
|
||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{#if view === 'graph'}
|
||||
<EntityGraph
|
||||
selectedSlug={lastOpened}
|
||||
onSelect={select}
|
||||
bind:root={graphRoot}
|
||||
depth={graphDepth}
|
||||
{search}
|
||||
reloadToken={graphReloadToken}
|
||||
resetToken={graphResetToken}
|
||||
activeNodeTypes={activeTypes}
|
||||
bind:activeRelTypes={graphActiveRelTypes}
|
||||
bind:info={graphInfo}
|
||||
/>
|
||||
<FleetMap selectedSlug={lastOpened} onSelect={select} {search} />
|
||||
{:else}
|
||||
<EntityTable entities={filteredEntities} loading={entitiesLoading} selectedSlug={lastOpened} onSelect={select} {childToParent} />
|
||||
{/if}
|
||||
|
||||
@@ -9,9 +9,22 @@
|
||||
import { Label } from '$lib/components/ui/label'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Separator } from '$lib/components/ui/separator'
|
||||
import { Checkbox } from '$lib/components/ui/checkbox'
|
||||
import { Slider } from '$lib/components/ui/slider'
|
||||
import { fetchWithAuth, setConfig, initConfig, getConfig, clearConfig, type OikosConfig } from '$lib/config'
|
||||
import { startLogin, logout as oidcLogout, getUser, isOIDCConfigured } from '$lib/oidc'
|
||||
import { getTheme, setTheme, THEME_LABELS, type Theme } from '$lib/stores/theme.svelte'
|
||||
import {
|
||||
getBackground,
|
||||
setBackgroundPattern,
|
||||
setPatternColor,
|
||||
setFillColor,
|
||||
setBackgroundOpacity,
|
||||
setBackgroundFade,
|
||||
setBackgroundScale,
|
||||
setBackgroundRotation
|
||||
} from '$lib/stores/background.svelte'
|
||||
import { PATTERNS, patternCss, type BackgroundPatternId } from '$lib/desktop-patterns'
|
||||
import { VERSION } from '$lib/version'
|
||||
import { toast } from 'svelte-sonner'
|
||||
import PlugIcon from '@lucide/svelte/icons/plug'
|
||||
@@ -22,12 +35,12 @@
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: 'connection', label: 'Connection', icon: PlugIcon },
|
||||
{ id: 'appearance', label: 'Appearance', icon: PaletteIcon }
|
||||
{ id: 'appearance', label: 'Appearance', icon: PaletteIcon },
|
||||
{ id: 'connection', label: 'Connection', icon: PlugIcon }
|
||||
] as const
|
||||
type SectionId = (typeof SECTIONS)[number]['id']
|
||||
|
||||
let section = $state<SectionId>('connection')
|
||||
let section = $state<SectionId>('appearance')
|
||||
|
||||
const existing = getConfig()
|
||||
let apiUrl = $state(existing.apiUrl ?? '')
|
||||
@@ -91,6 +104,37 @@
|
||||
function pickTheme(t: Theme) {
|
||||
setTheme(t)
|
||||
}
|
||||
|
||||
// Swatch previews render every pattern at a fixed, higher-contrast opacity
|
||||
// against a neutral tile so the shape reads clearly in the picker — the
|
||||
// live opacity slider (often tuned low, e.g. 0.08, so it reads as texture
|
||||
// rather than noise) would make most patterns nearly invisible here.
|
||||
const SWATCH_PREVIEW_OPACITY = 0.55
|
||||
|
||||
function swatchStyle(id: BackgroundPatternId, color: string): string {
|
||||
const css = patternCss(id, color)
|
||||
return css ? `opacity:${SWATCH_PREVIEW_OPACITY};${css}` : ''
|
||||
}
|
||||
|
||||
// Remembers the last custom fill color locally so toggling "Custom" off
|
||||
// and back on doesn't lose the pick — the store itself only ever holds
|
||||
// null (auto/theme) or the active custom color, not a disabled draft.
|
||||
let fillDraft = $state(getBackground().fillColor ?? '#ffffff')
|
||||
|
||||
function onFillToggle(checked: boolean) {
|
||||
setFillColor(checked ? fillDraft : null)
|
||||
}
|
||||
|
||||
function onFillColorInput(e: Event) {
|
||||
const val = (e.currentTarget as HTMLInputElement).value
|
||||
fillDraft = val
|
||||
setFillColor(val)
|
||||
}
|
||||
|
||||
// Size/rotation act on the pattern image itself — meaningless with no
|
||||
// pattern selected, unlike opacity/fade which also apply to a plain fill
|
||||
// color wash (pattern 'none' + a custom fill).
|
||||
const noPattern = $derived(getBackground().pattern === 'none')
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
@@ -170,20 +214,154 @@
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">Pick the theme for the whole desktop.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each Object.entries(THEME_LABELS) as [id, label] (id)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-between rounded-lg border px-3.5 py-2.5 text-left text-sm transition-colors {getTheme() === (id as Theme)
|
||||
class="flex items-center justify-between gap-1.5 rounded-lg border px-2.5 py-1.5 text-left text-sm transition-colors {getTheme() === (id as Theme)
|
||||
? 'border-primary/50 bg-primary/5 text-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted/50'}"
|
||||
onclick={() => pickTheme(id as Theme)}
|
||||
>
|
||||
{label}
|
||||
{#if getTheme() === (id as Theme)}<CheckIcon class="size-4 text-primary" />{/if}
|
||||
{#if getTheme() === (id as Theme)}<CheckIcon class="size-3.5 shrink-0 text-primary" />{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Separator decorative />
|
||||
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">Desktop background</h3>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">
|
||||
A subtle CSS pattern behind your icons, in the style of
|
||||
<a href="https://www.magicpattern.design/tools/css-backgrounds" target="_blank" rel="noreferrer">magicpattern.design</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
{#each PATTERNS as p (p.id)}
|
||||
{@const active = getBackground().pattern === p.id}
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center gap-1.5 rounded-lg border p-2 transition-colors {active
|
||||
? 'border-primary/60 bg-primary/5'
|
||||
: 'border-border hover:bg-muted/50'}"
|
||||
onclick={() => setBackgroundPattern(p.id)}
|
||||
title={p.label}
|
||||
>
|
||||
<span
|
||||
class="h-10 w-full rounded-md border border-border/60 bg-muted"
|
||||
style={swatchStyle(p.id, getBackground().color)}
|
||||
></span>
|
||||
<span class="flex items-center gap-1 text-[11px] {active ? 'font-medium text-foreground' : 'text-muted-foreground'}">
|
||||
{p.label}
|
||||
{#if active}<CheckIcon class="size-3 text-primary" />{/if}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Colors: pattern (foreground shapes) + an optional fill behind
|
||||
them. Fill defaults to "Auto", i.e. transparent, so the app's
|
||||
own theme background shows through the gaps — same as before
|
||||
this control existed. -->
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<Label for="bg-color" class="text-xs font-medium">Pattern color</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="bg-color"
|
||||
type="color"
|
||||
class="size-7 cursor-pointer rounded-md border border-border bg-transparent p-0.5"
|
||||
value={getBackground().color}
|
||||
oninput={(e) => setPatternColor((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
<span class="font-mono text-xs text-muted-foreground">{getBackground().color}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="bg-fill-toggle"
|
||||
checked={getBackground().fillColor !== null}
|
||||
onCheckedChange={(v) => onFillToggle(!!v)}
|
||||
/>
|
||||
<Label for="bg-fill-toggle" class="text-xs font-medium">Custom background fill</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="bg-fill-color"
|
||||
type="color"
|
||||
class="size-7 cursor-pointer rounded-md border border-border bg-transparent p-0.5 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
value={getBackground().fillColor ?? fillDraft}
|
||||
disabled={getBackground().fillColor === null}
|
||||
oninput={onFillColorInput}
|
||||
/>
|
||||
<span class="font-mono text-xs text-muted-foreground">{getBackground().fillColor ?? 'Auto'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="bg-opacity" class="text-xs font-medium">Opacity</Label>
|
||||
<span class="font-mono text-xs text-muted-foreground">{Math.round(getBackground().opacity * 100)}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="bg-opacity"
|
||||
type="single"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={getBackground().opacity}
|
||||
onValueChange={setBackgroundOpacity}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="bg-fade" class="text-xs font-medium">Fade mask</Label>
|
||||
<span class="font-mono text-xs text-muted-foreground">
|
||||
{getBackground().fade === 0 ? 'Off' : `${Math.round(getBackground().fade * 100)}%`}
|
||||
</span>
|
||||
</div>
|
||||
<Slider id="bg-fade" type="single" min={0} max={1} step={0.01} value={getBackground().fade} onValueChange={setBackgroundFade} />
|
||||
<p class="text-[11px] text-muted-foreground">Fades the pattern out toward the edges, like a vignette.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="bg-scale" class="text-xs font-medium">Size</Label>
|
||||
<span class="font-mono text-xs text-muted-foreground">{Math.round(getBackground().scale * 100)}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="bg-scale"
|
||||
type="single"
|
||||
min={0.4}
|
||||
max={3}
|
||||
step={0.05}
|
||||
value={getBackground().scale}
|
||||
onValueChange={setBackgroundScale}
|
||||
disabled={noPattern}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="bg-rotation" class="text-xs font-medium">Rotation</Label>
|
||||
<span class="font-mono text-xs text-muted-foreground">{Math.round(getBackground().rotation)}°</span>
|
||||
</div>
|
||||
<Slider
|
||||
id="bg-rotation"
|
||||
type="single"
|
||||
min={0}
|
||||
max={359}
|
||||
step={1}
|
||||
value={getBackground().rotation}
|
||||
onValueChange={setBackgroundRotation}
|
||||
disabled={noPattern}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user