This commit is contained in:
vadim committed 2026-09-24 15:08:46 +03:00
commit fcec12e5ba
78 files changed
+9569

No files matched your search

+8
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+39
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 120
}
+9
View File
@@ -0,0 +1,9 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"oxc.oxc-vscode",
"esbenp.prettier-vscode"
]
}
+48
View File
@@ -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
```
Vendored
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+26
View File
@@ -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,
)
+13
View File
@@ -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>
+6518
View File
File diff suppressed because it is too large. Load diff
+52
View File
@@ -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"
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+63
View File
@@ -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>
+81
View File
@@ -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')
}
+43
View File
@@ -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
}
+13
View File
@@ -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')
}
+22
View File
@@ -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}`)
}
+9
View File
@@ -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;
}
+21
View File
@@ -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>
+107
View File
@@ -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>
+33
View File
@@ -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>
+53
View File
@@ -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>
+69
View File
@@ -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>
+126
View File
@@ -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>
+33
View File
@@ -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>
+33
View File
@@ -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>
+78
View File
@@ -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>
+30
View File
@@ -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>
+65
View File
@@ -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>
+61
View File
@@ -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>
+36
View File
@@ -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>
+63
View File
@@ -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>
+53
View File
@@ -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>
+38
View File
@@ -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>
+13
View File
@@ -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>
+150
View File
@@ -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>
+19
View File
@@ -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)
}
})
}
+40
View File
@@ -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' }),
}
+6
View File
@@ -0,0 +1,6 @@
import { createAuthClient } from 'better-auth/vue'
export const authClient = createAuthClient({
baseURL: 'http://localhost:3333',
plugins: [],
})
+83
View File
@@ -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')
+13
View File
@@ -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,
},
],
}
+75
View File
@@ -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
+19
View File
@@ -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,
},
],
}
+8
View File
@@ -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 },
}
+23
View File
@@ -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,
},
],
}
+8
View File
@@ -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 },
}
+83
View File
@@ -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 }
})
+122
View File
@@ -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,
}
})
+13
View File
@@ -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 }
})
+35
View File
@@ -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 }
})
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts"></script>
<template>
<div>Home</div>
</template>
<style scoped lang="scss"></style>
+32
View File
@@ -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>
+258
View File
@@ -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>
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts"></script>
<template>
<div>Games</div>
</template>
<style scoped lang="scss"></style>
+115
View File
@@ -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>
+208
View File
@@ -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>
+96
View File
@@ -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>
+24
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
<script setup lang="ts"></script>
<template>summary</template>
<style scoped lang="scss"></style>
+22
View File
@@ -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>
+44
View File
@@ -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>
+45
View File
@@ -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>
+35
View File
@@ -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>
+18
View File
@@ -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"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}
+27
View File
@@ -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"
}
}
+37
View File
@@ -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)),
},
},
})