-
+
-
+
+
{
v-for="kf in track.kfs"
:key="kf.id"
class="kf"
- :class="{ hold: kf.hold }"
+ :class="{
+ hold: kf.hold,
+ selected: isKfSelected(track.layerId, track.kind, kf.id),
+ }"
:style="{ left: pxFromTime(kf.time) + 'px' }"
+ :data-layer-id="track.layerId"
+ :data-kind="track.kind"
+ :data-kf-id="kf.id"
:title="
kf.hold
- ? `Still Frame ${formatKfTime(kf.time)} — hält bis zum nächsten Keyframe`
- : `Keyframe ${formatKfTime(kf.time)} — ziehen zum Verschieben, Rechtsklick für Optionen`
+ ? t('timeline.stillFrameTooltip', { time: formatKfTime(kf.time) })
+ : t('timeline.keyframeTooltip', { time: formatKfTime(kf.time) })
"
- @pointerdown="onKeyframePointerDown($event, track.layerId, kf.id)"
+ @pointerdown="onKeyframePointerDown($event, track.layerId, kf.id, track.kind)"
@pointermove="onKeyframePointerMove"
@pointerup="onKeyframePointerUp"
@pointercancel="onKeyframePointerUp"
@@ -1104,11 +1356,11 @@ onBeforeUnmount(() => {
-
Noch keine Tracks.
+
{{ t('timeline.noTracks') }}
-
+
- Clip-Spur hinzufügen
+ {{ t('timeline.addClipTrack') }}
@@ -1141,31 +1393,31 @@ onBeforeUnmount(() => {
{{ opt.label }}
-
-
+
+
@@ -1176,7 +1428,7 @@ onBeforeUnmount(() => {
:style="{ left: trackMenu.x + 'px', top: trackMenu.y + 'px' }"
>
@@ -1405,6 +1657,26 @@ onBeforeUnmount(() => {
.tracks {
display: flex;
flex-direction: column;
+ position: relative;
+}
+
+.box-select-rect {
+ position: absolute;
+ z-index: 50;
+ border: 1px solid var(--accent);
+ background: rgba(110, 168, 255, 0.12);
+ pointer-events: none;
+ border-radius: 2px;
+}
+
+.kf.selected {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+.kf.hold.selected {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
}
.track {
@@ -1495,6 +1767,58 @@ onBeforeUnmount(() => {
opacity: 0.85;
}
+/* Neutralize hover states on locked tracks */
+.track.locked .track-name:hover {
+ background: var(--panel);
+ color: var(--text-dim);
+}
+
+.track.locked .track-name.selected:hover {
+ background:
+ linear-gradient(var(--accent-soft), var(--accent-soft)),
+ var(--panel);
+ color: var(--accent);
+}
+
+.track.locked.sub-track .track-name:hover {
+ background: var(--panel-alt);
+}
+
+.track.locked.inactive:hover .track-lane {
+ opacity: 0.4;
+}
+
+.track.locked .clip-block:hover {
+ background: color-mix(in srgb, var(--track-color, var(--accent)) 14%, transparent);
+ transform: none;
+}
+
+.track.locked .clip-block:hover .clip-handle {
+ opacity: 0;
+}
+
+.track.locked .clip-block.trimmed-start:hover .clip-handle.start,
+.track.locked .clip-block.trimmed-end:hover .clip-handle.end {
+ opacity: 0.55;
+}
+
+.track.locked .clip-handle:hover,
+.track.locked .clip-handle:active {
+ background: transparent !important;
+ opacity: 0 !important;
+}
+
+.track.locked .kf:hover {
+ background: var(--track-color, var(--accent));
+ transform: rotate(45deg);
+}
+
+.track.locked .kf.hold:hover {
+ background: var(--panel);
+ border-color: var(--track-color, var(--accent));
+ transform: none;
+}
+
.track-name {
position: sticky;
left: 0;
diff --git a/src/components/WelcomeDialog.vue b/src/components/WelcomeDialog.vue
index 59c1615..a284f34 100644
--- a/src/components/WelcomeDialog.vue
+++ b/src/components/WelcomeDialog.vue
@@ -12,7 +12,8 @@ import {
listRecentProjects,
type StoredProject,
} from '@/lib/projectStore'
-import { Modal } from '@/components/ui'
+import { confirmDialog, Modal } from '@/components/ui'
+import { t } from '@/i18n'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{
@@ -43,7 +44,9 @@ function formatDate(ts: number): string {
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate()
if (sameDay) {
- return `heute ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
+ return t('welcome.today', {
+ time: d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
+ })
}
return d.toLocaleString([], {
day: '2-digit',
@@ -65,7 +68,13 @@ function openProject(p: StoredProject) {
async function removeProject(e: MouseEvent, p: StoredProject) {
e.stopPropagation()
- if (!confirm(`"${p.name}" wirklich löschen?`)) return
+ const ok = await confirmDialog({
+ title: t('welcome.deleteDialog.title'),
+ message: t('welcome.deleteDialog.message', { name: p.name }),
+ confirmLabel: t('welcome.deleteDialog.confirm'),
+ variant: 'danger',
+ })
+ if (!ok) return
await deleteStoredProject(p.id)
await reload()
}
@@ -108,14 +117,14 @@ watch(
>
-
Willkommen bei Animatr
-
Zuletzt bearbeitete Projekte
+
{{ t('welcome.heading') }}
+
{{ t('welcome.subheading') }}
-
Laden…
+
{{ t('welcome.loading') }}
- Keine gespeicherten Projekte vorhanden.
+ {{ t('welcome.empty') }}
@@ -147,15 +156,15 @@ watch(
- Neues Projekt
+ {{ t('welcome.newProject') }}
- Datei importieren…
+ {{ t('welcome.importFile') }}
- Aktuelles exportieren
+ {{ t('welcome.exportCurrent') }}
diff --git a/src/components/ui/DialogsHost.vue b/src/components/ui/DialogsHost.vue
new file mode 100644
index 0000000..a62084c
--- /dev/null
+++ b/src/components/ui/DialogsHost.vue
@@ -0,0 +1,176 @@
+
+
+
+
+
+
{{ current.opts.message }}
+
+
+
+ {{ cancelLabel() }}
+
+ {{ confirmLabel() }}
+
+
+
+
+
+
diff --git a/src/components/ui/Modal.vue b/src/components/ui/Modal.vue
index 4fdf397..a152335 100644
--- a/src/components/ui/Modal.vue
+++ b/src/components/ui/Modal.vue
@@ -1,6 +1,7 @@
-
+
-
Einbinden in eine eigene Seite
-
<!-- einmal pro Seite -->
+ Embed on your own page
+ <!-- once per page -->
<script src="animatr-runtime.js"></script>
-<!-- je Animation ein Container + Scene-Script -->
+<!-- per animation: one container + scene script -->
<div style="width: 800px; height: 450px;">
<script src="animation.js"></script>
</div>
diff --git a/src/lib/projectFile.ts b/src/lib/projectFile.ts
index cd2d354..68da8b5 100644
--- a/src/lib/projectFile.ts
+++ b/src/lib/projectFile.ts
@@ -32,28 +32,28 @@ export function parseProject(text: string): Scene {
try {
data = JSON.parse(text)
} catch {
- throw new ProjectFileError('Datei ist kein gültiges JSON.')
+ throw new ProjectFileError('File is not valid JSON.')
}
if (!data || typeof data !== 'object') {
- throw new ProjectFileError('Datei ist leer oder kein Objekt.')
+ throw new ProjectFileError('File is empty or not an object.')
}
const obj = data as Record
if (obj.magic !== MAGIC) {
- throw new ProjectFileError('Keine Animatr-Projektdatei (magic fehlt).')
+ throw new ProjectFileError('Not an Animatr project file (magic missing).')
}
const scene = obj.scene
if (!scene || typeof scene !== 'object') {
- throw new ProjectFileError('Projektdatei enthält keine Szene.')
+ throw new ProjectFileError('Project file has no scene.')
}
const s = scene as Record
if (!s.canvas || typeof s.canvas !== 'object') {
- throw new ProjectFileError('Szenendaten unvollständig: canvas fehlt.')
+ throw new ProjectFileError('Scene data incomplete: canvas missing.')
}
if (!Array.isArray(s.layers)) {
- throw new ProjectFileError('Szenendaten unvollständig: layers fehlt.')
+ throw new ProjectFileError('Scene data incomplete: layers missing.')
}
if (!Array.isArray(s.assets)) {
- throw new ProjectFileError('Szenendaten unvollständig: assets fehlt.')
+ throw new ProjectFileError('Scene data incomplete: assets missing.')
}
return scene as Scene
}
diff --git a/src/lib/projectStore.ts b/src/lib/projectStore.ts
index 032636e..c6dbd09 100644
--- a/src/lib/projectStore.ts
+++ b/src/lib/projectStore.ts
@@ -98,7 +98,7 @@ export async function getStoredProject(id: string): Promise '')
- throw new Error(`Server start fehlgeschlagen (${startRes.status}): ${body.slice(0, 200)}`)
+ throw new Error(`Server start failed (${startRes.status}): ${body.slice(0, 200)}`)
}
const { jobId } = (await startRes.json()) as { jobId: string }
- if (!jobId) throw new Error('Server lieferte keine jobId')
+ if (!jobId) throw new Error('Server did not return a jobId')
const downloadUrl = await new Promise((resolve, reject) => {
const es = new EventSource(`${baseUrl}/render/${jobId}/events`)
@@ -78,13 +78,13 @@ export async function renderOnServer(
opts.signal?.removeEventListener('abort', onAbort)
if (data.error) reject(new Error(data.error))
else if (data.url) resolve(`${baseUrl}${data.url}`)
- else reject(new Error('Server meldete fertig ohne Download-URL'))
+ else reject(new Error('Server reported done without download URL'))
}
}
es.onerror = () => {
es.close()
opts.signal?.removeEventListener('abort', onAbort)
- reject(new Error('Verbindung zum Render-Server verloren'))
+ reject(new Error('Connection to render server lost'))
}
})
diff --git a/src/lib/videoExport.ts b/src/lib/videoExport.ts
index 72eaf59..38d7505 100644
--- a/src/lib/videoExport.ts
+++ b/src/lib/videoExport.ts
@@ -46,7 +46,7 @@ export async function renderSceneToVideo(
): Promise {
const mimeType = pickMimeType()
if (!mimeType) {
- throw new Error('MediaRecorder wird von diesem Browser nicht unterstützt.')
+ throw new Error('MediaRecorder is not supported by this browser.')
}
const canvas = document.createElement('canvas')
diff --git a/todo b/todo
index 43a085a..4d5a178 100644
--- a/todo
+++ b/todo
@@ -48,8 +48,19 @@ das timeline panel muss man per anfasser höher ziehen können
spuren muss man locken können. sie dürfen dann nur noch die halbe höhe haben und an der zeitlinie muss erkennbar sein das nicht bearbeitet werden kann
+per box select muss man über mehrere spuren keyframe multiselection durchführen können um sie zusammen in der zeit zu verschieben
+
+rect circle und text brauchen wir nicht als instanzen in der lib. da tauchen nur importierte elemente auf. diese muss man auch mehrmals einfügen können per DnD oder "+"
ALLE texte der gui bitte in englisch
-per box select muss man über mehrere spuren keyframe multiselection durchführen können um sie zusammen in der zeit zu verschieben
+
+
+
+ein element rein draggen muss das element (grafik) als draggable anzeigen, nicht den bibliothekseintrag
+
+context menüs dürfen nicht unten aus dem viewport rausgehen
+
+wir machen mal ein about popup und verwalten ab sofort eine version. wir starten mit 0.1.0
+about öffnet bei click auf den app namen oben links
\ No newline at end of file