Restructure Web App into server/ + web/ (GsmNode parity)
Reorganize the Web App to match the GsmNode project layout: a Go backend-for-frontend in server/ that embeds the built SPA and reverse-proxies /api/* to the API Server, with the Vue 3 + Vite frontend moved into web/. - Move all frontend files into web/ (history preserved via renames) - Point vite build output at ../server/dist for Go embedding - Add server/ Go BFF (main.go, go.mod, .env.example, Run-WebApp.ps1) - Drop Docker/nginx deploy (Dockerfile, docker-compose.yml, nginx.conf.template, .dockerignore) in favor of the BFF, matching GsmNode - Update .claude/launch.json to run the dev server from web/ - Rewrite README.md for the new layout Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
ba3f227361
commit
75a2ccc226
47 files changed
+230
-161
No files matched your search
@@ -0,0 +1,126 @@
|
||||
<script setup>
|
||||
import { onMounted, onBeforeUnmount, computed, ref } from "vue";
|
||||
import { RouterView, RouterLink, useRouter, useRoute } from "vue-router";
|
||||
import { state, isAuthenticated, isAdmin, logout, refreshProfile } from "./auth";
|
||||
import { prefs, applyProfilePrefs } from "./prefs";
|
||||
import { api } from "./api";
|
||||
import Logo from "./components/Logo.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(() => {});
|
||||
}
|
||||
|
||||
const userInitial = computed(() =>
|
||||
(state.user?.name || state.user?.email || "?").charAt(0).toUpperCase()
|
||||
);
|
||||
|
||||
// Sidebar nav. Admin item is filtered out for non-admins.
|
||||
const nav = computed(() =>
|
||||
[
|
||||
{ to: "/", label: "Garage", icon: "grid", exact: true },
|
||||
{ to: "/settings", label: "Settings", icon: "gear" },
|
||||
isAdmin.value ? { to: "/admin", label: "Users", icon: "users" } : null,
|
||||
].filter(Boolean)
|
||||
);
|
||||
|
||||
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>
|
||||
<!-- settings -->
|
||||
<svg v-else-if="item.icon === 'gear'" 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>
|
||||
<!-- users -->
|
||||
<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="M16 19v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 9a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm13 10v-2a4 4 0 0 0-3-3.9M16 2.1A4 4 0 0 1 16 9.9"/></svg>
|
||||
<span class="hidden md:inline">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<!-- Footer: theme toggle + user + logout -->
|
||||
<div class="mt-auto flex flex-col gap-2 border-t border-white/10 pt-3">
|
||||
<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 ? "Light mode" : "Dark mode" }}</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">Signed in</p>
|
||||
</div>
|
||||
<button class="hidden text-white/50 transition-colors hover:text-white md:block" title="Log out" @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 />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user