create webapp stencil with side bar, routes and fake login

This commit is contained in:
2025-02-16 21:25:45 -07:00
parent de771d83b1
commit 6a8a4a13ba
13 changed files with 533 additions and 9 deletions

View File

@ -1,15 +1,11 @@
<template>
<div class="about">
<h1>This app was generated using the Masonry CLI tool.</h1>
<div class="flex flex-col items-center justify-center h-screen">
<h1 class="font-light font-sans text-3xl">This app was generated using the Masonry CLI tool.</h1>
<span>&nbsp;</span>
<span>Vectors and icons by <a href="https://www.svgrepo.com" target="_blank">SVG Repo</a></span>
<span>Vectors and icons by <a href="https://www.figma.com/@thewolfkit?ref=svgrepo.com" target="_blank">Thewolfkit</a> in CC Attribution License via <a href="https://www.svgrepo.com/" target="_blank">SVG Repo</a></span>
</div>
</template>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>

View File

@ -0,0 +1,11 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
<style scoped>
</style>

View File

@ -0,0 +1,5 @@
<template>
<div class="flex justify-around">{{ .AppNameCaps }}</div>
</template>
<script setup lang="ts">
</script>

View File

@ -0,0 +1,6 @@
<template>
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M7.75 4.75C8.30228 4.75 8.75 4.30228 8.75 3.75V2.75C8.75 2.19772 8.30228 1.75 7.75 1.75H4.25C2.86929 1.75 1.75 2.86929 1.75 4.25V20.25C1.75 21.6307 2.86929 22.75 4.25 22.75H7.75C8.30228 22.75 8.75 22.3023 8.75 21.75V20.75C8.75 20.1977 8.30228 19.75 7.75 19.75H4.75V4.75H7.75Z" fill="currentColor"/>
<path d="M16.25 8.25003C16.25 7.84557 16.4936 7.48093 16.8673 7.32615C17.241 7.17137 17.6711 7.25692 17.9571 7.54292L21.9571 11.5429C22.3476 11.9334 22.3476 12.5666 21.9571 12.9571L17.9571 16.9571C17.6711 17.2431 17.241 17.3287 16.8673 17.1739C16.4936 17.0191 16.25 16.6545 16.25 16.25V13.75H8.25C7.69771 13.75 7.25 13.3023 7.25 12.75V11.75C7.25 11.1977 7.69772 10.75 8.25 10.75H16.25V8.25003Z" fill="currentColor"/>
</svg>
</template>

View File

@ -0,0 +1,6 @@
<template>
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.7848 0.449982C13.8239 0.449982 14.7167 1.16546 14.9122 2.15495L14.9991 2.59495C15.3408 4.32442 17.1859 5.35722 18.9016 4.7794L19.3383 4.63233C20.3199 4.30175 21.4054 4.69358 21.9249 5.56605L22.7097 6.88386C23.2293 7.75636 23.0365 8.86366 22.2504 9.52253L21.9008 9.81555C20.5267 10.9672 20.5267 13.0328 21.9008 14.1844L22.2504 14.4774C23.0365 15.1363 23.2293 16.2436 22.7097 17.1161L21.925 18.4339C21.4054 19.3064 20.3199 19.6982 19.3382 19.3676L18.9017 19.2205C17.1859 18.6426 15.3408 19.6754 14.9991 21.405L14.9122 21.845C14.7167 22.8345 13.8239 23.55 12.7848 23.55H11.2152C10.1761 23.55 9.28331 22.8345 9.08781 21.8451L9.00082 21.4048C8.65909 19.6754 6.81395 18.6426 5.09822 19.2205L4.66179 19.3675C3.68016 19.6982 2.59465 19.3063 2.07505 18.4338L1.2903 17.1161C0.770719 16.2436 0.963446 15.1363 1.74956 14.4774L2.09922 14.1844C3.47324 13.0327 3.47324 10.9672 2.09922 9.8156L1.74956 9.52254C0.963446 8.86366 0.77072 7.75638 1.2903 6.8839L2.07508 5.56608C2.59466 4.69359 3.68014 4.30176 4.66176 4.63236L5.09831 4.77939C6.81401 5.35722 8.65909 4.32449 9.00082 2.59506L9.0878 2.15487C9.28331 1.16542 10.176 0.449982 11.2152 0.449982H12.7848ZM12 15.3C13.8225 15.3 15.3 13.8225 15.3 12C15.3 10.1774 13.8225 8.69998 12 8.69998C10.1774 8.69998 8.69997 10.1774 8.69997 12C8.69997 13.8225 10.1774 15.3 12 15.3Z" fill="currentColor"/>
</svg>
</template>

