chore(initial): ajout de la structure de base du projet avec Vite et Vue 3

- Création des fichiers de configuration pour ESLint, Prettier, et Tailwind CSS
- Ajout de la configuration de l'éditeur avec .editorconfig
- Mise en place de la structure de répertoires pour les composants, les pages, et les données
- Intégration de la gestion des langues avec vue-i18n
- Ajout de la configuration de Vite et des dépendances nécessaires
- Création des fichiers de localisation pour l'anglais et le français
- Ajout de la structure de base pour le portfolio avec des exemples de projets
- Mise en place des composants de base pour l'interface utilisateur
This commit is contained in:
Mr¤KayJayDee
2025-06-22 15:00:35 +02:00
commit cc7368b550
122 changed files with 11938 additions and 0 deletions

9
.editorconfig Normal file
View File

@@ -0,0 +1,9 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

30
.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

6
.prettierrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

8
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode"
]
}

39
README.md Normal file
View File

@@ -0,0 +1,39 @@
# portfolio
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

1
env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

22
eslint.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
skipFormatting,
)

24
index.html Normal file
View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon.png">
<link rel="apple-touch-icon" sizes="180x180" href="/favicon.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description"
content="Portfolio de Killian, développeur Full Stack spécialisé dans les technologies web modernes">
<meta name="author" content="Killian">
<meta property="og:type" content="website">
<meta property="og:locale" content="fr_FR">
<title>Killian - Développeur Full Stack</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

70
old/projects.info Normal file
View File

@@ -0,0 +1,70 @@
cards = [
{
title: "Virtual Tour",
image: require("../assets/images/virtualtour.png"),
description: "My high school teacher and me had an idea to create a Virtual tour with 360° vidéos to allow everyone to visit the school from the web.",
buttons: [
{
title: "Visit",
link: "https://www.lycee-chabanne16.fr/visites/BACSN/index.htm",
},
],
},
{
title: "Xinko",
image: require("../assets/images/xinko.png"),
description: "Xinko is a multipurpose bot that can help you create and manage your discord servers with ease and fun. It has many commands and features.",
buttons: [
{
title: "Website",
link: "https://google.com",
},
{
title: "Invite",
link: "https://discord.com/api/oauth2/authorize?client_id=1035571329866407976&permissions=292288982151&scope=applications.commands%20bot",
},
],
},
{
title: "Image Manipulation",
image: require("../assets/images/dig.png"),
description: "Discord Image Generation: NPM package for code-based image manipulation. Originally an API, now open-source.",
buttons: [
{
title: "Repository",
link: "https://git.mrkayjaydee.xyz/Mr-KayJayDee/discord-image-generation",
},
{
title: "NPM Package",
link: "https://www.npmjs.com/package/discord-image-generation",
},
],
},
{
title: "Primate Web Admin",
image: require("../assets/images/primate.png"),
description: "Primate Web Admin is a Web interface to manage Primate that is a Munki-like deployment tool for Windows.",
},
{
title: "Instagram Bot",
image: require("../assets/images/instagram.png"),
description: "Fully functional Instagram bot using Insta.js by androz2091. It has many commands. Generate images with commands like: !stonk or !invert.",
buttons: [
{
title: "Repository",
link: "https://git.mrkayjaydee.xyz/Mr-KayJayDee/instagram-bot",
},
],
},
{
title: "Crowdin Status Bot",
image: require("../assets/images/crowdin.png"),
description: "A bot that fetches Crowdin translation status and updates Discord messages with the latest status. Stay informed on progress!",
buttons: [
{
title: "Repository",
link: "https://git.mrkayjaydee.xyz/Mr-KayJayDee/discord-crowdin-status",
},
],
},
];

279
old/techstack.json Normal file
View File

