feat(landscape): multi-photo gallery like rooms with uploads and ordering
This commit is contained in:
+18
-11
@@ -67,7 +67,8 @@ interface Place {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
photo: string | null;
|
photos: string[];
|
||||||
|
featured_photo: string | null;
|
||||||
distance_km: string | null;
|
distance_km: string | null;
|
||||||
visit_time: string | null;
|
visit_time: string | null;
|
||||||
suggestion: string | null;
|
suggestion: string | null;
|
||||||
@@ -140,7 +141,7 @@ export default function AdminPage() {
|
|||||||
const [editingMenu, setEditingMenu] = useState<number | null>(null);
|
const [editingMenu, setEditingMenu] = useState<number | null>(null);
|
||||||
|
|
||||||
const [places, setPlaces] = useState<Place[]>([]);
|
const [places, setPlaces] = useState<Place[]>([]);
|
||||||
const [placeForm, setPlaceForm] = useState<Partial<Place>>({ name: '', description: '', photo: '', distance_km: '', visit_time: '', suggestion: '', active: true, sort_order: 0 });
|
const [placeForm, setPlaceForm] = useState<Partial<Place>>({ name: '', description: '', photos: [], featured_photo: null, distance_km: '', visit_time: '', suggestion: '', active: true, sort_order: 0 });
|
||||||
const [editingPlace, setEditingPlace] = useState<number | null>(null);
|
const [editingPlace, setEditingPlace] = useState<number | null>(null);
|
||||||
|
|
||||||
const [reviews, setReviews] = useState<Review[]>([]);
|
const [reviews, setReviews] = useState<Review[]>([]);
|
||||||
@@ -305,7 +306,7 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const savePlace = async (e: React.FormEvent) => {
|
const savePlace = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const payload = { ...placeForm, active: placeForm.active !== false, sort_order: placeForm.sort_order || 0 };
|
const payload = { ...placeForm, photos: placeForm.photos || [], featured_photo: placeForm.featured_photo || null, active: placeForm.active !== false, sort_order: placeForm.sort_order || 0 };
|
||||||
if (editingPlace) {
|
if (editingPlace) {
|
||||||
await api(`/api/places/${editingPlace}`, 'PUT', payload);
|
await api(`/api/places/${editingPlace}`, 'PUT', payload);
|
||||||
setPlaces((prev) => prev.map((p) => (p.id === editingPlace ? { ...p, ...payload } as Place : p)));
|
setPlaces((prev) => prev.map((p) => (p.id === editingPlace ? { ...p, ...payload } as Place : p)));
|
||||||
@@ -314,11 +315,11 @@ export default function AdminPage() {
|
|||||||
const json = await api('/api/places', 'POST', payload);
|
const json = await api('/api/places', 'POST', payload);
|
||||||
setPlaces((prev) => [...prev, json.data]);
|
setPlaces((prev) => [...prev, json.data]);
|
||||||
}
|
}
|
||||||
setPlaceForm({ name: '', description: '', photo: '', distance_km: '', visit_time: '', suggestion: '', active: true, sort_order: 0 });
|
setPlaceForm({ name: '', description: '', photos: [], featured_photo: null, distance_km: '', visit_time: '', suggestion: '', active: true, sort_order: 0 });
|
||||||
notify('Place saved.');
|
notify('Place saved.');
|
||||||
};
|
};
|
||||||
|
|
||||||
const editPlace = (p: Place) => { setPlaceForm({ ...p }); setEditingPlace(p.id); };
|
const editPlace = (p: Place) => { setPlaceForm({ ...p, photos: p.photos || [], featured_photo: p.featured_photo || null }); setEditingPlace(p.id); };
|
||||||
const deletePlace = async (id: number) => { await api(`/api/places/${id}`, 'DELETE'); setPlaces((prev) => prev.filter((p) => p.id !== id)); };
|
const deletePlace = async (id: number) => { await api(`/api/places/${id}`, 'DELETE'); setPlaces((prev) => prev.filter((p) => p.id !== id)); };
|
||||||
|
|
||||||
const approveReview = async (id: number) => {
|
const approveReview = async (id: number) => {
|
||||||
@@ -558,7 +559,7 @@ export default function AdminPage() {
|
|||||||
<Text label="Price / night" value={roomForm.price || ''} onChange={(v) => setRoomForm({ ...roomForm, price: v })} />
|
<Text label="Price / night" value={roomForm.price || ''} onChange={(v) => setRoomForm({ ...roomForm, price: v })} />
|
||||||
<Text label="Description" value={roomForm.description || ''} onChange={(v) => setRoomForm({ ...roomForm, description: v })} />
|
<Text label="Description" value={roomForm.description || ''} onChange={(v) => setRoomForm({ ...roomForm, description: v })} />
|
||||||
<Number label="Sort order" value={roomForm.sort_order ?? 0} onChange={(v) => setRoomForm({ ...roomForm, sort_order: parseInt(v || '0', 10) })} />
|
<Number label="Sort order" value={roomForm.sort_order ?? 0} onChange={(v) => setRoomForm({ ...roomForm, sort_order: parseInt(v || '0', 10) })} />
|
||||||
<RoomPhotoPicker
|
<PhotoPicker
|
||||||
label="Room photos"
|
label="Room photos"
|
||||||
images={images}
|
images={images}
|
||||||
photos={roomForm.photos || []}
|
photos={roomForm.photos || []}
|
||||||
@@ -635,8 +636,14 @@ export default function AdminPage() {
|
|||||||
<Text label="Name" value={placeForm.name || ''} onChange={(v) => setPlaceForm({ ...placeForm, name: v })} />
|
<Text label="Name" value={placeForm.name || ''} onChange={(v) => setPlaceForm({ ...placeForm, name: v })} />
|
||||||
<Text label="Distance (km)" value={placeForm.distance_km || ''} onChange={(v) => setPlaceForm({ ...placeForm, distance_km: v })} />
|
<Text label="Distance (km)" value={placeForm.distance_km || ''} onChange={(v) => setPlaceForm({ ...placeForm, distance_km: v })} />
|
||||||
<Text label="Visit time" value={placeForm.visit_time || ''} onChange={(v) => setPlaceForm({ ...placeForm, visit_time: v })} />
|
<Text label="Visit time" value={placeForm.visit_time || ''} onChange={(v) => setPlaceForm({ ...placeForm, visit_time: v })} />
|
||||||
<Text label="Photo URL" value={placeForm.photo || ''} onChange={(v) => setPlaceForm({ ...placeForm, photo: v })} />
|
|
||||||
<Number label="Sort order" value={placeForm.sort_order ?? 0} onChange={(v) => setPlaceForm({ ...placeForm, sort_order: parseInt(v || '0', 10) })} />
|
<Number label="Sort order" value={placeForm.sort_order ?? 0} onChange={(v) => setPlaceForm({ ...placeForm, sort_order: parseInt(v || '0', 10) })} />
|
||||||
|
<PhotoPicker
|
||||||
|
label="Photos"
|
||||||
|
images={images}
|
||||||
|
photos={placeForm.photos || []}
|
||||||
|
featuredPhoto={placeForm.featured_photo || null}
|
||||||
|
onChange={(photos: string[], featured: string | null) => setPlaceForm({ ...placeForm, photos, featured_photo: featured })}
|
||||||
|
/>
|
||||||
<Text label="Description" value={placeForm.description || ''} onChange={(v) => setPlaceForm({ ...placeForm, description: v })} />
|
<Text label="Description" value={placeForm.description || ''} onChange={(v) => setPlaceForm({ ...placeForm, description: v })} />
|
||||||
<Text label="Suggestion" value={placeForm.suggestion || ''} onChange={(v) => setPlaceForm({ ...placeForm, suggestion: v })} />
|
<Text label="Suggestion" value={placeForm.suggestion || ''} onChange={(v) => setPlaceForm({ ...placeForm, suggestion: v })} />
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '14px', color: '#555' }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '14px', color: '#555' }}>
|
||||||
@@ -645,10 +652,10 @@ export default function AdminPage() {
|
|||||||
</label>
|
</label>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
|
||||||
<Button type="submit">{editingPlace ? 'Update place' : 'Add place'}</Button>
|
<Button type="submit">{editingPlace ? 'Update place' : 'Add place'}</Button>
|
||||||
{editingPlace && <SecondaryButton onClick={() => { setEditingPlace(null); setPlaceForm({ name: '', description: '', photo: '', distance_km: '', visit_time: '', suggestion: '', active: true, sort_order: 0 }); }}>Cancel</SecondaryButton>}
|
{editingPlace && <SecondaryButton onClick={() => { setEditingPlace(null); setPlaceForm({ name: '', description: '', photos: ([] as string[]), featured_photo: null, distance_km: '', visit_time: '', suggestion: '', active: true, sort_order: 0 }); }}>Cancel</SecondaryButton>}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<ListTable rows={places.map((p) => [p.name, `${p.distance_km || ''} km`, p.description.slice(0, 60), p.active ? 'On' : 'Off', `#${p.sort_order}`])} onEdit={(i) => editPlace(places[i])} onDelete={(i) => deletePlace(places[i].id)} />
|
<ListTable rows={places.map((p) => [p.name, `${p.distance_km || ''} km`, p.description.slice(0, 60), p.photos.length > 0 ? `🖼️ ${p.photos.length}` : 'no image', p.active ? 'On' : 'Off', `#${p.sort_order}`])} onEdit={(i) => editPlace(places[i])} onDelete={(i) => deletePlace(places[i].id)} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Pending Reviews">
|
<Section title="Pending Reviews">
|
||||||
@@ -823,7 +830,7 @@ function ListTable({ rows, onEdit, onDelete, onComments }: { rows: string[][]; o
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoomPhotoPicker({
|
function PhotoPicker({
|
||||||
label, images, photos, featuredPhoto, onChange,
|
label, images, photos, featuredPhoto, onChange,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -873,7 +880,7 @@ function RoomPhotoPicker({
|
|||||||
</div>
|
</div>
|
||||||
{allPhotos.length > 0 && (
|
{allPhotos.length > 0 && (
|
||||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||||
<span style={{ fontSize: '13px', color: '#777' }}>Featured photo:</span>
|
<span style={{ fontSize: '13px', color: '#777' }}>Featured:</span>
|
||||||
<select
|
<select
|
||||||
value={featuredPhoto || ''}
|
value={featuredPhoto || ''}
|
||||||
onChange={(e) => onChange(photos, e.target.value || null)}
|
onChange={(e) => onChange(photos, e.target.value || null)}
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri
|
|||||||
await requireRole('admin');
|
await requireRole('admin');
|
||||||
const id = parseInt(params.id, 10);
|
const id = parseInt(params.id, 10);
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, description, photo, distance_km, visit_time, suggestion, active, sort_order } = body;
|
const { name, description, photos, featured_photo, distance_km, visit_time, suggestion, active, sort_order } = body;
|
||||||
|
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`UPDATE places SET name = $1, description = $2, photo = $3, distance_km = $4, visit_time = $5, suggestion = $6, active = $7, sort_order = $8
|
`UPDATE places SET name = $1, description = $2, photos = $3, featured_photo = $4, distance_km = $5, visit_time = $6, suggestion = $7, active = $8, sort_order = $9
|
||||||
WHERE id = $9 RETURNING *`,
|
WHERE id = $10 RETURNING *`,
|
||||||
[name, description, photo || null, distance_km || null, visit_time || null, suggestion || null, active, sort_order || 0, id]
|
[name, description, photos || '{}', featured_photo || null, distance_km || null, visit_time || null, suggestion || null, active, sort_order || 0, id]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
|
|||||||
@@ -16,17 +16,17 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
await requireRole('admin');
|
await requireRole('admin');
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, description, photo, distance_km, visit_time, suggestion, sort_order } = body;
|
const { name, description, photos, featured_photo, distance_km, visit_time, suggestion, sort_order } = body;
|
||||||
|
|
||||||
if (!name || !description) {
|
if (!name || !description) {
|
||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`INSERT INTO places (name, description, photo, distance_km, visit_time, suggestion, sort_order)
|
`INSERT INTO places (name, description, photos, featured_photo, distance_km, visit_time, suggestion, sort_order)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[name, description, photo || null, distance_km || null, visit_time || null, suggestion || null, sort_order || 0]
|
[name, description, photos || '{}', featured_photo || null, distance_km || null, visit_time || null, suggestion || null, sort_order || 0]
|
||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json({ data: rows[0] });
|
return NextResponse.json({ data: rows[0] });
|
||||||
|
|||||||
+127
-43
@@ -6,16 +6,16 @@ interface Place {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
photo: string | null;
|
photos: string[];
|
||||||
|
featured_photo: string | null;
|
||||||
distance_km: string | null;
|
distance_km: string | null;
|
||||||
visit_time: string | null;
|
visit_time: string | null;
|
||||||
suggestion: string | null;
|
suggestion: string | null;
|
||||||
|
sort_order: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const gold = '#E8A849';
|
const gold = '#E8A849';
|
||||||
const navy = '#010D1E';
|
const navy = '#001321';
|
||||||
const cream = '#f5f0e8';
|
|
||||||
const warm = '#a6683c';
|
|
||||||
|
|
||||||
export default function LandscapePage() {
|
export default function LandscapePage() {
|
||||||
const [places, setPlaces] = useState<Place[]>([]);
|
const [places, setPlaces] = useState<Place[]>([]);
|
||||||
@@ -33,60 +33,144 @@ export default function LandscapePage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<main style={{ padding: '2rem', maxWidth: '72rem', margin: '0 auto', minHeight: '60vh' }}>
|
<main style={{ minHeight: '100vh', background: navy, color: '#d4c9b8', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<h1 style={{ fontSize: 'clamp(32px, 5vw, 48px)', fontWeight: 400, fontStyle: 'italic', color: navy, margin: '0 0 0.5rem' }}>Landscape</h1>
|
<p style={{ fontSize: '18px', fontStyle: 'italic' }}>Loading landscape...</p>
|
||||||
<p style={{ color: '#555', marginBottom: '2rem', maxWidth: '50ch' }}>
|
</main>
|
||||||
Trails, rivers, waterfalls, and villages around La Huasca.
|
);
|
||||||
</p>
|
}
|
||||||
|
|
||||||
{loading && <p style={{ color: warm }}>Loading places...</p>}
|
return (
|
||||||
{error && <p style={{ color: '#c23b22' }}>{error}</p>}
|
<main style={{ minHeight: '100vh', background: navy, color: '#f5f0e8' }}>
|
||||||
|
<section
|
||||||
{!loading && places.length === 0 && <p style={{ color: '#777' }}>No places added yet.</p>}
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '1.5rem' }}>
|
|
||||||
{places.map((place) => (
|
|
||||||
<article
|
|
||||||
key={place.id}
|
|
||||||
style={{
|
style={{
|
||||||
background: cream,
|
background: 'linear-gradient(rgba(0,19,33,0.75), rgba(0,19,33,0.85))',
|
||||||
|
padding: '5rem 1.5rem 3rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: 'clamp(34px, 5vw, 56px)',
|
||||||
|
fontWeight: 400,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
color: gold,
|
||||||
|
margin: '0 0 1rem',
|
||||||
|
letterSpacing: '-0.5px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Landscape
|
||||||
|
</h1>
|
||||||
|
<p style={{ maxWidth: '720px', margin: '0 auto', fontSize: '18px', color: '#d4c9b8', lineHeight: 1.6 }}>
|
||||||
|
Nearby destinations and Andean highlights worth exploring from La Huasca.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ padding: '2rem 1.5rem 5rem', maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
|
{error && <p style={{ textAlign: 'center', color: '#c23b22' }}>{error}</p>}
|
||||||
|
{places.length === 0 ? (
|
||||||
|
<p style={{ textAlign: 'center', color: '#aaa' }}>No places listed yet.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: '2rem' }}>
|
||||||
|
{places.map((place) => (
|
||||||
|
<PlaceCard key={place.id} place={place} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaceCard({ place }: { place: Place }) {
|
||||||
|
const [active, setActive] = useState(place.featured_photo || place.photos[0] || '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
style={{
|
||||||
|
background: '#f5f0e8',
|
||||||
|
color: navy,
|
||||||
borderRadius: '20px',
|
borderRadius: '20px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
boxShadow: '0 2px 8px rgba(1,13,30,0.08)',
|
display: 'grid',
|
||||||
display: 'flex',
|
gridTemplateColumns: '1fr 1fr',
|
||||||
flexDirection: 'column',
|
boxShadow: '0 12px 30px rgba(0,0,0,0.25)',
|
||||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-3px)'; e.currentTarget.style.boxShadow = '0 8px 24px rgba(1,13,30,0.14)'; }}
|
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(1,13,30,0.08)'; }}
|
|
||||||
>
|
>
|
||||||
|
<div style={{ position: 'relative', minHeight: '320px' }}>
|
||||||
|
{active ? (
|
||||||
|
<img
|
||||||
|
src={active}
|
||||||
|
alt={place.name}
|
||||||
|
style={{ width: '100%', height: '100%', minHeight: '320px', objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: '200px',
|
width: '100%',
|
||||||
background: '#ddd',
|
height: '100%',
|
||||||
backgroundImage: place.photo ? `url(${place.photo})` : undefined,
|
minHeight: '320px',
|
||||||
backgroundSize: 'cover',
|
display: 'flex',
|
||||||
backgroundPosition: 'center',
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: '#e0d8cc',
|
||||||
|
color: navy,
|
||||||
|
fontStyle: 'italic',
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<div style={{ padding: '1.5rem', flex: 1 }}>
|
No image
|
||||||
<h2 style={{ margin: '0 0 0.5rem', color: navy }}>{place.name}</h2>
|
|
||||||
<p style={{ color: '#555', lineHeight: 1.5 }}>{place.description}</p>
|
|
||||||
<div style={{ marginTop: '1rem', display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
|
||||||
{place.distance_km && <span style={{ fontSize: '13px', background: '#e8eff3', color: navy, padding: '0.3rem 0.7rem', borderRadius: '999px' }}>{place.distance_km} km</span>}
|
|
||||||
{place.visit_time && <span style={{ fontSize: '13px', background: '#e8eff3', color: navy, padding: '0.3rem 0.7rem', borderRadius: '999px' }}>{place.visit_time}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{place.photos.length > 1 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0.75rem',
|
||||||
|
overflowX: 'auto',
|
||||||
|
background: 'rgba(0,19,33,0.55)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{place.photos.map((photo, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => setActive(photo)}
|
||||||
|
style={{
|
||||||
|
padding: 0,
|
||||||
|
border: active === photo ? '2px solid #E8A849' : '2px solid transparent',
|
||||||
|
borderRadius: '8px',
|
||||||
|
flexShrink: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={photo}
|
||||||
|
alt={`${place.name} ${idx + 1}`}
|
||||||
|
style={{ width: '64px', height: '64px', objectFit: 'cover', borderRadius: '6px', display: 'block' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '2rem', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||||
|
<h2 style={{ fontSize: '28px', fontWeight: 400, fontStyle: 'italic', margin: '0 0 0.75rem', color: navy }}>{place.name}</h2>
|
||||||
|
{place.distance_km && (
|
||||||
|
<p style={{ margin: '0 0 0.5rem', color: '#a6683c', fontWeight: 600, fontSize: '15px' }}>{place.distance_km} km away · {place.visit_time || 'varies'}</p>
|
||||||
|
)}
|
||||||
|
<p style={{ margin: '0 0 1rem', lineHeight: 1.7, color: '#2b3a46' }}>{place.description}</p>
|
||||||
{place.suggestion && (
|
{place.suggestion && (
|
||||||
<div style={{ marginTop: '1rem', padding: '0.75rem', background: '#fff', borderLeft: '3px solid ' + gold, borderRadius: '0 10px 10px 0' }}>
|
<div style={{ background: navy, color: gold, padding: '1rem 1.25rem', borderRadius: '12px', fontSize: '15px' }}>
|
||||||
<strong style={{ fontSize: '13px', color: warm }}>Suggestion:</strong>
|
<strong>Suggestion:</strong> {place.suggestion}
|
||||||
<p style={{ margin: '0.25rem 0 0', fontSize: '14px', color: '#555' }}>{place.suggestion}</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -101,7 +101,8 @@ export async function initSchema() {
|
|||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
photo VARCHAR(500),
|
photos TEXT[] DEFAULT '{}',
|
||||||
|
featured_photo VARCHAR(500),
|
||||||
distance_km NUMERIC(8,2),
|
distance_km NUMERIC(8,2),
|
||||||
visit_time VARCHAR(100),
|
visit_time VARCHAR(100),
|
||||||
suggestion TEXT,
|
suggestion TEXT,
|
||||||
@@ -165,6 +166,12 @@ export async function initSchema() {
|
|||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(100);
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(100);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
await db.query(`
|
||||||
|
ALTER TABLE places ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
|
||||||
|
UPDATE places SET photos = ARRAY[photo] WHERE photo IS NOT NULL AND (photos IS NULL OR photos = '{}');
|
||||||
|
ALTER TABLE places DROP COLUMN IF EXISTS photo;
|
||||||
|
`);
|
||||||
|
|
||||||
await db.query(`
|
await db.query(`
|
||||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
|
|||||||
Reference in New Issue
Block a user