Site restructure + admin panel for self-management

- Split page.tsx into server component + home-client.tsx (data-driven)
- Add /about page with extended biography, Art as Ritual, Art in Motion
- Add /admin panel (password-protected) for artworks, events, services CRUD
- Restructure nav: Home | Art | Services | Re Evolution Art | About | Contact
- Services now show "How it works" / "Who it is for" bullet points
- Gallery: featured artworks (large), grid, sold/private carousel, lightbox with metadata
- "Future Gatherings" renamed to "PULSAR"
- JSON data layer with auto-seed from hardcoded content (/data volume)
- Image upload API with file type validation (max 10MB)
- HMAC session auth (Web Crypto API, Edge-compatible)
- Docker: named volume xhivart-data, /data directory, ADMIN_PASSWORD env

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-23 02:37:05 +00:00
parent b377c9a3b1
commit 630312c7ea
26 changed files with 2732 additions and 944 deletions

View File

@ -27,6 +27,9 @@ RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Create data directory for JSON storage and uploads
RUN mkdir -p /data && chown nextjs:nodejs /data
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next

View File

@ -9,6 +9,9 @@ services:
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- SMTP_FROM=${SMTP_FROM}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
volumes:
- xhivart-data:/data
networks:
- traefik-public
labels:
@ -24,6 +27,9 @@ services:
retries: 3
start_period: 15s
volumes:
xhivart-data:
networks:
traefik-public:
external: true

View File

