feat: enable project catalog management

This commit is contained in:
Erik
2025-11-27 16:22:14 -03:00
parent f077569bc1
commit 1138747565
6 changed files with 729 additions and 222 deletions

View File

@@ -1,11 +1,49 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePageContent } from "@/hooks/usePageContent";
import { useLocale } from "@/contexts/LocaleContext";
type PortfolioProject = {
id: string;
title: string;
category: string;
coverImage: string | null;
galleryImages: string[];
};
type FallbackProject = {
id: string;
title: string;
category: string;
image: string;
};
const FALLBACK_PROJECTS: FallbackProject[] = [
{
id: 'fallback-1',
title: 'Projeto de Adequação - Coca-Cola',
category: 'Engenharia Veicular',
image: 'https://images.unsplash.com/photo-1616401784845-180882ba9ba8?q=80&w=2070&auto=format&fit=crop',
},
{
id: 'fallback-2',
title: 'Laudo de Guindaste Articulado',
category: 'Inspeção Técnica',
image: 'https://images.unsplash.com/photo-1581092335397-9583eb92d232?q=80&w=2070&auto=format&fit=crop',
},
{
id: 'fallback-3',
title: 'Dispositivo de Içamento Especial',
category: 'Projeto Mecânico',
image: 'https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?q=80&w=2070&auto=format&fit=crop',
},
];
export default function Home() {
const { locale, t } = useLocale();
const [latestProjects, setLatestProjects] = useState<PortfolioProject[]>([]);
// Busca conteúdo JÁ TRADUZIDO do banco (sem tradução em tempo real!)
const { content, loading } = usePageContent('home', locale);
@@ -68,6 +106,35 @@ export default function Home() {
// Prefix para links baseado no locale
const prefix = locale === 'pt' ? '' : `/${locale}`;
useEffect(() => {
let isMounted = true;
const controller = new AbortController();
const fetchProjects = async () => {
try {
const response = await fetch('/api/projects?status=published&take=3', { signal: controller.signal });
if (!response.ok) {
throw new Error('Falha ao buscar projetos mais recentes');
}
const data: PortfolioProject[] = await response.json();
if (isMounted) {
setLatestProjects(data);
}
} catch (err) {
if ((err as Error).name !== 'AbortError') {
console.error('Erro ao buscar projetos recentes:', err);
}
}
};
fetchProjects();
return () => {
isMounted = false;
controller.abort();
};
}, []);
return (
<main className="bg-white dark:bg-secondary transition-colors duration-300">
{/* Hero Section */}
@@ -186,16 +253,20 @@ export default function Home() {
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{[
{ img: "https://images.unsplash.com/photo-1616401784845-180882ba9ba8?q=80&w=2070&auto=format&fit=crop", title: "Projeto de Adequação - Coca-Cola", cat: "Engenharia Veicular" },
{ img: "https://images.unsplash.com/photo-1581092335397-9583eb92d232?q=80&w=2070&auto=format&fit=crop", title: "Laudo de Guindaste Articulado", cat: "Inspeção Técnica" },
{ img: "https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?q=80&w=2070&auto=format&fit=crop", title: "Dispositivo de Içamento Especial", cat: "Projeto Mecânico" }
].map((project, index) => (
<div key={index} className="group relative overflow-hidden rounded-xl h-[400px] cursor-pointer">
<div className="absolute inset-0 bg-cover bg-center transition-transform duration-500 group-hover:scale-110" style={{ backgroundImage: `url('${project.img}')` }}></div>
{(latestProjects.length > 0
? latestProjects.map((project) => ({
id: project.id,
title: project.title,
category: project.category,
image: project.coverImage || project.galleryImages?.[0] || FALLBACK_PROJECTS[0].image,
}))
: FALLBACK_PROJECTS
).map((project) => (
<div key={project.id} className="group relative overflow-hidden rounded-xl h-[400px] cursor-pointer">
<div className="absolute inset-0 bg-cover bg-center transition-transform duration-500 group-hover:scale-110" style={{ backgroundImage: `url('${project.image}')` }}></div>
<div className="absolute inset-0 bg-linear-to-t from-black/90 via-black/20 to-transparent opacity-80 group-hover:opacity-90 transition-opacity"></div>
<div className="absolute bottom-0 left-0 p-8 w-full transform translate-y-4 group-hover:translate-y-0 transition-transform">
<span className="text-primary font-bold text-sm uppercase tracking-wider mb-2 block">{project.cat}</span>
<span className="text-primary font-bold text-sm uppercase tracking-wider mb-2 block">{project.category}</span>
<h3 className="text-2xl font-bold font-headline text-white mb-2">{project.title}</h3>
<div className="h-0 group-hover:h-auto overflow-hidden transition-all">
<span className="text-white/80 text-sm flex items-center gap-2 mt-4">

View File

@@ -1,63 +1,79 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useLocale } from "@/contexts/LocaleContext";
interface Project {
id: string;
title: string;
category: string;
description: string | null;
coverImage: string | null;
galleryImages: string[];
status: string;
client: string | null;
completionDate: string | null;
}
const FALLBACK_IMAGE = "https://images.unsplash.com/photo-1616401784845-180882ba9ba8?q=80&w=2070&auto=format&fit=crop";
export default function ProjetosPage() {
const { t, locale } = useLocale();
const prefix = locale === 'pt' ? '' : `/${locale}`;
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [selectedCategory, setSelectedCategory] = useState<string>('todos');
// Placeholder data - will be replaced by database content
const projects = [
{
id: 1,
title: t('projects.items.item1.title'),
category: t('projects.categories.vehicular'),
location: "Vitória, ES",
image: "https://images.unsplash.com/photo-1616401784845-180882ba9ba8?q=80&w=2070&auto=format&fit=crop",
description: t('projects.items.item1.description')
},
{
id: 2,
title: t('projects.items.item2.title'),
category: t('projects.categories.reports'),
location: "Serra, ES",
image: "https://images.unsplash.com/photo-1535082623926-b3a33d531740?q=80&w=2052&auto=format&fit=crop",
description: t('projects.items.item2.description')
},
{
id: 3,
title: t('projects.items.item3.title'),
category: t('projects.categories.mechanical'),
location: "Aracruz, ES",
image: "https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?q=80&w=2070&auto=format&fit=crop",
description: t('projects.items.item3.description')
},
{
id: 4,
title: t('projects.items.item4.title'),
category: t('projects.categories.safety'),
location: "Linhares, ES",
image: "https://images.unsplash.com/photo-1581092921461-eab62e97a782?q=80&w=2070&auto=format&fit=crop",
description: t('projects.items.item4.description')
},
{
id: 5,
title: t('projects.items.item5.title'),
category: t('projects.categories.vehicular'),
location: "Viana, ES",
image: "https://images.unsplash.com/photo-1591768793355-74d04bb6608f?q=80&w=2070&auto=format&fit=crop",
description: t('projects.items.item5.description')
},
{
id: 6,
title: t('projects.items.item6.title'),
category: t('projects.categories.safety'),
location: "Cariacica, ES",
image: "https://images.unsplash.com/photo-1504328345606-18bbc8c9d7d1?q=80&w=2070&auto=format&fit=crop",
description: t('projects.items.item6.description')
useEffect(() => {
let isMounted = true;
const controller = new AbortController();
const fetchProjects = async () => {
try {
const response = await fetch('/api/projects?status=published', { signal: controller.signal });
if (!response.ok) {
throw new Error('Falha ao buscar projetos');
}
const data: Project[] = await response.json();
if (isMounted) {
setProjects(data);
}
} catch (err) {
if ((err as Error).name !== 'AbortError') {
console.error('Erro ao carregar projetos:', err);
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
fetchProjects();
return () => {
isMounted = false;
controller.abort();
};
}, []);
const categories = useMemo(() => {
const unique = new Set<string>();
projects.forEach((project) => {
if (project.category) {
unique.add(project.category);
}
});
return Array.from(unique);
}, [projects]);
const filteredProjects = useMemo(() => {
if (selectedCategory === 'todos') {
return projects;
}
];
return projects.filter((project) => project.category === selectedCategory);
}, [projects, selectedCategory]);
return (
<main className="bg-white dark:bg-secondary transition-colors duration-300">
@@ -78,38 +94,67 @@ export default function ProjetosPage() {
<div className="container mx-auto px-4">
{/* Filters */}
<div className="flex flex-wrap gap-4 mb-12 justify-center">
<button className="px-6 py-2 bg-primary text-white rounded-full font-bold shadow-md">{t('projects.filters.all')}</button>
<button className="px-6 py-2 bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-300 rounded-full font-bold hover:bg-gray-200 dark:hover:bg-white/20 transition-colors">{t('projects.filters.implements')}</button>
<button className="px-6 py-2 bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-300 rounded-full font-bold hover:bg-gray-200 dark:hover:bg-white/20 transition-colors">{t('projects.filters.mechanical')}</button>
<button className="px-6 py-2 bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-300 rounded-full font-bold hover:bg-gray-200 dark:hover:bg-white/20 transition-colors">{t('projects.filters.reports')}</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{projects.map((project) => (
<div key={project.id} className="group bg-white dark:bg-secondary rounded-xl overflow-hidden shadow-sm hover:shadow-xl transition-all duration-300 border border-gray-100 dark:border-white/10 flex flex-col">
<div className="relative h-64 overflow-hidden">
<div className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110" style={{ backgroundImage: `url('${project.image}')` }}></div>
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/0 transition-colors"></div>
<div className="absolute top-4 left-4 bg-white/90 backdrop-blur-sm px-3 py-1 rounded-md text-xs font-bold text-secondary uppercase tracking-wider">
{project.category}
</div>
</div>
<div className="p-6 grow flex flex-col">
<h3 className="text-xl font-bold font-headline text-secondary dark:text-white mb-2 group-hover:text-primary transition-colors">{project.title}</h3>
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400 text-sm mb-4">
<i className="ri-map-pin-line"></i>
<span>{project.location}</span>
</div>
<p className="text-gray-600 dark:text-gray-400 text-sm mb-6 line-clamp-3 grow">
{project.description}
</p>
<Link href={`${prefix}/projetos/${project.id}`} className="inline-flex items-center gap-2 text-primary font-bold hover:gap-3 transition-all mt-auto">
{t('projects.viewDetails')} <i className="ri-arrow-right-line"></i>
</Link>
</div>
</div>
<button
onClick={() => setSelectedCategory('todos')}
className={`px-6 py-2 rounded-full font-bold transition-colors ${selectedCategory === 'todos' ? 'bg-primary text-white shadow-md' : 'bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-white/20'}`}
>
{t('projects.filters.all')}
</button>
{categories.map((category) => (
<button
key={category}
onClick={() => setSelectedCategory(category)}
className={`px-6 py-2 rounded-full font-bold transition-colors ${selectedCategory === category ? 'bg-primary text-white shadow-md' : 'bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-white/20'}`}
>
{category}
</button>
))}
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="animate-pulse bg-white dark:bg-secondary rounded-xl border border-gray-100 dark:border-white/10 h-96"></div>
))}
</div>
) : filteredProjects.length === 0 ? (
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
<i className="ri-briefcase-line text-5xl mb-4"></i>
<p>{locale === 'pt' ? 'Ainda não temos projetos publicados nesta categoria.' : locale === 'es' ? 'Todavía no hay proyectos publicados en esta categoría.' : 'No projects published in this category yet.'}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredProjects.map((project) => {
const image = project.coverImage || project.galleryImages[0] || FALLBACK_IMAGE;
const description = project.description || (locale === 'pt' ? 'Descrição disponível em breve.' : locale === 'es' ? 'Descripción disponible pronto.' : 'Description coming soon.');
return (
<div key={project.id} className="group bg-white dark:bg-secondary rounded-xl overflow-hidden shadow-sm hover:shadow-xl transition-all duration-300 border border-gray-100 dark:border-white/10 flex flex-col">
<div className="relative h-64 overflow-hidden">
<div className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110" style={{ backgroundImage: `url('${image}')` }}></div>
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/0 transition-colors"></div>
<div className="absolute top-4 left-4 bg-white/90 backdrop-blur-sm px-3 py-1 rounded-md text-xs font-bold text-secondary uppercase tracking-wider">
{project.category || t('projects.filters.all')}
</div>
</div>
<div className="p-6 grow flex flex-col">
<h3 className="text-xl font-bold font-headline text-secondary dark:text-white mb-2 group-hover:text-primary transition-colors">{project.title}</h3>
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400 text-sm mb-4">
<i className="ri-roadster-line"></i>
<span>{project.client || (locale === 'pt' ? 'Cliente confidencial' : locale === 'es' ? 'Cliente confidencial' : 'Confidential client')}</span>
</div>
<p className="text-gray-600 dark:text-gray-400 text-sm mb-6 line-clamp-3 grow">
{description}
</p>
<Link href={`${prefix}/projetos/${project.id}`} className="inline-flex items-center gap-2 text-primary font-bold hover:gap-3 transition-all mt-auto">
{t('projects.viewDetails')} <i className="ri-arrow-right-line"></i>
</Link>
</div>
</div>
);
})}
</div>
)}
</div>
</section>
</main>

View File

@@ -1,31 +1,175 @@
"use client";
import { useState } from 'react';
import { useRef, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useToast } from '@/contexts/ToastContext';
type UploadedImage = {
url: string;
path: string;
name: string;
};
const CATEGORY_OPTIONS = [
{ value: 'Engenharia Veicular', label: 'Engenharia Veicular' },
{ value: 'Projetos Mecânicos', label: 'Projetos Mecânicos' },
{ value: 'Laudos e Inspeções', label: 'Laudos e Inspeções' },
{ value: 'Segurança do Trabalho', label: 'Segurança do Trabalho' },
];
const STATUS_OPTIONS = [
{ value: 'Concluído', label: 'Concluído' },
{ value: 'Em andamento', label: 'Em andamento' },
{ value: 'Rascunho', label: 'Rascunho' },
];
export default function NewProject() {
const router = useRouter();
const { success, error } = useToast();
const coverInputRef = useRef<HTMLInputElement | null>(null);
const galleryInputRef = useRef<HTMLInputElement | null>(null);
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
title: '',
category: '',
client: '',
status: 'active',
status: 'Concluído',
description: '',
date: ''
date: '',
featured: false,
});
const [coverImage, setCoverImage] = useState<UploadedImage | null>(null);
const [galleryImages, setGalleryImages] = useState<UploadedImage[]>([]);
const [uploadingCover, setUploadingCover] = useState(false);
const [uploadingGallery, setUploadingGallery] = useState(false);
const isSaving = loading || uploadingCover || uploadingGallery;
const uploadFile = async (file: File): Promise<UploadedImage | null> => {
if (file.size > 2 * 1024 * 1024) {
error('Arquivo maior que 2MB. Escolha uma imagem menor.');
return null;
}
const supportedTypes = ['image/png', 'image/jpeg', 'image/webp'];
if (!supportedTypes.includes(file.type)) {
error('Formato inválido. Utilize PNG, JPG ou WEBP.');
return null;
}
const body = new FormData();
body.append('file', file);
const response = await fetch('/api/upload', {
method: 'POST',
body,
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data?.error || 'Erro ao enviar imagem');
}
const data = await response.json();
return {
url: data.url,
path: data.path,
name: file.name,
};
};
const handleCoverSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setUploadingCover(true);
try {
const uploaded = await uploadFile(file);
if (uploaded) {
setCoverImage(uploaded);
success('Imagem de capa carregada.');
}
} catch (err) {
console.error('Erro ao enviar imagem de capa:', err);
error(err instanceof Error ? err.message : 'Falha ao enviar imagem de capa.');
} finally {
setUploadingCover(false);
event.target.value = '';
}
};
const handleGallerySelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
if (files.length === 0) return;
if (galleryImages.length + files.length > 8) {
error('Limite de 8 imagens atingido. Remova algumas antes de adicionar novas.');
event.target.value = '';
return;
}
setUploadingGallery(true);
try {
for (const file of files) {
const uploaded = await uploadFile(file);
if (uploaded) {
setGalleryImages((prev) => [...prev, uploaded]);
}
}
success('Galeria atualizada.');
} catch (err) {
console.error('Erro ao enviar imagem da galeria:', err);
error(err instanceof Error ? err.message : 'Falha ao enviar imagem da galeria.');
} finally {
setUploadingGallery(false);
event.target.value = '';
}
};
const handleRemoveGalleryImage = (path: string) => {
setGalleryImages((prev) => prev.filter((image) => image.path !== path));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
if (!formData.title || !formData.category) {
error('Informe ao menos o título e a categoria do projeto.');
return;
}
// Simulate API call
setTimeout(() => {
console.log('Project data:', formData);
setLoading(false);
setLoading(true);
try {
const response = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: formData.title.trim(),
category: formData.category,
client: formData.client?.trim() || null,
status: formData.status,
description: formData.description?.trim() || null,
completionDate: formData.date ? new Date(formData.date).toISOString() : null,
coverImage: coverImage?.url ?? null,
galleryImages: galleryImages.map((image) => image.url),
featured: formData.featured,
}),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.error || 'Erro ao salvar projeto');
}
success('Projeto cadastrado com sucesso!');
router.push('/admin/projetos');
}, 1500);
} catch (err) {
console.error('Erro ao salvar projeto:', err);
error(err instanceof Error ? err.message : 'Não foi possível salvar o projeto.');
} finally {
setLoading(false);
}
};
return (
@@ -73,10 +217,9 @@ export default function NewProject() {
required
>
<option value="">Selecione uma categoria</option>
<option value="veicular">Engenharia Veicular</option>
<option value="mecanica">Projetos Mecânicos</option>
<option value="laudos">Laudos e Inspeções</option>
<option value="seguranca">Segurança do Trabalho</option>
{CATEGORY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
@@ -108,9 +251,9 @@ export default function NewProject() {
onChange={(e) => setFormData({...formData, status: e.target.value})}
className="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl text-gray-900 dark:text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all appearance-none cursor-pointer"
>
<option value="active">Concluído</option>
<option value="pending">Em Andamento</option>
<option value="draft">Rascunho</option>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
@@ -124,6 +267,19 @@ export default function NewProject() {
placeholder="Descreva os detalhes técnicos, desafios e soluções do projeto..."
></textarea>
</div>
<div className="md:col-span-2">
<label className="flex items-center gap-3 text-sm font-bold text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={formData.featured}
onChange={(e) => setFormData({ ...formData, featured: e.target.checked })}
className="h-5 w-5 rounded border-gray-300 text-primary focus:ring-primary"
/>
Destacar este projeto no site
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Projetos em destaque podem ser exibidos em seções especiais como a home.</p>
</div>
</div>
</div>
@@ -137,33 +293,97 @@ export default function NewProject() {
<div className="space-y-6">
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Imagem de Capa</label>
<div className="border-2 border-dashed border-gray-300 dark:border-white/20 rounded-xl p-8 text-center hover:border-primary dark:hover:border-primary transition-colors cursor-pointer bg-gray-50 dark:bg-white/5">
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center text-primary mx-auto mb-4">
<i className="ri-upload-cloud-2-line text-3xl"></i>
</div>
<p className="text-gray-600 dark:text-gray-300 font-medium mb-1">Clique para fazer upload ou arraste e solte</p>
<p className="text-xs text-gray-400">PNG, JPG ou WEBP (Max. 2MB)</p>
<div
onClick={() => coverInputRef.current?.click()}
className="border-2 border-dashed border-gray-300 dark:border-white/20 rounded-xl p-6 text-center hover:border-primary dark:hover:border-primary transition-colors cursor-pointer bg-gray-50 dark:bg-white/5 relative min-h-[200px] flex items-center justify-center"
>
{coverImage ? (
<div className="w-full h-full relative">
<img src={coverImage.url} alt={coverImage.name} className="w-full h-full object-cover rounded-lg" />
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setCoverImage(null);
}}
className="absolute top-3 right-3 bg-black/60 text-white w-8 h-8 rounded-full flex items-center justify-center hover:bg-black/80 transition-colors"
>
<i className="ri-close-line"></i>
</button>
</div>
) : (
<div className="flex flex-col items-center">
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center text-primary mb-4">
{uploadingCover ? (
<i className="ri-loader-4-line text-3xl animate-spin"></i>
) : (
<i className="ri-upload-cloud-2-line text-3xl"></i>
)}
</div>
<p className="text-gray-600 dark:text-gray-300 font-medium mb-1">
{uploadingCover ? 'Enviando imagem...' : 'Clique para fazer upload ou arraste e solte'}
</p>
<p className="text-xs text-gray-400">PNG, JPG ou WEBP (máximo 2MB)</p>
</div>
)}
<input
ref={coverInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={handleCoverSelect}
disabled={uploadingCover}
/>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">Galeria de Fotos</label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="aspect-square border-2 border-dashed border-gray-300 dark:border-white/20 rounded-xl flex flex-col items-center justify-center text-gray-400 hover:text-primary hover:border-primary transition-colors cursor-pointer bg-gray-50 dark:bg-white/5">
<i className="ri-add-line text-3xl mb-2"></i>
<span className="text-xs font-bold">Adicionar</span>
</div>
{/* Placeholders for uploaded images */}
{[1, 2].map((i) => (
<div key={i} className="aspect-square rounded-xl bg-gray-200 dark:bg-white/10 relative group overflow-hidden">
<button
type="button"
onClick={() => galleryInputRef.current?.click()}
className="aspect-square border-2 border-dashed border-gray-300 dark:border-white/20 rounded-xl flex flex-col items-center justify-center text-gray-400 hover:text-primary hover:border-primary transition-colors cursor-pointer bg-gray-50 dark:bg-white/5"
disabled={uploadingGallery}
>
{uploadingGallery ? (
<>
<i className="ri-loader-4-line text-3xl mb-2 animate-spin"></i>
<span className="text-xs font-bold">Enviando...</span>
</>
) : (
<>
<i className="ri-add-line text-3xl mb-2"></i>
<span className="text-xs font-bold">Adicionar</span>
</>
)}
</button>
{galleryImages.map((image) => (
<div key={image.path} className="aspect-square rounded-xl bg-gray-200 dark:bg-white/10 relative group overflow-hidden">
<img src={image.url} alt={image.name} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button type="button" className="w-8 h-8 rounded-lg bg-white/20 hover:bg-white/40 text-white flex items-center justify-center backdrop-blur-sm transition-colors">
<button
type="button"
onClick={() => handleRemoveGalleryImage(image.path)}
className="w-9 h-9 rounded-lg bg-white/20 hover:bg-white/40 text-white flex items-center justify-center backdrop-blur-sm transition-colors"
>
<i className="ri-delete-bin-line"></i>
</button>
</div>
</div>
))}
<input
ref={galleryInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
className="hidden"
onChange={handleGallerySelect}
disabled={uploadingGallery}
/>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">Adicione até 8 imagens para apresentar detalhes do projeto.</p>
</div>
</div>
</div>
@@ -178,10 +398,10 @@ export default function NewProject() {
</Link>
<button
type="submit"
disabled={loading}
disabled={isSaving}
className="px-8 py-3 bg-primary text-white rounded-xl font-bold hover-primary transition-colors shadow-lg shadow-primary/20 flex items-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed"
>
{loading ? (
{isSaving ? (
<>
<i className="ri-loader-4-line animate-spin"></i>
Salvando...

View File

@@ -1,8 +1,105 @@
"use client";
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useToast } from '@/contexts/ToastContext';
import { useConfirm } from '@/contexts/ConfirmContext';
interface Project {
id: string;
title: string;
category: string;
client: string | null;
status: string;
completionDate: string | null;
description: string | null;
coverImage: string | null;
galleryImages: string[];
featured: boolean;
createdAt: string;
updatedAt: string;
}
const STATUS_STYLES: Record<string, string> = {
'Concluído': 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
'Em andamento': 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
'Rascunho': 'bg-gray-100 text-gray-600 dark:bg-white/10 dark:text-gray-300',
};
export default function ProjectsList() {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const { success, error } = useToast();
const { confirm } = useConfirm();
useEffect(() => {
fetchProjects();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const fetchProjects = async () => {
try {
setLoading(true);
const response = await fetch('/api/projects');
if (!response.ok) {
throw new Error('Falha ao carregar projetos');
}
const data: Project[] = await response.json();
setProjects(data);
} catch (err) {
console.error('Erro ao carregar projetos:', err);
error('Não foi possível carregar os projetos.');
} finally {
setLoading(false);
}
};
const handleDelete = async (id: string) => {
const confirmed = await confirm({
title: 'Excluir projeto',
message: 'Tem certeza que deseja remover este projeto? Esta ação não pode ser desfeita.',
confirmText: 'Excluir',
cancelText: 'Cancelar',
type: 'danger',
});
if (!confirmed) return;
try {
const response = await fetch(`/api/projects/${id}`, { method: 'DELETE' });
const result = await response.json();
if (!response.ok) {
throw new Error(result?.error || 'Falha ao excluir projeto');
}
success('Projeto excluído com sucesso!');
fetchProjects();
} catch (err) {
console.error('Erro ao excluir projeto:', err);
error('Não foi possível excluir o projeto.');
}
};
const renderStatus = (status: string) => {
const style = STATUS_STYLES[status] || 'bg-gray-100 text-gray-600 dark:bg-white/10 dark:text-gray-300';
return (
<span className={`px-3 py-1 rounded-full text-xs font-bold ${style}`}>
{status}
</span>
);
};
const formatDate = (value: string | null) => {
if (!value) return '—';
try {
return new Intl.DateTimeFormat('pt-BR').format(new Date(value));
} catch (err) {
console.error('Erro ao formatar data:', err);
return '—';
}
};
return (
<div>
<div className="flex items-center justify-between mb-8">
@@ -10,8 +107,8 @@ export default function ProjectsList() {
<h1 className="text-3xl font-bold font-headline text-secondary dark:text-white mb-2">Projetos</h1>
<p className="text-gray-500 dark:text-gray-400">Gerencie os projetos exibidos no portfólio.</p>
</div>
<Link
href="/admin/projetos/novo"
<Link
href="/admin/projetos/novo"
className="px-6 py-3 bg-primary text-white rounded-xl font-bold hover-primary transition-colors shadow-lg shadow-primary/20 flex items-center gap-2"
>
<i className="ri-add-line text-xl"></i>
@@ -20,58 +117,74 @@ export default function ProjectsList() {
</div>
<div className="bg-white dark:bg-secondary rounded-xl border border-gray-200 dark:border-white/10 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-gray-50 dark:bg-white/5 border-b border-gray-200 dark:border-white/10">
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Projeto</th>
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Categoria</th>
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Cliente</th>
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Status</th>
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-white/5">
{[
{ title: 'Adequação Coca-Cola', cat: 'Engenharia Veicular', client: 'Coca-Cola FEMSA', status: 'Concluído' },
{ title: 'Laudo Guindaste Articulado', cat: 'Inspeção Técnica', client: 'Logística Express', status: 'Concluído' },
{ title: 'Dispositivo de Içamento', cat: 'Projeto Mecânico', client: 'Metalúrgica ABC', status: 'Em Andamento' },
].map((project, index) => (
<tr key={index} className="hover:bg-gray-50 dark:hover:bg-white/5 transition-colors group">
<td className="p-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-gray-200 dark:bg-white/10 overflow-hidden shrink-0">
<div className="w-full h-full bg-gray-300 dark:bg-white/20"></div>
</div>
<span className="font-bold text-secondary dark:text-white">{project.title}</span>
</div>
</td>
<td className="p-4 text-gray-600 dark:text-gray-400">{project.cat}</td>
<td className="p-4 text-gray-600 dark:text-gray-400">{project.client}</td>
<td className="p-4">
<span className={`px-3 py-1 rounded-full text-xs font-bold ${
project.status === 'Concluído'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
}`}>
{project.status}
</span>
</td>
<td className="p-4 text-right">
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="w-8 h-8 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10 flex items-center justify-center text-gray-500 hover:text-primary transition-colors cursor-pointer" title="Editar">
<i className="ri-pencil-line"></i>
</button>
<button className="w-8 h-8 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center justify-center text-gray-500 hover:text-red-500 transition-colors cursor-pointer" title="Excluir">
<i className="ri-delete-bin-line"></i>
</button>
</div>
</td>
{loading ? (
<div className="flex items-center justify-center py-16">
<i className="ri-loader-4-line animate-spin text-3xl text-primary"></i>
</div>
) : projects.length === 0 ? (
<div className="py-16 text-center text-gray-500 dark:text-gray-400 flex flex-col items-center gap-3">
<i className="ri-briefcase-line text-4xl"></i>
Nenhum projeto cadastrado ainda.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-gray-50 dark:bg-white/5 border-b border-gray-200 dark:border-white/10">
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Projeto</th>
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Categoria</th>
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Cliente</th>
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Conclusão</th>
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider">Status</th>
<th className="p-4 font-bold text-gray-600 dark:text-gray-300 text-sm uppercase tracking-wider text-right">Ações</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-white/5">
{projects.map((project) => (
<tr key={project.id} className="hover:bg-gray-50 dark:hover:bg-white/5 transition-colors group">
<td className="p-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-gray-200 dark:bg-white/10 overflow-hidden shrink-0 border border-gray-100 dark:border-white/10">
{project.coverImage ? (
<img src={project.coverImage} alt={project.title} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">
<i className="ri-image-line"></i>
</div>
)}
</div>
<div>
<p className="font-bold text-secondary dark:text-white flex items-center gap-2">
{project.title}
{project.featured && (
<span className="text-[10px] uppercase tracking-wider bg-primary/10 text-primary px-2 py-0.5 rounded-full font-bold">Destaque</span>
)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">Criado em {formatDate(project.createdAt)}</p>
</div>
</div>
</td>
<td className="p-4 text-gray-600 dark:text-gray-400">{project.category}</td>
<td className="p-4 text-gray-600 dark:text-gray-400">{project.client || '—'}</td>
<td className="p-4 text-gray-600 dark:text-gray-400">{formatDate(project.completionDate)}</td>
<td className="p-4">{renderStatus(project.status)}</td>
<td className="p-4 text-right">
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleDelete(project.id)}
className="w-8 h-8 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center justify-center text-gray-500 hover:text-red-500 transition-colors cursor-pointer"
title="Excluir"
>
<i className="ri-delete-bin-line"></i>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);

View File

@@ -1,60 +1,77 @@
import { NextRequest, NextResponse } from 'next/server';
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { Prisma } from '@prisma/client';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
type Params = {
params: { id: string };
};
export async function GET(_request: Request, { params }: Params) {
try {
const { id } = await params;
const project = await prisma.project.findUnique({
where: { id },
where: { id: params.id },
});
if (!project) return NextResponse.json({ error: 'Project not found' }, { status: 404 });
if (!project) {
return NextResponse.json({ error: 'Projeto não encontrado' }, { status: 404 });
}
return NextResponse.json(project);
} catch (error) {
return NextResponse.json({ error: 'Error fetching project' }, { status: 500 });
console.error('Error fetching project:', error);
return NextResponse.json({ error: 'Erro ao buscar projeto' }, { status: 500 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
export async function PATCH(request: Request, { params }: Params) {
try {
const { id } = await params;
const data = await request.json();
const body = await request.json();
const updateData: Record<string, unknown> = {};
if (body.title !== undefined) updateData.title = body.title;
if (body.category !== undefined) updateData.category = body.category;
if (body.client !== undefined) updateData.client = body.client;
if (body.status !== undefined) updateData.status = body.status;
if (body.description !== undefined) updateData.description = body.description;
if (body.coverImage !== undefined) updateData.coverImage = body.coverImage;
if (body.galleryImages !== undefined) {
updateData.galleryImages = Array.isArray(body.galleryImages) ? body.galleryImages : [];
}
if (body.featured !== undefined) updateData.featured = Boolean(body.featured);
if (body.completionDate !== undefined) {
updateData.completionDate = body.completionDate ? new Date(body.completionDate) : null;
}
const project = await prisma.project.update({
where: { id },
data: {
title: data.title,
category: data.category,
client: data.client,
status: data.status,
completionDate: data.completionDate ? new Date(data.completionDate) : null,
description: data.description,
coverImage: data.coverImage,
galleryImages: data.galleryImages,
featured: data.featured,
},
where: { id: params.id },
data: updateData,
});
return NextResponse.json(project);
} catch (error) {
return NextResponse.json({ error: 'Error updating project' }, { status: 500 });
const err = error as Prisma.PrismaClientKnownRequestError;
if (err?.code === 'P2025') {
return NextResponse.json({ error: 'Projeto não encontrado' }, { status: 404 });
}
console.error('Error updating project:', error);
return NextResponse.json({ error: 'Erro ao atualizar projeto' }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
export async function DELETE(_request: Request, { params }: Params) {
try {
const { id } = await params;
await prisma.project.delete({
where: { id },
where: { id: params.id },
});
return NextResponse.json({ message: 'Project deleted' });
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: 'Error deleting project' }, { status: 500 });
const err = error as Prisma.PrismaClientKnownRequestError;
if (err?.code === 'P2025') {
return NextResponse.json({ error: 'Projeto não encontrado' }, { status: 404 });
}
console.error('Error deleting project:', error);
return NextResponse.json({ error: 'Erro ao excluir projeto' }, { status: 500 });
}
}

View File

@@ -1,37 +1,78 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { Prisma } from '@prisma/client';
export async function GET() {
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const takeParam = searchParams.get('take');
const statusFilter = searchParams.get('status');
const featuredFilter = searchParams.get('featured');
const take = takeParam ? Number.parseInt(takeParam, 10) : undefined;
const where: Prisma.ProjectWhereInput = {};
if (statusFilter === 'published') {
where.status = { not: 'Rascunho' };
} else if (statusFilter === 'draft') {
where.status = 'Rascunho';
} else if (statusFilter) {
where.status = statusFilter;
}
if (featuredFilter === 'true') {
where.featured = true;
}
const projects = await prisma.project.findMany({
where,
orderBy: { createdAt: 'desc' },
take: Number.isInteger(take) && take! > 0 ? take : undefined,
});
return NextResponse.json(projects);
} catch (error) {
console.error('Error fetching projects:', error);
return NextResponse.json({ error: 'Error fetching projects' }, { status: 500 });
return NextResponse.json({ error: 'Erro ao buscar projetos' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const data = await request.json();
const {
title,
category,
client,
status,
description,
completionDate,
coverImage,
galleryImages,
featured,
} = data;
if (!title || !category) {
return NextResponse.json({ error: 'Título e categoria são obrigatórios.' }, { status: 400 });
}
const project = await prisma.project.create({
data: {
title: data.title,
category: data.category,
client: data.client,
status: data.status,
completionDate: data.completionDate ? new Date(data.completionDate) : null,
description: data.description,
coverImage: data.coverImage,
galleryImages: data.galleryImages,
featured: data.featured,
title,
category,
client,
status: status || 'Em andamento',
description,
completionDate: completionDate ? new Date(completionDate) : null,
coverImage,
galleryImages: Array.isArray(galleryImages) ? galleryImages : [],
featured: Boolean(featured),
},
});
return NextResponse.json(project);
return NextResponse.json(project, { status: 201 });
} catch (error) {
console.error('Error creating project:', error);
return NextResponse.json({ error: 'Error creating project' }, { status: 500 });
return NextResponse.json({ error: 'Erro ao criar projeto' }, { status: 500 });
}
}