|
|
|
@@ -1,27 +1,28 @@
|
|
|
|
|
import { Camera, CirclePlay, Maximize2, Minimize2, Square, X } from 'lucide-react'
|
|
|
|
|
import { useEffect, useRef, useState } from 'react'
|
|
|
|
|
import { useEffect, useRef, useState, type RefObject } from 'react'
|
|
|
|
|
import { createPortal } from 'react-dom'
|
|
|
|
|
import { useWorkspaceWindowFocus } from './useWorkspaceWindowFocus'
|
|
|
|
|
|
|
|
|
|
export function cameraViewerUrl(camera: { url: string; password: string }): string {
|
|
|
|
|
export function cameraViewerUrl(camera: { url: string }): string {
|
|
|
|
|
const input = new URL(camera.url)
|
|
|
|
|
if (input.protocol !== 'https:' || input.hostname !== 'vdo.ninja' || input.port || input.username || input.password || input.pathname !== '/') throw new Error('카메라 시청 URL이 올바르지 않습니다.')
|
|
|
|
|
const room = input.searchParams.get('room')
|
|
|
|
|
const view = input.searchParams.get('view')
|
|
|
|
|
if (!room?.trim() || !view?.trim()) throw new Error('카메라 시청 URL에 room과 view가 필요합니다.')
|
|
|
|
|
if (!room?.trim() || (!view?.trim() && !input.searchParams.has('scene'))) throw new Error('카메라 시청 URL에 room과 scene 또는 view가 필요합니다.')
|
|
|
|
|
const url = new URL('https://vdo.ninja/')
|
|
|
|
|
url.searchParams.set('room', room)
|
|
|
|
|
url.searchParams.set('view', view)
|
|
|
|
|
url.searchParams.set('scene', '')
|
|
|
|
|
if (camera.password) url.searchParams.set('password', camera.password)
|
|
|
|
|
if (view?.trim()) url.searchParams.set('view', view)
|
|
|
|
|
url.searchParams.set('scene', input.searchParams.get('scene') ?? '')
|
|
|
|
|
if (input.searchParams.has('password')) url.searchParams.set('password', input.searchParams.get('password')!)
|
|
|
|
|
return url.toString()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function WorkspaceCameraPanel({ camera, dark, onClose }: {
|
|
|
|
|
camera?: { url: string; password: string } | null
|
|
|
|
|
export default function WorkspaceCameraPanel({ camera, dark, onClose, containerRef }: {
|
|
|
|
|
containerRef: RefObject<HTMLDivElement | null>
|
|
|
|
|
camera?: { url: string } | null
|
|
|
|
|
dark: boolean
|
|
|
|
|
onClose: () => void
|
|
|
|
|
}): React.JSX.Element {
|
|
|
|
|
}): React.JSX.Element | null {
|
|
|
|
|
const [viewerUrl, setViewerUrl] = useState('')
|
|
|
|
|
const { frameRef, bringToFront, zIndex } = useWorkspaceWindowFocus('overview')
|
|
|
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
@@ -29,12 +30,36 @@ export default function WorkspaceCameraPanel({ camera, dark, onClose }: {
|
|
|
|
|
const [large, setLarge] = useState(false)
|
|
|
|
|
const [position, setPosition] = useState({ x: 32, y: 90 })
|
|
|
|
|
const panelRef = useRef<HTMLDivElement>(null)
|
|
|
|
|
const [container, setContainer] = useState<HTMLDivElement | null>(null)
|
|
|
|
|
const [bounds, setBounds] = useState({ width: 0, height: 0 })
|
|
|
|
|
const drag = useRef<{ x: number; y: number; left: number; top: number } | null>(null)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const element = containerRef.current
|
|
|
|
|
if (!element) return
|
|
|
|
|
setContainer(element)
|
|
|
|
|
const update = (): void => {
|
|
|
|
|
const width = element.clientWidth
|
|
|
|
|
const height = element.clientHeight
|
|
|
|
|
setBounds({ width, height })
|
|
|
|
|
setPosition((current) => {
|
|
|
|
|
const panelWidth = Math.min(panelRef.current?.offsetWidth ?? 660, Math.max(0, width - 24))
|
|
|
|
|
const panelHeight = Math.min(panelRef.current?.offsetHeight ?? 420, Math.max(0, height - 60))
|
|
|
|
|
return {
|
|
|
|
|
x: Math.max(12, Math.min(width - panelWidth - 12, current.x)),
|
|
|
|
|
y: Math.max(48, Math.min(height - panelHeight - 12, current.y))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
update()
|
|
|
|
|
const observer = new ResizeObserver(update)
|
|
|
|
|
observer.observe(element)
|
|
|
|
|
return () => observer.disconnect()
|
|
|
|
|
}, [containerRef])
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setViewerUrl('')
|
|
|
|
|
setLoading(false)
|
|
|
|
|
setError('')
|
|
|
|
|
}, [camera?.url, camera?.password])
|
|
|
|
|
}, [camera?.url])
|
|
|
|
|
const start = (): void => {
|
|
|
|
|
try {
|
|
|
|
|
if (!camera?.url) throw new Error('등록된 주행장 카메라가 없습니다.')
|
|
|
|
@@ -46,15 +71,18 @@ export default function WorkspaceCameraPanel({ camera, dark, onClose }: {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const buttonClass = 'grid h-7 w-7 shrink-0 cursor-pointer place-items-center rounded hover:bg-gray-500/20'
|
|
|
|
|
if (!container) return null
|
|
|
|
|
const maxWidth = Math.max(0, bounds.width - position.x - 12)
|
|
|
|
|
const maxHeight = Math.max(0, bounds.height - position.y - 12)
|
|
|
|
|
return createPortal(
|
|
|
|
|
<div ref={panelRef} role="dialog" aria-label="오버뷰"
|
|
|
|
|
onPointerDownCapture={bringToFront} onFocusCapture={bringToFront}
|
|
|
|
|
className={['fixed flex flex-col overflow-hidden rounded-md border shadow-xl', dark ? 'border-white/20 bg-[#242424] text-white' : 'border-gray-300 bg-white text-gray-800'].join(' ')}
|
|
|
|
|
style={large ? { inset: 24, zIndex } : { zIndex, left: position.x, top: position.y, width: 660, height: 420, minWidth: 280, minHeight: 220, maxWidth: `calc(100vw - ${position.x + 12}px)`, maxHeight: `calc(100vh - ${position.y + 12}px)`, resize: 'both' }}>
|
|
|
|
|
className={['absolute flex flex-col overflow-hidden rounded-md border shadow-xl', dark ? 'border-white/20 bg-[#242424] text-white' : 'border-gray-300 bg-white text-gray-800'].join(' ')}
|
|
|
|
|
style={large ? { left: 0, right: 0, top: 40, bottom: 0, zIndex } : { zIndex, left: position.x, top: position.y, width: 660, height: 420, minWidth: Math.min(280, maxWidth), minHeight: Math.min(220, maxHeight), maxWidth, maxHeight, resize: 'both' }}>
|
|
|
|
|
<div className="flex h-10 shrink-0 touch-none items-center gap-2 border-b border-gray-500/25 px-3 select-none"
|
|
|
|
|
style={{ cursor: large ? 'default' : 'move' }}
|
|
|
|
|
onPointerDown={(event) => {
|
|
|
|
|
if (large || (event.target as HTMLElement).closest('button')) return
|
|
|
|
|
if (large || event.button !== 0 || (event.target as HTMLElement).closest('button')) return
|
|
|
|
|
drag.current = { x: event.clientX, y: event.clientY, left: position.x, top: position.y }
|
|
|
|
|
event.currentTarget.setPointerCapture(event.pointerId)
|
|
|
|
|
}}
|
|
|
|
@@ -62,7 +90,7 @@ export default function WorkspaceCameraPanel({ camera, dark, onClose }: {
|
|
|
|
|
if (!drag.current) return
|
|
|
|
|
const width = panelRef.current?.offsetWidth ?? 660
|
|
|
|
|
const height = panelRef.current?.offsetHeight ?? 420
|
|
|
|
|
setPosition({ x: Math.max(0, Math.min(window.innerWidth - width, drag.current.left + event.clientX - drag.current.x)), y: Math.max(0, Math.min(window.innerHeight - height, drag.current.top + event.clientY - drag.current.y)) })
|
|
|
|
|
setPosition({ x: Math.max(12, Math.min(container.clientWidth - width - 12, drag.current.left + event.clientX - drag.current.x)), y: Math.max(48, Math.min(container.clientHeight - height - 12, drag.current.top + event.clientY - drag.current.y)) })
|
|
|
|
|
}}
|
|
|
|
|
onPointerUp={() => { drag.current = null }} onLostPointerCapture={() => { drag.current = null }}>
|
|
|
|
|
<Camera size={15} /><strong className="min-w-0 flex-1 text-[13px]">오버뷰</strong>
|
|
|
|
@@ -79,10 +107,11 @@ export default function WorkspaceCameraPanel({ camera, dark, onClose }: {
|
|
|
|
|
</> : <div className="absolute inset-0 bg-black/34">
|
|
|
|
|
<button type="button" disabled={!camera?.url} onClick={start} className="absolute left-1/2 top-1/2 inline-flex min-h-28 w-64 max-w-full -translate-x-1/2 -translate-y-1/2 cursor-pointer flex-col items-center justify-center gap-2 px-3 text-[15px] font-bold text-white/80 transition hover:text-white disabled:cursor-default disabled:opacity-40"><CirclePlay size={40} /><span>실행하기</span></button>
|
|
|
|
|
<div className="absolute inset-x-0 top-[70%] px-5 text-center">
|
|
|
|
|
<p className="text-[13px] font-bold text-white/72">주행장을 외부에서 촬영하는 영상입니다.</p>
|
|
|
|
|
{error && <p role="alert" className="text-[13px] font-bold text-red-300">{error}</p>}
|
|
|
|
|
{!camera?.url && <p className="text-[13px] font-bold text-white/72">등록된 주행장 카메라가 없습니다.</p>}
|
|
|
|
|
</div>
|
|
|
|
|
</div>}
|
|
|
|
|
</div>
|
|
|
|
|
</div>, document.body)
|
|
|
|
|
</div>, container)
|
|
|
|
|
}
|
|
|
|
|