Files
DriverVault/Web App/web/src/App.vue
T
tajniak81andClaude Opus 5 c9ffb9c698 One prompt for the whole app, asked where the browser cannot refuse it
Fifteen destructive actions were still gated behind window.confirm(), the
same call that made removing a home charger look broken: a browser that
suppresses native dialogs never shows it and returns false, so deleting a
service, a part, a user or an organization would quietly not happen and say
nothing about why.

askConfirm() puts the question in the page and resolves to what was
actually pressed, so each call site changed by one line and reads the way
it did before. ConfirmDialog is mounted once in App.vue and draws over
everything, including a modal — removing a server is asked from inside one,
which is what Modal's new zIndex is for.

The home charger keeps its own inline prompt: that one belongs to its row
rather than to the middle of the screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 12:02:38 +02:00

169 lines
8.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { onMounted, onBeforeUnmount, computed, ref, watch } from "vue";
import { RouterView, RouterLink, useRouter, useRoute } from "vue-router";
import { state, isAuthenticated, logout, refreshProfile } from "./auth";
import { prefs, applyProfilePrefs } from "./prefs";
import { servers } from "./servers";
import { api } from "./api";
import { t } from "./i18n";
import Logo from "./components/Logo.vue";
import ConfirmDialog from "./components/ConfirmDialog.vue";
import ServerSwitcher from "./components/ServerSwitcher.vue";
const router = useRouter();
const route = useRoute();
function onLogout() {
logout();
router.replace({ name: "login" });
}
// Effective dark state, kept reactive by observing the <html> class (prefs.js
// toggles `.dark` there, incl. on system-scheme changes).
const isDark = ref(document.documentElement.classList.contains("dark"));
let themeObserver = null;
// Global theme toggle (light ⇄ dark). Applies instantly and persists to the
// profile, mirroring what Settings Appearance does.
function toggleTheme() {
const next = isDark.value ? "light" : "dark";
applyProfilePrefs({ ...prefs, theme: next });
if (isAuthenticated.value) api.updateMe({ theme: next }).catch(() => {});
}
// Drag lock (on ⇄ off), the same shape as the theme toggle: applies instantly
// and persists to the profile, so a locked account stays locked on the next
// device. It holds every arrangement still — the garage, a car's tabs, its
// Information rows, the provider's readings — which is a guard against nudging
// a layout while reading it, not a permission: what a user may edit is
// unchanged.
function toggleDragLock() {
const next = !prefs.dragLocked;
prefs.dragLocked = next;
if (isAuthenticated.value) api.updateMe({ dragLocked: next }).catch(() => {});
}
// Switching servers puts you in front of a different garage. Record ids belong
// to the server that issued them, so nothing on screen survives the change —
// hence back to the garage every time, and a keyed RouterView below so the view
// remounts and re-fetches even when the route itself doesn't change.
watch(
() => servers.activeId,
() => {
if (route.name !== "login") router.replace({ name: "dashboard" });
}
);
const userInitial = computed(() =>
(state.user?.name || state.user?.email || "?").charAt(0).toUpperCase()
);
// Sidebar nav. User management is a tab inside Settings, not a rail item.
const nav = computed(() => [
{ to: "/", label: t("nav.garage"), icon: "grid", exact: true },
{ to: "/charging", label: t("nav.charging"), icon: "bolt" },
{ to: "/settings", label: t("nav.settings"), icon: "gear" },
]);
function active(item) {
return item.exact ? route.path === item.to : route.path.startsWith(item.to);
}
// Covers a page reload: the token survives in localStorage, but the richer
// profile (theme, etc.) is only ever kept in memory, so re-fetch it.
onMounted(() => {
if (isAuthenticated.value && !state.profile) refreshProfile().catch(() => {});
themeObserver = new MutationObserver(() => {
isDark.value = document.documentElement.classList.contains("dark");
});
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
});
onBeforeUnmount(() => themeObserver?.disconnect());
</script>
<template>
<!-- Login page is full-screen and chromeless. -->
<RouterView v-if="route.name === 'login'" />
<!-- Outer page: a step off the app surface so the centered shell reads as a panel. -->
<div v-else class="min-h-screen bg-sunken text-body">
<div class="mx-auto flex min-h-screen max-w-[1368px] bg-page shadow-pop">
<!-- Fixed dark app-shell rail -->
<aside class="sticky top-0 flex h-screen w-16 flex-none flex-col gap-1 bg-brand-900 px-2 py-5 text-white md:w-60 md:px-4">
<RouterLink to="/" class="mb-5 flex h-8 items-center px-1 md:px-2">
<Logo on-dark icon-only class="h-7 md:hidden" />
<Logo on-dark class="hidden h-7 md:flex" />
</RouterLink>
<nav class="flex flex-col gap-1">
<RouterLink
v-for="item in nav"
:key="item.to"
:to="item.to"
class="flex items-center gap-3 rounded-control px-2.5 py-2.5 text-[15px] font-medium transition-colors md:px-3"
:class="active(item)
? 'bg-brand-400/15 text-white'
: 'text-white/60 hover:bg-white/5 hover:text-white'"
>
<!-- garage -->
<svg v-if="item.icon === 'grid'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5 shrink-0" :style="active(item) ? 'color:#60A5FA' : ''"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>
<!-- charging -->
<svg v-else-if="item.icon === 'bolt'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5 shrink-0" :style="active(item) ? 'color:#60A5FA' : ''"><path stroke-linecap="round" stroke-linejoin="round" d="M13 2 4.5 13.5H11l-1 8.5 8.5-11.5H12z"/></svg>
<!-- settings -->
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5 shrink-0" :style="active(item) ? 'color:#60A5FA' : ''"><path stroke-linecap="round" stroke-linejoin="round" d="M9.6 3.6 9 6a7.5 7.5 0 0 0-1.7 1L5 6.3l-2 3.4 2 1.5a7.6 7.6 0 0 0 0 2l-2 1.5 2 3.4 2.3-.7c.5.4 1.1.8 1.7 1l.6 2.4h4l.6-2.4c.6-.2 1.2-.6 1.7-1l2.3.7 2-3.4-2-1.5a7.6 7.6 0 0 0 0-2l2-1.5-2-3.4-2.3.7A7.5 7.5 0 0 0 15 6l-.6-2.4z"/><circle cx="12" cy="12" r="2.6"/></svg>
<span class="hidden md:inline">{{ item.label }}</span>
</RouterLink>
</nav>
<!-- Footer: server + drag lock + theme toggle + user + logout -->
<div class="mt-auto flex flex-col gap-2 border-t border-white/10 pt-3">
<ServerSwitcher />
<button
class="flex items-center gap-3 rounded-control px-2.5 py-2 text-[15px] font-medium transition-colors hover:bg-white/5 hover:text-white md:px-3"
:class="prefs.dragLocked ? 'text-white/80' : 'text-white/60'"
:title="t('nav.dragLockHint')"
@click="toggleDragLock"
>
<!-- closed padlock while locked, open one while things can be dragged -->
<svg v-if="prefs.dragLocked" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5 shrink-0"><rect x="4" y="10.5" width="16" height="10.5" rx="2"/><path stroke-linecap="round" d="M8 10.5V7a4 4 0 0 1 8 0v3.5"/></svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5 shrink-0"><rect x="4" y="10.5" width="16" height="10.5" rx="2"/><path stroke-linecap="round" d="M8 10.5V7a4 4 0 0 1 7.7-1.5"/></svg>
<span class="hidden md:inline">{{ prefs.dragLocked ? t("nav.unlockDrag") : t("nav.lockDrag") }}</span>
</button>
<button
class="flex items-center gap-3 rounded-control px-2.5 py-2 text-[15px] font-medium text-white/60 transition-colors hover:bg-white/5 hover:text-white md:px-3"
@click="toggleTheme"
>
<svg v-if="isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5 shrink-0"><circle cx="12" cy="12" r="4"/><path stroke-linecap="round" d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5 shrink-0"><path stroke-linecap="round" stroke-linejoin="round" d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"/></svg>
<span class="hidden md:inline">{{ isDark ? t("nav.lightMode") : t("nav.darkMode") }}</span>
</button>
<div class="flex items-center gap-3 px-1 md:px-2">
<div class="grid h-9 w-9 flex-none place-items-center rounded-full bg-brand-600 font-mono text-sm text-white">{{ userInitial }}</div>
<div class="hidden min-w-0 flex-1 md:block">
<p class="truncate text-sm text-white">{{ state.user?.name || state.user?.email }}</p>
<p class="truncate font-mono text-[11px] text-white/50">{{ t("nav.signedIn") }}</p>
</div>
<button class="hidden text-white/50 transition-colors hover:text-white md:block" :title="t('nav.logOut')" @click="onLogout">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-5 w-5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3"/></svg>
</button>
</div>
</div>
</aside>
<!-- Main content -->
<main class="min-w-0 flex-1 px-5 py-6 md:px-8 lg:px-10">
<div class="mx-auto max-w-5xl">
<RouterView :key="servers.activeId" />
</div>
</main>
</div>
</div>
<!-- Asked from anywhere, drawn here, over everything. -->
<ConfirmDialog />
</template>