Files
RUWIO/src/components/AssetSidebar.vue
T
2026-04-22 11:53:41 +02:00

934 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
import {
PhCircle,
PhCursorClick,
PhFilmStrip,
PhImage,
PhImageSquare,
PhListBullets,
PhMusicNote,
PhPencilSimple,
PhPencilSimpleLine,
PhPlus,
PhPolygon,
PhSquare,
PhTextT,
PhTrash,
PhUploadSimple,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
import { importAudioFile, importImageFile, isAudioFile } from '@/lib/fileLoader'
import type { Asset } from '@/types/scene'
import { confirmDialog, promptDialog } from '@/components/ui'
import { loadSceneImages, renderButtonThumbnail } from '@/lib/render'
import { t } from '@/i18n'
const store = useSceneStore()
const fileInput = ref<HTMLInputElement | null>(null)
const uploadError = ref<string | null>(null)
const { enabled: showThumbs } = useAssetThumbsPref()
function addSampleRect() {
const asset = store.addAsset({
kind: 'rect',
name: 'Rect',
width: 160,
height: 100,
fill: '#6ea8ff',
})
store.addLayer(asset.id)
}
function addSampleCircle() {
const asset = store.addAsset({
kind: 'circle',
name: 'Circle',
radius: 60,
fill: '#ff7a59',
})
store.addLayer(asset.id)
}
function startFreeformDrawing() {
store.startFreeformDrawing()
}
async function addSamplePolygon() {
const input = await promptDialog({
title: t('sidebar.newPolygonDialog.title'),
message: t('sidebar.newPolygonDialog.message'),
defaultValue: '6',
confirmLabel: t('sidebar.newPolygonDialog.confirm'),
})
if (input === null) return
const parsed = parseInt(input.trim(), 10)
const sides = Number.isFinite(parsed) ? Math.max(3, Math.min(24, parsed)) : 6
const radius = 60
const points: { x: number; y: number }[] = []
for (let i = 0; i < sides; i++) {
const a = (i / sides) * Math.PI * 2 - Math.PI / 2
points.push({ x: Math.cos(a) * radius, y: Math.sin(a) * radius })
}
const asset = store.addAsset({
kind: 'polygon',
name: `Polygon ${sides}`,
points,
fill: '#9b7ede',
})
store.addLayer(asset.id)
}
async function addSampleText() {
const input = await promptDialog({
title: t('sidebar.newTextDialog.title'),
message: t('sidebar.newTextDialog.message'),
defaultValue: t('sidebar.newTextDialog.default'),
confirmLabel: t('sidebar.newTextDialog.confirm'),
})
if (input === null) return
const text = input.trim() || t('sidebar.newTextDialog.fallback')
const asset = store.addAsset({
kind: 'text',
name: text.length > 24 ? text.slice(0, 24) + '…' : text,
text,
fontSize: 48,
fill: '#f5d76e',
})
store.addLayer(asset.id)
}
async function addButton() {
const nameInput = await promptDialog({
title: t('sidebar.newButtonDialog.title'),
message: t('sidebar.newButtonDialog.nameMessage'),
defaultValue: t('sidebar.newButtonDialog.defaultName'),
confirmLabel: t('sidebar.newButtonDialog.next'),
})
if (nameInput === null) return
const sizeInput = await promptDialog({
title: t('sidebar.newButtonDialog.title'),
message: t('sidebar.newButtonDialog.sizeMessage'),
defaultValue: '200 × 80',
confirmLabel: t('sidebar.newButtonDialog.confirm'),
})
if (sizeInput === null) return
const m = sizeInput.match(/(\d+)\s*[×x*]\s*(\d+)/)
const w = m ? parseInt(m[1], 10) : 200
const h = m ? parseInt(m[2], 10) : 80
const asset = store.addButtonAsset(nameInput || 'Button', w, h)
store.enterButtonAssetEdit(asset.id)
}
const clips = computed(() => store.scene.clips ?? [])
const buttonAssets = computed(() =>
store.scene.assets.filter((a): a is Extract<Asset, { kind: 'button' }> => a.kind === 'button'),
)
const buttonThumbs = ref<Record<string, string | null>>({})
const sceneImageCache = ref<Record<string, HTMLImageElement>>({})
// Keep scene image cache in sync with the current scene's image assets so
// button thumbnails that reference images actually render them instead of
// producing a transparent canvas.
watchEffect(async (onCleanup) => {
let cancelled = false
onCleanup(() => {
cancelled = true
})
// Track changes to image assets: any add/remove/src change triggers reload.
const imgSrcs = store.scene.assets
.filter((a): a is Extract<Asset, { kind: 'image' }> => a.kind === 'image')
.map((a) => `${a.id}:${a.src}`)
.join('|')
void imgSrcs
const imgs = await loadSceneImages(store.scene)
if (!cancelled) sceneImageCache.value = imgs
})
watchEffect(() => {
if (!showThumbs.value) { buttonThumbs.value = {}; return }
const out: Record<string, string | null> = {}
for (const ba of buttonAssets.value) {
// Access layers deeply so Vue tracks changes inside the button asset
for (const l of ba.layers) {
void l.keyframes.length
void l.visible
}
out[ba.id] = renderButtonThumbnail(ba, store.scene, 80, sceneImageCache.value)
}
buttonThumbs.value = out
})
function onButtonAssetDragStart(e: DragEvent, assetId: string) {
if (!e.dataTransfer) return
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData('application/x-animatr-asset', assetId)
e.dataTransfer.setData('text/plain', assetId)
}
async function renameButtonAssetPrompt(id: string, current: string) {
const input = await promptDialog({
title: t('sidebar.renameButtonDialog.title'),
defaultValue: current,
confirmLabel: t('sidebar.renameButtonDialog.confirm'),
})
if (input === null) return
const asset = store.scene.assets.find((a) => a.id === id)
if (asset && asset.kind === 'button') asset.name = input.trim() || asset.name
}
async function deleteButtonAsset(id: string) {
const ok = await confirmDialog({
title: t('sidebar.deleteButtonDialog.title'),
message: t('sidebar.deleteButtonDialog.message'),
confirmLabel: t('sidebar.deleteButtonDialog.confirm'),
variant: 'danger',
})
if (!ok) return
store.removeAsset(id)
if (store.activeButtonAssetId === id) store.exitButtonAssetEdit()
}
async function addClip() {
const input = await promptDialog({
title: t('sidebar.newClipDialog.title'),
message: t('sidebar.newClipDialog.message'),
defaultValue: t('sidebar.newClipDialog.default'),
confirmLabel: t('sidebar.newClipDialog.confirm'),
})
if (input === null) return
store.addClip(input)
}
async function renameClipPrompt(clipId: string, current: string) {
const input = await promptDialog({
title: t('sidebar.renameClipDialog.title'),
defaultValue: current,
confirmLabel: t('sidebar.renameClipDialog.confirm'),
})
if (input === null) return
store.renameClip(clipId, input)
}
async function deleteClip(clipId: string) {
const ok = await confirmDialog({
title: t('sidebar.deleteClipDialog.title'),
message: t('sidebar.deleteClipDialog.message'),
confirmLabel: t('sidebar.deleteClipDialog.confirm'),
variant: 'danger',
})
if (!ok) return
store.removeClip(clipId)
}
function onClipDragStart(e: DragEvent, clipId: string) {
if (!e.dataTransfer) return
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData('application/x-animatr-clip', clipId)
e.dataTransfer.setData('text/plain', clipId)
}
async function handleFileSelected(event: Event) {
uploadError.value = null
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
try {
for (const file of files) await importFile(file)
} catch (err) {
uploadError.value = err instanceof Error ? err.message : String(err)
} finally {
if (fileInput.value) fileInput.value.value = ''
}
}
async function importFile(file: File) {
if (isAudioFile(file)) {
await importAudioFile(store, file)
return
}
await importImageFile(store, file)
}
// ---- OS-file drop target ----
const dropHot = ref(false)
// The browser fires dragleave on child transitions as well — debounce
// by tracking enter depth so the outline doesn't flicker mid-drop.
let dragDepth = 0
function panelAcceptsDrop(e: DragEvent): boolean {
if (store.activeButtonEditMode === 'hit') return false
const dt = e.dataTransfer
if (!dt) return false
// Only activate for real filesystem drops — internal Animatr DnD
// (clips, audio assets, layer references) has its own handlers.
return Array.from(dt.types).includes('Files')
}
function onPanelDragOver(e: DragEvent) {
if (!panelAcceptsDrop(e)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
dragDepth++
dropHot.value = true
}
function onPanelDragLeave() {
dragDepth = Math.max(0, dragDepth - 1)
if (dragDepth === 0) dropHot.value = false
}
async function onPanelDrop(e: DragEvent) {
if (!panelAcceptsDrop(e)) return
e.preventDefault()
dragDepth = 0
dropHot.value = false
const files = Array.from(e.dataTransfer?.files ?? [])
if (!files.length) return
uploadError.value = null
try {
for (const file of files) await importFile(file)
} catch (err) {
uploadError.value = err instanceof Error ? err.message : String(err)
}
}
function triggerUpload() {
fileInput.value?.click()
}
const assetIconMap: Record<Asset['kind'], typeof PhSquare> = {
rect: PhSquare,
circle: PhCircle,
image: PhImage,
text: PhTextT,
polygon: PhPolygon,
button: PhCursorClick,
audio: PhMusicNote,
}
const imageAssets = computed(() =>
store.scene.assets.filter((a): a is Extract<Asset, { kind: 'image' }> => a.kind === 'image'),
)
const audioAssets = computed(() =>
store.scene.assets.filter((a): a is Extract<Asset, { kind: 'audio' }> => a.kind === 'audio'),
)
function onAudioAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'audio' }>) {
if (!e.dataTransfer) return
e.dataTransfer.effectAllowed = 'copy'
// Matches Timeline's AUDIO_CLIP_DND_MIME — dropping on an audio lane
// creates a new AudioClip referencing this asset.
e.dataTransfer.setData('application/x-animatr-audio-asset', asset.id)
e.dataTransfer.setData('text/plain', asset.name)
}
async function deleteAudioAsset(id: string) {
const asset = audioAssets.value.find((a) => a.id === id)
if (!asset) return
const ok = await confirmDialog({
title: t('sidebar.deleteAudioDialog.title'),
message: t('sidebar.deleteAudioDialog.message', { name: asset.name }),
confirmLabel: t('sidebar.deleteAudioDialog.confirm'),
variant: 'danger',
})
if (!ok) return
store.removeAsset(id)
}
function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>) {
if (!e.dataTransfer) return
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData('application/x-animatr-asset', asset.id)
e.dataTransfer.setData('text/plain', asset.id)
// Use the actual graphic as drag ghost instead of the library entry.
const target = e.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
const thumbImg = target.querySelector('img') as HTMLImageElement | null
if (thumbImg && thumbImg.complete && thumbImg.naturalWidth > 0) {
const r = thumbImg.getBoundingClientRect()
const ox = Math.max(0, e.clientX - r.left)
const oy = Math.max(0, e.clientY - r.top)
e.dataTransfer.setDragImage(thumbImg, ox, oy)
return
}
// Fallback — create an offscreen image of the asset for use as drag ghost.
const ghost = document.createElement('img')
ghost.src = asset.src
ghost.style.cssText =
'position:fixed;top:-10000px;left:-10000px;width:72px;height:auto;pointer-events:none;'
document.body.appendChild(ghost)
const gx = Math.max(0, e.clientX - rect.left)
const gy = Math.max(0, e.clientY - rect.top)
e.dataTransfer.setDragImage(ghost, gx, gy)
setTimeout(() => {
ghost.remove()
}, 0)
}
</script>
<template>
<div
class="panel"
:class="{ 'hit-disabled': store.activeButtonEditMode === 'hit', 'drop-hot': dropHot }"
v-tooltip="store.activeButtonEditMode === 'hit' ? t('sidebar.hitModeDisabled') : undefined"
@dragover="onPanelDragOver"
@dragleave="onPanelDragLeave"
@drop="onPanelDrop"
>
<h3>Assets</h3>
<button class="upload-btn" @click="triggerUpload">
<PhUploadSimple :size="16" />
<span>{{ t('sidebar.addFile') }}</span>
</button>
<div class="actions">
<button class="shape-btn" v-tooltip="t('sidebar.shape.rect')" @click="addSampleRect">
<PhSquare :size="16" />
</button>
<button class="shape-btn" v-tooltip="t('sidebar.shape.circle')" @click="addSampleCircle">
<PhCircle :size="16" />
</button>
<button class="shape-btn" v-tooltip="t('sidebar.shape.text')" @click="addSampleText">
<PhTextT :size="16" />
</button>
<button class="shape-btn" v-tooltip="t('sidebar.shape.polygon')" @click="addSamplePolygon">
<PhPolygon :size="16" />
</button>
<button
class="shape-btn"
:class="{ active: store.drawingMode === 'freeform' }"
v-tooltip="store.drawingMode === 'freeform' ? t('sidebar.shape.freeformActive') : t('sidebar.shape.freeform')"
@click="startFreeformDrawing"
>
<PhPencilSimpleLine :size="16" />
</button>
<button class="shape-btn" v-tooltip="t('sidebar.shape.button')" @click="addButton">
<PhCursorClick :size="16" />
</button>
</div>
<input
ref="fileInput"
type="file"
accept="image/svg+xml,image/png,image/jpeg,image/webp,image/gif,audio/*,.mp3,.wav,.ogg,.m4a"
multiple
style="display: none"
@change="handleFileSelected"
/>
<p v-if="uploadError" class="error">{{ uploadError }}</p>
<div class="lib-header">
<h4>{{ t('sidebar.library') }}</h4>
<button
class="thumb-toggle"
v-tooltip="showThumbs ? t('sidebar.thumbnailsHide') : t('sidebar.thumbnailsShow')"
@click="showThumbs = !showThumbs"
>
<PhImageSquare v-if="showThumbs" :size="13" />
<PhListBullets v-else :size="13" />
</button>
</div>
<ul class="assets" :class="{ compact: !showThumbs }">
<li
v-for="asset in imageAssets"
:key="asset.id"
draggable="true"
@dragstart="onAssetDragStart($event, asset)"
>
<template v-if="showThumbs">
<div class="thumb">
<img :src="asset.src" :alt="asset.name" draggable="false" />
</div>
</template>
<template v-else>
<component :is="assetIconMap[asset.kind]" :size="14" class="asset-icon" />
</template>
<div class="meta">
<span class="name" v-tooltip="asset.name">{{ asset.name }}</span>
<span v-if="showThumbs" class="sub">{{ t('sidebar.imageLabel') }}</span>
</div>
<button
class="add-layer"
v-tooltip="t('sidebar.addLayerTooltip')"
@click="store.addLayer(asset.id)"
>
<PhPlus :size="12" weight="bold" />
</button>
</li>
<li v-if="!imageAssets.length" class="empty">{{ t('sidebar.noImages') }}</li>
</ul>
<div class="lib-header clips-header">
<h4>{{ t('sidebar.clips') }}</h4>
<button class="thumb-toggle" v-tooltip="t('sidebar.createClipTooltip')" @click="addClip">
<PhPlus :size="12" weight="bold" />
</button>
</div>
<ul class="clips">
<li
v-for="clip in clips"
:key="clip.id"
class="clip"
:class="{ editing: store.activeClipId === clip.id }"
draggable="true"
@dragstart="onClipDragStart($event, clip.id)"
@dblclick="store.enterClip(clip.id)"
>
<div class="clip-icon">
<PhFilmStrip :size="16" />
</div>
<div class="clip-meta">
<span class="clip-name" v-tooltip="clip.name">{{ clip.name }}</span>
<span class="clip-sub">
{{ t('sidebar.clipLayerCount', { n: clip.layers.length }) }} · {{ clip.duration.toFixed(1) }}s
</span>
</div>
<div class="clip-actions">
<button
class="clip-btn"
v-tooltip="t('sidebar.renameTooltip')"
@click.stop="renameClipPrompt(clip.id, clip.name)"
>
<PhPencilSimple :size="11" />
</button>
<button class="clip-btn danger" v-tooltip="t('sidebar.deleteTooltip')" @click.stop="deleteClip(clip.id)">
<PhTrash :size="11" />
</button>
</div>
</li>
<li v-if="!clips.length" class="empty">{{ t('sidebar.noClips') }}</li>
</ul>
<div class="lib-header clips-header">
<h4>{{ t('sidebar.buttons') }}</h4>
<button class="thumb-toggle" v-tooltip="t('sidebar.createButtonTooltip')" @click="addButton">
<PhPlus :size="12" weight="bold" />
</button>
</div>
<ul class="clips" :class="{ compact: !showThumbs }">
<li
v-for="ba in buttonAssets"
:key="ba.id"
class="clip button-asset"
:class="{ editing: store.activeButtonAssetId === ba.id }"
draggable="true"
@dragstart="onButtonAssetDragStart($event, ba.id)"
@dblclick="store.enterButtonAssetEdit(ba.id)"
>
<template v-if="showThumbs && buttonThumbs[ba.id]">
<div class="thumb">
<img :src="buttonThumbs[ba.id]!" :alt="ba.name" draggable="false" />
</div>
</template>
<template v-else-if="showThumbs">
<div class="clip-icon">
<PhCursorClick :size="16" />
</div>
</template>
<template v-else>
<PhCursorClick :size="14" class="asset-icon" />
</template>
<div class="clip-meta">
<span class="clip-name" v-tooltip="ba.name">{{ ba.name }}</span>
<span v-if="showThumbs" class="clip-sub">
{{ ba.canvas.width }} × {{ ba.canvas.height }}
</span>
</div>
<div class="clip-actions">
<button
class="clip-btn"
v-tooltip="t('sidebar.renameTooltip')"
@click.stop="renameButtonAssetPrompt(ba.id, ba.name)"
>
<PhPencilSimple :size="11" />
</button>
<button
class="clip-btn danger"
v-tooltip="t('sidebar.deleteTooltip')"
@click.stop="deleteButtonAsset(ba.id)"
>
<PhTrash :size="11" />
</button>
</div>
</li>
<li v-if="!buttonAssets.length" class="empty">{{ t('sidebar.noButtons') }}</li>
</ul>
<div class="lib-header clips-header">
<h4>{{ t('sidebar.audio') }}</h4>
</div>
<ul class="clips">
<li
v-for="asset in audioAssets"
:key="asset.id"
class="clip audio-asset"
draggable="true"
@dragstart="onAudioAssetDragStart($event, asset)"
>
<div class="clip-icon">
<PhMusicNote :size="16" />
</div>
<div class="clip-meta">
<span class="clip-name" v-tooltip="asset.name">{{ asset.name }}</span>
<span class="clip-sub">{{ asset.duration.toFixed(2) }}s</span>
</div>
<div class="clip-actions">
<button
class="clip-btn danger"
v-tooltip="t('sidebar.deleteTooltip')"
@click.stop="deleteAudioAsset(asset.id)"
>
<PhTrash :size="11" />
</button>
</div>
</li>
<li v-if="!audioAssets.length" class="empty">{{ t('sidebar.noAudio') }}</li>
</ul>
</div>
</template>
<style scoped>
.panel {
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.panel.hit-disabled {
opacity: 0.4;
pointer-events: none;
filter: saturate(0.5);
}
.panel.drop-hot {
outline: 2px dashed var(--accent);
outline-offset: -6px;
}
h3 {
margin: 0 0 2px;
font-size: 13px;
font-weight: 600;
}
h4 {
margin: 0;
font-size: 10px;
text-transform: uppercase;
color: var(--text-dim);
letter-spacing: 0.08em;
font-weight: 500;
}
.upload-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 12px;
background: var(--accent-soft);
border: 1px dashed var(--accent);
color: var(--accent);
border-radius: var(--radius);
font-size: 12px;
font-weight: 500;
}
.upload-btn:hover:not(:disabled) {
background: var(--accent-soft-strong);
}
.actions {
display: flex;
gap: 6px;
}
.shape-btn {
flex: 1;
justify-content: center;
align-items: center;
padding: 6px 0;
font-size: 12px;
}
.shape-btn.active {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent);
}
.error {
color: var(--danger);
font-size: 11px;
margin: 2px 0 0;
padding: 6px 8px;
background: var(--danger-soft);
border-radius: var(--radius-sm);
}
.lib-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 6px;
}
.thumb-toggle {
width: 22px;
height: 22px;
padding: 0;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
border-radius: var(--radius-sm);
}
.thumb-toggle:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.assets {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.assets li {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: var(--radius-sm);
background: var(--panel-alt);
font-size: 12px;
transition: background 140ms var(--ease);
}
.assets li:hover {
background: var(--panel-hover);
}
.assets li.unused {
opacity: 0.9;
}
.assets.compact li {
gap: 8px;
padding: 6px 8px;
}
.thumb {
width: 40px;
height: 40px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.thumb img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
pointer-events: none;
}
.shape-preview {
display: block;
}
.text-preview {
font-weight: 600;
font-size: 18px;
line-height: 1;
letter-spacing: -0.02em;
}
.asset-icon {
color: var(--text-dim);
flex-shrink: 0;
}
.meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub {
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.add-layer {
width: 22px;
height: 22px;
padding: 0;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
flex-shrink: 0;
}
.add-layer:hover:not(:disabled) {
color: var(--accent);
border-color: var(--accent);
}
.empty {
background: transparent !important;
color: var(--text-dim);
font-style: italic;
justify-content: center;
}
.clips-header {
margin-top: 14px;
}
.clips {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.clip {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: grab;
transition: background 140ms var(--ease), border-color 140ms var(--ease);
}
.clip:hover {
background: var(--panel-hover);
border-color: var(--border-strong);
}
/* In compact (no-thumbnail) mode, buttons match library entry height
exactly by dropping the 1px border (same as .assets li). */
.clips.compact .clip {
border: 0;
gap: 10px;
}
.clips.compact .clip.editing {
background: var(--accent-soft);
}
.clip:active {
cursor: grabbing;
}
.clip.editing {
border-color: var(--accent);
background: var(--accent-soft);
}
.clip-icon {
width: 26px;
height: 26px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
background: var(--bg);
border-radius: var(--radius-sm);
}
.clip-meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.clip-name {
font-size: 12px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.clip-sub {
font-size: 10px;
color: var(--text-dim);
}
.clip-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 140ms var(--ease);
}
.clip:hover .clip-actions,
.clip.editing .clip-actions {
opacity: 1;
}
.clip-btn {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
border-radius: 4px;
}
.clip-btn:hover {
background: var(--panel);
color: var(--text);
}
.clip-btn.danger:hover {
background: var(--danger-soft);
color: var(--danger);
border-color: var(--danger);
}
</style>