Skip to content

Commit fcc977f

Browse files
use cloudinary for image storage. upload image to cloudinary and take the url generated and store in backend
1 parent d2745e2 commit fcc977f

11 files changed

Lines changed: 326 additions & 60 deletions

File tree

HOSTEL_README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,33 @@ This project has been converted from a project management system to a comprehens
3030
- **Role-based Access**: Only authorized users can add/edit hostels
3131
- **User Reviews**: Any authenticated user can rate and review hostels
3232

33+
## Image Storage with Cloudinary
34+
35+
Hostel images are now uploaded and stored using [Cloudinary](https://cloudinary.com/). The backend handles image uploads and stores the returned Cloudinary URLs in the database. **Firebase Storage is no longer used for images.**
36+
37+
### Cloudinary Setup
38+
1. **Create a Cloudinary account** at https://cloudinary.com/ (free tier is sufficient).
39+
2. **Get your credentials** from the Cloudinary dashboard:
40+
- Cloud name
41+
- API Key
42+
- API Secret
43+
3. **Add these to your `.env.local` file:**
44+
```env
45+
CLOUDINARY_CLOUD_NAME=your_cloud_name
46+
CLOUDINARY_API_KEY=your_api_key
47+
CLOUDINARY_API_SECRET=your_api_secret
48+
```
49+
4. **Install the Cloudinary SDK:**
50+
```bash
51+
npm install cloudinary
52+
```
53+
5. **No further setup is needed.** The backend API will handle all uploads and return the Cloudinary image URLs for use in the app.
54+
3355
## Database Schema
3456

3557
### Core Tables
3658
- **hostels**: Main hostel information
37-
- **hostel_images**: Image management with primary image support
59+
- **hostel_images**: Image management with primary image support (stores Cloudinary URLs)
3860
- **ratings**: User ratings with multiple categories
3961
- **comments**: User reviews and feedback
4062
- **categories**: Dynamic category system

app/api/hostel-images/route.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
22
import { db } from '../../../lib/db';
33
import { hostelImages } from '../../../lib/schema';
44
import { eq } from 'drizzle-orm';
5+
import { uploadImageToCloudinary } from '@/util/uploadImage';
56

67
export async function GET(req: NextRequest) {
78
try {
@@ -25,19 +26,37 @@ export async function GET(req: NextRequest) {
2526

2627
export async function POST(req: NextRequest) {
2728
try {
28-
const { hostelId, imageUrl, imageType, isPrimary } = await req.json();
29+
// Parse multipart/form-data
30+
const formData = await req.formData();
31+
const hostelId = formData.get('hostelId');
32+
const imageType = formData.get('imageType') || 'general';
33+
const isPrimary = formData.get('isPrimary') === 'true';
34+
const file = formData.get('file');
35+
36+
if (!hostelId || !file || typeof file === 'string') {
37+
return NextResponse.json({ message: 'hostelId and image file are required.' }, { status: 400 });
38+
}
39+
40+
// Convert file to Buffer
41+
const arrayBuffer = await file.arrayBuffer();
42+
const buffer = Buffer.from(arrayBuffer);
43+
const filename = `${Date.now()}_${file.name}`;
44+
45+
// Upload to Cloudinary
46+
const result: any = await uploadImageToCloudinary(buffer, filename);
47+
const imageUrl = result.secure_url;
2948

3049
// If this is a primary image, unset other primary images for this hostel
3150
if (isPrimary) {
3251
await db.update(hostelImages)
3352
.set({ isPrimary: false })
34-
.where(eq(hostelImages.hostelId, hostelId));
53+
.where(eq(hostelImages.hostelId, Number(hostelId)));
3554
}
3655

3756
const [newImage] = await db.insert(hostelImages).values({
38-
hostelId,
57+
hostelId: Number(hostelId),
3958
imageUrl,
40-
imageType: imageType || 'general',
59+
imageType: imageType as string,
4160
isPrimary: isPrimary || false,
4261
uploadedAt: new Date(),
4362
}).returning();

app/hostels/[id]/page.tsx

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import HostelInteractionWrapper from './HostelInteractionWrapper';
44
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
55
import { Badge } from '@/components/ui/badge';
66
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
7+
import HostelImageSlider from '@/components/main/HostelImageSlider';
78

89
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
910

@@ -21,18 +22,13 @@ export default async function HostelDetailsPage({ params }: { params: Promise<{
2122
const mainImage = hostel.images && hostel.images.length > 0 ? hostel.images[0].imageUrl : null;
2223

2324
return (
24-
<div className="max-w-4xl mx-auto p-0 md:p-6">
25-
{/* Hero Section */}
26-
<Card className="relative rounded-3xl overflow-hidden shadow-lg mb-8">
27-
{mainImage ? (
28-
<Avatar className="w-full h-64 rounded-3xl">
29-
<AvatarImage src={mainImage} alt={hostel.hostelName} className="object-cover w-full h-64" />
30-
<AvatarFallback className="w-full h-64 flex items-center justify-center text-4xl text-white font-bold bg-gradient-to-r from-blue-200 to-blue-400">
31-
{hostel.hostelName.charAt(0)}
32-
</AvatarFallback>
33-
</Avatar>
25+
<div className="max-w-5xl mx-auto p-0 md:p-6">
26+
{/* Hero Section with Image Slider */}
27+
<Card className="relative rounded-3xl overflow-hidden shadow-lg mb-8 border-none">
28+
{hostel.images && hostel.images.length > 0 ? (
29+
<HostelImageSlider images={hostel.images} alt={hostel.hostelName} large />
3430
) : (
35-
<div className="w-full h-64 bg-gradient-to-r from-blue-200 to-blue-400 flex items-center justify-center text-4xl text-white font-bold">
31+
<div className="w-full h-[60vh] md:h-[66vh] bg-gradient-to-r from-blue-200 to-blue-400 flex items-center justify-center text-4xl text-white font-bold rounded-3xl">
3632
{hostel.hostelName.charAt(0)}
3733
</div>
3834
)}

app/manage-hostels/page.tsx

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
44
import { Button } from '@/components/ui/button';
55
import { Input } from '@/components/ui/input';
66
import { Textarea } from '@/components/ui/textarea';
7-
import { uploadFileToFirebase } from '@/lib/firebase/config';
87
import { auth, firestore } from '@/lib/firebase/config';
98
import { doc, getDoc } from 'firebase/firestore';
109
import { useUserSession } from '@/hook/use_user_session';
@@ -173,13 +172,25 @@ export default function ManageHostelsPage() {
173172
setSubmitError(null);
174173
try {
175174
const uploadedImageUrls: string[] = [];
176-
for (const file of imageFiles) {
177-
const url = await uploadFileToFirebase(file, 'hostel-images');
178-
uploadedImageUrls.push(url);
179-
}
180175
const hostelCategoryOptions = Object.entries(selectedOptions).map(([categoryName, optionName]) => ({ categoryName, optionName }));
181176
let hostelId = editingHostelId;
182177
if (editMode && editingHostelId) {
178+
// Edit mode: upload images with existing hostelId
179+
for (let i = 0; i < imageFiles.length; i++) {
180+
const file = imageFiles[i];
181+
const formData = new FormData();
182+
formData.append('hostelId', String(editingHostelId));
183+
formData.append('file', file);
184+
formData.append('imageType', 'general');
185+
formData.append('isPrimary', String(existingImages.length + i === primaryImageIndex));
186+
const res = await fetch('/api/hostel-images', {
187+
method: 'POST',
188+
body: formData,
189+
});
190+
if (!res.ok) throw new Error('Failed to upload image');
191+
const data = await res.json();
192+
uploadedImageUrls.push(data.imageUrl);
193+
}
183194
let hostelRes: Response;
184195
hostelRes = await fetch('/api/hostels', {
185196
method: 'PUT',
@@ -203,18 +214,7 @@ export default function ManageHostelsPage() {
203214
for (const id of removedImageIds) {
204215
await fetch(`/api/hostel-images?imageId=${id}`, { method: 'DELETE' });
205216
}
206-
for (let i = 0; i < uploadedImageUrls.length; i++) {
207-
await fetch('/api/hostel-images', {
208-
method: 'POST',
209-
headers: { 'Content-Type': 'application/json' },
210-
body: JSON.stringify({
211-
hostelId: editingHostelId,
212-
imageUrl: uploadedImageUrls[i],
213-
imageType: 'general',
214-
isPrimary: false,
215-
}),
216-
});
217-
}
217+
// Set primary image
218218
const allImages = [...existingImages, ...uploadedImageUrls.map((url, i) => ({ imageUrl: url, imageId: null, isPrimary: false, isNew: true, idx: i }))];
219219
for (let i = 0; i < allImages.length; i++) {
220220
const img = allImages[i];
@@ -243,6 +243,7 @@ export default function ManageHostelsPage() {
243243
}
244244
}
245245
} else {
246+
// Create hostel first, then upload images with real hostelId
246247
let hostelRes: Response;
247248
hostelRes = await fetch('/api/hostels', {
248249
method: 'POST',
@@ -262,17 +263,19 @@ export default function ManageHostelsPage() {
262263
if (!hostelRes.ok) throw new Error('Failed to create hostel');
263264
let hostelData: any = await hostelRes.json();
264265
hostelId = hostelData.hostelId;
265-
for (let i = 0; i < uploadedImageUrls.length; i++) {
266-
await fetch('/api/hostel-images', {
266+
// Now upload images with correct hostelId
267+
for (let i = 0; i < imageFiles.length; i++) {
268+
const file = imageFiles[i];
269+
const formData = new FormData();
270+
formData.append('hostelId', String(hostelId));
271+
formData.append('file', file);
272+
formData.append('imageType', 'general');
273+
formData.append('isPrimary', String(existingImages.length + i === primaryImageIndex));
274+
const res = await fetch('/api/hostel-images', {
267275
method: 'POST',
268-
headers: { 'Content-Type': 'application/json' },
269-
body: JSON.stringify({
270-
hostelId,
271-
imageUrl: uploadedImageUrls[i],
272-
imageType: 'general',
273-
isPrimary: i === primaryImageIndex,
274-
}),
276+
body: formData,
275277
});
278+
if (!res.ok) throw new Error('Failed to upload image');
276279
}
277280
}
278281
handleCloseModal();
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"use client";
2+
import React, { useState } from 'react';
3+
import { Swiper, SwiperSlide } from 'swiper/react';
4+
import { Navigation, Pagination } from 'swiper/modules';
5+
import 'swiper/css';
6+
import 'swiper/css/navigation';
7+
import 'swiper/css/pagination';
8+
9+
interface HostelImageSliderProps {
10+
images: { imageUrl: string; imageType?: string }[];
11+
alt?: string;
12+
large?: boolean; // If true, use large (details page) style
13+
}
14+
15+
// Helper to get high quality Cloudinary URL
16+
function getHighQualityUrl(url: string) {
17+
if (url.includes('res.cloudinary.com')) {
18+
// Insert transformation after '/upload/'
19+
return url.replace('/upload/', '/upload/q_auto:best/');
20+
}
21+
return url;
22+
}
23+
24+
const HostelImageSlider: React.FC<HostelImageSliderProps> = ({ images, alt = '', large = false }) => {
25+
const [modalOpen, setModalOpen] = useState(false);
26+
const [modalImg, setModalImg] = useState<string | null>(null);
27+
28+
if (!images || images.length === 0) return null;
29+
30+
const handleImgClick = (imgUrl: string) => {
31+
setModalImg(imgUrl);
32+
setModalOpen(true);
33+
};
34+
35+
const closeModal = () => {
36+
setModalOpen(false);
37+
setModalImg(null);
38+
};
39+
40+
return (
41+
<>
42+
<div className={large ? 'w-full h-[22rem] md:h-[24rem] rounded-3xl overflow-hidden' : 'w-full h-48 rounded-lg overflow-hidden'}>
43+
<Swiper
44+
modules={[Navigation, Pagination]}
45+
navigation={images.length > 1}
46+
pagination={{ clickable: true }}
47+
spaceBetween={0}
48+
slidesPerView={1}
49+
className="h-full"
50+
>
51+
{images.map((img, idx) => (
52+
<SwiperSlide key={idx}>
53+
<img
54+
src={getHighQualityUrl(img.imageUrl)}
55+
alt={alt || `Hostel image ${idx + 1}`}
56+
className="object-cover w-full h-full cursor-pointer"
57+
draggable={false}
58+
onClick={() => handleImgClick(getHighQualityUrl(img.imageUrl))}
59+
/>
60+
</SwiperSlide>
61+
))}
62+
</Swiper>
63+
</div>
64+
{/* Modal for full-size image */}
65+
{modalOpen && modalImg && (
66+
<div
67+
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-80"
68+
style={{ width: '100vw', height: '100vh' }}
69+
onClick={closeModal}
70+
>
71+
<img
72+
src={modalImg}
73+
alt="Full size hostel image"
74+
className="block w-auto h-auto max-w-[100vw] max-h-[100vh] rounded-lg shadow-2xl border-4 border-white"
75+
style={{ objectFit: 'contain' }}
76+
onClick={e => e.stopPropagation()}
77+
/>
78+
</div>
79+
)}
80+
</>
81+
);
82+
};
83+
84+
export default HostelImageSlider;

components/main/hostelCard.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState } from "react";
22
import { CalendarDays, MapPin, Star, Phone, Mail, Globe, DollarSign, ChevronDown, ChevronUp, ExternalLink } from "lucide-react";
33
import Link from "next/link";
44
import { Hostel } from "@/types/project";
5+
import HostelImageSlider from './HostelImageSlider';
56

67
interface HostelCardProps {
78
hostel: Hostel;
@@ -28,9 +29,17 @@ export default function HostelCard({ hostel }: HostelCardProps) {
2829

2930
return (
3031
<div
31-
className={`bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition-all duration-300 transform ${expanded ? "scale-105" : "hover:-translate-y-1"}`}
32+
className={`bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition-all duration-300 transform ${expanded ? "scale-105" : "hover:-translate-y-1"} w-full`}
3233
onClick={toggleExpand}
3334
>
35+
{/* Image Slider at the top */}
36+
{hostel.images && hostel.images.length > 0 ? (
37+
<HostelImageSlider images={hostel.images} alt={hostel.hostelName} />
38+
) : (
39+
<div className="w-full h-48 bg-gradient-to-r from-blue-200 to-blue-400 flex items-center justify-center text-3xl text-white font-bold">
40+
{hostel.hostelName.charAt(0)}
41+
</div>
42+
)}
3443
<div className="md:flex">
3544
<div className="bg-gradient-to-r from-blue-600 to-blue-800 p-6 md:w-1/3 relative">
3645
{primaryImage && (

components/main/hostelGrid.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Hostel } from "@/types/project";
66
import ProjectCardSkeleton from './hostelCardSkeleton'; // We'll rename this file later
77

88
interface HostelGridProps {
9-
activeTab: string;
9+
activeTab: string;
1010
filters: Record<string, string[]>; // Accept selected filters
1111
refreshTrigger?: boolean; // New prop to trigger re-fetch
1212
}
@@ -138,7 +138,7 @@ const HostelGrid = ({ activeTab, filters, refreshTrigger }: HostelGridProps) =>
138138

139139
return (
140140
<div className="container mx-auto px-4 py-8">
141-
<div className="space-y-6 animate-fadeIn">
141+
<div className="grid grid-cols-1 gap-8 animate-fadeIn">
142142
{hostels.length > 0 ? (
143143
hostels.map((hostel, index) => (
144144
<div
@@ -150,7 +150,7 @@ const HostelGrid = ({ activeTab, filters, refreshTrigger }: HostelGridProps) =>
150150
</div>
151151
))
152152
) : (
153-
<p className="text-gray-500 text-center">No hostels available.</p>
153+
<p className="text-gray-500 text-center col-span-full">No hostels available.</p>
154154
)}
155155
</div>
156156
</div>

lib/firebase/config.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,4 @@ export const firebaseApp: FirebaseApp =
2121

2222
export const auth = getAuth(firebaseApp);
2323
export const firestore = getFirestore(firebaseApp);
24-
export const storage = getStorage(firebaseApp);
25-
26-
export async function uploadFileToFirebase(file: File, folder: string): Promise<string> {
27-
const storageRef = ref(storage, `${folder}/${file.name}`);
28-
const snapshot = await uploadBytes(storageRef, file);
29-
return await getDownloadURL(snapshot.ref);
30-
}
24+
export const storage = getStorage(firebaseApp);

0 commit comments

Comments
 (0)