init
No files matched your search
@@ -0,0 +1,8 @@
|
|||||||
|
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||||
|
charset = utf-8
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
end_of_line = lf
|
||||||
|
max_line_length = 100
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Cypress
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Vitest
|
||||||
|
__screenshots__/
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
*.timestamp-*-*.mjs
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
|
||||||
|
"env": {
|
||||||
|
"browser": true
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"correctness": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 120
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"Vue.volar",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"EditorConfig.EditorConfig",
|
||||||
|
"oxc.oxc-vscode",
|
||||||
|
"esbenp.prettier-vscode"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# chat-app
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||||
|
|
||||||
|
## Recommended Browser Setup
|
||||||
|
|
||||||
|
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||||
|
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||||
|
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||||
|
- Firefox:
|
||||||
|
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||||
|
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||||
|
|
||||||
|
## Type Support for `.vue` Imports in TS
|
||||||
|
|
||||||
|
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||||
|
|
||||||
|
## Customize configuration
|
||||||
|
|
||||||
|
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compile and Hot-Reload for Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type-Check, Compile and Minify for Production
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lint with [ESLint](https://eslint.org/)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { globalIgnores } from 'eslint/config'
|
||||||
|
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||||
|
import pluginVue from 'eslint-plugin-vue'
|
||||||
|
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||||
|
import skipFormatting from 'eslint-config-prettier/flat'
|
||||||
|
|
||||||
|
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||||
|
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||||
|
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||||
|
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||||
|
|
||||||
|
export default defineConfigWithVueTs(
|
||||||
|
{
|
||||||
|
name: 'app/files-to-lint',
|
||||||
|
files: ['**/*.{vue,ts,mts,tsx}'],
|
||||||
|
},
|
||||||
|
|
||||||
|
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||||
|
|
||||||
|
...pluginVue.configs['flat/essential'],
|
||||||
|
vueTsConfigs.recommended,
|
||||||
|
|
||||||
|
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||||
|
|
||||||
|
skipFormatting,
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Vite App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "ulitka-app",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"build-only": "vite build",
|
||||||
|
"type-check": "vue-tsc --build",
|
||||||
|
"lint": "run-s \"lint:*\"",
|
||||||
|
"lint:oxlint": "oxlint . --fix",
|
||||||
|
"lint:eslint": "eslint . --fix --cache",
|
||||||
|
"format": "prettier --write --experimental-cli src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/roboto": "^5.3.0",
|
||||||
|
"@vueuse/core": "^14.4.0",
|
||||||
|
"better-auth": "^1.7.1",
|
||||||
|
"pinia": "^4.0.3",
|
||||||
|
"vue": "^3.5.41",
|
||||||
|
"vue-router": "^5.2.0",
|
||||||
|
"vuetify": "^4.1.11"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@mdi/font": "^7.4.47",
|
||||||
|
"@tsconfig/node24": "^24.0.5",
|
||||||
|
"@types/node": "^26.3.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
|
"@vue/eslint-config-typescript": "^14.9.0",
|
||||||
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"eslint": "^10.9.1",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-oxlint": "~1.80.0",
|
||||||
|
"eslint-plugin-vue": "~10.10.0",
|
||||||
|
"jiti": "^2.7.0",
|
||||||
|
"npm-run-all2": "^9.0.3",
|
||||||
|
"oxlint": "~1.80.0",
|
||||||
|
"prettier": "3.9.6",
|
||||||
|
"sass-embedded": "^1.103.1",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"unplugin-fonts": "^2.0.0",
|
||||||
|
"vite": "^8.2.2",
|
||||||
|
"vite-plugin-vue-devtools": "^8.2.1",
|
||||||
|
"vue-eslint-parser": "^10.4.1",
|
||||||
|
"vue-tsc": "^3.3.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.18.0 || >=24.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useEventSource } from '@vueuse/core'
|
||||||
|
import { computed, onMounted, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useStatusStore } from '@/stores/status.ts'
|
||||||
|
import { useIssueStore } from '@/stores/issue.ts'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const issueStore = useIssueStore()
|
||||||
|
const statusStore = useStatusStore()
|
||||||
|
|
||||||
|
interface IssueSseEvent {
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = useEventSource<string[], IssueSseEvent>(
|
||||||
|
'http://localhost:3333/api/issues/events',
|
||||||
|
['issue.created', 'issue.updated', 'issue.deleted'],
|
||||||
|
{
|
||||||
|
serializer: { read: (v) => (v ? JSON.parse(v) : null) },
|
||||||
|
autoReconnect: {
|
||||||
|
retries: 3,
|
||||||
|
delay: 1000,
|
||||||
|
onFailed() {
|
||||||
|
console.log('Failed to connect EventSource after 3 retries')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
autoConnect: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(data, () => {
|
||||||
|
if (
|
||||||
|
data.value?.type === 'issue.created' ||
|
||||||
|
data.value?.type === 'issue.updated' ||
|
||||||
|
data.value?.type === 'issue.deleted'
|
||||||
|
) {
|
||||||
|
issueStore.getStat()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const layout = computed(() => {
|
||||||
|
const layout = route?.meta?.layout
|
||||||
|
return layout ? `${layout}Layout` : 'DefaultLayout'
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await statusStore.list()
|
||||||
|
await issueStore.getStat()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component :is="layout">
|
||||||
|
<router-view v-slot="{ Component, route }">
|
||||||
|
<div :key="route.name">
|
||||||
|
<component :is="Component" />
|
||||||
|
</div>
|
||||||
|
</router-view>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { apiClient, toQuery } from '@/lib/api-client.ts'
|
||||||
|
import type { User } from '@/api/user.api.ts'
|
||||||
|
|
||||||
|
export interface Issue {
|
||||||
|
id: number
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
status: 'OPEN' | 'IN_PROGRESS' | 'DONE' | 'CANCELLED'
|
||||||
|
priority: 0 | 1 | 2 | 3 | 4
|
||||||
|
assigneeId: string
|
||||||
|
reporterId: string
|
||||||
|
assignee?: User
|
||||||
|
reporter?: User
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Meta {
|
||||||
|
page: number
|
||||||
|
totalCount: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateIssueInput {
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
projectId: string
|
||||||
|
content?: string
|
||||||
|
status?: Issue['status']
|
||||||
|
priority?: Issue['priority']
|
||||||
|
assignee?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateIssueInput = Partial<CreateIssueInput>
|
||||||
|
|
||||||
|
export interface IssueStatByStatus {
|
||||||
|
status: Issue['status']
|
||||||
|
label: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssueStat {
|
||||||
|
total: number
|
||||||
|
byStatus: IssueStatByStatus[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLIENT REQUEST
|
||||||
|
|
||||||
|
export async function itemsList(params: {
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
text?: string | null
|
||||||
|
projectId?: string | null
|
||||||
|
}) {
|
||||||
|
// `text` (free-text search) has no backend support yet — kept in the
|
||||||
|
// signature so callers don't need to change, but not sent over the wire.
|
||||||
|
const query = toQuery({ page: params.page, limit: params.limit, projectId: params.projectId })
|
||||||
|
return apiClient.get<{ list: Issue[]; meta: Meta }>(`/api/issues/list${query}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function itemGetByKey(ckey: string) {
|
||||||
|
return apiClient.get<Issue>(`/api/issues/by-key/${ckey}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function itemCreate(input: CreateIssueInput) {
|
||||||
|
return apiClient.post<Issue>('/api/issues', input)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function itemUpdate(id: number, input: UpdateIssueInput) {
|
||||||
|
return apiClient.patch<Issue>(`/api/issues/${id}`, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function itemDelete(id: number) {
|
||||||
|
await apiClient.delete(`/api/issues/${id}`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function itemStat() {
|
||||||
|
return apiClient.get<IssueStat>('/api/issues/stats')
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { apiClient, toQuery } from '@/lib/api-client.ts'
|
||||||
|
import type { Meta } from '@/api/issue.api.ts'
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: string
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
ownerId: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProjectInput {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProjectInput {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLIENT REQUEST
|
||||||
|
|
||||||
|
export async function itemsList(params: { page: number; limit: number }) {
|
||||||
|
return apiClient.get<{ list: Project[]; meta: Meta }>(`/api/projects/list${toQuery(params)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function itemGetById(id: string) {
|
||||||
|
return apiClient.get<Project>(`/api/projects/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function itemCreate(input: CreateProjectInput) {
|
||||||
|
return apiClient.post<Project>('/api/projects', input)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function itemUpdate(id: string, input: UpdateProjectInput) {
|
||||||
|
return apiClient.patch<Project>(`/api/projects/${id}`, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function itemDelete(id: string) {
|
||||||
|
await apiClient.delete(`/api/projects/${id}`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { apiClient } from '@/lib/api-client.ts'
|
||||||
|
|
||||||
|
export interface Status {
|
||||||
|
name: string
|
||||||
|
alias: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLIENT REQUEST
|
||||||
|
|
||||||
|
export async function statusesList() {
|
||||||
|
return apiClient.get<Status[]>('/api/statuses')
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { apiClient, toQuery } from '@/lib/api-client.ts'
|
||||||
|
import type { Meta } from '@/api/issue.api.ts'
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
emailVerified: boolean
|
||||||
|
image: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLIENT REQUEST
|
||||||
|
|
||||||
|
export async function itemsList(params: { page: number; limit: number }) {
|
||||||
|
return apiClient.get<{ list: User[]; meta: Meta }>(`/api/users/list${toQuery(params)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function itemGetById(id: string) {
|
||||||
|
return apiClient.get<User>(`/api/users/${id}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/*@import "tailwindcss";*/
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 400;
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
font-optical-sizing: auto;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const menu = [
|
||||||
|
{ id: 1, title: 'home', to: '/' },
|
||||||
|
{ id: 2, title: 'issues', to: '/issues' },
|
||||||
|
{ id: 3, title: 'summary', to: '/summary' },
|
||||||
|
{ id: 4, title: 'reports', to: '/reports' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-list class="mt-1" width="200" density="compact">
|
||||||
|
<v-list-item v-for="item in menu" :key="item.id" :to="item.to">
|
||||||
|
<v-list-item-title class="d-flex align-center ga-4">
|
||||||
|
<v-icon icon="mdi-information-outline" />
|
||||||
|
<div>{{ item.title }}</div>
|
||||||
|
</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { itemsList } from '@/api/user.api.ts'
|
||||||
|
import type { User } from '@/api/user.api.ts'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: User | null
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), { compact: true })
|
||||||
|
const emit = defineEmits<{ changed: [value: string] }>()
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
const users = ref<User[]>([])
|
||||||
|
const page = ref(0)
|
||||||
|
const hasMore = ref(true)
|
||||||
|
|
||||||
|
function initials(user: Pick<User, 'name' | 'email'>) {
|
||||||
|
return (user.name || user.email || '?')[0]!.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore({ done }: { done: (status: 'ok' | 'empty' | 'error') => void }) {
|
||||||
|
if (!hasMore.value) {
|
||||||
|
done('empty')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextPage = page.value + 1
|
||||||
|
const res = await itemsList({ page: nextPage, limit: PAGE_SIZE })
|
||||||
|
users.value.push(...res.list)
|
||||||
|
page.value = nextPage
|
||||||
|
hasMore.value = nextPage < res.meta.totalPages
|
||||||
|
done(hasMore.value ? 'ok' : 'empty')
|
||||||
|
} catch {
|
||||||
|
done('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentInitials = computed(() => (props.value ? initials(props.value) : '?'))
|
||||||
|
|
||||||
|
const select = (value: unknown) => {
|
||||||
|
if (Array.isArray(value) && typeof value[0] === 'string') {
|
||||||
|
emit('changed', value[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="">
|
||||||
|
<v-menu>
|
||||||
|
<template v-slot:activator="{ props: activatorProps }">
|
||||||
|
<div class="d-flex align-center ga-2">
|
||||||
|
<v-btn rounded="circle" density="compact" icon variant="text" v-bind="activatorProps">
|
||||||
|
<v-avatar
|
||||||
|
:image="value?.image ?? undefined"
|
||||||
|
:text="currentInitials"
|
||||||
|
rounded="circle"
|
||||||
|
density="compact"
|
||||||
|
color="primary"
|
||||||
|
border
|
||||||
|
/>
|
||||||
|
</v-btn>
|
||||||
|
<div v-if="!compact">{{ value?.name || value?.email || 'Unassigned' }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-infinite-scroll height="16rem" @load="loadMore">
|
||||||
|
<v-list
|
||||||
|
border
|
||||||
|
width="12rem"
|
||||||
|
class="mt-1"
|
||||||
|
select-strategy="single-leaf"
|
||||||
|
density="compact"
|
||||||
|
@update:selected="select"
|
||||||
|
>
|
||||||
|
<v-list-item value="">
|
||||||
|
<div class="d-flex align-center ga-2">
|
||||||
|
<v-avatar density="compact" border rounded="circle" icon="mdi-account" />
|
||||||
|
<div>Unassigned</div>
|
||||||
|
</div>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item v-for="user in users" :key="user.id" :value="user.id">
|
||||||
|
<div class="d-flex align-center ga-2">
|
||||||
|
<v-avatar
|
||||||
|
:image="user.image ?? undefined"
|
||||||
|
:text="initials(user)"
|
||||||
|
rounded="circle"
|
||||||
|
density="compact"
|
||||||
|
border
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
<div>{{ user.name || user.email }}</div>
|
||||||
|
</div>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<template #empty />
|
||||||
|
</v-infinite-scroll>
|
||||||
|
</v-menu>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const icons = ['mdi-chevron-right', 'mdi-chevron-down']
|
||||||
|
const isOpen = ref(true)
|
||||||
|
const { title } = defineProps<Props>()
|
||||||
|
|
||||||
|
const icon = computed(() => {
|
||||||
|
return isOpen.value ? icons[1] : icons[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
isOpen.value = !isOpen.value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="d-flex flex-column ga-1">
|
||||||
|
<div class="d-flex align-center ga-1">
|
||||||
|
<v-btn density="compact" size="small" :icon="icon" @click="open" />
|
||||||
|
<div @click="open" class="font-weight-bold text-capitalize text-title-medium cursor-pointer">{{ title }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="isOpen" class="ml-6 mt-2">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<{ changed: [value: string] }>()
|
||||||
|
|
||||||
|
const editing = ref(false)
|
||||||
|
const draft = ref(props.value ?? '')
|
||||||
|
|
||||||
|
const startEdit = () => {
|
||||||
|
draft.value = props.value ?? ''
|
||||||
|
editing.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
emit('changed', draft.value)
|
||||||
|
editing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
draft.value = props.value ?? ''
|
||||||
|
editing.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="issue-content">
|
||||||
|
<template v-if="!editing">
|
||||||
|
<v-sheet class="pa-2 cursor-pointer content-sheet" @click="startEdit">
|
||||||
|
{{ value ?? 'click to add content' }}
|
||||||
|
</v-sheet>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<v-textarea class="mb-1" hide-details v-model="draft" variant="outlined" auto-grow rows="4" />
|
||||||
|
|
||||||
|
<div class="d-flex justify-end ga-1">
|
||||||
|
<v-btn density="compact" variant="text" text="Cancel" @click="cancel" />
|
||||||
|
<v-btn density="compact" color="primary" variant="tonal" text="Save" @click="save" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.content-sheet:hover {
|
||||||
|
background-color: rgba(var(--v-theme-on-surface), 0.06);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { useIssueStore } from '@/stores/issue.ts'
|
||||||
|
import { useProjectStore } from '@/stores/project.ts'
|
||||||
|
import type { Issue } from '@/api/issue.api.ts'
|
||||||
|
|
||||||
|
const issueStore = useIssueStore()
|
||||||
|
const projectStore = useProjectStore()
|
||||||
|
const emit = defineEmits<{ created: [issue: Issue] }>()
|
||||||
|
|
||||||
|
const showForm = ref(false)
|
||||||
|
const newIssue = reactive({
|
||||||
|
key: 'type',
|
||||||
|
title: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const onCreate = async () => {
|
||||||
|
const projectId = projectStore.current?.id
|
||||||
|
if (!projectId) return
|
||||||
|
|
||||||
|
const created = await issueStore.create({ ...newIssue, projectId })
|
||||||
|
if (!created) return
|
||||||
|
|
||||||
|
emit('created', created)
|
||||||
|
newIssue.title = ''
|
||||||
|
showForm.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-list-item v-if="showForm" class="">
|
||||||
|
<template #prepend>
|
||||||
|
<v-menu v-if="true">
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-icon v-bind="props" icon="mdi-square" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list select-strategy="single-leaf">
|
||||||
|
<v-list-item v-for="(el, idx) in ['aaa', 'bbb']" :key="idx" :value="el">
|
||||||
|
<v-list-item-title>{{ el }}</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #title>
|
||||||
|
<div class="d-flex ga-2 align-center">
|
||||||
|
<v-text-field
|
||||||
|
v-model="newIssue.title"
|
||||||
|
variant="filled"
|
||||||
|
placeholder="Describe your issue"
|
||||||
|
autofocus
|
||||||
|
@keyup.enter="onCreate"
|
||||||
|
/>
|
||||||
|
<v-btn density="compact" variant="tonal" text="Create" @click="onCreate" />
|
||||||
|
<v-btn density="compact" variant="text" icon="mdi-close" @click="showForm = false" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item class="d-flex justify-center" v-else @click="showForm = !showForm">
|
||||||
|
<div class="d-flex ga-2 align-center">
|
||||||
|
<v-icon icon="mdi-plus" />
|
||||||
|
<div>add issue</div>
|
||||||
|
</div>
|
||||||
|
<!-- <v-btn density="compact" variant="plain" prepend-icon="mdi-plus"> create </v-btn>-->
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watchEffect } from 'vue'
|
||||||
|
import { useIssueStore } from '@/stores/issue.js'
|
||||||
|
import { useStatusStore } from '@/stores/status.ts'
|
||||||
|
import ExpansionPanel from '@/components/ExpansionPanel.vue'
|
||||||
|
import IssueContent from '@/components/IssueContent.vue'
|
||||||
|
import type { Issue } from '@/api/issue.api.ts'
|
||||||
|
import StatusMenu from '@/components/StatusMenu.vue'
|
||||||
|
import AssigneeMenu from '@/components/AssigneeMenu.vue'
|
||||||
|
import ReporterInfo from '@/components/ReporterInfo.vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ckey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
statusChanged: [issue: Issue]
|
||||||
|
assigneeChanged: [issue: Issue]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const issueStore = useIssueStore()
|
||||||
|
const statusStore = useStatusStore()
|
||||||
|
const issue = ref<Issue>()
|
||||||
|
|
||||||
|
const { ckey } = defineProps<Props>()
|
||||||
|
|
||||||
|
const statusName = computed(() => {
|
||||||
|
return statusStore.statuses?.find((el) => el.alias === issue.value?.status)?.name ?? issue.value?.status
|
||||||
|
})
|
||||||
|
|
||||||
|
const createdAtFormatted = computed(() => {
|
||||||
|
if (!issue.value?.createdAt) return ''
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', { dateStyle: 'medium', timeStyle: 'short' }).format(
|
||||||
|
new Date(issue.value.createdAt),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetch() {
|
||||||
|
const res = await issueStore.getByKey(ckey)
|
||||||
|
if (!res) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
issue.value = res
|
||||||
|
}
|
||||||
|
|
||||||
|
watchEffect(async () => fetch())
|
||||||
|
|
||||||
|
const onContentChanged = async (content: string) => {
|
||||||
|
if (!issue.value) return
|
||||||
|
const updated = await issueStore.update(issue.value.id, { content })
|
||||||
|
if (updated) issue.value = updated
|
||||||
|
}
|
||||||
|
|
||||||
|
const onStatusChanged = async (status: Issue['status']) => {
|
||||||
|
if (!issue.value) return
|
||||||
|
|
||||||
|
const updated = await issueStore.update(issue.value.id, { status })
|
||||||
|
if (updated) {
|
||||||
|
issue.value = updated
|
||||||
|
emit('statusChanged', updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAssigneeChanged = async (assignee: string) => {
|
||||||
|
if (!issue.value) return
|
||||||
|
|
||||||
|
const updated = await issueStore.update(issue.value.id, { assignee })
|
||||||
|
if (updated) {
|
||||||
|
issue.value = updated
|
||||||
|
emit('assigneeChanged', updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
applyUpdate: (updated: Issue) => {
|
||||||
|
if (issue.value && issue.value.id === updated.id) {
|
||||||
|
issue.value = updated
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="issue" class="d-flex flex-column ga-4 pa-4">
|
||||||
|
<div>{{ ckey }}</div>
|
||||||
|
<div class="text-uppercase text-title-large">{{ issue.title }}</div>
|
||||||
|
|
||||||
|
<v-card elevation="0" border>
|
||||||
|
<v-card-text class="u-grid">
|
||||||
|
<div class="text-medium-emphasis">Status:</div>
|
||||||
|
<status-menu :value="issue.status" @changed="onStatusChanged" />
|
||||||
|
|
||||||
|
<div class="text-medium-emphasis">Reporter:</div>
|
||||||
|
<reporter-info :user="issue.reporter" />
|
||||||
|
|
||||||
|
<div class="text-medium-emphasis">Assignee:</div>
|
||||||
|
<assignee-menu :value="issue.assignee" :compact="false" @changed="onAssigneeChanged" />
|
||||||
|
|
||||||
|
<div class="text-medium-emphasis">Created At:</div>
|
||||||
|
<div>{{ createdAtFormatted }}</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<expansion-panel title="Details">
|
||||||
|
<issue-content :value="issue.content" @changed="onContentChanged" />
|
||||||
|
</expansion-panel>
|
||||||
|
|
||||||
|
<expansion-panel title="Activity">
|
||||||
|
<v-btn-group divided border density="compact">
|
||||||
|
<v-btn>comments</v-btn>
|
||||||
|
<v-btn>history</v-btn>
|
||||||
|
</v-btn-group>
|
||||||
|
</expansion-panel>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.u-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 1rem;
|
||||||
|
row-gap: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const text = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function onSearch() {
|
||||||
|
console.log('search')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="u-grid2 ga-2">
|
||||||
|
<v-text-field
|
||||||
|
v-model="text"
|
||||||
|
clearable
|
||||||
|
placeholder="Search name..."
|
||||||
|
@keydown.enter.prevent="onSearch"
|
||||||
|
@click:clear="onSearch"
|
||||||
|
/>
|
||||||
|
<v-select variant="outlined" clearable :items="['abc', 'def']"></v-select>
|
||||||
|
|
||||||
|
<!-- <v-btn>filter</v-btn>-->
|
||||||
|
|
||||||
|
<!-- <v-btn variant="plain">clear filters</v-btn>-->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.u-grid2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useIssueStore } from '@/stores/issue.ts'
|
||||||
|
import { useStatusStore } from '@/stores/status.ts'
|
||||||
|
|
||||||
|
const issueStore = useIssueStore()
|
||||||
|
const statusStore = useStatusStore()
|
||||||
|
|
||||||
|
const items = computed(() => {
|
||||||
|
return (issueStore.stats?.byStatus ?? []).map((stat) => ({
|
||||||
|
...stat,
|
||||||
|
color: statusStore.statuses?.find((s) => s.alias === stat.status)?.color,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="d-flex ga-1">
|
||||||
|
<v-chip
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.status"
|
||||||
|
:color="item.color"
|
||||||
|
:title="item.label"
|
||||||
|
variant="flat"
|
||||||
|
label
|
||||||
|
density="compact"
|
||||||
|
>
|
||||||
|
{{ item.count }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PriorityMenu from '@/components/PriorityMenu.vue'
|
||||||
|
import StatusMenu from '@/components/StatusMenu.vue'
|
||||||
|
import { useIssueStore } from '@/stores/issue.ts'
|
||||||
|
import type { Issue } from '@/api/issue.api.ts'
|
||||||
|
import AssigneeMenu from '@/components/AssigneeMenu.vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
issue: Issue
|
||||||
|
selected?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const { issue, selected } = defineProps<Props>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
statusChanged: [issue: Issue]
|
||||||
|
priorityChanged: [issue: Issue]
|
||||||
|
assigneeChanged: [issue: Issue]
|
||||||
|
toggleSelect: [id: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const issueStore = useIssueStore()
|
||||||
|
|
||||||
|
const onStatusChanged = async (status: Issue['status']) => {
|
||||||
|
const updated = await issueStore.update(issue.id, { status })
|
||||||
|
if (updated) emit('statusChanged', updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPriorityChanged = async (priority: Issue['priority']) => {
|
||||||
|
const updated = await issueStore.update(issue.id, { priority })
|
||||||
|
if (updated) emit('priorityChanged', updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAssigneeChanged = async (assignee: string) => {
|
||||||
|
const updated = await issueStore.update(issue.id, { assignee })
|
||||||
|
if (updated) emit('assigneeChanged', updated)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-list-item class="bg-on-surface-variant" :value="issue.key">
|
||||||
|
<template #title>
|
||||||
|
<div class="d-flex ga-4">
|
||||||
|
<div>{{ issue.key }}</div>
|
||||||
|
<div class="d-flex ga-1 align-center">
|
||||||
|
<v-icon
|
||||||
|
v-if="[3, 4].includes(issue.priority) && ['OPEN', 'IN_PROGRESS'].includes(issue.status)"
|
||||||
|
icon="mdi-alert-box"
|
||||||
|
color="error"
|
||||||
|
></v-icon>
|
||||||
|
<div :class="{ 'text-decoration-line-through': issue.status === 'DONE' }">{{ issue.title }}</div>
|
||||||
|
</div>
|
||||||
|
<!-- <div :class="{ 'text-decoration-line-through': issue.status === 'DONE' }">{{ issue.content }}</div>-->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #prepend>
|
||||||
|
<div class="d-flex ga-1 align-center mr-2" @click.stop>
|
||||||
|
<v-checkbox
|
||||||
|
class="issue-checkbox"
|
||||||
|
density="compact"
|
||||||
|
:model-value="selected"
|
||||||
|
@update:model-value="emit('toggleSelect', issue.id)"
|
||||||
|
/>
|
||||||
|
<v-icon icon="mdi-square" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #append>
|
||||||
|
<div class="d-flex ga-4 align-center">
|
||||||
|
<status-menu :value="issue.status" @changed="onStatusChanged" />
|
||||||
|
<priority-menu :value="issue.priority" @changed="onPriorityChanged" />
|
||||||
|
<assignee-menu :value="issue.assignee" @changed="onAssigneeChanged" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
import { authClient } from '@/lib/auth-client.js'
|
||||||
|
|
||||||
|
const passwordData = reactive({
|
||||||
|
newPassword: '',
|
||||||
|
currentPassword: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const changePassword = async () => {
|
||||||
|
await authClient.changePassword({
|
||||||
|
newPassword: passwordData.newPassword,
|
||||||
|
currentPassword: passwordData.currentPassword,
|
||||||
|
revokeOtherSessions: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-sheet>
|
||||||
|
<div class="text-uppercase">Change password</div>
|
||||||
|
<div class="d-flex ga-2">
|
||||||
|
<v-text-field placeholder="current password" v-model="passwordData.currentPassword" />
|
||||||
|
<v-text-field placeholder="new password" v-model="passwordData.newPassword" />
|
||||||
|
<v-icon-btn variant="tonal" icon="mdi-check" @click="changePassword" />
|
||||||
|
</div>
|
||||||
|
</v-sheet>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import type { Issue } from '@/api/issue.api.ts'
|
||||||
|
|
||||||
|
enum Icons {
|
||||||
|
highest = 'mdi-chevron-double-up',
|
||||||
|
high = 'mdi-chevron-up',
|
||||||
|
eq = 'mdi-equal',
|
||||||
|
low = 'mdi-chevron-down',
|
||||||
|
lowest = 'mdi-chevron-double-down',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<{ changed: [value: Issue['priority']] }>()
|
||||||
|
|
||||||
|
const priorityList = ref([
|
||||||
|
{ icon: Icons.highest, value: 4 },
|
||||||
|
{ icon: Icons.high, value: 3 },
|
||||||
|
{ icon: Icons.eq, value: 2 },
|
||||||
|
{ icon: Icons.low, value: 1 },
|
||||||
|
{ icon: Icons.lowest, value: 0 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const currentIcon = computed(() => priorityList.value.find((el) => el.value === props.value)?.icon ?? Icons.eq)
|
||||||
|
|
||||||
|
const availablePriorities = computed(() => priorityList.value.filter((el) => el.value !== props.value))
|
||||||
|
|
||||||
|
function priorityColor(value?: number) {
|
||||||
|
if (value === 3 || value === 4) return 'error'
|
||||||
|
if (value === 2) return 'success'
|
||||||
|
return 'primary'
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentColor = computed(() => priorityColor(props.value))
|
||||||
|
|
||||||
|
const select = (value: unknown) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
emit('changed', value[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="">
|
||||||
|
<v-menu>
|
||||||
|
<template v-slot:activator="{ props: activatorProps }">
|
||||||
|
<v-btn density="compact" :icon="currentIcon" :color="currentColor" variant="text" v-bind="activatorProps" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list border class="mt-1" select-strategy="single-leaf" density="compact" @update:selected="select">
|
||||||
|
<v-list-item nav v-for="el in availablePriorities" :key="el.value" :value="el.value">
|
||||||
|
<div class="d-flex justify-center align-center">
|
||||||
|
<v-icon size="small" :icon="el.icon" :color="priorityColor(el.value)" />
|
||||||
|
</div>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useProjectStore } from '@/stores/project.ts'
|
||||||
|
|
||||||
|
const projectStore = useProjectStore()
|
||||||
|
const { projects, current } = storeToRefs(projectStore)
|
||||||
|
|
||||||
|
const currentProjectName = computed(() => current.value?.name ?? 'Select project')
|
||||||
|
const selected = computed(() => (current.value ? [current.value.id] : []))
|
||||||
|
|
||||||
|
function select(value: unknown) {
|
||||||
|
console.log(value)
|
||||||
|
const id = Array.isArray(value) ? value[0] : value
|
||||||
|
const project = projects.value.find((el) => el.id === id)
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new Error(`Project ${id} not found`)
|
||||||
|
}
|
||||||
|
projectStore.select(project)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await projectStore.list({ page: 1, limit: 100 })
|
||||||
|
await projectStore.restoreCurrent()
|
||||||
|
|
||||||
|
if (!current.value && projects.value[0]) {
|
||||||
|
projectStore.select(projects.value[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="projects.length" class="">
|
||||||
|
<v-menu>
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-btn border density="comfortable" variant="flat" :text="currentProjectName" v-bind="props" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list
|
||||||
|
width="12rem"
|
||||||
|
class="mt-1"
|
||||||
|
border
|
||||||
|
select-strategy="single-leaf"
|
||||||
|
density="compact"
|
||||||
|
:selected="selected"
|
||||||
|
mandatory
|
||||||
|
@update:selected="select"
|
||||||
|
>
|
||||||
|
<v-list-item slim v-for="project in projects" :key="project.id" :value="project.id">
|
||||||
|
<v-list-item-title>
|
||||||
|
<v-chip size="small" color="primary" variant="flat" density="compact">{{ project.key }}</v-chip>
|
||||||
|
{{ project.name }}
|
||||||
|
</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { User } from '@/api/user.api.ts'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user?: User | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = defineProps<Props>()
|
||||||
|
|
||||||
|
function initials(user: Pick<User, 'name' | 'email'>) {
|
||||||
|
return (user.name || user.email || '?')[0]!.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentInitials = computed(() => (user ? initials(user) : '?'))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="">
|
||||||
|
<div class="d-flex align-center ga-2">
|
||||||
|
<v-btn rounded="circle" density="compact" icon variant="text">
|
||||||
|
<v-avatar
|
||||||
|
:image="user?.image ?? undefined"
|
||||||
|
:text="currentInitials"
|
||||||
|
color="primary"
|
||||||
|
rounded="circle"
|
||||||
|
density="compact"
|
||||||
|
border
|
||||||
|
/>
|
||||||
|
</v-btn>
|
||||||
|
<div>{{ user?.name || user?.email }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { authClient } from '@/lib/auth-client.ts'
|
||||||
|
import { ref, watchEffect } from 'vue'
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
id: string
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
userId: string
|
||||||
|
expiresAt: Date
|
||||||
|
token: string
|
||||||
|
ipAddress?: string | null | undefined
|
||||||
|
userAgent?: string | null | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = ref<Session[]>([])
|
||||||
|
|
||||||
|
const listSessions = async () => {
|
||||||
|
const result = await authClient.listSessions()
|
||||||
|
if (result.error) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions.value = result.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const revokeSession = async (token: string) => {
|
||||||
|
await authClient.revokeSession({
|
||||||
|
token: token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const revokeOtherSessions = async () => {
|
||||||
|
await authClient.revokeOtherSessions()
|
||||||
|
}
|
||||||
|
|
||||||
|
const revokeAllSessions = async () => {
|
||||||
|
await authClient.revokeSessions()
|
||||||
|
}
|
||||||
|
|
||||||
|
watchEffect(async () => {
|
||||||
|
await listSessions()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="text-uppercase">Active sessions</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<v-btn prepend-icon="mdi-skull" @click="revokeOtherSessions()">revoke other sessions</v-btn>
|
||||||
|
</div>
|
||||||
|
<v-list border density="compact">
|
||||||
|
<v-list-item class="hover:bg-primary-300" v-for="session in sessions" :key="session.id">
|
||||||
|
<div class="text-truncate">{{ session.userAgent }}</div>
|
||||||
|
<template #append>
|
||||||
|
<v-icon-btn size="small" @click="revokeSession(session.token)" color="error" icon="mdi-trash-can" />
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useStatusStore } from '@/stores/status.ts'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { Issue } from '@/api/issue.api.ts'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<{ changed: [value: Issue['status']] }>()
|
||||||
|
|
||||||
|
const statusStore = useStatusStore()
|
||||||
|
|
||||||
|
const currentStatusName = computed(() => {
|
||||||
|
return statusStore.statuses?.find((el) => el.alias === props.value)?.name ?? props.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentStatusColor = computed(() => {
|
||||||
|
return statusStore.statuses?.find((el) => el.alias === props.value)?.color ?? 'white'
|
||||||
|
})
|
||||||
|
|
||||||
|
const select = (value: unknown) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
emit('changed', value[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="">
|
||||||
|
<v-menu>
|
||||||
|
<template v-slot:activator="{ props: activatorProps }">
|
||||||
|
<v-btn
|
||||||
|
slim
|
||||||
|
:color="currentStatusColor"
|
||||||
|
density="compact"
|
||||||
|
variant="flat"
|
||||||
|
v-bind="activatorProps"
|
||||||
|
:text="currentStatusName"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list border width="12rem" class="" select-strategy="single-leaf" density="compact" @update:selected="select">
|
||||||
|
<v-list-item slim v-for="(el, idx) in statusStore.statuses" :key="idx" :value="el.alias">
|
||||||
|
<v-list-item-title>{{ el.name }}</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user?: {
|
||||||
|
readonly id: string
|
||||||
|
readonly createdAt: Date
|
||||||
|
readonly updatedAt: Date
|
||||||
|
readonly email: string
|
||||||
|
readonly emailVerified: boolean
|
||||||
|
readonly name: string
|
||||||
|
readonly image?: string | null | undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = defineProps<Props>()
|
||||||
|
|
||||||
|
const avatar = computed(() => {
|
||||||
|
return user?.image ?? undefined
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="user" class="d-flex ga-4">
|
||||||
|
<v-avatar size="x-large" color="info" :image="avatar" />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div>name: {{ user.name }}</div>
|
||||||
|
<div>email: {{ user.email }}</div>
|
||||||
|
<div>created at: {{ user.createdAt }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<v-skeleton-loader v-else type="avatar, list-item-three-line" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-layout>
|
||||||
|
<v-main>
|
||||||
|
<v-container :fluid="true">
|
||||||
|
<slot></slot>
|
||||||
|
</v-container>
|
||||||
|
</v-main>
|
||||||
|
</v-layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { authClient } from '@/lib/auth-client.ts'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useTheme } from 'vuetify'
|
||||||
|
import ProjectMenu from '@/components/ProjectMenu.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const session = authClient.useSession()
|
||||||
|
const theme = useTheme()
|
||||||
|
|
||||||
|
const title = 'APP'
|
||||||
|
const version = '0.0.1'
|
||||||
|
|
||||||
|
const isAuth = computed(() => !!session.value.data)
|
||||||
|
|
||||||
|
const menu = ref([
|
||||||
|
{
|
||||||
|
title: 'Home',
|
||||||
|
value: 1,
|
||||||
|
props: { prependIcon: 'mdi-home', to: '/', requiresAuth: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'issues',
|
||||||
|
value: 2,
|
||||||
|
props: { prependIcon: 'mdi-list-status', to: '/issues', requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'summary',
|
||||||
|
value: 3,
|
||||||
|
props: { prependIcon: 'mdi-view-dashboard', to: '/summary', requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'games',
|
||||||
|
value: 4,
|
||||||
|
props: { prependIcon: 'mdi-view-dashboard', to: '/games', requiresAuth: false },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
function goToSignIn() {
|
||||||
|
router.push({ path: '/sign-in' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToSignUp() {
|
||||||
|
router.push({ path: '/sign-up' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToProfile() {
|
||||||
|
router.push({ path: '/profile' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToSignOut() {
|
||||||
|
await authClient.signOut({
|
||||||
|
fetchOptions: {
|
||||||
|
onSuccess: () => {
|
||||||
|
router.push('/sign-in')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme(e: MouseEvent) {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
theme.setTransitionOrigin(target)
|
||||||
|
theme.toggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToSettings = () => {
|
||||||
|
router.push({ name: 'settings' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-layout>
|
||||||
|
<v-app-bar density="compact" elevation="0" border>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-menu>
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-app-bar-nav-icon variant="text" v-bind="props" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list class="mt-1" width="200" density="compact">
|
||||||
|
<template v-for="item in menu" :key="item.value">
|
||||||
|
<v-list-item v-if="!item.props.requiresAuth || isAuth === item.props.requiresAuth" :to="item.props.to">
|
||||||
|
<v-list-item-title class="d-flex align-center ga-4">
|
||||||
|
<v-icon :icon="item.props.prependIcon" />
|
||||||
|
<div>{{ item.title }}</div>
|
||||||
|
</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-app-bar-title>
|
||||||
|
<div class="d-flex ga-2 align-center">
|
||||||
|
<router-link class="text-decoration-none text-link font-weight-bold" to="/">
|
||||||
|
{{ title }}
|
||||||
|
</router-link>
|
||||||
|
<span>v{{ version }}</span>
|
||||||
|
</div>
|
||||||
|
</v-app-bar-title>
|
||||||
|
|
||||||
|
<div class="d-flex ga-4 align-center" v-if="session.data">
|
||||||
|
<project-menu />
|
||||||
|
<div class="mr-2">
|
||||||
|
Hello,
|
||||||
|
<router-link class="text-decoration-none text-link text-capitalize" to="/profile">
|
||||||
|
{{ session?.data.user.name }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<v-btn v-if="!session.data" @click="goToSignIn">Sign In</v-btn>
|
||||||
|
<v-btn v-if="!session.data" @click="goToSignUp">Sign Up</v-btn>
|
||||||
|
|
||||||
|
<v-divider class="mx-1" vertical />
|
||||||
|
<v-btn icon="mdi-brightness-6" @click="toggleTheme" />
|
||||||
|
<v-divider class="mx-1" vertical />
|
||||||
|
|
||||||
|
<v-menu v-if="session.data">
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-btn icon v-bind="props">
|
||||||
|
<v-icon icon="mdi-account" />
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<v-list class="mt-1" width="200">
|
||||||
|
<v-list-item @click="goToProfile">
|
||||||
|
<v-icon icon="mdi-account" />
|
||||||
|
Profile
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item @click="goToSettings">
|
||||||
|
<v-icon icon="mdi-cog" />
|
||||||
|
settings
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item @click="goToSignOut">
|
||||||
|
<v-icon icon="mdi-logout" />
|
||||||
|
Sign Out
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</v-app-bar>
|
||||||
|
<v-main>
|
||||||
|
<v-container :fluid="false">
|
||||||
|
<slot></slot>
|
||||||
|
</v-container>
|
||||||
|
</v-main>
|
||||||
|
</v-layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { App, Component } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register layouts in the app.go instance
|
||||||
|
*
|
||||||
|
* @param {App<Element>} app
|
||||||
|
*/
|
||||||
|
export function registerLayouts(app: App<Element>) {
|
||||||
|
const modules = import.meta.glob<{ default: Component }>('@/layouts/*.vue', {
|
||||||
|
eager: true,
|
||||||
|
})
|
||||||
|
Object.entries(modules).forEach(([key, component]) => {
|
||||||
|
const componentName = key.split('/').at(-1)
|
||||||
|
|
||||||
|
if (componentName) {
|
||||||
|
app.component(componentName.replace('.vue', ''), component.default)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
interface ApiEnvelope<T> {
|
||||||
|
data: T | null
|
||||||
|
code: number
|
||||||
|
message?: string | string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status === 204) return undefined as T
|
||||||
|
|
||||||
|
const body = (await res.json()) as ApiEnvelope<T>
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const message = Array.isArray(body.message) ? body.message.join(', ') : body.message
|
||||||
|
throw new Error(message ?? `Request failed with status ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return body.data as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toQuery(params: Record<string, string | number | undefined | null>): string {
|
||||||
|
const sp = new URLSearchParams()
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v !== undefined && v !== null && v !== '') sp.set(k, String(v))
|
||||||
|
}
|
||||||
|
const s = sp.toString()
|
||||||
|
return s ? `?${s}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiClient = {
|
||||||
|
get: <T>(path: string) => request<T>(path),
|
||||||
|
post: <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
patch: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||||
|
delete: (path: string) => request<void>(path, { method: 'DELETE' }),
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createAuthClient } from 'better-auth/vue'
|
||||||
|
|
||||||
|
export const authClient = createAuthClient({
|
||||||
|
baseURL: 'http://localhost:3333',
|
||||||
|
plugins: [],
|
||||||
|
})
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
import './assets/main.css'
|
||||||
|
import 'unfonts.css'
|
||||||
|
import 'vuetify/styles'
|
||||||
|
import '@mdi/font/css/materialdesignicons.css'
|
||||||
|
|
||||||
|
import { createVuetify } from 'vuetify'
|
||||||
|
import * as components from 'vuetify/components'
|
||||||
|
import * as directives from 'vuetify/directives'
|
||||||
|
import { registerLayouts } from '@/layouts/register.ts'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
const vuetify = createVuetify({
|
||||||
|
components,
|
||||||
|
directives,
|
||||||
|
theme: {
|
||||||
|
defaultTheme: 'dark', // 'system' | 'light' | 'dark'
|
||||||
|
themes: {
|
||||||
|
dark: {
|
||||||
|
colors: {
|
||||||
|
link: '#CFD8DC',
|
||||||
|
alt: '#616161',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
colors: {
|
||||||
|
link: '#37474F',
|
||||||
|
alt: '#E0E0E0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
global: {
|
||||||
|
rounded: 'md',
|
||||||
|
},
|
||||||
|
VBtn: {
|
||||||
|
variant: 'tonal',
|
||||||
|
},
|
||||||
|
VTextField: {
|
||||||
|
variant: 'outlined',
|
||||||
|
density: 'compact',
|
||||||
|
hideDetails: true,
|
||||||
|
},
|
||||||
|
VSwitch: {
|
||||||
|
density: 'compact',
|
||||||
|
hideDetails: true,
|
||||||
|
},
|
||||||
|
VSheet: {},
|
||||||
|
VCombobox: {
|
||||||
|
variant: 'outlined',
|
||||||
|
density: 'compact',
|
||||||
|
hideDetails: true,
|
||||||
|
},
|
||||||
|
VCheckbox: {
|
||||||
|
density: 'compact',
|
||||||
|
hideDetails: true,
|
||||||
|
},
|
||||||
|
VList: {
|
||||||
|
density: 'compact',
|
||||||
|
},
|
||||||
|
VSelect: {
|
||||||
|
variant: 'solo',
|
||||||
|
density: 'compact',
|
||||||
|
hideDetails: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use(vuetify)
|
||||||
|
|
||||||
|
registerLayouts(app)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
import GamesView from '@/views/games/GamesView.vue'
|
||||||
|
|
||||||
|
export const gamesRoutes: RouteRecordRaw = {
|
||||||
|
path: '/games',
|
||||||
|
meta: { requiresAuth: false },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: GamesView,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import ProfileView from '@/views/users/ProfileView.vue'
|
||||||
|
import { issuesRoutes } from '@/router/issues.ts'
|
||||||
|
import { reportsRoutes } from '@/router/reports.ts'
|
||||||
|
import { summaryRoutes } from '@/router/summary.ts'
|
||||||
|
import HomeView from '@/views/HomeView.vue'
|
||||||
|
import VerifyEmailView from '@/views/users/VerifyEmailView.vue'
|
||||||
|
import SignUpView from '@/views/users/SignUpView.vue'
|
||||||
|
import SignInView from '@/views/users/SignInView.vue'
|
||||||
|
import { authClient } from '@/lib/auth-client.ts'
|
||||||
|
import { gamesRoutes } from '@/router/games.ts'
|
||||||
|
import { settingsRoutes } from '@/router/settings.ts'
|
||||||
|
|
||||||
|
declare module 'vue-router' {
|
||||||
|
interface RouteMeta {
|
||||||
|
layout?: 'Blank' | 'Default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: HomeView,
|
||||||
|
meta: { layout: 'Default' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sign-in',
|
||||||
|
path: '/sign-in',
|
||||||
|
component: SignInView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/sign-up',
|
||||||
|
component: SignUpView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/verify-email',
|
||||||
|
component: VerifyEmailView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/profile',
|
||||||
|
component: ProfileView,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
issuesRoutes,
|
||||||
|
summaryRoutes,
|
||||||
|
reportsRoutes,
|
||||||
|
gamesRoutes,
|
||||||
|
settingsRoutes,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach(async (to, from) => {
|
||||||
|
// 1. Fetch the current session from Better Auth
|
||||||
|
const { data: session } = await authClient.getSession()
|
||||||
|
|
||||||
|
// console.log('before each ', { session })
|
||||||
|
// const session = 'true'
|
||||||
|
const isAuthenticated = !!session
|
||||||
|
|
||||||
|
// 2. If the route requires auth and user is missing, redirect to login
|
||||||
|
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||||
|
return { name: 'sign-in', query: { redirect: to.fullPath } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. If route requires a guest (like Login page) and user is logged in, redirect to dashboard
|
||||||
|
if (to.meta.requiresGuest && isAuthenticated) {
|
||||||
|
return { name: 'dashboard' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implicitly returns undefined / true to allow navigation if conditions don't match
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
import IssuesView from '@/views/Issues/IssuesListView.vue'
|
||||||
|
import IssueView from '@/views/Issues/IssueView.vue'
|
||||||
|
|
||||||
|
export const issuesRoutes: RouteRecordRaw = {
|
||||||
|
path: '/issues',
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: IssuesView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':ckey',
|
||||||
|
component: IssueView,
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
import ReportsView from '@/views/reports/ReportsView.vue'
|
||||||
|
|
||||||
|
export const reportsRoutes: RouteRecordRaw = {
|
||||||
|
path: '/reports',
|
||||||
|
component: ReportsView,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
import ProjectsListView from '@/views/project/ProjectsListView.vue'
|
||||||
|
import SettingsView from '@/views/settings/SettingsView.vue'
|
||||||
|
import GeneralSettingsView from '@/views/settings/GeneralSettingsView.vue'
|
||||||
|
|
||||||
|
export const settingsRoutes: RouteRecordRaw = {
|
||||||
|
path: '/settings',
|
||||||
|
name: 'settings',
|
||||||
|
component: SettingsView,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'general-settings',
|
||||||
|
component: GeneralSettingsView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'projects',
|
||||||
|
name: 'settings-projects',
|
||||||
|
component: ProjectsListView,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
import SummaryView from '@/views/summary/SummaryView.vue'
|
||||||
|
|
||||||
|
export const summaryRoutes: RouteRecordRaw = {
|
||||||
|
path: '/summary',
|
||||||
|
component: SummaryView,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import {
|
||||||
|
type CreateIssueInput,
|
||||||
|
type Issue,
|
||||||
|
itemCreate,
|
||||||
|
itemDelete,
|
||||||
|
itemGetByKey,
|
||||||
|
itemsList,
|
||||||
|
itemStat,
|
||||||
|
itemUpdate,
|
||||||
|
type IssueStat,
|
||||||
|
type Meta,
|
||||||
|
type UpdateIssueInput,
|
||||||
|
} from '@/api/issue.api.ts'
|
||||||
|
|
||||||
|
export const useIssueStore = defineStore('item', () => {
|
||||||
|
const issues = ref<Issue[]>([])
|
||||||
|
const stats = ref<IssueStat>()
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const lastMeta = ref<Meta>({
|
||||||
|
page: 1,
|
||||||
|
totalCount: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function list(params: { page: number; limit: number; text?: string | null; projectId?: string | null }) {
|
||||||
|
try {
|
||||||
|
const res = await itemsList(params)
|
||||||
|
issues.value = res.list
|
||||||
|
return res
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'error get list of items'
|
||||||
|
return { list: [], meta: lastMeta.value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getByKey(ckey: string) {
|
||||||
|
try {
|
||||||
|
const res = await itemGetByKey(ckey)
|
||||||
|
return res
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'error get item by key'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(input: CreateIssueInput) {
|
||||||
|
try {
|
||||||
|
return await itemCreate(input)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'error creating issue'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id: number, input: UpdateIssueInput) {
|
||||||
|
try {
|
||||||
|
return await itemUpdate(id, input)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'error updating issue'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number) {
|
||||||
|
try {
|
||||||
|
return await itemDelete(id)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'error deleting issue'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStat(): Promise<IssueStat | undefined> {
|
||||||
|
try {
|
||||||
|
stats.value = await itemStat()
|
||||||
|
|
||||||
|
return stats.value
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'error getting issue stat'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stats, list, getByKey, create, update, remove, getStat }
|
||||||
|
})
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import {
|
||||||
|
type CreateProjectInput,
|
||||||
|
itemCreate,
|
||||||
|
itemDelete,
|
||||||
|
itemGetById,
|
||||||
|
itemsList,
|
||||||
|
itemUpdate,
|
||||||
|
type Project,
|
||||||
|
type UpdateProjectInput,
|
||||||
|
} from '@/api/project.api.ts'
|
||||||
|
import type { Meta } from '@/api/issue.api.ts'
|
||||||
|
|
||||||
|
const CURRENT_PROJECT_KEY = 'currentProjectId'
|
||||||
|
|
||||||
|
function readStoredProjectId(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(CURRENT_PROJECT_KEY)
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors (e.g. disabled in private mode)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useProjectStore = defineStore('project', () => {
|
||||||
|
const projects = ref<Project[]>([])
|
||||||
|
const current = ref<Project | null>(null)
|
||||||
|
// Seeded synchronously from localStorage so consumers (e.g. the issues list)
|
||||||
|
// know the selected project id immediately, without waiting for `current`
|
||||||
|
// to be resolved over the network via restoreCurrent().
|
||||||
|
const currentProjectId = ref<string | null>(readStoredProjectId())
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const lastMeta = ref<Meta>({
|
||||||
|
page: 1,
|
||||||
|
totalCount: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function list(params: { page: number; limit: number }) {
|
||||||
|
try {
|
||||||
|
const res = await itemsList(params)
|
||||||
|
projects.value = res.list
|
||||||
|
return res
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'error get list of projects'
|
||||||
|
return { list: [], meta: lastMeta.value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getById(id: string) {
|
||||||
|
try {
|
||||||
|
return await itemGetById(id)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'error get project by id'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(input: CreateProjectInput) {
|
||||||
|
try {
|
||||||
|
return await itemCreate(input)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'error creating project'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id: string, input: UpdateProjectInput) {
|
||||||
|
try {
|
||||||
|
return await itemUpdate(id, input)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'error updating project'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string) {
|
||||||
|
try {
|
||||||
|
return await itemDelete(id)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'error deleting project'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearError() {
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(project: Project | null) {
|
||||||
|
current.value = project
|
||||||
|
currentProjectId.value = project?.id ?? null
|
||||||
|
try {
|
||||||
|
if (project) localStorage.setItem(CURRENT_PROJECT_KEY, project.id)
|
||||||
|
else localStorage.removeItem(CURRENT_PROJECT_KEY)
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors (e.g. disabled in private mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreCurrent() {
|
||||||
|
const id = readStoredProjectId()
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
const project = projects.value.find((el) => el.id === id) ?? (await getById(id))
|
||||||
|
if (project) current.value = project
|
||||||
|
else select(null) // stored id no longer refers to an existing project
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects,
|
||||||
|
current,
|
||||||
|
currentProjectId,
|
||||||
|
error,
|
||||||
|
list,
|
||||||
|
getById,
|
||||||
|
create,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
clearError,
|
||||||
|
select,
|
||||||
|
restoreCurrent,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { type Status, statusesList } from '@/api/status.api.ts'
|
||||||
|
|
||||||
|
export const useStatusStore = defineStore('counter', () => {
|
||||||
|
const statuses = ref<Status[]>()
|
||||||
|
|
||||||
|
async function list() {
|
||||||
|
statuses.value = await statusesList()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { statuses, list }
|
||||||
|
})
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { itemGetById, itemsList, type User } from '@/api/user.api.ts'
|
||||||
|
import type { Meta } from '@/api/issue.api.ts'
|
||||||
|
|
||||||
|
export const useUserStore = defineStore('user', () => {
|
||||||
|
const users = ref<User[]>([])
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const lastMeta = ref<Meta>({
|
||||||
|
page: 1,
|
||||||
|
totalCount: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function list(params: { page: number; limit: number }) {
|
||||||
|
try {
|
||||||
|
const res = await itemsList(params)
|
||||||
|
users.value = res.list
|
||||||
|
return res
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'error get list of users'
|
||||||
|
return { list: [], meta: lastMeta.value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getById(id: string) {
|
||||||
|
try {
|
||||||
|
return await itemGetById(id)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'error get user by id'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { users, error, list, getById }
|
||||||
|
})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>Home</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watchEffect } from 'vue'
|
||||||
|
import { useIssueStore } from '@/stores/issue.ts'
|
||||||
|
import IssueDetails from '@/components/IssueDetails.vue'
|
||||||
|
import type { Issue } from '@/api/issue.api.ts'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ckey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueStore = useIssueStore()
|
||||||
|
const issue = ref<Issue>()
|
||||||
|
|
||||||
|
const { ckey } = defineProps<Props>()
|
||||||
|
|
||||||
|
async function fetch() {
|
||||||
|
const res = await issueStore.getByKey(ckey)
|
||||||
|
if (!res) return
|
||||||
|
|
||||||
|
issue.value = res
|
||||||
|
}
|
||||||
|
|
||||||
|
watchEffect(async () => fetch())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-sheet border>
|
||||||
|
<issue-details :ckey="ckey" />
|
||||||
|
</v-sheet>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useIssueStore } from '@/stores/issue'
|
||||||
|
import { useProjectStore } from '@/stores/project.ts'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import IssueDetails from '@/components/IssueDetails.vue'
|
||||||
|
import IssueCreate from '@/components/IssueCreate.vue'
|
||||||
|
import type { Issue } from '@/api/issue.api.ts'
|
||||||
|
import IssuesListElement from '@/components/IssuesListElement.vue'
|
||||||
|
import IssueFilter from '@/components/IssueFilter.vue'
|
||||||
|
import IssueStats from '@/components/IssueStats.vue'
|
||||||
|
|
||||||
|
const issueStore = useIssueStore()
|
||||||
|
const issueDetailsRef = ref<InstanceType<typeof IssueDetails>>()
|
||||||
|
const projectStore = useProjectStore()
|
||||||
|
const { currentProjectId } = storeToRefs(projectStore)
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const itemsPerPage = ref(10)
|
||||||
|
const page = ref(1)
|
||||||
|
const totalItems = ref(0)
|
||||||
|
const totalPages = ref(1)
|
||||||
|
|
||||||
|
const serverItems = ref<Issue[]>([])
|
||||||
|
|
||||||
|
const text = ref<string | null>(null)
|
||||||
|
const selected = ref<[string] | []>([])
|
||||||
|
const batch = ref<number[]>([])
|
||||||
|
const deleting = ref(false)
|
||||||
|
|
||||||
|
async function fetch() {
|
||||||
|
const res = await issueStore.list({
|
||||||
|
page: page.value,
|
||||||
|
limit: itemsPerPage.value,
|
||||||
|
text: text.value,
|
||||||
|
projectId: currentProjectId.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
serverItems.value = [...res.list]
|
||||||
|
totalPages.value = res.meta.totalPages
|
||||||
|
totalItems.value = res.meta.totalCount
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
syncQuery()
|
||||||
|
await fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// async function onSearch() {
|
||||||
|
// await updatePage(1)
|
||||||
|
// }
|
||||||
|
|
||||||
|
function syncQuery() {
|
||||||
|
const query: Record<string, string> = { page: String(page.value) }
|
||||||
|
|
||||||
|
if (text.value) query.text = text.value
|
||||||
|
if (selected.value.length) query.selected_issue = selected.value[0]
|
||||||
|
|
||||||
|
router.replace({ query })
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query,
|
||||||
|
(query) => {
|
||||||
|
page.value = query.page ? Number(query.page) : 1
|
||||||
|
text.value = query.text ? String(query.text) : null
|
||||||
|
selected.value = query.selected_issue ? [String(query.selected_issue)] : []
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(selected, () => syncQuery())
|
||||||
|
|
||||||
|
watch(currentProjectId, async () => {
|
||||||
|
page.value = 1
|
||||||
|
|
||||||
|
clearBatch()
|
||||||
|
close()
|
||||||
|
await load()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => await load())
|
||||||
|
const gridTemplate = computed(() => (selected.value.length ? '1fr 1fr' : '1fr'))
|
||||||
|
const goToIssue = (key: string[]) => router.push(`/issues/${key[0]}`)
|
||||||
|
|
||||||
|
async function updatePage(val: number) {
|
||||||
|
page.value = val
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
selected.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function onIssueUpdated(updated: Issue) {
|
||||||
|
const idx = serverItems.value.findIndex((el) => el.id === updated.id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
serverItems.value.splice(idx, 1, updated)
|
||||||
|
}
|
||||||
|
issueDetailsRef.value?.applyUpdate(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onIssueCreated(created: Issue) {
|
||||||
|
await fetch()
|
||||||
|
|
||||||
|
if (selected.value.length) {
|
||||||
|
selected.value = [created.key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBatchSelect(id: number) {
|
||||||
|
const idx = batch.value.indexOf(id)
|
||||||
|
if (idx === -1) batch.value.push(id)
|
||||||
|
else batch.value.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBatch() {
|
||||||
|
batch.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const allVisibleSelected = computed(
|
||||||
|
() => serverItems.value.length > 0 && serverItems.value.every((el) => batch.value.includes(el.id)),
|
||||||
|
)
|
||||||
|
const someVisibleSelected = computed(() => serverItems.value.some((el) => batch.value.includes(el.id)))
|
||||||
|
|
||||||
|
function toggleSelectAllVisible() {
|
||||||
|
if (allVisibleSelected.value) {
|
||||||
|
const visibleIds = new Set(serverItems.value.map((el) => el.id))
|
||||||
|
batch.value = batch.value.filter((id) => !visibleIds.has(id))
|
||||||
|
} else {
|
||||||
|
batch.value = [...new Set([...batch.value, ...serverItems.value.map((el) => el.id)])]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDeleteBatch() {
|
||||||
|
deleting.value = true
|
||||||
|
try {
|
||||||
|
await Promise.all(batch.value.map((id) => issueStore.remove(id)))
|
||||||
|
batch.value = []
|
||||||
|
close()
|
||||||
|
await fetch()
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="d-flex flex-column ga-2">
|
||||||
|
<div class="u-grid ga-1">
|
||||||
|
<div class="u-height">
|
||||||
|
<v-list class="pa-0" density="compact" v-model:selected="selected" mandatory>
|
||||||
|
<!-- <issue-filter />-->
|
||||||
|
<v-list-item>
|
||||||
|
<template #prepend>
|
||||||
|
<div class="mr-2" @click.stop>
|
||||||
|
<v-checkbox
|
||||||
|
:model-value="allVisibleSelected"
|
||||||
|
:indeterminate="someVisibleSelected && !allVisibleSelected"
|
||||||
|
@update:model-value="toggleSelectAllVisible"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #title>
|
||||||
|
<div>
|
||||||
|
<div>({{ serverItems.length }} of {{ totalItems }} elements visible)</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #append>
|
||||||
|
<issue-stats />
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<issue-create @created="onIssueCreated" />
|
||||||
|
|
||||||
|
<issues-list-element
|
||||||
|
v-for="el in serverItems"
|
||||||
|
:key="el.id"
|
||||||
|
:issue="el"
|
||||||
|
:selected="batch.includes(el.id)"
|
||||||
|
@status-changed="onIssueUpdated"
|
||||||
|
@priority-changed="onIssueUpdated"
|
||||||
|
@assignee-changed="onIssueUpdated"
|
||||||
|
@toggle-select="toggleBatchSelect"
|
||||||
|
/>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<div class="d-flex justify-center pa-2">
|
||||||
|
<v-pagination
|
||||||
|
:model-value="page"
|
||||||
|
:length="totalPages"
|
||||||
|
:total-visible="7"
|
||||||
|
density="comfortable"
|
||||||
|
@update:model-value="updatePage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="batch.length" class="d-flex justify-center ga-2">
|
||||||
|
<v-banner max-width="30rem" border lines="one">
|
||||||
|
<div class="d-flex align-center ga-2">
|
||||||
|
<div>{{ batch.length }} selected</div>
|
||||||
|
<v-divider vertical />
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
prepend-icon="mdi-trash-can"
|
||||||
|
density="compact"
|
||||||
|
variant="tonal"
|
||||||
|
:loading="deleting"
|
||||||
|
@click="onDeleteBatch"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</v-btn>
|
||||||
|
<v-btn prepend-icon="mdi-circle" density="compact" variant="tonal"> Done </v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<div class="d-flex align-center ga-2">
|
||||||
|
<v-divider vertical />
|
||||||
|
<v-btn density="comfortable" icon="mdi-close" @click="clearBatch"></v-btn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-banner>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-sheet border v-if="selected.length" class="overflow-y-auto">
|
||||||
|
<div class="d-flex align-center ga-2 px-4 py-2">
|
||||||
|
<div>Work item</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-icon-btn icon="mdi-open-in-new" @click="goToIssue(selected)" />
|
||||||
|
<v-icon-btn icon="mdi-close" @click="close" />
|
||||||
|
</div>
|
||||||
|
<v-divider />
|
||||||
|
<issue-details
|
||||||
|
ref="issueDetailsRef"
|
||||||
|
:ckey="selected[0]"
|
||||||
|
@status-changed="onIssueUpdated"
|
||||||
|
@assignee-changed="onIssueUpdated"
|
||||||
|
/>
|
||||||
|
</v-sheet>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.u-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: v-bind(gridTemplate);
|
||||||
|
}
|
||||||
|
|
||||||
|
.u-height {
|
||||||
|
height: calc(100vh - 6rem);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>Games</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const cards = ref<string[]>([])
|
||||||
|
|
||||||
|
const size = ref(2)
|
||||||
|
const data = ['1', '2', '3', '4', '5', '6']
|
||||||
|
const result = ref(new Map<string, [string, number]>())
|
||||||
|
const currentCheck = ref(new Map<string, [string, number]>())
|
||||||
|
const checking = ref(false)
|
||||||
|
const startDate = ref<Date>()
|
||||||
|
|
||||||
|
const timeout = ref<number>(0)
|
||||||
|
|
||||||
|
const generate = () => {
|
||||||
|
checking.value = false
|
||||||
|
result.value.clear()
|
||||||
|
currentCheck.value.clear()
|
||||||
|
|
||||||
|
const arr = Array.from({ length: size.value }, () => data).flat()
|
||||||
|
cards.value = shuffle(arr)
|
||||||
|
|
||||||
|
startDate.value = new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffle(array: string[]) {
|
||||||
|
const arr = array.slice()
|
||||||
|
|
||||||
|
let i = arr.length
|
||||||
|
let j = 0
|
||||||
|
let temp: string
|
||||||
|
|
||||||
|
while (--i > 0) {
|
||||||
|
j = Math.floor(Math.random() * (i + 1))
|
||||||
|
temp = arr[j]!
|
||||||
|
arr[j] = arr[i]!
|
||||||
|
arr[i] = temp
|
||||||
|
}
|
||||||
|
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareCards(dataMap: Map<string, [string, number]>) {
|
||||||
|
const set = new Set()
|
||||||
|
dataMap.forEach((el) => set.add(el[0]))
|
||||||
|
return set.size === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCorrect(dataMap: Map<string, [string, number]>) {
|
||||||
|
for (const [key, value] of dataMap) {
|
||||||
|
result.value.set(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkCard(card: string, idx: number) {
|
||||||
|
if (checking.value) return
|
||||||
|
if (currentCheck.value.has(`${card}:${idx}`)) return
|
||||||
|
|
||||||
|
currentCheck.value.set(`${card}:${idx}`, [card, idx])
|
||||||
|
|
||||||
|
if (currentCheck.value.size === size.value) {
|
||||||
|
checking.value = true
|
||||||
|
const ok = compareCards(currentCheck.value)
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
addCorrect(currentCheck.value)
|
||||||
|
currentCheck.value.clear()
|
||||||
|
checking.value = false
|
||||||
|
} else {
|
||||||
|
timeout.value = setTimeout(() => {
|
||||||
|
currentCheck.value.clear()
|
||||||
|
checking.value = false
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWin = computed(() => data.length === result.value.size / size.value)
|
||||||
|
|
||||||
|
const roundTime = computed(() => {
|
||||||
|
const endDate = new Date()
|
||||||
|
return isWin.value && startDate.value ? endDate.valueOf() - startDate.value.valueOf() / 1000 : null
|
||||||
|
})
|
||||||
|
|
||||||
|
function checkPressed(card: string, idx: number) {
|
||||||
|
return result.value.has(`${card}:${idx}`) || currentCheck.value.has(`${card}:${idx}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="d-flex flex-column ga-2">
|
||||||
|
<div class="d-flex ga-2 align-center">
|
||||||
|
<v-btn height="40px" @click="generate">Generate</v-btn>
|
||||||
|
<v-text-field v-model="size" />
|
||||||
|
</div>
|
||||||
|
<div v-if="cards.length" class="field rounded">
|
||||||
|
<div class="card" v-for="(card, idx) in cards" :key="idx">
|
||||||
|
<v-img v-if="checkPressed(card, idx)" :src="`${card}.jpeg`" rounded />
|
||||||
|
<v-img v-else src="back.jpeg" @click="checkCard(card, idx)" rounded />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<v-alert v-if="isWin" :text="`Congratulations ${roundTime} sec`" type="success"></v-alert>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 50rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useProjectStore } from '@/stores/project.ts'
|
||||||
|
import type { Project } from '@/api/project.api.ts'
|
||||||
|
import { authClient } from '@/lib/auth-client.ts'
|
||||||
|
|
||||||
|
const projectStore = useProjectStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const session = authClient.useSession()
|
||||||
|
|
||||||
|
function isOwner(project: Project) {
|
||||||
|
return project.ownerId === session.value.data?.user.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsPerPage = ref(10)
|
||||||
|
const page = ref(1)
|
||||||
|
const totalItems = ref(0)
|
||||||
|
const totalPages = ref(0)
|
||||||
|
|
||||||
|
const serverItems = ref<Project[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
{ key: 'key', title: 'Key', sortable: false },
|
||||||
|
{ key: 'name', title: 'Name', sortable: false },
|
||||||
|
{ key: 'createdAt', title: 'Created', sortable: false },
|
||||||
|
{ key: 'actions', title: '', sortable: false, align: 'end' as const },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function fetch() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await projectStore.list({ page: page.value, limit: itemsPerPage.value })
|
||||||
|
serverItems.value = res.list
|
||||||
|
totalItems.value = res.meta.totalCount
|
||||||
|
totalPages.value = res.meta.totalPages
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadItems(options: { page: number; itemsPerPage: number }) {
|
||||||
|
page.value = options.page
|
||||||
|
itemsPerPage.value = options.itemsPerPage
|
||||||
|
await fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePage(val: number) {
|
||||||
|
page.value = val
|
||||||
|
await fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CREATE / EDIT DIALOG
|
||||||
|
|
||||||
|
const showDialog = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const editingProject = ref<Project | null>(null)
|
||||||
|
const form = reactive({ key: '', name: '' })
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingProject.value = null
|
||||||
|
form.key = ''
|
||||||
|
form.name = ''
|
||||||
|
projectStore.clearError()
|
||||||
|
showDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(project: Project) {
|
||||||
|
editingProject.value = project
|
||||||
|
form.key = project.key
|
||||||
|
form.name = project.name
|
||||||
|
projectStore.clearError()
|
||||||
|
showDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
showDialog.value = false
|
||||||
|
editingProject.value = null
|
||||||
|
projectStore.clearError()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (!form.key || !form.name) return
|
||||||
|
|
||||||
|
projectStore.clearError()
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (editingProject.value) {
|
||||||
|
const updated = await projectStore.update(editingProject.value.id, { name: form.name })
|
||||||
|
if (!updated) return
|
||||||
|
|
||||||
|
if (projectStore.current?.id === updated.id) projectStore.select(updated)
|
||||||
|
} else {
|
||||||
|
const created = await projectStore.create({ key: form.key.toUpperCase(), name: form.name })
|
||||||
|
if (!created) return
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDialog()
|
||||||
|
await fetch()
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openIssues(project: Project) {
|
||||||
|
projectStore.select(project)
|
||||||
|
router.push('/issues')
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
|
||||||
|
const deletingId = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function onDelete(project: Project) {
|
||||||
|
deletingId.value = project.id
|
||||||
|
try {
|
||||||
|
const ok = await projectStore.remove(project.id)
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
|
if (projectStore.current?.id === project.id) projectStore.select(null)
|
||||||
|
await fetch()
|
||||||
|
} finally {
|
||||||
|
deletingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="d-flex flex-column ga-2">
|
||||||
|
<div class="d-flex justify-end">
|
||||||
|
<v-btn prepend-icon="mdi-plus" text="Create project" @click="openCreate" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-data-table-server
|
||||||
|
density="compact"
|
||||||
|
hover
|
||||||
|
hide-default-footer
|
||||||
|
:loading="loading"
|
||||||
|
:headers="headers"
|
||||||
|
:items-per-page="itemsPerPage"
|
||||||
|
:page="page"
|
||||||
|
:items="serverItems"
|
||||||
|
:items-length="totalItems"
|
||||||
|
item-value="id"
|
||||||
|
@update:options="loadItems"
|
||||||
|
@click:row="(_e: unknown, { item }: { item: Project }) => openIssues(item)"
|
||||||
|
>
|
||||||
|
<template #[`item.createdAt`]="{ item }">
|
||||||
|
{{ new Date(item.createdAt).toLocaleDateString('ru-RU') }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #[`item.actions`]="{ item }">
|
||||||
|
<div v-if="isOwner(item)" class="d-flex justify-end ga-1">
|
||||||
|
<v-icon-btn density="compact" icon="mdi-pencil" @click.stop="openEdit(item)" />
|
||||||
|
<v-icon-btn
|
||||||
|
color="error"
|
||||||
|
variant="text"
|
||||||
|
density="compact"
|
||||||
|
icon="mdi-trash-can"
|
||||||
|
:loading="deletingId === item.id"
|
||||||
|
@click.stop="onDelete(item)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-data-table-server>
|
||||||
|
|
||||||
|
<v-pagination
|
||||||
|
:model-value="page"
|
||||||
|
:length="totalPages"
|
||||||
|
:total-visible="5"
|
||||||
|
density="comfortable"
|
||||||
|
@update:model-value="updatePage"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-dialog v-model="showDialog" max-width="35rem">
|
||||||
|
<v-card :title="editingProject ? 'Edit project' : 'Create project'">
|
||||||
|
<v-card-text class="d-flex flex-column ga-4">
|
||||||
|
<v-alert
|
||||||
|
v-if="projectStore.error"
|
||||||
|
type="error"
|
||||||
|
density="compact"
|
||||||
|
variant="flat"
|
||||||
|
closable
|
||||||
|
@click:close="projectStore.clearError()"
|
||||||
|
>
|
||||||
|
{{ projectStore.error }}
|
||||||
|
</v-alert>
|
||||||
|
<v-text-field v-model="form.key" label="Key" :disabled="!!editingProject" autofocus />
|
||||||
|
<v-text-field v-model="form.name" label="Name" @keyup.enter="onSubmit" />
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" text="Cancel" @click="closeDialog" />
|
||||||
|
<v-btn variant="tonal" text="Save" :loading="saving" @click="onSubmit" />
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
:deep(tbody tr) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, watchEffect } from 'vue'
|
||||||
|
import { type Item, useIssueStore } from '@/stores/issue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const issueStore = useIssueStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const itemsPerPage = ref(10)
|
||||||
|
const page = ref(1)
|
||||||
|
const text = ref<string | null>(null)
|
||||||
|
const serverItems = ref<Item[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const totalItems = ref(0)
|
||||||
|
|
||||||
|
async function fetch() {
|
||||||
|
syncQuery()
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
const res = await issueStore.list({
|
||||||
|
page: page.value,
|
||||||
|
itemsPerPage: itemsPerPage.value,
|
||||||
|
text: text.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
serverItems.value = res.items
|
||||||
|
totalItems.value = res.total
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadItems(params: { page: number; itemsPerPage: number; sortBy?: unknown }) {
|
||||||
|
page.value = params.page
|
||||||
|
itemsPerPage.value = params.itemsPerPage
|
||||||
|
|
||||||
|
await fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSearch() {
|
||||||
|
page.value = 1
|
||||||
|
await fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncQuery() {
|
||||||
|
const query: Record<string, string> = {
|
||||||
|
page: String(page.value),
|
||||||
|
}
|
||||||
|
if (text.value) query.text = text.value
|
||||||
|
|
||||||
|
router.replace({ query })
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query,
|
||||||
|
(query) => {
|
||||||
|
page.value = query.page ? Number(query.page) : 1
|
||||||
|
text.value = query.text ? String(query.text) : null
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="d-flex flex-column ga-2">
|
||||||
|
<v-text-field
|
||||||
|
v-model="text"
|
||||||
|
clearable
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
placeholder="Search name..."
|
||||||
|
hide-details
|
||||||
|
@keydown.enter.prevent="onSearch"
|
||||||
|
@click:clear="onSearch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-data-table-server
|
||||||
|
density="compact"
|
||||||
|
hide-default-header
|
||||||
|
hover
|
||||||
|
:headers="[
|
||||||
|
{ key: 'key', title: 'key' },
|
||||||
|
{ key: 'text', title: 'text' },
|
||||||
|
{ key: 'status', title: 'status' },
|
||||||
|
{ key: 'priority', title: 'priority' },
|
||||||
|
{ key: 'assignee', title: 'assignee' },
|
||||||
|
]"
|
||||||
|
:items-per-page="itemsPerPage"
|
||||||
|
:items-per-page-options="[itemsPerPage]"
|
||||||
|
:page="page"
|
||||||
|
:items="serverItems"
|
||||||
|
:items-length="totalItems"
|
||||||
|
item-value="text"
|
||||||
|
@update:options="loadItems"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>general settings</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const tab = ref(0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="text-uppercase text-title-large mb-4">Settings</div>
|
||||||
|
|
||||||
|
<v-sheet class="mb-2">
|
||||||
|
<v-tabs v-model="tab">
|
||||||
|
<v-tab value="common" to="/settings">General</v-tab>
|
||||||
|
<v-tab value="projects" to="/settings/projects">Projects</v-tab>
|
||||||
|
</v-tabs>
|
||||||
|
|
||||||
|
<div class="pa-6">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</v-sheet>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
|
<template>summary</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { authClient } from '@/lib/auth-client.js'
|
||||||
|
import SessionsList from '@/components/SessionsList.vue'
|
||||||
|
import UserProfile from '@/components/UserProfile.vue'
|
||||||
|
import PasswordChange from '@/components/PasswordChange.vue'
|
||||||
|
|
||||||
|
const session = authClient.useSession()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-sheet rounded border class="pa-4 d-flex flex-column ga-4">
|
||||||
|
<div class="text-headline-medium text-uppercase">profile</div>
|
||||||
|
|
||||||
|
<user-profile :user="session.data?.user" />
|
||||||
|
|
||||||
|
<password-change />
|
||||||
|
|
||||||
|
<sessions-list />
|
||||||
|
</v-sheet>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
import { authClient } from '@/lib/auth-client.js'
|
||||||
|
|
||||||
|
const signInData = reactive({
|
||||||
|
email: 'vadim.olonin@gmail.com',
|
||||||
|
password: '',
|
||||||
|
rememberMe: true,
|
||||||
|
callbackURL: 'https://madsky.ru',
|
||||||
|
})
|
||||||
|
|
||||||
|
const signIn = async () => {
|
||||||
|
const { data, error } = await authClient.signIn.email({
|
||||||
|
email: signInData.email,
|
||||||
|
password: signInData.password,
|
||||||
|
rememberMe: signInData.rememberMe,
|
||||||
|
callbackURL: '/',
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(data)
|
||||||
|
console.log(error)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="4" offset="4">
|
||||||
|
<div class="d-flex flex-column ga-2">
|
||||||
|
<div class="text-uppercase">sign in</div>
|
||||||
|
<v-text-field prepend-inner-icon="mdi-email" v-model="signInData.email" />
|
||||||
|
<v-text-field prepend-inner-icon="mdi-lock" v-model="signInData.password" />
|
||||||
|
<div class="d-flex ga-2 align-center">
|
||||||
|
<v-switch color="info" v-model="signInData.rememberMe" label="Remember me" />
|
||||||
|
<v-spacer />
|
||||||
|
<div>
|
||||||
|
<v-btn @click="signIn">login</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { authClient } from '@/lib/auth-client.js'
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
const signUpData = reactive({
|
||||||
|
name: 'Vadim',
|
||||||
|
email: 'vadim.olonin@gmail.com',
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const signUp = async () => {
|
||||||
|
const { data, error } = await authClient.signUp.email({
|
||||||
|
name: signUpData.name,
|
||||||
|
email: signUpData.email,
|
||||||
|
password: signUpData.password,
|
||||||
|
// image: 'https://example.com/image.png',
|
||||||
|
callbackURL: 'https://madsky.ru',
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(data)
|
||||||
|
console.log(error)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="4" offset="4">
|
||||||
|
<div class="d-flex flex-column ga-2">
|
||||||
|
<div class="text-uppercase">sign up</div>
|
||||||
|
<v-text-field placeholder="name" prepend-inner-icon="mdi-account" v-model="signUpData.name" />
|
||||||
|
<v-text-field placeholder="email" prepend-inner-icon="mdi-email" v-model="signUpData.email" />
|
||||||
|
<v-text-field placeholder="password" prepend-inner-icon="mdi-lock" v-model="signUpData.password" />
|
||||||
|
<div class="d-flex ga-2 align-center">
|
||||||
|
<div>
|
||||||
|
<v-btn @click="signUp">sign up</v-btn>
|
||||||
|
</div>
|
||||||
|
<v-spacer />
|
||||||
|
<router-link to="/verify-email" class="text-warning text-decoration-none"> Verify Email </router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { authClient } from '@/lib/auth-client.js'
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
const verifyData = reactive({
|
||||||
|
email: 'vadim.olonin@gmail.com',
|
||||||
|
})
|
||||||
|
|
||||||
|
const verify = async () => {
|
||||||
|
const { data, error } = await authClient.sendVerificationEmail({
|
||||||
|
email: verifyData.email,
|
||||||
|
callbackURL: 'https://madsky.ru',
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(data)
|
||||||
|
console.log(error)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="4" offset="4">
|
||||||
|
<div class="d-flex flex-column ga-2">
|
||||||
|
<div class="text-uppercase">verify email</div>
|
||||||
|
|
||||||
|
<v-text-field placeholder="email" prepend-inner-icon="mdi-email" v-model="verifyData.email" />
|
||||||
|
<div>
|
||||||
|
<v-btn @click="verify">verify</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
// Extra safety for array and object lookups, but may have false positives.
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
|
||||||
|
// Path mapping for cleaner imports.
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||||
|
// Specified here to keep it out of the root directory.
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||||
|
{
|
||||||
|
"extends": "@tsconfig/node24/tsconfig.json",
|
||||||
|
"include": [
|
||||||
|
"vite.config.*",
|
||||||
|
"vitest.config.*",
|
||||||
|
"cypress.config.*",
|
||||||
|
"playwright.config.*",
|
||||||
|
"eslint.config.*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||||
|
// Bundler mode provides a smoother developer experience.
|
||||||
|
"module": "preserve",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
|
||||||
|
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
||||||
|
"types": ["node"],
|
||||||
|
|
||||||
|
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||||
|
// Specified here to keep it out of the root directory.
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
// import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
import ViteFonts from 'unplugin-fonts/vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
port: 3333,
|
||||||
|
strictPort: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
// vueDevTools(),
|
||||||
|
ViteFonts({
|
||||||
|
fontsource: {
|
||||||
|
families: [
|
||||||
|
{
|
||||||
|
name: 'Roboto',
|
||||||
|
weights: [100, 300, 400, 500, 700, 900],
|
||||||
|
styles: ['normal', 'italic'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||