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:
tajniak81andClaude Opus 4.8 committed 2026-07-13 11:34:02 +02:00
1 parent ba3f227361
commit 75a2ccc226
47 files changed
+230 -161

No files matched your search

+126
View File
@@ -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>