From cd63464bc2425e009ae154de46bfc7dbee9b3de9 Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Fri, 30 May 2025 17:07:06 +0300 Subject: [PATCH] Initial release --- .env | 2 + .gitignore | 30 ++++++++ Caddyfile | 17 +++++ Dockerfile | 22 ++++++ LICENSE.md | 13 ++++ README.md | 3 + docker-compose.yml | 14 ++++ env.d.ts | 1 + index.html | 13 ++++ package.json | 29 ++++++++ public/favicon.ico | Bin 0 -> 67758 bytes src/App.vue | 75 +++++++++++++++++++ src/assets/base.css | 86 ++++++++++++++++++++++ src/assets/main.css | 35 +++++++++ src/main.ts | 14 ++++ src/router/index.ts | 44 ++++++++++++ src/services/api.ts | 50 +++++++++++++ src/services/authService.ts | 13 ++++ src/stores/auth.ts | 63 ++++++++++++++++ src/stores/data.ts | 98 +++++++++++++++++++++++++ src/types/api.ts | 44 ++++++++++++ src/views/Login.vue | 93 ++++++++++++++++++++++++ src/views/Project.vue | 140 ++++++++++++++++++++++++++++++++++++ src/views/ProjectList.vue | 32 +++++++++ tsconfig.app.json | 12 ++++ tsconfig.json | 11 +++ tsconfig.node.json | 19 +++++ vite.config.ts | 15 ++++ 28 files changed, 988 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 Caddyfile create mode 100644 Dockerfile create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 env.d.ts create mode 100644 index.html create mode 100644 package.json create mode 100644 public/favicon.ico create mode 100644 src/App.vue create mode 100644 src/assets/base.css create mode 100644 src/assets/main.css create mode 100644 src/main.ts create mode 100644 src/router/index.ts create mode 100644 src/services/api.ts create mode 100644 src/services/authService.ts create mode 100644 src/stores/auth.ts create mode 100644 src/stores/data.ts create mode 100644 src/types/api.ts create mode 100644 src/views/Login.vue create mode 100644 src/views/Project.vue create mode 100644 src/views/ProjectList.vue create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.env b/.env new file mode 100644 index 0000000..96bae8e --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +VITE_API_BASE_URL=https://test-taiga.lpr12.ru +VITE_ROOT_BASE_URL=tableview diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ee54e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.DS_Store +dist +dist-ssr +coverage +*.local + +/cypress/videos/ +/cypress/screenshots/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +*.tsbuildinfo diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..bd365d4 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,17 @@ +:80 { + # Ensure VITE_ROOT_BASE_URL in your .env file is 'tableview' (no slashes) + handle_path /{$VITE_ROOT_BASE_URL}/* { + root * /usr/share/caddy + + # try_files will attempt to serve the {path} directly. + # If {path} (e.g., /assets/app.js after /tableview/ is stripped) is found, it's served. + # If {path} (e.g., /some-spa-route after /tableview/ is stripped) is not found, + # it falls back to serving /index.html. + try_files {path} /index.html + + # file_server serves the file determined by try_files (either the original asset or index.html). + file_server + + encode gzip + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d938c3f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM node:18-alpine AS builder +WORKDIR /app + +COPY package*.json ./ +RUN npm install +COPY . . + +ARG VITE_API_BASE_URL +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} + +ARG VITE_ROOT_BASE_URL +ENV VITE_ROOT_BASE_URL=${VITE_ROOT_BASE_URL} + + +RUN npm run build + +FROM caddy:2-alpine + +COPY Caddyfile /etc/caddy/Caddyfile +COPY --from=builder /app/dist /usr/share/caddy + +EXPOSE 80 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..bea50f9 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,13 @@ +DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + + Copyright (C) 2025 Vladimir Zagainov + + Everyone is permitted to copy and distribute verbatim or modified + copies of this license document, and changing it is allowed as long + as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b597fc6 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +Change VITE_API_BASE_URL (taiga api) and VITE_ROOT_BASE_URL (frontend base path) in .env file +Change project (table) size in styles .userstory-table-container max-height: 500px; +Change port in docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..15f7622 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +version: "3.8" + +services: + app: + build: + context: . + args: + VITE_API_BASE_URL: ${VITE_API_BASE_URL} + VITE_ROOT_BASE_URL: ${VITE_ROOT_BASE_URL} + ports: + - "80:80" + env_file: + - .env + restart: unless-stopped diff --git a/env.d.ts b/env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/index.html b/index.html new file mode 100644 index 0000000..e2a72fa --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Базовая база + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..073fefe --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "vue-caddy-app", + "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" + }, + "dependencies": { + "pinia": "^3.0.1", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.1", + "@types/node": "^22.14.0", + "@vitejs/plugin-vue": "^5.2.3", + "@vue/tsconfig": "^0.7.0", + "npm-run-all2": "^7.0.2", + "typescript": "~5.8.0", + "vite": "^6.2.4", + "vite-plugin-vue-devtools": "^7.7.2", + "vue-tsc": "^2.2.8" + } +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..e9813e590c221b1a93515d7ded2b1a5e4e2015d2 GIT binary patch literal 67758 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 +
+
+

