mise a jour du frontend

This commit is contained in:
toine 2025-10-05 08:52:12 +02:00
parent 618b740588
commit 9737caff99
9 changed files with 332 additions and 60 deletions

View file

@ -24,3 +24,92 @@ export async function GET() {
return NextResponse.json({ error: "Unexpected error" }, { status: 500 });
}
}
export async function POST(req: Request) {
try {
const RESEND_API_KEY = process.env.RESEND_API_KEY;
const RESEND_FROM = process.env.RESEND_FROM || "no-reply@resend.dev";
const CONTACT_TO = process.env.CONTACT_TO || process.env.CONTACT_EMAIL; // fallback name
if (!RESEND_API_KEY) {
return NextResponse.json({ error: "RESEND_API_KEY not configured" }, { status: 500 });
}
if (!CONTACT_TO) {
return NextResponse.json({ error: "CONTACT_TO (destination email) not configured" }, { status: 500 });
}
const payload = await req.json().catch(() => null);
if (!payload || typeof payload !== "object") {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const name = String(payload.name || "").trim();
const email = String(payload.email || "").trim();
const subject = String(payload.subject || "").trim() || "Nouveau message via le site";
const message = String(payload.message || "").trim();
if (!name || !email || !message) {
return NextResponse.json({ error: "Champs requis manquants: name, email, message" }, { status: 400 });
}
// Basic email format check (very permissive)
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return NextResponse.json({ error: "Email invalide" }, { status: 400 });
}
const html = `
<div>
<p><strong>Nom:</strong> ${escapeHtml(name)}</p>
<p><strong>Email:</strong> ${escapeHtml(email)}</p>
<p><strong>Sujet:</strong> ${escapeHtml(subject)}</p>
<p><strong>Message:</strong><br/>${escapeHtml(message).replace(/\n/g, '<br/>')}</p>
</div>
`;
const resendRes = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Authorization": `Bearer ${RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: RESEND_FROM,
to: [CONTACT_TO],
reply_to: [email],
subject: subject,
html,
text: `Nom: ${name}\nEmail: ${email}\nSujet: ${subject}\n\n${message}`,
}),
});
const text = await resendRes.text();
if (!resendRes.ok) {
try {
const err = JSON.parse(text);
return NextResponse.json(err, { status: resendRes.status });
} catch {
return new NextResponse(text || "Failed to send message via Resend", { status: resendRes.status });
}
}
try {
const json = JSON.parse(text || "{}");
return NextResponse.json(json, { status: 200 });
} catch {
return NextResponse.json({ ok: true }, { status: 200 });
}
} catch (error) {
return NextResponse.json({ error: "Unexpected error" }, { status: 500 });
}
}
// Small helper to prevent HTML injection in email body
function escapeHtml(input: string): string {
return input
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}

View file

@ -64,36 +64,7 @@ export default function ContactComponent() {
</div>
{data?.email ? (
<form action={`mailto:${data.email}`} method="post" encType="text/plain" className="stack">
<div>
<label htmlFor="name">Votre nom</label>
<input id="name" name="Nom" type="text" required />
</div>
<div>
<label htmlFor="email">Votre email</label>
<input id="email" name="Email" type="email" required />
</div>
<div>
<label htmlFor="subject">Sujet</label>
<input id="subject" name="Sujet" type="text" placeholder="Prise de contact" />
</div>
<div>
<label htmlFor="message">Message</label>
<textarea id="message" name="Message" required />
</div>
<div className="btn-group">
<button type="submit" className="btn btn--primary btn--pill">
Envoyer
</button>
<button type="reset" className="btn btn--ghost">
Réinitialiser
</button>
</div>
</form>
<ContactForm />
) : (
<p className="u-muted">Une erreur est survenue. Vous pouvez tout de même me contacter directement par email : <a href="mailto:violet.anthony90@gmail.com">Me joindre par email</a></p>
)}
@ -101,3 +72,97 @@ export default function ContactComponent() {
</div>
);
}
function ContactForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [subject, setSubject] = useState("");
const [message, setMessage] = useState("");
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!name || !email || !message) {
setError("Veuillez remplir les champs requis.");
return;
}
setStatus("loading");
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, subject, message }),
});
if (!res.ok) {
const txt = await res.text();
throw new Error(txt || "Erreur lors de l'envoi du message");
}
setStatus("success");
setName("");
setEmail("");
setSubject("");
setMessage("");
} catch (err: any) {
setStatus("error");
setError(err?.message || "Une erreur est survenue.");
}
}
return (
<form onSubmit={handleSubmit} className="stack" noValidate>
<div>
<label htmlFor="name">Votre nom</label>
<input id="name" name="name" type="text" required value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<label htmlFor="email">Votre email</label>
<input id="email" name="email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div>
<label htmlFor="subject">Sujet</label>
<input id="subject" name="subject" type="text" placeholder="Prise de contact" value={subject} onChange={(e) => setSubject(e.target.value)} />
</div>
<div>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required value={message} onChange={(e) => setMessage(e.target.value)} />
</div>
{status === "success" ? (
<p className="u-success">Votre message a bien é envoyé. Merci !</p>
) : null}
{status === "error" && error ? (
<p className="u-error">{error}</p>
) : null}
<div className="btn-group">
<button type="submit" className="btn btn--primary btn--pill" disabled={status === "loading"}>
{status === "loading" ? "Envoi…" : "Envoyer"}
</button>
<button
type="button"
className="btn btn--ghost"
onClick={() => {
setName("");
setEmail("");
setSubject("");
setMessage("");
setError(null);
setStatus("idle");
}}
disabled={status === "loading"}
>
Réinitialiser
</button>
</div>
</form>
);
}