View File

@ -0,0 +1,13 @@
<template>
<svg fill="currentColor" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 45.973 45.972" xml:space="preserve">
<g>
<g>
<path d="M44.752,20.914L25.935,2.094c-0.781-0.781-1.842-1.22-2.946-1.22c-1.105,0-2.166,0.439-2.947,1.22L1.221,20.914
c-1.191,1.191-1.548,2.968-0.903,4.525c0.646,1.557,2.165,2.557,3.85,2.557h2.404v13.461c0,2.013,1.607,3.642,3.621,3.642h3.203
V32.93c0-0.927,0.766-1.651,1.692-1.651h6.223c0.926,0,1.673,0.725,1.673,1.651v12.168h12.799c2.013,0,3.612-1.629,3.612-3.642
V27.996h2.411c1.685,0,3.204-1,3.85-2.557C46.3,23.882,45.944,22.106,44.752,20.914z"/>
</g>
</g>
</svg>
</template>

View File

@ -0,0 +1,11 @@
<template>
<div>
<main>
<router-view />
</main>
</div>
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router';
</script>

View File

@ -0,0 +1,67 @@
<template>
<div class="flex">
<!-- Pass links, a custom theme and include optional branding via the "brand" slot -->
<SideNavBar :links="navLinks" :theme="customTheme">
<!-- Branding slot: the logo or app name will be sized by the sidebar -->
<template #brand>
<!-- For example, an image with controlled size -->
<!-- <img src="/src/assets/logo.svg" alt="App Logo" class="w-full h-full object-contain" />-->
<!-- Alternatively, you could use a text element here -->
<div class="flex flex-col justify-around w-full h-full">
<span class="text-xl font-bold">{{ .AppNameCaps }}</span>
</div>
</template>
</SideNavBar>
<main class="flex-1 p-4 bg-blue-50">
<Header />
<router-view />
</main>
</div>
</template>
<script setup lang="ts">
import { ref, markRaw } from 'vue';
import SideNavBar from '../components/SideNavBar.vue';
import IconHome from '../components/icons/IconHome.vue';
import IconGear from '../components/icons/IconGear.vue';
import IconArrowFromShapeRight from '../components/icons/IconArrowFromShapeRight.vue';
import Header from '../components/Header.vue';
import type { NavLink, Theme } from '../components/SideNavBar.vue';
const homeIcon = markRaw(IconHome);
const gearIcon = markRaw(IconGear);
const logoutIcon = markRaw(IconArrowFromShapeRight);
const navLinks = ref<NavLink[]>([
{
title: 'Home',
icon: homeIcon,
route: '/',
subLinks: [
{ title: 'Overview', route: '/overview' },
]
},
{
title: 'Settings',
icon: gearIcon,
route: '/settings'
},
{
title: 'Logout',
icon: logoutIcon,
route: '/logout'
}
]);
// Define a custom theme for the sidebar.
const customTheme: Theme = {
// sidebarBg: "bg-blue-800",
// sidebarText: "text-gray-100",
// sidebarLink: "rounded-md",
// sidebarHover: "hover:bg-blue-700",
// sidebarActive: "bg-blue-900",
// toggleButtonBg: "bg-blue-700"
};
</script>

View File

@ -0,0 +1,21 @@
<template>
<div>
<main>
Username and password form <br>
<button class="bg-blue-400 pl-4 pr-4 pt-2 pb-2 cursor-pointer rounded-2xl" @click="login">Login</button>
</main>
</div>
</template>
<script setup lang="ts">
import { useAuthenticationStore } from '@/stores/authentication.ts'
import { useRouter } from 'vue-router'
const router = useRouter();
function login() {
const authenticationStore = useAuthenticationStore();
authenticationStore.login();
router.push('/');
}
</script>

View File

