@revenexx/studio-shared 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/components/DataTable/ColumnsMenu.vue +57 -0
- package/components/DataTable/DataTable.vue +518 -0
- package/components/DataTable/FilterMenu.vue +40 -0
- package/components/DataTable/types.ts +82 -0
- package/components/Shared/BuildLogsFlyout.vue +127 -0
- package/components/Shared/GitDeploymentSettings.vue +87 -0
- package/components/Shared/KpiTile.vue +41 -0
- package/components/Shared/PageHeader.vue +34 -0
- package/components/Shared/SidebarNav.vue +100 -0
- package/components/Shared/SortHeader.vue +50 -0
- package/components/Shared/StateCard.vue +66 -0
- package/components/Shared/StudioCard.vue +24 -0
- package/components/Shared/TabBar.vue +50 -0
- package/components/Shared/ViewTab.vue +26 -0
- package/components/Shared/Wizard.vue +80 -0
- package/composables/usePlatformAuth.ts +35 -0
- package/composables/usePlatformTenant.ts +35 -0
- package/index.ts +12 -0
- package/nuxt.config.ts +23 -0
- package/package.json +29 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Shared types for the cockpit-wide DataTable component.
|
|
2
|
+
//
|
|
3
|
+
// A page builds a `columns` array (mapping data fields to display +
|
|
4
|
+
// behaviour) and passes it to `<DataTable>` together with its source
|
|
5
|
+
// rows. Sorting, text search, column visibility and pagination are
|
|
6
|
+
// handled inside the table; backend-driven modes can take over by
|
|
7
|
+
// setting `server-mode` and reacting to the emitted `state-change`.
|
|
8
|
+
|
|
9
|
+
import type { Component } from 'vue'
|
|
10
|
+
|
|
11
|
+
export type Comparable = string | number | boolean | Date | null | undefined
|
|
12
|
+
|
|
13
|
+
export interface DataTableColumn<T = unknown> {
|
|
14
|
+
/** Stable key — matches the slot name `cell-<key>` and is used in the
|
|
15
|
+
* sort state. */
|
|
16
|
+
key: string
|
|
17
|
+
/** Header label. */
|
|
18
|
+
label: string
|
|
19
|
+
/** CSS grid track. Defaults to `minmax(0,1fr)` for text columns; pass
|
|
20
|
+
* a fixed value like `120px` or `minmax(0,140px)` for compact ones. */
|
|
21
|
+
width?: string
|
|
22
|
+
/** Enable sort affordance on this column. */
|
|
23
|
+
sortable?: boolean
|
|
24
|
+
/** Returns the sort key for the row. Falls back to `row[key]` if it
|
|
25
|
+
* looks like a primitive. ISO date strings sort correctly as-is. */
|
|
26
|
+
sortGetter?: (row: T) => Comparable
|
|
27
|
+
/** Returns the string a text-search query is matched against. Falls
|
|
28
|
+
* back to the stringified sortGetter result. */
|
|
29
|
+
searchGetter?: (row: T) => string
|
|
30
|
+
/** Initial visibility (default `true`). */
|
|
31
|
+
visible?: boolean
|
|
32
|
+
/** Hide-able via the column menu (default `true`). */
|
|
33
|
+
toggleable?: boolean
|
|
34
|
+
/** Right-align cell content (numeric columns). */
|
|
35
|
+
alignRight?: boolean
|
|
36
|
+
/** Optional icon component for the header. */
|
|
37
|
+
icon?: Component
|
|
38
|
+
/** Marks the identifying column. When set (and no `#cell-<key>` slot is
|
|
39
|
+
* given) the DataTable renders the value as the bold record anchor —
|
|
40
|
+
* `font-semibold text-[#0B0522]`, truncated — optionally led by a tinted
|
|
41
|
+
* icon tile (see `cellIcon`). One per table. (datatable-ux) */
|
|
42
|
+
primary?: boolean
|
|
43
|
+
/** Leading tinted icon tile for the cell (typically the `primary` column).
|
|
44
|
+
* A static icon component, rendered as the `bg-primary/10` rounded tile the
|
|
45
|
+
* scanning rules call for. (For a per-row icon use a `#cell-<key>` slot —
|
|
46
|
+
* `cellIcon` must NOT be a getter: Solar icons are functional components, so
|
|
47
|
+
* the table can't tell a getter from an icon and would crash invoking it.) */
|
|
48
|
+
cellIcon?: Component
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A built-in filter dimension rendered under the generic "Filter" dropdown as
|
|
52
|
+
* a labelled sub-menu (datatable-ux). Static mode only: the DataTable filters
|
|
53
|
+
* `data` in-memory and shows a count per option. Pass one, or an array of
|
|
54
|
+
* several dimensions — each becomes its own sub-menu. */
|
|
55
|
+
export interface DataTableFilter<T = unknown> {
|
|
56
|
+
/** Stable id for this dimension (defaults to `label`). */
|
|
57
|
+
key?: string
|
|
58
|
+
/** Sub-menu title, e.g. "Status" (default `Status`). */
|
|
59
|
+
label?: string
|
|
60
|
+
/** The row's value for this dimension (e.g. `r => r.status`). */
|
|
61
|
+
getter: (row: T) => string | null | undefined
|
|
62
|
+
/** The selectable values + their labels. */
|
|
63
|
+
options: { value: string, label: string }[]
|
|
64
|
+
/** Label for the reset/"all" entry (default `All`). */
|
|
65
|
+
allLabel?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type SortDirection = 'asc' | 'desc' | null
|
|
69
|
+
|
|
70
|
+
export interface SortState {
|
|
71
|
+
column: string | null
|
|
72
|
+
direction: SortDirection
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface DataTableState {
|
|
76
|
+
search: string
|
|
77
|
+
sort: SortState
|
|
78
|
+
page: number
|
|
79
|
+
pageSize: number
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type RowKey<T> = keyof T | ((row: T) => string | number)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { RefreshCircle as Loader2, Copy as CopyIcon } from '#solar-icons'
|
|
3
|
+
import {
|
|
4
|
+
Badge,
|
|
5
|
+
Button,
|
|
6
|
+
Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle,
|
|
7
|
+
} from '@revenexx/studio'
|
|
8
|
+
import { toast } from 'vue-sonner'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Minimal shape of a site deployment — just the fields this flyout renders.
|
|
12
|
+
* Kept local so studio-shared has no dependency on the private SDK; a full
|
|
13
|
+
* SDK `Models.Deployment` is structurally assignable to it.
|
|
14
|
+
*/
|
|
15
|
+
interface BuildDeployment {
|
|
16
|
+
$id?: string
|
|
17
|
+
status?: string
|
|
18
|
+
buildLogs?: string
|
|
19
|
+
buildDuration?: number
|
|
20
|
+
providerBranch?: string
|
|
21
|
+
providerCommitHash?: string
|
|
22
|
+
providerCommitUrl?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
defineProps<{
|
|
26
|
+
open: boolean
|
|
27
|
+
deployment: BuildDeployment | null
|
|
28
|
+
loading: boolean
|
|
29
|
+
isBuilding?: boolean
|
|
30
|
+
userPinned?: boolean
|
|
31
|
+
error?: string | null
|
|
32
|
+
}>()
|
|
33
|
+
|
|
34
|
+
const emit = defineEmits<{
|
|
35
|
+
'update:open': [boolean]
|
|
36
|
+
'refresh': []
|
|
37
|
+
}>()
|
|
38
|
+
|
|
39
|
+
async function copyId(id: string | undefined) {
|
|
40
|
+
if (!id) return
|
|
41
|
+
try {
|
|
42
|
+
await navigator.clipboard.writeText(id)
|
|
43
|
+
toast.success('ID copied')
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
toast.error('Could not copy')
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<template>
|
|
52
|
+
<Sheet :open="open" @update:open="(v: boolean) => emit('update:open', v)">
|
|
53
|
+
<SheetContent side="right" class="flex w-full flex-col gap-0 p-0 sm:max-w-[max(50vw,640px)]">
|
|
54
|
+
<SheetHeader class="border-b px-4 py-3">
|
|
55
|
+
<div class="flex items-center gap-2">
|
|
56
|
+
<Badge variant="outline" class="shrink-0">Build</Badge>
|
|
57
|
+
<SheetTitle class="truncate font-mono text-sm">
|
|
58
|
+
{{ deployment?.$id || 'Build logs' }}
|
|
59
|
+
</SheetTitle>
|
|
60
|
+
<button v-if="deployment?.$id" title="Copy ID" class="ml-auto" @click="copyId(deployment.$id)">
|
|
61
|
+
<CopyIcon :size="12" class="text-muted-foreground hover:text-foreground" />
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
<SheetDescription v-if="deployment" class="flex items-center gap-2 text-xs">
|
|
65
|
+
<span v-if="deployment.providerBranch" class="font-mono">{{ deployment.providerBranch }}</span>
|
|
66
|
+
<template v-if="deployment.providerCommitHash">
|
|
67
|
+
<span class="text-muted-foreground">·</span>
|
|
68
|
+
<a
|
|
69
|
+
v-if="deployment.providerCommitUrl"
|
|
70
|
+
:href="deployment.providerCommitUrl"
|
|
71
|
+
target="_blank"
|
|
72
|
+
rel="noreferrer"
|
|
73
|
+
class="font-mono hover:underline"
|
|
74
|
+
>{{ deployment.providerCommitHash.slice(0, 7) }}</a>
|
|
75
|
+
<span v-else class="font-mono">{{ deployment.providerCommitHash.slice(0, 7) }}</span>
|
|
76
|
+
</template>
|
|
77
|
+
<template v-if="userPinned">
|
|
78
|
+
<span class="text-muted-foreground">·</span>
|
|
79
|
+
<span>pinned</span>
|
|
80
|
+
</template>
|
|
81
|
+
</SheetDescription>
|
|
82
|
+
</SheetHeader>
|
|
83
|
+
|
|
84
|
+
<div class="flex-1 overflow-y-auto">
|
|
85
|
+
<div v-if="loading && !deployment" class="py-8 text-center text-sm text-muted-foreground">
|
|
86
|
+
<Loader2 :size="16" class="mr-2 inline animate-spin" /> Loading…
|
|
87
|
+
</div>
|
|
88
|
+
<p v-else-if="error" class="p-4 text-sm text-rose-700">{{ error }}</p>
|
|
89
|
+
|
|
90
|
+
<template v-else-if="deployment">
|
|
91
|
+
<div class="flex items-center justify-between gap-2 px-4 py-2 border-b bg-muted/30">
|
|
92
|
+
<span class="text-xs text-muted-foreground">
|
|
93
|
+
Status: <span class="font-medium text-foreground">{{ deployment.status || '—' }}</span>
|
|
94
|
+
<template v-if="deployment.buildDuration">
|
|
95
|
+
· {{ deployment.buildDuration }}s
|
|
96
|
+
</template>
|
|
97
|
+
</span>
|
|
98
|
+
<div class="flex items-center gap-2">
|
|
99
|
+
<Loader2 v-if="isBuilding || loading" :size="12" class="animate-spin text-muted-foreground" />
|
|
100
|
+
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="emit('refresh')">
|
|
101
|
+
Refresh
|
|
102
|
+
</Button>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<div class="p-4">
|
|
107
|
+
<pre
|
|
108
|
+
v-if="deployment.buildLogs"
|
|
109
|
+
class="min-h-[300px] max-h-[calc(100vh-180px)] overflow-auto rounded-md border bg-[#0B0522] text-emerald-100 p-4 font-mono text-sm leading-relaxed whitespace-pre-wrap"
|
|
110
|
+
>{{ stripAnsi(deployment.buildLogs) }}</pre>
|
|
111
|
+
<p v-else-if="isBuilding" class="flex items-center gap-2 text-sm text-muted-foreground py-8">
|
|
112
|
+
<Loader2 :size="12" class="animate-spin" />
|
|
113
|
+
Waiting for first log output…
|
|
114
|
+
</p>
|
|
115
|
+
<p v-else class="text-sm text-muted-foreground py-8 text-center">
|
|
116
|
+
No logs captured for this build.
|
|
117
|
+
</p>
|
|
118
|
+
</div>
|
|
119
|
+
</template>
|
|
120
|
+
|
|
121
|
+
<div v-else class="py-12 text-center text-sm text-muted-foreground">
|
|
122
|
+
Select a build from the list to see its logs.
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</SheetContent>
|
|
126
|
+
</Sheet>
|
|
127
|
+
</template>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Deployment-trigger settings for a git-connected resource (app / site /
|
|
4
|
+
* integration). Two orthogonal controls, mapped to the platform attributes
|
|
5
|
+
* added in V33 (26.1.9):
|
|
6
|
+
*
|
|
7
|
+
* - vcsTriggerMode ('push' | 'tag'): when a deployment is triggered — on every
|
|
8
|
+
* branch push, or only when a tag is pushed / a GitHub release is published.
|
|
9
|
+
* - vcsPreviewsEnabled: whether pull requests get a preview deployment + a
|
|
10
|
+
* status comment. Hidden via `showPreviews=false` for resources where it
|
|
11
|
+
* makes no sense.
|
|
12
|
+
*
|
|
13
|
+
* Presentational only — two v-models, parent owns persistence.
|
|
14
|
+
*/
|
|
15
|
+
import {
|
|
16
|
+
Card, CardContent, CardDescription, CardHeader, CardTitle,
|
|
17
|
+
Label,
|
|
18
|
+
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
19
|
+
Switch,
|
|
20
|
+
} from '@revenexx/studio'
|
|
21
|
+
|
|
22
|
+
withDefaults(defineProps<{
|
|
23
|
+
triggerMode: string
|
|
24
|
+
previewsEnabled: boolean
|
|
25
|
+
disabled?: boolean
|
|
26
|
+
showPreviews?: boolean
|
|
27
|
+
}>(), {
|
|
28
|
+
disabled: false,
|
|
29
|
+
showPreviews: true,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
defineEmits<{
|
|
33
|
+
'update:triggerMode': [value: string]
|
|
34
|
+
'update:previewsEnabled': [value: boolean]
|
|
35
|
+
}>()
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<template>
|
|
39
|
+
<Card class="bg-white/60 backdrop-blur-sm">
|
|
40
|
+
<CardHeader>
|
|
41
|
+
<CardTitle class="text-sm">Deployment triggers</CardTitle>
|
|
42
|
+
<CardDescription class="text-sm">
|
|
43
|
+
Control when a git push deploys this resource.
|
|
44
|
+
</CardDescription>
|
|
45
|
+
</CardHeader>
|
|
46
|
+
<CardContent class="space-y-4">
|
|
47
|
+
<div class="space-y-1">
|
|
48
|
+
<Label for="vcs-trigger-mode">When to deploy</Label>
|
|
49
|
+
<Select
|
|
50
|
+
:model-value="triggerMode"
|
|
51
|
+
:disabled="disabled"
|
|
52
|
+
@update:model-value="(v) => $emit('update:triggerMode', v as string)"
|
|
53
|
+
>
|
|
54
|
+
<SelectTrigger id="vcs-trigger-mode">
|
|
55
|
+
<SelectValue placeholder="On every push" />
|
|
56
|
+
</SelectTrigger>
|
|
57
|
+
<SelectContent>
|
|
58
|
+
<SelectItem value="push">On every push</SelectItem>
|
|
59
|
+
<SelectItem value="tag">Only on tag / release</SelectItem>
|
|
60
|
+
</SelectContent>
|
|
61
|
+
</Select>
|
|
62
|
+
<p class="text-sm text-muted-foreground">
|
|
63
|
+
<template v-if="triggerMode === 'tag'">
|
|
64
|
+
Builds only when you push a tag or publish a GitHub release.
|
|
65
|
+
</template>
|
|
66
|
+
<template v-else>
|
|
67
|
+
Builds on every push to the production branch.
|
|
68
|
+
</template>
|
|
69
|
+
</p>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div v-if="showPreviews" class="flex items-center gap-2 pt-1">
|
|
73
|
+
<Switch
|
|
74
|
+
:model-value="previewsEnabled"
|
|
75
|
+
:disabled="disabled"
|
|
76
|
+
@update:model-value="(v) => $emit('update:previewsEnabled', v as boolean)"
|
|
77
|
+
/>
|
|
78
|
+
<div class="text-sm">
|
|
79
|
+
<div class="font-medium">Preview deployments</div>
|
|
80
|
+
<div class="text-sm text-muted-foreground">
|
|
81
|
+
Build a preview and comment the status on every pull request.
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</CardContent>
|
|
86
|
+
</Card>
|
|
87
|
+
</template>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Polished KPI tile — the standard big-number card used across dashboards.
|
|
4
|
+
* Refined card token (hairline border + outline), tinted icon chip, large
|
|
5
|
+
* tabular value, optional delta (up/down) and a sub line. Optionally a link.
|
|
6
|
+
*/
|
|
7
|
+
import { NuxtLink } from '#components'
|
|
8
|
+
|
|
9
|
+
defineProps<{
|
|
10
|
+
label: string
|
|
11
|
+
value: string | number
|
|
12
|
+
icon?: unknown
|
|
13
|
+
/** Colored change indicator, e.g. "+12.5%". */
|
|
14
|
+
delta?: string
|
|
15
|
+
up?: boolean
|
|
16
|
+
/** Muted line under the value (e.g. "vs last month"). */
|
|
17
|
+
sub?: string
|
|
18
|
+
to?: string
|
|
19
|
+
}>()
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<component
|
|
24
|
+
:is="to ? NuxtLink : 'div'"
|
|
25
|
+
:to="to"
|
|
26
|
+
class="block rounded-lg border border-gray-400/10 outline-1 outline-white/30"
|
|
27
|
+
:class="to ? 'transition-transform hover:-translate-y-0.5' : ''"
|
|
28
|
+
>
|
|
29
|
+
<div class="relative h-full overflow-hidden rounded-lg bg-white p-4">
|
|
30
|
+
<div class="flex items-center justify-between gap-2">
|
|
31
|
+
<span class="text-xs text-muted-foreground truncate">{{ label }}</span>
|
|
32
|
+
<span v-if="icon" class="flex size-7 items-center justify-center rounded-md bg-primary/10 text-primary shrink-0"><component :is="icon" :size="14" /></span>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="mt-1.5 text-2xl font-semibold leading-none tabular-nums text-[#0B0522]">{{ value }}</div>
|
|
35
|
+
<div v-if="delta || sub" class="mt-1.5 text-xs">
|
|
36
|
+
<span v-if="delta" class="font-medium tabular-nums" :class="up ? 'text-emerald-600' : 'text-rose-600'">{{ delta }}</span>
|
|
37
|
+
<span v-if="sub" class="text-muted-foreground" :class="delta ? 'ml-1' : ''">{{ sub }}</span>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</component>
|
|
41
|
+
</template>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{
|
|
3
|
+
/** Lucide icon component shown left of the title. Rendered as a plain
|
|
4
|
+
* outline icon (no tile background) to keep the header calm. */
|
|
5
|
+
icon?: unknown
|
|
6
|
+
title: string
|
|
7
|
+
description?: string
|
|
8
|
+
}>()
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<div class="sticky top-0 z-20 border-b border-black/[0.06] bg-white/40 backdrop-blur-xl">
|
|
13
|
+
<div class="flex items-center gap-2 px-4 pt-4 pb-3">
|
|
14
|
+
<component
|
|
15
|
+
:is="icon"
|
|
16
|
+
v-if="icon"
|
|
17
|
+
:size="18"
|
|
18
|
+
:stroke-width="2"
|
|
19
|
+
class="shrink-0 text-foreground/70"
|
|
20
|
+
/>
|
|
21
|
+
<h1 class="text-xl font-medium text-[#0B0522] truncate">
|
|
22
|
+
<slot name="title">{{ title }}</slot>
|
|
23
|
+
</h1>
|
|
24
|
+
|
|
25
|
+
<div class="flex-1" />
|
|
26
|
+
|
|
27
|
+
<slot name="actions" />
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div v-if="$slots.tabs" class="flex items-center gap-1 px-3 pb-2 overflow-x-auto">
|
|
31
|
+
<slot name="tabs" />
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
</template>
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Component } from 'vue'
|
|
3
|
+
|
|
4
|
+
export type SidebarNavItem = {
|
|
5
|
+
label: string
|
|
6
|
+
to: string
|
|
7
|
+
icon?: Component
|
|
8
|
+
badge?: number | string | null
|
|
9
|
+
/** Dim + show a "soon" tag (not-yet-shipped surfaces). */
|
|
10
|
+
preview?: boolean
|
|
11
|
+
/** Match the route exactly (for studio roots like `/settings`). */
|
|
12
|
+
exact?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type SidebarNavSection = {
|
|
16
|
+
key: string
|
|
17
|
+
/** Empty/omitted → no group label (pinned items at the top). */
|
|
18
|
+
label?: string
|
|
19
|
+
items: SidebarNavItem[]
|
|
20
|
+
}
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<script setup lang="ts">
|
|
24
|
+
/**
|
|
25
|
+
* Shared studio sidebar.
|
|
26
|
+
*
|
|
27
|
+
* Header (icon + title) + a sectioned, link list driven by data. Every
|
|
28
|
+
* studio sidebar (Settings, Integrations, Commerce, Apps, …) renders
|
|
29
|
+
* through this so spacing, active state, badges and "soon" tags stay
|
|
30
|
+
* identical. Pass `sections`; use `#notice` for loading/empty hints.
|
|
31
|
+
*/
|
|
32
|
+
import type { Component } from 'vue'
|
|
33
|
+
|
|
34
|
+
defineProps<{
|
|
35
|
+
title: string
|
|
36
|
+
icon?: Component
|
|
37
|
+
sections: SidebarNavSection[]
|
|
38
|
+
/** Hide the header icon and use a lighter header title weight. */
|
|
39
|
+
minimal?: boolean
|
|
40
|
+
}>()
|
|
41
|
+
|
|
42
|
+
const route = useRoute()
|
|
43
|
+
function isActive(item: SidebarNavItem): boolean {
|
|
44
|
+
if (item.exact) return route.path === item.to
|
|
45
|
+
return route.path === item.to || route.path.startsWith(`${item.to}/`)
|
|
46
|
+
}
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<aside class="h-full flex flex-col">
|
|
51
|
+
<div class="flex items-center gap-2 px-4 pt-4 pb-3 shrink-0 border-b border-black/[0.06]">
|
|
52
|
+
<slot v-if="!minimal" name="icon">
|
|
53
|
+
<component :is="icon" v-if="icon" :size="18" :stroke-width="2" class="text-foreground/70 shrink-0" />
|
|
54
|
+
</slot>
|
|
55
|
+
<h2 class="text-lg text-[#0B0522] truncate" :class="minimal ? 'font-medium' : 'font-semibold'">
|
|
56
|
+
{{ title }}
|
|
57
|
+
</h2>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div class="flex-1 overflow-y-auto pt-3 pb-3">
|
|
61
|
+
<slot name="top" />
|
|
62
|
+
|
|
63
|
+
<template v-for="section in sections" :key="section.key">
|
|
64
|
+
<div v-if="section.label" class="mt-3 px-4 pb-1">
|
|
65
|
+
<div class="text-mono text-muted-foreground">
|
|
66
|
+
{{ section.label }}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
<nav class="flex flex-col gap-0.5 px-2">
|
|
70
|
+
<NuxtLink
|
|
71
|
+
v-for="item in section.items"
|
|
72
|
+
:key="item.to"
|
|
73
|
+
:to="item.to"
|
|
74
|
+
class="group flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm transition-colors hover:text-primary"
|
|
75
|
+
:class="[
|
|
76
|
+
isActive(item)
|
|
77
|
+
? 'bg-primary/10 text-[#0B0522] font-medium'
|
|
78
|
+
: 'text-foreground/80 hover:bg-primary/5',
|
|
79
|
+
item.preview ? 'opacity-70' : '',
|
|
80
|
+
]"
|
|
81
|
+
>
|
|
82
|
+
<component :is="item.icon" v-if="item.icon" :size="14" :stroke-width="2" class="shrink-0 text-foreground/60 group-hover:text-primary" />
|
|
83
|
+
<span class="flex-1 truncate flex items-center gap-1.5">
|
|
84
|
+
{{ item.label }}
|
|
85
|
+
<span
|
|
86
|
+
v-if="item.preview"
|
|
87
|
+
class="rounded bg-muted px-1 text-mono text-muted-foreground"
|
|
88
|
+
title="Coming soon"
|
|
89
|
+
>
|
|
90
|
+
soon
|
|
91
|
+
</span>
|
|
92
|
+
</span>
|
|
93
|
+
</NuxtLink>
|
|
94
|
+
</nav>
|
|
95
|
+
</template>
|
|
96
|
+
|
|
97
|
+
<slot name="notice" />
|
|
98
|
+
</div>
|
|
99
|
+
</aside>
|
|
100
|
+
</template>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { RoundSortVertical, SortFromBottomToTop, SortFromTopToBottom } from '#solar-icons'
|
|
3
|
+
|
|
4
|
+
// Clickable column header for data tables (datatable-ux). One Solar sort
|
|
5
|
+
// glyph that switches with the sort state — matching cover's tokens:
|
|
6
|
+
// idle → round-sort-vertical
|
|
7
|
+
// asc → sort-from-bottom-to-top
|
|
8
|
+
// desc → sort-from-top-to-bottom
|
|
9
|
+
// Outline weight (Linear), never duotone. Active = primary accent;
|
|
10
|
+
// inactive = dimmed, revealed on hover.
|
|
11
|
+
//
|
|
12
|
+
// <SortHeader column="name" :active="sort.column"
|
|
13
|
+
// :direction="sort.direction" @toggle="toggle">Name</SortHeader>
|
|
14
|
+
|
|
15
|
+
const props = defineProps<{
|
|
16
|
+
column: string
|
|
17
|
+
active: string | null
|
|
18
|
+
direction: 'asc' | 'desc' | null
|
|
19
|
+
/** Right-align the indicator for numeric columns. */
|
|
20
|
+
alignRight?: boolean
|
|
21
|
+
}>()
|
|
22
|
+
|
|
23
|
+
const emit = defineEmits<{ toggle: [column: string] }>()
|
|
24
|
+
|
|
25
|
+
const isActive = computed(() => props.active === props.column && !!props.direction)
|
|
26
|
+
const sortIcon = computed(() => {
|
|
27
|
+
if (isActive.value && props.direction === 'asc') return SortFromBottomToTop
|
|
28
|
+
if (isActive.value && props.direction === 'desc') return SortFromTopToBottom
|
|
29
|
+
return RoundSortVertical
|
|
30
|
+
})
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
class="group inline-flex cursor-pointer items-center gap-1.5 -m-1 p-1 rounded-md text-left transition-colors hover:text-primary"
|
|
37
|
+
:class="alignRight ? 'flex-row-reverse w-full justify-end' : ''"
|
|
38
|
+
@click="emit('toggle', column)"
|
|
39
|
+
>
|
|
40
|
+
<slot />
|
|
41
|
+
<component
|
|
42
|
+
:is="sortIcon"
|
|
43
|
+
weight="Linear"
|
|
44
|
+
:size="14"
|
|
45
|
+
class="shrink-0 transition-colors"
|
|
46
|
+
:class="isActive ? 'text-primary' : 'text-foreground/30 group-hover:text-primary'"
|
|
47
|
+
aria-hidden="true"
|
|
48
|
+
/>
|
|
49
|
+
</button>
|
|
50
|
+
</template>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Shared empty / loading / error / not-found state.
|
|
4
|
+
*
|
|
5
|
+
* One calm centered card used across the studios so every "nothing here
|
|
6
|
+
* yet", "still loading" and "something broke" surface looks the same.
|
|
7
|
+
* Pass copy via props; put a CTA in the `#actions` slot.
|
|
8
|
+
*/
|
|
9
|
+
import type { Component } from 'vue'
|
|
10
|
+
import {
|
|
11
|
+
Card, CardContent, CardDescription, CardHeader, CardTitle,
|
|
12
|
+
} from '@revenexx/studio'
|
|
13
|
+
import { Box as Boxes, DangerTriangle as AlertTriangle, MagniferZoomIn as Magnifier } from '#solar-icons'
|
|
14
|
+
|
|
15
|
+
type Variant = 'empty' | 'loading' | 'error' | 'notfound'
|
|
16
|
+
|
|
17
|
+
const props = withDefaults(defineProps<{
|
|
18
|
+
variant?: Variant
|
|
19
|
+
/** Override the default icon for the variant. */
|
|
20
|
+
icon?: Component
|
|
21
|
+
title: string
|
|
22
|
+
description?: string
|
|
23
|
+
}>(), {
|
|
24
|
+
variant: 'empty',
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const defaultIcon: Record<Variant, Component> = {
|
|
28
|
+
empty: Boxes,
|
|
29
|
+
loading: Boxes,
|
|
30
|
+
error: AlertTriangle,
|
|
31
|
+
notfound: Magnifier,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const resolvedIcon = computed(() => props.icon ?? defaultIcon[props.variant])
|
|
35
|
+
|
|
36
|
+
const tileClass = computed(() =>
|
|
37
|
+
props.variant === 'error'
|
|
38
|
+
? 'bg-rose-500/10 text-rose-600'
|
|
39
|
+
: 'bg-primary/10 text-primary',
|
|
40
|
+
)
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<template>
|
|
44
|
+
<Card class="bg-white/60 backdrop-blur-sm">
|
|
45
|
+
<CardHeader class="items-center text-center">
|
|
46
|
+
<div
|
|
47
|
+
class="flex size-12 items-center justify-center rounded-full mb-2"
|
|
48
|
+
:class="tileClass"
|
|
49
|
+
>
|
|
50
|
+
<span
|
|
51
|
+
v-if="variant === 'loading'"
|
|
52
|
+
class="size-5 rounded-full border-2 border-primary/30 border-t-primary animate-spin"
|
|
53
|
+
/>
|
|
54
|
+
<component :is="resolvedIcon" v-else :size="22" />
|
|
55
|
+
</div>
|
|
56
|
+
<CardTitle>{{ title }}</CardTitle>
|
|
57
|
+
<CardDescription v-if="description">
|
|
58
|
+
{{ description }}
|
|
59
|
+
</CardDescription>
|
|
60
|
+
<slot name="description" />
|
|
61
|
+
</CardHeader>
|
|
62
|
+
<CardContent v-if="$slots.actions" class="flex justify-center">
|
|
63
|
+
<slot name="actions" />
|
|
64
|
+
</CardContent>
|
|
65
|
+
</Card>
|
|
66
|
+
</template>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The standard cockpit card — the 2-layer "glass" used on the dashboard:
|
|
4
|
+
* an outer transparent ring (hairline border + white outline) wrapping a solid
|
|
5
|
+
* white inner pane clipped to the radius. Use everywhere instead of a single
|
|
6
|
+
* bordered div. Optional `to` makes the whole card a link.
|
|
7
|
+
*/
|
|
8
|
+
import { NuxtLink } from '#components'
|
|
9
|
+
|
|
10
|
+
defineProps<{ to?: string }>()
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<component
|
|
15
|
+
:is="to ? NuxtLink : 'div'"
|
|
16
|
+
:to="to"
|
|
17
|
+
class="block rounded-lg border border-gray-400/10 outline-1 outline-white/30"
|
|
18
|
+
:class="to ? 'transition-transform hover:-translate-y-0.5' : ''"
|
|
19
|
+
>
|
|
20
|
+
<div class="relative h-full overflow-hidden rounded-lg bg-white">
|
|
21
|
+
<slot />
|
|
22
|
+
</div>
|
|
23
|
+
</component>
|
|
24
|
+
</template>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Underline tab bar — the standard cockpit detail-page tabs. A full-width
|
|
4
|
+
* hairline sits behind the row; the active/hover indicator is a 2px bar that
|
|
5
|
+
* spans exactly the label width (inset-x-3 = the px-3 padding), sitting on the
|
|
6
|
+
* baseline over the hairline. Active = primary; hover = soft primary. Tabs
|
|
7
|
+
* scroll horizontally on overflow. To bleed the line to the panel edges, pass
|
|
8
|
+
* e.g. `class="-mx-4 px-4"` from a padded page.
|
|
9
|
+
*/
|
|
10
|
+
export interface TabItem { key: string, label: string, badge?: number | string | null }
|
|
11
|
+
|
|
12
|
+
defineProps<{ tabs: TabItem[], modelValue: string }>()
|
|
13
|
+
defineEmits<{ 'update:modelValue': [key: string] }>()
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<template>
|
|
17
|
+
<!-- Sticks flush against the bottom of the sticky PageHeader (57px = its
|
|
18
|
+
pt-4 pb-3 + text-xl line + 1px border) so header and tabs stay glued as
|
|
19
|
+
one unit while the panel scrolls. Glassy frosted bg (blur + low opacity)
|
|
20
|
+
lets content scroll through. -->
|
|
21
|
+
<div class="sticky top-[57px] z-10 bg-white/40 backdrop-blur-xl">
|
|
22
|
+
<!-- Pull the row out by the tab padding so the first/last label sits flush
|
|
23
|
+
with the surrounding content (headline) edges. -->
|
|
24
|
+
<div class="relative z-10 -mx-2.5 flex gap-1 overflow-x-auto">
|
|
25
|
+
<button
|
|
26
|
+
v-for="t in tabs"
|
|
27
|
+
:key="t.key"
|
|
28
|
+
type="button"
|
|
29
|
+
class="group relative shrink-0 cursor-pointer whitespace-nowrap px-2.5 py-2 text-sm font-medium transition-colors"
|
|
30
|
+
:class="modelValue === t.key
|
|
31
|
+
? 'text-[#0B0522] hover:text-primary'
|
|
32
|
+
: 'text-[#0B0522]/80 hover:text-primary'"
|
|
33
|
+
@click="$emit('update:modelValue', t.key)"
|
|
34
|
+
>
|
|
35
|
+
{{ t.label }}
|
|
36
|
+
<span
|
|
37
|
+
v-if="t.badge"
|
|
38
|
+
class="ml-1 rounded-full bg-muted px-1.5 text-mono font-medium tabular-nums text-muted-foreground"
|
|
39
|
+
>{{ t.badge }}</span>
|
|
40
|
+
<!-- 2px indicator, exactly under the label (inset-x-3 = px-3). -->
|
|
41
|
+
<span
|
|
42
|
+
class="pointer-events-none absolute inset-x-2.5 bottom-0 h-0.5 rounded-full transition-colors"
|
|
43
|
+
:class="modelValue === t.key ? 'bg-primary' : 'bg-transparent group-hover:bg-primary/40'"
|
|
44
|
+
/>
|
|
45
|
+
</button>
|
|
46
|
+
</div>
|
|
47
|
+
<!-- Full-width hairline behind the tabs. -->
|
|
48
|
+
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-px bg-black/[0.06]" />
|
|
49
|
+
</div>
|
|
50
|
+
</template>
|