feat:use locations&finds

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

View File

@@ -0,0 +1,443 @@
<script lang="ts">
import { Input } from '$lib/components/input';
import { Label } from '$lib/components/label';
import { Button } from '$lib/components/button';
import { coordinates } from '$lib/stores/location';
import POISearch from '../map/POISearch.svelte';
import type { PlaceResult } from '$lib/utils/places';
interface Props {
isOpen: boolean;
onClose: () => void;
onLocationCreated: (event: CustomEvent<{ locationId: string; reload?: boolean }>) => void;
}
let { isOpen, onClose, onLocationCreated }: Props = $props();
let latitude = $state('');
let longitude = $state('');
let isSubmitting = $state(false);
let useManualLocation = $state(false);
let isMobile = $state(false);
$effect(() => {
if (typeof window === 'undefined') return;
const checkIsMobile = () => {
isMobile = window.innerWidth < 768;
};
checkIsMobile();
window.addEventListener('resize', checkIsMobile);
return () => window.removeEventListener('resize', checkIsMobile);
});
$effect(() => {
if (isOpen && $coordinates) {
latitude = $coordinates.latitude.toString();
longitude = $coordinates.longitude.toString();
}
});
async function handleSubmit() {
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
if (isNaN(lat) || isNaN(lng)) {
return;
}
isSubmitting = true;
try {
const response = await fetch('/api/locations', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
latitude: lat,
longitude: lng
})
});
if (!response.ok) {
throw new Error('Failed to create location');
}
const result = await response.json();
resetForm();
onLocationCreated(
new CustomEvent('locationCreated', {
detail: { locationId: result.location.id, reload: true }
})
);
onClose();
} catch (error) {
console.error('Error creating location:', error);
alert('Failed to create location. Please try again.');
} finally {
isSubmitting = false;
}
}
function handlePlaceSelected(place: PlaceResult) {
latitude = place.latitude.toString();
longitude = place.longitude.toString();
}
function toggleLocationMode() {
useManualLocation = !useManualLocation;
if (!useManualLocation && $coordinates) {
latitude = $coordinates.latitude.toString();
longitude = $coordinates.longitude.toString();
}
}
function resetForm() {
latitude = '';
longitude = '';
useManualLocation = false;
}
function closeModal() {
resetForm();
onClose();
}
</script>
{#if isOpen}
<div class="modal-container" class:mobile={isMobile}>
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">Create Location</h2>
<button type="button" class="close-button" onclick={closeModal} aria-label="Close">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
d="M18 6L6 18M6 6L18 18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="form"
>
<div class="modal-body">
<p class="description">
Choose a location where you and others can create finds (posts). This will be a point on
the map where discoveries can be shared.
</p>
<div class="location-section">
<div class="location-header">
<Label>Location</Label>
<button type="button" onclick={toggleLocationMode} class="toggle-button">
{useManualLocation ? 'Use Search' : 'Manual Entry'}
</button>
</div>
{#if !useManualLocation}
<POISearch
onPlaceSelected={handlePlaceSelected}
placeholder="Search for a place..."
label=""
showNearbyButton={true}
/>
{/if}
</div>
{#if useManualLocation || (!latitude && !longitude)}
<div class="field-group">
<div class="field">
<Label for="latitude">Latitude</Label>
<Input name="latitude" type="text" required bind:value={latitude} />
</div>
<div class="field">
<Label for="longitude">Longitude</Label>
<Input name="longitude" type="text" required bind:value={longitude} />
</div>
</div>
{:else if latitude && longitude}
<div class="coordinates-display">
<Label>Selected coordinates</Label>
<div class="coordinates-info">
<span class="coordinate">Lat: {parseFloat(latitude).toFixed(6)}</span>
<span class="coordinate">Lng: {parseFloat(longitude).toFixed(6)}</span>
<button
type="button"
onclick={() => (useManualLocation = true)}
class="edit-coords-button"
>
Edit
</button>
</div>
</div>
{/if}
</div>
<div class="modal-footer">
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !latitude || !longitude}>
{isSubmitting ? 'Creating...' : 'Create Location'}
</Button>
</div>
</form>
</div>
</div>
{/if}
<style>
.modal-container {
position: fixed;
top: 80px;
right: 20px;
width: 40%;
max-width: 600px;
min-width: 500px;
max-height: calc(100vh - 100px);
backdrop-filter: blur(10px);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 50;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.modal-container.mobile {
top: auto;
bottom: 0;
left: 0;
right: 0;
width: 100%;
min-width: 0;
max-width: none;
max-height: 90vh;
border-radius: 16px 16px 0 0;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-content {
display: flex;
flex-direction: column;
height: 100%;
background: rgba(255, 255, 255, 0.6);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.modal-title {
font-family: 'Washington', serif;
font-size: 1.5rem;
font-weight: 600;
margin: 0;
color: hsl(var(--foreground));
}
.close-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: hsl(var(--muted-foreground));
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.close-button:hover {
background: hsl(var(--muted) / 0.5);
color: hsl(var(--foreground));
}
.form {
height: 100%;
display: flex;
flex-direction: column;
}
.modal-body {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
min-height: 0;
}
.description {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0;
line-height: 1.5;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.modal-footer {
display: flex;
gap: 0.75rem;
padding: 1.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.modal-footer :global(button) {
flex: 1;
}
.location-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.location-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.toggle-button {
font-size: 0.75rem;
padding: 0.375rem 0.75rem;
height: auto;
background: hsl(var(--secondary));
border: 1px solid hsl(var(--border));
border-radius: 6px;
color: hsl(var(--secondary-foreground));
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.toggle-button:hover {
background: hsl(var(--secondary) / 0.8);
}
.coordinates-display {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.coordinates-info {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: hsl(var(--muted) / 0.5);
border: 1px solid hsl(var(--border));
border-radius: 8px;
font-size: 0.875rem;
}
.coordinate {
color: hsl(var(--muted-foreground));
font-family: monospace;
font-size: 0.8125rem;
}
.edit-coords-button {
margin-left: auto;
font-size: 0.75rem;
padding: 0.375rem 0.75rem;
height: auto;
background: hsl(var(--secondary));
border: 1px solid hsl(var(--border));
border-radius: 6px;
color: hsl(var(--secondary-foreground));
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.edit-coords-button:hover {
background: hsl(var(--secondary) / 0.8);
}
/* Mobile specific adjustments */
@media (max-width: 767px) {
.modal-header {
padding: 1rem;
}
.modal-title {
font-size: 1.25rem;
}
.modal-body {
padding: 1rem;
gap: 1.25rem;
}
.modal-footer {
padding: 1rem;
gap: 0.5rem;
}
.field-group {
grid-template-columns: 1fr;
}
}
</style>

View File

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

View File

@@ -0,0 +1,324 @@
<script lang="ts">
import FindsList from '../finds/FindsList.svelte';
import { Button } from '$lib/components/button';
interface Find {
id: string;
locationId: string;
title: string;
description?: string;
category?: string;
locationName?: string;
isPublic: number;
userId: string;
username: string;
profilePictureUrl?: string | null;
likeCount?: number;
isLikedByUser?: boolean;
isFromFriend?: boolean;
media?: Array<{
id: string;
type: string;
url: string;
thumbnailUrl: string;
orderIndex?: number | null;
}>;
}
interface Location {
id: string;
latitude: string;
longitude: string;
createdAt: string;
userId: string;
username: string;
findCount: number;
finds?: Find[];
}
interface Props {
isOpen: boolean;
location: Location | null;
currentUserId?: string;
onClose: () => void;
onCreateFind?: () => void;
}
let { isOpen, location, currentUserId, onClose, onCreateFind }: Props = $props();
let isMobile = $state(false);
$effect(() => {
if (typeof window === 'undefined') return;
const checkIsMobile = () => {
isMobile = window.innerWidth < 768;
};
checkIsMobile();
window.addEventListener('resize', checkIsMobile);
return () => window.removeEventListener('resize', checkIsMobile);
});
function handleCreateFind() {
onCreateFind?.();
}
</script>
{#if isOpen && location}
<div class="modal-container" class:mobile={isMobile}>
<div class="modal-content">
<div class="modal-header">
<div class="header-info">
<h2 class="modal-title">Location Finds</h2>
<p class="location-coords">
{parseFloat(location.latitude).toFixed(4)}, {parseFloat(location.longitude).toFixed(4)}
</p>
</div>
<button type="button" class="close-button" onclick={onClose} aria-label="Close">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
d="M18 6L6 18M6 6L18 18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<div class="modal-body">
{#if location.finds && location.finds.length > 0}
<FindsList
finds={location.finds.map((find) => ({
id: find.id,
title: find.title,
description: find.description,
category: find.category,
locationName: find.locationName,
isPublic: find.isPublic,
userId: find.userId,
user: {
username: find.username,
profilePictureUrl: find.profilePictureUrl
},
likeCount: find.likeCount,
isLiked: find.isLikedByUser,
media: find.media
}))}
hideTitle={true}
{currentUserId}
/>
{:else}
<div class="empty-state">
<div class="empty-icon">
<svg
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
</div>
<h3 class="empty-title">No finds yet</h3>
<p class="empty-message">Be the first to share a discovery at this location!</p>
</div>
{/if}
</div>
{#if currentUserId}
<div class="modal-footer">
<Button onclick={handleCreateFind} class="w-full">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
</svg>
Create Find Here
</Button>
</div>
{/if}
</div>
</div>
{/if}
<style>
.modal-container {
position: fixed;
top: 80px;
right: 20px;
width: 40%;
max-width: 600px;
min-width: 500px;
height: calc(100vh - 100px);
backdrop-filter: blur(10px);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 50;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.modal-container.mobile {
top: auto;
bottom: 0;
left: 0;
right: 0;
width: 100%;
min-width: 0;
max-width: none;
height: 90vh;
border-radius: 16px 16px 0 0;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-content {
display: flex;
flex-direction: column;
height: 100%;
background: rgba(255, 255, 255, 0.6);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.header-info {
flex: 1;
}
.modal-title {
font-family: 'Washington', serif;
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.25rem 0;
color: hsl(var(--foreground));
}
.location-coords {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
margin: 0;
font-family: monospace;
}
.close-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: hsl(var(--muted-foreground));
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.close-button:hover {
background: hsl(var(--muted) / 0.5);
color: hsl(var(--foreground));
}
.modal-body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
height: 100%;
}
.empty-icon {
margin-bottom: 1.5rem;
color: hsl(var(--muted-foreground));
opacity: 0.4;
}
.empty-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.75rem 0;
color: hsl(var(--foreground));
}
.empty-message {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0;
line-height: 1.5;
}
.modal-footer {
padding: 1.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
:global(.w-full) {
width: 100%;
}
:global(.mr-2) {
margin-right: 0.5rem;
}
@media (max-width: 767px) {
.modal-header {
padding: 1rem;
}
.modal-title {
font-size: 1.25rem;
}
.modal-footer {
padding: 1rem;
}
}
</style>

View File

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

File diff suppressed because it is too large Load Diff

View File

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