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