@ -12,6 +12,19 @@ const nextConfig: NextConfig = {
pathname: "/images/**",
},
],
remotePatterns: [
{
hostname: "localhost",
},
],
},
async rewrites() {
return [
{
source: "/uploads/:path*",
destination: "/api/uploads/:path*",
},
];
},
async headers() {
return [

257
src/app/about/page.tsx Normal file
View File

@ -0,0 +1,257 @@
import Image from 'next/image';
import Link from 'next/link';
export const metadata = {
title: 'About Ximena Xaguar | XHIVA ART',
description: 'Multidisciplinary visionary artist working at the intersection of art, ritual and embodied presence.',
};
export default function AboutPage() {
return (
<>
{/* Navigation — simplified for sub-page */}
<nav className="fixed top-0 left-0 right-0 z-50 glass">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<Link href="/" className="text-2xl font-light tracking-widest">
XHIVA ART
</Link>
<div className="hidden md:flex items-center gap-2">
<Link href="/" className="nav-link">Home</Link>
<Link href="/#art" className="nav-link">Art</Link>
<Link href="/#services" className="nav-link">Services</Link>
<Link href="/#reevolution" className="nav-link">Re Evolution Art</Link>
<Link href="/about" className="nav-link" style={{ color: 'var(--accent-gold)' }}>About</Link>
<Link href="/#contact" className="nav-link">Contact</Link>
</div>
</div>
</nav>
<main>
{/* Hero */}
<section className="pt-32 pb-16 px-6">
<div className="max-w-6xl mx-auto">
<div className="grid md:grid-cols-2 gap-12 items-center">
{/* Portrait */}
<div className="relative aspect-[3/4] rounded-2xl overflow-hidden shadow-xl">
<Image
src="/images/about/portrait-1.jpg"
alt="Ximena Xaguar"
fill
className="object-cover object-[30%_center]"
priority
/>
</div>
<div>
<p className="font-sans-alt text-xs tracking-[0.2em] text-[var(--accent-gold)] mb-4">
THE ARTIST
</p>
<h1 className="text-4xl md:text-5xl font-light mb-6">
About Ximena
</h1>
<div className="divider" style={{ margin: '1.5rem 0' }}></div>
<p className="text-lg leading-relaxed mb-6 opacity-80">
Ximena Xaguar is a multidisciplinary visionary artist, healer and cultural
producer based in Z&uuml;rich, Switzerland, with deep roots in Bolivia. Her
work weaves ancestral memory with contemporary expression across painting,
ceremony, immersive gatherings and community art.
</p>
<p className="text-lg leading-relaxed mb-6 opacity-80">
With over fifteen years of practice guiding Temazcal ceremonies, crystal
healing sessions and transformative group experiences, Ximena creates spaces
where art becomes a lived experience &mdash; a bridge between inner worlds
and shared reality.
</p>
<p className="text-lg leading-relaxed opacity-80">
Her paintings emerge from cycles of transformation, shadow work, intuitive
vision and ancestral cosmovision. Each artwork is a portal &mdash; an ally
for contemplation, energetic coherence and spiritual insight.
</p>
</div>
</div>
</div>
</section>
{/* Extended Biography */}
<section className="section bg-white">
<div className="max-w-3xl mx-auto">
<div className="text-center mb-12">
<p className="font-sans-alt text-xs tracking-[0.2em] text-[var(--accent-gold)] mb-4">
BIOGRAPHY
</p>
<h2 className="text-3xl md:text-4xl font-light mb-6">
The Path
</h2>
<div className="divider"></div>
</div>
<div className="space-y-6 text-lg leading-relaxed opacity-80">
<p>
Born in La Paz, Bolivia, Ximena grew up immersed in the rich cultural
tapestry of the Andes &mdash; a world where art, ceremony and daily life
are inseparable. This early foundation shaped her understanding of art as
something alive, relational and deeply connected to land and community.
</p>
<p>
She studied Fine Arts and later expanded her practice through years of
apprenticeship in ancestral healing traditions across Bolivia and Peru.
The Temazcal (sweatlodge) became a central pillar of her ceremonial work,
which she has guided for over fifteen years.
</p>
<p>
Moving to Switzerland, Ximena founded Re Evolution Art &mdash; a cultural
platform bridging South American ancestral wisdom with European contemporary
art. Through events like Visionary Art Week Z&uuml;rich, TRIBAL Nights and
PULSAR, she creates spaces where artists, musicians, ritualists and seekers
converge.
</p>
<p>
Her artistic practice spans large-scale canvas painting, murals, live
performance and collaborative installation. Each work draws on symbolic
language, universal cosmovision and the transformative power of colour
and form.
</p>
<p>
Today, Ximena continues to paint, guide ceremonies and produce cultural
events from her base in Z&uuml;rich, while maintaining deep connections
to her Bolivian roots and the broader network of visionary artists and
healers worldwide.
</p>
</div>
</div>
</section>
{/* Second Portrait */}
<section className="section">
<div className="max-w-6xl mx-auto">
<div className="grid md:grid-cols-2 gap-12 items-center">
<div>
<p className="font-sans-alt text-xs tracking-[0.2em] text-[var(--accent-gold)] mb-4">
PRACTICE
</p>
<h2 className="text-3xl md:text-4xl font-light mb-6">
Art as Ritual
</h2>
<div className="divider" style={{ margin: '1.5rem 0' }}></div>
<p className="text-lg leading-relaxed mb-6 opacity-80">
For Ximena, every painting is a ceremony. The studio becomes a ritual
space &mdash; candles, incense, music and intention set the container
for creation. The painting process mirrors the inner journey: death
and rebirth, shadow and light, dissolution and integration.
</p>
<p className="text-lg leading-relaxed opacity-80">
Her works are not decorative objects but living presences &mdash; portals
that continue to work on the viewer long after the first encounter.
Collectors and participants consistently describe a felt sense of
connection, activation and deep recognition when engaging with her art.
</p>
</div>
<div className="relative aspect-[3/4] rounded-2xl overflow-hidden shadow-xl">
<Image
src="/images/about/portrait-artist-1.webp"
alt="Ximena painting"
fill
className="object-cover"
/>
</div>
</div>
</div>
</section>
{/* Art in Motion */}
<section className="section bg-white">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<p className="font-sans-alt text-xs tracking-[0.2em] text-[var(--accent-gold)] mb-4">
THE PROCESS
</p>
<h2 className="text-3xl md:text-4xl font-light mb-6">
Art in Motion
</h2>
<div className="divider"></div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{[
{ src: '/images/about/painting-process.webp', alt: 'Ximena painting', position: 'top' },
{ src: '/images/art/mural-bio-centro.webp', alt: 'Mural Bio Centro Guembe' },
{ src: '/images/about/portrait-2.jpg', alt: 'Ximena painting mural' },
{ src: '/images/about/portrait-3.jpg', alt: 'Ximena in studio', position: 'top' },
{ src: '/images/about/portrait-4.jpg', alt: 'Ximena at ceremony' },
{ src: '/images/about/pachamama.jpg', alt: 'Ritual with smoke' },
].map((photo, index) => (
<div key={index} className="relative aspect-[4/3] rounded-lg overflow-hidden group">
<Image
src={photo.src}
alt={photo.alt}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
style={photo.position ? { objectPosition: photo.position } : undefined}
/>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="section text-center">
<div className="max-w-2xl mx-auto">
<h2 className="text-3xl font-light mb-6">Explore Further</h2>
<div className="divider"></div>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center mt-8">
<Link href="/#art" className="btn-outline">
View Gallery
</Link>
<Link href="/#services" className="btn-outline">
Services
</Link>
<Link href="/#contact" className="btn-filled">
Get in Touch
</Link>
</div>
</div>
</section>
</main>
{/* Footer */}
<footer className="dark-section py-16 px-6">
<div className="max-w-6xl mx-auto">
<div className="grid md:grid-cols-4 gap-12 mb-12">
<div className="md:col-span-2">
<h3 className="text-2xl font-light tracking-widest mb-4">XHIVA ART</h3>
<p className="text-sm opacity-70 leading-relaxed mb-6">
Visionary Art &middot; Ceremony &middot; Cultural Experiences
</p>
</div>
<div>
<h4 className="font-sans-alt text-xs tracking-widest mb-4">EXPLORE</h4>
<div className="flex flex-col gap-2">
<Link href="/#art" className="footer-link">Art</Link>
<Link href="/#services" className="footer-link">Services</Link>
<Link href="/#reevolution" className="footer-link">Re Evolution Art</Link>
<Link href="/about" className="footer-link">About</Link>
</div>
</div>
<div>
<h4 className="font-sans-alt text-xs tracking-widest mb-4">CONNECT</h4>
<div className="flex flex-col gap-2">
<Link href="/#contact" className="footer-link">Contact</Link>
<a href="https://instagram.com/ximena_xaguar" target="_blank" rel="noopener noreferrer" className="footer-link">
@ximena_xaguar
</a>
<a href="https://www.facebook.com/XimenaXhivart" target="_blank" rel="noopener noreferrer" className="footer-link">
Facebook
</a>
</div>
</div>
</div>
<div className="border-t border-white/10 pt-8 text-center">
<p className="font-sans-alt text-xs tracking-widest opacity-50">
&copy; {new Date().getFullYear()} XHIVA ART. ALL RIGHTS RESERVED.
</p>
</div>
</div>
</footer>
</>
);
}

View File

@ -0,0 +1,206 @@
'use client';
import { useState, useEffect } from 'react';
import type { ArtEvent } from '@/lib/types';
export default function AdminEvents() {
const [events, setEvents] = useState<ArtEvent[]>([]);
const [editing, setEditing] = useState<ArtEvent | null>(null);
const [isNew, setIsNew] = useState(false);
const [loading, setLoading] = useState(true);
const fetchEvents = async () => {
const res = await fetch('/api/admin/events');
setEvents(await res.json());
setLoading(false);
};
useEffect(() => { fetchEvents(); }, []);
const handleSave = async () => {
if (!editing) return;
const method = isNew ? 'POST' : 'PUT';
const url = isNew ? '/api/admin/events' : `/api/admin/events/${editing.id}`;
await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editing),
});
setEditing(null);
setIsNew(false);
fetchEvents();
};
const handleDelete = async (id: string) => {
if (!confirm('Delete this event?')) return;
await fetch(`/api/admin/events/${id}`, { method: 'DELETE' });
fetchEvents();
};
const handleUpload = async (file: File): Promise<string> => {
const form = new FormData();
form.append('file', file);
const res = await fetch('/api/admin/upload', { method: 'POST', body: form });
const data = await res.json();
return data.url || '';
};
const newEvent = (): ArtEvent => ({
id: `event-${Date.now()}`,
title: '',
description: '',
image: '',
date: '',
link: '',
section: 'reevolution',
sortOrder: events.length + 1,
});
if (loading) return <div className="p-8 text-gray-400">Loading...</div>;
const reevolutionEvents = events.filter(e => e.section === 'reevolution').sort((a, b) => a.sortOrder - b.sortOrder);
const xhivaEvents = events.filter(e => e.section === 'xhiva').sort((a, b) => a.sortOrder - b.sortOrder);
return (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-light">Events</h1>
<button
onClick={() => { setEditing(newEvent()); setIsNew(true); }}
className="px-4 py-2 bg-[#2d2d2d] text-white rounded-lg text-sm hover:bg-[#c9a962] transition-colors"
>
+ Add Event
</button>
</div>
{/* Edit Modal */}
{editing && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl max-w-lg w-full max-h-[90vh] overflow-y-auto p-6">
<h2 className="text-xl font-light mb-4">{isNew ? 'New Event' : 'Edit Event'}</h2>
<div className="space-y-4">
<div>
<label className="block text-xs tracking-widest mb-1">TITLE</label>
<input value={editing.title} onChange={(e) => setEditing({...editing, title: e.target.value})}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962]" />
</div>
<div>
<label className="block text-xs tracking-widest mb-1">DESCRIPTION</label>
<textarea value={editing.description} onChange={(e) => setEditing({...editing, description: e.target.value})} rows={3}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962] resize-none" />
</div>
<div>
<label className="block text-xs tracking-widest mb-1">IMAGE</label>
{editing.image && (
<div className="w-20 h-14 bg-gray-100 rounded overflow-hidden mb-2">
<img src={editing.image} alt="" className="w-full h-full object-cover" />
</div>
)}
<label className="text-xs text-blue-600 hover:text-blue-800 cursor-pointer block mb-2">
Upload image
<input type="file" accept="image/*" className="hidden" onChange={async (e) => {
const file = e.target.files?.[0];
if (file) { const url = await handleUpload(file); if (url) setEditing({...editing, image: url}); }
}} />
</label>
<input value={editing.image} onChange={(e) => setEditing({...editing, image: e.target.value})}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962]"
placeholder="Or paste image path" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs tracking-widest mb-1">DATE</label>
<input value={editing.date} onChange={(e) => setEditing({...editing, date: e.target.value})}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962]"
placeholder="e.g. March 2026" />
</div>
<div>
<label className="block text-xs tracking-widest mb-1">SECTION</label>
<select value={editing.section} onChange={(e) => setEditing({...editing, section: e.target.value as 'xhiva' | 'reevolution'})}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962]">
<option value="reevolution">Re Evolution Art</option>
<option value="xhiva">XHIVA</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs tracking-widest mb-1">LINK</label>
<input value={editing.link} onChange={(e) => setEditing({...editing, link: e.target.value})}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962]"
placeholder="https://..." />
</div>
<div>
<label className="block text-xs tracking-widest mb-1">SORT ORDER</label>
<input type="number" value={editing.sortOrder} onChange={(e) => setEditing({...editing, sortOrder: parseInt(e.target.value) || 0})}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962]" />
</div>
</div>
</div>
<div className="flex gap-3 mt-6">
<button onClick={handleSave} className="px-6 py-2 bg-[#2d2d2d] text-white rounded-lg text-sm hover:bg-[#c9a962] transition-colors">
Save
</button>
<button onClick={() => { setEditing(null); setIsNew(false); }} className="px-6 py-2 border rounded-lg text-sm hover:bg-gray-50">
Cancel
</button>
</div>
</div>
</div>
)}
{/* Event List by section */}
{reevolutionEvents.length > 0 && (
<div className="mb-8">
<h2 className="text-sm tracking-widest text-gray-500 mb-3">RE EVOLUTION ART</h2>
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
<EventTable events={reevolutionEvents} onEdit={(e) => { setEditing(e); setIsNew(false); }} onDelete={handleDelete} />
</div>
</div>
)}
{xhivaEvents.length > 0 && (
<div>
<h2 className="text-sm tracking-widest text-gray-500 mb-3">XHIVA</h2>
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
<EventTable events={xhivaEvents} onEdit={(e) => { setEditing(e); setIsNew(false); }} onDelete={handleDelete} />
</div>
</div>
)}
</div>
);
}
function EventTable({ events, onEdit, onDelete }: { events: ArtEvent[]; onEdit: (e: ArtEvent) => void; onDelete: (id: string) => void }) {
return (
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left">
<tr>
<th className="px-4 py-3 text-xs tracking-widest text-gray-500">IMAGE</th>
<th className="px-4 py-3 text-xs tracking-widest text-gray-500">TITLE</th>
<th className="px-4 py-3 text-xs tracking-widest text-gray-500">DATE</th>
<th className="px-4 py-3 text-xs tracking-widest text-gray-500">ACTIONS</th>
</tr>
</thead>
<tbody className="divide-y">
{events.map((event) => (
<tr key={event.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
{event.image && (
<div className="w-16 h-10 rounded overflow-hidden bg-gray-100">
<img src={event.image} alt="" className="w-full h-full object-cover" />
</div>
)}
</td>
<td className="px-4 py-3 font-medium">{event.title}</td>
<td className="px-4 py-3 text-gray-500">{event.date || '—'}</td>
<td className="px-4 py-3">
<button onClick={() => onEdit(event)} className="text-blue-600 hover:text-blue-800 mr-3 text-xs">Edit</button>
<button onClick={() => onDelete(event.id)} className="text-red-600 hover:text-red-800 text-xs">Delete</button>
</td>
</tr>
))}
</tbody>
</table>
);
}

77
src/app/admin/layout.tsx Normal file
View File

@ -0,0 +1,77 @@
'use client';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
if (pathname === '/admin/login') {
return <>{children}</>;
}
const navItems = [
{ href: '/admin', label: 'Artworks', icon: '🖼' },
{ href: '/admin/events', label: 'Events', icon: '📅' },
{ href: '/admin/services', label: 'Services', icon: '✨' },
];
const handleLogout = async () => {
await fetch('/api/admin/logout', { method: 'POST' });
router.push('/admin/login');
};
return (
<div className="min-h-screen bg-gray-50 flex">
{/* Sidebar */}
<aside className="w-56 bg-[#2d2d2d] text-white flex flex-col shrink-0">
<div className="p-5 border-b border-white/10">
<Link href="/admin" className="text-lg font-light tracking-wider">XHIVA ART</Link>
<p className="text-xs text-gray-400 mt-1">Admin</p>
</div>
<nav className="flex-1 py-4">
{navItems.map((item) => {
const isActive = item.href === '/admin'
? pathname === '/admin' || pathname === '/admin/artworks'
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-5 py-3 text-sm transition-colors ${
isActive ? 'bg-white/10 text-[#c9a962]' : 'text-gray-300 hover:bg-white/5'
}`}
>
<span>{item.icon}</span>
{item.label}
</Link>
);
})}
</nav>
<div className="p-4 border-t border-white/10 space-y-2">
<a
href="/"
target="_blank"
className="block text-xs text-gray-400 hover:text-white transition-colors px-1"
>
View Site &rarr;
</a>
<button
onClick={handleLogout}
className="block text-xs text-gray-400 hover:text-red-400 transition-colors px-1"
>
Logout
</button>
</div>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
);
}

View File

@ -0,0 +1,79 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function AdminLogin() {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const res = await fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
if (!res.ok) {
setError('Invalid password');
setLoading(false);
return;
}
router.push('/admin');
} catch {
setError('Something went wrong');
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-[#faf8f5] px-4">
<div className="w-full max-w-sm">
<h1 className="text-3xl font-light tracking-wider text-center mb-8">XHIVA ART</h1>
<p className="text-center text-sm text-gray-500 mb-8 font-[var(--font-montserrat)]">Admin Panel</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs tracking-widest mb-2 font-[var(--font-montserrat)]" htmlFor="password">
PASSWORD
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-3 border border-gray-200 rounded-lg focus:outline-none focus:border-[#c9a962] transition-colors"
placeholder="Enter admin password"
autoFocus
disabled={loading}
/>
</div>
{error && <p className="text-red-600 text-sm">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-[#2d2d2d] text-[#faf8f5] rounded-full text-xs tracking-widest uppercase hover:bg-[#c9a962] transition-colors disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<p className="text-center mt-8">
<a href="/" className="text-xs text-gray-400 hover:text-[#c9a962] tracking-widest uppercase">
Back to site
</a>
</p>
</div>
</div>
);
}

222
src/app/admin/page.tsx Normal file
View File

@ -0,0 +1,222 @@
'use client';
import { useState, useEffect } from 'react';
import type { Artwork } from '@/lib/types';
function ImageUpload({ currentImage, onUpload }: { currentImage: string; onUpload: (url: string) => void }) {
const [uploading, setUploading] = useState(false);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const form = new FormData();
form.append('file', file);
try {
const res = await fetch('/api/admin/upload', { method: 'POST', body: form });
const data = await res.json();
if (data.url) onUpload(data.url);
} catch { /* ignore */ }
setUploading(false);
};
return (
<div>
{currentImage && (
<div className="w-20 h-20 bg-gray-100 rounded overflow-hidden mb-2">
<img src={currentImage} alt="" className="w-full h-full object-cover" />
</div>
)}
<label className="text-xs text-blue-600 hover:text-blue-800 cursor-pointer">
{uploading ? 'Uploading...' : currentImage ? 'Change image' : 'Upload image'}
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" disabled={uploading} />
</label>
</div>
);
}
export default function AdminArtworks() {
const [artworks, setArtworks] = useState<Artwork[]>([]);
const [editing, setEditing] = useState<Artwork | null>(null);
const [isNew, setIsNew] = useState(false);
const [loading, setLoading] = useState(true);
const fetchArtworks = async () => {
const res = await fetch('/api/admin/artworks');
setArtworks(await res.json());
setLoading(false);
};
useEffect(() => { fetchArtworks(); }, []);
const handleSave = async () => {
if (!editing) return;
const method = isNew ? 'POST' : 'PUT';
const url = isNew ? '/api/admin/artworks' : `/api/admin/artworks/${editing.id}`;
await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editing),
});
setEditing(null);
setIsNew(false);
fetchArtworks();
};
const handleDelete = async (id: string) => {
if (!confirm('Delete this artwork?')) return;
await fetch(`/api/admin/artworks/${id}`, { method: 'DELETE' });
fetchArtworks();
};
const newArtwork = (): Artwork => ({
id: `art-${Date.now()}`,
title: '',
medium: '',
dimensions: '',
year: '',
price: '',
image: '',
category: 'available',
featured: false,
sortOrder: artworks.length + 1,
});
if (loading) return <div className="p-8 text-gray-400">Loading...</div>;
return (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-light">Artworks</h1>
<button
onClick={() => { setEditing(newArtwork()); setIsNew(true); }}
className="px-4 py-2 bg-[#2d2d2d] text-white rounded-lg text-sm hover:bg-[#c9a962] transition-colors"
>
+ Add Artwork
</button>
</div>
{/* Edit Modal */}
{editing && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl max-w-lg w-full max-h-[90vh] overflow-y-auto p-6">
<h2 className="text-xl font-light mb-4">{isNew ? 'New Artwork' : 'Edit Artwork'}</h2>
<div className="space-y-4">
<div>
<label className="block text-xs tracking-widest mb-1">TITLE</label>
<input value={editing.title} onChange={(e) => setEditing({...editing, title: e.target.value})}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962]" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs tracking-widest mb-1">MEDIUM</label>
<input value={editing.medium} onChange={(e) => setEditing({...editing, medium: e.target.value})}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962]" />
</div>
<div>
<label className="block text-xs tracking-widest mb-1">DIMENSIONS</label>
<input value={editing.dimensions} onChange={(e) => setEditing({...editing, dimensions: e.target.value})}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962]" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs tracking-widest mb-1">YEAR</label>
<input value={editing.year} onChange={(e) => setEditing({...editing, year: e.target.value})}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962]" />
</div>
<div>
<label className="block text-xs tracking-widest mb-1">PRICE</label>
<input value={editing.price} onChange={(e) => setEditing({...editing, price: e.target.value})}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962]" />
</div>
</div>
<div>
<label className="block text-xs tracking-widest mb-1">IMAGE</label>
<ImageUpload currentImage={editing.image} onUpload={(url) => setEditing({...editing, image: url})} />
<input value={editing.image} onChange={(e) => setEditing({...editing, image: e.target.value})}
className="w-full px-3 py-2 border rounded-lg text-sm mt-2 focus:outline-none focus:border-[#c9a962]"
placeholder="Or paste image path" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs tracking-widest mb-1">CATEGORY</label>
<select value={editing.category} onChange={(e) => setEditing({...editing, category: e.target.value as Artwork['category']})}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962]">
<option value="available">Available</option>
<option value="sold">Sold</option>
<option value="private">Private Collection</option>
</select>
</div>
<div>
<label className="block text-xs tracking-widest mb-1">SORT ORDER</label>
<input type="number" value={editing.sortOrder} onChange={(e) => setEditing({...editing, sortOrder: parseInt(e.target.value) || 0})}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962]" />
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={editing.featured} onChange={(e) => setEditing({...editing, featured: e.target.checked})} />
Featured artwork
</label>
</div>
<div className="flex gap-3 mt-6">
<button onClick={handleSave} className="px-6 py-2 bg-[#2d2d2d] text-white rounded-lg text-sm hover:bg-[#c9a962] transition-colors">
Save
</button>
<button onClick={() => { setEditing(null); setIsNew(false); }} className="px-6 py-2 border rounded-lg text-sm hover:bg-gray-50">
Cancel
</button>
</div>
</div>
</div>
)}
{/* Artwork List */}
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left">
<tr>
<th className="px-4 py-3 text-xs tracking-widest text-gray-500">IMAGE</th>
<th className="px-4 py-3 text-xs tracking-widest text-gray-500">TITLE</th>
<th className="px-4 py-3 text-xs tracking-widest text-gray-500">CATEGORY</th>
<th className="px-4 py-3 text-xs tracking-widest text-gray-500">FEATURED</th>
<th className="px-4 py-3 text-xs tracking-widest text-gray-500">ACTIONS</th>
</tr>
</thead>
<tbody className="divide-y">
{artworks.sort((a, b) => a.sortOrder - b.sortOrder).map((artwork) => (
<tr key={artwork.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
{artwork.image && (
<div className="w-12 h-12 rounded overflow-hidden bg-gray-100">
<img src={artwork.image} alt="" className="w-full h-full object-cover" />
</div>
)}
</td>
<td className="px-4 py-3 font-medium">{artwork.title}</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-1 rounded-full ${
artwork.category === 'available' ? 'bg-green-100 text-green-700' :
artwork.category === 'sold' ? 'bg-red-100 text-red-700' :
'bg-purple-100 text-purple-700'
}`}>
{artwork.category}
</span>
</td>
<td className="px-4 py-3">{artwork.featured ? 'Yes' : ''}</td>
<td className="px-4 py-3">
<button onClick={() => { setEditing(artwork); setIsNew(false); }} className="text-blue-600 hover:text-blue-800 mr-3 text-xs">
Edit
</button>
<button onClick={() => handleDelete(artwork.id)} className="text-red-600 hover:text-red-800 text-xs">
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,252 @@
'use client';
import { useState, useEffect } from 'react';
import type { Service } from '@/lib/types';
export default function AdminServices() {
const [services, setServices] = useState<Service[]>([]);
const [editing, setEditing] = useState<Service | null>(null);
const [isNew, setIsNew] = useState(false);
const [loading, setLoading] = useState(true);
const fetchServices = async () => {
const res = await fetch('/api/admin/services');
setServices(await res.json());
setLoading(false);
};
useEffect(() => { fetchServices(); }, []);
const handleSave = async () => {
if (!editing) return;
const method = isNew ? 'POST' : 'PUT';
const url = isNew ? '/api/admin/services' : `/api/admin/services/${editing.id}`;
await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editing),
});
setEditing(null);
setIsNew(false);
fetchServices();
};
const handleDelete = async (id: string) => {
if (!confirm('Delete this service?')) return;
await fetch(`/api/admin/services/${id}`, { method: 'DELETE' });
fetchServices();
};
const handleUpload = async (file: File): Promise<string> => {
const form = new FormData();
form.append('file', file);
const res = await fetch('/api/admin/upload', { method: 'POST', body: form });
const data = await res.json();
return data.url || '';
};
const updateBullet = (field: 'howItWorks' | 'whoItIsFor', index: number, value: string) => {
if (!editing) return;
const arr = [...editing[field]];
arr[index] = value;
setEditing({ ...editing, [field]: arr });
};
const addBullet = (field: 'howItWorks' | 'whoItIsFor') => {
if (!editing) return;
setEditing({ ...editing, [field]: [...editing[field], ''] });
};
const removeBullet = (field: 'howItWorks' | 'whoItIsFor', index: number) => {
if (!editing) return;
setEditing({ ...editing, [field]: editing[field].filter((_, i) => i !== index) });
};
const newService = (): Service => ({
id: `service-${Date.now()}`,
title: '',
subtitle: '',
duration: '',
description: '',
howItWorks: ['', '', ''],
whoItIsFor: ['', '', ''],
image: '',
color: 'lavender',
calendlyLink: '',
highlighted: false,
sortOrder: services.length + 1,
});
if (loading) return <div className="p-8 text-gray-400">Loading...</div>;
return (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-light">Services</h1>
<button
onClick={() => { setEditing(newService()); setIsNew(true); }}
className="px-4 py-2 bg-[#2d2d2d] text-white rounded-lg text-sm hover:bg-[#c9a962] transition-colors"
>
+ Add Service
</button>
</div>
{/* Edit Modal */}
{editing && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto p-6">
<h2 className="text-xl font-light mb-4">{isNew ? 'New Service' : 'Edit Service'}</h2>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs tracking-widest mb-1">TITLE</label>
<input value={editing.title} onChange={(e) => setEditing({...editing, title: e.target.value})}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962]" />
</div>
<div>
<label className="block text-xs tracking-widest mb-1">SUBTITLE</label>
<input value={editing.subtitle} onChange={(e) => setEditing({...editing, subtitle: e.target.value})}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962]" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs tracking-widest mb-1">DURATION</label>
<input value={editing.duration} onChange={(e) => setEditing({...editing, duration: e.target.value})}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962]" />
</div>
<div>
<label className="block text-xs tracking-widest mb-1">COLOR</label>
<select value={editing.color} onChange={(e) => setEditing({...editing, color: e.target.value})}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962]">
<option value="lavender">Lavender</option>
<option value="pink">Pink</option>
<option value="mint">Mint</option>
<option value="rose">Rose</option>
</select>
</div>
</div>
<div>
<label className="block text-xs tracking-widest mb-1">DESCRIPTION</label>
<textarea value={editing.description} onChange={(e) => setEditing({...editing, description: e.target.value})} rows={3}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962] resize-none" />
</div>
<div>
<label className="block text-xs tracking-widest mb-1">IMAGE</label>
{editing.image && (
<div className="w-20 h-14 bg-gray-100 rounded overflow-hidden mb-2">
<img src={editing.image} alt="" className="w-full h-full object-cover" />
</div>
)}
<label className="text-xs text-blue-600 hover:text-blue-800 cursor-pointer block mb-2">
Upload image
<input type="file" accept="image/*" className="hidden" onChange={async (e) => {
const file = e.target.files?.[0];
if (file) { const url = await handleUpload(file); if (url) setEditing({...editing, image: url}); }
}} />
</label>
<input value={editing.image} onChange={(e) => setEditing({...editing, image: e.target.value})}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962]"
placeholder="Or paste image path" />
</div>
{/* How it works */}
<div>
<label className="block text-xs tracking-widest mb-2">HOW IT WORKS</label>
{editing.howItWorks.map((bullet, i) => (
<div key={i} className="flex gap-2 mb-2">
<input value={bullet} onChange={(e) => updateBullet('howItWorks', i, e.target.value)}
className="flex-1 px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962]" />
<button onClick={() => removeBullet('howItWorks', i)} className="text-red-400 hover:text-red-600 text-xs px-2">x</button>
</div>
))}
<button onClick={() => addBullet('howItWorks')} className="text-xs text-blue-600 hover:text-blue-800">+ Add bullet</button>
</div>
{/* Who it is for */}
<div>
<label className="block text-xs tracking-widest mb-2">WHO IT IS FOR</label>
{editing.whoItIsFor.map((bullet, i) => (
<div key={i} className="flex gap-2 mb-2">
<input value={bullet} onChange={(e) => updateBullet('whoItIsFor', i, e.target.value)}
className="flex-1 px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962]" />
<button onClick={() => removeBullet('whoItIsFor', i)} className="text-red-400 hover:text-red-600 text-xs px-2">x</button>
</div>
))}
<button onClick={() => addBullet('whoItIsFor')} className="text-xs text-blue-600 hover:text-blue-800">+ Add bullet</button>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs tracking-widest mb-1">CALENDLY LINK</label>
<input value={editing.calendlyLink} onChange={(e) => setEditing({...editing, calendlyLink: e.target.value})}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962]"
placeholder="https://calendly.com/..." />
</div>
<div>
<label className="block text-xs tracking-widest mb-1">SORT ORDER</label>
<input type="number" value={editing.sortOrder} onChange={(e) => setEditing({...editing, sortOrder: parseInt(e.target.value) || 0})}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:border-[#c9a962]" />
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={editing.highlighted} onChange={(e) => setEditing({...editing, highlighted: e.target.checked})} />
Highlighted (gold ring)
</label>
</div>
<div className="flex gap-3 mt-6">
<button onClick={handleSave} className="px-6 py-2 bg-[#2d2d2d] text-white rounded-lg text-sm hover:bg-[#c9a962] transition-colors">
Save
</button>
<button onClick={() => { setEditing(null); setIsNew(false); }} className="px-6 py-2 border rounded-lg text-sm hover:bg-gray-50">
Cancel
</button>
</div>
</div>
</div>
)}
{/* Services List */}
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left">
<tr>
<th className="px-4 py-3 text-xs tracking-widest text-gray-500">IMAGE</th>
<th className="px-4 py-3 text-xs tracking-widest text-gray-500">TITLE</th>
<th className="px-4 py-3 text-xs tracking-widest text-gray-500">SUBTITLE</th>
<th className="px-4 py-3 text-xs tracking-widest text-gray-500">COLOR</th>
<th className="px-4 py-3 text-xs tracking-widest text-gray-500">ACTIONS</th>
</tr>
</thead>
<tbody className="divide-y">
{services.sort((a, b) => a.sortOrder - b.sortOrder).map((service) => (
<tr key={service.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
{service.image && (
<div className="w-16 h-10 rounded overflow-hidden bg-gray-100">
<img src={service.image} alt="" className="w-full h-full object-cover" />
</div>
)}
</td>
<td className="px-4 py-3 font-medium">{service.title}</td>
<td className="px-4 py-3 text-gray-500">{service.subtitle}</td>
<td className="px-4 py-3">
<span className={`inline-block w-4 h-4 rounded-full ${
service.color === 'lavender' ? 'bg-[#d4c4e8]' :
service.color === 'pink' ? 'bg-[#e8c4d4]' :
service.color === 'mint' ? 'bg-[#c4e8d4]' :
'bg-[#e8d4c4]'
}`} />
</td>
<td className="px-4 py-3">
<button onClick={() => { setEditing(service); setIsNew(false); }} className="text-blue-600 hover:text-blue-800 mr-3 text-xs">Edit</button>
<button onClick={() => handleDelete(service.id)} className="text-red-600 hover:text-red-800 text-xs">Delete</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { getArtwork, updateArtwork, deleteArtwork } from '@/lib/data';
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const artwork = getArtwork(id);
if (!artwork) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json(artwork);
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
try {
const body = await request.json();
const updated = updateArtwork(id, body);
if (!updated) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json(updated);
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const deleted = deleteArtwork(id);
if (!deleted) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ success: true });
}

View File

@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { getArtworks, addArtwork } from '@/lib/data';
import type { Artwork } from '@/lib/types';
export async function GET() {
return NextResponse.json(getArtworks());
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const artwork: Artwork = {
id: body.id || `art-${Date.now()}`,
title: body.title || '',
medium: body.medium || '',
dimensions: body.dimensions || '',
year: body.year || '',
price: body.price || '',
image: body.image || '',
category: body.category || 'available',
featured: body.featured || false,
sortOrder: body.sortOrder ?? getArtworks().length + 1,
};
addArtwork(artwork);
return NextResponse.json(artwork, { status: 201 });
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}

View File

@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { getEvent, updateEvent, deleteEvent } from '@/lib/data';
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const event = getEvent(id);
if (!event) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json(event);
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
try {
const body = await request.json();
const updated = updateEvent(id, body);
if (!updated) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json(updated);
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const deleted = deleteEvent(id);
if (!deleted) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ success: true });
}

