ci: use internal registry (bypass Cloudflare upload limit)
CI/CD / deploy (push) Failing after 41s Details

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-01 10:47:08 -07:00
parent 7e3ed3da6c
commit c7b97aa0cc
7 changed files with 532 additions and 13 deletions

View File

@ -9,8 +9,8 @@ on:
branches: [main] branches: [main]
env: env:
REGISTRY: gitea.jeffemmett.com REGISTRY: localhost:3000
IMAGE: gitea.jeffemmett.com/jeffemmett/xhivart-mirror IMAGE: localhost:3000/jeffemmett/xhivart-mirror
jobs: jobs:
deploy: deploy:

View File

@ -34,6 +34,10 @@ RUN mkdir -p /data && chown nextjs:nodejs /data
RUN mkdir .next RUN mkdir .next
RUN chown nextjs:nodejs .next RUN chown nextjs:nodejs .next
# Create CMS data directories
RUN mkdir -p /app/data/content /app/data/uploads
RUN chown -R nextjs:nodejs /app/data
# Copy standalone output # Copy standalone output
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

View File

@ -11,7 +11,7 @@ services:
- SMTP_FROM=${SMTP_FROM} - SMTP_FROM=${SMTP_FROM}
- ADMIN_PASSWORD=${ADMIN_PASSWORD} - ADMIN_PASSWORD=${ADMIN_PASSWORD}
volumes: volumes:
- xhivart-data:/data - xhivart-data:/app/data
networks: networks:
- traefik-public - traefik-public
labels: labels:

View File

