feat:update and delete finds
This commit is contained in:
892
src/lib/components/finds/EditFindModal.svelte
Normal file
892
src/lib/components/finds/EditFindModal.svelte
Normal 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>
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/button';
|
import { Button } from '$lib/components/button';
|
||||||
import { Badge } from '$lib/components/badge';
|
import { Badge } from '$lib/components/badge';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuSeparator
|
||||||
|
} from '$lib/components/dropdown-menu';
|
||||||
import LikeButton from './LikeButton.svelte';
|
import LikeButton from './LikeButton.svelte';
|
||||||
import VideoPlayer from '../media/VideoPlayer.svelte';
|
import VideoPlayer from '../media/VideoPlayer.svelte';
|
||||||
import ProfilePicture from '../profile/ProfilePicture.svelte';
|
import ProfilePicture from '../profile/ProfilePicture.svelte';
|
||||||
import CommentsList from './CommentsList.svelte';
|
import CommentsList from './CommentsList.svelte';
|
||||||
import { Ellipsis, MessageCircle, Share } from '@lucide/svelte';
|
import { Ellipsis, MessageCircle, Share, Edit, Trash2 } from '@lucide/svelte';
|
||||||
|
|
||||||
interface FindCardProps {
|
interface FindCardProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,20 +20,29 @@
|
|||||||
description?: string;
|
description?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
locationName?: string;
|
locationName?: string;
|
||||||
|
latitude?: string;
|
||||||
|
longitude?: string;
|
||||||
|
isPublic?: number;
|
||||||
|
userId?: string;
|
||||||
user: {
|
user: {
|
||||||
username: string;
|
username: string;
|
||||||
profilePictureUrl?: string | null;
|
profilePictureUrl?: string | null;
|
||||||
};
|
};
|
||||||
media?: Array<{
|
media?: Array<{
|
||||||
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
url: string;
|
url: string;
|
||||||
thumbnailUrl: string;
|
thumbnailUrl: string;
|
||||||
|
orderIndex?: number | null;
|
||||||
}>;
|
}>;
|
||||||
likeCount?: number;
|
likeCount?: number;
|
||||||
isLiked?: boolean;
|
isLiked?: boolean;
|
||||||
commentCount?: number;
|
commentCount?: number;
|
||||||
currentUserId?: string;
|
currentUserId?: string;
|
||||||
onExplore?: (id: string) => void;
|
onExplore?: (id: string) => void;
|
||||||
|
onDeleted?: () => void;
|
||||||
|
onUpdated?: () => void;
|
||||||
|
onEdit?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -35,16 +51,26 @@
|
|||||||
description,
|
description,
|
||||||
category,
|
category,
|
||||||
locationName,
|
locationName,
|
||||||
|
latitude: _latitude,
|
||||||
|
longitude: _longitude,
|
||||||
|
isPublic: _isPublic,
|
||||||
|
userId,
|
||||||
user,
|
user,
|
||||||
media,
|
media,
|
||||||
likeCount = 0,
|
likeCount = 0,
|
||||||
isLiked = false,
|
isLiked = false,
|
||||||
commentCount = 0,
|
commentCount = 0,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
onExplore
|
onExplore,
|
||||||
|
onDeleted,
|
||||||
|
onUpdated: _onUpdated,
|
||||||
|
onEdit
|
||||||
}: FindCardProps = $props();
|
}: FindCardProps = $props();
|
||||||
|
|
||||||
let showComments = $state(false);
|
let showComments = $state(false);
|
||||||
|
let isDeleting = $state(false);
|
||||||
|
|
||||||
|
const isOwner = $derived(currentUserId && userId && currentUserId === userId);
|
||||||
|
|
||||||
function handleExplore() {
|
function handleExplore() {
|
||||||
onExplore?.(id);
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="find-card">
|
<div class="find-card">
|
||||||
@@ -112,9 +166,24 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" class="more-button">
|
{#if isOwner}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger class="more-button-trigger">
|
||||||
<Ellipsis size={16} />
|
<Ellipsis size={16} />
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Post Content -->
|
<!-- Post Content -->
|
||||||
@@ -244,6 +313,33 @@
|
|||||||
color: hsl(var(--muted-foreground));
|
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 */
|
||||||
.post-content {
|
.post-content {
|
||||||
padding: 0 1rem 0.75rem 1rem;
|
padding: 0 1rem 0.75rem 1rem;
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
description?: string;
|
description?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
locationName?: string;
|
locationName?: string;
|
||||||
|
latitude?: string;
|
||||||
|
longitude?: string;
|
||||||
|
isPublic?: number;
|
||||||
|
userId?: string;
|
||||||
user: {
|
user: {
|
||||||
username: string;
|
username: string;
|
||||||
profilePictureUrl?: string | null;
|
profilePictureUrl?: string | null;
|
||||||
@@ -14,15 +18,20 @@
|
|||||||
likeCount?: number;
|
likeCount?: number;
|
||||||
isLiked?: boolean;
|
isLiked?: boolean;
|
||||||
media?: Array<{
|
media?: Array<{
|
||||||
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
url: string;
|
url: string;
|
||||||
thumbnailUrl: string;
|
thumbnailUrl: string;
|
||||||
|
orderIndex?: number | null;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FindsListProps {
|
interface FindsListProps {
|
||||||
finds: Find[];
|
finds: Find[];
|
||||||
onFindExplore?: (id: string) => void;
|
onFindExplore?: (id: string) => void;
|
||||||
|
currentUserId?: string;
|
||||||
|
onFindsChanged?: () => void;
|
||||||
|
onEdit?: (find: Find) => void;
|
||||||
title?: string;
|
title?: string;
|
||||||
showEmpty?: boolean;
|
showEmpty?: boolean;
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
@@ -32,6 +41,9 @@
|
|||||||
let {
|
let {
|
||||||
finds,
|
finds,
|
||||||
onFindExplore,
|
onFindExplore,
|
||||||
|
currentUserId,
|
||||||
|
onFindsChanged,
|
||||||
|
onEdit,
|
||||||
title = 'Finds',
|
title = 'Finds',
|
||||||
showEmpty = true,
|
showEmpty = true,
|
||||||
emptyMessage = 'No finds to display',
|
emptyMessage = 'No finds to display',
|
||||||
@@ -41,6 +53,14 @@
|
|||||||
function handleFindExplore(id: string) {
|
function handleFindExplore(id: string) {
|
||||||
onFindExplore?.(id);
|
onFindExplore?.(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleFindDeleted() {
|
||||||
|
onFindsChanged?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFindUpdated() {
|
||||||
|
onFindsChanged?.();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="finds-feed">
|
<section class="finds-feed">
|
||||||
@@ -59,11 +79,19 @@
|
|||||||
description={find.description}
|
description={find.description}
|
||||||
category={find.category}
|
category={find.category}
|
||||||
locationName={find.locationName}
|
locationName={find.locationName}
|
||||||
|
latitude={find.latitude}
|
||||||
|
longitude={find.longitude}
|
||||||
|
isPublic={find.isPublic}
|
||||||
|
userId={find.userId}
|
||||||
user={find.user}
|
user={find.user}
|
||||||
media={find.media}
|
media={find.media}
|
||||||
likeCount={find.likeCount}
|
likeCount={find.likeCount}
|
||||||
isLiked={find.isLiked}
|
isLiked={find.isLiked}
|
||||||
|
{currentUserId}
|
||||||
onExplore={handleFindExplore}
|
onExplore={handleFindExplore}
|
||||||
|
onDeleted={handleFindDeleted}
|
||||||
|
onUpdated={handleFindUpdated}
|
||||||
|
onEdit={() => onEdit?.(find)}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export { default as CommentForm } from './CommentForm.svelte';
|
|||||||
export { default as CommentsList } from './CommentsList.svelte';
|
export { default as CommentsList } from './CommentsList.svelte';
|
||||||
export { default as Comments } from './Comments.svelte';
|
export { default as Comments } from './Comments.svelte';
|
||||||
export { default as CreateFindModal } from './CreateFindModal.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 FindCard } from './FindCard.svelte';
|
||||||
export { default as FindPreview } from './FindPreview.svelte';
|
export { default as FindPreview } from './FindPreview.svelte';
|
||||||
export { default as FindsFilter } from './FindsFilter.svelte';
|
export { default as FindsFilter } from './FindsFilter.svelte';
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Map } from '$lib';
|
import { Map } from '$lib';
|
||||||
import FindsList from '$lib/components/finds/FindsList.svelte';
|
import FindsList from '$lib/components/finds/FindsList.svelte';
|
||||||
import CreateFindModal from '$lib/components/finds/CreateFindModal.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 FindPreview from '$lib/components/finds/FindPreview.svelte';
|
||||||
import FindsFilter from '$lib/components/finds/FindsFilter.svelte';
|
import FindsFilter from '$lib/components/finds/FindsFilter.svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
@@ -10,6 +11,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { apiSync, type FindState } from '$lib/stores/api-sync';
|
import { apiSync, type FindState } from '$lib/stores/api-sync';
|
||||||
|
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||||
|
|
||||||
// Server response type
|
// Server response type
|
||||||
interface ServerFind {
|
interface ServerFind {
|
||||||
@@ -59,9 +61,11 @@
|
|||||||
isLiked?: boolean;
|
isLiked?: boolean;
|
||||||
isFromFriend?: boolean;
|
isFromFriend?: boolean;
|
||||||
media?: Array<{
|
media?: Array<{
|
||||||
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
url: string;
|
url: string;
|
||||||
thumbnailUrl: string;
|
thumbnailUrl: string;
|
||||||
|
orderIndex?: number | null;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +96,8 @@
|
|||||||
let { data }: { data: PageData & { finds?: ServerFind[] } } = $props();
|
let { data }: { data: PageData & { finds?: ServerFind[] } } = $props();
|
||||||
|
|
||||||
let showCreateModal = $state(false);
|
let showCreateModal = $state(false);
|
||||||
|
let showEditModal = $state(false);
|
||||||
|
let editingFind: ServerFind | null = $state(null);
|
||||||
let selectedFind: FindPreviewData | null = $state(null);
|
let selectedFind: FindPreviewData | null = $state(null);
|
||||||
let currentFilter = $state('all');
|
let currentFilter = $state('all');
|
||||||
let isSidebarVisible = $state(true);
|
let isSidebarVisible = $state(true);
|
||||||
@@ -137,10 +143,18 @@
|
|||||||
isLiked: serverFind.isLikedByUser,
|
isLiked: serverFind.isLikedByUser,
|
||||||
isFromFriend: serverFind.isFromFriend,
|
isFromFriend: serverFind.isFromFriend,
|
||||||
media: serverFind.media?.map(
|
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,
|
type: m.type,
|
||||||
url: m.url,
|
url: m.url,
|
||||||
thumbnailUrl: m.thumbnailUrl || m.url
|
thumbnailUrl: m.thumbnailUrl || m.url,
|
||||||
|
orderIndex: m.orderIndex
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
})) as MapFind[]
|
})) as MapFind[]
|
||||||
@@ -217,9 +231,80 @@
|
|||||||
showCreateModal = false;
|
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() {
|
function toggleSidebar() {
|
||||||
isSidebarVisible = !isSidebarVisible;
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -248,8 +333,34 @@
|
|||||||
<Map
|
<Map
|
||||||
autoCenter={true}
|
autoCenter={true}
|
||||||
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
|
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
|
||||||
{finds}
|
finds={finds.map((find) => ({
|
||||||
onFindClick={handleFindClick}
|
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}
|
sidebarVisible={isSidebarVisible}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,7 +388,40 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="finds-list-container">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<!-- Toggle button -->
|
<!-- Toggle button -->
|
||||||
@@ -307,6 +451,26 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/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}
|
{#if selectedFind}
|
||||||
<FindPreview find={selectedFind} onClose={closeFindPreview} currentUserId={data.user?.id} />
|
<FindPreview find={selectedFind} onClose={closeFindPreview} currentUserId={data.user?.id} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { RequestHandler } from './$types';
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { find, findMedia, user, findLike, findComment } from '$lib/server/db/schema';
|
import { find, findMedia, user, findLike, findComment } from '$lib/server/db/schema';
|
||||||
import { eq, sql } from 'drizzle-orm';
|
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 }) => {
|
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||||
const findId = params.findId;
|
const findId = params.findId;
|
||||||
@@ -113,3 +113,195 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
|||||||
throw error(500, 'Failed to load find');
|
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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
68
src/routes/api/finds/[findId]/media/[mediaId]/+server.ts
Normal file
68
src/routes/api/finds/[findId]/media/[mediaId]/+server.ts
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,15 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Map } from '$lib';
|
import { Map } from '$lib';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import LikeButton from '$lib/components/finds/LikeButton.svelte';
|
import LikeButton from '$lib/components/finds/LikeButton.svelte';
|
||||||
import VideoPlayer from '$lib/components/media/VideoPlayer.svelte';
|
import VideoPlayer from '$lib/components/media/VideoPlayer.svelte';
|
||||||
import ProfilePicture from '$lib/components/profile/ProfilePicture.svelte';
|
import ProfilePicture from '$lib/components/profile/ProfilePicture.svelte';
|
||||||
import CommentsList from '$lib/components/finds/CommentsList.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';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
let currentMediaIndex = $state(0);
|
let currentMediaIndex = $state(0);
|
||||||
let isPanelVisible = $state(true);
|
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() {
|
function nextMedia() {
|
||||||
if (!data.find?.media) return;
|
if (!data.find?.media) return;
|
||||||
@@ -77,6 +85,47 @@
|
|||||||
isPanelVisible = !isPanelVisible;
|
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
|
// Create the map find format
|
||||||
let mapFinds = $derived(
|
let mapFinds = $derived(
|
||||||
data.find
|
data.find
|
||||||
@@ -187,6 +236,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
@@ -351,6 +417,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<style>
|
||||||
.public-find-page {
|
.public-find-page {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -444,6 +530,22 @@
|
|||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
background: rgba(255, 255, 255, 0.5);
|
background: rgba(255, 255, 255, 0.5);
|
||||||
flex-shrink: 0;
|
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 {
|
.user-section {
|
||||||
|
|||||||
Reference in New Issue
Block a user