initial commit (WIP)

This commit is contained in:
Vitaliy Pavlov 2024-07-04 04:20:19 +07:00
commit 9d8f424e1b
101 changed files with 9599 additions and 0 deletions

4
.browserslistrc Normal file
View File

@ -0,0 +1,4 @@
> 1%
last 2 versions
not dead
not ie 11

5
.editorconfig Normal file
View File

@ -0,0 +1,5 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

1
.env Normal file
View File

@ -0,0 +1 @@
VITE_API_URL=http://localhost:8080/

View File

@ -0,0 +1,69 @@
{
"globals": {
"Component": true,
"ComponentPublicInstance": true,
"ComputedRef": true,
"EffectScope": true,
"ExtractDefaultPropTypes": true,
"ExtractPropTypes": true,
"ExtractPublicPropTypes": true,
"InjectionKey": true,
"PropType": true,
"Ref": true,
"VNode": true,
"WritableComputedRef": true,
"computed": true,
"createApp": true,
"customRef": true,
"defineAsyncComponent": true,
"defineComponent": true,
"effectScope": true,
"getCurrentInstance": true,
"getCurrentScope": true,
"h": true,
"inject": true,
"isProxy": true,
"isReactive": true,
"isReadonly": true,
"isRef": true,
"markRaw": true,
"nextTick": true,
"onActivated": true,
"onBeforeMount": true,
"onBeforeUnmount": true,
"onBeforeUpdate": true,
"onDeactivated": true,
"onErrorCaptured": true,
"onMounted": true,
"onRenderTracked": true,
"onRenderTriggered": true,
"onScopeDispose": true,
"onServerPrefetch": true,
"onUnmounted": true,
"onUpdated": true,
"provide": true,
"reactive": true,
"readonly": true,
"ref": true,
"resolveComponent": true,
"shallowReactive": true,
"shallowReadonly": true,
"shallowRef": true,
"toRaw": true,
"toRef": true,
"toRefs": true,
"toValue": true,
"triggerRef": true,
"unref": true,
"useAttrs": true,
"useCssModule": true,
"useCssVars": true,
"useRoute": true,
"useRouter": true,
"useSlots": true,
"watch": true,
"watchEffect": true,
"watchPostEffect": true,
"watchSyncEffect": true
}
}

29
.eslintrc.js Normal file
View File

@ -0,0 +1,29 @@
/**
* .eslint.js
*
* ESLint configuration file.
*/
module.exports = {
root: true,
env: {
node: true,
},
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'vue3-recommended',
'plugin:prettier/recommend',
'@vue/eslint-config-typescript',
],
rules: {
'vue/multi-word-component-names': 'off',
'vue/valid-v-slot': 'off',
'prettier/prettier': [
'error',
{
endOfLine: 'auto',
},
],
},
}

22
.gitignore vendored Normal file
View File

@ -0,0 +1,22 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

10
.prettierrc Normal file
View File

@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": true,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 80,
"trailingComma": "all",
"arrowParens": "always",
"endOfLine": "auto"
}

81
README.md Normal file
View File

