Pripomienky: - odkaz /review/<token> so snimkou newslettra: nic sa neda upravit, da sa komentovat (spendlik), pisat text do newslettra a kreslit; meno autora sa pamata - pripomienky su ukotvene k modulu a ulozene k odkazu - vidi ich kazdy s odkazom - v editore: tlacidlo Pripomienky (pocet otvorenych), vytvorenie a kopirovanie odkazu, aktualizacia nahladu v odkaze, zoznam pripomienok, Hotovo a mazanie; pripomienky sa zobrazuju aj priamo na platne a daju sa tam oznacit ako hotove - supabase/migrations/002_reviews.sql (tabulky reviews, review_comments) - pismo Poppins v layoute (nahlad vernejsi e-mailu) Stitky predajni: - predvolena poloha -15 vodorovne / +5 zvisle od praveho horneho rohu pozadia cenovky - tlacidlo Polohu stitkov pouzit na vsetky produkty Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
186 lines
7.4 KiB
TypeScript
186 lines
7.4 KiB
TypeScript
'use client';
|
|
|
|
// Vrstva pripomienok nad newsletterom (stránka s odkazom aj editor).
|
|
// Kontajner musí mať position: relative a moduly v ňom atribút data-module-id.
|
|
import React, { useLayoutEffect, useState } from 'react';
|
|
import { ReviewComment } from '@/lib/reviews';
|
|
|
|
interface ModuleLayout {
|
|
width: number;
|
|
tops: Record<string, number>;
|
|
}
|
|
|
|
// Prepočet súradníc kliknutia na súradnice ukotvené k modulu
|
|
export function pointToModuleCoords(
|
|
container: HTMLElement,
|
|
clientX: number,
|
|
clientY: number,
|
|
moduleId?: string
|
|
): { moduleId: string; x: number; y: number } | null {
|
|
const rect = container.getBoundingClientRect();
|
|
const modules = Array.from(container.querySelectorAll<HTMLElement>('[data-module-id]'));
|
|
const target = moduleId
|
|
? modules.find((m) => m.dataset.moduleId === moduleId)
|
|
: modules.find((m) => {
|
|
const r = m.getBoundingClientRect();
|
|
return clientY >= r.top && clientY <= r.bottom;
|
|
}) || modules[modules.length - 1];
|
|
if (!target || !rect.width) return null;
|
|
const top = target.getBoundingClientRect().top;
|
|
return {
|
|
moduleId: target.dataset.moduleId!,
|
|
x: Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)),
|
|
y: Math.round(clientY - top)
|
|
};
|
|
}
|
|
|
|
// Poloha modulov v kontajneri; prepočíta sa pri zmene veľkosti a obsahu
|
|
export function useModuleLayout(containerRef: React.RefObject<HTMLElement | null>, deps: unknown[]): ModuleLayout | null {
|
|
const [layout, setLayout] = useState<ModuleLayout | null>(null);
|
|
useLayoutEffect(() => {
|
|
const el = containerRef.current;
|
|
if (!el) return;
|
|
const measure = () => {
|
|
const cr = el.getBoundingClientRect();
|
|
const tops: Record<string, number> = {};
|
|
el.querySelectorAll<HTMLElement>('[data-module-id]').forEach((m) => {
|
|
tops[m.dataset.moduleId!] = m.getBoundingClientRect().top - cr.top;
|
|
});
|
|
setLayout({ width: cr.width, tops });
|
|
};
|
|
measure();
|
|
const ro = new ResizeObserver(measure);
|
|
ro.observe(el);
|
|
// obrázky sa načítavajú postupne a menia výšku modulov
|
|
el.addEventListener('load', measure, true);
|
|
return () => {
|
|
ro.disconnect();
|
|
el.removeEventListener('load', measure, true);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [containerRef, ...deps]);
|
|
return layout;
|
|
}
|
|
|
|
interface AnnotationLayerProps {
|
|
layout: ModuleLayout | null;
|
|
comments: ReviewComment[];
|
|
showDone?: boolean;
|
|
selectedId?: string | null;
|
|
onSelect?: (id: string | null) => void;
|
|
// Akcie v bubline vybranej pripomienky (napr. Hotovo v editore)
|
|
renderActions?: (c: ReviewComment) => React.ReactNode;
|
|
}
|
|
|
|
export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
|
layout,
|
|
comments,
|
|
showDone = true,
|
|
selectedId,
|
|
onSelect,
|
|
renderActions
|
|
}) => {
|
|
if (!layout) return null;
|
|
const visible = comments.filter((c) => (showDone || !c.done) && layout.tops[c.module_id] !== undefined);
|
|
const pos = (moduleId: string, x: number, y: number) => ({ left: x * layout.width, top: layout.tops[moduleId] + y });
|
|
const commentNumbers = new Map(comments.filter((c) => c.kind === 'comment').map((c, i) => [c.id, i + 1]));
|
|
|
|
return (
|
|
<div className="absolute inset-0 pointer-events-none z-30">
|
|
{/* Kresby */}
|
|
<svg className="absolute inset-0 w-full h-full overflow-visible">
|
|
{visible
|
|
.filter((c) => c.kind === 'drawing' && c.path && c.path.length > 1)
|
|
.map((c) => (
|
|
<polyline
|
|
key={c.id}
|
|
points={c.path!.map(([x, y]) => {
|
|
const p = pos(c.module_id, x, y);
|
|
return `${p.left},${p.top}`;
|
|
}).join(' ')}
|
|
fill="none"
|
|
stroke={c.color}
|
|
strokeWidth={selectedId === c.id ? 5 : 3}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
opacity={c.done ? 0.3 : 0.9}
|
|
className="pointer-events-auto cursor-pointer"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onSelect?.(selectedId === c.id ? null : c.id);
|
|
}}
|
|
/>
|
|
))}
|
|
</svg>
|
|
|
|
{/* Komentáre (špendlíky) a texty */}
|
|
{visible
|
|
.filter((c) => c.kind !== 'drawing')
|
|
.map((c) => {
|
|
const p = pos(c.module_id, c.x, c.y);
|
|
const isSelected = selectedId === c.id;
|
|
return (
|
|
<div
|
|
key={c.id}
|
|
className="absolute pointer-events-auto"
|
|
style={{ left: p.left, top: p.top, zIndex: isSelected ? 40 : 30 }}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onSelect?.(isSelected ? null : c.id);
|
|
}}
|
|
>
|
|
{c.kind === 'comment' ? (
|
|
<div
|
|
className={`-translate-x-1/2 -translate-y-full w-7 h-7 rounded-full rounded-bl-none rotate-[-45deg] flex items-center justify-center shadow-lg border-2 border-white cursor-pointer ${c.done ? 'opacity-40' : ''} ${isSelected ? 'scale-125' : ''}`}
|
|
style={{ backgroundColor: c.color }}
|
|
>
|
|
<span className="rotate-45 text-[11px] font-black text-white">{c.done ? '✓' : commentNumbers.get(c.id)}</span>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className={`-translate-y-1/2 max-w-[260px] px-1.5 py-0.5 rounded bg-white/85 text-[13px] font-bold leading-tight whitespace-pre-wrap shadow cursor-pointer ${c.done ? 'opacity-40 line-through' : ''} ${isSelected ? 'ring-2 ring-offset-1' : ''}`}
|
|
style={{ color: c.color }}
|
|
>
|
|
{c.text}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Bublina vybranej pripomienky */}
|
|
{(() => {
|
|
const c = visible.find((v) => v.id === selectedId);
|
|
if (!c || c.kind === 'text') {
|
|
return c && renderActions ? (
|
|
<div className="absolute pointer-events-auto" style={{ left: pos(c.module_id, c.x, c.y).left, top: pos(c.module_id, c.x, c.y).top + 14 }}>
|
|
<div className="flex gap-1 bg-white rounded-lg shadow-xl border border-gray-200 p-1">{renderActions(c)}</div>
|
|
</div>
|
|
) : null;
|
|
}
|
|
const anchor = c.kind === 'drawing' && c.path?.length ? c.path[c.path.length - 1] : [c.x, c.y];
|
|
const p = pos(c.module_id, anchor[0], anchor[1]);
|
|
return (
|
|
<div
|
|
className="absolute pointer-events-auto w-64 bg-white rounded-xl shadow-2xl border border-gray-200 p-3 text-left"
|
|
style={{ left: Math.min(p.left + 12, layout.width - 256), top: p.top + 8, zIndex: 50 }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center justify-between gap-2 mb-1">
|
|
<span className="text-[11px] font-bold text-gray-900 truncate">{c.author || 'Anonym'}</span>
|
|
<span className="text-[10px] text-gray-400 shrink-0">{new Date(c.created_at).toLocaleString('sk-SK')}</span>
|
|
</div>
|
|
{c.text ? (
|
|
<p className="text-xs text-gray-700 whitespace-pre-wrap">{c.text}</p>
|
|
) : (
|
|
<p className="text-xs text-gray-400 italic">{c.kind === 'drawing' ? 'Kresba bez poznámky' : ''}</p>
|
|
)}
|
|
{c.done && <p className="mt-1 text-[10px] font-bold text-emerald-600">✓ Hotovo</p>}
|
|
{renderActions && <div className="mt-2 flex gap-1.5">{renderActions(c)}</div>}
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
);
|
|
};
|