jedmund-svelte/src/lib/components/DebugPanel.svelte
Justin Edmund f1d0453b63 feat: add comprehensive debug panel for development
- Create DebugPanel component with Now Playing, Albums, and Cache tabs
- Show real-time connection status and update intervals
- Display detailed Apple Music data for each album
- Add inline cache clearing for individual albums
- Implement Apple Music search modal for testing queries
- Add admin endpoints for cache management and API testing
- Only visible in development mode

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-10 21:32:59 -07:00

1058 lines
No EOL
25 KiB
Svelte

<script lang="ts">
import { dev } from '$app/environment'
import { musicStream } from '$lib/stores/music-stream'
import type { Album } from '$lib/types/lastfm'
import { toast } from 'svelte-sonner'
// Import SVG icons
import SettingsIcon from '$icons/settings.svg'
import CheckIcon from '$icons/check.svg'
import XIcon from '$icons/x.svg'
import TrashIcon from '$icons/trash.svg'
import ClockIcon from '$icons/clock.svg'
import LoaderIcon from '$icons/loader.svg'
import AppleMusicSearchModal from './AppleMusicSearchModal.svelte'
// Only show in development
if (!dev) {
// Return empty component in production
}
let isMinimized = $state(true)
let activeTab = $state<'nowplaying' | 'albums' | 'cache'>('nowplaying')
// Music stream state
let albums = $state<Album[]>([])
let nowPlaying = $state<{ album: Album; track?: string } | null>(null)
let connected = $state(false)
let lastUpdate = $state<Date | null>(null)
let updateFlash = $state(false)
// Expanded album view
let expandedAlbumId = $state<string | null>(null)
// Update timing
let nextUpdateIn = $state<number>(0)
let updateInterval = $state<number>(30) // seconds
let trackRemainingTime = $state<number>(0) // seconds
// Cache management
let cacheKey = $state('')
let isClearing = $state(false)
let clearingAlbums = $state(new Set<string>())
// Search modal reference
let searchModal: AppleMusicSearchModal
// Subscribe to music stream
$effect(() => {
const unsubscribe = musicStream.subscribe((state) => {
albums = state.albums
connected = state.connected
// Flash indicator when update is received
if (state.lastUpdate && (!lastUpdate || state.lastUpdate.getTime() !== lastUpdate.getTime())) {
updateFlash = true
setTimeout(() => updateFlash = false, 500)
}
lastUpdate = state.lastUpdate
// Calculate smart interval based on track remaining time
const nowPlayingAlbum = state.albums.find(a => a.isNowPlaying)
if (nowPlayingAlbum?.nowPlayingTrack && nowPlayingAlbum.appleMusicData?.tracks && nowPlayingAlbum.lastScrobbleTime) {
const track = nowPlayingAlbum.appleMusicData.tracks.find(
t => t.name === nowPlayingAlbum.nowPlayingTrack
)
if (track?.durationMs) {
const elapsed = Date.now() - new Date(nowPlayingAlbum.lastScrobbleTime).getTime()
const remaining = Math.max(0, track.durationMs - elapsed)
trackRemainingTime = Math.round(remaining / 1000)
// Smart interval based on remaining time
if (remaining < 20000) {
updateInterval = 5
} else if (remaining < 60000) {
updateInterval = 10
} else {
updateInterval = 15
}
} else {
// No duration info, use default fast interval
trackRemainingTime = 0
updateInterval = 10
}
} else {
// Not playing
trackRemainingTime = 0
updateInterval = 30
}
})
return unsubscribe
})
$effect(() => {
const unsubscribe = musicStream.nowPlaying.subscribe((np) => {
nowPlaying = np
})
return unsubscribe
})
// Calculate next update countdown
$effect(() => {
if (!lastUpdate) {
nextUpdateIn = updateInterval
return
}
// Calculate initial remaining time
const calculateRemaining = () => {
const elapsed = Date.now() - lastUpdate.getTime()
const remaining = (updateInterval * 1000) - elapsed
return Math.max(0, Math.ceil(remaining / 1000))
}
// Set initial value
nextUpdateIn = calculateRemaining()
// Update every second
const timer = setInterval(() => {
nextUpdateIn = calculateRemaining()
}, 1000)
return () => clearInterval(timer)
})
async function clearCache() {
if (!cacheKey.trim()) {
toast.error('Please enter a cache key')
return
}
isClearing = true
try {
const response = await fetch('/api/admin/debug/clear-cache', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: cacheKey })
})
if (response.ok) {
const result = await response.json()
toast.success(`Cleared cache: ${result.deleted} keys deleted`)
cacheKey = ''
} else {
toast.error('Failed to clear cache')
}
} catch (error) {
toast.error('Error clearing cache')
console.error(error)
} finally {
isClearing = false
}
}
async function clearAllMusicCache() {
isClearing = true
try {
const response = await fetch('/api/admin/debug/clear-cache', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pattern: 'apple:album:*' })
})
if (response.ok) {
const result = await response.json()
toast.success(`Cleared all music cache: ${result.deleted} keys deleted`)
} else {
toast.error('Failed to clear music cache')
}
} catch (error) {
toast.error('Error clearing music cache')
console.error(error)
} finally {
isClearing = false
}
}
async function clearAlbumCache(album: Album) {
const albumKey = `apple:album:${album.artist.name}:${album.name}`
const albumId = `${album.artist.name}:${album.name}`
clearingAlbums.add(albumId)
try {
const response = await fetch('/api/admin/debug/clear-cache', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: albumKey })
})
if (response.ok) {
const result = await response.json()
toast.success(`Cleared cache for "${album.name}"`)
} else {
toast.error(`Failed to clear cache for "${album.name}"`)
}
} catch (error) {
toast.error('Error clearing album cache')
console.error(error)
} finally {
clearingAlbums.delete(albumId)
clearingAlbums = clearingAlbums // Trigger reactivity
}
}
function formatTime(seconds: number): string {
if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
return `${minutes}m ${secs}s`
}
</script>
{#if dev}
<div class="debug-panel" class:minimized={isMinimized}>
<div class="debug-header" onclick={() => isMinimized = !isMinimized}>
<h3>Debug Panel</h3>
<button class="minimize-btn" aria-label={isMinimized ? 'Expand' : 'Minimize'}>
{isMinimized ? '▲' : '▼'}
</button>
</div>
{#if !isMinimized}
<div class="debug-content">
<div class="tabs">
<button
class="tab"
class:active={activeTab === 'nowplaying'}
onclick={() => activeTab = 'nowplaying'}
>
Now Playing
</button>
<button
class="tab"
class:active={activeTab === 'albums'}
onclick={() => activeTab = 'albums'}
>
Albums
</button>
<button
class="tab"
class:active={activeTab === 'cache'}
onclick={() => activeTab = 'cache'}
>
Cache
</button>
</div>
<div class="tab-content">
{#if activeTab === 'nowplaying'}
<div class="section">
<h4>Connection</h4>
<p class="status" class:connected>
Status: {#if connected}<CheckIcon class="icon status-icon success" /> Connected{:else}<XIcon class="icon status-icon error" /> Disconnected{/if}
</p>
<p class:flash={updateFlash}>
Last Update: {lastUpdate ? lastUpdate.toLocaleTimeString() : 'Never'}
</p>
<p>Next Update: {formatTime(nextUpdateIn)}</p>
<p>Interval: {updateInterval}s {trackRemainingTime > 0 ? `(smart mode)` : nowPlaying ? '(fast mode)' : '(normal)'}</p>
{#if trackRemainingTime > 0}
<p>Track Remaining: {formatTime(trackRemainingTime)}</p>
{/if}
</div>
<div class="section">
<h4>Now Playing</h4>
{#if nowPlaying}
<div class="now-playing-info">
<p class="artist">{nowPlaying.album.artist.name}</p>
<p class="album">{nowPlaying.album.name}</p>
{#if nowPlaying.track}
<p class="track">{nowPlaying.track}</p>
{/if}
{#if nowPlaying.album.appleMusicData}
<p class="preview">
<span>Preview:</span> {#if nowPlaying.album.appleMusicData.previewUrl}<CheckIcon class="icon success" /> Available{:else}<XIcon class="icon error" /> Not found{/if}
</p>
{/if}
</div>
{:else}
<p class="no-music">No music playing</p>
{/if}
</div>
{/if}
{#if activeTab === 'albums'}
<div class="section">
<h4>Recent Albums ({albums.length})</h4>
<div class="albums-list">
{#each albums as album}
{@const albumId = `${album.artist.name}:${album.name}`}
<div class="album-item" class:playing={album.isNowPlaying} class:expanded={expandedAlbumId === albumId}>
<div class="album-header" onclick={() => expandedAlbumId = expandedAlbumId === albumId ? null : albumId}>
<div class="album-content">
<div class="album-info">
<span class="name">{album.name}</span>
<span class="artist">by {album.artist.name}</span>
</div>
{#if album.isNowPlaying}
<span class="playing-badge">NOW</span>
{/if}
<div class="album-meta">
{#if album.appleMusicData}
<span class="meta-item">
{album.appleMusicData.tracks?.length || 0} tracks
</span>
<span class="meta-item">
{#if album.appleMusicData.previewUrl}<CheckIcon class="icon success inline" /> Preview{:else}<XIcon class="icon error inline" /> No preview{/if}
</span>
{:else}
<span class="meta-item">No Apple Music data</span>
{/if}
</div>
</div>
<button
class="clear-cache-btn"
onclick={(e) => { e.stopPropagation(); clearAlbumCache(album) }}
disabled={clearingAlbums.has(albumId)}
title="Clear Apple Music cache for this album"
>
{#if clearingAlbums.has(albumId)}
<LoaderIcon class="icon spinning" />
{:else}
<TrashIcon class="icon" />
{/if}
</button>
</div>
{#if expandedAlbumId === albumId}
<div class="album-details">
{#if album.appleMusicData}
{#if album.appleMusicData.searchMetadata}
<h5>Search Information</h5>
<div class="search-metadata">
<p><strong>Search Query:</strong> <code>{album.appleMusicData.searchMetadata.searchQuery}</code></p>
<p><strong>Search Time:</strong> {new Date(album.appleMusicData.searchMetadata.searchTime).toLocaleString()}</p>
<p><strong>Status:</strong>
{#if album.appleMusicData.searchMetadata.found}
<CheckIcon class="icon success inline" /> Found
{:else}
<XIcon class="icon error inline" /> Not Found
{/if}
</p>
{#if album.appleMusicData.searchMetadata.error}
<p><strong>Error:</strong> <span class="error-text">{album.appleMusicData.searchMetadata.error}</span></p>
{/if}
</div>
{/if}
{#if album.appleMusicData.appleMusicId}
<h5>Apple Music Details</h5>
<p><strong>Apple Music ID:</strong> {album.appleMusicData.appleMusicId}</p>
{/if}
{#if album.appleMusicData.releaseDate}
<p><strong>Release Date:</strong> {album.appleMusicData.releaseDate}</p>
{/if}
{#if album.appleMusicData.recordLabel}
<p><strong>Label:</strong> {album.appleMusicData.recordLabel}</p>
{/if}
{#if album.appleMusicData.genres?.length}
<p><strong>Genres:</strong> {album.appleMusicData.genres.join(', ')}</p>
{/if}
{#if album.appleMusicData.previewUrl}
<p><strong>Preview URL:</strong> <code>{album.appleMusicData.previewUrl}</code></p>
{/if}
{#if album.appleMusicData.tracks?.length}
<div class="tracks-section">
<h6>Tracks ({album.appleMusicData.tracks.length})</h6>
<div class="tracks-list">
{#each album.appleMusicData.tracks as track, i}
<div class="track-item">
<span class="track-number">{i + 1}.</span>
<span class="track-name">{track.name}</span>
{#if track.durationMs}
<span class="track-duration">{Math.floor(track.durationMs / 60000)}:{String(Math.floor((track.durationMs % 60000) / 1000)).padStart(2, '0')}</span>
{/if}
{#if track.previewUrl}
<CheckIcon class="icon success inline" title="Has preview" />
{/if}
</div>
{/each}
</div>
</div>
{/if}
<div class="raw-data">
<h6>Raw Data</h6>
<pre>{JSON.stringify(album.appleMusicData, null, 2)}</pre>
</div>
{:else}
<h5>No Apple Music Data</h5>
<p class="no-data">This album was not searched in Apple Music or the search is pending.</p>
{/if}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
{#if activeTab === 'cache'}
<div class="section">
<h4>Redis Cache Management</h4>
<div class="cache-controls">
<input
type="text"
bind:value={cacheKey}
placeholder="Cache key (e.g., apple:album:Artist:Album)"
disabled={isClearing}
/>
<button
onclick={clearCache}
disabled={isClearing || !cacheKey.trim()}
class="clear-btn"
>
{isClearing ? 'Clearing...' : 'Clear Key'}
</button>
</div>
<div class="cache-actions">
<button
onclick={clearAllMusicCache}
disabled={isClearing}
class="clear-all-btn"
>
{isClearing ? 'Clearing...' : 'Clear All Music Cache'}
</button>
<button
onclick={async () => {
isClearing = true
try {
const response = await fetch('/api/admin/debug/clear-cache', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pattern: 'notfound:apple-music:*' })
})
if (response.ok) {
const result = await response.json()
toast.success(`Cleared "not found" cache: ${result.deleted} keys deleted`)
} else {
toast.error('Failed to clear not found cache')
}
} catch (error) {
toast.error('Error clearing not found cache')
console.error(error)
} finally {
isClearing = false
}
}}
disabled={isClearing}
class="clear-not-found-btn"
>
{isClearing ? 'Clearing...' : 'Clear Not Found Cache'}
</button>
<button
onclick={() => searchModal?.open()}
class="search-btn"
>
Test Apple Music Search
</button>
</div>
<div class="cache-help">
<p>Key format: <code>apple:album:ArtistName:AlbumName</code></p>
<p>Example: <code>apple:album:藤井風:Hachikō</code></p>
</div>
</div>
{/if}
</div>
</div>
{/if}
</div>
<AppleMusicSearchModal bind:this={searchModal} />
{/if}
<style lang="scss">
.debug-panel {
position: fixed;
bottom: $unit * 2;
right: $unit * 2;
background: rgba(0, 0, 0, 0.95);
color: white;
border-radius: $unit;
width: 420px;
max-height: 600px;
z-index: 9999;
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
font-size: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.8);
backdrop-filter: blur(10px);
transition: all 0.2s ease;
&.minimized {
width: auto;
max-height: auto;
}
}
.debug-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: $unit * 1.5;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
user-select: none;
h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.minimize-btn {
background: none;
border: none;
color: white;
font-size: 12px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
}
.debug-content {
overflow-y: auto;
max-height: 520px;
}
.tabs {
display: flex;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
.tab {
flex: 1;
padding: $unit $unit * 2;
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.2s;
&:hover {
color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.05);
}
&.active {
color: white;
background: rgba(255, 255, 255, 0.1);
border-bottom: 2px solid $primary-color;
}
}
}
.tab-content {
padding: $unit * 2;
}
.section {
margin-bottom: $unit * 2;
&:last-child {
margin-bottom: 0;
}
h4 {
margin: 0 0 $unit 0;
color: #87ceeb;
font-size: 13px;
font-weight: 600;
}
p {
margin: $unit-half 0;
line-height: 1.4;
}
.connected {
color: #4caf50;
}
.flash {
animation: flash 0.5s ease-out;
}
}
.now-playing-info {
background: rgba(255, 255, 255, 0.05);
padding: $unit;
border-radius: 6px;
.artist {
font-weight: 600;
color: #ffd700;
}
.album {
color: #87ceeb;
}
.track {
font-size: 11px;
color: rgba(255, 255, 255, 0.8);
}
.preview {
font-size: 11px;
margin-top: $unit;
}
}
.no-music {
color: rgba(255, 255, 255, 0.5);
font-style: italic;
}
.albums-list {
display: flex;
flex-direction: column;
gap: $unit;
}
.album-item {
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
position: relative;
transition: all 0.2s;
&.playing {
background: rgba(76, 175, 80, 0.2);
border: 1px solid rgba(76, 175, 80, 0.5);
}
&.expanded {
background: rgba(255, 255, 255, 0.08);
}
.album-header {
padding: $unit;
display: flex;
align-items: center;
gap: $unit;
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.03);
}
}
.album-content {
flex: 1;
min-width: 0; // Allows text truncation
}
.album-info {
display: flex;
flex-direction: column;
gap: 2px;
.name {
font-weight: 600;
color: #87ceeb;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.artist {
font-size: 11px;
color: rgba(255, 255, 255, 0.7);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.playing-badge {
position: absolute;
top: $unit;
right: 40px; // Make room for the clear button
background: #4caf50;
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
}
.album-meta {
display: flex;
gap: $unit * 2;
margin-top: $unit-half;
.meta-item {
font-size: 10px;
color: rgba(255, 255, 255, 0.6);
}
}
.clear-cache-btn {
flex-shrink: 0;
width: 28px;
height: 28px;
padding: 0;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: white;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
&:hover:not(:disabled) {
background: rgba(255, 59, 48, 0.2);
border-color: rgba(255, 59, 48, 0.5);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.album-details {
padding: $unit * 2;
border-top: 1px solid rgba(255, 255, 255, 0.1);
h5 {
margin: 0 0 $unit 0;
color: #87ceeb;
font-size: 13px;
font-weight: 600;
}
h6 {
margin: $unit * 1.5 0 $unit 0;
color: #ffd700;
font-size: 12px;
font-weight: 600;
}
p {
margin: $unit-half 0;
font-size: 11px;
line-height: 1.5;
strong {
color: rgba(255, 255, 255, 0.8);
margin-right: $unit-half;
}
}
code {
background: rgba(255, 255, 255, 0.1);
padding: 2px 4px;
border-radius: 3px;
font-size: 10px;
word-break: break-all;
}
.tracks-section {
margin-top: $unit * 2;
}
.tracks-list {
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
padding: $unit;
max-height: 200px;
overflow-y: auto;
}
.track-item {
display: flex;
align-items: center;
gap: $unit;
padding: $unit-half 0;
font-size: 11px;
&:not(:last-child) {
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.track-number {
color: rgba(255, 255, 255, 0.5);
min-width: 20px;
}
.track-name {
flex: 1;
color: rgba(255, 255, 255, 0.9);
}
.track-duration {
color: rgba(255, 255, 255, 0.6);
font-size: 10px;
}
}
.raw-data {
margin-top: $unit * 2;
pre {
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
padding: $unit;
font-size: 10px;
overflow-x: auto;
max-height: 300px;
overflow-y: auto;
margin: 0;
}
}
.search-metadata {
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
padding: $unit;
margin-bottom: $unit * 2;
.error-text {
color: #ff6b6b;
}
}
.no-data {
color: rgba(255, 255, 255, 0.6);
font-style: italic;
}
}
}
.cache-controls {
display: flex;
gap: $unit;
margin-bottom: $unit * 2;
input {
flex: 1;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
padding: $unit;
border-radius: 4px;
font-size: 12px;
font-family: inherit;
&::placeholder {
color: rgba(255, 255, 255, 0.4);
}
&:focus {
outline: none;
border-color: $primary-color;
background: rgba(255, 255, 255, 0.15);
}
&:disabled {
opacity: 0.5;
}
}
.clear-btn {
padding: $unit $unit * 2;
background: $primary-color;
border: none;
color: white;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
background: darken($primary-color, 10%);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.cache-actions {
margin-bottom: $unit * 2;
display: flex;
flex-wrap: wrap;
gap: $unit;
.clear-all-btn, .clear-not-found-btn {
flex: 1;
min-width: 140px;
padding: $unit * 1.5;
background: rgba(255, 59, 48, 0.2);
border: 1px solid rgba(255, 59, 48, 0.5);
color: #ff6b6b;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
background: rgba(255, 59, 48, 0.3);
border-color: rgba(255, 59, 48, 0.7);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.clear-not-found-btn {
background: rgba(255, 149, 0, 0.2);
border-color: rgba(255, 149, 0, 0.5);
color: #ff9500;
&:hover:not(:disabled) {
background: rgba(255, 149, 0, 0.3);
border-color: rgba(255, 149, 0, 0.7);
}
}
.search-btn {
flex: 1;
min-width: 140px;
padding: $unit * 1.5;
background: rgba(135, 206, 235, 0.2);
border: 1px solid rgba(135, 206, 235, 0.5);
color: #87ceeb;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(135, 206, 235, 0.3);
border-color: rgba(135, 206, 235, 0.7);
}
}
}
.cache-help {
background: rgba(255, 255, 255, 0.05);
padding: $unit;
border-radius: 4px;
p {
margin: $unit-half 0;
font-size: 11px;
color: rgba(255, 255, 255, 0.7);
code {
background: rgba(255, 255, 255, 0.1);
padding: 2px 4px;
border-radius: 3px;
font-size: 10px;
}
}
}
@keyframes flash {
0% {
background: rgba(76, 175, 80, 0.5);
transform: scale(1.05);
}
100% {
background: transparent;
transform: scale(1);
}
}
.debug-panel :global(.icon) {
width: 14px;
height: 14px;
display: inline-block;
vertical-align: text-bottom;
&:global(.success) {
color: #4caf50;
}
&:global(.error) {
color: #ff6b6b;
}
&:global(.inline) {
width: 12px;
height: 12px;
}
&:global(.spinning) {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.debug-header h3 {
text-align: center;
flex: 1;
margin: 0;
}
.status {
display: flex;
align-items: center;
gap: 4px;
:global(.status-icon) {
margin-left: 4px;
}
}
.preview {
display: flex;
align-items: center;
gap: 4px;
:global(.icon) {
margin-left: 4px;
}
}
</style>