From f8acec9a794d3b28e012b25499be5cf20f6de2e0 Mon Sep 17 00:00:00 2001 From: Zias van Nes Date: Mon, 1 Dec 2025 12:54:41 +0100 Subject: [PATCH] feat:update and delete finds --- src/lib/components/finds/EditFindModal.svelte | 892 ++++++++++++++++++ src/lib/components/finds/FindCard.svelte | 106 ++- src/lib/components/finds/FindsList.svelte | 28 + src/lib/components/finds/index.ts | 1 + src/routes/+page.svelte | 174 +++- src/routes/api/finds/[findId]/+server.ts | 194 +++- .../finds/[findId]/media/[mediaId]/+server.ts | 68 ++ src/routes/finds/[findId]/+page.svelte | 102 ++ 8 files changed, 1554 insertions(+), 11 deletions(-) create mode 100644 src/lib/components/finds/EditFindModal.svelte create mode 100644 src/routes/api/finds/[findId]/media/[mediaId]/+server.ts diff --git a/src/lib/components/finds/EditFindModal.svelte b/src/lib/components/finds/EditFindModal.svelte new file mode 100644 index 0000000..f272cfc --- /dev/null +++ b/src/lib/components/finds/EditFindModal.svelte @@ -0,0 +1,892 @@ + + +{#if isOpen} + +{/if} + + diff --git a/src/lib/components/finds/FindCard.svelte b/src/lib/components/finds/FindCard.svelte index 1677f7a..98714c7 100644 --- a/src/lib/components/finds/FindCard.svelte +++ b/src/lib/components/finds/FindCard.svelte @@ -1,11 +1,18 @@
@@ -112,9 +166,24 @@ {/if}
- + {#if isOwner} + + + + + + + + Edit + + + + + {isDeleting ? 'Deleting...' : 'Delete'} + + + + {/if} @@ -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; diff --git a/src/lib/components/finds/FindsList.svelte b/src/lib/components/finds/FindsList.svelte index 564ca0e..f11d2a9 100644 --- a/src/lib/components/finds/FindsList.svelte +++ b/src/lib/components/finds/FindsList.svelte @@ -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?.(); + }
@@ -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} diff --git a/src/lib/components/finds/index.ts b/src/lib/components/finds/index.ts index f4f9aef..b606d6e 100644 --- a/src/lib/components/finds/index.ts +++ b/src/lib/components/finds/index.ts @@ -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'; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index d1f8749..f7c93de 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -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); + } + } + } @@ -248,8 +333,34 @@ ({ + 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} /> @@ -277,7 +388,40 @@ {/if}
- + ({ + 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} + />
@@ -307,6 +451,26 @@ /> {/if} +{#if showEditModal && editingFind} + +{/if} + {#if selectedFind} {/if} diff --git a/src/routes/api/finds/[findId]/+server.ts b/src/routes/api/finds/[findId]/+server.ts index cdf01e0..a1f05b2 100644 --- a/src/routes/api/finds/[findId]/+server.ts +++ b/src/routes/api/finds/[findId]/+server.ts @@ -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'); + } +}; diff --git a/src/routes/api/finds/[findId]/media/[mediaId]/+server.ts b/src/routes/api/finds/[findId]/media/[mediaId]/+server.ts new file mode 100644 index 0000000..b2383ff --- /dev/null +++ b/src/routes/api/finds/[findId]/media/[mediaId]/+server.ts @@ -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'); + } +}; diff --git a/src/routes/finds/[findId]/+page.svelte b/src/routes/finds/[findId]/+page.svelte index 03eacfc..8917b44 100644 --- a/src/routes/finds/[findId]/+page.svelte +++ b/src/routes/finds/[findId]/+page.svelte @@ -1,15 +1,23 @@