abhishek-portfolio-template 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +59 -0
  2. package/bin/cli.js +54 -0
  3. package/package.json +27 -0
  4. package/template/components.json +22 -0
  5. package/template/next.config.ts +79 -0
  6. package/template/package.json +43 -0
  7. package/template/postcss.config.js +6 -0
  8. package/template/public/BoliviaSignature-ZpWnz.ttf +0 -0
  9. package/template/public/Gemini_Generated_Image_xc97toxc97toxc97.png +0 -0
  10. package/template/public/Hendrigo.otf +0 -0
  11. package/template/public/audiomass-output.mp3 +0 -0
  12. package/template/public/file.svg +1 -0
  13. package/template/public/globe.svg +1 -0
  14. package/template/public/googlec77e59474f5a09cb.html +1 -0
  15. package/template/public/icon-192x192.png +0 -0
  16. package/template/public/icon-512x512.png +0 -0
  17. package/template/public/next.svg +1 -0
  18. package/template/public/paper sound .mpeg +0 -0
  19. package/template/public/removebg.png +0 -0
  20. package/template/public/resume.pdf +0 -0
  21. package/template/public/sw.js +1 -0
  22. package/template/public/swe-worker-5c72df51bb1f6ee0.js +1 -0
  23. package/template/public/vercel.svg +1 -0
  24. package/template/public/window.svg +1 -0
  25. package/template/public/workbox-f1770938.js +1 -0
  26. package/template/src/app/about/page.tsx +91 -0
  27. package/template/src/app/actions/optimize-text.ts +54 -0
  28. package/template/src/app/gaming/page.tsx +308 -0
  29. package/template/src/app/github/[username]/page.tsx +97 -0
  30. package/template/src/app/globals.css +321 -0
  31. package/template/src/app/layout.tsx +39 -0
  32. package/template/src/app/manifest.ts +25 -0
  33. package/template/src/app/not-found.tsx +16 -0
  34. package/template/src/app/page.tsx +28 -0
  35. package/template/src/app/robots.ts +12 -0
  36. package/template/src/app/sitemap.ts +38 -0
  37. package/template/src/app/template.tsx +5 -0
  38. package/template/src/app/works/[slug]/page.tsx +50 -0
  39. package/template/src/app/works/client.tsx +44 -0
  40. package/template/src/app/works/page.tsx +24 -0
  41. package/template/src/components/about/about-client.tsx +259 -0
  42. package/template/src/components/home/bento-gallery.tsx +52 -0
  43. package/template/src/components/home/contact-section.tsx +34 -0
  44. package/template/src/components/home/craft-card.tsx +18 -0
  45. package/template/src/components/home/featured-projects.tsx +186 -0
  46. package/template/src/components/home/focus-card.tsx +171 -0
  47. package/template/src/components/home/identity-card.tsx +45 -0
  48. package/template/src/components/home/philosophy-card.tsx +104 -0
  49. package/template/src/components/home/skills-in-motion.tsx +109 -0
  50. package/template/src/components/home/tech-stack-marquee.tsx +56 -0
  51. package/template/src/components/ui/3d-folder.tsx +569 -0
  52. package/template/src/components/ui/avatar.tsx +50 -0
  53. package/template/src/components/ui/badge.tsx +36 -0
  54. package/template/src/components/ui/basic-avatar.tsx +12 -0
  55. package/template/src/components/ui/button.tsx +117 -0
  56. package/template/src/components/ui/clipboard-secret.tsx +39 -0
  57. package/template/src/components/ui/command-menu.tsx +519 -0
  58. package/template/src/components/ui/command-palette.tsx +152 -0
  59. package/template/src/components/ui/consciousness-mode.tsx +200 -0
  60. package/template/src/components/ui/copy-code-button.tsx +135 -0
  61. package/template/src/components/ui/display-cards.tsx +70 -0
  62. package/template/src/components/ui/dotted-map.tsx +128 -0
  63. package/template/src/components/ui/dropdown-menu.tsx +200 -0
  64. package/template/src/components/ui/emoji-rating.tsx +123 -0
  65. package/template/src/components/ui/exit-message.tsx +50 -0
  66. package/template/src/components/ui/image-zoom-overlay.tsx +178 -0
  67. package/template/src/components/ui/input-otp.tsx +71 -0
  68. package/template/src/components/ui/kbd.tsx +87 -0
  69. package/template/src/components/ui/location-tag.tsx +232 -0
  70. package/template/src/components/ui/minimal-testimonial.tsx +97 -0
  71. package/template/src/components/ui/mobile-menu.tsx +191 -0
  72. package/template/src/components/ui/navbar.tsx +148 -0
  73. package/template/src/components/ui/page-transition.tsx +24 -0
  74. package/template/src/components/ui/pixeleted-404-not-found.tsx +110 -0
  75. package/template/src/components/ui/preloader-wrapper.tsx +102 -0
  76. package/template/src/components/ui/preloader.tsx +104 -0
  77. package/template/src/components/ui/project-contributors.tsx +57 -0
  78. package/template/src/components/ui/scroll-area.tsx +117 -0
  79. package/template/src/components/ui/signature.tsx +173 -0
  80. package/template/src/components/ui/smooth-scroll.tsx +31 -0
  81. package/template/src/components/ui/social-icons.tsx +103 -0
  82. package/template/src/components/ui/social-stories.tsx +394 -0
  83. package/template/src/components/ui/sound-constants.ts +1 -0
  84. package/template/src/components/ui/text-explode.tsx +188 -0
  85. package/template/src/components/ui/toast.tsx +80 -0
  86. package/template/src/components/ui/tooltip.tsx +30 -0
  87. package/template/src/components/ui/user-location.tsx +151 -0
  88. package/template/src/components/ui/vertical-image-stack.tsx +345 -0
  89. package/template/src/components/works/changelog-overlay.tsx +212 -0
  90. package/template/src/components/works/currently-working-card.tsx +130 -0
  91. package/template/src/components/works/project-details-view.tsx +464 -0
  92. package/template/src/components/works/project-grid.tsx +81 -0
  93. package/template/src/fonts/BoliviaSignature-ZpWnz.ttf +0 -0
  94. package/template/src/fonts/Hendrigo.otf +0 -0
  95. package/template/src/lib/data.ts +61 -0
  96. package/template/src/lib/fonts.ts +14 -0
  97. package/template/src/lib/github.ts +15 -0
  98. package/template/src/lib/supabase.ts +11 -0
  99. package/template/src/lib/utils.ts +6 -0
  100. package/template/tailwind.config.ts +31 -0
  101. package/template/tsconfig.json +34 -0
