Add admin slideshow, horizontal animated calendar, and weather strip
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface CalendarEvent {
|
||||
id: number;
|
||||
date: string;
|
||||
title: string;
|
||||
type: 'holiday' | 'season' | 'weather' | 'event';
|
||||
description: string | null;
|
||||
color: string | null;
|
||||
}
|
||||
|
||||
interface WeatherData {
|
||||
current?: { temperature: number; summary: string };
|
||||
today?: { max: number; min: number; summary: string };
|
||||
}
|
||||
|
||||
const gold = '#E8A849';
|
||||
const navy = '#010D1E';
|
||||
const cream = '#f5f0e8';
|
||||
const typeEmoji: Record<CalendarEvent['type'], string> = {
|
||||
holiday: '🎉',
|
||||
season: '🌿',
|
||||
weather: '☀️',
|
||||
event: '📌',
|
||||
};
|
||||
|
||||
function formatMonthDay(dateStr: string) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
return { month: d.toLocaleString('en-US', { month: 'short' }), day: d.getDate() };
|
||||
}
|
||||
|
||||
function daysUntil(dateStr: string) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
const diff = Math.ceil((d.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (diff === 0) return 'Today';
|
||||
if (diff === 1) return 'Tomorrow';
|
||||
if (diff < 0) return 'Past';
|
||||
return `in ${diff} days`;
|
||||
}
|
||||
|
||||
export default function CalendarStrip() {
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [weather, setWeather] = useState<WeatherData | null>(null);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/calendar_events')
|
||||
.then((r) => r.json())
|
||||
.then((j) => setEvents((j.data || []).slice(0, 12)))
|
||||
.catch(() => {});
|
||||
|
||||
fetch('/api/weather')
|
||||
.then((r) => r.json())
|
||||
.then((j) => setWeather(j.data || null))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const cards = events.map((e) => ({ ...e, isWeather: false })).concat(
|
||||
weather
|
||||
? [
|
||||
{
|
||||
id: 9999,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
title: `${weather.current?.temperature ?? '--'}°C ${weather.current?.summary ?? ''}`,
|
||||
type: 'weather' as const,
|
||||
description: `Today ${weather.today?.max ?? '--'}° / ${weather.today?.min ?? '--'}°`,
|
||||
color: '#4FC3F7',
|
||||
isWeather: true,
|
||||
},
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
if (cards.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${navy}, #002436)`,
|
||||
padding: '2rem 0',
|
||||
overflow: 'hidden',
|
||||
borderTop: `1px solid rgba(232,168,73,0.25)`,
|
||||
borderBottom: `1px solid rgba(232,168,73,0.25)`,
|
||||
}}
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '1rem',
|
||||
padding: '0 1rem',
|
||||
width: 'max-content',
|
||||
animation: paused ? 'none' : 'marquee 38s linear infinite',
|
||||
}}
|
||||
ref={trackRef}
|
||||
>
|
||||
{[...cards, ...cards].map((e, i) => {
|
||||
const { month, day } = formatMonthDay(e.date);
|
||||
const accent = e.color || gold;
|
||||
return (
|
||||
<div
|
||||
key={`${e.id}-${i}`}
|
||||
style={{
|
||||
width: '220px',
|
||||
flexShrink: 0,
|
||||
borderRadius: '18px',
|
||||
padding: '1.25rem',
|
||||
background: 'rgba(245,240,232,0.06)',
|
||||
border: '1px solid rgba(245,240,232,0.12)',
|
||||
backdropFilter: 'blur(6px)',
|
||||
color: cream,
|
||||
transition: 'transform 300ms, box-shadow 300ms',
|
||||
}}
|
||||
onMouseEnter={(event) => {
|
||||
event.currentTarget.style.transform = 'scale(1.04) translateY(-4px)';
|
||||
event.currentTarget.style.boxShadow = `0 12px 30px rgba(0,0,0,0.25)`;
|
||||
}}
|
||||
onMouseLeave={(event) => {
|
||||
event.currentTarget.style.transform = 'scale(1)';
|
||||
event.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.8rem' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '14px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: accent,
|
||||
color: navy,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '10px', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{month}</span>
|
||||
<span style={{ fontSize: '20px' }}>{day}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '0.08em', color: accent, marginBottom: '0.2rem' }}>
|
||||
{typeEmoji[e.type]} {e.type}
|
||||
</div>
|
||||
<div style={{ fontSize: '15px', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{e.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ margin: 0, fontSize: '13px', color: 'rgba(245,240,232,0.72)', lineHeight: 1.45 }}>
|
||||
{e.description || ''}
|
||||
</p>
|
||||
<div style={{ marginTop: '0.8rem', fontSize: '12px', color: 'rgba(245,240,232,0.5)' }}>
|
||||
{e.isWeather ? 'Otavalo now' : daysUntil(e.date)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<style jsx global>{`
|
||||
@keyframes marquee {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -47,9 +47,7 @@ export default function Footer() {
|
||||
<div>
|
||||
<h3 style={{ color: gold, fontSize: '18px', margin: '0 0 1rem', fontStyle: 'italic' }}>La Huasca</h3>
|
||||
<p style={{ color: 'rgba(245,240,232,0.75)', lineHeight: 1.55, margin: 0 }}>
|
||||
Hotel · Restaurant · Vineyard
|
||||
<br />
|
||||
A quiet corner of the Honduran highlands.
|
||||
A quiet corner of the Ecuadorian Andes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -60,6 +58,13 @@ export default function Footer() {
|
||||
{config.email && <p style={{ margin: 0 }}><a href={`mailto:${config.email}`} style={{ color: cream, textDecoration: 'none' }}>{config.email}</a></p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 style={{ color: gold, fontSize: '14px', letterSpacing: '0.08em', textTransform: 'uppercase', margin: '0 0 1rem' }}>Atmosphere</h4>
|
||||
<p style={{ color: 'rgba(245,240,232,0.75)', lineHeight: 1.55, margin: 0 }}>
|
||||
Hotel · Restaurant · Andean Highlands
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 style={{ color: gold, fontSize: '14px', letterSpacing: '0.08em', textTransform: 'uppercase', margin: '0 0 1rem' }}>Follow us</h4>
|
||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
interface Slide {
|
||||
id: number;
|
||||
title: string | null;
|
||||
subtitle: string | null;
|
||||
image_data: string | null;
|
||||
image_url: string | null;
|
||||
}
|
||||
|
||||
const gold = '#E8A849';
|
||||
const navy = '#010D1E';
|
||||
const cream = '#f5f0e8';
|
||||
|
||||
export default function Slideshow() {
|
||||
const [slides, setSlides] = useState<Slide[]>([]);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/slides')
|
||||
.then((r) => r.json())
|
||||
.then((j) => {
|
||||
const data = j.data || [];
|
||||
setSlides(data);
|
||||
if (data.length > 0) setLoaded(true);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const next = useCallback(() => {
|
||||
setIndex((i) => (slides.length ? (i + 1) % slides.length : 0));
|
||||
}, [slides.length]);
|
||||
|
||||
const prev = () => {
|
||||
setIndex((i) => (slides.length ? (i - 1 + slides.length) % slides.length : 0));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (slides.length <= 1) return;
|
||||
const id = setInterval(next, 6000);
|
||||
return () => clearInterval(id);
|
||||
}, [slides.length, next]);
|
||||
|
||||
if (!loaded || slides.length === 0) return null;
|
||||
|
||||
const slide = slides[index];
|
||||
const image = slide.image_data || slide.image_url || '';
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: 'clamp(380px, 60vh, 720px)',
|
||||
overflow: 'hidden',
|
||||
background: navy,
|
||||
}}
|
||||
>
|
||||
{slides.map((s, i) => {
|
||||
const src = s.image_data || s.image_url || '';
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
opacity: i === index ? 1 : 0,
|
||||
transition: 'opacity 900ms ease-in-out',
|
||||
backgroundImage: src ? `url(${src})` : undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
transform: i === index ? 'scale(1)' : 'scale(1.06)',
|
||||
transitionProperty: 'opacity, transform',
|
||||
transitionDuration: '900ms',
|
||||
transitionTimingFunction: 'ease-in-out',
|
||||
}}
|
||||
>
|
||||
{!src && <div style={{ width: '100%', height: '100%', background: `linear-gradient(135deg, ${navy}, #00334a)` }} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'linear-gradient(to top, rgba(1,13,30,0.78) 0%, rgba(1,13,30,0.22) 50%, rgba(1,13,30,0.42) 100%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
padding: '2rem',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: 'clamp(32px, 6vw, 64px)',
|
||||
fontWeight: 400,
|
||||
fontStyle: 'italic',
|
||||
color: cream,
|
||||
margin: '0 0 0.75rem',
|
||||
opacity: 0,
|
||||
animation: 'slideUpFade 800ms forwards',
|
||||
animationDelay: '120ms',
|
||||
}}
|
||||
>
|
||||
{slide.title || 'La Huasca'}
|
||||
</h2>
|
||||
{slide.subtitle && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: 'clamp(16px, 2.2vw, 22px)',
|
||||
color: 'rgba(245,240,232,0.82)',
|
||||
maxWidth: '42ch',
|
||||
opacity: 0,
|
||||
animation: 'slideUpFade 800ms forwards',
|
||||
animationDelay: '280ms',
|
||||
}}
|
||||
>
|
||||
{slide.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{slides.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={prev}
|
||||
aria-label="Previous slide"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '1rem',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: 'rgba(245,240,232,0.14)',
|
||||
color: cream,
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
backdropFilter: 'blur(4px)',
|
||||
transition: 'background 200ms, transform 200ms',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = gold; e.currentTarget.style.color = navy; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(245,240,232,0.14)'; e.currentTarget.style.color = cream; }}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
onClick={next}
|
||||
aria-label="Next slide"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '1rem',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: 'rgba(245,240,232,0.14)',
|
||||
color: cream,
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
backdropFilter: 'blur(4px)',
|
||||
transition: 'background 200ms, transform 200ms',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = gold; e.currentTarget.style.color = navy; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(245,240,232,0.14)'; e.currentTarget.style.color = cream; }}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<div style={{ position: 'absolute', bottom: '1.5rem', left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: '0.6rem' }}>
|
||||
{slides.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setIndex(i)}
|
||||
aria-label={`Go to slide ${i + 1}`}
|
||||
style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: i === index ? gold : 'rgba(245,240,232,0.35)',
|
||||
transition: 'background 300ms',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<style jsx global>{`
|
||||
@keyframes slideUpFade {
|
||||
from { opacity: 0; transform: translateY(28px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user