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:
88
src/components/LanguageSwitcher.vue
Normal file
88
src/components/LanguageSwitcher.vue
Normal 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>
|
258
src/components/ProjectCard.vue
Normal file
258
src/components/ProjectCard.vue
Normal 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>
|
133
src/components/TechBadge.vue
Normal file
133
src/components/TechBadge.vue
Normal 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>
|
68
src/components/ThemeToggle.vue
Normal file
68
src/components/ThemeToggle.vue
Normal 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>
|
7
src/components/icons/IconCommunity.vue
Normal file
7
src/components/icons/IconCommunity.vue
Normal 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>
|
7
src/components/icons/IconDocumentation.vue
Normal file
7
src/components/icons/IconDocumentation.vue
Normal 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>
|
7
src/components/icons/IconEcosystem.vue
Normal file
7
src/components/icons/IconEcosystem.vue
Normal 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>
|
7
src/components/icons/IconSupport.vue
Normal file
7
src/components/icons/IconSupport.vue
Normal 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>
|
19
src/components/icons/IconTooling.vue
Normal file
19
src/components/icons/IconTooling.vue
Normal 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>
|
212
src/components/layout/AppFooter.vue
Normal file
212
src/components/layout/AppFooter.vue
Normal 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>
|
113
src/components/layout/AppHeader.vue
Normal file
113
src/components/layout/AppHeader.vue
Normal 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>
|
Reference in New Issue
Block a user