diff --git a/frontend/src/app/[locale]/page.tsx b/frontend/src/app/[locale]/page.tsx index 0dde1a8..fa91d47 100644 --- a/frontend/src/app/[locale]/page.tsx +++ b/frontend/src/app/[locale]/page.tsx @@ -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([]); // 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 (
{/* Hero Section */} @@ -186,16 +253,20 @@ export default function Home() {
- {[ - { 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) => ( -
-
+ {(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) => ( +
+
- {project.cat} + {project.category}

{project.title}

diff --git a/frontend/src/app/[locale]/projetos/page.tsx b/frontend/src/app/[locale]/projetos/page.tsx index 686f9a0..517d02a 100644 --- a/frontend/src/app/[locale]/projetos/page.tsx +++ b/frontend/src/app/[locale]/projetos/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [selectedCategory, setSelectedCategory] = useState('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(); + 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 (
@@ -78,38 +94,67 @@ export default function ProjetosPage() {
{/* Filters */}
- - - - -
- -
- {projects.map((project) => ( -
-
-
-
-
- {project.category} -
-
-
-

{project.title}

-
- - {project.location} -
-

- {project.description} -

- - {t('projects.viewDetails')} - -
-
+ + {categories.map((category) => ( + ))}
+ + {loading ? ( +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+ ))} +
+ ) : filteredProjects.length === 0 ? ( +
+ +

{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.'}

+
+ ) : ( +
+ {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 ( +
+
+
+
+
+ {project.category || t('projects.filters.all')} +
+
+
+

{project.title}

+
+ + {project.client || (locale === 'pt' ? 'Cliente confidencial' : locale === 'es' ? 'Cliente confidencial' : 'Confidential client')} +
+

+ {description} +

+ + {t('projects.viewDetails')} + +
+
+ ); + })} +
+ )}
diff --git a/frontend/src/app/admin/projetos/novo/page.tsx b/frontend/src/app/admin/projetos/novo/page.tsx index 25471e4..88819c8 100644 --- a/frontend/src/app/admin/projetos/novo/page.tsx +++ b/frontend/src/app/admin/projetos/novo/page.tsx @@ -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(null); + const galleryInputRef = useRef(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(null); + const [galleryImages, setGalleryImages] = useState([]); + const [uploadingCover, setUploadingCover] = useState(false); + const [uploadingGallery, setUploadingGallery] = useState(false); + + const isSaving = loading || uploadingCover || uploadingGallery; + + const uploadFile = async (file: File): Promise => { + 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) => { + 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) => { + 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 > - - - - + {CATEGORY_OPTIONS.map((option) => ( + + ))}
@@ -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" > - - - + {STATUS_OPTIONS.map((option) => ( + + ))}
@@ -124,6 +267,19 @@ export default function NewProject() { placeholder="Descreva os detalhes técnicos, desafios e soluções do projeto..." >
+ +
+ +

Projetos em destaque podem ser exibidos em seções especiais como a home.

+
@@ -137,33 +293,97 @@ export default function NewProject() {
-
-
- -
-

Clique para fazer upload ou arraste e solte

-

PNG, JPG ou WEBP (Max. 2MB)

+
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 ? ( +
+ {coverImage.name} + +
+ ) : ( +
+
+ {uploadingCover ? ( + + ) : ( + + )} +
+

+ {uploadingCover ? 'Enviando imagem...' : 'Clique para fazer upload ou arraste e solte'} +

+

PNG, JPG ou WEBP (máximo 2MB)

+
+ )} +
-
- - Adicionar -
- {/* Placeholders for uploaded images */} - {[1, 2].map((i) => ( -
+ + + {galleryImages.map((image) => ( +
+ {image.name}
-
))} +
+

Adicione até 8 imagens para apresentar detalhes do projeto.

@@ -178,10 +398,10 @@ export default function NewProject() { +
+ + + ))} + + + + )} ); diff --git a/frontend/src/app/api/projects/[id]/route.ts b/frontend/src/app/api/projects/[id]/route.ts index 26f48b2..5dc4eab 100644 --- a/frontend/src/app/api/projects/[id]/route.ts +++ b/frontend/src/app/api/projects/[id]/route.ts @@ -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 = {}; + + 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 }); } } diff --git a/frontend/src/app/api/projects/route.ts b/frontend/src/app/api/projects/route.ts index 062de20..a2f91e9 100644 --- a/frontend/src/app/api/projects/route.ts +++ b/frontend/src/app/api/projects/route.ts @@ -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 }); } }