|
| 1 | +<script setup lang="ts"> |
| 2 | +import type { DevframeMessageEntry, DevframeMessageEntryFrom } from '@devframes/hub/types' |
| 3 | +import ActionIconButton from '@antfu/design/components/Action/ActionIconButton.vue' |
| 4 | +import DisplayBadge from '@antfu/design/components/Display/DisplayBadge.vue' |
| 5 | +import FeedbackSpinner from '@antfu/design/components/Feedback/FeedbackSpinner.vue' |
| 6 | +import LayoutSeparator from '@antfu/design/components/Layout/LayoutSeparator.vue' |
| 7 | +import { useClipboard, useTimeAgo } from '@vueuse/core' |
| 8 | +import { computed } from 'vue' |
| 9 | +import { fromEntries, levels } from './message-styles' |
| 10 | +
|
| 11 | +const props = defineProps<{ |
| 12 | + entry: DevframeMessageEntry |
| 13 | + /** Show the "open file" affordance for entries with a `filePosition`. */ |
| 14 | + canOpenFile?: boolean |
| 15 | +}>() |
| 16 | +
|
| 17 | +const emit = defineEmits<{ |
| 18 | + close: [] |
| 19 | + dismiss: [id: string] |
| 20 | + openFile: [entry: DevframeMessageEntry] |
| 21 | + toggleCategory: [category: string] |
| 22 | + toggleLabel: [label: string] |
| 23 | +}>() |
| 24 | +
|
| 25 | +const timeAgo = useTimeAgo(computed(() => props.entry.timestamp)) |
| 26 | +const { copy: copyStacktrace, copied: stacktraceCopied } = useClipboard() |
| 27 | +
|
| 28 | +const from = computed(() => fromEntries[props.entry.from as DevframeMessageEntryFrom]) |
| 29 | +
|
| 30 | +function formatAbsoluteTime(ts: number): string { |
| 31 | + return new Date(ts).toLocaleTimeString() |
| 32 | +} |
| 33 | +
|
| 34 | +function filePositionLabel(pos: NonNullable<DevframeMessageEntry['filePosition']>): string { |
| 35 | + let path = pos.file |
| 36 | + if (pos.line) |
| 37 | + path += `:${pos.line}` |
| 38 | + if (pos.column) |
| 39 | + path += `:${pos.column}` |
| 40 | + return path |
| 41 | +} |
| 42 | +</script> |
| 43 | + |
| 44 | +<template> |
| 45 | + <div class="h-full of-y-auto border-l border-base p-4"> |
| 46 | + <!-- Header --> |
| 47 | + <div class="flex items-start gap-2 mb-3"> |
| 48 | + <div class="flex-1 font-medium text-lg"> |
| 49 | + {{ entry.message }} |
| 50 | + </div> |
| 51 | + <ActionIconButton |
| 52 | + icon="i-ph:trash-duotone" |
| 53 | + tooltip="Dismiss" |
| 54 | + label="Dismiss" |
| 55 | + @click="emit('dismiss', entry.id)" |
| 56 | + /> |
| 57 | + <ActionIconButton |
| 58 | + icon="i-ph:x" |
| 59 | + tooltip="Close detail" |
| 60 | + label="Close detail" |
| 61 | + @click="emit('close')" |
| 62 | + /> |
| 63 | + </div> |
| 64 | + |
| 65 | + <!-- Metadata row --> |
| 66 | + <div class="flex flex-wrap items-center gap-2 mb-3 text-xs"> |
| 67 | + <span class="flex items-center gap-1" :class="levels[entry.level].color"> |
| 68 | + <div :class="levels[entry.level].icon" class="w-3.5 h-3.5" /> |
| 69 | + <span class="capitalize">{{ entry.level }}</span> |
| 70 | + </span> |
| 71 | + <span v-if="from" class="flex items-center gap-1" :class="from.color"> |
| 72 | + <div :class="from.icon" class="w-3.5 h-3.5" /> |
| 73 | + {{ from.label }} |
| 74 | + </span> |
| 75 | + <span v-if="entry.status === 'loading'" class="flex items-center gap-1 text-amber"> |
| 76 | + <FeedbackSpinner size="0.75rem" /> |
| 77 | + Loading |
| 78 | + </span> |
| 79 | + <span class="op40" :title="formatAbsoluteTime(entry.timestamp)"> |
| 80 | + {{ timeAgo }} |
| 81 | + </span> |
| 82 | + <span v-if="entry.notify" class="flex items-center gap-0.5 op40"> |
| 83 | + <div class="i-ph:bell-duotone w-3.5 h-3.5" /> |
| 84 | + notify |
| 85 | + </span> |
| 86 | + </div> |
| 87 | + |
| 88 | + <!-- Description --> |
| 89 | + <div v-if="entry.description" class="text-sm op80 mb-3 whitespace-pre-wrap"> |
| 90 | + {{ entry.description }} |
| 91 | + </div> |
| 92 | + |
| 93 | + <!-- Category + Labels --> |
| 94 | + <div v-if="entry.category || entry.labels?.length" class="flex flex-wrap gap-1 mb-3"> |
| 95 | + <DisplayBadge v-if="entry.category" :text="entry.category" as="button" class="text-xs cursor-pointer" @click="emit('toggleCategory', entry.category)" /> |
| 96 | + <DisplayBadge v-for="label of entry.labels" :key="label" :text="label" as="button" class="text-xs cursor-pointer" @click="emit('toggleLabel', label)" /> |
| 97 | + </div> |
| 98 | + |
| 99 | + <!-- File position --> |
| 100 | + <button |
| 101 | + v-if="entry.filePosition && canOpenFile" |
| 102 | + type="button" |
| 103 | + class="flex items-start gap-1.5 text-left text-sm color-active hover:underline mb-3 break-all" |
| 104 | + @click="emit('openFile', entry)" |
| 105 | + > |
| 106 | + <div class="i-ph:file-code-duotone w-4 h-4 flex-none mt-0.5" /> |
| 107 | + <span>{{ filePositionLabel(entry.filePosition) }}</span> |
| 108 | + </button> |
| 109 | + <div |
| 110 | + v-else-if="entry.filePosition" |
| 111 | + class="flex items-start gap-1.5 text-sm op60 mb-3 break-all" |
| 112 | + > |
| 113 | + <div class="i-ph:file-code-duotone w-4 h-4 flex-none mt-0.5" /> |
| 114 | + <span>{{ filePositionLabel(entry.filePosition) }}</span> |
| 115 | + </div> |
| 116 | + |
| 117 | + <!-- Element position --> |
| 118 | + <div v-if="entry.elementPosition" class="text-sm mb-3 bg-gray/5 rounded p-2"> |
| 119 | + <div class="op50 text-xs mb-1"> |
| 120 | + Element |
| 121 | + </div> |
| 122 | + <div v-if="entry.elementPosition.selector" class="font-mono text-xs"> |
| 123 | + {{ entry.elementPosition.selector }} |
| 124 | + </div> |
| 125 | + <div v-if="entry.elementPosition.description" class="text-xs op70 mt-1"> |
| 126 | + {{ entry.elementPosition.description }} |
| 127 | + </div> |
| 128 | + <div v-if="entry.elementPosition.boundingBox" class="text-xs op50 mt-1 font-mono"> |
| 129 | + {{ entry.elementPosition.boundingBox.x }}, {{ entry.elementPosition.boundingBox.y }} |
| 130 | + ({{ entry.elementPosition.boundingBox.width }} × {{ entry.elementPosition.boundingBox.height }}) |
| 131 | + </div> |
| 132 | + </div> |
| 133 | + |
| 134 | + <!-- Stacktrace --> |
| 135 | + <div v-if="entry.stacktrace" class="mb-3"> |
| 136 | + <div class="op50 text-xs mb-1"> |
| 137 | + Stack Trace |
| 138 | + </div> |
| 139 | + <div class="group relative"> |
| 140 | + <pre class="text-xs bg-gray/5 rounded p-2 of-x-auto whitespace-pre-wrap font-mono">{{ entry.stacktrace }}</pre> |
| 141 | + <ActionIconButton |
| 142 | + compact |
| 143 | + class="absolute top-1.5 right-1.5 op0 group-hover:op100 text-xs bg-base border border-base" |
| 144 | + :icon="stacktraceCopied ? 'i-ph:check' : 'i-ph:copy'" |
| 145 | + tooltip="Copy" |
| 146 | + label="Copy stack trace" |
| 147 | + @click="copyStacktrace(entry.stacktrace)" |
| 148 | + /> |
| 149 | + </div> |
| 150 | + </div> |
| 151 | + |
| 152 | + <!-- Timers --> |
| 153 | + <div v-if="entry.autoDismiss || entry.autoDelete" class="flex flex-wrap gap-3 mb-3 text-xs op50"> |
| 154 | + <span v-if="entry.autoDismiss" class="flex items-center gap-1"> |
| 155 | + <div class="i-ph:bell-slash-duotone w-3.5 h-3.5" /> |
| 156 | + Auto-dismiss: {{ entry.autoDismiss / 1000 }}s |
| 157 | + </span> |
| 158 | + <span v-if="entry.autoDelete" class="flex items-center gap-1"> |
| 159 | + <div class="i-ph:timer-duotone w-3.5 h-3.5" /> |
| 160 | + Auto-delete: {{ entry.autoDelete / 1000 }}s |
| 161 | + </span> |
| 162 | + </div> |
| 163 | + |
| 164 | + <!-- ID + Timestamp --> |
| 165 | + <LayoutSeparator /> |
| 166 | + <div class="flex flex-col gap-1 text-xs op40 font-mono"> |
| 167 | + <span>ID: {{ entry.id }}</span> |
| 168 | + <span>{{ formatAbsoluteTime(entry.timestamp) }} ({{ new Date(entry.timestamp).toLocaleDateString() }})</span> |
| 169 | + </div> |
| 170 | + </div> |
| 171 | +</template> |
0 commit comments