Show portfolio videos on site cards and detail pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-13 12:11:38 +03:30
co-authored by Cursor
parent ae4f2f721c
commit d7a130c7f5
4 changed files with 87 additions and 18 deletions
+9
View File
@@ -24,6 +24,15 @@ const { portfolioCategories } = useNoroData()
<div
class="absolute inset-0 bg-gradient-to-t from-black/50 via-black/10 to-transparent transition duration-300 group-hover:from-black/60"
/>
<span
v-if="project.video"
class="absolute left-4 top-4 inline-flex items-center gap-1.5 rounded-full bg-black/55 px-2.5 py-1 text-[11px] font-medium text-white backdrop-blur-sm"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M8 5v14l11-7z" />
</svg>
ویدیو
</span>
<span
class="absolute bottom-4 right-4 rounded-full bg-white/20 px-3 py-1 text-xs font-medium text-white backdrop-blur-sm"
>
+1
View File
@@ -17,6 +17,7 @@ export interface PortfolioProject {
year: string
featured?: boolean
image: string
video?: string
gallery: string[]
client?: string
deliverables: string[]
+67 -17
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { isVideoUrl } from '~/utils/cms'
const {
getProjectById,
getRelatedProjects,
@@ -27,6 +29,11 @@ const lightboxIndex = ref<number | null>(null)
const galleryLength = computed(() => project.value?.gallery.length ?? 0)
const lightboxItem = computed(() => {
if (lightboxIndex.value === null || !project.value) return null
return project.value.gallery[lightboxIndex.value] || null
})
function openLightbox(index: number) {
lightboxIndex.value = index
}
@@ -96,11 +103,22 @@ useHead({
<div class="grid gap-8 lg:grid-cols-2 lg:items-center">
<div>
<span
class="inline-block rounded-full bg-noro-brand/10 px-3 py-1 text-xs font-medium text-noro-brand"
>
{{ portfolioCategories[project.category].label }}
</span>
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-block rounded-full bg-noro-brand/10 px-3 py-1 text-xs font-medium text-noro-brand"
>
{{ portfolioCategories[project.category].label }}
</span>
<span
v-if="project.video"
class="inline-flex items-center gap-1 rounded-full bg-noro-text/5 px-3 py-1 text-xs font-medium text-noro-muted"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M8 5v14l11-7z" />
</svg>
ویدیو
</span>
</div>
<h1 class="mt-4 text-3xl font-bold text-noro-text sm:text-4xl lg:text-5xl">
{{ project.title }}
</h1>
@@ -120,7 +138,17 @@ useHead({
</div>
<div class="overflow-hidden rounded-2xl border border-noro-border bg-noro-surface shadow-lg">
<video
v-if="project.video"
:src="project.video"
:poster="project.image"
controls
playsinline
preload="metadata"
class="aspect-[16/9] w-full bg-black object-contain"
/>
<img
v-else
:src="project.image"
:alt="project.title"
class="aspect-[4/3] w-full object-cover"
@@ -168,24 +196,35 @@ useHead({
</div>
</section>
<section class="noro-section border-t border-noro-border bg-noro-surface/50">
<section
v-if="project.gallery.length"
class="noro-section border-t border-noro-border bg-noro-surface/50"
>
<div class="noro-container">
<SectionHeading
eyebrow="Gallery"
title="گالری تصاویر"
title="گالری رسانه"
description="نمایی نزدیک‌تر از جزئیات و خروجی‌های این پروژه."
/>
<div class="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<button
v-for="(image, index) in project.gallery"
v-for="(item, index) in project.gallery"
:key="index"
type="button"
class="group relative overflow-hidden rounded-2xl border border-noro-border bg-noro-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-noro-brand"
@click="openLightbox(index)"
>
<video
v-if="isVideoUrl(item)"
:src="item"
muted
preload="metadata"
class="pointer-events-none aspect-[4/3] w-full bg-black object-cover"
/>
<img
:src="image"
:alt="`${project.title} — تصویر ${index + 1}`"
v-else
:src="item"
:alt="`${project.title} — رسانه ${index + 1}`"
class="aspect-[4/3] w-full object-cover transition duration-500 group-hover:scale-105"
loading="lazy"
decoding="async"
@@ -196,7 +235,7 @@ useHead({
<span
class="rounded-full bg-white px-3 py-1 text-xs font-medium text-[rgb(15_20_20)] opacity-0 shadow-md transition group-hover:opacity-100"
>
بزرگ‌نمایی
{{ isVideoUrl(item) ? 'پخش ویدیو' : 'بزرگ‌نمایی' }}
</span>
</div>
</button>
@@ -258,11 +297,11 @@ useHead({
<Teleport to="body">
<div
v-if="lightboxIndex !== null"
v-if="lightboxIndex !== null && lightboxItem"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
:aria-label="`تصویر ${lightboxIndex + 1} از ${project.gallery.length}`"
:aria-label="`رسانه ${lightboxIndex + 1} از ${project.gallery.length}`"
@click.self="closeLightbox"
>
<button
@@ -280,7 +319,7 @@ useHead({
v-if="galleryLength > 1"
type="button"
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition hover:bg-white/20"
aria-label="تصویر قبلی"
aria-label="قبلی"
@click.stop="showPrevImage"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -292,7 +331,7 @@ useHead({
v-if="galleryLength > 1"
type="button"
class="absolute left-4 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition hover:bg-white/20"
aria-label="تصویر بعدی"
aria-label="بعدی"
@click.stop="showNextImage"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -307,9 +346,20 @@ useHead({
{{ lightboxIndex + 1 }} / {{ galleryLength }}
</p>
<video
v-if="isVideoUrl(lightboxItem)"
:key="lightboxItem"
:src="lightboxItem"
controls
autoplay
playsinline
class="max-h-[90vh] max-w-full rounded-xl bg-black"
@click.stop
/>
<img
:src="project.gallery[lightboxIndex]"
:alt="`${project.title} — تصویر ${lightboxIndex + 1}`"
v-else
:src="lightboxItem"
:alt="`${project.title} — رسانه ${lightboxIndex + 1}`"
class="max-h-[90vh] max-w-full rounded-xl object-contain"
@click.stop
>
+10 -1
View File
@@ -45,6 +45,7 @@ export interface CmsProject {
year?: string
featured?: boolean
imageUrl?: string
videoUrl?: string
galleryUrls?: string[]
client?: string
deliverables?: string[]
@@ -69,8 +70,15 @@ export function resolveCmsUrl(path: string | undefined, apiBase: string): string
return path.startsWith('/') ? `${base}${path}` : `${base}/${path}`
}
export function isVideoUrl(url?: string): boolean {
if (!url) return false
const clean = url.split('?')[0]?.split('#')[0] || ''
return /\.(mp4|webm|ogg|ogv|mov)$/i.test(clean)
}
export function mapProject(project: CmsProject, apiBase: string): PortfolioProject {
const image = resolveCmsUrl(project.imageUrl, apiBase)
const video = resolveCmsUrl(project.videoUrl, apiBase)
const gallery = (project.galleryUrls?.length ? project.galleryUrls : [project.imageUrl])
.filter(Boolean)
.map((url) => resolveCmsUrl(url, apiBase))
@@ -83,7 +91,8 @@ export function mapProject(project: CmsProject, apiBase: string): PortfolioProje
longDescription: project.longDescription || project.description,
year: project.year || '',
featured: project.featured,
image: image || gallery[0] || '',
image: image || gallery.find((url) => !isVideoUrl(url)) || '',
video: video || undefined,
gallery: gallery.length ? gallery : image ? [image] : [],
client: project.client,
deliverables: project.deliverables || []