feat:use locations&finds

big overhaul! now we use locations that can have finds. multiple finds
can be at the same location, so users can register the same place.
This commit is contained in:
2025-12-08 18:15:41 +01:00
parent b792be5e98
commit 495e67f14d
19 changed files with 2909 additions and 639 deletions

View File

@@ -0,0 +1,47 @@
-- Create location table
CREATE TABLE "location" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"latitude" text NOT NULL,
"longitude" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
-- Add foreign key constraint for location table
ALTER TABLE "location" ADD CONSTRAINT "location_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
-- Migrate existing find data to location table and update find table
-- First, create locations from existing finds
INSERT INTO "location" ("id", "user_id", "latitude", "longitude", "created_at")
SELECT
'loc_' || "id" as "id",
"user_id",
"latitude",
"longitude",
"created_at"
FROM "find";
--> statement-breakpoint
-- Add location_id column to find table
ALTER TABLE "find" ADD COLUMN "location_id" text;
--> statement-breakpoint
-- Update find table to reference the new location entries
UPDATE "find"
SET "location_id" = 'loc_' || "id";
--> statement-breakpoint
-- Make location_id NOT NULL
ALTER TABLE "find" ALTER COLUMN "location_id" SET NOT NULL;
--> statement-breakpoint
-- Add foreign key constraint
ALTER TABLE "find" ADD CONSTRAINT "find_location_id_location_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."location"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
-- Drop the latitude and longitude columns from find table
ALTER TABLE "find" DROP COLUMN "latitude";
--> statement-breakpoint
ALTER TABLE "find" DROP COLUMN "longitude";

View File