@ -0,0 +1,193 @@
<template>
<!-- Mobile Toggle Button when menu is closed: shows a hamburger button at top-left -->
<button
v-if="!mobileOpen"
@click="toggleMobile"
:class="[theme.hamburgerButtonBg, 'sm:hidden fixed top-4 left-4 z-40 p-2 rounded focus:outline-none']">
<!-- Hamburger Icon -->
<span class="block w-6 h-0.5 bg-current mb-1"></span>
<span class="block w-6 h-0.5 bg-current mb-1"></span>
<span class="block w-6 h-0.5 bg-current"></span>
</button>
<!-- Sidebar container -->
<!-- In mobile view, the sidebar overlays content; in desktop (sm and up), it's static -->
<nav :class="sidebarClasses">
<!-- Desktop Header: Branding (optional) and Minimize Toggle -->
<div class="hidden sm:flex justify-between items-center p-2">
<!-- Optional Branding Slot: only shown when sidebar is expanded -->
<div v-if="!minimized && $slots.brand" class="flex-shrink-0">
<!-- Wrap branding in a container that controls size -->
<div class="w-32 h-10 overflow-hidden">
<slot name="brand"></slot>
</div>
</div>
<!-- Minimize Toggle Button -->
<button
@click="toggleMinimized"
:class="[theme.toggleButtonBg, 'p-2 rounded hover:opacity-80 focus:outline-none']">
<!-- Toggle icon: changes based on minimized state -->
<svg v-if="minimized" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 19l-7-7 7-7" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-6 w-6"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<!-- Mobile Close Button (X icon) displayed when menu is open -->
<button
v-if="mobileOpen"
@click="toggleMobile"
:class="[theme.toggleButtonBg, 'sm:hidden absolute top-4 right-4 z-50 p-2 rounded focus:outline-none']">
<!-- X Icon -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Navigation Links -->
<ul>
<li v-for="(link, i) in links" :key="i" class="my-1">
<RouterLink
:to="link.route || '#'"
class="flex items-center p-2 transition-colors"
:class="[isActive(link) ? theme.sidebarActive : theme.sidebarHover, theme.sidebarLink]">
<span class="flex-shrink-0" :class="minimized ? 'mx-auto' : 'mr-3'">
<!-- Render icon provided in the link -->
<component :is="link.icon" class="w-6 h-6" />
</span>
<span v-if="!minimized" class="flex-1">
{{ link.title }}
</span>
</RouterLink>
<!-- Sub-links; visible only when the sidebar is not minimized -->
<ul v-if="link.subLinks && link.subLinks.length" v-show="!minimized" class="ml-8">
<li v-for="(sub, j) in link.subLinks" :key="j" class="my-1">
<RouterLink
:to="sub.route || '#'"
class="flex items-center p-2 transition-colors"
:class="[isActive(sub) ? theme.sidebarActive : theme.sidebarHover, theme.sidebarLink]">
<span v-if="sub.icon" class="flex-shrink-0 mr-3">
<component :is="sub.icon" class="w-5 h-5" />
</span>
<span>{{ sub.title }}</span>
</RouterLink>
</li>
</ul>
</li>
</ul>
</nav>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
// Define the interface for navigation links.
export interface NavLink {
title: string;
icon?: any; // Vue component, SVG, or any renderable element.
route?: string;
subLinks?: Omit<NavLink, 'subLinks'>[];
}
// Define the interface for theming the component.
export interface Theme {
// Sidebar container background and text colors.
sidebarBg?: string; // e.g. "bg-gray-800"
sidebarText?: string; // e.g. "text-white"
// Classes for links (default state)
sidebarLink?: string; // e.g. "rounded"
// Classes on hover state
sidebarHover?: string; // e.g. "hover:bg-gray-700"
// Classes for the active link
sidebarActive?: string; // e.g. "bg-gray-900"
// Toggle button (used for mobile hamburger & desktop minimize toggle)
toggleButtonBg?: string; // e.g. "bg-gray-700"
hamburgerButtonBg?: string;
}
// Accept props: links are required; theme is optional.
const props = defineProps<{
links: NavLink[];
theme?: Partial<Theme>;
}>();
// Provide default theme values.
const defaultTheme: Theme = {
sidebarBg: "bg-gray-800",
sidebarText: "text-white",
sidebarLink: "",
sidebarHover: "hover:bg-gray-700",
sidebarActive: "bg-gray-900",
toggleButtonBg: "bg-gray-700",
hamburgerButtonBg: "bg-white"
};
// Merge provided theme with defaults.
const theme = {
...defaultTheme,
...props.theme
};
// Get current route to highlight active links.
const route = useRoute();
// Local state: mobile sidebar open/closed and desktop minimized state.
const mobileOpen = ref(false);
const minimized = ref(false);
// Toggle mobile sidebar visibility.
const toggleMobile = () => {
mobileOpen.value = !mobileOpen.value;
};
// Toggle desktop minimized state.
const toggleMinimized = () => {
minimized.value = !minimized.value;
};
// Check if a given link (or any of its sub-links) is active based on the current route.
const isActive = (link: NavLink) => {
if (link.route && route.path === link.route) return true;
if (link.subLinks) {
return link.subLinks.some(sub => sub.route === route.path);
}
return false;
};
// Compute sidebar classes with a 200ms transition for both mobile sliding and desktop width changes.
const sidebarClasses = computed(() => {
let classes = `${theme.sidebarBg} ${theme.sidebarText} transition-all duration-200 overflow-y-auto shadow-lg z-30`;
// On mobile: fixed positioning to overlay content.
classes += " fixed sm:static top-0 h-screen";
// Mobile: animate slide in/out.
classes += mobileOpen.value ? " transform translate-x-0" : " transform -translate-x-full";
// On desktop, ensure sidebar is visible.
classes += " sm:translate-x-0";
// Animate width change on desktop for minimized/expanded states.
classes += minimized.value ? " w-20" : " w-64";
return classes;
});
</script>
<style scoped>
/* Styling for internal scrolling within the sidebar */
nav::-webkit-scrollbar {
width: 6px;
}
nav::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.3);
border-radius: 3px;
}
</style>