View File

@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { getEvents, addEvent } from '@/lib/data';
import type { ArtEvent } from '@/lib/types';
export async function GET() {
return NextResponse.json(getEvents());
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const event: ArtEvent = {
id: body.id || `event-${Date.now()}`,
title: body.title || '',
description: body.description || '',
image: body.image || '',
date: body.date || '',
link: body.link || '',
section: body.section || 'reevolution',
objectPosition: body.objectPosition || undefined,
sortOrder: body.sortOrder ?? getEvents().length + 1,
};
addEvent(event);
return NextResponse.json(event, { status: 201 });
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}

View File

@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
import { validatePassword, createSessionToken, getSessionCookie } from '@/lib/auth';
export async function POST(request: NextRequest) {
try {
const { password } = await request.json();
if (!password || !validatePassword(password)) {
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
}
const token = await createSessionToken();
const response = NextResponse.json({ success: true });
response.headers.set('Set-Cookie', getSessionCookie(token));
return response;
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}

View File

@ -0,0 +1,8 @@
import { NextResponse } from 'next/server';
import { clearSessionCookie } from '@/lib/auth';
export async function POST() {
const response = NextResponse.json({ success: true });
response.headers.set('Set-Cookie', clearSessionCookie());
return response;
}

View File

@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { getService, updateService, deleteService } from '@/lib/data';
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const service = getService(id);
if (!service) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json(service);
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
try {
const body = await request.json();
const updated = updateService(id, body);
if (!updated) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json(updated);
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const deleted = deleteService(id);
if (!deleted) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ success: true });
}

View File

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServices, addService } from '@/lib/data';
import type { Service } from '@/lib/types';
export async function GET() {
return NextResponse.json(getServices());
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const service: Service = {
id: body.id || `service-${Date.now()}`,
title: body.title || '',
subtitle: body.subtitle || '',
duration: body.duration || '',
description: body.description || '',
howItWorks: body.howItWorks || [],
whoItIsFor: body.whoItIsFor || [],
image: body.image || '',
color: body.color || 'lavender',
calendlyLink: body.calendlyLink || '',
highlighted: body.highlighted || false,
sortOrder: body.sortOrder ?? getServices().length + 1,
};
addService(service);
return NextResponse.json(service, { status: 201 });
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}

View File

@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server';
import { writeFileSync, mkdirSync, existsSync } from 'fs';
import { join } from 'path';
const DATA_DIR = process.env.DATA_DIR || '/data';
const UPLOAD_DIR = join(DATA_DIR, 'uploads');
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
if (file.size > MAX_SIZE) {
return NextResponse.json({ error: 'File too large (max 10MB)' }, { status: 400 });
}
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json({ error: 'Invalid file type' }, { status: 400 });
}
if (!existsSync(UPLOAD_DIR)) {
mkdirSync(UPLOAD_DIR, { recursive: true });
}
const ext = file.name.split('.').pop() || 'jpg';
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_');
const filename = `${Date.now()}-${safeName}`;
const filepath = join(UPLOAD_DIR, filename);
const buffer = Buffer.from(await file.arrayBuffer());
writeFileSync(filepath, buffer);
return NextResponse.json({
url: `/uploads/${filename}`,
filename,
size: file.size,
type: file.type,
});
} catch {
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
}
}

View File

