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

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
}
}

View File

@@ -0,0 +1,68 @@
import { ref, watch, onMounted } from 'vue'
export type Theme = 'light' | 'dark'
const isDark = ref<boolean>(false)
export function useTheme() {
const toggleTheme = () => {
isDark.value = !isDark.value
}
const setTheme = (theme: Theme) => {
isDark.value = theme === 'dark'
}
const getTheme = (): Theme => {
return isDark.value ? 'dark' : 'light'
}
// Apply theme to document
const applyTheme = () => {
if (typeof document !== 'undefined') {
document.documentElement.classList.toggle('dark', isDark.value)
document.documentElement.setAttribute('data-theme', getTheme())
}
}
// Save theme to localStorage
const saveTheme = () => {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('theme', getTheme())
}
}
// Load theme from localStorage or system preference
const loadTheme = () => {
if (typeof window !== 'undefined') {
const savedTheme = localStorage.getItem('theme') as Theme | null
if (savedTheme) {
setTheme(savedTheme)
} else {
// Use system preference
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
setTheme(prefersDark ? 'dark' : 'light')
}
}
}
// Watch for theme changes
watch(isDark, () => {
applyTheme()
saveTheme()
})
// Initialize theme on mount
onMounted(() => {
loadTheme()
applyTheme()
})
return {
isDark,
toggleTheme,
setTheme,
getTheme
}
}

62
src/config/site.ts Normal file
View File

@@ -0,0 +1,62 @@
export interface SocialLink {
name: string
url: string
icon: string
username?: string
}
export interface ContactInfo {
email: string
phone: string
location: string
}
export interface SiteConfig {
name: string
title: string
description: string
author: string
url: string
contact: ContactInfo
social: SocialLink[]
}
export const siteConfig: SiteConfig = {
name: 'Killian',
title: 'Killian - Full Stack Developer', // This will be overridden by translations
description: 'Full Stack Developer passionate about creating modern and performant web experiences.', // This will be overridden by translations
author: 'Killian',
url: 'https://killiandalcin.fr',
contact: {
email: 'contact@killiandalcin.fr',
phone: '+33 6 49 19 38 16',
location: 'France'
},
social: [
{
name: 'Gitea',
url: 'https://gitea.kamisama.ovh/kayjaydee',
icon: 'github',
username: 'killiandalcin'
},
{
name: 'LinkedIn',
url: 'https://linkedin.com/in/killian-dalcin',
icon: 'linkedin',
username: 'killian-dalcin'
},
{
name: 'Discord',
url: 'https://discord.com/users/370940770225618954',
icon: 'discord',
username: 'kayjaydee'
},
{
name: 'Email',
url: 'mailto:contact@killiandalcin.fr',
icon: 'email'
}
]
}

95
src/data/projects.ts Normal file
View File

@@ -0,0 +1,95 @@
import type { Project } from '@/types'
export const projects: Project[] = [
{
id: 'virtual-tour',
title: 'Virtual Tour',
image: '@/assets/images/virtualtour.png',
description: 'Développement d\'une plateforme de visite virtuelle interactive et immersive.',
longDescription: 'Virtual Tour est une plateforme innovante permettant de créer des visites virtuelles interactives et immersives. Développée avec les dernières technologies web, elle offre une expérience utilisateur fluide et engageante pour explorer des espaces en 3D.',
technologies: ['Vue.js', 'Three.js', 'WebGL', 'Node.js'],
category: 'Web Development',
featured: true,
date: '2022'
},
{
id: 'xinko',
title: 'Xinko',
image: '@/assets/images/xinko.png',
description: 'Xinko is a multiplatform bot that can be used to create primary with ease and fun in it.',
longDescription: 'Xinko est un bot multiplateforme innovant conçu pour simplifier la création de contenu primaire. Avec une interface intuitive et des fonctionnalités avancées, il permet aux utilisateurs de générer du contenu de qualité avec facilité et plaisir.',
technologies: ['Node.js', 'Discord.js', 'MongoDB', 'Express'],
category: 'Bot Development',
featured: true,
buttons: [
{
title: 'Website',
link: 'https://xinko.bot'
}
],
date: '2023'
},
{
id: 'image-manipulation',
title: 'Image Manipulation',
image: '@/assets/images/dig.png',
description: 'Discord Image Generation: NPM package for code-based image manipulation. Originally an API, now open-source.',
longDescription: 'Un package NPM complet pour la génération et la manipulation d\'images dans Discord. Ce projet open-source offre une API simple pour créer des memes, appliquer des filtres et générer des images dynamiques. Utilisé par de nombreux bots Discord avec plus de 100k téléchargements.',
technologies: ['JavaScript', 'Node.js', 'Canvas', 'npm'],
category: 'Open Source',
featured: true,
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'
}
],
date: '2022'
},
{
id: 'primate-web-admin',
title: 'Primate Web Admin',
image: '@/assets/images/primate.png',
description: 'Primate Web Admin is a Web interface to manage Primate that is a Munki-like deployment tool for Windows.',
longDescription: 'Interface web moderne pour gérer Primate, un outil de déploiement pour Windows inspiré de Munki. Cette application web permet aux administrateurs système de déployer et gérer des logiciels sur un parc informatique Windows de manière centralisée.',
technologies: ['React', 'TypeScript', 'Node.js', 'Express'],
category: 'Enterprise Software',
date: '2023'
},
{
id: 'instagram-bot',
title: 'Instagram Bot',
image: '@/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.',
longDescription: 'Bot Instagram entièrement fonctionnel développé avec Insta.js. Il propose de nombreuses commandes pour générer des images personnalisées, des memes et des effets visuels. Parfait pour animer vos stories et posts Instagram avec du contenu original.',
technologies: ['JavaScript', 'Node.js', 'Instagram API', 'Canvas'],
category: 'Social Media Bot',
buttons: [
{
title: 'Repository',
link: 'https://git.mrkayjaydee.xyz/Mr-KayJayDee/instagram-bot'
}
],
date: '2022'
},
{
id: 'crowdin-status-bot',
title: 'Crowdin Status Bot',
image: '@/assets/images/crowdin.png',
description: 'A bot that fetches Crowdin translation status and updates Discord messages with the latest status. Stay informed on progress!',
longDescription: 'Bot Discord automatisé qui récupère le statut des traductions Crowdin et met à jour les messages Discord avec les dernières informations. Idéal pour les équipes de traduction qui souhaitent rester informées du progrès de leurs projets en temps réel.',
technologies: ['Node.js', 'Discord.js', 'Crowdin API', 'Cron'],
category: 'Automation',
buttons: [
{
title: 'Repository',
link: 'https://git.mrkayjaydee.xyz/Mr-KayJayDee/discord-crowdin-status'
}
],
date: '2023'
}
]

67
src/data/techstack.ts Normal file
View File

@@ -0,0 +1,67 @@
import type { TechStack } from '@/types'
export const techStack: TechStack = {
programming: [
{ name: 'JavaScript', level: 'Intermediate', image: '@/assets/images/javascript.png' },
{ name: 'TypeScript', level: 'Intermediate', image: '@/assets/images/typescript.png' },
{ name: 'Node.js', level: 'Intermediate', image: '@/assets/images/nodejs.png' },
{ name: 'Bash', level: 'Intermediate', image: '@/assets/images/bash.png' },
{ name: 'Markdown', level: 'Beginner', image: '@/assets/images/markdown.png' }
],
front: [
{ name: 'Vue.js', level: 'Intermediate', image: '@/assets/images/vuejs.png' },
{ name: 'React', level: 'Intermediate', image: '@/assets/images/react.png' },
{ name: 'Angular', level: 'Intermediate', image: '@/assets/images/angular.png' },
{ name: 'HTML', level: 'Intermediate', image: '@/assets/images/html.png' },
{ name: 'CSS', level: 'Beginner', image: '@/assets/images/css.png' },
{ name: 'Figma', level: 'Intermediate', image: '@/assets/images/figma.png' },
{ name: 'WordPress', level: 'Intermediate', image: '@/assets/images/wordpress.png' }
],
database: [
{ name: 'MongoDB', level: 'Intermediate', image: '@/assets/images/mongodb.png' },
{ name: 'MySQL', level: 'Intermediate', image: '@/assets/images/mysql.png' },
{ name: 'Redis', level: 'Intermediate', image: '@/assets/images/redis.png' },
{ name: 'SQLite', level: 'Intermediate', image: '@/assets/images/sqlite.png' }
],
devtools: [
{ name: 'Git', level: 'Intermediate', image: '@/assets/images/git.png' },
{ name: 'GitHub', level: 'Intermediate', image: '@/assets/images/github.png' },
{ name: 'GitLab', level: 'Intermediate', image: '@/assets/images/gitlab.png' },
{ name: 'GitKraken', level: 'Intermediate', image: '@/assets/images/gitkraken.png' },
{ name: 'Visual Studio Code', level: 'Intermediate', image: '@/assets/images/vscode.png' },
{ name: 'Atom', level: 'Intermediate', image: '@/assets/images/atom.png' },
{ name: 'Docker', level: 'Intermediate', image: '@/assets/images/docker.png' },
{ name: 'npm', level: 'Intermediate', image: '@/assets/images/npm.png' },
{ name: 'Postman', level: 'Intermediate', image: '@/assets/images/postman.png' },
{ name: 'FileZilla', level: 'Beginner', image: '@/assets/images/filezilla.png' },
{ name: 'Termius', level: 'Intermediate', image: '@/assets/images/termius.png' },
{ name: 'HeidiSQL', level: 'Intermediate', image: '@/assets/images/heidisql.png' },
{ name: 'MySQL Workbench', level: 'Intermediate', image: '@/assets/images/mysqlworkbench.png' },
{ name: 'Sequel Pro', level: 'Beginner', image: '@/assets/images/sequelpro.png' }
],
operating_systems: [
{ name: 'Linux', level: 'Intermediate', image: '@/assets/images/linux.png' },
{ name: 'Ubuntu', level: 'Intermediate', image: '@/assets/images/ubuntu.png' },
{ name: 'Debian', level: 'Intermediate', image: '@/assets/images/debian.png' },
{ name: 'Arch Linux', level: 'Intermediate', image: '@/assets/images/archlinux.png' },
{ name: 'Kali Linux', level: 'Intermediate', image: '@/assets/images/kalilinux.png' },
{ name: 'Deepin', level: 'Intermediate', image: '@/assets/images/deepin.png' },
{ name: 'Windows', level: 'Intermediate', image: '@/assets/images/windows.png' },
{ name: 'macOS', level: 'Intermediate', image: '@/assets/images/macos.png' },
{ name: 'Android', level: 'Intermediate', image: '@/assets/images/android.png' },
{ name: 'iOS', level: 'Intermediate', image: '@/assets/images/ios.png' },
{ name: 'Wear OS', level: 'Intermediate', image: '@/assets/images/wearos.png' },
{ name: 'watchOS', level: 'Intermediate', image: '@/assets/images/watchos.png' }
],
socials: [
{ name: 'Discord', level: 'Intermediate', image: '@/assets/images/discord.png' },
{ name: 'Instagram', level: 'Intermediate', image: '@/assets/images/instagram.png' },
{ name: 'LinkedIn', level: 'Intermediate', image: '@/assets/images/linkedin.png' },
{ name: 'Twitter', level: 'Intermediate', image: '@/assets/images/twitter.png' },
{ name: 'Reddit', level: 'Intermediate', image: '@/assets/images/reddit.png' },
{ name: 'Facebook', level: 'Intermediate', image: '@/assets/images/facebook.png' },
{ name: 'Messenger', level: 'Intermediate', image: '@/assets/images/messenger.png' },
{ name: 'WhatsApp', level: 'Intermediate', image: '@/assets/images/whatsapp.png' },
{ name: 'Telegram', level: 'Intermediate', image: '@/assets/images/telegram.png' }
]
}

