You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
225 lines
7.9 KiB
225 lines
7.9 KiB
<template>
|
|
<div class="project-detail">
|
|
<div class="back-button">
|
|
<NuxtLink :to="projectsPath" class="back-link">
|
|
<span>←</span> {{ TEXTS.COMMON.BACK_TO_PROJECTS }}
|
|
</NuxtLink>
|
|
</div>
|
|
<div v-if="currentProject" class="project-content">
|
|
<div class="project-header">
|
|
<h1>{{ currentProject.title }}</h1>
|
|
<div class="project-technologies">
|
|
<span v-for="tech in currentProject.technologies" :key="tech" class="tech-tag">
|
|
{{ tech }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="project-image">
|
|
<img :src="projectImage" :alt="currentProject.title" loading="lazy" @error="handleImageError" class="project-img" />
|
|
</div>
|
|
<div class="project-description">
|
|
<h2>{{ TEXTS.PROJECTS.DESCRIPTION.TITLE }}</h2>
|
|
<p>{{ currentProject.longDescription || currentProject.description }}</p>
|
|
</div>
|
|
<div class="project-features">
|
|
<h2>{{ TEXTS.PROJECTS.DESCRIPTION.FEATURES }}</h2>
|
|
<ul>
|
|
<li v-for="feature in currentProject.features" :key="feature">
|
|
{{ feature }}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<div class="project-resources">
|
|
<h2>{{ TEXTS.PROJECTS.DESCRIPTION.RESOURCES }}</h2>
|
|
<div class="resources-grid">
|
|
<a v-if="currentProject.github" :href="currentProject.github" target="_blank" rel="noopener noreferrer" class="resource-link">
|
|
<img v-if="PATHS.ICONS.GITHUB" :src="PATHS.ICONS.GITHUB" :alt="TEXTS.PROJECTS.RESOURCES.GITHUB" class="resource-icon" @error="handleIconError" loading="lazy" />
|
|
<span>{{ TEXTS.PROJECTS.RESOURCES.GITHUB }}</span>
|
|
</a>
|
|
<a v-if="currentProject.demo" :href="currentProject.demo" target="_blank" rel="noopener noreferrer" class="resource-link" >
|
|
<img v-if="PATHS.ICONS.DEMO" :src="PATHS.ICONS.DEMO" :alt="TEXTS.PROJECTS.RESOURCES.DEMO" class="resource-icon" @error="handleIconError" loading="lazy" />
|
|
<span>{{ TEXTS.PROJECTS.RESOURCES.DEMO }}</span>
|
|
</a>
|
|
<NuxtLink :to="`${projectsPath}/${currentProject.id}/docs`" class="resource-link">
|
|
<img v-if="PATHS.ICONS.DOCUMENTATION" :src="PATHS.ICONS.DOCUMENTATION" :alt="TEXTS.PROJECTS.RESOURCES.DOCUMENTATION" class="resource-icon" @error="handleIconError" loading="lazy" />
|
|
<span>{{ TEXTS.PROJECTS.RESOURCES.DOCUMENTATION }}</span>
|
|
</NuxtLink>
|
|
<a v-if="currentProject.youtube" :href="currentProject.youtube" target="_blank" rel="noopener noreferrer" class="resource-link" >
|
|
<img v-if="PATHS.ICONS.YOUTUBE" :src="PATHS.ICONS.YOUTUBE" :alt="TEXTS.PROJECTS.RESOURCES.YOUTUBE" class="resource-icon" @error="handleIconError" loading="lazy" />
|
|
<span>{{ TEXTS.PROJECTS.RESOURCES.YOUTUBE }}</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<div v-if="projectGallery && projectGallery.length > 0" class="project-gallery">
|
|
<h2>{{ TEXTS.PROJECTS.DESCRIPTION.GALLERY }}</h2>
|
|
<div class="gallery-grid">
|
|
<div v-for="(image, index) in projectGallery" :key="index" class="gallery-item" @click="openLightbox(index)">
|
|
<img :src="image" :alt="`${currentProject.title} gallery image ${index + 1}`" loading="lazy" class="gallery-image" @error="handleImageError" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="error">
|
|
<h2>{{ TEXTS.COMMON.ERROR.NOT_FOUND }}</h2>
|
|
<p>{{ TEXTS.COMMON.ERROR.PROJECT_NOT_FOUND }}</p>
|
|
<NuxtLink :to="projectsPath" class="back-link">
|
|
{{ TEXTS.COMMON.BACK_TO_PROJECTS }}
|
|
</NuxtLink>
|
|
</div>
|
|
|
|
<!-- Lightbox -->
|
|
<div v-if="showLightbox" class="lightbox" @click.self="closeLightbox">
|
|
<button class="lightbox-close" @click="closeLightbox">×</button>
|
|
<button class="lightbox-prev" @click="prevImage"><</button>
|
|
<img v-if="projectGallery[currentImageIndex]" :src="projectGallery[currentImageIndex]" :alt="`${currentProject?.title} gallery image ${currentImageIndex + 1}`" class="lightbox-image" />
|
|
<button class="lightbox-next" @click="nextImage">></button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref,computed, onMounted, onUnmounted } from 'vue';
|
|
import { navigationItems } from '~/config/navigation';
|
|
import { TEXTS } from '~/config/content';
|
|
import { PATHS , DEFAULT_IMAGES} from '~/config/paths';
|
|
import { projects } from '~/config/projects';
|
|
import type { Project } from '~/types/project';
|
|
|
|
export const handleLightboxImageError = (event: Event): void => {
|
|
const target = event.target as HTMLImageElement;
|
|
target.src = DEFAULT_IMAGES.PROJECT;
|
|
};
|
|
|
|
export const handleImageError = (event: Event): void => {
|
|
const target = event.target as HTMLImageElement;
|
|
target.src = DEFAULT_IMAGES.PROJECT;
|
|
};
|
|
|
|
export const handleIconError = (event: Event): void => {
|
|
const target = event.target as HTMLImageElement;
|
|
target.src = DEFAULT_IMAGES.ICON;
|
|
};
|
|
|
|
|
|
export const useProjectDetail = (id: number) => {
|
|
const { $theme } = useNuxtApp();
|
|
const showLightbox = ref(false);
|
|
const currentImageIndex = ref(0);
|
|
const processedImages = ref<string[]>([]);
|
|
const currentProject = ref<Project | null>(null);
|
|
|
|
currentProject.value = projects.find((project) => project.id === id) || null;
|
|
|
|
const openLightbox = (index: number): void => {
|
|
currentImageIndex.value = index;
|
|
showLightbox.value = true;
|
|
};
|
|
|
|
const closeLightbox = (): void => {
|
|
showLightbox.value = false;
|
|
};
|
|
|
|
const nextImage = (): void => {
|
|
if (!processedImages.value.length) return;
|
|
currentImageIndex.value = (currentImageIndex.value + 1) % processedImages.value.length;
|
|
};
|
|
|
|
const prevImage = (): void => {
|
|
if (!processedImages.value.length) return;
|
|
currentImageIndex.value = (currentImageIndex.value - 1 + processedImages.value.length) % processedImages.value.length;
|
|
};
|
|
|
|
const handleKeyDown = (event: KeyboardEvent): void => {
|
|
if (!showLightbox.value) return;
|
|
|
|
switch (event.key) {
|
|
case 'Escape':
|
|
closeLightbox();
|
|
break;
|
|
case 'ArrowLeft':
|
|
prevImage();
|
|
break;
|
|
case 'ArrowRight':
|
|
nextImage();
|
|
break;
|
|
}
|
|
};
|
|
|
|
onMounted(async () => {
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('keydown', handleKeyDown);
|
|
});
|
|
|
|
const getIconPath = (iconType: keyof typeof PATHS.ICONS) => {
|
|
return $theme.isDark.value
|
|
? PATHS.ICONS[iconType]?.DARK
|
|
: PATHS.ICONS[iconType]?.LIGHT;
|
|
};
|
|
|
|
const projectResources = computed(() => ({
|
|
github: currentProject.value?.github ? {
|
|
icon: getIconPath('GITHUB'),
|
|
label: TEXTS.PROJECTS.RESOURCES.GITHUB
|
|
} : null,
|
|
demo: currentProject.value?.demo ? {
|
|
icon: getIconPath('DEMO'),
|
|
label: TEXTS.PROJECTS.RESOURCES.DEMO
|
|
} : null,
|
|
documentation: currentProject.value?.documentation ? {
|
|
icon: getIconPath('DOCUMENTATION'),
|
|
label: TEXTS.PROJECTS.RESOURCES.DOCUMENTATION
|
|
} : null,
|
|
youtube: currentProject.value?.youtube ? {
|
|
icon: getIconPath('YOUTUBE'),
|
|
label: TEXTS.PROJECTS.RESOURCES.YOUTUBE
|
|
} : null
|
|
}));
|
|
|
|
return {
|
|
currentProject,
|
|
projectResources,
|
|
showLightbox,
|
|
currentImageIndex,
|
|
openLightbox,
|
|
closeLightbox,
|
|
nextImage,
|
|
prevImage,
|
|
handleImageError,
|
|
handleIconError,
|
|
handleLightboxImageError
|
|
};
|
|
};
|
|
|
|
const route = useRoute();
|
|
const projectId = Number(route.params.id);
|
|
|
|
const projectsPath = navigationItems.find((item) => item.finder === 'projects')?.path || '/projects';
|
|
|
|
const {
|
|
currentProject,
|
|
showLightbox,
|
|
currentImageIndex,
|
|
openLightbox,
|
|
closeLightbox,
|
|
nextImage,
|
|
prevImage
|
|
} = useProjectDetail(projectId);
|
|
|
|
const projectImage = computed(() => {return currentProject.value?.image || DEFAULT_IMAGES.PROJECT;});
|
|
const projectGallery = computed(() => {return currentProject.value?.gallery || [];});
|
|
|
|
</script>
|
|
|
|
<style scoped>
|
|
@import '~/assets/css/pages/project-detail.css';
|
|
|
|
.project-img {
|
|
width: 100%;
|
|
height: auto;
|
|
object-fit: cover;
|
|
}
|
|
</style>
|