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

450 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { PhClock, PhFrameCorners, PhMoon, PhPalette, PhSun } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { useTheme } from '@/composables/useTheme'
import type { Theme } from '@/composables/useTheme'
import { Modal } from '@/components/ui'
import { t } from '@/i18n'
defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore()
const { theme, setTheme } = useTheme()
type TabId = 'document' | 'timeline' | 'theme'
const tabs: { id: TabId; label: string; icon: typeof PhFrameCorners }[] = [
{ id: 'document', label: t('settings.tab.document'), icon: PhFrameCorners },
{ id: 'timeline', label: t('settings.tab.timeline'), icon: PhClock },
{ id: 'theme', label: t('settings.tab.theme'), icon: PhPalette },
]
const activeTab = ref<TabId>('document')
const width = computed({
get: () => store.scene.canvas.width,
set: (v: number) => store.setCanvasSize(v, store.scene.canvas.height),
})
const height = computed({
get: () => store.scene.canvas.height,
set: (v: number) => store.setCanvasSize(store.scene.canvas.width, v),
})
const background = computed({
get: () => store.scene.canvas.background,
set: (v: string) => store.setCanvasBackground(v),
})
const duration = computed({
get: () => store.scene.duration,
set: (v: number) => store.setDuration(v),
})
const fps = computed({
get: () => store.scene.fps,
set: (v: number) => store.setFps(v),
})
const presets: { label: string; w: number; h: number }[] = [
{ label: '16:9 · 1280×720', w: 1280, h: 720 },
{ label: '16:9 · 1920×1080', w: 1920, h: 1080 },
{ label: '1:1 · 1080×1080', w: 1080, h: 1080 },
{ label: '9:16 · 1080×1920', w: 1080, h: 1920 },
{ label: '4:5 · 1080×1350', w: 1080, h: 1350 },
]
function applyPreset(p: { w: number; h: number }) {
store.setCanvasSize(p.w, p.h)
}
function close() {
emit('update:modelValue', false)
}
function applyTheme(t: Theme) {
setTheme(t)
}
</script>
<template>
<Modal
:model-value="modelValue"
:title="t('settings.title')"
size="lg"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="body">
<nav class="tabs">
<button
v-for="tab in tabs"
:key="tab.id"
class="tab"
:class="{ active: activeTab === tab.id }"
@click="activeTab = tab.id"
>
<component :is="tab.icon" :size="14" />
{{ tab.label }}
</button>
</nav>
<div class="content">
<section v-if="activeTab === 'document'">
<h3>{{ t('settings.canvas') }}</h3>
<div class="row">
<label>
{{ t('settings.width') }}
<input type="number" min="1" step="1" v-model.number="width" />
</label>
<label>
{{ t('settings.height') }}
<input type="number" min="1" step="1" v-model.number="height" />
</label>
</div>
<div class="presets">
<button
v-for="p in presets"
:key="p.label"
class="preset"
:class="{ active: width === p.w && height === p.h }"
@click="applyPreset(p)"
>
{{ p.label }}
</button>
</div>
<label class="stacked">
{{ t('settings.background') }}
<div class="color-row">
<input
type="color"
class="color-swatch"
:value="background"
@input="background = ($event.target as HTMLInputElement).value"
/>
<span class="hex">{{ background.toUpperCase() }}</span>
</div>
</label>
</section>
<section v-else-if="activeTab === 'timeline'">
<h3>{{ t('settings.tab.timeline') }}</h3>
<div class="row">
<label>
{{ t('settings.duration') }}
<input type="number" min="0.1" step="0.1" v-model.number="duration" />
</label>
<label>
{{ t('settings.fps') }}
<input type="number" min="1" step="1" v-model.number="fps" />
</label>
</div>
<p class="hint">
{{ t('settings.timelineHint') }}
</p>
</section>
<section v-else-if="activeTab === 'theme'">
<h3>{{ t('settings.tab.theme') }}</h3>
<div class="theme-grid">
<button
class="theme-card"
:class="{ active: theme === 'dark' }"
@click="applyTheme('dark')"
>
<div class="theme-preview theme-preview-dark">
<div class="tp-panel"></div>
<div class="tp-accent"></div>
</div>
<div class="theme-label">
<PhMoon :size="14" />
<span>{{ t('settings.themeDark') }}</span>
</div>
</button>
<button
class="theme-card"
:class="{ active: theme === 'light' }"
@click="applyTheme('light')"
>
<div class="theme-preview theme-preview-light">
<div class="tp-panel"></div>
<div class="tp-accent"></div>
</div>
<div class="theme-label">
<PhSun :size="14" />
<span>{{ t('settings.themeLight') }}</span>
</div>
</button>
</div>
<p class="hint">{{ t('settings.themeHint') }}</p>
</section>
</div>
</div>
<template #footer>
<button class="primary" @click="close">{{ t('settings.done') }}</button>
</template>
</Modal>
</template>
<style scoped>
.body {
display: flex;
min-height: 340px;
}
.tabs {
width: 150px;
flex-shrink: 0;
border-right: 1px solid var(--border);
padding: 10px 8px;
display: flex;
flex-direction: column;
gap: 2px;
background: var(--panel-alt);
}
.tab {
justify-content: flex-start;
padding: 8px 10px;
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
font-size: 12px;
border-radius: var(--radius-sm);
}
.tab:hover:not(:disabled) {
background: var(--panel-hover);
color: var(--text);
}
.tab.active {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent);
}
.content {
flex: 1;
padding: 16px 20px;
overflow: auto;
min-width: 0;
}
section {
display: flex;
flex-direction: column;
gap: 12px;
}
section h3 {
margin: 0;
font-size: 10px;
text-transform: uppercase;
color: var(--text-dim);
letter-spacing: 0.08em;
font-weight: 500;
}
.row {
display: flex;
gap: 10px;
}
label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
flex: 1 1 0;
min-width: 0;
}
label.stacked {
flex: 0 0 auto;
}
input[type='number'] {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 8px;
border-radius: var(--radius-sm);
font: inherit;
font-size: 12px;
text-transform: none;
letter-spacing: 0;
}
.presets {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.preset {
padding: 4px 10px;
font-size: 11px;
background: var(--panel-alt);
border: 1px solid var(--border);
color: var(--text-dim);
border-radius: 999px;
}
.preset:hover:not(:disabled) {
background: var(--panel-hover);
color: var(--text);
}
.preset.active {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.color-row {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 0;
}
.color-swatch {
width: 36px;
height: 24px;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: transparent;
cursor: pointer;
flex-shrink: 0;
}
.color-swatch::-webkit-color-swatch-wrapper {
padding: 0;
}
.color-swatch::-webkit-color-swatch {
border: none;
border-radius: 3px;
}
.color-swatch::-moz-color-swatch {
border: none;
border-radius: 3px;
}
.hex {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--text-dim-strong);
}
.hint {
font-size: 11px;
color: var(--text-dim);
font-style: italic;
margin: 0;
}
.theme-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.theme-card {
flex-direction: column;
padding: 12px;
gap: 10px;
background: var(--panel-alt);
border: 2px solid var(--border);
border-radius: var(--radius);
align-items: stretch;
}
.theme-card:hover:not(:disabled) {
background: var(--panel-hover);
border-color: var(--border-strong);
}
.theme-card.active {
border-color: var(--accent);
background: var(--accent-soft);
}
.theme-preview {
height: 74px;
border-radius: var(--radius-sm);
display: flex;
align-items: stretch;
gap: 6px;
padding: 8px;
}
.theme-preview-dark {
background: #17171b;
border: 1px solid #2e2e37;
}
.theme-preview-dark .tp-panel {
background: #272730;
flex: 1;
border-radius: 3px;
}
.theme-preview-dark .tp-accent {
background: #6ea8ff;
width: 22px;
border-radius: 3px;
}
.theme-preview-light {
background: #f3f3f6;
border: 1px solid #e2e2e7;
}
.theme-preview-light .tp-panel {
background: #ffffff;
flex: 1;
border-radius: 3px;
border: 1px solid #e2e2e7;
}
.theme-preview-light .tp-accent {
background: #2b6af5;
width: 22px;
border-radius: 3px;
}
.theme-label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text);
font-weight: 500;
justify-content: center;
}
.theme-card.active .theme-label {
color: var(--accent);
}
.primary {
padding: 6px 14px;
background: var(--accent-soft);
border: 1px solid var(--accent);
color: var(--accent);
border-radius: var(--radius-sm);
font-weight: 500;
}
.primary:hover:not(:disabled) {
background: var(--accent-soft-strong);
}
</style>