From f9aa2297ba512115b1368a1ba3691ae2b2db850a Mon Sep 17 00:00:00 2001 From: Jandl-hostacny Date: Thu, 24 Sep 2026 11:52:24 +0200 Subject: [PATCH] feat: drag and drop text layers in badge editor, anchor flyers/recipes above footer, and editable titles in flyers section --- src/app/page.tsx | 85 +++++++++----- src/components/editor/BadgeEditorModal.tsx | 129 +++++++++++++++++++-- src/components/editor/ConfigPanel.tsx | 34 +++++- 3 files changed, 205 insertions(+), 43 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 89b815f..6ce4467 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -27,15 +27,31 @@ export default function Home() { const [isSavedCampaignsOpen, setIsSavedCampaignsOpen] = useState(false); const [targetProductIdForBadge, setTargetProductIdForBadge] = useState(null); - // Pomocné funkcie pre fixné ukotvenie hlavičky a pätičky - const isTopModule = (type: string) => type === 'header' || type === 'announcement'; - const isBottomModule = (type: string) => type === 'footer'; - const isAnchoredModule = (type: string) => isTopModule(type) || isBottomModule(type); + // Pomocné funkcie pre fixné ukotvenie hlavičky, spodných letákov, receptov a pätičky + const isTopModule = (m: NewsletterModule) => m.type === 'header' || m.type === 'announcement'; + + const isFlyersModule = (m: NewsletterModule) => + m.id === 'mod-flyers-row' || + (m.type === 'articles_row' && m.title?.toLowerCase().includes('leták')); + + const isRecipesModule = (m: NewsletterModule) => + m.id === 'mod-recipes-row' || + (m.type === 'articles_row' && + (m.title?.toLowerCase().includes('recept') || m.title?.toLowerCase().includes('článk'))); + + const isFooterModule = (m: NewsletterModule) => m.type === 'footer'; + + const isBottomModule = (m: NewsletterModule) => + isFlyersModule(m) || isRecipesModule(m) || isFooterModule(m); + + const isAnchoredModule = (m: NewsletterModule) => isTopModule(m) || isBottomModule(m); const enforceAnchors = (mods: NewsletterModule[]): NewsletterModule[] => { - const topMods = mods.filter((m) => isTopModule(m.type)); - const bottomMods = mods.filter((m) => isBottomModule(m.type)); - const middleMods = mods.filter((m) => !isAnchoredModule(m.type)); + const topMods = mods.filter((m) => isTopModule(m)); + const flyerMods = mods.filter((m) => isFlyersModule(m)); + const recipeMods = mods.filter((m) => isRecipesModule(m)); + const footerMods = mods.filter((m) => isFooterModule(m)); + const middleMods = mods.filter((m) => !isAnchoredModule(m)); // announcement je pred headerom, ak existujú oba topMods.sort((a, b) => { @@ -44,7 +60,7 @@ export default function Home() { return 0; }); - return [...topMods, ...middleMods, ...bottomMods]; + return [...topMods, ...middleMods, ...flyerMods, ...recipeMods, ...footerMods]; }; // Načítanie existujúceho draftu z databázy pri štarte @@ -135,14 +151,14 @@ export default function Home() { })); }; - // Posun modulu: Hlavička a pätička sú ukotvené a bežné moduly sa nemôžu dostať pred hlavičku ani za pätičku + // Posun modulu: Hlavička, letáky, recepty a pätička sú ukotvené a bežné moduly sa nemôžu dostať mimo vyhradený stred const moveModule = (index: number, direction: 'up' | 'down') => { const currentMod = campaign.modules[index]; - if (!currentMod || isAnchoredModule(currentMod.type)) return; + if (!currentMod || isAnchoredModule(currentMod)) return; - const topCount = campaign.modules.filter((m) => isTopModule(m.type)).length; - const footerIndex = campaign.modules.findIndex((m) => isBottomModule(m.type)); - const maxAllowedIndex = footerIndex !== -1 ? footerIndex - 1 : campaign.modules.length - 1; + const topCount = campaign.modules.filter((m) => isTopModule(m)).length; + const firstBottomIndex = campaign.modules.findIndex((m) => isBottomModule(m)); + const maxAllowedIndex = firstBottomIndex !== -1 ? firstBottomIndex - 1 : campaign.modules.length - 1; const targetIndex = direction === 'up' ? index - 1 : index + 1; if (targetIndex < topCount || targetIndex > maxAllowedIndex) return; @@ -155,10 +171,10 @@ export default function Home() { setCampaign((prev) => ({ ...prev, modules: enforceAnchors(newModules) })); }; - // Duplikácia: Hlavička a pätička sa nedajú duplikovať + // Duplikácia: Ukotvené moduly sa nedajú duplikovať const duplicateModule = (index: number) => { const original = campaign.modules[index]; - if (isAnchoredModule(original.type)) return; + if (isAnchoredModule(original)) return; const clone: NewsletterModule = { ...original, @@ -173,10 +189,13 @@ export default function Home() { setSelectedModuleId(clone.id); }; - // Zmazanie: Hlavička a pätička sa nedajú vymazať + // Zmazanie: Ukotvené moduly (hlavička, letáky, články/recepty, pätička) sa nedajú vymazať const deleteModule = (index: number) => { const mod = campaign.modules[index]; - if (isAnchoredModule(mod.type)) return; + if (isAnchoredModule(mod)) { + alert('Hlavičku, letáky, články/recepty ani pätičku nie je možné vymazať.'); + return; + } if (campaign.modules.length <= 1) return; const newModules = campaign.modules.filter((_, i) => i !== index); @@ -184,7 +203,7 @@ export default function Home() { setSelectedModuleId(newModules[0]?.id || null); }; - // Pridanie modulu: Vloží sa vždy PRED pätičku (pätička ostáva pevne na spodku) + // Pridanie modulu: Vloží sa vždy PRED spodné bloky (letáky, recepty a pätičku) const handleAddModule = (type: any) => { let newMod: NewsletterModule; if (type === 'banner') { @@ -236,10 +255,10 @@ export default function Home() { } setCampaign((prev) => { - const footerIdx = prev.modules.findIndex((m) => isBottomModule(m.type)); + const firstBottomIdx = prev.modules.findIndex((m) => isBottomModule(m)); const newModules = [...prev.modules]; - if (footerIdx !== -1) { - newModules.splice(footerIdx, 0, newMod); + if (firstBottomIdx !== -1) { + newModules.splice(firstBottomIdx, 0, newMod); } else { newModules.push(newMod); } @@ -471,8 +490,8 @@ export default function Home() { }); }; - const topCount = campaign.modules.filter((m) => isTopModule(m.type)).length; - const footerIdx = campaign.modules.findIndex((m) => isBottomModule(m.type)); + const topCount = campaign.modules.filter((m) => isTopModule(m)).length; + const firstBottomIdx = campaign.modules.findIndex((m) => isBottomModule(m)); return (
@@ -507,12 +526,14 @@ export default function Home() {
{campaign.modules.map((mod, idx) => { const isSelected = mod.id === selectedModuleId; - const isAnchored = isAnchoredModule(mod.type); - const isTop = isTopModule(mod.type); - const isBottom = isBottomModule(mod.type); + const isAnchored = isAnchoredModule(mod); + const isTop = isTopModule(mod); + const isFlyers = isFlyersModule(mod); + const isRecipes = isRecipesModule(mod); + const isFooter = isFooterModule(mod); const canMoveUp = !isAnchored && idx > topCount; - const canMoveDown = !isAnchored && idx < (footerIdx !== -1 ? footerIdx - 1 : campaign.modules.length - 1); + const canMoveDown = !isAnchored && idx < (firstBottomIdx !== -1 ? firstBottomIdx - 1 : campaign.modules.length - 1); return (
- {/* Ak je modul ukotvený (Hlavička / Pätička), nezobrazujeme tlačidlá na presun a zmazanie */} + {/* Ak je modul ukotvený (Hlavička / Letáky / Recepty / Pätička), nezobrazujeme tlačidlá na presun a zmazanie */} {isAnchored ? ( - {isBottom ? 'Ukotvená pätička' : 'Ukotvená hlavička'} + {isFooter + ? 'Ukotvená pätička' + : isFlyers + ? 'Ukotvené letáky' + : isRecipes + ? 'Ukotvené recepty' + : 'Ukotvená hlavička'} ) : ( <> diff --git a/src/components/editor/BadgeEditorModal.tsx b/src/components/editor/BadgeEditorModal.tsx index 85b9754..c3dac3d 100644 --- a/src/components/editor/BadgeEditorModal.tsx +++ b/src/components/editor/BadgeEditorModal.tsx @@ -4,7 +4,7 @@ import React, { useState, useRef, useEffect } from 'react'; import { BadgeTemplate, BadgeLayer } from '@/types/newsletter'; import { supabase } from '@/lib/supabase'; import { toPng } from 'html-to-image'; -import { Tag, Plus, Check, Trash2, X, Upload, Eye, FileCode } from 'lucide-react'; +import { Tag, Plus, Check, Trash2, X, Upload, Eye, FileCode, Move } from 'lucide-react'; interface BadgeEditorModalProps { isOpen: boolean; @@ -72,6 +72,98 @@ export const BadgeEditorModal: React.FC = ({ const [isExportingPng, setIsExportingPng] = useState(false); const [isSavingDb, setIsSavingDb] = useState(false); + // Stav pre priamy Drag & Drop vrstiev na plátne + const [dragState, setDragState] = useState<{ + layerId: string; + startX: number; + startY: number; + layerStartX: number; + layerStartY: number; + } | null>(null); + + const handleLayerMouseDown = (e: React.MouseEvent, layer: BadgeLayer) => { + e.stopPropagation(); + e.preventDefault(); + setSelectedLayerId(layer.id); + setDragState({ + layerId: layer.id, + startX: e.clientX, + startY: e.clientY, + layerStartX: layer.x, + layerStartY: layer.y + }); + }; + + const handleLayerTouchStart = (e: React.TouchEvent, layer: BadgeLayer) => { + e.stopPropagation(); + const touch = e.touches[0]; + if (!touch) return; + setSelectedLayerId(layer.id); + setDragState({ + layerId: layer.id, + startX: touch.clientX, + startY: touch.clientY, + layerStartX: layer.x, + layerStartY: layer.y + }); + }; + + useEffect(() => { + if (!dragState) return; + + const handleMouseMove = (e: MouseEvent) => { + const deltaX = e.clientX - dragState.startX; + const deltaY = e.clientY - dragState.startY; + const newX = Math.round(dragState.layerStartX + deltaX); + const newY = Math.round(dragState.layerStartY + deltaY); + + setActiveBadge((prev) => { + if (!prev) return prev; + return { + ...prev, + layersConfig: prev.layersConfig.map((l) => + l.id === dragState.layerId ? { ...l, x: newX, y: newY } : l + ) + }; + }); + }; + + const handleTouchMove = (e: TouchEvent) => { + const touch = e.touches[0]; + if (!touch) return; + const deltaX = touch.clientX - dragState.startX; + const deltaY = touch.clientY - dragState.startY; + const newX = Math.round(dragState.layerStartX + deltaX); + const newY = Math.round(dragState.layerStartY + deltaY); + + setActiveBadge((prev) => { + if (!prev) return prev; + return { + ...prev, + layersConfig: prev.layersConfig.map((l) => + l.id === dragState.layerId ? { ...l, x: newX, y: newY } : l + ) + }; + }); + }; + + const handleDragEnd = () => { + setDragState(null); + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleDragEnd); + window.addEventListener('touchmove', handleTouchMove); + window.addEventListener('touchend', handleDragEnd); + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleDragEnd); + window.removeEventListener('touchmove', handleTouchMove); + window.removeEventListener('touchend', handleDragEnd); + }; + }, [dragState]); + // Načítanie písiem pre všetky vrstvy aktívnej cenovky useEffect(() => { if (activeBadge?.layersConfig) { @@ -388,6 +480,9 @@ export const BadgeEditorModal: React.FC = ({ Náhľad cenovky + + Drag & Drop textových polí +
{activeBadge ? ( @@ -395,22 +490,27 @@ export const BadgeEditorModal: React.FC = ({ {/* Vektorové plátno pre export do PNG */}
{/* SVG Podklad */}
- {/* Dynamické textové vrstvy nad SVG podkladom */} + {/* Dynamické textové vrstvy nad SVG podkladom (Drag & Drop) */} {activeBadge.layersConfig.map((layer) => { const isSelected = layer.id === selectedLayerId; + const isDraggingThis = dragState?.layerId === layer.id; return (
setSelectedLayerId(layer.id)} + onMouseDown={(e) => handleLayerMouseDown(e, layer)} + onTouchStart={(e) => handleLayerTouchStart(e, layer)} + onClick={(e) => { + e.stopPropagation(); + setSelectedLayerId(layer.id); + }} style={{ position: 'absolute', left: `${layer.x}px`, @@ -423,15 +523,24 @@ export const BadgeEditorModal: React.FC = ({ ? `'${layer.fontFamily}', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif` : 'Poppins, Arial, sans-serif', whiteSpace: 'nowrap', - cursor: 'pointer' + cursor: isDraggingThis ? 'grabbing' : 'grab', + userSelect: 'none' }} - className={`px-1.5 py-0.5 rounded transition-all ${ + className={`px-1.5 py-0.5 rounded transition-all select-none ${ isSelected - ? 'ring-2 ring-blue-400 ring-offset-2 ring-offset-transparent bg-blue-500/10' - : 'hover:outline hover:outline-1 hover:outline-white/40' + ? 'ring-2 ring-blue-400 ring-offset-2 ring-offset-slate-900 bg-blue-500/20 shadow-lg' + : 'hover:outline hover:outline-1 hover:outline-white/60 hover:bg-white/10' }`} + title="Kliknite a ťahajte myšou pre zmenu pozície (Drag & Drop)" > {replaceVariables(layer.text || '')} + + {/* Indikátor pozície a uchopenia */} + {isSelected && ( + + {layer.x}px, {layer.y}px + + )}
); })} diff --git a/src/components/editor/ConfigPanel.tsx b/src/components/editor/ConfigPanel.tsx index afe1edf..f0e7177 100644 --- a/src/components/editor/ConfigPanel.tsx +++ b/src/components/editor/ConfigPanel.tsx @@ -838,10 +838,22 @@ export const ConfigPanel: React.FC = ({
- Leták 1: {module.data.item1?.title || 'Aktuálne letáky'} + Leták 1 (Ľavý stĺpec) -

Farby, texty a rozloženie sú fixne podľa šablóny. Môžete upraviť odkaz a náhľadový obrázok.

+

Môžete upraviť nadpis letáku, cieľový odkaz a náhľadový obrázok.

+
+ + updateData({ + item1: { ...module.data.item1, title: e.target.value } + })} + className="w-full text-xs border border-gray-300 rounded p-1.5 bg-white font-bold" + placeholder="Aktuálne letáky" + /> +
= ({ item1: { ...module.data.item1, linkUrl: e.target.value } })} className="w-full text-xs border border-gray-300 rounded p-1.5 bg-white" + placeholder="https://www.coop.sk/sk/letaky" />
= ({
- Leták 2: {module.data.item2?.title || 'Rozšírený leták'} + Leták 2 (Pravý stĺpec) -

Farby, texty a rozloženie sú fixne podľa šablóny. Môžete upraviť odkaz a náhľadový obrázok.

+

Môžete upraviť nadpis letáku, cieľový odkaz a náhľadový obrázok.

+
+ + updateData({ + item2: { ...module.data.item2, title: e.target.value } + })} + className="w-full text-xs border border-gray-300 rounded p-1.5 bg-white font-bold" + placeholder="Rozšírený leták" + /> +
= ({ item2: { ...module.data.item2, linkUrl: e.target.value } })} className="w-full text-xs border border-gray-300 rounded p-1.5 bg-white" + placeholder="https://www.coop.sk/sk/letaky" />