+2
apps/web/package.json
+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
+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
+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
+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
+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
+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
+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