网页优化

This commit is contained in:
tianmo committed 2026-10-31 15:26:31 +08:00
1 parent ab32146e8d
commit 753e3b1e21
47 files changed
+187 -56

No files matched your search

+2
View File
@@ -3,12 +3,14 @@ import { onMounted, onUnmounted, ref } from 'vue'
import WelcomePage from './views/WelcomePage.vue'
import EditorWorkspace from './views/EditorWorkspace.vue'
import { i18n } from './i18n'
import { trackMatomoPageView } from './editor/analytics'
const editorActive = ref(window.location.hash === '#/editor')
function syncViewFromUrl() {
editorActive.value = window.location.hash === '#/editor'
document.title = i18n.global.t(editorActive.value ? 'app.title' : 'welcome.documentTitle')
trackMatomoPageView()
}
function openEditor() {
if (window.location.hash !== '#/editor') window.location.hash = '/editor'
+19 -1
View File
@@ -1,7 +1,7 @@
import { i18n, SUPPORTED_LOCALES } from '../i18n'
type MatomoWindow = Window & { _paq?: unknown[][] }
type EventCategory = '时间轴' | '系统设置' | '属性面板' | '使用说明'
type EventCategory = '欢迎页' | '页面导航' | '时间轴' | '系统设置' | '属性面板' | '使用说明'
type EventAction = '点击按钮' | '点击开关' | '调整数值' | '点击下载'
type MessageTree = Record<string, unknown>
@@ -36,6 +36,7 @@ function localizedToChinese(value: string | null | undefined) {
function categoryFor(element: Element): EventCategory {
const section = element.closest<HTMLElement>('[data-matomo-section]')?.dataset.matomoSection
if (section === 'welcome') return '欢迎页'
if (section === 'timeline') return '时间轴'
if (section === 'settings') return '系统设置'
return '属性面板'
@@ -78,6 +79,16 @@ export function trackMatomoEvent(category: EventCategory, action: EventAction, n
;(target._paq ||= []).push(['trackEvent', category, action, name])
}
export function trackMatomoPageView() {
if (!import.meta.env.PROD) return
const target = window as MatomoWindow
;(target._paq ||= []).push(
['setCustomUrl', window.location.href],
['setDocumentTitle', document.title],
['trackPageView'],
)
}
export function installMatomoEventTracking() {
if (!import.meta.env.PROD) return
document.addEventListener('click', (event) => {
@@ -92,6 +103,13 @@ export function installMatomoEventTracking() {
)
return
}
const tracked = target.closest<HTMLElement>('[data-matomo-event]')
if (tracked) {
const category = tracked.dataset.matomoCategory as EventCategory | undefined
const action = tracked.dataset.matomoAction as EventAction | undefined
trackMatomoEvent(category || categoryFor(tracked), action || '点击按钮', tracked.dataset.matomoEvent || '')
return
}
const button = target.closest<HTMLButtonElement>('button')
if (!button) return
trackMatomoEvent(categoryFor(button), '点击按钮', buttonName(button))
+3 -2
View File
@@ -5,10 +5,10 @@
"particlesBones": "Partikel (Knochen)",
"rootBone": "Wurzelknochen",
"hudTip": "Radzoom · Klicken Sie mit der linken Maustaste, um die Leinwand zu ziehen",
"title": "Spine-Partikeleditor - Yeomai"
"title": "Spine-Partikeleditor"
},
"welcome": {
"documentTitle": "Yeomai Spine-Werkzeuge",
"documentTitle": "Spine-Partikeleditor",
"titleLead": "Von Spine-Partikeln",
"titleAccent": "bis zur Spine-Toolchain",
"backToTools": "Zurück zur Werkzeugauswahl",
@@ -111,6 +111,7 @@
"axisColor": "Achsenfarbe",
"showAxisLabels": "Skalenwert anzeigen",
"unsavedPrompt": "Warnung bei ungespeicherten Änderungen",
"unsavedLeaveConfirm": "Dieses Projekt enthält ungespeicherte Änderungen. Zur Werkzeugauswahl zurückkehren?",
"showProjectSize": "Speicherplatz der Konfigurationen anzeigen",
"cacheNote": "Die gesamten Systemeinstellungen werden automatisch im aktuellen Browser gespeichert"
},
+3 -2
View File
@@ -1,6 +1,6 @@
{
"app": {
"title": "Spine Particle Editor - Yeomai",
"title": "Spine Particle Editor",
"resizeTimeline": "Drag to resize the timeline",
"systems": "Systems",
"particlesBones": "Particles (bones)",
@@ -20,7 +20,7 @@
"es": "Español"
},
"welcome": {
"documentTitle": "Yeomai Spine Tools",
"documentTitle": "Spine Particle Editor",
"titleLead": "From Spine particles",
"titleAccent": "to the Spine toolchain",
"backToTools": "Back to tools",
@@ -123,6 +123,7 @@
"axisColor": "Axis color",
"showAxisLabels": "Show tick values",
"unsavedPrompt": "Prompt for unsaved changes",
"unsavedLeaveConfirm": "This project has unsaved changes. Return to the tools home page?",
"showProjectSize": "Show configuration storage usage",
"cacheNote": "Settings are saved automatically in this browser"
},
+3 -2
View File
@@ -5,10 +5,10 @@
"particlesBones": "Partículas (huesos)",
"rootBone": "hueso de la raíz",
"hudTip": "Zoom de rueda · Clic izquierdo para arrastrar el lienzo",
"title": "Editor de partículas Spine - Yeomai"
"title": "Editor de partículas Spine"
},
"welcome": {
"documentTitle": "Herramientas Spine de Yeomai",
"documentTitle": "Editor de partículas Spine",
"titleLead": "De las partículas Spine",
"titleAccent": "a las herramientas Spine",
"backToTools": "Volver a las herramientas",
@@ -111,6 +111,7 @@
"axisColor": "color del eje",
"showAxisLabels": "Mostrar valor de escala",
"unsavedPrompt": "Avisar sobre cambios sin guardar",
"unsavedLeaveConfirm": "Este proyecto tiene cambios sin guardar. ¿Volver al inicio de herramientas?",
"showProjectSize": "Mostrar espacio usado por las configuraciones",
"cacheNote": "Toda la configuración del sistema se guarda automáticamente en el navegador actual."
},
+3 -2
View File
@@ -5,10 +5,10 @@
"particlesBones": "Particules (os)",
"rootBone": "os de la racine",
"hudTip": "Zoom sur la molette · Clic gauche pour faire glisser la toile",
"title": "Éditeur de particules Spine - Yeomai"
"title": "Éditeur de particules Spine"
},
"welcome": {
"documentTitle": "Outils Spine Yeomai",
"documentTitle": "Éditeur de particules Spine",
"titleLead": "Des particules Spine",
"titleAccent": "à la chaîne d’outils Spine",
"backToTools": "Retour aux outils",
@@ -111,6 +111,7 @@
"axisColor": "couleur de l'axe",
"showAxisLabels": "Afficher la valeur de l'échelle",
"unsavedPrompt": "Avertir des modifications non enregistrées",
"unsavedLeaveConfirm": "Ce projet contient des modifications non enregistrées. Revenir à l’accueil des outils ?",
"showProjectSize": "Afficher la taille des configurations",
"cacheNote": "L'ensemble des paramètres du système sont automatiquement enregistrés dans le navigateur actuel"
},
+3 -2
View File
@@ -5,10 +5,10 @@
"particlesBones": "粒子(骨)",
"rootBone": "根の骨",
"hudTip": "ホイールズーム・左クリックでキャンバスをドラッグ",
"title": "Spineパーティクルエディター-Yeomai"
"title": "Spineパーティクルエディター"
},
"welcome": {
"documentTitle": "Yeomai Spine ツール",
"documentTitle": "Spineパーティクルエディター",
"titleLead": "Spineパーティクルから",
"titleAccent": "Spine ツールチェーンへ",
"backToTools": "ツール一覧へ戻る",
@@ -111,6 +111,7 @@
"axisColor": "軸の色",
"showAxisLabels": "表示スケール値",
"unsavedPrompt": "未保存の変更を確認",
"unsavedLeaveConfirm": "保存されていない変更があります。ツール一覧に戻りますか?",
"showProjectSize": "設定の使用容量を表示",
"cacheNote": "システム設定全体が現在のブラウザに自動的に保存されます"
},
+3 -2
View File
@@ -5,10 +5,10 @@
"particlesBones": "입자(뼈)",
"rootBone": "뿌리뼈",
"hudTip": "휠 확대/축소 · 캔버스를 드래그하려면 왼쪽 클릭",
"title": "Spine 파티클 에디터-Yeomai"
"title": "Spine 파티클 에디터"
},
"welcome": {
"documentTitle": "Yeomai Spine 도구",
"documentTitle": "Spine 파티클 에디터",
"titleLead": "Spine 파티클 시스템에서",
"titleAccent": "Spine 도구까지",
"backToTools": "도구 목록으로 돌아가기",
@@ -111,6 +111,7 @@
"axisColor": "축 색상",
"showAxisLabels": "표시 스케일 값",
"unsavedPrompt": "저장되지 않은 변경 확인",
"unsavedLeaveConfirm": "저장되지 않은 변경 사항이 있습니다. 도구 홈으로 돌아가시겠습니까?",
"showProjectSize": "설정 사용 공간 표시",
"cacheNote": "전체 시스템 설정이 현재 브라우저에 자동으로 저장됩니다."
},
+3 -2
View File
@@ -5,10 +5,10 @@
"particlesBones": "Partículas (ossos)",
"rootBone": "osso da raiz",
"hudTip": "Zoom da roda · Clique com o botão esquerdo para arrastar a tela",
"title": "Editor de Partículas Spine - Yeomai"
"title": "Editor de Partículas Spine"
},
"welcome": {
"documentTitle": "Ferramentas Spine Yeomai",
"documentTitle": "Editor de Partículas Spine",
"titleLead": "Das partículas Spine",
"titleAccent": "à cadeia de ferramentas Spine",
"backToTools": "Voltar às ferramentas",
@@ -111,6 +111,7 @@
"axisColor": "cor do eixo",
"showAxisLabels": "Exibir valor da escala",
"unsavedPrompt": "Avisar sobre alterações não salvas",
"unsavedLeaveConfirm": "Este projeto tem alterações não salvas. Voltar à página inicial das ferramentas?",
"showProjectSize": "Mostrar espaço usado pelas configurações",
"cacheNote": "Todas as configurações do sistema são salvas automaticamente no navegador atual"
},
+4 -3
View File
@@ -1,6 +1,6 @@
{
"app": {
"title": "Spine粒子编辑器-Yeomai",
"title": "Spine粒子编辑器",
"resizeTimeline": "拖动调整时间轴高度",
"systems": "系统",
"particlesBones": "粒子(骨骼)",
@@ -20,9 +20,9 @@
"es": "Español"
},
"welcome": {
"documentTitle": "Yeomai Spine 工具集",
"documentTitle": "Spine粒子编辑器",
"titleLead": "从Spine粒子系统",
"titleAccent": "延伸到 Spine 工具链",
"titleAccent": "到 Spine 工具链",
"backToTools": "返回工具首页",
"editor": {
"badge": "粒子编辑",
@@ -123,6 +123,7 @@
"axisColor": "坐标轴颜色",
"showAxisLabels": "显示刻度数值",
"unsavedPrompt": "未保存提示",
"unsavedLeaveConfirm": "当前项目有未保存的修改,确定返回工具首页吗?",
"showProjectSize": "显示配置占用空间",
"cacheNote": "整个系统设置会自动保存到当前浏览器"
},
+4 -3
View File
@@ -5,12 +5,12 @@
"particlesBones": "粒子(骨骼)",
"rootBone": "根骨骼",
"hudTip": "滾輪縮放 · 左鍵拖動畫布",
"title": "Spine粒子編輯器-Yeomai"
"title": "Spine粒子編輯器"
},
"welcome": {
"documentTitle": "Yeomai Spine 工具集",
"documentTitle": "Spine粒子編輯器",
"titleLead": "從Spine粒子系統",
"titleAccent": "延伸到 Spine 工具鏈",
"titleAccent": "到 Spine 工具鏈",
"backToTools": "返回工具首頁",
"editor": {
"badge": "粒子編輯",
@@ -111,6 +111,7 @@
"axisColor": "座標軸顏色",
"showAxisLabels": "顯示刻度數值",
"unsavedPrompt": "未儲存提示",
"unsavedLeaveConfirm": "目前專案有未儲存的變更,確定返回工具首頁嗎?",
"showProjectSize": "顯示設定佔用空間",
"cacheNote": "整個系統設定會自動儲存到目前瀏覽器"
},
+32 -1
View File
@@ -8,6 +8,20 @@
alt=""
aria-hidden="true"
/>
<button
type="button"
class="stage-home-btn"
:class="{ 'home-right': !store.settings.parameterPanelOnRight }"
:title="$t('welcome.backToTools')"
:aria-label="$t('welcome.backToTools')"
data-matomo-category="页面导航"
data-matomo-event="editor.open-welcome"
@click="returnToWelcome"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m3.5 10.5 8.5-7 8.5 7V21h-6v-6h-5v6h-6z" />
</svg>
</button>
<StageToolbar
:ctx="stageContext"
@@ -18,7 +32,6 @@
@update:active-tool="activeTool = $event"
@update:show-settings="showSettings = $event"
@update:show-help="showHelp = $event"
@home="emit('home')"
@clear-background="clearBackground"
@background-file="onBackgroundFileChange"
/>
@@ -48,6 +61,7 @@ import { SpineRuntimeLayer } from '../spine/SpineRuntimeLayer'
import { editorHistoryState, installEditorHistory, undoLastOperation } from '../editor/editorHistory'
import { useEditorShortcuts } from '../editor/useEditorShortcuts'
import { PROJECT_SAVE_EVENT } from '../editor/projectLibrary'
import { translate } from '../i18n'
import HelpDialog from './HelpDialog.vue'
import StageToolbar from './StageToolbar.vue'
import StageSettingsPanel from './StageSettingsPanel.vue'
@@ -179,6 +193,12 @@ function onBeforeUnload(event: BeforeUnloadEvent) {
event.returnValue = ''
}
function returnToWelcome() {
if (store.settings.unsavedChangesPrompt && editorHistoryState.dirty
&& !window.confirm(translate('settings.unsavedLeaveConfirm'))) return
emit('home')
}
function onWheelEvent(e: WheelEvent) {
onWheel(stageContext, e)
}
@@ -669,4 +689,15 @@ watch(() => [store.activeObjectType, store.activeObjectId], () => pathEditing.re
opacity: .8; pointer-events: none; user-select: none;
}
.stage-watermark.watermark-left-panel { left: auto; right: 14px; }
.stage-home-btn {
position: absolute; top: 14px; left: calc(24px + clamp(105px, 13.5vw, 150px)); z-index: 2;
display: grid; width: 32px; height: 32px; padding: 7px; place-items: center;
border: 1px solid rgba(66, 77, 99, .76); border-radius: 7px;
background: rgba(15, 18, 27, .8); color: #aeb8cc; cursor: pointer;
backdrop-filter: blur(6px);
}
.stage-home-btn.home-right { left: auto; right: calc(24px + clamp(105px, 13.5vw, 150px)); }
.stage-home-btn:hover { border-color: #7774ff; background: rgba(43, 58, 92, .92); color: #fff; }
.stage-home-btn:focus-visible { outline: 2px solid #8b86ff; outline-offset: 2px; }
.stage-home-btn svg { display: block; width: 100%; height: 100%; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linejoin: round; }
</style>
-7
View File
@@ -18,7 +18,6 @@ const emit = defineEmits<{
(e: 'update:activeTool', v: '' | 'translate' | 'rotate' | 'scale'): void
(e: 'update:showSettings', v: boolean): void
(e: 'update:showHelp', v: boolean): void
(e: 'home'): void
(e: 'clear-background', resetControls: boolean): void
(e: 'background-file', event: Event): void
}>()
@@ -67,7 +66,6 @@ function undo() {
<template>
<div class="top-operation-stack" data-matomo-section="toolbar">
<div class="topbar">
<button class="home-btn" @click="emit('home')" :title="t('welcome.backToTools')">⌂</button>
<button class="settings-btn" @click="toggleSettings" :title="t('toolbar.settings')">{{ t('toolbar.settings') }}</button>
<button class="undo-btn" :disabled="editorHistoryState.undoCount === 0" @click="undo" :title="t('toolbar.undo')">↶</button>
@@ -209,11 +207,6 @@ b { color: #ffcc33; }
.background-opacity-row { display: grid; grid-template-columns: 46px 1fr 34px; align-items: center; gap: 7px; margin-top: 10px; font-size: 11px; }
.background-opacity-row input { min-width: 0; accent-color: #675de0; }
.background-opacity-row b { color: #aeb8cc; font-size: 10px; text-align: right; font-variant-numeric: tabular-nums; }
.home-btn {
width: 30px; height: 28px; flex: 0 0 30px; border: 1px solid #3a4963; border-radius: 6px;
background: #202a3b; color: #aeb9cf; font-size: 15px; cursor: pointer;
}
.home-btn:hover { border-color: #7774ff; background: #2b3a5c; color: #fff; }
.help-btn {
height: 28px; padding: 0 11px; flex: 0 0 auto; border: 1px solid #3a4963; border-radius: 6px;
background: #202a3b; color: #cbd7ed; font-size: 12px; font-weight: 700; cursor: pointer;
+95 -15
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import LanguagePicker from './LanguagePicker.vue'
@@ -14,10 +14,40 @@ const promotionLocale = computed(() => {
const particlePromotion = computed(() => `${import.meta.env.BASE_URL}images/SpineParticles/${promotionLocale.value}.png`)
const viewerPromotion = computed(() => `${import.meta.env.BASE_URL}images/SpineViewer/${promotionLocale.value}.png`)
const thumbnailPromotion = computed(() => `${import.meta.env.BASE_URL}images/SpineThumbnail/${promotionLocale.value}.png`)
const previewKind = ref<'particles' | 'viewer' | 'thumbnail' | null>(null)
let previewTimer: ReturnType<typeof setTimeout> | null = null
const previewImage = computed(() => {
if (previewKind.value === 'particles') return particlePromotion.value
if (previewKind.value === 'viewer') return viewerPromotion.value
if (previewKind.value === 'thumbnail') return thumbnailPromotion.value
return ''
})
const previewTitle = computed(() => {
if (previewKind.value === 'particles') return t('welcome.editor.title')
if (previewKind.value === 'viewer') return 'SpineViewer'
if (previewKind.value === 'thumbnail') return 'SpineThumbnails'
return ''
})
function schedulePromotionPreview(event: PointerEvent, kind: 'particles' | 'viewer' | 'thumbnail') {
if (event.pointerType !== 'mouse') return
closePromotionPreview()
previewTimer = setTimeout(() => {
previewTimer = null
previewKind.value = kind
}, 1000)
}
function closePromotionPreview() {
if (previewTimer) clearTimeout(previewTimer)
previewTimer = null
previewKind.value = null
}
watch(locale, () => {
document.title = t('welcome.documentTitle')
}, { immediate: true })
onBeforeUnmount(closePromotionPreview)
</script>
<template>
@@ -47,7 +77,12 @@ watch(locale, () => {
data-matomo-event="welcome.open-editor"
@click="emit('open-editor')"
>
<div class="hub-preview editor-preview" aria-hidden="true">
<div
class="hub-preview editor-preview"
aria-hidden="true"
@pointerenter="schedulePromotionPreview($event, 'particles')"
@pointerleave="closePromotionPreview"
>
<img class="promotion-image" :src="particlePromotion" alt="" />
<span class="preview-badge badge-with-icon">
<img src="/favicon.png" alt="" aria-hidden="true" />
@@ -62,8 +97,8 @@ watch(locale, () => {
<span>{{ t('welcome.editor.tagSequence') }}</span>
<span>{{ t('welcome.editor.tagExport') }}</span>
</div>
<span class="card-cta">{{ t('welcome.editor.cta') }} <b>→</b></span>
</div>
<span class="card-action">{{ t('welcome.editor.cta') }}</span>
</button>
<a
@@ -73,7 +108,12 @@ watch(locale, () => {
rel="noopener noreferrer"
data-matomo-event="welcome.open-viewer"
>
<div class="hub-preview viewer-preview" aria-hidden="true">
<div
class="hub-preview viewer-preview"
aria-hidden="true"
@pointerenter="schedulePromotionPreview($event, 'viewer')"
@pointerleave="closePromotionPreview"
>
<img class="promotion-image" :src="viewerPromotion" alt="" />
<span class="preview-badge badge-with-icon">
<img src="/images/eagle-logo.png" alt="" aria-hidden="true" />
@@ -88,8 +128,8 @@ watch(locale, () => {
<span>{{ t('welcome.viewer.tagSkins') }}</span>
<span>{{ t('welcome.viewer.tagTimeline') }}</span>
</div>
<span class="card-cta">{{ t('welcome.viewer.cta') }} <b>→</b></span>
</div>
<span class="card-action">{{ t('welcome.viewer.cta') }}</span>
</a>
<a
@@ -99,7 +139,12 @@ watch(locale, () => {
rel="noopener noreferrer"
data-matomo-event="welcome.open-thumbnail"
>
<div class="hub-preview thumbnail-preview" aria-hidden="true">
<div
class="hub-preview thumbnail-preview"
aria-hidden="true"
@pointerenter="schedulePromotionPreview($event, 'thumbnail')"
@pointerleave="closePromotionPreview"
>
<img class="promotion-image" :src="thumbnailPromotion" alt="" />
<span class="preview-badge badge-with-icon">
<img src="/images/eagle-logo.png" alt="" aria-hidden="true" />
@@ -114,11 +159,21 @@ watch(locale, () => {
<span>{{ t('welcome.thumbnail.tagModes') }}</span>
<span>{{ t('welcome.thumbnail.tagBatch') }}</span>
</div>
<span class="card-cta">{{ t('welcome.thumbnail.cta') }} <b>→</b></span>
</div>
<span class="card-action">{{ t('welcome.thumbnail.cta') }}</span>
</a>
</section>
</main>
<Teleport to="body">
<Transition name="promotion-preview">
<div v-if="previewKind" class="promotion-lightbox" aria-hidden="true">
<div class="promotion-lightbox-frame">
<div class="promotion-lightbox-title">{{ previewTitle }}</div>
<img :src="previewImage" alt="" />
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
@@ -146,7 +201,10 @@ watch(locale, () => {
.brand-link:focus-visible { outline: 2px solid #ef5147; outline-offset: 5px; }
.brand-logo { display: block; width: clamp(128px, 10vw, 156px); height: auto; }
.welcome-intro { padding: clamp(34px, 6vh, 64px) 0 clamp(22px, 3.5vh, 34px); text-align: center; }
.welcome-intro h1 { max-width: 980px; margin: 0 auto; color: #f3f3f5; font-size: clamp(31px, 3.2vw, 48px); line-height: 1.1; letter-spacing: -.025em; }
.welcome-intro h1 {
max-width: none; margin: 0 auto; color: #f3f3f5;
font-size: clamp(20px, 2.4vw, 40px); line-height: 1.1; letter-spacing: -.025em; white-space: nowrap;
}
.welcome-intro h1 span { color: #ff4e47; }
.welcome-intro p { max-width: 390px; margin: 0 0 3px; color: #8c93a3; font-size: 13px; line-height: 1.75; }
.hub-grid {
@@ -155,17 +213,19 @@ watch(locale, () => {
width: min(100%, 1540px); margin: 0 auto;
}
.hub-card {
--hub-border: #242731;
--hub-divider: #242731;
position: relative; display: flex; min-width: 0; min-height: 0; flex-direction: column;
padding: 0; overflow: hidden; border: 1px solid #242731; border-radius: 18px;
padding: 0; overflow: hidden; border: 1px solid var(--hub-border); border-radius: 18px;
background: linear-gradient(180deg, rgba(26,29,38,.94), rgba(15,17,23,.98));
color: inherit; font: inherit; text-align: left; text-decoration: none; cursor: pointer;
box-shadow: 0 20px 52px rgba(0,0,0,.25);
transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease;
}
.hub-card:hover { transform: translateY(-3px); border-color: #4a4650; box-shadow: 0 25px 64px rgba(0,0,0,.38); }
.hub-card:hover { transform: translateY(-3px); border-color: var(--hub-border); box-shadow: 0 25px 64px rgba(0,0,0,.38); }
.hub-card:focus-visible { outline: 2px solid #ff5b50; outline-offset: 2px; }
.editor-card:hover { border-color: #706bea; }
.viewer-card:hover, .thumbnail-card:hover { border-color: #f04d45; }
.editor-card:hover { --hub-border: #706bea; }
.viewer-card:hover, .thumbnail-card:hover { --hub-border: #f04d45; }
.hub-preview { position: relative; width: 100%; aspect-ratio: 3 / 2; flex: 0 0 auto; overflow: hidden; border-bottom: 1px solid #252832; }
.preview-badge {
position: absolute; top: 14px; left: 14px; z-index: 4; padding: 5px 9px;
@@ -175,15 +235,20 @@ watch(locale, () => {
}
.badge-with-icon { display: inline-flex; align-items: center; gap: 6px; }
.badge-with-icon img { display: block; width: 16px; height: 16px; border-radius: 4px; }
.card-body { display: flex; flex: 1 1 auto; flex-direction: column; padding: clamp(16px, 1.7vw, 24px) clamp(16px, 1.8vw, 25px) clamp(18px, 2vw, 27px); }
.card-body { display: flex; flex: 1 1 auto; flex-direction: column; padding: clamp(16px, 1.7vw, 24px) clamp(16px, 1.8vw, 25px) 0; }
.card-body h2 { margin: 0 0 8px; color: #f1f2f5; font-size: clamp(22px, 1.8vw, 28px); line-height: 1.1; letter-spacing: -.02em; }
.card-body p { min-height: 40px; margin: 0; color: #858c9c; font-size: 12px; line-height: 1.65; }
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin: 14px 0 18px; }
.tag-row span { padding: 4px 8px; border: 1px solid #34394a; border-radius: 999px; background: #1a1e2a; color: #8f98ad; font-size: 9px; }
.editor-card .tag-row span { border-color: rgba(119,116,255,.35); background: rgba(74,72,150,.16); color: #aaa7f5; }
.viewer-card .tag-row span, .thumbnail-card .tag-row span { border-color: rgba(238,76,68,.24); background: rgba(111,36,37,.14); color: #d99a97; }
.card-cta { display: flex; align-items: center; justify-content: space-between; margin-top: auto; color: #f2f3f6; font-size: 12px; font-weight: 700; }
.card-cta b { color: #ff5a51; font-size: 18px; font-weight: 500; }
.card-action {
display: flex; width: 100%; min-height: 76px; flex: 0 0 auto; align-items: center; justify-content: center;
border-top: 1px solid var(--hub-divider); background: #13161d; color: #f3f4f7;
font-size: clamp(17px, 1.35vw, 21px); font-weight: 750; letter-spacing: -.01em;
transition: background-color .18s ease, color .18s ease;
}
.card-action:hover { border-top-color: #d94740; background: #d94740; color: #fff; }
.viewer-preview, .thumbnail-preview { background: #101218; }
.promotion-image { display: block; width: 100%; height: 100%; object-fit: cover; object-position: center; }
@media (max-width: 1180px) {
@@ -199,4 +264,19 @@ watch(locale, () => {
@media (prefers-reduced-motion: reduce) {
.hub-card { transition: none; }
}
.promotion-lightbox {
position: fixed; inset: 0; z-index: 9998; display: grid; place-items: center; padding: clamp(24px, 4vw, 60px);
background: rgba(3, 4, 8, .76); backdrop-filter: blur(8px); pointer-events: none;
}
.promotion-lightbox-frame {
display: flex; max-width: min(1400px, 92vw); max-height: 88vh; flex-direction: column; gap: 10px;
padding: 12px; border: 1px solid #353b49; border-radius: 14px;
background: #0d1017; box-shadow: 0 30px 90px rgba(0, 0, 0, .66);
}
.promotion-lightbox-title { color: #d8dce6; font-size: 12px; font-weight: 700; letter-spacing: .06em; }
.promotion-lightbox-frame img {
display: block; max-width: 100%; max-height: calc(88vh - 54px); object-fit: contain; border-radius: 8px;
}
.promotion-preview-enter-active, .promotion-preview-leave-active { transition: opacity .5s ease; }
.promotion-preview-enter-from, .promotion-preview-leave-to { opacity: 0; }
</style>