b97f7f94e4
- Stickers: captura automática por fileSha256 (Baileys) no MessageHandler,
tabela Prisma 'stickers' com deduplicação por [tenantId, sha256],
API REST /api/stickers (list + favorite toggle), cache IndexedDB permanente
(sem TTL — conteúdo imutável), StickerPanel.tsx com abas Recentes/Favoritos
- Emoji picker: migrado de emoji-picker-react para @emoji-mart/react com
locale="pt" (tradução completa: categorias, busca, mensagem de sem resultado)
- Preloader: aparece apenas em hard reload (F5/URL direta) via
performance.getEntriesByType('navigation').type + sessionStorage flag,
navegação client-side Next.js pula o overlay instantaneamente
- Avatar cache: Wasabi como storage persistente + IndexedDB client-side,
avatar_version via MD5(avatarUrl) sem coluna extra no banco,
rota /api/storage/view/* para servir arquivos Wasabi ao frontend
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
229 lines
7.5 KiB
TypeScript
229 lines
7.5 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { Heart, Clock, Loader2 } from 'lucide-react';
|
|
import { stickerApi, type StickerRecord } from '../services/chatApiService';
|
|
import { getStickerFromCache, setStickerInCache } from '../hooks/useStickerCache';
|
|
|
|
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3003';
|
|
const LIST_VERSION_KEY = 'nw:sticker-list-version';
|
|
|
|
interface StickerPanelProps {
|
|
instanceId: string;
|
|
jid: string;
|
|
onSend: (blob: Blob) => void;
|
|
}
|
|
|
|
// Converte URL Wasabi → URL do proxy do backend
|
|
function stickerSrcUrl(wasabiPath: string): string {
|
|
const clean = wasabiPath.startsWith('/') ? wasabiPath.slice(1) : wasabiPath;
|
|
return `${API}/api/storage/view/${clean}`;
|
|
}
|
|
|
|
// Carrega sticker: tenta IndexedDB, depois proxy, depois persiste no cache
|
|
function useStickerBlob(sticker: StickerRecord) {
|
|
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
getStickerFromCache(sticker.sha256).then((cached) => {
|
|
if (cancelled) return;
|
|
if (cached) { setDataUrl(cached); return; }
|
|
|
|
// Carrega do proxy e converte para dataURL via canvas
|
|
const img = new Image();
|
|
img.crossOrigin = 'anonymous';
|
|
img.onload = () => {
|
|
if (cancelled) return;
|
|
try {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = img.naturalWidth || 96;
|
|
canvas.height = img.naturalHeight || 96;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) { setDataUrl(img.src); return; }
|
|
ctx.drawImage(img, 0, 0);
|
|
const url = canvas.toDataURL('image/webp', 0.9);
|
|
setDataUrl(url);
|
|
setStickerInCache(sticker.sha256, url);
|
|
} catch {
|
|
setDataUrl(img.src);
|
|
}
|
|
};
|
|
img.onerror = () => { if (!cancelled) setDataUrl(null); };
|
|
img.src = stickerSrcUrl(sticker.wasabiPath);
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, [sticker.sha256, sticker.wasabiPath]);
|
|
|
|
return dataUrl;
|
|
}
|
|
|
|
// Item individual de sticker
|
|
function StickerItem({
|
|
sticker,
|
|
onSend,
|
|
onToggleFavorite,
|
|
}: {
|
|
sticker: StickerRecord;
|
|
onSend: (s: StickerRecord) => void;
|
|
onToggleFavorite: (s: StickerRecord) => void;
|
|
}) {
|
|
const dataUrl = useStickerBlob(sticker);
|
|
const [hovering, setHovering] = useState(false);
|
|
|
|
return (
|
|
<div
|
|
className="relative group cursor-pointer"
|
|
onMouseEnter={() => setHovering(true)}
|
|
onMouseLeave={() => setHovering(false)}
|
|
onClick={() => dataUrl && onSend(sticker)}
|
|
>
|
|
<div className="w-16 h-16 flex items-center justify-center rounded-xl hover:bg-black/5 transition-all active:scale-90 p-1">
|
|
{dataUrl ? (
|
|
<img
|
|
src={dataUrl}
|
|
alt=""
|
|
className="w-full h-full object-contain"
|
|
loading="lazy"
|
|
/>
|
|
) : (
|
|
<div className="w-10 h-10 bg-gray-100 rounded-lg animate-pulse" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Botão de favoritar — aparece no hover */}
|
|
{hovering && (
|
|
<button
|
|
className={`absolute top-0.5 right-0.5 p-0.5 rounded-full transition-all z-10 ${
|
|
sticker.isFavorite
|
|
? 'text-red-400 bg-white/90'
|
|
: 'text-gray-300 bg-white/80 hover:text-red-400'
|
|
}`}
|
|
onClick={(e) => { e.stopPropagation(); onToggleFavorite(sticker); }}
|
|
title={sticker.isFavorite ? 'Remover dos favoritos' : 'Favoritar'}
|
|
>
|
|
<Heart className="w-3 h-3" fill={sticker.isFavorite ? 'currentColor' : 'none'} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function StickerPanel({ instanceId, jid, onSend }: StickerPanelProps) {
|
|
const [tab, setTab] = useState<'recent' | 'favorites'>('recent');
|
|
const [stickers, setStickers] = useState<StickerRecord[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const fetchedRef = useRef(false);
|
|
|
|
const load = useCallback(async (force = false) => {
|
|
setLoading(true);
|
|
try {
|
|
const { listVersion, stickers: list } = await stickerApi.list();
|
|
|
|
if (!force) {
|
|
const cached = localStorage.getItem(LIST_VERSION_KEY);
|
|
if (cached === listVersion) {
|
|
// versão igual — sem novidades, mas ainda mostra o que temos
|
|
}
|
|
localStorage.setItem(LIST_VERSION_KEY, listVersion);
|
|
}
|
|
setStickers(list);
|
|
} catch {
|
|
// silencioso — sem stickers ainda
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (fetchedRef.current) return;
|
|
fetchedRef.current = true;
|
|
load();
|
|
}, [load]);
|
|
|
|
const handleSend = useCallback(async (sticker: StickerRecord) => {
|
|
const dataUrl = await getStickerFromCache(sticker.sha256);
|
|
if (!dataUrl) return;
|
|
|
|
// Converte dataURL → Blob → envia
|
|
const res = await fetch(dataUrl);
|
|
const blob = await res.blob();
|
|
onSend(blob);
|
|
}, [onSend]);
|
|
|
|
const handleToggleFavorite = useCallback(async (sticker: StickerRecord) => {
|
|
const result = await stickerApi.toggleFavorite(sticker.id);
|
|
setStickers((prev) =>
|
|
prev.map((s) => s.id === sticker.id ? { ...s, isFavorite: result.isFavorite } : s)
|
|
);
|
|
}, []);
|
|
|
|
const displayed =
|
|
tab === 'favorites'
|
|
? stickers.filter((s) => s.isFavorite)
|
|
: stickers; // recent = all sorted by lastSeenAt (backend already sorts)
|
|
|
|
return (
|
|
<div className="w-72 bg-white rounded-2xl shadow-2xl border border-gray-100 overflow-hidden">
|
|
{/* Tabs */}
|
|
<div className="flex border-b border-gray-100">
|
|
<button
|
|
onClick={() => setTab('recent')}
|
|
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
|
|
tab === 'recent' ? 'text-[#00a884] border-b-2 border-[#00a884]' : 'text-[#54656f] hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
<Clock className="w-3.5 h-3.5" />
|
|
Recentes
|
|
</button>
|
|
<button
|
|
onClick={() => setTab('favorites')}
|
|
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
|
|
tab === 'favorites' ? 'text-[#00a884] border-b-2 border-[#00a884]' : 'text-[#54656f] hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
<Heart className="w-3.5 h-3.5" />
|
|
Favoritos
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="h-[220px] overflow-y-auto p-2">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center h-full text-gray-300">
|
|
<Loader2 className="w-6 h-6 animate-spin" />
|
|
</div>
|
|
) : displayed.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center h-full gap-2 text-gray-300">
|
|
{tab === 'favorites' ? (
|
|
<>
|
|
<Heart className="w-8 h-8" />
|
|
<span className="text-xs">Nenhum favorito ainda</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="text-3xl">🙂</span>
|
|
<span className="text-xs text-center">
|
|
As figurinhas que você receber<br />aparecem aqui automaticamente
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-4 gap-0.5">
|
|
{displayed.map((s) => (
|
|
<StickerItem
|
|
key={s.id}
|
|
sticker={s}
|
|
onSend={handleSend}
|
|
onToggleFavorite={handleToggleFavorite}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|