advanced image management
This commit is contained in:
@@ -7,6 +7,12 @@ export interface Release {
|
||||
UpdatedAt: string
|
||||
}
|
||||
|
||||
export interface SoftwareImage {
|
||||
url: string
|
||||
isDefault: boolean
|
||||
position: number
|
||||
}
|
||||
|
||||
export interface Software {
|
||||
name: string
|
||||
title: string
|
||||
@@ -18,6 +24,7 @@ export interface Software {
|
||||
story?: string
|
||||
externalLinks?: { label: string; url: string }[]
|
||||
imageUrl?: string | null
|
||||
images?: SoftwareImage[]
|
||||
}
|
||||
|
||||
export interface SoftwareEntry {
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
<div v-show="activeFilter === 'all' || software.status === activeFilter" class="software-card group">
|
||||
<div class="software-card-content">
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div v-if="software.imageUrl" class="flex-shrink-0">
|
||||
<img :src="software.imageUrl" :alt="software.title" class="software-thumb" />
|
||||
<div v-if="getDefaultImageUrl(software)" class="flex-shrink-0">
|
||||
<img :src="getDefaultImageUrl(software)!" :alt="software.title" class="software-thumb" />
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
@@ -66,6 +66,7 @@ import { storeToRefs } from 'pinia'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSoftwareStore } from '../../stores/software.store'
|
||||
import type { Software } from '../../lib/interfaces/software.interface'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -76,6 +77,14 @@ const store = useSoftwareStore()
|
||||
const { items: softwares } = storeToRefs(store)
|
||||
const { getLatestStable } = store
|
||||
|
||||
function getDefaultImageUrl(sw: Software): string | null {
|
||||
if (sw.images?.length) {
|
||||
const def = sw.images.find(i => i.isDefault)
|
||||
return def?.url ?? sw.images[0]?.url ?? null
|
||||
}
|
||||
return sw.imageUrl ?? null
|
||||
}
|
||||
|
||||
onMounted(() => store.fetchAll())
|
||||
</script>
|
||||
|
||||
|
||||
@@ -27,8 +27,26 @@
|
||||
<!-- Sidebar Info -->
|
||||
<div class="lg:col-span-1 space-y-6">
|
||||
<div class="bg-white rounded-3xl shadow-xl p-8 border border-gray-100">
|
||||
<div v-if="software.imageUrl" class="mb-6 flex justify-center">
|
||||
<img :src="software.imageUrl" :alt="software.title" class="max-h-48 w-auto object-contain border-4 border-gray-100 shadow-sm" />
|
||||
<div v-if="defaultImageUrl" class="mb-6">
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
:src="defaultImageUrl"
|
||||
:alt="software.title"
|
||||
:class="['max-h-48 w-auto object-contain border-4 border-gray-100 shadow-sm', softwareImages.length > 0 ? 'cursor-pointer hover:opacity-80 transition-opacity' : '']"
|
||||
@click="openLightbox(defaultImageIndex)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="softwareImages.length > 1" class="flex gap-2 mt-3 justify-center">
|
||||
<img
|
||||
v-for="(img, idx) in softwareImages"
|
||||
:key="idx"
|
||||
:src="img.url"
|
||||
:alt="`${software.title} ${idx + 1}`"
|
||||
:class="['w-12 h-12 object-cover rounded border-2 cursor-pointer transition-all',
|
||||
img.isDefault ? 'border-purple-400' : 'border-gray-200 hover:border-purple-300']"
|
||||
@click="openLightbox(idx)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-gray-900 mb-6 flex items-center gap-2">
|
||||
<span class="text-purple-600">ℹ</span> {{ t('catalogShow.projectInfo') }}
|
||||
@@ -158,6 +176,13 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<ImageLightbox
|
||||
:images="softwareImages"
|
||||
:is-open="lightboxOpen"
|
||||
:start-index="lightboxStartIndex"
|
||||
@close="lightboxOpen = false"
|
||||
/>
|
||||
|
||||
<main v-else class="main-container -mt-10">
|
||||
<div class="bg-white rounded-3xl shadow-xl p-12 text-center text-gray-400">
|
||||
{{ error || t('catalogShow.loading') }}
|
||||
@@ -172,7 +197,8 @@ import { RouterLink, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatDateTime } from '../../lib/dateFormat'
|
||||
import { useSoftwareStore } from '../../stores/software.store'
|
||||
import type { Release, Software } from '../../lib/interfaces/software.interface'
|
||||
import type { Release, Software, SoftwareImage } from '../../lib/interfaces/software.interface'
|
||||
import ImageLightbox from './ImageLightbox.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
@@ -181,6 +207,33 @@ const store = useSoftwareStore()
|
||||
const software = ref<Software | null>(null)
|
||||
const releases = ref<Release[]>([])
|
||||
const error = ref<string | null>(null)
|
||||
const lightboxOpen = ref(false)
|
||||
const lightboxStartIndex = ref(0)
|
||||
|
||||
const softwareImages = computed<SoftwareImage[]>(() => {
|
||||
if (software.value?.images?.length) {
|
||||
return [...software.value.images].sort((a, b) => a.position - b.position)
|
||||
}
|
||||
if (software.value?.imageUrl) {
|
||||
return [{ url: software.value.imageUrl, isDefault: true, position: 0 }]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const defaultImageIndex = computed(() => {
|
||||
const idx = softwareImages.value.findIndex(i => i.isDefault)
|
||||
return idx >= 0 ? idx : 0
|
||||
})
|
||||
|
||||
const defaultImageUrl = computed(() =>
|
||||
softwareImages.value[defaultImageIndex.value]?.url ?? null
|
||||
)
|
||||
|
||||
function openLightbox(index: number) {
|
||||
if (softwareImages.value.length === 0) return
|
||||
lightboxStartIndex.value = index
|
||||
lightboxOpen.value = true
|
||||
}
|
||||
|
||||
const stableReleases = computed(() =>
|
||||
releases.value
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||||
@click.self="close"
|
||||
@keydown.escape="close"
|
||||
@keydown.left="prev"
|
||||
@keydown.right="next"
|
||||
tabindex="0"
|
||||
ref="overlay"
|
||||
>
|
||||
<button @click="close" class="absolute top-4 right-4 text-white/70 hover:text-white text-3xl font-bold z-10">×</button>
|
||||
|
||||
<button v-if="images.length > 1" @click="prev"
|
||||
class="absolute left-4 text-white/70 hover:text-white text-5xl font-bold z-10 select-none">‹</button>
|
||||
|
||||
<img
|
||||
:src="images[currentIndex].url"
|
||||
:alt="`Image ${currentIndex + 1}`"
|
||||
class="max-h-[85vh] max-w-[90vw] object-contain rounded-lg shadow-2xl"
|
||||
/>
|
||||
|
||||
<button v-if="images.length > 1" @click="next"
|
||||
class="absolute right-4 text-white/70 hover:text-white text-5xl font-bold z-10 select-none">›</button>
|
||||
|
||||
<div v-if="images.length > 1" class="absolute bottom-6 text-white/60 text-sm font-mono">
|
||||
{{ currentIndex + 1 }} / {{ images.length }}
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import type { SoftwareImage } from '../../lib/interfaces/software.interface'
|
||||
|
||||
const props = defineProps<{
|
||||
images: SoftwareImage[]
|
||||
isOpen: boolean
|
||||
startIndex?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const currentIndex = ref(0)
|
||||
const overlay = ref<HTMLElement | null>(null)
|
||||
|
||||
watch(() => props.isOpen, async (open) => {
|
||||
if (open) {
|
||||
currentIndex.value = props.startIndex ?? 0
|
||||
document.body.style.overflow = 'hidden'
|
||||
await nextTick()
|
||||
overlay.value?.focus()
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
|
||||
function close() { emit('close') }
|
||||
function next() { currentIndex.value = (currentIndex.value + 1) % props.images.length }
|
||||
function prev() { currentIndex.value = (currentIndex.value - 1 + props.images.length) % props.images.length }
|
||||
</script>
|
||||
@@ -39,8 +39,8 @@
|
||||
<!-- Featured Software Section -->
|
||||
<section v-if="highlightedSoftware" class="mb-16">
|
||||
<div class="featured-card group">
|
||||
<div v-if="highlightedSoftware.software.imageUrl" class="featured-image-panel">
|
||||
<img :src="highlightedSoftware.software.imageUrl" :alt="highlightedSoftware.software.title" class="featured-image" />
|
||||
<div v-if="highlightedImageUrl" class="featured-image-panel">
|
||||
<img :src="highlightedImageUrl" :alt="highlightedSoftware.software.title" class="featured-image" />
|
||||
</div>
|
||||
<div class="featured-content">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
@@ -220,7 +220,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -236,6 +236,16 @@ const { nextEvent, followingEvents, countdownText } = storeToRefs(eventStore)
|
||||
const softwareStore = useSoftwareStore()
|
||||
const { highlighted: highlightedSoftware, highlightedStableRelease } = storeToRefs(softwareStore)
|
||||
|
||||
const highlightedImageUrl = computed(() => {
|
||||
const sw = highlightedSoftware.value?.software
|
||||
if (!sw) return null
|
||||
if (sw.images?.length) {
|
||||
const def = sw.images.find(i => i.isDefault)
|
||||
return def?.url ?? sw.images[0]?.url ?? null
|
||||
}
|
||||
return sw.imageUrl ?? null
|
||||
})
|
||||
|
||||
const ytStore = useYoutubeStore()
|
||||
const { state: ytState, videoId: ytVideoId, title: ytTitle, publishDate: ytPublishDate, viewCount: ytViewCount, error: ytError } = storeToRefs(ytStore)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user