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>
169 lines
8.9 KiB
Vue
169 lines
8.9 KiB
Vue
<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>
|