504 lines
19 KiB
TypeScript
504 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo, useEffect, useCallback } from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import Image from "next/image";
|
|
import {
|
|
Search,
|
|
Filter,
|
|
Heart,
|
|
Monitor,
|
|
Palette,
|
|
FileImage,
|
|
Grid,
|
|
LayoutGrid,
|
|
X,
|
|
ChevronDown,
|
|
Eye
|
|
} from "lucide-react";
|
|
|
|
// Mock data - In production, this would come from the API
|
|
const mockArtworks = [
|
|
{
|
|
id: "1",
|
|
title: "Digital Dreams",
|
|
artist: "Rahul K.",
|
|
category: "digital-art",
|
|
imageUrl: "https://images.unsplash.com/photo-1549490349-8643362247b5?w=600&h=600&fit=crop",
|
|
department: "CSE",
|
|
year: "3rd Year",
|
|
},
|
|
{
|
|
id: "2",
|
|
title: "Ocean Serenity",
|
|
artist: "Priya M.",
|
|
category: "paintings",
|
|
imageUrl: "https://images.unsplash.com/photo-1579783902614-a3fb3927b6a5?w=600&h=600&fit=crop",
|
|
department: "ECE",
|
|
year: "2nd Year",
|
|
},
|
|
{
|
|
id: "3",
|
|
title: "Climate Action Now",
|
|
artist: "Arun S.",
|
|
category: "poster-making",
|
|
imageUrl: "https://images.unsplash.com/photo-1561214115-f2f134cc4912?w=600&h=600&fit=crop",
|
|
department: "ISE",
|
|
year: "4th Year",
|
|
},
|
|
{
|
|
id: "4",
|
|
title: "Neon Nights",
|
|
artist: "Sneha R.",
|
|
category: "digital-art",
|
|
imageUrl: "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=600&h=600&fit=crop",
|
|
department: "CSE",
|
|
year: "2nd Year",
|
|
},
|
|
{
|
|
id: "5",
|
|
title: "Mountain Sunrise",
|
|
artist: "Karthik V.",
|
|
category: "paintings",
|
|
imageUrl: "https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8b?w=600&h=600&fit=crop",
|
|
department: "ME",
|
|
year: "3rd Year",
|
|
},
|
|
{
|
|
id: "6",
|
|
title: "Tech Innovation",
|
|
artist: "Divya N.",
|
|
category: "poster-making",
|
|
imageUrl: "https://images.unsplash.com/photo-1513364776144-60967b0f800f?w=600&h=600&fit=crop",
|
|
department: "AIML",
|
|
year: "2nd Year",
|
|
},
|
|
{
|
|
id: "7",
|
|
title: "Abstract Emotions",
|
|
artist: "Vijay K.",
|
|
category: "digital-art",
|
|
imageUrl: "https://images.unsplash.com/photo-1547826039-bfc35e0f1ea8?w=600&h=600&fit=crop",
|
|
department: "CSE",
|
|
year: "4th Year",
|
|
},
|
|
{
|
|
id: "8",
|
|
title: "Sunset Reflections",
|
|
artist: "Meera S.",
|
|
category: "paintings",
|
|
imageUrl: "https://images.unsplash.com/photo-1578926288207-a90a5366759d?w=600&h=600&fit=crop",
|
|
department: "EEE",
|
|
year: "3rd Year",
|
|
},
|
|
{
|
|
id: "9",
|
|
title: "Save Our Planet",
|
|
artist: "Nikhil B.",
|
|
category: "poster-making",
|
|
imageUrl: "https://images.unsplash.com/photo-1482160549825-59d1b23cb208?w=600&h=600&fit=crop",
|
|
department: "CE",
|
|
year: "2nd Year",
|
|
},
|
|
];
|
|
|
|
const categories = [
|
|
{ id: "all", label: "All Categories", icon: LayoutGrid },
|
|
{ id: "digital-art", label: "Digital Art", icon: Monitor },
|
|
{ id: "paintings", label: "Paintings", icon: Palette },
|
|
{ id: "poster-making", label: "Poster Making", icon: FileImage },
|
|
];
|
|
|
|
// Seeded random shuffle function
|
|
function seededShuffle<T>(array: T[], seed: number): T[] {
|
|
const shuffled = [...array];
|
|
let currentIndex = shuffled.length;
|
|
|
|
// Simple seeded random number generator
|
|
const seededRandom = () => {
|
|
seed = (seed * 9301 + 49297) % 233280;
|
|
return seed / 233280;
|
|
};
|
|
|
|
while (currentIndex > 0) {
|
|
const randomIndex = Math.floor(seededRandom() * currentIndex);
|
|
currentIndex--;
|
|
[shuffled[currentIndex], shuffled[randomIndex]] = [shuffled[randomIndex], shuffled[currentIndex]];
|
|
}
|
|
|
|
return shuffled;
|
|
}
|
|
|
|
export default function GalleryPage() {
|
|
const [selectedCategory, setSelectedCategory] = useState("all");
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [viewMode, setViewMode] = useState<"grid" | "large">("grid");
|
|
const [selectedArtwork, setSelectedArtwork] = useState<typeof mockArtworks[0] | null>(null);
|
|
const [votedArtworks, setVotedArtworks] = useState<Set<string>>(() => {
|
|
if (typeof window !== "undefined") {
|
|
try {
|
|
const stored = localStorage.getItem("artsplash-votes");
|
|
return stored ? new Set(JSON.parse(stored)) : new Set();
|
|
} catch {
|
|
return new Set();
|
|
}
|
|
}
|
|
return new Set();
|
|
});
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
|
|
// Lock body scroll when modal is open
|
|
useEffect(() => {
|
|
if (selectedArtwork) {
|
|
document.body.style.overflow = "hidden";
|
|
} else {
|
|
document.body.style.overflow = "";
|
|
}
|
|
return () => { document.body.style.overflow = ""; };
|
|
}, [selectedArtwork]);
|
|
|
|
// Close modal on Escape key
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") setSelectedArtwork(null);
|
|
};
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, []);
|
|
|
|
// Use a daily seed for consistent randomization throughout the day
|
|
const dailySeed = useMemo(() => {
|
|
const today = new Date();
|
|
return today.getFullYear() * 10000 + (today.getMonth() + 1) * 100 + today.getDate();
|
|
}, []);
|
|
|
|
// Filter and shuffle artworks
|
|
const filteredArtworks = useMemo(() => {
|
|
let result = mockArtworks;
|
|
|
|
// Filter by category
|
|
if (selectedCategory !== "all") {
|
|
result = result.filter(art => art.category === selectedCategory);
|
|
}
|
|
|
|
// Filter by search query
|
|
if (searchQuery) {
|
|
const query = searchQuery.toLowerCase();
|
|
result = result.filter(
|
|
art =>
|
|
art.title.toLowerCase().includes(query) ||
|
|
art.artist.toLowerCase().includes(query)
|
|
);
|
|
}
|
|
|
|
// Shuffle with seed
|
|
return seededShuffle(result, dailySeed);
|
|
}, [selectedCategory, searchQuery, dailySeed]);
|
|
|
|
const handleVote = useCallback((artworkId: string) => {
|
|
if (votedArtworks.has(artworkId)) return;
|
|
setVotedArtworks(prev => {
|
|
const next = new Set([...prev, artworkId]);
|
|
try {
|
|
localStorage.setItem("artsplash-votes", JSON.stringify([...next]));
|
|
} catch { /* storage full - ignore */ }
|
|
return next;
|
|
});
|
|
}, [votedArtworks]);
|
|
|
|
return (
|
|
<main className="min-h-screen bg-black pt-32 pb-20">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
{/* Header */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.6 }}
|
|
className="text-center mb-12"
|
|
>
|
|
<span className="inline-block bg-lime-400 text-black px-4 py-1.5 text-xs font-bold tracking-wider rounded-full mb-6">
|
|
GALLERY
|
|
</span>
|
|
<h1 className="text-4xl sm:text-5xl font-black tracking-tight text-white mb-4">
|
|
Explore <span className="text-lime-400">Artworks</span>
|
|
</h1>
|
|
<p className="text-zinc-400 text-lg max-w-2xl mx-auto">
|
|
Browse through amazing artworks submitted by talented artists from SJEC.
|
|
Vote for your favorites to help them win!
|
|
</p>
|
|
</motion.div>
|
|
|
|
{/* Filters Bar */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.6, delay: 0.1 }}
|
|
className="mb-8"
|
|
>
|
|
<div className="flex flex-col lg:flex-row gap-4 items-stretch lg:items-center justify-between">
|
|
{/* Search */}
|
|
<div className="relative flex-1 max-w-md">
|
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-500" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Search by title or artist..."
|
|
className="w-full bg-zinc-900 border border-zinc-800 rounded-xl pl-12 pr-4 py-3 text-white placeholder-zinc-500 focus:outline-none focus:border-lime-400 transition-colors"
|
|
/>
|
|
</div>
|
|
|
|
{/* Category Pills - Desktop */}
|
|
<div className="hidden lg:flex items-center gap-2">
|
|
{categories.map((cat) => (
|
|
<button
|
|
key={cat.id}
|
|
onClick={() => setSelectedCategory(cat.id)}
|
|
className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all ${
|
|
selectedCategory === cat.id
|
|
? "bg-lime-400 text-black"
|
|
: "bg-zinc-900 text-zinc-400 hover:text-white border border-zinc-800"
|
|
}`}
|
|
>
|
|
<cat.icon className="w-4 h-4" />
|
|
{cat.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Mobile Filter Button */}
|
|
<button
|
|
onClick={() => setShowFilters(!showFilters)}
|
|
className="lg:hidden flex items-center justify-center gap-2 bg-zinc-900 border border-zinc-800 text-white px-4 py-3 rounded-xl"
|
|
>
|
|
<Filter className="w-5 h-5" />
|
|
Filters
|
|
<ChevronDown className={`w-4 h-4 transition-transform ${showFilters ? "rotate-180" : ""}`} />
|
|
</button>
|
|
|
|
{/* View Toggle */}
|
|
<div className="hidden sm:flex items-center gap-2 bg-zinc-900 border border-zinc-800 rounded-xl p-1">
|
|
<button
|
|
onClick={() => setViewMode("grid")}
|
|
className={`p-2 rounded-lg transition-colors ${
|
|
viewMode === "grid" ? "bg-lime-400 text-black" : "text-zinc-400 hover:text-white"
|
|
}`}
|
|
>
|
|
<Grid className="w-5 h-5" />
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode("large")}
|
|
className={`p-2 rounded-lg transition-colors ${
|
|
viewMode === "large" ? "bg-lime-400 text-black" : "text-zinc-400 hover:text-white"
|
|
}`}
|
|
>
|
|
<LayoutGrid className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile Category Filters */}
|
|
<AnimatePresence>
|
|
{showFilters && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: "auto", opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
className="lg:hidden overflow-hidden"
|
|
>
|
|
<div className="flex flex-wrap gap-2 pt-4">
|
|
{categories.map((cat) => (
|
|
<button
|
|
key={cat.id}
|
|
onClick={() => {
|
|
setSelectedCategory(cat.id);
|
|
setShowFilters(false);
|
|
}}
|
|
className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all ${
|
|
selectedCategory === cat.id
|
|
? "bg-lime-400 text-black"
|
|
: "bg-zinc-900 text-zinc-400 border border-zinc-800"
|
|
}`}
|
|
>
|
|
<cat.icon className="w-4 h-4" />
|
|
{cat.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
|
|
{/* Results Count */}
|
|
<div className="text-zinc-500 text-sm mb-6">
|
|
Showing {filteredArtworks.length} artwork{filteredArtworks.length !== 1 ? "s" : ""}
|
|
</div>
|
|
|
|
{/* Gallery Grid */}
|
|
{filteredArtworks.length > 0 ? (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ duration: 0.6, delay: 0.2 }}
|
|
className={`grid gap-6 ${
|
|
viewMode === "grid"
|
|
? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
|
|
: "grid-cols-1 sm:grid-cols-2"
|
|
}`}
|
|
>
|
|
{filteredArtworks.map((artwork, index) => (
|
|
<motion.div
|
|
key={artwork.id}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4, delay: index * 0.05 }}
|
|
className="group bg-zinc-900 border border-zinc-800 rounded-2xl overflow-hidden hover:border-zinc-700 transition-all"
|
|
>
|
|
{/* Image */}
|
|
<div
|
|
className="relative aspect-square cursor-pointer overflow-hidden"
|
|
onClick={() => setSelectedArtwork(artwork)}
|
|
>
|
|
<Image
|
|
src={artwork.imageUrl}
|
|
alt={artwork.title}
|
|
fill
|
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
|
className="object-cover group-hover:scale-105 transition-transform duration-500"
|
|
loading="lazy"
|
|
/>
|
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center opacity-0 group-hover:opacity-100">
|
|
<Eye className="w-8 h-8 text-white" />
|
|
</div>
|
|
{/* Category Badge */}
|
|
<div className="absolute top-3 left-3">
|
|
<span className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium bg-black/70 text-white backdrop-blur-sm">
|
|
{artwork.category === "digital-art" && <Monitor className="w-3 h-3" />}
|
|
{artwork.category === "paintings" && <Palette className="w-3 h-3" />}
|
|
{artwork.category === "poster-making" && <FileImage className="w-3 h-3" />}
|
|
{categories.find(c => c.id === artwork.category)?.label}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="p-4">
|
|
<h3 className="text-white font-bold text-lg mb-1 truncate">{artwork.title}</h3>
|
|
<p className="text-zinc-400 text-sm mb-4">
|
|
by {artwork.artist} • {artwork.department}
|
|
</p>
|
|
|
|
{/* Vote Button */}
|
|
<button
|
|
onClick={() => handleVote(artwork.id)}
|
|
disabled={votedArtworks.has(artwork.id)}
|
|
className={`w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl font-semibold transition-all ${
|
|
votedArtworks.has(artwork.id)
|
|
? "bg-lime-400/20 text-lime-400 cursor-default"
|
|
: "bg-zinc-800 text-white hover:bg-lime-400 hover:text-black"
|
|
}`}
|
|
>
|
|
<Heart className={`w-5 h-5 ${votedArtworks.has(artwork.id) ? "fill-lime-400" : ""}`} />
|
|
{votedArtworks.has(artwork.id) ? "Voted!" : "Vote for this"}
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</motion.div>
|
|
) : (
|
|
<div className="text-center py-20">
|
|
<div className="w-16 h-16 rounded-full bg-zinc-900 flex items-center justify-center mx-auto mb-4">
|
|
<Search className="w-8 h-8 text-zinc-600" />
|
|
</div>
|
|
<h3 className="text-white font-bold text-xl mb-2">No artworks found</h3>
|
|
<p className="text-zinc-500">Try adjusting your search or filter criteria</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Artwork Modal */}
|
|
<AnimatePresence>
|
|
{selectedArtwork && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 bg-black/90 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
|
onClick={() => setSelectedArtwork(null)}
|
|
>
|
|
<motion.div
|
|
initial={{ scale: 0.9, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
exit={{ scale: 0.9, opacity: 0 }}
|
|
className="bg-zinc-900 border border-zinc-800 rounded-3xl overflow-hidden max-w-4xl w-full max-h-[90vh] overflow-y-auto"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="relative">
|
|
<div className="relative aspect-video">
|
|
<Image
|
|
src={selectedArtwork.imageUrl}
|
|
alt={selectedArtwork.title}
|
|
fill
|
|
sizes="(max-width: 1024px) 100vw, 896px"
|
|
className="object-cover"
|
|
priority
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={() => setSelectedArtwork(null)}
|
|
className="absolute top-4 right-4 p-2 bg-black/50 backdrop-blur-sm rounded-full hover:bg-black/70 transition-colors"
|
|
>
|
|
<X className="w-6 h-6 text-white" />
|
|
</button>
|
|
</div>
|
|
<div className="p-6 md:p-8">
|
|
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4 mb-6">
|
|
<div>
|
|
<h2 className="text-2xl md:text-3xl font-black text-white mb-2">
|
|
{selectedArtwork.title}
|
|
</h2>
|
|
<p className="text-zinc-400">
|
|
by <span className="text-lime-400">{selectedArtwork.artist}</span>
|
|
</p>
|
|
</div>
|
|
<span className="inline-flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium bg-zinc-800 text-white">
|
|
{selectedArtwork.category === "digital-art" && <Monitor className="w-4 h-4" />}
|
|
{selectedArtwork.category === "paintings" && <Palette className="w-4 h-4" />}
|
|
{selectedArtwork.category === "poster-making" && <FileImage className="w-4 h-4" />}
|
|
{categories.find(c => c.id === selectedArtwork.category)?.label}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="grid sm:grid-cols-2 gap-4 mb-6">
|
|
<div className="bg-zinc-800/50 rounded-xl p-4">
|
|
<p className="text-zinc-500 text-sm mb-1">Department</p>
|
|
<p className="text-white font-semibold">{selectedArtwork.department}</p>
|
|
</div>
|
|
<div className="bg-zinc-800/50 rounded-xl p-4">
|
|
<p className="text-zinc-500 text-sm mb-1">Year</p>
|
|
<p className="text-white font-semibold">{selectedArtwork.year}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => handleVote(selectedArtwork.id)}
|
|
disabled={votedArtworks.has(selectedArtwork.id)}
|
|
className={`w-full flex items-center justify-center gap-2 px-6 py-4 rounded-xl font-bold transition-all ${
|
|
votedArtworks.has(selectedArtwork.id)
|
|
? "bg-lime-400/20 text-lime-400 cursor-default"
|
|
: "bg-lime-400 text-black hover:bg-lime-300"
|
|
}`}
|
|
>
|
|
<Heart className={`w-5 h-5 ${votedArtworks.has(selectedArtwork.id) ? "fill-lime-400" : ""}`} />
|
|
{votedArtworks.has(selectedArtwork.id) ? "You voted for this artwork!" : "Vote for this Artwork"}
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</main>
|
|
);
|
|
}
|