@@ -0,0 +1,279 @@
{
"programming": [
{
"name": "JavaScript",
"level": "Intermediate",
"image": "javascript.png"
},
{
"name": "Bash",
"level": "Intermediate",
"image": "bash.png"
},
{
"name": "Markdown",
"level": "Beginner",
"image": "markdown.png"
},
{
"name": "TypeScript",
"level": "Intermediate",
"image": "typescript.png"
},
{
"name": "Node.js",
"level": "Intermediate",
"image": "nodejs.png"
},
{
"name": "Nginx",
"level": "Intermediate",
"image": "nginx.png"
}
],
"front": [
{
"name": "Angular",
"level": "Intermediate",
"image": "angular.png"
},
{
"name": "HTML",
"level": "Intermediate",
"image": "HTML.png"
},
{
"name": "CSS",
"level": "Beginner",
"image": "css.png"
},
{
"name": "React",
"level": "Intermediate",
"image": "react.png"
},
{
"name": "Vue.JS",
"level": "Intermediate",
"image": "vuejs.png"
},
{
"name": "Figma",
"level": "Intermediate",
"image": "figma.png"
},
{
"name": "Wordpress",
"level": "Intermediate",
"image": "wordpress.png"
}
],
"database": [
{
"name": "MongoDB",
"level": "Intermediate",
"image": "mongodb.png"
},
{
"name": "Redis",
"level": "Intermediate",
"image": "redis.png"
},
{
"name": "MYSQL",
"level": "Intermediate",
"image": "mysql.png"
},
{
"name": "SQLite",
"level": "Intermediate",
"image": "sqlite.png"
}
],
"devtools": [
{
"name": "Docker",
"level": "Intermediate",
"image": "docker.png"
},
{
"name": "Discord Bot",
"level": "Intermediate",
"image": "discordbot.png"
},
{
"name": "Postman",
"level": "Intermediate",
"image": "postman.png"
},
{
"name": "FileZilla",
"level": "Beginner",
"image": "filezilla.png"
},
{
"name": "Termius",
"level": "Intermediate",
"image": "termius.png"
},
{
"name": "GitHub",
"level": "Intermediate",
"image": "github.png"
},
{
"name": "Git",
"level": "Intermediate",
"image": "git.png"
},
{
"name": "npm",
"level": "Intermediate",
"image": "npm.png"
},
{
"name": "GitLab",
"level": "Intermediate",
"image": "gitlab.png"
},
{
"name": "Visual Studio Code",
"level": "Intermediate",
"image": "vscode.png"
},
{
"name": "Atom",
"level": "Intermediate",
"image": "atom.png"
},
{
"name": "DB Browser for SQLite",
"level": "Beginner",
"image": "sqlitebrowser.png"
},
{
"name": "HeidiSQL",
"level": "Intermediate",
"image": "heidisql.png"
},
{
"name": "MySQL Workbench",
"level": "Intermediate",
"image": "mysqlworkbench.png"
},
{
"name": "GitKraken",
"level": "Intermediate",
"image": "gitkraken.png"
}
],
"operating_systems": [
{
"name": "Linux",
"level": "Intermediate",
"image": "linux.png"
},
{
"name": "Debian",
"level": "Intermediate",
"image": "debian.png"
},
{
"name": "Arch Linux",
"level": "Intermediate",
"image": "archlinux.png"
},
{
"name": "Ubuntu",
"level": "Intermediate",
"image": "ubuntu.png"
},
{
"name": "Kali Linux",
"level": "Intermediate",
"image": "kalilinux.png"
},
{
"name": "macOS",
"level": "Intermediate",
"image": "macos.png"
},
{
"name": "Windows",
"level": "Intermediate",
"image": "windows.png"
},
{
"name": "Deepin",
"level": "Intermediate",
"image": "deepin.png"
},
{
"name": "Android",
"level": "Intermediate",
"image": "android.png"
},
{
"name": "Wear OS",
"level": "Intermediate",
"image": "wearos.png"
},
{
"name": "watchOS",
"level": "Intermediate",
"image": "watchos.png"
},
{
"name": "iOS",
"level": "Intermediate",
"image": "ios.png"
}
],
"socials": [
{
"name": "Discord",
"level": "Intermediate",
"image": "discord.png"
},
{
"name": "Instagram",
"level": "Intermediate",
"image": "instagram.png"
},
{
"name": "LinkedIn",
"level": "Intermediate",
"image": "linkedin.png"
},
{
"name": "Twitter",
"level": "Intermediate",
"image": "twitter.png"
},
{
"name": "Reddit",
"level": "Intermediate",
"image": "reddit.png"
},
{
"name": "Messenger",
"level": "Intermediate",
"image": "messenger.png"
},
{
"name": "WhatsApp",
"level": "Intermediate",
"image": "whatsapp.png"
},
{
"name": "Facebook",
"level": "Intermediate",
"image": "facebook.png"
},
{
"name": "Telegram",
"level": "Intermediate",
"image": "telegram.png"
}
]
}

