Merge pull request #13 from IT4Change/boilerplate-function-layout

feat(frontend): boilerplate function & layout
This commit is contained in:
Ulf Gebhardt 2023-11-23 17:50:53 +01:00 committed by GitHub
commit 55604e5907
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 353 additions and 168 deletions

3
.env.dist Normal file
View File

@ -0,0 +1,3 @@
# META
PUBLIC_ENV__META__DEFAULT_TITLE="IT4C"
PUBLIC_ENV__META__DEFAULT_DESCRIPTION="IT4C Frontend Boilerplate"

View File

@ -72,7 +72,7 @@
"import/no-relative-packages": "error",
"import/no-relative-parent-imports": [
"error",
{ "ignore": ["#[src,root,components,pages,assets,plugins,context,types]/*"] }
{ "ignore": ["#[src,root,components,pages,assets,layouts,stores,plugins,context,types]/*"] }
],
"import/no-self-import": "error",
"import/no-unresolved": "error",

3
.gitignore vendored
View File

@ -5,4 +5,5 @@ coverage/
!.vuepress/
.vuepress/.temp/
.vuepress/.cache/
build-storybook.log
build-storybook.log
.env

View File

@ -26,7 +26,7 @@ The project uses `vite` as builder, `vike` to do the SSR. The design framework i
Testing is done with `vitest` and code style is enforced with `eslint`, `remark-cli` and `stylelint`.
This projects utilizes `storybook` to develop frontend components and `vuepress` for static documentation generation.
This projects utilizes `storybook` and `chromatic` to develop, document & test frontend components and `vuepress` for static documentation generation.
## Commands
@ -97,8 +97,8 @@ The following endpoints are provided given the right command is executed or all
## TODO
- [ ] figma
- [ ] feature zähler -> pinia tore
- [ ] tests
- [ ] stories
## Known Problems

9
package-lock.json generated
View File

