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:
47
drizzle/0008_location_refactor.sql
Normal file
47
drizzle/0008_location_refactor.sql
Normal 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";
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
443
src/lib/components/locations/CreateLocationModal.svelte
Normal file
443
src/lib/components/locations/CreateLocationModal.svelte
Normal 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>
|
||||||
236
src/lib/components/locations/LocationCard.svelte
Normal file
236
src/lib/components/locations/LocationCard.svelte
Normal 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>
|
||||||
324
src/lib/components/locations/LocationFindsModal.svelte
Normal file
324
src/lib/components/locations/LocationFindsModal.svelte
Normal 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>
|
||||||
186
src/lib/components/locations/LocationsList.svelte
Normal file
186
src/lib/components/locations/LocationsList.svelte
Normal 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>
|
||||||
1078
src/lib/components/locations/SelectLocationModal.svelte
Normal file
1078
src/lib/components/locations/SelectLocationModal.svelte
Normal file
File diff suppressed because it is too large
Load Diff
5
src/lib/components/locations/index.ts
Normal file
5
src/lib/components/locations/index.ts
Normal 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';
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
41
src/lib/utils/distance.ts
Normal 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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
262
src/routes/api/locations/+server.ts
Normal file
262
src/routes/api/locations/+server.ts
Normal 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] });
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user