6098
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "portfolio",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"dependencies": {
"@vueuse/head": "^2.0.0",
"pinia": "^3.0.1",
"vue": "^3.5.13",
"vue-i18n": "^9.14.4",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.10",
"@tsconfig/node22": "^22.0.1",
"@types/node": "^22.14.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.5.0",
"@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.22.0",
"eslint-plugin-vue": "~10.0.0",
"jiti": "^2.4.2",
"npm-run-all2": "^7.0.2",
"postcss": "^8.5.6",
"prettier": "3.5.3",
"tailwindcss": "^4.1.10",
"typescript": "~5.8.0",
"vite": "^6.2.4",
"vite-plugin-vue-devtools": "^7.7.2",
"vue-tsc": "^2.2.8"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

40
public/images/README.md Normal file
View File

@@ -0,0 +1,40 @@
# Images du Portfolio
Ce dossier doit contenir les images pour votre portfolio :
## Structure des dossiers
```
images/
├── virtualtour.png # Image du projet Virtual Tour
├── xinko.png # Image du projet Xinko
├── dig.png # Image du projet Image Manipulation
├── primate.png # Image du projet Primate Web Admin
├── instagram.png # Image du projet Instagram Bot
├── crowdin.png # Image du projet Crowdin Status Bot
└── tech/ # Dossier pour les logos des technologies
├── javascript.png
├── typescript.png
├── nodejs.png
├── vuejs.png
├── react.png
├── angular.png
└── ... # Autres logos de technologies
```
## Recommandations
- **Format** : PNG ou JPG
- **Dimensions recommandées pour les projets** : 1200x630px (ratio 1.9:1)
- **Dimensions recommandées pour les logos tech** : 100x100px
- **Taille maximale** : 500KB par image
## Images temporaires
En attendant vos vraies images, vous pouvez utiliser des services comme :
- https://placeholder.com/
- https://placehold.co/
- https://via.placeholder.com/
Exemple : `https://placehold.co/1200x630/2563eb/ffffff?text=Virtual+Tour`

53
src/App.vue Normal file
View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import { RouterView, useRoute } from 'vue-router'
import { watch, nextTick } from 'vue'
import AppHeader from '@/components/layout/AppHeader.vue'
import AppFooter from '@/components/layout/AppFooter.vue'
import { useTheme } from '@/composables/useTheme'
const route = useRoute()
// Initialize theme
useTheme()
// Force scroll to top on route change (backup solution)
watch(() => route.fullPath, () => {
nextTick(() => {
window.scrollTo({ top: 0, behavior: 'auto' })
})
})
</script>
<template>
<div id="app" class="min-h-screen flex flex-col">
<AppHeader />
<div class="flex-grow">
<RouterView v-slot="{ Component }" :key="$route.fullPath">
<transition mode="out-in" enter-active-class="transition duration-300 ease-out"
enter-from-class="transform opacity-0" enter-to-class="transform opacity-100"
leave-active-class="transition duration-200 ease-in" leave-from-class="transform opacity-100"
leave-to-class="transform opacity-0">
<component :is="Component" />
</transition>
</RouterView>
</div>
<AppFooter />
</div>
</template>
<style>
/* Remove default margins */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Focus styles */
:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
src/assets/images/atom.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
src/assets/images/bash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
src/assets/images/css.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 B

BIN
src/assets/images/dig.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
src/assets/images/figma.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
src/assets/images/git.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
src/assets/images/html.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
src/assets/images/ios.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
src/assets/images/linux.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
src/assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
src/assets/images/macos.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

BIN
src/assets/images/mail.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

BIN
src/assets/images/mysql.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
src/assets/images/nginx.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
src/assets/images/npm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 957 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

BIN
src/assets/images/react.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

BIN
src/assets/images/redis.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
src/assets/images/vuejs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
src/assets/images/xinko.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

1
src/assets/logo.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

848
src/assets/main.css Normal file
View File

@@ -0,0 +1,848 @@
@import 'tailwindcss/preflight';
@import 'tailwindcss/utilities';
/* Modern CSS Reset & Base Styles */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* Custom Properties - Design System */
:root {
/* Colors */
--color-primary: #85cb85;
--color-primary-dark: #6bb06b;
--color-primary-light: #a3d6a3;
--color-secondary: #7c3aed;
--color-accent: #f59e0b;
--color-success: #10b981;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-info: #3b82f6;
/* Grays */
--color-gray-50: #f9fafb;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-300: #d1d5db;
--color-gray-400: #9ca3af;
--color-gray-500: #6b7280;
--color-gray-600: #4b5563;
--color-gray-700: #374151;
--color-gray-800: #1f2937;
--color-gray-900: #111827;
/* Background */
--bg-primary: #ffffff;
--bg-secondary: var(--color-gray-50);
--bg-tertiary: var(--color-gray-100);
--bg-dark: var(--color-gray-900);
/* Text */
--text-primary: var(--color-gray-900);
--text-secondary: var(--color-gray-600);
--text-tertiary: var(--color-gray-500);
--text-inverse: #ffffff;
/* Spacing */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 3rem;
--space-3xl: 4rem;
--space-4xl: 6rem;
/* Typography */
--font-family-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-family-mono: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 1.875rem;
--font-size-4xl: 2.25rem;
--font-size-5xl: 3rem;
--font-size-6xl: 3.75rem;
--font-weight-light: 300;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--font-weight-extrabold: 800;
--line-height-tight: 1.25;
--line-height-snug: 1.375;
--line-height-normal: 1.5;
--line-height-relaxed: 1.625;
--line-height-loose: 2;
/* Borders */
--border-radius-sm: 0.375rem;
--border-radius-md: 0.5rem;
--border-radius-lg: 0.75rem;
--border-radius-xl: 1rem;
--border-radius-2xl: 1.5rem;
--border-radius-full: 9999px;
--border-width: 1px;
--border-color: var(--color-gray-200);
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
/* Transitions */
--transition-fast: 150ms ease-in-out;
--transition-normal: 300ms ease-in-out;
--transition-slow: 500ms ease-in-out;
/* Z-index */
--z-dropdown: 1000;
--z-sticky: 1020;
--z-fixed: 1030;
--z-modal: 1040;
--z-popover: 1050;
--z-tooltip: 1060;
}
/* Dark Mode Variables */
.dark {
/* Colors - Dark mode overrides */
--color-primary: #a3d6a3;
--color-primary-dark: #85cb85;
--color-primary-light: #c3e6c3;
/* Background - Dark mode */
--bg-primary: #111827;
--bg-secondary: #1f2937;
--bg-tertiary: #374151;
--bg-dark: #000000;
/* Text - Dark mode */
--text-primary: #f9fafb;
--text-secondary: #d1d5db;
--text-tertiary: #9ca3af;
--text-inverse: #111827;
/* Borders - Dark mode */
--border-color: #374151;
/* Shadows - Dark mode */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3), 0 4px 6px -4px rgb(0 0 0 / 0.3);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.3), 0 8px 10px -6px rgb(0 0 0 / 0.3);
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.5);
}
/* Base HTML Elements */
html {
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
line-height: var(--line-height-normal);
color: var(--text-primary);
background-color: var(--bg-primary);
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
min-height: 100vh;
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
}
/* Typography */
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: var(--font-weight-bold);
line-height: var(--line-height-tight);
color: var(--text-primary);
margin-bottom: var(--space-md);
}
h1 {
font-size: var(--font-size-5xl);
font-weight: var(--font-weight-extrabold);
}
h2 {
font-size: var(--font-size-4xl);
}
h3 {
font-size: var(--font-size-3xl);
}
h4 {
font-size: var(--font-size-2xl);
}
h5 {
font-size: var(--font-size-xl);
}
h6 {
font-size: var(--font-size-lg);
}
p {
color: var(--text-secondary);
line-height: var(--line-height-relaxed);
margin-bottom: var(--space-md);
}
a {
color: var(--color-primary);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
color: var(--color-primary-dark);
}
/* Layout Components */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 var(--space-md);
}
@media (min-width: 640px) {
.container {
padding: 0 var(--space-lg);
}
}
@media (min-width: 1024px) {
.container {
padding: 0 var(--space-xl);
}
}
/* Button System */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-md) var(--space-xl);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
line-height: 1.2;
border: 2px solid transparent;
border-radius: var(--border-radius-xl);
cursor: pointer;
transition: all var(--transition-fast);
text-decoration: none;
white-space: nowrap;
position: relative;
overflow: hidden;
}
.btn:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
/* Primary Button - Vert avec effet moderne */
.btn-primary {
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%);
color: #1f2937;
border-color: var(--color-primary);
box-shadow: 0 2px 8px rgba(133, 203, 133, 0.2);
}
.btn-primary:hover {
background: linear-gradient(135deg, var(--color-primary-dark) 0%, var(--color-primary) 100%);
color: #1f2937;
box-shadow: 0 4px 12px rgba(133, 203, 133, 0.25);
transform: translateY(-2px);
}
.btn-primary:active {
color: #1f2937;
transform: translateY(0);
box-shadow: 0 1px 4px rgba(133, 203, 133, 0.2);
}
/* Secondary Button - Contour vert */
.btn-secondary {
background: transparent;
color: var(--color-primary);
border-color: var(--color-primary);
transition: all var(--transition-fast);
}
.btn-secondary:hover {
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%);
color: #1f2937;
transform: translateY(-2px);
box-shadow: 0 3px 10px rgba(133, 203, 133, 0.15);
}
/* Ghost Button - Subtil */
.btn-ghost {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--border-color);
}
.btn-ghost:hover {
background: var(--bg-tertiary);
color: var(--color-primary);
border-color: var(--color-primary);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
/* Outline Button - Alias pour secondary */
.btn-outline {
background: transparent;
color: var(--color-primary);
border-color: var(--color-primary);
transition: all var(--transition-fast);
}
.btn-outline:hover {
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%);
color: #1f2937;
transform: translateY(-2px);
box-shadow: 0 3px 10px rgba(133, 203, 133, 0.15);
}
/* Button Sizes */
.btn-sm {
padding: var(--space-sm) var(--space-lg);
font-size: var(--font-size-sm);
border-radius: var(--border-radius-lg);
}
.btn-lg {
padding: var(--space-lg) var(--space-2xl);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
}
/* Button with icons */
.btn-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
/* Card System */
.card {
background: var(--bg-primary);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius-xl);
box-shadow: var(--shadow-md);
transition: all var(--transition-normal);
overflow: hidden;
}
.card:hover {
box-shadow: var(--shadow-xl);
transform: translateY(-4px);
}
.card-header {
padding: var(--space-xl);
border-bottom: var(--border-width) solid var(--border-color);
}
.card-body {
padding: var(--space-xl);
}
.card-footer {
padding: var(--space-xl);
border-top: var(--border-width) solid var(--border-color);
background: var(--bg-secondary);
}
/* Badge System */
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-md);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
border-radius: var(--border-radius-full);
white-space: nowrap;
}
.badge-primary {
background: var(--color-primary);
color: var(--text-inverse);
}
.badge-secondary {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.badge-success {
background: var(--color-success);
color: var(--text-inverse);
}
.badge-warning {
background: var(--color-warning);
color: var(--text-inverse);
}
.badge-error {
background: var(--color-error);
color: var(--text-inverse);
}
/* Form Elements */
.form-group {
margin-bottom: var(--space-lg);
}
.form-label {
display: block;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--text-primary);
margin-bottom: var(--space-sm);
}
.form-input,
.form-textarea,
.form-select {
width: 100%;
padding: var(--space-md);
font-size: var(--font-size-base);
line-height: var(--line-height-normal);
color: var(--text-primary);
background: var(--bg-primary);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius-md);
transition: all var(--transition-fast);
}
.form-input:focus,
.form-textarea:focus,
.form-select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgb(37 99 235 / 0.1);
}
.form-textarea {
resize: vertical;
min-height: 120px;
}
/* Navigation */
.nav {
display: flex;
align-items: center;
gap: var(--space-lg);
}
.nav-link {
position: relative;
padding: var(--space-sm) var(--space-md);
font-weight: var(--font-weight-medium);
color: var(--text-secondary);
transition: color var(--transition-fast);
}
.nav-link:hover,
.nav-link.active {
color: var(--color-primary);
}
.nav-link::after {
content: '';
position: absolute;
bottom: -2px;
left: 50%;
width: 0;
height: 2px;
background: var(--color-primary);
transition: all var(--transition-fast);
transform: translateX(-50%);
}
.nav-link:hover::after,
.nav-link.active::after {
width: 100%;
}
/* Header */
.header {
position: sticky;
top: 0;
z-index: var(--z-sticky);
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-bottom: var(--border-width) solid var(--border-color);
transition: all var(--transition-fast);
}
/* Header dark mode */
.dark .header {
background: rgba(17, 24, 39, 0.95);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
height: 80px;
}
.logo {
display: flex;
align-items: center;
gap: var(--space-md);
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
text-decoration: none;
}
.logo-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%);
border-radius: var(--border-radius-lg);
color: var(--text-inverse);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-xl);
}
/* Hero Section */
.hero {
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23f3f4f6' fill-opacity='0.4'%3E%3Ccircle cx='30' cy='30' r='1'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
opacity: 0.5;
}
/* Hero dark mode pattern */
.dark .hero::before {
background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23374151' fill-opacity='0.4'%3E%3Ccircle cx='30' cy='30' r='1'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
}
.hero-content {
position: relative;
z-index: 1;
text-align: center;
max-width: 800px;
margin: 0 auto;
}
.hero-title {
font-size: clamp(var(--font-size-4xl), 5vw, var(--font-size-6xl));
font-weight: var(--font-weight-extrabold);
margin-bottom: var(--space-lg);
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-subtitle {
font-size: var(--font-size-xl);
color: var(--text-secondary);
margin-bottom: var(--space-2xl);
line-height: var(--line-height-relaxed);
}
/* Section Spacing */
.section {
padding: var(--space-4xl) 0;
}
.section-sm {
padding: var(--space-2xl) 0;
}
.section-lg {
padding: var(--space-4xl) 0;
}
/* Grid System */
.grid {
display: grid;
gap: var(--space-xl);
}
.grid-cols-1 {
grid-template-columns: 1fr;
}
.grid-cols-2 {
grid-template-columns: repeat(2, 1fr);
}
.grid-cols-3 {
grid-template-columns: repeat(3, 1fr);
}
.grid-cols-4 {
grid-template-columns: repeat(4, 1fr);
}
@media (max-width: 1024px) {
.grid-cols-4 {
grid-template-columns: repeat(2, 1fr);
}
.grid-cols-3 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.grid-cols-4,
.grid-cols-3,
.grid-cols-2 {
grid-template-columns: 1fr;
}
}
/* Utilities */
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.gap-sm {
gap: var(--space-sm);
}
.gap-md {
gap: var(--space-md);
}
.gap-lg {
gap: var(--space-lg);
}
.gap-xl {
gap: var(--space-xl);
}
.mb-sm {
margin-bottom: var(--space-sm);
}
.mb-md {
margin-bottom: var(--space-md);
}
.mb-lg {
margin-bottom: var(--space-lg);
}
.mb-xl {
margin-bottom: var(--space-xl);
}
.mb-2xl {
margin-bottom: var(--space-2xl);
}
/* Animations */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.6s ease-out;
}
.animate-fade-in {
animation: fadeIn 0.6s ease-out;
}
.animate-slide-in-right {
animation: slideInRight 0.6s ease-out;
}
/* Mobile Menu */
.mobile-menu {
position: fixed;
top: 80px;
left: 0;
right: 0;
background: var(--bg-primary);
border-bottom: var(--border-width) solid var(--border-color);
box-shadow: var(--shadow-lg);
z-index: var(--z-dropdown);
transform: translateY(-100%);
transition: transform var(--transition-normal);
}
.mobile-menu.open {
transform: translateY(0);
}
.mobile-menu-nav {
display: flex;
flex-direction: column;
padding: var(--space-lg);
}
.mobile-menu-nav .nav-link {
padding: var(--space-md) 0;
border-bottom: var(--border-width) solid var(--border-color);
}
.mobile-menu-nav .nav-link:last-child {
border-bottom: none;
}
/* Footer */
.footer {
background: var(--bg-secondary);
color: var(--text-primary);
padding: var(--space-4xl) 0 var(--space-xl);
border-top: var(--border-width) solid var(--border-color);
}
.footer a {
color: var(--text-secondary);
}
.footer a:hover {
color: var(--color-primary);
}
/* Responsive Design */
@media (max-width: 768px) {
:root {
--font-size-5xl: 2.5rem;
--font-size-4xl: 2rem;
--font-size-3xl: 1.5rem;
}
.container {
padding: 0 var(--space-md);
}
.hero {
min-height: 80vh;
padding: var(--space-2xl) 0;
}
.section {
padding: var(--space-2xl) 0;
}
}
/* Focus States */
*:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Smooth Transitions */
* {
transition-property:
color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow,
transform, filter, backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n'
const { currentLocale, switchLocale, isEnglish, isFrench } = useI18n()
const languages = [
{ code: 'fr', name: 'Français', flag: '🇫🇷' },
{ code: 'en', name: 'English', flag: '🇬🇧' }
]
</script>
<template>
<div class="language-switcher">
<div class="language-switcher-buttons">
<button v-for="lang in languages" :key="lang.code" @click="switchLocale(lang.code)" :class="[
'language-btn',
{ 'active': currentLocale === lang.code }
]" :title="lang.name">
<span class="flag">{{ lang.flag }}</span>
<span class="lang-code">{{ lang.code.toUpperCase() }}</span>
</button>
</div>
</div>
</template>
<style scoped>
.language-switcher {
display: flex;
align-items: center;
}
.language-switcher-buttons {
display: flex;
gap: var(--space-xs);
background: var(--bg-secondary);
border-radius: var(--border-radius-lg);
padding: var(--space-xs);
border: 1px solid var(--border-color);
}
.language-btn {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
border: none;
background: transparent;
border-radius: var(--border-radius-md);
cursor: pointer;
transition: all 0.2s ease;
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
}
.language-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.language-btn.active {
background: var(--color-primary);
color: white;
box-shadow: var(--shadow-sm);
}
.flag {
font-size: 1rem;
line-height: 1;
}
.lang-code {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.05em;
}
/* Mobile responsive */
@media (max-width: 640px) {
.lang-code {
display: none;
}
.language-btn {
padding: var(--space-xs);
}
}
</style>