18
src/i18n/index.ts Normal file
View File

@@ -0,0 +1,18 @@
import { createI18n } from 'vue-i18n'
import en from '@/locales/en'
import fr from '@/locales/fr'
// Get the saved locale from localStorage or default to French
const savedLocale = localStorage.getItem('locale') || 'fr'
const i18n = createI18n({
legacy: false,
locale: savedLocale,
fallbackLocale: 'fr',
messages: {
en,
fr
}
})
export default i18n

266
src/locales/en.ts Normal file
View File

@@ -0,0 +1,266 @@
export default {
// Navigation
nav: {
home: 'Home',
projects: 'Projects',
about: 'About',
contact: 'Contact'
},
// Home page
home: {
title: 'Hi, I\'m Killian',
subtitle: 'Full Stack Developer passionate about creating exceptional web experiences and innovative solutions to bring your projects to life.',
cta: {
viewProjects: 'View my projects',
contactMe: 'Contact me'
},
featuredProjects: {
title: 'Featured Projects',
subtitle: 'Discover a selection of my most recent and innovative projects, showcasing my skills in modern web development.',
viewAll: 'View all projects'
},
services: {
title: 'My Services',
subtitle: 'I offer a complete range of services to support your projects from concept to completion.',
webDev: {
title: 'Web Dev',
description: 'Modern web applications with Vue.js, React and Node.js. Custom solutions tailored to your needs.'
},
mobileApps: {
title: 'Mobile Applications',
description: 'Development of high-performance and intuitive cross-platform applications.'
},
optimization: {
title: 'Optimization & Performance',
description: 'Performance improvement and SEO optimization for a better user experience.'
},
maintenance: {
title: 'Maintenance & Support',
description: 'Ongoing maintenance and technical support for your existing projects.'
}
},
cta2: {
title: 'Ready to start your project?',
subtitle: 'Let\'s discuss your vision and create something extraordinary together. I\'m here to turn your ideas into reality.',
startProject: 'Start a project',
learnMore: 'Learn more'
}
},
// Projects page
projects: {
title: 'My Projects',
subtitle: 'Explore my portfolio of web applications, tools, and innovative solutions. Each project represents a unique challenge solved with creativity and technical expertise.',
categories: {
all: 'All',
'webdevelopment': 'Web Development',
'botdevelopment': 'Bot Development',
'opensource': 'Open Source',
'enterprisesoftware': 'Enterprise Software',
'socialmediabot': 'Social Media Bot',
'automation': 'Automation'
},
buttons: {
website: 'Website',
repository: 'Repository',
npmpackage: 'NPM Package',
viewProject: 'View Project'
},
noResults: {
title: 'No projects found',
description: 'Try modifying your search or filter criteria.'
}
},
// About page
about: {
title: 'About Me',
subtitle: 'Learn more about my journey, skills, and passion for web development.',
intro: {
title: 'Who am I?',
content: 'I\'m Killian, a passionate full-stack developer with several years of experience in web development. I specialize in creating modern, performant, and user-friendly applications using the latest technologies.'
},
skills: {
title: 'My Skills',
programming: 'Programming',
frontend: 'Frontend',
backend: 'Backend',
tools: 'Tools & Others',
systems: 'Operating Systems'
},
experience: {
title: 'Experience',
content: 'With years of experience in web development, I\'ve worked on various projects ranging from simple websites to complex web applications. I\'m always eager to learn new technologies and improve my skills.'
},
approach: {
title: 'My Approach',
subtitle: 'My development philosophy is built on four fundamental pillars that ensure the success of every project.',
performance: {
title: 'Performance',
description: 'I place particular importance on performance and optimization, using best practices to ensure fast and responsive applications.'
},
architecture: {
title: 'Architecture',
description: 'I design modular and maintainable architectures, facilitating the evolution and scalability of projects in the long term.'
},
quality: {
title: 'Quality',
description: 'Automated testing, code reviews, and continuous integration are integral parts of my development process to ensure optimal quality.'
},
collaboration: {
title: 'Collaboration',
description: 'I prioritize clear communication and close collaboration with teams to ensure project success.'
}
},
cta: {
title: 'Ready to work together?',
description: 'I\'m always open to new opportunities and interesting collaborations.',
button: 'Contact me'
}
},
// Contact page
contact: {
title: 'Contact Me',
subtitle: 'Ready to discuss your next project? I\'d love to hear from you. Let\'s create something amazing together.',
stats: {
responseTime: 'Response Time',
satisfaction: 'Client Satisfaction',
collaboration: 'Collaboration'
},
quickContact: 'Quick Contact',
findMeOn: 'Find me on',
methods: {
email: 'Email',
phone: 'Phone',
location: 'Location',
responseTime: 'Response within 24-48h',
availability: 'Remote & on-site'
},
faq: {
title: 'Frequently Asked Questions',
subtitle: 'Here are the answers to the most common questions about my services and working method.',
responseTime: {
title: 'Response Time',
description: 'I generally respond within 24-48h to all messages received.'
},
projectTypes: {
title: 'Project Types',
description: 'Web applications, APIs, automation, consulting and custom solutions.'
},
collaboration: {
title: 'Collaboration',
description: 'Remote or on-site work according to your needs and preferences.'
}
},
form: {
name: 'Name',
email: 'Email',
subject: 'Subject',
message: 'Message',
send: 'Send Message',
sending: 'Sending...',
success: 'Message sent successfully!',
error: 'Error sending message. Please try again.',
required: 'This field is required',
invalidEmail: 'Please enter a valid email address'
},
info: {
title: 'Get in Touch',
description: 'Feel free to reach out to me for any questions, project discussions, or collaboration opportunities.',
email: 'Email',
social: 'Social Media'
}
},
// Project data
projectData: {
'virtual-tour': {
title: 'Virtual Tour',
description: 'Development of an interactive and immersive virtual tour platform.',
longDescription: 'Virtual Tour is an innovative platform for creating interactive and immersive virtual tours. Developed with the latest web technologies, it offers a smooth and engaging user experience for exploring 3D spaces.'
},
'xinko': {
title: 'Xinko',
description: 'Xinko is a multiplatform bot that can be used to create primary with ease and fun in it.',
longDescription: 'Xinko is an innovative multiplatform bot designed to simplify primary content creation. With an intuitive interface and advanced features, it allows users to generate quality content with ease and fun.'
},
'image-manipulation': {
title: 'Image Manipulation',
description: 'Discord Image Generation: NPM package for code-based image manipulation. Originally an API, now open-source.',
longDescription: 'A complete NPM package for image generation and manipulation in Discord. This open-source project offers a simple API for creating memes, applying filters, and generating dynamic images. Used by many Discord bots with over 100k downloads.'
},
'primate-web-admin': {
title: 'Primate Web Admin',
description: 'Primate Web Admin is a Web interface to manage Primate that is a Munki-like deployment tool for Windows.',
longDescription: 'Modern web interface for managing Primate, a Windows deployment tool inspired by Munki. This web application allows system administrators to deploy and manage software on a Windows computer fleet in a centralized manner.'
},
'instagram-bot': {
title: 'Instagram Bot',
description: 'Fully functional Instagram bot using Insta.js by androz2091. It has many commands. Generate images with commands like: !stonk or !invert.',
longDescription: 'Fully functional Instagram bot developed with Insta.js. It offers many commands to generate custom images, memes, and visual effects. Perfect for animating your Instagram stories and posts with original content.'
},
'crowdin-status-bot': {
title: 'Crowdin Status Bot',
description: 'A bot that fetches Crowdin translation status and updates Discord messages with the latest status. Stay informed on progress!',
longDescription: 'Automated Discord bot that fetches Crowdin translation status and updates Discord messages with the latest information. Ideal for translation teams who want to stay informed about their project progress in real-time.'
}
},
// Footer
footer: {
navigation: 'Navigation',
services: 'Services',
copyright: 'All rights reserved.',
legalNotices: 'Legal Notices',
privacyPolicy: 'Privacy Policy',
servicesList: {
webDev: 'Web Dev',
mobileApps: 'Mobile Apps',
apiBackend: 'API & Backend',
consulting: 'Consulting'
}
},
// Common
common: {
loading: 'Loading...',
error: 'An error occurred',
retry: 'Retry',
close: 'Close',
save: 'Save',
cancel: 'Cancel',
confirm: 'Confirm',
delete: 'Delete',
edit: 'Edit',
view: 'View',
back: 'Back',
next: 'Next',
previous: 'Previous',
search: 'Search',
filter: 'Filter',
sort: 'Sort',
reset: 'Reset'
},
// SEO
seo: {
home: {
title: 'Killian - Full Stack Developer',
description: 'Portfolio of Killian, full stack developer specialized in Vue.js, React and Node.js. Discover my projects and services.'
},
projects: {
title: 'Projects - Killian',
description: 'Explore my portfolio of web applications, tools, and innovative solutions.'
},
about: {
title: 'About - Killian',
description: 'Learn more about my journey, skills, and passion for web development.'
},
contact: {
title: 'Contact - Killian',
description: 'Ready to discuss your next project? Let\'s create something amazing together.'
}
}
}