@ -0,0 +1,81 @@
# Vuetify (Default)
This is the official scaffolding tool for Vuetify, designed to give you a head start in building your new Vuetify application. It sets up a base template with all the necessary configurations and standard directory structure, enabling you to begin development without the hassle of setting up the project from scratch.
## ❗️ Important Links
- 📄 [Docs](https://vuetifyjs.com/)
- 🚨 [Issues](https://issues.vuetifyjs.com/)
- 🏬 [Store](https://store.vuetifyjs.com/)
- 🎮 [Playground](https://play.vuetifyjs.com/)
- 💬 [Discord](https://community.vuetifyjs.com)
## 💿 Install
Set up your project using your preferred package manager. Use the corresponding command to install the dependencies:
| Package Manager | Command |
|---------------------------------------------------------------|----------------|
| [yarn](https://yarnpkg.com/getting-started) | `yarn install` |
| [npm](https://docs.npmjs.com/cli/v7/commands/npm-install) | `npm install` |
| [pnpm](https://pnpm.io/installation) | `pnpm install` |
| [bun](https://bun.sh/#getting-started) | `bun install` |
After completing the installation, your environment is ready for Vuetify development.
## ✨ Features
- 🖼️ **Optimized Front-End Stack**: Leverage the latest Vue 3 and Vuetify 3 for a modern, reactive UI development experience. [Vue 3](https://v3.vuejs.org/) | [Vuetify 3](https://vuetifyjs.com/en/)
- 🗃️ **State Management**: Integrated with [Pinia](https://pinia.vuejs.org/), the intuitive, modular state management solution for Vue.
- 🚦 **Routing and Layouts**: Utilizes Vue Router for SPA navigation and vite-plugin-vue-layouts for organizing Vue file layouts. [Vue Router](https://router.vuejs.org/) | [vite-plugin-vue-layouts](https://github.com/JohnCampionJr/vite-plugin-vue-layouts)
- 💻 **Enhanced Development Experience**: Benefit from TypeScript's static type checking and the ESLint plugin suite for Vue, ensuring code quality and consistency. [TypeScript](https://www.typescriptlang.org/) | [ESLint Plugin Vue](https://eslint.vuejs.org/)
- ⚡ **Next-Gen Tooling**: Powered by Vite, experience fast cold starts and instant HMR (Hot Module Replacement). [Vite](https://vitejs.dev/)
- 🧩 **Automated Component Importing**: Streamline your workflow with unplugin-vue-components, automatically importing components as you use them. [unplugin-vue-components](https://github.com/antfu/unplugin-vue-components)
- 🛠️ **Strongly-Typed Vue**: Use vue-tsc for type-checking your Vue components, and enjoy a robust development experience. [vue-tsc](https://github.com/johnsoncodehk/volar/tree/master/packages/vue-tsc)
These features are curated to provide a seamless development experience from setup to deployment, ensuring that your Vuetify application is both powerful and maintainable.
## 💡 Usage
This section covers how to start the development server and build your project for production.
### Starting the Development Server
To start the development server with hot-reload, run the following command. The server will be accessible at [http://localhost:3000](http://localhost:3000):
```bash
yarn dev
```
(Repeat for npm, pnpm, and bun with respective commands.)
> Add NODE_OPTIONS='--no-warnings' to suppress the JSON import warnings that happen as part of the Vuetify import mapping. If you are on Node [v21.3.0](https://nodejs.org/en/blog/release/v21.3.0) or higher, you can change this to NODE_OPTIONS='--disable-warning=5401'. If you don't mind the warning, you can remove this from your package.json dev script.
### Building for Production
To build your project for production, use:
```bash
yarn build
```
(Repeat for npm, pnpm, and bun with respective commands.)
Once the build process is completed, your application will be ready for deployment in a production environment.
## 💪 Support Vuetify Development
This project is built with [Vuetify](https://vuetifyjs.com/en/), a UI Library with a comprehensive collection of Vue components. Vuetify is an MIT licensed Open Source project that has been made possible due to the generous contributions by our [sponsors and backers](https://vuetifyjs.com/introduction/sponsors-and-backers/). If you are interested in supporting this project, please consider:
- [Requesting Enterprise Support](https://support.vuetifyjs.com/)
- [Sponsoring John on Github](https://github.com/users/johnleider/sponsorship)
- [Sponsoring Kael on Github](https://github.com/users/kaelwd/sponsorship)
- [Supporting the team on Open Collective](https://opencollective.com/vuetify)
- [Becoming a sponsor on Patreon](https://www.patreon.com/vuetify)
- [Becoming a subscriber on Tidelift](https://tidelift.com/subscription/npm/vuetify)
- [Making a one-time donation with Paypal](https://paypal.me/vuetify)
## 📑 License
[MIT](http://opensource.org/licenses/MIT)
Copyright (c) 2016-present Vuetify, LLC

16
index.html Normal file
View File

@ -0,0 +1,16 @@
<!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>Peresvet - System Trace</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

6645
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
package.json Normal file
View File

@ -0,0 +1,51 @@
{
"name": "interface",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint . --fix --ignore-path .gitignore"
},
"dependencies": {
"@mdi/font": "6.2.95",
"@vueuse/core": "^10.10.0",
"axios": "^1.7.2",
"core-js": "^3.34.0",
"moment": "^2.30.1",
"roboto-fontface": "*",
"vue": "^3.4.21",
"vuetify": "^3.5.8"
},
"devDependencies": {
"@babel/types": "^7.24.0",
"@types/node": "^20.11.25",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-typescript": "^13.0.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-config-standard": "^17.1.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-n": "^16.6.2",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-vue": "^9.22.0",
"pinia": "^2.1.7",
"postcss": "^8.4.38",
"prettier": "3.3.2",
"sass": "^1.71.1",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.2",
"unplugin-auto-import": "^0.17.5",
"unplugin-fonts": "^1.1.1",
"unplugin-vue-components": "^0.26.0",
"unplugin-vue-router": "^0.8.4",
"vite": "^5.1.5",
"vite-plugin-pages": "^0.32.2",
"vite-plugin-vue-layouts": "^0.11.0",
"vite-plugin-vuetify": "^2.0.3",
"vue-router": "^4.3.0",
"vue-tsc": "^2.0.6"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

25
public/images/flag_en.svg Normal file
View File

@ -0,0 +1,25 @@
<svg width="32" height="24" viewBox="0 0 32 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_270_67366)">
<rect width="32" height="24" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0V24H32V0H0Z" fill="#2E42A5"/>
<mask id="mask0_270_67366" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="32" height="24">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0V24H32V0H0Z" fill="white"/>
</mask>
<g mask="url(#mask0_270_67366)">
<path d="M-3.56311 22.2854L3.47858 25.2635L32.1598 3.23787L35.8741 -1.18761L28.3441 -2.18297L16.6457 7.3085L7.22968 13.7035L-3.56311 22.2854Z" fill="white"/>
<path d="M-2.59912 24.3719L0.988295 26.1001L34.5403 -1.59881H29.5032L-2.59912 24.3719Z" fill="#F50100"/>
<path d="M35.5631 22.2854L28.5214 25.2635L-0.159817 3.23787L-3.87415 -1.18761L3.65593 -2.18297L15.3543 7.3085L24.7703 13.7035L35.5631 22.2854Z" fill="white"/>
<path d="M35.3229 23.7829L31.7355 25.5111L17.4487 13.6518L13.2129 12.3267L-4.23151 -1.17246H0.805637L18.2403 12.0063L22.8713 13.5952L35.3229 23.7829Z" fill="#F50100"/>
<mask id="path-7-inside-1_270_67366" fill="white">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.7778 -2H12.2222V8H-1.97247V16H12.2222V26H19.7778V16H34.0275V8H19.7778V-2Z"/>
</mask>
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.7778 -2H12.2222V8H-1.97247V16H12.2222V26H19.7778V16H34.0275V8H19.7778V-2Z" fill="#F50100"/>
<path d="M12.2222 -2V-4H10.2222V-2H12.2222ZM19.7778 -2H21.7778V-4H19.7778V-2ZM12.2222 8V10H14.2222V8H12.2222ZM-1.97247 8V6H-3.97247V8H-1.97247ZM-1.97247 16H-3.97247V18H-1.97247V16ZM12.2222 16H14.2222V14H12.2222V16ZM12.2222 26H10.2222V28H12.2222V26ZM19.7778 26V28H21.7778V26H19.7778ZM19.7778 16V14H17.7778V16H19.7778ZM34.0275 16V18H36.0275V16H34.0275ZM34.0275 8H36.0275V6H34.0275V8ZM19.7778 8H17.7778V10H19.7778V8ZM12.2222 0H19.7778V-4H12.2222V0ZM14.2222 8V-2H10.2222V8H14.2222ZM-1.97247 10H12.2222V6H-1.97247V10ZM0.0275269 16V8H-3.97247V16H0.0275269ZM12.2222 14H-1.97247V18H12.2222V14ZM14.2222 26V16H10.2222V26H14.2222ZM19.7778 24H12.2222V28H19.7778V24ZM17.7778 16V26H21.7778V16H17.7778ZM34.0275 14H19.7778V18H34.0275V14ZM32.0275 8V16H36.0275V8H32.0275ZM19.7778 10H34.0275V6H19.7778V10ZM17.7778 -2V8H21.7778V-2H17.7778Z" fill="white" mask="url(#path-7-inside-1_270_67366)"/>
</g>
</g>
<defs>
<clipPath id="clip0_270_67366">
<rect width="32" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

18
public/images/flag_ru.svg Normal file
View File

@ -0,0 +1,18 @@
<svg width="32" height="24" viewBox="0 0 32 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_270_67492)">
<rect width="32" height="24" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0V24H32V0H0Z" fill="#3D58DB"/>
<mask id="mask0_270_67492" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="32" height="24">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0V24H32V0H0Z" fill="white"/>
</mask>
<g mask="url(#mask0_270_67492)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0V8H32V0H0Z" fill="#F7FCFF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 16V24H32V16H0Z" fill="#C51918"/>
</g>
</g>
<defs>
<clipPath id="clip0_270_67492">
<rect width="32" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 788 B

11
src/App.vue Normal file
View File

@ -0,0 +1,11 @@
<template>
<v-app>
<v-main>
<router-view />
</v-main>
</v-app>
</template>
<script lang="ts" setup>
//
</script>

16
src/api/auth.ts Normal file
View File

@ -0,0 +1,16 @@
import { $axios } from '@/plugins/axios'
import { AxiosResponse } from 'axios'
export default {
login: async (email: string, password: string): Promise<[AxiosResponse?, string?]> => {
try {
const data = await $axios.post('v1/auth/login', {
email,
password,
})
return [data, undefined]
} catch (e: any) {
return [undefined, e?.response?.data?.error ?? e.message]
}
}
}

24
src/api/groups.ts Normal file
View File

@ -0,0 +1,24 @@
import { $axios } from '@/plugins/axios';
import { AxiosResponse } from 'axios';
import { OrderBy } from '../types/order-by';
import { camelToSnake } from '../mixins/case-converter';
export default {
getAll: async (
count: number = 10,
page: number = 1,
orderBy: OrderBy,
): Promise<[AxiosResponse?, string?]> => {
try {
const order: string = camelToSnake(
new URLSearchParams([...Object.entries(orderBy)]).toString(),
);
const data = await $axios.get(
`v1/groups?count=${count}&page=${page}&${order}`,
);
return [data, undefined];
} catch (e: any) {
return [undefined, e?.response?.data?.error ?? e.message];
}
},
};

9
src/api/index.ts Normal file
View File

@ -0,0 +1,9 @@
import auth from './auth';
import users from './users';
import groups from './groups';
export default {
auth,
users,
groups,
};

24
src/api/users.ts Normal file
View File

@ -0,0 +1,24 @@
import { $axios } from '@/plugins/axios';
import { AxiosResponse } from 'axios';
import { OrderBy } from '../types/order-by';
import { camelToSnake } from '../mixins/case-converter';
export default {
getAll: async (
count: number = 10,
page: number = 1,
orderBy: OrderBy,
): Promise<[AxiosResponse?, string?]> => {
try {
const order: string = camelToSnake(
new URLSearchParams([...Object.entries(orderBy)]).toString(),
);
const data = await $axios.get(
`v1/users?count=${count}&page=${page}&${order}`,
);
return [data, undefined];
} catch (e: any) {
return [undefined, e?.response?.data?.error ?? e.message];
}
},
};

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

8
src/assets/logo.svg Normal file
View File

@ -0,0 +1,8 @@
<svg width="81" height="24" viewBox="0 0 81 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="48" y="16.0547" width="32.1081" height="7.94595" fill="black"/>
<rect width="32.1081" height="7.94595" fill="black"/>
<rect x="40.0547" y="7.94531" width="7.94595" height="8.10811" fill="black"/>
<rect x="32.1094" y="7.94531" width="7.94595" height="16.0541" fill="black"/>
<rect y="7.94531" width="7.94595" height="16.0541" fill="black"/>
<rect x="40.0547" width="40.0541" height="7.94595" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 518 B

View File

@ -0,0 +1,3 @@
# Styles
This directory is for configuring the styles of the application.

View File

@ -0,0 +1,66 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import './fonts.css';
@import './table.css';
.accent-modal {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)) !important;
background: rgb(var(--v-theme-code)) !important;
}
.border-b {
border-bottom-width: 1px !important;
border-color: rgba(var(--v-border-color), var(--v-border-opacity)) !important;
}
.border-t {
border-top-width: 1px !important;
border-color: rgba(var(--v-border-color), var(--v-border-opacity)) !important;
}
.border-r {
border-right-width: 1px !important;
border-color: rgba(var(--v-border-color), var(--v-border-opacity)) !important;
}
.border-l {
border-left-width: 1px !important;
border-color: rgba(var(--v-border-color), var(--v-border-opacity)) !important;
}
.border-a {
border-width: 1px !important;
border-color: rgba(var(--v-border-color), var(--v-border-opacity)) !important;
}
.text-h6 {
font-weight: 400;
}
.cm-mini {
min-height: 30px !important;
}
html {
font-size: 0.875rem;
/* 14px */
line-height: 1.25rem;
/* 20px */
overscroll-behavior: none;
font-family: 'Roboto', sans-serif;
}
.v-theme--light {
--v-theme-primary: 11, 87, 208 !important;
}
.v-theme--dark {
--v-theme-primary: 51, 129, 255 !important;
.v-overlay__scrim {
--v-theme-on-surface: 0, 0, 0;
--v-overlay-opacity: 0.5;
}
}

View File

@ -0,0 +1,61 @@
html {
font-family: 'Roboto';
}
@font-face {
font-family: 'Roboto';
src: url('../fonts/Roboto/Roboto-Thin.ttf') format('truetype');
font-weight: 100;
}
@font-face {
font-family: 'Roboto';
src: url('../fonts/Roboto/Roboto-Light.ttf') format('truetype');
font-weight: 300;
}
@font-face {
font-family: 'Roboto';
src: url('../fonts/Roboto/Roboto-Regular.ttf') format('truetype');
font-weight: 400;
}
@font-face {
font-family: 'Roboto';
src: url('../fonts/Roboto/Roboto-Medium.ttf') format('truetype');
font-weight: 500;
}
@font-face {
font-family: 'Roboto';
src: url('../fonts/Roboto/Roboto-Bold.ttf') format('truetype');
font-weight: 700;
}
@font-face {
font-family: 'Roboto';
src: url('../fonts/Roboto/Roboto-Black.ttf') format('truetype');
font-weight: 900;
}
@font-face {
font-family: 'MDI';
src: url('../fonts/MDI/MaterialSymbolsOutlined-Light.ttf') format('truetype');
font-weight: 300;
}
@font-face {
font-family: 'MDI';
src: url('../fonts/MDI/MaterialSymbolsOutlined-Regular.ttf') format('truetype');
font-weight: 400;
}
.mso {
font-family: 'MDI';
font-size: 24px;
font-variation-settings:
'FILL' 0,
'wght' 300,
'GRAD' 0,
'opsz' 24
}

View File

@ -0,0 +1,83 @@
/**
* src/styles/settings.scss
*
* Configures SASS variables and Vuetify overwrites
*/
// https://vuetifyjs.com/features/sass-variables/`
@use 'vuetify/settings' with ( // $color-pack: false
$card-item-padding: .625rem 0,
$selection-control-size: 25px,
$utilities: ("align-content": false,
"align-items": false,
"align-self": false,
"border-bottom": false,
"border-end": false,
"border-opacity": false,
"border-start": false,
"border-style": false,
"border-top": false,
"border": false,
"display": false,
"flex-direction": false,
"flex-grow": false,
"flex-shrink": false,
"flex-wrap": false,
"flex": false,
"float-ltr": false,
"float-rtl": false,
"float": false,
"font-italic": false,
"font-weight": false,
"justify-content": false,
"margin-bottom": false,
"margin-end": false,
"margin-left": false,
"margin-right": false,
"margin-start": false,
"margin-top": false,
"margin-x": false,
"margin-y": false,
"margin": false,
"negative-margin-bottom": false,
"negative-margin-end": false,
"negative-margin-left": false,
"negative-margin-right": false,
"negative-margin-start": false,
"negative-margin-top": false,
"negative-margin-x": false,
"negative-margin-y": false,
"negative-margin": false,
"order": false,
"overflow-wrap": false,
"overflow-x": false,
"overflow-y": false,
"overflow": false,
"padding-bottom": false,
"padding-end": false,
"padding-left": false,
"padding-right": false,
"padding-start": false,
"padding-top": false,
"padding-x": false,
"padding-y": false,
"padding": false,
// "rounded-bottom-end": false,
// "rounded-bottom-start": false,
// "rounded-bottom": false,
// "rounded-end": false,
// "rounded-start": false,
// "rounded-top-end": false,
// "rounded-top-start": false,
// "rounded-top": false,
// "rounded": false,
"text-align": false,
"text-decoration": false,
"text-mono": false,
"text-opacity": false,
"text-overflow": false,
"text-transform": false,
// "typography": false,
"white-space": false,
),
);

View File

@ -0,0 +1,29 @@
.v-table .v-table__wrapper>table>tbody>tr:not(:last-child)>td,
.v-table .v-table__wrapper>table>tbody>tr:not(:last-child)>th {
border-bottom: none !important;
}
.v-table .v-table__wrapper>table>tbody>tr>td,
.v-table .v-table__wrapper>table>tbody>tr>th {
box-shadow: inset 0 -1px 0 rgba(var(--v-border-color), var(--v-border-opacity));
}
table>tbody>tr>td:last-child,
table>thead>tr>th:last-child {
position: sticky !important;
position: -webkit-sticky !important;
display: flex;
align-items: center;
justify-content: center;
right: 0;
border-right: none;
border-bottom: none !important;
z-index: 9998;
background: rgb(var(--v-theme-surface));
border-left-width: 1px !important;
border-color: rgba(var(--v-border-color), var(--v-border-opacity)) !important;
}
table>thead>tr>th:last-child {
z-index: 9999 !important;
}

191
src/auto-imports.d.ts vendored Normal file
View File

@ -0,0 +1,191 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const effectScope: typeof import('vue')['effectScope']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useLink: typeof import('vue-router')['useLink']
const useRoute: typeof import('vue-router/auto')['useRoute']
const useRouter: typeof import('vue-router/auto')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
import('vue')
}
// for vue template auto import
import { UnwrapRef } from 'vue'
declare module 'vue' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
readonly ref: UnwrapRef<typeof import('vue')['ref']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly unref: UnwrapRef<typeof import('vue')['unref']>
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
readonly useRoute: UnwrapRef<typeof import('vue-router/auto')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router/auto')['useRouter']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
readonly watch: UnwrapRef<typeof import('vue')['watch']>
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
}
}
declare module '@vue/runtime-core' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
readonly ref: UnwrapRef<typeof import('vue')['ref']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly unref: UnwrapRef<typeof import('vue')['unref']>
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
readonly useRoute: UnwrapRef<typeof import('vue-router/auto')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router/auto')['useRouter']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
readonly watch: UnwrapRef<typeof import('vue')['watch']>
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
}
}

20
src/components.d.ts vendored Normal file
View File

@ -0,0 +1,20 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
AppFooter: typeof import('./components/AppFooter.vue')['default']
ErrorPlate: typeof import('./components/ErrorPlate.vue')['default']
HelloWorld: typeof import('./components/HelloWorld.vue')['default']
LanguageSwitcher: typeof import('./components/LanguageSwitcher.vue')['default']
LogoComponent: typeof import('./components/Icons/LogoComponent.vue')['default']
NavigationDrawer: typeof import('./components/NavigationDrawer.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ThemeSwitcher: typeof import('./components/ThemeSwitcher.vue')['default']
}
}

View File

@ -0,0 +1,79 @@
<template>
<v-footer height="40" app>
<a
v-for="item in items"
:key="item.title"
:href="item.href"
:title="item.title"
class="d-inline-block mx-2 social-link"
rel="noopener noreferrer"
target="_blank"
>
<v-icon
:icon="item.icon"
:size="item.icon === '$vuetify' ? 24 : 16"
/>
</a>
<div
class="text-caption text-disabled"
style="position: absolute; right: 16px;"
>
&copy; 2016-{{ (new Date()).getFullYear() }} <span class="d-none d-sm-inline-block">Vuetify, LLC</span>
<a
class="text-decoration-none on-surface"
href="https://vuetifyjs.com/about/licensing/"
rel="noopener noreferrer"
target="_blank"
>
MIT License
</a>
</div>
</v-footer>
</template>
<script setup lang="ts">
const items = [
{
title: 'Vuetify Documentation',
icon: `$vuetify`,
href: 'https://vuetifyjs.com/',
},
{
title: 'Vuetify Support',
icon: 'mdi-shield-star-outline',
href: 'https://support.vuetifyjs.com/',
},
{
title: 'Vuetify X',
icon: `svg:M2.04875 3.00002L9.77052 13.3248L1.99998 21.7192H3.74882L10.5519 14.3697L16.0486 21.7192H22L13.8437 10.8137L21.0765 3.00002H19.3277L13.0624 9.76874L8.0001 3.00002H2.04875ZM4.62054 4.28821H7.35461L19.4278 20.4308H16.6937L4.62054 4.28821Z`,
href: 'https://x.com/vuetifyjs',
},
{
title: 'Vuetify GitHub',
icon: `mdi-github`,
href: 'https://github.com/vuetifyjs/vuetify',
},
{
title: 'Vuetify Discord',
icon: `mdi-discord`,
href: 'https://community.vuetifyjs.com/',
},
{
title: 'Vuetify Reddit',
icon: `mdi-reddit`,
href: 'https://reddit.com/r/vuetifyjs',
},
]
</script>
<style scoped lang="sass">
.social-link :deep(.v-icon)
color: rgba(var(--v-theme-on-background), var(--v-disabled-opacity))
text-decoration: none
transition: .2s ease-in-out
&:hover
color: rgba(25, 118, 210, 1)
</style>

View File

@ -0,0 +1,67 @@
<script setup lang="ts">
import { useLocale } from 'vuetify'
defineProps({
error: {
type: String,
default: ''
},
block: {
type: Boolean,
default: false,
},
noBorder: {
type: Boolean,
default: false,
}
})
const { t } = useLocale()
function parseError(e: string): string {
if (e.includes('$vuetify', 0)) {
return e
}
if (e.includes('validator')) {
const errs = e.split(' || ')
for (const err of errs) {
if (err.includes('validator', 0)) {
const splitted = err.split(':')
if (splitted?.length) {
const right = splitted.pop()
if (right && right.includes('/')) {
const [field, key] = right.split('/')
return t(`$vuetify.validator.field`,
field.toLowerCase(),
t(`$vuetify.validator.${key.replace(/'/gm, '')}`),
)
}
}
}
}
}
return e
}
</script>
<template>
<span
v-if="error?.length"
class="text-sm text-error flex items-center"
:class="{
'error-block': block,
'my-2': !block,
'border-b': !noBorder,
}"
><span class="mso mr-2 text-2xl">error</span> {{ parseError(error) }}</span>
</template>
<style scoped>
.error-block {
background-color: rgba(var(--v-theme-error), var(--v-border-opacity));
border-color: rgba(var(--v-theme-error), var(--v-border-opacity));
padding: 0 0.8rem;
}
</style>

View File

@ -0,0 +1,157 @@
<template>
<v-container class="fill-height">
<v-responsive
class="align-centerfill-height mx-auto"
max-width="900"
>
<v-img
class="mb-4"
height="150"
src="@/assets/logo.png"
/>
<div class="text-center">
<div class="text-body-2 font-weight-light mb-n1">Welcome to</div>
<h1 class="text-h2 font-weight-bold">Vuetify</h1>
</div>
<div class="py-4" />
<v-row>
<v-col cols="12">
<v-card
class="py-4"
color="surface-variant"
image="https://cdn.vuetifyjs.com/docs/images/one/create/feature.png"
prepend-icon="mdi-rocket-launch-outline"
rounded="lg"
variant="outlined"
>
<template #image>
<v-img position="top right" />
</template>
<template #title>
<h2 class="text-h5 font-weight-bold">Get started</h2>
</template>
<template #subtitle>
<div class="text-subtitle-1">
Replace this page by removing <v-kbd>{{ `<HelloWorld />` }}</v-kbd> in <v-kbd>pages/index.vue</v-kbd>.
</div>
</template>
<v-overlay
opacity=".12"
scrim="primary"
contained
model-value
persistent
/>
</v-card>
</v-col>
<v-col cols="6">
<v-card
append-icon="mdi-open-in-new"
class="py-4"
color="surface-variant"
href="https://vuetifyjs.com/"
prepend-icon="mdi-text-box-outline"
rel="noopener noreferrer"
rounded="lg"
subtitle="Learn about all things Vuetify in our documentation."
target="_blank"
title="Documentation"
variant="text"
>
<v-overlay
opacity=".06"
scrim="primary"
contained
model-value
persistent
/>
</v-card>
</v-col>
<v-col cols="6">
<v-card
append-icon="mdi-open-in-new"
class="py-4"
color="surface-variant"
href="https://vuetifyjs.com/introduction/why-vuetify/#feature-guides"
prepend-icon="mdi-star-circle-outline"
rel="noopener noreferrer"
rounded="lg"
subtitle="Explore available framework Features."
target="_blank"
title="Features"
variant="text"
>
<v-overlay
opacity=".06"
scrim="primary"
contained
model-value
persistent
/>
</v-card>
</v-col>
<v-col cols="6">
<v-card
append-icon="mdi-open-in-new"
class="py-4"
color="surface-variant"
href="https://vuetifyjs.com/components/all"
prepend-icon="mdi-widgets-outline"
rel="noopener noreferrer"
rounded="lg"
subtitle="Discover components in the API Explorer."
target="_blank"
title="Components"
variant="text"
>
<v-overlay
opacity=".06"
scrim="primary"
contained
model-value
persistent
/>
</v-card>
</v-col>
<v-col cols="6">
<v-card
append-icon="mdi-open-in-new"
class="py-4"
color="surface-variant"
href="https://discord.vuetifyjs.com"
prepend-icon="mdi-account-group-outline"
rel="noopener noreferrer"
rounded="lg"
subtitle="Connect with Vuetify developers."
target="_blank"
title="Community"
variant="text"
>
<v-overlay
opacity=".06"
scrim="primary"
contained
model-value
persistent
/>
</v-card>
</v-col>
</v-row>
</v-responsive>
</v-container>
</template>
<script setup lang="ts">
//
</script>

View File

@ -0,0 +1,23 @@
<script setup lang="ts">
import { computed, type ComputedRef } from 'vue';
const props = defineProps({
theme: {
type: String,
default: 'dark'
}
})
const fill: ComputedRef<string> = computed(() => props.theme === 'dark' ? '#FFF' : '#000')
</script>
<template>
<svg width="81" height="24" viewBox="0 0 81 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="48" y="16.0547" width="32.1081" height="7.94595" :fill="fill"/>
<rect width="32.1081" height="7.94595" :fill="fill"/>
<rect x="40.0547" y="7.94531" width="7.94595" height="8.10811" :fill="fill"/>
<rect x="32.1094" y="7.94531" width="7.94595" height="16.0541" :fill="fill"/>
<rect y="7.94531" width="7.94595" height="16.0541" :fill="fill"/>
<rect x="40.0547" width="40.0541" height="7.94595" :fill="fill"/>
</svg>
</template>

View File

@ -0,0 +1,75 @@
<script lang="ts" setup>
import { useLocale } from 'vuetify';
import { $cookie } from '@/plugins/cookie'
import { onBeforeMount } from 'vue';
import { COOKIE_LOCALE } from '../constants/static';
import Moment from '@/plugins/moment'
const { current } = useLocale()
const languages = [
{
title: 'Русский',
locale: 'ru'
},
{
title: 'English',
locale: 'en'
}
]
const key = COOKIE_LOCALE
const get = (): string => {
const v = $cookie.get(key);
Moment.$moment.locale(v);
return v
}
const set = (locale: string): void => {
current.value = locale;
Moment.$moment.locale(locale);
$cookie.set(key, locale);
}
const setLocaleFromExistCookie = (): void => {
const v = get()
if (v?.length && typeof v === 'string') {
set(v)
}
}
onBeforeMount(setLocaleFromExistCookie)
</script>
<template>
<v-menu
transition="slide-y-transition"
close-delay="0"
open-delay="0"
>
<template v-slot:activator="{ props }">
<v-btn icon density="comfortable">
<span class="mso" v-bind="props">language</span>
</v-btn>
</template>
<v-list density="compact" class="mt-3 accent-modal" elevation="0">
<v-list-item
v-for="(item, i) in languages"
:key="i"
class="cm-mini"
@click="set(item.locale)"
>
<template v-slot:prepend>
<img
class="flag mr-2"
:src="`/images/flag_${item.locale}.svg`"
/>
</template>
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
<style scoped>
.flag {
height: 14px;
border-radius: 4px;
}
</style>

View File

@ -0,0 +1,45 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useLocale } from 'vuetify';
import { ROUTES } from '@/constants/routes';
const props = defineProps({
drawer: Boolean
})
const emits = defineEmits(['change'])
const { t } = useLocale()
const modelValue = ref<boolean>(props.drawer)
watch(props, (v: any, _) => {
modelValue.value = v.drawer
})
function change(e: boolean) {
emits('change', e)
}
</script>
<template>
<v-navigation-drawer
v-model="modelValue"
@update:modelValue="change"
class="pt-3 elevation-0 border-r"
:scrim="false"
>
<v-list-item
v-for="(item, i) in ROUTES"
:key="i"
:value="item"
color="primary"
:to="item.value"
>
<template v-slot:prepend>
<span class="mso mr-3">{{ item.icon }}</span>
</template>
<v-list-item-title>{{ t(item.title) }}</v-list-item-title>
</v-list-item>
</v-navigation-drawer>
</template>

35
src/components/README.md Normal file
View File

@ -0,0 +1,35 @@
# Components
Vue template files in this folder are automatically imported.
## 🚀 Usage
Importing is handled by [unplugin-vue-components](https://github.com/unplugin/unplugin-vue-components). This plugin automatically imports `.vue` files created in the `src/components` directory, and registers them as global components. This means that you can use any component in your application without having to manually import it.
The following example assumes a component located at `src/components/MyComponent.vue`:
```vue
<template>
<div>
<MyComponent />
</div>
</template>
<script lang="ts" setup>
//
</script>
```
When your template is rendered, the component's import will automatically be inlined, which renders to this:
```vue
<template>
<div>
<MyComponent />
</div>
</template>
<script lang="ts" setup>
import MyComponent from '@/components/MyComponent.vue'
</script>
```

View File

@ -0,0 +1,35 @@
<script setup lang="ts">
import { useTheme } from 'vuetify'
import { $cookie } from '@/plugins/cookie'
import { onBeforeMount } from 'vue';
import { COOKIE_THEME } from '../constants/static';
const theme = useTheme()
const key = COOKIE_THEME
const get = (): string => {
return $cookie.get(key);
}
const set = (value: string): void => {
theme.global.name.value = value;
$cookie.set(key, value);
}
const toggle = (): void => {
const t = theme.global.current?.value?.dark ? 'light' : 'dark'
theme.global.name.value = t
$cookie.set(key, t);
}
const setThemeFromExistCookie = (): void => {
const v = get()
if (v?.length && typeof v === 'string') {
set(v)
}
}
onBeforeMount(setThemeFromExistCookie)
</script>
<template>
<v-btn icon density="comfortable" @click="toggle">
<span class="mso">routine</span>
</v-btn>
</template>

11
src/constants/defaults.ts Normal file
View File

@ -0,0 +1,11 @@
export const defaultCursor = JSON.stringify({
count: 10,
currentPage: 1,
totalPages: 1,
totalRows: 10,
});
export const defaultSort = JSON.stringify({
key: 'id',
order: 'asc',
});

32
src/constants/routes.ts Normal file
View File

@ -0,0 +1,32 @@
export const ROUTES = [
{
title: '$vuetify.navigation.dashboards',
value: '/',
icon: 'dashboard'
},
{
title: '$vuetify.navigation.tasks',
value: '/tasks',
icon: 'web_traffic'
},
{
title: '$vuetify.navigation.servers',
value: '/servers',
icon: 'dns'
},
{
title: '$vuetify.navigation.users',
value: '/users',
icon: 'group'
},
{
title: '$vuetify.navigation.patterns',
value: '/patterns',
icon: 'layers'
},
{
title: '$vuetify.navigation.logs',
value: '/logs',
icon: 'menu'
},
]

4
src/constants/static.ts Normal file
View File

@ -0,0 +1,4 @@
export const ITEMS_PER_PAGE = [10, 25, 50, 100]
export const COOKIE_LOCALE = 'user_locale'
export const COOKIE_THEME = 'user_theme'

View File

@ -0,0 +1,7 @@
export enum UserActions {
EDIT,
BLOCK,
UNBLOCK,
RESET_PASSWORD,
DELETE,
}

View File

@ -0,0 +1,4 @@
export enum UsersPageTabs {
USERS,
GROUPS,
}

5
src/layouts/README.md Normal file
View File

@ -0,0 +1,5 @@
# Layouts
Layouts are reusable components that wrap around pages. They are used to provide a consistent look and feel across multiple pages.
Full documentation for this feature can be found in the Official [vite-plugin-vue-layouts](https://github.com/JohnCampionJr/vite-plugin-vue-layouts) repository.

13
src/layouts/centered.vue Normal file
View File

@ -0,0 +1,13 @@
<script lang="ts" setup>
//
</script>
<template>
<v-app>
<v-main>
<div class="flex flex-col items-center justify-center h-full w-full">
<router-view />
</div>
</v-main>
</v-app>
</template>

39
src/layouts/default.vue Normal file
View File

@ -0,0 +1,39 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTheme } from 'vuetify'
import NavigationDrawer from '../components/NavigationDrawer.vue'
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
import ThemeSwitcher from '../components/ThemeSwitcher.vue'
const drawer = ref(true)
const theme = useTheme()
const currentTheme = computed(() => theme.global.name.value)
</script>
<template>
<v-app :style="`background-color: ${currentTheme === 'light' ? '#f0f4f9' : '#1c1c1c'}`">
<v-app-bar
prominent
class="elevation-0 border-b px-2"
>
<v-app-bar-nav-icon density="comfortable" @click="drawer = !drawer"></v-app-bar-nav-icon>
<v-app-bar-title>System Trace</v-app-bar-title>
<v-spacer></v-spacer>
<!-- Theme switcher -->
<ThemeSwitcher class="mr-1" />
<!-- Language switcher -->
<LanguageSwitcher />
</v-app-bar>
<NavigationDrawer
:drawer="drawer"
@change="(e) => drawer = e"
/>
<v-main>
<router-view></router-view>
</v-main>
</v-app>
</template>

View File

@ -0,0 +1,13 @@
{
"actions": {
"refresh": "Refresh",
"create-group": "Create group",
"create-user": "Create user",
"reset-password": "Reset password",
"edit": "Edit",
"block": "Block",
"unblock": "Unblock",
"confirm": "Confirm",
"cancel": "Cancel"
}
}

11
src/locales/en/auth.json Normal file
View File

@ -0,0 +1,11 @@
{
"auth": {
"copyright": "Product of Peresvet LLC",
"title": "Authentication",
"email": "E-Mail address",
"password": "Password",
"login": "Log in",
"help": "Any problems? Contact ",
"help-link": "administrator"
}
}

View File

@ -0,0 +1,12 @@
{
"User with specified E-Mail and password not found": "User with specified email and password was not found",
"validator": {
"field": "Field {0} is not compliant: {1}",
"required": "required",
"min": "min. value",
"max": "max. value",
"password": "password rules",
"email": "E-Mail rules",
"endswith": "invalid value"
}
}

15
src/locales/en/index.ts Normal file
View File

@ -0,0 +1,15 @@
import errors from './errors.json'
import navigation from './navigation.json'
import users from './users.json'
import actions from './actions.json'
import auth from './auth.json'
import misc from './misc.json'
export default {
...errors,
...navigation,
...users,
...actions,
...auth,
...misc,
}

5
src/locales/en/misc.json Normal file
View File

@ -0,0 +1,5 @@
{
"misc": {
"actions": "Actions"
}
}

View File

@ -0,0 +1,10 @@
{
"navigation": {
"dashboards": "Dashboards",
"tasks": "Tasks",
"servers": "Servers",
"users": "Users",
"patterns": "Patterns",
"logs": "Logs"
}
}

31
src/locales/en/users.json Normal file
View File

@ -0,0 +1,31 @@
{
"users": {
"title": "Users",
"search": "Search",
"tabs": {
"users": "Users",
"groups": "Groups"
},
"all": "Users in group",
"heads": {
"id": "ID",
"email": "E-Mail",
"group": "Group",
"real-name": "Real name",
"password": "Password",
"status": "Status",
"last-visit": "Last visit",
"created-at": "Created at",
"updated-at": "Last update",
"name": "Name",
"permissions": "Permissions",
"issuer": "Issuer"
},
"statuses": {
"active": "Active",
"blocked": "Blocked",
"set": "Set",
"unset": "Not set"
}
}
}

View File

@ -0,0 +1,13 @@
{
"actions": {
"refresh": "Обновить",
"create-group": "Создать группу",
"create-user": "Создать пользователя",
"reset-password": "Сбросить пароль",
"edit": "Редактировать",
"block": "Заблокировать",
"unblock": "Разблокировать",
"confirm": "Применить",
"cancel": "Отменить"
}
}

11
src/locales/ru/auth.json Normal file
View File

@ -0,0 +1,11 @@
{
"auth": {
"copyright": "Продукт компании Пересвет",
"title": "Авторизация",
"email": "Адрес эл. почты",
"password": "Пароль",
"login": "Войти",
"help": "Проблемы? Сообщите ",
"help-link": "администратору"
}
}

View File

@ -0,0 +1,12 @@
{
"User with specified E-Mail and password not found": "Пользователь с указанными E-Mail и паролем не найден",
"validator": {
"field": "Поле {0} не соответствует требованиям: {1}",
"required": "обязательное поле",
"min": "мин. значение",
"max": "макс. значение",
"password": "требования к паролю",
"email": "требования к E-Mail",
"endswith": "недействительное значение"
}
}

15
src/locales/ru/index.ts Normal file
View File

@ -0,0 +1,15 @@
import errors from './errors.json'
import navigation from './navigation.json'
import users from './users.json'
import actions from './actions.json'
import auth from './auth.json'
import misc from './misc.json'
export default {
...errors,
...navigation,
...users,
...actions,
...auth,
...misc,
}

5
src/locales/ru/misc.json Normal file
View File

@ -0,0 +1,5 @@
{
"misc": {
"actions": "Действия"
}
}

View File

@ -0,0 +1,10 @@
{
"navigation": {
"dashboards": "Дашборды",
"tasks": "Задачи",
"servers": "Серверы",
"users": "Пользователи",
"patterns": "Паттерны",
"logs": "Логи"
}
}

34
src/locales/ru/users.json Normal file
View File

@ -0,0 +1,34 @@
{
"users": {
"title": "Пользователи",
"search": "Поиск",
"tabs": {
"users": "Пользователи",
"groups": "Группы"
},
"all": "Пользователи в группе",
"heads": {
"id": "ID",
"email": "E-Mail",
"group": "Группа",
"real-name": "Наст. имя",
"password": "Пароль",
"status": "Статус",
"last-visit": "Посл. вход",
"created-at": "Создан",
"updated-at": "Посл. изменения",
"name": "Название",
"permissions": "Права",
"issuer": "Издатель"
},
"statuses": {
"active": "Активный",
"blocked": "Заблокирован",
"set": "Установлен",
"unset": "Не установлен"
},
"creation": {
"create": "Создание пользователя"
}
}
}

23
src/main.ts Normal file
View File

@ -0,0 +1,23 @@
/**
* main.ts
*
* Bootstraps Vuetify and other plugins then mounts the App`
*/
// Plugins
import { registerPlugins } from '@/plugins'
// Components
import App from './App.vue'
// Styles
import './assets/styles/core.scss'
// Composables
import { createApp } from 'vue'
const app = createApp(App)
registerPlugins(app)
app.mount('#app')

View File

@ -0,0 +1,3 @@
export function camelToSnake(str: string): string {
return str.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
}

12
src/mixins/format.ts Normal file
View File

@ -0,0 +1,12 @@
export function secondsToHms(seconds: any): string {
const d = Math.floor(seconds / (3600 * 24));
const h = Math.floor((seconds % (3600 * 24)) / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const dDisplay = d > 0 ? d + 'd ' : '';
const hDisplay = h > 0 ? h + 'h ' : '';
const mDisplay = m > 0 ? m + 'm ' : '';
const sDisplay = s > 0 ? s + 's ' : '';
return dDisplay + hDisplay + mDisplay + sDisplay;
}

5
src/pages/README.md Normal file
View File

@ -0,0 +1,5 @@
# Pages
Vue components created in this folder will automatically be converted to navigatable routes.
Full documentation for this feature can be found in the Official [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) repository.

100
src/pages/auth.vue Normal file
View File

@ -0,0 +1,100 @@
<script lang="ts" setup>
import { type Ref, ref, computed } from 'vue'
import ErrorPlate from '../components/ErrorPlate.vue'
import LogoComponent from '../components/Icons/LogoComponent.vue'
import api from '../api/index'
import { useLocale, useTheme } from 'vuetify';
type AuthForm = {
email: string;
password: string;
}
const { t } = useLocale()
const theme = useTheme()
const currentTheme = computed(() => theme.global.name.value)
const err: Ref<string> = ref('')
const form: Ref<AuthForm> = ref({
email: '',
password: '',
})
const doAuth = async () => {
const [_, e] = await api.auth.login(form.value.email, form.value.password)
if (e && typeof e === 'string') {
err.value = e
}
}
</script>
<template>
<div class="flex relative flex-col items-center justify-center h-full">
<!-- Card -->
<v-card
width="800px"
class="p-12 rounded-3xl"
elevation="0"
>
<!-- Logo -->
<LogoComponent :theme="currentTheme" />
<div class="grid grid-cols-5 mt-8">
<!-- Left side -->
<div class="items-start flex flex-col col-span-2">
<span class="text-3xl font-medium">{{ t('$vuetify.auth.title') }}</span>
<!-- Copyright -->
<span class="text-sm mt-2 opacity-75">{{ t('$vuetify.auth.copyright') }}</span>
</div>
<!-- Right side -->
<div class="flex flex-col col-span-3">
<!-- Login -->
<v-text-field
v-model="form.email"
:label="t('$vuetify.auth.email')"
:variant="currentTheme === 'dark' ? 'solo-filled' : 'outlined'"
flat
hide-details
>
<!-- <template v-slot:prepend-inner>
<span class="mso mr-1 text-2xl">email</span>
</template> -->
</v-text-field>
<!-- Password -->
<v-text-field
v-model="form.password"
:label="t('$vuetify.auth.password')"
:variant="currentTheme === 'dark' ? 'solo-filled' : 'outlined'"
hide-details
flat
class="mt-2"
>
<!-- <template v-slot:prepend-inner>
<span class="mso mr-1 text-2xl">password</span>
</template> -->
</v-text-field>
<!-- Error -->
<ErrorPlate :error="err" no-border />
<!-- Help -->
<span class="mt-5">{{ t('$vuetify.auth.help') }}<a href="#" class="text-primary font-bold">{{ t('$vuetify.auth.help-link') }}</a></span>
<!-- Buttons -->
<v-card-actions class="flex justify-end px-0 mt-8">
<v-btn
:variant="currentTheme === 'dark' ? 'flat' : 'flat'"
color="primary"
class="px-8 rounded-3xl"
@click="doAuth"
>
<i class="mi-log-in"/>
<span>{{ t('$vuetify.auth.login') }}</span>
</v-btn>
</v-card-actions>
</div>
</div>
</v-card>
</div>
</template>
<!-- <route lang="yaml">
meta:
layout: centered
</route> -->

7
src/pages/index.vue Normal file
View File

@ -0,0 +1,7 @@
<template>
<HelloWorld />
</template>
<script lang="ts" setup>
//
</script>

View File

@ -0,0 +1,44 @@
<script setup lang="ts">
import { ref, type Ref } from 'vue';
import { useLocale, useTheme } from 'vuetify';
defineProps<{
modelValue: boolean;
}>()
defineEmits(['close'])
const { t } = useLocale()
const theme = useTheme()
</script>
<template>
<v-dialog persistent :model-value="modelValue" width="450">
<v-card
text="When using the activator slot, you must bind the slot props to the activator element."
title="Slot Activator"
>
<template v-slot:title>
<v-card-title class="p-0 w-full flex items-center">
<span class="mso mr-1 text-2xl">person</span>
<span>{{ t('$vuetify.users.creation.create') }}</span>
</v-card-title>
</template>
<v-card-actions class="flex justify-end p-5 gap-1">
<!-- Cancel -->
<v-btn
variant="text"
class="px-4"
:text="t('$vuetify.actions.cancel')"
@click="$emit('close')"
></v-btn>
<!-- Confirm -->
<v-btn
variant="flat"
color="primary"
class="px-4"
:text="t('$vuetify.actions.confirm')"
></v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>

View File

@ -0,0 +1,198 @@
<script setup lang="ts">
import {
computed,
ref,
watch,
onBeforeMount,
type Ref,
type ComputedRef,
} from 'vue';
import { useLocale } from 'vuetify';
import { ITEMS_PER_PAGE } from '@/constants/static';
import { useGroupsStore } from '@/stores/groups';
import { UserActions } from '@/enums/user-actions.enum';
import Moment from '@/plugins/moment';
defineProps<{
tableHeight: number;
}>();
const { t } = useLocale();
const $groups = useGroupsStore();
const headers: ComputedRef<any[]> = computed(() => [
{ title: t('$vuetify.users.heads.id'), key: 'ID' },
{ title: t('$vuetify.users.heads.email'), key: 'email' },
{ title: t('$vuetify.users.heads.group'), key: 'group' },
{ title: t('$vuetify.users.heads.real-name'), key: 'realName' },
{ title: t('$vuetify.users.heads.password'), key: 'isRequiredToSetPassword' },
{ title: t('$vuetify.users.heads.status'), key: 'isActive' },
{ title: t('$vuetify.users.heads.last-visit'), key: 'lastLogin' },
{ title: t('$vuetify.users.heads.created-at'), key: 'createdAt' },
{
title: t('$vuetify.misc.actions'),
key: 'actions',
sortable: false,
align: 'center',
},
]);
const actions = computed(() => [
{
title: t('$vuetify.actions.edit'),
value: UserActions.EDIT,
blocked: false,
},
{
title: t('$vuetify.actions.block'),
value: UserActions.BLOCK,
blocked: false,
},
{
title: t('$vuetify.actions.reset-password'),
value: UserActions.RESET_PASSWORD,
blocked: false,
},
{
title: t('$vuetify.actions.unblock'),
value: UserActions.UNBLOCK,
blocked: true,
},
]);
const selectedGroups: Ref<number[]> = ref([]);
onBeforeMount($groups.getAll);
watch(() => [$groups.page, $groups.perPage, $groups.orderBy], $groups.getAll);
watch(selectedGroups, (n, o) => console.log(n, o));
</script>
<template>
<v-data-table-server
v-model="selectedGroups"
:search="$groups.search"
:header-props="{ nowrap: true }"
:headers="headers"
:items="$groups.all"
:items-per-page-options="ITEMS_PER_PAGE"
:items-length="$groups.cursor?.totalRows"
:loading="$groups.loading"
:height="tableHeight - 62"
fixed-footer
fixed-header
show-select
density="compact"
style="--fixed: 10"
@update:sort-by="$groups.setSort"
@update:page="$groups.setPage"
@update:itemsPerPage="$groups.setPerPage"
>
<!-- Fields -->
<!-- Group -->
<template v-slot:item.group="{ item }">
<template v-if="item.group">
<div class="flex items-center gap-1">
<span class="text-nowrap">{{ item.group.name }}</span>
<!-- Group users -->
<v-tooltip
:text="t('$vuetify.users.all')"
:close-delay="0"
:open-delay="0"
transition="no"
>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
size="small"
variant="tonal"
density="compact"
>
<span class="mso text-base">group</span>
</v-btn>
</template>
</v-tooltip>
</div>
</template>
<span v-else class="text-nowrap"></span>
</template>
<!-- Real name -->
<template v-slot:item.realName="{ item }">
<span class="text-nowrap">
{{ item.realName?.length ? item.realName : '—' }}
</span>
</template>
<!-- Required to set password -->
<template v-slot:item.isRequiredToSetPassword="{ item }">
<span
:class="!item.isRequiredToSetPassword ? '' : 'text-error'"
class="text-nowrap"
>
{{
item.isRequiredToSetPassword
? t('$vuetify.users.statuses.unset')
: t('$vuetify.users.statuses.set')
}}
</span>
</template>
<!-- User status -->
<template v-slot:item.isActive="{ item }">
<span :class="item.isActive ? '' : 'text-error'">
{{
item.isActive
? t('$vuetify.users.statuses.active')
: t('$vuetify.users.statuses.blocked')
}}
</span>
</template>
<!-- Last visit -->
<template v-slot:item.lastLogin="{ item }">
<span class="text-nowrap">
{{ Moment.$moment(item.lastLogin).format('DD MMM YYYY HH:mm') }}
</span>
</template>
<!-- Created at -->
<template v-slot:item.createdAt="{ item }">
<span class="text-nowrap">
{{ Moment.$moment(item.createdAt).format('DD MMM YYYY HH:mm') }}
</span>
</template>
<!-- Actions -->
<template v-slot:item.actions="{ item }">
<v-menu transition="slide-y-transition">
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
size="small"
variant="text"
density="comfortable"
class="mr-1"
>
<span class="mso text-xl">more_vert</span>
</v-btn>
</template>
<v-list class="mt-2 accent-modal" elevation="0" density="compact">
<template v-for="(act, index) of actions">
<v-list-item
v-if="
(act?.blocked && !item.isActive) ||
(!act?.blocked && item.isActive)
"
:key="index"
:value="act.value"
lines="one"
class="cm-mini"
>
<v-list-item-title>{{ act.title }}</v-list-item-title>
</v-list-item>
</template>
</v-list>
</v-menu>
<!-- Delete -->
<v-btn icon size="small" variant="text" density="comfortable">
<span class="mso text-xl">close</span>
</v-btn>
</template>
</v-data-table-server>
</template>

View File

@ -0,0 +1,198 @@
<script setup lang="ts">
import {
computed,
ref,
watch,
onBeforeMount,
type Ref,
type ComputedRef,
} from 'vue';
import { useLocale } from 'vuetify';
import { ITEMS_PER_PAGE } from '@/constants/static';
import { useUsersStore } from '@/stores/users';
import { UserActions } from '@/enums/user-actions.enum';
import Moment from '@/plugins/moment';
defineProps<{
tableHeight: number;
}>();
const { t } = useLocale();
const $users = useUsersStore();
const headers: ComputedRef<any[]> = computed(() => [
{ title: t('$vuetify.users.heads.id'), key: 'ID' },
{ title: t('$vuetify.users.heads.email'), key: 'email' },
{ title: t('$vuetify.users.heads.group'), key: 'group' },
{ title: t('$vuetify.users.heads.real-name'), key: 'realName' },
{ title: t('$vuetify.users.heads.password'), key: 'isRequiredToSetPassword' },
{ title: t('$vuetify.users.heads.status'), key: 'isActive' },
{ title: t('$vuetify.users.heads.last-visit'), key: 'lastLogin' },
{ title: t('$vuetify.users.heads.created-at'), key: 'createdAt' },
{
title: t('$vuetify.misc.actions'),
key: 'actions',
sortable: false,
align: 'center',
},
]);
const actions = computed(() => [
{
title: t('$vuetify.actions.edit'),
value: UserActions.EDIT,
blocked: false,
},
{
title: t('$vuetify.actions.block'),
value: UserActions.BLOCK,
blocked: false,
},
{
title: t('$vuetify.actions.reset-password'),
value: UserActions.RESET_PASSWORD,
blocked: false,
},
{
title: t('$vuetify.actions.unblock'),
value: UserActions.UNBLOCK,
blocked: true,
},
]);
const selectedUsers: Ref<number[]> = ref([]);
onBeforeMount($users.getAll);
watch(() => [$users.page, $users.perPage, $users.orderBy], $users.getAll);
watch(selectedUsers, (n, o) => console.log(n, o));
</script>
<template>
<v-data-table-server
v-model="selectedUsers"
:search="$users.search"
:header-props="{ nowrap: true }"
:headers="headers"
:items="$users.all"
:items-per-page-options="ITEMS_PER_PAGE"
:items-length="$users.cursor?.totalRows"
:loading="$users.loading"
:height="tableHeight - 62"
fixed-footer
fixed-header
show-select
density="compact"
style="--fixed: 10"
@update:sort-by="$users.setSort"
@update:page="$users.setPage"
@update:itemsPerPage="$users.setPerPage"
>
<!-- Fields -->
<!-- Group -->
<template v-slot:item.group="{ item }">
<template v-if="item.group">
<div class="flex items-center gap-1">
<span class="text-nowrap">{{ item.group.name }}</span>
<!-- Group users -->
<v-tooltip
:text="t('$vuetify.users.all')"
:close-delay="0"
:open-delay="0"
transition="no"
>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
size="small"
variant="tonal"
density="compact"
>
<span class="mso text-base">group</span>
</v-btn>
</template>
</v-tooltip>
</div>
</template>
<span v-else class="text-nowrap"></span>
</template>
<!-- Real name -->
<template v-slot:item.realName="{ item }">
<span class="text-nowrap">
{{ item.realName?.length ? item.realName : '—' }}
</span>
</template>
<!-- Required to set password -->
<template v-slot:item.isRequiredToSetPassword="{ item }">
<span
:class="!item.isRequiredToSetPassword ? '' : 'text-error'"
class="text-nowrap"
>
{{
item.isRequiredToSetPassword
? t('$vuetify.users.statuses.unset')
: t('$vuetify.users.statuses.set')
}}
</span>
</template>
<!-- User status -->
<template v-slot:item.isActive="{ item }">
<span :class="item.isActive ? '' : 'text-error'">
{{
item.isActive
? t('$vuetify.users.statuses.active')
: t('$vuetify.users.statuses.blocked')
}}
</span>
</template>
<!-- Last visit -->
<template v-slot:item.lastLogin="{ item }">
<span class="text-nowrap">
{{ Moment.$moment(item.lastLogin).format('DD MMM YYYY HH:mm') }}
</span>
</template>
<!-- Created at -->
<template v-slot:item.createdAt="{ item }">
<span class="text-nowrap">
{{ Moment.$moment(item.createdAt).format('DD MMM YYYY HH:mm') }}
</span>
</template>
<!-- Actions -->
<template v-slot:item.actions="{ item }">
<v-menu transition="slide-y-transition">
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
size="small"
variant="text"
density="comfortable"
class="mr-1"
>
<span class="mso text-xl">more_vert</span>
</v-btn>
</template>
<v-list class="mt-2 accent-modal" elevation="0" density="compact">
<template v-for="(act, index) of actions">
<v-list-item
v-if="
(act?.blocked && !item.isActive) ||
(!act?.blocked && item.isActive)
"
:key="index"
:value="act.value"
lines="one"
class="cm-mini"
>
<v-list-item-title>{{ act.title }}</v-list-item-title>
</v-list-item>
</template>
</v-list>
</v-menu>
<!-- Delete -->
<v-btn icon size="small" variant="text" density="comfortable">
<span class="mso text-xl">close</span>
</v-btn>
</template>
</v-data-table-server>
</template>

117
src/pages/users/index.vue Normal file
View File

@ -0,0 +1,117 @@
<script setup lang="ts">
import { ref, Ref } from 'vue';
import { useLocale } from 'vuetify';
import { UsersPageTabs } from '@/enums/user-tab.enum';
import { useUsersStore } from '@/stores/users';
import { useGroupsStore } from '@/stores/groups';
import ErrorPlate from '@/components/ErrorPlate.vue';
import AddUser from './components/AddUser.vue';
import UsersTab from './components/UsersTab.vue';
import GroupsTab from './components/GroupsTab.vue';
const { t } = useLocale();
const $users = useUsersStore();
const $groups = useGroupsStore();
const tableHeight: Ref<number> = ref(500);
setInterval(() => {
const wY = window.innerHeight;
const el = document.getElementById('table');
if (el) {
const bb = el.getBoundingClientRect();
tableHeight.value = wY - bb.top;
}
}, 100);
const tab: Ref<UsersPageTabs> = ref(UsersPageTabs.USERS);
const userModal: Ref<boolean> = ref(false);
const getAll = () => {
tab.value === UsersPageTabs.USERS ? $users.getAll() : $groups.getAll();
};
</script>
<template>
<div>
<!-- Header -->
<v-app-bar prominent class="elevation-0 border-b">
<!-- Title -->
<span class="text-h6 ml-5 mr-10">{{ t('$vuetify.users.title') }}</span>
<!-- Create user -->
<v-btn
color="primary"
variant="text"
class="mr-3"
@click="userModal = true"
>
<span class="mso mr-1 text-xl">person_add</span>
<span>{{ t('$vuetify.actions.create-user') }}</span>
</v-btn>
<AddUser v-model="userModal" @close="userModal = false" />
<!-- Refresh users -->
<v-btn color="primary" variant="text" @click="getAll()">
<span class="mso mr-1 text-xl">refresh</span>
<span>{{ t('$vuetify.actions.refresh') }}</span>
</v-btn>
</v-app-bar>
<!-- Tabs -->
<v-tabs
v-model="tab"
color="primary"
class="border-b"
density="comfortable"
bg-color="surface"
>
<v-tab :value="UsersPageTabs.USERS">{{
t('$vuetify.users.tabs.users')
}}</v-tab>
<v-tab :value="UsersPageTabs.GROUPS">{{
t('$vuetify.users.tabs.groups')
}}</v-tab>
</v-tabs>
<v-card-title class="p-0 w-full">
<v-text-field
v-model="$users.search"
density="compact"
:label="t('$vuetify.users.search')"
prepend-inner-icon="mdi-magnify"
variant="solo-filled"
class="border-b"
:rounded="false"
flat
hide-details
single-line
></v-text-field>
</v-card-title>
<!-- Error -->
<ErrorPlate :error="$users.error" block />
<div class="h-0" id="table"></div>
<v-tabs-window v-model="tab">
<!-- USERS -->
<v-tabs-window-item :value="UsersPageTabs.USERS">
<!-- Data -->
<v-card flat rounded="0" :height="`${tableHeight}px`">
<users-tab :tableHeight="tableHeight" />
</v-card>
</v-tabs-window-item>
<!-- GROUPS -->
<v-tabs-window-item :value="UsersPageTabs.GROUPS">
<!-- Data -->
<v-card flat rounded="0" :height="`${tableHeight}px`">
<groups-tab :tableHeight="tableHeight" />
</v-card>
</v-tabs-window-item>
</v-tabs-window>
</div>
</template>

3
src/plugins/README.md Normal file
View File

@ -0,0 +1,3 @@
# Plugins
Plugins are a way to extend the functionality of your Vue application. Use this folder for registering plugins that you want to use globally.

12
src/plugins/axios.ts Normal file
View File

@ -0,0 +1,12 @@
import axios, { Axios } from 'axios';
import type { App } from 'vue';
export const $axios: Axios = axios.create({
baseURL: import.meta.env.VITE_API_URL,
});
export default {
install: (app: App) => {
app.config.globalProperties.$axios = $axios;
},
};

43
src/plugins/cookie.ts Normal file
View File

@ -0,0 +1,43 @@
import { ref, type Ref, type App } from 'vue';
class Cookie {
private items: Ref<Map<any, any>> = ref(new Map());
constructor() {
const stringified: string = document.cookie;
if (stringified?.length) {
const splitted = stringified.split('; ')
for (const cookie of splitted) {
const [key, value] = cookie.split('=')
this.items.value.set(key, value)
}
}
}
public has(key: string): boolean {
return this.items.value.has(key);
}
public get(key: string): any {
return this.items.value.get(key);
}
public set(key: string, value: any): void {
this.items.value.set(key, value);
document.cookie = `${key}=${value}; Secure`;
}
public remove(key: string): void {
this.items.value.delete(key);
document.cookie =
`${key}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure`;
}
}
export const $cookie: Cookie = new Cookie()
export default {
install: (app: App) => {
app.config.globalProperties.$cookie = $cookie;
},
};

20
src/plugins/index.ts Normal file
View File

@ -0,0 +1,20 @@
/**
* plugins/index.ts
*
* Automatically included in `./src/main.ts`
*/
// Plugins
import vuetify from './vuetify'
import pinia from '../stores'
import router from '../router'
// Types
import type { App } from 'vue'
export function registerPlugins(app: App) {
app
.use(vuetify)
.use(router)
.use(pinia)
}

9
src/plugins/moment.ts Normal file
View File

@ -0,0 +1,9 @@
import moment from 'moment/min/moment-with-locales';
class Moment {
public $moment = moment
public locale(locale: string) {
this.$moment.locale(locale)
}
}
export default new Moment()

38
src/plugins/vuetify.ts Normal file
View File

@ -0,0 +1,38 @@
/**
* plugins/vuetify.ts
*
* Framework documentation: https://vuetifyjs.com`
*/
// Styles
import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'
// Composables
import { createVuetify } from 'vuetify'
// Locales
import { ru, en } from 'vuetify/locale'
import ruLocal from '../locales/ru'
import enLocal from '../locales/en'
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
export default createVuetify({
theme: {
defaultTheme: 'dark',
},
locale: {
locale: 'ru',
fallback: 'en',
messages: {
en: {
...en,
...enLocal,
},
ru: {
...ru,
...ruLocal,
},
},
},
})

16
src/router/index.ts Normal file
View File

@ -0,0 +1,16 @@
/**
* router/index.ts
*
* Automatic routes for `./src/pages/*.vue`
*/
// Composables
import { createRouter, createWebHistory } from 'vue-router/auto'
import { setupLayouts } from 'virtual:generated-layouts'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
extendRoutes: setupLayouts,
})
export default router

5
src/stores/README.md Normal file
View File

@ -0,0 +1,5 @@
# Store
Pinia stores are used to store reactive state and expose actions to mutate it.
Full documentation for this feature can be found in the Official [Pinia](https://pinia.esm.dev/) repository.

8
src/stores/app.ts Normal file
View File

@ -0,0 +1,8 @@
// Utilities
import { defineStore } from 'pinia'
export const useAppStore = defineStore('app', {
state: () => ({
//
}),
})

72
src/stores/groups.ts Normal file
View File

@ -0,0 +1,72 @@
import { defineStore } from 'pinia';
import { ref, type Ref } from 'vue';
import { User } from '@/types/user';
import { Cursor } from '@/types/cursor';
import { OrderBy } from '@/types/order-by';
import { ITEMS_PER_PAGE } from '@/constants/static';
import { defaultCursor, defaultSort } from '@/constants/defaults';
import api from '@/api';
export const useGroupsStore = defineStore('groups', () => {
const all: Ref<User[]> = ref([]);
const cursor: Ref<Cursor> = ref(JSON.parse(defaultCursor));
const search: Ref<string> = ref('');
const page: Ref<number> = ref(1);
const perPage: Ref<number> = ref(ITEMS_PER_PAGE[0]);
const orderBy: Ref<OrderBy> = ref(JSON.parse(defaultSort));
const loading: Ref<boolean> = ref(false);
const error: Ref<string> = ref('');
const getAll = async () => {
if (loading.value) {
return;
}
loading.value = true;
const [res, e] = await api.groups.getAll(
perPage.value,
page.value,
orderBy.value,
);
if (e && typeof e === 'string') {
error.value = e;
} else {
error.value = '';
all.value = res?.data?.data;
cursor.value = res?.data?.cursor;
}
loading.value = false;
};
const setPage = (v: number) => (page.value = v);
const setPerPage = (v: number) => (perPage.value = v);
const setSort = (v: OrderBy[]) => {
if (!v || !v.length) {
orderBy.value = JSON.parse(defaultSort);
} else {
orderBy.value = v[0];
}
};
return {
all,
cursor,
search,
page,
perPage,
orderBy,
loading,
error,
getAll,
setPage,
setPerPage,
setSort,
};
});

4
src/stores/index.ts Normal file
View File

@ -0,0 +1,4 @@
// Utilities
import { createPinia } from 'pinia'
export default createPinia()

72
src/stores/users.ts Normal file
View File

@ -0,0 +1,72 @@
import { defineStore } from 'pinia';
import { ref, type Ref } from 'vue';
import { User } from '@/types/user';
import { Cursor } from '@/types/cursor';
import { OrderBy } from '@/types/order-by';
import { ITEMS_PER_PAGE } from '@/constants/static';
import { defaultCursor, defaultSort } from '@/constants/defaults';
import api from '@/api';
export const useUsersStore = defineStore('users', () => {
const all: Ref<User[]> = ref([]);
const cursor: Ref<Cursor> = ref(JSON.parse(defaultCursor));
const search: Ref<string> = ref('');
const page: Ref<number> = ref(1);
const perPage: Ref<number> = ref(ITEMS_PER_PAGE[0]);
const orderBy: Ref<OrderBy> = ref(JSON.parse(defaultSort));
const loading: Ref<boolean> = ref(false);
const error: Ref<string> = ref('');
const getAll = async () => {
if (loading.value) {
return;
}
loading.value = true;
const [res, e] = await api.users.getAll(
perPage.value,
page.value,
orderBy.value,
);
if (e && typeof e === 'string') {
error.value = e;
} else {
error.value = '';
all.value = res?.data?.data;
cursor.value = res?.data?.cursor;
}
loading.value = false;
};
const setPage = (v: number) => (page.value = v);
const setPerPage = (v: number) => (perPage.value = v);
const setSort = (v: OrderBy[]) => {
if (!v || !v.length) {
orderBy.value = JSON.parse(defaultSort);
} else {
orderBy.value = v[0];
}
};
return {
all,
cursor,
search,
page,
perPage,
orderBy,
loading,
error,
getAll,
setPage,
setPerPage,
setSort,
};
});

28
src/typed-router.d.ts vendored Normal file
View File

@ -0,0 +1,28 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️
// It's recommended to commit this file.
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
declare module 'vue-router/auto-routes' {
import type {
RouteRecordInfo,
ParamValue,
ParamValueOneOrMore,
ParamValueZeroOrMore,
ParamValueZeroOrOne,
} from 'unplugin-vue-router/types'
/**
* Route name map generated by unplugin-vue-router
*/
export interface RouteNamedMap {
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
'/auth': RouteRecordInfo<'/auth', '/auth', Record<never, never>, Record<never, never>>,
'/users/': RouteRecordInfo<'/users/', '/users', Record<never, never>, Record<never, never>>,
'/users/components/AddUser': RouteRecordInfo<'/users/components/AddUser', '/users/components/AddUser', Record<never, never>, Record<never, never>>,
'/users/components/GroupsTab': RouteRecordInfo<'/users/components/GroupsTab', '/users/components/GroupsTab', Record<never, never>, Record<never, never>>,
'/users/components/UsersTab': RouteRecordInfo<'/users/components/UsersTab', '/users/components/UsersTab', Record<never, never>, Record<never, never>>,
}
}

6
src/types/cursor.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
export interface Cursor {
count: number;
currentPage: number;
totalPages: number;
totalRows: number;
}

4
src/types/group-permission.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
export interface GroupPermission {
groupID: number;
value: number
}

14
src/types/group.d.ts vendored Normal file
View File

@ -0,0 +1,14 @@
import { GroupPermission } from "./group-permission";
import { User } from "./user";
export interface Group {
ID: number;
issuerID: number;
issuer: User;
name: string;
users: User[];
permissions: GroupPermission[];
createdAt: Date;
updatedAt: Date;
deletedAt: Date;
}

4
src/types/order-by.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
export interface OrderBy {
key: string;
order?: boolean | 'asc' | 'desc';
}

17
src/types/user.d.ts vendored Normal file
View File

@ -0,0 +1,17 @@
import { Group } from "./group";
export interface User {
ID: number;
email: string;
passwordHash: string;
passwordLength: number;
realName: string;
groupID: number;
group: Group;
isRequiredToSetPassword: boolean;
isActive: boolean;
lastLogin: Date;
createdAt: Date;
updatedAt: Date;
deletedAt: Date;
}

8
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
/// <reference types="vite/client" />
/* eslint-disable */
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

6
src/vue.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const component: DefineComponent<object, object, any>
export default component
}

13
tailwind.config.js Normal file
View File

@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx,vue}",
],
theme: {
extend: {},
},
plugins: [],
important: true,
}

48
tsconfig.json Normal file
View File

@ -0,0 +1,48 @@
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"lib": [
"DOM",
"ESNext"
],
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "Node",
"paths": {
"@/*": [
"./src/*"
]
},
"resolveJsonModule": true,
"types": [
"vite/client",
"vite-plugin-vue-layouts/client",
"unplugin-vue-router/client"
],
"allowJs": true,
"strict": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"skipLibCheck": true
},
"include": [
"./src/**/*.d.ts",
"./src/**/*",
"./src/**/*.{vue,ts,js}",
"./src/**/**/*.json"
],
"exclude": [
"dist",
"node_modules",
"cypress"
],
"references": [
{
"path": "./tsconfig.node.json"
}
],
}

11
tsconfig.node.json Normal file
View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": [
"vite.config.mts"
]
}

Some files were not shown because too many files have changed in this diff Show More