zakladna implementacia by Codex

This commit is contained in:
2026-08-01 07:28:21 +02:00
parent 1b71803407
commit a5973a5168
21 changed files with 1629 additions and 46 deletions

View File

@ -0,0 +1,109 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useMosaicStore } from '@/stores/mosaic'
import EditorToolbar from '@/components/EditorToolbar.vue'
import MosaicCanvas from '@/components/MosaicCanvas.vue'
import SettingsPanel from '@/components/SettingsPanel.vue'
import ColorGroupList from '@/components/ColorGroupList.vue'
import RegionList from '@/components/RegionList.vue'
import ExportPanel from '@/components/ExportPanel.vue'
const store = useMosaicStore()
const { imageName, imageWidth, imageHeight, regions, groups, toasts, hasRecovery, recoveryTimestamp } = storeToRefs(store)
const mosaicCanvas = ref<InstanceType<typeof MosaicCanvas> | null>(null)
function keyboard(event: KeyboardEvent): void {
const target = event.target as HTMLElement | null
if (target?.matches('input, select, textarea, [contenteditable="true"]')) return
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
event.preventDefault()
if (event.shiftKey) store.redo()
else store.undo()
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'y') {
event.preventDefault()
store.redo()
} else if (event.key === 'Delete' && store.selectedRegionId) {
event.preventDefault()
store.removeRegion(store.selectedRegionId)
} else if (event.key === 'Escape') {
store.selectedRegionId = null
store.selectedGroupId = null
}
}
onMounted(() => {
window.addEventListener('keydown', keyboard)
void store.checkRecovery()
})
onBeforeUnmount(() => window.removeEventListener('keydown', keyboard))
</script>
<template>
<div class="app-shell">
<EditorToolbar @reset-viewport="mosaicCanvas?.resetViewport()" />
<div v-if="hasRecovery" class="recovery-banner">
<span><b>Nájdená rozpracovaná práca.</b> Uložená {{ new Date(recoveryTimestamp).toLocaleString('sk-SK') }}.</span>
<div><button class="button primary" type="button" @click="store.restoreRecovery">Pokračovať</button><button class="button" type="button" @click="store.dismissRecovery">Zahodiť</button></div>
</div>
<main class="workspace">
<section class="editor-pane"><MosaicCanvas ref="mosaicCanvas" /></section>
<aside class="control-panel">
<section class="panel-section image-info">
<div class="section-heading"><h2>Obrázok</h2><span class="local-badge">Iba lokálne</span></div>
<template v-if="imageWidth">
<b class="filename" :title="imageName">{{ imageName }}</b>
<div class="stats"><span>{{ imageWidth }} × {{ imageHeight }} px</span><span>{{ regions.length }} plôch</span><span>{{ groups.length }} skupín</span></div>
</template>
<p v-else class="empty-copy">Načítajte obrázok a klikajte dovnútra samostatných farebných plôch.</p>
</section>
<SettingsPanel />
<ColorGroupList />
<RegionList />
<ExportPanel />
</aside>
</main>
<div class="toast-stack" aria-live="polite">
<button v-for="toast in toasts" :key="toast.id" type="button" class="toast" :class="toast.type" @click="store.dismissToast(toast.id)">{{ toast.text }}</button>
</div>
</div>
</template>
<style>
:root { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #1e293b; background: #f2f4f7; font-synthesis: none; text-rendering: optimizeLegibility; }
* { box-sizing: border-box; }
html, body, #app { min-width: 320px; min-height: 100%; margin: 0; }
body { min-height: 100vh; overflow: hidden; }
button, input, select { font: inherit; }
button:focus-visible, input:focus-visible, select:focus-visible, canvas:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.app-shell { min-height: 100vh; }
.workspace { display: grid; height: calc(100vh - 58px); grid-template-columns: minmax(0, 1fr) 360px; }
.editor-pane { min-width: 0; min-height: 0; border-right: 1px solid #d9e0e7; }
.control-panel { min-width: 0; overflow-y: auto; background: #f8fafc; }
.panel-section { padding: 15px; border-bottom: 1px solid #dfe5eb; }
.panel-section h2 { margin: 0; color: #263244; font-size: .82rem; font-weight: 720; letter-spacing: .01em; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.section-heading > span { color: #718096; font-size: .7rem; }
.empty-copy { margin: 10px 0 0; color: #718096; font-size: .73rem; line-height: 1.5; }
.button { min-height: 31px; padding: 6px 10px; border: 1px solid #cbd5e1; border-radius: 7px; background: #fff; color: #334155; font-size: .73rem; font-weight: 600; white-space: nowrap; cursor: pointer; }
.button:hover:not(:disabled) { border-color: #94a3b8; background: #f8fafc; }
.button.primary { border-color: #1d4ed8; background: #2563eb; color: white; }
.button.primary:hover:not(:disabled) { border-color: #1e40af; background: #1d4ed8; }
.button.danger-quiet { color: #b42318; }
.button.small { min-height: 28px; padding: 4px 8px; font-size: .67rem; }
.button:disabled, .icon-button:disabled { opacity: .42; cursor: not-allowed; }
.icon-button { display: grid; width: 32px; height: 32px; place-items: center; padding: 0; border: 1px solid #cbd5e1; border-radius: 7px; background: white; color: #334155; font-size: 19px; cursor: pointer; }
select { min-height: 31px; padding: 5px 7px; border: 1px solid #cbd5e1; border-radius: 7px; background: white; color: #334155; font-size: .72rem; }
input[type="range"] { width: 100%; accent-color: #2563eb; }
input[type="checkbox"] { accent-color: #2563eb; }
.image-info .filename { display: block; overflow: hidden; margin-top: 9px; color: #334155; font-size: .76rem; text-overflow: ellipsis; white-space: nowrap; }
.stats { display: flex; flex-wrap: wrap; gap: 5px 11px; margin-top: 7px; color: #64748b; font-size: .68rem; }
.local-badge { padding: 3px 6px; border-radius: 9px; background: #e8f5ee; color: #247044 !important; font-weight: 650; }
.recovery-banner { position: fixed; z-index: 30; top: 68px; left: 50%; display: flex; max-width: min(680px, calc(100vw - 24px)); align-items: center; gap: 16px; transform: translateX(-50%); padding: 11px 13px; border: 1px solid #93b4e8; border-radius: 10px; background: #eff6ff; box-shadow: 0 10px 30px #0f172a22; color: #334155; font-size: .76rem; }
.recovery-banner > div { display: flex; gap: 6px; }
.toast-stack { position: fixed; z-index: 50; right: 16px; bottom: 16px; display: grid; width: min(370px, calc(100vw - 32px)); gap: 8px; }
.toast { padding: 11px 13px; border: 1px solid #cbd5e1; border-left-width: 4px; border-radius: 8px; background: white; box-shadow: 0 8px 25px #0f172a22; color: #334155; font-size: .76rem; line-height: 1.4; text-align: left; cursor: pointer; }
.toast.success { border-left-color: #16a34a; }.toast.warning { border-left-color: #d97706; }.toast.error { border-left-color: #dc2626; }.toast.info { border-left-color: #2563eb; }
@media (max-width: 950px) { .workspace { grid-template-columns: minmax(0,1fr) 330px; } }
@media (max-width: 800px) { body { overflow: auto; } .workspace { height: auto; grid-template-columns: 1fr; } .editor-pane { height: 58vh; border-right: 0; border-bottom: 1px solid #d9e0e7; } .control-panel { overflow: visible; } .recovery-banner { align-items: flex-start; } }
</style>