266
src/locales/fr.ts Normal file
View File

@@ -0,0 +1,266 @@
export default {
// Navigation
nav: {
home: 'Accueil',
projects: 'Projets',
about: 'À propos',
contact: 'Contact'
},
// Home page
home: {
title: 'Salut, je suis Killian',
subtitle: 'Développeur Full Stack passionné par la création d\'expériences web exceptionnelles et de solutions innovantes pour donner vie à vos projets.',
cta: {
viewProjects: 'Voir mes projets',
contactMe: 'Me contacter'
},
featuredProjects: {
title: 'Projets en vedette',
subtitle: 'Découvrez une sélection de mes projets les plus récents et innovants, démontrant mes compétences en développement web moderne.',
viewAll: 'Voir tous les projets'
},
services: {
title: 'Mes services',
subtitle: 'Je propose une gamme complète de services pour accompagner vos projets du concept à la réalisation.',
webDev: {
title: 'Dev Web',
description: 'Applications web modernes avec Vue.js, React et Node.js. Solutions sur mesure adaptées à vos besoins.'
},
mobileApps: {
title: 'Applications Mobile',
description: 'Développement d\'applications multiplateformes performantes et intuitives.'
},
optimization: {
title: 'Optimisation & Performance',
description: 'Amélioration des performances et optimisation SEO pour une meilleure expérience utilisateur.'
},
maintenance: {
title: 'Maintenance & Support',
description: 'Maintenance continue et support technique pour vos projets existants.'
}
},
cta2: {
title: 'Prêt à démarrer votre projet ?',
subtitle: 'Discutons de votre vision et créons ensemble quelque chose d\'extraordinaire. Je suis là pour transformer vos idées en réalité.',
startProject: 'Commencer un projet',
learnMore: 'En savoir plus'
}
},
// Projects page
projects: {
title: 'Mes Projets',
subtitle: 'Explorez mon portfolio d\'applications web, d\'outils et de solutions innovantes. Chaque projet représente un défi unique résolu avec créativité et expertise technique.',
categories: {
all: 'Tous',
'webdevelopment': 'Développement Web',
'botdevelopment': 'Développement de Bot',
'opensource': 'Open Source',
'enterprisesoftware': 'Logiciel d\'Entreprise',
'socialmediabot': 'Bot de Réseaux Sociaux',
'automation': 'Automatisation'
},
buttons: {
website: 'Site Web',
repository: 'Dépôt',
npmpackage: 'Package NPM',
viewProject: 'Voir le Projet'
},
noResults: {
title: 'Aucun projet trouvé',
description: 'Essayez de modifier vos critères de recherche ou de filtrage.'
}
},
// About page
about: {
title: 'À propos de moi',
subtitle: 'Découvrez mon parcours, mes compétences et ma passion pour le développement web.',
intro: {
title: 'Qui suis-je ?',
content: 'Je suis Killian, un développeur full-stack passionné avec plusieurs années d\'expérience en développement web. Je me spécialise dans la création d\'applications modernes, performantes et conviviales en utilisant les dernières technologies.'
},
skills: {
title: 'Mes Compétences',
programming: 'Programmation',
frontend: 'Frontend',
backend: 'Backend',
tools: 'Outils & Autres',
systems: 'Systèmes d\'exploitation'
},
experience: {
title: 'Expérience',
content: 'Avec des années d\'expérience en développement web, j\'ai travaillé sur divers projets allant de simples sites web à des applications web complexes. Je suis toujours désireux d\'apprendre de nouvelles technologies et d\'améliorer mes compétences.'
},
approach: {
title: 'Mon Approche',
subtitle: 'Ma philosophie de développement repose sur quatre piliers fondamentaux qui garantissent le succès de chaque projet.',
performance: {
title: 'Performance',
description: 'J\'accorde une importance particulière à la performance et à l\'optimisation, en utilisant les meilleures pratiques pour garantir des applications rapides et réactives.'
},
architecture: {
title: 'Architecture',
description: 'Je conçois des architectures modulaires et maintenables, facilitant l\'évolution et la scalabilité des projets sur le long terme.'
},
quality: {
title: 'Qualité',
description: 'Tests automatisés, revues de code et intégration continue font partie intégrante de mon processus de développement pour garantir une qualité optimale.'
},
collaboration: {
title: 'Collaboration',
description: 'Je privilégie une communication claire et une collaboration étroite avec les équipes pour assurer le succès des projets.'
}
},
cta: {
title: 'Prêt à travailler ensemble ?',
description: 'Je suis toujours ouvert aux nouvelles opportunités et collaborations intéressantes.',
button: 'Me contacter'
}
},
// Contact page
contact: {
title: 'Me Contacter',
subtitle: 'Prêt à discuter de votre prochain projet ? J\'aimerais avoir de vos nouvelles. Créons ensemble quelque chose d\'incroyable.',
stats: {
responseTime: 'Délai de réponse',
satisfaction: 'Satisfaction client',
collaboration: 'Collaboration'
},
quickContact: 'Contact rapide',
findMeOn: 'Retrouvez-moi sur',
methods: {
email: 'Email',
phone: 'Téléphone',
location: 'Localisation',
responseTime: 'Réponse sous 24-48h',
availability: 'Remote & sur site'
},
faq: {
title: 'Questions fréquentes',
subtitle: 'Voici les réponses aux questions les plus courantes concernant mes services et ma méthode de travail.',
responseTime: {
title: 'Délai de réponse',
description: 'Je réponds généralement sous 24-48h à tous les messages reçus.'
},
projectTypes: {
title: 'Types de projets',
description: 'Applications web, API, automatisation, consulting et solutions sur mesure.'
},
collaboration: {
title: 'Collaboration',
description: 'Travail en remote ou sur site selon vos besoins et préférences.'
}
},
form: {
name: 'Nom',
email: 'Email',
subject: 'Sujet',
message: 'Message',
send: 'Envoyer le Message',
sending: 'Envoi en cours...',
success: 'Message envoyé avec succès !',
error: 'Erreur lors de l\'envoi du message. Veuillez réessayer.',
required: 'Ce champ est requis',
invalidEmail: 'Veuillez entrer une adresse email valide'
},
info: {
title: 'Restons en Contact',
description: 'N\'hésitez pas à me contacter pour toute question, discussion de projet ou opportunité de collaboration.',
email: 'Email',
social: 'Réseaux Sociaux'
}
},
// Project data
projectData: {
'virtual-tour': {
title: 'Virtual Tour',
description: 'Développement d\'une plateforme de visite virtuelle interactive et immersive.',
longDescription: 'Virtual Tour est une plateforme innovante permettant de créer des visites virtuelles interactives et immersives. Développée avec les dernières technologies web, elle offre une expérience utilisateur fluide et engageante pour explorer des espaces en 3D.'
},
'xinko': {
title: 'Xinko',
description: 'Xinko est un bot multiplateforme qui peut être utilisé pour créer du contenu primaire avec facilité et plaisir.',
longDescription: 'Xinko est un bot multiplateforme innovant conçu pour simplifier la création de contenu primaire. Avec une interface intuitive et des fonctionnalités avancées, il permet aux utilisateurs de générer du contenu de qualité avec facilité et plaisir.'
},
'image-manipulation': {
title: 'Manipulation d\'Images',
description: 'Discord Image Generation : Package NPM pour la manipulation d\'images basée sur le code. Initialement une API, maintenant open-source.',
longDescription: 'Un package NPM complet pour la génération et la manipulation d\'images dans Discord. Ce projet open-source offre une API simple pour créer des memes, appliquer des filtres et générer des images dynamiques. Utilisé par de nombreux bots Discord avec plus de 100k téléchargements.'
},
'primate-web-admin': {
title: 'Primate Web Admin',
description: 'Primate Web Admin est une interface Web pour gérer Primate qui est un outil de déploiement similaire à Munki pour Windows.',
longDescription: 'Interface web moderne pour gérer Primate, un outil de déploiement pour Windows inspiré de Munki. Cette application web permet aux administrateurs système de déployer et gérer des logiciels sur un parc informatique Windows de manière centralisée.'
},
'instagram-bot': {
title: 'Bot Instagram',
description: 'Bot Instagram entièrement fonctionnel utilisant Insta.js par androz2091. Il a de nombreuses commandes. Générez des images avec des commandes comme : !stonk ou !invert.',
longDescription: 'Bot Instagram entièrement fonctionnel développé avec Insta.js. Il propose de nombreuses commandes pour générer des images personnalisées, des memes et des effets visuels. Parfait pour animer vos stories et posts Instagram avec du contenu original.'
},
'crowdin-status-bot': {
title: 'Bot de Statut Crowdin',
description: 'Un bot qui récupère le statut de traduction Crowdin et met à jour les messages Discord avec le dernier statut. Restez informé des progrès !',
longDescription: 'Bot Discord automatisé qui récupère le statut des traductions Crowdin et met à jour les messages Discord avec les dernières informations. Idéal pour les équipes de traduction qui souhaitent rester informées du progrès de leurs projets en temps réel.'
}
},
// Footer
footer: {
navigation: 'Navigation',
services: 'Services',
copyright: 'Tous droits réservés.',
legalNotices: 'Mentions légales',
privacyPolicy: 'Politique de confidentialité',
servicesList: {
webDev: 'Dev Web',
mobileApps: 'Applications Mobile',
apiBackend: 'API & Backend',
consulting: 'Consultation'
}
},
// Common
common: {
loading: 'Chargement...',
error: 'Une erreur s\'est produite',
retry: 'Réessayer',
close: 'Fermer',
save: 'Sauvegarder',
cancel: 'Annuler',
confirm: 'Confirmer',
delete: 'Supprimer',
edit: 'Modifier',
view: 'Voir',
back: 'Retour',
next: 'Suivant',
previous: 'Précédent',
search: 'Rechercher',
filter: 'Filtrer',
sort: 'Trier',
reset: 'Réinitialiser'
},
// SEO
seo: {
home: {
title: 'Killian - Développeur Full Stack',
description: 'Portfolio de Killian, développeur full stack spécialisé en Vue.js, React et Node.js. Découvrez mes projets et services.'
},
projects: {
title: 'Projets - Killian',
description: 'Explorez mon portfolio d\'applications web, d\'outils et de solutions innovantes.'
},
about: {
title: 'À propos - Killian',
description: 'Découvrez mon parcours, mes compétences et ma passion pour le développement web.'
},
contact: {
title: 'Contact - Killian',
description: 'Prêt à discuter de votre prochain projet ? Créons ensemble quelque chose d\'incroyable.'
}
}
}

