mirror of
https://github.com/PeterMaquiran/tvone.git
synced 2026-04-18 07:17:52 +00:00
add
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import Cropper from 'react-easy-crop';
|
||||
import { ImageIcon, Maximize2, RefreshCw, Copy, Check, X } from 'lucide-react';
|
||||
|
||||
const ASPECT_RATIOS = [
|
||||
{ id: 'hero', label: 'Hero Banner (21:9)', ratio: 21 / 9 },
|
||||
{ id: 'news', label: 'Notícia/Media (16:9)', ratio: 16 / 9 },
|
||||
{ id: 'square', label: 'Quadrado/Social (1:1)', ratio: 1 / 1 },
|
||||
];
|
||||
|
||||
const MultiAspectEditor = ({ image, onClose, onExport }: { image: string, onClose: () => void, onExport: (results: Record<string, string>) => void }) => {
|
||||
const [crops, setCrops] = useState(
|
||||
ASPECT_RATIOS.reduce((acc, curr) => ({
|
||||
...acc,
|
||||
[curr.id]: { crop: { x: 0, y: 0 }, zoom: 1, croppedAreaPixels: null }
|
||||
}), {})
|
||||
);
|
||||
|
||||
const onCropChange = (id: string, crop: any) => {
|
||||
setCrops(prev => ({ ...prev, [id]: { ...(prev as any)[id], crop } }));
|
||||
};
|
||||
|
||||
const onZoomChange = (id: string, zoom: number) => {
|
||||
setCrops(prev => ({ ...prev, [id]: { ...(prev as any)[id], zoom } }));
|
||||
};
|
||||
|
||||
const onCropComplete = useCallback((id: string, _: any, croppedAreaPixels: any) => {
|
||||
setCrops(prev => ({ ...prev, [id]: { ...(prev as any)[id], croppedAreaPixels } }));
|
||||
}, []);
|
||||
|
||||
const generateBase64 = async () => {
|
||||
const results: Record<string, string> = {};
|
||||
for (const ratio of ASPECT_RATIOS) {
|
||||
const pixelCrop = (crops as any)[ratio.id].croppedAreaPixels;
|
||||
if (pixelCrop) {
|
||||
results[ratio.id as keyof typeof results] = await getCroppedImg(image, pixelCrop);
|
||||
}
|
||||
}
|
||||
onExport(results);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="bg-white w-full max-w-6xl h-[90vh] rounded-3xl shadow-2xl flex flex-col overflow-hidden border border-white/20">
|
||||
|
||||
{/* Header do Modal */}
|
||||
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-white/50 backdrop-blur-md">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">Editor de Enquadramento</h2>
|
||||
<p className="text-xs text-slate-500">Ajuste a imagem para os diferentes formatos do portal</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-full transition-colors">
|
||||
<X size={20} className="text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Área de Edição - Grid de Croppers */}
|
||||
<div className="flex-1 overflow-y-auto p-8 bg-slate-50/50">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{ASPECT_RATIOS.map((r) => (
|
||||
<div key={r.id} className="flex flex-col gap-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-400">{r.label}</span>
|
||||
<button
|
||||
onClick={() => onZoomChange(r.id, 1)}
|
||||
className="text-blue-600 p-1 hover:bg-blue-50 rounded text-[10px] flex items-center gap-1"
|
||||
>
|
||||
<RefreshCw size={12}/> Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Container do Cropper */}
|
||||
<div className="relative h-64 bg-slate-200 rounded-2xl overflow-hidden border border-slate-200 shadow-inner">
|
||||
<Cropper
|
||||
image={image}
|
||||
crop={(crops as any)[r.id].crop}
|
||||
zoom={(crops as any)[r.id].zoom}
|
||||
aspect={r.ratio}
|
||||
onCropChange={(c) => onCropChange(r.id, c)}
|
||||
onCropComplete={(_, pix) => onCropComplete(r.id, _, pix)}
|
||||
onZoomChange={(z) => onZoomChange(r.id, z)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Slider de Zoom Customizado */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-slate-400"><Maximize2 size={14}/></span>
|
||||
<input
|
||||
type="range"
|
||||
value={(crops as any)[r.id].zoom}
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
aria-labelledby="Zoom"
|
||||
onChange={(e) => onZoomChange(r.id, Number(e.target.value))}
|
||||
className="w-full h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer com Ações */}
|
||||
<div className="p-6 border-t border-slate-100 bg-white flex justify-end gap-4">
|
||||
<button onClick={onClose} className="px-6 py-2.5 text-sm font-semibold text-slate-500 hover:text-slate-700">
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={generateBase64}
|
||||
className="px-8 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-bold shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all flex items-center gap-2"
|
||||
>
|
||||
<Check size={18}/> Finalizar e Exportar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Função Utilitária para Canvas -> Base64
|
||||
async function getCroppedImg(imageSrc: string, pixelCrop: any) {
|
||||
const image = await new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.addEventListener('load', () => resolve(img));
|
||||
img.addEventListener('error', (error) => reject(error));
|
||||
img.setAttribute('crossOrigin', 'anonymous');
|
||||
img.src = imageSrc;
|
||||
});
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D | null;
|
||||
if (!ctx) return '';
|
||||
|
||||
canvas.width = pixelCrop.width;
|
||||
canvas.height = pixelCrop.height;
|
||||
|
||||
ctx.drawImage(
|
||||
image as CanvasImageSource,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height
|
||||
);
|
||||
|
||||
return canvas.toDataURL('image/jpeg', 0.9);
|
||||
}
|
||||
|
||||
export default MultiAspectEditor;
|
||||
+91
-36
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
"use client";
|
||||
import React, { useState, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import {
|
||||
LayoutDashboard, Newspaper, Users, BarChart3,
|
||||
@@ -6,19 +7,58 @@ import {
|
||||
Type, Calendar, Clock, Tag, User, Save, Eye, Send
|
||||
} from 'lucide-react';
|
||||
|
||||
// Importe o componente que criámos (ajuste o caminho se necessário)
|
||||
import MultiAspectEditor from '../components/MultiAspectEditor';
|
||||
|
||||
const CreateNewsPage = () => {
|
||||
// 1. Estados para o Crop
|
||||
const [tempImage, setTempImage] = useState<string | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [finalCrops, setFinalCrops] = useState<any>(null); // Guarda os Base64 finais
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 2. Lógica de Upload
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(e.target.files[0]);
|
||||
reader.onload = () => {
|
||||
setTempImage(reader.result as string);
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const triggerUpload = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-slate-100/50 text-slate-900 font-sans">
|
||||
|
||||
{/* Sidebar Lateral - Glassmorphism */}
|
||||
{/* --- MODAL DO EDITOR (Renderiza fora do fluxo quando ativo) --- */}
|
||||
{isEditorOpen && tempImage && (
|
||||
<MultiAspectEditor
|
||||
image={tempImage}
|
||||
onClose={() => setIsEditorOpen(false)}
|
||||
onExport={(data) => {
|
||||
setFinalCrops(data); // Aqui tens o objeto com hero, news, square em Base64
|
||||
setIsEditorOpen(false);
|
||||
console.log("Imagens prontas para envio:", data);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar Lateral */}
|
||||
<aside className="w-64 backdrop-blur-xl bg-white/80 border-r border-slate-200 p-6 flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-10 px-2">
|
||||
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center text-white font-bold italic">
|
||||
<Image src="/logo.png" alt="TVone" width={50} height={50} />
|
||||
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center border border-slate-100 shadow-sm overflow-hidden">
|
||||
{/* Fallback caso não tenhas o logo.png localmente */}
|
||||
<span className="text-blue-600 font-black">TV</span>
|
||||
</div>
|
||||
|
||||
<span className="font-bold text-sm tracking-tight leading-tight uppercase">
|
||||
TVone<br/><span className="text-blue-600 text-[10px]">de Notícias</span>
|
||||
TVone<br/><span className="text-blue-600 text-[10px]">Portal Admin</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +72,7 @@ const CreateNewsPage = () => {
|
||||
<NavItem icon={<HelpCircle size={18}/>} label="Ajuda" />
|
||||
</nav>
|
||||
|
||||
{/* User Activity Feed - Sidebar Bottom */}
|
||||
{/* User Activity Feed */}
|
||||
<div className="mt-auto pt-6 border-t border-slate-200">
|
||||
<h4 className="text-[10px] font-bold uppercase text-slate-400 mb-4 px-2 tracking-widest">Atividade Recente</h4>
|
||||
<div className="space-y-3 px-2">
|
||||
@@ -44,12 +84,11 @@ const CreateNewsPage = () => {
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
{/* Header Superior */}
|
||||
<header className="h-16 border-b border-slate-200 bg-white/50 backdrop-blur-md sticky top-0 z-10 px-8 flex items-center justify-between">
|
||||
<div className="w-1/3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Pesquisar artigos, autores..."
|
||||
placeholder="Pesquisar artigos..."
|
||||
className="w-full bg-slate-100 border-none rounded-full px-4 py-1.5 text-sm focus:ring-2 focus:ring-blue-500/20 outline-none"
|
||||
/>
|
||||
</div>
|
||||
@@ -58,7 +97,9 @@ const CreateNewsPage = () => {
|
||||
<p className="text-xs font-bold">James Wilson</p>
|
||||
<p className="text-[10px] text-slate-500">Editor</p>
|
||||
</div>
|
||||
<div className="w-8 h-8 bg-slate-300 rounded-full"></div>
|
||||
<div className="w-8 h-8 bg-slate-300 rounded-full border border-white shadow-sm overflow-hidden">
|
||||
<img src="https://ui-avatars.com/api/?name=James+Wilson" alt="User" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -69,14 +110,14 @@ const CreateNewsPage = () => {
|
||||
<button className="px-5 py-2 rounded-lg border border-slate-200 bg-white text-sm font-medium hover:bg-slate-50 transition-all flex items-center gap-2">
|
||||
<Save size={16}/> Salvar Rascunho
|
||||
</button>
|
||||
<button className="px-6 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-600 transition-all shadow-lg shadow-blue-200 flex items-center gap-2">
|
||||
<button className="px-6 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-all shadow-lg shadow-blue-200 flex items-center gap-2">
|
||||
<Send size={16}/> Publicar Artigo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-8">
|
||||
{/* Coluna Principal (Editor) */}
|
||||
{/* Coluna Principal */}
|
||||
<div className="col-span-2 space-y-6">
|
||||
<section className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm">
|
||||
<label className="block text-xs font-bold uppercase text-slate-400 mb-2">Título do Artigo</label>
|
||||
@@ -86,12 +127,10 @@ const CreateNewsPage = () => {
|
||||
className="w-full text-xl font-bold border-none focus:ring-0 placeholder:text-slate-300 p-0"
|
||||
/>
|
||||
<hr className="my-6 border-slate-100" />
|
||||
|
||||
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 leading-8">Conteúdo Principal</label>
|
||||
{/* Toolbar Simplificada */}
|
||||
<div className="flex gap-4 mb-4 text-slate-400 border-b border-slate-50 pb-2">
|
||||
<Type size={18} className="cursor-pointer hover:text-blue-600" />
|
||||
<ImageIcon size={18} className="cursor-pointer hover:text-blue-600" />
|
||||
<Type size={18} className="cursor-pointer hover:text-blue-600 transition-colors" />
|
||||
<ImageIcon size={18} className="cursor-pointer hover:text-blue-600 transition-colors" />
|
||||
<div className="w-px h-5 bg-slate-200" />
|
||||
<span className="font-serif font-bold cursor-pointer hover:text-blue-600">B</span>
|
||||
<span className="italic cursor-pointer hover:text-blue-600">I</span>
|
||||
@@ -102,42 +141,58 @@ const CreateNewsPage = () => {
|
||||
className="w-full border-none focus:ring-0 resize-none text-slate-700 leading-relaxed p-0"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm">
|
||||
<label className="block text-xs font-bold uppercase text-slate-400 mb-2 leading-8">Resumo (Lead)</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="Escreva um resumo curto para visualização..."
|
||||
className="w-full border-none focus:ring-0 resize-none text-slate-600 text-sm italic p-0"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Coluna Lateral (Meta-dados) */}
|
||||
{/* Coluna Lateral */}
|
||||
<div className="space-y-6">
|
||||
<section className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm space-y-6">
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-xs font-bold uppercase text-slate-400 mb-3">
|
||||
<Tag size={14}/> Categoria
|
||||
</label>
|
||||
<select className="w-full bg-slate-50 border border-slate-100 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500/10">
|
||||
<select className="w-full bg-slate-50 border border-slate-100 rounded-lg px-3 py-2 text-sm outline-none">
|
||||
<option>Negócios</option>
|
||||
<option>Tecnologia</option>
|
||||
<option>Desporto</option>
|
||||
<option>Política</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* --- INPUT DE UPLOAD ATUALIZADO --- */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-xs font-bold uppercase text-slate-400 mb-3">
|
||||
<ImageIcon size={14}/> Imagem de Capa
|
||||
</label>
|
||||
<div className="border-2 border-dashed border-slate-100 rounded-xl p-8 flex flex-col items-center justify-center bg-slate-50/50 hover:bg-slate-50 transition-colors cursor-pointer group">
|
||||
<div className="w-10 h-10 bg-white rounded-full shadow-sm flex items-center justify-center text-blue-500 mb-2 group-hover:scale-110 transition-transform">
|
||||
<ImageIcon size={20}/>
|
||||
</div>
|
||||
<p className="text-[10px] font-medium text-slate-500">Clique para carregar</p>
|
||||
<p className="text-[9px] text-slate-400">JPG, PNG (Máx 5MB)</p>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
ref={fileInputRef}
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
onClick={triggerUpload}
|
||||
className={`border-2 border-dashed rounded-xl p-8 flex flex-col items-center justify-center transition-all cursor-pointer group
|
||||
${finalCrops ? 'border-emerald-200 bg-emerald-50/30' : 'border-slate-100 bg-slate-50/50 hover:bg-slate-50'}`}
|
||||
>
|
||||
{finalCrops ? (
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 bg-emerald-100 text-emerald-600 rounded-full flex items-center justify-center mx-auto mb-2">
|
||||
<ImageIcon size={24}/>
|
||||
</div>
|
||||
<p className="text-[10px] font-bold text-emerald-700 uppercase">3 Formatos Gerados</p>
|
||||
<p className="text-[9px] text-emerald-500 mt-1">Clique para alterar</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-10 h-10 bg-white rounded-full shadow-sm flex items-center justify-center text-blue-500 mb-2 group-hover:scale-110 transition-transform">
|
||||
<ImageIcon size={20}/>
|
||||
</div>
|
||||
<p className="text-[10px] font-medium text-slate-500 text-center">Clique para carregar e enquadrar</p>
|
||||
<p className="text-[9px] text-slate-400">Suporta JPG, PNG</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -146,8 +201,8 @@ const CreateNewsPage = () => {
|
||||
<Calendar size={14}/> Agendamento
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input type="date" className="bg-slate-50 border border-slate-100 rounded-lg px-2 py-2 text-xs outline-none" />
|
||||
<input type="time" className="bg-slate-50 border border-slate-100 rounded-lg px-2 py-2 text-xs outline-none" />
|
||||
<input type="date" className="bg-slate-50 border border-slate-100 rounded-lg px-2 py-2 text-[11px] outline-none" />
|
||||
<input type="time" className="bg-slate-50 border border-slate-100 rounded-lg px-2 py-2 text-[11px] outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user