Portfolio.vue 4.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
  1. <template>
  2. <div class="min-h-screen bg-background">
  3. <Header />
  4. <main class="container mx-auto px-4 pt-32 pb-24">
  5. <div class="text-center mb-16">
  6. <div v-motion :initial="{ opacity: 0, scale: 0.9 }" :enter="{ opacity: 1, scale: 1 }"
  7. class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-primary/10 border border-primary/20 text-primary mb-6">
  8. <Sparkles class="w-4 h-4" />
  9. <span class="text-xs font-display font-medium tracking-wider uppercase">{{ t("nav.portfolio") }}</span>
  10. </div>
  11. <h1 class="font-display text-4xl sm:text-5xl lg:text-6xl font-bold mb-6">
  12. {{ t("portfolio.title") }} <span class="text-gradient">{{ t("portfolio.titleGradient") }}</span>
  13. </h1>
  14. <p class="text-muted-foreground max-w-2xl mx-auto text-lg leading-relaxed">
  15. {{ t("portfolio.description") }}
  16. </p>
  17. </div>
  18. <div v-if="isLoading" class="flex flex-col items-center justify-center py-24 gap-4 opacity-50">
  19. <RefreshCw class="w-8 h-8 text-primary animate-spin mb-4" />
  20. <p class="text-sm font-medium animate-pulse">{{ t("portfolio.loading") }}</p>
  21. </div>
  22. <div v-else-if="items.length > 0" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
  23. <div
  24. v-for="(item, idx) in items"
  25. :key="item.id"
  26. v-motion
  27. :initial="{ opacity: 0, y: 20 }"
  28. :enter="{ opacity: 1, y: 0, transition: { delay: idx * 50 } }"
  29. class="group relative aspect-square overflow-hidden rounded-3xl border border-border/50 bg-card/40 backdrop-blur-sm cursor-pointer hover:border-primary/50 transition-all duration-500"
  30. @click="selectedImage = `${API_BASE_URL}${item.file_path.startsWith('/') ? '' : '/'}${item.file_path}`"
  31. >
  32. <img :src="`${API_BASE_URL}${item.file_path.startsWith('/') ? '' : '/'}${item.file_path}`" :alt="item.material_name"
  33. class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110" />
  34. <div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-all duration-500 flex flex-col justify-end p-6">
  35. <span class="text-[10px] font-bold uppercase tracking-widest text-primary mb-1">{{ item.material_name }}</span>
  36. <div class="flex items-center justify-end">
  37. <ExternalLink class="w-4 h-4 text-white/70" />
  38. </div>
  39. </div>
  40. </div>
  41. </div>
  42. <div v-else class="text-center py-32 bg-card/20 border border-dashed border-border/50 rounded-[40px]">
  43. <div class="w-20 h-20 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-6">
  44. <ImageIcon class="w-10 h-10 text-primary opacity-60" />
  45. </div>
  46. <h3 class="text-xl font-bold mb-2">{{ t("portfolio.emptyTitle") }}</h3>
  47. <p class="text-muted-foreground">{{ t("portfolio.emptyDesc") }}</p>
  48. </div>
  49. </main>
  50. <!-- Lightbox -->
  51. <Teleport to="body">
  52. <Transition enter-active-class="transition duration-200" enter-from-class="opacity-0" enter-to-class="opacity-100"
  53. leave-active-class="transition duration-150" leave-from-class="opacity-100" leave-to-class="opacity-0">
  54. <div v-if="selectedImage"
  55. class="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-background/95 backdrop-blur-md"
  56. @click="selectedImage = null">
  57. <div
  58. v-motion :initial="{ scale: 0.9, opacity: 0 }" :enter="{ scale: 1, opacity: 1 }"
  59. class="relative max-w-5xl w-full max-h-[90vh] overflow-hidden rounded-2xl shadow-glow"
  60. @click.stop>
  61. <img :src="selectedImage" class="w-full h-full object-contain" />
  62. <button @click="selectedImage = null"
  63. class="absolute top-4 right-4 w-10 h-10 bg-black/50 hover:bg-black/80 text-white rounded-full flex items-center justify-center transition-colors">
  64. </button>
  65. </div>
  66. </div>
  67. </Transition>
  68. </Teleport>
  69. <Footer />
  70. </div>
  71. </template>
  72. <script setup lang="ts">
  73. import { ref, onMounted } from "vue";
  74. import { useI18n } from "vue-i18n";
  75. import { Sparkles, Loader2, Camera, ExternalLink, RefreshCw, Image as ImageIcon } from "lucide-vue-next";
  76. import Header from "@/components/Header.vue";
  77. import Footer from "@/components/Footer.vue";
  78. import { getPortfolio, API_BASE_URL } from "@/lib/api";
  79. const { t } = useI18n();
  80. const items = ref<any[]>([]);
  81. const isLoading = ref(true);
  82. const selectedImage = ref<string | null>(null);
  83. onMounted(async () => {
  84. try { items.value = await getPortfolio(); }
  85. catch (e) { console.error("Failed to load portfolio:", e); }
  86. finally { isLoading.value = false; }
  87. });
  88. </script>