feat:add Finds feature with media upload and R2 support

This commit is contained in:
2025-10-10 13:31:40 +02:00
parent c454b66b39
commit e0f5595e88
18 changed files with 3272 additions and 2 deletions

View File

@@ -50,7 +50,7 @@ export const handle: Handle = async ({ event, resolve }) => {
"worker-src 'self' blob:; " +
"style-src 'self' 'unsafe-inline' fonts.googleapis.com; " +
"font-src 'self' fonts.gstatic.com; " +
"img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org; " +
"img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org pub-6495d15c9d09b19ddc65f0a01892a183.r2.dev; " +
"connect-src 'self' *.openstreetmap.org; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +

View File

@@ -0,0 +1,432 @@
<script lang="ts">
import Modal from '$lib/components/Modal.svelte';
import Input from '$lib/components/Input.svelte';
import Button from '$lib/components/Button.svelte';
import { coordinates } from '$lib/stores/location';
interface Props {
isOpen: boolean;
onClose: () => void;
onFindCreated: (event: CustomEvent) => void;
}
let { isOpen, onClose, onFindCreated }: Props = $props();
// Form state
let title = $state('');
let description = $state('');
let latitude = $state('');
let longitude = $state('');
let locationName = $state('');
let category = $state('cafe');
let isPublic = $state(true);
let selectedFiles = $state<FileList | null>(null);
let isSubmitting = $state(false);
let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]);
// Categories
const categories = [
{ value: 'cafe', label: 'Café' },
{ value: 'restaurant', label: 'Restaurant' },
{ value: 'park', label: 'Park' },
{ value: 'landmark', label: 'Landmark' },
{ value: 'shop', label: 'Shop' },
{ value: 'museum', label: 'Museum' },
{ value: 'other', label: 'Other' }
];
// Auto-fill location when modal opens
$effect(() => {
if (isOpen && $coordinates) {
latitude = $coordinates.latitude.toString();
longitude = $coordinates.longitude.toString();
}
});
// Handle file selection
function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement;
selectedFiles = target.files;
}
// Upload media files
async function uploadMedia(): Promise<void> {
if (!selectedFiles || selectedFiles.length === 0) return;
const formData = new FormData();
Array.from(selectedFiles).forEach((file) => {
formData.append('files', file);
});
const response = await fetch('/api/finds/upload', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Failed to upload media');
}
const result = await response.json();
uploadedMedia = result.media;
}
// Submit form
async function handleSubmit() {
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
if (!title.trim() || isNaN(lat) || isNaN(lng)) {
return;
}
isSubmitting = true;
try {
// Upload media first if any
if (selectedFiles && selectedFiles.length > 0) {
await uploadMedia();
}
// Create the find
const response = await fetch('/api/finds', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: title.trim(),
description: description.trim() || null,
latitude: lat,
longitude: lng,
locationName: locationName.trim() || null,
category,
isPublic,
media: uploadedMedia
})
});
if (!response.ok) {
throw new Error('Failed to create find');
}
// Reset form and close modal
resetForm();
onClose();
// Notify parent about new find creation
onFindCreated(new CustomEvent('findCreated', { detail: { reload: true } }));
} catch (error) {
console.error('Error creating find:', error);
alert('Failed to create find. Please try again.');
} finally {
isSubmitting = false;
}
}
function resetForm() {
title = '';
description = '';
locationName = '';
category = 'cafe';
isPublic = true;
selectedFiles = null;
uploadedMedia = [];
// Reset file input
const fileInput = document.querySelector('#media-files') as HTMLInputElement;
if (fileInput) {
fileInput.value = '';
}
}
function closeModal() {
resetForm();
onClose();
}
</script>
{#if isOpen}
<Modal bind:showModal={isOpen} positioning="center">
{#snippet header()}
<h2>Create Find</h2>
{/snippet}
<div class="form-container">
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<div class="form-group">
<label for="title">Title *</label>
<Input name="title" placeholder="What did you find?" required bind:value={title} />
<span class="char-count">{title.length}/100</span>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
name="description"
placeholder="Tell us about this place..."
maxlength="500"
bind:value={description}
></textarea>
<span class="char-count">{description.length}/500</span>
</div>
<div class="form-group">
<label for="location-name">Location Name</label>
<Input
name="location-name"
placeholder="e.g., Café Belga, Brussels"
bind:value={locationName}
/>
</div>
<div class="form-row">
<div class="form-group">
<label for="latitude">Latitude *</label>
<Input name="latitude" type="text" required bind:value={latitude} />
</div>
<div class="form-group">
<label for="longitude">Longitude *</label>
<Input name="longitude" type="text" required bind:value={longitude} />
</div>
</div>
<div class="form-group">
<label for="category">Category</label>
<select name="category" bind:value={category}>
{#each categories as cat (cat.value)}
<option value={cat.value}>{cat.label}</option>
{/each}
</select>
</div>
<div class="form-group">
<label for="media-files">Photos/Videos (max 5)</label>
<input
id="media-files"
type="file"
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
multiple
max="5"
onchange={handleFileChange}
/>
{#if selectedFiles && selectedFiles.length > 0}
<div class="file-preview">
{#each Array.from(selectedFiles) as file (file.name)}
<span class="file-name">{file.name}</span>
{/each}
</div>
{/if}
</div>
<div class="form-group">
<label class="privacy-toggle">
<input type="checkbox" bind:checked={isPublic} />
<span class="checkmark"></span>
Make this find public
</label>
<p class="privacy-help">
{isPublic ? 'Everyone can see this find' : 'Only your friends can see this find'}
</p>
</div>
<div class="form-actions">
<button
type="button"
class="button secondary"
onclick={closeModal}
disabled={isSubmitting}
>
Cancel
</button>
<Button type="submit" disabled={isSubmitting || !title.trim()}>
{isSubmitting ? 'Creating...' : 'Create Find'}
</Button>
</div>
</form>
</div>
</Modal>
{/if}
<style>
.form-container {
padding: 24px;
max-height: 70vh;
overflow-y: auto;
}
.form-group {
margin-bottom: 20px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
font-size: 0.9rem;
font-family: 'Washington', serif;
}
textarea {
width: 100%;
padding: 1.25rem 1.5rem;
border: none;
border-radius: 2rem;
background-color: #e0e0e0;
font-size: 1rem;
color: #333;
outline: none;
transition: background-color 0.2s ease;
resize: vertical;
min-height: 100px;
font-family: inherit;
}
textarea:focus {
background-color: #d5d5d5;
}
textarea::placeholder {
color: #888;
}
select {
width: 100%;
padding: 1.25rem 1.5rem;
border: none;
border-radius: 2rem;
background-color: #e0e0e0;
font-size: 1rem;
color: #333;
outline: none;
transition: background-color 0.2s ease;
cursor: pointer;
}
select:focus {
background-color: #d5d5d5;
}
input[type='file'] {
width: 100%;
padding: 12px;
border: 2px dashed #ccc;
border-radius: 8px;
background-color: #f9f9f9;
cursor: pointer;
transition: border-color 0.2s ease;
}
input[type='file']:hover {
border-color: #999;
}
.char-count {
font-size: 0.8rem;
color: #666;
text-align: right;
display: block;
margin-top: 4px;
}
.file-preview {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.file-name {
background-color: #f0f0f0;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8rem;
color: #666;
}
.privacy-toggle {
display: flex;
align-items: center;
cursor: pointer;
font-weight: normal;
}
.privacy-toggle input[type='checkbox'] {
margin-right: 8px;
width: 18px;
height: 18px;
}
.privacy-help {
font-size: 0.8rem;
color: #666;
margin-top: 4px;
margin-left: 26px;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 32px;
padding-top: 20px;
border-top: 1px solid #e5e5e5;
}
.button {
flex: 1;
padding: 1.25rem 2rem;
border: none;
border-radius: 2rem;
font-size: 1rem;
font-weight: 500;
transition: all 0.2s ease;
cursor: pointer;
height: 3.5rem;
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.button:disabled:hover {
transform: none;
}
.button.secondary {
background-color: #6c757d;
color: white;
}
.button.secondary:hover:not(:disabled) {
background-color: #5a6268;
transform: translateY(-1px);
}
@media (max-width: 640px) {
.form-container {
padding: 16px;
}
.form-row {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,403 @@
<script lang="ts">
import Modal from '$lib/components/Modal.svelte';
interface Find {
id: string;
title: string;
description?: string;
latitude: string;
longitude: string;
locationName?: string;
category?: string;
createdAt: string;
user: {
id: string;
username: string;
};
media?: Array<{
type: string;
url: string;
thumbnailUrl: string;
}>;
}
interface Props {
find: Find | null;
onClose: () => void;
}
let { find, onClose }: Props = $props();
let showModal = $state(true);
let currentMediaIndex = $state(0);
// Close modal when showModal changes to false
$effect(() => {
if (!showModal) {
onClose();
}
});
function nextMedia() {
if (!find?.media) return;
currentMediaIndex = (currentMediaIndex + 1) % find.media.length;
}
function prevMedia() {
if (!find?.media) return;
currentMediaIndex = currentMediaIndex === 0 ? find.media.length - 1 : currentMediaIndex - 1;
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function getDirections() {
if (!find) return;
const lat = parseFloat(find.latitude);
const lng = parseFloat(find.longitude);
const url = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
window.open(url, '_blank');
}
function shareFindUrl() {
if (!find) return;
const url = `${window.location.origin}/finds/${find.id}`;
if (navigator.share) {
navigator.share({
title: find.title,
text: find.description || `Check out this find: ${find.title}`,
url: url
});
} else {
navigator.clipboard.writeText(url);
alert('Find URL copied to clipboard!');
}
}
</script>
{#if find}
<Modal bind:showModal positioning="center">
{#snippet header()}
<div class="find-header">
<div class="find-info">
<h2 class="find-title">{find.title}</h2>
<div class="find-meta">
<span class="username">@{find.user.username}</span>
<span class="separator"></span>
<span class="date">{formatDate(find.createdAt)}</span>
{#if find.category}
<span class="separator"></span>
<span class="category">{find.category}</span>
{/if}
</div>
</div>
</div>
{/snippet}
<div class="find-content">
{#if find.media && find.media.length > 0}
<div class="media-container">
<div class="media-viewer">
{#if find.media[currentMediaIndex].type === 'photo'}
<img src={find.media[currentMediaIndex].url} alt={find.title} class="media-image" />
{:else}
<video
src={find.media[currentMediaIndex].url}
controls
class="media-video"
poster={find.media[currentMediaIndex].thumbnailUrl}
>
<track kind="captions" />
Your browser does not support the video tag.
</video>
{/if}
{#if find.media.length > 1}
<button class="media-nav prev" onclick={prevMedia} aria-label="Previous media">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M15 18L9 12L15 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<button class="media-nav next" onclick={nextMedia} aria-label="Next media">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M9 18L15 12L9 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{/if}
</div>
{#if find.media.length > 1}
<div class="media-indicators">
{#each find.media, index (index)}
<button
class="indicator"
class:active={index === currentMediaIndex}
onclick={() => (currentMediaIndex = index)}
aria-label={`View media ${index + 1}`}
></button>
{/each}
</div>
{/if}
</div>
{/if}
<div class="find-details">
{#if find.description}
<p class="description">{find.description}</p>
{/if}
{#if find.locationName}
<div class="location-info">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M21 10C21 17 12 23 12 23S3 17 3 10A9 9 0 0 1 12 1A9 9 0 0 1 21 10Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
</svg>
<span>{find.locationName}</span>
</div>
{/if}
<div class="actions">
<button class="button primary" onclick={getDirections}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M3 11L22 2L13 21L11 13L3 11Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Directions
</button>
<button class="button secondary" onclick={shareFindUrl}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M4 12V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="16,6 12,2 8,6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="12"
y1="2"
x2="12"
y2="15"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Share
</button>
</div>
</div>
</div>
</Modal>
{/if}
<style>
.find-content {
max-height: 80vh;
overflow-y: auto;
}
.find-header {
width: 100%;
}
.find-title {
font-family: 'Washington', serif;
font-size: 1.5rem;
font-weight: 600;
margin: 0;
color: #333;
line-height: 1.3;
}
.find-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
font-size: 0.9rem;
color: #666;
}
.username {
font-weight: 500;
color: #3b82f6;
}
.separator {
color: #ccc;
}
.category {
background: #f0f0f0;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.8rem;
text-transform: capitalize;
}
.media-container {
position: relative;
margin-bottom: 20px;
}
.media-viewer {
position: relative;
width: 100%;
height: 300px;
border-radius: 12px;
overflow: hidden;
background: #f5f5f5;
}
.media-image,
.media-video {
width: 100%;
height: 100%;
object-fit: cover;
}
.media-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.5);
color: white;
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.2s ease;
}
.media-nav:hover {
background: rgba(0, 0, 0, 0.7);
}
.media-nav.prev {
left: 12px;
}
.media-nav.next {
right: 12px;
}
.media-indicators {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 12px;
}
.indicator {
width: 8px;
height: 8px;
border-radius: 50%;
border: none;
background: #ddd;
cursor: pointer;
transition: background-color 0.2s ease;
}
.indicator.active {
background: #3b82f6;
}
.find-details {
padding: 0 24px 24px;
}
.description {
font-size: 1rem;
line-height: 1.6;
color: #333;
margin: 0 0 20px 0;
}
.location-info {
display: flex;
align-items: center;
gap: 8px;
color: #666;
font-size: 0.9rem;
margin-bottom: 24px;
}
.actions {
display: flex;
gap: 12px;
}
.actions :global(.button) {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
justify-content: center;
}
@media (max-width: 640px) {
.find-title {
font-size: 1.3rem;
}
.find-details {
padding: 0 16px 16px;
}
.media-viewer {
height: 250px;
}
.actions {
flex-direction: column;
}
}
</style>

View File

@@ -11,6 +11,28 @@
import LocationButton from './LocationButton.svelte';
import { Skeleton } from '$lib/components/skeleton';
interface Find {
id: string;
title: string;
description?: string;
latitude: string;
longitude: string;
locationName?: string;
category?: string;
isPublic: number;
createdAt: Date;
userId: string;
user: {
id: string;
username: string;
};
media?: Array<{
type: string;
url: string;
thumbnailUrl: string;
}>;
}
interface Props {
style?: StyleSpecification;
center?: [number, number];
@@ -18,6 +40,8 @@
class?: string;
showLocationButton?: boolean;
autoCenter?: boolean;
finds?: Find[];
onFindClick?: (find: Find) => void;
}
let {
@@ -43,7 +67,9 @@
zoom,
class: className = '',
showLocationButton = true,
autoCenter = true
autoCenter = true,
finds = [],
onFindClick
}: Props = $props();
let mapLoaded = $state(false);
@@ -121,6 +147,33 @@
</div>
</Marker>
{/if}
{#each finds as find (find.id)}
<Marker lngLat={[parseFloat(find.longitude), parseFloat(find.latitude)]}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="find-marker"
role="button"
tabindex="0"
onclick={() => onFindClick?.(find)}
title={find.title}
>
<div class="find-marker-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M12 2L13.09 8.26L19 9.27L13.09 10.28L12 16.54L10.91 10.28L5 9.27L10.91 8.26L12 2Z"
fill="currentColor"
/>
</svg>
</div>
{#if find.media && find.media.length > 0}
<div class="find-marker-preview">
<img src={find.media[0].thumbnailUrl} alt={find.title} />
</div>
{/if}
</div>
</Marker>
{/each}
</MapLibre>
{#if showLocationButton}
@@ -221,6 +274,55 @@
}
}
/* Find marker styles */
:global(.find-marker) {
width: 40px;
height: 40px;
cursor: pointer;
position: relative;
transform: translate(-50%, -50%);
transition: all 0.2s ease;
}
:global(.find-marker:hover) {
transform: translate(-50%, -50%) scale(1.1);
z-index: 100;
}
:global(.find-marker-icon) {
width: 32px;
height: 32px;
background: #ff6b35;
border: 3px solid white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
position: relative;
z-index: 2;
}
:global(.find-marker-preview) {
position: absolute;
top: -8px;
right: -8px;
width: 20px;
height: 20px;
border-radius: 50%;
overflow: hidden;
border: 2px solid white;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
z-index: 3;
}
:global(.find-marker-preview img) {
width: 100%;
height: 100%;
object-fit: cover;
}
@media (max-width: 768px) {
.map-container {
height: 300px;

View File

@@ -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;

View 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
View 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 });
}

View File

@@ -0,0 +1,131 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { find, findMedia, user } from '$lib/server/db/schema';
import { eq, and, sql } from 'drizzle-orm';
import { encodeBase64url } from '@oslojs/encoding';
function generateFindId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(15));
return encodeBase64url(bytes);
}
export const GET: RequestHandler = async ({ url, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const lat = url.searchParams.get('lat');
const lng = url.searchParams.get('lng');
const radius = url.searchParams.get('radius') || '10';
if (!lat || !lng) {
throw error(400, 'Latitude and longitude are required');
}
// Query finds within radius (simplified - in production use PostGIS)
// For MVP, using a simple bounding box approach
const latNum = parseFloat(lat);
const lngNum = parseFloat(lng);
const radiusNum = parseFloat(radius);
// Rough conversion: 1 degree ≈ 111km
const latDelta = radiusNum / 111;
const lngDelta = radiusNum / (111 * Math.cos((latNum * Math.PI) / 180));
const finds = await db
.select({
find: find,
user: {
id: user.id,
username: user.username
}
})
.from(find)
.innerJoin(user, eq(find.userId, user.id))
.where(
and(
eq(find.isPublic, 1),
// Simple bounding box filter
sql`CAST(${find.latitude} AS NUMERIC) BETWEEN ${latNum - latDelta} AND ${latNum + latDelta}`,
sql`CAST(${find.longitude} AS NUMERIC) BETWEEN ${lngNum - lngDelta} AND ${lngNum + lngDelta}`
)
)
.orderBy(find.createdAt);
// Get media for each find
const findsWithMedia = await Promise.all(
finds.map(async (item) => {
const media = await db
.select()
.from(findMedia)
.where(eq(findMedia.findId, item.find.id))
.orderBy(findMedia.orderIndex);
return {
...item.find,
user: item.user,
media: media
};
})
);
return json(findsWithMedia);
};
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const data = await request.json();
const { title, description, latitude, longitude, locationName, category, isPublic, media } = data;
if (!title || !latitude || !longitude) {
throw error(400, 'Title, latitude, and longitude are required');
}
if (title.length > 100) {
throw error(400, 'Title must be 100 characters or less');
}
if (description && description.length > 500) {
throw error(400, 'Description must be 500 characters or less');
}
const findId = generateFindId();
// Create find
const newFind = await db
.insert(find)
.values({
id: findId,
userId: locals.user.id,
title,
description,
latitude: latitude.toString(),
longitude: longitude.toString(),
locationName,
category,
isPublic: isPublic ? 1 : 0
})
.returning();
// Create media records if provided
if (media && media.length > 0) {
const mediaRecords = media.map(
(item: { type: string; url: string; thumbnailUrl?: string }, index: number) => ({
id: generateFindId(),
findId,
type: item.type,
url: item.url,
thumbnailUrl: item.thumbnailUrl,
orderIndex: index
})
);
await db.insert(findMedia).values(mediaRecords);
}
return json({ success: true, find: newFind[0] });
};

View File

@@ -0,0 +1,52 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { processAndUploadImage, processAndUploadVideo } from '$lib/server/media-processor';
import { encodeBase64url } from '@oslojs/encoding';
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
const MAX_FILES = 5;
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/quicktime'];
function generateFindId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(15));
return encodeBase64url(bytes);
}
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const formData = await request.formData();
const files = formData.getAll('files') as File[];
const findId = (formData.get('findId') as string) || generateFindId(); // Generate if creating new Find
if (files.length === 0 || files.length > MAX_FILES) {
throw error(400, `Must upload between 1 and ${MAX_FILES} files`);
}
const uploadedMedia: Array<{ type: string; url: string; thumbnailUrl: string }> = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
// Validate file size
if (file.size > MAX_FILE_SIZE) {
throw error(400, `File ${file.name} exceeds maximum size of 100MB`);
}
// Process based on type
if (ALLOWED_IMAGE_TYPES.includes(file.type)) {
const result = await processAndUploadImage(file, findId, i);
uploadedMedia.push({ type: 'photo', ...result });
} else if (ALLOWED_VIDEO_TYPES.includes(file.type)) {
const result = await processAndUploadVideo(file, findId, i);
uploadedMedia.push({ type: 'video', ...result });
} else {
throw error(400, `File type ${file.type} not allowed`);
}
}
return json({ findId, media: uploadedMedia });
};

View File

@@ -0,0 +1,123 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { find, findMedia, user } from '$lib/server/db/schema';
import { eq, and, sql, desc } from 'drizzle-orm';
export const load: PageServerLoad = async ({ locals, url }) => {
if (!locals.user) {
throw error(401, 'Authentication required');
}
// Get query parameters for location-based filtering
const lat = url.searchParams.get('lat');
const lng = url.searchParams.get('lng');
const radius = url.searchParams.get('radius') || '50'; // Default 50km radius
try {
// Build where conditions
const baseCondition = sql`(${find.isPublic} = 1 OR ${find.userId} = ${locals.user.id})`;
let whereConditions = baseCondition;
// Add location filtering if coordinates provided
if (lat && lng) {
const radiusKm = parseFloat(radius);
// Simple bounding box query for MVP (can upgrade to proper distance calculation later)
const latOffset = radiusKm / 111; // Approximate degrees per km for latitude
const lngOffset = radiusKm / (111 * Math.cos((parseFloat(lat) * Math.PI) / 180));
const locationConditions = and(
baseCondition,
sql`${find.latitude} BETWEEN ${parseFloat(lat) - latOffset} AND ${
parseFloat(lat) + latOffset
}`,
sql`${find.longitude} BETWEEN ${parseFloat(lng) - lngOffset} AND ${
parseFloat(lng) + lngOffset
}`
);
if (locationConditions) {
whereConditions = locationConditions;
}
}
// Get all finds with filtering
const finds = await db
.select({
id: find.id,
title: find.title,
description: find.description,
latitude: find.latitude,
longitude: find.longitude,
locationName: find.locationName,
category: find.category,
isPublic: find.isPublic,
createdAt: find.createdAt,
userId: find.userId,
username: user.username
})
.from(find)
.innerJoin(user, eq(find.userId, user.id))
.where(whereConditions)
.orderBy(desc(find.createdAt))
.limit(100);
// Get media for all finds
const findIds = finds.map((f) => f.id);
let media: Array<{
id: string;
findId: string;
type: string;
url: string;
thumbnailUrl: string | null;
orderIndex: number | null;
}> = [];
if (findIds.length > 0) {
media = await db
.select({
id: findMedia.id,
findId: findMedia.findId,
type: findMedia.type,
url: findMedia.url,
thumbnailUrl: findMedia.thumbnailUrl,
orderIndex: findMedia.orderIndex
})
.from(findMedia)
.where(
sql`${findMedia.findId} IN (${sql.join(
findIds.map((id) => sql`${id}`),
sql`, `
)})`
)
.orderBy(findMedia.orderIndex);
}
// Group media by find
const mediaByFind = media.reduce(
(acc, item) => {
if (!acc[item.findId]) {
acc[item.findId] = [];
}
acc[item.findId].push(item);
return acc;
},
{} as Record<string, typeof media>
);
// Combine finds with their media
const findsWithMedia = finds.map((findItem) => ({
...findItem,
media: mediaByFind[findItem.id] || []
}));
return {
finds: findsWithMedia
};
} catch (err) {
console.error('Error loading finds:', err);
return {
finds: []
};
}
};

View File

@@ -0,0 +1,518 @@
<script lang="ts">
import Map from '$lib/components/Map.svelte';
import CreateFindModal from '$lib/components/CreateFindModal.svelte';
import FindPreview from '$lib/components/FindPreview.svelte';
import type { PageData } from './$types';
import { coordinates } from '$lib/stores/location';
// Server response type
interface ServerFind {
id: string;
title: string;
description?: string;
latitude: string;
longitude: string;
locationName?: string;
category?: string;
isPublic: number;
createdAt: Date;
userId: string;
username: string;
media: Array<{
id: string;
findId: string;
type: string;
url: string;
thumbnailUrl: string | null;
orderIndex: number | null;
}>;
}
// Map component type
interface MapFind {
id: string;
title: string;
description?: string;
latitude: string;
longitude: string;
locationName?: string;
category?: string;
isPublic: number;
createdAt: Date;
userId: string;
user: {
id: string;
username: string;
};
media?: Array<{
type: string;
url: string;
thumbnailUrl: string;
}>;
}
// Interface for FindPreview component
interface FindPreviewData {
id: string;
title: string;
description?: string;
latitude: string;
longitude: string;
locationName?: string;
category?: string;
createdAt: string;
user: {
id: string;
username: string;
};
media?: Array<{
type: string;
url: string;
thumbnailUrl: string;
}>;
}
let { data }: { data: PageData } = $props();
let showCreateModal = $state(false);
let selectedFind: FindPreviewData | null = $state(null);
let viewMode = $state<'map' | 'list'>('map');
// Reactive finds list - convert server format to component format
let finds = $derived(
(data.finds as ServerFind[]).map((serverFind) => ({
...serverFind,
user: {
id: serverFind.userId,
username: serverFind.username
},
media: serverFind.media?.map((m) => ({
type: m.type,
url: m.url,
thumbnailUrl: m.thumbnailUrl || m.url
}))
})) as MapFind[]
);
function handleFindCreated(event: CustomEvent) {
// For now, just close modal and refresh page as in original implementation
showCreateModal = false;
if (event.detail?.reload) {
window.location.reload();
}
}
function handleFindClick(find: MapFind) {
// Convert MapFind to FindPreviewData format
selectedFind = {
id: find.id,
title: find.title,
description: find.description,
latitude: find.latitude,
longitude: find.longitude,
locationName: find.locationName,
category: find.category,
createdAt: find.createdAt.toISOString(),
user: find.user,
media: find.media?.map((m) => ({
type: m.type,
url: m.url,
thumbnailUrl: m.thumbnailUrl || m.url
}))
};
}
function closeFindPreview() {
selectedFind = null;
}
function openCreateModal() {
showCreateModal = true;
}
function closeCreateModal() {
showCreateModal = false;
}
</script>
<svelte:head>
<title>Finds - Serengo</title>
<meta
name="description"
content="Discover and share memorable places with the Serengo community"
/>
</svelte:head>
<div class="finds-page">
<div class="finds-header">
<h1 class="finds-title">Finds</h1>
<div class="finds-actions">
<div class="view-toggle">
<button
class="view-button"
class:active={viewMode === 'map'}
onclick={() => (viewMode = 'map')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M21 10C21 17 12 23 12 23S3 17 3 10A9 9 0 0 1 12 1A9 9 0 0 1 21 10Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
</svg>
Map
</button>
<button
class="view-button"
class:active={viewMode === 'list'}
onclick={() => (viewMode = 'list')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<line x1="8" y1="6" x2="21" y2="6" stroke="currentColor" stroke-width="2" />
<line x1="8" y1="12" x2="21" y2="12" stroke="currentColor" stroke-width="2" />
<line x1="8" y1="18" x2="21" y2="18" stroke="currentColor" stroke-width="2" />
<line x1="3" y1="6" x2="3.01" y2="6" stroke="currentColor" stroke-width="2" />
<line x1="3" y1="12" x2="3.01" y2="12" stroke="currentColor" stroke-width="2" />
<line x1="3" y1="18" x2="3.01" y2="18" stroke="currentColor" stroke-width="2" />
</svg>
List
</button>
</div>
<button class="create-find-button" onclick={openCreateModal}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
</svg>
Create Find
</button>
</div>
</div>
<div class="finds-content">
{#if viewMode === 'map'}
<div class="map-container">
<Map
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
{finds}
onFindClick={handleFindClick}
/>
</div>
{:else}
<div class="finds-list">
{#each finds as find (find.id)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="find-card" role="button" tabindex="0" onclick={() => handleFindClick(find)}>
<div class="find-card-media">
{#if find.media && find.media.length > 0}
{#if find.media[0].type === 'photo'}
<img
src={find.media[0].thumbnailUrl || find.media[0].url}
alt={find.title}
loading="lazy"
/>
{:else}
<video src={find.media[0].url} poster={find.media[0].thumbnailUrl} muted>
<track kind="captions" />
</video>
{/if}
{:else}
<div class="no-media">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
d="M21 10C21 17 12 23 12 23S3 17 3 10A9 9 0 0 1 12 1A9 9 0 0 1 21 10Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
</svg>
</div>
{/if}
</div>
<div class="find-card-content">
<h3 class="find-card-title">{find.title}</h3>
<p class="find-card-location">{find.locationName || 'Unknown location'}</p>
<p class="find-card-author">by @{find.user.username}</p>
</div>
</div>
{/each}
{#if finds.length === 0}
<div class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none">
<path
d="M21 10C21 17 12 23 12 23S3 17 3 10A9 9 0 0 1 12 1A9 9 0 0 1 21 10Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
</svg>
<h3>No finds nearby</h3>
<p>Be the first to create a find in this area!</p>
</div>
{/if}
</div>
{/if}
</div>
<!-- Floating action button for mobile -->
<button class="fab" onclick={openCreateModal} aria-label="Create new find">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
</svg>
</button>
</div>
<!-- Modals -->
{#if showCreateModal}
<CreateFindModal
isOpen={showCreateModal}
onClose={closeCreateModal}
onFindCreated={handleFindCreated}
/>
{/if}
{#if selectedFind}
<FindPreview find={selectedFind} onClose={closeFindPreview} />
{/if}
<style>
.finds-page {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.finds-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
background: white;
border-bottom: 1px solid #eee;
flex-shrink: 0;
}
.finds-title {
font-family: 'Washington', serif;
font-size: 2rem;
font-weight: bold;
margin: 0;
color: #333;
}
.finds-actions {
display: flex;
gap: 1rem;
align-items: center;
}
.view-toggle {
display: flex;
background: #f5f5f5;
border-radius: 8px;
padding: 2px;
}
.view-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
color: #666;
transition: all 0.2s;
}
.view-button.active {
background: white;
color: #333;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.create-find-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #007bff;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: background-color 0.2s;
}
.create-find-button:hover {
background: #0056b3;
}
.finds-content {
flex: 1;
overflow: hidden;
}
.map-container {
height: 100%;
width: 100%;
}
.finds-list {
height: 100%;
overflow-y: auto;
padding: 1rem 2rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.find-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition:
transform 0.2s,
box-shadow 0.2s;
}
.find-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.find-card-media {
aspect-ratio: 16 / 9;
overflow: hidden;
background: #f8f9fa;
}
.find-card-media img,
.find-card-media video {
width: 100%;
height: 100%;
object-fit: cover;
}
.no-media {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #666;
}
.find-card-content {
padding: 1rem;
}
.find-card-title {
font-family: 'Washington', serif;
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.5rem 0;
color: #333;
}
.find-card-location {
color: #666;
font-size: 0.875rem;
margin: 0 0 0.25rem 0;
}
.find-card-author {
color: #999;
font-size: 0.75rem;
margin: 0;
}
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 3rem;
color: #666;
}
.empty-state svg {
margin-bottom: 1rem;
}
.empty-state h3 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
}
.empty-state p {
margin: 0;
opacity: 0.7;
}
.fab {
position: fixed;
bottom: 2rem;
right: 2rem;
width: 56px;
height: 56px;
background: #007bff;
color: white;
border: none;
border-radius: 50%;
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.3);
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
transition: all 0.2s;
z-index: 100;
}
.fab:hover {
background: #0056b3;
transform: scale(1.1);
}
/* Mobile styles */
@media (max-width: 768px) {
.finds-header {
padding: 1rem;
flex-wrap: wrap;
gap: 1rem;
}
.finds-actions {
width: 100%;
justify-content: space-between;
}
.create-find-button {
display: none;
}
.fab {
display: flex;
}
.finds-list {
padding: 1rem;
grid-template-columns: 1fr;
}
.finds-title {
font-size: 1.5rem;
}
}
</style>