refactor(config): update nuxt.config.ts to enhance module configuration, remove deprecated files, and improve contact form validation with zod schema
This commit is contained in:
@@ -46,8 +46,8 @@ defineExpose({ openGallery })
|
|||||||
icon="i-lucide-x"
|
icon="i-lucide-x"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="lg"
|
size="lg"
|
||||||
@click="isOpen = false"
|
|
||||||
:aria-label="'Close gallery'"
|
:aria-label="'Close gallery'"
|
||||||
|
@click="isOpen = false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ const techData = computed((): Technology => {
|
|||||||
|
|
||||||
let found = allTechs.find((t) => t.name.toLowerCase() === techName.toLowerCase())
|
let found = allTechs.find((t) => t.name.toLowerCase() === techName.toLowerCase())
|
||||||
|
|
||||||
if (!found && techMapping[techName]) {
|
const mapped = techMapping[techName]
|
||||||
found = allTechs.find((t) => t.name.toLowerCase() === techMapping[techName].toLowerCase())
|
if (!found && mapped) {
|
||||||
|
found = allTechs.find((t) => t.name.toLowerCase() === mapped.toLowerCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
return found ?? { name: techName, image: '', level: 'Intermediate' as const }
|
return found ?? { name: techName, image: '', level: 'Intermediate' as const }
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="px-4 py-3 prose prose-neutral dark:prose-invert max-w-none
|
<div
|
||||||
|
class="px-4 py-3 prose prose-neutral dark:prose-invert max-w-none
|
||||||
prose-code:before:content-none prose-code:after:content-none
|
prose-code:before:content-none prose-code:after:content-none
|
||||||
prose-pre:p-0 prose-pre:bg-transparent text-sm">
|
prose-pre:p-0 prose-pre:bg-transparent text-sm">
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const wrapperStyle = computed(() => {
|
|||||||
:title="props.title || props.caption"
|
:title="props.title || props.caption"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
:class="props.align === 'full' && !attrs.class ? 'w-full rounded-lg' : 'rounded-lg'"
|
:class="props.align === 'full' && !attrs.class ? 'w-full rounded-lg' : 'rounded-lg'"
|
||||||
/>
|
>
|
||||||
<span
|
<span
|
||||||
v-if="props.caption"
|
v-if="props.caption"
|
||||||
class="mt-2 block text-center text-xs text-neutral-500 dark:text-neutral-400 italic"
|
class="mt-2 block text-center text-xs text-neutral-500 dark:text-neutral-400 italic"
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ const quickLinks = computed(() => [
|
|||||||
<!-- Brand column -->
|
<!-- Brand column -->
|
||||||
<div class="sm:col-span-2 lg:col-span-1 space-y-5">
|
<div class="sm:col-span-2 lg:col-span-1 space-y-5">
|
||||||
<NuxtLink :to="localePath('/')" class="flex items-center gap-2.5 group">
|
<NuxtLink :to="localePath('/')" class="flex items-center gap-2.5 group">
|
||||||
<NuxtImg src="/images/logo.webp" alt="Killian' DAL-CIN" width="36" height="36" loading="lazy"
|
<NuxtImg
|
||||||
|
src="/images/logo.webp" alt="Killian' DAL-CIN" width="36" height="36" loading="lazy"
|
||||||
class="rounded-lg transition-transform duration-300 group-hover:scale-110" />
|
class="rounded-lg transition-transform duration-300 group-hover:scale-110" />
|
||||||
<span class="text-lg font-bold text-gray-900 dark:text-white">Killian' DAL-CIN</span>
|
<span class="text-lg font-bold text-gray-900 dark:text-white">Killian' DAL-CIN</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
@@ -39,7 +40,8 @@ const quickLinks = computed(() => [
|
|||||||
Navigation
|
Navigation
|
||||||
</h3>
|
</h3>
|
||||||
<nav class="flex flex-col gap-3">
|
<nav class="flex flex-col gap-3">
|
||||||
<NuxtLink v-for="link in quickLinks" :key="link.key" :to="localePath(link.path)"
|
<NuxtLink
|
||||||
|
v-for="link in quickLinks" :key="link.key" :to="localePath(link.path)"
|
||||||
class="text-sm text-gray-600 dark:text-gray-400 hover:text-brand-500 dark:hover:text-brand-400 transition-colors duration-200">
|
class="text-sm text-gray-600 dark:text-gray-400 hover:text-brand-500 dark:hover:text-brand-400 transition-colors duration-200">
|
||||||
{{ t(`nav.${link.key}`) }}
|
{{ t(`nav.${link.key}`) }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
@@ -65,10 +67,12 @@ const quickLinks = computed(() => [
|
|||||||
Connect
|
Connect
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<a v-for="link in socialLinks" :key="link.name" :href="link.url" target="_blank" rel="noopener noreferrer"
|
<a
|
||||||
|
v-for="link in socialLinks" :key="link.name" :href="link.url" target="_blank" rel="noopener noreferrer"
|
||||||
:aria-label="t(link.ariaKey)"
|
:aria-label="t(link.ariaKey)"
|
||||||
class="w-10 h-10 inline-flex items-center justify-center rounded-xl border border-gray-200/80 dark:border-gray-800/50 bg-white/60 dark:bg-gray-900/40 hover:border-brand-500/40 hover:bg-brand-500/10 dark:hover:bg-brand-500/10 transition-all duration-300 group">
|
class="w-10 h-10 inline-flex items-center justify-center rounded-xl border border-gray-200/80 dark:border-gray-800/50 bg-white/60 dark:bg-gray-900/40 hover:border-brand-500/40 hover:bg-brand-500/10 dark:hover:bg-brand-500/10 transition-all duration-300 group">
|
||||||
<UIcon :name="link.icon"
|
<UIcon
|
||||||
|
:name="link.icon"
|
||||||
class="w-4.5 h-4.5 text-gray-500 dark:text-gray-400 group-hover:text-brand-500 dark:group-hover:text-brand-400 transition-colors" />
|
class="w-4.5 h-4.5 text-gray-500 dark:text-gray-400 group-hover:text-brand-500 dark:group-hover:text-brand-400 transition-colors" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,14 +34,16 @@ function isActive(path: string): boolean {
|
|||||||
<div class="flex items-center justify-between h-16">
|
<div class="flex items-center justify-between h-16">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<NuxtLink :to="localePath('/')" :aria-label="t('a11y.logoLabel')" class="flex items-center gap-2.5 shrink-0">
|
<NuxtLink :to="localePath('/')" :aria-label="t('a11y.logoLabel')" class="flex items-center gap-2.5 shrink-0">
|
||||||
<NuxtImg src="/images/logo.webp" alt="Killian' DAL-CIN" width="36" height="36" loading="eager"
|
<NuxtImg
|
||||||
|
src="/images/logo.webp" alt="Killian' DAL-CIN" width="36" height="36" loading="eager"
|
||||||
class="rounded-lg" />
|
class="rounded-lg" />
|
||||||
<span class="text-base font-semibold tracking-tight text-gray-900 dark:text-white">Killian'</span>
|
<span class="text-base font-semibold tracking-tight text-gray-900 dark:text-white">Killian'</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<!-- Desktop nav -->
|
<!-- Desktop nav -->
|
||||||
<nav class="hidden md:flex items-center gap-1" aria-label="Main navigation">
|
<nav class="hidden md:flex items-center gap-1" aria-label="Main navigation">
|
||||||
<NuxtLink v-for="link in navLinks" :key="link.key" :to="localePath(link.path)"
|
<NuxtLink
|
||||||
|
v-for="link in navLinks" :key="link.key" :to="localePath(link.path)"
|
||||||
:aria-current="isActive(link.path) ? 'page' : undefined"
|
:aria-current="isActive(link.path) ? 'page' : undefined"
|
||||||
class="px-3 py-2 text-sm font-medium rounded-lg transition-colors" :class="[
|
class="px-3 py-2 text-sm font-medium rounded-lg transition-colors" :class="[
|
||||||
isActive(link.path)
|
isActive(link.path)
|
||||||
@@ -60,13 +62,15 @@ function isActive(path: string): boolean {
|
|||||||
</UButton>
|
</UButton>
|
||||||
|
|
||||||
<!-- Theme toggle -->
|
<!-- Theme toggle -->
|
||||||
<UButton variant="ghost" color="neutral" size="sm"
|
<UButton
|
||||||
|
variant="ghost" color="neutral" size="sm"
|
||||||
:icon="colorMode.value === 'dark' ? 'i-lucide-sun' : 'i-lucide-moon'"
|
:icon="colorMode.value === 'dark' ? 'i-lucide-sun' : 'i-lucide-moon'"
|
||||||
:aria-label="colorMode.value === 'dark' ? t('a11y.themeDark') : t('a11y.themeLight')"
|
:aria-label="colorMode.value === 'dark' ? t('a11y.themeDark') : t('a11y.themeLight')"
|
||||||
@click="toggleTheme" />
|
@click="toggleTheme" />
|
||||||
|
|
||||||
<!-- Mobile hamburger -->
|
<!-- Mobile hamburger -->
|
||||||
<UButton variant="ghost" color="neutral" size="sm" icon="i-lucide-menu" class="md:hidden"
|
<UButton
|
||||||
|
variant="ghost" color="neutral" size="sm" icon="i-lucide-menu" class="md:hidden"
|
||||||
:aria-label="t('a11y.openMenu')" @click="mobileOpen = true" />
|
:aria-label="t('a11y.openMenu')" @click="mobileOpen = true" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,7 +87,8 @@ function isActive(path: string): boolean {
|
|||||||
|
|
||||||
<template #body>
|
<template #body>
|
||||||
<nav class="flex flex-col gap-1" aria-label="Mobile navigation">
|
<nav class="flex flex-col gap-1" aria-label="Mobile navigation">
|
||||||
<NuxtLink v-for="link in navLinks" :key="link.key" :to="localePath(link.path)"
|
<NuxtLink
|
||||||
|
v-for="link in navLinks" :key="link.key" :to="localePath(link.path)"
|
||||||
:aria-current="isActive(link.path) ? 'page' : undefined"
|
:aria-current="isActive(link.path) ? 'page' : undefined"
|
||||||
class="px-4 py-3 text-base font-medium rounded-lg transition-colors" :class="[
|
class="px-4 py-3 text-base font-medium rounded-lg transition-colors" :class="[
|
||||||
isActive(link.path)
|
isActive(link.path)
|
||||||
@@ -100,7 +105,8 @@ function isActive(path: string): boolean {
|
|||||||
<UButton variant="ghost" color="neutral" :aria-label="t('a11y.langToggle')" @click="toggleLocale">
|
<UButton variant="ghost" color="neutral" :aria-label="t('a11y.langToggle')" @click="toggleLocale">
|
||||||
{{ locale === 'fr' ? 'EN' : 'FR' }}
|
{{ locale === 'fr' ? 'EN' : 'FR' }}
|
||||||
</UButton>
|
</UButton>
|
||||||
<UButton variant="ghost" color="neutral" :icon="colorMode.value === 'dark' ? 'i-lucide-sun' : 'i-lucide-moon'"
|
<UButton
|
||||||
|
variant="ghost" color="neutral" :icon="colorMode.value === 'dark' ? 'i-lucide-sun' : 'i-lucide-moon'"
|
||||||
:aria-label="colorMode.value === 'dark' ? t('a11y.themeDark') : t('a11y.themeLight')"
|
:aria-label="colorMode.value === 'dark' ? t('a11y.themeDark') : t('a11y.themeLight')"
|
||||||
@click="toggleTheme" />
|
@click="toggleTheme" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ const resolvedSecondaryText = computed(() => props.secondaryText || t('home.cta2
|
|||||||
<div class="max-w-5xl mx-auto">
|
<div class="max-w-5xl mx-auto">
|
||||||
<div class="relative overflow-hidden rounded-3xl px-8 py-20 sm:px-16 sm:py-24 text-center border border-gray-200/60 dark:border-gray-800/40 bg-gray-50 dark:bg-gray-900">
|
<div class="relative overflow-hidden rounded-3xl px-8 py-20 sm:px-16 sm:py-24 text-center border border-gray-200/60 dark:border-gray-800/40 bg-gray-50 dark:bg-gray-900">
|
||||||
<!-- Subtle dot pattern -->
|
<!-- Subtle dot pattern -->
|
||||||
<div class="absolute inset-0 opacity-[0.03] dark:opacity-[0.06]" aria-hidden="true"
|
<div
|
||||||
|
class="absolute inset-0 opacity-[0.03] dark:opacity-[0.06]" aria-hidden="true"
|
||||||
style="background-image: radial-gradient(circle, currentColor 1px, transparent 1px); background-size: 24px 24px;" />
|
style="background-image: radial-gradient(circle, currentColor 1px, transparent 1px); background-size: 24px 24px;" />
|
||||||
<!-- Brand glow -->
|
<!-- Brand glow -->
|
||||||
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-[600px] h-[300px] bg-brand-500/8 dark:bg-brand-500/15 rounded-full blur-3xl pointer-events-none" aria-hidden="true" />
|
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-[600px] h-[300px] bg-brand-500/8 dark:bg-brand-500/15 rounded-full blur-3xl pointer-events-none" aria-hidden="true" />
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ const discordUrl = siteConfig.social.find(s => s.name === 'Discord')?.url ?? '#'
|
|||||||
<section class="relative min-h-[80vh] flex items-center overflow-hidden bg-white dark:bg-gray-950">
|
<section class="relative min-h-[80vh] flex items-center overflow-hidden bg-white dark:bg-gray-950">
|
||||||
<!-- Dot grid background pattern -->
|
<!-- Dot grid background pattern -->
|
||||||
<div class="absolute inset-0 opacity-[0.03] dark:opacity-[0.06]" aria-hidden="true">
|
<div class="absolute inset-0 opacity-[0.03] dark:opacity-[0.06]" aria-hidden="true">
|
||||||
<div class="absolute inset-0"
|
<div
|
||||||
|
class="absolute inset-0"
|
||||||
style="background-image: radial-gradient(circle, currentColor 1px, transparent 1px); background-size: 32px 32px;" />
|
style="background-image: radial-gradient(circle, currentColor 1px, transparent 1px); background-size: 32px 32px;" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { NuxtError } from '#app'
|
import type { NuxtError } from '#app'
|
||||||
|
|
||||||
const props = defineProps<{ error: NuxtError }>()
|
defineProps<{ error: NuxtError }>()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
function handleError() {
|
function handleError() {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { techStack } from '~/data/techstack'
|
import { techStack } from '~/data/techstack'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const localePath = useLocalePath()
|
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title: () => t('seo.about.title'),
|
title: () => t('seo.about.title'),
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ function resetFilters() {
|
|||||||
</div>
|
</div>
|
||||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">{{ t('projects.noResults.title') }}</h3>
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">{{ t('projects.noResults.title') }}</h3>
|
||||||
<p class="text-gray-500 dark:text-gray-400 mb-8 max-w-md mx-auto leading-relaxed">{{ t('projects.noResults.description') }}</p>
|
<p class="text-gray-500 dark:text-gray-400 mb-8 max-w-md mx-auto leading-relaxed">{{ t('projects.noResults.description') }}</p>
|
||||||
<UButton @click="resetFilters" variant="soft" size="md" icon="i-lucide-rotate-ccw">
|
<UButton variant="soft" size="md" icon="i-lucide-rotate-ccw" @click="resetFilters">
|
||||||
{{ t('common.reset') }}
|
{{ t('common.reset') }}
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-1
@@ -19,7 +19,8 @@ const { data: page } = await useAsyncData('test', () =>
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<article class="prose prose-neutral dark:prose-invert max-w-none
|
<article
|
||||||
|
class="prose prose-neutral dark:prose-invert max-w-none
|
||||||
prose-headings:font-semibold
|
prose-headings:font-semibold
|
||||||
prose-code:before:content-none prose-code:after:content-none
|
prose-code:before:content-none prose-code:after:content-none
|
||||||
prose-pre:p-0 prose-pre:bg-transparent">
|
prose-pre:p-0 prose-pre:bg-transparent">
|
||||||
|
|||||||
+41
-45
@@ -1,18 +1,15 @@
|
|||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: '2026-04-21',
|
compatibilityDate: '2026-04-21',
|
||||||
future: {
|
|
||||||
compatibilityVersion: 4
|
|
||||||
},
|
|
||||||
ssr: true,
|
ssr: true,
|
||||||
css: ['~/assets/css/main.css'],
|
css: ['~/assets/css/main.css'],
|
||||||
modules: [
|
modules: [
|
||||||
'@nuxt/ui',
|
'@nuxt/ui',
|
||||||
'@nuxtjs/i18n',
|
'@nuxt/image',
|
||||||
|
'@nuxt/content',
|
||||||
'@nuxt/eslint',
|
'@nuxt/eslint',
|
||||||
|
'@nuxtjs/i18n',
|
||||||
'@nuxtjs/sitemap',
|
'@nuxtjs/sitemap',
|
||||||
'nuxt-gtag',
|
'nuxt-gtag',
|
||||||
'@nuxt/image',
|
|
||||||
'@nuxt/content'
|
|
||||||
],
|
],
|
||||||
components: [
|
components: [
|
||||||
{
|
{
|
||||||
@@ -21,68 +18,67 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
typescript: {
|
typescript: {
|
||||||
strict: true
|
strict: true,
|
||||||
},
|
},
|
||||||
colorMode: {
|
colorMode: {
|
||||||
preference: 'dark',
|
preference: 'dark',
|
||||||
fallback: 'dark',
|
fallback: 'dark',
|
||||||
storage: 'cookie',
|
storage: 'cookie',
|
||||||
storageKey: 'nuxt-color-mode',
|
storageKey: 'nuxt-color-mode',
|
||||||
classSuffix: ''
|
classSuffix: '',
|
||||||
},
|
},
|
||||||
site: {
|
site: {
|
||||||
url: 'https://killiandalcin.fr',
|
url: 'https://killiandalcin.fr',
|
||||||
name: "Killian' DAL-CIN - Developpeur Full Stack"
|
name: "Killian' DAL-CIN - Developpeur Full Stack",
|
||||||
},
|
},
|
||||||
i18n: {
|
i18n: {
|
||||||
strategy: 'prefix',
|
strategy: 'prefix',
|
||||||
defaultLocale: 'fr',
|
defaultLocale: 'fr',
|
||||||
baseUrl: 'https://killiandalcin.fr',
|
locales: [
|
||||||
locales: [
|
{ code: 'fr', language: 'fr-FR', file: 'fr.json' },
|
||||||
{ code: 'fr', language: 'fr-FR', file: 'fr.json' },
|
{ code: 'en', language: 'en-US', file: 'en.json' },
|
||||||
{ code: 'en', language: 'en-US', file: 'en.json' },
|
],
|
||||||
],
|
langDir: 'locales/',
|
||||||
langDir: 'locales/',
|
detectBrowserLanguage: {
|
||||||
detectBrowserLanguage: {
|
useCookie: true,
|
||||||
useCookie: true,
|
cookieKey: 'i18n_redirected',
|
||||||
cookieKey: 'i18n_redirected',
|
redirectOn: 'root',
|
||||||
redirectOn: 'root',
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
smtpHost: '',
|
smtpHost: '',
|
||||||
smtpUser: '',
|
smtpUser: '',
|
||||||
smtpPass: '',
|
smtpPass: '',
|
||||||
smtpTo: '',
|
smtpTo: '',
|
||||||
public: {
|
public: {
|
||||||
gtag: {
|
gtag: {
|
||||||
id: '',
|
id: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
gtag: {
|
gtag: {
|
||||||
id: '',
|
enabled: !import.meta.dev,
|
||||||
enabled: import.meta.env.NODE_ENV === 'production',
|
|
||||||
},
|
|
||||||
routeRules: {
|
|
||||||
'/blog/**': { redirect: { to: '/fr/blog/**', statusCode: 301 } },
|
|
||||||
},
|
},
|
||||||
vite: {
|
routeRules: {
|
||||||
optimizeDeps: {
|
'/blog': { redirect: { to: '/fr/blog', statusCode: 301 } },
|
||||||
include: ['zod'],
|
'/blog/**': { redirect: { to: '/fr/blog', statusCode: 301 } },
|
||||||
},
|
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
build: {
|
build: {
|
||||||
markdown: {
|
markdown: {
|
||||||
highlight: {
|
highlight: {
|
||||||
theme: 'github-dark',
|
theme: 'github-dark',
|
||||||
langs: ['kotlin', 'java', 'typescript', 'shell', 'bash', 'json', 'vue', 'html', 'css']
|
langs: ['kotlin', 'java', 'typescript', 'shell', 'bash', 'json', 'vue', 'html', 'css'],
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
sqliteConnector: 'native'
|
sqliteConnector: 'native',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
|
vite: {
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['zod'],
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
-19402
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -14,24 +14,24 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/content": "^3.13.0",
|
"@nuxt/content": "^3.13.0",
|
||||||
"@nuxt/eslint": "^1.15.2",
|
|
||||||
"@nuxt/image": "^2.0.0",
|
"@nuxt/image": "^2.0.0",
|
||||||
"@nuxt/ui": "^3.0.0",
|
"@nuxt/ui": "^3.3.2",
|
||||||
"@nuxtjs/i18n": "^10.2.4",
|
"@nuxtjs/i18n": "^10.2.4",
|
||||||
"@nuxtjs/sitemap": "^8.0.12",
|
"@nuxtjs/sitemap": "^8.0.12",
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.5",
|
||||||
"nuxt": "^4.0.0",
|
"nuxt": "^4.4.2",
|
||||||
"nuxt-gtag": "^4.1.0",
|
"nuxt-gtag": "^4.1.0",
|
||||||
"vue": "^3.5.0",
|
"vue": "^3.5.0",
|
||||||
"vue-router": "^4.5.0",
|
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.102",
|
"@iconify-json/lucide": "^1.2.102",
|
||||||
|
"@nuxt/eslint": "^1.15.2",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/nodemailer": "^8.0.0",
|
"@types/nodemailer": "^8.0.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.3",
|
||||||
"typescript": "~5.8.0"
|
"typescript": "~5.9.0",
|
||||||
|
"vue-tsc": "^3.2.6"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
|
|||||||
Generated
+231
-219
File diff suppressed because it is too large
Load Diff
+27
-26
@@ -1,21 +1,33 @@
|
|||||||
import nodemailer from 'nodemailer'
|
import nodemailer from 'nodemailer'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const contactSchema = z.object({
|
||||||
|
name: z.string().min(2).max(100),
|
||||||
|
email: z.string().email().max(200),
|
||||||
|
message: z.string().min(10).max(5000),
|
||||||
|
})
|
||||||
|
|
||||||
|
const htmlEscapes: Record<string, string> = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value.replace(/[&<>"']/g, (c) => htmlEscapes[c] ?? c)
|
||||||
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
|
const parsed = contactSchema.safeParse(body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Invalid payload' })
|
||||||
|
}
|
||||||
|
const { name, email, message } = parsed.data
|
||||||
const config = useRuntimeConfig(event)
|
const config = useRuntimeConfig(event)
|
||||||
|
|
||||||
// Server-side validation (T-03-01)
|
|
||||||
const { name, email, message } = body
|
|
||||||
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 100) {
|
|
||||||
throw createError({ statusCode: 400, message: 'Invalid name' })
|
|
||||||
}
|
|
||||||
if (!email || typeof email !== 'string' || !email.includes('@') || email.length > 200) {
|
|
||||||
throw createError({ statusCode: 400, message: 'Invalid email' })
|
|
||||||
}
|
|
||||||
if (!message || typeof message !== 'string' || message.length < 10 || message.length > 5000) {
|
|
||||||
throw createError({ statusCode: 400, message: 'Invalid message' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
host: config.smtpHost,
|
host: config.smtpHost,
|
||||||
port: 465,
|
port: 465,
|
||||||
@@ -26,20 +38,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Escape HTML to prevent XSS in email body (T-03-02)
|
const escapedName = escapeHtml(name)
|
||||||
const escapedName = name.replace(/[&<>"']/g, (c: string) => {
|
const escapedEmail = escapeHtml(email)
|
||||||
const map: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
const escapedMessage = escapeHtml(message)
|
||||||
return map[c] ?? c
|
|
||||||
})
|
|
||||||
const escapedMessage = message.replace(/[&<>"']/g, (c: string) => {
|
|
||||||
const map: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
||||||
return map[c] ?? c
|
|
||||||
})
|
|
||||||
|
|
||||||
const escapedEmail = email.replace(/[&<>"']/g, (c: string) => {
|
|
||||||
const map: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
||||||
return map[c] ?? c
|
|
||||||
})
|
|
||||||
|
|
||||||
const dateStr = new Date().toLocaleString('fr-FR', {
|
const dateStr = new Date().toLocaleString('fr-FR', {
|
||||||
day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
|
||||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
|
||||||
"exclude": ["src/**/__tests__/*"],
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
||||||
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@tsconfig/node22/tsconfig.json",
|
|
||||||
"include": [
|
|
||||||
"vite.config.*",
|
|
||||||
"vitest.config.*",
|
|
||||||
"cypress.config.*",
|
|
||||||
"nightwatch.conf.*",
|
|
||||||
"playwright.config.*",
|
|
||||||
"eslint.config.*"
|
|
||||||
],
|
|
||||||
"compilerOptions": {
|
|
||||||
"noEmit": true,
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
|
||||||
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "Bundler",
|
|
||||||
"types": ["node"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user