@leavepulse/ui 0.12.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/LICENSE +21 -0
- package/README.md +61 -0
- package/dist/component-names.d.ts +1 -0
- package/dist/components/LayoutCanvas.vue.d.ts +28 -0
- package/dist/components/LayoutNode.vue.d.ts +40 -0
- package/dist/components/LpAlert.vue.d.ts +44 -0
- package/dist/components/LpAppShell.vue.d.ts +83 -0
- package/dist/components/LpAutocomplete.vue.d.ts +50 -0
- package/dist/components/LpAvatar.vue.d.ts +20 -0
- package/dist/components/LpBadge.vue.d.ts +47 -0
- package/dist/components/LpBreadcrumbs.vue.d.ts +14 -0
- package/dist/components/LpButton.vue.d.ts +113 -0
- package/dist/components/LpCalendar.vue.d.ts +20 -0
- package/dist/components/LpCard.vue.d.ts +29 -0
- package/dist/components/LpCheckbox.vue.d.ts +28 -0
- package/dist/components/LpCodeBlock.vue.d.ts +39 -0
- package/dist/components/LpCommandPalette.vue.d.ts +42 -0
- package/dist/components/LpConfirmDialog.vue.d.ts +32 -0
- package/dist/components/LpContextMenu.vue.d.ts +30 -0
- package/dist/components/LpDatePicker.vue.d.ts +25 -0
- package/dist/components/LpDisclosure.vue.d.ts +37 -0
- package/dist/components/LpDivider.vue.d.ts +17 -0
- package/dist/components/LpDrawer.vue.d.ts +88 -0
- package/dist/components/LpDropdownMenu.vue.d.ts +23 -0
- package/dist/components/LpEmptyState.vue.d.ts +18 -0
- package/dist/components/LpFormField.vue.d.ts +21 -0
- package/dist/components/LpIcon.vue.d.ts +8 -0
- package/dist/components/LpInfraNode.vue.d.ts +21 -0
- package/dist/components/LpInput.vue.d.ts +68 -0
- package/dist/components/LpLaneNode.vue.d.ts +24 -0
- package/dist/components/LpLink.vue.d.ts +27 -0
- package/dist/components/LpLogViewer.vue.d.ts +92 -0
- package/dist/components/LpModal.vue.d.ts +43 -0
- package/dist/components/LpNotificationBell.vue.d.ts +51 -0
- package/dist/components/LpNumberField.vue.d.ts +14 -0
- package/dist/components/LpOtpInput.vue.d.ts +27 -0
- package/dist/components/LpPagination.vue.d.ts +25 -0
- package/dist/components/LpPasswordInput.vue.d.ts +28 -0
- package/dist/components/LpPhoneInput.vue.d.ts +37 -0
- package/dist/components/LpPopover.vue.d.ts +34 -0
- package/dist/components/LpProgress.vue.d.ts +12 -0
- package/dist/components/LpRadio.vue.d.ts +18 -0
- package/dist/components/LpRadioGroup.vue.d.ts +26 -0
- package/dist/components/LpScrollArea.vue.d.ts +24 -0
- package/dist/components/LpSegmented.vue.d.ts +24 -0
- package/dist/components/LpSelect.vue.d.ts +45 -0
- package/dist/components/LpServiceNode.vue.d.ts +14 -0
- package/dist/components/LpSidebar.vue.d.ts +85 -0
- package/dist/components/LpSidebarNav.vue.d.ts +34 -0
- package/dist/components/LpSkeleton.vue.d.ts +6 -0
- package/dist/components/LpSlider.vue.d.ts +23 -0
- package/dist/components/LpStat.vue.d.ts +22 -0
- package/dist/components/LpStepper.vue.d.ts +10 -0
- package/dist/components/LpSwitch.vue.d.ts +11 -0
- package/dist/components/LpTable.vue.d.ts +63 -0
- package/dist/components/LpTableOfContents.vue.d.ts +19 -0
- package/dist/components/LpTabs.vue.d.ts +45 -0
- package/dist/components/LpTextarea.vue.d.ts +17 -0
- package/dist/components/LpThemeSwitcher.vue.d.ts +40 -0
- package/dist/components/LpToaster.vue.d.ts +3 -0
- package/dist/components/LpTooltip.vue.d.ts +19 -0
- package/dist/components/LpTopologyCanvas.vue.d.ts +154 -0
- package/dist/components/LpUptimeBar.vue.d.ts +30 -0
- package/dist/components/codeHighlight.d.ts +8 -0
- package/dist/components/countries-data.d.ts +2 -0
- package/dist/components/countries.d.ts +20 -0
- package/dist/components/dropdown.d.ts +29 -0
- package/dist/components/sidebar.d.ts +18 -0
- package/dist/composables/useInputFilter.d.ts +14 -0
- package/dist/composables/usePillTransition.d.ts +9 -0
- package/dist/composables/useToast.d.ts +39 -0
- package/dist/countries-data-9rgqJWHo.js +203 -0
- package/dist/icons.d.ts +1 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +100 -0
- package/dist/index.js +17600 -0
- package/dist/layout/registry.d.ts +26 -0
- package/dist/layout/tree.d.ts +75 -0
- package/dist/layout/useLayout.d.ts +39 -0
- package/dist/theme/config.d.ts +16 -0
- package/dist/theme/define.d.ts +12 -0
- package/dist/theme/presets.d.ts +20 -0
- package/dist/theme/tokens.d.ts +72 -0
- package/dist/theme/useTheme.d.ts +36 -0
- package/package.json +93 -0
- package/src/canvas.css +67 -0
- package/src/component-names.ts +65 -0
- package/src/components/LayoutCanvas.vue +76 -0
- package/src/components/LayoutNode.vue +248 -0
- package/src/components/LpAlert.vue +59 -0
- package/src/components/LpAppShell.vue +210 -0
- package/src/components/LpAutocomplete.vue +244 -0
- package/src/components/LpAvatar.vue +67 -0
- package/src/components/LpBadge.vue +32 -0
- package/src/components/LpBreadcrumbs.vue +35 -0
- package/src/components/LpButton.vue +111 -0
- package/src/components/LpCalendar.vue +134 -0
- package/src/components/LpCard.vue +71 -0
- package/src/components/LpCheckbox.vue +44 -0
- package/src/components/LpCodeBlock.vue +298 -0
- package/src/components/LpCommandPalette.vue +323 -0
- package/src/components/LpConfirmDialog.vue +49 -0
- package/src/components/LpContextMenu.vue +111 -0
- package/src/components/LpDatePicker.vue +117 -0
- package/src/components/LpDisclosure.vue +92 -0
- package/src/components/LpDivider.vue +20 -0
- package/src/components/LpDrawer.vue +402 -0
- package/src/components/LpDropdownMenu.vue +54 -0
- package/src/components/LpEmptyState.vue +21 -0
- package/src/components/LpFormField.vue +31 -0
- package/src/components/LpIcon.vue +49 -0
- package/src/components/LpInfraNode.vue +108 -0
- package/src/components/LpInput.vue +147 -0
- package/src/components/LpLaneNode.vue +48 -0
- package/src/components/LpLink.vue +48 -0
- package/src/components/LpLogViewer.vue +569 -0
- package/src/components/LpModal.vue +123 -0
- package/src/components/LpNotificationBell.vue +238 -0
- package/src/components/LpNumberField.vue +56 -0
- package/src/components/LpOtpInput.vue +81 -0
- package/src/components/LpPagination.vue +165 -0
- package/src/components/LpPasswordInput.vue +50 -0
- package/src/components/LpPhoneInput.vue +241 -0
- package/src/components/LpPopover.vue +42 -0
- package/src/components/LpProgress.vue +30 -0
- package/src/components/LpRadio.vue +28 -0
- package/src/components/LpRadioGroup.vue +43 -0
- package/src/components/LpScrollArea.vue +82 -0
- package/src/components/LpSegmented.vue +83 -0
- package/src/components/LpSelect.vue +0 -0
- package/src/components/LpServiceNode.vue +57 -0
- package/src/components/LpSidebar.vue +200 -0
- package/src/components/LpSidebarNav.vue +118 -0
- package/src/components/LpSkeleton.vue +16 -0
- package/src/components/LpSlider.vue +53 -0
- package/src/components/LpStat.vue +90 -0
- package/src/components/LpStepper.vue +46 -0
- package/src/components/LpSwitch.vue +19 -0
- package/src/components/LpTable.vue +252 -0
- package/src/components/LpTableOfContents.vue +183 -0
- package/src/components/LpTabs.vue +120 -0
- package/src/components/LpTextarea.vue +39 -0
- package/src/components/LpThemeSwitcher.vue +168 -0
- package/src/components/LpToaster.vue +216 -0
- package/src/components/LpTooltip.vue +30 -0
- package/src/components/LpTopologyCanvas.vue +370 -0
- package/src/components/LpUptimeBar.vue +126 -0
- package/src/components/codeHighlight.ts +137 -0
- package/src/components/countries-data.ts +207 -0
- package/src/components/countries.ts +67 -0
- package/src/components/dropdown.ts +64 -0
- package/src/components/sidebar.ts +26 -0
- package/src/composables/useInputFilter.ts +89 -0
- package/src/composables/usePillTransition.ts +19 -0
- package/src/composables/useToast.ts +92 -0
- package/src/icons.ts +11 -0
- package/src/index.css +21 -0
- package/src/index.ts +146 -0
- package/src/layout/registry.ts +44 -0
- package/src/layout/tree.ts +288 -0
- package/src/layout/useLayout.ts +134 -0
- package/src/nuxt.ts +22 -0
- package/src/theme/config.ts +48 -0
- package/src/theme/define.ts +50 -0
- package/src/theme/presets.ts +279 -0
- package/src/theme/tokens.ts +151 -0
- package/src/theme/useTheme.ts +288 -0
- package/src/tokens/scrollbar.css +138 -0
- package/src/tokens/tokens.css +333 -0
- package/src/tokens/utilities.css +23 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Terminal-flavoured log stream. Monospace rows, a tonal level gutter, optional
|
|
4
|
+
* timestamps + source tags, search highlighting, and a sticky "tail" that pins
|
|
5
|
+
* to the bottom as lines arrive (drops out the moment you scroll up, with a
|
|
6
|
+
* jump-to-latest pill to re-arm). Built on LpScrollArea, which exposes its
|
|
7
|
+
* viewport element and re-emits the native scroll event — exactly what tailing
|
|
8
|
+
* needs.
|
|
9
|
+
*/
|
|
10
|
+
import { computed, nextTick, ref, watch } from "vue"
|
|
11
|
+
import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
|
|
12
|
+
import LpIcon from "./LpIcon.vue"
|
|
13
|
+
import LpScrollArea from "./LpScrollArea.vue"
|
|
14
|
+
|
|
15
|
+
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal" | "success"
|
|
16
|
+
|
|
17
|
+
export interface LogLine {
|
|
18
|
+
/** Body of the line. */
|
|
19
|
+
message: string
|
|
20
|
+
level?: LogLevel
|
|
21
|
+
/** Epoch ms, ISO string, or pre-formatted label — shown in the time column. */
|
|
22
|
+
time?: number | string
|
|
23
|
+
/** Short origin tag (service / module), rendered as a dim chip. */
|
|
24
|
+
source?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const props = withDefaults(
|
|
28
|
+
defineProps<{
|
|
29
|
+
lines: LogLine[]
|
|
30
|
+
/** Show the timestamp column. */
|
|
31
|
+
showTime?: boolean
|
|
32
|
+
/**
|
|
33
|
+
* Show the per-line level chip (INFO/WARN/…). On by default. Turn off for
|
|
34
|
+
* raw streams (e.g. docker logs) where most lines have no real level and a
|
|
35
|
+
* blanket "INFO" chip is just noise — the tonal level RAIL still renders, so
|
|
36
|
+
* errors/warnings stay visually distinct.
|
|
37
|
+
*/
|
|
38
|
+
showLevel?: boolean
|
|
39
|
+
/** Show 1-based gutter line numbers. */
|
|
40
|
+
lineNumbers?: boolean
|
|
41
|
+
/** Wrap long lines instead of scrolling horizontally. */
|
|
42
|
+
wrap?: boolean
|
|
43
|
+
/** Case-insensitive substring to highlight in message bodies. */
|
|
44
|
+
highlight?: string
|
|
45
|
+
/** When set with `highlight`, hide every line that doesn't match. */
|
|
46
|
+
filterMatches?: boolean
|
|
47
|
+
/** Collapse consecutive identical lines into one, badged with a ×N count. */
|
|
48
|
+
compact?: boolean
|
|
49
|
+
/** Pin to the bottom as new lines arrive (until the user scrolls up). */
|
|
50
|
+
tail?: boolean
|
|
51
|
+
/** Fixed height. Anything CSS-valid; defaults to a comfortable terminal. */
|
|
52
|
+
height?: string
|
|
53
|
+
emptyLabel?: string
|
|
54
|
+
/**
|
|
55
|
+
* When true, a "loading older…" row is shown pinned at the top — set it while
|
|
56
|
+
* fetching a previous page in response to `reach-top`, clear it when done.
|
|
57
|
+
*/
|
|
58
|
+
loadingOlder?: boolean
|
|
59
|
+
/**
|
|
60
|
+
* When true, the viewer emits `reach-top` as the user scrolls near the top so
|
|
61
|
+
* the consumer can prepend older history (infinite scroll-back). The viewer
|
|
62
|
+
* preserves the scroll position across a prepend so the view doesn't jump.
|
|
63
|
+
*/
|
|
64
|
+
loadOlder?: boolean
|
|
65
|
+
/**
|
|
66
|
+
* Right-click row menu: built-in copy actions (+ "filter by" when the line
|
|
67
|
+
* has a source/level). Set false to disable; the row keeps the native menu.
|
|
68
|
+
*/
|
|
69
|
+
rowMenu?: boolean
|
|
70
|
+
/** Extra menu items per line, appended below the built-ins with a divider. */
|
|
71
|
+
extraRowItems?: (line: LogLine, index: number) => ContextMenuItemDef[]
|
|
72
|
+
/**
|
|
73
|
+
* Recognise common log formats inside each `message` and lift the level,
|
|
74
|
+
* timestamp and [source] out of the text into the dedicated columns. When a
|
|
75
|
+
* line already carries an explicit `level`/`time`/`source`, those win and
|
|
76
|
+
* the message is left untouched. Off by default (raw). Handles e.g.:
|
|
77
|
+
* "2025-01-02T03:04:05.123Z INFO [auth] user logged in"
|
|
78
|
+
* "[2025-01-02 03:04:05] WARN: disk almost full"
|
|
79
|
+
* "ERROR something broke"
|
|
80
|
+
* so a duplicate level word / timestamp doesn't repeat in the message body.
|
|
81
|
+
*/
|
|
82
|
+
parse?: boolean
|
|
83
|
+
}>(),
|
|
84
|
+
{
|
|
85
|
+
showTime: true,
|
|
86
|
+
showLevel: true,
|
|
87
|
+
lineNumbers: false,
|
|
88
|
+
wrap: false,
|
|
89
|
+
tail: true,
|
|
90
|
+
height: "20rem",
|
|
91
|
+
emptyLabel: "No logs yet",
|
|
92
|
+
rowMenu: true,
|
|
93
|
+
parse: false,
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
const emit = defineEmits<{
|
|
98
|
+
/** A "Filter by source/level" item was chosen — the consumer applies it. */
|
|
99
|
+
(e: "filter", by: { source?: string; level?: LogLevel }): void
|
|
100
|
+
/**
|
|
101
|
+
* The user scrolled near the top with `loadOlder` on — fetch the previous
|
|
102
|
+
* page. Re-armed only after the scroll leaves the top zone, so one trip up
|
|
103
|
+
* fires once. The viewer keeps the scroll anchored when the page prepends.
|
|
104
|
+
*/
|
|
105
|
+
(e: "reach-top"): void
|
|
106
|
+
}>()
|
|
107
|
+
|
|
108
|
+
// Level → gutter rail + text colour. Reads only semantic tokens so re-skinning
|
|
109
|
+
// just works. trace/debug stay muted; success is green; warn amber; error/fatal
|
|
110
|
+
// red (fatal gets a filled chip to stand apart from plain error).
|
|
111
|
+
const levelMeta: Record<LogLevel, { rail: string; text: string; chip: string }> = {
|
|
112
|
+
trace: { rail: "bg-line-strong", text: "text-muted", chip: "text-muted" },
|
|
113
|
+
debug: { rail: "bg-line-strong", text: "text-muted-strong", chip: "text-muted-strong" },
|
|
114
|
+
info: { rail: "bg-brand", text: "text-ink", chip: "text-brand" },
|
|
115
|
+
success: { rail: "bg-action", text: "text-ink", chip: "text-action" },
|
|
116
|
+
warn: { rail: "bg-accent", text: "text-ink", chip: "text-accent" },
|
|
117
|
+
error: { rail: "bg-danger", text: "text-ink", chip: "text-danger" },
|
|
118
|
+
fatal: { rail: "bg-danger", text: "text-ink", chip: "rounded-xs bg-danger px-1 text-ink-inverse" },
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function metaFor(level?: LogLevel) {
|
|
122
|
+
return levelMeta[level ?? "info"]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── format recognition (opt-in via `parse`) ──────────────────────────────
|
|
126
|
+
// Map a recognised level word to a canonical LogLevel; unknown → undefined.
|
|
127
|
+
const LEVEL_WORDS: Record<string, LogLevel> = {
|
|
128
|
+
trace: "trace",
|
|
129
|
+
debug: "debug",
|
|
130
|
+
info: "info",
|
|
131
|
+
notice: "info",
|
|
132
|
+
warn: "warn",
|
|
133
|
+
warning: "warn",
|
|
134
|
+
error: "error",
|
|
135
|
+
err: "error",
|
|
136
|
+
fatal: "fatal",
|
|
137
|
+
crit: "fatal",
|
|
138
|
+
critical: "fatal",
|
|
139
|
+
panic: "fatal",
|
|
140
|
+
success: "success",
|
|
141
|
+
ok: "success",
|
|
142
|
+
}
|
|
143
|
+
// Leading ISO-8601 / "YYYY-MM-DD HH:MM:SS" timestamp, optionally bracketed.
|
|
144
|
+
const TS_RE =
|
|
145
|
+
/^\[?(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[.,]\d{1,9})?(?:Z|[+-]\d{2}:?\d{2})?)\]?\s+/
|
|
146
|
+
// A leading LEVEL token, optionally followed by ':' .
|
|
147
|
+
const LEVEL_RE = /^([A-Za-z]{2,8})\s*:?\s+/
|
|
148
|
+
// A leading "[source]" tag.
|
|
149
|
+
const SOURCE_RE = /^\[([^\]]{1,40})\]\s+/
|
|
150
|
+
|
|
151
|
+
// Pull level/time/source out of a raw message; only fields not already set on
|
|
152
|
+
// the line are filled, and only the recognised prefixes are stripped from the
|
|
153
|
+
// body — so an unrecognised line is returned unchanged.
|
|
154
|
+
function parseLine(line: LogLine): LogLine {
|
|
155
|
+
if (!props.parse) return line
|
|
156
|
+
let msg = line.message
|
|
157
|
+
let time = line.time
|
|
158
|
+
let level = line.level
|
|
159
|
+
let source = line.source
|
|
160
|
+
|
|
161
|
+
// 1) timestamp prefix
|
|
162
|
+
if (time == null) {
|
|
163
|
+
const m = TS_RE.exec(msg)
|
|
164
|
+
if (m) {
|
|
165
|
+
time = m[1]
|
|
166
|
+
msg = msg.slice(m[0].length)
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
// strip a duplicate leading timestamp even if time is already set
|
|
170
|
+
const m = TS_RE.exec(msg)
|
|
171
|
+
if (m) msg = msg.slice(m[0].length)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 2) level word
|
|
175
|
+
const lm = LEVEL_RE.exec(msg)
|
|
176
|
+
if (lm) {
|
|
177
|
+
const canon = LEVEL_WORDS[lm[1].toLowerCase()]
|
|
178
|
+
if (canon) {
|
|
179
|
+
if (level == null) level = canon
|
|
180
|
+
msg = msg.slice(lm[0].length) // drop the duplicate word from the body
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 3) [source] tag
|
|
185
|
+
if (source == null) {
|
|
186
|
+
const sm = SOURCE_RE.exec(msg)
|
|
187
|
+
if (sm) {
|
|
188
|
+
source = sm[1]
|
|
189
|
+
msg = msg.slice(sm[0].length)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return msg === line.message && time === line.time && level === line.level && source === line.source
|
|
194
|
+
? line
|
|
195
|
+
: { ...line, message: msg, time, level, source }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const parsedLines = computed<LogLine[]>(() =>
|
|
199
|
+
props.parse ? props.lines.map(parseLine) : props.lines,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
// Rows actually rendered, each carrying its ORIGINAL index so line numbers stay
|
|
203
|
+
// truthful when filtering, plus a `count` for compact mode (1 unless folded).
|
|
204
|
+
// With filterMatches + a search term, keep only lines whose message contains it
|
|
205
|
+
// (case-insensitive); otherwise pass everything.
|
|
206
|
+
const visibleLines = computed<{ line: LogLine; n: number; count: number }[]>(() => {
|
|
207
|
+
const term = props.filterMatches ? props.highlight?.trim().toLowerCase() : ""
|
|
208
|
+
const filtered = parsedLines.value
|
|
209
|
+
.map((line, n) => ({ line, n }))
|
|
210
|
+
.filter(({ line }) => !term || line.message.toLowerCase().includes(term))
|
|
211
|
+
|
|
212
|
+
if (!props.compact) return filtered.map((row) => ({ ...row, count: 1 }))
|
|
213
|
+
|
|
214
|
+
// Fold runs of consecutive identical lines (same message/level/source, time
|
|
215
|
+
// ignored). Keeps the FIRST occurrence so its index/time stay meaningful;
|
|
216
|
+
// non-adjacent repeats are left alone so chronology isn't scrambled.
|
|
217
|
+
const folded: { line: LogLine; n: number; count: number }[] = []
|
|
218
|
+
for (const row of filtered) {
|
|
219
|
+
const prev = folded[folded.length - 1]
|
|
220
|
+
if (
|
|
221
|
+
prev &&
|
|
222
|
+
prev.line.message === row.line.message &&
|
|
223
|
+
prev.line.level === row.line.level &&
|
|
224
|
+
prev.line.source === row.line.source
|
|
225
|
+
) {
|
|
226
|
+
prev.count++
|
|
227
|
+
} else {
|
|
228
|
+
folded.push({ ...row, count: 1 })
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return folded
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
// True when filtering has emptied an otherwise non-empty stream.
|
|
235
|
+
const filteredEmpty = computed(
|
|
236
|
+
() => props.lines.length > 0 && visibleLines.value.length === 0,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
function fmtTime(time?: number | string): string {
|
|
240
|
+
if (time == null) return ""
|
|
241
|
+
if (typeof time === "number" || /^\d+$/.test(time)) {
|
|
242
|
+
const d = new Date(Number(time))
|
|
243
|
+
if (Number.isNaN(d.getTime())) return String(time)
|
|
244
|
+
return d.toLocaleTimeString(undefined, { hour12: false }) + "." +
|
|
245
|
+
String(d.getMilliseconds()).padStart(3, "0")
|
|
246
|
+
}
|
|
247
|
+
return time
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── row context menu ─────────────────────────────────────────────────
|
|
251
|
+
async function copy(text: string) {
|
|
252
|
+
try {
|
|
253
|
+
await navigator.clipboard?.writeText(text)
|
|
254
|
+
} catch {
|
|
255
|
+
// Clipboard can reject (insecure context / denied permission); swallow —
|
|
256
|
+
// the menu action is best-effort and shouldn't throw into the UI.
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// The whole line as one copyable string: "time LEVEL [source] message".
|
|
261
|
+
function lineText(line: LogLine): string {
|
|
262
|
+
return [fmtTime(line.time), line.level?.toUpperCase(), line.source && `[${line.source}]`, line.message]
|
|
263
|
+
.filter(Boolean)
|
|
264
|
+
.join(" ")
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Built-in copy actions + optional "filter by" + consumer extras. Rebuilt per
|
|
268
|
+
// right-click so it reflects the line under the cursor.
|
|
269
|
+
function menuFor(line: LogLine, index: number): ContextMenuItemDef[] {
|
|
270
|
+
if (!props.rowMenu) return []
|
|
271
|
+
const items: ContextMenuItemDef[] = [
|
|
272
|
+
{ label: "Copy message", icon: "lucide:copy", onSelect: () => copy(line.message) },
|
|
273
|
+
{ label: "Copy line", icon: "lucide:clipboard-list", onSelect: () => copy(lineText(line)) },
|
|
274
|
+
]
|
|
275
|
+
if (line.time != null) {
|
|
276
|
+
items.push({ label: "Copy timestamp", icon: "lucide:clock", onSelect: () => copy(fmtTime(line.time)) })
|
|
277
|
+
}
|
|
278
|
+
if (line.source) {
|
|
279
|
+
items.push({
|
|
280
|
+
label: `Filter by [${line.source}]`,
|
|
281
|
+
icon: "lucide:filter",
|
|
282
|
+
separatorBefore: true,
|
|
283
|
+
onSelect: () => emit("filter", { source: line.source }),
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
if (line.level) {
|
|
287
|
+
items.push({
|
|
288
|
+
label: `Filter by ${line.level}`,
|
|
289
|
+
icon: "lucide:filter",
|
|
290
|
+
separatorBefore: !line.source,
|
|
291
|
+
onSelect: () => emit("filter", { level: line.level }),
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
const extra = props.extraRowItems?.(line, index) ?? []
|
|
295
|
+
if (extra.length) extra[0] = { ...extra[0], separatorBefore: true }
|
|
296
|
+
return [...items, ...extra]
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Split a message around the (case-insensitive) highlight term so we can wrap
|
|
300
|
+
// matches in <mark> without v-html. Empty/absent term → single plain part.
|
|
301
|
+
interface Part {
|
|
302
|
+
text: string
|
|
303
|
+
hit: boolean
|
|
304
|
+
}
|
|
305
|
+
function splitHighlight(message: string): Part[] {
|
|
306
|
+
const term = props.highlight?.trim()
|
|
307
|
+
if (!term) return [{ text: message, hit: false }]
|
|
308
|
+
const parts: Part[] = []
|
|
309
|
+
const lower = message.toLowerCase()
|
|
310
|
+
const needle = term.toLowerCase()
|
|
311
|
+
let i = 0
|
|
312
|
+
while (i < message.length) {
|
|
313
|
+
const at = lower.indexOf(needle, i)
|
|
314
|
+
if (at === -1) {
|
|
315
|
+
parts.push({ text: message.slice(i), hit: false })
|
|
316
|
+
break
|
|
317
|
+
}
|
|
318
|
+
if (at > i) parts.push({ text: message.slice(i, at), hit: false })
|
|
319
|
+
parts.push({ text: message.slice(at, at + needle.length), hit: true })
|
|
320
|
+
i = at + needle.length
|
|
321
|
+
}
|
|
322
|
+
return parts
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ── tail / auto-scroll ───────────────────────────────────────────────
|
|
326
|
+
const scrollRef = ref<{ viewportEl: HTMLElement | null } | null>(null)
|
|
327
|
+
const pinned = ref(true) // currently glued to the bottom
|
|
328
|
+
// Slack from the bottom that still counts as "pinned". Generous (a few rows
|
|
329
|
+
// tall) so a freshly pushed line — which momentarily grows scrollHeight before
|
|
330
|
+
// the tail catches up — doesn't unpin us and flash the jump-to-latest pill;
|
|
331
|
+
// only a deliberate scroll up past this clears it.
|
|
332
|
+
const NEAR = 120
|
|
333
|
+
|
|
334
|
+
function viewport(): HTMLElement | null {
|
|
335
|
+
return scrollRef.value?.viewportEl ?? null
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function atBottom(el: HTMLElement): boolean {
|
|
339
|
+
return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Distance from the top that arms a scroll-back fetch (a few rows of slack so
|
|
343
|
+
// the next page is on its way before the user hits the very top).
|
|
344
|
+
const TOP_NEAR = 80
|
|
345
|
+
// Armed = ready to fire reach-top; cleared until the scroll leaves the top zone
|
|
346
|
+
// so one trip to the top emits exactly once.
|
|
347
|
+
let topArmed = true
|
|
348
|
+
|
|
349
|
+
function onScroll(e: Event) {
|
|
350
|
+
const el = e.target as HTMLElement
|
|
351
|
+
pinned.value = atBottom(el)
|
|
352
|
+
if (!props.loadOlder) return
|
|
353
|
+
if (el.scrollTop <= TOP_NEAR) {
|
|
354
|
+
if (topArmed && !props.loadingOlder) {
|
|
355
|
+
topArmed = false
|
|
356
|
+
emit("reach-top")
|
|
357
|
+
}
|
|
358
|
+
} else {
|
|
359
|
+
topArmed = true
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Preserve scroll position across a prepend: when older lines are added at the
|
|
364
|
+
// top, scrollTop must grow by the height they introduced or the view jumps. We
|
|
365
|
+
// snapshot scrollHeight before the lines change and restore the offset after.
|
|
366
|
+
let prevScrollHeight = 0
|
|
367
|
+
let prevScrollTop = 0
|
|
368
|
+
function captureScrollAnchor() {
|
|
369
|
+
const el = viewport()
|
|
370
|
+
if (!el) return
|
|
371
|
+
prevScrollHeight = el.scrollHeight
|
|
372
|
+
prevScrollTop = el.scrollTop
|
|
373
|
+
}
|
|
374
|
+
function restoreScrollAnchor() {
|
|
375
|
+
const el = viewport()
|
|
376
|
+
if (!el) return
|
|
377
|
+
const added = el.scrollHeight - prevScrollHeight
|
|
378
|
+
if (added > 0) el.scrollTop = prevScrollTop + added
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Glide to the bottom. Honours OS reduce-motion. For auto-tail we skip the
|
|
382
|
+
// animation on large jumps (`far`), where a smooth chase would feel sluggish —
|
|
383
|
+
// but a deliberate "jump to latest" passes force=true to always animate.
|
|
384
|
+
function rideToBottom(smooth = true, force = false) {
|
|
385
|
+
const el = viewport()
|
|
386
|
+
if (!el) return
|
|
387
|
+
const reduce =
|
|
388
|
+
typeof window !== "undefined" &&
|
|
389
|
+
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
|
390
|
+
const far = !force && el.scrollHeight - el.scrollTop - el.clientHeight > el.clientHeight
|
|
391
|
+
el.scrollTo({ top: el.scrollHeight, behavior: smooth && !reduce && !far ? "smooth" : "auto" })
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function scrollToBottom() {
|
|
395
|
+
rideToBottom(true, true)
|
|
396
|
+
pinned.value = true
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Snapshot the scroll anchor BEFORE the DOM updates for a line change (flush:pre)
|
|
400
|
+
// so a prepend can be compensated post-update. Only matters when NOT pinned to
|
|
401
|
+
// the bottom (i.e. the user is reading history up the stream).
|
|
402
|
+
watch(
|
|
403
|
+
() => props.lines.length,
|
|
404
|
+
() => {
|
|
405
|
+
if (!pinned.value) captureScrollAnchor()
|
|
406
|
+
},
|
|
407
|
+
{ flush: "pre" },
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
// After the DOM updates: tail to the bottom if pinned, else keep the view glued
|
|
411
|
+
// to where it was so a top prepend (older history) doesn't yank it.
|
|
412
|
+
watch(
|
|
413
|
+
() => props.lines.length,
|
|
414
|
+
async () => {
|
|
415
|
+
await nextTick()
|
|
416
|
+
if (props.tail && pinned.value) {
|
|
417
|
+
requestAnimationFrame(() => rideToBottom(true))
|
|
418
|
+
} else {
|
|
419
|
+
restoreScrollAnchor()
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
const showJump = computed(() => props.tail && !pinned.value && props.lines.length > 0)
|
|
425
|
+
</script>
|
|
426
|
+
|
|
427
|
+
<template>
|
|
428
|
+
<div
|
|
429
|
+
class="relative overflow-hidden rounded-card border border-line bg-surface font-mono text-xs leading-relaxed"
|
|
430
|
+
>
|
|
431
|
+
<LpScrollArea
|
|
432
|
+
ref="scrollRef"
|
|
433
|
+
fade
|
|
434
|
+
:content-class="wrap ? '' : 'min-w-max'"
|
|
435
|
+
:style="{ height }"
|
|
436
|
+
@scroll="onScroll"
|
|
437
|
+
>
|
|
438
|
+
<div
|
|
439
|
+
v-if="lines.length === 0 || filteredEmpty"
|
|
440
|
+
class="flex h-full flex-col items-center justify-center gap-1 px-4 py-10 text-center"
|
|
441
|
+
>
|
|
442
|
+
<span class="text-muted">{{ filteredEmpty ? "No lines match" : emptyLabel }}</span>
|
|
443
|
+
<span v-if="filteredEmpty" class="text-muted/70">“{{ highlight }}”</span>
|
|
444
|
+
</div>
|
|
445
|
+
|
|
446
|
+
<template v-else>
|
|
447
|
+
<!-- Older-history loading row, pinned at the top while a scroll-back page
|
|
448
|
+
is fetched (driven by `loadingOlder`) — sits ABOVE the lines so it
|
|
449
|
+
doesn't replace them. -->
|
|
450
|
+
<div
|
|
451
|
+
v-if="loadingOlder"
|
|
452
|
+
class="flex items-center justify-center gap-2 py-2 text-muted"
|
|
453
|
+
>
|
|
454
|
+
<LpIcon name="lucide:loader-circle" :size="14" class="animate-spin" />
|
|
455
|
+
<span class="text-[11px]">loading older…</span>
|
|
456
|
+
</div>
|
|
457
|
+
|
|
458
|
+
<!-- Leave collapses a folded-away duplicate (max-height + opacity + a small
|
|
459
|
+
lift) while the survivors FLIP-slide up via move-class — so toggling
|
|
460
|
+
`compact` reads as the dupes melting into the kept line, not a jump. -->
|
|
461
|
+
<TransitionGroup
|
|
462
|
+
tag="ol"
|
|
463
|
+
class="py-1"
|
|
464
|
+
enter-active-class="transition duration-200 ease-[var(--ease-emphasized)]"
|
|
465
|
+
enter-from-class="-translate-x-1 opacity-0"
|
|
466
|
+
leave-active-class="overflow-hidden transition-all duration-200 ease-[var(--ease-emphasized)]"
|
|
467
|
+
leave-from-class="max-h-8 opacity-100"
|
|
468
|
+
leave-to-class="max-h-0 -translate-y-1 opacity-0"
|
|
469
|
+
move-class="transition-transform duration-200 ease-[var(--ease-emphasized)]"
|
|
470
|
+
>
|
|
471
|
+
<LpContextMenu
|
|
472
|
+
v-for="{ line, n, count } in visibleLines"
|
|
473
|
+
:key="n"
|
|
474
|
+
:items="menuFor(line, n)"
|
|
475
|
+
>
|
|
476
|
+
<li
|
|
477
|
+
class="group flex items-start gap-0 px-0 transition-colors hover:bg-surface-soft/60"
|
|
478
|
+
>
|
|
479
|
+
<!-- level rail -->
|
|
480
|
+
<span
|
|
481
|
+
class="mr-2 w-0.5 shrink-0 self-stretch"
|
|
482
|
+
:class="metaFor(line.level).rail"
|
|
483
|
+
aria-hidden="true"
|
|
484
|
+
/>
|
|
485
|
+
|
|
486
|
+
<!-- line number -->
|
|
487
|
+
<span
|
|
488
|
+
v-if="lineNumbers"
|
|
489
|
+
class="mr-3 w-10 shrink-0 select-none text-right tabular-nums text-muted/70"
|
|
490
|
+
>{{ n + 1 }}</span>
|
|
491
|
+
|
|
492
|
+
<!-- timestamp -->
|
|
493
|
+
<span
|
|
494
|
+
v-if="showTime"
|
|
495
|
+
class="mr-3 shrink-0 select-none tabular-nums text-muted"
|
|
496
|
+
>{{ fmtTime(line.time) }}</span>
|
|
497
|
+
|
|
498
|
+
<!-- level chip -->
|
|
499
|
+
<span
|
|
500
|
+
v-if="showLevel"
|
|
501
|
+
class="mr-3 w-12 shrink-0 select-none text-[10px] font-semibold uppercase tracking-[0.06em]"
|
|
502
|
+
:class="metaFor(line.level).chip"
|
|
503
|
+
>{{ line.level ?? "info" }}</span>
|
|
504
|
+
|
|
505
|
+
<!-- source tag -->
|
|
506
|
+
<span
|
|
507
|
+
v-if="line.source"
|
|
508
|
+
class="mr-2 shrink-0 select-none text-muted-strong"
|
|
509
|
+
>[{{ line.source }}]</span>
|
|
510
|
+
|
|
511
|
+
<!-- message -->
|
|
512
|
+
<span
|
|
513
|
+
class="min-w-0 pr-3"
|
|
514
|
+
:class="[metaFor(line.level).text, wrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre']"
|
|
515
|
+
>
|
|
516
|
+
<template v-for="(part, p) in splitHighlight(line.message)" :key="p">
|
|
517
|
+
<mark
|
|
518
|
+
v-if="part.hit"
|
|
519
|
+
class="rounded-xs bg-accent/30 text-ink"
|
|
520
|
+
>{{ part.text }}</mark>
|
|
521
|
+
<template v-else>{{ part.text }}</template>
|
|
522
|
+
</template>
|
|
523
|
+
<!-- compact: how many consecutive identical lines were folded here.
|
|
524
|
+
Keyed on count so each new fold re-mounts the chip and pops it,
|
|
525
|
+
cueing where the duplicate just merged. -->
|
|
526
|
+
<Transition
|
|
527
|
+
enter-active-class="inline-block transition duration-200 ease-[var(--ease-emphasized)]"
|
|
528
|
+
enter-from-class="scale-50 opacity-0"
|
|
529
|
+
>
|
|
530
|
+
<span
|
|
531
|
+
v-if="count > 1"
|
|
532
|
+
:key="count"
|
|
533
|
+
class="ml-2 select-none rounded-pill bg-surface-soft px-1.5 align-[1px] text-[10px] font-semibold tabular-nums text-muted-strong"
|
|
534
|
+
:title="`${count} identical lines`"
|
|
535
|
+
>×{{ count }}</span>
|
|
536
|
+
</Transition>
|
|
537
|
+
</span>
|
|
538
|
+
</li>
|
|
539
|
+
</LpContextMenu>
|
|
540
|
+
</TransitionGroup>
|
|
541
|
+
</template>
|
|
542
|
+
</LpScrollArea>
|
|
543
|
+
|
|
544
|
+
<!-- jump-to-latest: shown only when tailing and scrolled away. The
|
|
545
|
+
Transition rides an outer wrapper so its transform/opacity don't fight
|
|
546
|
+
the button's own -translate-x-1/2 centring and hover colour transition. -->
|
|
547
|
+
<Transition
|
|
548
|
+
enter-active-class="transition duration-200 ease-[var(--ease-emphasized)]"
|
|
549
|
+
enter-from-class="translate-y-3 scale-90 opacity-0"
|
|
550
|
+
leave-active-class="transition duration-150 ease-in"
|
|
551
|
+
leave-to-class="translate-y-3 scale-90 opacity-0"
|
|
552
|
+
>
|
|
553
|
+
<div v-if="showJump" class="absolute inset-x-0 bottom-3 flex justify-center">
|
|
554
|
+
<button
|
|
555
|
+
type="button"
|
|
556
|
+
class="group flex items-center gap-1.5 rounded-pill border border-line bg-surface-overlay px-3 py-1 text-xs font-medium text-ink shadow-[var(--shadow-panel)] backdrop-blur outline-none transition-colors hover:bg-surface-soft focus-visible:ring-2 focus-visible:ring-ring"
|
|
557
|
+
@click="scrollToBottom"
|
|
558
|
+
>
|
|
559
|
+
<LpIcon
|
|
560
|
+
name="lucide:arrow-down"
|
|
561
|
+
:size="14"
|
|
562
|
+
class="transition-transform duration-200 ease-[var(--ease-emphasized)] group-hover:translate-y-0.5"
|
|
563
|
+
/>
|
|
564
|
+
Jump to latest
|
|
565
|
+
</button>
|
|
566
|
+
</div>
|
|
567
|
+
</Transition>
|
|
568
|
+
</div>
|
|
569
|
+
</template>
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
DialogClose,
|
|
4
|
+
DialogContent,
|
|
5
|
+
DialogDescription,
|
|
6
|
+
DialogOverlay,
|
|
7
|
+
DialogPortal,
|
|
8
|
+
DialogRoot,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
} from "reka-ui"
|
|
11
|
+
import { computed, useSlots } from "vue"
|
|
12
|
+
import { CLOSE_ICON } from "./dropdown"
|
|
13
|
+
import LpIcon from "./LpIcon.vue"
|
|
14
|
+
import LpScrollArea from "./LpScrollArea.vue"
|
|
15
|
+
|
|
16
|
+
const props = withDefaults(
|
|
17
|
+
defineProps<{
|
|
18
|
+
open?: boolean
|
|
19
|
+
title?: string
|
|
20
|
+
description?: string
|
|
21
|
+
/**
|
|
22
|
+
* Max content width preset. sm≈24rem … xl≈42rem, 2xl≈56rem, 3xl≈72rem,
|
|
23
|
+
* full≈96vw (near-fullscreen, for dense catalogues).
|
|
24
|
+
*/
|
|
25
|
+
size?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "full"
|
|
26
|
+
/** Explicit width override (any CSS length), wins over `size`. */
|
|
27
|
+
width?: string
|
|
28
|
+
/**
|
|
29
|
+
* Let the body fill the available height as a flex column instead of
|
|
30
|
+
* scrolling itself. Use for dense dashboards that own their inner scroll
|
|
31
|
+
* regions (e.g. multi-pane catalogues) — the panes scroll, not the modal.
|
|
32
|
+
*/
|
|
33
|
+
fillBody?: boolean
|
|
34
|
+
}>(),
|
|
35
|
+
{ size: "md" },
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
defineEmits<{ (e: "update:open", value: boolean): void }>()
|
|
39
|
+
|
|
40
|
+
const widthClass = computed(() => {
|
|
41
|
+
if (props.width) return ""
|
|
42
|
+
return {
|
|
43
|
+
sm: "w-[min(92vw,24rem)]",
|
|
44
|
+
md: "w-[min(92vw,28rem)]",
|
|
45
|
+
lg: "w-[min(92vw,34rem)]",
|
|
46
|
+
xl: "w-[min(92vw,42rem)]",
|
|
47
|
+
"2xl": "w-[min(94vw,56rem)]",
|
|
48
|
+
"3xl": "w-[min(95vw,72rem)]",
|
|
49
|
+
full: "w-[96vw]",
|
|
50
|
+
}[props.size]
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Body padding, shared by the fillBody flex path and the scroll path. Always
|
|
54
|
+
// inset horizontally; top only when there's no header, bottom only when there's
|
|
55
|
+
// no footer (header/footer own those edges).
|
|
56
|
+
const slots = useSlots()
|
|
57
|
+
const bodyPad = computed(() =>
|
|
58
|
+
[
|
|
59
|
+
"px-5",
|
|
60
|
+
props.title || slots.title ? "" : "pt-5",
|
|
61
|
+
slots.footer ? "" : "pb-5",
|
|
62
|
+
]
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.join(" "),
|
|
65
|
+
)
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<template>
|
|
69
|
+
<DialogRoot :open="open" @update:open="(v) => $emit('update:open', v)">
|
|
70
|
+
<DialogPortal>
|
|
71
|
+
<DialogOverlay
|
|
72
|
+
class="fixed inset-0 z-(--z-overlay) bg-black/50 backdrop-blur-sm data-[state=open]:animate-[fade-in_150ms_ease] data-[state=closed]:animate-[fade-out_130ms_ease]"
|
|
73
|
+
/>
|
|
74
|
+
<DialogContent
|
|
75
|
+
class="fixed left-1/2 top-1/2 z-(--z-modal) flex max-h-[min(90vh,calc(100dvh-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col rounded-card border border-line bg-surface-raised shadow-panel outline-none data-[state=open]:animate-[pop-in_160ms_var(--ease-emphasized)] data-[state=closed]:animate-[pop-out_130ms_ease]"
|
|
76
|
+
:class="widthClass"
|
|
77
|
+
:style="width ? { width } : undefined"
|
|
78
|
+
>
|
|
79
|
+
<header v-if="title || $slots.title" class="flex shrink-0 items-start justify-between gap-4 p-5 pb-3">
|
|
80
|
+
<div class="flex flex-col gap-1">
|
|
81
|
+
<DialogTitle class="text-base font-semibold text-ink">
|
|
82
|
+
<slot name="title">{{ title }}</slot>
|
|
83
|
+
</DialogTitle>
|
|
84
|
+
<DialogDescription v-if="description" class="text-sm text-muted">
|
|
85
|
+
{{ description }}
|
|
86
|
+
</DialogDescription>
|
|
87
|
+
</div>
|
|
88
|
+
<DialogClose
|
|
89
|
+
class="group flex shrink-0 items-center rounded-md p-1 text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
|
|
90
|
+
aria-label="Close"
|
|
91
|
+
>
|
|
92
|
+
<LpIcon
|
|
93
|
+
name="lucide:x"
|
|
94
|
+
:size="18"
|
|
95
|
+
:class="CLOSE_ICON"
|
|
96
|
+
/>
|
|
97
|
+
</DialogClose>
|
|
98
|
+
</header>
|
|
99
|
+
|
|
100
|
+
<!-- fillBody: a plain flex column that owns its own inner scroll regions.
|
|
101
|
+
Otherwise the body scrolls itself via LpScrollArea's overlay bar. -->
|
|
102
|
+
<div
|
|
103
|
+
v-if="fillBody"
|
|
104
|
+
class="flex min-h-0 flex-1 flex-col overflow-hidden text-sm text-ink/90"
|
|
105
|
+
:class="bodyPad"
|
|
106
|
+
>
|
|
107
|
+
<slot />
|
|
108
|
+
</div>
|
|
109
|
+
<LpScrollArea
|
|
110
|
+
v-else
|
|
111
|
+
class="min-h-0 flex-1 text-sm text-ink/90"
|
|
112
|
+
:content-class="bodyPad"
|
|
113
|
+
>
|
|
114
|
+
<slot />
|
|
115
|
+
</LpScrollArea>
|
|
116
|
+
|
|
117
|
+
<footer v-if="$slots.footer" class="flex shrink-0 justify-end gap-2 p-5 pt-4">
|
|
118
|
+
<slot name="footer" />
|
|
119
|
+
</footer>
|
|
120
|
+
</DialogContent>
|
|
121
|
+
</DialogPortal>
|
|
122
|
+
</DialogRoot>
|
|
123
|
+
</template>
|