View File

@ -0,0 +1,21 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
export const useAuthenticationStore = defineStore('authentication', () => {
// TODO: this should be replaced with a request to the server to check if the user is authenticated
const isAuthenticated = ref(localStorage.getItem('isAuthenticated') === 'true');
function login() {
// TODO: make a request to the server to authenticate the user
isAuthenticated.value = true;
localStorage.setItem('isAuthenticated', 'true');
}
function logout() {
// TODO: make a request to the server to log out the user
isAuthenticated.value = false;
localStorage.setItem('isAuthenticated', 'false');
}
return { isAuthenticated, login, logout };
});

View File

@ -0,0 +1,76 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthenticationStore } from '@/stores/authentication.ts'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/login',
name: 'Login',
component: () => import('../layouts/Login.vue'), // Lazy-loaded
meta: { public: true }, // Indicates this route doesnt require auth
children: [
{
path: '',
name: 'Password',
component: () => import('../views/Password.vue'),
},
],
},
{
path: '/',
component: () => import('../layouts/MainLayout.vue'), // Protected layout
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Home',
component: () => import('../views/HomeView.vue'),
},
{
path: '/overview',
name: 'Overview',
component: () => import('../views/AboutView.vue'),
},
{
path: '/settings',
name: 'Settings',
component: () => import('../views/HomeView.vue'),
},
],
},
{
path: '/logout',
name: 'Logout',
meta: { public: true },
redirect: '/login',
},
],
});
router.beforeEach(async (to, from, next) => {
const authenticationStore = useAuthenticationStore();
if (to.redirectedFrom?.path === '/logout') {
authenticationStore.logout();
}
const isAuthenticated = await Promise.resolve(authenticationStore.isAuthenticated); /* logic to check auth state, e.g., from a store */
if (to.meta.requiresAuth && !isAuthenticated) {
// save the desired route in localStorage and redirect to login
localStorage.setItem('redirect', to.fullPath);
return next({ path: '/login' });
}
// if (to.meta.public && isAuthenticated) {
// // TODO: Decide if you want to redirect to a different page if the user is already authenticated
// }
const deepLink = localStorage.getItem('redirect');
if (to.meta.requiresAuth && isAuthenticated && !!deepLink) {
// remove redirect from localStorage and redirect to the deep link
localStorage.removeItem('redirect');
return next({ path: deepLink });
}
next();
});
export default router

View File