@@ -0,0 +1,80 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, useState, useCallback } from "react";
4
+ import { AnimatePresence, motion } from "framer-motion";
5
+ import { X, CheckCircle, AlertCircle, Info } from "lucide-react";
6
+
7
+ type ToastType = "success" | "error" | "info";
8
+
9
+ interface Toast {
10
+ id: string;
11
+ message: string;
12
+ type: ToastType;
13
+ }
14
+
15
+ interface ToastContextType {
16
+ showToast: (message: string, type?: ToastType) => void;
17
+ }
18
+
19
+ const ToastContext = createContext<ToastContextType | undefined>(undefined);
20
+
21
+ export function ToastProvider({ children }: { children: React.ReactNode }) {
22
+ const [toasts, setToasts] = useState<Toast[]>([]);
23
+
24
+ const showToast = useCallback((message: string, type: ToastType = "info") => {
25
+ const id = Math.random().toString(36).substring(2, 9);
26
+ setToasts((prev) => [...prev, { id, message, type }]);
27
+
28
+ // Auto remove after 3 seconds
29
+ setTimeout(() => {
30
+ setToasts((prev) => prev.filter((t) => t.id !== id));
31
+ }, 3000);
32
+ }, []);
33
+
34
+ const removeToast = (id: string) => {
35
+ setToasts((prev) => prev.filter((t) => t.id !== id));
36
+ };
37
+
38
+ return (
39
+ <ToastContext.Provider value={{ showToast }}>
40
+ {children}
41
+ <div className="fixed bottom-6 right-6 z-[100] flex flex-col gap-3 pointer-events-none">
42
+ <AnimatePresence>
43
+ {toasts.map((toast) => (
44
+ <motion.div
45
+ key={toast.id}
46
+ initial={{ opacity: 0, y: 20, scale: 0.95 }}
47
+ animate={{ opacity: 1, y: 0, scale: 1 }}
48
+ exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.2 } }}
49
+ layout
50
+ className="pointer-events-auto min-w-[300px] bg-[#1a1a1a]/90 backdrop-blur-md border border-white/10 p-4 rounded-xl shadow-2xl flex items-start gap-3"
51
+ >
52
+ <div className="mt-0.5">
53
+ {toast.type === "success" && <CheckCircle className="w-5 h-5 text-emerald-500" />}
54
+ {toast.type === "error" && <AlertCircle className="w-5 h-5 text-rose-500" />}
55
+ {toast.type === "info" && <Info className="w-5 h-5 text-blue-500" />}
56
+ </div>
57
+ <div className="flex-1">
58
+ <p className="text-sm font-medium text-white/90 leading-snug">{toast.message}</p>
59
+ </div>
60
+ <button
61
+ onClick={() => removeToast(toast.id)}
62
+ className="text-white/40 hover:text-white transition-colors"
63
+ >
64
+ <X className="w-4 h-4" />
65
+ </button>
66
+ </motion.div>
67
+ ))}
68
+ </AnimatePresence>
69
+ </div>
70
+ </ToastContext.Provider>
71
+ );
72
+ }
73
+
74
+ export function useToast() {
75
+ const context = useContext(ToastContext);
76
+ if (!context) {
77
+ throw new Error("useToast must be used within a ToastProvider");
78
+ }
79
+ return context;
80
+ }
@@ -0,0 +1,30 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ const TooltipProvider = TooltipPrimitive.Provider
9
+
10
+ const Tooltip = TooltipPrimitive.Root
11
+
12
+ const TooltipTrigger = TooltipPrimitive.Trigger
13
+
14
+ const TooltipContent = React.forwardRef<
15
+ React.ElementRef<typeof TooltipPrimitive.Content>,
16
+ React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
17
+ >(({ className, sideOffset = 4, ...props }, ref) => (
18
+ <TooltipPrimitive.Content
19
+ ref={ref}
20
+ sideOffset={sideOffset}
21
+ className={cn(
22
+ "z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
23
+ className,
24
+ )}
25
+ {...props}
26
+ />
27
+ ))
28
+ TooltipContent.displayName = TooltipPrimitive.Content.displayName
29
+
30
+ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
@@ -0,0 +1,151 @@
1
+ "use client"
2
+
3
+ import { useState, useEffect, useRef } from "react"
4
+ import { motion, AnimatePresence } from "framer-motion"
5
+ import { supabase } from "@/lib/supabase"
6
+ import { MapPin, Clock } from "lucide-react"
7
+
8
+ interface UserLocationProps {
9
+ className?: string
10
+ }
11
+
12
+ export function UserLocation({ className = "" }: UserLocationProps) {
13
+ const [location, setLocation] = useState({ city: "Kolkata", country: "India", timezone: "Asia/Kolkata" })
14
+ const [showTime, setShowTime] = useState(false)
15
+ const [currentTime, setCurrentTime] = useState("")
16
+
17
+
18
+ useEffect(() => {
19
+ const fetchLocation = async () => {
20
+ const { data } = await supabase.from("profile").select("location_city, location_country, location_timezone").single();
21
+ if (data) {
22
+ setLocation({
23
+ city: data.location_city || "Kolkata",
24
+ country: data.location_country || "India",
25
+ timezone: data.location_timezone || "Asia/Kolkata"
26
+ });
27
+ }
28
+ };
29
+ fetchLocation();
30
+ }, []);
31
+
32
+ useEffect(() => {
33
+ const updateTime = () => {
34
+ try {
35
+ const now = new Date()
36
+ // Format: "10:30 PM IST"
37
+ const time = now.toLocaleTimeString("en-US", {
38
+ hour: "numeric",
39
+ minute: "2-digit",
40
+ hour12: true,
41
+ timeZone: location.timezone,
42
+ });
43
+
44
+ // Explicitly use "IST" for Indian time for compactness
45
+ const suffix = location.timezone.includes("Kolkata") ? " IST" : "";
46
+ setCurrentTime(`${time}${suffix}`);
47
+ } catch (e) {
48
+ setCurrentTime("00:00 AM")
49
+ }
50
+ }
51
+ updateTime()
52
+ const interval = setInterval(updateTime, 1000)
53
+ return () => clearInterval(interval)
54
+ }, [location.timezone])
55
+
56
+ const timerRef = useRef<NodeJS.Timeout | null>(null);
57
+
58
+ const handleMobileClick = () => {
59
+ // Toggle or just show? User said "click... show type... after 1.5s auto animate change"
60
+ // Let's force show then revert.
61
+ setShowTime(true);
62
+
63
+ if (timerRef.current) clearTimeout(timerRef.current);
64
+
65
+ timerRef.current = setTimeout(() => {
66
+ setShowTime(false);
67
+ }, 1500);
68
+ };
69
+
70
+ return (
71
+ <motion.button
72
+ onMouseEnter={() => setShowTime(true)}
73
+ onMouseLeave={() => setShowTime(false)}
74
+ onClick={handleMobileClick}
75
+ onBlur={() => setShowTime(false)}
76
+ className={`group relative flex items-center gap-3 rounded-full border border-white/10 bg-white/5 px-4 py-2 transition-colors duration-300 hover:border-white/20 hover:bg-white/10 select-none touch-none overflow-hidden ${className}`}
77
+ >
78
+ <div className="relative flex items-center justify-center w-4 h-4">
79
+ <AnimatePresence mode="popLayout" initial={false}>
80
+ {showTime ? (
81
+ <motion.div
82
+ key="clock"
83
+ initial={{ scale: 0.5, opacity: 0, rotate: -180 }}
84
+ animate={{ scale: 1, opacity: 1, rotate: 0 }}
85
+ exit={{ scale: 0.5, opacity: 0, rotate: 180 }}
86
+ transition={{
87
+ type: "spring",
88
+ stiffness: 300,
89
+ damping: 20
90
+ }}
91
+ className="absolute inset-0 m-auto"
92
+ >
93
+ <Clock className="w-4 h-4 text-white/70" />
94
+ </motion.div>
95
+ ) : (
96
+ <motion.div
97
+ key="pin"
98
+ initial={{ scale: 0.5, opacity: 0, rotate: -180 }}
99
+ animate={{ scale: 1, opacity: 1, rotate: 0 }}
100
+ exit={{ scale: 0.5, opacity: 0, rotate: 180 }}
101
+ transition={{
102
+ type: "spring",
103
+ stiffness: 300,
104
+ damping: 20
105
+ }}
106
+ className="absolute inset-0 m-auto"
107
+ >
108
+ <MapPin className="w-4 h-4 text-white/70" />
109
+ </motion.div>
110
+ )}
111
+ </AnimatePresence>
112
+ </div>
113
+
114
+ <div className="relative h-5 flex flex-col justify-center items-start w-full overflow-hidden">
115
+ <AnimatePresence mode="popLayout" initial={false}>
116
+ {showTime ? (
117
+ <motion.span
118
+ key="time"
119
+ initial={{ y: 20, opacity: 0, filter: "blur(4px)" }}
120
+ animate={{ y: 0, opacity: 1, filter: "blur(0px)" }}
121
+ exit={{ y: -20, opacity: 0, filter: "blur(4px)" }}
122
+ transition={{
123
+ type: "spring",
124
+ stiffness: 400,
125
+ damping: 30
126
+ }}
127
+ className="text-sm font-medium text-white/90 font-sans block whitespace-nowrap absolute left-0"
128
+ >
129
+ {currentTime}
130
+ </motion.span>
131
+ ) : (
132
+ <motion.span
133
+ key="location"
134
+ initial={{ y: 20, opacity: 0, filter: "blur(4px)" }}
135
+ animate={{ y: 0, opacity: 1, filter: "blur(0px)" }}
136
+ exit={{ y: -20, opacity: 0, filter: "blur(4px)" }}
137
+ transition={{
138
+ type: "spring",
139
+ stiffness: 400,
140
+ damping: 30
141
+ }}
142
+ className="text-sm font-medium text-white/90 font-sans block whitespace-nowrap absolute left-0"
143
+ >
144
+ {location.city}, {location.country}
145
+ </motion.span>
146
+ )}
147
+ </AnimatePresence>
148
+ </div>
149
+ </motion.button>
150
+ )
151
+ }
@@ -0,0 +1,345 @@
1
+ "use client"
2
+
3
+ import { useEffect, useState, useRef, useCallback } from "react"
4
+ import { motion, PanInfo } from "framer-motion"
5
+ import Image from "next/image"
6
+ import { supabase } from "@/lib/supabase"
7
+ import { PAPER_SOUND_BASE64 } from "@/components/ui/sound-constants"
8
+ import { usePreloader } from "@/components/ui/preloader-wrapper"
9
+
10
+ interface GalleryItem {
11
+ id: number;
12
+ src: string;
13
+ alt: string;
14
+ }
15
+
16
+ const isVideo = (url: string) => {
17
+ return url?.match(/\.(mp4|webm|ogg|mov)$/i);
18
+ };
19
+
20
+ export function VerticalImageStack() {
21
+ const { hasShown } = usePreloader();
22
+ const [images, setImages] = useState<GalleryItem[]>([]);
23
+
24
+ // Remove dummy data. If no images, we will handle in render.
25
+
26
+ useEffect(() => {
27
+ const fetchGallery = async () => {
28
+ const { data } = await supabase
29
+ .from("gallery_images")
30
+ .select("*")
31
+ .order("display_order", { ascending: true });
32
+
33
+ if (data && data.length > 0) {
34
+ setImages(data.map((item, i) => ({
35
+ id: i + 1,
36
+ src: item.image_url,
37
+ alt: item.alt_text || "Gallery Image"
38
+ })));
39
+ }
40
+ };
41
+ fetchGallery();
42
+ }, []);
43
+
44
+ // Ensure we handles empty case in UI
45
+ const displayImages = images;
46
+
47
+ const [currentIndex, setCurrentIndex] = useState(0)
48
+ const lastNavigationTime = useRef(0)
49
+ const navigationCooldown = 400 // Adjusted cooldown
50
+
51
+ const navigate = useCallback((newDirection: number) => {
52
+ const now = Date.now()
53
+ if (now - lastNavigationTime.current < navigationCooldown) return
54
+ lastNavigationTime.current = now
55
+
56
+ setCurrentIndex((prev) => {
57
+ if (newDirection > 0) {
58
+ return prev === displayImages.length - 1 ? 0 : prev + 1
59
+ }
60
+ return prev === 0 ? displayImages.length - 1 : prev - 1
61
+ })
62
+ }, [displayImages.length])
63
+
64
+ const handleDragEnd = (_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
65
+ const threshold = 60 // Significant pull required
66
+ const velocityThreshold = 100 // High velocity sweep
67
+
68
+ // Swipe triggers on distance OR high velocity
69
+ if (info.offset.y < -threshold || info.velocity.y < -velocityThreshold) {
70
+ navigate(1)
71
+ } else if (info.offset.y > threshold || info.velocity.y > velocityThreshold) {
72
+ navigate(-1)
73
+ }
74
+ }
75
+
76
+ const handleWheel = useCallback(
77
+ (e: WheelEvent) => {
78
+ e.stopPropagation();
79
+ // Debounce wheel slightly less or differently?
80
+ // Actually just relying on cooldown is enough.
81
+ if (Math.abs(e.deltaY) > 40) { // Higher threshold for stability
82
+ if (e.deltaY > 0) {
83
+ navigate(1)
84
+ } else {
85
+ navigate(-1)
86
+ }
87
+ }
88
+ },
89
+ [navigate],
90
+ )
91
+
92
+ const containerRef = useRef<HTMLDivElement>(null)
93
+
94
+ useEffect(() => {
95
+ const container = containerRef.current
96
+ if (!container) return
97
+
98
+ const onWheel = (e: WheelEvent) => {
99
+ e.preventDefault() // prevent page scroll
100
+ handleWheel(e)
101
+ }
102
+
103
+ container.addEventListener("wheel", onWheel, { passive: false })
104
+ return () => container.removeEventListener("wheel", onWheel)
105
+ }, [handleWheel])
106
+
107
+ const [isMobile, setIsMobile] = useState(false)
108
+
109
+ // --- Sound & Haptics Setup ---
110
+ const audioRef = useRef<HTMLAudioElement | null>(null);
111
+ const isFirstRender = useRef(true);
112
+
113
+ // Short, crisp "tick" sound (iPhone-like wheel click)
114
+ const TICK_SOUND = "data:audio/mp3;base64,SUQzBAAAAAABAFRYWFgAAAASAAADbWFqb3JfYnJhbmQAbXA0MgBUWFZYAAAAEQAAA21pbm9yX3ZlcnNpb24AMABUWFZYAAAAHAAAA2NvbXBhdGlibGVfYnJhbmRzAGlzb21tcTQyAP/7UAAAABwAAAAAABAAAAABACAAAABHAAAASgAAAAUAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//7UAAAAABAAAAAABACAAAABHAAAASgAAAAUAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//7UAAAAABAAAAAABACAAAABHAAAASgAAAAUAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//7UAAAAABAAAAAABACAAAABHAAAASgAAAAUAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//7UAAAAABAAAAAABACAAAABHAAAASgAAAAUAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//7UAAAAABAAAAAABACAAAABHAAAASgAAAAUAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//7UAAAAABAAAAAABACAAAABHAAAASgAAAAUAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//7UAAAAABAAAAAABACAAAABHAAAASgAAAAUAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; // Placeholder - Replacing with real tick logic below
115
+
116
+ // Real Tick Sound Data (Shortened for brevity but functional placeholder for logic)
117
+ // Actually, I'll use a reliable "pop" sound URL or just a simple beep logic if Base64 is too risky to guess.
118
+ // User requested "iPhone tick". I will use a very short logic for now.
119
+
120
+ useEffect(() => {
121
+ if (!hasShown) return; // Defer audio init until preloader is done
122
+
123
+ // Initialize Audio
124
+ // Using Embedded Base64 for INSTANT playback (no network delay)
125
+ audioRef.current = new Audio(PAPER_SOUND_BASE64);
126
+ audioRef.current.load();
127
+
128
+ }, [hasShown]);
129
+
130
+ useEffect(() => {
131
+ const checkMobile = () => setIsMobile(window.innerWidth < 640)
132
+ checkMobile()
133
+ window.addEventListener('resize', checkMobile)
134
+ return () => window.removeEventListener('resize', checkMobile)
135
+ }, [])
136
+
137
+ // Unlock Audio Context on first interaction (Click/Touch/Key)
138
+ useEffect(() => {
139
+ const unlockAudio = () => {
140
+ if (audioRef.current) {
141
+ // Play silent brief note to unlock
142
+ const vol = audioRef.current.volume;
143
+ audioRef.current.volume = 0;
144
+ audioRef.current.play().then(() => {
145
+ audioRef.current?.pause();
146
+ audioRef.current!.currentTime = 0;
147
+ audioRef.current!.volume = 0.5; // Restore volume
148
+ }).catch((e) => {
149
+ // Fail silently but RESTORE VOLUME so future plays work
150
+ if (audioRef.current) audioRef.current.volume = 0.5;
151
+ console.log("Audio unlock attempt:", e);
152
+ });
153
+ }
154
+ // Remove listeners once unlocked (or attempted)
155
+ window.removeEventListener('click', unlockAudio);
156
+ window.removeEventListener('touchstart', unlockAudio);
157
+ window.removeEventListener('keydown', unlockAudio);
158
+ };
159
+
160
+ window.addEventListener('click', unlockAudio);
161
+ window.addEventListener('touchstart', unlockAudio);
162
+ window.addEventListener('keydown', unlockAudio);
163
+
164
+ return () => {
165
+ window.removeEventListener('click', unlockAudio);
166
+ window.removeEventListener('touchstart', unlockAudio);
167
+ window.removeEventListener('keydown', unlockAudio);
168
+ }
169
+ }, []);
170
+
171
+ // Trigger Sound & Haptics on Change
172
+ useEffect(() => {
173
+ if (isFirstRender.current) {
174
+ isFirstRender.current = false;
175
+ return;
176
+ }
177
+
178
+ // Haptic Feedback (Vibration)
179
+ // Soft "flip" feel (8ms)
180
+ if (typeof navigator !== "undefined" && navigator.vibrate) {
181
+ navigator.vibrate(15);
182
+ }
183
+
184
+ // Sound Effect
185
+ if (audioRef.current) {
186
+ // "audiomass-output.mp3" - Playing from start
187
+ audioRef.current.pause(); // Stop previous
188
+ audioRef.current.volume = 0.5; // FORCE VOLUME RESTORE
189
+ audioRef.current.currentTime = 0;
190
+ const playPromise = audioRef.current.play();
191
+ if (playPromise !== undefined) {
192
+ playPromise.catch(error => {
193
+ // Suppress "NotAllowedError" if user hasn't interacted yet
194
+ if (error.name !== "NotAllowedError") {
195
+ console.error("Audio playback failed:", error);
196
+ }
197
+ });
198
+ }
199
+ }
200
+
201
+ }, [currentIndex]);
202
+
203
+ const getCardStyle = (index: number) => {
204
+ const total = displayImages.length
205
+ let diff = index - currentIndex
206
+ if (diff > total / 2) diff -= total
207
+ if (diff < -total / 2) diff += total
208
+
209
+ // Responsive offsets
210
+ const yBase = isMobile ? 85 : 140
211
+ const ySecond = isMobile ? 150 : 240
212
+ const yHidden = isMobile ? 220 : 350
213
+
214
+ if (diff === 0) {
215
+ return { y: 0, scale: 1, opacity: 1, zIndex: 5, rotateX: 0, filter: "brightness(1)" }
216
+ } else if (diff === -1) {
217
+ return { y: -yBase, scale: 0.85, opacity: 0.4, zIndex: 4, rotateX: 5, filter: "brightness(0.5)" }
218
+ } else if (diff === -2) {
219
+ return { y: -ySecond, scale: 0.75, opacity: 0.2, zIndex: 3, rotateX: 10, filter: "brightness(0.3)" }
220
+ } else if (diff === 1) {
221
+ return { y: yBase, scale: 0.85, opacity: 0.4, zIndex: 4, rotateX: -5, filter: "brightness(0.5)" }
222
+ } else if (diff === 2) {
223
+ return { y: ySecond, scale: 0.75, opacity: 0.2, zIndex: 3, rotateX: -10, filter: "brightness(0.3)" }
224
+ } else {
225
+ return { y: diff > 0 ? yHidden : -yHidden, scale: 0.6, opacity: 0, zIndex: 0, rotateX: diff > 0 ? -20 : 20, filter: "brightness(0)" }
226
+ }
227
+ }
228
+
229
+ const isVisible = (index: number) => {
230
+ const total = displayImages.length
231
+ let diff = index - currentIndex
232
+ if (diff > total / 2) diff -= total
233
+ if (diff < -total / 2) diff += total
234
+ return Math.abs(diff) <= 2
235
+ }
236
+
237
+ return (
238
+ <div ref={containerRef} className="relative flex h-full w-full items-center justify-center overflow-hidden bg-[#111111] rounded-[32px] border border-white/5 select-none pointer-events-none sm:pointer-events-auto sm:touch-none">
239
+ {/* Subtle ambient glow */}
240
+ <div className="pointer-events-none absolute inset-0">
241
+ <div className="absolute left-1/2 top-1/2 h-[500px] w-[500px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-white/[0.02] blur-3xl opacity-50" />
242
+ </div>
243
+
244
+ {/* Card Stack */}
245
+ <div className="relative flex h-[320px] sm:h-[500px] w-full max-w-[320px] items-center justify-center py-10" style={{ perspective: "1000px" }}>
246
+ {displayImages.length === 0 && (
247
+ <div className="absolute flex flex-col items-center justify-center z-10 text-white/20">
248
+ <span className="text-sm tracking-widest uppercase">No Gallery Items</span>
249
+ </div>
250
+ )}
251
+ {displayImages.map((image, index) => {
252
+ if (!isVisible(index)) return null
253
+ const style = getCardStyle(index)
254
+ const isCurrent = index === currentIndex
255
+
256
+ // Priority load current, previous, and next images for smoothness
257
+ const shouldPrioritize = Math.abs(index - currentIndex) <= 1 || (currentIndex === 0 && index === displayImages.length - 1) || (currentIndex === displayImages.length - 1 && index === 0);
258
+
259
+ return (
260
+ <motion.div
261
+ key={image.id}
262
+ className="absolute cursor-grab active:cursor-grabbing w-[180px] sm:w-full flex justify-center pointer-events-auto"
263
+ animate={{
264
+ y: style.y,
265
+ scale: style.scale,
266
+ opacity: style.opacity,
267
+ rotateX: style.rotateX,
268
+ zIndex: style.zIndex,
269
+ filter: style.filter
270
+ }}
271
+ transition={{
272
+ type: "spring",
273
+ stiffness: 120, // Slower, smoother settle
274
+ damping: 20, // Less bouncy, more controlled
275
+ mass: 1.2, // "Heavier" feel
276
+ }}
277
+ drag={isCurrent ? "y" : false}
278
+ dragConstraints={{ top: 0, bottom: 0 }}
279
+ dragElastic={0.25} // Resistance for "pulling weight" feel
280
+ onDragEnd={handleDragEnd}
281
+ style={{
282
+ transformStyle: "preserve-3d",
283
+ zIndex: style.zIndex,
284
+ willChange: "transform, opacity, filter"
285
+ }}
286
+ >
287
+ <div
288
+ className="relative h-[260px] w-[180px] sm:h-[420px] sm:w-[280px] overflow-hidden rounded-3xl bg-[#1a1a1a] ring-1 ring-white/10"
289
+ style={{
290
+ boxShadow: isCurrent
291
+ ? "0 30px 60px -12px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.08)"
292
+ : "0 10px 20px -10px rgba(0,0,0,0.5)",
293
+ }}
294
+ >
295
+ {/* Card inner glow */}
296
+ <div className="absolute inset-0 rounded-3xl bg-gradient-to-b from-white/10 via-transparent to-transparent z-10 pointer-events-none" />
297
+
298
+ {isVideo(image.src) ? (
299
+ <video
300
+ src={image.src}
301
+ className="w-full h-full object-cover pointer-events-none"
302
+ autoPlay
303
+ loop
304
+ muted
305
+ playsInline
306
+ />
307
+ ) : (
308
+ <Image
309
+ src={image.src || "/placeholder.svg"}
310
+ alt={image.alt}
311
+ fill
312
+ className="object-cover w-full h-full pointer-events-none"
313
+ draggable={false}
314
+ priority={shouldPrioritize}
315
+ sizes="300px"
316
+ quality={90}
317
+ />
318
+ )}
319
+
320
+ {/* Bottom gradient overlay */}
321
+ <div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-black/80 to-transparent z-10 pointer-events-none" />
322
+ </div>
323
+ </motion.div>
324
+ )
325
+ })}
326
+ </div>
327
+
328
+ {/* Navigation dots - Optimized */}
329
+ <div className="absolute right-6 top-1/2 flex -translate-y-1/2 flex-col gap-2 z-20">
330
+ {displayImages.map((_, index) => (
331
+ <button
332
+ key={index}
333
+ onClick={() => {
334
+ // Allow instant jump
335
+ setCurrentIndex(index)
336
+ }}
337
+ className={`transition-all duration-300 rounded-full ${index === currentIndex ? "h-8 w-1.5 bg-white shadow-[0_0_10px_rgba(255,255,255,0.5)]" : "h-1.5 w-1.5 bg-white/20 hover:bg-white/50"
338
+ }`}
339
+ aria-label={`Go to image ${index + 1}`}
340
+ />
341
+ ))}
342
+ </div>
343
+ </div>
344
+ )
345
+ }