Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
934 lines
24 KiB
Vue
934 lines
24 KiB
Vue
<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>
|