File size: 9,101 Bytes
a04c5e9
 
 
 
 
 
2d5923d
 
 
 
a04c5e9
 
161c8f7
 
1730528
2d5923d
161c8f7
1730528
 
4331e77
 
f17bf16
4331e77
1d6db01
9f870c5
2d5923d
21b8785
 
 
 
 
f17bf16
161c8f7
21b8785
 
161c8f7
4331e77
 
1d6db01
 
 
 
4331e77
 
f0aa0e8
 
 
1730528
 
 
161c8f7
1730528
f17bf16
21b8785
161c8f7
1730528
 
f17bf16
1730528
 
 
 
21b8785
 
 
f0aa0e8
 
 
 
 
2d5923d
 
 
 
 
 
 
95e2b9b
2d5923d
 
 
 
 
 
 
 
 
 
 
 
 
 
3bf15b0
 
 
2d5923d
 
 
 
 
 
 
 
be83bb2
 
 
 
2d5923d
 
 
 
be83bb2
 
 
 
2d5923d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95e2b9b
 
2d5923d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95e2b9b
2d5923d
 
 
95e2b9b
 
 
2d5923d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95e2b9b
2d5923d
 
 
 
bb5b6b1
 
2d5923d
 
 
 
 
 
 
 
 
 
 
 
 
161c8f7
 
43fd2d2
4331e77
43fd2d2
161c8f7
 
aac3c77
f17bf16
161c8f7
4331e77
161c8f7
977b7da
053849e
043d48d
 
 
053849e
1730528
4331e77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9f870c5
 
 
 
 
 
 
 
 
4331e77
 
 
161c8f7
4f861c3
2d5923d
 
f0aa0e8
 
 
2d5923d
f0aa0e8
 
 
 
 
f17bf16
2d5923d
 
 
 
34f6218
4331e77
f17bf16
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
<script lang="ts" module>
	let isOpen = $state(false);

	export function closeMobileNav() {
		isOpen = false;
	}

	export function openMobileNav() {
		isOpen = true;
	}
</script>

