This commit is contained in:
2026-04-27 22:36:44 +09:30
parent 86b6d6c0b9
commit 2d03f3a7f4
58 changed files with 4376 additions and 62 deletions
+34
View File
@@ -0,0 +1,34 @@
# Frontend ESLint configuration
{
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:vue/vue3-recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "vue-eslint-parser",
"parserOptions": {
"ecmaVersion": "latest",
"parser": "@typescript-eslint/parser",
"sourceType": "module"
},
"plugins": [
"vue",
"@typescript-eslint"
],
"rules": {
"vue/multi-word-component-names": "off",
"vue/no-setup-props-destructure": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"no-console": "warn"
}
}
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.env.local
.DS_Store
*.log
npm-debug.log*
+3
View File
@@ -0,0 +1,3 @@
frontend/.eslintrc.json
frontend/.prettierrc
frontend/.gitignore
+11
View File
@@ -0,0 +1,11 @@
; Frontend Prettier configuration
{
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always",
"vueIndentScriptAndStyle": true
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MTG Search</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
{
"name": "mtg-search-frontend",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"pinia": "^2.2.0",
"axios": "^1.7.7",
"pinia-plugin-persistedstate": "^3.2.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.3",
"vite": "^5.3.1",
"typescript": "^5.4.5",
"vue-tsc": "^1.8.27",
"@vue/test-utils": "^2.4.6",
"vitest": "^2.0.0",
"eslint": "^9.0.0",
"eslint-plugin-vue": "^9.26.0",
"@typescript-eslint/eslint-plugin": "^7.8.0",
"@typescript-eslint/parser": "^7.8.0"
}
}
+2
View File
@@ -0,0 +1,2 @@
*
!.gitkeep
+131
View File
@@ -0,0 +1,131 @@
<template>
<div id="app" class="app">
<header class="navbar">
<div class="navbar-container">
<router-link to="/" class="navbar-brand">
<h1>MTG Search</h1>
</router-link>
<nav class="navbar-menu">
<router-link v-if="!authStore.isAuthenticated" to="/login" class="nav-link">Login</router-link>
<router-link v-if="!authStore.isAuthenticated" to="/register" class="nav-link">Register</router-link>
<router-link v-if="authStore.isAuthenticated" to="/dashboard" class="nav-link">Dashboard</router-link>
<button v-if="authStore.isAuthenticated" @click="logout" class="nav-link logout-btn">Logout</button>
</nav>
</div>
</header>
<main class="main-content">
<router-view />
</main>
<footer class="footer">
<p>&copy; 2024 MTG Search. All rights reserved.</p>
</footer>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from './stores/auth'
import { useRouter } from 'vue-router'
const authStore = useAuthStore()
const router = useRouter()
const logout = () => {
authStore.logout()
router.push('/login')
}
</script>
<style scoped>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: #f5f5f5;
}
.navbar {
background-color: #1a1a2e;
color: white;
padding: 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.navbar-container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar-brand {
text-decoration: none;
color: white;
}
.navbar-brand h1 {
font-size: 1.5rem;
font-weight: 600;
}
.navbar-menu {
display: flex;
gap: 2rem;
align-items: center;
}
.nav-link {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.3s ease;
cursor: pointer;
border: none;
background: transparent;
font-size: 1rem;
}
.nav-link:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.logout-btn {
background-color: #e74c3c;
}
.logout-btn:hover {
background-color: #c0392b;
}
.main-content {
flex: 1;
max-width: 1200px;
margin: 0 auto;
width: 100%;
padding: 2rem 1rem;
}
.footer {
background-color: #1a1a2e;
color: white;
text-align: center;
padding: 2rem;
margin-top: 2rem;
}
</style>
+15
View File
@@ -0,0 +1,15 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
import createPersistedState from 'pinia-plugin-persistedstate'
const app = createApp(App)
const pinia = createPinia()
pinia.use(createPersistedState())
app.use(pinia)
app.use(router)
app.mount('#app')
+135
View File
@@ -0,0 +1,135 @@
<template>
<div class="dashboard-page">
<div class="dashboard-container">
<h1>Welcome, {{ authStore.user?.username }}!</h1>
<div class="dashboard-grid">
<div class="card">
<h2>Profile Information</h2>
<div class="profile-info">
<p><strong>Username:</strong> {{ authStore.user?.username }}</p>
<p><strong>Email:</strong> {{ authStore.user?.email }}</p>
<p><strong>ID:</strong> {{ authStore.user?.id }}</p>
</div>
</div>
<div class="card">
<h2>Quick Actions</h2>
<div class="actions">
<button class="btn btn-secondary">Search Cards</button>
<button class="btn btn-secondary">View Decks</button>
<button class="btn btn-secondary">Settings</button>
</div>
</div>
</div>
<div class="card">
<h2>Search Cards</h2>
<p style="color: #666; margin-bottom: 1rem;">Coming soon...</p>
<input
type="text"
placeholder="Search Magic The Gathering cards..."
class="search-input"
disabled
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
</script>
<style scoped>
.dashboard-page {
display: flex;
flex-direction: column;
gap: 2rem;
}
.dashboard-container h1 {
color: #1a1a2e;
margin-bottom: 2rem;
font-size: 2rem;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
.card {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.card h2 {
color: #1a1a2e;
margin-bottom: 1.5rem;
font-size: 1.4rem;
}
.profile-info {
display: flex;
flex-direction: column;
gap: 1rem;
}
.profile-info p {
color: #333;
line-height: 1.6;
}
.actions {
display: flex;
flex-direction: column;
gap: 1rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
font-size: 1rem;
}
.btn-secondary {
background-color: #e74c3c;
color: white;
}
.btn-secondary:hover {
background-color: #c0392b;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.search-input {
width: 100%;
padding: 1rem;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.3s ease;
}
.search-input:focus {
outline: none;
border-color: #1a1a2e;
}
.search-input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
</style>
+96
View File
@@ -0,0 +1,96 @@
<template>
<div class="home-page">
<div class="hero">
<h1>Welcome to MTG Search</h1>
<p>Search and explore Magic The Gathering cards</p>
<div class="hero-buttons" v-if="!authStore.isAuthenticated">
<router-link to="/login" class="btn btn-primary">Login</router-link>
<router-link to="/register" class="btn btn-secondary">Register</router-link>
</div>
<div class="hero-buttons" v-else>
<router-link to="/dashboard" class="btn btn-primary">Go to Dashboard</router-link>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
</script>
<style scoped>
.home-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
}
.hero {
text-align: center;
animation: slideIn 0.5s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hero h1 {
font-size: 3rem;
color: #1a1a2e;
margin-bottom: 1rem;
}
.hero p {
font-size: 1.2rem;
color: #666;
margin-bottom: 2rem;
}
.hero-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.btn {
padding: 0.75rem 1.5rem;
border-radius: 4px;
text-decoration: none;
font-weight: 600;
transition: all 0.3s ease;
display: inline-block;
}
.btn-primary {
background-color: #1a1a2e;
color: white;
}
.btn-primary:hover {
background-color: #0f0f1e;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.btn-secondary {
background-color: #e74c3c;
color: white;
}
.btn-secondary:hover {
background-color: #c0392b;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
</style>
+181
View File
@@ -0,0 +1,181 @@
<template>
<div class="login-page">
<div class="login-container">
<h1>Login to MTG Search</h1>
<form @submit.prevent="handleLogin">
<div class="form-group">
<label for="username">Username</label>
<input
v-model="form.username"
id="username"
type="text"
placeholder="Enter your username"
required
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
v-model="form.password"
id="password"
type="password"
placeholder="Enter your password"
required
/>
</div>
<div v-if="error" class="alert alert-error">
{{ error }}
</div>
<button type="submit" class="btn btn-primary" :disabled="loading">
{{ loading ? 'Logging in...' : 'Login' }}
</button>
</form>
<p class="signup-link">
Don't have an account? <router-link to="/register">Register here</router-link>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const form = ref({
username: '',
password: '',
})
const loading = ref(false)
const error = ref('')
const handleLogin = async () => {
loading.value = true
error.value = ''
try {
await authStore.login(form.username, form.password)
router.push('/dashboard')
} catch (err: any) {
error.value = err.response?.data?.error || 'Login failed. Please try again.'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 70vh;
}
.login-container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.login-container h1 {
text-align: center;
color: #1a1a2e;
margin-bottom: 2rem;
font-size: 1.8rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #333;
font-weight: 600;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.3s ease;
}
.form-group input:focus {
outline: none;
border-color: #1a1a2e;
box-shadow: 0 0 0 3px rgba(26, 26, 46, 0.1);
}
.btn {
width: 100%;
padding: 0.75rem;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-primary {
background-color: #1a1a2e;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #0f0f1e;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.alert {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
text-align: center;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.signup-link {
text-align: center;
margin-top: 1.5rem;
color: #666;
}
.signup-link a {
color: #1a1a2e;
text-decoration: none;
font-weight: 600;
}
.signup-link a:hover {
text-decoration: underline;
}
</style>
+229
View File
@@ -0,0 +1,229 @@
<template>
<div class="register-page">
<div class="register-container">
<h1>Create Account</h1>
<form @submit.prevent="handleRegister">
<div class="form-group">
<label for="username">Username</label>
<input
v-model="form.username"
id="username"
type="text"
placeholder="Choose a username"
required
minlength="3"
/>
</div>
<div class="form-group">
<label for="email">Email</label>
<input
v-model="form.email"
id="email"
type="email"
placeholder="Enter your email"
required
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
v-model="form.password"
id="password"
type="password"
placeholder="Create a password"
required
minlength="8"
/>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input
v-model="form.confirmPassword"
id="confirmPassword"
type="password"
placeholder="Confirm your password"
required
/>
</div>
<div v-if="error" class="alert alert-error">
{{ error }}
</div>
<div v-if="success" class="alert alert-success">
{{ success }}
</div>
<button type="submit" class="btn btn-primary" :disabled="loading">
{{ loading ? 'Creating account...' : 'Register' }}
</button>
</form>
<p class="login-link">
Already have an account? <router-link to="/login">Login here</router-link>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const form = ref({
username: '',
email: '',
password: '',
confirmPassword: '',
})
const loading = ref(false)
const error = ref('')
const success = ref('')
const handleRegister = async () => {
error.value = ''
success.value = ''
if (form.value.password !== form.value.confirmPassword) {
error.value = 'Passwords do not match'
return
}
loading.value = true
try {
await authStore.register(form.value.username, form.value.email, form.value.password)
success.value = 'Account created successfully! Redirecting to login...'
setTimeout(() => {
router.push('/login')
}, 2000)
} catch (err: any) {
error.value = err.response?.data?.error || 'Registration failed. Please try again.'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.register-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 75vh;
}
.register-container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.register-container h1 {
text-align: center;
color: #1a1a2e;
margin-bottom: 2rem;
font-size: 1.8rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #333;
font-weight: 600;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.3s ease;
}
.form-group input:focus {
outline: none;
border-color: #1a1a2e;
box-shadow: 0 0 0 3px rgba(26, 26, 46, 0.1);
}
.btn {
width: 100%;
padding: 0.75rem;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-primary {
background-color: #1a1a2e;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #0f0f1e;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.alert {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
text-align: center;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.login-link {
text-align: center;
margin-top: 1.5rem;
color: #666;
}
.login-link a {
color: #1a1a2e;
text-decoration: none;
font-weight: 600;
}
.login-link a:hover {
text-decoration: underline;
}
</style>
+49
View File
@@ -0,0 +1,49 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import Home from '@/pages/Home.vue'
import Login from '@/pages/Login.vue'
import Register from '@/pages/Register.vue'
import Dashboard from '@/pages/Dashboard.vue'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: Home,
},
{
path: '/login',
name: 'Login',
component: Login,
},
{
path: '/register',
name: 'Register',
component: Register,
},
{
path: '/dashboard',
name: 'Dashboard',
component: Dashboard,
meta: { requiresAuth: true },
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next('/login')
} else if ((to.path === '/login' || to.path === '/register') && authStore.isAuthenticated) {
next('/dashboard')
} else {
next()
}
})
export default router
+11
View File
@@ -0,0 +1,11 @@
import axios from 'axios'
const apiClient = axios.create({
baseURL: typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8080',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
})
export default apiClient
+109
View File
@@ -0,0 +1,109 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import apiClient from '@/services/api'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(null)
const user = ref<any>(null)
const isAuthenticated = computed(() => !!token.value)
const login = async (username: string, password: string) => {
try {
const response = await apiClient.post('/api/v1/auth/login', {
username,
password,
})
token.value = response.data.token
user.value = {
id: response.data.id,
username: response.data.username,
email: response.data.email,
}
localStorage.setItem('token', token.value)
localStorage.setItem('user', JSON.stringify(user.value))
return response.data
} catch (error) {
token.value = null
user.value = null
localStorage.removeItem('token')
localStorage.removeItem('user')
throw error
}
}
const register = async (username: string, email: string, password: string) => {
try {
const response = await apiClient.post('/api/v1/auth/register', {
username,
email,
password,
})
return response.data
} catch (error) {
throw error
}
}
const logout = () => {
token.value = null
user.value = null
localStorage.removeItem('token')
localStorage.removeItem('user')
}
const setupAuthInterceptors = () => {
apiClient.interceptors.request.use((config) => {
if (token.value) {
config.headers.Authorization = `Bearer ${token.value}`
}
return config
})
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
logout()
}
return Promise.reject(error)
}
)
}
const loadSavedAuth = () => {
const savedToken = localStorage.getItem('token')
const savedUser = localStorage.getItem('user')
if (savedToken) {
token.value = savedToken
}
if (savedUser) {
user.value = JSON.parse(savedUser)
}
}
return {
token,
user,
isAuthenticated,
login,
register,
logout,
setupAuthInterceptors,
loadSavedAuth,
}
}, {
persist: {
enabled: true,
strategies: [
{
key: 'auth',
storage: localStorage,
},
],
},
})
+35
View File
@@ -0,0 +1,35 @@
/**
* API Response types
*/
export interface ApiResponse<T> {
data?: T
error?: string
message?: string
status?: number
}
export interface User {
id: number
username: string
email: string
createdAt?: string
updatedAt?: string
}
export interface AuthResponse {
token: string
id: number
username: string
email: string
}
export interface RegisterRequest {
username: string
email: string
password: string
}
export interface LoginRequest {
username: string
password: string
}
+32
View File
@@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
/* Bundler mode */
"moduleResolution": "node",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts", "vitest.config.ts"]
}
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
server: {
port: 5173,
strictPort: false,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
outDir: resolve(__dirname, '../backend/src/main/resources/static'),
emptyOutDir: true,
sourcemap: false,
minify: 'terser',
},
})
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom',
},
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
})
+2
View File
@@ -0,0 +1,2 @@
<!-- Vitest Configuration UI -->
export {}