679 lines
19 KiB
Svelte
679 lines
19 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/state';
|
|
import { browser } from '$app/environment';
|
|
import { getContext, onMount } from 'svelte';
|
|
import { SvelteSet } from 'svelte/reactivity';
|
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
|
import {
|
|
usePollingQuery,
|
|
usePollingMutation,
|
|
usePollingClient
|
|
} from '$lib/convex-polling.svelte';
|
|
import { api } from '$lib/convex/_generated/api';
|
|
import type { Id } from '$lib/convex/_generated/dataModel';
|
|
import ChatMessage from '$lib/components/ChatMessage.svelte';
|
|
import ChatInput from '$lib/components/ChatInput.svelte';
|
|
import FollowUpButtons from '$lib/components/FollowUpButtons.svelte';
|
|
import StealthOverlay from '$lib/components/StealthOverlay.svelte';
|
|
import CameraCapture from '$lib/components/CameraCapture.svelte';
|
|
import WatchCountdown from '$lib/components/WatchCountdown.svelte';
|
|
import PhotoPreview from '$lib/components/PhotoPreview.svelte';
|
|
import DraftBadge from '$lib/components/DraftBadge.svelte';
|
|
import SilentCapture from '$lib/components/SilentCapture.svelte';
|
|
|
|
const usePolling = getContext<boolean>('convex-use-polling') ?? false;
|
|
let mnemonic = $derived(page.params.mnemonic);
|
|
|
|
let lastMessageElement: HTMLDivElement | null = $state(null);
|
|
let showScrollButton = $state(false);
|
|
|
|
let deviceId = $state('');
|
|
let hasCamera = $state(false);
|
|
let showCamera = $state(false);
|
|
let showWatchCountdown = $state(false);
|
|
let activeRequestId: Id<'photoRequests'> | null = $state(null);
|
|
let previewPhoto: {
|
|
thumbnail: string;
|
|
mediaType: string;
|
|
requestId: Id<'photoRequests'>;
|
|
} | null = $state(null);
|
|
let shownPreviewIds = new SvelteSet<string>();
|
|
let silentCaptureRef: SilentCapture | null = $state(null);
|
|
let processedCaptureNowIds = new SvelteSet<string>();
|
|
|
|
function generateId(): string {
|
|
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
return crypto.randomUUID();
|
|
}
|
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
const r = (Math.random() * 16) | 0;
|
|
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
return v.toString(16);
|
|
});
|
|
}
|
|
|
|
function getOrCreateDeviceId(): string {
|
|
if (!browser) return '';
|
|
let id = localStorage.getItem('stealth-device-id');
|
|
if (!id) {
|
|
id = generateId();
|
|
localStorage.setItem('stealth-device-id', id);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
async function checkCamera(): Promise<boolean> {
|
|
if (!browser) return false;
|
|
if (!navigator.mediaDevices?.enumerateDevices) return false;
|
|
try {
|
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
return devices.some((d) => d.kind === 'videoinput');
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
deviceId = getOrCreateDeviceId();
|
|
checkCamera().then((has) => {
|
|
hasCamera = has;
|
|
});
|
|
});
|
|
|
|
$effect(() => {
|
|
if (!lastMessageElement) return;
|
|
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
showScrollButton = !entry.isIntersecting;
|
|
},
|
|
{ threshold: 0, rootMargin: '0px 0px -90% 0px' }
|
|
);
|
|
|
|
observer.observe(lastMessageElement);
|
|
return () => observer.disconnect();
|
|
});
|
|
|
|
function scrollToLastMessage() {
|
|
lastMessageElement?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
|
|
const chatDataWs = usePolling
|
|
? null
|
|
: useQuery(api.chats.getWithUser, () => (mnemonic ? { mnemonic } : 'skip'));
|
|
const chatDataPoll = usePolling
|
|
? usePollingQuery(api.chats.getWithUser, () => (mnemonic ? { mnemonic } : 'skip'))
|
|
: null;
|
|
const chatData = $derived(usePolling ? chatDataPoll! : chatDataWs!);
|
|
|
|
const chatId = $derived(chatData.data?.chat?._id);
|
|
|
|
const messagesQueryWs = usePolling
|
|
? null
|
|
: useQuery(api.messages.listByChat, () => (chatId ? { chatId } : 'skip'));
|
|
const messagesQueryPoll = usePolling
|
|
? usePollingQuery(api.messages.listByChat, () => (chatId ? { chatId } : 'skip'))
|
|
: null;
|
|
const messagesQuery = $derived(usePolling ? messagesQueryPoll! : messagesQueryWs!);
|
|
|
|
let messages = $derived(messagesQuery.data ?? []);
|
|
let lastMessage = $derived(messages[messages.length - 1]);
|
|
let followUpOptions = $derived(
|
|
lastMessage?.role === 'assistant' && lastMessage.followUpOptions
|
|
? lastMessage.followUpOptions
|
|
: []
|
|
);
|
|
|
|
const myDeviceWs = usePolling
|
|
? null
|
|
: useQuery(api.devicePairings.getMyDevice, () =>
|
|
chatId && deviceId ? { chatId, deviceId } : 'skip'
|
|
);
|
|
const myDevicePoll = usePolling
|
|
? usePollingQuery(api.devicePairings.getMyDevice, () =>
|
|
chatId && deviceId ? { chatId, deviceId } : 'skip'
|
|
)
|
|
: null;
|
|
const myDevice = $derived(usePolling ? myDevicePoll! : myDeviceWs!);
|
|
|
|
const pairedDeviceWs = usePolling
|
|
? null
|
|
: useQuery(api.devicePairings.getPairedDevice, () =>
|
|
chatId && deviceId ? { chatId, deviceId } : 'skip'
|
|
);
|
|
const pairedDevicePoll = usePolling
|
|
? usePollingQuery(api.devicePairings.getPairedDevice, () =>
|
|
chatId && deviceId ? { chatId, deviceId } : 'skip'
|
|
)
|
|
: null;
|
|
const pairedDevice = $derived(usePolling ? pairedDevicePoll! : pairedDeviceWs!);
|
|
|
|
const isPaired = $derived(!!myDevice.data?.pairedWithDeviceId && !!pairedDevice.data);
|
|
|
|
const pendingPairingWs = usePolling
|
|
? null
|
|
: useQuery(api.pairingRequests.getPending, () =>
|
|
chatId && deviceId ? { chatId, excludeDeviceId: deviceId } : 'skip'
|
|
);
|
|
const pendingPairingPoll = usePolling
|
|
? usePollingQuery(api.pairingRequests.getPending, () =>
|
|
chatId && deviceId ? { chatId, excludeDeviceId: deviceId } : 'skip'
|
|
)
|
|
: null;
|
|
const pendingPairing = $derived(usePolling ? pendingPairingPoll! : pendingPairingWs!);
|
|
|
|
const captureNowRequestWs = usePolling
|
|
? null
|
|
: useQuery(api.photoRequests.getCaptureNowRequest, () => (chatId ? { chatId } : 'skip'));
|
|
const captureNowRequestPoll = usePolling
|
|
? usePollingQuery(api.photoRequests.getCaptureNowRequest, () => (chatId ? { chatId } : 'skip'))
|
|
: null;
|
|
const captureNowRequest = $derived(usePolling ? captureNowRequestPoll! : captureNowRequestWs!);
|
|
|
|
const myActiveRequestWs = usePolling
|
|
? null
|
|
: useQuery(api.photoRequests.getMyActiveRequest, () =>
|
|
chatId && deviceId ? { chatId, deviceId } : 'skip'
|
|
);
|
|
const myActiveRequestPoll = usePolling
|
|
? usePollingQuery(api.photoRequests.getMyActiveRequest, () =>
|
|
chatId && deviceId ? { chatId, deviceId } : 'skip'
|
|
)
|
|
: null;
|
|
const myActiveRequest = $derived(usePolling ? myActiveRequestPoll! : myActiveRequestWs!);
|
|
|
|
const photoDraftWs = usePolling
|
|
? null
|
|
: useQuery(api.photoDrafts.get, () => (chatId && deviceId ? { chatId, deviceId } : 'skip'));
|
|
const photoDraftPoll = usePolling
|
|
? usePollingQuery(api.photoDrafts.get, () =>
|
|
chatId && deviceId ? { chatId, deviceId } : 'skip'
|
|
)
|
|
: null;
|
|
const photoDraft = $derived(usePolling ? photoDraftPoll! : photoDraftWs!);
|
|
const draftPhotos = $derived(photoDraft.data?.photos ?? []);
|
|
|
|
$effect(() => {
|
|
const req = captureNowRequest.data;
|
|
if (req && hasCamera && !processedCaptureNowIds.has(req._id)) {
|
|
processedCaptureNowIds.add(req._id);
|
|
const tryCapture = () => {
|
|
const success = silentCaptureRef?.capture();
|
|
if (!success) {
|
|
setTimeout(tryCapture, 100);
|
|
}
|
|
};
|
|
tryCapture();
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
const req = myActiveRequest.data;
|
|
if (req?.status === 'captured' && req.photoMediaType) {
|
|
if (shownPreviewIds.has(req._id)) return;
|
|
shownPreviewIds.add(req._id);
|
|
|
|
const client = pollingClient ?? clientWs;
|
|
if (client) {
|
|
client.query(api.photoRequests.getPhotoPreview, { requestId: req._id }).then((data) => {
|
|
if (data) {
|
|
previewPhoto = {
|
|
thumbnail: data.thumbnailBase64,
|
|
mediaType: data.photoMediaType,
|
|
requestId: req._id
|
|
};
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
let prevMessageCount = 0;
|
|
let prevLastMessageId: string | undefined;
|
|
|
|
$effect(() => {
|
|
const count = messages.length;
|
|
const lastId = lastMessage?._id;
|
|
if (count > prevMessageCount || (lastId && lastId !== prevLastMessageId)) {
|
|
prevMessageCount = count;
|
|
prevLastMessageId = lastId;
|
|
window.scrollTo(0, document.body.scrollHeight);
|
|
}
|
|
});
|
|
|
|
const clientWs = usePolling ? null : useConvexClient();
|
|
const pollingClient = usePolling ? usePollingClient() : null;
|
|
const createMessagePoll = usePolling ? usePollingMutation(api.messages.create) : null;
|
|
const registerDevicePoll = usePolling ? usePollingMutation(api.devicePairings.register) : null;
|
|
const heartbeatPoll = usePolling ? usePollingMutation(api.devicePairings.heartbeat) : null;
|
|
const addPhotoPoll = usePolling ? usePollingMutation(api.photoDrafts.addPhoto) : null;
|
|
const removePhotoPoll = usePolling ? usePollingMutation(api.photoDrafts.removePhoto) : null;
|
|
const createPairingPoll = usePolling ? usePollingMutation(api.pairingRequests.create) : null;
|
|
const acceptPairingPoll = usePolling ? usePollingMutation(api.pairingRequests.accept) : null;
|
|
const rejectPairingPoll = usePolling ? usePollingMutation(api.pairingRequests.reject) : null;
|
|
const unpairPoll = usePolling ? usePollingMutation(api.pairingRequests.unpair) : null;
|
|
const createRequestPoll = usePolling ? usePollingMutation(api.photoRequests.create) : null;
|
|
const markCaptureNowPoll = usePolling
|
|
? usePollingMutation(api.photoRequests.markCaptureNow)
|
|
: null;
|
|
const submitPhotoPoll = usePolling ? usePollingMutation(api.photoRequests.submitPhoto) : null;
|
|
const markRejectedPoll = usePolling ? usePollingMutation(api.photoRequests.markRejected) : null;
|
|
const acceptPhotoToDraftPoll = usePolling
|
|
? usePollingMutation(api.photoRequests.acceptPhotoToDraft)
|
|
: null;
|
|
|
|
$effect(() => {
|
|
if (!chatId || !deviceId) return;
|
|
|
|
if (usePolling && registerDevicePoll) {
|
|
registerDevicePoll({ chatId, deviceId, hasCamera });
|
|
} else if (clientWs) {
|
|
clientWs.mutation(api.devicePairings.register, {
|
|
chatId,
|
|
deviceId,
|
|
hasCamera
|
|
});
|
|
}
|
|
|
|
const interval = setInterval(() => {
|
|
if (usePolling && heartbeatPoll) {
|
|
heartbeatPoll({ chatId, deviceId });
|
|
} else if (clientWs) {
|
|
clientWs.mutation(api.devicePairings.heartbeat, { chatId, deviceId });
|
|
}
|
|
}, 10000);
|
|
|
|
return () => clearInterval(interval);
|
|
});
|
|
|
|
function sendMessage(content: string) {
|
|
const chat = chatData.data?.chat;
|
|
if (!chat) return;
|
|
|
|
const photos = draftPhotos;
|
|
const photoDraftIds = photos.length > 0 ? photos.map((p) => p._id) : undefined;
|
|
|
|
const messageContent =
|
|
content || (photos.length > 0 ? 'Process images according to your task' : '');
|
|
if (!messageContent) return;
|
|
|
|
if (usePolling && createMessagePoll) {
|
|
createMessagePoll({
|
|
chatId: chat._id,
|
|
role: 'user',
|
|
content: messageContent,
|
|
source: 'web',
|
|
photoDraftIds
|
|
});
|
|
} else if (clientWs) {
|
|
clientWs.mutation(api.messages.create, {
|
|
chatId: chat._id,
|
|
role: 'user',
|
|
content: messageContent,
|
|
source: 'web',
|
|
photoDraftIds
|
|
});
|
|
}
|
|
}
|
|
|
|
function summarize() {
|
|
const chat = chatData.data?.chat;
|
|
if (!chat) return;
|
|
|
|
if (usePolling && createMessagePoll) {
|
|
createMessagePoll({
|
|
chatId: chat._id,
|
|
role: 'user',
|
|
content: '/summarize',
|
|
source: 'web'
|
|
});
|
|
} else if (clientWs) {
|
|
clientWs.mutation(api.messages.create, {
|
|
chatId: chat._id,
|
|
role: 'user',
|
|
content: '/summarize',
|
|
source: 'web'
|
|
});
|
|
}
|
|
}
|
|
|
|
function handleTakePhoto() {
|
|
showCamera = true;
|
|
}
|
|
|
|
function handleCameraCapture(base64: string, mediaType: string) {
|
|
showCamera = false;
|
|
if (!chatId) return;
|
|
if (usePolling && addPhotoPoll) {
|
|
addPhotoPoll({ chatId, deviceId, photo: { base64, mediaType } });
|
|
} else if (clientWs) {
|
|
clientWs.mutation(api.photoDrafts.addPhoto, {
|
|
chatId,
|
|
deviceId,
|
|
photo: { base64, mediaType }
|
|
});
|
|
}
|
|
}
|
|
|
|
function handleCameraClose() {
|
|
showCamera = false;
|
|
}
|
|
|
|
function handlePair() {
|
|
if (!chatId) return;
|
|
if (usePolling && createPairingPoll) {
|
|
createPairingPoll({ chatId, fromDeviceId: deviceId });
|
|
} else if (clientWs) {
|
|
clientWs.mutation(api.pairingRequests.create, {
|
|
chatId,
|
|
fromDeviceId: deviceId
|
|
});
|
|
}
|
|
}
|
|
|
|
function handleAcceptPairing() {
|
|
const req = pendingPairing.data;
|
|
if (!req) return;
|
|
if (usePolling && acceptPairingPoll) {
|
|
acceptPairingPoll({ requestId: req._id, acceptingDeviceId: deviceId });
|
|
} else if (clientWs) {
|
|
clientWs.mutation(api.pairingRequests.accept, {
|
|
requestId: req._id,
|
|
acceptingDeviceId: deviceId
|
|
});
|
|
}
|
|
}
|
|
|
|
function handleRejectPairing() {
|
|
const req = pendingPairing.data;
|
|
if (!req) return;
|
|
if (usePolling && rejectPairingPoll) {
|
|
rejectPairingPoll({ requestId: req._id });
|
|
} else if (clientWs) {
|
|
clientWs.mutation(api.pairingRequests.reject, { requestId: req._id });
|
|
}
|
|
}
|
|
|
|
function handleUnpair() {
|
|
if (!chatId) return;
|
|
if (usePolling && unpairPoll) {
|
|
unpairPoll({ chatId, deviceId });
|
|
} else if (clientWs) {
|
|
clientWs.mutation(api.pairingRequests.unpair, {
|
|
chatId,
|
|
deviceId
|
|
});
|
|
}
|
|
}
|
|
|
|
function handleRequestPhoto() {
|
|
if (!chatId || !pairedDevice.data) return;
|
|
const captureDeviceId = pairedDevice.data.deviceId;
|
|
|
|
if (usePolling && createRequestPoll) {
|
|
createRequestPoll({ chatId, requesterId: deviceId, captureDeviceId }).then((id) => {
|
|
if (id) {
|
|
activeRequestId = id as Id<'photoRequests'>;
|
|
showWatchCountdown = true;
|
|
}
|
|
});
|
|
} else if (clientWs) {
|
|
clientWs
|
|
.mutation(api.photoRequests.create, {
|
|
chatId,
|
|
requesterId: deviceId,
|
|
captureDeviceId
|
|
})
|
|
.then((id) => {
|
|
activeRequestId = id;
|
|
showWatchCountdown = true;
|
|
});
|
|
}
|
|
}
|
|
|
|
function handleWatchCountdownComplete() {
|
|
showWatchCountdown = false;
|
|
if (!activeRequestId) return;
|
|
const reqId = activeRequestId;
|
|
activeRequestId = null;
|
|
|
|
if (usePolling && markCaptureNowPoll) {
|
|
markCaptureNowPoll({ requestId: reqId });
|
|
} else if (clientWs) {
|
|
clientWs.mutation(api.photoRequests.markCaptureNow, { requestId: reqId });
|
|
}
|
|
}
|
|
|
|
function handleWatchCountdownCancel() {
|
|
showWatchCountdown = false;
|
|
if (activeRequestId && markRejectedPoll) {
|
|
if (usePolling) {
|
|
markRejectedPoll({ requestId: activeRequestId });
|
|
} else if (clientWs) {
|
|
clientWs.mutation(api.photoRequests.markRejected, { requestId: activeRequestId });
|
|
}
|
|
}
|
|
activeRequestId = null;
|
|
}
|
|
|
|
function handleSilentCapture(base64: string, mediaType: string, thumbnailBase64: string) {
|
|
const req = captureNowRequest.data;
|
|
if (!req) return;
|
|
|
|
if (usePolling && submitPhotoPoll) {
|
|
submitPhotoPoll({
|
|
requestId: req._id,
|
|
photoBase64: base64,
|
|
photoMediaType: mediaType,
|
|
thumbnailBase64
|
|
});
|
|
} else if (clientWs) {
|
|
clientWs.mutation(api.photoRequests.submitPhoto, {
|
|
requestId: req._id,
|
|
photoBase64: base64,
|
|
photoMediaType: mediaType,
|
|
thumbnailBase64
|
|
});
|
|
}
|
|
}
|
|
|
|
function handlePreviewAccept() {
|
|
if (!previewPhoto || !chatId) return;
|
|
|
|
const reqId = previewPhoto.requestId;
|
|
previewPhoto = null;
|
|
|
|
if (usePolling && acceptPhotoToDraftPoll) {
|
|
acceptPhotoToDraftPoll({ requestId: reqId, chatId, deviceId });
|
|
} else if (clientWs) {
|
|
clientWs.mutation(api.photoRequests.acceptPhotoToDraft, {
|
|
requestId: reqId,
|
|
chatId,
|
|
deviceId
|
|
});
|
|
}
|
|
}
|
|
|
|
function handlePreviewReject() {
|
|
if (!previewPhoto) return;
|
|
|
|
const reqId = previewPhoto.requestId;
|
|
previewPhoto = null;
|
|
|
|
if (usePolling && markRejectedPoll) {
|
|
markRejectedPoll({ requestId: reqId });
|
|
} else if (clientWs) {
|
|
clientWs.mutation(api.photoRequests.markRejected, {
|
|
requestId: reqId
|
|
});
|
|
}
|
|
}
|
|
|
|
function handleRemoveDraftPhoto(index: number) {
|
|
if (!chatId) return;
|
|
if (usePolling && removePhotoPoll) {
|
|
removePhotoPoll({ chatId, deviceId, index });
|
|
} else if (clientWs) {
|
|
clientWs.mutation(api.photoDrafts.removePhoto, {
|
|
chatId,
|
|
deviceId,
|
|
index
|
|
});
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Chat</title>
|
|
<meta
|
|
name="viewport"
|
|
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
|
/>
|
|
</svelte:head>
|
|
|
|
<div class="min-h-dvh bg-black p-1.5 text-white">
|
|
{#if chatData.isLoading}
|
|
<div class="py-4 text-center text-xs text-neutral-500">Loading...</div>
|
|
{:else if chatData.error}
|
|
<div class="py-4 text-center text-red-500">
|
|
<div class="text-xs">Error</div>
|
|
<div class="text-[8px] break-all">{chatData.error.message}</div>
|
|
</div>
|
|
{:else if !chatData.data}
|
|
<div class="py-4 text-center text-xs text-neutral-500">Not found</div>
|
|
{:else}
|
|
<div class="space-y-1">
|
|
{#each messages as message, i (message._id)}
|
|
{#if i === messages.length - 1}
|
|
<div bind:this={lastMessageElement}>
|
|
<ChatMessage
|
|
role={message.role}
|
|
content={message.content}
|
|
isStreaming={message.isStreaming}
|
|
/>
|
|
</div>
|
|
{:else}
|
|
<ChatMessage
|
|
role={message.role}
|
|
content={message.content}
|
|
isStreaming={message.isStreaming}
|
|
/>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
|
|
{#if followUpOptions.length > 0}
|
|
<div class="mt-2">
|
|
<FollowUpButtons options={followUpOptions} onselect={sendMessage} />
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="mt-3 space-y-2">
|
|
<div class="flex gap-2">
|
|
{#if hasCamera}
|
|
<button
|
|
onclick={handleTakePhoto}
|
|
class="flex-1 rounded bg-neutral-800 py-2 text-xs text-neutral-300"
|
|
>
|
|
+ photo
|
|
</button>
|
|
{/if}
|
|
{#if isPaired && pairedDevice.data?.hasCamera}
|
|
<button
|
|
onclick={handleRequestPhoto}
|
|
class="flex-1 rounded bg-neutral-800 py-2 text-xs text-neutral-300"
|
|
>
|
|
request
|
|
</button>
|
|
{/if}
|
|
{#if isPaired}
|
|
<button
|
|
onclick={handleUnpair}
|
|
class="flex-1 rounded bg-red-900/50 py-2 text-xs text-red-300"
|
|
>
|
|
unpair
|
|
</button>
|
|
{:else}
|
|
<button
|
|
onclick={handlePair}
|
|
class="flex-1 rounded bg-neutral-800 py-2 text-xs text-neutral-300"
|
|
>
|
|
pair
|
|
</button>
|
|
{/if}
|
|
<button
|
|
onclick={summarize}
|
|
class="flex-1 rounded bg-neutral-800 py-2 text-xs text-neutral-300"
|
|
>
|
|
/sum
|
|
</button>
|
|
</div>
|
|
{#if draftPhotos.length > 0}
|
|
<DraftBadge photos={draftPhotos} onremove={handleRemoveDraftPhoto} />
|
|
{/if}
|
|
<ChatInput onsubmit={sendMessage} allowEmpty={draftPhotos.length > 0} />
|
|
</div>
|
|
{/if}
|
|
|
|
{#if showScrollButton}
|
|
<button
|
|
onclick={scrollToLastMessage}
|
|
class="fixed right-3 bottom-12 z-50 flex h-8 w-8 animate-pulse items-center justify-center rounded-full bg-blue-600 text-white shadow-lg"
|
|
>
|
|
↓
|
|
</button>
|
|
{/if}
|
|
|
|
<StealthOverlay />
|
|
|
|
{#if showCamera}
|
|
<CameraCapture oncapture={handleCameraCapture} onclose={handleCameraClose} />
|
|
{/if}
|
|
|
|
{#if pendingPairing.data && !isPaired}
|
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/90" data-camera-ui>
|
|
<div class="rounded-lg bg-neutral-900 p-6 text-center">
|
|
<p class="mb-4 text-sm text-white">Accept pairing request?</p>
|
|
<div class="flex gap-3">
|
|
<button
|
|
onclick={handleAcceptPairing}
|
|
class="flex-1 rounded bg-blue-600 py-2 text-sm text-white"
|
|
>
|
|
Accept
|
|
</button>
|
|
<button
|
|
onclick={handleRejectPairing}
|
|
class="flex-1 rounded bg-neutral-700 py-2 text-sm text-white"
|
|
>
|
|
Reject
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if showWatchCountdown}
|
|
<WatchCountdown
|
|
oncomplete={handleWatchCountdownComplete}
|
|
oncancel={handleWatchCountdownCancel}
|
|
/>
|
|
{/if}
|
|
|
|
{#if previewPhoto}
|
|
<PhotoPreview
|
|
base64={previewPhoto.thumbnail}
|
|
mediaType={previewPhoto.mediaType}
|
|
onaccept={handlePreviewAccept}
|
|
onreject={handlePreviewReject}
|
|
/>
|
|
{/if}
|
|
|
|
{#if hasCamera && isPaired}
|
|
<SilentCapture
|
|
bind:this={silentCaptureRef}
|
|
oncapture={handleSilentCapture}
|
|
onunpair={handleUnpair}
|
|
/>
|
|
{/if}
|
|
</div>
|