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

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