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)),
|
||||
},
|
||||
},
|
||||
})
|
||||