@@ -2,7 +2,6 @@
import { cn, type WithElementRef } from '$lib/utils.js'; import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements'; import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants'; import { type VariantProps, tv } from 'tailwind-variants';
import { resolveRoute } from '$app/paths';
export const buttonVariants = tv({ export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
@@ -59,7 +58,7 @@
bind:this={ref} bind:this={ref}
data-slot="button" data-slot="button"
class={cn(buttonVariants({ variant, size }), className)} class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : resolveRoute(href)} href={disabled ? undefined : href}
aria-disabled={disabled} aria-disabled={disabled}
role={disabled ? 'link' : undefined} role={disabled ? 'link' : undefined}
tabindex={disabled ? -1 : undefined} tabindex={disabled ? -1 : undefined}

View File

@@ -2,29 +2,24 @@
import { Input } from '$lib/components/input'; import { Input } from '$lib/components/input';
import { Label } from '$lib/components/label'; import { Label } from '$lib/components/label';
import { Button } from '$lib/components/button'; import { Button } from '$lib/components/button';
import { coordinates } from '$lib/stores/location';
import POISearch from '../map/POISearch.svelte';
import type { PlaceResult } from '$lib/utils/places';
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
locationId: string;
onClose: () => void; onClose: () => void;
onFindCreated: (event: CustomEvent) => void; onFindCreated: (event: CustomEvent) => void;
} }
let { isOpen, onClose, onFindCreated }: Props = $props(); let { isOpen, locationId, onClose, onFindCreated }: Props = $props();
let title = $state(''); let title = $state('');
let description = $state(''); let description = $state('');
let latitude = $state('');
let longitude = $state('');
let locationName = $state(''); let locationName = $state('');
let category = $state('cafe'); let category = $state('cafe');
let isPublic = $state(true); let isPublic = $state(true);
let selectedFiles = $state<FileList | null>(null); let selectedFiles = $state<FileList | null>(null);
let isSubmitting = $state(false); let isSubmitting = $state(false);
let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]); let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]);
let useManualLocation = $state(false);
const categories = [ const categories = [
{ value: 'cafe', label: 'Café' }, { value: 'cafe', label: 'Café' },
@@ -51,13 +46,6 @@
return () => window.removeEventListener('resize', checkIsMobile); return () => window.removeEventListener('resize', checkIsMobile);
}); });
$effect(() => {
if (isOpen && $coordinates) {
latitude = $coordinates.latitude.toString();
longitude = $coordinates.longitude.toString();
}
});
function handleFileChange(event: Event) { function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
selectedFiles = target.files; selectedFiles = target.files;
@@ -85,10 +73,7 @@
} }
async function handleSubmit() { async function handleSubmit() {
const lat = parseFloat(latitude); if (!title.trim()) {
const lng = parseFloat(longitude);
if (!title.trim() || isNaN(lat) || isNaN(lng)) {
return; return;
} }
@@ -105,10 +90,9 @@
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
locationId,
title: title.trim(), title: title.trim(),
description: description.trim() || null, description: description.trim() || null,
latitude: lat,
longitude: lng,
locationName: locationName.trim() || null, locationName: locationName.trim() || null,
category, category,
isPublic, isPublic,
@@ -131,31 +115,14 @@
} }
} }
function handlePlaceSelected(place: PlaceResult) {
locationName = place.name;
latitude = place.latitude.toString();
longitude = place.longitude.toString();
}
function toggleLocationMode() {
useManualLocation = !useManualLocation;
if (!useManualLocation && $coordinates) {
latitude = $coordinates.latitude.toString();
longitude = $coordinates.longitude.toString();
}
}
function resetForm() { function resetForm() {
title = ''; title = '';
description = ''; description = '';
locationName = ''; locationName = '';
latitude = '';
longitude = '';
category = 'cafe'; category = 'cafe';
isPublic = true; isPublic = true;
selectedFiles = null; selectedFiles = null;
uploadedMedia = []; uploadedMedia = [];
useManualLocation = false;
const fileInput = document.querySelector('#media-files') as HTMLInputElement; const fileInput = document.querySelector('#media-files') as HTMLInputElement;
if (fileInput) { if (fileInput) {
@@ -210,31 +177,13 @@
></textarea> ></textarea>
</div> </div>
<div class="location-section"> <div class="field">
<div class="location-header"> <Label for="location-name">Location name (optional)</Label>
<Label>Location</Label> <Input
<button type="button" onclick={toggleLocationMode} class="toggle-button"> name="location-name"
{useManualLocation ? 'Use Search' : 'Manual Entry'} placeholder="Café Central, Brussels"
</button> bind:value={locationName}
</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>
<div class="field-group"> <div class="field-group">
@@ -307,34 +256,6 @@
</div> </div>
{/if} {/if}
</div> </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>
<div class="modal-footer"> <div class="modal-footer">
@@ -615,76 +536,6 @@
flex: 1; flex: 1;
} }
.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 */ /* Mobile specific adjustments */
@media (max-width: 767px) { @media (max-width: 767px) {
.modal-header { .modal-header {

View File

@@ -0,0 +1,443 @@
<script lang="ts">
import { Input } from '$lib/components/input';
import { Label } from '$lib/components/label';
import { Button } from '$lib/components/button';
import { coordinates } from '$lib/stores/location';
import POISearch from '../map/POISearch.svelte';
import type { PlaceResult } from '$lib/utils/places';
interface Props {
isOpen: boolean;
onClose: () => void;
onLocationCreated: (event: CustomEvent<{ locationId: string; reload?: boolean }>) => void;
}
let { isOpen, onClose, onLocationCreated }: Props = $props();
let latitude = $state('');
let longitude = $state('');
let isSubmitting = $state(false);
let useManualLocation = $state(false);
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);
});
$effect(() => {
if (isOpen && $coordinates) {
latitude = $coordinates.latitude.toString();
longitude = $coordinates.longitude.toString();
}
});
async function handleSubmit() {
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
if (isNaN(lat) || isNaN(lng)) {
return;
}
isSubmitting = true;
try {
const response = await fetch('/api/locations', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
latitude: lat,
longitude: lng
})
});
if (!response.ok) {
throw new Error('Failed to create location');
}
const result = await response.json();
resetForm();
onLocationCreated(
new CustomEvent('locationCreated', {
detail: { locationId: result.location.id, reload: true }
})
);
onClose();
} catch (error) {
console.error('Error creating location:', error);
alert('Failed to create location. Please try again.');
} finally {
isSubmitting = false;
}
}
function handlePlaceSelected(place: PlaceResult) {
latitude = place.latitude.toString();
longitude = place.longitude.toString();
}
function toggleLocationMode() {
useManualLocation = !useManualLocation;
if (!useManualLocation && $coordinates) {
latitude = $coordinates.latitude.toString();
longitude = $coordinates.longitude.toString();
}
}
function resetForm() {
latitude = '';
longitude = '';
useManualLocation = false;
}
function closeModal() {
resetForm();
onClose();
}
</script>
{#if isOpen}
<div class="modal-container" class:mobile={isMobile}>
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">Create Location</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">
<p class="description">
Choose a location where you and others can create finds (posts). This will be a point on
the map where discoveries can be shared.
</p>
<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}
<POISearch
onPlaceSelected={handlePlaceSelected}
placeholder="Search for a place..."
label=""
showNearbyButton={true}
/>
{/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="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !latitude || !longitude}>
{isSubmitting ? 'Creating...' : 'Create Location'}
</Button>
</div>
</form>
</div>
</div>
{/if}
<style>
.modal-container {
position: fixed;
top: 80px;
right: 20px;
width: 40%;
max-width: 600px;
min-width: 500px;
max-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;
max-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;
}
.description {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0;
line-height: 1.5;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.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;
}
.modal-footer :global(button) {
flex: 1;
}
.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;
}
.field-group {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,236 @@
<script lang="ts">
import { formatDistance } from '$lib/utils/distance';
interface Location {
id: string;
latitude: string;
longitude: string;
createdAt: string;
userId: string;
username: string;
profilePictureUrl?: string | null;
findCount: number;
distance?: number;
}
interface Props {
location: Location;
onExplore?: (id: string) => void;
}
let { location, onExplore }: Props = $props();
function handleExplore() {
onExplore?.(location.id);
}
</script>
<article class="location-card">
<div class="location-info">
<div class="location-header">
<div class="location-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" class="location-icon">
<path
d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
</svg>
<div>
<h3 class="title">
{#if location.distance !== undefined}
{formatDistance(location.distance)} away
{:else}
Location
{/if}
</h3>
<p class="coordinates">
{parseFloat(location.latitude).toFixed(4)}, {parseFloat(location.longitude).toFixed(4)}
</p>
</div>
</div>
{#if location.distance !== undefined}
<div class="distance-badge">{formatDistance(location.distance)}</div>
{/if}
</div>
<div class="location-meta">
<div class="meta-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="2" />
<path
d="M6 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
<span>Created by {location.username}</span>
</div>
<div class="meta-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span>{location.findCount} {location.findCount === 1 ? 'find' : 'finds'}</span>
</div>
</div>
</div>
<button type="button" class="explore-button" onclick={handleExplore}>
<span>Explore</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M5 12h14M12 5l7 7-7 7"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</article>
<style>
.location-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 12px;
transition: all 0.2s ease;
gap: 1rem;
}
.location-card:hover {
border-color: hsl(var(--primary) / 0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.location-info {
flex: 1;
min-width: 0;
}
.location-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
gap: 1rem;
}
.location-title {
display: flex;
align-items: flex-start;
gap: 0.75rem;
min-width: 0;
}
.location-icon {
color: hsl(var(--primary));
flex-shrink: 0;
margin-top: 2px;
}
.title {
font-size: 1rem;
font-weight: 600;
margin: 0 0 0.25rem 0;
color: hsl(var(--foreground));
line-height: 1.3;
}
.coordinates {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
margin: 0;
font-family: monospace;
}
.distance-badge {
background: hsl(var(--primary) / 0.1);
color: hsl(var(--primary));
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.location-meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.meta-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
}
.meta-item svg {
flex-shrink: 0;
}
.explore-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
flex-shrink: 0;
}
.explore-button:hover {
background: hsl(var(--primary) / 0.9);
transform: translateX(2px);
}
.explore-button svg {
transition: transform 0.2s ease;
}
.explore-button:hover svg {
transform: translateX(2px);
}
@media (max-width: 640px) {
.location-card {
flex-direction: column;
align-items: stretch;
}
.explore-button {
width: 100%;
justify-content: center;
}
.distance-badge {
display: none;
}
}
</style>

View File

@@ -0,0 +1,324 @@
<script lang="ts">
import FindsList from '../finds/FindsList.svelte';
import { Button } from '$lib/components/button';
interface Find {
id: string;
locationId: string;
title: string;
description?: string;
category?: string;
locationName?: string;
isPublic: number;
userId: string;
username: string;
profilePictureUrl?: string | null;
likeCount?: number;
isLikedByUser?: boolean;
isFromFriend?: boolean;
media?: Array<{
id: string;
type: string;
url: string;
thumbnailUrl: string;
orderIndex?: number | null;
}>;
}
interface Location {
id: string;
latitude: string;
longitude: string;
createdAt: string;
userId: string;
username: string;
findCount: number;
finds?: Find[];
}
interface Props {
isOpen: boolean;
location: Location | null;
currentUserId?: string;
onClose: () => void;
onCreateFind?: () => void;
}
let { isOpen, location, currentUserId, onClose, onCreateFind }: Props = $props();
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);
});
function handleCreateFind() {
onCreateFind?.();
}
</script>
{#if isOpen && location}
<div class="modal-container" class:mobile={isMobile}>
<div class="modal-content">
<div class="modal-header">
<div class="header-info">
<h2 class="modal-title">Location Finds</h2>
<p class="location-coords">
{parseFloat(location.latitude).toFixed(4)}, {parseFloat(location.longitude).toFixed(4)}
</p>
</div>
<button type="button" class="close-button" onclick={onClose} 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>
<div class="modal-body">
{#if location.finds && location.finds.length > 0}
<FindsList
finds={location.finds.map((find) => ({
id: find.id,
title: find.title,
description: find.description,
category: find.category,
locationName: find.locationName,
isPublic: find.isPublic,
userId: find.userId,
user: {
username: find.username,
profilePictureUrl: find.profilePictureUrl
},
likeCount: find.likeCount,
isLiked: find.isLikedByUser,
media: find.media
}))}
hideTitle={true}
{currentUserId}
/>
{:else}
<div class="empty-state">
<div class="empty-icon">
<svg
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
</div>
<h3 class="empty-title">No finds yet</h3>
<p class="empty-message">Be the first to share a discovery at this location!</p>
</div>
{/if}
</div>
{#if currentUserId}
<div class="modal-footer">
<Button onclick={handleCreateFind} class="w-full">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
</svg>
Create Find Here
</Button>
</div>
{/if}
</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;
}
.header-info {
flex: 1;
}
.modal-title {
font-family: 'Washington', serif;
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.25rem 0;
color: hsl(var(--foreground));
}
.location-coords {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
margin: 0;
font-family: monospace;
}
.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;
flex-shrink: 0;
}
.close-button:hover {
background: hsl(var(--muted) / 0.5);
color: hsl(var(--foreground));
}
.modal-body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
height: 100%;
}
.empty-icon {
margin-bottom: 1.5rem;
color: hsl(var(--muted-foreground));
opacity: 0.4;
}
.empty-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.75rem 0;
color: hsl(var(--foreground));
}
.empty-message {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0;
line-height: 1.5;
}
.modal-footer {
padding: 1.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
:global(.w-full) {
width: 100%;
}
:global(.mr-2) {
margin-right: 0.5rem;
}
@media (max-width: 767px) {
.modal-header {
padding: 1rem;
}
.modal-title {
font-size: 1.25rem;
}
.modal-footer {
padding: 1rem;
}
}
</style>

View File

@@ -0,0 +1,186 @@
<script lang="ts">
import LocationCard from './LocationCard.svelte';
interface Location {
id: string;
latitude: string;
longitude: string;
createdAt: string;
userId: string;
username: string;
profilePictureUrl?: string | null;
findCount: number;
distance?: number;
}
interface Props {
locations: Location[];
onLocationExplore?: (id: string) => void;
title?: string;
showEmpty?: boolean;
emptyMessage?: string;
hideTitle?: boolean;
}
let {
locations,
onLocationExplore,
title = 'Locations',
showEmpty = true,
emptyMessage = 'No locations nearby',
hideTitle = false
}: Props = $props();
function handleLocationExplore(id: string) {
onLocationExplore?.(id);
}
</script>
<section class="locations-feed">
{#if !hideTitle}
<div class="feed-header">
<h2 class="feed-title">{title}</h2>
</div>
{/if}
{#if locations.length > 0}
<div class="feed-container">
{#each locations as location (location.id)}
<LocationCard {location} onExplore={handleLocationExplore} />
{/each}
</div>
{:else if showEmpty}
<div class="empty-state">
<div class="empty-icon">
<svg
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
</div>
<h3 class="empty-title">No locations discovered yet</h3>
<p class="empty-message">{emptyMessage}</p>
<div class="empty-action">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M12 5v14M5 12h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
<span>Create a location to start sharing finds</span>
</div>
</div>
{/if}
</section>
<style>
.locations-feed {
width: 100%;
padding: 0 24px 24px 24px;
}
.feed-header {
margin-bottom: 1.5rem;
padding: 0 0.5rem;
}
.feed-title {
font-family: 'Washington', serif;
font-size: 1.875rem;
font-weight: 700;
margin: 0;
color: hsl(var(--foreground));
}
.feed-container {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
background: hsl(var(--card));
border-radius: 12px;
border: 1px solid hsl(var(--border));
}
.empty-icon {
margin-bottom: 1.5rem;
color: hsl(var(--muted-foreground));
opacity: 0.4;
}
.empty-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.75rem 0;
color: hsl(var(--foreground));
}
.empty-message {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0 0 1.5rem 0;
line-height: 1.5;
}
.empty-action {
display: flex;
align-items: center;
gap: 0.5rem;
color: hsl(var(--primary));
font-size: 0.875rem;
font-weight: 500;
}
@media (max-width: 768px) {
.feed-header {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 1rem;
padding: 0;
}
.feed-title {
font-size: 1.5rem;
}
.empty-state {
padding: 3rem 1.5rem;
}
}
.feed-container {
scroll-behavior: smooth;
}
:global(.location-card) {
animation: fadeInUp 0.4s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
export { default as LocationCard } from './LocationCard.svelte';
export { default as LocationsList } from './LocationsList.svelte';
export { default as CreateLocationModal } from './CreateLocationModal.svelte';
export { default as SelectLocationModal } from './SelectLocationModal.svelte';
export { default as LocationFindsModal } from './LocationFindsModal.svelte';

View File

@@ -12,25 +12,26 @@
} from '$lib/stores/location'; } from '$lib/stores/location';
import { Skeleton } from '$lib/components/skeleton'; import { Skeleton } from '$lib/components/skeleton';
interface Find { interface Location {
id: string; id: string;
title: string;
description?: string;
latitude: string; latitude: string;
longitude: string; longitude: string;
locationName?: string;
category?: string;
isPublic: number;
createdAt: Date; createdAt: Date;
userId: string; userId: string;
user: { user: {
id: string; id: string;
username: string; username: string;
}; };
media?: Array<{ finds: Array<{
type: string; id: string;
url: string; title: string;
thumbnailUrl: string; description?: string;
isPublic: number;
media?: Array<{
type: string;
url: string;
thumbnailUrl: string;
}>;
}>; }>;
} }
@@ -40,8 +41,8 @@
zoom?: number; zoom?: number;
class?: string; class?: string;
autoCenter?: boolean; autoCenter?: boolean;
finds?: Find[]; locations?: Location[];
onFindClick?: (find: Find) => void; onLocationClick?: (location: Location) => void;
sidebarVisible?: boolean; sidebarVisible?: boolean;
} }
@@ -68,8 +69,8 @@
zoom, zoom,
class: className = '', class: className = '',
autoCenter = true, autoCenter = true,
finds = [], locations = [],
onFindClick, onLocationClick,
sidebarVisible = false sidebarVisible = false
}: Props = $props(); }: Props = $props();
@@ -268,27 +269,31 @@
</Marker> </Marker>
{/if} {/if}
{#each finds as find (find.id)} {#each locations as location (location.id)}
<Marker lngLat={[parseFloat(find.longitude), parseFloat(find.latitude)]}> <Marker lngLat={[parseFloat(location.longitude), parseFloat(location.latitude)]}>
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div <div
class="find-marker" class="location-pin-marker"
role="button" role="button"
tabindex="0" tabindex="0"
onclick={() => onFindClick?.(find)} onclick={() => onLocationClick?.(location)}
title={find.title} title={`${location.finds.length} find${location.finds.length !== 1 ? 's' : ''}`}
> >
<div class="find-marker-icon"> <div class="location-pin-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path <path
d="M12 2L13.09 8.26L19 9.27L13.09 10.28L12 16.54L10.91 10.28L5 9.27L10.91 8.26L12 2Z" d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"
fill="currentColor" fill="currentColor"
/> />
<circle cx="12" cy="9" r="2.5" fill="white" />
</svg> </svg>
</div> </div>
{#if find.media && find.media.length > 0} <div class="location-find-count">
<div class="find-marker-preview"> {location.finds.length}
<img src={find.media[0].thumbnailUrl} alt={find.title} /> </div>
{#if location.finds.length > 0 && location.finds[0].media && location.finds[0].media.length > 0}
<div class="location-marker-preview">
<img src={location.finds[0].media[0].thumbnailUrl} alt="Preview" />
</div> </div>
{/if} {/if}
</div> </div>
@@ -473,42 +478,62 @@
} }
} }
/* Find marker styles */ /* Location pin marker styles */
:global(.find-marker) { :global(.location-pin-marker) {
width: 40px; width: 50px;
height: 40px; height: 50px;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
transform: translate(-50%, -50%); transform: translate(-50%, -100%);
transition: all 0.2s ease; transition: all 0.2s ease;
display: flex;
flex-direction: column;
align-items: center;
} }
:global(.find-marker:hover) { :global(.location-pin-marker:hover) {
transform: translate(-50%, -50%) scale(1.1); transform: translate(-50%, -100%) scale(1.1);
z-index: 100; z-index: 100;
} }
:global(.find-marker-icon) { :global(.location-pin-icon) {
width: 32px; width: 36px;
height: 32px; height: 36px;
background: #ff6b35;
border: 3px solid white;
border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: white; color: #ff6b35;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
position: relative; position: relative;
z-index: 2; z-index: 2;
} }
:global(.find-marker-preview) { :global(.location-find-count) {
position: absolute; position: absolute;
top: -8px; top: 2px;
right: -8px; left: 50%;
width: 20px; transform: translateX(-50%);
height: 20px; background: white;
color: #ff6b35;
font-weight: 600;
font-size: 11px;
min-width: 18px;
height: 18px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
z-index: 3;
}
:global(.location-marker-preview) {
position: absolute;
top: -2px;
right: -4px;
width: 24px;
height: 24px;
border-radius: 50%; border-radius: 50%;
overflow: hidden; overflow: hidden;
border: 2px solid white; border: 2px solid white;
@@ -516,7 +541,7 @@
z-index: 3; z-index: 3;
} }
:global(.find-marker-preview img) { :global(.location-marker-preview img) {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;

View File

@@ -161,7 +161,7 @@
/** /**
* Convert VAPID public key from base64 to Uint8Array * Convert VAPID public key from base64 to Uint8Array
*/ */
function urlBase64ToUint8Array(base64String: string): Uint8Array { function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
@@ -171,7 +171,7 @@
for (let i = 0; i < rawData.length; ++i) { for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i); outputArray[i] = rawData.charCodeAt(i);
} }
return outputArray; return outputArray as Uint8Array<ArrayBuffer>;
} }
/** /**

View File

@@ -21,16 +21,28 @@ export type Session = typeof session.$inferSelect;
export type User = typeof user.$inferSelect; export type User = typeof user.$inferSelect;
// Finds feature tables // Location table - represents geographical points where finds can be made
export const location = pgTable('location', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
latitude: text('latitude').notNull(), // Using text for precision
longitude: text('longitude').notNull(), // Using text for precision
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
});
// Find table - represents posts/content made at a location
export const find = pgTable('find', { export const find = pgTable('find', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
locationId: text('location_id')
.notNull()
.references(() => location.id, { onDelete: 'cascade' }),
userId: text('user_id') userId: text('user_id')
.notNull() .notNull()
.references(() => user.id, { onDelete: 'cascade' }), .references(() => user.id, { onDelete: 'cascade' }),
title: text('title').notNull(), title: text('title').notNull(),
description: text('description'), description: text('description'),
latitude: text('latitude').notNull(), // Using text for precision
longitude: text('longitude').notNull(), // Using text for precision
locationName: text('location_name'), // e.g., "Café Belga, Brussels" locationName: text('location_name'), // e.g., "Café Belga, Brussels"
category: text('category'), // e.g., "cafe", "restaurant", "park", "landmark" category: text('category'), // e.g., "cafe", "restaurant", "park", "landmark"
isPublic: integer('is_public').default(1), // Using integer for boolean (1 = true, 0 = false) isPublic: integer('is_public').default(1), // Using integer for boolean (1 = true, 0 = false)
@@ -130,6 +142,7 @@ export const notificationPreferences = pgTable('notification_preferences', {
}); });
// Type exports for the tables // Type exports for the tables
export type Location = typeof location.$inferSelect;
export type Find = typeof find.$inferSelect; export type Find = typeof find.$inferSelect;
export type FindMedia = typeof findMedia.$inferSelect; export type FindMedia = typeof findMedia.$inferSelect;
export type FindLike = typeof findLike.$inferSelect; export type FindLike = typeof findLike.$inferSelect;
@@ -139,6 +152,7 @@ export type Notification = typeof notification.$inferSelect;
export type NotificationSubscription = typeof notificationSubscription.$inferSelect; export type NotificationSubscription = typeof notificationSubscription.$inferSelect;
export type NotificationPreferences = typeof notificationPreferences.$inferSelect; export type NotificationPreferences = typeof notificationPreferences.$inferSelect;
export type LocationInsert = typeof location.$inferInsert;
export type FindInsert = typeof find.$inferInsert; export type FindInsert = typeof find.$inferInsert;
export type FindMediaInsert = typeof findMedia.$inferInsert; export type FindMediaInsert = typeof findMedia.$inferInsert;
export type FindLikeInsert = typeof findLike.$inferInsert; export type FindLikeInsert = typeof findLike.$inferInsert;

41
src/lib/utils/distance.ts Normal file
View File

@@ -0,0 +1,41 @@
/**
* Calculate distance between two points using Haversine formula
* @param lat1 Latitude of first point
* @param lon1 Longitude of first point
* @param lat2 Latitude of second point
* @param lon2 Longitude of second point
* @returns Distance in kilometers
*/
export function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371; // Radius of the Earth in kilometers
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const distance = R * c;
return distance;
}
function toRad(degrees: number): number {
return degrees * (Math.PI / 180);
}
/**
* Format distance for display
* @param distance Distance in kilometers
* @returns Formatted string
*/
export function formatDistance(distance: number): string {
if (distance < 1) {
return `${Math.round(distance * 1000)}m`;
} else if (distance < 10) {
return `${distance.toFixed(1)}km`;
} else {
return `${Math.round(distance)}km`;
}
}

