fix: image gallery organized with sections and borders, images now show correctly
This commit is contained in:
+225
-19
@@ -203,33 +203,239 @@ function SectionUploader({ category, room_id, label, onUploaded }: { category: s
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Image gallery filter buttons ─── */
|
||||
function GalleryFilter({ images, onDelete, filter, onFilter }: { images: Image[]; onDelete: (id: number) => void; filter: string; onFilter: (f: string) => void }) {
|
||||
const cats = ['all', 'rooms', 'slides', 'social', 'landscape', 'other'];
|
||||
/* ─── Organized image gallery with sections and borders ─── */
|
||||
function ImageGallery({ images, onDelete }: { images: Image[]; onDelete: (id: number) => void }) {
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [hoveredCard, setHoveredCard] = useState<number | null>(null);
|
||||
|
||||
const sectionCategories = [
|
||||
{ key: 'slides', label: '🎞️ Slideshow Gallery', filter: (i: Image) => i.category === 'slides' || i.location_target === 'slides' },
|
||||
{ key: 'calendar', label: '📅 Calendar Events Gallery', filter: (i: Image) => i.category === 'calendar' || i.location_target === 'calendar' },
|
||||
{ key: 'social', label: '🎪 Social Events Gallery', filter: (i: Image) => i.category === 'social' || i.location_target === 'social' },
|
||||
];
|
||||
|
||||
// Group room images by room location_target (e.g. "room-1", "room-2")
|
||||
const roomImages = images.filter(
|
||||
(i) => i.category === 'rooms' || i.location_target?.startsWith('room-')
|
||||
);
|
||||
const roomsByNumber: Record<string, Image[]> = roomImages.reduce((acc, img) => {
|
||||
const key = img.location_target?.replace('room-', '') || 'uncategorized';
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(img);
|
||||
return acc;
|
||||
}, {} as Record<string, Image[]>);
|
||||
|
||||
const filteredImages = images.filter((img) => {
|
||||
if (selectedCategory === 'all') return true;
|
||||
if (selectedCategory === 'rooms') return img.category === 'rooms' || img.location_target?.startsWith('room-');
|
||||
return img.category === selectedCategory || img.location_target === selectedCategory;
|
||||
});
|
||||
|
||||
const ImageCard = ({ img }: { img: Image }) => {
|
||||
const isHov = hoveredCard === img.id;
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: '6px', marginBottom: '16px', flexWrap: 'wrap' }}>
|
||||
{cats.map(c => (
|
||||
<Btn key={c} onClick={() => onFilter(c)} variant={filter === c ? 'primary' : 'secondary'} size="sm">{c === 'all' ? 'All' : c.charAt(0).toUpperCase() + c.slice(1)}</Btn>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))', gap: '12px' }}>
|
||||
{images.filter(img => filter === 'all' || img.category === filter).map(img => (
|
||||
<div key={img.id} style={{ position: 'relative', borderRadius: 12, overflow: 'hidden', boxShadow: C.shadow }}>
|
||||
<img src={img.data} alt={img.filename} style={{ width: '100%', height: '90px', objectFit: 'cover', display: 'block' }} />
|
||||
<div style={{ padding: '6px 8px', fontSize: '11px', color: C.text, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', background: C.card }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative', borderRadius: '10px', overflow: 'hidden',
|
||||
boxShadow: isHov ? C.shadowHover : C.shadow,
|
||||
border: `1px solid ${isHov ? C.gold : C.border}`,
|
||||
transform: isHov ? 'translateY(-2px)' : 'none',
|
||||
transition: 'all 200ms ease',
|
||||
cursor: 'default',
|
||||
}}
|
||||
onMouseEnter={() => setHoveredCard(img.id)}
|
||||
onMouseLeave={() => setHoveredCard(null)}
|
||||
>
|
||||
<img
|
||||
src={img.data}
|
||||
alt={img.filename}
|
||||
style={{
|
||||
width: '100%', height: '110px', objectFit: 'cover', display: 'block',
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
padding: '6px 8px', fontSize: '11px', color: C.text,
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
background: C.card,
|
||||
}}>
|
||||
{img.filename}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '2px' }}>
|
||||
<button onClick={() => onDelete(img.id)} style={{ background: 'transparent', border: 'none', cursor: 'pointer', color: C.danger, fontSize: '16px', padding: '2px 6px' }}>×</button>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '4px' }}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(img.id); }}
|
||||
style={{
|
||||
width: '24px', height: '24px', borderRadius: '6px',
|
||||
border: '1px solid rgba(211,47,47,0.2)',
|
||||
background: isHov ? 'rgba(211,47,47,0.15)' : 'transparent',
|
||||
color: C.danger, fontSize: '14px', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'all 200ms',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/* Category label overlay on hover */}
|
||||
{isHov && img.location_target && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: '30px', left: '4px', right: '4px',
|
||||
padding: '3px 6px', fontSize: '10px', color: '#fff',
|
||||
background: 'rgba(1,13,30,0.75)', borderRadius: '4px',
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{img.location_target}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SectionBox = ({ title, count, children }: { title: string; count: number; children: React.ReactNode }) => (
|
||||
<div style={{
|
||||
borderRadius: '12px', overflow: 'hidden',
|
||||
border: `1px solid ${C.border}`, background: '#fff',
|
||||
boxShadow: C.shadow,
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '10px 16px',
|
||||
background: C.goldLight,
|
||||
borderBottom: `1px solid ${C.border}`,
|
||||
fontWeight: 700, color: C.navy, fontSize: '14px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
}}>
|
||||
<span>{title}</span>
|
||||
<span style={{
|
||||
fontSize: '11px', padding: '2px 10px', borderRadius: '999px',
|
||||
background: C.success, color: '#fff', fontWeight: 600,
|
||||
}}>{count}</span>
|
||||
</div>
|
||||
<div style={{ padding: '16px' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Category filter bar with borders */}
|
||||
<div style={{
|
||||
display: 'flex', gap: '8px', marginBottom: '20px', flexWrap: 'wrap',
|
||||
padding: '12px 16px', borderRadius: '12px',
|
||||
border: `1px solid ${C.border}`, background: '#fff',
|
||||
boxShadow: C.shadow,
|
||||
}}>
|
||||
{['all', 'slides', 'calendar', 'social', 'rooms', 'other'].map((cat) => (
|
||||
<Btn
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
variant={selectedCategory === cat ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
>
|
||||
{cat === 'all' ? 'All Images' : cat.charAt(0).toUpperCase() + cat.slice(1)}
|
||||
</Btn>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedCategory === 'all' ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
{/* Static sections */}
|
||||
{sectionCategories.map((section) => {
|
||||
const imgs = images.filter(section.filter);
|
||||
if (imgs.length === 0) return null;
|
||||
return (
|
||||
<SectionBox key={section.key} title={section.label} count={imgs.length}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||||
gap: '12px',
|
||||
}}>
|
||||
{imgs.map((img) => <ImageCard key={img.id} img={img} />)}
|
||||
</div>
|
||||
</SectionBox>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Rooms section with room number subdivision */}
|
||||
{Object.keys(roomsByNumber).length > 0 && (
|
||||
<SectionBox title="🏨 Rooms Gallery" count={roomImages.length}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{Object.entries(roomsByNumber).map(([roomKey, imgs]) => (
|
||||
<div key={roomKey}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '8px',
|
||||
marginBottom: '10px', padding: '8px 12px',
|
||||
background: '#FAFAFA', borderRadius: '8px',
|
||||
border: `1px solid ${C.border}`,
|
||||
}}>
|
||||
<span style={{ fontSize: '13px', fontWeight: 600, color: C.navy }}>
|
||||
🛏️ Room {roomKey}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '11px', padding: '2px 8px', borderRadius: '999px',
|
||||
background: C.goldLight, color: C.gold, fontWeight: 600,
|
||||
}}>{imgs.length} photo{imgs.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||||
gap: '12px',
|
||||
}}>
|
||||
{imgs.map((img) => <ImageCard key={img.id} img={img} />)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{/* Uncategorized/other */}
|
||||
{(() => {
|
||||
const otherImages = images.filter(
|
||||
(i) => !sectionCategories.some(s => s.filter(i)) &&
|
||||
!(i.category === 'rooms' || i.location_target?.startsWith('room-'))
|
||||
);
|
||||
if (otherImages.length === 0) return null;
|
||||
return (
|
||||
<SectionBox title="📁 Uncategorized" count={otherImages.length}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||||
gap: '12px',
|
||||
}}>
|
||||
{otherImages.map((img) => <ImageCard key={img.id} img={img} />)}
|
||||
</div>
|
||||
</SectionBox>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<SectionBox title={
|
||||
selectedCategory === 'rooms' ? '🏨 Rooms' :
|
||||
selectedCategory === 'other' ? '📁 Other' :
|
||||
selectedCategory.charAt(0).toUpperCase() + selectedCategory.slice(1)
|
||||
} count={filteredImages.length}>
|
||||
{filteredImages.length === 0 ? (
|
||||
<div style={{ padding: '40px 20px', textAlign: 'center', color: C.textLight }}>
|
||||
No images in this category
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||||
gap: '12px',
|
||||
}}>
|
||||
{filteredImages.map((img) => <ImageCard key={img.id} img={img} />)}
|
||||
</div>
|
||||
)}
|
||||
</SectionBox>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Entity card ─── */
|
||||
|
||||
/* ─── Entity card ─── */
|
||||
function EntityCard({ title, desc, meta, thumb, actions, photoUrls }: any) {
|
||||
return (
|
||||
@@ -769,7 +975,7 @@ function MenuSection({ items, setItems, images, onToast }: any) {
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<span style={{ fontSize: '18px', color: C.textLight, userSelect: 'none', flexShrink: 0, cursor: 'grab' }}
|
||||
onMouseDown={(e) => { e.dataTransfer!.effectAllowed = 'move'; }}>⠿</span>
|
||||
onMouseDown={() => {}}>⠿</span>
|
||||
|
||||
{/* Position number */}
|
||||
<span style={{ fontSize: '13px', fontWeight: 700, color: C.textLight, width: '22px', textAlign: 'center', flexShrink: 0 }}>
|
||||
@@ -1159,7 +1365,7 @@ export default function AdminPage() {
|
||||
if (menuRes.ok) { const d = await menuRes.json(); setMenuItems(Array.isArray(d) ? d : (d.data || [])); }
|
||||
if (placesRes.ok) { const d = await placesRes.json(); setPlaces(Array.isArray(d) ? d : []); }
|
||||
if (reviewsRes.ok) { const d = await reviewsRes.json(); setReviews(Array.isArray(d) ? d : []); }
|
||||
if (imagesRes?.ok) { const d = await imagesRes.json(); setImages(Array.isArray(d) ? d : []); }
|
||||
if (imagesRes?.ok) { const d = await imagesRes.json(); setImages(Array.isArray(d) ? d : (d.images || [])); }
|
||||
} catch (err) { console.error('Failed to load admin data:', err); }
|
||||
};
|
||||
fetchAll();
|
||||
|
||||
@@ -2,10 +2,32 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const { rows } = await db.query('SELECT id, filename, category, room_id, location_target, uploaded_at FROM images ORDER BY uploaded_at DESC');
|
||||
const { searchParams } = new URL(request.url);
|
||||
const category = searchParams.get('category');
|
||||
const roomId = searchParams.get('room_id');
|
||||
|
||||
let query = 'SELECT id, filename, data, category, room_id, location_target, uploaded_at FROM images ORDER BY uploaded_at DESC';
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (category) {
|
||||
conditions.push(`category = $${paramIndex++}`);
|
||||
params.push(category);
|
||||
}
|
||||
if (roomId) {
|
||||
conditions.push(`room_id = $${paramIndex++}`);
|
||||
params.push(parseInt(roomId));
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
|
||||
const { rows } = await db.query(query, params);
|
||||
return NextResponse.json({ images: rows, count: rows.length });
|
||||
} catch (error) {
|
||||
if ((error as Error).message === 'Unauthorized') {
|
||||
@@ -27,7 +49,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const { rows } = await db.query(
|
||||
'INSERT INTO images (filename, data, category, room_id, location_target) VALUES ($1, $2, $3, $4, $5) RETURNING id, filename, category, room_id, location_target, uploaded_at',
|
||||
'INSERT INTO images (filename, data, category, room_id, location_target) VALUES ($1, $2, $3, $4, $5) RETURNING id, filename, data, category, room_id, location_target, uploaded_at',
|
||||
[filename, data, category || 'other', room_id || null, location_target || 'gallery']
|
||||
);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const { category, name, description, price, is_daily_special, sort_order } = body;
|
||||
|
||||
if (!category || !name || price === undefined) {
|
||||
if (!category || !name || price === undefined || price === '') {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@@ -2,38 +2,53 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
const { rows } = await db.query('SELECT * FROM places WHERE id = $1', [id]);
|
||||
if (!rows.length) return NextResponse.json({ error: 'Place not found' }, { status: 404 });
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error: any) {
|
||||
console.error('GET /api/places/[id]:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const id = parseInt(params.id, 10);
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
const body = await request.json();
|
||||
const { name, description, photos, featured_photo, distance_km, visit_time, suggestion, active, sort_order } = body;
|
||||
const { name, description, photos, featured_photo, distance_km, visit_time, suggestion, sort_order, active } = body;
|
||||
|
||||
const { rows } = await db.query(
|
||||
`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 = $10 RETURNING *`,
|
||||
[name, description, photos || '{}', featured_photo || null, distance_km || null, visit_time || null, suggestion || null, active, sort_order || 0, id]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Place not found' }, { status: 404 });
|
||||
if (!name || !description) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { rows } = await db.query(
|
||||
`UPDATE places SET name = $1, description = $2, photos = $3, featured_photo = $4, distance_km = $5, visit_time = $6, suggestion = $7, sort_order = $8, active = $9 WHERE id = $10 RETURNING *`,
|
||||
[name, description, photos || '{}', featured_photo || null, distance_km || null, visit_time || null, suggestion || null, sort_order || 0, active !== false, id]
|
||||
);
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error: any) {
|
||||
console.error('PUT /api/places/:id error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Failed to update place' }, { status: 500 });
|
||||
if ((error as Error).message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
console.error('PUT /api/places/[id]:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const id = parseInt(params.id, 10);
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
await db.query('DELETE FROM places WHERE id = $1', [id]);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error: any) {
|
||||
console.error('DELETE /api/places/:id error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Failed to delete place' }, { status: 500 });
|
||||
if ((error as Error).message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
console.error('DELETE /api/places/[id]:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,38 +2,53 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireRole } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
const { rows } = await db.query('SELECT * FROM rooms WHERE id = $1', [id]);
|
||||
if (!rows.length) return NextResponse.json({ error: 'Room not found' }, { status: 404 });
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error: any) {
|
||||
console.error('GET /api/rooms/[id]:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const id = parseInt(params.id, 10);
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
const body = await request.json();
|
||||
const { name, description, price, photos, active, sort_order, featured_photo } = body;
|
||||
const { name, description, price, photos, sort_order, featured_photo, active } = body;
|
||||
|
||||
const { rows } = await db.query(
|
||||
`UPDATE rooms SET name = $1, description = $2, price = $3, photos = $4, active = $5, sort_order = $6, featured_photo = $7, updated_at = NOW()
|
||||
WHERE id = $8 RETURNING *`,
|
||||
[name, description, price, photos || [], active, sort_order || 0, featured_photo || null, id]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 });
|
||||
if (!name || !description || price === undefined) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { rows } = await db.query(
|
||||
`UPDATE rooms SET name = $1, description = $2, price = $3, photos = $4, sort_order = $5, featured_photo = $6, active = $7 WHERE id = $8 RETURNING *`,
|
||||
[name, description, price, photos || [], sort_order || 0, featured_photo || null, active !== false, id]
|
||||
);
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error: any) {
|
||||
console.error('PUT /api/rooms/:id error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Failed to update room' }, { status: 500 });
|
||||
if ((error as Error).message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
console.error('PUT /api/rooms/[id]:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
await requireRole('admin');
|
||||
const id = parseInt(params.id, 10);
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
await db.query('DELETE FROM rooms WHERE id = $1', [id]);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error: any) {
|
||||
console.error('DELETE /api/rooms/:id error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Failed to delete room' }, { status: 500 });
|
||||
if ((error as Error).message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
console.error('DELETE /api/rooms/[id]:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
+200
-59
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
|
||||
interface Room {
|
||||
id: number;
|
||||
@@ -32,6 +32,12 @@ interface User {
|
||||
comments_disabled: boolean;
|
||||
}
|
||||
|
||||
interface RoomLike {
|
||||
room_id: number;
|
||||
user_id: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const gold = '#E8A849';
|
||||
const navy = '#010D1E';
|
||||
const cream = '#f5f0e8';
|
||||
@@ -43,10 +49,13 @@ export default function RoomsPage() {
|
||||
const [error, setError] = useState('');
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [commentsByRoom, setCommentsByRoom] = useState<Record<number, RoomComment[]>>({});
|
||||
const [likesByRoom, setLikesByRoom] = useState<Record<number, number>>({});
|
||||
const [userLikesByRoom, setUserLikesByRoom] = useState<Record<number, boolean>>({});
|
||||
const [expandedRoom, setExpandedRoom] = useState<number | null>(null);
|
||||
const [activePhoto, setActivePhoto] = useState<Record<number, string>>({});
|
||||
const [activePhotoByRoom, setActivePhotoByRoom] = useState<Record<number, number>>({});
|
||||
const [draft, setDraft] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [loadingComments, setLoadingComments] = useState<Record<number, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/rooms')
|
||||
@@ -54,7 +63,10 @@ export default function RoomsPage() {
|
||||
if (!res.ok) throw new Error('Failed to load rooms');
|
||||
const json = await res.json();
|
||||
setRooms(json.data || []);
|
||||
json.data?.forEach((r: Room) => loadComments(r.id));
|
||||
json.data?.forEach((r: Room) => {
|
||||
loadComments(r.id);
|
||||
loadLikes(r.id);
|
||||
});
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -66,21 +78,34 @@ export default function RoomsPage() {
|
||||
}, []);
|
||||
|
||||
const loadComments = async (roomId: number) => {
|
||||
try {
|
||||
setLoadingComments((prev) => ({ ...prev, [roomId]: true }));
|
||||
const res = await fetch(`/api/rooms/${roomId}/comments`, { credentials: 'same-origin' });
|
||||
const json = await res.json().catch(() => ({ data: [] }));
|
||||
setCommentsByRoom((prev) => ({ ...prev, [roomId]: json.data || [] }));
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoadingComments((prev) => ({ ...prev, [roomId]: false })); }
|
||||
};
|
||||
|
||||
const loadLikes = async (roomId: number) => {
|
||||
try {
|
||||
const res = await fetch(`/api/rooms/${roomId}/likes`, { credentials: 'same-origin' });
|
||||
const json = await res.json().catch(() => ({ count: 0 }));
|
||||
setLikesByRoom((prev) => ({ ...prev, [roomId]: json.count || 0 }));
|
||||
setUserLikesByRoom((prev) => ({ ...prev, [roomId]: !!json.user_liked }));
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const postComment = async (roomId: number) => {
|
||||
if (!draft.trim()) return;
|
||||
if (!draft.trim() || sending) return;
|
||||
setSending(true);
|
||||
const photo = activePhoto[roomId] || null;
|
||||
const photo = getPhoto(roomId, activePhotoByRoom[roomId] || 0);
|
||||
await fetch(`/api/rooms/${roomId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ text: draft.trim(), photo_url: photo }),
|
||||
});
|
||||
}).catch(() => {});
|
||||
setDraft('');
|
||||
await loadComments(roomId);
|
||||
setSending(false);
|
||||
@@ -91,6 +116,33 @@ export default function RoomsPage() {
|
||||
await loadComments(roomId);
|
||||
};
|
||||
|
||||
const toggleLike = async (roomId: number) => {
|
||||
if (!user) {
|
||||
alert('Log in to like rooms');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/rooms/${roomId}/likes`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
setLikesByRoom((prev) => ({ ...prev, [roomId]: json.count || (likesByRoom[roomId] || 0) + (userLikesByRoom[roomId] ? -1 : 1) }));
|
||||
setUserLikesByRoom((prev) => ({ ...prev, [roomId]: !userLikesByRoom[roomId] }));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const getPhoto = (room: Room, index: number): string | null => {
|
||||
if (!room.photos || room.photos.length === 0) return null;
|
||||
return room.photos[Math.min(index, room.photos.length - 1)] || null;
|
||||
};
|
||||
|
||||
const getActiveIndex = (room: Room): number => {
|
||||
return activePhotoByRoom[room.id] ?? 0;
|
||||
};
|
||||
|
||||
const displayedRooms = rooms.filter((r) => r.active !== false);
|
||||
|
||||
return (
|
||||
@@ -104,13 +156,15 @@ export default function RoomsPage() {
|
||||
{error && <p style={{ color: '#c23b22' }}>{error}</p>}
|
||||
{!loading && displayedRooms.length === 0 && <p style={{ color: '#777' }}>No rooms available yet.</p>}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: '2rem' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))', gap: '2rem' }}>
|
||||
{displayedRooms.map((room) => {
|
||||
const photos = room.photos?.length ? room.photos : [];
|
||||
const featured = room.featured_photo || photos[0];
|
||||
const currentPhoto = activePhoto[room.id] || featured || photos[0];
|
||||
const photos = room.photos || [];
|
||||
const activeIdx = getActiveIndex(room);
|
||||
const activeSrc = getPhoto(room, activeIdx);
|
||||
const comments = commentsByRoom[room.id] || [];
|
||||
const isExpanded = expandedRoom === room.id;
|
||||
const totalLikes = likesByRoom[room.id] || 0;
|
||||
const hasLiked = userLikesByRoom[room.id] || false;
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -127,41 +181,112 @@ export default function RoomsPage() {
|
||||
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' }}>
|
||||
<div
|
||||
{/* ─── Main Image ─── */}
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
{activeSrc ? (
|
||||
<img
|
||||
src={activeSrc}
|
||||
alt={room.name}
|
||||
style={{
|
||||
height: '260px',
|
||||
background: '#ddd',
|
||||
backgroundImage: currentPhoto ? `url(${currentPhoto})` : undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
cursor: photos.length > 1 ? 'pointer' : 'default',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (photos.length > 1) {
|
||||
const idx = photos.indexOf(currentPhoto);
|
||||
setActivePhoto((prev) => ({ ...prev, [room.id]: photos[(idx + 1) % photos.length] }));
|
||||
}
|
||||
width: '100%',
|
||||
height: '300px',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
{photos.length > 1 && (
|
||||
<div style={{ position: 'absolute', bottom: 10, right: 10, display: 'flex', gap: '0.4rem' }}>
|
||||
{photos.map((p, i) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setActivePhoto((prev) => ({ ...prev, [room.id]: p }))}
|
||||
style={{
|
||||
width: 10, height: 10, borderRadius: '50%', border: 'none',
|
||||
background: p === currentPhoto ? gold : 'rgba(255,255,255,0.7)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
aria-label={`Photo ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '300px', background: '#ddd', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>
|
||||
No photo
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Like button overlay */}
|
||||
<button
|
||||
onClick={() => toggleLike(room.id)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
left: 12,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: hasLiked ? 'rgba(231,76,60,0.9)' : 'rgba(0,0,0,0.4)',
|
||||
color: '#fff',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.15)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
aria-label="Like this room"
|
||||
>
|
||||
{hasLiked ? '\u2764\uFE0F' : '\u2661'}
|
||||
</button>
|
||||
|
||||
{/* Like count */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
right: 12,
|
||||
background: hasLiked ? 'rgba(231,76,60,0.85)' : 'rgba(0,0,0,0.5)',
|
||||
color: '#fff',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
padding: '4px 10px',
|
||||
borderRadius: '999px',
|
||||
}}>
|
||||
{'\u2764\uFE0F'} {totalLikes}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Thumbnail Strip ─── */}
|
||||
{photos.length > 1 && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
padding: '10px 14px 14px',
|
||||
overflowX: 'auto',
|
||||
}}>
|
||||
{photos.map((p, i) => {
|
||||
const isActive = i === activeIdx;
|
||||
return (
|
||||
<button
|
||||
key={`${room.id}-${i}`}
|
||||
onClick={() => setActivePhotoByRoom((prev) => ({ ...prev, [room.id]: i }))}
|
||||
style={{
|
||||
width: 60,
|
||||
height: 45,
|
||||
flexShrink: 0,
|
||||
borderRadius: 8,
|
||||
border: isActive ? `2px solid ${gold}` : '2px solid transparent',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
transition: 'border-color 0.2s, transform 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) e.currentTarget.style.transform = 'scale(1.08)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
{p ? (
|
||||
<img src={p} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', background: '#ddd' }} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Room Info ─── */}
|
||||
<div style={{ padding: '1.5rem', flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<h2 style={{ margin: '0 0 0.5rem', color: navy, fontSize: '24px' }}>{room.name}</h2>
|
||||
<p style={{ color: '#555', lineHeight: 1.5, flex: 1 }}>{room.description}</p>
|
||||
@@ -170,34 +295,32 @@ export default function RoomsPage() {
|
||||
<span style={{ fontSize: '13px', color: '#777' }}>per night</span>
|
||||
</div>
|
||||
|
||||
{photos.length > 1 && (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem', overflowX: 'auto' }}>
|
||||
{photos.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setActivePhoto((prev) => ({ ...prev, [room.id]: p }))}
|
||||
style={{
|
||||
width: 64, height: 48, flexShrink: 0, borderRadius: 8, border: `2px solid ${p === currentPhoto ? gold : 'transparent'}`,
|
||||
backgroundImage: `url(${p})`, backgroundSize: 'cover', backgroundPosition: 'center', cursor: 'pointer',
|
||||
}}
|
||||
aria-label="View photo"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments button — cream */}
|
||||
<button
|
||||
onClick={() => setExpandedRoom(isExpanded ? null : room.id)}
|
||||
style={{
|
||||
marginTop: '1rem', padding: '0.6rem 1rem', borderRadius: '999px', border: 'none',
|
||||
background: navy, color: '#fff', cursor: 'pointer', fontWeight: 600, fontSize: '14px',
|
||||
marginTop: '1rem',
|
||||
padding: '0.6rem 1rem',
|
||||
borderRadius: '999px',
|
||||
border: `2px solid ${cream}`,
|
||||
background: cream,
|
||||
color: navy,
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
fontSize: '14px',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = gold; e.currentTarget.style.borderColor = gold; e.currentTarget.style.color = navy; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = cream; e.currentTarget.style.borderColor = cream; e.currentTarget.style.color = navy; }}
|
||||
>
|
||||
{isExpanded ? 'Hide comments' : `Comments (${comments.length})`}
|
||||
</button>
|
||||
|
||||
{/* Comments section */}
|
||||
{isExpanded && (
|
||||
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{loadingComments[room.id] && <p style={{ color: '#777', fontSize: '13px' }}>Loading comments...</p>}
|
||||
|
||||
{user ? (
|
||||
user.comments_disabled ? (
|
||||
<p style={{ color: '#c23b22', fontSize: '14px' }}>Comments are disabled for your account.</p>
|
||||
@@ -208,14 +331,30 @@ export default function RoomsPage() {
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="Share your thoughts about this room..."
|
||||
rows={3}
|
||||
style={{ padding: '0.75rem', borderRadius: '10px', border: '1px solid rgba(1,13,30,0.18)', fontFamily: 'inherit', fontSize: '14px' }}
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid rgba(1,13,30,0.18)',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '14px',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '12px', color: '#777' }}>Posting as {user.first_name || user.username}</span>
|
||||
<button
|
||||
onClick={() => postComment(room.id)}
|
||||
disabled={sending || !draft.trim()}
|
||||
style={{ padding: '0.5rem 1rem', borderRadius: '8px', border: 'none', background: gold, color: navy, fontWeight: 700, cursor: 'pointer' }}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: gold,
|
||||
color: navy,
|
||||
fontWeight: 700,
|
||||
cursor: draft.trim() ? 'pointer' : 'default',
|
||||
opacity: draft.trim() ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
{sending ? 'Sending...' : 'Post'}
|
||||
</button>
|
||||
@@ -236,7 +375,9 @@ export default function RoomsPage() {
|
||||
<strong style={{ color: navy, fontSize: '14px' }}>
|
||||
{c.first_name}{c.last_initial ? ` ${c.last_initial}.` : ''}
|
||||
</strong>
|
||||
<span style={{ fontSize: '12px', color: '#777' }}>{new Date(c.created_at).toLocaleDateString()}</span>
|
||||
<span style={{ fontSize: '12px', color: '#777' }}>
|
||||
{new Date(c.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: 0, color: '#555', fontSize: '14px' }}>{c.text}</p>
|
||||
{user?.role === 'admin' && (
|
||||
|
||||
Reference in New Issue
Block a user