feat: initialise Vue 3 SPA template
- Add Tailwind CSS v4 with light/dark theme support - Add layout system (default, sidebar, blank) - Add mock authentication with Pinia store and route guards - Add error pages (404, 500) and example views - Add Vitest with 10 example tests across stores - Configure Prettier formatting and ESLint linting
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
max_line_length = 100
|
||||
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
### Linux
|
||||
*~
|
||||
.fuse_hidden*
|
||||
.directory
|
||||
.Trash-*
|
||||
.nfs*
|
||||
nohup.out
|
||||
|
||||
### macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
._*
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
### Windows
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
Thumbs.db:encryptable
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
### JetBrains
|
||||
# Covers JetBrains IDEs: IntelliJ, GoLand, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
|
||||
# AWS User-specific
|
||||
.idea/**/aws.xml
|
||||
|
||||
# Generated files
|
||||
.idea/**/contentModel.xml
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
|
||||
# Gradle
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/artifacts
|
||||
# .idea/compiler.xml
|
||||
# .idea/jarRepositories.xml
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
# *.iml
|
||||
# *.ipr
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# SonarLint plugin
|
||||
.idea/sonarlint/
|
||||
# see https://community.sonarsource.com/t/is-the-file-idea-idea-idea-sonarlint-xml-intended-to-be-under-source-control/121119
|
||||
.idea/sonarlint.xml
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
# Editor-based HTTP Client
|
||||
.idea/httpRequests
|
||||
http-client.private.env.json
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
# Apifox Helper cache
|
||||
.idea/.cache/.Apifox_Helper
|
||||
.idea/ApifoxUploaderProjectSetting.xml
|
||||
|
||||
# Github Copilot persisted session migrations, see: https://github.com/microsoft/copilot-intellij-feedback/issues/712#issuecomment-3322062215
|
||||
.idea/**/copilot.data.migration.*.xml
|
||||
|
||||
### Node
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
.output
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp directory
|
||||
.temp
|
||||
|
||||
# Sveltekit cache directory
|
||||
.svelte-kit/
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# Firebase cache directory
|
||||
.firebase/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# pnpm
|
||||
.pnpm-store
|
||||
|
||||
# yarn v3
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
# Vite files
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
.vite/
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
|
||||
"env": {
|
||||
"browser": true
|
||||
},
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": false,
|
||||
"singleQuote": false,
|
||||
"quoteProps": "as-needed",
|
||||
"jsxSingleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": true,
|
||||
"arrowParens": "always",
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"oxc.oxc-vscode"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# vue-template
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
pnpm lint
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import 'vue-router'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
requiresAuth?: boolean
|
||||
guestOnly?: boolean
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||
import skipFormatting from 'eslint-config-prettier/flat'
|
||||
|
||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{vue,ts,mts,tsx}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
...pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
|
||||
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||
|
||||
skipFormatting,
|
||||
)
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en-GB">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>Vue SPA Template</title>
|
||||
</head>
|
||||
<body class="bg-surface text-foreground">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "vue-template",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint": "run-s lint:*",
|
||||
"lint:oxlint": "oxlint . --fix",
|
||||
"lint:eslint": "eslint . --fix --cache",
|
||||
"format": "prettier --write src/",
|
||||
"test:unit": "vitest",
|
||||
"test:unit:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"pinia": "^3.0.4",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/node": "^24.12.2",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/eslint-config-typescript": "^14.7.0",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.60.0",
|
||||
"eslint-plugin-vue": "~10.8.0",
|
||||
"jiti": "^2.6.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxlint": "~1.60.0",
|
||||
"prettier": "^3.8.4",
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.0.8",
|
||||
"vite-plugin-vue-devtools": "^8.1.1",
|
||||
"vitest": "^4.1.9",
|
||||
"vue-tsc": "^3.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
}
|
||||
Generated
+4278
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,5 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--color-surface: var(--colour-surface);
|
||||
--color-surface-alt: var(--colour-surface-alt);
|
||||
--color-foreground: var(--colour-foreground);
|
||||
--color-muted: var(--colour-muted);
|
||||
--color-primary: var(--colour-primary);
|
||||
--color-primary-hover: var(--colour-primary-hover);
|
||||
--color-border: var(--colour-border);
|
||||
--color-error: var(--colour-error);
|
||||
}
|
||||
|
||||
:root {
|
||||
--colour-surface: #ffffff;
|
||||
--colour-surface-alt: #f9fafb;
|
||||
--colour-foreground: #111827;
|
||||
--colour-muted: #6b7280;
|
||||
--colour-primary: #2563eb;
|
||||
--colour-primary-hover: #1d4ed8;
|
||||
--colour-border: #d1d5db;
|
||||
--colour-error: #dc2626;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--colour-surface: #111827;
|
||||
--colour-surface-alt: #1f2937;
|
||||
--colour-foreground: #f9fafb;
|
||||
--colour-muted: #9ca3af;
|
||||
--colour-primary: #3b82f6;
|
||||
--colour-primary-hover: #60a5fa;
|
||||
--colour-border: #374151;
|
||||
--colour-error: #ef4444;
|
||||
color-scheme: dark;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { useTheme } from "@/stores/theme"
|
||||
|
||||
const { mode, toggle } = useTheme()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer rounded-md border border-border bg-surface-alt p-2 text-foreground hover:bg-border transition-colours"
|
||||
:aria-label="mode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||
@click="toggle">
|
||||
<span v-if="mode === 'dark'" aria-hidden="true">☀️</span>
|
||||
<span v-else aria-hidden="true">🌙</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from "vue-router"
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center bg-surface text-foreground p-4">
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView, RouterLink } from "vue-router"
|
||||
import { useAuthStore } from "@/stores/auth"
|
||||
import ThemeToggle from "@/components/ThemeToggle.vue"
|
||||
|
||||
const auth = useAuthStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen flex-col bg-surface text-foreground">
|
||||
<header class="border-b border-border bg-surface-alt">
|
||||
<nav class="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
|
||||
<div class="flex items-center gap-6">
|
||||
<RouterLink to="/" class="text-lg font-semibold">Vue Template</RouterLink>
|
||||
<RouterLink
|
||||
to="/dashboard"
|
||||
class="text-sm text-muted hover:text-foreground transition-colours">
|
||||
Dashboard
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<ThemeToggle />
|
||||
<template v-if="auth.isAuthenticated">
|
||||
<span class="text-sm text-muted">{{ auth.user?.displayName }}</span>
|
||||
<button
|
||||
class="cursor-pointer rounded-md bg-primary px-3 py-1.5 text-sm text-white hover:bg-primary-hover transition-colours"
|
||||
@click="auth.logout()">
|
||||
Log out
|
||||
</button>
|
||||
</template>
|
||||
<RouterLink
|
||||
v-else
|
||||
to="/login"
|
||||
class="text-sm text-primary hover:text-primary-hover transition-colours">
|
||||
Log in
|
||||
</RouterLink>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto w-full max-w-5xl flex-1 px-4 py-8">
|
||||
<RouterView />
|
||||
</main>
|
||||
|
||||
<footer class="border-t border-border bg-surface-alt py-4 text-center text-sm text-muted">
|
||||
© {{ new Date().getFullYear() }} Vue SPA Template
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView, RouterLink } from "vue-router"
|
||||
import { useAuthStore } from "@/stores/auth"
|
||||
import ThemeToggle from "@/components/ThemeToggle.vue"
|
||||
|
||||
const auth = useAuthStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen bg-surface text-foreground">
|
||||
<aside class="flex w-56 shrink-0 flex-col border-r border-border bg-surface-alt p-4">
|
||||
<RouterLink to="/" class="mb-6 text-lg font-semibold">Vue Template</RouterLink>
|
||||
<nav class="flex flex-col gap-2">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="rounded-md px-3 py-2 text-sm text-muted hover:bg-surface hover:text-foreground transition-colours">
|
||||
Home
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/dashboard"
|
||||
class="rounded-md px-3 py-2 text-sm text-muted hover:bg-surface hover:text-foreground transition-colours">
|
||||
Dashboard
|
||||
</RouterLink>
|
||||
</nav>
|
||||
<div class="mt-auto flex flex-col gap-2 border-t border-border pt-4">
|
||||
<ThemeToggle />
|
||||
<template v-if="auth.isAuthenticated">
|
||||
<span class="text-xs text-muted">{{ auth.user?.displayName }}</span>
|
||||
<button
|
||||
class="cursor-pointer rounded-md bg-primary px-3 py-1.5 text-sm text-white hover:bg-primary-hover transition-colours"
|
||||
@click="auth.logout()">
|
||||
Log out
|
||||
</button>
|
||||
</template>
|
||||
<RouterLink
|
||||
v-else
|
||||
to="/login"
|
||||
class="text-sm text-primary hover:text-primary-hover transition-colours">
|
||||
Log in
|
||||
</RouterLink>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="flex-1 overflow-auto p-6">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import "./assets/main.css"
|
||||
import "@/stores/theme"
|
||||
|
||||
import { createApp } from "vue"
|
||||
import { createPinia } from "pinia"
|
||||
|
||||
import App from "./App.vue"
|
||||
import router from "./router"
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount("#app")
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createRouter, createWebHistory } from "vue-router"
|
||||
import { useAuthStore } from "@/stores/auth"
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
component: () => import("@/layouts/DefaultLayout.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "home",
|
||||
component: () => import("@/views/HomeView.vue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
component: () => import("@/layouts/SidebarLayout.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "dashboard",
|
||||
name: "dashboard",
|
||||
component: () => import("@/views/DashboardView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
component: () => import("@/layouts/BlankLayout.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "login",
|
||||
name: "login",
|
||||
component: () => import("@/views/LoginView.vue"),
|
||||
meta: { guestOnly: true },
|
||||
},
|
||||
{
|
||||
path: "500",
|
||||
name: "server-error",
|
||||
component: () => import("@/views/ServerErrorView.vue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
name: "not-found",
|
||||
component: () => import("@/views/NotFoundView.vue"),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||
next({ name: "login", query: { redirect: to.fullPath } })
|
||||
} else if (to.meta.guestOnly && auth.isAuthenticated) {
|
||||
next({ name: "home" })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import { setActivePinia, createPinia } from "pinia"
|
||||
import { useAuthStore } from "@/stores/auth"
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key]
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {}
|
||||
}),
|
||||
}
|
||||
})()
|
||||
|
||||
Object.defineProperty(window, "localStorage", { value: localStorageMock })
|
||||
|
||||
describe("Auth store", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorageMock.clear()
|
||||
})
|
||||
|
||||
it("is not authenticated initially", () => {
|
||||
const auth = useAuthStore()
|
||||
expect(auth.isAuthenticated).toBe(false)
|
||||
expect(auth.token).toBeNull()
|
||||
})
|
||||
|
||||
it("logs in successfully with valid credentials", async () => {
|
||||
const auth = useAuthStore()
|
||||
await auth.login("testuser", "password")
|
||||
expect(auth.isAuthenticated).toBe(true)
|
||||
expect(auth.token).toBeTruthy()
|
||||
expect(auth.user?.username).toBe("testuser")
|
||||
})
|
||||
|
||||
it("throws an error when username is empty", async () => {
|
||||
const auth = useAuthStore()
|
||||
await expect(auth.login("", "password")).rejects.toThrow("Username is required.")
|
||||
})
|
||||
|
||||
it("logs out and clears state", async () => {
|
||||
const auth = useAuthStore()
|
||||
await auth.login("testuser", "password")
|
||||
auth.logout()
|
||||
expect(auth.isAuthenticated).toBe(false)
|
||||
expect(auth.token).toBeNull()
|
||||
expect(auth.user).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest"
|
||||
import { setActivePinia, createPinia } from "pinia"
|
||||
import { useCounterStore } from "@/stores/counter"
|
||||
|
||||
describe("Counter store", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it("starts at zero", () => {
|
||||
const counter = useCounterStore()
|
||||
expect(counter.count).toBe(0)
|
||||
})
|
||||
|
||||
it("increments the count", () => {
|
||||
const counter = useCounterStore()
|
||||
counter.increment()
|
||||
expect(counter.count).toBe(1)
|
||||
})
|
||||
|
||||
it("computes doubleCount correctly", () => {
|
||||
const counter = useCounterStore()
|
||||
counter.increment()
|
||||
counter.increment()
|
||||
expect(counter.doubleCount).toBe(4)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key]
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {}
|
||||
}),
|
||||
}
|
||||
})()
|
||||
|
||||
Object.defineProperty(window, "localStorage", { value: localStorageMock })
|
||||
|
||||
// Must be set before importing the theme module — it calls matchMedia at module level
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
})),
|
||||
})
|
||||
|
||||
describe("Theme store", () => {
|
||||
let useTheme: typeof import("@/stores/theme").useTheme
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import("@/stores/theme")
|
||||
useTheme = mod.useTheme
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear()
|
||||
document.documentElement.classList.remove("dark")
|
||||
})
|
||||
|
||||
it("toggles between light and dark", () => {
|
||||
const { mode, toggle } = useTheme()
|
||||
const initial = mode.value
|
||||
toggle()
|
||||
expect(mode.value).not.toBe(initial)
|
||||
})
|
||||
|
||||
it("persists the chosen mode to localStorage", () => {
|
||||
const { setMode } = useTheme()
|
||||
setMode("dark")
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith("theme-mode", "dark")
|
||||
})
|
||||
|
||||
it("applies the dark class to the html element", () => {
|
||||
const { setMode } = useTheme()
|
||||
setMode("dark")
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true)
|
||||
setMode("light")
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ref, computed } from "vue"
|
||||
import { defineStore } from "pinia"
|
||||
|
||||
interface User {
|
||||
username: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
const TOKEN_KEY = "auth-token"
|
||||
|
||||
export const useAuthStore = defineStore("auth", () => {
|
||||
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
|
||||
const user = ref<User | null>(null)
|
||||
const isAuthenticated = computed(() => token.value !== null)
|
||||
|
||||
async function login(username: string, _password: string): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
if (!username.trim()) {
|
||||
throw new Error("Username is required.")
|
||||
}
|
||||
const mockToken = `mock-jwt-${username}-${Date.now()}`
|
||||
token.value = mockToken
|
||||
user.value = { username, displayName: username }
|
||||
localStorage.setItem(TOKEN_KEY, mockToken)
|
||||
}
|
||||
|
||||
function logout(): void {
|
||||
token.value = null
|
||||
user.value = null
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
return { token, user, isAuthenticated, login, logout }
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ref, computed } from "vue"
|
||||
import { defineStore } from "pinia"
|
||||
|
||||
export const useCounterStore = defineStore("counter", () => {
|
||||
const count = ref(0)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment }
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ref } from "vue"
|
||||
|
||||
type ThemeMode = "light" | "dark"
|
||||
|
||||
const STORAGE_KEY = "theme-mode"
|
||||
|
||||
function getSystemPreference(): ThemeMode {
|
||||
if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) return "dark"
|
||||
return "light"
|
||||
}
|
||||
|
||||
function readStored(): ThemeMode {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored === "light" || stored === "dark") return stored
|
||||
return getSystemPreference()
|
||||
}
|
||||
|
||||
function applyMode(mode: ThemeMode): void {
|
||||
document.documentElement.classList.toggle("dark", mode === "dark")
|
||||
document.querySelector('meta[name="color-scheme"]')?.setAttribute("content", mode)
|
||||
}
|
||||
|
||||
const mode = ref<ThemeMode>(readStored())
|
||||
|
||||
applyMode(mode.value)
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
mediaQuery.addEventListener("change", (e) => {
|
||||
if (localStorage.getItem(STORAGE_KEY) === null) {
|
||||
mode.value = e.matches ? "dark" : "light"
|
||||
}
|
||||
})
|
||||
|
||||
export function useTheme() {
|
||||
function setMode(newMode: ThemeMode): void {
|
||||
mode.value = newMode
|
||||
localStorage.setItem(STORAGE_KEY, newMode)
|
||||
applyMode(newMode)
|
||||
}
|
||||
|
||||
function toggle(): void {
|
||||
setMode(mode.value === "dark" ? "light" : "dark")
|
||||
}
|
||||
|
||||
return { mode, setMode, toggle }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Dashboard</h1>
|
||||
<p class="mt-2 text-muted">
|
||||
This page uses the sidebar layout. Replace this content with your own dashboard widgets.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { useCounterStore } from "@/stores/counter"
|
||||
|
||||
const counter = useCounterStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-6 py-12">
|
||||
<h1 class="text-4xl font-bold">Welcome to the Vue SPA Template</h1>
|
||||
<p class="max-w-lg text-center text-muted">
|
||||
This project uses Vue 3, Vite, TypeScript, Pinia, Vue Router, and Tailwind CSS v4. It includes
|
||||
a layout system, authentication, and dark mode out of the box.
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-center gap-4 rounded-lg border border-border bg-surface-alt p-8">
|
||||
<p class="text-sm text-muted">Counter store example</p>
|
||||
<p class="font-mono text-3xl font-bold">{{ counter.count }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="cursor-pointer rounded-md bg-primary px-4 py-2 text-sm text-white hover:bg-primary-hover transition-colours"
|
||||
@click="counter.increment()">
|
||||
Increment
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-muted">Double: {{ counter.doubleCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { useRouter, useRoute } from "vue-router"
|
||||
import { useAuthStore } from "@/stores/auth"
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const username = ref("")
|
||||
const password = ref("")
|
||||
const error = ref("")
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
error.value = ""
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(username.value, password.value)
|
||||
const redirect = (route.query.redirect as string) || "/"
|
||||
router.push(redirect)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Login failed. Please try again."
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full max-w-sm rounded-lg border border-border bg-surface-alt p-8">
|
||||
<h1 class="mb-6 text-center text-2xl font-bold">Log in</h1>
|
||||
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label for="username" class="mb-1 block text-sm font-medium">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="username"
|
||||
type="text"
|
||||
class="w-full rounded-md border border-border bg-surface px-3 py-2 text-foreground outline-hidden focus:ring-2 focus:ring-primary transition-colours"
|
||||
placeholder="Enter your username"
|
||||
:disabled="loading"
|
||||
autocomplete="username" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="mb-1 block text-sm font-medium">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
class="w-full rounded-md border border-border bg-surface px-3 py-2 text-foreground outline-hidden focus:ring-2 focus:ring-primary transition-colours"
|
||||
placeholder="Enter your password"
|
||||
:disabled="loading"
|
||||
autocomplete="current-password" />
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-error" role="alert">{{ error }}</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="cursor-pointer rounded-md bg-primary px-4 py-2 text-white hover:bg-primary-hover disabled:opacity-50 transition-colours"
|
||||
:disabled="loading">
|
||||
{{ loading ? "Logging in…" : "Log in" }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-4 text-center text-xs text-muted">
|
||||
Enter any username and password to log in. This is a mock authentication flow for
|
||||
demonstration purposes.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from "vue-router"
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex min-h-screen flex-col items-center justify-center gap-4 bg-surface text-foreground p-4">
|
||||
<h1 class="text-6xl font-bold text-muted">404</h1>
|
||||
<p class="text-lg">Page not found</p>
|
||||
<p class="text-sm text-muted">The page you are looking for does not exist or has been moved.</p>
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="mt-4 rounded-md bg-primary px-4 py-2 text-sm text-white hover:bg-primary-hover transition-colours">
|
||||
Back to home
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from "vue-router"
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full max-w-sm flex-col items-center gap-4 text-center">
|
||||
<h1 class="text-5xl font-bold text-muted">500</h1>
|
||||
<p class="text-lg">Internal server error</p>
|
||||
<p class="text-sm text-muted">Something went wrong on our end. Please try again later.</p>
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="mt-2 rounded-md bg-primary px-4 py-2 text-sm text-white hover:bg-primary-hover transition-colours">
|
||||
Back to home
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
// Extra safety for array and object lookups, but may have false positives.
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
// Path mapping for cleaner imports.
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||
// Bundler mode provides a smoother developer experience.
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
||||
"types": ["node"],
|
||||
|
||||
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||
"noEmit": true,
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
|
||||
import viteConfig from './vite.config'
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||
root: fileURLToPath(new URL('./', import.meta.url)),
|
||||
css: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
Reference in New Issue
Block a user