feat:add Finds feature with media upload and R2 support
This commit is contained in:
@@ -19,3 +19,66 @@ export const session = pgTable('session', {
|
||||
export type Session = typeof session.$inferSelect;
|
||||
|
||||
export type User = typeof user.$inferSelect;
|
||||
|
||||
// Finds feature tables
|
||||
export const find = pgTable('find', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
latitude: text('latitude').notNull(), // Using text for precision
|
||||
longitude: text('longitude').notNull(), // Using text for precision
|
||||
locationName: text('location_name'), // e.g., "Café Belga, Brussels"
|
||||
category: text('category'), // e.g., "cafe", "restaurant", "park", "landmark"
|
||||
isPublic: integer('is_public').default(1), // Using integer for boolean (1 = true, 0 = false)
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const findMedia = pgTable('find_media', {
|
||||
id: text('id').primaryKey(),
|
||||
findId: text('find_id')
|
||||
.notNull()
|
||||
.references(() => find.id, { onDelete: 'cascade' }),
|
||||
type: text('type').notNull(), // 'photo' or 'video'
|
||||
url: text('url').notNull(),
|
||||
thumbnailUrl: text('thumbnail_url'),
|
||||
orderIndex: integer('order_index').default(0),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const findLike = pgTable('find_like', {
|
||||
id: text('id').primaryKey(),
|
||||
findId: text('find_id')
|
||||
.notNull()
|
||||
.references(() => find.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const friendship = pgTable('friendship', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
friendId: text('friend_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
status: text('status').notNull(), // 'pending', 'accepted', 'blocked'
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
// Type exports for the new tables
|
||||
export type Find = typeof find.$inferSelect;
|
||||
export type FindMedia = typeof findMedia.$inferSelect;
|
||||
export type FindLike = typeof findLike.$inferSelect;
|
||||
export type Friendship = typeof friendship.$inferSelect;
|
||||
|
||||
export type FindInsert = typeof find.$inferInsert;
|
||||
export type FindMediaInsert = typeof findMedia.$inferInsert;
|
||||
export type FindLikeInsert = typeof findLike.$inferInsert;
|
||||
export type FriendshipInsert = typeof friendship.$inferInsert;
|
||||
|
||||
68
src/lib/server/media-processor.ts
Normal file
68
src/lib/server/media-processor.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import sharp from 'sharp';
|
||||
import { uploadToR2 } from './r2';
|
||||
|
||||
const THUMBNAIL_SIZE = 400;
|
||||
const MAX_IMAGE_SIZE = 1920;
|
||||
|
||||
export async function processAndUploadImage(
|
||||
file: File,
|
||||
findId: string,
|
||||
index: number
|
||||
): Promise<{ url: string; thumbnailUrl: string }> {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
// Generate unique filename
|
||||
const timestamp = Date.now();
|
||||
const filename = `finds/${findId}/image-${index}-${timestamp}`;
|
||||
|
||||
// Process full-size image (resize if too large, optimize)
|
||||
const processedImage = await sharp(buffer)
|
||||
.resize(MAX_IMAGE_SIZE, MAX_IMAGE_SIZE, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true
|
||||
})
|
||||
.jpeg({ quality: 85, progressive: true })
|
||||
.toBuffer();
|
||||
|
||||
// Generate thumbnail
|
||||
const thumbnail = await sharp(buffer)
|
||||
.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, {
|
||||
fit: 'cover',
|
||||
position: 'centre'
|
||||
})
|
||||
.jpeg({ quality: 80 })
|
||||
.toBuffer();
|
||||
|
||||
// Upload both to R2
|
||||
const imageFile = new File([new Uint8Array(processedImage)], `${filename}.jpg`, {
|
||||
type: 'image/jpeg'
|
||||
});
|
||||
const thumbFile = new File([new Uint8Array(thumbnail)], `${filename}-thumb.jpg`, {
|
||||
type: 'image/jpeg'
|
||||
});
|
||||
|
||||
const [url, thumbnailUrl] = await Promise.all([
|
||||
uploadToR2(imageFile, `${filename}.jpg`, 'image/jpeg'),
|
||||
uploadToR2(thumbFile, `${filename}-thumb.jpg`, 'image/jpeg')
|
||||
]);
|
||||
|
||||
return { url, thumbnailUrl };
|
||||
}
|
||||
|
||||
export async function processAndUploadVideo(
|
||||
file: File,
|
||||
findId: string,
|
||||
index: number
|
||||
): Promise<{ url: string; thumbnailUrl: string }> {
|
||||
const timestamp = Date.now();
|
||||
const filename = `finds/${findId}/video-${index}-${timestamp}.mp4`;
|
||||
|
||||
// Upload video directly (no processing on server to save resources)
|
||||
const url = await uploadToR2(file, filename, 'video/mp4');
|
||||
|
||||
// For video thumbnail, generate on client-side or use placeholder
|
||||
// This keeps server-side processing minimal
|
||||
const thumbnailUrl = `/video-placeholder.jpg`; // Use static placeholder
|
||||
|
||||
return { url, thumbnailUrl };
|
||||
}
|
||||
74
src/lib/server/r2.ts
Normal file
74
src/lib/server/r2.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
// Environment variables with validation
|
||||
const R2_ACCOUNT_ID = env.R2_ACCOUNT_ID;
|
||||
const R2_ACCESS_KEY_ID = env.R2_ACCESS_KEY_ID;
|
||||
const R2_SECRET_ACCESS_KEY = env.R2_SECRET_ACCESS_KEY;
|
||||
const R2_BUCKET_NAME = env.R2_BUCKET_NAME;
|
||||
|
||||
// Validate required environment variables
|
||||
function validateR2Config() {
|
||||
if (!R2_ACCOUNT_ID) throw new Error('R2_ACCOUNT_ID environment variable is required');
|
||||
if (!R2_ACCESS_KEY_ID) throw new Error('R2_ACCESS_KEY_ID environment variable is required');
|
||||
if (!R2_SECRET_ACCESS_KEY)
|
||||
throw new Error('R2_SECRET_ACCESS_KEY environment variable is required');
|
||||
if (!R2_BUCKET_NAME) throw new Error('R2_BUCKET_NAME environment variable is required');
|
||||
}
|
||||
|
||||
export const r2Client = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: R2_ACCESS_KEY_ID!,
|
||||
secretAccessKey: R2_SECRET_ACCESS_KEY!
|
||||
}
|
||||
});
|
||||
|
||||
export const R2_PUBLIC_URL = `https://pub-${R2_ACCOUNT_ID}.r2.dev`;
|
||||
|
||||
export async function uploadToR2(file: File, path: string, contentType: string): Promise<string> {
|
||||
validateR2Config();
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
await r2Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: path,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
CacheControl: 'public, max-age=31536000, immutable'
|
||||
})
|
||||
);
|
||||
|
||||
return `${R2_PUBLIC_URL}/${path}`;
|
||||
}
|
||||
|
||||
export async function deleteFromR2(path: string): Promise<void> {
|
||||
validateR2Config();
|
||||
|
||||
await r2Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: path
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSignedR2Url(path: string, expiresIn = 3600): Promise<string> {
|
||||
validateR2Config();
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: path
|
||||
});
|
||||
|
||||
return await getSignedUrl(r2Client, command, { expiresIn });
|
||||
}
|
||||
Reference in New Issue
Block a user