<script lang="ts">
	import { browser } from "$app/environment";
	import { beforeNavigate } from "$app/navigation";
	import { onMount, onDestroy } from "svelte";
	import { base } from "$app/paths";
	import { page } from "$app/state";
	import IconNew from "$lib/components/icons/IconNew.svelte";
	import IconShare from "$lib/components/icons/IconShare.svelte";
	import IconBurger from "$lib/components/icons/IconBurger.svelte";
	import { Spring } from "svelte/motion";
	import { shareModal } from "$lib/stores/shareModal";
	import { loading } from "$lib/stores/loading";
	import { requireAuthUser } from "$lib/utils/auth";

	interface Props {
		title: string | undefined;
		children?: import("svelte").Snippet;
	}

	let { title = $bindable(), children }: Props = $props();

	let closeEl: HTMLButtonElement | undefined = $state();
	let openEl: HTMLButtonElement | undefined = $state();

	const isHuggingChat = $derived(Boolean(page.data?.publicConfig?.isHuggingChat));
	const canShare = $derived(
		isHuggingChat &&
			!$loading &&
			Boolean(page.params?.id) &&
			page.route.id?.startsWith("/conversation/")
	);

	// Define the width for the drawer (less than 100% to create the gap)
	const drawerWidthPercentage = 85;

	$effect(() => {
		title ??= "New Chat";
	});

	beforeNavigate(() => {
		isOpen = false;
	});

	let shouldFocusClose = $derived(isOpen && closeEl);
	let shouldRefocusOpen = $derived(!isOpen && browser && document.activeElement === closeEl);

	$effect(() => {
		if (shouldFocusClose) {
			closeEl?.focus();
		} else if (shouldRefocusOpen) {
			openEl?.focus();
		}
	});

	// Function to close the drawer when background is tapped
	function closeDrawer() {
		isOpen = false;
	}

	// Swipe gesture support for opening/closing the nav with live feedback
	// Thresholds from vaul drawer library
	const VELOCITY_THRESHOLD = 0.4; // px/ms - if exceeded, snap in swipe direction
	const DIRECTION_LOCK_THRESHOLD = 10; // px - movement needed to lock direction

	let touchstart: Touch | null = null;
	let lastTouchX: number | null = null;
	let dragStartTime: number = 0;
	let isDragging = $state(false);
	let dragOffset = $state(-100); // percentage: -100 (closed) to 0 (open)
	let dragStartedOpen = false;

	// Direction lock: null = undecided, 'horizontal' = drawer drag, 'vertical' = scroll
	let directionLock: "horizontal" | "vertical" | null = null;
	let potentialDrag = false;

	// Spring target: follows dragOffset during drag, follows isOpen after drag ends
	const springTarget = $derived(isDragging ? dragOffset : isOpen ? 0 : -100);
	const tween = Spring.of(() => springTarget, { stiffness: 0.2, damping: 0.8 });

	function onTouchStart(e: TouchEvent) {
		// Ignore touch events when a modal is open (app is inert)
		if (document.getElementById("app")?.hasAttribute("inert")) return;

		const touch = e.changedTouches[0];
		touchstart = touch;
		dragStartTime = Date.now();
		directionLock = null;

		const drawerWidth = window.innerWidth * (drawerWidthPercentage / 100);
		const touchOnDrawer = isOpen && touch.clientX < drawerWidth;

		// Check if touch is on an interactive element (don't block taps on buttons/links)
		const target = e.target as HTMLElement;
		const isInteractive = target.closest("button, a, input, [role='button']");

		// Potential drag scenarios - never start isDragging until direction is locked
		// Exception: overlay tap (no scroll content, so no direction conflict)
		if (!isOpen && touch.clientX < 40) {
			// Opening gesture - wait for direction lock before starting drag
			// Prevent Safari's back navigation gesture on iOS (but not on interactive elements)
			if (!isInteractive) {
				e.preventDefault();
			}
			potentialDrag = true;
			dragStartedOpen = false;
		} else if (isOpen && !touchOnDrawer) {
			// Touch on overlay - can start immediately (no scroll conflict)
			potentialDrag = true;
			isDragging = true;
			dragStartedOpen = true;
			dragOffset = 0;
			directionLock = "horizontal";
		} else if (isOpen && touchOnDrawer) {
			// Touch on drawer content - wait for direction lock
			potentialDrag = true;
			dragStartedOpen = true;
		}
	}

	function onTouchMove(e: TouchEvent) {
		if (!touchstart || !potentialDrag) return;

		const touch = e.changedTouches[0];
		const deltaX = touch.clientX - touchstart.clientX;
		const deltaY = touch.clientY - touchstart.clientY;

		// Determine direction lock if not yet decided
		if (directionLock === null) {
			const absX = Math.abs(deltaX);
			const absY = Math.abs(deltaY);

			if (absX > DIRECTION_LOCK_THRESHOLD || absY > DIRECTION_LOCK_THRESHOLD) {
				if (absX > absY) {
					// Horizontal movement - commit to drawer drag
					directionLock = "horizontal";
					isDragging = true;
					dragOffset = dragStartedOpen ? 0 : -100;
				} else {
					// Vertical movement - abort potential drag, let content scroll
					directionLock = "vertical";
					potentialDrag = false;
					return;
				}
			} else {
				return;
			}
		}

		if (directionLock !== "horizontal") return;

		const drawerWidth = window.innerWidth * (drawerWidthPercentage / 100);

		if (dragStartedOpen) {
			dragOffset = Math.max(-100, Math.min(0, (deltaX / drawerWidth) * 100));
		} else {
			dragOffset = Math.max(-100, Math.min(0, -100 + (deltaX / drawerWidth) * 100));
		}

		lastTouchX = touch.clientX;
	}

	function onTouchEnd(e: TouchEvent) {
		if (!potentialDrag) return;

		if (!isDragging || !touchstart) {
			resetDragState();
			return;
		}

		const touch = e.changedTouches[0];
		const timeTaken = Date.now() - dragStartTime;
		const distMoved = touch.clientX - touchstart.clientX;
		const velocity = Math.abs(distMoved) / timeTaken;

		// Determine snap direction based on velocity first, then final movement direction
		if (velocity > VELOCITY_THRESHOLD) {
			isOpen = distMoved > 0;
		} else {
			// For slow drags, use the final movement direction (allows "change of mind")
			const finalDirection = lastTouchX !== null ? touch.clientX - lastTouchX : distMoved;
			isOpen = finalDirection > 0;
		}

		resetDragState();
	}

	function onTouchCancel() {
		if (isDragging) {
			isOpen = dragStartedOpen;
		}
		resetDragState();
	}

	function resetDragState() {
		isDragging = false;
		potentialDrag = false;
		touchstart = null;
		lastTouchX = null;
		directionLock = null;
	}

	onMount(() => {
		// touchstart needs passive: false to allow preventDefault() for Safari back gesture
		window.addEventListener("touchstart", onTouchStart, { passive: false });
		window.addEventListener("touchmove", onTouchMove, { passive: true });
		window.addEventListener("touchend", onTouchEnd, { passive: true });
		window.addEventListener("touchcancel", onTouchCancel, { passive: true });
	});

	onDestroy(() => {
		if (browser) {
			window.removeEventListener("touchstart", onTouchStart);
			window.removeEventListener("touchmove", onTouchMove);
			window.removeEventListener("touchend", onTouchEnd);
			window.removeEventListener("touchcancel", onTouchCancel);
		}
	});