Добро пожаловать, {{ authStore.fullName || authStore.username }}, в базированную базу!

+ +
+
+ +
+
+ + + + + diff --git a/src/assets/base.css b/src/assets/base.css new file mode 100644 index 0000000..8816868 --- /dev/null +++ b/src/assets/base.css @@ -0,0 +1,86 @@ +/* color palette from */ +:root { + --vt-c-white: #ffffff; + --vt-c-white-soft: #f8f8f8; + --vt-c-white-mute: #f2f2f2; + + --vt-c-black: #181818; + --vt-c-black-soft: #222222; + --vt-c-black-mute: #282828; + + --vt-c-indigo: #2c3e50; + + --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); + --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); + --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); + --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); + + --vt-c-text-light-1: var(--vt-c-indigo); + --vt-c-text-light-2: rgba(60, 60, 60, 0.66); + --vt-c-text-dark-1: var(--vt-c-white); + --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); +} + +/* semantic color variables for this project */ +:root { + --color-background: var(--vt-c-white); + --color-background-soft: var(--vt-c-white-soft); + --color-background-mute: var(--vt-c-white-mute); + + --color-border: var(--vt-c-divider-light-2); + --color-border-hover: var(--vt-c-divider-light-1); + + --color-heading: var(--vt-c-text-light-1); + --color-text: var(--vt-c-text-light-1); + + --section-gap: 160px; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-background: var(--vt-c-black); + --color-background-soft: var(--vt-c-black-soft); + --color-background-mute: var(--vt-c-black-mute); + + --color-border: var(--vt-c-divider-dark-2); + --color-border-hover: var(--vt-c-divider-dark-1); + + --color-heading: var(--vt-c-text-dark-1); + --color-text: var(--vt-c-text-dark-2); + } +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + font-weight: normal; +} + +body { + min-height: 100vh; + color: var(--color-text); + background: var(--color-background); + transition: + color 0.5s, + background-color 0.5s; + line-height: 1.6; + font-family: + Inter, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + Oxygen, + Ubuntu, + Cantarell, + 'Fira Sans', + 'Droid Sans', + 'Helvetica Neue', + sans-serif; + font-size: 15px; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/src/assets/main.css b/src/assets/main.css new file mode 100644 index 0000000..eda6643 --- /dev/null +++ b/src/assets/main.css @@ -0,0 +1,35 @@ +@import "./base.css"; + +#app { + max-width: 1800px; + margin: 0 auto; + padding: 2rem; + font-weight: normal; +} + +a, +.green { + text-decoration: none; + color: hsla(160, 100%, 37%, 1); + transition: 0.4s; + padding: 3px; +} + +@media (hover: hover) { + a:hover { + background-color: hsla(160, 100%, 37%, 0.2); + } +} + +@media (min-width: 1024px) { + body { + display: flex; + place-items: center; + } + + #app { + display: grid; + grid-template-columns: 1fr 1fr; + padding: 0 2rem; + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..8caa583 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,14 @@ +import "./assets/main.css"; // Если есть общие стили + +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"); diff --git a/src/router/index.ts b/src/router/index.ts new file mode 100644 index 0000000..4bda15b --- /dev/null +++ b/src/router/index.ts @@ -0,0 +1,44 @@ +import { createRouter, createWebHistory } from "vue-router"; +import ProjectList from "../views/ProjectList.vue"; +import Login from "../views/Login.vue"; +import { useAuthStore } from "@/stores/auth"; + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), // BASE_URL из vite.config.ts + routes: [ + { + path: "/login", + name: "Login", + component: Login, + meta: { requiresGuest: true }, + }, + { + path: "/", + name: "ProjectList", + component: ProjectList, + meta: { requiresAuth: true }, + }, + // Каждый проект (доска) имеет отдельную страницу, возможно такой вариант будет лучше, особенно для больших отделений + { + path: "/project/:projectId", + name: "ProjectDetails", + component: () => import("../views/Project.vue"), + props: true, + meta: { requiresAuth: true }, + }, + ], +}); + +router.beforeEach((to, from, next) => { + const authStore = useAuthStore(); + + if (to.meta.requiresAuth && !authStore.isAuthenticated) { + next({ name: "Login" }); + } else if (to.meta.requiresGuest && authStore.isAuthenticated) { + next({ name: "ProjectList" }); + } else { + next(); + } +}); + +export default router; diff --git a/src/services/api.ts b/src/services/api.ts new file mode 100644 index 0000000..36c32ee --- /dev/null +++ b/src/services/api.ts @@ -0,0 +1,50 @@ +import { useAuthStore } from "@/stores/auth"; +import router from "@/router"; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; + +async function request(url: string, options: RequestInit = {}): Promise { + const authStore = useAuthStore(); + const headers = new Headers(options.headers || {}); + + if (authStore.token) { + // В localStorage токен уже с кавычками, приходится парсить его, чтобы использовать без кавычек + const tokenValue = JSON.parse(authStore.token); + headers.set("Authorization", `Bearer ${tokenValue}`); + } + + if (options.method === "POST" || options.method === "PUT" || options.method === "PATCH") { + if (!headers.has("Content-Type") && !(options.body instanceof FormData)) { + headers.set("Content-Type", "application/json"); + } + } + + const response = await fetch(`${API_BASE_URL}${url}`, { + ...options, + headers, + }); + + if (response.status === 401 || response.status === 403) { + // Если не залогинен (или токен протух), то перенаправляем на логин + if (router.currentRoute.value.name !== "Login") { + authStore.logout(); // разлогиниваем, если токен протух (возвращается 401/403 при наличии токена) + router.push({ name: "Login" }); + } + const errorData = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(errorData.detail || errorData.message || `Request failed with status ${response.status}`); + } + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(errorData.detail || errorData.message || `Request failed with status ${response.status}`); + } + + // Если пустой ответ (например, 204 No Content) + if (response.status === 204) { + return null as T; + } + + return response.json() as Promise; +} + +export default request; diff --git a/src/services/authService.ts b/src/services/authService.ts new file mode 100644 index 0000000..61c422c --- /dev/null +++ b/src/services/authService.ts @@ -0,0 +1,13 @@ +import request from "./api"; +import type { AuthResponse } from "@/types/api"; + +const login = (username: string, password: string): Promise => { + return request("/api/v1/auth", { + method: "POST", + body: JSON.stringify({ type: "normal", username, password }), + }); +}; + +export default { + login, +}; diff --git a/src/stores/auth.ts b/src/stores/auth.ts new file mode 100644 index 0000000..b576f3c --- /dev/null +++ b/src/stores/auth.ts @@ -0,0 +1,63 @@ +import { defineStore } from "pinia"; +import { ref, computed } from "vue"; +import type { AuthResponse } from "@/types/api"; +import router from "@/router"; +import authService from "@/services/authService"; + +export const useAuthStore = defineStore("auth", () => { + const token = ref(localStorage.getItem("token")); + const refreshToken = ref(localStorage.getItem("refresh")); + const userId = ref(JSON.parse(localStorage.getItem("userId") || "null")); + const username = ref(localStorage.getItem("username")); + const fullName = ref(localStorage.getItem("fullName")); + + const isAuthenticated = computed(() => !!token.value); + + function setAuthData(data: AuthResponse) { + // Сохраняем токены с кавычками, как требует ТЗ + localStorage.setItem("token", JSON.stringify(data.auth_token)); + localStorage.setItem("refresh", JSON.stringify(data.refresh)); + localStorage.setItem("userId", JSON.stringify(data.id)); + localStorage.setItem("username", data.username); + localStorage.setItem("fullName", data.full_name); + + token.value = JSON.stringify(data.auth_token); + refreshToken.value = JSON.stringify(data.refresh); + userId.value = data.id; + username.value = data.username; + fullName.value = data.full_name; + } + + function logout() { + localStorage.removeItem("token"); + localStorage.removeItem("refresh"); + localStorage.removeItem("userId"); + localStorage.removeItem("username"); + localStorage.removeItem("fullName"); + + token.value = null; + refreshToken.value = null; + userId.value = null; + username.value = null; + fullName.value = null; + + router.push({ name: "Login" }); + } + + async function login(email: string, pass: string) { + const data = await authService.login(email, pass); + setAuthData(data); + } + + return { + token, + refreshToken, + userId, + username, + fullName, + isAuthenticated, + login, + logout, + setAuthData, + }; +}); diff --git a/src/stores/data.ts b/src/stores/data.ts new file mode 100644 index 0000000..dc1ecc3 --- /dev/null +++ b/src/stores/data.ts @@ -0,0 +1,98 @@ +import { defineStore } from "pinia"; +import { ref, reactive } from "vue"; +import type { Project, ProjectField, Userstory, UserstoryAttributeValuesResponse } from "@/types/api"; +import request from "@/services/api"; + +export const useDataStore = defineStore("data", () => { + const projects = ref([]); + const projectFieldsMap = reactive>(new Map()); // Ключ - ID проекта (доски), значение - массив полей для userstories + const userstoriesMap = reactive>(new Map()); // Ключ - ID userstory (карточки), значение - объект с кастомными полями + const userstoryAttributesMap = reactive>(new Map()); // Ключ - ID кастомного поля, значение - значение кастомного поля (заголовок) + + const loadingProjects = ref(false); + const loadingFields = ref(false); + const loadingUserstories = ref(false); + const loadingAttributes = ref(false); + + async function fetchProjects() { + loadingProjects.value = true; + try { + const data = await request("/api/v1/projects"); + projects.value = data; + } catch (error) { + console.error("Failed to fetch projects:", error); + } finally { + loadingProjects.value = false; + } + } + + async function fetchProjectFields(projectId: number) { + if (projectFieldsMap.has(projectId)) return; + loadingFields.value = true; + try { + const data = await request(`/api/v1/userstory-custom-attributes?project=${projectId}`); + projectFieldsMap.set(projectId, data); + } catch (error) { + console.error(`Failed to fetch fields for project ${projectId}:`, error); + } finally { + loadingFields.value = false; + } + } + + async function fetchUserstories(projectId: number) { + if (userstoriesMap.has(projectId)) return; + loadingUserstories.value = true; + try { + const data = await request(`/api/v1/userstories?project=${projectId}`); + userstoriesMap.set(projectId, data); + // Можно попробовать загружать кастомные поля при загрузке карточки + // data.forEach(us => fetchUserstoryAttributes(us.id)); + } catch (error) { + console.error(`Failed to fetch userstories for project ${projectId}:`, error); + } finally { + loadingUserstories.value = false; + } + } + + async function fetchUserstoryAttributes(userstoryId: number) { + if (userstoryAttributesMap.has(userstoryId)) return; + // loadingAttributes.value = true; // Если общий лоадер + try { + const data = await request(`/api/v1/userstories/custom-attributes-values/${userstoryId}`); + userstoryAttributesMap.set(userstoryId, data.attributes_values); + } catch (error) { + console.error(`Failed to fetch attributes for userstory ${userstoryId}:`, error); + } finally { + // loadingAttributes.value = false; + } + } + + function getProjectById(projectId: number): Project | undefined { + return projects.value.find((p) => p.id === projectId); + } + + function clearData() { + // При логауте очищаем загруженные данные + projects.value = []; + projectFieldsMap.clear(); + userstoriesMap.clear(); + userstoryAttributesMap.clear(); + } + + return { + projects, + projectFieldsMap, + userstoriesMap, + userstoryAttributesMap, + loadingProjects, + loadingFields, + loadingUserstories, + loadingAttributes, + fetchProjects, + fetchProjectFields, + fetchUserstories, + fetchUserstoryAttributes, + getProjectById, + clearData, + }; +}); diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..9669a71 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,44 @@ +export interface AuthResponse { + id: number; + username: string; + full_name: string; + auth_token: string; + refresh: string; +} + +export interface Project { + id: number; + name: string; + slug: string; +} + +export interface ProjectField { + id: number; + name: string; + description: string; + type: "text" | "number" | "date" | string; // Возможно, понадобятся другие типы, но пока так + order: number; + project: number; // ID проекта (доски) +} + +export interface UserstoryStatusInfo { + name: string; + color: string; + is_closed: boolean; +} + +export interface Userstory { + id: number; + subject: string; + status: number; // ID статуса + status_extra_info: UserstoryStatusInfo; + project: number; // ID проекта (доски) +} + +export interface UserstoryAttributeValuesResponse { + attributes_values: { + [fieldId: string]: string | number | null; + }; + version: number; + user_story: number; // ID юзерстори (карточки) +} diff --git a/src/views/Login.vue b/src/views/Login.vue new file mode 100644 index 0000000..a975d91 --- /dev/null +++ b/src/views/Login.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/src/views/Project.vue b/src/views/Project.vue new file mode 100644 index 0000000..33fd55b --- /dev/null +++ b/src/views/Project.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/src/views/ProjectList.vue b/src/views/ProjectList.vue new file mode 100644 index 0000000..a247801 --- /dev/null +++ b/src/views/ProjectList.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..913b8f2 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,12 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], + "exclude": ["src/**/__tests__/*"], + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..66b5e57 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.node.json" + }, + { + "path": "./tsconfig.app.json" + } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..a83dfc9 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,19 @@ +{ + "extends": "@tsconfig/node22/tsconfig.json", + "include": [ + "vite.config.*", + "vitest.config.*", + "cypress.config.*", + "nightwatch.conf.*", + "playwright.config.*", + "eslint.config.*" + ], + "compilerOptions": { + "noEmit": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"] + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..65aed97 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,15 @@ +import { fileURLToPath, URL } from "node:url"; +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; + +// const ROOT_BASE_URL = "/" + process.env.VITE_ROOT_BASE_URL + "/" || "/"; +const ROOT_BASE_URL = "/tableview/"; +export default defineConfig({ + base: ROOT_BASE_URL, + plugins: [vue()], + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, +});