@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { readFileSync, existsSync } from 'fs';
import { join, extname } from 'path';
const DATA_DIR = process.env.DATA_DIR || '/data';
const UPLOAD_DIR = join(DATA_DIR, 'uploads');
const MIME_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif',
};
export async function GET(_request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params;
const filename = path.join('/');
// Prevent directory traversal
if (filename.includes('..')) {
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
}
const filepath = join(UPLOAD_DIR, filename);
if (!existsSync(filepath)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const ext = extname(filepath).toLowerCase();
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
const buffer = readFileSync(filepath);
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
}

843
src/app/home-client.tsx Normal file
View File

@ -0,0 +1,843 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import type { Artwork, ArtEvent, Service } from '@/lib/types';
// Navigation Component
function Navigation() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
return (
<>
<nav className="fixed top-0 left-0 right-0 z-50 glass">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<Link href="/" className="text-2xl font-light tracking-widest">
XHIVA ART
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-2">
<Link href="/" className="nav-link">Home</Link>
<Link href="#art" className="nav-link">Art</Link>
<Link href="#services" className="nav-link">Services</Link>
<Link href="#reevolution" className="nav-link">Re Evolution Art</Link>
<Link href="/about" className="nav-link">About</Link>
<Link href="#contact" className="nav-link">Contact</Link>
</div>
{/* Mobile Menu Button */}
<button
className="md:hidden p-2"
onClick={() => setMobileMenuOpen(true)}
aria-label="Open menu"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
</div>
</nav>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="mobile-menu">
<button
className="absolute top-6 right-6 p-2"
onClick={() => setMobileMenuOpen(false)}
aria-label="Close menu"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
<Link href="/" className="nav-link text-xl" onClick={() => setMobileMenuOpen(false)}>Home</Link>
<Link href="#art" className="nav-link text-xl" onClick={() => setMobileMenuOpen(false)}>Art</Link>
<Link href="#services" className="nav-link text-xl" onClick={() => setMobileMenuOpen(false)}>Services</Link>
<Link href="#reevolution" className="nav-link text-xl" onClick={() => setMobileMenuOpen(false)}>Re Evolution Art</Link>
<Link href="/about" className="nav-link text-xl" onClick={() => setMobileMenuOpen(false)}>About</Link>
<Link href="#contact" className="nav-link text-xl" onClick={() => setMobileMenuOpen(false)}>Contact</Link>
</div>
)}
</>
);
}
// Hero Section
function HeroSection() {
return (
<section className="min-h-screen relative flex flex-col items-center justify-center text-center px-6 pt-20">
<div className="absolute inset-0 z-0">
<Image
src="/images/hero/hero-main.jpg"
alt="Ximena Xaguar"
fill
className="object-cover"
priority
/>
<div className="absolute inset-0 bg-gradient-to-b from-[var(--bg-cream)]/90 via-[var(--bg-cream)]/70 to-[var(--bg-cream)]/90" />
</div>
<div className="max-w-4xl mx-auto relative z-10">
<p className="font-sans-alt text-xs tracking-[0.3em] text-[var(--text-muted)] mb-6 fade-in">
MULTIDISCIPLINARY VISIONARY ARTIST
</p>
<h1 className="text-5xl md:text-7xl lg:text-8xl font-light tracking-wider mb-8 fade-in stagger-1">
XIMENA XAGUAR
</h1>
<div className="divider fade-in stagger-2"></div>
<p className="text-lg md:text-xl max-w-2xl mx-auto mb-16 leading-relaxed opacity-80 fade-in stagger-2">
Visionary art, immersive experiences and ceremonial practice.
Creating at the intersection of art, ritual and embodied presence.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center fade-in stagger-3">
<Link href="#art" className="btn-outline">
Explore the Gallery
</Link>
<Link href="#reevolution" className="btn-filled">
Discover Re Evolution Art
</Link>
</div>
</div>
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce opacity-50 z-10">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M12 5v14M5 12l7 7 7-7"/>
</svg>
</div>
</section>
);
}
// Art Gallery Section — enhanced with featured, metadata, sold carousel
function GallerySection({ artworks }: { artworks: Artwork[] }) {
const [selectedArtwork, setSelectedArtwork] = useState<Artwork | null>(null);
const featured = artworks.filter(a => a.featured && a.category !== 'sold').sort((a, b) => a.sortOrder - b.sortOrder);
const available = artworks.filter(a => !a.featured && a.category === 'available').sort((a, b) => a.sortOrder - b.sortOrder);
const soldOrPrivate = artworks.filter(a => a.category === 'sold' || a.category === 'private').sort((a, b) => a.sortOrder - b.sortOrder);
return (
<section id="art" className="section bg-white">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-12">
<p className="font-sans-alt text-xs tracking-[0.2em] text-[var(--accent-gold)] mb-4">
GALLERY
</p>
<h2 className="text-4xl md:text-5xl font-light mb-6">
Visionary Artworks
</h2>
<div className="divider"></div>
</div>
{/* Featured Artworks — large format */}
{featured.length > 0 && (
<div className="grid md:grid-cols-3 gap-6 mb-12">
{featured.map((artwork) => (
<div
key={artwork.id}
className="relative aspect-[3/4] rounded-xl overflow-hidden group cursor-pointer shadow-lg"
onClick={() => setSelectedArtwork(artwork)}
>
<Image
src={artwork.image}
alt={artwork.title}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="absolute bottom-0 left-0 right-0 p-4 translate-y-full group-hover:translate-y-0 transition-transform duration-300">
<h3 className="text-white text-lg font-light">{artwork.title}</h3>
{artwork.medium && <p className="text-white/70 text-xs mt-1">{artwork.medium}</p>}
{artwork.price && <p className="text-[var(--accent-gold)] text-sm mt-1">{artwork.price}</p>}
</div>
</div>
))}
</div>
)}
{/* All Artworks Grid */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{available.map((artwork) => (
<div
key={artwork.id}
className="relative aspect-square rounded-lg overflow-hidden group cursor-pointer"
onClick={() => setSelectedArtwork(artwork)}
>
<Image
src={artwork.image}
alt={artwork.title}
fill
className="object-cover transition-transform duration-500 group-hover:scale-110"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors duration-300 flex items-end justify-center pb-4">
<span className="text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300 font-sans-alt text-xs tracking-widest">
{artwork.title}
</span>
</div>
</div>
))}
</div>
{/* Sold & Private Collections */}
{soldOrPrivate.length > 0 && (
<div className="mt-16">
<h3 className="text-center text-2xl font-light mb-2">Sold &amp; Private Collections</h3>
<div className="divider"></div>
<div className="flex gap-4 overflow-x-auto pb-4 mt-8 snap-x snap-mandatory">
{soldOrPrivate.map((artwork) => (
<div
key={artwork.id}
className="relative w-48 h-48 rounded-lg overflow-hidden group cursor-pointer shrink-0 snap-start opacity-80 hover:opacity-100 transition-opacity"
onClick={() => setSelectedArtwork(artwork)}
>
<Image
src={artwork.image}
alt={artwork.title}
fill
className="object-cover"
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 p-2">
<p className="text-white text-xs font-sans-alt tracking-wider">{artwork.title}</p>
<p className="text-white/50 text-[10px] uppercase">{artwork.category}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Lightbox Modal */}
{selectedArtwork && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4 md:p-8"
onClick={() => setSelectedArtwork(null)}
>
<button
className="absolute top-4 right-4 md:top-6 md:right-6 z-50 text-white/70 hover:text-white transition-colors p-2"
onClick={() => setSelectedArtwork(null)}
aria-label="Close"
>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
<div className="relative w-full h-full max-w-6xl max-h-[85vh] flex flex-col items-center justify-center">
<div
className="relative w-full flex-1 min-h-0"
onClick={(e) => e.stopPropagation()}
>
<Image
src={selectedArtwork.image}
alt={selectedArtwork.title}
fill
className="object-contain"
sizes="(max-width: 768px) 100vw, 90vw"
/>
</div>
<div
className="mt-4 text-center"
onClick={(e) => e.stopPropagation()}
>
<p className="text-white/90 font-light text-lg">{selectedArtwork.title}</p>
<div className="flex items-center justify-center gap-4 mt-2 text-white/50 font-sans-alt text-xs tracking-wider">
{selectedArtwork.medium && <span>{selectedArtwork.medium}</span>}
{selectedArtwork.dimensions && <span>{selectedArtwork.dimensions}</span>}
{selectedArtwork.year && <span>{selectedArtwork.year}</span>}
</div>
{selectedArtwork.price && selectedArtwork.category === 'available' && (
<p className="text-[var(--accent-gold)] text-sm mt-2">{selectedArtwork.price}</p>
)}
{selectedArtwork.category === 'sold' && (
<p className="text-red-400/70 text-xs mt-2 uppercase tracking-widest">Sold</p>
)}
</div>
</div>
</div>
)}
</section>
);
}
// Services Section — restructured with bullet points
function ServicesSection({ services }: { services: Service[] }) {
const sorted = [...services].sort((a, b) => a.sortOrder - b.sortOrder);
return (
<section id="services" className="section">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<p className="font-sans-alt text-xs tracking-[0.2em] text-[var(--accent-gold)] mb-4">
OFFERINGS
</p>
<h2 className="text-4xl md:text-5xl font-light mb-6">
Art, Ceremony &amp; Experience
</h2>
<div className="divider"></div>
<p className="text-lg max-w-2xl mx-auto opacity-80">
Personal offerings rooted in decades of embodied practice art, ceremony,
group immersion and creative exploration.
</p>
</div>
<div className="grid md:grid-cols-2 gap-8">
{sorted.map((service) => (
<div key={service.id} className={`group ${service.highlighted ? 'ring-2 ring-[var(--accent-gold)] rounded-xl' : ''}`}>
<div className="relative aspect-[4/3] rounded-t-xl overflow-hidden">
<Image
src={service.image}
alt={service.title}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</div>
<div className={`service-card ${service.color} rounded-t-none`}>
<h3 className="text-2xl font-light mb-2">{service.title}</h3>
<p className="font-sans-alt text-xs tracking-widest text-[var(--text-muted)] mb-1">
{service.subtitle}
</p>
<p className="font-sans-alt text-xs tracking-widest text-[var(--accent-gold)] mb-4">
{service.duration}
</p>
<p className="text-sm leading-relaxed opacity-80 mb-4">
{service.description}
</p>
{/* How it works */}
{service.howItWorks.length > 0 && service.howItWorks[0] && (
<div className="text-left mt-4 mb-3">
<p className="font-sans-alt text-[10px] tracking-[0.2em] text-[var(--text-muted)] mb-2 uppercase">How it works</p>
<ul className="space-y-1">
{service.howItWorks.filter(Boolean).map((item, i) => (
<li key={i} className="text-sm opacity-80 flex items-start gap-2">
<span className="text-[var(--accent-gold)] mt-0.5">&#8226;</span>
{item}
</li>
))}
</ul>
</div>
)}
{/* Who it is for */}
{service.whoItIsFor.length > 0 && service.whoItIsFor[0] && (
<div className="text-left mb-4">
<p className="font-sans-alt text-[10px] tracking-[0.2em] text-[var(--text-muted)] mb-2 uppercase">Who it is for</p>
<ul className="space-y-1">
{service.whoItIsFor.filter(Boolean).map((item, i) => (
<li key={i} className="text-sm opacity-80 flex items-start gap-2">
<span className="text-[var(--accent-gold)] mt-0.5">&#8226;</span>
{item}
</li>
))}
</ul>
</div>
)}
{service.calendlyLink && (
<div className="mt-4">
<a href={service.calendlyLink} target="_blank" rel="noopener noreferrer" className="btn-outline text-xs">
Book Now
</a>
</div>
)}
</div>
</div>
))}
</div>
<div className="flex justify-center mt-20">
<a href="https://booking.xhiva.art" target="_blank" rel="noopener noreferrer" className="btn-outline">
Book a Session
</a>
</div>
</div>
</section>
);
}
// Re Evolution Art Section — clearly separated
function ReEvolutionSection({ events }: { events: ArtEvent[] }) {
const reevolutionEvents = events
.filter(e => e.section === 'reevolution')
.sort((a, b) => a.sortOrder - b.sortOrder);
return (
<section id="reevolution" className="section dark-section">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
{/* Logo */}
<div className="relative w-full max-w-96 aspect-[3/2] mx-auto mb-2 overflow-hidden">
<Image
src="/images/reevolution/logo.png"
alt="Re Evolution Art Logo"
fill
className="object-contain object-center scale-150"
/>
</div>
<p style={{ fontFamily: "'Narrenschiff', sans-serif" }} className="text-xs tracking-[0.2em] text-[var(--accent-gold)] mb-4">
CULTURAL PLATFORM
</p>
<h2 style={{ fontFamily: "'Krown', sans-serif", fontWeight: 700 }} className="text-4xl md:text-5xl mb-6">
Re Evolution Art
</h2>
<div className="divider"></div>
<p style={{ fontFamily: "'Narrenschiff', sans-serif" }} className="text-lg max-w-3xl mx-auto opacity-80">
A visionary cultural platform based in Switzerland and rooted in Bolivia. Through exhibitions,
immersive gatherings, TRIBAL events and collaborative projects, we bring together artists,
ritualists, musicians and independent creators engaged in inner work and cultural dialogue.
</p>
</div>
<div className="grid md:grid-cols-2 gap-6">
{reevolutionEvents.map((event) => (
<div key={event.id} className="event-card group overflow-hidden">
<div className="relative aspect-video rounded-lg overflow-hidden mb-4">
<Image
src={event.image}
alt={event.title}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
style={event.objectPosition ? { objectPosition: event.objectPosition } : undefined}
/>
</div>
<h3 style={{ fontFamily: "'Krown', sans-serif", fontWeight: 700 }} className="text-xl mb-2 text-[var(--accent-gold)]">
{event.title}
</h3>
{event.date && (
<p style={{ fontFamily: "'Narrenschiff', sans-serif" }} className="text-xs text-[var(--accent-gold)]/70 mb-2">
{event.date}
</p>
)}
<p style={{ fontFamily: "'Narrenschiff', sans-serif" }} className="text-sm opacity-70 leading-relaxed">
{event.description}
</p>
{event.link && (
<a href={event.link} target="_blank" rel="noopener noreferrer"
className="inline-block mt-3 text-xs text-[var(--accent-gold)] hover:text-white transition-colors"
style={{ fontFamily: "'Narrenschiff', sans-serif" }}>
Learn more &rarr;
</a>
)}
</div>
))}
</div>
{/* Social Links */}
<div className="flex justify-center gap-6 mt-16">
<a
href="https://www.instagram.com/reevolutionart"
target="_blank"
rel="noopener noreferrer"
className="btn-outline btn-outline-light"
style={{ fontFamily: "'Narrenschiff', sans-serif" }}
>
Instagram
</a>
<a
href="https://www.facebook.com/groups/383432167715274/"
target="_blank"
rel="noopener noreferrer"
className="btn-outline btn-outline-light"
style={{ fontFamily: "'Narrenschiff', sans-serif" }}
>
Facebook Community
</a>
</div>
</div>
</section>
);
}
// Testimonials Section
function TestimonialsSection() {
const testimonials = [
{
quote: "The crystal healing session created an immediate connection and brought my soul forth. I deeply appreciate the support through the process.",
author: "Dieter Katterbach",
},
{
quote: "The sweat lodge ceremony was powerfully moving, highlighting the integration of healing, compassion and authentic expression.",
author: "Kermit Goodman",
},
{
quote: "The experience was powerfully transformative \u2014 it changed my understanding of ceremony itself.",
author: "Verana Bailowitz",
},
];
return (
<section className="section bg-white">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<p className="font-sans-alt text-xs tracking-[0.2em] text-[var(--accent-gold)] mb-4">
TESTIMONIALS
</p>
<h2 className="text-4xl md:text-5xl font-light mb-6">
Words of Gratitude
</h2>
<div className="divider"></div>
</div>
<div className="grid md:grid-cols-3 gap-8">
{testimonials.map((testimonial, index) => (
<div key={index} className="testimonial-card">
<span className="quote-mark">&ldquo;</span>
<p className="testimonial mb-6">
{testimonial.quote}
</p>
<p className="font-sans-alt text-xs tracking-widest text-[var(--accent-gold)]">
&mdash; {testimonial.author}
</p>
</div>
))}
</div>
</div>
</section>
);
}
// Work With Me Section
function WorkWithMeSection() {
return (
<section className="section relative overflow-hidden">
<div className="absolute inset-0 z-0 bg-[#0f0f0f]">
<Image
src="/images/art/wayra-bg.webp"
alt="Wayra"
fill
className="object-contain opacity-40"
/>
</div>
<div className="max-w-3xl mx-auto relative z-10">
<div className="text-center">
<h2 className="text-4xl md:text-5xl font-light mb-6 text-[var(--text-light)]">
Work With Me
</h2>
<div className="divider"></div>
<p className="text-lg leading-relaxed mb-16 opacity-80 max-w-2xl mx-auto text-[var(--text-light)]">
This work is for those ready to meet themselves honestly with courage,
sensitivity and presence.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<a href="https://booking.xhiva.art" target="_blank" rel="noopener noreferrer" className="btn-outline btn-outline-light">
Book a Session
</a>
<Link href="#contact" className="btn-filled" style={{ background: 'var(--accent-gold)', borderColor: 'var(--accent-gold)' }}>
Contact Me
</Link>
</div>
</div>
</section>
);
}
// Contact Section
function ContactSection() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !email.trim() || !message.trim()) {
setStatus('error');
setErrorMessage('Please fill in all fields.');
return;
}
setStatus('loading');
setErrorMessage('');
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim(), email: email.trim(), message: message.trim() }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Something went wrong');
}
setStatus('success');
setName('');
setEmail('');
setMessage('');
} catch (err) {
setStatus('error');
setErrorMessage(err instanceof Error ? err.message : 'Failed to send message. Please try again.');
}
};
return (
<section id="contact" className="section bg-white">
<div className="max-w-4xl mx-auto">
<div className="text-center">
<p className="font-sans-alt text-xs tracking-[0.2em] text-[var(--accent-gold)] mb-4">
GET IN TOUCH
</p>
<h2 className="text-4xl md:text-5xl font-light mb-6">
Get in Touch
</h2>
<div className="divider"></div>
<p className="text-lg leading-relaxed mb-12 opacity-80 max-w-2xl mx-auto">
For inquiries about sessions, art commissions, or event collaborations,
please reach out through the form below or connect on social media.
</p>
</div>
{status === 'success' ? (
<div className="max-w-lg mx-auto text-center py-12">
<div className="text-5xl mb-6 text-[var(--accent-gold)]">&#10003;</div>
<h3 className="text-2xl font-light mb-4">Message Sent</h3>
<p className="text-lg opacity-80 mb-8">
Thank you for reaching out. I will get back to you soon.
</p>
<button
onClick={() => setStatus('idle')}
className="btn-outline"
>
Send Another Message
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="max-w-lg mx-auto text-left">
<div className="mb-6">
<label className="block font-sans-alt text-xs tracking-widest mb-2" htmlFor="name">
NAME
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-3 border border-gray-200 rounded-lg focus:outline-none focus:border-[var(--accent-gold)] transition-colors"
placeholder="Your name"
disabled={status === 'loading'}
/>
</div>
<div className="mb-6">
<label className="block font-sans-alt text-xs tracking-widest mb-2" htmlFor="email">
EMAIL
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 border border-gray-200 rounded-lg focus:outline-none focus:border-[var(--accent-gold)] transition-colors"
placeholder="your@email.com"
disabled={status === 'loading'}
/>
</div>
<div className="mb-6">
<label className="block font-sans-alt text-xs tracking-widest mb-2" htmlFor="message">
MESSAGE
</label>
<textarea
id="message"
rows={5}
value={message}
onChange={(e) => setMessage(e.target.value)}
className="w-full px-4 py-3 border border-gray-200 rounded-lg focus:outline-none focus:border-[var(--accent-gold)] transition-colors resize-none"
placeholder="How can I help?"
disabled={status === 'loading'}
/>
</div>
{status === 'error' && errorMessage && (
<p className="text-red-600 text-sm mb-4 font-sans-alt">{errorMessage}</p>
)}
<button
type="submit"
className="btn-filled w-full mt-4"
disabled={status === 'loading'}
style={status === 'loading' ? { opacity: 0.7, cursor: 'not-allowed' } : undefined}
>
{status === 'loading' ? 'Sending...' : 'Send Message'}
</button>
</form>
)}
</div>
</section>
);
}
// Social Connect Section
function SocialSection() {
const instagramPosts = [
{ image: '/images/art/soul-agreement.webp', alt: 'Soul Agreement' },
{ image: '/images/art/goddess.webp?v=2', alt: 'Goddess' },
{ image: '/images/art/twin-flames.webp', alt: 'Twin Flames' },
{ image: '/images/art/featured.jpg?v=2', alt: 'Featured Art' },
{ image: '/images/about/portrait-1.jpg', alt: 'Portrait' },
{ image: '/images/reevolution/dj-xhiva.jpg', alt: 'DJ XHIVA' },
];
return (
<section className="section bg-[var(--bg-cream)]">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<p className="font-sans-alt text-xs tracking-[0.2em] text-[var(--accent-gold)] mb-4">
FOLLOW THE JOURNEY
</p>
<h2 className="text-4xl md:text-5xl font-light mb-6">
Connect on Socials
</h2>
<div className="divider"></div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-10 max-w-4xl mx-auto">
{instagramPosts.map((post, index) => (
<a
key={index}
href="https://www.instagram.com/xhiva_art"
target="_blank"
rel="noopener noreferrer"
className="group bg-white rounded-xl shadow-md hover:shadow-xl transition-shadow duration-300 overflow-hidden border border-gray-100"
>
<div className="flex items-center gap-2 px-3 py-2.5">
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-[#f09433] via-[#e6683c] to-[#bc1888] p-[2px]">
<div className="w-full h-full rounded-full bg-white p-[1px]">
<div className="relative w-full h-full rounded-full overflow-hidden">
<Image src="/images/about/portrait-1.jpg" alt="xhiva_art" fill className="object-cover object-[30%_center]" />
</div>
</div>
</div>
<span className="text-xs font-semibold text-[var(--text-dark)]">xhiva_art</span>
</div>
<div className="relative aspect-square">
<Image
src={post.image}
alt={post.alt}
fill
className="object-cover"
style={post.image.includes('portrait-1') ? { objectPosition: '30% center' } : post.image.includes('dj-xhiva') ? { objectPosition: 'center top' } : undefined}
/>
</div>
<div className="px-3 py-2.5 flex items-center gap-4">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-[var(--text-dark)] group-hover:text-red-500 transition-colors">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-[var(--text-dark)]">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-[var(--text-dark)]">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</div>
</a>
))}
</div>
<div className="flex flex-col sm:flex-row justify-center items-center gap-4">
<a
href="https://www.instagram.com/xhiva_art"
target="_blank"
rel="noopener noreferrer"
className="btn-filled"
>
Follow @xhiva_art on Instagram
</a>
<a
href="https://www.facebook.com/XimenaXhivart"
target="_blank"
rel="noopener noreferrer"
className="btn-outline"
>
Follow on Facebook
</a>
</div>
</div>
</section>
);
}
// Footer
function Footer() {
return (
<footer className="dark-section py-16 px-6">
<div className="max-w-6xl mx-auto">
<div className="grid md:grid-cols-4 gap-12 mb-12">
<div className="md:col-span-2">
<h3 className="text-2xl font-light tracking-widest mb-4">XHIVA ART</h3>
<p className="text-sm opacity-70 leading-relaxed mb-6">
Visionary Art &middot; Ceremony &middot; Cultural Experiences
</p>
</div>
<div>
<h4 className="font-sans-alt text-xs tracking-widest mb-4">EXPLORE</h4>
<div className="flex flex-col gap-2">
<Link href="#art" className="footer-link">Art</Link>
<Link href="#services" className="footer-link">Services</Link>
<Link href="#reevolution" className="footer-link">Re Evolution Art</Link>
<Link href="/about" className="footer-link">About</Link>
</div>
</div>
<div>
<h4 className="font-sans-alt text-xs tracking-widest mb-4">CONNECT</h4>
<div className="flex flex-col gap-2">
<Link href="#contact" className="footer-link">Contact</Link>
<a href="https://instagram.com/ximena_xaguar" target="_blank" rel="noopener noreferrer" className="footer-link">
@ximena_xaguar
</a>
<a href="https://www.facebook.com/XimenaXhivart" target="_blank" rel="noopener noreferrer" className="footer-link">
Facebook
</a>
</div>
</div>
</div>
<div className="border-t border-white/10 pt-8 text-center">
<p className="font-sans-alt text-xs tracking-widest opacity-50">
&copy; {new Date().getFullYear()} XHIVA ART. ALL RIGHTS RESERVED.
</p>
</div>
</div>
</footer>
);
}
// Main Client Component
export default function HomeClient({
artworks,
events,
services,
}: {
artworks: Artwork[];
events: ArtEvent[];
services: Service[];
}) {
return (
<>
<Navigation />
<main>
<HeroSection />
<GallerySection artworks={artworks} />
<ServicesSection services={services} />
<ReEvolutionSection events={events} />
<TestimonialsSection />
<WorkWithMeSection />
<ContactSection />
<SocialSection />
</main>
<Footer />
</>
);
}

View File

@ -1,948 +1,12 @@
'use client';
import { getArtworks, getEvents, getServices } from '@/lib/data';
import HomeClient from './home-client';
import { useState } from 'react';
import Link from 'next/link';
import Image from 'next/image';
export const dynamic = 'force-dynamic';
// Navigation Component
function Navigation() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
return (
<>
<nav className="fixed top-0 left-0 right-0 z-50 glass">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<Link href="/" className="text-2xl font-light tracking-widest">
XHIVA ART
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-2">
<Link href="/" className="nav-link">Home</Link>
<Link href="#art" className="nav-link">Art</Link>
<Link href="#about" className="nav-link">About</Link>
<Link href="#services" className="nav-link">Services</Link>
<Link href="#reevolution" className="nav-link">Re Evolution Art</Link>
<Link href="#contact" className="nav-link">Contact</Link>
</div>
{/* Mobile Menu Button */}
<button
className="md:hidden p-2"
onClick={() => setMobileMenuOpen(true)}
aria-label="Open menu"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
</div>
</nav>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="mobile-menu">
<button
className="absolute top-6 right-6 p-2"
onClick={() => setMobileMenuOpen(false)}
aria-label="Close menu"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
<Link href="/" className="nav-link text-xl" onClick={() => setMobileMenuOpen(false)}>Home</Link>
<Link href="#art" className="nav-link text-xl" onClick={() => setMobileMenuOpen(false)}>Art</Link>
<Link href="#about" className="nav-link text-xl" onClick={() => setMobileMenuOpen(false)}>About</Link>
<Link href="#services" className="nav-link text-xl" onClick={() => setMobileMenuOpen(false)}>Services</Link>
<Link href="#reevolution" className="nav-link text-xl" onClick={() => setMobileMenuOpen(false)}>Re Evolution Art</Link>
<Link href="#contact" className="nav-link text-xl" onClick={() => setMobileMenuOpen(false)}>Contact</Link>
</div>
)}
</>
);
}
// Hero Section
function HeroSection() {
return (
<section className="min-h-screen relative flex flex-col items-center justify-center text-center px-6 pt-20">
{/* Background Image */}
<div className="absolute inset-0 z-0">
<Image
src="/images/hero/hero-main.jpg"
alt="Ximena Xaguar"
fill
className="object-cover"
priority
/>
<div className="absolute inset-0 bg-gradient-to-b from-[var(--bg-cream)]/90 via-[var(--bg-cream)]/70 to-[var(--bg-cream)]/90" />
</div>
<div className="max-w-4xl mx-auto relative z-10">
<p className="font-sans-alt text-xs tracking-[0.3em] text-[var(--text-muted)] mb-6 fade-in">
MULTIDISCIPLINARY VISIONARY ARTIST
</p>
<h1 className="text-5xl md:text-7xl lg:text-8xl font-light tracking-wider mb-8 fade-in stagger-1">
XIMENA XAGUAR
</h1>
<div className="divider fade-in stagger-2"></div>
<p className="text-lg md:text-xl max-w-2xl mx-auto mb-16 leading-relaxed opacity-80 fade-in stagger-2">
Working at the intersection of art, ritual and embodied presence, creating experiences
that support clarity, integration, exploration and transformation.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center fade-in stagger-3">
<Link href="#art" className="btn-outline">
Explore Art & Ritual Sessions
</Link>
<Link href="#reevolution" className="btn-filled">
Discover Re Evolution Art
</Link>
</div>
</div>
{/* Scroll indicator */}
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce opacity-50 z-10">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M12 5v14M5 12l7 7 7-7"/>
</svg>
</div>
</section>
);
}
// Ritual Art Alchemy Section
function RitualArtSection() {
return (
<section id="art" className="section bg-white">
<div className="max-w-6xl mx-auto">
<div className="grid md:grid-cols-2 gap-12 items-center">
{/* Featured Art Image */}
<div className="relative aspect-[3/4] rounded-2xl overflow-hidden shadow-xl">
<Image
src="/images/art/featured.jpg?v=2"
alt="Visionary Art by Ximena Xaguar"
fill
className="object-cover"
/>
</div>
<div className="text-center">
<p className="font-sans-alt text-xs tracking-[0.2em] text-[var(--accent-gold)] mb-4">
VISUAL ALCHEMY
</p>
<h2 className="text-4xl md:text-5xl font-light mb-6">
Ritual Art Alchemy
</h2>
<div className="divider"></div>
<p className="text-lg leading-relaxed mb-6 opacity-80">
My art is my ritual a journey of transformation. Each painting emerges from
cycles of death and rebirth, shadow work, intuitive vision and ancestral memory.
</p>
<p className="text-lg leading-relaxed mb-12 opacity-80">
Through symbolic language and universal cosmovision, I translate inner processes
into living images. These artworks are not objects. They are portals. Allies for
emotional integration, transformation, spiritual insight and energetic coherence.
</p>
<div className="pt-2">
<Link href="#gallery" className="btn-outline">
Enter the Gallery
</Link>
</div>
</div>
</div>
</div>
</section>
);
}
// Art Gallery Section
function GallerySection() {
const [selectedArtwork, setSelectedArtwork] = useState<{ src: string; title: string } | null>(null);
const artworks = [
{ src: '/images/art/goddess.webp?v=2', title: 'Goddess' },
{ src: '/images/art/mujer-medicina.webp', title: 'Mujer Medicina' },
{ src: '/images/art/shiva-main.webp', title: 'Shiva' },
{ src: '/images/art/twin-flames.webp', title: 'Twin Flames' },
{ src: '/images/art/soul-agreement.webp', title: 'Soul Agreement' },
{ src: '/images/art/madre.webp', title: 'Madre' },
{ src: '/images/art/warmy-munachi.webp', title: 'Warmy Munachi' },
{ src: '/images/art/mi-uma.webp', title: 'Mi Uma' },
{ src: '/images/art/re-evolucion-arte.webp', title: 'Re Evolución Arte' },
{ src: '/images/art/mujer-ser.webp', title: 'Mujer Ser' },
{ src: '/images/art/arte-parque.webp', title: 'Arte Parque' },
{ src: '/images/art/rainbow-tara.webp', title: 'Rainbow Tara' },
{ src: '/images/art/female-fire.webp', title: 'Female Fire' },
{ src: '/images/art/trinidad.webp', title: 'Trinidad' },
{ src: '/images/art/tantra.webp', title: 'Tantra' },
{ src: '/images/art/amazonas.webp', title: 'Amazonas' },
{ src: '/images/art/mujer-jaguar.webp', title: 'Mujer Jaguar' },
{ src: '/images/art/coming-from-the-darkness.webp', title: 'Coming from the Darkness' },
{ src: '/images/art/white-tara.webp', title: 'White Tara' },
{ src: '/images/art/aya.webp', title: 'Aya' },
{ src: '/images/art/pacha.webp', title: 'Pacha' },
{ src: '/images/art/pacha-detalle.webp', title: 'Pacha (Detalle)' },
{ src: '/images/art/warmy-arkanum.webp', title: 'Warmy Arkanum' },
{ src: '/images/art/sacro.webp', title: 'Sacro' },
{ src: '/images/art/inti-om.webp', title: 'Inti Om' },
{ src: '/images/art/totem-astral.webp', title: 'Totem Astral' },
{ src: '/images/art/sabiduria-ancestral.webp', title: 'Sabiduría Ancestral' },
{ src: '/images/art/amor-astral.webp', title: 'Amor Astral' },
{ src: '/images/art/escencia-mistica.webp', title: 'Escencia Mística' },
{ src: '/images/art/raiz.webp', title: 'Raíz' },
{ src: '/images/art/uni-verso.webp', title: 'Uni Verso' },
{ src: '/images/art/wayra.webp', title: 'Wayra' },
{ src: '/images/art/la-illimani.webp', title: 'La Illimani' },
{ src: '/images/about/process-1.webp', title: 'Eagle & Jaguar' },
{ src: '/images/about/process-2.webp', title: 'Kundalini' },
{ src: '/images/about/process-3.webp', title: 'Ancestral Totem' },
{ src: '/images/about/process-5.webp', title: 'Detail — Fire' },
{ src: '/images/about/process-6.webp', title: 'Sacred Union' },
{ src: '/images/about/process-7.webp', title: 'Shiva II' },
{ src: '/images/about/portrait-artist-2.webp', title: 'Golden Meditation' },
];
return (
<section id="gallery" className="section bg-[var(--bg-cream)]">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-12">
<p className="font-sans-alt text-xs tracking-[0.2em] text-[var(--accent-gold)] mb-4">
GALLERY
</p>
<h2 className="text-4xl md:text-5xl font-light mb-6">
Visionary Artworks
</h2>
<div className="divider"></div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{artworks.map((artwork, index) => (
<div
key={index}
className="relative aspect-square rounded-lg overflow-hidden group cursor-pointer"
onClick={() => setSelectedArtwork(artwork)}
>
<Image
src={artwork.src}
alt={artwork.title}
fill
className="object-cover transition-transform duration-500 group-hover:scale-110"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors duration-300 flex items-end justify-center pb-4">
<span className="text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300 font-sans-alt text-xs tracking-widest">
{artwork.title}
</span>
</div>
</div>
))}
</div>
</div>
{/* Lightbox Modal */}
{selectedArtwork && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4 md:p-8"
onClick={() => setSelectedArtwork(null)}
>
<button
className="absolute top-4 right-4 md:top-6 md:right-6 z-50 text-white/70 hover:text-white transition-colors p-2"
onClick={() => setSelectedArtwork(null)}
aria-label="Close"
>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
<div className="relative w-full h-full max-w-6xl max-h-[85vh] flex flex-col items-center justify-center">
<div
className="relative w-full flex-1 min-h-0"
onClick={(e) => e.stopPropagation()}
>
<Image
src={selectedArtwork.src}
alt={selectedArtwork.title}
fill
className="object-contain"
sizes="(max-width: 768px) 100vw, 90vw"
/>
</div>
<p
className="text-white/80 font-sans-alt text-sm tracking-widest mt-4 text-center"
onClick={(e) => e.stopPropagation()}
>
{selectedArtwork.title}
</p>
</div>
</div>
)}
</section>
);
}
// Services Section
function ServicesSection() {
const services = [
{
title: 'Crystal Therapy',
subtitle: 'Nervous System Alignment',
duration: 'Crystal reading / 1 hour',
description: 'One-to-one sessions supporting nervous system regulation and emotional integration through crystal energetic work, elemental somatic presence and intuitive mapping addressing emotional, physical, mental and spiritual coherence.',
color: 'lavender',
image: '/images/services/crystal-therapy.webp',
},
{
title: 'Temazcal',
subtitle: 'Medicine of the Four Elements',
duration: 'Fire - Earth - Water - Air',
description: 'Sweatlodge ceremonies guided for 15+ years, rooted in Native American ancestral tradition. Working with four elements and four bodies (physical, emotional, mental, spiritual) within a contained ritual space supporting purification, closure and renewal.',
color: 'mint',
image: '/images/services/temazcal.jpg',
},
{
title: 'Deep Integration',
subtitle: 'Transformation Session',
duration: '2 Hours',
description: 'Tailored container for life transitions, post-ceremonial integration or emotional transformation combining somatic work, crystal mapping, astrological and ritual guidance supporting embodiment of insight and conscious transformation.',
color: 'rose',
image: '/images/services/deep-integration.webp',
},
{
title: 'Soul Portrait',
subtitle: 'Art Alchemy',
duration: 'Group Workshops · TRIBAL Experience',
description: 'Weaving visionary art with therapeutic process through guided creative immersion. In group workshop settings, participants access deeper self-expression, emotional release and soul integration.',
color: 'pink',
image: '/images/art/soul-agreement.webp',
highlighted: true,
},
];
return (
<section id="services" className="section">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<p className="font-sans-alt text-xs tracking-[0.2em] text-[var(--accent-gold)] mb-4">
OFFERINGS
</p>
<h2 className="text-4xl md:text-5xl font-light mb-6">
Ritual Healing & Integration
</h2>
<div className="divider"></div>
<p className="text-lg max-w-2xl mx-auto opacity-80">
Decades of embodied practice since 2009. Current focus is integration and embodiment
supporting people to anchor insight, develop inner coherence and regulate nervous systems
within deep transformation processes.
</p>
</div>
<div className="grid md:grid-cols-2 gap-8">
{services.map((service, index) => (
<div key={index} className={`group ${service.highlighted ? 'ring-2 ring-[var(--accent-gold)] rounded-xl' : ''}`}>
<div className="relative aspect-[4/3] rounded-t-xl overflow-hidden">
<Image
src={service.image}
alt={service.title}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</div>
<div className={`service-card ${service.color} rounded-t-none`}>
<h3 className="text-2xl font-light mb-2">{service.title}</h3>
<p className="font-sans-alt text-xs tracking-widest text-[var(--text-muted)] mb-1">
{service.subtitle}
</p>
<p className="font-sans-alt text-xs tracking-widest text-[var(--accent-gold)] mb-6">
{service.duration}
</p>
<p className="text-sm leading-relaxed opacity-80">
{service.description}
</p>
</div>
</div>
))}
</div>
<div className="flex justify-center mt-20">
<a href="https://booking.xhiva.art" target="_blank" rel="noopener noreferrer" className="btn-outline">
Book a Session
</a>
</div>
</div>
</section>
);
}
// Re Evolution Art Section
function ReEvolutionSection() {
const events = [
{
title: 'Tribal Nights',
description: 'A signature event weaving ritual, dance and immersive art into one transformative experience.',
image: '/images/reevolution/tribal-night.jpg',
},
{
title: 'Tribal Experience',
description: 'Group immersion exploring the unconscious through shamanic journeying, art alchemy and embodied movement.',
image: '/images/reevolution/event-2.jpg',
},
{
title: 'Visionary Art Week Zürich',
description: 'Curated week featuring international artists, performances, live music, talks and workshops.',
image: '/images/reevolution/event-3.jpg',
},
{
title: 'Future Gatherings',
description: 'Experimental experiences weaving DJ sets with expanded states of presence.',
image: '/images/reevolution/dj-xhiva.jpg',
objectPosition: 'center top',
},
];
return (
<section id="reevolution" className="section dark-section">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
{/* Logo */}
<div className="relative w-full max-w-96 aspect-[3/2] mx-auto mb-2 overflow-hidden">
<Image
src="/images/reevolution/logo.png"
alt="Re Evolution Art Logo"
fill
className="object-contain object-center scale-150"
/>
</div>
<p style={{ fontFamily: "'Narrenschiff', sans-serif" }} className="text-xs tracking-[0.2em] text-[var(--accent-gold)] mb-4">
CULTURAL PLATFORM
</p>
<h2 style={{ fontFamily: "'Krown', sans-serif", fontWeight: 700 }} className="text-4xl md:text-5xl mb-6">
Re Evolution Art
</h2>
<div className="divider"></div>
<p style={{ fontFamily: "'Narrenschiff', sans-serif" }} className="text-lg max-w-3xl mx-auto opacity-80">
A visionary cultural platform based in Switzerland and rooted in Bolivia. Through exhibitions,
immersive gatherings, TRIBAL events and collaborative projects, we bring together artists,
ritualists, musicians and independent creators engaged in inner work and cultural dialogue.
We bridge ancestral memory with contemporary expression.
</p>
</div>
<div className="grid md:grid-cols-2 gap-6">
{events.map((event, index) => (
<div key={index} className="event-card group overflow-hidden">
<div className="relative aspect-video rounded-lg overflow-hidden mb-4">
<Image
src={event.image}
alt={event.title}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
style={event.objectPosition ? { objectPosition: event.objectPosition } : undefined}
/>
</div>
<h3 style={{ fontFamily: "'Krown', sans-serif", fontWeight: 700 }} className="text-xl mb-3 text-[var(--accent-gold)]">
{event.title}
</h3>
<p style={{ fontFamily: "'Narrenschiff', sans-serif" }} className="text-sm opacity-70 leading-relaxed">
{event.description}
</p>
</div>
))}
</div>
{/* Social Links */}
<div className="flex justify-center gap-6 mt-16">
<a
href="https://www.instagram.com/reevolutionart"
target="_blank"
rel="noopener noreferrer"
className="btn-outline btn-outline-light"
style={{ fontFamily: "'Narrenschiff', sans-serif" }}
>
Instagram
</a>
<a
href="https://www.facebook.com/groups/383432167715274/"
target="_blank"
rel="noopener noreferrer"
className="btn-outline btn-outline-light"
style={{ fontFamily: "'Narrenschiff', sans-serif" }}
>
Facebook Community
</a>
</div>
</div>
</section>
);
}
// Testimonials Section
function TestimonialsSection() {
const testimonials = [
{
quote: "The crystal healing session created an immediate connection and brought my soul forth. I deeply appreciate the support through the process.",
author: "Dieter Katterbach",
},
{
quote: "The sweat lodge ceremony was powerfully moving, highlighting the integration of healing, compassion and authentic expression.",
author: "Kermit Goodman",
},
{
quote: "The experience was powerfully transformative — it changed my understanding of ceremony itself.",
author: "Verana Bailowitz",
},
];
return (
<section className="section bg-white">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<p className="font-sans-alt text-xs tracking-[0.2em] text-[var(--accent-gold)] mb-4">
TESTIMONIALS
</p>
<h2 className="text-4xl md:text-5xl font-light mb-6">
Words of Gratitude
</h2>
<div className="divider"></div>
</div>
<div className="grid md:grid-cols-3 gap-8">
{testimonials.map((testimonial, index) => (
<div key={index} className="testimonial-card">
<span className="quote-mark">"</span>
<p className="testimonial mb-6">
{testimonial.quote}
</p>
<p className="font-sans-alt text-xs tracking-widest text-[var(--accent-gold)]">
{testimonial.author}
</p>
</div>
))}
</div>
</div>
</section>
);
}
// About Section
function AboutSection() {
return (
<section id="about" className="section">
<div className="max-w-6xl mx-auto">
<div className="grid md:grid-cols-2 gap-12 items-center">
<div className="text-center order-2 md:order-1">
<p className="font-sans-alt text-xs tracking-[0.2em] text-[var(--accent-gold)] mb-4">
THE ARTIST
</p>
<h2 className="text-4xl md:text-5xl font-light mb-6">
About Ximena
</h2>
<div className="divider"></div>
<p className="text-lg leading-relaxed mb-6 opacity-80">
Years dedicated to work in Bolivia, Peru and Switzerland shaped this path through
emotional integration, somatic healing and ceremonial support.
</p>
<p className="text-lg leading-relaxed mb-12 opacity-80">
A beautiful, talented artist rooted in ancestral culture and land connection
healer, visionary artist, mother and friend providing deep support through inner work.
</p>
<div className="pt-2">
<Link href="#contact" className="btn-outline">
Connect
</Link>
</div>
</div>
{/* Portrait */}
<div className="relative aspect-[3/4] rounded-2xl overflow-hidden shadow-xl order-1 md:order-2">
<Image
src="/images/about/portrait-1.jpg"
alt="Ximena Xaguar"
fill
className="object-cover object-[30%_center]"
/>
</div>
</div>
{/* Art in Motion */}
<div className="mt-16 text-center">
<p className="font-sans-alt text-xs tracking-[0.2em] text-[var(--accent-gold)] mb-4">
THE PROCESS
</p>
<h3 className="text-3xl md:text-4xl font-light mb-6">
Art in Motion
</h3>
<div className="divider"></div>
</div>
<div className="grid grid-cols-3 gap-3 mt-8">
{[
{ src: '/images/about/painting-process.webp', alt: 'Ximena painting', position: 'top' },
{ src: '/images/art/mural-bio-centro.webp', alt: 'Mural Bio Centro Guembe' },
{ src: '/images/about/portrait-2.jpg', alt: 'Ximena painting mural' },
{ src: '/images/about/portrait-3.jpg', alt: 'Ximena in studio', position: 'top' },
{ src: '/images/about/portrait-4.jpg', alt: 'Ximena at ceremony' },
{ src: '/images/about/pachamama.jpg', alt: 'Ximena — ritual with smoke' },
].map((photo, index) => (
<div key={index} className="relative aspect-[4/3] rounded-lg overflow-hidden group">
<Image
src={photo.src}
alt={photo.alt}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
style={photo.position ? { objectPosition: photo.position } : undefined}
/>
</div>
))}
</div>
</div>
</section>
);
}
// Work With Me Section
function WorkWithMeSection() {
return (
<section className="section relative overflow-hidden">
{/* Background Image */}
<div className="absolute inset-0 z-0 bg-[#0f0f0f]">
<Image
src="/images/art/wayra-bg.webp"
alt="Wayra"
fill
className="object-contain opacity-40"
/>
</div>
<div className="max-w-3xl mx-auto relative z-10">
<div className="text-center">
<h2 className="text-4xl md:text-5xl font-light mb-6 text-[var(--text-light)]">
Work With Me
</h2>
<div className="divider"></div>
<p className="text-lg leading-relaxed mb-16 opacity-80 max-w-2xl mx-auto text-[var(--text-light)]">
This work is for those ready to meet themselves honestly with courage,
sensitivity and presence.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<a href="https://booking.xhiva.art" target="_blank" rel="noopener noreferrer" className="btn-outline btn-outline-light">
Book a Session
</a>
<Link href="#contact" className="btn-filled" style={{ background: 'var(--accent-gold)', borderColor: 'var(--accent-gold)' }}>
Contact Me
</Link>
</div>
</div>
</section>
);
}
// Contact Section
function ContactSection() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !email.trim() || !message.trim()) {
setStatus('error');
setErrorMessage('Please fill in all fields.');
return;
}
setStatus('loading');
setErrorMessage('');
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim(), email: email.trim(), message: message.trim() }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Something went wrong');
}
setStatus('success');
setName('');
setEmail('');
setMessage('');
} catch (err) {
setStatus('error');
setErrorMessage(err instanceof Error ? err.message : 'Failed to send message. Please try again.');
}
};
return (
<section id="contact" className="section bg-white">
<div className="max-w-4xl mx-auto">
<div className="text-center">
<p className="font-sans-alt text-xs tracking-[0.2em] text-[var(--accent-gold)] mb-4">
GET IN TOUCH
</p>
<h2 className="text-4xl md:text-5xl font-light mb-6">
Begin Your Journey
</h2>
<div className="divider"></div>
<p className="text-lg leading-relaxed mb-12 opacity-80 max-w-2xl mx-auto">
For inquiries about sessions, art commissions, or event collaborations,
please reach out through the form below or connect on social media.
</p>
</div>
{status === 'success' ? (
<div className="max-w-lg mx-auto text-center py-12">
<div className="text-5xl mb-6 text-[var(--accent-gold)]">&#10003;</div>
<h3 className="text-2xl font-light mb-4">Message Sent</h3>
<p className="text-lg opacity-80 mb-8">
Thank you for reaching out. I will get back to you soon.
</p>
<button
onClick={() => setStatus('idle')}
className="btn-outline"
>
Send Another Message
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="max-w-lg mx-auto text-left">
<div className="mb-6">
<label className="block font-sans-alt text-xs tracking-widest mb-2" htmlFor="name">
NAME
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-3 border border-gray-200 rounded-lg focus:outline-none focus:border-[var(--accent-gold)] transition-colors"
placeholder="Your name"
disabled={status === 'loading'}
/>
</div>
<div className="mb-6">
<label className="block font-sans-alt text-xs tracking-widest mb-2" htmlFor="email">
EMAIL
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 border border-gray-200 rounded-lg focus:outline-none focus:border-[var(--accent-gold)] transition-colors"
placeholder="your@email.com"
disabled={status === 'loading'}
/>
</div>
<div className="mb-6">
<label className="block font-sans-alt text-xs tracking-widest mb-2" htmlFor="message">
MESSAGE
</label>
<textarea
id="message"
rows={5}
value={message}
onChange={(e) => setMessage(e.target.value)}
className="w-full px-4 py-3 border border-gray-200 rounded-lg focus:outline-none focus:border-[var(--accent-gold)] transition-colors resize-none"
placeholder="How can I support your journey?"
disabled={status === 'loading'}
/>
</div>
{status === 'error' && errorMessage && (
<p className="text-red-600 text-sm mb-4 font-sans-alt">{errorMessage}</p>
)}
<button
type="submit"
className="btn-filled w-full mt-4"
disabled={status === 'loading'}
style={status === 'loading' ? { opacity: 0.7, cursor: 'not-allowed' } : undefined}
>
{status === 'loading' ? 'Sending...' : 'Send Message'}
</button>
</form>
)}
</div>
</section>
);
}
// Social Connect Section
function SocialSection() {
const instagramPosts = [
{ image: '/images/art/soul-agreement.webp', alt: 'Soul Agreement' },
{ image: '/images/art/goddess.webp?v=2', alt: 'Goddess' },
{ image: '/images/art/twin-flames.webp', alt: 'Twin Flames' },
{ image: '/images/art/featured.jpg?v=2', alt: 'Featured Art' },
{ image: '/images/about/portrait-1.jpg', alt: 'Portrait' },
{ image: '/images/reevolution/dj-xhiva.jpg', alt: 'DJ XHIVA' },
];
return (
<section className="section bg-[var(--bg-cream)]">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<p className="font-sans-alt text-xs tracking-[0.2em] text-[var(--accent-gold)] mb-4">
FOLLOW THE JOURNEY
</p>
<h2 className="text-4xl md:text-5xl font-light mb-6">
Connect on Socials
</h2>
<div className="divider"></div>
</div>
{/* Instagram-style Grid */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-10 max-w-4xl mx-auto">
{instagramPosts.map((post, index) => (
<a
key={index}
href="https://www.instagram.com/xhiva_art"
target="_blank"
rel="noopener noreferrer"
className="group bg-white rounded-xl shadow-md hover:shadow-xl transition-shadow duration-300 overflow-hidden border border-gray-100"
>
{/* Post Header */}
<div className="flex items-center gap-2 px-3 py-2.5">
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-[#f09433] via-[#e6683c] to-[#bc1888] p-[2px]">
<div className="w-full h-full rounded-full bg-white p-[1px]">
<div className="relative w-full h-full rounded-full overflow-hidden">
<Image src="/images/about/portrait-1.jpg" alt="xhiva_art" fill className="object-cover object-[30%_center]" />
</div>
</div>
</div>
<span className="text-xs font-semibold text-[var(--text-dark)]">xhiva_art</span>
</div>
{/* Post Image */}
<div className="relative aspect-square">
<Image
src={post.image}
alt={post.alt}
fill
className="object-cover"
style={post.image.includes('portrait-1') ? { objectPosition: '30% center' } : post.image.includes('dj-xhiva') ? { objectPosition: 'center top' } : undefined}
/>
</div>
{/* Post Actions */}
<div className="px-3 py-2.5 flex items-center gap-4">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-[var(--text-dark)] group-hover:text-red-500 transition-colors">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-[var(--text-dark)]">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-[var(--text-dark)]">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</div>
</a>
))}
</div>
{/* Social Buttons */}
<div className="flex flex-col sm:flex-row justify-center items-center gap-4">
<a
href="https://www.instagram.com/xhiva_art"
target="_blank"
rel="noopener noreferrer"
className="btn-filled"
>
Follow @xhiva_art on Instagram
</a>
<a
href="https://www.facebook.com/XimenaXhivart"
target="_blank"
rel="noopener noreferrer"
className="btn-outline"
>
Follow on Facebook
</a>
</div>
</div>
</section>
);
}
// Footer
function Footer() {
return (
<footer className="dark-section py-16 px-6">
<div className="max-w-6xl mx-auto">
<div className="grid md:grid-cols-4 gap-12 mb-12">
{/* Brand */}
<div className="md:col-span-2">
<h3 className="text-2xl font-light tracking-widest mb-4">XHIVA ART</h3>
<p className="text-sm opacity-70 leading-relaxed mb-6">
Visionary Art · Ritual Healing · Cultural and Artistic Experiences
</p>
</div>
{/* Quick Links */}
<div>
<h4 className="font-sans-alt text-xs tracking-widest mb-4">EXPLORE</h4>
<div className="flex flex-col gap-2">
<Link href="#art" className="footer-link">Art</Link>
<Link href="#gallery" className="footer-link">Gallery</Link>
<Link href="#about" className="footer-link">About</Link>
<Link href="#services" className="footer-link">Services</Link>
<Link href="#reevolution" className="footer-link">Re Evolution Art</Link>
</div>
</div>
{/* Connect */}
<div>
<h4 className="font-sans-alt text-xs tracking-widest mb-4">CONNECT</h4>
<div className="flex flex-col gap-2">
<Link href="#contact" className="footer-link">Contact</Link>
<a href="https://instagram.com/ximena_xaguar" target="_blank" rel="noopener noreferrer" className="footer-link">
@ximena_xaguar
</a>
<a href="https://www.facebook.com/XimenaXhivart" target="_blank" rel="noopener noreferrer" className="footer-link">
Facebook
</a>
</div>
</div>
</div>
{/* Copyright */}
<div className="border-t border-white/10 pt-8 text-center">
<p className="font-sans-alt text-xs tracking-widest opacity-50">
© {new Date().getFullYear()} XHIVA ART. ALL RIGHTS RESERVED.
</p>
</div>
</div>
</footer>
);
}
// Main Page Component
export default function Home() {
return (
<>
<Navigation />
<main>
<HeroSection />
<RitualArtSection />
<GallerySection />
<ServicesSection />
<ReEvolutionSection />
<TestimonialsSection />
<AboutSection />
<WorkWithMeSection />
<ContactSection />
<SocialSection />
</main>
<Footer />
</>
);
const artworks = getArtworks();
const events = getEvents();
const services = getServices();
return <HomeClient artworks={artworks} events={events} services={services} />;
}

67
src/lib/auth.ts Normal file
View File

@ -0,0 +1,67 @@
const COOKIE_NAME = 'xhiva_session';
const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days
function getPassword(): string {
return process.env.ADMIN_PASSWORD || '';
}
async function sign(timestamp: number): Promise<string> {
const password = getPassword();
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(password),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(String(timestamp)));
return Array.from(new Uint8Array(signature))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
export async function createSessionToken(): Promise<string> {
const timestamp = Date.now();
const sig = await sign(timestamp);
return `${timestamp}.${sig}`;
}
export async function verifySessionToken(token: string): Promise<boolean> {
const password = getPassword();
if (!password) return false;
const parts = token.split('.');
if (parts.length !== 2) return false;
const timestamp = parseInt(parts[0], 10);
if (isNaN(timestamp)) return false;
// Check expiry
if (Date.now() - timestamp > SESSION_DURATION) return false;
// Check signature
const expected = await sign(timestamp);
return parts[1] === expected;
}
export function validatePassword(input: string): boolean {
const password = getPassword();
if (!password) return false;
return input === password;
}
export function getSessionCookie(token: string): string {
const maxAge = SESSION_DURATION / 1000;
return `${COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`;
}
export function clearSessionCookie(): string {
return `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
}
export function getTokenFromCookies(cookieHeader: string | null): string | null {
if (!cookieHeader) return null;
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]*)`));
return match ? match[1] : null;
}

