Files
RUWIO/src/App.vue
T
2026-04-19 21:17:07 +02:00

501 lines
12 KiB
Vue

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import {
PhArrowClockwise,
PhArrowCounterClockwise,
PhArrowLeft,
PhExport,
PhFilmStrip,
PhFolderOpen,
PhFloppyDisk,
PhGearSix,
PhPause,
PhPlay,
PhSkipBack,
PhSparkle,
PhStop,
} from '@phosphor-icons/vue'
import AssetSidebar from './components/AssetSidebar.vue'
import CanvasStage from './components/CanvasStage.vue'
import DocumentSettings from './components/DocumentSettings.vue'
import ExportDialog from './components/ExportDialog.vue'
import FullscreenPreview from './components/FullscreenPreview.vue'
import PropertiesPanel from './components/PropertiesPanel.vue'
import Timeline from './components/Timeline.vue'
import WelcomeDialog from './components/WelcomeDialog.vue'
import TestOverlay from './devtools/TestOverlay.vue'
import { IconButton, Toast } from './components/ui'
import { useHistory } from './composables/useHistory'
import { usePlaybackLoop } from './composables/usePlaybackLoop'
import {
ProjectFileError,
downloadProject,
pickProjectFile,
readProjectFile,
} from './lib/projectFile'
import {
getLastOpenedId,
listRecentProjects,
saveProjectToStore,
setLastOpenedId,
type StoredProject,
} from './lib/projectStore'
import { useSceneStore } from './stores/scene'
const settingsOpen = ref(false)
const exportOpen = ref(false)
const testsOpen = ref(false)
const welcomeOpen = ref(false)
const fullscreenOpen = ref(false)
const loadError = ref<string | null>(null)
const savedToast = ref<string | null>(null)
const currentProjectId = ref<string | null>(null)
const currentProjectName = ref<string | null>(null)
usePlaybackLoop()
const store = useSceneStore()
const history = useHistory()
function rewind() {
store.setPlayhead(0)
}
function stop() {
store.pause()
}
async function saveProject() {
const scene = JSON.parse(JSON.stringify(store.scene))
let name = currentProjectName.value
if (!name) {
const suggested = `Projekt ${new Date().toLocaleDateString()}`
const entered = prompt('Projektname?', suggested)
if (entered === null) return
name = entered.trim() || suggested
}
const rec = await saveProjectToStore(scene, {
id: currentProjectId.value ?? undefined,
name,
})
currentProjectId.value = rec.id
currentProjectName.value = rec.name
setLastOpenedId(rec.id)
showSavedToast(`"${rec.name}" gespeichert`)
}
function exportProjectFile() {
const scene = JSON.parse(JSON.stringify(store.scene))
const base = currentProjectName.value ?? 'animatr'
const safe = base.replace(/[^a-z0-9äöüß_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'animatr'
downloadProject(scene, `${safe}.json`)
}
async function importFromFile() {
const file = await pickProjectFile()
if (!file) return
const hasContent = store.scene.layers.length > 0 || store.scene.assets.length > 0
if (hasContent) {
const ok = confirm(
'Aktuelles Projekt wird durch die geladene Datei ersetzt. Ungespeicherte Änderungen gehen verloren. Fortfahren?',
)
if (!ok) return
}
try {
const scene = await readProjectFile(file)
store.loadScene(scene)
history.resetTo(scene)
currentProjectId.value = null
currentProjectName.value = file.name.replace(/\.[^.]+$/, '')
loadError.value = null
} catch (err) {
const msg =
err instanceof ProjectFileError ? err.message : (err as Error).message || String(err)
loadError.value = msg
setTimeout(() => {
loadError.value = null
}, 6000)
}
}
function openFromRecent(project: StoredProject) {
const cloned = JSON.parse(JSON.stringify(project.scene))
store.loadScene(cloned)
history.resetTo(cloned)
currentProjectId.value = project.id
currentProjectName.value = project.name
setLastOpenedId(project.id)
loadError.value = null
}
function showSavedToast(msg: string) {
savedToast.value = msg
setTimeout(() => {
if (savedToast.value === msg) savedToast.value = null
}, 2500)
}
async function bootWelcome() {
const recents = await listRecentProjects()
if (recents.length > 0) {
welcomeOpen.value = true
const lastId = getLastOpenedId()
if (lastId) {
const last = recents.find((p) => p.id === lastId)
if (last) {
currentProjectName.value = last.name
}
}
}
}
function isEditable(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false
const tag = el.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
if (el.isContentEditable) return true
return false
}
function onKeyDown(e: KeyboardEvent) {
if (e.ctrlKey && e.shiftKey && e.altKey && (e.code === 'KeyT' || e.key.toLowerCase() === 't')) {
e.preventDefault()
testsOpen.value = true
return
}
const mod = e.ctrlKey || e.metaKey
if (mod && !isEditable(e.target)) {
const key = e.key.toLowerCase()
if (key === 'z' && !e.shiftKey) {
e.preventDefault()
history.undo()
return
}
if ((key === 'z' && e.shiftKey) || key === 'y') {
e.preventDefault()
history.redo()
return
}
}
if (e.code !== 'Space') return
if (isEditable(e.target)) return
e.preventDefault()
if (e.shiftKey) {
store.pause()
fullscreenOpen.value = true
return
}
store.togglePlay()
}
onMounted(() => {
document.addEventListener('keydown', onKeyDown)
bootWelcome()
})
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
</script>
<template>
<div class="app">
<header class="topbar">
<div class="brand-area">
<span class="brand">
<PhSparkle :size="16" weight="fill" class="brand-icon" />
Animatr
</span>
</div>
<div class="transport">
<IconButton
:icon="PhArrowCounterClockwise"
icon-weight="bold"
size="md"
:disabled="!history.canUndo.value"
title="Rückgängig (Ctrl+Z)"
@click="history.undo"
/>
<IconButton
:icon="PhArrowClockwise"
icon-weight="bold"
size="md"
:disabled="!history.canRedo.value"
title="Wiederholen (Ctrl+Shift+Z)"
@click="history.redo"
/>
<div class="tsep"></div>
<IconButton
:icon="PhSkipBack"
icon-weight="fill"
size="md"
title="Zum Anfang"
@click="rewind"
/>
<IconButton
:icon="store.playing ? PhPause : PhPlay"
icon-weight="fill"
size="md"
variant="primary"
:title="store.playing ? 'Pause (Space)' : 'Play (Space)'"
@click="store.togglePlay()"
/>
<IconButton
:icon="PhStop"
icon-weight="fill"
size="md"
title="Stop"
@click="stop"
/>
<span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration }}s</span>
</div>
<div class="topbar-right">
<span v-if="currentProjectName" class="project-name" :title="currentProjectName">
{{ currentProjectName }}
</span>
<button class="icon-btn" title="Zuletzt verwendete Projekte" @click="welcomeOpen = true">
<PhFolderOpen :size="16" />
</button>
<button class="icon-btn" title="Projekt speichern (in Browser-Speicher)" @click="saveProject">
<PhFloppyDisk :size="16" />
</button>
<button class="icon-btn" title="Document Settings" @click="settingsOpen = true">
<PhGearSix :size="16" />
</button>
<button class="export-btn" title="Exportieren" @click="exportOpen = true">
<PhExport :size="14" weight="bold" />
Export
</button>
</div>
</header>
<aside class="sidebar">
<div class="sidebar-scroll"><AssetSidebar /></div>
<div v-if="store.activeClipId" class="scope-footer">
<button class="scope-back" @click="store.exitClip">
<PhArrowLeft :size="12" weight="bold" />
Haupt-Timeline
</button>
<span class="scope-current" :title="store.activeClip?.name ?? ''">
<PhFilmStrip :size="12" />
{{ store.activeClip?.name ?? '' }}
</span>
</div>
</aside>
<main class="stage"><CanvasStage /></main>
<aside class="properties"><PropertiesPanel /></aside>
<footer class="timeline"><Timeline /></footer>
<DocumentSettings v-model="settingsOpen" />
<ExportDialog v-model="exportOpen" />
<TestOverlay v-model="testsOpen" />
<FullscreenPreview v-model="fullscreenOpen" />
<WelcomeDialog
v-model="welcomeOpen"
@open="openFromRecent"
@new="() => { currentProjectId = null; currentProjectName = null }"
@import="importFromFile"
@export="exportProjectFile"
/>
<Toast :visible="!!loadError" variant="error">
<strong>Projekt konnte nicht geladen werden.</strong>
<span>{{ loadError }}</span>
</Toast>
<Toast :visible="!!savedToast" variant="info" :message="savedToast ?? ''" />
</div>
</template>
<style scoped>
.app {
display: grid;
grid-template-columns: 260px 1fr 300px;
grid-template-rows: 48px 1fr 220px;
grid-template-areas:
'top top top'
'side stage props'
'time time time';
height: 100vh;
width: 100vw;
}
.topbar {
grid-area: top;
display: flex;
align-items: center;
padding: 0 16px;
background: var(--panel);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow-sm);
}
.brand-area {
flex: 1 1 0;
display: flex;
gap: 10px;
align-items: center;
min-width: 0;
}
.brand {
font-weight: 600;
font-size: 14px;
letter-spacing: -0.01em;
display: inline-flex;
align-items: center;
gap: 6px;
}
.brand-icon {
color: var(--accent);
}
.transport {
display: flex;
gap: 4px;
align-items: center;
}
.tsep {
width: 1px;
height: 18px;
background: var(--border);
margin: 0 4px;
}
.time {
margin-left: 10px;
font-variant-numeric: tabular-nums;
color: var(--text-dim);
font-size: 11px;
min-width: 92px;
text-align: center;
}
.topbar-right {
flex: 1 1 0;
display: flex;
justify-content: flex-end;
gap: 6px;
}
.export-btn {
padding: 6px 12px;
border: 1px solid var(--accent);
color: var(--accent);
background: var(--accent-soft);
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 500;
}
.export-btn:hover:not(:disabled) {
background: var(--accent-soft-strong);
}
.icon-btn {
width: 30px;
height: 30px;
padding: 0;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
}
.icon-btn:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.sidebar {
grid-area: side;
background: var(--panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.sidebar-scroll {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
}
.scope-footer {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-top: 1px solid var(--border-strong);
background: var(--accent-soft);
color: var(--accent);
font-size: 11px;
min-width: 0;
}
.scope-back {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-dim-strong);
font-size: 11px;
flex-shrink: 0;
}
.scope-back:hover {
background: var(--panel-alt);
color: var(--text);
border-color: var(--border-strong);
}
.scope-current {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 500;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 0;
}
.stage {
grid-area: stage;
background: var(--bg);
overflow: hidden;
min-width: 0;
min-height: 0;
}
.properties {
grid-area: props;
background: var(--panel);
border-left: 1px solid var(--border);
overflow: auto;
}
.timeline {
grid-area: time;
background: var(--panel);
border-top: 1px solid var(--border);
overflow: hidden;
}
.project-name {
display: inline-flex;
align-items: center;
max-width: 220px;
padding: 4px 10px;
font-size: 11px;
color: var(--text-dim-strong);
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 4px;
}
</style>