View file

@ -1,21 +1,8 @@
"use client";
import { useEffect, useState } from "react";
export default function Footer() {
const [isTop, setIsTop] = useState(true);
useEffect(() => {
const onScroll = () => setIsTop(window.scrollY <= 0);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<footer className={isTop ? "sticky-footer" : "sticky-footer is-visible"}>
<footer>
<p>© 2025 - Toine</p>
<p><small>Développé avec Python, JavaScript et React. Alimenté par Next.js.</small></p>
<p><small>Développé avec Python, JavaScript et React. Alimenté par Next.js.</small></p>
</footer>
);
}

View file

@ -43,12 +43,12 @@ export default function Header() {
</div>
<nav>
<ul>
<li><Link href="/">Accueil</Link></li>
<li><Link href="/projects" >Projets</Link></li>
<li><Link href="/competences">Compétences</Link></li>
<li><Link href="https://toinesensei.itch.io/" target="_blank">Itch.io</Link></li>
<li><Link href="/contact">Contact</Link></li>
<li><Link href="https://fr.malt.be/profile/anthonyviolet1" target="_blank">Malt</Link></li>
<li><Link href="https://www.linkedin.com/in/anthony-violet/" target="_blank">LinkedIn</Link></li>
<li><Link href="/contact">Contact</Link></li>
</ul>
</nav>
<div className="cta">
@ -61,7 +61,7 @@ export default function Header() {
>
{theme === "dark" ? "☀️" : "🌙"}
</button>
<Link href="mailto:tonemail@domaine.com" className="button">Travaillons ensemble</Link>
<Link href="mailto:violet.anthony90@gmail.com" className="button">Travaillons ensemble</Link>
</div>
</header>

View file

@ -19,7 +19,7 @@ export default async function Services() {
<ul className="grid-3">
{services.map((service: any) => (
<li key={service.id} className="service-card card feature">
<span className="feature__icon" aria-hidden="true" />
<span className="feature__icon" aria-hidden="true"></span>
<div>
<h3 className="feature__title">{service.name}</h3>
{"description" in service && service.description ? (

View file

@ -9,9 +9,28 @@ async function GetSkills() {
return res.json();
}
export default async function Skills(){
const skills = await GetSkills();
const data = await GetSkills();
// Support both array and object responses from the backend and merge all items if array
let obj: Record<string, string[]> = {};
if (Array.isArray(data)) {
for (const item of data) {
if (item && typeof item === "object") {
for (const [category, items] of Object.entries(item as Record<string, unknown>)) {
if (Array.isArray(items)) {
obj[category] = [...(obj[category] ?? []), ...items.filter((x): x is string => typeof x === "string")];
}
}
}
}
// Deduplicate items per category
obj = Object.fromEntries(
Object.entries(obj).map(([k, arr]) => [k, Array.from(new Set(arr))])
);
} else if (data && typeof data === "object") {
obj = data as Record<string, string[]>;
}
const obj: Record<string, string[]> = Array.isArray(skills) ? (skills[0] ?? {}) : {};
const entries = Object.entries(obj) as [string, string[]][];
if (entries.length === 0) {

View file

@ -81,7 +81,7 @@
width: 40px;
height: 40px;
border-radius: 999px;
background: var(--color-accent-50, #fff4ec);
background: var(--color-accent-600);
border: 1px solid var(--color-accent-100, #ffe4d6);
box-shadow: inset 0 0 0 2px rgba(255, 107, 53, 0.08);
}
@ -574,3 +574,61 @@ section > ul > li > ul > li {
font-size: 0.85rem;
font-weight: 600;
}
/* =========================
Projets cartes avec header image et footer tags
========================= */
.projects-list > li.project-card {
padding: 0;
overflow: hidden;
}
.project-card__link {
display: flex;
flex-direction: column;
height: 100%;
color: inherit;
text-decoration: none;
}
.project-card__header {
position: relative;
}
.project-card__image {
width: 100%;
height: 160px;
background-size: cover;
background-position: center;
background-color: var(--color-accent-50, #fff4ec);
}
.project-card__image--placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-accent-700, #c74d25);
font-weight: 700;
font-size: 1.75rem;
}
.project-card__body {
padding: var(--space-5, 1.25rem) var(--space-6, 1.5rem);
}
.project-card__body h3 {
margin: 0 0 var(--space-2, 0.5rem);
font-size: 1.125rem;
}
.project-card__body p {
margin: 0;
color: var(--color-muted, #6b7280);
}
.project-card__footer {
margin-top: auto;
padding: var(--space-4, 1rem) var(--space-6, 1.5rem) var(--space-5, 1.25rem);
border-top: 1px solid var(--color-border, #efefef);
}

View file

@ -15,8 +15,9 @@ body {
color: var(--color-text, #1f2937);
min-height: 100svh;
padding-top: var(--header-height, 80px); /* espace pour le header fixe */
transition: background-color 0.25s ease, color 0.25s ease;
transition: background-color 0.25s ease, color 0.25s ease;
display: flex;
flex-direction: column;
}
/* Images fluides */
@ -29,6 +30,7 @@ img, svg, video {
/* Mise en page commune */
main {
display: block;
flex: 1 0 auto;
}
.container {

View file

@ -15,6 +15,29 @@ async function getProjects() {
return res.json();
}
function toTags(technologies: unknown): string[] {
if (Array.isArray(technologies)) return technologies.filter(Boolean).map(String);
if (typeof technologies === 'string') {
// Split on common separators and the word 'et' (French 'and')
return technologies
.split(/,|;|\||\//g)
.flatMap(part => part.split(/\bet\b/i))
.flatMap(part => part.split(/\s{2,}/))
.map(s => s.trim())
.filter(s => s.length > 0);
}
return [];
}
function normalizeImageUrl(img: unknown): string | null {
if (typeof img !== 'string' || img.trim() === '') return null;
const val = img.trim();
// If already absolute (http/https or data URL), return as is
if (/^(https?:)?\/\//i.test(val) || /^data:/i.test(val)) return val;
// Otherwise, try to use as-is; backend might serve it relatively
return val;
}
export default async function ProjectsPage() {
const projects = await getProjects();
@ -23,12 +46,41 @@ export default async function ProjectsPage() {
<div className="container">
<h2 className="section-title">Mes projets</h2>
<ul className="projects-list">
{projects.map((p: any) => (
<li key={p.id}>
<h3>{p.name}</h3>
<p>{p.description}</p>
</li>
))}
{projects.map((p: any) => {
const tags = toTags(p.technologies);
const href = p.link || p.url || '#';
const imgUrl = normalizeImageUrl(p.image);
return (
<li key={p.id} className="project-card">
<a href={href} target="_blank" rel="noopener noreferrer" className="project-card__link">
<div className="project-card__header" aria-hidden="true">
{p.image ? (
<div className="project-card__image" style={{ backgroundImage: `url(${p.image || ''})` }} />
) : (
<div className="project-card__image project-card__image--placeholder">
<span>{(p.name || '').slice(0, 1).toUpperCase()}</span>
</div>
)}
</div>
<div className="project-card__body">
<h3>{p.name}</h3>
<p>{p.description}</p>
</div>
<div className="project-card__footer">
<div className="tech-badges">
{tags.length > 0 ? (
tags.map((t: string, idx: number) => (
<span key={idx} className="badge badge--mono">{t}</span>
))
) : (
p.technologies ? <span className="badge badge--mono">{String(p.technologies)}</span> : null
)}
</div>
</div>
</a>
</li>
);
})}
</ul>
</div>
</section>