Keep track of ICE and police locations in your city. Very much a work-in-progress and not ready yet, stay tuned I guess?

show media on report sheet

aria.yuri.observer 0117a71c 16c91aa9

verified
Changed files
+411 -158
apps
+2
apps/web/package.json
··· 64 64 "dependencies": { 65 65 "@fontsource/roboto": "^5.2.8", 66 66 "@lucide/svelte": "^0.544.0", 67 + "date-fns": "^4.1.0", 67 68 "framework7-icons": "^5.0.5", 68 69 "geojson": "^0.5.0", 69 70 "konsta": "^4.0.0-next.1", 71 + "maplibre-gl": "^5.9.0", 70 72 "svelte-bottom-sheet": "^2.2.2", 71 73 "svelte-maplibre-gl": "^1.0.1" 72 74 }
+112 -107
apps/web/src/app.css
··· 1 - @import "tailwindcss"; 1 + @import 'tailwindcss'; 2 2 3 - @import "tw-animate-css"; 3 + @import 'tw-animate-css'; 4 4 5 5 @custom-variant dark (&:is(.dark *)); 6 6 7 7 :root { 8 - --radius: 0.625rem; 9 - --background: oklch(1 0 0); 10 - --foreground: oklch(0.147 0.004 49.25); 11 - --card: oklch(1 0 0); 12 - --card-foreground: oklch(0.147 0.004 49.25); 13 - --popover: oklch(1 0 0); 14 - --popover-foreground: oklch(0.147 0.004 49.25); 15 - --primary: oklch(0.216 0.006 56.043); 16 - --primary-foreground: oklch(0.985 0.001 106.423); 17 - --secondary: oklch(0.97 0.001 106.424); 18 - --secondary-foreground: oklch(0.216 0.006 56.043); 19 - --muted: oklch(0.97 0.001 106.424); 20 - --muted-foreground: oklch(0.553 0.013 58.071); 21 - --accent: oklch(0.97 0.001 106.424); 22 - --accent-foreground: oklch(0.216 0.006 56.043); 23 - --destructive: oklch(0.577 0.245 27.325); 24 - --border: oklch(0.923 0.003 48.717); 25 - --input: oklch(0.923 0.003 48.717); 26 - --ring: oklch(0.709 0.01 56.259); 27 - --chart-1: oklch(0.646 0.222 41.116); 28 - --chart-2: oklch(0.6 0.118 184.704); 29 - --chart-3: oklch(0.398 0.07 227.392); 30 - --chart-4: oklch(0.828 0.189 84.429); 31 - --chart-5: oklch(0.769 0.188 70.08); 32 - --sidebar: oklch(0.985 0.001 106.423); 33 - --sidebar-foreground: oklch(0.147 0.004 49.25); 34 - --sidebar-primary: oklch(0.216 0.006 56.043); 35 - --sidebar-primary-foreground: oklch(0.985 0.001 106.423); 36 - --sidebar-accent: oklch(0.97 0.001 106.424); 37 - --sidebar-accent-foreground: oklch(0.216 0.006 56.043); 38 - --sidebar-border: oklch(0.923 0.003 48.717); 39 - --sidebar-ring: oklch(0.709 0.01 56.259); 8 + --radius: 0.65rem; 9 + --background: oklch(1 0 0); 10 + --foreground: oklch(0.141 0.005 285.823); 11 + --card: oklch(1 0 0); 12 + --card-foreground: oklch(0.141 0.005 285.823); 13 + --popover: oklch(1 0 0); 14 + --popover-foreground: oklch(0.141 0.005 285.823); 15 + --primary: oklch(0.606 0.25 292.717); 16 + --primary-foreground: oklch(0.969 0.016 293.756); 17 + --secondary: oklch(0.967 0.001 286.375); 18 + --secondary-foreground: oklch(0.21 0.006 285.885); 19 + --muted: oklch(0.967 0.001 286.375); 20 + --muted-foreground: oklch(0.552 0.016 285.938); 21 + --accent: oklch(0.967 0.001 286.375); 22 + --accent-foreground: oklch(0.21 0.006 285.885); 23 + --destructive: oklch(0.577 0.245 27.325); 24 + --border: oklch(0.92 0.004 286.32); 25 + --input: oklch(0.92 0.004 286.32); 26 + --ring: oklch(0.606 0.25 292.717); 27 + --chart-1: oklch(0.646 0.222 41.116); 28 + --chart-2: oklch(0.6 0.118 184.704); 29 + --chart-3: oklch(0.398 0.07 227.392); 30 + --chart-4: oklch(0.828 0.189 84.429); 31 + --chart-5: oklch(0.769 0.188 70.08); 32 + --sidebar: oklch(0.985 0 0); 33 + --sidebar-foreground: oklch(0.141 0.005 285.823); 34 + --sidebar-primary: oklch(0.606 0.25 292.717); 35 + --sidebar-primary-foreground: oklch(0.969 0.016 293.756); 36 + --sidebar-accent: oklch(0.967 0.001 286.375); 37 + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); 38 + --sidebar-border: oklch(0.92 0.004 286.32); 39 + --sidebar-ring: oklch(0.606 0.25 292.717); 40 40 } 41 41 42 42 .dark { 43 - --background: oklch(0.147 0.004 49.25); 44 - --foreground: oklch(0.985 0.001 106.423); 45 - --card: oklch(0.216 0.006 56.043); 46 - --card-foreground: oklch(0.985 0.001 106.423); 47 - --popover: oklch(0.216 0.006 56.043); 48 - --popover-foreground: oklch(0.985 0.001 106.423); 49 - --primary: oklch(0.923 0.003 48.717); 50 - --primary-foreground: oklch(0.216 0.006 56.043); 51 - --secondary: oklch(0.268 0.007 34.298); 52 - --secondary-foreground: oklch(0.985 0.001 106.423); 53 - --muted: oklch(0.268 0.007 34.298); 54 - --muted-foreground: oklch(0.709 0.01 56.259); 55 - --accent: oklch(0.268 0.007 34.298); 56 - --accent-foreground: oklch(0.985 0.001 106.423); 57 - --destructive: oklch(0.704 0.191 22.216); 58 - --border: oklch(1 0 0 / 10%); 59 - --input: oklch(1 0 0 / 15%); 60 - --ring: oklch(0.553 0.013 58.071); 61 - --chart-1: oklch(0.488 0.243 264.376); 62 - --chart-2: oklch(0.696 0.17 162.48); 63 - --chart-3: oklch(0.769 0.188 70.08); 64 - --chart-4: oklch(0.627 0.265 303.9); 65 - --chart-5: oklch(0.645 0.246 16.439); 66 - --sidebar: oklch(0.216 0.006 56.043); 67 - --sidebar-foreground: oklch(0.985 0.001 106.423); 68 - --sidebar-primary: oklch(0.488 0.243 264.376); 69 - --sidebar-primary-foreground: oklch(0.985 0.001 106.423); 70 - --sidebar-accent: oklch(0.268 0.007 34.298); 71 - --sidebar-accent-foreground: oklch(0.985 0.001 106.423); 72 - --sidebar-border: oklch(1 0 0 / 10%); 73 - --sidebar-ring: oklch(0.553 0.013 58.071); 43 + --background: oklch(0.141 0.005 285.823); 44 + --foreground: oklch(0.985 0 0); 45 + --card: oklch(0.21 0.006 285.885); 46 + --card-foreground: oklch(0.985 0 0); 47 + --popover: oklch(0.21 0.006 285.885); 48 + --popover-foreground: oklch(0.985 0 0); 49 + --primary: oklch(0.541 0.281 293.009); 50 + --primary-foreground: oklch(0.969 0.016 293.756); 51 + --secondary: oklch(0.274 0.006 286.033); 52 + --secondary-foreground: oklch(0.985 0 0); 53 + --muted: oklch(0.274 0.006 286.033); 54 + --muted-foreground: oklch(0.705 0.015 286.067); 55 + --accent: oklch(0.274 0.006 286.033); 56 + --accent-foreground: oklch(0.985 0 0); 57 + --destructive: oklch(0.704 0.191 22.216); 58 + --border: oklch(1 0 0 / 10%); 59 + --input: oklch(1 0 0 / 15%); 60 + --ring: oklch(0.541 0.281 293.009); 61 + --chart-1: oklch(0.488 0.243 264.376); 62 + --chart-2: oklch(0.696 0.17 162.48); 63 + --chart-3: oklch(0.769 0.188 70.08); 64 + --chart-4: oklch(0.627 0.265 303.9); 65 + --chart-5: oklch(0.645 0.246 16.439); 66 + --sidebar: oklch(0.21 0.006 285.885); 67 + --sidebar-foreground: oklch(0.985 0 0); 68 + --sidebar-primary: oklch(0.541 0.281 293.009); 69 + --sidebar-primary-foreground: oklch(0.969 0.016 293.756); 70 + --sidebar-accent: oklch(0.274 0.006 286.033); 71 + --sidebar-accent-foreground: oklch(0.985 0 0); 72 + --sidebar-border: oklch(1 0 0 / 10%); 73 + --sidebar-ring: oklch(0.541 0.281 293.009); 74 74 } 75 75 76 76 @theme inline { 77 - --radius-sm: calc(var(--radius) - 4px); 78 - --radius-md: calc(var(--radius) - 2px); 79 - --radius-lg: var(--radius); 80 - --radius-xl: calc(var(--radius) + 4px); 81 - --color-background: var(--background); 82 - --color-foreground: var(--foreground); 83 - --color-card: var(--card); 84 - --color-card-foreground: var(--card-foreground); 85 - --color-popover: var(--popover); 86 - --color-popover-foreground: var(--popover-foreground); 87 - --color-primary: var(--primary); 88 - --color-primary-foreground: var(--primary-foreground); 89 - --color-secondary: var(--secondary); 90 - --color-secondary-foreground: var(--secondary-foreground); 91 - --color-muted: var(--muted); 92 - --color-muted-foreground: var(--muted-foreground); 93 - --color-accent: var(--accent); 94 - --color-accent-foreground: var(--accent-foreground); 95 - --color-destructive: var(--destructive); 96 - --color-border: var(--border); 97 - --color-input: var(--input); 98 - --color-ring: var(--ring); 99 - --color-chart-1: var(--chart-1); 100 - --color-chart-2: var(--chart-2); 101 - --color-chart-3: var(--chart-3); 102 - --color-chart-4: var(--chart-4); 103 - --color-chart-5: var(--chart-5); 104 - --color-sidebar: var(--sidebar); 105 - --color-sidebar-foreground: var(--sidebar-foreground); 106 - --color-sidebar-primary: var(--sidebar-primary); 107 - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 108 - --color-sidebar-accent: var(--sidebar-accent); 109 - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 110 - --color-sidebar-border: var(--sidebar-border); 111 - --color-sidebar-ring: var(--sidebar-ring); 77 + --radius-sm: calc(var(--radius) - 4px); 78 + --radius-md: calc(var(--radius) - 2px); 79 + --radius-lg: var(--radius); 80 + --radius-xl: calc(var(--radius) + 4px); 81 + --color-background: var(--background); 82 + --color-foreground: var(--foreground); 83 + --color-card: var(--card); 84 + --color-card-foreground: var(--card-foreground); 85 + --color-popover: var(--popover); 86 + --color-popover-foreground: var(--popover-foreground); 87 + --color-primary: var(--primary); 88 + --color-primary-foreground: var(--primary-foreground); 89 + --color-secondary: var(--secondary); 90 + --color-secondary-foreground: var(--secondary-foreground); 91 + --color-muted: var(--muted); 92 + --color-muted-foreground: var(--muted-foreground); 93 + --color-accent: var(--accent); 94 + --color-accent-foreground: var(--accent-foreground); 95 + --color-destructive: var(--destructive); 96 + --color-border: var(--border); 97 + --color-input: var(--input); 98 + --color-ring: var(--ring); 99 + --color-chart-1: var(--chart-1); 100 + --color-chart-2: var(--chart-2); 101 + --color-chart-3: var(--chart-3); 102 + --color-chart-4: var(--chart-4); 103 + --color-chart-5: var(--chart-5); 104 + --color-sidebar: var(--sidebar); 105 + --color-sidebar-foreground: var(--sidebar-foreground); 106 + --color-sidebar-primary: var(--sidebar-primary); 107 + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 108 + --color-sidebar-accent: var(--sidebar-accent); 109 + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 110 + --color-sidebar-border: var(--sidebar-border); 111 + --color-sidebar-ring: var(--sidebar-ring); 112 112 } 113 113 114 114 @layer base { 115 - * { 116 - @apply border-border outline-ring/50; 117 - } 118 - body { 119 - @apply bg-background text-foreground; 120 - } 121 - } 115 + * { 116 + @apply border-border outline-ring/50; 117 + } 118 + body { 119 + @apply bg-background text-foreground; 120 + } 121 + 122 + .maplibregl-ctrl { 123 + @apply rounded-md! border-border! bg-accent! text-accent-foreground!; 124 + /* background-color: red !important; */ 125 + } 126 + }
+35 -15
apps/web/src/lib/components/info-sheet.svelte
··· 1 1 <script module lang="ts"> 2 + import { IsMobile } from '$lib/hooks/is-mobile.svelte'; 3 + import { cn } from '$lib/utils/cn'; 2 4 import type { ComponentProps, Snippet } from 'svelte'; 3 5 4 - export type Props = ComponentProps<typeof BottomSheet> & { 6 + export type Props = ComponentProps<typeof Drawer.Root> & { 5 7 isSheetOpen: boolean; 6 8 children: Snippet; 9 + class?: string; 10 + title?: string; 7 11 }; 8 12 </script> 9 13 10 14 <script lang="ts"> 11 - import { BottomSheet, type BottomSheetSettings } from 'svelte-bottom-sheet'; 15 + import * as Drawer from '$lib/components/ui/drawer'; 16 + import { XIcon } from '@lucide/svelte'; 17 + import Button from './ui/button/button.svelte'; 18 + 19 + let { 20 + isSheetOpen = $bindable(false), 21 + children, 22 + class: className = '', 23 + title = '', 24 + ...props 25 + }: Props = $props(); 12 26 13 - let { isSheetOpen = $bindable(false), children, settings = {}, ...props }: Props = $props(); 27 + const isMobile = new IsMobile(); 14 28 </script> 15 29 16 - <BottomSheet 17 - settings={{ maxHeight: 1, snapPoints: [0.333, 0.666, 1], startingSnapPoint: 0.333, ...settings }} 18 - bind:isSheetOpen 30 + <Drawer.Root 31 + dismissible={isMobile.current} 32 + bind:open={isSheetOpen} 33 + direction={isMobile.current ? 'bottom' : 'right'} 19 34 {...props} 20 35 > 21 - <BottomSheet.Overlay> 22 - <BottomSheet.Sheet class=" !bg-card !text-card-foreground"> 23 - <BottomSheet.Handle class="!bg-transparent" /> 24 - <BottomSheet.Content> 25 - {@render children?.()} 26 - </BottomSheet.Content> 27 - </BottomSheet.Sheet> 28 - </BottomSheet.Overlay> 29 - </BottomSheet> 36 + <Drawer.Content class={cn('', className)}> 37 + {#if !isMobile.current} 38 + <Drawer.Header class="flex flex-row"> 39 + <Drawer.Title class="flex-1 ">{title}</Drawer.Title> 40 + <Button variant="ghost" size="icon" onclick={() => (isSheetOpen = false)}> 41 + <XIcon /> 42 + </Button> 43 + </Drawer.Header> 44 + {/if} 45 + <div class="w-full p-4"> 46 + {@render children?.()} 47 + </div> 48 + </Drawer.Content> 49 + </Drawer.Root>
+68 -21
apps/web/src/lib/components/pig-map.svelte
··· 1 1 <script module lang="ts"> 2 + export type SelectedPoint = { 3 + report: NonNullable<GetReportsType>[number]; 4 + context: NonNullable<GetReportsType>[number]['context'][number]; 5 + }; 2 6 export type Props = { 3 7 reports: GetReportsType; 4 - selectedContext?: NonNullable<GetReportsType>[number]['context'][number] | null; 8 + selectedPoint?: SelectedPoint | null; 9 + zoom?: number; 10 + zoomSteps?: number; 5 11 }; 6 12 </script> 7 13 8 14 <script lang="ts"> 9 15 import ModeSwitcher from '$lib/components/mode-switcher.svelte'; 10 - import { PlusIcon } from '@lucide/svelte'; 16 + 17 + import { PlusIcon, MinusIcon } from '@lucide/svelte'; 11 18 import { mode } from 'mode-watcher'; 19 + import { type Map } from 'maplibre-gl'; 12 20 import { 13 21 MapLibre, 14 22 NavigationControl, ··· 17 25 Marker, 18 26 Popup, 19 27 CustomControl, 28 + GeolocateControl, 20 29 } from 'svelte-maplibre-gl'; 21 30 import { type GetReportsType } from '$lib/api/reports/reports.remote'; 22 31 import { onMount } from 'svelte'; 23 32 import { getCenterpointFromCoords, getCurrentPosition } from '$lib/utils'; 33 + import { Button } from '$lib/components/ui/button'; 24 34 25 - let { reports, selectedContext = $bindable() }: Props = $props(); 35 + let { 36 + reports, 37 + selectedPoint = $bindable(), 38 + zoom = $bindable(16), 39 + zoomSteps = 5, 40 + }: Props = $props(); 26 41 27 42 const baseMapStyles = { 28 43 voyager: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json', // default light ··· 34 49 mode.current === 'dark' ? 'dark-matter' : 'voyager' 35 50 ); 36 51 37 - // let selectedContext: NonNullable<GetReportsType>[0]['context'][0] | null = $state(null); 38 - let allReports: GetReportsType = $state([]); 52 + let map: Map | undefined = $state(); 39 53 40 54 let userLocation: [number, number] = $state([0, 0]); 41 - 42 - let updateReports = (reports: GetReportsType) => { 43 - allReports = reports; 44 - }; 45 55 46 56 const getLocation = async () => { 47 57 const currentPosition = await getCurrentPosition(); ··· 73 83 }); 74 84 </script> 75 85 86 + <!-- <M.div> --> 87 + 76 88 <MapLibre 77 89 class="h-full min-h-screen w-full min-w-screen" 78 90 style={baseMapStyles[currentStyle]} 79 - zoom={16} 91 + bind:zoom 92 + bind:map 80 93 center={{ 81 94 lng: userLocation[0], 82 95 lat: userLocation[1], ··· 96 109 {/snippet} 97 110 98 111 <Popup 99 - class="text-black" 100 - open={selectedContext?.id === context.id} 112 + class="sr-only text-black lg:not-sr-only" 113 + open={selectedPoint?.context.id === context.id} 101 114 onopen={() => { 102 - selectedContext = context; 103 - }} 104 - onclose={() => { 105 - selectedContext = null; 115 + selectedPoint = { context: { ...context }, report: { ...report } }; 106 116 }} 107 117 > 108 118 <span class="text-lg">{context.text}</span> ··· 110 120 </Marker> 111 121 {/each} 112 122 {/each} 113 - <NavigationControl /> 114 - <ScaleControl /> 115 - <GlobeControl /> 116 - <CustomControl position="top-left" class="text-gray-900"> 117 - <ModeSwitcher class="flex! items-center justify-center border-none! text-gray-900!" /> 123 + <!-- <NavigationControl /> --> 124 + <!-- <ScaleControl /> --> 125 + <!-- <GlobeControl /> --> 126 + <GeolocateControl 127 + position="top-left" 128 + positionOptions={{ enableHighAccuracy: true }} 129 + trackUserLocation={true} 130 + showAccuracyCircle={true} 131 + ontrackuserlocationstart={() => console.log('trackuserlocationstart')} 132 + ontrackuserlocationend={() => console.log('trackuserlocationend')} 133 + ongeolocate={(ev) => console.log(`geolocate ${JSON.stringify(ev.coords, null, 2)}`)} 134 + /> 135 + <CustomControl position="top-left" class=""> 136 + <ModeSwitcher class="flex! items-center justify-center" /> 137 + </CustomControl> 138 + 139 + <CustomControl 140 + position="top-right" 141 + class="bg-none! [&>*]:flex! [&>*]:items-center [&>*]:justify-center " 142 + > 143 + <!-- <ModeSwitcher class="flex! items-center justify-center" /> --> 144 + <Button 145 + size="icon" 146 + class="rounded-b-none border! border-border!" 147 + onclick={() => 148 + map?.zoomIn({ 149 + animate: true, 150 + })} 151 + > 152 + <PlusIcon class="h-4 w-4 " /> 153 + </Button> 154 + <Button 155 + size="icon" 156 + class="rounded-t-none !border border-border!" 157 + onclick={() => 158 + map?.zoomOut({ 159 + animate: true, 160 + })} 161 + > 162 + <MinusIcon class="h-4 w-4 " /> 163 + </Button> 118 164 </CustomControl> 119 165 </MapLibre> 166 + <!-- </M.div> -->
+9 -9
apps/web/src/lib/components/ui/drawer/drawer-content.svelte
··· 1 1 <script lang="ts"> 2 - import { Drawer as DrawerPrimitive } from "vaul-svelte"; 3 - import DrawerOverlay from "./drawer-overlay.svelte"; 4 - import { cn } from "$lib/utils/cn.js"; 2 + import { Drawer as DrawerPrimitive } from 'vaul-svelte'; 3 + import DrawerOverlay from './drawer-overlay.svelte'; 4 + import { cn } from '$lib/utils/cn.js'; 5 5 6 6 let { 7 7 ref = $bindable(null), ··· 20 20 bind:ref 21 21 data-slot="drawer-content" 22 22 class={cn( 23 - "group/drawer-content bg-background fixed z-50 flex h-auto flex-col", 24 - "data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b", 25 - "data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t", 26 - "data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm", 27 - "data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm", 23 + 'group/drawer-content fixed z-50 flex h-auto flex-col bg-background', 24 + 'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b', 25 + 'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[90vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t', 26 + 'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-1/2', 27 + 'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm', 28 28 className 29 29 )} 30 30 {...restProps} 31 31 > 32 32 <div 33 - class="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" 33 + class="mx-auto mt-4 mb-4 hidden h-2 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" 34 34 ></div> 35 35 {@render children?.()} 36 36 </DrawerPrimitive.Content>
+181 -6
apps/web/src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { getReports, type GetReportsType } from '../lib/api/reports/reports.remote'; 2 + import { untrack } from 'svelte'; 3 + import { format } from 'date-fns'; 4 + import { Clock, Calendar, ChevronDown, ChevronRight } from '@lucide/svelte'; 5 + 6 + import Button from '$lib/components/ui/button/button.svelte'; 7 + import * as Carousel from '$lib/components/ui/carousel'; 8 + import * as Card from '$lib/components/ui/card'; 9 + // import * as Collapsible from '$lib/components/ui/collapsible'; 10 + 3 11 import InfoSheet from '$lib/components/info-sheet.svelte'; 4 - import PigMap from '$lib/components/pig-map.svelte'; 12 + import PigMap, { type SelectedPoint } from '$lib/components/pig-map.svelte'; 13 + import { getReports } from '$lib/api/reports/reports.remote'; 14 + import { cn } from '$lib/utils/cn'; 15 + 16 + let selectedPoint: SelectedPoint | null = $state(null); 17 + 18 + // let activeCollapsed: boolean = $state(false); 19 + 20 + const formatTimestamp = (timestamp: string): { date: string; time: string } => { 21 + console.log({ timestamp }); 22 + const date = format(new Date(parseInt(timestamp)), 'MMM d, yyyy'); 23 + const time = format(new Date(parseInt(timestamp)), 'h:mm a'); 24 + return { date, time }; 25 + }; 26 + 27 + const getRootContext = () => { 28 + if (!selectedPoint) return null; 29 + const contexts = selectedPoint.report.context; 30 + 31 + const rootContextIndex = contexts.findIndex((c) => c.type === null); 32 + return contexts[rootContextIndex]; 33 + }; 34 + const sortContext = (contexts: SelectedPoint['context'][]): SelectedPoint['context'][] => { 35 + // extract type "null" context first, that's the "root" 36 + const rootContextIndex = contexts.findIndex((c) => c.type === null || c.type === 'ROOT'); 37 + 38 + // sort the rest by date 39 + const nonRootContexts = contexts 40 + .filter((c) => c.type !== null) 41 + .sort((a, b) => { 42 + return ( 43 + new Date(parseInt(b.createdAt)).getTime() - new Date(parseInt(a.createdAt)).getTime() 44 + ); 45 + }); 5 46 6 - let selectedContext: NonNullable<GetReportsType>[number]['context'][number] | null = $state(null); 47 + return [contexts[rootContextIndex], ...nonRootContexts]; 48 + }; 49 + 50 + let isSheetOpen = $state(false); 51 + 52 + $effect(() => { 53 + selectedPoint; 54 + untrack(() => { 55 + isSheetOpen = !!selectedPoint?.context; 56 + // activeCollapsed = false; 57 + }); 58 + }); 7 59 </script> 8 60 9 61 {#await getReports() then reports} 10 - <PigMap {reports} bind:selectedContext /> 62 + <PigMap {reports} bind:selectedPoint zoom={8} /> 11 63 {/await} 12 64 13 - <InfoSheet isSheetOpen={!!selectedContext} onclose={() => (selectedContext = null)}> 14 - <pre>{JSON.stringify(selectedContext, null, 2)}</pre> 65 + <InfoSheet 66 + class="space-y-2" 67 + bind:isSheetOpen 68 + onOpenChange={(open) => { 69 + console.log({ open }); 70 + }} 71 + onClose={() => { 72 + selectedPoint = null; 73 + }} 74 + title={getRootContext()?.text ?? undefined} 75 + > 76 + <!-- title={selectedPoint?.report.context[0].text ?? 'No context selected'} --> 77 + {#if selectedPoint} 78 + {@const rootContext = getRootContext()!} 79 + <div class="@container space-y-2"> 80 + {#each sortContext(selectedPoint.report.context) as context} 81 + {@const isRoot = context.id === rootContext.id} 82 + {@const isSelected = context.id === selectedPoint.context.id} 83 + {@const isCollapsed = !isSelected} 84 + <div 85 + class={cn('animate-in space-y-2 rounded-md border p-2 shadow-md transition-all', { 86 + 'bg-card text-card-foreground': isCollapsed, 87 + 'bg-background text-foreground': !isCollapsed, 88 + })} 89 + > 90 + <Button 91 + disabled={isRoot && isSelected} 92 + variant={isCollapsed ? 'secondary' : 'ghost'} 93 + class="flex h-auto w-full items-center justify-between gap-2 rounded-sm border p-2 align-middle! text-xs tracking-tight text-muted-foreground " 94 + onclick={() => { 95 + if (!selectedPoint) return; 96 + if (isRoot && selectedPoint.context.id === rootContext.id) return; // don't collapse if root already 97 + if (!isRoot && selectedPoint.context.id === context.id) 98 + return (selectedPoint.context = rootContext); // if not root and already selected, collapse and switch to root 99 + selectedPoint.context = context; 100 + }} 101 + > 102 + <span 103 + class="flex w-full items-center justify-between gap-2 rounded-sm text-xs [&_*]:items-center [&_*]:align-middle [&>*]:flex [&>*]:items-center [&>*]:gap-1" 104 + > 105 + <span class="text-right"> 106 + <Calendar class="size-3 " /> 107 + {formatTimestamp(context.createdAt).date} 108 + </span> 109 + <span class="[grid-area:timestamp]"> 110 + <Clock class="size-3" /> 111 + {formatTimestamp(context.createdAt).time} 112 + </span> 113 + </span> 114 + 115 + {#if !isCollapsed} 116 + <ChevronDown class="size-6 [grid-area:chevron]" /> 117 + {:else} 118 + <ChevronRight class="size-6 [grid-area:chevron]" /> 119 + {/if} 120 + </Button> 121 + 122 + <div 123 + class={cn( 124 + 'overflow-hidden border border-transparent p-2 text-xs transition-transform duration-200', 125 + { 126 + 'rounded-md border-border bg-muted/50 shadow-sm ': isCollapsed, 127 + } 128 + )} 129 + > 130 + <span 131 + class={cn('h-full w-full', { 132 + 'line-clamp-2': isCollapsed, 133 + 'line-clamp-none': !isCollapsed, 134 + })} 135 + > 136 + {context?.text} Lorem ipsum dolor sit amet, consectetur adipisicing elit. Itaque fugit 137 + ratione ab facere culpa ad inventore aperiam enim iste laudantium maxime repudiandae porro 138 + commodi nostrum doloremque, veniam quo, ea repellat! 139 + </span> 140 + </div> 141 + 142 + {#if context?.media.length >= 1} 143 + <Carousel.Root 144 + opts={{ 145 + align: 'start', 146 + }} 147 + class="w-full" 148 + > 149 + <Carousel.Content> 150 + {#each context?.media as media} 151 + {#if media.type === 'IMAGE'} 152 + <Carousel.Item 153 + class={cn('aspect-square transition-all duration-150', { 154 + 'basis-1/3': !isCollapsed, 155 + 'basis-1/5': isCollapsed, 156 + })} 157 + > 158 + <Button variant="ghost" class="h-full w-full cursor-pointer p-0"> 159 + <img 160 + draggable={false} 161 + src={media.url} 162 + alt={media.altText} 163 + class="h-full w-full overflow-hidden rounded-md border object-cover transition-all duration-150" 164 + /> 165 + </Button> 166 + </Carousel.Item> 167 + {:else if media.type === 'VIDEO'} 168 + <Carousel.Item 169 + class={cn(' transition-all duration-150', { 170 + 'basis-1/3': !isCollapsed, 171 + 'basis-1/5': isCollapsed, 172 + })} 173 + > 174 + <video 175 + src={media.url} 176 + class=" h-full w-full rounded-md border object-contain" 177 + > 178 + <track kind="captions" src={media.altText} srclang="en" label="English" /> 179 + </video> 180 + </Carousel.Item> 181 + {/if} 182 + {/each} 183 + </Carousel.Content> 184 + </Carousel.Root> 185 + {/if} 186 + </div> 187 + {/each} 188 + </div> 189 + {/if} 15 190 </InfoSheet>
+4
bun.lock
··· 13 13 "dependencies": { 14 14 "@fontsource/roboto": "^5.2.8", 15 15 "@lucide/svelte": "^0.544.0", 16 + "date-fns": "^4.1.0", 16 17 "framework7-icons": "^5.0.5", 17 18 "geojson": "^0.5.0", 18 19 "konsta": "^4.0.0-next.1", 20 + "maplibre-gl": "^5.9.0", 19 21 "svelte-bottom-sheet": "^2.2.2", 20 22 "svelte-maplibre-gl": "^1.0.1", 21 23 }, ··· 515 517 "d3-tricontour": ["d3-tricontour@1.1.0", "", { "dependencies": { "d3-delaunay": "6", "d3-scale": "4" } }, "sha512-G7gHKj89n2owmkGb6WX6ixcnQ0Kf/0wpa9VIh9DGdbHu8wdrlaHU4ir3/bFNERl8N8nn4G7e7qbtBG8N9caihQ=="], 516 518 517 519 "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], 520 + 521 + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], 518 522 519 523 "dayjs": ["dayjs@1.11.18", "", {}, "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA=="], 520 524