View File

@@ -0,0 +1,258 @@
<script setup lang="ts">
import { computed } from 'vue'
import { type Project } from '@/types'
import { useAssets } from '@/composables/useAssets'
import { useI18n } from '@/composables/useI18n'
interface Props {
project: Project
}
const props = defineProps<Props>()
const { getImageUrl } = useAssets()
const { t } = useI18n()
// Get the actual image URL
const imageUrl = computed(() => {
return getImageUrl(props.project.image)
})
// Get translated project data
const translatedTitle = computed(() => {
return t(`projectData.${props.project.id}.title`, props.project.title)
})
const translatedDescription = computed(() => {
return t(`projectData.${props.project.id}.description`, props.project.description)
})
const translatedCategory = computed(() => {
if (!props.project.category) return ''
const categoryKey = props.project.category.replace(/\s+/g, '').toLowerCase()
return t(`projects.categories.${categoryKey}`, props.project.category)
})
</script>
<template>
<article class="card group">
<!-- Image -->
<div class="relative overflow-hidden" style="aspect-ratio: 16/9;">
<img :src="imageUrl" :alt="project.title"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110" loading="lazy">
<!-- Overlay -->
<div
class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div class="absolute bottom-4 left-4 right-4">
<div v-if="project.buttons && project.buttons.length > 0" class="flex gap-2">
<a v-for="button in project.buttons" :key="button.title" :href="button.link" target="_blank"
rel="noopener noreferrer" class="btn btn-primary btn-sm" @click.stop>
{{ t(`projects.buttons.${button.title.toLowerCase().replace(/\s+/g, '')}`, button.title) }}
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14">
</path>
</svg>
</a>
</div>
</div>
</div>
</div>
<!-- Content -->
<div class="card-body">
<!-- Category & Date -->
<div class="flex items-center justify-between mb-md">
<span v-if="project.category" class="badge badge-primary">
{{ translatedCategory }}
</span>
<span v-if="project.date" class="text-sm text-secondary">
{{ project.date }}
</span>
</div>
<!-- Title -->
<h3 class="text-xl font-bold mb-md group-hover:text-primary transition-colors">
{{ translatedTitle }}
</h3>
<!-- Description -->
<p class="text-secondary mb-lg line-clamp-3">
{{ translatedDescription }}
</p>
<!-- Technologies -->
<div v-if="project.technologies && project.technologies.length > 0" class="flex flex-wrap gap-2 mb-lg">
<span v-for="tech in project.technologies.slice(0, 3)" :key="tech" class="badge badge-secondary text-xs">
{{ tech }}
</span>
<span v-if="project.technologies.length > 3" class="badge badge-secondary text-xs">
+{{ project.technologies.length - 3 }}
</span>
</div>
<!-- Action -->
<div class="flex items-center justify-between">
<RouterLink :to="`/project/${project.id}`" class="btn btn-secondary btn-sm">
{{ t('projects.buttons.viewProject') }}
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</RouterLink>
</div>
</div>
</article>
</template>
<style scoped>
/* Line clamp utility */
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Custom utilities */
.w-full {
width: 100%;
}
.h-full {
height: 100%;
}
.object-cover {
object-fit: cover;
}
.relative {
position: relative;
}
.absolute {
position: absolute;
}
.inset-0 {
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.bottom-4 {
bottom: 1rem;
}
.left-4 {
left: 1rem;
}
.right-4 {
right: 1rem;
}
.text-xs {
font-size: var(--font-size-xs);
}
.text-sm {
font-size: var(--font-size-sm);
}
.text-xl {
font-size: var(--font-size-xl);
}
.font-bold {
font-weight: var(--font-weight-bold);
}
.font-medium {
font-weight: var(--font-weight-medium);
}
.text-primary {
color: var(--color-primary);
}
.text-secondary {
color: var(--text-secondary);
}
.text-warning {
color: var(--color-warning);
}
.group-hover\:text-primary {
transition: color var(--transition-fast);
}
.group:hover .group-hover\:text-primary {
color: var(--color-primary);
}
.transition-colors {
transition: color var(--transition-fast);
}
.transition-transform {
transition: transform var(--transition-normal);
}
.transition-opacity {
transition: opacity var(--transition-normal);
}
.duration-300 {
transition-duration: 300ms;
}
.group-hover\:scale-110 {
transition: transform var(--transition-normal);
}
.group:hover .group-hover\:scale-110 {
transform: scale(1.1);
}
.opacity-0 {
opacity: 0;
}
.group-hover\:opacity-100 {
transition: opacity var(--transition-normal);
}
.group:hover .group-hover\:opacity-100 {
opacity: 1;
}
.overflow-hidden {
overflow: hidden;
}
.flex {
display: flex;
}
.flex-wrap {
flex-wrap: wrap;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.gap-1 {
gap: 0.25rem;
}
.gap-2 {
gap: 0.5rem;
}
</style>

View File

@@ -0,0 +1,133 @@
<script setup lang="ts">
import { computed } from 'vue'
import { type Technology } from '@/types'
import { useAssets } from '@/composables/useAssets'
import { techStack } from '@/data/techstack'
interface Props {
tech: Technology | string
showLevel?: boolean
showImage?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showLevel: true,
showImage: true
})
const { getImageUrl } = useAssets()
// Get the technology data (handle both string and object)
const techData = computed(() => {
if (typeof props.tech === 'string') {
// Create a mapping for technologies that don't match exactly
const techMapping: Record<string, string> = {
'Three.js': 'JavaScript',
'WebGL': 'JavaScript',
'Discord.js': 'JavaScript',
'Express': 'Node.js',
'Canvas': 'JavaScript',
'Insta.js': 'JavaScript',
'Instagram API': 'JavaScript',
'Crowdin API': 'JavaScript',
'Cron': 'Node.js'
}
// Try to find the exact match first
let foundTech = Object.values(techStack)
.flat()
.find(t => t.name.toLowerCase() === props.tech.toLowerCase())
// If not found, try the mapping
if (!foundTech && techMapping[props.tech]) {
foundTech = Object.values(techStack)
.flat()
.find(t => t.name.toLowerCase() === techMapping[props.tech].toLowerCase())
}
if (foundTech) {
return foundTech
}
// Fallback: create a basic tech object from string
return {
name: props.tech,
image: '', // No image for unknown techs
level: 'Intermediate' as const
}
}
return props.tech
})
// Get the actual image URL
const imageUrl = computed(() => {
if (!techData.value.image) return ''
return getImageUrl(techData.value.image)
})
const getLevelColor = (level: string) => {
switch (level) {
case 'Advanced':
return 'badge-success'
case 'Intermediate':
return 'badge-primary'
case 'Beginner':
return 'badge-secondary'
default:
return 'badge-secondary'
}
}
</script>
<template>
<div class="tech-badge">
<!-- Tech image -->
<img v-if="showImage && imageUrl" :src="imageUrl" :alt="techData.name" class="tech-image" loading="lazy">
<!-- Tech name -->
<span class="tech-name">{{ techData.name }}</span>
<!-- Level indicator -->
<span v-if="showLevel" :class="['badge', getLevelColor(techData.level)]" class="tech-level">
{{ techData.level }}
</span>
</div>
</template>
<style scoped>
.tech-badge {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--bg-primary);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius-lg);
transition: all var(--transition-fast);
white-space: nowrap;
}
.tech-badge:hover {
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.tech-image {
width: 20px;
height: 20px;
object-fit: contain;
flex-shrink: 0;
}
.tech-name {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--text-primary);
}
.tech-level {
font-size: var(--font-size-xs);
padding: var(--space-xs) var(--space-sm);
}
</style>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'
const { isDark, toggleTheme } = useTheme()
</script>
<template>
<button @click="toggleTheme" class="theme-toggle"
:aria-label="isDark ? 'Activer le mode clair' : 'Activer le mode sombre'"
:title="isDark ? 'Activer le mode clair' : 'Activer le mode sombre'">
<!-- Sun icon for light mode -->
<svg v-if="isDark" class="theme-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<!-- Moon icon for dark mode -->
<svg v-else class="theme-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
</button>
</template>
<style scoped>
.theme-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: var(--border-radius-lg);
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
position: relative;
overflow: hidden;
}
.theme-toggle:hover {
background: var(--color-primary);
color: var(--text-inverse);
transform: scale(1.05);
}
.theme-toggle:active {
transform: scale(0.95);
}
.theme-icon {
width: 20px;
height: 20px;
transition: all var(--transition-fast);
}
/* Dark mode styles */
:global(.dark) .theme-toggle {
background: var(--bg-secondary);
color: var(--text-secondary);
}
:global(.dark) .theme-toggle:hover {
background: var(--color-primary);
color: var(--text-inverse);
}
</style>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>