@ -3,9 +3,12 @@ package main
import (
_ "embed"
"fmt"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"os"
"os/exec"
"strings"
"text/template"
)
//go:embed templates/vue/AboutView.vue.tmpl
@ -14,6 +17,39 @@ var aboutViewTemplate string
//go:embed templates/vue/HomeView.vue.tmpl
var homeViewTemplate string
//go:embed templates/vue/SideNavBar.vue.tmpl
var sideNavBarTemplate string
//go:embed templates/vue/Header.vue.tmpl
var headerTemplateSource string
//go:embed templates/vue/LoginLayout.vue.tmpl
var loginLayoutTemplate string
//go:embed templates/vue/MainLayout.vue.tmpl
var mainLayoutTemplate string
//go:embed templates/vue/Password.vue.tmpl
var passwordTemplate string
//go:embed templates/vue/authentication.ts.tmpl
var authenticationTemplate string
//go:embed templates/vue/App.vue.tmpl
var vueAppTemplate string
//go:embed templates/vue/router-index.ts.tmpl
var routerIndexTemplate string
//go:embed templates/vue/IconArrowFromShapeRight.vue.tmpl
var iconArrowFromShapeRightTemplate string
//go:embed templates/vue/IconHome.vue.tmpl
var iconHomeTemplate string
//go:embed templates/vue/IconGear.vue.tmpl
var iconGearTemplate string
func setupWebapp(name string) error {
fmt.Println("Setting up webapp with Vue 3, TypeScript, Vue Router, Pinia, and ESLint with Prettier")
cmd := exec.Command("npm", "create", "vue@latest", "--", "--ts", "--router", "--pinia", "--eslint-with-prettier", name)
@ -67,6 +103,12 @@ func setupWebapp(name string) error {
"src/assets/logo.svg",
})
// make sure "src/layouts/" exists
err = os.Mkdir("src/layouts", 0755)
if err != nil {
return fmt.Errorf("error creating layouts directory | %w", err)
}
// create a new about view from the aboutViewTemplate
err = os.WriteFile("src/views/AboutView.vue", []byte(aboutViewTemplate), 0644)
if err != nil {
@ -78,6 +120,48 @@ func setupWebapp(name string) error {
return fmt.Errorf("error writing new HomeView.vue | %w", err)
}
// create src/components/SideNavBar.vue from a template
err = os.WriteFile("src/components/SideNavBar.vue", []byte(sideNavBarTemplate), 0644)
if err != nil {
return fmt.Errorf("error writing new SideNavBar.vue | %w", err)
}
titleMaker := cases.Title(language.English)
// create src/components/Header.vue from a template
err = renderTemplate("src/components/Header.vue", headerTemplateSource, map[string]string{"AppName": strings.ToLower(name), "AppNameCaps": titleMaker.String(name)})
if err != nil {
return fmt.Errorf("error writing new Header.vue | %w", err)
}
err = renderTemplate("src/layouts/MainLayout.vue", mainLayoutTemplate, map[string]string{"AppName": strings.ToLower(name), "AppNameCaps": titleMaker.String(name)})
if err != nil {
return fmt.Errorf("error writing new MainLayout.vue | %w", err)
}
// create src/layouts/Login.vue from a template
err = os.WriteFile("src/layouts/Login.vue", []byte(loginLayoutTemplate), 0644)
if err != nil {
return fmt.Errorf("error writing new Login.vue | %w", err)
}
straightCopies := []struct{ src, dest string }{
{vueAppTemplate, "src/App.vue"},
{passwordTemplate, "src/views/Password.vue"},
{authenticationTemplate, "src/stores/authentication.ts"},
{routerIndexTemplate, "src/router/index.ts"},
{iconArrowFromShapeRightTemplate, "src/components/icons/IconArrowFromShapeRight.vue"},
{iconHomeTemplate, "src/components/icons/IconHome.vue"},
{iconGearTemplate, "src/components/icons/IconGear.vue"},
}
for _, srcDest := range straightCopies {
err = os.WriteFile(srcDest.dest, []byte(srcDest.src), 0644)
if err != nil {
return fmt.Errorf("error writing new file %s | %w", srcDest.dest, err)
}
}
fmt.Println("Exiting the new directory")
err = os.Chdir("..")
if err != nil {
@ -87,6 +171,20 @@ func setupWebapp(name string) error {
return nil
}
func renderTemplate(fileLocation string, templateSource string, templateData map[string]string) error {
headerFile, err := os.Create(fileLocation)
if err != nil {
return fmt.Errorf("error creating new file %s | %w", fileLocation, err)
}
defer headerFile.Close()
headerTemplate := template.Must(template.New("header").Parse(templateSource))
err = headerTemplate.Execute(headerFile, templateData)
if err != nil {
return fmt.Errorf("error rendering file %s | %w", fileLocation, err)
}
return nil
}
func setupTailwind() error {
// check that there is a vite.config.ts file and a package.json file
_, err := os.Stat("vite.config.ts")