openpencil/src/components/CollabPanel.vue
Danila Poyarkov 56aa194785 Toast notifications via Reka UI Toast
- AppToast component: top-center blue pill (info), red pill (errors)
- Global error handler catches window.error + unhandledrejection
- Link copied toast on share and copy link button
- use-toast composable: toast.show(message, variant)
2026-03-01 18:25:04 +03:00

277 lines
9.4 KiB
Vue

<script setup lang="ts">
import { ref, computed } from 'vue'
import {
PopoverRoot,
PopoverTrigger,
PopoverPortal,
PopoverContent,
TooltipRoot,
TooltipTrigger,
TooltipPortal,
TooltipContent,
TooltipProvider
} from 'reka-ui'
import type { CollabState, RemotePeer } from '@/composables/use-collab'
import { toast } from '@/composables/use-toast'
import type { Color } from '@/types'
const props = defineProps<{
state: CollabState
peers: RemotePeer[]
pendingRoomId?: string | null
followingPeer?: number | null
}>()
const emit = defineEmits<{
share: []
join: [roomId: string]
disconnect: []
'update:name': [name: string]
follow: [clientId: number | null]
}>()
const joinInput = ref('')
const nameDraft = ref(props.state.localName)
const copied = ref(false)
const popoverOpen = ref(!!props.pendingRoomId)
const shareUrl = computed(() => {
if (!props.state.roomId) return ''
return `${window.location.origin}/share/${props.state.roomId}`
})
const isJoining = computed(() => !!props.pendingRoomId && !props.state.connected)
function copyLink() {
if (!shareUrl.value) return
navigator.clipboard.writeText(shareUrl.value)
toast.show('Link copied to clipboard')
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
}
function onShare() {
if (!nameDraft.value.trim()) return
emit('update:name', nameDraft.value.trim())
emit('share')
popoverOpen.value = false
}
function onJoin() {
const roomId = props.pendingRoomId || joinInput.value.trim().replace(/.*\/share\//, '')
if (!roomId || !nameDraft.value.trim()) return
emit('update:name', nameDraft.value.trim())
emit('join', roomId)
popoverOpen.value = false
}
function colorToCSS(c: Color): string {
return `rgb(${Math.round(c.r * 255)}, ${Math.round(c.g * 255)}, ${Math.round(c.b * 255)})`
}
function initials(name: string): string {
return (
name
.split(' ')
.map((w) => w[0])
.join('')
.toUpperCase()
.slice(0, 2) || '?'
)
}
</script>
<template>
<div class="flex w-full items-center gap-2">
<!-- Avatar stack for connected peers -->
<TooltipProvider v-if="state.connected" :delay-duration="200">
<div class="flex -space-x-1.5">
<TooltipRoot>
<TooltipTrigger as-child>
<div
class="flex size-6 items-center justify-center rounded-full border-2 border-panel text-[10px] font-semibold text-white"
:style="{ background: colorToCSS(state.localColor) }"
>
{{ initials(state.localName || 'You') }}
</div>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent
class="rounded bg-neutral-800 px-2 py-1 text-xs text-white shadow-lg"
:side-offset="4"
>
{{ state.localName || 'You' }} (you)
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
<TooltipRoot v-for="peer in peers" :key="peer.clientId">
<TooltipTrigger as-child>
<div
class="flex size-6 cursor-pointer items-center justify-center rounded-full border-2 text-[10px] font-semibold text-white transition-all"
:class="
followingPeer === peer.clientId
? 'border-white ring-2 ring-white/40'
: 'border-panel'
"
:style="{ background: colorToCSS(peer.color) }"
@click="emit('follow', followingPeer === peer.clientId ? null : peer.clientId)"
>
{{ initials(peer.name) }}
</div>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent
class="rounded bg-neutral-800 px-2 py-1 text-xs text-white shadow-lg"
:side-offset="4"
>
{{
followingPeer === peer.clientId
? `Following ${peer.name} (click to stop)`
: `Click to follow ${peer.name}`
}}
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
</div>
</TooltipProvider>
<div class="flex-1" />
<!-- Share button / popover -->
<PopoverRoot v-model:open="popoverOpen">
<PopoverTrigger as-child>
<button
class="flex h-7 cursor-pointer items-center gap-1.5 rounded-md border-none px-3 text-xs font-medium transition-colors"
:class="
state.connected
? 'bg-green-600 text-white hover:bg-green-700'
: isJoining
? 'bg-amber-600 text-white animate-pulse'
: 'bg-accent text-white hover:bg-accent/90'
"
>
<icon-lucide-share-2 class="size-3.5" />
{{ state.connected ? 'Connected' : isJoining ? 'Join room' : 'Share' }}
</button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
class="z-50 w-72 rounded-lg border border-border bg-panel p-3 shadow-xl"
:side-offset="8"
side="bottom"
align="end"
>
<!-- Connected state -->
<template v-if="state.connected">
<div class="mb-3 text-xs font-medium text-surface">Room link</div>
<div class="mb-3 flex items-center gap-1.5">
<input
:value="shareUrl"
readonly
class="min-w-0 flex-1 rounded border border-border bg-input px-2 py-1 text-xs text-surface"
@focus="($event.target as HTMLInputElement).select()"
/>
<button
class="flex h-7 cursor-pointer items-center gap-1 rounded border-none bg-accent px-2 text-xs text-white hover:bg-accent/90"
@click="copyLink"
>
<icon-lucide-check v-if="copied" class="size-3" />
<icon-lucide-copy v-else class="size-3" />
{{ copied ? 'Copied' : 'Copy' }}
</button>
</div>
<div class="mb-2 text-xs font-medium text-surface">
{{ peers.length + 1 }} {{ peers.length === 0 ? 'person' : 'people' }} in this room
</div>
<button
class="flex h-7 w-full cursor-pointer items-center justify-center rounded border border-border bg-transparent text-xs text-muted hover:bg-hover hover:text-surface"
@click="emit('disconnect')"
>
Disconnect
</button>
</template>
<!-- Joining via /share/ link -->
<template v-else-if="isJoining">
<div class="mb-1 text-xs font-medium text-surface">Join collaboration</div>
<div class="mb-3 text-[11px] text-muted">
Someone shared this file with you. Enter your name to join.
</div>
<div class="mb-3">
<label class="mb-1 block text-xs text-muted">Your name</label>
<input
v-model="nameDraft"
class="w-full rounded border border-border bg-input px-2 py-1 text-xs text-surface"
placeholder="Enter your name"
autofocus
@keydown.enter="onJoin"
/>
</div>
<button
class="flex h-8 w-full cursor-pointer items-center justify-center gap-1.5 rounded border-none bg-accent text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50"
:disabled="!nameDraft.trim()"
@click="onJoin"
>
<icon-lucide-users class="size-3.5" />
Join room
</button>
</template>
<!-- Not connected: share or join -->
<template v-else>
<div class="mb-3">
<label class="mb-1 block text-xs text-muted">Your name</label>
<input
v-model="nameDraft"
class="w-full rounded border border-border bg-input px-2 py-1 text-xs text-surface"
placeholder="Enter your name"
@keydown.enter="onShare"
/>
</div>
<button
class="mb-3 flex h-8 w-full cursor-pointer items-center justify-center gap-1.5 rounded border-none bg-accent text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50"
:disabled="!nameDraft.trim()"
@click="onShare"
>
<icon-lucide-share-2 class="size-3.5" />
Share this file
</button>
<div class="mb-2 flex items-center gap-2">
<div class="h-px flex-1 bg-border" />
<span class="text-[11px] text-muted">or join a room</span>
<div class="h-px flex-1 bg-border" />
</div>
<div class="flex items-center gap-1.5">
<input
v-model="joinInput"
class="min-w-0 flex-1 rounded border border-border bg-input px-2 py-1 text-xs text-surface"
placeholder="Paste room link or ID"
@keydown.enter="onJoin"
/>
<button
class="flex h-7 cursor-pointer items-center rounded border-none bg-accent px-3 text-xs text-white hover:bg-accent/90 disabled:opacity-50"
:disabled="!joinInput.trim() || !nameDraft.trim()"
@click="onJoin"
>
Join
</button>
</div>
</template>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</div>
</template>