feat:update and delete finds

This commit is contained in:
2025-12-01 12:54:41 +01:00
parent 82d0e54d72
commit f8acec9a79
8 changed files with 1554 additions and 11 deletions

View File

@@ -0,0 +1,892 @@
<script lang="ts">
import { Input } from '$lib/components/input';
import { Label } from '$lib/components/label';
import { Button } from '$lib/components/button';
import POISearch from '../map/POISearch.svelte';
import type { PlaceResult } from '$lib/utils/places';
interface FindData {
id: string;
title: string;
description?: string | null;
latitude: string;
longitude: string;
locationName?: string | null;
category?: string | null;
isPublic: number;
media?: Array<{
id: string;
type: string;
url: string;
thumbnailUrl: string | null;
orderIndex: number | null;
}>;
}
interface Props {
isOpen: boolean;
find: FindData;
onClose: () => void;
onFindUpdated: (event: CustomEvent) => void;
onFindDeleted?: (event: CustomEvent) => void;
}
let { isOpen, find, onClose, onFindUpdated, onFindDeleted }: Props = $props();
let title = $state(find.title);
let description = $state(find.description || '');
let latitude = $state(find.latitude);
let longitude = $state(find.longitude);
let locationName = $state(find.locationName || '');
let category = $state(find.category || 'cafe');
let isPublic = $state(find.isPublic === 1);
let selectedFiles = $state<FileList | null>(null);
let isSubmitting = $state(false);
let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]);
let useManualLocation = $state(false);
let existingMedia = $state<
Array<{
id: string;
type: string;
url: string;
thumbnailUrl: string | null;
orderIndex: number | null;
}>
>(find.media || []);
let mediaToDelete = $state<string[]>([]);
let showDeleteConfirm = $state(false);
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' }
];
let isMobile = $state(false);
$effect(() => {
if (typeof window === 'undefined') return;
const checkIsMobile = () => {
isMobile = window.innerWidth < 768;
};
checkIsMobile();
window.addEventListener('resize', checkIsMobile);
return () => window.removeEventListener('resize', checkIsMobile);
});
// Reset state when find changes
$effect(() => {
if (isOpen) {
title = find.title;
description = find.description || '';
latitude = find.latitude;
longitude = find.longitude;
locationName = find.locationName || '';
category = find.category || 'cafe';
isPublic = find.isPublic === 1;
existingMedia = find.media || [];
mediaToDelete = [];
uploadedMedia = [];
selectedFiles = null;
useManualLocation = false;
}
});
function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement;
selectedFiles = target.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;
}
function removeExistingMedia(mediaId: string) {
mediaToDelete = [...mediaToDelete, mediaId];
existingMedia = existingMedia.filter((m) => m.id !== mediaId);
}
async function handleSubmit() {
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
if (!title.trim() || isNaN(lat) || isNaN(lng)) {
return;
}
isSubmitting = true;
try {
// Upload new media files if any
if (selectedFiles && selectedFiles.length > 0) {
await uploadMedia();
}
// Combine existing media with new uploads
const allMedia = [
...existingMedia.map((m) => ({
id: m.id,
type: m.type,
url: m.url,
thumbnailUrl: m.thumbnailUrl || m.url
})),
...uploadedMedia
];
const response = await fetch(`/api/finds/${find.id}`, {
method: 'PUT',
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: allMedia,
mediaToDelete
})
});
if (!response.ok) {
throw new Error('Failed to update find');
}
onFindUpdated(new CustomEvent('findUpdated', { detail: { reload: true } }));
onClose();
} catch (error) {
console.error('Error updating find:', error);
alert('Failed to update find. Please try again.');
} finally {
isSubmitting = false;
}
}
async function handleDelete() {
if (!showDeleteConfirm) {
showDeleteConfirm = true;
return;
}
isSubmitting = true;
try {
const response = await fetch(`/api/finds/${find.id}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete find');
}
if (onFindDeleted) {
onFindDeleted(new CustomEvent('findDeleted', { detail: { findId: find.id } }));
}
onClose();
} catch (error) {
console.error('Error deleting find:', error);
alert('Failed to delete find. Please try again.');
} finally {
isSubmitting = false;
showDeleteConfirm = false;
}
}
function handlePlaceSelected(place: PlaceResult) {
locationName = place.name;
latitude = place.latitude.toString();
longitude = place.longitude.toString();
}
function toggleLocationMode() {
useManualLocation = !useManualLocation;
}
function closeModal() {
showDeleteConfirm = false;
onClose();
}
</script>
{#if isOpen}
<div class="modal-container" class:mobile={isMobile}>
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">Edit Find</h2>
<button type="button" class="close-button" onclick={closeModal} aria-label="Close">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
d="M18 6L6 18M6 6L18 18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="form"
>
<div class="modal-body">
<div class="field">
<Label for="title">What did you find?</Label>
<Input name="title" placeholder="Amazing coffee shop..." required bind:value={title} />
</div>
<div class="field">
<Label for="description">Tell us about it</Label>
<textarea
name="description"
placeholder="The best cappuccino in town..."
maxlength="500"
bind:value={description}
></textarea>
</div>
<div class="location-section">
<div class="location-header">
<Label>Location</Label>
<button type="button" onclick={toggleLocationMode} class="toggle-button">
{useManualLocation ? 'Use Search' : 'Manual Entry'}
</button>
</div>
{#if useManualLocation}
<div class="field">
<Label for="location-name">Location name</Label>
<Input
name="location-name"
placeholder="Café Central, Brussels"
bind:value={locationName}
/>
</div>
{:else}
<POISearch
onPlaceSelected={handlePlaceSelected}
placeholder="Search for cafés, restaurants, landmarks..."
label=""
showNearbyButton={true}
/>
{/if}
</div>
<div class="field-group">
<div class="field">
<Label for="category">Category</Label>
<select name="category" bind:value={category} class="select">
{#each categories as cat (cat.value)}
<option value={cat.value}>{cat.label}</option>
{/each}
</select>
</div>
<div class="field">
<Label>Privacy</Label>
<label class="privacy-toggle">
<input type="checkbox" bind:checked={isPublic} />
<span>{isPublic ? 'Public' : 'Private'}</span>
</label>
</div>
</div>
{#if existingMedia.length > 0}
<div class="field">
<Label>Current Media</Label>
<div class="existing-media-grid">
{#each existingMedia as mediaItem (mediaItem.id)}
<div class="media-item">
{#if mediaItem.type === 'photo'}
<img
src={mediaItem.thumbnailUrl || mediaItem.url}
alt="Media"
class="media-thumbnail"
/>
{:else}
<video src={mediaItem.url} class="media-thumbnail" muted>
<track kind="captions" />
</video>
{/if}
<button
type="button"
class="remove-media-button"
onclick={() => removeExistingMedia(mediaItem.id)}
aria-label="Remove media"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M18 6L6 18M6 6L18 18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
{/each}
</div>
</div>
{/if}
<div class="field">
<Label for="media-files">Add new photo or video</Label>
<div class="file-upload">
<input
id="media-files"
type="file"
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
onchange={handleFileChange}
class="file-input"
/>
<div class="file-content">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M14.5 4H6A2 2 0 0 0 4 6V18A2 2 0 0 0 6 20H18A2 2 0 0 0 20 18V9.5L14.5 4Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M14 4V10H20"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span>Click to upload</span>
</div>
</div>
{#if selectedFiles && selectedFiles.length > 0}
<div class="file-selected">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M14.5 4H6A2 2 0 0 0 4 6V18A2 2 0 0 0 6 20H18A2 2 0 0 0 20 18V9.5L14.5 4Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M14 4V10H20"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span>{selectedFiles[0].name}</span>
</div>
{/if}
</div>
{#if useManualLocation || (!latitude && !longitude)}
<div class="field-group">
<div class="field">
<Label for="latitude">Latitude</Label>
<Input name="latitude" type="text" required bind:value={latitude} />
</div>
<div class="field">
<Label for="longitude">Longitude</Label>
<Input name="longitude" type="text" required bind:value={longitude} />
</div>
</div>
{:else if latitude && longitude}
<div class="coordinates-display">
<Label>Selected coordinates</Label>
<div class="coordinates-info">
<span class="coordinate">Lat: {parseFloat(latitude).toFixed(6)}</span>
<span class="coordinate">Lng: {parseFloat(longitude).toFixed(6)}</span>
<button
type="button"
onclick={() => (useManualLocation = true)}
class="edit-coords-button"
>
Edit
</button>
</div>
</div>
{/if}
</div>
<div class="modal-footer">
<Button
variant="destructive"
type="button"
onclick={handleDelete}
disabled={isSubmitting}
class="delete-button"
>
{showDeleteConfirm ? 'Confirm Delete?' : 'Delete Find'}
</Button>
<div class="action-buttons">
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !title.trim()}>
{isSubmitting ? 'Updating...' : 'Update Find'}
</Button>
</div>
</div>
</form>
</div>
</div>
{/if}
<style>
.modal-container {
position: fixed;
top: 80px;
right: 20px;
width: 40%;
max-width: 600px;
min-width: 500px;
height: calc(100vh - 100px);
backdrop-filter: blur(10px);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 50;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.modal-container.mobile {
top: auto;
bottom: 0;
left: 0;
right: 0;
width: 100%;
min-width: 0;
max-width: none;
height: 90vh;
border-radius: 16px 16px 0 0;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-content {
display: flex;
flex-direction: column;
height: 100%;
background: rgba(255, 255, 255, 0.6);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.modal-title {
font-family: 'Washington', serif;
font-size: 1.5rem;
font-weight: 600;
margin: 0;
color: hsl(var(--foreground));
}
.close-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: hsl(var(--muted-foreground));
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.close-button:hover {
background: hsl(var(--muted) / 0.5);
color: hsl(var(--foreground));
}
.form {
height: 100%;
display: flex;
flex-direction: column;
}
.modal-body {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
min-height: 0;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
textarea {
min-height: 100px;
padding: 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--background));
font-family: inherit;
font-size: 0.875rem;
resize: vertical;
outline: none;
transition: border-color 0.2s ease;
line-height: 1.5;
}
textarea:focus {
border-color: hsl(var(--primary));
box-shadow: 0 0 0 1px hsl(var(--primary));
}
textarea::placeholder {
color: hsl(var(--muted-foreground));
}
.select {
padding: 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--background));
font-size: 0.875rem;
outline: none;
transition: border-color 0.2s ease;
cursor: pointer;
}
.select:focus {
border-color: hsl(var(--primary));
box-shadow: 0 0 0 1px hsl(var(--primary));
}
.privacy-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
padding: 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--background));
transition: background-color 0.2s ease;
}
.privacy-toggle:hover {
background: hsl(var(--muted) / 0.5);
}
.privacy-toggle input[type='checkbox'] {
width: 18px;
height: 18px;
accent-color: hsl(var(--primary));
cursor: pointer;
}
.existing-media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.75rem;
}
.media-item {
position: relative;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
background: hsl(var(--muted));
}
.media-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
}
.remove-media-button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 28px;
height: 28px;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.7);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s ease;
}
.remove-media-button:hover {
background: rgba(255, 0, 0, 0.8);
}
.file-upload {
position: relative;
border: 2px dashed hsl(var(--border));
border-radius: 8px;
background: hsl(var(--muted) / 0.3);
transition: all 0.2s ease;
cursor: pointer;
}
.file-upload:hover {
border-color: hsl(var(--primary));
background: hsl(var(--muted) / 0.5);
}
.file-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.file-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
text-align: center;
gap: 0.5rem;
color: hsl(var(--muted-foreground));
}
.file-content span {
font-size: 0.875rem;
font-weight: 500;
}
.file-selected {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: hsl(var(--muted) / 0.5);
border: 1px solid hsl(var(--border));
border-radius: 8px;
font-size: 0.875rem;
margin-top: 0.5rem;
}
.file-selected svg {
color: hsl(var(--primary));
flex-shrink: 0;
}
.file-selected span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: hsl(var(--foreground));
}
.modal-footer {
display: flex;
gap: 0.75rem;
padding: 1.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
justify-content: space-between;
}
.action-buttons {
display: flex;
gap: 0.75rem;
}
.action-buttons :global(button) {
flex: 1;
}
:global(.delete-button) {
flex-shrink: 0;
}
.location-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.location-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.toggle-button {
font-size: 0.75rem;
padding: 0.375rem 0.75rem;
height: auto;
background: hsl(var(--secondary));
border: 1px solid hsl(var(--border));
border-radius: 6px;
color: hsl(var(--secondary-foreground));
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.toggle-button:hover {
background: hsl(var(--secondary) / 0.8);
}
.coordinates-display {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.coordinates-info {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: hsl(var(--muted) / 0.5);
border: 1px solid hsl(var(--border));
border-radius: 8px;
font-size: 0.875rem;
}
.coordinate {
color: hsl(var(--muted-foreground));
font-family: monospace;
font-size: 0.8125rem;
}
.edit-coords-button {
margin-left: auto;
font-size: 0.75rem;
padding: 0.375rem 0.75rem;
height: auto;
background: hsl(var(--secondary));
border: 1px solid hsl(var(--border));
border-radius: 6px;
color: hsl(var(--secondary-foreground));
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.edit-coords-button:hover {
background: hsl(var(--secondary) / 0.8);
}
/* Mobile specific adjustments */
@media (max-width: 767px) {
.modal-header {
padding: 1rem;
}
.modal-title {
font-size: 1.25rem;
}
.modal-body {
padding: 1rem;
gap: 1.25rem;
}
.modal-footer {
padding: 1rem;
gap: 0.5rem;
flex-direction: column;
}
.action-buttons {
width: 100%;
}
:global(.delete-button) {
width: 100%;
}
.file-content {
padding: 1.5rem 1rem;
}
.field-group {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,11 +1,18 @@
<script lang="ts">
import { Button } from '$lib/components/button';
import { Badge } from '$lib/components/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator
} from '$lib/components/dropdown-menu';
import LikeButton from './LikeButton.svelte';
import VideoPlayer from '../media/VideoPlayer.svelte';
import ProfilePicture from '../profile/ProfilePicture.svelte';
import CommentsList from './CommentsList.svelte';
import { Ellipsis, MessageCircle, Share } from '@lucide/svelte';
import { Ellipsis, MessageCircle, Share, Edit, Trash2 } from '@lucide/svelte';
interface FindCardProps {
id: string;
@@ -13,20 +20,29 @@
description?: string;
category?: string;
locationName?: string;
latitude?: string;
longitude?: string;
isPublic?: number;
userId?: string;
user: {
username: string;
profilePictureUrl?: string | null;
};
media?: Array<{
id: string;
type: string;
url: string;
thumbnailUrl: string;
orderIndex?: number | null;
}>;
likeCount?: number;
isLiked?: boolean;
commentCount?: number;
currentUserId?: string;
onExplore?: (id: string) => void;
onDeleted?: () => void;
onUpdated?: () => void;
onEdit?: () => void;
}
let {
@@ -35,16 +51,26 @@
description,
category,
locationName,
latitude: _latitude,
longitude: _longitude,
isPublic: _isPublic,
userId,
user,
media,
likeCount = 0,
isLiked = false,
commentCount = 0,
currentUserId,
onExplore
onExplore,
onDeleted,
onUpdated: _onUpdated,
onEdit
}: FindCardProps = $props();
let showComments = $state(false);
let isDeleting = $state(false);
const isOwner = $derived(currentUserId && userId && currentUserId === userId);
function handleExplore() {
onExplore?.(id);
@@ -82,6 +108,34 @@
});
}
}
function handleEdit() {
onEdit?.();
}
async function handleDelete() {
if (!confirm('Are you sure you want to delete this find? This action cannot be undone.')) {
return;
}
isDeleting = true;
try {
const response = await fetch(`/api/finds/${id}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete find');
}
onDeleted?.();
} catch (error) {
console.error('Error deleting find:', error);
alert('Failed to delete find. Please try again.');
} finally {
isDeleting = false;
}
}
</script>
<div class="find-card">
@@ -112,9 +166,24 @@
{/if}
</div>
</div>
<Button variant="ghost" size="sm" class="more-button">
<Ellipsis size={16} />
</Button>
{#if isOwner}
<DropdownMenu>
<DropdownMenuTrigger class="more-button-trigger">
<Ellipsis size={16} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onclick={handleEdit}>
<Edit size={16} />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onclick={handleDelete} disabled={isDeleting} class="text-destructive">
<Trash2 size={16} />
<span>{isDeleting ? 'Deleting...' : 'Delete'}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/if}
</div>
<!-- Post Content -->
@@ -244,6 +313,33 @@
color: hsl(var(--muted-foreground));
}
:global(.more-button-trigger) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: hsl(var(--muted-foreground));
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
:global(.more-button-trigger:hover) {
background: hsl(var(--muted) / 0.5);
color: hsl(var(--foreground));
}
:global(.text-destructive) {
color: hsl(var(--destructive));
}
:global(.text-destructive:hover) {
color: hsl(var(--destructive));
}
/* Post Content */
.post-content {
padding: 0 1rem 0.75rem 1rem;

View File

@@ -7,6 +7,10 @@
description?: string;
category?: string;
locationName?: string;
latitude?: string;
longitude?: string;
isPublic?: number;
userId?: string;
user: {
username: string;
profilePictureUrl?: string | null;
@@ -14,15 +18,20 @@
likeCount?: number;
isLiked?: boolean;
media?: Array<{
id: string;
type: string;
url: string;
thumbnailUrl: string;
orderIndex?: number | null;
}>;
}
interface FindsListProps {
finds: Find[];
onFindExplore?: (id: string) => void;
currentUserId?: string;
onFindsChanged?: () => void;
onEdit?: (find: Find) => void;
title?: string;
showEmpty?: boolean;
emptyMessage?: string;
@@ -32,6 +41,9 @@
let {
finds,
onFindExplore,
currentUserId,
onFindsChanged,
onEdit,
title = 'Finds',
showEmpty = true,
emptyMessage = 'No finds to display',
@@ -41,6 +53,14 @@
function handleFindExplore(id: string) {
onFindExplore?.(id);
}
function handleFindDeleted() {
onFindsChanged?.();
}
function handleFindUpdated() {
onFindsChanged?.();
}
</script>
<section class="finds-feed">
@@ -59,11 +79,19 @@
description={find.description}
category={find.category}
locationName={find.locationName}
latitude={find.latitude}
longitude={find.longitude}
isPublic={find.isPublic}
userId={find.userId}
user={find.user}
media={find.media}
likeCount={find.likeCount}
isLiked={find.isLiked}
{currentUserId}
onExplore={handleFindExplore}
onDeleted={handleFindDeleted}
onUpdated={handleFindUpdated}
onEdit={() => onEdit?.(find)}
/>
{/each}
</div>

View File

@@ -3,6 +3,7 @@ export { default as CommentForm } from './CommentForm.svelte';
export { default as CommentsList } from './CommentsList.svelte';
export { default as Comments } from './Comments.svelte';
export { default as CreateFindModal } from './CreateFindModal.svelte';
export { default as EditFindModal } from './EditFindModal.svelte';
export { default as FindCard } from './FindCard.svelte';
export { default as FindPreview } from './FindPreview.svelte';
export { default as FindsFilter } from './FindsFilter.svelte';

View File

@@ -2,6 +2,7 @@
import { Map } from '$lib';
import FindsList from '$lib/components/finds/FindsList.svelte';
import CreateFindModal from '$lib/components/finds/CreateFindModal.svelte';
import EditFindModal from '$lib/components/finds/EditFindModal.svelte';
import FindPreview from '$lib/components/finds/FindPreview.svelte';
import FindsFilter from '$lib/components/finds/FindsFilter.svelte';
import type { PageData } from './$types';
@@ -10,6 +11,7 @@
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { apiSync, type FindState } from '$lib/stores/api-sync';
import { SvelteURLSearchParams } from 'svelte/reactivity';
// Server response type
interface ServerFind {
@@ -59,9 +61,11 @@
isLiked?: boolean;
isFromFriend?: boolean;
media?: Array<{
id: string;
type: string;
url: string;
thumbnailUrl: string;
orderIndex?: number | null;
}>;
}
@@ -92,6 +96,8 @@
let { data }: { data: PageData & { finds?: ServerFind[] } } = $props();
let showCreateModal = $state(false);
let showEditModal = $state(false);
let editingFind: ServerFind | null = $state(null);
let selectedFind: FindPreviewData | null = $state(null);
let currentFilter = $state('all');
let isSidebarVisible = $state(true);
@@ -137,10 +143,18 @@
isLiked: serverFind.isLikedByUser,
isFromFriend: serverFind.isFromFriend,
media: serverFind.media?.map(
(m: { type: string; url: string; thumbnailUrl: string | null }) => ({
(m: {
id: string;
type: string;
url: string;
thumbnailUrl: string | null;
orderIndex: number | null;
}) => ({
id: m.id,
type: m.type,
url: m.url,
thumbnailUrl: m.thumbnailUrl || m.url
thumbnailUrl: m.thumbnailUrl || m.url,
orderIndex: m.orderIndex
})
)
})) as MapFind[]
@@ -217,9 +231,80 @@
showCreateModal = false;
}
function openEditModal(find: MapFind) {
// Convert MapFind type to ServerFind format
const serverFind: ServerFind = {
id: find.id,
title: find.title,
description: find.description,
latitude: find.latitude || '0',
longitude: find.longitude || '0',
locationName: find.locationName,
category: find.category,
isPublic: find.isPublic || 0,
createdAt: find.createdAt.toISOString(),
userId: find.userId || '',
username: find.user?.username || '',
profilePictureUrl: find.user?.profilePictureUrl,
likeCount: find.likeCount,
isLikedByUser: find.isLiked,
isFromFriend: find.isFromFriend || false,
media: (find.media || []).map((mediaItem) => ({
...mediaItem,
findId: find.id,
thumbnailUrl: mediaItem.thumbnailUrl || null,
orderIndex: mediaItem.orderIndex || null
}))
};
editingFind = serverFind;
showEditModal = true;
}
function closeEditModal() {
showEditModal = false;
editingFind = null;
}
function handleFindUpdated() {
closeEditModal();
handleFindsChanged();
}
function handleFindDeleted() {
closeEditModal();
handleFindsChanged();
}
function toggleSidebar() {
isSidebarVisible = !isSidebarVisible;
}
async function handleFindsChanged() {
// Reload finds data after a find is updated or deleted
if (browser) {
try {
const params = new SvelteURLSearchParams();
if ($coordinates) {
params.set('lat', $coordinates.latitude.toString());
params.set('lng', $coordinates.longitude.toString());
}
if (data.user) {
params.set('includePrivate', 'true');
if (currentFilter === 'friends') {
params.set('includeFriends', 'true');
}
}
const response = await fetch(`/api/finds?${params}`);
if (response.ok) {
const updatedFinds = await response.json();
data.finds = updatedFinds;
}
} catch (error) {
console.error('Error reloading finds:', error);
}
}
}
</script>
<svelte:head>
@@ -248,8 +333,34 @@
<Map
autoCenter={true}
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
{finds}
onFindClick={handleFindClick}
finds={finds.map((find) => ({
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,
user: {
id: find.user.id,
username: find.user.username
},
media: find.media?.map((m) => ({
type: m.type,
url: m.url,
thumbnailUrl: m.thumbnailUrl
}))
}))}
onFindClick={(mapFind) => {
// Find the corresponding MapFind from the finds array
const originalFind = finds.find((f) => f.id === mapFind.id);
if (originalFind) {
handleFindClick(originalFind);
}
}}
sidebarVisible={isSidebarVisible}
/>
</div>
@@ -277,7 +388,40 @@
{/if}
</div>
<div class="finds-list-container">
<FindsList {finds} onFindExplore={handleFindExplore} hideTitle={true} />
<FindsList
finds={finds.map((find) => ({
id: find.id,
title: find.title,
description: find.description,
category: find.category,
locationName: find.locationName,
latitude: find.latitude,
longitude: find.longitude,
isPublic: find.isPublic,
userId: find.userId,
user: {
username: find.user.username,
profilePictureUrl: find.user.profilePictureUrl
},
likeCount: find.likeCount,
isLiked: find.isLiked,
media: find.media?.map((m) => ({
id: m.id,
type: m.type,
url: m.url,
thumbnailUrl: m.thumbnailUrl,
orderIndex: m.orderIndex
}))
}))}
onFindExplore={handleFindExplore}
currentUserId={data.user?.id}
onFindsChanged={handleFindsChanged}
onEdit={(find) => {
const mapFind = finds.find((f) => f.id === find.id);
if (mapFind) openEditModal(mapFind);
}}
hideTitle={true}
/>
</div>
</div>
<!-- Toggle button -->
@@ -307,6 +451,26 @@
/>
{/if}
{#if showEditModal && editingFind}
<EditFindModal
isOpen={showEditModal}
find={{
id: editingFind.id,
title: editingFind.title,
description: editingFind.description || null,
latitude: editingFind.latitude || '0',
longitude: editingFind.longitude || '0',
locationName: editingFind.locationName || null,
category: editingFind.category || null,
isPublic: editingFind.isPublic,
media: editingFind.media || []
}}
onClose={closeEditModal}
onFindUpdated={handleFindUpdated}
onFindDeleted={handleFindDeleted}
/>
{/if}
{#if selectedFind}
<FindPreview find={selectedFind} onClose={closeFindPreview} currentUserId={data.user?.id} />
{/if}

View File

@@ -3,7 +3,7 @@ import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { find, findMedia, user, findLike, findComment } from '$lib/server/db/schema';
import { eq, sql } from 'drizzle-orm';
import { getLocalR2Url } from '$lib/server/r2';
import { getLocalR2Url, deleteFromR2 } from '$lib/server/r2';
export const GET: RequestHandler = async ({ params, locals }) => {
const findId = params.findId;
@@ -113,3 +113,195 @@ export const GET: RequestHandler = async ({ params, locals }) => {
throw error(500, 'Failed to load find');
}
};
export const PUT: RequestHandler = async ({ params, request, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const findId = params.findId;
if (!findId) {
throw error(400, 'Find ID is required');
}
try {
// First, verify the find exists and user owns it
const existingFind = await db
.select({ userId: find.userId })
.from(find)
.where(eq(find.id, findId))
.limit(1);
if (existingFind.length === 0) {
throw error(404, 'Find not found');
}
if (existingFind[0].userId !== locals.user.id) {
throw error(403, 'You do not have permission to edit this find');
}
// Parse request body
const data = await request.json();
const {
title,
description,
latitude,
longitude,
locationName,
category,
isPublic,
media,
mediaToDelete
} = data;
// Validate required fields
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');
}
// Delete media items if specified
if (mediaToDelete && Array.isArray(mediaToDelete) && mediaToDelete.length > 0) {
// Get media URLs before deleting from database
const mediaToRemove = await db
.select({ url: findMedia.url, thumbnailUrl: findMedia.thumbnailUrl })
.from(findMedia)
.where(
sql`${findMedia.id} IN (${sql.join(
mediaToDelete.map((id: string) => sql`${id}`),
sql`, `
)})`
);
// Delete from R2 storage
for (const mediaItem of mediaToRemove) {
try {
await deleteFromR2(mediaItem.url);
if (mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')) {
await deleteFromR2(mediaItem.thumbnailUrl);
}
} catch (err) {
console.error('Error deleting media from R2:', err);
// Continue even if R2 deletion fails
}
}
// Delete from database
await db.delete(findMedia).where(
sql`${findMedia.id} IN (${sql.join(
mediaToDelete.map((id: string) => sql`${id}`),
sql`, `
)})`
);
}
// Update the find
const updatedFind = await db
.update(find)
.set({
title,
description: description || null,
latitude: latitude.toString(),
longitude: longitude.toString(),
locationName: locationName || null,
category: category || null,
isPublic: isPublic ? 1 : 0,
updatedAt: new Date()
})
.where(eq(find.id, findId))
.returning();
// Add new media records if provided
if (media && Array.isArray(media) && media.length > 0) {
const newMediaRecords = media
.filter((item: { id?: string }) => !item.id) // Only insert media without IDs (new uploads)
.map((item: { type: string; url: string; thumbnailUrl?: string }, index: number) => ({
id: crypto.randomUUID(),
findId,
type: item.type,
url: item.url,
thumbnailUrl: item.thumbnailUrl || null,
orderIndex: index
}));
if (newMediaRecords.length > 0) {
await db.insert(findMedia).values(newMediaRecords);
}
}
return json({ success: true, find: updatedFind[0] });
} catch (err) {
console.error('Error updating find:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to update find');
}
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const findId = params.findId;
if (!findId) {
throw error(400, 'Find ID is required');
}
try {
// First, verify the find exists and user owns it
const existingFind = await db
.select({ userId: find.userId })
.from(find)
.where(eq(find.id, findId))
.limit(1);
if (existingFind.length === 0) {
throw error(404, 'Find not found');
}
if (existingFind[0].userId !== locals.user.id) {
throw error(403, 'You do not have permission to delete this find');
}
// Get all media for this find to delete from R2
const mediaItems = await db
.select({ url: findMedia.url, thumbnailUrl: findMedia.thumbnailUrl })
.from(findMedia)
.where(eq(findMedia.findId, findId));
// Delete media from R2 storage
for (const mediaItem of mediaItems) {
try {
await deleteFromR2(mediaItem.url);
if (mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')) {
await deleteFromR2(mediaItem.thumbnailUrl);
}
} catch (err) {
console.error('Error deleting media from R2:', err);
// Continue even if R2 deletion fails
}
}
// Delete the find (cascade will handle media, likes, and comments)
await db.delete(find).where(eq(find.id, findId));
return json({ success: true });
} catch (err) {
console.error('Error deleting find:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to delete find');
}
};

View File

@@ -0,0 +1,68 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { find, findMedia } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { deleteFromR2 } from '$lib/server/r2';
export const DELETE: RequestHandler = async ({ params, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const { findId, mediaId } = params;
if (!findId || !mediaId) {
throw error(400, 'Find ID and Media ID are required');
}
try {
// First, verify the find exists and user owns it
const existingFind = await db
.select({ userId: find.userId })
.from(find)
.where(eq(find.id, findId))
.limit(1);
if (existingFind.length === 0) {
throw error(404, 'Find not found');
}
if (existingFind[0].userId !== locals.user.id) {
throw error(403, 'You do not have permission to delete this media');
}
// Get the media item to delete
const mediaItem = await db
.select({ url: findMedia.url, thumbnailUrl: findMedia.thumbnailUrl })
.from(findMedia)
.where(and(eq(findMedia.id, mediaId), eq(findMedia.findId, findId)))
.limit(1);
if (mediaItem.length === 0) {
throw error(404, 'Media not found');
}
// Delete from R2 storage
try {
await deleteFromR2(mediaItem[0].url);
if (mediaItem[0].thumbnailUrl && !mediaItem[0].thumbnailUrl.startsWith('/')) {
await deleteFromR2(mediaItem[0].thumbnailUrl);
}
} catch (err) {
console.error('Error deleting media from R2:', err);
// Continue even if R2 deletion fails
}
// Delete from database
await db.delete(findMedia).where(eq(findMedia.id, mediaId));
return json({ success: true });
} catch (err) {
console.error('Error deleting media:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to delete media');
}
};

View File

@@ -1,15 +1,23 @@
<script lang="ts">
import { Map } from '$lib';
import { goto } from '$app/navigation';
import LikeButton from '$lib/components/finds/LikeButton.svelte';
import VideoPlayer from '$lib/components/media/VideoPlayer.svelte';
import ProfilePicture from '$lib/components/profile/ProfilePicture.svelte';
import CommentsList from '$lib/components/finds/CommentsList.svelte';
import EditFindModal from '$lib/components/finds/EditFindModal.svelte';
import { Button } from '$lib/components/button';
import { Edit, Trash2 } from 'lucide-svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let currentMediaIndex = $state(0);
let isPanelVisible = $state(true);
let showEditModal = $state(false);
let isDeleting = $state(false);
const isOwner = $derived(data.user && data.find && data.user.id === data.find.userId);
function nextMedia() {
if (!data.find?.media) return;
@@ -77,6 +85,47 @@
isPanelVisible = !isPanelVisible;
}
function handleEdit() {
showEditModal = true;
}
async function handleDelete() {
if (!data.find) return;
if (!confirm('Are you sure you want to delete this find? This action cannot be undone.')) {
return;
}
isDeleting = true;
try {
const response = await fetch(`/api/finds/${data.find.id}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete find');
}
// Redirect to home page after successful deletion
goto('/');
} catch (error) {
console.error('Error deleting find:', error);
alert('Failed to delete find. Please try again.');
isDeleting = false;
}
}
function handleFindUpdated() {
showEditModal = false;
// Reload the page to get updated data
window.location.reload();
}
function handleFindDeleted() {
showEditModal = false;
goto('/');
}
// Create the map find format
let mapFinds = $derived(
data.find
@@ -187,6 +236,23 @@
</div>
</div>
</div>
{#if isOwner}
<div class="action-buttons-header">
<Button variant="outline" size="sm" onclick={handleEdit}>
<Edit size={16} />
<span>Edit</span>
</Button>
<Button
variant="destructive"
size="sm"
onclick={handleDelete}
disabled={isDeleting}
>
<Trash2 size={16} />
<span>{isDeleting ? 'Deleting...' : 'Delete'}</span>
</Button>
</div>
{/if}
</div>
<div class="panel-body">
@@ -351,6 +417,26 @@
</div>
</div>
{#if showEditModal && isOwner && data.find}
<EditFindModal
isOpen={showEditModal}
find={{
id: data.find.id,
title: data.find.title,
description: data.find.description || null,
latitude: data.find.latitude,
longitude: data.find.longitude,
locationName: data.find.locationName || null,
category: data.find.category || null,
isPublic: data.find.isPublic ?? 1,
media: data.find.media || []
}}
onClose={() => (showEditModal = false)}
onFindUpdated={handleFindUpdated}
onFindDeleted={handleFindDeleted}
/>
{/if}
<style>
.public-find-page {
position: relative;
@@ -444,6 +530,22 @@
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.action-buttons-header {
display: flex;
gap: 0.5rem;
align-items: center;
}
.action-buttons-header :global(button) {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
.user-section {