@ -21,6 +21,7 @@
"cross-env": "^7.0.3",
"express": "^4.18.2",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.0",
"sass": "^1.69.5",
"sass-loader": "^13.3.2",
"sirv": "^2.0.3",
@ -22520,6 +22521,14 @@
}
}
},
"node_modules/pinia-plugin-persistedstate": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-3.2.0.tgz",
"integrity": "sha512-tZbNGf2vjAQcIm7alK40sE51Qu/m9oWr+rEgNm/2AWr1huFxj72CjvpQcIQzMknDBJEkQznCLAGtJTIcLKrKdw==",
"peerDependencies": {
"pinia": "^2.0.0"
}
},
"node_modules/pinia/node_modules/vue-demi": {
"version": "0.14.6",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.6.tgz",

View File

@ -9,20 +9,22 @@
"url": "git+https://github.com/IT4Change/boilerplate-frontend.git"
},
"keywords": [
"npm",
"nodejs",
"vite",
"vike",
"npm",
"docker",
"jq",
"vue",
"vike",
"vuetify",
"pinia",
"storybook",
"eslint",
"vitest",
"vue-i18n",
"eslint",
"remark-cli",
"stylelint",
"vitest",
"storybook",
"vuepress",
"docker",
"remark-cli"
"chromatic"
],
"author": {
"name": "Ulf Gebhardt"
@ -67,6 +69,7 @@
"cross-env": "^7.0.3",
"express": "^4.18.2",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.0",
"sass": "^1.69.5",
"sass-loader": "^13.3.2",
"sirv": "^2.0.3",
@ -133,6 +136,8 @@
"#components/*": "./src/components/*",
"#pages/*": "./src/pages/*",
"#assets/*": "./src/assets/*",
"#layouts/*": "./src/layouts/*",
"#stores/*": "./src/stores/*",
"#plugins/*": "./renderer/plugins/*",
"#context/*": "./renderer/context/*",
"#types/*": "./types/*"

View File

@ -1,17 +1,30 @@
import { createApp } from './app'
import type { PageContextClient } from '#types/PageContext'
import type { PageContext, VikePageContext } from '#types/PageContext'
// This render() hook only supports SSR, see https://vike.dev/render-modes for how to modify render() to support SPA
async function render(pageContext: PageContextClient) {
const { Page, pageProps } = pageContext
if (!Page) throw new Error('Client-side render() hook expects pageContext.Page to be defined')
const app = createApp(Page, pageProps, pageContext)
app.mount('#app')
let app: ReturnType<typeof createApp>
async function render(pageContext: VikePageContext & PageContext) {
if (!app) {
app = createApp(pageContext)
app.mount('#app')
} else {
app.changePage(pageContext)
}
}
/* To enable Client-side Routing:
export const clientRouting = true
// !! WARNING !! Before doing so, read https://vike.dev/clientRouting */
function onHydrationEnd() {
// console.log('Hydration finished; page is now interactive.')
}
function onPageTransitionStart() {
// console.log('Page transition start')
}
function onPageTransitionEnd() {
// console.log('Page transition end')
}
export const clientRouting = true
export const prefetchStaticAssets = 'viewport'
export { render }
export { onHydrationEnd }
export { onPageTransitionStart }
export { onPageTransitionEnd }

View File

@ -2,27 +2,25 @@ import { renderToString as renderToString_ } from '@vue/server-renderer'
import { escapeInject, dangerouslySkipEscape } from 'vike/server'
import logoUrl from '#assets/favicon.ico'
import { META } from '#src/env'
import { createApp } from './app'
import type { PageContextServer } from '#types/PageContext'
import type { PageContextServer, PageContext } from '#types/PageContext'
import type { App } from 'vue'
// See https://vike.dev/data-fetching
export const passToClient = ['pageProps', 'urlPathname']
export const passToClient = ['pageProps', /* 'urlPathname', */ 'routeParams']
async function render(pageContext: PageContextServer) {
const { Page, pageProps } = pageContext
// This render() hook only supports SSR, see https://vike.dev/render-modes for how to modify render() to support SPA
if (!Page) throw new Error('My render() hook expects pageContext.Page to be defined')
const app = createApp(Page, pageProps, pageContext)
async function render(pageContext: PageContextServer & PageContext) {
const app = createApp(pageContext, false)
const appHtml = await renderToString(app)
// See https://vike.dev/head
const { documentProps } = pageContext.exports
const title = (documentProps && documentProps.title) || 'Vite SSR app'
const desc = (documentProps && documentProps.description) || 'App using Vite + Vike'
const title = (documentProps && documentProps.title) || META.DEFAULT_TITLE
const desc = (documentProps && documentProps.description) || META.DEFAULT_DESCRIPTION
const documentHtml = escapeInject`<!DOCTYPE html>
<html lang="en">

View File

@ -1,39 +1,75 @@
import { createSSRApp, defineComponent, h } from 'vue'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import { createSSRApp, defineComponent, h, markRaw, reactive } from 'vue'
import PageShell from '#components/PageShell.vue'
import { setPageContext } from '#context/usePageContext'
import i18n from '#plugins/i18n'
import pinia from '#plugins/pinia'
import CreateVuetify from '#plugins/vuetify'
import { Page } from '#types/Page'
import { PageProps } from '#types/PageProps'
import type { PageContext } from '#types/PageContext'
import type { Component } from '#types/Component'
import type { PageContext, VikePageContext } from '#types/PageContext'
function createApp(Page: Page, pageProps: PageProps | undefined, pageContext: PageContext) {
const PageWithLayout = defineComponent({
const vuetify = CreateVuetify(i18n)
function createApp(pageContext: VikePageContext & PageContext, isClient = true) {
let rootComponent: Component
const PageWithWrapper = defineComponent({
data: () => ({
Page: markRaw(pageContext.Page),
pageProps: markRaw(pageContext.pageProps || {}),
isClient,
}),
created() {
// eslint-disable-next-line @typescript-eslint/no-this-alias
rootComponent = this
},
render() {
return h(
PageShell,
{},
{
default() {
return h(Page, pageProps || {})
default: () => {
return h(this.Page, this.pageProps)
},
},
)
},
})
const app = createSSRApp(PageWithLayout)
if (isClient) {
pinia.use(piniaPluginPersistedstate)
}
const app = createSSRApp(PageWithWrapper)
app.use(pinia)
app.use(i18n)
app.use(CreateVuetify(i18n))
app.use(vuetify)
objectAssign(app, {
changePage: (pageContext: VikePageContext & PageContext) => {
Object.assign(pageContextReactive, pageContext)
rootComponent.Page = markRaw(pageContext.Page)
rootComponent.pageProps = markRaw(pageContext.pageProps || {})
},
})
// When doing Client Routing, we mutate pageContext (see usage of `app.changePage()` in `_default.page.client.js`).
// We therefore use a reactive pageContext.
const pageContextReactive = reactive(pageContext)
// Make pageContext available from any Vue component
setPageContext(app, pageContext)
setPageContext(app, pageContextReactive)
return app
}
// Same as `Object.assign()` but with type inference
function objectAssign<Obj extends object, ObjAddendum>(
obj: Obj,
objAddendum: ObjAddendum,
): asserts obj is Obj & ObjAddendum {
Object.assign(obj, objAddendum)
}
export { createApp }

View File

@ -3,11 +3,11 @@
import { inject } from 'vue'
import { PageContext } from '#types/PageContext'
import { PageContext, VikePageContext } from '#types/PageContext'
import type { App, InjectionKey } from 'vue'
const key: InjectionKey<PageContext> = Symbol(undefined)
const key: InjectionKey<VikePageContext & PageContext> = Symbol(undefined)
function usePageContext() {
const pageContext = inject(key)
@ -15,7 +15,7 @@ function usePageContext() {
return pageContext
}
function setPageContext(app: App, pageContext: PageContext) {
function setPageContext(app: App, pageContext: VikePageContext & PageContext) {
app.provide(key, pageContext)
}

View File

@ -1,3 +1,4 @@
import { createPinia } from 'pinia'
export default createPinia()
const pinia = createPinia()
export default pinia

View File

@ -1,7 +1,7 @@
import { mount, config } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import ClickCounter from './ClickCounter.vue'
import ClickCounter from './ClickCounter.delete.vue'
describe('clickCounter', () => {
const wrapper = mount(ClickCounter)

View File

@ -0,0 +1,13 @@
<template>
<template v-if="isMounted"><slot /></template>
<template v-else><slot name="placeholder" /></template>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const isMounted = ref(false)
onMounted(() => {
isMounted.value = true
})
</script>

View File

@ -1,66 +1,5 @@
<template>
<v-app>
<v-container>
<div class="layout">
<div class="navigation">
<a href="/" class="logo">
<img :src="Logo" height="64" width="64" alt="logo" />
</a>
<VikeLink href="/">Home</VikeLink>
<VikeLink href="/about">About</VikeLink>
</div>
<div class="content"><slot /></div>
</div>
</v-container>
<slot />
</v-app>
</template>
<script lang="ts" setup>
import Logo from '#assets/it4c-logo2-clean-bg_alpha-128x128.png'
import VikeLink from './VikeLink.vue'
</script>
<style>
body {
margin: 0;
font-family: sans-serif;
}
* {
box-sizing: border-box;
}
a {
text-decoration: none;
}
</style>
<style scoped>
.layout {
display: flex;
max-width: 900px;
margin: auto;
}
.content {
min-height: 100vh;
padding: 20px;
padding-bottom: 50px;
border-left: 2px solid #eee;
}
.navigation {
display: flex;
flex-direction: column;
flex-shrink: 0;
align-items: center;
padding: 20px;
line-height: 1.8em;
}
.logo {
margin-top: 20px;
margin-bottom: 10px;
}
</style>

View File

@ -0,0 +1,17 @@
<template>
<v-btn :variant="isRouteSelected($attrs.href as string) ? 'tonal' : 'flat'">
<slot />
</v-btn>
</template>
<script lang="ts" setup>
import { usePageContext } from '#context/usePageContext'
const pageContext = usePageContext()
const isRouteSelected = (href: string) => {
if (href === '/app') {
return pageContext.urlPathname.indexOf(href) === 0
}
return pageContext.urlPathname === href
}
</script>

View File

@ -1,19 +0,0 @@
<template>
<a :class="{ active: pageContext.urlPathname === $attrs.href }">
<slot />
</a>
</template>
<script lang="ts" setup>
import { usePageContext } from '#context/usePageContext'
const pageContext = usePageContext()
</script>
<style scoped>
a {
padding: 3px 10px;
}
a.active {
background-color: #eee;
}
</style>

View File

@ -0,0 +1,7 @@
<template>
<v-avatar color="#333" class="ma-2 pa-1" :image="Logo" size="48" />
</template>
<script lang="ts" setup>
import Logo from '#assets/it4c-logo2-clean-bg_alpha-128x128.png'
</script>

View File

@ -0,0 +1,16 @@
<template>
<v-app-bar flat>
<LogoAvatar />
<v-container class="mx-auto d-flex align-center justify-center">
<VikeBtn href="/">Home</VikeBtn>
<VikeBtn href="/app">App</VikeBtn>
<VikeBtn href="/about">About</VikeBtn>
</v-container>
</v-app-bar>
</template>
<script lang="ts" setup>
import VikeBtn from '#components/VikeBtn.vue'
import LogoAvatar from './LogoAvatar.vue'
</script>

7
src/env.ts Normal file
View File

@ -0,0 +1,7 @@
const META = {
DEFAULT_TITLE: import.meta.env.PUBLIC_ENV__META__DEFAULT_TITLE ?? 'IT4C',
DEFAULT_DESCRIPTION:
import.meta.env.PUBLIC_ENV__META__DEFAULT_DESCRIPTION ?? 'IT4C Frontend Boilerplate',
}
export { META }

View File

@ -0,0 +1,28 @@
<template>
<TopMenu />
<v-main class="bg-grey-lighten-3">
<v-container>
<v-row>
<v-col v-if="slots.sidemenu" cols="2">
<v-sheet rounded>
<slot name="sidemenu"></slot>
</v-sheet>
</v-col>
<v-col>
<v-sheet rounded class="pa-3">
<slot />
</v-sheet>
</v-col>
</v-row>
</v-container>
</v-main>
</template>
<script lang="ts" setup>
import { useSlots } from 'vue'
import TopMenu from '#components/menu/TopMenu.vue'
const slots = useSlots()
</script>

View File

@ -1,13 +1,20 @@
<template>
<h1>About</h1>
<p>Example of app using Vike.</p>
<DefaultLayout>
<h1>About</h1>
<p>
To find out more about this boilerplate you can look at the sources:
<a href="https://github.com/IT4Change/boilerplate-frontend/" target="_blank"
>github.com/IT4Change/boilerplate-frontend</a
>.
</p>
<br />
<p>
Want to get in touch? Find out how on our
<a href="https://it4c.dev" target="_blank">website</a>
</p>
</DefaultLayout>
</template>
<style>
code {
padding: 3px 5px;
font-family: monospace;
background-color: #eaeaea;
border-radius: 4px;
}
</style>
<script lang="ts" setup>
import DefaultLayout from '#layouts/DefaultLayout.vue'
</script>

View File

@ -0,0 +1,18 @@
import { resolveRoute } from 'vike/routing'
import { PageContext } from '#types/PageContext'
export default (pageContext: PageContext) => {
{
const result = resolveRoute('/app', pageContext.urlPathname)
if (result.match) {
return result
}
}
const result = resolveRoute('/app/@page', pageContext.urlPathname)
if (!['inc', 'reset'].includes(result.routeParams.page)) {
return false
}
return result
}

View File

@ -0,0 +1,13 @@
import type { PageContextBuiltInServer } from 'vike/types'
export { onBeforeRender }
async function onBeforeRender(pageContext: PageContextBuiltInServer) {
const { page } = pageContext.routeParams
const pageProps = { page }
return {
pageContext: {
pageProps,
},
}
}

View File

@ -0,0 +1,46 @@
<template>
<DefaultLayout>
<template #sidemenu>
<v-list rounded>
<v-list-item link title="Value" :active="page === null" href="/app"></v-list-item>
<v-list-item link title="Increase" :active="page === 'inc'" href="/app/inc"></v-list-item>
<v-divider class="my-2"></v-divider>
<v-list-item link title="Reset" :active="page === 'reset'" href="/app/reset"></v-list-item>
</v-list>
</template>
<template #default>
<div v-if="page === null">
<h1>The Counter</h1>
<p>
The current value of the counter is:
<ClientOnly
><b>{{ counter.count }}</b></ClientOnly
>
</p>
</div>
<div v-else-if="page === 'inc'">
<h1>Increase the Counter</h1>
<ClientOnly>
<v-btn elevation="2" @click="counter.increment()">{{ counter.count }}</v-btn>
</ClientOnly>
</div>
<div v-else-if="page === 'reset'">
<h1>Reset the Counter</h1>
<ClientOnly>
<v-btn elevation="2" @click="counter.reset()">{{ counter.count }}</v-btn>
</ClientOnly>
</div>
</template>
</DefaultLayout>
</template>
<script lang="ts" setup>
import ClientOnly from '#components/ClientOnly.vue'
import DefaultLayout from '#layouts/DefaultLayout.vue'
import { useCounterStore } from '#stores/counter'
const counter = useCounterStore()
defineProps({ page: { type: String, default: null } })
</script>

View File

@ -1,12 +1,19 @@
<template>
<h1>Welcome</h1>
This page is:
<ul>
<li>Rendered to HTML.</li>
<li>Interactive. <ClickCounter /></li>
</ul>
<DefaultLayout>
<h1>IT4C Frontend Boilerplate</h1>
<p>Welcome to this minimal starter for frontends.</p>
<br />
<p>This is just a basic example to demonstrate things - nothing fancy.</p>
<br />
<p>In the App Section you will find a counter example utilizing the local storage.</p>
<br />
<p>Happy Coding <v-icon icon="mdi-heart" color="red" /></p>
<br />
<p>Sincerly</p>
<p>Your IT Team For Change</p>
</DefaultLayout>
</template>
<script lang="ts" setup>
import ClickCounter from '#components/ClickCounter.vue'
import DefaultLayout from '#layouts/DefaultLayout.vue'
</script>

14
src/stores/counter.ts Normal file
View File

@ -0,0 +1,14 @@
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
actions: {
increment() {
this.count++
},
reset() {
this.count = 0
},
},
persist: true,
})

View File

@ -16,6 +16,8 @@
"#components/*": ["./src/components/*"],
"#pages/*": ["./src/pages/*"],
"#assets/*": ["./src/assets/*"],
"#layouts/*": ["./src/layouts/*"],
"#stores/*": ["./src/stores/*"],
"#plugins/*": ["./renderer/plugins/*"],
"#context/*": ["./renderer/context/*"],
"#types/*": ["./types/*"]

2
types/Component.ts Normal file
View File

@ -0,0 +1,2 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Component = any

View File

@ -3,31 +3,31 @@ import { PageProps } from '#types/PageProps'
export type {
PageContextServer,
/*
// When using Client Routing https://vike.dev/clientRouting
PageContextClient,
PageContext,
/ */
// When using Client Routing https://vike.dev/clientRouting
PageContextClient,
PageContext as VikePageContext,
PageContextBuiltInClientWithClientRouting as PageContextBuiltInClient,
// When using Server Routing
/*
PageContextClientWithServerRouting as PageContextClient,
PageContextWithServerRouting as PageContext,
*/
//* /
} from 'vike/types'
// https://vike.dev/pageContext#typescript
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Vike {
interface PageContext {
Page: Page
pageProps?: PageProps
urlPathname: string
exports: {
documentProps?: {
title?: string
description?: string
}
}
export type PageContext = {
Page: Page
pageProps?: PageProps
urlPathname: string
exports: {
documentProps?: {
title?: string
description?: string
}
}
documentProps?: {
title: string
description?: string
}
}

View File

@ -27,6 +27,8 @@ const config: UserConfig = {
'#components': path.join(__dirname, '/src/components'),
'#pages': path.join(__dirname, '/src/pages'),
'#assets': path.join(__dirname, '/src/assets'),
'#layouts': path.join(__dirname, '/src/layouts'),
'#stores': path.join(__dirname, '/src/stores'),
'#plugins': path.join(__dirname, '/renderer/plugins'),
'#context': path.join(__dirname, '/renderer/context'),
'#types': path.join(__dirname, '/types'),

View File

@ -12,10 +12,10 @@ export default mergeConfig(
coverage: {
all: true,
include: ['src/**/*.{js,jsx,ts,tsx,vue}'],
lines: 2,
lines: 1,
functions: 0,
branches: 8,
statements: 2,
branches: 4,
statements: 1,
// 100: true,
},
},