View File

@@ -2,7 +2,7 @@ import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, url, fetch, request }) => { export const load: PageServerLoad = async ({ locals, url, fetch, request }) => {
// Build API URL with query parameters // Build API URL with query parameters
const apiUrl = new URL('/api/finds', url.origin); const apiUrl = new URL('/api/locations', url.origin);
// Forward location filtering parameters // Forward location filtering parameters
const lat = url.searchParams.get('lat'); const lat = url.searchParams.get('lat');
@@ -32,15 +32,15 @@ export const load: PageServerLoad = async ({ locals, url, fetch, request }) => {
throw new Error(`API request failed: ${response.status}`); throw new Error(`API request failed: ${response.status}`);
} }
const finds = await response.json(); const locations = await response.json();
return { return {
finds locations
}; };
} catch (err) { } catch (err) {
console.error('Error loading finds:', err); console.error('Error loading locations:', err);
return { return {
finds: [] locations: []
}; };
} }
}; };

View File

@@ -1,65 +1,30 @@
<script lang="ts"> <script lang="ts">
import { Map } from '$lib'; import { Map } from '$lib';
import FindsList from '$lib/components/finds/FindsList.svelte'; import {
import CreateFindModal from '$lib/components/finds/CreateFindModal.svelte'; LocationsList,
import EditFindModal from '$lib/components/finds/EditFindModal.svelte'; SelectLocationModal,
import FindPreview from '$lib/components/finds/FindPreview.svelte'; LocationFindsModal
import FindsFilter from '$lib/components/finds/FindsFilter.svelte'; } from '$lib/components/locations';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { coordinates } from '$lib/stores/location'; import { coordinates } from '$lib/stores/location';
import { Button } from '$lib/components/button'; import { Button } from '$lib/components/button';
import { onMount } from 'svelte'; import { calculateDistance } from '$lib/utils/distance';
import { browser } from '$app/environment';
import { apiSync, type FindState } from '$lib/stores/api-sync';
import { SvelteURLSearchParams } from 'svelte/reactivity';
// Server response type interface Find {
interface ServerFind {
id: string; id: string;
locationId: string;
title: string; title: string;
description?: string; description?: string;
latitude: string;
longitude: string;
locationName?: string;
category?: string; category?: string;
locationName?: string;
isPublic: number; isPublic: number;
createdAt: string; // Will be converted to Date type, but is a string from api
userId: string; userId: string;
username: string; username: string;
profilePictureUrl?: string | null; profilePictureUrl?: string | null;
likeCount?: number; likeCount?: number;
isLikedByUser?: boolean; isLikedByUser?: boolean;
isFromFriend?: boolean; isFromFriend?: boolean;
media: Array<{ createdAt: string;
id: string;
findId: string;
type: string;
url: string;
thumbnailUrl: string | null;
orderIndex: number | null;
}>;
}
// Map component type
interface MapFind {
id: string;
title: string;
description?: string;
latitude: string;
longitude: string;
locationName?: string;
category?: string;
isPublic: number;
createdAt: Date;
userId: string;
user: {
id: string;
username: string;
profilePictureUrl?: string | null;
};
likeCount?: number;
isLiked?: boolean;
isFromFriend?: boolean;
media?: Array<{ media?: Array<{
id: string; id: string;
type: string; type: string;
@@ -69,223 +34,130 @@
}>; }>;
} }
// Interface for FindPreview component interface Location {
interface FindPreviewData {
id: string; id: string;
title: string;
description?: string;
latitude: string; latitude: string;
longitude: string; longitude: string;
locationName?: string;
category?: string;
createdAt: string; createdAt: string;
userId: string;
username: string;
profilePictureUrl?: string | null;
findCount: number;
finds?: Find[];
distance?: number;
}
interface MapLocation {
id: string;
latitude: string;
longitude: string;
createdAt: Date;
userId: string;
user: { user: {
id: string; id: string;
username: string; username: string;
profilePictureUrl?: string | null;
}; };
likeCount?: number; finds: Array<{
isLiked?: boolean; id: string;
media?: Array<{ title: string;
type: string; description?: string;
url: string; isPublic: number;
thumbnailUrl: string; media?: Array<{
type: string;
url: string;
thumbnailUrl: string;
}>;
}>; }>;
distance?: number;
} }
let { data }: { data: PageData & { finds?: ServerFind[] } } = $props(); let { data }: { data: PageData & { locations?: Location[] } } = $props();
let showCreateModal = $state(false); let showCreateFindModal = $state(false);
let showEditModal = $state(false); let showLocationFindsModal = $state(false);
let editingFind: ServerFind | null = $state(null); let selectedLocation: Location | null = $state(null);
let selectedFind: FindPreviewData | null = $state(null);
let currentFilter = $state('all');
let isSidebarVisible = $state(true); let isSidebarVisible = $state(true);
// Subscribe to all finds from api-sync // Process locations with distance
const allFindsStore = apiSync.subscribeAllFinds(); let locations = $derived.by(() => {
let allFindsFromSync = $state<FindState[]>([]); if (!data.locations || !$coordinates) return data.locations || [];
// Initialize API sync with server data on mount return data.locations
onMount(() => { .map((loc: Location) => ({
if (browser && data.finds && data.finds.length > 0) { ...loc,
const findStates: FindState[] = data.finds.map((serverFind: ServerFind) => ({ distance: calculateDistance(
id: serverFind.id, $coordinates.latitude,
title: serverFind.title, $coordinates.longitude,
description: serverFind.description, parseFloat(loc.latitude),
latitude: serverFind.latitude, parseFloat(loc.longitude)
longitude: serverFind.longitude, )
locationName: serverFind.locationName,
category: serverFind.category,
isPublic: Boolean(serverFind.isPublic),
createdAt: new Date(serverFind.createdAt),
userId: serverFind.userId,
username: serverFind.username,
profilePictureUrl: serverFind.profilePictureUrl || undefined,
media: serverFind.media,
isLikedByUser: Boolean(serverFind.isLikedByUser),
likeCount: serverFind.likeCount || 0,
commentCount: 0,
isFromFriend: Boolean(serverFind.isFromFriend)
}));
apiSync.initializeFindData(findStates);
}
});
// Subscribe to find updates using $effect
$effect(() => {
const unsubscribe = allFindsStore.subscribe((finds) => {
allFindsFromSync = finds;
});
return unsubscribe;
});
// All finds - convert FindState to MapFind format
let allFinds = $derived(
allFindsFromSync.map((findState: FindState) => ({
id: findState.id,
title: findState.title,
description: findState.description,
latitude: findState.latitude,
longitude: findState.longitude,
locationName: findState.locationName,
category: findState.category,
isPublic: findState.isPublic ? 1 : 0,
createdAt: findState.createdAt,
userId: findState.userId,
user: {
id: findState.userId,
username: findState.username,
profilePictureUrl: findState.profilePictureUrl || undefined
},
likeCount: findState.likeCount,
isLiked: findState.isLikedByUser,
isFromFriend: findState.isFromFriend,
media: findState.media?.map((m) => ({
id: m.id,
type: m.type,
url: m.url,
thumbnailUrl: m.thumbnailUrl || m.url,
orderIndex: m.orderIndex
})) }))
})) as MapFind[] .sort(
(a: Location & { distance?: number }, b: Location & { distance?: number }) =>
(a.distance || 0) - (b.distance || 0)
);
});
// Convert locations to map markers - keep the full location object
let mapLocations: MapLocation[] = $derived(
locations.map(
(loc: Location): MapLocation => ({
id: loc.id,
latitude: loc.latitude,
longitude: loc.longitude,
createdAt: new Date(loc.createdAt),
userId: loc.userId,
user: {
id: loc.userId,
username: loc.username
},
finds: (loc.finds || []).map((find) => ({
id: find.id,
title: find.title,
description: find.description,
isPublic: find.isPublic,
media: find.media || []
})),
distance: loc.distance
})
)
); );
// Filtered finds based on current filter function handleLocationExplore(id: string) {
let finds = $derived.by(() => { const location = locations.find((l: Location) => l.id === id);
if (!data.user) return allFinds; if (location) {
selectedLocation = location;
switch (currentFilter) { showLocationFindsModal = true;
case 'public':
return allFinds.filter((find) => find.isPublic === 1);
case 'friends':
return allFinds.filter((find) => find.isFromFriend === true);
case 'mine':
return allFinds.filter((find) => find.userId === data.user!.id);
case 'all':
default:
return allFinds;
}
});
function handleFilterChange(filter: string) {
currentFilter = filter;
}
function handleFindCreated(event: CustomEvent) {
// For now, just close modal and refresh page as in original implementation
showCreateModal = false;
if (event.detail?.reload) {
window.location.reload();
} }
} }
function handleFindClick(find: MapFind) { function handleMapLocationClick(location: MapLocation) {
// Convert MapFind to FindPreviewData format handleLocationExplore(location.id);
selectedFind = {
id: find.id,
title: find.title,
description: find.description,
latitude: find.latitude,
longitude: find.longitude,
locationName: find.locationName,
category: find.category,
createdAt: find.createdAt.toISOString(),
user: find.user,
likeCount: find.likeCount,
isLiked: find.isLiked,
media: find.media?.map((m) => ({
type: m.type,
url: m.url,
thumbnailUrl: m.thumbnailUrl || m.url
}))
};
} }
function handleFindExplore(id: string) { function openCreateFindModal() {
// Find the specific find and show preview showCreateFindModal = true;
const find = finds.find((f) => f.id === id);
if (find) {
handleFindClick(find);
}
} }
function closeFindPreview() { function closeCreateFindModal() {
selectedFind = null; showCreateFindModal = false;
} }
function openCreateModal() { function closeLocationFindsModal() {
showCreateModal = true; showLocationFindsModal = false;
selectedLocation = null;
} }
function closeCreateModal() { function handleFindCreated() {
showCreateModal = false; closeCreateFindModal();
// Reload page to show new find
window.location.reload();
} }
function openEditModal(find: MapFind) { function handleCreateFindFromLocation() {
// Convert MapFind type to ServerFind format // Close location modal and open create find modal
const serverFind: ServerFind = { showLocationFindsModal = false;
id: find.id, showCreateFindModal = true;
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();
// api-sync handles the update, no manual reload needed
}
function handleFindDeleted() {
closeEditModal();
// api-sync handles the deletion, no manual reload needed
} }
function toggleSidebar() { function toggleSidebar() {
@@ -319,46 +191,20 @@
<Map <Map
autoCenter={true} autoCenter={true}
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]} center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
finds={finds.map((find) => ({ locations={mapLocations}
id: find.id, onLocationClick={handleMapLocationClick}
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>
<!-- Sidebar container --> <!-- Sidebar container -->
<div class="sidebar-container"> <div class="sidebar-container">
<!-- Left sidebar with finds list --> <!-- Left sidebar with locations list -->
<div class="finds-sidebar" class:hidden={!isSidebarVisible}> <div class="finds-sidebar" class:hidden={!isSidebarVisible}>
<div class="finds-header"> <div class="finds-header">
{#if data.user} {#if data.user}
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} /> <h3 class="header-title">Locations</h3>
<Button onclick={openCreateModal} class="create-find-button"> <Button onclick={openCreateFindModal} class="create-find-button">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" /> <line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" /> <line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
@@ -374,39 +220,7 @@
{/if} {/if}
</div> </div>
<div class="finds-list-container"> <div class="finds-list-container">
<FindsList <LocationsList {locations} onLocationExplore={handleLocationExplore} hideTitle={true} />
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}
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 -->
@@ -414,7 +228,7 @@
class="sidebar-toggle" class="sidebar-toggle"
class:collapsed={!isSidebarVisible} class:collapsed={!isSidebarVisible}
onclick={toggleSidebar} onclick={toggleSidebar}
aria-label="Toggle finds list" aria-label="Toggle locations list"
> >
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none">
{#if isSidebarVisible} {#if isSidebarVisible}
@@ -428,38 +242,24 @@
</div> </div>
<!-- Modals --> <!-- Modals -->
{#if showCreateModal} {#if showCreateFindModal}
<CreateFindModal <SelectLocationModal
isOpen={showCreateModal} isOpen={showCreateFindModal}
onClose={closeCreateModal} onClose={closeCreateFindModal}
onFindCreated={handleFindCreated} onFindCreated={handleFindCreated}
/> />
{/if} {/if}
{#if showEditModal && editingFind} {#if showLocationFindsModal && selectedLocation}
<EditFindModal <LocationFindsModal
isOpen={showEditModal} isOpen={showLocationFindsModal}
find={{ location={selectedLocation}
id: editingFind.id, currentUserId={data.user?.id}
title: editingFind.title, onClose={closeLocationFindsModal}
description: editingFind.description || null, onCreateFind={handleCreateFindFromLocation}
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}
{#if selectedFind}
<FindPreview find={selectedFind} onClose={closeFindPreview} currentUserId={data.user?.id} />
{/if}
<style> <style>
.home-container { .home-container {
position: relative; position: relative;
@@ -545,12 +345,21 @@
.finds-header { .finds-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center;
padding: 20px; padding: 20px;
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;
} }
.header-title {
font-family: 'Washington', serif;
font-size: 1.5rem;
font-weight: 600;
margin: 0;
color: hsl(var(--foreground));
}
.login-prompt { .login-prompt {
width: 100%; width: 100%;
text-align: center; text-align: center;
@@ -598,6 +407,10 @@
flex-shrink: 0; flex-shrink: 0;
} }
:global(.mr-2) {
margin-right: 0.5rem;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar-container { .sidebar-container {
position: fixed; position: fixed;

View File

@@ -1,25 +1,27 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { find, findMedia, user, findLike, friendship } from '$lib/server/db/schema'; import { location, find, findMedia, user, findLike, friendship } from '$lib/server/db/schema';
import { eq, and, sql, desc, or } from 'drizzle-orm'; import { eq, and, sql, desc, or } from 'drizzle-orm';
import { encodeBase64url } from '@oslojs/encoding'; import { encodeBase64url } from '@oslojs/encoding';
import { getLocalR2Url } from '$lib/server/r2'; import { getLocalR2Url } from '$lib/server/r2';
function generateFindId(): string { function generateId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(15)); const bytes = crypto.getRandomValues(new Uint8Array(15));
return encodeBase64url(bytes); return encodeBase64url(bytes);
} }
// GET endpoint now returns finds for a specific location
export const GET: RequestHandler = async ({ url, locals }) => { export const GET: RequestHandler = async ({ url, locals }) => {
const lat = url.searchParams.get('lat'); const locationId = url.searchParams.get('locationId');
const lng = url.searchParams.get('lng');
const radius = url.searchParams.get('radius') || '50';
const includePrivate = url.searchParams.get('includePrivate') === 'true'; const includePrivate = url.searchParams.get('includePrivate') === 'true';
const order = url.searchParams.get('order') || 'desc'; const order = url.searchParams.get('order') || 'desc';
const includeFriends = url.searchParams.get('includeFriends') === 'true'; const includeFriends = url.searchParams.get('includeFriends') === 'true';
if (!locationId) {
throw error(400, 'locationId is required');
}
try { try {
// Get user's friends if needed and user is logged in // Get user's friends if needed and user is logged in
let friendIds: string[] = []; let friendIds: string[] = [];
@@ -58,39 +60,16 @@ export const GET: RequestHandler = async ({ url, locals }) => {
); );
} }
const baseCondition = sql`(${sql.join(conditions, sql` OR `)})`; const privacyCondition = sql`(${sql.join(conditions, sql` OR `)})`;
const whereConditions = and(eq(find.locationId, locationId), privacyCondition);
let whereConditions = baseCondition; // Get all finds at this location with filtering, like counts, and user's liked status
// Add location filtering if coordinates provided
if (lat && lng) {
const radiusKm = parseFloat(radius);
const latOffset = radiusKm / 111;
const lngOffset = radiusKm / (111 * Math.cos((parseFloat(lat) * Math.PI) / 180));
const locationConditions = and(
baseCondition,
sql`${find.latitude} BETWEEN ${parseFloat(lat) - latOffset} AND ${
parseFloat(lat) + latOffset
}`,
sql`${find.longitude} BETWEEN ${parseFloat(lng) - lngOffset} AND ${
parseFloat(lng) + lngOffset
}`
);
if (locationConditions) {
whereConditions = locationConditions;
}
}
// Get all finds with filtering, like counts, and user's liked status
const finds = await db const finds = await db
.select({ .select({
id: find.id, id: find.id,
locationId: find.locationId,
title: find.title, title: find.title,
description: find.description, description: find.description,
latitude: find.latitude,
longitude: find.longitude,
locationName: find.locationName, locationName: find.locationName,
category: find.category, category: find.category,
isPublic: find.isPublic, isPublic: find.isPublic,
@@ -122,8 +101,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
.leftJoin(findLike, eq(find.id, findLike.findId)) .leftJoin(findLike, eq(find.id, findLike.findId))
.where(whereConditions) .where(whereConditions)
.groupBy(find.id, user.username, user.profilePictureUrl) .groupBy(find.id, user.username, user.profilePictureUrl)
.orderBy(order === 'desc' ? desc(find.createdAt) : find.createdAt) .orderBy(order === 'desc' ? desc(find.createdAt) : find.createdAt);
.limit(100);
// Get media for all finds // Get media for all finds
const findIds = finds.map((f) => f.id); const findIds = finds.map((f) => f.id);
@@ -176,12 +154,11 @@ export const GET: RequestHandler = async ({ url, locals }) => {
// Generate signed URLs for all media items // Generate signed URLs for all media items
const mediaWithSignedUrls = await Promise.all( const mediaWithSignedUrls = await Promise.all(
findMedia.map(async (mediaItem) => { findMedia.map(async (mediaItem) => {
// URLs in database are now paths, generate local proxy URLs
const localUrl = getLocalR2Url(mediaItem.url); const localUrl = getLocalR2Url(mediaItem.url);
const localThumbnailUrl = const localThumbnailUrl =
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/') mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
? getLocalR2Url(mediaItem.thumbnailUrl) ? getLocalR2Url(mediaItem.thumbnailUrl)
: mediaItem.thumbnailUrl; // Keep static placeholder paths as-is : mediaItem.thumbnailUrl;
return { return {
...mediaItem, ...mediaItem,
@@ -214,16 +191,17 @@ export const GET: RequestHandler = async ({ url, locals }) => {
} }
}; };
// POST endpoint creates a find (post) at a location
export const POST: RequestHandler = async ({ request, locals }) => { export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) { if (!locals.user) {
throw error(401, 'Unauthorized'); throw error(401, 'Unauthorized');
} }
const data = await request.json(); const data = await request.json();
const { title, description, latitude, longitude, locationName, category, isPublic, media } = data; const { locationId, title, description, locationName, category, isPublic, media } = data;
if (!title || !latitude || !longitude) { if (!title || !locationId) {
throw error(400, 'Title, latitude, and longitude are required'); throw error(400, 'Title and locationId are required');
} }
if (title.length > 100) { if (title.length > 100) {
@@ -234,18 +212,28 @@ export const POST: RequestHandler = async ({ request, locals }) => {
throw error(400, 'Description must be 500 characters or less'); throw error(400, 'Description must be 500 characters or less');
} }
const findId = generateFindId(); // Verify location exists
const locationExists = await db
.select({ id: location.id })
.from(location)
.where(eq(location.id, locationId))
.limit(1);
if (locationExists.length === 0) {
throw error(404, 'Location not found');
}
const findId = generateId();
// Create find // Create find
const newFind = await db const newFind = await db
.insert(find) .insert(find)
.values({ .values({
id: findId, id: findId,
locationId,
userId: locals.user.id, userId: locals.user.id,
title, title,
description, description,
latitude: latitude.toString(),
longitude: longitude.toString(),
locationName, locationName,
category, category,
isPublic: isPublic ? 1 : 0 isPublic: isPublic ? 1 : 0
@@ -256,7 +244,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
if (media && media.length > 0) { if (media && media.length > 0) {
const mediaRecords = media.map( const mediaRecords = media.map(
(item: { type: string; url: string; thumbnailUrl?: string }, index: number) => ({ (item: { type: string; url: string; thumbnailUrl?: string }, index: number) => ({
id: generateFindId(), id: generateId(),
findId, findId,
type: item.type, type: item.type,
url: item.url, url: item.url,

View File

@@ -1,7 +1,7 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; 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, location } from '$lib/server/db/schema';
import { eq, sql } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import { getLocalR2Url, deleteFromR2 } from '$lib/server/r2'; import { getLocalR2Url, deleteFromR2 } from '$lib/server/r2';
@@ -19,8 +19,8 @@ export const GET: RequestHandler = async ({ params, locals }) => {
id: find.id, id: find.id,
title: find.title, title: find.title,
description: find.description, description: find.description,
latitude: find.latitude, latitude: location.latitude,
longitude: find.longitude, longitude: location.longitude,
locationName: find.locationName, locationName: find.locationName,
category: find.category, category: find.category,
isPublic: find.isPublic, isPublic: find.isPublic,
@@ -42,10 +42,17 @@ export const GET: RequestHandler = async ({ params, locals }) => {
: sql<boolean>`0` : sql<boolean>`0`
}) })
.from(find) .from(find)
.innerJoin(location, eq(find.locationId, location.id))
.innerJoin(user, eq(find.userId, user.id)) .innerJoin(user, eq(find.userId, user.id))
.leftJoin(findLike, eq(find.id, findLike.findId)) .leftJoin(findLike, eq(find.id, findLike.findId))
.where(eq(find.id, findId)) .where(eq(find.id, findId))
.groupBy(find.id, user.username, user.profilePictureUrl) .groupBy(
find.id,
location.latitude,
location.longitude,
user.username,
user.profilePictureUrl
)
.limit(1); .limit(1);
if (findResult.length === 0) { if (findResult.length === 0) {
@@ -143,21 +150,11 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
// Parse request body // Parse request body
const data = await request.json(); const data = await request.json();
const { const { title, description, category, isPublic, media, mediaToDelete } = data;
title,
description,
latitude,
longitude,
locationName,
category,
isPublic,
media,
mediaToDelete
} = data;
// Validate required fields // Validate required fields
if (!title || !latitude || !longitude) { if (!title) {
throw error(400, 'Title, latitude, and longitude are required'); throw error(400, 'Title is required');
} }
if (title.length > 100) { if (title.length > 100) {
@@ -209,9 +206,6 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
.set({ .set({
title, title,
description: description || null, description: description || null,
latitude: latitude.toString(),
longitude: longitude.toString(),
locationName: locationName || null,
category: category || null, category: category || null,
isPublic: isPublic ? 1 : 0, isPublic: isPublic ? 1 : 0,
updatedAt: new Date() updatedAt: new Date()

View File

@@ -0,0 +1,262 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { location, find, findMedia, user, findLike, friendship } from '$lib/server/db/schema';
import { eq, and, sql, desc, or } from 'drizzle-orm';
import { encodeBase64url } from '@oslojs/encoding';
import { getLocalR2Url } from '$lib/server/r2';
function generateId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(15));
return encodeBase64url(bytes);
}
export const GET: RequestHandler = async ({ url, locals }) => {
const lat = url.searchParams.get('lat');
const lng = url.searchParams.get('lng');
const radius = url.searchParams.get('radius') || '50';
const includePrivate = url.searchParams.get('includePrivate') === 'true';
const order = url.searchParams.get('order') || 'desc';
const includeFriends = url.searchParams.get('includeFriends') === 'true';
try {
// Get user's friends if needed and user is logged in
let friendIds: string[] = [];
if (locals.user && (includeFriends || includePrivate)) {
const friendships = await db
.select({
userId: friendship.userId,
friendId: friendship.friendId
})
.from(friendship)
.where(
and(
eq(friendship.status, 'accepted'),
or(eq(friendship.userId, locals.user.id), eq(friendship.friendId, locals.user.id))
)
);
friendIds = friendships.map((f) => (f.userId === locals.user!.id ? f.friendId : f.userId));
}
// Build base condition for locations (always public since locations don't have privacy)
let whereConditions = sql`1=1`;
// Add location filtering if coordinates provided
if (lat && lng) {
const radiusKm = parseFloat(radius);
const latOffset = radiusKm / 111;
const lngOffset = radiusKm / (111 * Math.cos((parseFloat(lat) * Math.PI) / 180));
whereConditions = and(
whereConditions,
sql`${location.latitude} BETWEEN ${parseFloat(lat) - latOffset} AND ${
parseFloat(lat) + latOffset
}`,
sql`${location.longitude} BETWEEN ${parseFloat(lng) - lngOffset} AND ${
parseFloat(lng) + lngOffset
}`
)!;
}
// Get all locations with their find counts
const locations = await db
.select({
id: location.id,
latitude: location.latitude,
longitude: location.longitude,
createdAt: location.createdAt,
userId: location.userId,
username: user.username,
profilePictureUrl: user.profilePictureUrl,
findCount: sql<number>`COALESCE(COUNT(DISTINCT ${find.id}), 0)`
})
.from(location)
.innerJoin(user, eq(location.userId, user.id))
.leftJoin(find, eq(location.id, find.locationId))
.where(whereConditions)
.groupBy(location.id, user.username, user.profilePictureUrl)
.orderBy(order === 'desc' ? desc(location.createdAt) : location.createdAt)
.limit(100);
// For each location, get finds with privacy filtering
const locationsWithFinds = await Promise.all(
locations.map(async (loc) => {
// Build privacy conditions for finds
const findConditions = [sql`${find.isPublic} = 1`]; // Always include public finds
if (locals.user && includePrivate) {
// Include user's own finds
findConditions.push(sql`${find.userId} = ${locals.user.id}`);
}
if (locals.user && includeFriends && friendIds.length > 0) {
// Include friends' finds
findConditions.push(
sql`${find.userId} IN (${sql.join(
friendIds.map((id) => sql`${id}`),
sql`, `
)})`
);
}
const findPrivacyCondition = sql`(${sql.join(findConditions, sql` OR `)})`;
// Get finds for this location
const finds = await db
.select({
id: find.id,
title: find.title,
description: find.description,
locationName: find.locationName,
category: find.category,
isPublic: find.isPublic,
createdAt: find.createdAt,
userId: find.userId,
username: user.username,
profilePictureUrl: user.profilePictureUrl,
likeCount: sql<number>`COALESCE(COUNT(DISTINCT ${findLike.id}), 0)`,
isLikedByUser: locals.user
? sql<boolean>`CASE WHEN EXISTS(
SELECT 1 FROM ${findLike}
WHERE ${findLike.findId} = ${find.id}
AND ${findLike.userId} = ${locals.user.id}
) THEN 1 ELSE 0 END`
: sql<boolean>`0`
})
.from(find)
.innerJoin(user, eq(find.userId, user.id))
.leftJoin(findLike, eq(find.id, findLike.findId))
.where(and(eq(find.locationId, loc.id), findPrivacyCondition))
.groupBy(find.id, user.username, user.profilePictureUrl)
.orderBy(desc(find.createdAt));
// Get media for all finds at this location
const findIds = finds.map((f) => f.id);
let media: Array<{
id: string;
findId: string;
type: string;
url: string;
thumbnailUrl: string | null;
orderIndex: number | null;
}> = [];
if (findIds.length > 0) {
media = await db
.select({
id: findMedia.id,
findId: findMedia.findId,
type: findMedia.type,
url: findMedia.url,
thumbnailUrl: findMedia.thumbnailUrl,
orderIndex: findMedia.orderIndex
})
.from(findMedia)
.where(
sql`${findMedia.findId} IN (${sql.join(
findIds.map((id) => sql`${id}`),
sql`, `
)})`
)
.orderBy(findMedia.orderIndex);
}
// Group media by find
const mediaByFind = media.reduce(
(acc, item) => {
if (!acc[item.findId]) {
acc[item.findId] = [];
}
acc[item.findId].push(item);
return acc;
},
{} as Record<string, typeof media>
);
// Combine finds with their media and generate signed URLs
const findsWithMedia = await Promise.all(
finds.map(async (findItem) => {
const findMedia = mediaByFind[findItem.id] || [];
// Generate signed URLs for all media items
const mediaWithSignedUrls = await Promise.all(
findMedia.map(async (mediaItem) => {
const localUrl = getLocalR2Url(mediaItem.url);
const localThumbnailUrl =
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
? getLocalR2Url(mediaItem.thumbnailUrl)
: mediaItem.thumbnailUrl;
return {
...mediaItem,
url: localUrl,
thumbnailUrl: localThumbnailUrl
};
})
);
// Generate local proxy URL for user profile picture if it exists
let userProfilePictureUrl = findItem.profilePictureUrl;
if (userProfilePictureUrl && !userProfilePictureUrl.startsWith('http')) {
userProfilePictureUrl = getLocalR2Url(userProfilePictureUrl);
}
return {
...findItem,
profilePictureUrl: userProfilePictureUrl,
media: mediaWithSignedUrls,
isLikedByUser: Boolean(findItem.isLikedByUser)
};
})
);
// Generate local proxy URL for location creator profile picture
let locProfilePictureUrl = loc.profilePictureUrl;
if (locProfilePictureUrl && !locProfilePictureUrl.startsWith('http')) {
locProfilePictureUrl = getLocalR2Url(locProfilePictureUrl);
}
return {
...loc,
profilePictureUrl: locProfilePictureUrl,
finds: findsWithMedia
};
})
);
return json(locationsWithFinds);
} catch (err) {
console.error('Error loading locations:', err);
throw error(500, 'Failed to load locations');
}
};
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const data = await request.json();
const { latitude, longitude } = data;
if (!latitude || !longitude) {
throw error(400, 'Latitude and longitude are required');
}
const locationId = generateId();
// Create location
const newLocation = await db
.insert(location)
.values({
id: locationId,
userId: locals.user.id,
latitude: latitude.toString(),
longitude: longitude.toString()
})
.returning();
return json({ success: true, location: newLocation[0] });
};

View File

@@ -121,40 +121,6 @@
goto('/'); goto('/');
} }
// Create the map find format
let mapFinds = $derived(
data.find
? [
{
id: data.find.id,
title: data.find.title,
description: data.find.description,
latitude: data.find.latitude,
longitude: data.find.longitude,
locationName: data.find.locationName,
category: data.find.category,
isPublic: data.find.isPublic,
createdAt: new Date(data.find.createdAt),
userId: data.find.userId,
user: {
id: data.find.userId,
username: data.find.username,
profilePictureUrl: data.find.profilePictureUrl
},
likeCount: data.find.likeCount,
isLiked: data.find.isLikedByUser,
media: data.find.media?.map(
(m: { type: string; url: string; thumbnailUrl: string | null }) => ({
type: m.type,
url: m.url,
thumbnailUrl: m.thumbnailUrl || m.url
})
)
}
]
: []
);
// Get first media for OG image // Get first media for OG image
let ogImage = $derived(data.find?.media?.[0]?.url || ''); let ogImage = $derived(data.find?.media?.[0]?.url || '');
</script> </script>
@@ -200,8 +166,6 @@
<Map <Map
autoCenter={true} autoCenter={true}
center={[parseFloat(data.find?.longitude || '0'), parseFloat(data.find?.latitude || '0')]} center={[parseFloat(data.find?.longitude || '0'), parseFloat(data.find?.latitude || '0')]}
finds={mapFinds}
onFindClick={() => {}}
/> />
</div> </div>