</script>

<nav
	class="flex h-12 items-center justify-between rounded-b-xl border-b bg-gray-50 px-3 dark:border-gray-800 dark:bg-gray-800/30 dark:shadow-xl md:hidden"
>
	<button
		type="button"
		class="-ml-3 flex size-12 shrink-0 items-center justify-center text-lg"
		onclick={() => (isOpen = true)}
		aria-label="Open menu"
		bind:this={openEl}><IconBurger /></button
	>
	<div class="flex h-full items-center justify-center overflow-hidden">
		{#if page.params?.id}
			<span class="max-w-full truncate px-4 first-letter:uppercase" data-testid="chat-title"
				>{title}</span
			>
		{/if}
	</div>
	<div class="flex items-center">
		{#if isHuggingChat}
			<button
				type="button"
				class="flex size-8 shrink-0 items-center justify-center text-lg"
				disabled={!canShare}
				onclick={() => {
					if (!canShare) return;
					shareModal.open();
				}}
				aria-label="Share conversation"
			>
				<IconShare classNames={!canShare ? "opacity-40" : ""} />
			</button>
		{/if}
		<a
			href="{base}/"
			class="flex size-8 shrink-0 items-center justify-center text-lg"
			onclick={(e) => {
				if (requireAuthUser()) {
					e.preventDefault();
				}
			}}
		>
			<IconNew />
		</a>
	</div>
</nav>

<!-- Mobile drawer overlay - shows when drawer is open or dragging -->
{#if isOpen || isDragging}
	<button
		type="button"
		class="fixed inset-0 z-20 cursor-default bg-black/30 md:hidden"
		style="opacity: {Math.max(0, Math.min(1, (100 + tween.current) / 100))}; will-change: opacity;"
		onclick={closeDrawer}
		aria-label="Close mobile navigation"
	></button>
{/if}

<nav
	style="transform: translateX({isDragging
		? dragOffset
		: tween.current}%); width: {drawerWidthPercentage}%; will-change: transform;"
	class:shadow-[5px_0_15px_0_rgba(0,0,0,0.3)]={isOpen || isDragging}
	class="fixed bottom-0 left-0 top-0 z-30 grid max-h-dvh grid-cols-1
	grid-rows-[auto,1fr,auto,auto] rounded-r-xl bg-white pt-4 dark:bg-gray-900 md:hidden"
>
	{@render children?.()}
</nav>