commit cd63464bc2425e009ae154de46bfc7dbee9b3de9 Author: Vladimir Zagainov Date: Fri May 30 17:07:06 2025 +0300 Initial release 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 0000000..e9813e5 Binary files /dev/null and b/public/favicon.ico differ diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..97578ac --- /dev/null +++ b/src/App.vue @@ -0,0 +1,75 @@ + + + + + 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)), + }, + }, +});