feat(viewer): add GM Ops Console window replacing ops strip

Introduces a floating, draggable, resizable Game Master console as the
first of a planned series of GM/debug windows. Replaces the horizontal
ops-strip card layout with proper data tables using TanStack Table v8.

- GmWindow.vue: reusable draggable+resizable floating window base;
  snapshots offsetWidth/Height on drag start so resize is preserved
- GmOpsWindow.vue: Ships / Stations / Factions tabs with global filter,
  column sorting, and drag-to-reorder columns (useColumnOrder composable)
- gmStore.ts: Pinia store fed from ViewerWorldLifecycle.rebuildFactions
  with raw world arrays (ships, stations, factions)
- Removes opsStripEl binding (was stored but never read by controller)
- GM Console toggle button replaces the bottom ops strip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 00:24:32 -04:00
parent cd1fe776a5
commit 892d069b92
10 changed files with 977 additions and 14 deletions

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
const props = withDefaults(defineProps<{
title: string;
initialX?: number;
initialY?: number;
initialWidth?: number;
initialHeight?: number;
}>(), {
initialX: 80,
initialY: 80,
initialWidth: 960,
initialHeight: 580,
});
const emit = defineEmits<{
close: [];
}>();
const windowEl = ref<HTMLDivElement | null>(null);
const x = ref(props.initialX);
const y = ref(props.initialY);
const w = ref(props.initialWidth);
const h = ref(props.initialHeight);
const isDragging = ref(false);
let dragOffsetX = 0;
let dragOffsetY = 0;
function onTitleMouseDown(e: MouseEvent) {
if ((e.target as HTMLElement).closest("button")) return;
// Snapshot current rendered size so resize isn't lost on drag
if (windowEl.value) {
w.value = windowEl.value.offsetWidth;
h.value = windowEl.value.offsetHeight;
}
isDragging.value = true;
dragOffsetX = e.clientX - x.value;
dragOffsetY = e.clientY - y.value;
e.preventDefault();
}
function onMouseMove(e: MouseEvent) {
if (!isDragging.value) return;
x.value = e.clientX - dragOffsetX;
y.value = e.clientY - dragOffsetY;
}
function onMouseUp() {
isDragging.value = false;
}
onMounted(() => {
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
});
onUnmounted(() => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
});
</script>
<template>
<div
ref="windowEl"
class="gm-window pointer-events-auto fixed flex flex-col overflow-hidden rounded-xl border"
:style="{
left: `${x}px`,
top: `${y}px`,
width: `${w}px`,
height: `${h}px`,
cursor: isDragging ? 'grabbing' : 'default',
zIndex: 200,
}"
>
<!-- Title bar -->
<div
class="gm-window-titlebar flex shrink-0 cursor-grab select-none items-center gap-2 px-4 py-2.5"
:style="{ cursor: isDragging ? 'grabbing' : 'grab' }"
@mousedown="onTitleMouseDown"
>
<span class="gm-window-title-badge mr-1 rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-widest">GM</span>
<h2 class="flex-1 font-[Space_Grotesk] text-sm font-semibold tracking-wide">{{ title }}</h2>
<button
type="button"
class="gm-window-close-btn flex h-6 w-6 items-center justify-center rounded text-xs opacity-60 transition hover:opacity-100"
aria-label="Close window"
@click="emit('close')"
>
</button>
</div>
<!-- Content -->
<div class="min-h-0 flex-1 overflow-hidden">
<slot />
</div>
</div>
</template>