Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 839c584b0a | |||
| 20a5b5d85f | |||
| 7cd1531e06 | |||
| fd18ea99e1 | |||
| 277b407361 | |||
| 06f47cbe11 |
@@ -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'),
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ const { locale } = useI18n()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const slug = Array.isArray(route.params.slug) ? route.params.slug.join('/') : route.params.slug
|
const slug = Array.isArray(route.params.slug) ? route.params.slug.join('/') : route.params.slug
|
||||||
const path = `/blog/${slug}`
|
const isFr = locale.value === 'fr'
|
||||||
|
const collection = isFr ? 'blog_fr' : 'blog_en'
|
||||||
|
// blog_fr prefix = /fr/blog, blog_en prefix = /en/blog (aligned with content.config.ts)
|
||||||
|
const path = isFr ? `/fr/blog/${slug}` : `/en/blog/${slug}`
|
||||||
|
|
||||||
const { data: page } = await useAsyncData(`blog-${locale.value}-${slug}`, () => {
|
const { data: page } = await useAsyncData(`blog-${locale.value}-${slug}`, () =>
|
||||||
const collection = locale.value === 'fr' ? 'blog_fr' : 'blog_en'
|
queryCollection(collection).path(path).first()
|
||||||
return queryCollection(collection).path(path).first()
|
)
|
||||||
})
|
|
||||||
|
|
||||||
if (!page.value) {
|
if (!page.value) {
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Article introuvable' })
|
throw createError({ statusCode: 404, statusMessage: 'Article introuvable' })
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { data: page } = await useAsyncData('test', () =>
|
const { data: page } = await useAsyncData('test', () =>
|
||||||
queryCollection('blog_fr').path('/blog/test-kotlin-syntax').first()
|
queryCollection('blog_fr').path('/fr/blog/test-kotlin-syntax').first()
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -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">
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ export default defineContentConfig({
|
|||||||
collections: {
|
collections: {
|
||||||
blog_fr: defineCollection({
|
blog_fr: defineCollection({
|
||||||
type: 'page',
|
type: 'page',
|
||||||
source: { include: 'fr/blog/**/*.md', prefix: '/blog' },
|
source: { include: 'fr/blog/**/*.md', prefix: '/fr/blog' },
|
||||||
schema: blogSchema,
|
schema: blogSchema,
|
||||||
}),
|
}),
|
||||||
blog_en: defineCollection({
|
blog_en: defineCollection({
|
||||||
|
|||||||
@@ -1,49 +1,240 @@
|
|||||||
---
|
---
|
||||||
title: "Test Kotlin Syntax Highlighting"
|
title: "Markdown Format Guide"
|
||||||
description: "Test article to validate the @nuxt/content renderer"
|
description: "Complete reference of all elements and components available in articles"
|
||||||
date: "2026-04-21"
|
date: "2026-04-21"
|
||||||
tags: ["kotlin", "hytale", "test"]
|
tags: ["guide", "markdown", "mdc"]
|
||||||
---
|
---
|
||||||
|
|
||||||
## Kotlin Code Block
|
## Basic Typography
|
||||||
|
|
||||||
|
Normal paragraph with **bold**, *italic*, ~~strikethrough~~ and `inline code`.
|
||||||
|
|
||||||
|
Simple link: [killiandalcin.fr](https://killiandalcin.fr)
|
||||||
|
|
||||||
|
Blockquote:
|
||||||
|
|
||||||
|
> The best Hytale plugins are born from an obsession with details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Blocks
|
||||||
|
|
||||||
|
Kotlin block with syntax highlighting:
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
fun main() {
|
fun createPlugin(name: String): HytalePlugin {
|
||||||
println("Hello, Hytale!")
|
return HytalePlugin.builder()
|
||||||
}
|
.name(name)
|
||||||
|
.version("1.0.0")
|
||||||
fun createPlugin(name: String): Plugin {
|
.onLoad { println("Plugin $name loaded!") }
|
||||||
return Plugin(name = name, version = "1.0.0")
|
.build()
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Optimized Image
|
TypeScript:
|
||||||
|
|
||||||

|
```typescript
|
||||||
|
interface PluginConfig {
|
||||||
|
name: string
|
||||||
|
version: `${number}.${number}.${number}`
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
## Table
|
const config: PluginConfig = {
|
||||||
|
name: 'hytale-core',
|
||||||
|
version: '1.0.0',
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
| Feature | Status | Notes |
|
Shell:
|
||||||
|---------|--------|-------|
|
|
||||||
| Syntax highlighting | ✅ Active | Kotlin, Java, TypeScript, Shell |
|
```bash
|
||||||
| Optimized images | ✅ Active | Via NuxtImg (lazy + srcset) |
|
pnpm install @hytale/sdk
|
||||||
| Tables | ✅ Active | Prose rendering |
|
pnpm run build
|
||||||
| Callouts | ✅ Active | MDC ::alert{type} |
|
docker build -t portfolio:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Images
|
||||||
|
|
||||||
|
**Full width (default):**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Centered with fixed size and caption:**
|
||||||
|
|
||||||
|
{align="center" width="120" caption="Killian' DAL-CIN Logo"}
|
||||||
|
|
||||||
|
**Floating left:**
|
||||||
|
|
||||||
|
{align="left" caption="Float left"}
|
||||||
|
|
||||||
|
Text wrapping around the floating image. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco.
|
||||||
|
|
||||||
|
::clear
|
||||||
|
::
|
||||||
|
|
||||||
|
**Floating right:**
|
||||||
|
|
||||||
|
{align="right" caption="Float right"}
|
||||||
|
|
||||||
|
Text wrapping around the right-floating image. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
||||||
|
|
||||||
|
::clear
|
||||||
|
::
|
||||||
|
|
||||||
|
**Direct Tailwind classes:**
|
||||||
|
|
||||||
|
{.w-16 .mx-auto .block}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tables
|
||||||
|
|
||||||
|
| Component | Syntax | Usage |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| Alert | `::alert{type="info"}` | Colored callouts |
|
||||||
|
| Image | `{align="left"}` | Floating photos |
|
||||||
|
| Columns | `::columns{cols=2}` | Page layout |
|
||||||
|
| Details | `::details{summary="View"}` | Collapsible content |
|
||||||
|
| Video | `::video{src="..."}` | YouTube/local embed |
|
||||||
|
| Badge | `:badge[text]{color="blue"}` | Inline tags |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Callouts
|
## Callouts
|
||||||
|
|
||||||
::alert{type="info"}
|
::alert{type="info"}
|
||||||
This is an information callout.
|
**Info** — Informational message. Supports **bold** and `inline code`.
|
||||||
::
|
::
|
||||||
|
|
||||||
::alert{type="warning"}
|
::alert{type="warning"}
|
||||||
This is a warning.
|
**Warning** — Check Nuxt 4 compatibility before installing a module.
|
||||||
::
|
::
|
||||||
|
|
||||||
::alert{type="tip"}
|
::alert{type="tip"}
|
||||||
Practical Kotlin development tip.
|
**Tip** — Use `pnpm` instead of `npm` for Nuxt projects (faster resolution).
|
||||||
::
|
::
|
||||||
|
|
||||||
::alert{type="danger"}
|
::alert{type="danger"}
|
||||||
Critical error — do not ignore.
|
**Danger** — Never commit API keys or secrets in plain text.
|
||||||
::
|
::
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Columns
|
||||||
|
|
||||||
|
::columns{cols=2}
|
||||||
|
::div
|
||||||
|
**Column 1**
|
||||||
|
|
||||||
|
Content of the first column. Ideal for comparing two approaches, showing before/after, or listing pros and cons.
|
||||||
|
::
|
||||||
|
|
||||||
|
::div
|
||||||
|
**Column 2**
|
||||||
|
|
||||||
|
Content of the second column. Columns automatically stack on mobile.
|
||||||
|
::
|
||||||
|
::
|
||||||
|
|
||||||
|
::columns{cols=3 gap="lg"}
|
||||||
|
::div
|
||||||
|
🚀 **Fast**
|
||||||
|
|
||||||
|
Nuxt SSR generates HTML server-side.
|
||||||
|
::
|
||||||
|
|
||||||
|
::div
|
||||||
|
🔍 **SEO**
|
||||||
|
|
||||||
|
Every page is crawlable without JavaScript.
|
||||||
|
::
|
||||||
|
|
||||||
|
::div
|
||||||
|
🎨 **Flexible**
|
||||||
|
|
||||||
|
Tailwind + Nuxt UI for all your styling needs.
|
||||||
|
::
|
||||||
|
::
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Collapsible Content
|
||||||
|
|
||||||
|
::details{summary="View full plugin implementation"}
|
||||||
|
```kotlin
|
||||||
|
class HytalePlugin(
|
||||||
|
val name: String,
|
||||||
|
val version: String,
|
||||||
|
private val onLoad: () -> Unit,
|
||||||
|
) {
|
||||||
|
fun load() {
|
||||||
|
println("Loading plugin: $name v$version")
|
||||||
|
onLoad()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun builder() = Builder()
|
||||||
|
}
|
||||||
|
|
||||||
|
class Builder {
|
||||||
|
private var name = ""
|
||||||
|
private var version = "0.0.1"
|
||||||
|
private var onLoad: () -> Unit = {}
|
||||||
|
|
||||||
|
fun name(n: String) = apply { name = n }
|
||||||
|
fun version(v: String) = apply { version = v }
|
||||||
|
fun onLoad(fn: () -> Unit) = apply { onLoad = fn }
|
||||||
|
fun build() = HytalePlugin(name, version, onLoad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
::
|
||||||
|
|
||||||
|
::details{summary="Why use @nuxt/content?" open=true}
|
||||||
|
`@nuxt/content` transforms Markdown files into crawlable SSR pages. Benefits:
|
||||||
|
|
||||||
|
- **Zero CMS** — articles live in the Git repo
|
||||||
|
- **Typed** — Zod schema on every collection
|
||||||
|
- **MDC** — Vue components inside Markdown
|
||||||
|
::
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inline Badges
|
||||||
|
|
||||||
|
Versions: :badge[v1.0]{color="green"} :badge[v0.9 LTS]{color="blue"} :badge[deprecated]{color="red"}
|
||||||
|
|
||||||
|
Status: :badge[stable]{color="green"} :badge[beta]{color="yellow"} :badge[wip]{color="orange"}
|
||||||
|
|
||||||
|
Technologies: :badge[Kotlin]{color="purple"} :badge[Nuxt 4]{color="green"} :badge[TypeScript]{color="blue"}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Video
|
||||||
|
|
||||||
|
**YouTube:**
|
||||||
|
|
||||||
|
::video{src="https://www.youtube.com/watch?v=dQw4w9WgXcQ" title="YouTube embed example"}
|
||||||
|
::
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lists
|
||||||
|
|
||||||
|
Unordered list:
|
||||||
|
|
||||||
|
- First item
|
||||||
|
- Second item with `code`
|
||||||
|
- Third item with **bold**
|
||||||
|
- Nested sub-item
|
||||||
|
- Another sub-item
|
||||||
|
|
||||||
|
Ordered list:
|
||||||
|
|
||||||
|
1. Install dependencies: `pnpm install`
|
||||||
|
2. Start the dev server: `pnpm dev`
|
||||||
|
3. Build for production: `pnpm build`
|
||||||
|
|||||||
+43
-43
@@ -1,17 +1,15 @@
|
|||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
future: {
|
compatibilityDate: '2026-04-21',
|
||||||
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: [
|
||||||
{
|
{
|
||||||
@@ -20,65 +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_except_default',
|
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: {
|
||||||
vite: {
|
'/blog': { redirect: { to: '/fr/blog', statusCode: 301 } },
|
||||||
optimizeDeps: {
|
'/blog/**': { redirect: { to: '/fr/blog', statusCode: 301 } },
|
||||||
include: ['zod'],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
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