Add admin slideshow, horizontal animated calendar, and weather strip

This commit is contained in:
2026-06-27 18:40:56 -05:00
parent 75da132327
commit 8394b60bc2
12 changed files with 955 additions and 66 deletions
+174
View File
@@ -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>
);
}
+8 -3
View File
@@ -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' }}>
+208
View File
@@ -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>
);
}