View File

@@ -0,0 +1,19 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>

View File

@@ -0,0 +1,212 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useAssets } from '@/composables/useAssets'
import { useI18n } from '@/composables/useI18n'
import { useSiteConfig } from '@/composables/useSiteConfig'
const { getImageUrl } = useAssets()
const { t } = useI18n()
const { siteConfig } = useSiteConfig()
const quickLinks = computed(() => [
{ name: t('nav.home'), path: '/' },
{ name: t('nav.projects'), path: '/projects' },
{ name: t('nav.about'), path: '/about' },
{ name: t('nav.contact'), path: '/contact' }
])
const services = computed(() => [
t('footer.servicesList.webDev'),
t('footer.servicesList.mobileApps'),
t('footer.servicesList.apiBackend'),
t('footer.servicesList.consulting')
])
</script>
<template>
<footer class="footer">
<div class="container">
<!-- Main Footer Content -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-xl mb-2xl">
<!-- Brand -->
<div class="md:col-span-2">
<div class="flex items-center gap-md mb-lg">
<img :src="getImageUrl('@/assets/images/logo.png')" alt="Killian" class="footer-logo">
<span class="text-xl font-bold footer-brand">{{ siteConfig.name }}</span>
</div>
<p class="mb-lg max-w-md footer-description">
{{ siteConfig.description }}
</p>
<!-- Social Links -->
<div class="flex gap-md">
<a v-for="social in siteConfig.social" :key="social.name" :href="social.url" target="_blank"
rel="noopener noreferrer" class="social-link" :title="social.name">
<!-- GitHub Icon -->
<svg v-if="social.icon === 'github'" class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.237 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
<!-- LinkedIn Icon -->
<svg v-else-if="social.icon === 'linkedin'" class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
<!-- Discord Icon -->
<svg v-else-if="social.icon === 'discord'" class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419-.0190 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9460 2.4189-2.1568 2.4189Z" />
</svg>
<!-- Email Icon -->
<svg v-else-if="social.icon === 'email'" class="w-5 h-5" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</a>
</div>
</div>
<!-- Quick Links -->
<div>
<h3 class="text-lg font-bold mb-lg footer-title">{{ t('footer.navigation') }}</h3>
<ul class="space-y-sm">
<li v-for="link in quickLinks" :key="link.name">
<RouterLink :to="link.path" class="footer-link">
{{ link.name }}
</RouterLink>
</li>
</ul>
</div>
<!-- Services -->
<div>
<h3 class="text-lg font-bold mb-lg footer-title">{{ t('footer.services') }}</h3>
<ul class="space-y-sm">
<li v-for="service in services" :key="service">
<span class="footer-link cursor-default">{{ service }}</span>
</li>
</ul>
</div>
</div>
<!-- Bottom Bar -->
<div class="footer-bottom">
<div class="flex flex-col md:flex-row justify-between items-center gap-md">
<p class="text-sm footer-copyright">
© {{ new Date().getFullYear() }} {{ siteConfig.author }}. {{ t('footer.copyright') }}
</p>
<div class="flex gap-lg text-sm">
<a href="#" class="footer-link">{{ t('footer.legalNotices') }}</a>
<a href="#" class="footer-link">{{ t('footer.privacyPolicy') }}</a>
</div>
</div>
</div>
</div>
</footer>
</template>
<style scoped>
/* Footer Base */
.footer {
background: var(--bg-tertiary);
color: var(--text-primary);
padding: var(--space-4xl) 0 var(--space-xl);
border-top: var(--border-width) solid var(--border-color);
}
/* Footer logo */
.footer-logo {
width: 32px;
height: 32px;
object-fit: contain;
border-radius: var(--border-radius-md);
}
/* Footer Brand */
.footer-brand {
color: var(--text-primary);
}
/* Footer Titles */
.footer-title {
color: var(--text-primary);
}
/* Footer Description */
.footer-description {
color: var(--text-secondary);
}
/* Footer Copyright */
.footer-copyright {
color: var(--text-tertiary);
}
/* Footer Bottom */
.footer-bottom {
border-top: var(--border-width) solid var(--border-color);
padding-top: var(--space-lg);
}
/* Social Links */
.social-link {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background: var(--bg-secondary);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius-lg);
color: var(--text-secondary);
transition: all var(--transition-fast);
}
.social-link:hover {
background: var(--color-primary);
color: var(--text-inverse);
border-color: var(--color-primary);
box-shadow: var(--shadow-md);
scale: 1.05;
}
/* Footer Links */
.footer-link {
color: var(--text-secondary);
transition: color var(--transition-fast);
text-decoration: none;
}
.footer-link:hover {
color: var(--color-primary);
}
/* Custom utilities */
.space-y-sm>*+* {
margin-top: var(--space-sm);
}
.max-w-md {
max-width: 28rem;
}
.cursor-default {
cursor: default;
}
/* Responsive */
@media (min-width: 768px) {
.md\:col-span-2 {
grid-column: span 2;
}
.md\:flex-row {
flex-direction: row;
}
}
</style>

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useAssets } from '@/composables/useAssets'
import { useI18n } from '@/composables/useI18n'
import ThemeToggle from '@/components/ThemeToggle.vue'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const { getImageUrl } = useAssets()
const { t } = useI18n()
const isMenuOpen = ref(false)
const navigation = computed(() => [
{ name: t('nav.home'), path: '/' },
{ name: t('nav.projects'), path: '/projects' },
{ name: t('nav.about'), path: '/about' },
{ name: t('nav.contact'), path: '/contact' },
])
const toggleMenu = () => {
isMenuOpen.value = !isMenuOpen.value
}
</script>
<template>
<header class="header">
<div class="container">
<div class="header-content">
<!-- Logo -->
<RouterLink to="/" class="logo">
<img :src="getImageUrl('@/assets/images/logo.png')" alt="Killian" class="logo-image">
<span>Killian</span>
</RouterLink>
<!-- Desktop Navigation -->
<nav class="nav hidden md:flex">
<RouterLink v-for="item in navigation" :key="item.name" :to="item.path" class="nav-link"
:class="{ 'active': $route.path === item.path }">
{{ item.name }}
</RouterLink>
</nav>
<!-- Right side controls -->
<div class="header-actions">
<!-- Language switcher -->
<LanguageSwitcher />
<!-- Theme toggle -->
<ThemeToggle />
<!-- Mobile menu button -->
<button @click="toggleMenu" class="md:hidden btn btn-ghost p-2" aria-label="Toggle menu">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-if="!isMenuOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16M4 18h16" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<!-- Mobile Navigation -->
<div class="mobile-menu md:hidden" :class="{ 'open': isMenuOpen }">
<nav class="mobile-menu-nav">
<RouterLink v-for="item in navigation" :key="item.name" :to="item.path" class="nav-link"
@click="isMenuOpen = false">
{{ item.name }}
</RouterLink>
</nav>
</div>
</header>
</template>
<style scoped>
/* Header actions */
.header-actions {
display: flex;
align-items: center;
gap: var(--space-sm);
}
/* Logo styling */
.logo-image {
width: 32px;
height: 32px;
object-fit: contain;
border-radius: var(--border-radius-md);
}
/* Mobile responsive utilities */
@media (min-width: 768px) {
.md\:flex {
display: flex;
}
.md\:hidden {
display: none;
}
}
.hidden {
display: none;
}
/* Navigation active state */
.nav-link.router-link-active {
color: var(--color-primary);
}
.nav-link.router-link-active::after {
width: 100%;
}
</style>

