Files
RUWIO/src/components/AssetSidebar.vue
T
2026-04-19 22:32:06 +02:00

610 lines
13 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
PhCircle,
PhFilmStrip,
PhImage,
PhImageSquare,
PhListBullets,
PhPencilSimple,
PhPlus,
PhSquare,
PhTextT,
PhTrash,
PhUploadSimple,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { isSvgFile, loadImage, parseSvgDimensions, readAsDataUrl } from '@/lib/fileLoader'
import type { Asset } from '@/types/scene'
import { confirmDialog, promptDialog } from '@/components/ui'
import { t } from '@/i18n'
const store = useSceneStore()
const fileInput = ref<HTMLInputElement | null>(null)
const uploadError = ref<string | null>(null)
const STORAGE_KEY = 'animatr.assetThumbs'
const storedThumbs = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null
const showThumbs = ref(storedThumbs !== 'false')
watch(showThumbs, (v) => {
try {
localStorage.setItem(STORAGE_KEY, String(v))
} catch {
/* ignore */
}
})
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)
}
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)
}
const clips = computed(() => store.scene.clips ?? [])
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) {
const dataUrl = await readAsDataUrl(file)
let width = 0
let height = 0
if (isSvgFile(file)) {
const text = await file.text()
const dims = parseSvgDimensions(text)
if (dims) {
width = dims.width
height = dims.height
}
}
if (!width || !height) {
const img = await loadImage(dataUrl)
width = img.naturalWidth || 300
height = img.naturalHeight || 300
}
const asset = store.addAsset({
kind: 'image',
name: file.name,
src: dataUrl,
width,
height,
})
store.addLayer(asset.id)
}
function triggerUpload() {
fileInput.value?.click()
}
const assetIconMap: Record<Asset['kind'], typeof PhSquare> = {
rect: PhSquare,
circle: PhCircle,
image: PhImage,
text: PhTextT,
}
const imageAssets = computed(() =>
store.scene.assets.filter((a): a is Extract<Asset, { kind: 'image' }> => a.kind === 'image'),
)
function onAssetDragStart(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)
}
</script>
<template>
<div class="panel">
<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" @click="addSampleRect">
<PhSquare :size="14" />
{{ t('sidebar.shape.rect') }}
</button>
<button class="shape-btn" @click="addSampleCircle">
<PhCircle :size="14" />
{{ t('sidebar.shape.circle') }}
</button>
<button class="shape-btn" @click="addSampleText">
<PhTextT :size="14" />
{{ t('sidebar.shape.text') }}
</button>
</div>
<input
ref="fileInput"
type="file"
accept="image/svg+xml,image/png,image/jpeg,image/webp,image/gif"
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"
:title="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.id)"
>
<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" :title="asset.name">{{ asset.name }}</span>
<span v-if="showThumbs" class="sub">{{ t('sidebar.imageLabel') }}</span>
</div>
<button
class="add-layer"
:title="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" :title="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" :title="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"
:title="t('sidebar.renameTooltip')"
@click.stop="renameClipPrompt(clip.id, clip.name)"
>
<PhPencilSimple :size="11" />
</button>
<button class="clip-btn danger" :title="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>
</template>
<style scoped>
.panel {
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
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;
font-size: 12px;
}
.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);
}
.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>