atmo.rsvp
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #28 from 01100100/yolo-merger

Clean up map component and improve attibution styling

authored by

Florian and committed by
GitHub
4ee99f77 7bb8bc08

+137 -156
+22 -1
src/app.css
··· 9 9 @import '@foxui/core/theme.css'; 10 10 11 11 @source "../node_modules/@foxui"; 12 - 12 + 13 + .maplibregl-ctrl-bottom-left > .maplibregl-ctrl.maplibregl-ctrl-attrib { 14 + font: 7px / 10px monospace; 15 + border-top-right-radius: 0.5rem; 16 + border: none; 17 + box-shadow: none; 18 + min-height: 1rem; 19 + background-color: rgba(0, 0, 0, 0.45); 20 + } 21 + 22 + .maplibregl-ctrl-attrib-button { 23 + display: none; 24 + } 25 + 26 + .maplibregl-ctrl.maplibregl-ctrl-attrib .maplibregl-ctrl-attrib-inner { 27 + color: rgba(255, 255, 255, 0.85); 28 + line-height: 1rem; 29 + } 30 + 31 + .maplibregl-ctrl.maplibregl-ctrl-attrib .maplibregl-ctrl-attrib-inner a { 32 + color: inherit; 33 + }
+2 -2
src/lib/components/EventView.svelte
··· 25 25 import EventLinksList from './event-view/EventLinksList.svelte'; 26 26 import AddToCalendarButton from './event-view/AddToCalendarButton.svelte'; 27 27 import InviteShareFlow from './event-view/InviteShareFlow.svelte'; 28 - import { buildDescriptionHtml, getLocationData, resolveGeoLocation } from './event-view/format'; 28 + import { buildDescriptionHtml, getLocationData, resolveGeoLocation, type GeoLocation } from './event-view/format'; 29 29 30 30 let { data } = $props(); 31 31 ··· 50 50 let endDate = $derived(eventData.endsAt ? new Date(eventData.endsAt) : null); 51 51 52 52 let locationData = $derived(getLocationData(eventData.locations)); 53 - let geoLocation: { lat: number; lng: number } | null = $state(null); 53 + let geoLocation: GeoLocation | null = $state(null); 54 54 55 55 let showShareModal = $state(false); 56 56 let shareModalTitle = $state('Event created!');
+19 -120
src/lib/components/Map.svelte
··· 1 1 <script lang="ts"> 2 - import { MapLibre, Projection, Marker } from 'svelte-maplibre-gl'; 3 - import type maplibregl from 'maplibre-gl'; 2 + import { MapLibre, Projection, Marker, AttributionControl } from 'svelte-maplibre-gl'; 3 + import maplibregl from 'maplibre-gl'; 4 4 5 - let { 6 - lat, 7 - lng, 8 - zoom = 14 9 - }: { 10 - lat: number; 11 - lng: number; 12 - zoom?: number; 13 - } = $props(); 5 + let { lat, lng, zoom = 11 }: { lat: number; lng: number; zoom?: number } = $props(); 14 6 15 - let center = $state({ lng, lat }); 16 - let showAttribution = $state(false); 17 7 let map: maplibregl.Map | undefined = $state(); 18 - 19 - const fixedCenter = { lng, lat }; 20 - 21 - function handleZoom() { 22 - if (map) { 23 - map.setCenter(fixedCenter); 24 - } 25 - } 26 - 27 - $effect(() => { 28 - if (map) { 29 - map.getCanvas().style.touchAction = 'pan-x pan-y'; 30 - } 31 - }); 32 8 </script> 33 9 34 - <div 35 - class="relative isolate h-full w-full overflow-hidden rounded-xl" 36 - onfocusin={(e) => { 37 - if (e.target instanceof HTMLElement) e.target.blur(); 38 - }} 10 + <MapLibre 11 + bind:map 12 + class="h-full w-full overflow-hidden rounded-xl" 13 + style="https://tiles.openfreemap.org/styles/liberty" 14 + {zoom} 15 + center={[lng, lat]} 16 + attributionControl={false} 39 17 > 40 - <MapLibre 41 - bind:map 42 - class="h-full w-full" 43 - style="https://tiles.openfreemap.org/styles/liberty" 44 - {zoom} 45 - {center} 46 - attributionControl={false} 47 - dragPan={false} 48 - dragRotate={false} 49 - keyboard={false} 50 - touchZoomRotate={true} 51 - scrollZoom={true} 52 - boxZoom={false} 53 - pitchWithRotate={false} 54 - touchPitch={false} 55 - onzoom={handleZoom} 56 - > 57 - <Projection type="globe" /> 58 - 59 - <Marker bind:lnglat={center}> 60 - {#snippet content()} 61 - <div class="from-accent-400 size-10 rounded-full bg-radial via-transparent p-3"> 62 - <div class="bg-accent-500 size-4 rounded-full ring-2 ring-white"></div> 63 - </div> 64 - {/snippet} 65 - </Marker> 66 - </MapLibre> 67 - 68 - {#snippet infoIcon()} 69 - <svg 70 - xmlns="http://www.w3.org/2000/svg" 71 - width="24" 72 - height="24" 73 - fill-rule="evenodd" 74 - viewBox="0 0 20 20" 75 - > 76 - <path 77 - d="M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0" 78 - /> 79 - </svg> 80 - {/snippet} 81 - 82 - {#if showAttribution} 83 - <div 84 - class="absolute right-2.5 bottom-2.5 z-10 rounded-xl bg-white text-black" 85 - style="width: calc(100% - 20px); max-width: 12rem;" 86 - > 87 - <button 88 - class="float-right flex size-6 cursor-pointer items-center justify-center rounded-full shadow-[0_0_6px_rgba(59,130,246,0.6)]" 89 - onclick={() => (showAttribution = false)} 90 - aria-label="Toggle attribution" 91 - > 92 - {@render infoIcon()} 93 - </button> 94 - <div class="p-2 text-left text-xs leading-snug text-black/75"> 95 - <a 96 - href="https://openfreemap.org" 97 - target="_blank" 98 - rel="noopener noreferrer" 99 - class="text-black/75 no-underline hover:underline" 100 - onclick={(e) => e.stopPropagation()}>OpenFreeMap</a 101 - > 102 - <a 103 - href="https://openmaptiles.org" 104 - target="_blank" 105 - rel="noopener noreferrer" 106 - class="text-black/75 no-underline hover:underline" 107 - onclick={(e) => e.stopPropagation()}>© OpenMapTiles</a 108 - > 109 - Data from 110 - <a 111 - href="https://www.openstreetmap.org/copyright" 112 - target="_blank" 113 - rel="noopener noreferrer" 114 - class="text-black/75 no-underline hover:underline" 115 - onclick={(e) => e.stopPropagation()}>OpenStreetMap</a 116 - > 18 + <AttributionControl position="bottom-left" compact={false} /> 19 + <Projection type="globe" /> 20 + <Marker lnglat={[lng, lat]}> 21 + {#snippet content()} 22 + <div class="from-accent-400 size-10 rounded-full bg-radial via-transparent p-3"> 23 + <div class="bg-accent-500 size-4 rounded-full ring-2 ring-white"></div> 117 24 </div> 118 - </div> 119 - {:else} 120 - <button 121 - class="absolute right-2.5 bottom-2.5 z-10 flex size-6 items-center justify-center rounded-full bg-white text-black" 122 - onclick={() => (showAttribution = true)} 123 - aria-label="Toggle attribution" 124 - > 125 - {@render infoIcon()} 126 - </button> 127 - {/if} 128 - </div> 25 + {/snippet} 26 + </Marker> 27 + </MapLibre>
+1 -1
src/lib/components/event-view/EventLocationBlock.svelte
··· 6 6 7 7 {#if locationData} 8 8 <a 9 - href={locationData.mapsUrl} 9 + href={locationData.googleMapsUrl} 10 10 target="_blank" 11 11 rel="noopener noreferrer" 12 12 class="mb-6 flex items-center gap-4 transition-opacity hover:opacity-80"
+56 -19
src/lib/components/event-view/EventLocationMap.svelte
··· 1 1 <script lang="ts"> 2 + import { Badge } from '@foxui/core'; 2 3 import Map from '$lib/components/Map.svelte'; 3 - import type { LocationData } from './format'; 4 + import type { LocationData, GeoLocation } from './format'; 4 5 5 6 let { 6 7 locationData, 7 8 geoLocation 8 9 }: { 9 10 locationData: LocationData | null; 10 - geoLocation: { lat: number; lng: number } | null; 11 + geoLocation: GeoLocation | null; 11 12 } = $props(); 13 + 14 + let copied = $state(false); 15 + 16 + async function copyCoords() { 17 + if (!geoLocation) return; 18 + const text = `${geoLocation.lat.toFixed(5)}, ${geoLocation.lng.toFixed(5)}`; 19 + try { 20 + await navigator.clipboard.writeText(text); 21 + copied = true; 22 + setTimeout(() => (copied = false), 2000); 23 + } catch {} 24 + } 12 25 </script> 13 26 14 27 {#if geoLocation && locationData} 15 28 <div class="mt-8 mb-8"> 16 - <p 17 - class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 18 - > 19 - Location 29 + <div class="mb-3 flex items-baseline gap-2"> 30 + <p class="text-base-500 dark:text-base-400 text-xs font-semibold tracking-wider uppercase"> 31 + Location: 32 + </p> 33 + <button 34 + type="button" 35 + onclick={copyCoords} 36 + class="ml-auto cursor-pointer transition-opacity active:opacity-60" 37 + title="Copy coordinates" 38 + aria-label="Copy coordinates" 39 + > 40 + <Badge size="sm" variant="secondary" class="font-mono"> 41 + {copied 42 + ? 'Copied!' 43 + : `${geoLocation.lat.toFixed(5)}, ${geoLocation.lng.toFixed(5)}`} 44 + </Badge> 45 + </button> 46 + </div> 47 + <div class="h-64 w-full overflow-hidden rounded-xl"> 48 + <Map lat={geoLocation.lat} lng={geoLocation.lng} /> 49 + </div> 50 + <p class="text-base-700 dark:text-base-200 mt-3 text-sm">{locationData.fullString}</p> 51 + <p class="text-base-500 dark:text-base-400 mt-1 text-xs"> 52 + Open in 53 + <a 54 + href={geoLocation.googleMapsUrl} 55 + target="_blank" 56 + rel="noopener noreferrer" 57 + class="text-base-700 dark:text-base-300 hover:underline" 58 + > 59 + Google Maps 60 + </a> 61 + | 62 + <a 63 + href={geoLocation.osmUrl} 64 + target="_blank" 65 + rel="noopener noreferrer" 66 + class="text-base-700 dark:text-base-300 hover:underline" 67 + > 68 + OpenStreetMap 69 + </a> 20 70 </p> 21 - <a 22 - href={locationData.mapsUrl} 23 - target="_blank" 24 - rel="noopener noreferrer" 25 - class="block transition-opacity hover:opacity-80" 26 - > 27 - <div class="h-64 w-full overflow-hidden rounded-xl"> 28 - <Map lat={geoLocation.lat} lng={geoLocation.lng} /> 29 - </div> 30 - <p class="text-base-500 dark:text-base-400 mt-2 text-sm"> 31 - {locationData.fullString} 32 - </p> 33 - </a> 34 71 </div> 35 72 {/if}
+37 -13
src/lib/components/event-view/format.ts
··· 45 45 shortAddress: string; 46 46 fullAddress: string; 47 47 fullString: string; 48 - mapsUrl: string; 48 + googleMapsUrl: string; 49 49 }; 50 50 51 51 export function getLocationData(locations: FlatEventRecord['locations']): LocationData | null { 52 - if (!locations || locations.length === 0) return null; 52 + if (!locations?.length) return null; 53 53 54 54 const loc = locations.find((v) => v.$type === 'community.lexicon.location.address') as 55 55 | { name?: string; street?: string; locality?: string; region?: string; country?: string } ··· 64 64 const fullAddress = fullParts.join(', '); 65 65 const displayName = loc.name || undefined; 66 66 const fullString = displayName ? `${displayName}, ${fullAddress}` : fullAddress; 67 - const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(fullString)}`; 67 + const googleMapsUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(fullString)}`; 68 + 69 + return { name: displayName, shortAddress, fullAddress, fullString, googleMapsUrl }; 70 + } 71 + 72 + export type GeoLocation = { 73 + lat: number; 74 + lng: number; 75 + googleMapsUrl: string; 76 + osmUrl: string; 77 + }; 68 78 69 - return { name: displayName, shortAddress, fullAddress, fullString, mapsUrl }; 79 + function geoUrls(lat: number, lng: number, osmType?: string, osmId?: number) { 80 + return { 81 + googleMapsUrl: `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`, 82 + osmUrl: 83 + osmType && osmId 84 + ? `https://www.openstreetmap.org/${osmType}/${osmId}` 85 + : `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=17/${lat}/${lng}` 86 + }; 70 87 } 71 88 72 89 export async function resolveGeoLocation( 73 90 locations: FlatEventRecord['locations'], 74 91 locationData: LocationData | null 75 - ): Promise<{ lat: number; lng: number } | null> { 76 - if (!locations || locations.length === 0) return null; 92 + ): Promise<GeoLocation | null> { 93 + if (!locations?.length) return null; 77 94 78 95 const geo = locations.find((v) => v.$type === 'community.lexicon.location.geo') as 79 96 | { latitude?: string; longitude?: string } ··· 81 98 if (geo?.latitude && geo?.longitude) { 82 99 const lat = parseFloat(geo.latitude); 83 100 const lng = parseFloat(geo.longitude); 84 - if (!isNaN(lat) && !isNaN(lng)) return { lat, lng }; 101 + if (!isNaN(lat) && !isNaN(lng)) return { lat, lng, ...geoUrls(lat, lng) }; 85 102 } 86 103 87 - const addressQuery = locationData?.fullAddress; 88 - if (!addressQuery) return null; 104 + if (!locationData?.fullAddress) return null; 89 105 90 106 try { 91 - const r = await fetch(`/api/geocoding?q=${encodeURIComponent(addressQuery)}`); 107 + const r = await fetch(`/api/geocoding?q=${encodeURIComponent(locationData.fullAddress)}`); 92 108 if (!r.ok) return null; 93 - const data = (await r.json()) as Record<string, unknown> | null; 94 - if (!data || !data.lat || !data.lon) return null; 95 - return { lat: parseFloat(data.lat as string), lng: parseFloat(data.lon as string) }; 109 + const data = (await r.json()) as { 110 + lat?: string; 111 + lon?: string; 112 + osm_type?: string; 113 + osm_id?: number; 114 + } | null; 115 + if (!data?.lat || !data?.lon) return null; 116 + const lat = parseFloat(data.lat); 117 + const lng = parseFloat(data.lon); 118 + if (isNaN(lat) || isNaN(lng)) return null; 119 + return { lat, lng, ...geoUrls(lat, lng, data.osm_type, data.osm_id) }; 96 120 } catch { 97 121 return null; 98 122 }