143
src/lib/data.ts Normal file
View File

@ -0,0 +1,143 @@
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
import { join } from 'path';
import type { Artwork, ArtEvent, Service } from './types';
import { seedArtworks, seedEvents, seedServices } from './seed';
const DATA_DIR = process.env.DATA_DIR || '/data';
function ensureDir() {
if (!existsSync(DATA_DIR)) {
mkdirSync(DATA_DIR, { recursive: true });
}
}
function readJSON<T>(filename: string, seedData: T[]): T[] {
const filepath = join(DATA_DIR, filename);
if (existsSync(filepath)) {
try {
return JSON.parse(readFileSync(filepath, 'utf-8'));
} catch {
return seedData;
}
}
// Auto-seed on first read
ensureDir();
writeFileSync(filepath, JSON.stringify(seedData, null, 2));
return seedData;
}
function writeJSON<T>(filename: string, data: T[]) {
ensureDir();
writeFileSync(join(DATA_DIR, filename), JSON.stringify(data, null, 2));
}
// Artworks
export function getArtworks(): Artwork[] {
return readJSON<Artwork>('artworks.json', seedArtworks);
}
export function getArtwork(id: string): Artwork | undefined {
return getArtworks().find(a => a.id === id);
}
export function saveArtworks(artworks: Artwork[]) {
writeJSON('artworks.json', artworks);
}
export function addArtwork(artwork: Artwork) {
const artworks = getArtworks();
artworks.push(artwork);
saveArtworks(artworks);
return artwork;
}
export function updateArtwork(id: string, updates: Partial<Artwork>): Artwork | null {
const artworks = getArtworks();
const index = artworks.findIndex(a => a.id === id);
if (index === -1) return null;
artworks[index] = { ...artworks[index], ...updates, id };
saveArtworks(artworks);
return artworks[index];
}
export function deleteArtwork(id: string): boolean {
const artworks = getArtworks();
const filtered = artworks.filter(a => a.id !== id);
if (filtered.length === artworks.length) return false;
saveArtworks(filtered);
return true;
}
// Events
export function getEvents(): ArtEvent[] {
return readJSON<ArtEvent>('events.json', seedEvents);
}
export function getEvent(id: string): ArtEvent | undefined {
return getEvents().find(e => e.id === id);
}
export function saveEvents(events: ArtEvent[]) {
writeJSON('events.json', events);
}
export function addEvent(event: ArtEvent) {
const events = getEvents();
events.push(event);
saveEvents(events);
return event;
}
export function updateEvent(id: string, updates: Partial<ArtEvent>): ArtEvent | null {
const events = getEvents();
const index = events.findIndex(e => e.id === id);
if (index === -1) return null;
events[index] = { ...events[index], ...updates, id };
saveEvents(events);
return events[index];
}
export function deleteEvent(id: string): boolean {
const events = getEvents();
const filtered = events.filter(e => e.id !== id);
if (filtered.length === events.length) return false;
saveEvents(filtered);
return true;
}
// Services
export function getServices(): Service[] {
return readJSON<Service>('services.json', seedServices);
}
export function getService(id: string): Service | undefined {
return getServices().find(s => s.id === id);
}
export function saveServices(services: Service[]) {
writeJSON('services.json', services);
}
export function addService(service: Service) {
const services = getServices();
services.push(service);
saveServices(services);
return service;
}
export function updateService(id: string, updates: Partial<Service>): Service | null {
const services = getServices();
const index = services.findIndex(s => s.id === id);
if (index === -1) return null;
services[index] = { ...services[index], ...updates, id };
saveServices(services);
return services[index];
}
export function deleteService(id: string): boolean {
const services = getServices();
const filtered = services.filter(s => s.id !== id);
if (filtered.length === services.length) return false;
saveServices(filtered);
return true;
}