16
src/main.ts Normal file
View File

@@ -0,0 +1,16 @@
import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import i18n from './i18n'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(i18n)
app.mount('#app')

47
src/router/index.ts Normal file
View File

@@ -0,0 +1,47 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomePage from '../views/HomePage.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomePage
},
{
path: '/projects',
name: 'projects',
component: () => import('../views/ProjectsPage.vue')
},
{
path: '/project/:id',
name: 'project-detail',
component: () => import('../views/ProjectDetailPage.vue')
},
{
path: '/about',
name: 'about',
component: () => import('../views/AboutPage.vue')
},
{
path: '/contact',
name: 'contact',
component: () => import('../views/ContactPage.vue')
}
],
scrollBehavior() {
// Always scroll to top for consistent navigation
return { top: 0 }
}
})
// Force scroll to top on every navigation
router.afterEach(() => {
// Use nextTick to ensure DOM is updated
setTimeout(() => {
window.scrollTo(0, 0)
}, 0)
})
export default router

12
src/stores/counter.ts Normal file
View File

@@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

1
src/style.css Normal file
View File

@@ -0,0 +1 @@
@import 'tailwindcss';

37
src/types/index.ts Normal file
View File

@@ -0,0 +1,37 @@
export interface Project {
id: string
title: string
image: string
description: string
longDescription?: string
technologies?: string[]
category?: string
featured?: boolean
buttons?: ProjectButton[]
date?: string
demoUrl?: string
githubUrl?: string
features?: string[]
gallery?: string[]
status?: string
}
export interface ProjectButton {
title: string
link: string
}
export interface Technology {
name: string
level: 'Beginner' | 'Intermediate' | 'Advanced'
image: string
}
export interface TechStack {
programming: Technology[]
front: Technology[]
database: Technology[]
devtools: Technology[]
operating_systems: Technology[]
socials: Technology[]
}

257
src/views/AboutPage.vue Normal file
View File