View File

@@ -0,0 +1,49 @@
/**
* Composable for handling dynamic asset imports in Vite
*/
export function useAssets() {
// Pre-load all images using Vite's import.meta.glob
const imageModules = import.meta.glob('../assets/images/*', { eager: true })
/**
* Get image URL from assets folder
* @param path - Path like '@/assets/images/filename.png' or 'filename.png'
* @returns string - The image URL
*/
const getImageUrl = (path: string | undefined): string => {
try {
// Handle undefined or empty path
if (!path || path.trim() === '') {
console.warn('getImageUrl called with empty or undefined path')
return `https://via.placeholder.com/400x300/f3f4f6/9ca3af?text=${encodeURIComponent('No image')}`
}
// Clean the path to get just the filename
let cleanPath = path
if (path.startsWith('@/assets/images/')) {
cleanPath = path.replace('@/assets/images/', '')
}
// Build the full path for the module lookup
const fullPath = `../assets/images/${cleanPath}`
// Get the image module
const imageModule = imageModules[fullPath] as { default: string }
if (imageModule && imageModule.default) {
return imageModule.default
}
// Fallback: try to construct URL directly
return new URL(`../assets/images/${cleanPath}`, import.meta.url).href
} catch (error) {
console.warn(`Failed to load image: ${path}`, error)
// Return a placeholder image
return `https://via.placeholder.com/400x300/f3f4f6/9ca3af?text=${encodeURIComponent('Image not found')}`
}
}
return {
getImageUrl
}
}

View File

@@ -0,0 +1,34 @@
import { useI18n as useVueI18n } from 'vue-i18n'
import { computed } from 'vue'
export function useI18n() {
const { locale, t, availableLocales } = useVueI18n()
const currentLocale = computed(() => locale.value)
const isEnglish = computed(() => locale.value === 'en')
const isFrench = computed(() => locale.value === 'fr')
const switchLocale = (newLocale: string) => {
if (availableLocales.includes(newLocale)) {
locale.value = newLocale
localStorage.setItem('locale', newLocale)
}
}
const toggleLocale = () => {
const newLocale = locale.value === 'en' ? 'fr' : 'en'
switchLocale(newLocale)
}
return {
t,
locale,
currentLocale,
isEnglish,
isFrench,
switchLocale,
toggleLocale,
availableLocales
}
}

77
src/composables/useSeo.ts Normal file
View File

@@ -0,0 +1,77 @@
import { onMounted, onUnmounted } from 'vue'
interface SeoOptions {
title?: string
description?: string
ogTitle?: string
ogDescription?: string
ogImage?: string
}
export function useSeo(options: SeoOptions = {}) {
const originalTitle = document.title
const metaElements: HTMLMetaElement[] = []
const setTitle = (title: string) => {
document.title = title
}
const setMetaTag = (name: string, content: string, property?: boolean) => {
let meta = document.querySelector(`meta[${property ? 'property' : 'name'}="${name}"]`) as HTMLMetaElement
if (!meta) {
meta = document.createElement('meta')
if (property) {
meta.setAttribute('property', name)
} else {
meta.setAttribute('name', name)
}
document.head.appendChild(meta)
metaElements.push(meta)
}
meta.setAttribute('content', content)
}
onMounted(() => {
if (options.title) {
setTitle(options.title)
}
if (options.description) {
setMetaTag('description', options.description)
}
if (options.ogTitle) {
setMetaTag('og:title', options.ogTitle, true)
}
if (options.ogDescription) {
setMetaTag('og:description', options.ogDescription, true)
}
if (options.ogImage) {
setMetaTag('og:image', options.ogImage, true)
}
// Set default Open Graph type
setMetaTag('og:type', 'website', true)
})
onUnmounted(() => {
// Restore original title
document.title = originalTitle
// Remove meta tags we added
metaElements.forEach(meta => {
if (meta.parentNode) {
meta.parentNode.removeChild(meta)
}
})
})
return {
setTitle,
setMetaTag
}
}

View File

@@ -0,0 +1,20 @@
import { computed } from 'vue'
import { useI18n } from '@/composables/useI18n'
import { siteConfig as baseSiteConfig } from '@/config/site'
export function useSiteConfig() {
const { t } = useI18n()
const siteConfig = computed(() => ({
...baseSiteConfig,
title: t('seo.home.title'),
description: t('seo.home.description'),
contact: {
...baseSiteConfig.contact
}
}))
return {
siteConfig
}
}

Some files were not shown because too many files have changed in this diff Show More