201
src/lib/seed.ts Normal file
View File

@ -0,0 +1,201 @@
import type { Artwork, ArtEvent, Service } from './types';
export const seedArtworks: Artwork[] = [
{ id: 'goddess', title: 'Goddess', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/goddess.webp?v=2', category: 'available', featured: true, sortOrder: 1 },
{ id: 'mujer-medicina', title: 'Mujer Medicina', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/mujer-medicina.webp', category: 'available', featured: true, sortOrder: 2 },
{ id: 'shiva', title: 'Shiva', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/shiva-main.webp', category: 'available', featured: true, sortOrder: 3 },
{ id: 'twin-flames', title: 'Twin Flames', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/twin-flames.webp', category: 'available', featured: false, sortOrder: 4 },
{ id: 'soul-agreement', title: 'Soul Agreement', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/soul-agreement.webp', category: 'available', featured: false, sortOrder: 5 },
{ id: 'madre', title: 'Madre', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/madre.webp', category: 'available', featured: false, sortOrder: 6 },
{ id: 'warmy-munachi', title: 'Warmy Munachi', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/warmy-munachi.webp', category: 'available', featured: false, sortOrder: 7 },
{ id: 'mi-uma', title: 'Mi Uma', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/mi-uma.webp', category: 'available', featured: false, sortOrder: 8 },
{ id: 're-evolucion-arte', title: 'Re Evoluci\u00f3n Arte', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/re-evolucion-arte.webp', category: 'available', featured: false, sortOrder: 9 },
{ id: 'mujer-ser', title: 'Mujer Ser', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/mujer-ser.webp', category: 'available', featured: false, sortOrder: 10 },
{ id: 'arte-parque', title: 'Arte Parque', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/arte-parque.webp', category: 'available', featured: false, sortOrder: 11 },
{ id: 'rainbow-tara', title: 'Rainbow Tara', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/rainbow-tara.webp', category: 'available', featured: false, sortOrder: 12 },
{ id: 'female-fire', title: 'Female Fire', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/female-fire.webp', category: 'available', featured: false, sortOrder: 13 },
{ id: 'trinidad', title: 'Trinidad', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/trinidad.webp', category: 'available', featured: false, sortOrder: 14 },
{ id: 'tantra', title: 'Tantra', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/tantra.webp', category: 'available', featured: false, sortOrder: 15 },
{ id: 'amazonas', title: 'Amazonas', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/amazonas.webp', category: 'available', featured: false, sortOrder: 16 },
{ id: 'mujer-jaguar', title: 'Mujer Jaguar', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/mujer-jaguar.webp', category: 'available', featured: false, sortOrder: 17 },
{ id: 'coming-from-the-darkness', title: 'Coming from the Darkness', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/coming-from-the-darkness.webp', category: 'available', featured: false, sortOrder: 18 },
{ id: 'white-tara', title: 'White Tara', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/white-tara.webp', category: 'available', featured: false, sortOrder: 19 },
{ id: 'aya', title: 'Aya', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/aya.webp', category: 'available', featured: false, sortOrder: 20 },
{ id: 'pacha', title: 'Pacha', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/pacha.webp', category: 'available', featured: false, sortOrder: 21 },
{ id: 'pacha-detalle', title: 'Pacha (Detalle)', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/pacha-detalle.webp', category: 'available', featured: false, sortOrder: 22 },
{ id: 'warmy-arkanum', title: 'Warmy Arkanum', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/warmy-arkanum.webp', category: 'available', featured: false, sortOrder: 23 },
{ id: 'sacro', title: 'Sacro', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/sacro.webp', category: 'available', featured: false, sortOrder: 24 },
{ id: 'inti-om', title: 'Inti Om', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/inti-om.webp', category: 'available', featured: false, sortOrder: 25 },
{ id: 'totem-astral', title: 'Totem Astral', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/totem-astral.webp', category: 'available', featured: false, sortOrder: 26 },
{ id: 'sabiduria-ancestral', title: 'Sabidur\u00eda Ancestral', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/sabiduria-ancestral.webp', category: 'available', featured: false, sortOrder: 27 },
{ id: 'amor-astral', title: 'Amor Astral', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/amor-astral.webp', category: 'available', featured: false, sortOrder: 28 },
{ id: 'escencia-mistica', title: 'Escencia M\u00edstica', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/escencia-mistica.webp', category: 'available', featured: false, sortOrder: 29 },
{ id: 'raiz', title: 'Ra\u00edz', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/raiz.webp', category: 'available', featured: false, sortOrder: 30 },
{ id: 'uni-verso', title: 'Uni Verso', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/uni-verso.webp', category: 'available', featured: false, sortOrder: 31 },
{ id: 'wayra', title: 'Wayra', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/wayra.webp', category: 'available', featured: false, sortOrder: 32 },
{ id: 'la-illimani', title: 'La Illimani', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/art/la-illimani.webp', category: 'available', featured: false, sortOrder: 33 },
{ id: 'eagle-jaguar', title: 'Eagle & Jaguar', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/about/process-1.webp', category: 'available', featured: false, sortOrder: 34 },
{ id: 'kundalini', title: 'Kundalini', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/about/process-2.webp', category: 'available', featured: false, sortOrder: 35 },
{ id: 'ancestral-totem', title: 'Ancestral Totem', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/about/process-3.webp', category: 'available', featured: false, sortOrder: 36 },
{ id: 'detail-fire', title: 'Detail \u2014 Fire', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/about/process-5.webp', category: 'available', featured: false, sortOrder: 37 },
{ id: 'sacred-union', title: 'Sacred Union', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/about/process-6.webp', category: 'available', featured: false, sortOrder: 38 },
{ id: 'shiva-ii', title: 'Shiva II', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/about/process-7.webp', category: 'available', featured: false, sortOrder: 39 },
{ id: 'golden-meditation', title: 'Golden Meditation', medium: 'Acrylic on canvas', dimensions: '', year: '', price: '', image: '/images/about/portrait-artist-2.webp', category: 'available', featured: false, sortOrder: 40 },
];
export const seedEvents: ArtEvent[] = [
{
id: 'tribal-nights',
title: 'Tribal Nights',
description: 'A signature event weaving ritual, dance and immersive art into one transformative experience.',
image: '/images/reevolution/tribal-night.jpg',
date: '',
link: '',
section: 'reevolution',
sortOrder: 1,
},
{
id: 'tribal-experience',
title: 'TRIBAL Experience',
description: 'Group immersion (5\u20136 hours) exploring the unconscious through shamanic journeying, art alchemy and embodied movement.',
image: '/images/reevolution/event-2.jpg',
date: '',
link: '',
section: 'reevolution',
sortOrder: 2,
},
{
id: 'visionary-art-week',
title: 'Visionary Art Week Z\u00fcrich',
description: 'Curated week featuring international artists, performances, live music, talks and workshops.',
image: '/images/reevolution/event-3.jpg',
date: '',
link: '',
section: 'reevolution',
sortOrder: 3,
},
{
id: 'pulsar',
title: 'PULSAR',
description: 'Experimental experiences weaving DJ sets with expanded states of presence.',
image: '/images/reevolution/dj-xhiva.jpg',
date: '',
link: '',
section: 'reevolution',
objectPosition: 'center top',
sortOrder: 4,
},
];
export const seedServices: Service[] = [
{
id: 'healing-circle',
title: 'Healing Circle',
subtitle: 'El Camino del Jaguar',
duration: 'Bi-weekly at Photobastei, Z\u00fcrich',
description: 'Guided group healing circle combining crystal work, somatic practice and ritual in an intimate setting.',
howItWorks: [
'Bi-weekly gatherings at Photobastei cultural centre',
'Crystal healing, somatic awareness and guided meditation',
'Small group format for deeper connection',
],
whoItIsFor: [
'Those seeking regular grounding practice',
'People exploring healing in a supportive group',
'Anyone drawn to crystal and somatic work',
],
image: '/images/services/crystal-therapy.webp',
color: 'lavender',
calendlyLink: '',
highlighted: false,
sortOrder: 1,
},
{
id: 'tribal-experience',
title: 'TRIBAL Experience',
subtitle: 'Group Immersion',
duration: '5\u20136 Hours',
description: 'Deep group immersion weaving shamanic journeying, visionary art creation and embodied movement into a single transformative arc.',
howItWorks: [
'Extended 5\u20136 hour immersive group session',
'Combines shamanic journeying, art alchemy and movement',
'Guided by Ximena with live music and ritual elements',
],
whoItIsFor: [
'Those ready for deep group transformation work',
'Artists and creatives seeking embodied inspiration',
'Anyone drawn to shamanic and ceremonial experiences',
],
image: '/images/reevolution/event-2.jpg',
color: 'pink',
calendlyLink: '',
highlighted: false,
sortOrder: 2,
},
{
id: 'temazcal',
title: 'Temazcal Ceremonies',
subtitle: 'Medicine of the Four Elements',
duration: 'Fire \u2013 Earth \u2013 Water \u2013 Air',
description: 'Sweatlodge ceremonies guided for 15+ years, rooted in Native American ancestral tradition. Working with four elements within a contained ritual space supporting purification, closure and renewal.',
howItWorks: [
'Traditional sweatlodge ceremony with four elements',
'Guided by Ximena with 15+ years of experience',
'Held in natural settings with proper ritual preparation',
],
whoItIsFor: [
'Those seeking purification and renewal',
'People in life transitions or seeking closure',
'Anyone drawn to earth-based ceremonial practice',
],
image: '/images/services/temazcal.jpg',
color: 'mint',
calendlyLink: '',
highlighted: false,
sortOrder: 3,
},
{
id: 'retreats',
title: 'Retreats',
subtitle: 'Immersive Multi-Day Experiences',
duration: 'Weekend & Multi-Day',
description: 'Multi-day immersions combining art, ceremony, nature and inner work in carefully curated settings.',
howItWorks: [
'Multi-day immersive retreat in nature settings',
'Combines art creation, ceremony and contemplative practice',
'Small group format with personal attention',
],
whoItIsFor: [
'Those ready for deep, sustained inner work',
'People seeking a break from daily life for transformation',
'Anyone wanting to combine art and spiritual practice',
],
image: '/images/services/deep-integration.webp',
color: 'rose',
calendlyLink: '',
highlighted: false,
sortOrder: 4,
},
{
id: 'painting-collection',
title: 'Painting & Art Collection',
subtitle: 'Original Visionary Artworks',
duration: 'Commission & Collection',
description: 'Original paintings, commissions and art collection. Each work emerges from ritual process \u2014 portals for transformation and contemplation.',
howItWorks: [
'Browse available works in the gallery',
'Commission a custom piece with personal consultation',
'Worldwide shipping with certificate of authenticity',
],
whoItIsFor: [
'Art collectors drawn to visionary and spiritual art',
'Those wanting a meaningful artwork for their space',
'Anyone seeking a personal commission',
],
image: '/images/art/featured.jpg?v=2',
color: 'pink',
calendlyLink: '',
highlighted: true,
sortOrder: 5,
},
];

39
src/lib/types.ts Normal file
View File

@ -0,0 +1,39 @@
export interface Artwork {
id: string;
title: string;
medium: string;
dimensions: string;
year: string;
price: string;
image: string;
category: 'available' | 'sold' | 'private';
featured: boolean;
sortOrder: number;
}
export interface ArtEvent {
id: string;
title: string;
description: string;
image: string;
date: string;
link: string;
section: 'xhiva' | 'reevolution';
objectPosition?: string;
sortOrder: number;
}
export interface Service {
id: string;
title: string;
subtitle: string;
duration: string;
description: string;
howItWorks: string[];
whoItIsFor: string[];
image: string;
color: string;
calendlyLink: string;
highlighted: boolean;
sortOrder: number;
}

28
src/middleware.ts Normal file
View File

@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { getTokenFromCookies, verifySessionToken } from './lib/auth';
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Protect /admin/* and /api/admin/* (except login)
const isAdminPage = pathname.startsWith('/admin') && pathname !== '/admin/login';
const isAdminApi = pathname.startsWith('/api/admin') && pathname !== '/api/admin/login';
if (isAdminPage || isAdminApi) {
const cookieHeader = request.headers.get('cookie');
const token = getTokenFromCookies(cookieHeader);
if (!token || !(await verifySessionToken(token))) {
if (isAdminApi) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
return NextResponse.redirect(new URL('/admin/login', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/admin/:path*', '/api/admin/:path*'],
};