@ -22,7 +22,7 @@ function escapeHtml(str: string): string {
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const body = await request.json();
const { name, email, message } = body; const { name, email, service, message } = body;
if (!name || typeof name !== 'string' || name.trim().length === 0) { if (!name || typeof name !== 'string' || name.trim().length === 0) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 }); return NextResponse.json({ error: 'Name is required' }, { status: 400 });
@ -36,8 +36,16 @@ export async function POST(request: Request) {
const safeName = escapeHtml(name.trim()); const safeName = escapeHtml(name.trim());
const safeEmail = escapeHtml(email.trim()); const safeEmail = escapeHtml(email.trim());
const safeService = service && typeof service === 'string' ? escapeHtml(service.trim()) : '';
const safeMessage = escapeHtml(message.trim()); const safeMessage = escapeHtml(message.trim());
const serviceSection = safeService
? `<div style="margin-bottom: 24px;">
<p style="font-family: Montserrat, sans-serif; font-size: 11px; letter-spacing: 0.15em; text-transform: uppercase; color: #6b6b6b; margin: 0 0 4px 0;">SERVICE</p>
<p style="font-size: 16px; color: #2d2d2d; margin: 0;">${safeService}</p>
</div>`
: '';
await transporter.sendMail({ await transporter.sendMail({
from: `XHIVA Art <${process.env.SMTP_FROM || process.env.SMTP_USER}>`, from: `XHIVA Art <${process.env.SMTP_FROM || process.env.SMTP_USER}>`,
to: 'xhivart@gmail.com', to: 'xhivart@gmail.com',
@ -60,13 +68,14 @@ export async function POST(request: Request) {
<a href="mailto:${safeEmail}" style="color: #c9a962;">${safeEmail}</a> <a href="mailto:${safeEmail}" style="color: #c9a962;">${safeEmail}</a>
</p> </p>
</div> </div>
${serviceSection}
<div style="margin-bottom: 24px;"> <div style="margin-bottom: 24px;">
<p style="font-family: Montserrat, sans-serif; font-size: 11px; letter-spacing: 0.15em; text-transform: uppercase; color: #6b6b6b; margin: 0 0 4px 0;">MESSAGE</p> <p style="font-family: Montserrat, sans-serif; font-size: 11px; letter-spacing: 0.15em; text-transform: uppercase; color: #6b6b6b; margin: 0 0 4px 0;">MESSAGE</p>
<p style="font-size: 16px; color: #2d2d2d; margin: 0; white-space: pre-wrap;">${safeMessage}</p> <p style="font-size: 16px; color: #2d2d2d; margin: 0; white-space: pre-wrap;">${safeMessage}</p>
</div> </div>
<div style="border-top: 1px solid #e5e5e5; padding-top: 20px; margin-top: 30px;"> <div style="border-top: 1px solid #e5e5e5; padding-top: 20px; margin-top: 30px;">
<p style="font-family: Montserrat, sans-serif; font-size: 10px; letter-spacing: 0.1em; color: #6b6b6b; margin: 0;"> <p style="font-family: Montserrat, sans-serif; font-size: 10px; letter-spacing: 0.1em; color: #6b6b6b; margin: 0;">
Sent from xhivart.jeffemmett.com contact form Sent from xhiva.art contact form
</p> </p>
</div> </div>
</div> </div>

View File

@ -89,12 +89,71 @@
color: var(--text-dark); color: var(--text-dark);
transition: color 0.3s ease; transition: color 0.3s ease;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
} }
.nav-link:hover { .nav-link:hover {
color: var(--accent-gold); color: var(--accent-gold);
} }
.nav-link-active {
color: var(--accent-gold);
}
.nav-chevron {
transition: transform 0.2s ease;
}
/* Dropdown wrapper */
.nav-dropdown-wrapper {
position: relative;
}
.nav-dropdown {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
min-width: 200px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 0.75rem;
padding: 0.5rem 0;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
}
.nav-dropdown-wrapper:hover .nav-dropdown {
opacity: 1;
visibility: visible;
}
.nav-dropdown-wrapper:hover .nav-chevron {
transform: rotate(180deg);
}
.nav-dropdown-item {
display: block;
padding: 0.5rem 1.5rem;
font-family: var(--font-montserrat), 'Montserrat', sans-serif;
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
text-decoration: none;
color: var(--text-dark);
transition: color 0.2s ease, background 0.2s ease;
}
.nav-dropdown-item:hover {
color: var(--accent-gold);
background: rgba(201, 169, 98, 0.05);
}
/* Elegant button styles */ /* Elegant button styles */
.btn-outline { .btn-outline {
display: inline-block; display: inline-block;
@ -178,6 +237,18 @@
} }
} }
/* Page hero — shorter than homepage hero */
.page-hero {
min-height: 60vh;
padding: 8rem 2rem 4rem;
}
@media (min-width: 768px) {
.page-hero {
padding: 10rem 4rem 6rem;
}
}
/* Decorative elements */ /* Decorative elements */
.divider { .divider {
width: 60px; width: 60px;
@ -219,6 +290,41 @@
text-align: center; text-align: center;
} }
/* Price tag */
.price-tag {
font-family: var(--font-montserrat), 'Montserrat', sans-serif;
font-size: 0.875rem;
font-weight: 500;
letter-spacing: 0.1em;
color: var(--accent-gold);
}
/* Methodology step */
.methodology-step {
padding: 1.5rem;
border-left: 2px solid rgba(201, 169, 98, 0.3);
transition: border-color 0.3s ease;
}
.methodology-step:hover {
border-color: var(--accent-gold);
}
/* Role card */
.role-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1rem;
padding: 2rem;
transition: all 0.3s ease;
text-align: center;
}
.role-card:hover {
background: rgba(255, 255, 255, 0.08);
border-color: var(--accent-gold);
}
/* Footer */ /* Footer */
.footer-link { .footer-link {
font-family: var(--font-montserrat), 'Montserrat', sans-serif; font-family: var(--font-montserrat), 'Montserrat', sans-serif;

View File

@ -1,6 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Cormorant_Garamond, Montserrat } from "next/font/google"; import { Cormorant_Garamond, Montserrat } from "next/font/google";
import "./globals.css"; import "./globals.css";
import Navigation from "@/components/Navigation";
import Footer from "@/components/Footer";
const cormorant = Cormorant_Garamond({ const cormorant = Cormorant_Garamond({
variable: "--font-cormorant", variable: "--font-cormorant",
@ -34,7 +36,9 @@ export default function RootLayout({
<body <body
className={`${cormorant.variable} ${montserrat.variable} antialiased`} className={`${cormorant.variable} ${montserrat.variable} antialiased`}
> >
{children} <Navigation />
<main>{children}</main>
<Footer />
</body> </body>
</html> </html>
); );

View File

@ -1,12 +1,408 @@
import { getArtworks, getEvents, getServices } from '@/lib/data'; import Link from 'next/link';
import HomeClient from './home-client'; import Image from 'next/image';
import { services as defaultServices } from '@/lib/data/services';
import { testimonials as defaultTestimonials } from '@/lib/data/testimonials';
import { readData } from '@/lib/cms';
import type { Service } from '@/lib/data/services';
import type { Testimonial } from '@/lib/data/testimonials';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export default function Home() { // Hero Section
const artworks = getArtworks(); function HeroSection() {
const events = getEvents(); return (
const services = getServices(); <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>
return <HomeClient artworks={artworks} events={events} services={services} />; <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="/re-evolution-art" 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>
);
}
// Ritual Art Alchemy Preview
function RitualArtSection() {
return (
<section className="section bg-white">
<div className="max-w-6xl mx-auto">
<div className="grid md:grid-cols-2 gap-12 items-center">
<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.
</p>
<div className="pt-2">
<Link href="/art" className="btn-outline">
Enter the Gallery
</Link>
</div>
</div>
</div>
</div>
</section>
);
}
// Services Preview (abbreviated)
function ServicesPreview({ services }: { services: Service[] }) {
const previewServices = services.slice(0, 4);
return (
<section 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">
{previewServices.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">
<Link href="/services" className="btn-outline">
View All Services
</Link>
</div>
</div>
</section>
);
}
// Re Evolution Art Preview
function ReEvolutionPreview() {
return (
<section className="section dark-section">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<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="flex justify-center gap-4 mt-8">
<Link href="/re-evolution-art" className="btn-outline btn-outline-light" style={{ fontFamily: "'Narrenschiff', sans-serif" }}>
Discover the Platform
</Link>
</div>
</div>
</section>
);
}
// Testimonials (showing 3 on homepage)
function TestimonialsSection({ testimonials }: { testimonials: Testimonial[] }) {
const homeTestimonials = testimonials.slice(0, 3);
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">
{homeTestimonials.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)]">
{testimonial.author}
</p>
</div>
))}
</div>
</div>
</section>
);
}
// Work With Me
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">
<Link href="/services" className="btn-outline btn-outline-light">
Explore Sessions
</Link>
<Link href="/contact" className="btn-filled" style={{ background: 'var(--accent-gold)', borderColor: 'var(--accent-gold)' }}>
Contact Me
</Link>
</div>
</div>
</section>
);
}
// Social 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>
);
}
// Newsletter Section
function NewsletterSection() {
return (
<section className="section dark-section">
<div className="max-w-3xl mx-auto text-center">
<p className="font-sans-alt text-xs tracking-[0.2em] text-[var(--accent-gold)] mb-4">
STAY CONNECTED
</p>
<h2 className="text-4xl md:text-5xl font-light mb-6">
Be Part of the RE EVOLUTION
</h2>
<div className="divider"></div>
<p className="text-lg leading-relaxed mb-10 opacity-80 max-w-2xl mx-auto">
Receive updates on exhibitions, events, new artworks and offerings.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<Link href="/contact" className="btn-outline btn-outline-light">
Get in Touch
</Link>
<a
href="https://www.instagram.com/xhiva_art"
target="_blank"
rel="noopener noreferrer"
className="btn-outline btn-outline-light"
>
Follow on Instagram
</a>
</div>
</div>
</section>
);
}
export default function Home() {
const services = readData<Service>('services', defaultServices);
const testimonials = readData<Testimonial>('testimonials', defaultTestimonials);
return (
<>
<HeroSection />
<RitualArtSection />
<ServicesPreview services={services} />
<ReEvolutionPreview />
<TestimonialsSection testimonials={testimonials} />
<WorkWithMeSection />
<SocialSection />
<NewsletterSection />
</>
);
} }