@@ -0,0 +1,257 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useSeo } from '@/composables/useSeo'
import { useI18n } from '@/composables/useI18n'
import TechBadge from '@/components/TechBadge.vue'
import { techStack } from '@/data/techstack'
const { t } = useI18n()
// SEO
useSeo({
title: t('seo.about.title'),
description: t('seo.about.description')
})
const techCategories = computed(() => [
{
key: 'programming',
title: t('about.skills.programming'),
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
color: 'var(--color-primary)'
},
{
key: 'front',
title: t('about.skills.frontend'),
icon: 'M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01',
color: 'var(--color-secondary)'
},
{
key: 'database',
title: t('about.skills.backend'),
icon: 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4',
color: 'var(--color-success)'
},
{
key: 'devtools',
title: t('about.skills.tools'),
icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z',
color: 'var(--color-warning)'
}
])
const approachCards = computed(() => [
{
title: t('about.approach.performance.title'),
description: t('about.approach.performance.description'),
icon: 'M13 10V3L4 14h7v7l9-11h-7z',
color: 'var(--color-primary)'
},
{
title: t('about.approach.architecture.title'),
description: t('about.approach.architecture.description'),
icon: 'M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4',
color: 'var(--color-secondary)'
},
{
title: t('about.approach.quality.title'),
description: t('about.approach.quality.description'),
icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
color: 'var(--color-success)'
},
{
title: t('about.approach.collaboration.title'),
description: t('about.approach.collaboration.description'),
icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
color: 'var(--color-warning)'
}
])
</script>
<template>
<main>
<!-- Hero Section -->
<section class="hero">
<div class="container">
<div class="hero-content animate-fade-in-up">
<h1 class="hero-title">
{{ t('about.title') }}
</h1>
<p class="hero-subtitle">
{{ t('about.subtitle') }}
</p>
<div class="max-w-4xl mx-auto text-center">
<div class="space-y-lg text-xl text-secondary">
<p>
{{ t('about.intro.content') }}
</p>
<p>
{{ t('about.experience.content') }}
</p>
</div>
</div>
</div>
</div>
</section>
<!-- Skills Section -->
<section class="section">
<div class="container">
<div class="text-center mb-2xl">
<h2 class="mb-lg">{{ t('about.skills.title') }}</h2>
<p class="text-xl text-secondary max-w-2xl mx-auto">
{{ t('about.subtitle') }}
</p>
</div>
<!-- Tech Categories Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-xl mb-2xl">
<div v-for="category in techCategories" :key="category.key" class="card animate-fade-in-up">
<div class="card-body">
<div class="flex items-center mb-lg">
<div class="w-12 h-12 rounded-lg flex items-center justify-center mr-md"
:style="{ background: category.color, color: 'var(--text-inverse)' }">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="category.icon"></path>
</svg>
</div>
<h3 class="text-2xl font-bold">{{ category.title }}</h3>
</div>
<div class="flex flex-wrap gap-sm">
<TechBadge v-for="tech in techStack[category.key as keyof typeof techStack]" :key="tech.name"
:tech="tech" :show-level="true" />
</div>
</div>
</div>
</div>
<!-- Operating Systems -->
<div class="card animate-fade-in-up">
<div class="card-body">
<div class="flex items-center mb-lg">
<div class="w-12 h-12 rounded-lg flex items-center justify-center mr-md"
style="background: var(--color-primary); color: var(--text-inverse);">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z">
</path>
</svg>
</div>
<h3 class="text-2xl font-bold">{{ t('about.skills.systems') }}</h3>
</div>
<div class="flex flex-wrap gap-sm">
<TechBadge v-for="tech in techStack.operating_systems" :key="tech.name" :tech="tech"
:show-level="false" />
</div>
</div>
</div>
</div>
</section>
<!-- Approach Section -->
<section class="section" style="background: var(--bg-secondary);">
<div class="container">
<div class="text-center mb-2xl">
<h2 class="mb-lg">{{ t('about.approach.title') }}</h2>
<p class="text-xl text-secondary max-w-2xl mx-auto">
{{ t('about.approach.subtitle') }}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-xl">
<div v-for="(card, index) in approachCards" :key="index" class="card animate-fade-in-up"
:style="{ 'animation-delay': `${index * 0.1}s` }">
<div class="card-body">
<div class="flex items-start">
<div class="w-12 h-12 rounded-lg flex items-center justify-center mr-md flex-shrink-0"
:style="{ background: card.color, color: 'var(--text-inverse)' }">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="card.icon"></path>
</svg>
</div>
<div>
<h3 class="text-xl font-bold mb-md">{{ card.title }}</h3>
<p class="text-secondary">{{ card.description }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- CTA Section -->
<section class="section">
<div class="container">
<div class="text-center">
<h2 class="mb-lg">{{ t('about.cta.title') }}</h2>
<p class="text-xl text-secondary max-w-2xl mx-auto mb-2xl">
{{ t('about.cta.description') }}
</p>
<div class="flex flex-col sm:flex-row gap-md justify-center">
<RouterLink to="/contact" class="btn btn-primary btn-lg">
{{ t('about.cta.button') }}
<svg class="btn-icon" 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">
</path>
</svg>
</RouterLink>
<RouterLink to="/projects" class="btn btn-secondary btn-lg">
{{ t('home.cta.viewProjects') }}
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6">
</path>
</svg>
</RouterLink>
</div>
</div>
</div>
</section>
</main>
</template>
<style scoped>
/* Custom animations with delays */
.animate-fade-in-up {
animation: fadeInUp 0.6s ease-out forwards;
opacity: 0;
}
/* Spacing utilities */
.space-y-lg>*+* {
margin-top: var(--space-lg);
}
/* Responsive utilities */
@media (min-width: 640px) {
.sm\:flex-row {
flex-direction: row;
}
}
@media (min-width: 768px) {
.md\:grid-cols-2 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.lg\:grid-cols-2 {
grid-template-columns: repeat(2, 1fr);
}
}
/* Custom utilities */
.max-w-2xl {
max-width: 42rem;
}
.max-w-4xl {
max-width: 56rem;
}
.flex-shrink-0 {
flex-shrink: 0;
}
</style>

502
src/views/ContactPage.vue Normal file
View File

@@ -0,0 +1,502 @@
<script setup lang="ts">
import { useSeo } from '@/composables/useSeo'
import { useI18n } from '@/composables/useI18n'
import { useSiteConfig } from '@/composables/useSiteConfig'
const { t } = useI18n()
const { siteConfig } = useSiteConfig()
// SEO
useSeo({
title: t('seo.contact.title'),
description: t('seo.contact.description'),
ogTitle: t('seo.contact.title'),
ogDescription: t('seo.contact.description')
})
</script>
<template>
<main class="contact-page">
<!-- Hero Section -->
<section class="contact-hero">
<div class="container">
<div class="hero-content text-center">
<h1 class="hero-title">{{ t('contact.title') }}</h1>
<p class="hero-subtitle">
{{ t('contact.subtitle') }}
</p>
<!-- Contact Stats -->
<div class="stats-grid">
<div class="stat-item">
<div class="stat-number">24-48h</div>
<div class="stat-label">{{ t('contact.stats.responseTime') }}</div>
</div>
<div class="stat-item">
<div class="stat-number">100%</div>
<div class="stat-label">{{ t('contact.stats.satisfaction') }}</div>
</div>
<div class="stat-item">
<div class="stat-number">Remote</div>
<div class="stat-label">{{ t('contact.stats.collaboration') }}</div>
</div>
</div>
</div>
</div>
</section>
<!-- Contact Info Section -->
<section class="section">
<div class="container">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-2xl max-w-4xl mx-auto">
<!-- Contact Methods -->
<div class="card">
<div class="card-body">
<h2 class="text-2xl font-bold mb-lg">{{ t('contact.quickContact') }}</h2>
<div class="space-y-md">
<!-- Email -->
<div class="contact-method">
<div class="contact-icon" style="background: var(--color-primary); color: var(--text-inverse);">
<svg class="w-6 h-6" 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">
</path>
</svg>
</div>
<div class="contact-info">
<div class="contact-title">{{ t('contact.methods.email') }}</div>
<a :href="siteConfig.social.find(s => s.icon === 'email')?.url" class="contact-link">
{{ siteConfig.contact.email }}
</a>
<div class="contact-description">{{ t('contact.methods.responseTime') }}</div>
</div>
</div>
<!-- Phone -->
<div class="contact-method">
<div class="contact-icon" style="background: var(--color-info); color: var(--text-inverse);">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z">
</path>
</svg>
</div>
<div class="contact-info">
<div class="contact-title">{{ t('contact.methods.phone') }}</div>
<a :href="`tel:${siteConfig.contact.phone.replace(/\s/g, '')}`" class="contact-link">
{{ siteConfig.contact.phone }}
</a>
<div class="contact-description">{{ t('contact.methods.availability') }}</div>
</div>
</div>
<!-- Location -->
<div class="contact-method">
<div class="contact-icon" style="background: var(--color-secondary); color: var(--text-inverse);">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z">
</path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z">
</path>
</svg>
</div>
<div class="contact-info">
<div class="contact-title">{{ t('contact.methods.location') }}</div>
<div class="contact-text">{{ siteConfig.contact.location }}</div>
<div class="contact-description">{{ t('contact.methods.availability') }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Social Links -->
<div class="card">
<div class="card-body">
<h2 class="text-2xl font-bold mb-lg">{{ t('contact.findMeOn') }}</h2>
<div class="space-y-sm">
<a v-for="social in siteConfig.social.filter(s => s.icon !== 'email')" :key="social.name"
:href="social.url" target="_blank" rel="noopener noreferrer" class="social-link">
<div class="social-icon">
<!-- 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>
</div>
<span class="social-name">{{ social.name }}</span>
<svg class="social-arrow" 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>
</div>
</section>
<!-- FAQ Section -->
<section class="section" style="background: var(--bg-secondary);">
<div class="container">
<div class="text-center mb-2xl">
<h2 class="mb-lg">{{ t('contact.faq.title') }}</h2>
<p class="text-xl text-secondary max-w-2xl mx-auto">
{{ t('contact.faq.subtitle') }}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-xl">
<div class="card text-center">
<div class="card-body">
<div class="w-12 h-12 rounded-lg flex items-center justify-center mx-auto mb-lg"
style="background: var(--color-success); color: var(--text-inverse);">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<h3 class="text-xl font-bold mb-md">{{ t('contact.faq.responseTime.title') }}</h3>
<p class="text-secondary">{{ t('contact.faq.responseTime.description') }}</p>
</div>
</div>
<div class="card text-center">
<div class="card-body">
<div class="w-12 h-12 rounded-lg flex items-center justify-center mx-auto mb-lg"
style="background: var(--color-primary); color: var(--text-inverse);">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4">
</path>
</svg>
</div>
<h3 class="text-xl font-bold mb-md">{{ t('contact.faq.projectTypes.title') }}</h3>
<p class="text-secondary">{{ t('contact.faq.projectTypes.description') }}</p>
</div>
</div>
<div class="card text-center">
<div class="card-body">
<div class="w-12 h-12 rounded-lg flex items-center justify-center mx-auto mb-lg"
style="background: var(--color-secondary); color: var(--text-inverse);">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z">
</path>
</svg>
</div>
<h3 class="text-xl font-bold mb-md">{{ t('contact.faq.collaboration.title') }}</h3>
<p class="text-secondary">{{ t('contact.faq.collaboration.description') }}</p>
</div>
</div>
</div>
</div>
</section>
</main>
</template>
<style scoped>
/* Contact Page Styles */
.contact-page {
min-height: 100vh;
background: var(--bg-primary);
}
/* Hero Section */
.contact-hero {
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
padding: var(--space-4xl) 0 var(--space-3xl);
position: relative;
overflow: hidden;
}
.contact-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-content {
position: relative;
z-index: 1;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: var(--space-xl);
max-width: 500px;
margin: var(--space-2xl) auto 0;
}
.stat-item {
text-align: center;
}
.stat-number {
font-size: 2rem;
font-weight: bold;
color: var(--color-primary);
line-height: 1;
}
.stat-label {
font-size: 0.875rem;
color: var(--text-secondary);
margin-top: var(--space-xs);
}
/* Contact Methods */
.contact-method {
display: flex;
align-items: flex-start;
padding: var(--space-md);
background: var(--bg-secondary);
border-radius: var(--border-radius-lg);
transition: all var(--transition-fast);
}
.contact-method:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.contact-icon {
width: 40px;
height: 40px;
border-radius: var(--border-radius-lg);
display: flex;
align-items: center;
justify-content: center;
margin-right: var(--space-md);
flex-shrink: 0;
}
.contact-info {
flex: 1;
}
.contact-title {
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-xs);
}
.contact-link {
color: var(--color-primary);
text-decoration: none;
font-weight: 500;
transition: color var(--transition-fast);
}
.contact-link:hover {
color: var(--color-primary-dark);
}
.contact-text {
color: var(--text-primary);
font-weight: 500;
}
.contact-description {
font-size: 0.875rem;
color: var(--text-secondary);
margin-top: var(--space-xs);
}
/* Social Links */
.social-link {
display: flex;
align-items: center;
padding: var(--space-sm) var(--space-md);
background: var(--bg-secondary);
border-radius: var(--border-radius-lg);
text-decoration: none;
transition: all var(--transition-fast);
group: true;
}
.social-link:hover {
background: var(--color-primary);
color: var(--text-inverse);
transform: translateX(4px);
}
.social-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
margin-right: var(--space-sm);
color: var(--text-secondary);
transition: color var(--transition-fast);
}
.social-link:hover .social-icon {
color: var(--text-inverse);
}
.social-name {
flex: 1;
font-weight: 500;
color: var(--text-primary);
transition: color var(--transition-fast);
}
.social-link:hover .social-name {
color: var(--text-inverse);
}
.social-arrow {
width: 16px;
height: 16px;
color: var(--text-secondary);
transition: all var(--transition-fast);
}
.social-link:hover .social-arrow {
color: var(--text-inverse);
transform: translateX(2px);
}
/* Responsive */
@media (min-width: 768px) {
.md\:grid-cols-3 {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 1024px) {
.lg\:grid-cols-2 {
grid-template-columns: repeat(2, 1fr);
}
}
/* Utilities */
.max-w-2xl {
max-width: 42rem;
}
.max-w-4xl {
max-width: 56rem;
}
.mx-auto {
margin-left: auto;
margin-right: auto;
}
.text-center {
text-align: center;
}
.space-y-sm>*+* {
margin-top: var(--space-sm);
}
.space-y-md>*+* {
margin-top: var(--space-md);
}
.grid {
display: grid;
}
.gap-xl {
gap: var(--space-xl);
}
.gap-2xl {
gap: var(--space-2xl);
}
.flex {
display: flex;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.mb-lg {
margin-bottom: var(--space-lg);
}
.mb-md {
margin-bottom: var(--space-md);
}
.mb-2xl {
margin-bottom: var(--space-2xl);
}
.text-xl {
font-size: 1.25rem;
}
.text-2xl {
font-size: 1.5rem;
}
.font-bold {
font-weight: 700;
}
/* Removed hardcoded colors - using CSS variables instead */
.w-5 {
width: 1.25rem;
}
.h-5 {
height: 1.25rem;
}
.w-6 {
width: 1.5rem;
}
.h-6 {
height: 1.5rem;
}
.w-12 {
width: 3rem;
}
.h-12 {
height: 3rem;
}
.rounded-lg {
border-radius: var(--border-radius-lg);
}
</style>

262
src/views/HomePage.vue Normal file
View File

@@ -0,0 +1,262 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useSeo } from '@/composables/useSeo'
import { useI18n } from '@/composables/useI18n'
import { projects } from '@/data/projects'
import ProjectCard from '@/components/ProjectCard.vue'
const { t } = useI18n()
// SEO
useSeo({
title: t('seo.home.title'),
description: t('seo.home.description')
})
// Featured projects
const featuredProjects = computed(() => {
return projects.filter(project => project.featured).slice(0, 3)
})
// Services data
const services = computed(() => [
{
icon: '💻',
title: t('home.services.webDev.title'),
description: t('home.services.webDev.description')
},
{
icon: '📱',
title: t('home.services.mobileApps.title'),
description: t('home.services.mobileApps.description')
},
{
icon: '🚀',
title: t('home.services.optimization.title'),
description: t('home.services.optimization.description')
},
{
icon: '🛠️',
title: t('home.services.maintenance.title'),
description: t('home.services.maintenance.description')
}
])
</script>
<template>
<main>
<!-- Hero Section -->
<section class="hero">
<div class="container">
<div class="hero-content animate-fade-in-up">
<h1 class="hero-title">
{{ t('home.title') }}
</h1>
<p class="hero-subtitle">
{{ t('home.subtitle') }}
</p>
<div class="flex flex-col sm:flex-row gap-md justify-center">
<RouterLink to="/projects" class="btn btn-primary btn-lg">
{{ t('home.cta.viewProjects') }}
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6">
</path>
</svg>
</RouterLink>
<RouterLink to="/contact" class="btn btn-secondary btn-lg">
{{ t('home.cta.contactMe') }}
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z">
</path>
</svg>
</RouterLink>
</div>
</div>
</div>
<!-- Scroll indicator -->
<div class="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce">
<svg class="w-6 h-6" style="color: var(--text-tertiary);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3"></path>
</svg>
</div>
</section>
<!-- Featured Projects Section -->
<section class="section">
<div class="container">
<div class="text-center mb-2xl">
<h2 class="mb-lg">{{ t('home.featuredProjects.title') }}</h2>
<p class="text-xl text-secondary max-w-2xl mx-auto">
{{ t('home.featuredProjects.subtitle') }}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-xl mb-2xl">
<ProjectCard v-for="project in featuredProjects" :key="project.id" :project="project"
class="animate-fade-in-up" />
</div>
<div class="text-center">
<RouterLink to="/projects" class="btn btn-secondary">
{{ t('home.featuredProjects.viewAll') }}
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
</svg>
</RouterLink>
</div>
</div>
</section>
<!-- Services Section -->
<section class="section" style="background: var(--bg-secondary);">
<div class="container">
<div class="text-center mb-2xl">
<h2 class="mb-lg">{{ t('home.services.title') }}</h2>
<p class="text-xl text-secondary max-w-2xl mx-auto">
{{ t('home.services.subtitle') }}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-xl">
<div v-for="(service, index) in services" :key="service.title" class="card text-center animate-fade-in-up"
:style="{ 'animation-delay': `${index * 0.1}s` }">
<div class="card-body">
<div class="text-4xl mb-lg">{{ service.icon }}</div>
<h3 class="text-xl font-bold mb-md">{{ service.title }}</h3>
<p class="text-secondary">{{ service.description }}</p>
</div>
</div>
</div>
</div>
</section>
<!-- CTA Section -->
<section class="section">
<div class="container">
<div class="text-center">
<h2 class="mb-lg">{{ t('home.cta2.title') }}</h2>
<p class="text-xl text-secondary max-w-2xl mx-auto mb-2xl">
{{ t('home.cta2.subtitle') }}
</p>
<div class="flex flex-col sm:flex-row gap-md justify-center">
<RouterLink to="/contact" class="btn btn-primary btn-lg">
{{ t('home.cta2.startProject') }}
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z">
</path>
</svg>
</RouterLink>
<RouterLink to="/about" class="btn btn-secondary btn-lg">
{{ t('home.cta2.learnMore') }}
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z">
</path>
</svg>
</RouterLink>
</div>
</div>
</div>
</section>
</main>
</template>
<style scoped>
/* Custom animations with delays */
.animate-fade-in-up {
animation: fadeInUp 0.6s ease-out forwards;
opacity: 0;
}
/* Responsive utilities */
@media (min-width: 640px) {
.sm\:flex-row {
flex-direction: row;
}
}
@media (min-width: 768px) {
.md\:grid-cols-2 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.lg\:grid-cols-3 {
grid-template-columns: repeat(3, 1fr);
}
.lg\:grid-cols-4 {
grid-template-columns: repeat(4, 1fr);
}
}
/* Custom utilities */
.max-w-2xl {
max-width: 42rem;
}
.mx-auto {
margin-left: auto;
margin-right: auto;
}
.opacity-90 {
opacity: 0.9;
}
.transform {
transform: translateX(0);
}
.-translate-x-1\/2 {
transform: translateX(-50%);
}
.absolute {
position: absolute;
}
.bottom-8 {
bottom: 2rem;
}
.left-1\/2 {
left: 50%;
}
.animate-bounce {
animation: bounce 1s infinite;
}
@keyframes bounce {
0%,
20%,
53%,
80%,
100% {
transform: translate3d(-50%, 0, 0);
}
40%,
43% {
transform: translate3d(-50%, -30px, 0);
}
70% {
transform: translate3d(-50%, -15px, 0);
}
90% {
transform: translate3d(-50%, -4px, 0);
}
}
.text-secondary {
color: var(--text-secondary);
}
</style>

View File

@@ -0,0 +1,764 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSeo } from '@/composables/useSeo'
import { useAssets } from '@/composables/useAssets'
import { projects } from '@/data/projects'
import TechBadge from '@/components/TechBadge.vue'
const route = useRoute()
const router = useRouter()
const { getImageUrl } = useAssets()
// Find project by ID
const project = computed(() => {
const id = route.params.id as string
return projects.find(p => p.id === id)
})
// Related projects
const relatedProjects = computed(() => {
if (!project.value) return []
return projects
.filter(p => p.id !== project.value?.id && p.category === project.value?.category)
.slice(0, 3)
})
// SEO
const seoTitle = computed(() => project.value ? `${project.value.title} - Killian` : 'Projet - Killian')
const seoDescription = computed(() => project.value?.description || 'Découvrez ce projet en détail')
useSeo({
title: seoTitle.value,
description: seoDescription.value
})
// Navigation
const goBack = () => {
router.push('/projects')
}
const shareProject = () => {
if (navigator.share && project.value) {
navigator.share({
title: project.value.title,
text: project.value.description,
url: window.location.href
})
} else {
// Fallback: copy to clipboard
navigator.clipboard.writeText(window.location.href)
}
}
// Check if project exists
onMounted(() => {
if (!project.value) {
router.push('/projects')
}
})
</script>
<template>
<main v-if="project" class="project-detail-page">
<!-- Hero Section - Redesigned -->
<section class="project-hero">
<div class="container">
<div class="hero-content">
<!-- Navigation -->
<nav class="breadcrumb">
<button @click="goBack" class="breadcrumb-link">
<svg class="breadcrumb-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
Retour aux projets
</button>
</nav>
<div class="hero-grid">
<!-- Project Image -->
<div class="project-image-container">
<img v-if="project.image" :src="getImageUrl(project.image)" :alt="project.title" class="project-image">
<div class="image-overlay"></div>
</div>
<!-- Project Info -->
<div class="project-info">
<div class="project-meta">
<span v-if="project.category" class="project-category">{{ project.category }}</span>
<span v-if="project.date" class="project-date">{{ project.date }}</span>
</div>
<h1 class="project-title">{{ project.title }}</h1>
<p class="project-description">{{ project.description }}</p>
<!-- Actions -->
<div class="project-actions">
<a v-if="project.demoUrl" :href="project.demoUrl" target="_blank" rel="noopener noreferrer"
class="btn btn-primary">
<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>
Voir la démo
</a>
<a v-if="project.githubUrl" :href="project.githubUrl" target="_blank" rel="noopener noreferrer"
class="btn btn-secondary">
<svg class="btn-icon" 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.839 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>
Code source
</a>
<!-- Buttons from project data -->
<a v-for="button in project.buttons" :key="button.title" :href="button.link" target="_blank"
rel="noopener noreferrer" class="btn btn-outline">
<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>
{{ button.title }}
</a>
<button @click="shareProject" class="btn btn-ghost">
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.367 2.684 3 3 0 00-5.367-2.684z">
</path>
</svg>
Partager
</button>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Content Section -->
<section class="project-content">
<div class="container">
<div class="content-grid">
<!-- Main Content -->
<div class="main-content">
<!-- Project Details -->
<div class="content-section">
<h2 class="section-title">À propos du projet</h2>
<div class="section-content">
<p class="project-long-description">
{{ project.longDescription || project.description }}
</p>
<!-- Features -->
<div v-if="project.features" class="features-list">
<h3 class="features-title">Fonctionnalités principales</h3>
<ul class="features">
<li v-for="feature in project.features" :key="feature" class="feature-item">
<svg class="feature-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
{{ feature }}
</li>
</ul>
</div>
</div>
</div>
<!-- Technologies -->
<div v-if="project.technologies" class="content-section">
<h2 class="section-title">Technologies utilisées</h2>
<div class="section-content">
<div class="tech-grid">
<TechBadge v-for="tech in project.technologies" :key="tech" :tech="tech" class="tech-item" />
</div>
</div>
</div>
<!-- Gallery -->
<div v-if="project.gallery" class="content-section">
<h2 class="section-title">Galerie</h2>
<div class="section-content">
<div class="gallery-grid">
<div v-for="(image, index) in project.gallery" :key="index" class="gallery-item">
<img :src="getImageUrl(image)" :alt="`${project.title} - Image ${index + 1}`" class="gallery-image">
</div>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<aside class="sidebar">
<!-- Project Info Card -->
<div class="info-card">
<h3 class="info-title">Informations du projet</h3>
<div class="info-list">
<div v-if="project.date" class="info-item">
<span class="info-label">Date</span>
<span class="info-value">{{ project.date }}</span>
</div>
<div v-if="project.category" class="info-item">
<span class="info-label">Catégorie</span>
<span class="info-value">{{ project.category }}</span>
</div>
<div v-if="project.status" class="info-item">
<span class="info-label">Statut</span>
<span class="info-value">{{ project.status }}</span>
</div>
<!-- <div v-if="project.duration" class="info-item">
<span class="info-label">Durée</span>
<span class="info-value">{{ project.duration }}</span>
</div> -->
</div>
</div>
<!-- Related Projects -->
<div v-if="relatedProjects.length > 0" class="related-projects">
<h3 class="related-title">Projets similaires</h3>
<div class="related-list">
<router-link v-for="relatedProject in relatedProjects" :key="relatedProject.id"
:to="`/projects/${relatedProject.id}`" class="related-item">
<img v-if="relatedProject.image" :src="getImageUrl(relatedProject.image)" :alt="relatedProject.title"
class="related-image">
<div class="related-content">
<h4 class="related-project-title">{{ relatedProject.title }}</h4>
<p class="related-project-description">{{ relatedProject.description }}</p>
</div>
</router-link>
</div>
</div>
</aside>
</div>
</div>
</section>
</main>
</template>
<style scoped>
/* Project Detail Page Styles - Redesigned */
.project-detail-page {
min-height: 100vh;
background: var(--bg-primary);
}
/* Hero Section - New Layout */
.project-hero {
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
padding: var(--space-2xl) 0 var(--space-4xl);
position: relative;
overflow: hidden;
}
.project-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.3;
}
.hero-content {
position: relative;
z-index: 1;
}
/* Breadcrumb */
.breadcrumb {
margin-bottom: var(--space-2xl);
}
.breadcrumb-link {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
color: var(--text-secondary);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
transition: all var(--transition-fast);
background: none;
border: none;
cursor: pointer;
padding: var(--space-sm) var(--space-md);
border-radius: var(--border-radius-md);
}
.breadcrumb-link:hover {
color: var(--color-primary);
background: var(--bg-secondary);
}
.breadcrumb-icon {
width: 16px;
height: 16px;
}
/* Hero Grid Layout */
.hero-grid {
display: grid;
grid-template-columns: 400px 1fr;
gap: var(--space-3xl);
align-items: start;
}
/* Project Image Container */
.project-image-container {
position: relative;
border-radius: var(--border-radius-xl);
overflow: hidden;
box-shadow: var(--shadow-xl);
background: var(--bg-secondary);
}
.project-image {
width: 100%;
height: 300px;
object-fit: cover;
display: block;
}
.image-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(45deg, rgba(37, 99, 235, 0.1) 0%, rgba(124, 58, 237, 0.1) 100%);
opacity: 0;
transition: opacity var(--transition-fast);
}
.project-image-container:hover .image-overlay {
opacity: 1;
}
/* Project Info */
.project-info {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 300px;
}
.project-meta {
display: flex;
gap: var(--space-md);
margin-bottom: var(--space-lg);
flex-wrap: wrap;
}
.project-category {
background: var(--color-primary);
color: white;
padding: var(--space-xs) var(--space-md);
border-radius: var(--border-radius-full);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}
.project-date {
background: var(--bg-secondary);
color: var(--text-secondary);
padding: var(--space-xs) var(--space-md);
border-radius: var(--border-radius-full);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
border: var(--border-width) solid var(--border-color);
}
.project-title {
font-size: clamp(var(--font-size-3xl), 4vw, var(--font-size-4xl));
font-weight: var(--font-weight-extrabold);
margin-bottom: var(--space-lg);
line-height: var(--line-height-tight);
color: var(--text-primary);
}
.project-description {
font-size: var(--font-size-lg);
line-height: var(--line-height-relaxed);
margin-bottom: var(--space-2xl);
color: var(--text-secondary);
}
/* Actions */
.project-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-md);
}
/* Styles de boutons supprimés - utilisent maintenant les styles globaux */
/* Content Section */
.project-content {
padding: var(--space-4xl) 0;
}
.content-grid {
display: grid;
grid-template-columns: 1fr 320px;
gap: var(--space-4xl);
}
.main-content {
min-width: 0;
}
.content-section {
margin-bottom: var(--space-4xl);
}
.section-title {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
margin-bottom: var(--space-xl);
padding-bottom: var(--space-md);
border-bottom: 3px solid var(--color-primary);
position: relative;
}
.section-title::after {
content: '';
position: absolute;
bottom: -3px;
left: 0;
width: 60px;
height: 3px;
background: var(--color-secondary);
}
.section-content {
color: var(--text-secondary);
line-height: var(--line-height-relaxed);
}
.project-long-description {
font-size: var(--font-size-lg);
margin-bottom: var(--space-xl);
line-height: var(--line-height-relaxed);
}
/* Features */
.features-list {
margin-top: var(--space-2xl);
}
.features-title {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
margin-bottom: var(--space-lg);
}
.features {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: var(--space-md);
}
.feature-item {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md);
background: var(--bg-secondary);
border-radius: var(--border-radius-lg);
border: var(--border-width) solid var(--border-color);
font-size: var(--font-size-base);
transition: all var(--transition-fast);
}
.feature-item:hover {
transform: translateX(4px);
border-color: var(--color-primary);
}
.feature-icon {
width: 20px;
height: 20px;
color: var(--color-success);
flex-shrink: 0;
}
/* Technologies */
.tech-grid {
display: flex;
flex-wrap: wrap;
gap: var(--space-md);
}
.tech-item {
animation: fadeInUp 0.6s ease-out;
}
/* Gallery */
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--space-xl);
}
.gallery-item {
border-radius: var(--border-radius-xl);
overflow: hidden;
box-shadow: var(--shadow-lg);
transition: all var(--transition-fast);
background: var(--bg-secondary);
}
.gallery-item:hover {
transform: translateY(-8px);
box-shadow: var(--shadow-xl);
}
.gallery-image {
width: 100%;
height: 200px;
object-fit: cover;
}
/* Sidebar */
.sidebar {
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
/* Info Card */
.info-card {
background: var(--bg-secondary);
border-radius: var(--border-radius-xl);
padding: var(--space-xl);
border: var(--border-width) solid var(--border-color);
box-shadow: var(--shadow-sm);
}
.info-title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
margin-bottom: var(--space-lg);
}
.info-list {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-md) 0;
border-bottom: var(--border-width) solid var(--border-color);
}
.info-item:last-child {
border-bottom: none;
}
.info-label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--text-secondary);
}
.info-value {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
/* Related Projects */
.related-projects {
background: var(--bg-secondary);
border-radius: var(--border-radius-xl);
padding: var(--space-xl);
border: var(--border-width) solid var(--border-color);
box-shadow: var(--shadow-sm);
}
.related-title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
margin-bottom: var(--space-lg);
}
.related-list {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.related-item {
display: flex;
gap: var(--space-md);
padding: var(--space-md);
border-radius: var(--border-radius-lg);
transition: all var(--transition-fast);
text-decoration: none;
color: inherit;
border: var(--border-width) solid transparent;
}
.related-item:hover {
background: var(--bg-primary);
border-color: var(--border-color);
transform: translateX(4px);
}
.related-image {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: var(--border-radius-md);
flex-shrink: 0;
}
.related-content {
flex: 1;
min-width: 0;
}
.related-project-title {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
margin-bottom: var(--space-xs);
line-height: var(--line-height-tight);
}
.related-project-description {
font-size: var(--font-size-xs);
color: var(--text-secondary);
line-height: var(--line-height-relaxed);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Responsive Design */
@media (max-width: 1200px) {
.hero-grid {
grid-template-columns: 350px 1fr;
gap: var(--space-2xl);
}
.content-grid {
grid-template-columns: 1fr 280px;
gap: var(--space-3xl);
}
}
@media (max-width: 1024px) {
.hero-grid {
grid-template-columns: 1fr;
gap: var(--space-2xl);
}
.project-image-container {
max-width: 500px;
margin: 0 auto;
}
.content-grid {
grid-template-columns: 1fr;
gap: var(--space-2xl);
}
.sidebar {
order: -1;
}
}
@media (max-width: 768px) {
.project-hero {
padding: var(--space-xl) 0 var(--space-2xl);
}
.hero-grid {
gap: var(--space-xl);
}
.project-image {
height: 250px;
}
.project-info {
min-height: auto;
}
.project-actions {
flex-direction: column;
align-items: stretch;
}
.project-actions .btn,
.project-actions .btn-outline {
justify-content: center;
}
.gallery-grid {
grid-template-columns: 1fr;
}
.related-item {
flex-direction: column;
text-align: center;
}
.related-image {
width: 100%;
height: 120px;
}
}
@media (max-width: 480px) {
.project-meta {
flex-direction: column;
gap: var(--space-sm);
}
.project-category,
.project-date {
display: inline-block;
width: fit-content;
}
.features {
gap: var(--space-sm);
}
.feature-item {
padding: var(--space-sm);
}
}
/* Animations */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

454
src/views/ProjectsPage.vue Normal file
View File

@@ -0,0 +1,454 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useSeo } from '@/composables/useSeo'
import { useI18n } from '@/composables/useI18n'
import { projects } from '@/data/projects'
import ProjectCard from '@/components/ProjectCard.vue'
const { t } = useI18n()
// SEO
useSeo({
title: t('seo.projects.title'),
description: t('seo.projects.description')
})
// Filters and search
const searchQuery = ref('')
const selectedCategory = ref('all')
const sortBy = ref('date')
// Get unique categories
const categories = computed(() => {
const cats = ['all', ...new Set(projects.map(p => p.category).filter(Boolean))]
return cats
})
// Filtered and sorted projects
const filteredProjects = computed(() => {
let filtered = projects
// Filter by search query
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(project =>
project.title.toLowerCase().includes(query) ||
project.description.toLowerCase().includes(query) ||
project.technologies?.some(tech => tech.toLowerCase().includes(query))
)
}
// Filter by category
if (selectedCategory.value !== 'all') {
filtered = filtered.filter(project => project.category === selectedCategory.value)
}
// Sort projects
if (sortBy.value === 'date') {
filtered = filtered.sort((a, b) => {
const dateA = parseInt(a.date || '0')
const dateB = parseInt(b.date || '0')
return dateB - dateA // Most recent first
})
} else if (sortBy.value === 'name') {
filtered = filtered.sort((a, b) => a.title.localeCompare(b.title))
}
return filtered
})
// Stats
const totalProjects = computed(() => projects.length)
const featuredProjects = computed(() => projects.filter(p => p.featured).length)
</script>
<template>
<main class="projects-page">
<!-- Hero Section -->
<section class="projects-hero">
<div class="container">
<div class="hero-content text-center">
<h1 class="hero-title">{{ t('projects.title') }}</h1>
<p class="hero-subtitle">
{{ t('projects.subtitle') }}
</p>
<!-- Stats -->
<div class="stats-grid">
<div class="stat-item">
<div class="stat-number">{{ totalProjects }}</div>
<div class="stat-label">{{ t('nav.projects') }}</div>
</div>
<div class="stat-item">
<div class="stat-number">{{ featuredProjects }}</div>
<div class="stat-label">{{ t('home.featuredProjects.title') }}</div>
</div>
<div class="stat-item">
<div class="stat-number">{{ categories.length - 1 }}</div>
<div class="stat-label">{{ t('projects.categories.all') }}</div>
</div>
</div>
</div>
</div>
</section>
<!-- Filters Section -->
<section class="filters-section">
<div class="container">
<div class="filters-container">
<!-- Search -->
<div class="search-container">
<div class="search-input-wrapper">
<svg class="search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<input v-model="searchQuery" type="text" :placeholder="t('common.search') + '...'" class="search-input">
</div>
</div>
<!-- Category Filter -->
<div class="filter-group">
<label class="filter-label">{{ t('common.filter') }}</label>
<select v-model="selectedCategory" class="filter-select">
<option value="all">{{ t('projects.categories.all') }}</option>
<option v-for="category in categories.slice(1)" :key="category" :value="category">
{{ t(`projects.categories.${category?.replace(/\s+/g, '').toLowerCase()}`) || category }}
</option>
</select>
</div>
<!-- Sort -->
<div class="filter-group">
<label class="filter-label">{{ t('common.sort') }}</label>
<select v-model="sortBy" class="filter-select">
<option value="date">Date</option>
<option value="name">{{ t('nav.projects') }}</option>
</select>
</div>
</div>
</div>
</section>
<!-- Projects Grid -->
<section class="projects-grid-section">
<div class="container">
<!-- Results Info -->
<div class="results-info">
<p class="results-text">
{{ filteredProjects.length }} {{ t('nav.projects').toLowerCase() }}{{ filteredProjects.length > 1 ? 's' : ''
}} {{ t('common.search').toLowerCase() }}{{ filteredProjects.length > 1 ? 's' : '' }}
</p>
</div>
<!-- Projects Grid -->
<div v-if="filteredProjects.length > 0" class="projects-grid">
<ProjectCard v-for="project in filteredProjects" :key="project.id" :project="project"
class="project-card-item" />
</div>
<!-- No Results -->
<div v-else class="no-results">
<div class="no-results-content">
<svg class="no-results-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6-4h6m2 5.291A7.962 7.962 0 0112 15c-2.34 0-4.291-1.1-5.5-2.709">
</path>
</svg>
<h3 class="no-results-title">{{ t('projects.noResults.title') }}</h3>
<p class="no-results-description">
{{ t('projects.noResults.description') }}
</p>
<button @click="searchQuery = ''; selectedCategory = 'all'" class="btn btn-primary">
{{ t('common.reset') }}
</button>
</div>
</div>
</div>
</section>
</main>
</template>
<style scoped>
/* Projects Page Styles */
.projects-page {
min-height: 100vh;
background: var(--bg-primary);
}
/* Hero Section */
.projects-hero {
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
padding: var(--space-4xl) 0 var(--space-3xl);
position: relative;
overflow: hidden;
}
.projects-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-content {
position: relative;
z-index: 1;
}
.hero-title {
font-size: clamp(var(--font-size-4xl), 4vw, var(--font-size-5xl));
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-lg);
color: var(--text-secondary);
margin-bottom: var(--space-2xl);
max-width: 600px;
margin-left: auto;
margin-right: auto;
line-height: var(--line-height-relaxed);
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-xl);
max-width: 400px;
margin: 0 auto;
}
.stat-item {
text-align: center;
}
.stat-number {
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-bold);
color: var(--color-primary);
margin-bottom: var(--space-xs);
}
.stat-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
font-weight: var(--font-weight-medium);
}
/* Filters Section */
.filters-section {
background: var(--bg-primary);
padding: var(--space-xl) 0;
border-bottom: var(--border-width) solid var(--border-color);
position: sticky;
top: 80px;
z-index: 10;
}
.filters-container {
display: flex;
flex-wrap: wrap;
gap: var(--space-lg);
align-items: end;
}
.search-container {
flex: 1;
min-width: 300px;
}
.search-input-wrapper {
position: relative;
}
.search-icon {
position: absolute;
left: var(--space-md);
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
color: var(--text-tertiary);
pointer-events: none;
}
.search-input {
width: 100%;
padding: var(--space-md) var(--space-md) var(--space-md) 48px;
font-size: var(--font-size-base);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius-lg);
background: var(--bg-primary);
color: var(--text-primary);
transition: all var(--transition-fast);
}
.search-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgb(37 99 235 / 0.1);
}
.filter-group {
display: flex;
flex-direction: column;
gap: var(--space-sm);
min-width: 150px;
}
.filter-label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--text-primary);
}
.filter-select {
padding: var(--space-md);
font-size: var(--font-size-base);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius-lg);
background: var(--bg-primary);
color: var(--text-primary);
transition: all var(--transition-fast);
cursor: pointer;
}
.filter-select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgb(37 99 235 / 0.1);
}
/* Projects Grid Section */
.projects-grid-section {
padding: var(--space-2xl) 0;
}
.results-info {
margin-bottom: var(--space-xl);
}
.results-text {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: var(--space-xl);
}
.project-card-item {
animation: fadeInUp 0.6s ease-out;
}
/* No Results */
.no-results {
display: flex;
justify-content: center;
padding: var(--space-4xl) 0;
}
.no-results-content {
text-align: center;
max-width: 400px;
}
.no-results-icon {
width: 64px;
height: 64px;
color: var(--text-tertiary);
margin: 0 auto var(--space-lg);
}
.no-results-title {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
margin-bottom: var(--space-md);
}
.no-results-description {
color: var(--text-secondary);
margin-bottom: var(--space-xl);
line-height: var(--line-height-relaxed);
}
/* Responsive Design */
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(3, 1fr);
gap: var(--space-md);
}
.stat-number {
font-size: var(--font-size-2xl);
}
.filters-container {
flex-direction: column;
align-items: stretch;
}
.search-container {
min-width: auto;
}
.filter-group {
min-width: auto;
}
.projects-grid {
grid-template-columns: 1fr;
gap: var(--space-lg);
}
.filters-section {
position: static;
}
}
@media (max-width: 480px) {
.hero-subtitle {
font-size: var(--font-size-base);
}
.stats-grid {
gap: var(--space-sm);
}
.stat-number {
font-size: var(--font-size-xl);
}
}
/* Animations */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Utility Classes */
.text-center {
text-align: center;
}
</style>