mise a jour du frontend
This commit is contained in:
parent
618b740588
commit
9737caff99
9 changed files with 332 additions and 60 deletions
|
|
@ -24,3 +24,92 @@ export async function GET() {
|
||||||
return NextResponse.json({ error: "Unexpected error" }, { status: 500 });
|
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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,40 +64,105 @@ export default function ContactComponent() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data?.email ? (
|
{data?.email ? (
|
||||||
<form action={`mailto:${data.email}`} method="post" encType="text/plain" className="stack">
|
<ContactForm />
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<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>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 été 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,8 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export default function Footer() {
|
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 (
|
return (
|
||||||
<footer className={isTop ? "sticky-footer" : "sticky-footer is-visible"}>
|
<footer>
|
||||||
<p>© 2025 - Toine</p>
|
<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>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,12 +43,12 @@ export default function Header() {
|
||||||
</div>
|
</div>
|
||||||
<nav>
|
<nav>
|
||||||
<ul>
|
<ul>
|
||||||
|
<li><Link href="/">Accueil</Link></li>
|
||||||
<li><Link href="/projects" >Projets</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="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://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="https://www.linkedin.com/in/anthony-violet/" target="_blank">LinkedIn</Link></li>
|
||||||
|
<li><Link href="/contact">Contact</Link></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="cta">
|
<div className="cta">
|
||||||
|
|
@ -61,7 +61,7 @@ export default function Header() {
|
||||||
>
|
>
|
||||||
{theme === "dark" ? "☀️" : "🌙"}
|
{theme === "dark" ? "☀️" : "🌙"}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export default async function Services() {
|
||||||
<ul className="grid-3">
|
<ul className="grid-3">
|
||||||
{services.map((service: any) => (
|
{services.map((service: any) => (
|
||||||
<li key={service.id} className="service-card card feature">
|
<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>
|
<div>
|
||||||
<h3 className="feature__title">{service.name}</h3>
|
<h3 className="feature__title">{service.name}</h3>
|
||||||
{"description" in service && service.description ? (
|
{"description" in service && service.description ? (
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,28 @@ async function GetSkills() {
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
export default async function Skills(){
|
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[]][];
|
const entries = Object.entries(obj) as [string, string[]][];
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--color-accent-50, #fff4ec);
|
background: var(--color-accent-600);
|
||||||
border: 1px solid var(--color-accent-100, #ffe4d6);
|
border: 1px solid var(--color-accent-100, #ffe4d6);
|
||||||
box-shadow: inset 0 0 0 2px rgba(255, 107, 53, 0.08);
|
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-size: 0.85rem;
|
||||||
font-weight: 600;
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,9 @@ body {
|
||||||
color: var(--color-text, #1f2937);
|
color: var(--color-text, #1f2937);
|
||||||
min-height: 100svh;
|
min-height: 100svh;
|
||||||
padding-top: var(--header-height, 80px); /* espace pour le header fixe */
|
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;
|
transition: background-color 0.25s ease, color 0.25s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Images fluides */
|
/* Images fluides */
|
||||||
|
|
@ -29,6 +30,7 @@ img, svg, video {
|
||||||
/* Mise en page commune */
|
/* Mise en page commune */
|
||||||
main {
|
main {
|
||||||
display: block;
|
display: block;
|
||||||
|
flex: 1 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,29 @@ async function getProjects() {
|
||||||
return res.json();
|
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() {
|
export default async function ProjectsPage() {
|
||||||
const projects = await getProjects();
|
const projects = await getProjects();
|
||||||
|
|
||||||
|
|
@ -23,12 +46,41 @@ export default async function ProjectsPage() {
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<h2 className="section-title">Mes projets</h2>
|
<h2 className="section-title">Mes projets</h2>
|
||||||
<ul className="projects-list">
|
<ul className="projects-list">
|
||||||
{projects.map((p: any) => (
|
{projects.map((p: any) => {
|
||||||
<li key={p.id}>
|
const tags = toTags(p.technologies);
|
||||||
<h3>{p.name}</h3>
|
const href = p.link || p.url || '#';
|
||||||
<p>{p.description}</p>
|
const imgUrl = normalizeImageUrl(p.image);
|
||||||
</li>
|
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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue