This commit is contained in:
th committed 2026-04-19 19:32:23 +02:00
1 parent 94e9846c13
commit e4aa0dce86
11 files changed
+2227 -32

No files matched your search

+198 -1
View File
@@ -2,6 +2,8 @@
import { onBeforeUnmount, onMounted, ref } from 'vue'
import {
PhExport,
PhFolderOpen,
PhFloppyDisk,
PhGearSix,
PhPause,
PhPlay,
@@ -15,11 +17,32 @@ import DocumentSettings from './components/DocumentSettings.vue'
import ExportDialog from './components/ExportDialog.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 { 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 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()
@@ -32,6 +55,87 @@ 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)
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) {
store.loadScene(JSON.parse(JSON.stringify(project.scene)))
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
@@ -41,13 +145,21 @@ function isEditable(el: EventTarget | null): boolean {
}
function onKeyDown(e: KeyboardEvent) {
if (e.ctrlKey && e.shiftKey && e.altKey && (e.code === 'KeyT' || e.key.toLowerCase() === 't')) {
e.preventDefault()
testsOpen.value = true
return
}
if (e.code !== 'Space') return
if (isEditable(e.target)) return
e.preventDefault()
store.togglePlay()
}
onMounted(() => document.addEventListener('keydown', onKeyDown))
onMounted(() => {
document.addEventListener('keydown', onKeyDown)
bootWelcome()
})
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
</script>
@@ -79,6 +191,15 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
<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>
@@ -94,6 +215,23 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
<footer class="timeline"><Timeline /></footer>
<DocumentSettings v-model="settingsOpen" />
<ExportDialog v-model="exportOpen" />
<TestOverlay v-model="testsOpen" />
<WelcomeDialog
v-model="welcomeOpen"
@open="openFromRecent"
@new="() => { currentProjectId = null; currentProjectName = null }"
@import="importFromFile"
@export="exportProjectFile"
/>
<Transition name="fade">
<div v-if="loadError" class="load-error" role="alert">
<strong>Projekt konnte nicht geladen werden.</strong>
<span>{{ loadError }}</span>
</div>
</Transition>
<Transition name="fade">
<div v-if="savedToast" class="saved-toast" role="status">{{ savedToast }}</div>
</Transition>
</div>
</template>
@@ -259,4 +397,63 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
border-top: 1px solid var(--border);
overflow: hidden;
}
.load-error {
position: fixed;
bottom: 240px;
left: 50%;
transform: translateX(-50%);
z-index: 1500;
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 14px;
background: var(--danger-soft);
color: var(--danger);
border: 1px solid var(--danger);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
font-size: 12px;
max-width: 520px;
}
.saved-toast {
position: fixed;
bottom: 240px;
left: 50%;
transform: translateX(-50%);
z-index: 1500;
padding: 8px 14px;
background: var(--panel);
border: 1px solid var(--border-strong);
color: var(--text);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
font-size: 12px;
}
.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;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 140ms var(--ease);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>