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.
- package/README.md +59 -0
- package/bin/cli.js +54 -0
- package/package.json +27 -0
- package/template/components.json +22 -0
- package/template/next.config.ts +79 -0
- package/template/package.json +43 -0
- package/template/postcss.config.js +6 -0
- package/template/public/BoliviaSignature-ZpWnz.ttf +0 -0
- package/template/public/Gemini_Generated_Image_xc97toxc97toxc97.png +0 -0
- package/template/public/Hendrigo.otf +0 -0
- package/template/public/audiomass-output.mp3 +0 -0
- package/template/public/file.svg +1 -0
- package/template/public/globe.svg +1 -0
- package/template/public/googlec77e59474f5a09cb.html +1 -0
- package/template/public/icon-192x192.png +0 -0
- package/template/public/icon-512x512.png +0 -0
- package/template/public/next.svg +1 -0
- package/template/public/paper sound .mpeg +0 -0
- package/template/public/removebg.png +0 -0
- package/template/public/resume.pdf +0 -0
- package/template/public/sw.js +1 -0
- package/template/public/swe-worker-5c72df51bb1f6ee0.js +1 -0
- package/template/public/vercel.svg +1 -0
- package/template/public/window.svg +1 -0
- package/template/public/workbox-f1770938.js +1 -0
- package/template/src/app/about/page.tsx +91 -0
- package/template/src/app/actions/optimize-text.ts +54 -0
- package/template/src/app/gaming/page.tsx +308 -0
- package/template/src/app/github/[username]/page.tsx +97 -0
- package/template/src/app/globals.css +321 -0
- package/template/src/app/layout.tsx +39 -0
- package/template/src/app/manifest.ts +25 -0
- package/template/src/app/not-found.tsx +16 -0
- package/template/src/app/page.tsx +28 -0
- package/template/src/app/robots.ts +12 -0
- package/template/src/app/sitemap.ts +38 -0
- package/template/src/app/template.tsx +5 -0
- package/template/src/app/works/[slug]/page.tsx +50 -0
- package/template/src/app/works/client.tsx +44 -0
- package/template/src/app/works/page.tsx +24 -0
- package/template/src/components/about/about-client.tsx +259 -0
- package/template/src/components/home/bento-gallery.tsx +52 -0
- package/template/src/components/home/contact-section.tsx +34 -0
- package/template/src/components/home/craft-card.tsx +18 -0
- package/template/src/components/home/featured-projects.tsx +186 -0
- package/template/src/components/home/focus-card.tsx +171 -0
- package/template/src/components/home/identity-card.tsx +45 -0
- package/template/src/components/home/philosophy-card.tsx +104 -0
- package/template/src/components/home/skills-in-motion.tsx +109 -0
- package/template/src/components/home/tech-stack-marquee.tsx +56 -0
- package/template/src/components/ui/3d-folder.tsx +569 -0
- package/template/src/components/ui/avatar.tsx +50 -0
- package/template/src/components/ui/badge.tsx +36 -0
- package/template/src/components/ui/basic-avatar.tsx +12 -0
- package/template/src/components/ui/button.tsx +117 -0
- package/template/src/components/ui/clipboard-secret.tsx +39 -0
- package/template/src/components/ui/command-menu.tsx +519 -0
- package/template/src/components/ui/command-palette.tsx +152 -0
- package/template/src/components/ui/consciousness-mode.tsx +200 -0
- package/template/src/components/ui/copy-code-button.tsx +135 -0
- package/template/src/components/ui/display-cards.tsx +70 -0
- package/template/src/components/ui/dotted-map.tsx +128 -0
- package/template/src/components/ui/dropdown-menu.tsx +200 -0
- package/template/src/components/ui/emoji-rating.tsx +123 -0
- package/template/src/components/ui/exit-message.tsx +50 -0
- package/template/src/components/ui/image-zoom-overlay.tsx +178 -0
- package/template/src/components/ui/input-otp.tsx +71 -0
- package/template/src/components/ui/kbd.tsx +87 -0
- package/template/src/components/ui/location-tag.tsx +232 -0
- package/template/src/components/ui/minimal-testimonial.tsx +97 -0
- package/template/src/components/ui/mobile-menu.tsx +191 -0
- package/template/src/components/ui/navbar.tsx +148 -0
- package/template/src/components/ui/page-transition.tsx +24 -0
- package/template/src/components/ui/pixeleted-404-not-found.tsx +110 -0
- package/template/src/components/ui/preloader-wrapper.tsx +102 -0
- package/template/src/components/ui/preloader.tsx +104 -0
- package/template/src/components/ui/project-contributors.tsx +57 -0
- package/template/src/components/ui/scroll-area.tsx +117 -0
- package/template/src/components/ui/signature.tsx +173 -0
- package/template/src/components/ui/smooth-scroll.tsx +31 -0
- package/template/src/components/ui/social-icons.tsx +103 -0
- package/template/src/components/ui/social-stories.tsx +394 -0
- package/template/src/components/ui/sound-constants.ts +1 -0
- package/template/src/components/ui/text-explode.tsx +188 -0
- package/template/src/components/ui/toast.tsx +80 -0
- package/template/src/components/ui/tooltip.tsx +30 -0
- package/template/src/components/ui/user-location.tsx +151 -0
- package/template/src/components/ui/vertical-image-stack.tsx +345 -0
- package/template/src/components/works/changelog-overlay.tsx +212 -0
- package/template/src/components/works/currently-working-card.tsx +130 -0
- package/template/src/components/works/project-details-view.tsx +464 -0
- package/template/src/components/works/project-grid.tsx +81 -0
- package/template/src/fonts/BoliviaSignature-ZpWnz.ttf +0 -0
- package/template/src/fonts/Hendrigo.otf +0 -0
- package/template/src/lib/data.ts +61 -0
- package/template/src/lib/fonts.ts +14 -0
- package/template/src/lib/github.ts +15 -0
- package/template/src/lib/supabase.ts +11 -0
- package/template/src/lib/utils.ts +6 -0
- package/template/tailwind.config.ts +31 -0
- 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
|
+
}
|