feat: 新增作品和动态详情页

 Work 详情页 (/work/[slug]):
- Hero 大图 + 渐变遮罩
- 项目概述、挑战、解决方案
- 项目截图画廊
- 右侧信息卡片(客户、年份、周期、服务)
- 访问项目按钮
- 相关项目推荐
- 返回列表按钮
- 完整中英文内容

 News 详情页 (/news/[slug]):
- Hero 大图 + 渐变遮罩
- 分类标签 + 日期 + 阅读时间 + 作者
- 文章正文(多段落 + 引用 + 图片)
- 分享按钮(Twitter、LinkedIn、复制链接)
- 相关文章推荐
- 返回列表按钮
- 完整中英文内容

🔧 列表页更新:
- work.vue → work/index.vue
- news.vue → news/index.vue
- 卡片链接改为 NuxtLink 到详情页

🌍 国际化:
- 6 个作品详情(中文 + 英文)
- 6 篇文章详情(中文 + 英文)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
陈春
2026-06-03 20:35:02 +08:00
parent 05f2e56ff3
commit 3d68f56a94
6 changed files with 811 additions and 163 deletions
+236
View File
@@ -0,0 +1,236 @@
<template>
<main class="min-h-screen">
<!-- 返回按钮 -->
<div class="fixed top-28 left-4 md:left-8 z-30">
<NuxtLink
to="/news"
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-full bg-white/90 backdrop-blur-sm shadow-lg hover:shadow-xl transition-all duration-300 text-sm font-semibold text-black hover:-translate-x-1"
>
<Icon name="ph:arrow-left" class="w-5 h-5" />
{{ t('news.backToList') }}
</NuxtLink>
</div>
<!-- 文章 Hero -->
<section class="relative h-[50vh] md:h-[60vh] overflow-hidden">
<img
:src="article.image"
:alt="article.title"
class="w-full h-full object-cover"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent"></div>
<div class="absolute bottom-0 left-0 right-0 p-8 md:p-16">
<div class="max-w-[800px] mx-auto">
<span
class="inline-block px-3 py-1.5 text-xs md:text-sm font-semibold rounded-full mb-4"
:class="article.badge"
>
{{ article.category }}
</span>
<h1 class="text-3xl md:text-5xl lg:text-6xl font-bold text-white mb-4 tracking-tight">
{{ article.title }}
</h1>
<div class="flex items-center gap-4 text-white/80 text-sm md:text-base">
<span class="flex items-center gap-1.5">
<Icon name="ph:calendar-blank" class="w-4 h-4" />
{{ article.date }}
</span>
<span class="flex items-center gap-1.5">
<Icon name="ph:clock" class="w-4 h-4" />
{{ article.readTime }}
</span>
<span class="flex items-center gap-1.5">
<Icon name="ph:user" class="w-4 h-4" />
{{ article.author }}
</span>
</div>
</div>
</div>
</section>
<!-- 文章内容 -->
<section class="max-w-[800px] mx-auto px-6 md:px-10 py-16 md:py-24">
<article class="prose prose-lg max-w-none">
<p class="text-gray-700 text-lg leading-relaxed mb-8">
{{ t(`news.detail.items.${slug}.content`) }}
</p>
<h2 class="text-2xl md:text-3xl font-bold text-black mb-6 mt-12">
{{ t(`news.detail.items.${slug}.section1Title`) }}
</h2>
<p class="text-gray-700 text-lg leading-relaxed mb-8">
{{ t(`news.detail.items.${slug}.section1Content`) }}
</p>
<!-- 引用 -->
<blockquote class="border-l-4 border-black pl-6 my-10 italic text-gray-600 text-lg">
"{{ t(`news.detail.items.${slug}.quote`) }}"
</blockquote>
<h2 class="text-2xl md:text-3xl font-bold text-black mb-6 mt-12">
{{ t(`news.detail.items.${slug}.section2Title`) }}
</h2>
<p class="text-gray-700 text-lg leading-relaxed mb-8">
{{ t(`news.detail.items.${slug}.section2Content`) }}
</p>
<!-- 文章内图片 -->
<div class="my-10 rounded-2xl overflow-hidden">
<img :src="article.image" :alt="article.title" class="w-full h-auto" />
</div>
<h2 class="text-2xl md:text-3xl font-bold text-black mb-6 mt-12">
{{ t(`news.detail.items.${slug}.section3Title`) }}
</h2>
<p class="text-gray-700 text-lg leading-relaxed mb-8">
{{ t(`news.detail.items.${slug}.section3Content`) }}
</p>
</article>
<!-- 分享按钮 -->
<div class="mt-16 pt-8 border-t border-gray-200">
<div class="flex items-center justify-between">
<p class="text-sm text-gray-500">{{ t('news.detail.shareArticle') }}</p>
<div class="flex gap-3">
<button class="w-10 h-10 rounded-full bg-gray-100 hover:bg-black hover:text-white flex items-center justify-center transition-all duration-300">
<Icon name="ph:x-logo" class="w-5 h-5" />
</button>
<button class="w-10 h-10 rounded-full bg-gray-100 hover:bg-black hover:text-white flex items-center justify-center transition-all duration-300">
<Icon name="ph:linkedin-logo" class="w-5 h-5" />
</button>
<button class="w-10 h-10 rounded-full bg-gray-100 hover:bg-black hover:text-white flex items-center justify-center transition-all duration-300">
<Icon name="ph:link" class="w-5 h-5" />
</button>
</div>
</div>
</div>
</section>
<!-- 相关文章 -->
<section class="bg-gray-50 py-16 md:py-24">
<div class="max-w-[1200px] mx-auto px-6 md:px-10">
<h2 class="text-2xl md:text-3xl font-bold text-black mb-10 text-center">{{ t('news.detail.relatedArticles') }}</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<NuxtLink
v-for="related in relatedArticles"
:key="related.id"
:to="`/news/${related.id}`"
class="group block rounded-2xl overflow-hidden bg-white shadow-lg hover:shadow-xl transition-all duration-300 hover:-translate-y-1"
>
<div class="relative aspect-[16/10] overflow-hidden">
<img :src="related.image" :alt="related.title" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" />
</div>
<div class="p-5">
<span class="text-xs text-gray-500">{{ related.date }}</span>
<h3 class="text-lg font-bold text-black group-hover:text-gray-600 transition-colors mt-1 line-clamp-2">
{{ related.title }}
</h3>
</div>
</NuxtLink>
</div>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useSeoMeta, createError } from '#imports'
const { t } = useI18n()
const route = useRoute()
const slug = route.params.slug as string
interface NewsItem {
id: string
title: string
image: string
category: string
badge: string
date: string
readTime: string
author: string
}
const newsData: Record<string, NewsItem> = {
'design-trends-2026': {
id: 'design-trends-2026',
title: 'Design Trends Shaping 2026',
image: 'https://picsum.photos/id/1/1200/800',
category: 'Insights',
badge: 'bg-purple-100 text-purple-700',
date: 'May 28, 2026',
readTime: '5 min',
author: 'ArcticNewOne Team'
},
'forma-digital-launch': {
id: 'forma-digital-launch',
title: 'Forma Digital Platform Launch',
image: 'https://picsum.photos/id/201/1200/800',
category: 'Projects',
badge: 'bg-blue-100 text-blue-700',
date: 'May 15, 2026',
readTime: '3 min',
author: 'ArcticNewOne Team'
},
'studio-expansion': {
id: 'studio-expansion',
title: 'Studio Expansion Announcement',
image: 'https://picsum.photos/id/164/1200/800',
category: 'Announcements',
badge: 'bg-green-100 text-green-700',
date: 'May 10, 2026',
readTime: '2 min',
author: 'ArcticNewOne Team'
},
'ux-research-methods': {
id: 'ux-research-methods',
title: 'Modern UX Research Methods',
image: 'https://picsum.photos/id/180/1200/800',
category: 'Insights',
badge: 'bg-purple-100 text-purple-700',
date: 'Apr 28, 2026',
readTime: '7 min',
author: 'ArcticNewOne Team'
},
'bold-moves-case-study': {
id: 'bold-moves-case-study',
title: 'Bold Moves: A Case Study',
image: 'https://picsum.photos/id/119/1200/800',
category: 'Projects',
badge: 'bg-blue-100 text-blue-700',
date: 'Apr 20, 2026',
readTime: '6 min',
author: 'ArcticNewOne Team'
},
'new-tools-integration': {
id: 'new-tools-integration',
title: 'New Tools Added to Our Platform',
image: 'https://picsum.photos/id/326/1200/800',
category: 'Announcements',
badge: 'bg-green-100 text-green-700',
date: 'Apr 12, 2026',
readTime: '4 min',
author: 'ArcticNewOne Team'
}
}
const article = newsData[slug]
if (!article) {
throw createError({ statusCode: 404, statusMessage: 'Article not found' })
}
const relatedArticles = computed(() => {
return Object.values(newsData)
.filter(a => a.id !== slug)
.slice(0, 3)
})
useSeoMeta({
title: () => `${article.title} - ${t('news.title')}`,
description: () => article.title,
})
</script>
+20 -83
View File
@@ -3,7 +3,6 @@
<div
ref="headerWrapRef"
class="flex flex-col items-center justify-center text-center mb-10 md:mb-16"
style="opacity: 0; visibility: hidden;"
>
<h1 ref="titleRef" class="text-3xl md:text-5xl font-bold mb-4 md:mb-6 tracking-tight text-black">{{ t('news.title') }}</h1>
<p ref="descRef" class="text-base md:text-lg text-gray-600 max-w-2xl">{{ t('news.description') }}</p>
@@ -12,7 +11,6 @@
<div
ref="filterWrapRef"
class="flex justify-center gap-3 md:gap-4 mb-10 md:mb-14 flex-wrap"
style="opacity: 0; visibility: hidden;"
>
<button
v-for="category in categories"
@@ -30,11 +28,11 @@
<div
ref="articlesWrapRef"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8"
style="opacity: 0; visibility: hidden;"
>
<article
<NuxtLink
v-for="article in filteredNewsData"
:key="article.id"
:to="`/news/${article.id}`"
class="news-card group relative flex flex-col rounded-2xl md:rounded-3xl overflow-hidden bg-white border border-gray-100 hover:shadow-xl transition-all duration-500 cursor-pointer hover:-translate-y-2"
>
<div class="relative aspect-[16/10] overflow-hidden bg-gray-100">
@@ -82,13 +80,13 @@
</div>
</div>
</div>
</article>
</NuxtLink>
</div>
</main>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, computed } from 'vue'
import { nextTick, onMounted, ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSeoMeta } from '#imports'
import gsap from 'gsap'
@@ -103,11 +101,9 @@ const descRef = ref<HTMLElement | null>(null)
const filterWrapRef = ref<HTMLElement | null>(null)
const articlesWrapRef = ref<HTMLElement | null>(null)
const activeCategory = ref('all')
let articlesCtx: gsap.Context | null = null
const categories = ['all', 'insights', 'projects', 'announcements']
//
const filteredNewsData = computed(() => {
if (activeCategory.value === 'all') {
return newsData
@@ -124,74 +120,24 @@ const playPageIntro = async () => {
await nextTick()
if (!headerWrapRef.value || !titleRef.value || !descRef.value || !filterWrapRef.value || !articlesWrapRef.value) return
articlesCtx?.revert()
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } })
articlesCtx = gsap.context(() => {
const filterButtons = gsap.utils.toArray<HTMLElement>('button', filterWrapRef.value!)
const newsCards = gsap.utils.toArray<HTMLElement>('.news-card', articlesWrapRef.value!)
tl.fromTo(titleRef.value, { autoAlpha: 0, y: 20 }, { autoAlpha: 1, y: 0, duration: 0.85, ease: 'power4.out', clearProps: 'transform' })
tl.fromTo(descRef.value, { autoAlpha: 0, y: 16 }, { autoAlpha: 1, y: 0, duration: 0.7, ease: 'power4.out', clearProps: 'transform' }, '-=0.45')
gsap.delayedCall(0, () => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } })
const filterButtons = gsap.utils.toArray<HTMLElement>('button', filterWrapRef.value!)
tl.fromTo(filterButtons, { autoAlpha: 0, y: 20 }, { autoAlpha: 1, y: 0, duration: 0.7, stagger: 0.08, clearProps: 'transform' }, '-=0.3')
tl.set(headerWrapRef.value!, { autoAlpha: 1 })
tl.fromTo(titleRef.value!, { autoAlpha: 0, y: 20 }, { autoAlpha: 1, y: 0, duration: 0.85, ease: 'power4.out', clearProps: 'transform' })
tl.fromTo(descRef.value!, { autoAlpha: 0, y: 16 }, { autoAlpha: 1, y: 0, duration: 0.7, ease: 'power4.out', clearProps: 'transform' }, '-=0.45')
tl.set(filterWrapRef.value!, { autoAlpha: 1 }, '-=0.2')
tl.fromTo(
filterButtons,
{ autoAlpha: 0, y: 20 },
{
autoAlpha: 1,
y: 0,
duration: 0.7,
ease: 'power4.out',
stagger: { each: 0.08, from: 'start' },
clearProps: 'transform',
},
'-=0.3'
)
tl.set(articlesWrapRef.value!, { autoAlpha: 1 }, '-=0.05')
tl.fromTo(
newsCards,
{ autoAlpha: 0, y: 34, scale: 0.965 },
{
autoAlpha: 1,
y: 0,
scale: 1,
duration: 0.95,
ease: 'power4.out',
stagger: { each: 0.11, from: 'start' },
clearProps: 'transform',
},
'+=0.08'
)
tl.set([headerWrapRef.value!, filterWrapRef.value!, articlesWrapRef.value!], { clearProps: 'opacity,visibility' })
})
})
})
}, headerWrapRef.value)
const newsCards = gsap.utils.toArray<HTMLElement>('.news-card', articlesWrapRef.value!)
tl.fromTo(newsCards, { autoAlpha: 0, y: 34, scale: 0.965 }, { autoAlpha: 1, y: 0, scale: 1, duration: 0.95, stagger: 0.11, clearProps: 'transform' }, '+=0.08')
}
onMounted(() => {
const handleLoadingDone = () => {
playPageIntro()
}
if (!appStore.isAppLoading) {
playPageIntro()
return
} else {
window.addEventListener('app-loading-done', playPageIntro, { once: true })
}
window.addEventListener('app-loading-done', handleLoadingDone, { once: true })
})
onBeforeUnmount(() => {
articlesCtx?.revert()
})
interface NewsItem {
@@ -203,7 +149,6 @@ interface NewsItem {
badge: string
date: string
readTime: string
url: string
}
const newsData: NewsItem[] = [
@@ -215,8 +160,7 @@ const newsData: NewsItem[] = [
category: 'Insights',
badge: 'bg-purple-100 text-purple-700',
date: 'May 28, 2026',
readTime: '5 min',
url: '#'
readTime: '5 min'
},
{
id: 'forma-digital-launch',
@@ -226,8 +170,7 @@ const newsData: NewsItem[] = [
category: 'Projects',
badge: 'bg-blue-100 text-blue-700',
date: 'May 15, 2026',
readTime: '3 min',
url: '#'
readTime: '3 min'
},
{
id: 'studio-expansion',
@@ -237,8 +180,7 @@ const newsData: NewsItem[] = [
category: 'Announcements',
badge: 'bg-green-100 text-green-700',
date: 'May 10, 2026',
readTime: '2 min',
url: '#'
readTime: '2 min'
},
{
id: 'ux-research-methods',
@@ -248,8 +190,7 @@ const newsData: NewsItem[] = [
category: 'Insights',
badge: 'bg-purple-100 text-purple-700',
date: 'Apr 28, 2026',
readTime: '7 min',
url: '#'
readTime: '7 min'
},
{
id: 'bold-moves-case-study',
@@ -259,8 +200,7 @@ const newsData: NewsItem[] = [
category: 'Projects',
badge: 'bg-blue-100 text-blue-700',
date: 'Apr 20, 2026',
readTime: '6 min',
url: '#'
readTime: '6 min'
},
{
id: 'new-tools-integration',
@@ -270,14 +210,11 @@ const newsData: NewsItem[] = [
category: 'Announcements',
badge: 'bg-green-100 text-green-700',
date: 'Apr 12, 2026',
readTime: '4 min',
url: '#'
readTime: '4 min'
}
]
</script>
<style scoped>
p {
word-break: break-word;
}
p { word-break: break-word; }
</style>
+289
View File
@@ -0,0 +1,289 @@
<template>
<main class="min-h-screen">
<!-- 返回按钮 -->
<div class="fixed top-28 left-4 md:left-8 z-30">
<NuxtLink
to="/work"
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-full bg-white/90 backdrop-blur-sm shadow-lg hover:shadow-xl transition-all duration-300 text-sm font-semibold text-black hover:-translate-x-1"
>
<Icon name="ph:arrow-left" class="w-5 h-5" />
{{ t('work.backToList') }}
</NuxtLink>
</div>
<!-- Hero 大图 -->
<section class="relative h-[60vh] md:h-[70vh] overflow-hidden">
<img
:src="project.image"
:alt="project.title"
class="w-full h-full object-cover"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent"></div>
<div class="absolute bottom-0 left-0 right-0 p-8 md:p-16">
<div class="max-w-[1200px] mx-auto">
<div class="flex flex-wrap gap-2 mb-4">
<span
v-for="tag in project.tags"
:key="tag"
class="px-3 py-1 text-xs md:text-sm bg-white/20 backdrop-blur-sm text-white rounded-full"
>
{{ tag }}
</span>
</div>
<h1 class="text-4xl md:text-6xl lg:text-7xl font-bold text-white mb-4 tracking-tight">
{{ project.title }}
</h1>
<p class="text-lg md:text-xl text-white/80 max-w-2xl">
{{ project.description }}
</p>
</div>
</div>
</section>
<!-- 项目信息 -->
<section class="max-w-[1200px] mx-auto px-6 md:px-10 py-16 md:py-24">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-12 lg:gap-16">
<!-- 左侧项目详情 -->
<div class="lg:col-span-2 space-y-12">
<div>
<h2 class="text-2xl md:text-3xl font-bold text-black mb-6">{{ t('work.detail.overview') }}</h2>
<p class="text-gray-700 text-base md:text-lg leading-relaxed">
{{ t(`work.detail.items.${slug}.overview`) }}
</p>
</div>
<div>
<h2 class="text-2xl md:text-3xl font-bold text-black mb-6">{{ t('work.detail.challenge') }}</h2>
<p class="text-gray-700 text-base md:text-lg leading-relaxed">
{{ t(`work.detail.items.${slug}.challenge`) }}
</p>
</div>
<div>
<h2 class="text-2xl md:text-3xl font-bold text-black mb-6">{{ t('work.detail.solution') }}</h2>
<p class="text-gray-700 text-base md:text-lg leading-relaxed">
{{ t(`work.detail.items.${slug}.solution`) }}
</p>
</div>
<!-- 项目截图 -->
<div class="space-y-6">
<h2 class="text-2xl md:text-3xl font-bold text-black">{{ t('work.detail.gallery') }}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div
v-for="(img, index) in project.gallery"
:key="index"
class="rounded-2xl overflow-hidden shadow-lg hover:shadow-2xl transition-shadow duration-300"
>
<img :src="img" :alt="`${project.title} screenshot ${index + 1}`" class="w-full h-auto" />
</div>
</div>
</div>
</div>
<!-- 右侧项目信息卡片 -->
<div class="space-y-8">
<div class="bg-gray-50 rounded-2xl p-6 md:p-8 sticky top-32">
<h3 class="text-lg font-bold text-black mb-6">{{ t('work.detail.projectInfo') }}</h3>
<div class="space-y-5">
<div>
<p class="text-sm text-gray-500 mb-1">{{ t('work.detail.client') }}</p>
<p class="text-base font-semibold text-black">{{ t(`work.detail.items.${slug}.client`) }}</p>
</div>
<div>
<p class="text-sm text-gray-500 mb-1">{{ t('work.detail.year') }}</p>
<p class="text-base font-semibold text-black">{{ project.year }}</p>
</div>
<div>
<p class="text-sm text-gray-500 mb-1">{{ t('work.detail.duration') }}</p>
<p class="text-base font-semibold text-black">{{ t(`work.detail.items.${slug}.duration`) }}</p>
</div>
<div>
<p class="text-sm text-gray-500 mb-1">{{ t('work.detail.services') }}</p>
<div class="flex flex-wrap gap-2 mt-2">
<span
v-for="tag in project.tags"
:key="tag"
class="px-3 py-1 text-xs bg-black text-white rounded-full"
>
{{ tag }}
</span>
</div>
</div>
</div>
<!-- 访问项目按钮 -->
<a
v-if="project.url"
:href="project.url"
target="_blank"
rel="noopener noreferrer"
class="mt-8 w-full inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-full bg-black text-white font-semibold hover:bg-gray-800 transition-colors duration-300"
>
{{ t('work.detail.visitProject') }}
<Icon name="ph:arrow-up-right" class="w-5 h-5" />
</a>
</div>
</div>
</div>
</section>
<!-- 相关项目 -->
<section class="bg-gray-50 py-16 md:py-24">
<div class="max-w-[1200px] mx-auto px-6 md:px-10">
<h2 class="text-2xl md:text-3xl font-bold text-black mb-10 text-center">{{ t('work.detail.relatedProjects') }}</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<NuxtLink
v-for="related in relatedProjects"
:key="related.id"
:to="`/work/${related.id}`"
class="group block rounded-2xl overflow-hidden bg-white shadow-lg hover:shadow-xl transition-all duration-300 hover:-translate-y-1"
>
<div class="relative aspect-[4/3] overflow-hidden">
<img :src="related.image" :alt="related.title" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" />
</div>
<div class="p-5">
<h3 class="text-lg font-bold text-black group-hover:text-gray-600 transition-colors">{{ related.title }}</h3>
<p class="text-sm text-gray-500 mt-1">{{ related.tags[0] }}</p>
</div>
</NuxtLink>
</div>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useSeoMeta, createError } from '#imports'
const { t } = useI18n()
const route = useRoute()
const slug = route.params.slug as string
interface WorkItem {
id: string
title: string
description: string
image: string
tags: string[]
category: string
year: string
url?: string
gallery: string[]
}
const workData: Record<string, WorkItem> = {
'forma-digital': {
id: 'forma-digital',
title: 'Forma Digital',
description: 'A modern e-commerce platform with seamless user experience and dynamic product showcases.',
image: 'https://picsum.photos/id/201/1200/800',
tags: ['Web Design', 'E-commerce', 'React'],
category: 'webDesign',
year: '2026',
url: '#',
gallery: [
'https://picsum.photos/id/201/800/600',
'https://picsum.photos/id/202/800/600',
'https://picsum.photos/id/203/800/600',
'https://picsum.photos/id/204/800/600',
]
},
'one-step': {
id: 'one-step',
title: 'One Step',
description: 'Brand identity and marketing campaign for a fitness app focused on sustainable habits.',
image: 'https://picsum.photos/id/180/1200/800',
tags: ['Branding', 'Marketing', 'UI/UX'],
category: 'branding',
year: '2024',
gallery: [
'https://picsum.photos/id/180/800/600',
'https://picsum.photos/id/181/800/600',
'https://picsum.photos/id/182/800/600',
'https://picsum.photos/id/183/800/600',
]
},
'nero-vision': {
id: 'nero-vision',
title: 'Nero Vision',
description: 'Full-stack web application for creative professionals to manage their portfolios.',
image: 'https://picsum.photos/id/119/1200/800',
tags: ['Development', 'Vue.js', 'Node.js'],
category: 'development',
year: '2025',
gallery: [
'https://picsum.photos/id/119/800/600',
'https://picsum.photos/id/120/800/600',
'https://picsum.photos/id/121/800/600',
'https://picsum.photos/id/122/800/600',
]
},
'bold-moves': {
id: 'bold-moves',
title: 'Bold Moves',
description: 'Interactive website showcasing innovative architectural designs with 3D visualizations.',
image: 'https://picsum.photos/id/164/1200/800',
tags: ['Web Design', '3D', 'Three.js'],
category: 'webDesign',
year: '2024',
gallery: [
'https://picsum.photos/id/164/800/600',
'https://picsum.photos/id/165/800/600',
'https://picsum.photos/id/166/800/600',
'https://picsum.photos/id/167/800/600',
]
},
'echo-labs': {
id: 'echo-labs',
title: 'Echo Labs',
description: 'Corporate branding and visual identity for a tech startup in the AI space.',
image: 'https://picsum.photos/id/101/1200/800',
tags: ['Branding', 'Visual Identity', 'Corporate'],
category: 'branding',
year: '2025',
gallery: [
'https://picsum.photos/id/101/800/600',
'https://picsum.photos/id/102/800/600',
'https://picsum.photos/id/103/800/600',
'https://picsum.photos/id/104/800/600',
]
},
'flux-api': {
id: 'flux-api',
title: 'Flux API',
description: 'Scalable REST API with comprehensive documentation and developer tools.',
image: 'https://picsum.photos/id/326/1200/800',
tags: ['Development', 'API', 'Python'],
category: 'development',
year: '2025',
gallery: [
'https://picsum.photos/id/326/800/600',
'https://picsum.photos/id/327/800/600',
'https://picsum.photos/id/328/800/600',
'https://picsum.photos/id/329/800/600',
]
}
}
const project = workData[slug]
if (!project) {
throw createError({ statusCode: 404, statusMessage: 'Project not found' })
}
const relatedProjects = computed(() => {
return Object.values(workData)
.filter(p => p.id !== slug)
.slice(0, 3)
})
useSeoMeta({
title: () => `${project.title} - ${t('work.title')}`,
description: () => project.description,
})
</script>
+14 -78
View File
@@ -3,7 +3,6 @@
<div
ref="headerWrapRef"
class="flex flex-col items-center justify-center text-center mb-10 md:mb-16"
style="opacity: 0; visibility: hidden;"
>
<h1 ref="titleRef" class="text-3xl md:text-5xl font-bold mb-4 md:mb-6 tracking-tight text-black">{{ t('work.title') }}</h1>
<p ref="descRef" class="text-base md:text-lg text-gray-600 max-w-2xl">{{ t('work.description') }}</p>
@@ -12,7 +11,6 @@
<div
ref="filterWrapRef"
class="flex justify-center gap-3 md:gap-4 mb-10 md:mb-14 flex-wrap"
style="opacity: 0; visibility: hidden;"
>
<button
v-for="category in categories"
@@ -30,12 +28,11 @@
<div
ref="projectsWrapRef"
class="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8"
style="opacity: 0; visibility: hidden;"
>
<a
<NuxtLink
v-for="project in filteredWorkData"
:key="project.id"
:href="project.url"
:to="`/work/${project.id}`"
class="work-card group relative block rounded-2xl md:rounded-3xl overflow-hidden cursor-pointer hover:shadow-2xl transition-all duration-500"
>
<div class="relative aspect-[4/3] overflow-hidden bg-gray-100">
@@ -65,13 +62,13 @@
</span>
</div>
</div>
</a>
</NuxtLink>
</div>
</main>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, computed } from 'vue'
import { nextTick, onMounted, ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSeoMeta } from '#imports'
import gsap from 'gsap'
@@ -86,11 +83,9 @@ const descRef = ref<HTMLElement | null>(null)
const filterWrapRef = ref<HTMLElement | null>(null)
const projectsWrapRef = ref<HTMLElement | null>(null)
const activeCategory = ref('all')
let projectsCtx: gsap.Context | null = null
const categories = ['all', 'webDesign', 'branding', 'development']
//
const filteredWorkData = computed(() => {
if (activeCategory.value === 'all') {
return workData
@@ -107,74 +102,24 @@ const playPageIntro = async () => {
await nextTick()
if (!headerWrapRef.value || !titleRef.value || !descRef.value || !filterWrapRef.value || !projectsWrapRef.value) return
projectsCtx?.revert()
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } })
projectsCtx = gsap.context(() => {
const filterButtons = gsap.utils.toArray<HTMLElement>('button', filterWrapRef.value!)
const projectCards = gsap.utils.toArray<HTMLElement>('.work-card', projectsWrapRef.value!)
tl.fromTo(titleRef.value, { autoAlpha: 0, y: 20 }, { autoAlpha: 1, y: 0, duration: 0.85, ease: 'power4.out', clearProps: 'transform' })
tl.fromTo(descRef.value, { autoAlpha: 0, y: 16 }, { autoAlpha: 1, y: 0, duration: 0.7, ease: 'power4.out', clearProps: 'transform' }, '-=0.45')
gsap.delayedCall(0, () => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } })
const filterButtons = gsap.utils.toArray<HTMLElement>('button', filterWrapRef.value!)
tl.fromTo(filterButtons, { autoAlpha: 0, y: 20 }, { autoAlpha: 1, y: 0, duration: 0.7, stagger: 0.08, clearProps: 'transform' }, '-=0.3')
tl.set(headerWrapRef.value!, { autoAlpha: 1 })
tl.fromTo(titleRef.value!, { autoAlpha: 0, y: 20 }, { autoAlpha: 1, y: 0, duration: 0.85, ease: 'power4.out', clearProps: 'transform' })
tl.fromTo(descRef.value!, { autoAlpha: 0, y: 16 }, { autoAlpha: 1, y: 0, duration: 0.7, ease: 'power4.out', clearProps: 'transform' }, '-=0.45')
tl.set(filterWrapRef.value!, { autoAlpha: 1 }, '-=0.2')
tl.fromTo(
filterButtons,
{ autoAlpha: 0, y: 20 },
{
autoAlpha: 1,
y: 0,
duration: 0.7,
ease: 'power4.out',
stagger: { each: 0.08, from: 'start' },
clearProps: 'transform',
},
'-=0.3'
)
tl.set(projectsWrapRef.value!, { autoAlpha: 1 }, '-=0.05')
tl.fromTo(
projectCards,
{ autoAlpha: 0, y: 34, scale: 0.965 },
{
autoAlpha: 1,
y: 0,
scale: 1,
duration: 0.95,
ease: 'power4.out',
stagger: { each: 0.13, from: 'start' },
clearProps: 'transform',
},
'+=0.08'
)
tl.set([headerWrapRef.value!, filterWrapRef.value!, projectsWrapRef.value!], { clearProps: 'opacity,visibility' })
})
})
})
}, headerWrapRef.value)
const projectCards = gsap.utils.toArray<HTMLElement>('.work-card', projectsWrapRef.value!)
tl.fromTo(projectCards, { autoAlpha: 0, y: 34, scale: 0.965 }, { autoAlpha: 1, y: 0, scale: 1, duration: 0.95, stagger: 0.13, clearProps: 'transform' }, '+=0.08')
}
onMounted(() => {
const handleLoadingDone = () => {
playPageIntro()
}
if (!appStore.isAppLoading) {
playPageIntro()
return
} else {
window.addEventListener('app-loading-done', playPageIntro, { once: true })
}
window.addEventListener('app-loading-done', handleLoadingDone, { once: true })
})
onBeforeUnmount(() => {
projectsCtx?.revert()
})
interface WorkItem {
@@ -182,7 +127,6 @@ interface WorkItem {
title: string
description: string
image: string
url: string
tags: string[]
category: string
}
@@ -193,7 +137,6 @@ const workData: WorkItem[] = [
title: 'Forma Digital',
description: 'A modern e-commerce platform with seamless user experience and dynamic product showcases.',
image: 'https://picsum.photos/id/201/800/600',
url: '#',
tags: ['Web Design', 'E-commerce', 'React'],
category: 'webDesign'
},
@@ -202,7 +145,6 @@ const workData: WorkItem[] = [
title: 'One Step',
description: 'Brand identity and marketing campaign for a fitness app focused on sustainable habits.',
image: 'https://picsum.photos/id/180/800/600',
url: '#',
tags: ['Branding', 'Marketing', 'UI/UX'],
category: 'branding'
},
@@ -211,7 +153,6 @@ const workData: WorkItem[] = [
title: 'Nero Vision',
description: 'Full-stack web application for creative professionals to manage their portfolios.',
image: 'https://picsum.photos/id/119/800/600',
url: '#',
tags: ['Development', 'Vue.js', 'Node.js'],
category: 'development'
},
@@ -220,7 +161,6 @@ const workData: WorkItem[] = [
title: 'Bold Moves',
description: 'Interactive website showcasing innovative architectural designs with 3D visualizations.',
image: 'https://picsum.photos/id/164/800/600',
url: '#',
tags: ['Web Design', '3D', 'Three.js'],
category: 'webDesign'
},
@@ -229,7 +169,6 @@ const workData: WorkItem[] = [
title: 'Echo Labs',
description: 'Corporate branding and visual identity for a tech startup in the AI space.',
image: 'https://picsum.photos/id/101/800/600',
url: '#',
tags: ['Branding', 'Visual Identity', 'Corporate'],
category: 'branding'
},
@@ -238,7 +177,6 @@ const workData: WorkItem[] = [
title: 'Flux API',
description: 'Scalable REST API with comprehensive documentation and developer tools.',
image: 'https://picsum.photos/id/326/800/600',
url: '#',
tags: ['Development', 'API', 'Python'],
category: 'development'
}
@@ -246,7 +184,5 @@ const workData: WorkItem[] = [
</script>
<style scoped>
p {
word-break: break-word;
}
p { word-break: break-word; }
</style>