Files
Coop-NL/src/components/review/AnnotationLayer.tsx
T
Jakub_HostacnyandClaude Opus 5.5 cfacf2474b feat: pripomienky cez zdielany odkaz; predvolena poloha stitkov a pouzit na vsetky
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>
2026-10-21 11:03:34 +02:00

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>
);
};