Rework settings/log into draggable dialogs; selectable, copyable log window
release / gui-build (linux, bash) (push) Successful in 59s
release / gui-build (windows, cmd) (push) Successful in 1m21s
release / gui-build (macos, bash) (push) Successful in 1m54s
release / publish-pypi (push) Successful in 12s

Settings and application log are now separate floating windows with
draggable title bars and top-right close buttons instead of one shared
two-column dialog whose log column stretched the layout. The log opens
from a small Log button on the settings version/update row (GUI mode
only), displays in its own wider window on the left with internal
scrolling, and its text is selectable with a Copy button for the full
log.
This commit is contained in:
LeoVasanko committed 2026-09-24 06:30:42 +00:00
1 parent 7e99ec2f16
commit 1223b8cee7
1 file changed
+222 -102
+222 -102
View File
@@ -91,35 +91,42 @@
</svg> </svg>
</button> </button>
<!-- Floating settings window --> <!-- Floating settings/log overlay -->
<div v-if="showSettings" class="settings-view" @click.self="closeSettings"> <div v-if="showSettings" class="settings-view" @click.self="closeSettings">
<div class="settings-window"> <!-- Floating log window (left side) -->
<div class="settings-header"> <div v-if="showLog" ref="logWindowEl" class="log-window" :style="logDragStyle">
<button class="settings-back" @click="closeSettings"> <div class="dialog-header" @pointerdown="onLogDragStart">
<svg <h1 class="settings-title">Application log</h1>
xmlns="http://www.w3.org/2000/svg" <div class="log-actions">
width="24" <button class="log-copy-btn" title="Copy entire log" @click="copyLog">
height="24" {{ logCopied ? "Copied" : "Copy" }}
viewBox="0 0 24 24" </button>
fill="none" <button class="dialog-close" title="Close log" @click="closeLog">✕</button>
stroke="currentColor" </div>
stroke-width="2" </div>
stroke-linecap="round" <pre ref="logEl" class="diag-log" @scroll="onLogScroll">{{ appLog }}</pre>
stroke-linejoin="round" </div>
>
<path d="M19 12H5M12 19l-7-7 7-7" /> <!-- Floating settings window (right side) -->
</svg> <div ref="settingsWindowEl" class="settings-window" :style="settingsDragStyle">
<span>Back</span> <div class="dialog-header" @pointerdown="onSettingsDragStart">
</button>
<h1 class="settings-title">Settings</h1> <h1 class="settings-title">Settings</h1>
<div class="settings-header-spacer"></div> <button class="dialog-close" title="Close settings" @click="closeSettings">✕</button>
</div> </div>
<div class="settings-scroll"> <div class="settings-scroll">
<div class="settings-body" :class="{ 'settings-body--no-log': !hasLog }"> <div class="settings-body">
<section class="settings-section settings-update"> <section class="settings-section">
<div class="update-row"> <div class="update-row">
<span class="update-version"> MediaHive {{ updateStatus?.version ?? "…" }} </span> <span class="update-version"> MediaHive {{ updateStatus?.version ?? "…" }} </span>
<button
v-if="isDesktopApp"
class="log-open-btn"
title="View application log"
@click="openLog"
>
Log
</button>
<label class="update-auto"> <label class="update-auto">
<input <input
type="checkbox" type="checkbox"
@@ -336,11 +343,6 @@
</button> </button>
</div> </div>
</section> </section>
<section v-if="hasLog" class="settings-section settings-diag">
<h2 class="settings-section-title">Application log</h2>
<pre ref="logEl" class="diag-log" @scroll="onLogScroll">{{ appLog }}</pre>
</section>
</div> </div>
</div> </div>
@@ -486,8 +488,68 @@ function openSettings() {
function closeSettings() { function closeSettings() {
showSettings.value = false showSettings.value = false
showLog.value = false
} }
const showLog = ref(false)
function openLog() {
showLog.value = true
}
function closeLog() {
showLog.value = false
}
// Draggable floating windows: pointer drag on the dialog header repositions
// the window by switching it to fixed positioning at the dragged offset.
function useDraggable() {
const el = ref<HTMLElement | null>(null)
const pos = ref<{ x: number; y: number } | null>(null)
function onDragStart(e: PointerEvent) {
if ((e.target as HTMLElement).closest("button")) return
const rect = el.value?.getBoundingClientRect()
if (!rect) return
e.preventDefault()
const grabX = e.clientX - rect.left
const grabY = e.clientY - rect.top
pos.value = { x: rect.left, y: rect.top }
const onMove = (ev: PointerEvent) => {
pos.value = {
x: Math.min(Math.max(ev.clientX - grabX, 0), window.innerWidth - 80),
y: Math.min(Math.max(ev.clientY - grabY, 0), window.innerHeight - 40),
}
}
const onUp = () => {
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", onUp)
}
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", onUp)
}
const dragStyle = computed(() =>
pos.value
? {
position: "fixed" as const,
left: `${pos.value.x}px`,
top: `${pos.value.y}px`,
margin: "0",
}
: undefined,
)
return { el, dragStyle, onDragStart }
}
const {
el: settingsWindowEl,
dragStyle: settingsDragStyle,
onDragStart: onSettingsDragStart,
} = useDraggable()
const { el: logWindowEl, dragStyle: logDragStyle, onDragStart: onLogDragStart } = useDraggable()
function setPreferredResolution(value: ResolutionPreference) { function setPreferredResolution(value: ResolutionPreference) {
settings.preferredResolution = value settings.preferredResolution = value
} }
@@ -557,7 +619,6 @@ async function restartToUpdate() {
} }
const appLog = ref("") const appLog = ref("")
const hasLog = ref(false)
const logEl = ref<HTMLElement | null>(null) const logEl = ref<HTMLElement | null>(null)
let logSocket: WebSocket | null = null let logSocket: WebSocket | null = null
let pinnedToBottom = true let pinnedToBottom = true
@@ -568,22 +629,48 @@ function onLogScroll() {
pinnedToBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 48 pinnedToBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 48
} }
async function applyLogText(text: string) {
appLog.value = text
await nextTick()
const el = logEl.value
if (el && pinnedToBottom) el.scrollTop = el.scrollHeight
}
const logCopied = ref(false)
let logCopiedTimer: number | null = null
async function copyLog() {
const text = appLog.value
try {
await navigator.clipboard.writeText(text)
} catch {
// Fallback for contexts where the async clipboard API is unavailable.
const ta = document.createElement("textarea")
ta.value = text
ta.style.position = "fixed"
ta.style.opacity = "0"
document.body.appendChild(ta)
ta.select()
document.execCommand("copy")
ta.remove()
}
logCopied.value = true
if (logCopiedTimer !== null) window.clearTimeout(logCopiedTimer)
logCopiedTimer = window.setTimeout(() => (logCopied.value = false), 1500)
}
function connectLogSocket() { function connectLogSocket() {
if (logSocket) return if (logSocket) return
const proto = location.protocol === "https:" ? "wss" : "ws" const proto = location.protocol === "https:" ? "wss" : "ws"
const ws = new WebSocket(`${proto}://${location.host}/api/log/ws`) const ws = new WebSocket(`${proto}://${location.host}/api/log/ws`)
logSocket = ws logSocket = ws
pinnedToBottom = true pinnedToBottom = true
ws.onmessage = async (ev) => { ws.onmessage = (ev) => {
appLog.value = String(ev.data) void applyLogText(String(ev.data))
hasLog.value = appLog.value.length > 0
await nextTick()
const el = logEl.value
if (el && pinnedToBottom) el.scrollTop = el.scrollHeight
} }
ws.onclose = () => { ws.onclose = () => {
if (logSocket === ws) logSocket = null if (logSocket === ws) logSocket = null
if (showSettings.value) setTimeout(connectLogSocket, 3000) if (showLog.value) setTimeout(connectLogSocket, 3000)
} }
} }
@@ -636,6 +723,14 @@ watch(showSettings, (visible) => {
if (visible) { if (visible) {
void refreshPlayers() void refreshPlayers()
void refreshUpdateStatus() void refreshUpdateStatus()
} else {
showLog.value = false
disconnectLogSocket()
}
})
watch(showLog, (visible) => {
if (visible) {
connectLogSocket() connectLogSocket()
} else { } else {
disconnectLogSocket() disconnectLogSocket()
@@ -823,6 +918,8 @@ onUnmounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 2.5rem;
padding-left: 5vw;
padding-right: 15vw; padding-right: 15vw;
} }
@@ -830,6 +927,21 @@ onUnmounted(() => {
width: fit-content; width: fit-content;
max-width: 94vw; max-width: 94vw;
height: min(85vh, 56.25rem); height: min(85vh, 56.25rem);
flex: 0 0 auto;
background: var(--bg-primary);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.75rem;
box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
overflow: hidden;
}
.log-window {
flex: 1 1 auto;
min-width: 20rem;
max-width: 75rem;
height: min(85vh, 56.25rem);
background: var(--bg-primary); background: var(--bg-primary);
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.75rem; border-radius: 0.75rem;
@@ -842,42 +954,59 @@ onUnmounted(() => {
@media (max-width: 43.75rem) { @media (max-width: 43.75rem) {
.settings-view { .settings-view {
justify-content: center; justify-content: center;
padding-left: 0;
padding-right: 0; padding-right: 0;
} }
.settings-window { .settings-window,
.log-window {
width: 100vw; width: 100vw;
max-width: none; max-width: none;
height: 100vh; height: 100vh;
border: none; border: none;
border-radius: 0; border-radius: 0;
} }
.log-window {
position: absolute;
inset: 0;
}
} }
.settings-header { .dialog-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem;
padding: 16px 24px; padding: 16px 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08); border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0; flex-shrink: 0;
cursor: grab;
user-select: none;
touch-action: none;
} }
.settings-back { .dialog-header:active {
cursor: grabbing;
}
.dialog-close {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; justify-content: center;
background: none; background: none;
border: none; border: none;
color: var(--text-secondary); color: #fff;
font-size: 0.9rem; font-size: 1rem;
line-height: 1;
cursor: pointer; cursor: pointer;
padding: 8px 0; opacity: 0.7;
transition: color 0.2s; transition: opacity 0.15s;
padding: 4px;
} }
.settings-back:hover { .dialog-close:hover {
color: var(--text-primary); opacity: 1;
} }
.settings-title { .settings-title {
@@ -886,10 +1015,6 @@ onUnmounted(() => {
margin: 0; margin: 0;
} }
.settings-header-spacer {
width: 80px;
}
.settings-scroll { .settings-scroll {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -898,45 +1023,8 @@ onUnmounted(() => {
.settings-body { .settings-body {
margin: 0 auto; margin: 0 auto;
padding: 2rem 1.5rem; padding: 2rem 1.5rem;
display: grid; width: 32rem;
grid-template-columns: minmax(23.75rem, 28.75rem) minmax(20rem, 37.5rem); max-width: 100%;
column-gap: 3rem;
align-items: stretch;
}
.settings-body > .settings-section {
grid-column: 1;
min-width: 0;
}
.settings-body > .settings-update {
grid-column: 1 / -1;
grid-row: 1;
}
.settings-body > .settings-diag {
grid-column: 2;
grid-row: 2 / span 3;
display: flex;
flex-direction: column;
min-width: 0;
}
.settings-body--no-log {
grid-template-columns: minmax(23.75rem, 35rem);
}
@media (max-width: 56.25rem) {
.settings-body,
.settings-body--no-log {
grid-template-columns: minmax(0, 35rem);
}
.settings-body > .settings-section,
.settings-body > .settings-diag {
grid-column: 1;
grid-row: auto;
}
} }
.settings-section { .settings-section {
@@ -1233,25 +1321,57 @@ onUnmounted(() => {
.diag-log { .diag-log {
font-family: ui-monospace, Menlo, Consolas, monospace; font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.75rem; font-size: 0.75rem;
white-space: pre-wrap; white-space: pre;
word-break: break-all;
width: 100%;
flex: 1; flex: 1;
min-height: 15rem; min-height: 0;
max-height: none; overflow: auto;
overflow-y: auto;
margin: 0; margin: 0;
padding: 0.625rem; padding: 0.625rem 1.5rem 1.5rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 0.5rem;
color: var(--text-secondary); color: var(--text-secondary);
user-select: text;
cursor: text;
} }
@media (max-width: 56.25rem) { .log-actions {
.diag-log { display: inline-flex;
flex: none; align-items: center;
max-height: 60vh; gap: 0.75rem;
} }
.log-copy-btn {
padding: 0.125rem 0.5rem;
background: none;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0.375rem;
color: var(--text-muted, var(--text-secondary));
font-size: 0.8rem;
cursor: pointer;
transition:
color 0.2s,
border-color 0.2s;
}
.log-copy-btn:hover {
color: var(--text-primary);
border-color: rgba(255, 255, 255, 0.35);
}
.log-open-btn {
padding: 0.125rem 0.5rem;
background: none;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0.375rem;
color: var(--text-muted, var(--text-secondary));
font-size: 0.8rem;
cursor: pointer;
transition:
color 0.2s,
border-color 0.2s;
}
.log-open-btn:hover {
color: var(--text-primary);
border-color: rgba(255, 255, 255, 0.35);
} }
/* Library activity footer strip */ /* Library activity footer strip */