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>
This commit is contained in:
1 parent
b0a050bd12
commit
cfacf2474b
13 files changed
+1162
-10
No files matched your search
@@ -0,0 +1,185 @@
|
||||
'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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user