mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 00:46:00 +08:00
neues pm ui
This commit is contained in:
77
app/components/CommLog.vue
Normal file
77
app/components/CommLog.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
entries: readonly any[]
|
||||
limit?: number
|
||||
dense?: boolean
|
||||
}>(), {
|
||||
limit: 8,
|
||||
dense: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ (e: 'clear'): void }>()
|
||||
|
||||
const formatTime = (date: Date): string => {
|
||||
const d = date instanceof Date ? date : new Date(date)
|
||||
return d.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card class="bg-white/5 border border-white/10 h-full">
|
||||
<v-card-text class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-lg font-semibold">Communication Log</h3>
|
||||
<v-chip size="small" color="cyan" variant="outlined">{{ entries.length }}</v-chip>
|
||||
</div>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
color="cyan"
|
||||
class="text-xs uppercase tracking-[0.2em]"
|
||||
:disabled="entries.length === 0"
|
||||
@click="emit('clear')"
|
||||
>
|
||||
Clear
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 overflow-y-auto" :class="dense ? 'max-h-[70vh]' : 'max-h-64'">
|
||||
<div
|
||||
v-for="entry in entries.slice(-limit)"
|
||||
:key="entry.timestamp.getTime?.() ?? entry.timestamp"
|
||||
class="rounded-2xl border border-white/10 bg-black/40 p-3"
|
||||
>
|
||||
<div class="flex items-center justify-between text-xs uppercase tracking-[0.3em] text-white/40 mb-1">
|
||||
<span class="flex items-center gap-2">
|
||||
<v-icon
|
||||
:icon="entry.speaker === 'pilot' ? 'mdi-account-pilot' : 'mdi-radio-tower'"
|
||||
size="12"
|
||||
:class="entry.speaker === 'pilot' ? 'text-blue-400' : 'text-green-400'"
|
||||
/>
|
||||
{{ entry.speaker.toUpperCase() }}
|
||||
<v-chip v-if="entry.radioCheck" size="x-small" color="orange" variant="flat">RADIO CHECK</v-chip>
|
||||
<v-chip v-if="entry.offSchema" size="x-small" color="red" variant="flat">OFF-SCHEMA</v-chip>
|
||||
</span>
|
||||
<span>{{ formatTime(entry.timestamp) }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-white font-mono">{{ entry.message }}</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<v-chip v-if="entry.flow" size="x-small" color="purple" variant="outlined">{{ entry.flow }}</v-chip>
|
||||
<v-chip size="x-small" color="cyan" variant="outlined">{{ entry.frequency || 'N/A' }}</v-chip>
|
||||
<span class="text-xs text-white/40">{{ entry.state }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="entries.length === 0" class="text-xs text-white/50 text-center py-4">
|
||||
No communications recorded yet.
|
||||
</p>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
321
app/components/HoldSelect.vue
Normal file
321
app/components/HoldSelect.vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, onBeforeUnmount } from 'vue'
|
||||
|
||||
export interface HoldSelectOption {
|
||||
value: string | number
|
||||
label: string
|
||||
sublabel?: string
|
||||
color?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
options: HoldSelectOption[]
|
||||
placement?: 'auto' | 'up' | 'down'
|
||||
disabled?: boolean
|
||||
title?: string
|
||||
}>(), {
|
||||
placement: 'auto',
|
||||
disabled: false,
|
||||
title: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', option: HoldSelectOption): void
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
const sticky = ref(false)
|
||||
const highlightIndex = ref(-1)
|
||||
|
||||
const triggerRef = ref<HTMLElement>()
|
||||
const menuRef = ref<HTMLElement>()
|
||||
const optionEls = ref<HTMLElement[]>([])
|
||||
const menuStyle = ref<Record<string, string>>({})
|
||||
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
let moved = false
|
||||
let activePointerId: number | null = null
|
||||
const MOVE_THRESHOLD = 8
|
||||
|
||||
const setOptionEl = (el: any, i: number) => {
|
||||
if (el) optionEls.value[i] = el as HTMLElement
|
||||
}
|
||||
|
||||
function vibrate(ms: number) {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && 'vibrate' in navigator) navigator.vibrate(ms)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function openMenu() {
|
||||
open.value = true
|
||||
sticky.value = false
|
||||
highlightIndex.value = -1
|
||||
optionEls.value = []
|
||||
await nextTick()
|
||||
positionMenu()
|
||||
}
|
||||
|
||||
function positionMenu() {
|
||||
const trigger = triggerRef.value
|
||||
const menu = menuRef.value
|
||||
if (!trigger || !menu) return
|
||||
const t = trigger.getBoundingClientRect()
|
||||
const m = menu.getBoundingClientRect()
|
||||
const vh = window.innerHeight
|
||||
const vw = window.innerWidth
|
||||
const gap = 8
|
||||
|
||||
let placeUp = props.placement === 'up'
|
||||
if (props.placement === 'auto') placeUp = t.top > vh / 2
|
||||
|
||||
let top = placeUp ? t.top - gap - m.height : t.bottom + gap
|
||||
top = Math.max(8, Math.min(top, vh - m.height - 8))
|
||||
|
||||
let left = t.left
|
||||
left = Math.max(8, Math.min(left, vw - m.width - 8))
|
||||
|
||||
menuStyle.value = {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
minWidth: `${Math.max(t.width, 160)}px`,
|
||||
}
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
open.value = false
|
||||
sticky.value = false
|
||||
highlightIndex.value = -1
|
||||
removeOutsideListener()
|
||||
}
|
||||
|
||||
function optionIndexAt(x: number, y: number): number {
|
||||
for (let i = 0; i < optionEls.value.length; i++) {
|
||||
const el = optionEls.value[i]
|
||||
if (!el) continue
|
||||
const r = el.getBoundingClientRect()
|
||||
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function updateHighlight(x: number, y: number) {
|
||||
const idx = optionIndexAt(x, y)
|
||||
if (idx !== highlightIndex.value) {
|
||||
highlightIndex.value = idx
|
||||
if (idx >= 0 && !props.options[idx]?.disabled) vibrate(8)
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (props.disabled || !props.options.length) return
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return
|
||||
|
||||
// If already open in sticky mode, a press on the trigger toggles it closed.
|
||||
if (sticky.value) {
|
||||
closeMenu()
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
activePointerId = e.pointerId
|
||||
startX = e.clientX
|
||||
startY = e.clientY
|
||||
moved = false
|
||||
void openMenu()
|
||||
|
||||
window.addEventListener('pointermove', onPointerMove, { passive: false })
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('pointercancel', onPointerUp)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (e.pointerId !== activePointerId) return
|
||||
const dx = e.clientX - startX
|
||||
const dy = e.clientY - startY
|
||||
if (!moved && Math.hypot(dx, dy) > MOVE_THRESHOLD) moved = true
|
||||
if (moved) {
|
||||
e.preventDefault()
|
||||
updateHighlight(e.clientX, e.clientY)
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (e.pointerId !== activePointerId) return
|
||||
cleanupDragListeners()
|
||||
activePointerId = null
|
||||
|
||||
if (moved) {
|
||||
const idx = optionIndexAt(e.clientX, e.clientY)
|
||||
const opt = idx >= 0 ? props.options[idx] : undefined
|
||||
if (opt && !opt.disabled) selectOption(opt)
|
||||
closeMenu()
|
||||
} else {
|
||||
// Plain tap: keep menu open so the user can tap an option normally.
|
||||
sticky.value = true
|
||||
highlightIndex.value = -1
|
||||
addOutsideListener()
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupDragListeners() {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('pointercancel', onPointerUp)
|
||||
}
|
||||
|
||||
function onOptionClick(opt: HoldSelectOption) {
|
||||
if (opt.disabled) return
|
||||
selectOption(opt)
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
function selectOption(opt: HoldSelectOption) {
|
||||
vibrate(12)
|
||||
emit('select', opt)
|
||||
}
|
||||
|
||||
function onOutsidePointer(e: PointerEvent) {
|
||||
const target = e.target as Node
|
||||
if (menuRef.value?.contains(target)) return
|
||||
if (triggerRef.value?.contains(target)) return
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
function addOutsideListener() {
|
||||
window.addEventListener('pointerdown', onOutsidePointer, true)
|
||||
}
|
||||
function removeOutsideListener() {
|
||||
window.removeEventListener('pointerdown', onOutsidePointer, true)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupDragListeners()
|
||||
removeOutsideListener()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="triggerRef"
|
||||
class="hold-select"
|
||||
:class="{ 'is-open': open, 'is-disabled': disabled }"
|
||||
@pointerdown="onPointerDown"
|
||||
>
|
||||
<slot :open="open" :sticky="sticky" />
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
ref="menuRef"
|
||||
class="hold-select-menu"
|
||||
:style="menuStyle"
|
||||
>
|
||||
<p v-if="title" class="hs-title">{{ title }}</p>
|
||||
<div
|
||||
v-for="(opt, i) in options"
|
||||
:key="opt.value"
|
||||
:ref="(el) => setOptionEl(el, i)"
|
||||
class="hs-option"
|
||||
:class="{
|
||||
'is-highlight': i === highlightIndex,
|
||||
'is-disabled': opt.disabled,
|
||||
}"
|
||||
:style="opt.color ? { '--hs-accent': opt.color } : undefined"
|
||||
@click="onOptionClick(opt)"
|
||||
>
|
||||
<slot name="option" :option="opt" :highlighted="i === highlightIndex">
|
||||
<span class="hs-opt-label">{{ opt.label }}</span>
|
||||
<span v-if="opt.sublabel" class="hs-opt-sub">{{ opt.sublabel }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
<p v-if="!options.length" class="hs-empty">Keine Optionen</p>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hold-select {
|
||||
display: inline-flex;
|
||||
touch-action: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.hold-select.is-disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hold-select-menu {
|
||||
position: fixed;
|
||||
z-index: 4000;
|
||||
max-height: min(60vh, 420px);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
border-radius: 18px;
|
||||
background: rgba(8, 13, 24, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(12px);
|
||||
animation: hs-pop 120ms ease-out;
|
||||
}
|
||||
@keyframes hs-pop {
|
||||
from { opacity: 0; transform: scale(0.96); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.hs-title {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.25em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
padding: 4px 10px 2px;
|
||||
}
|
||||
|
||||
.hs-option {
|
||||
--hs-accent: #22d3ee;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: background 90ms ease, border-color 90ms ease;
|
||||
}
|
||||
.hs-option.is-highlight {
|
||||
background: color-mix(in srgb, var(--hs-accent) 22%, transparent);
|
||||
border-color: color-mix(in srgb, var(--hs-accent) 60%, transparent);
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.hs-option:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
}
|
||||
.hs-option.is-disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.hs-opt-label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
.hs-opt-sub {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.hs-empty {
|
||||
padding: 12px 14px;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
</style>
|
||||
1986
app/pages/pm.vue
1986
app/pages/pm.vue
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user