Compare commits
63
Commits
4c6f21ef78
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c96ef32fe9 | ||
|
|
10ad1569fb | ||
|
|
609fe517d7 | ||
|
|
76bababa91 | ||
|
|
696166d68a | ||
|
|
b9c5f64989 | ||
|
|
f0706432d4 | ||
|
|
63861449cf | ||
|
|
ff0008d346 | ||
|
|
81dd976d8f | ||
|
|
9303a66aa8 | ||
|
|
327f59104d | ||
|
|
2b94bcc20e | ||
|
|
7639a54c50 | ||
|
|
cc490f8d4a | ||
|
|
6af9993c4f | ||
|
|
2d4b8ea9cf | ||
|
|
a0c557f9b2 | ||
|
|
66a6400cda | ||
|
|
b2444a8118 | ||
|
|
0fe2c70848 | ||
|
|
c0e815ae63 | ||
|
|
564ece4ff3 | ||
|
|
9b55fdf2d0 | ||
|
|
9e7afb8c03 | ||
|
|
a3b36e299c | ||
|
|
a8241406ce | ||
|
|
7967654988 | ||
|
|
d25ec80de8 | ||
|
|
0d310c2732 | ||
|
|
5b2fbb46ef | ||
|
|
591c4c9499 | ||
|
|
4caaf74b9f | ||
|
|
6674a6fa2a | ||
|
|
daf2cacf75 | ||
|
|
72b9d0d407 | ||
|
|
fcc438e5fe | ||
|
|
adf182a914 | ||
|
|
fff4e79324 | ||
|
|
410b182801 | ||
|
|
e12d2163bf | ||
|
|
d5642909e0 | ||
|
|
37fa2b5966 | ||
|
|
51debefee5 | ||
|
|
b80e88b121 | ||
|
|
cdd7147d8e | ||
|
|
ab539bf84b | ||
|
|
eb5b3a095b | ||
|
|
1274dece67 | ||
|
|
c03f9826af | ||
|
|
9b70fb774f | ||
|
|
c405507603 | ||
|
|
5b58203a7b | ||
|
|
17ad52c532 | ||
|
|
9a3c3bb189 | ||
|
|
e62747d62d | ||
|
|
3157946186 | ||
|
|
67d30a6f32 | ||
|
|
3082794436 | ||
|
|
6f651eeaeb | ||
|
|
baf0fa186e | ||
|
|
9c65fc05f4 | ||
|
|
1b42c0d916 |
No files matched your search
@@ -1,4 +1,4 @@
|
||||
name: Gitea Actions Demo
|
||||
name: Gitea Actions
|
||||
run-name: Deploy 🚀
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
services:
|
||||
ulitka-app:
|
||||
image: git.madsky.ru/vadim/ulitka-app:latest
|
||||
container_name: ulitka-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:80"
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<link rel="icon" href="/favicon_.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
|
||||
@@ -6,6 +6,8 @@ server {
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
# Matches the backend FILE_MAX_SIZE_MB (default 1m would reject uploads)
|
||||
client_max_body_size 25m;
|
||||
proxy_pass http://${BACKEND_UPSTREAM}/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
Generated
+1886
-194
File diff suppressed because it is too large.
Load diff
+11
-2
@@ -16,10 +16,19 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/roboto": "^5.3.0",
|
||||
"@tiptap/extension-list": "^3.32.0",
|
||||
"@tiptap/extensions": "^3.32.0",
|
||||
"@tiptap/markdown": "^3.32.0",
|
||||
"@tiptap/pm": "^3.32.0",
|
||||
"@tiptap/starter-kit": "^3.32.0",
|
||||
"@tiptap/vue-3": "^3.32.0",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"better-auth": "^1.7.1",
|
||||
"better-auth": "^1.7.13",
|
||||
"chart.js": "^4.5.1",
|
||||
"firebase": "^13.0.0",
|
||||
"pinia": "^4.0.3",
|
||||
"vue": "^3.5.41",
|
||||
"vue-chartjs": "^5.3.4",
|
||||
"vue-router": "^5.2.0",
|
||||
"vuetify": "^4.2.2"
|
||||
},
|
||||
@@ -41,7 +50,7 @@
|
||||
"sass-embedded": "^1.103.1",
|
||||
"typescript": "^6.0.3",
|
||||
"unplugin-fonts": "^2.0.0",
|
||||
"vite": "^8.2.2",
|
||||
"vite": "^8.3.5",
|
||||
"vite-plugin-vue-devtools": "^8.2.1",
|
||||
"vue-eslint-parser": "^10.4.1",
|
||||
"vue-tsc": "^3.3.11"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 627 B |
@@ -0,0 +1,23 @@
|
||||
importScripts('https://www.gstatic.com/firebasejs/13.0.0/firebase-app-compat.js')
|
||||
importScripts('https://www.gstatic.com/firebasejs/13.0.0/firebase-messaging-compat.js')
|
||||
|
||||
firebase.initializeApp({
|
||||
apiKey: 'AIzaSyAKY4nPht7-YsIQfhcECxVwZFx7iAnCQSM',
|
||||
authDomain: 'ulitka-c811b.firebaseapp.com',
|
||||
projectId: 'ulitka-c811b',
|
||||
storageBucket: 'ulitka-c811b.firebasestorage.app',
|
||||
messagingSenderId: '82243223520',
|
||||
appId: '1:82243223520:web:2c0e68358f8d5f2d2a7585',
|
||||
measurementId: 'G-CMDB21T1WG',
|
||||
})
|
||||
|
||||
const messaging = firebase.messaging()
|
||||
|
||||
messaging.onBackgroundMessage((payload) => {
|
||||
// Если в payload есть блок "notification", SDK покажет уведомление сам —
|
||||
// без этой проверки получится дубль "Новое сообщение" с пустым телом.
|
||||
if (payload.notification) return
|
||||
// Для data-only сообщений показываем вручную:
|
||||
const { title, body } = payload.data || {}
|
||||
self.registration.showNotification(title || 'Новое сообщение', { body })
|
||||
})
|
||||
+53
-15
@@ -1,19 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventSource } from '@vueuse/core'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useStatusStore } from '@/stores/status.ts'
|
||||
import { useIssueTypeStore } from '@/stores/issue-type.ts'
|
||||
import { useIssueStore } from '@/stores/issue.ts'
|
||||
import { useProjectStore } from '@/stores/project.ts'
|
||||
import { authClient } from '@/lib/auth-client.ts'
|
||||
import { usePushNotifications } from '@/composables/usePushNotifications.ts'
|
||||
import SnackbarQueue from '@/components/SnackbarQueue.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const issueStore = useIssueStore()
|
||||
const statusStore = useStatusStore()
|
||||
const issueTypeStore = useIssueTypeStore()
|
||||
const projectStore = useProjectStore()
|
||||
const session = authClient.useSession()
|
||||
const push = usePushNotifications()
|
||||
|
||||
const { currentProjectId } = storeToRefs(projectStore)
|
||||
|
||||
interface IssueSseEvent {
|
||||
type: string
|
||||
}
|
||||
|
||||
const { data } = useEventSource<string[], IssueSseEvent>(
|
||||
const { event, open, close } = useEventSource<string[], IssueSseEvent>(
|
||||
`${window.location.origin}/api/issues/events`,
|
||||
['issue.created', 'issue.updated', 'issue.deleted'],
|
||||
{
|
||||
@@ -25,39 +37,65 @@ const { data } = useEventSource<string[], IssueSseEvent>(
|
||||
console.log('Failed to connect EventSource after 3 retries')
|
||||
},
|
||||
},
|
||||
autoConnect: true,
|
||||
autoConnect: false,
|
||||
},
|
||||
)
|
||||
|
||||
watch(data, () => {
|
||||
if (
|
||||
data.value?.type === 'issue.created' ||
|
||||
data.value?.type === 'issue.updated' ||
|
||||
data.value?.type === 'issue.deleted'
|
||||
) {
|
||||
issueStore.getStat()
|
||||
watch(event, () => {
|
||||
if (event.value === 'issue.created' || event.value === 'issue.updated' || event.value === 'issue.deleted') {
|
||||
issueStore.getStat(currentProjectId.value)
|
||||
}
|
||||
})
|
||||
|
||||
// FID сохраняется на бэкенде под текущим пользователем, поэтому стартуем только с сессией
|
||||
watch(
|
||||
() => session.value.data?.user.id,
|
||||
(userId) => {
|
||||
if (userId) push.autoStart().catch((e) => console.error('Failed to start push', e))
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const layout = computed(() => {
|
||||
const layout = route?.meta?.layout
|
||||
return layout ? `${layout}Layout` : 'DefaultLayout'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await statusStore.list()
|
||||
await issueStore.getStat()
|
||||
})
|
||||
// Все эти ресурсы требуют авторизации: грузим при появлении сессии и чистим при выходе,
|
||||
// иначе запросы, ушедшие до логина, падают с 401 и больше не повторяются
|
||||
watch(
|
||||
() => session.value.data?.user.id,
|
||||
async (userId) => {
|
||||
if (!userId) {
|
||||
close()
|
||||
statusStore.reset()
|
||||
issueTypeStore.reset()
|
||||
issueStore.reset()
|
||||
projectStore.reset()
|
||||
return
|
||||
}
|
||||
|
||||
open()
|
||||
try {
|
||||
await Promise.all([statusStore.list(), issueTypeStore.list()])
|
||||
await issueStore.getStat(currentProjectId.value)
|
||||
} catch (e) {
|
||||
console.error('Failed to load dictionaries', e)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="layout">
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<div :key="route.name">
|
||||
<div :key="route.name" class="h-100">
|
||||
<component :is="Component" />
|
||||
</div>
|
||||
</router-view>
|
||||
</component>
|
||||
<snackbar-queue />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,72 @@
|
||||
import { apiClient, toQuery } from '@/lib/api-client.ts'
|
||||
import type { User } from '@/api/user.api.ts'
|
||||
|
||||
export interface CommentFile {
|
||||
id: string
|
||||
fileName: string
|
||||
mimeType: string
|
||||
size: number
|
||||
uploaderId: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string
|
||||
issueId: number
|
||||
authorId: string | null
|
||||
/** null — the author deleted their account */
|
||||
author: User | null
|
||||
/** null for a deleted comment */
|
||||
content: string | null
|
||||
attachments: CommentFile[]
|
||||
editedAt: string | null
|
||||
deletedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CommentsPage {
|
||||
list: Comment[]
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
export interface CreateCommentInput {
|
||||
content: string
|
||||
fileIds?: string[]
|
||||
}
|
||||
|
||||
export type UpdateCommentInput = CreateCommentInput
|
||||
|
||||
/** forward — newer than the cursor, backward — older than the cursor (no cursor: the latest page) */
|
||||
export type CommentListDirection = 'forward' | 'backward'
|
||||
|
||||
export const COMMENT_EVENT_TYPES = ['comment.created', 'comment.updated', 'comment.deleted'] as const
|
||||
|
||||
export type CommentEventType = (typeof COMMENT_EVENT_TYPES)[number]
|
||||
|
||||
// CLIENT REQUEST
|
||||
|
||||
export async function itemsList(
|
||||
issueId: number,
|
||||
params: { limit?: number; cursor?: string | null; direction?: CommentListDirection } = {},
|
||||
) {
|
||||
const query = toQuery({ limit: params.limit, cursor: params.cursor, direction: params.direction })
|
||||
return apiClient.get<CommentsPage>(`/api/issues/${issueId}/comments${query}`)
|
||||
}
|
||||
|
||||
export async function itemCreate(issueId: number, input: CreateCommentInput) {
|
||||
return apiClient.post<Comment>(`/api/issues/${issueId}/comments`, input)
|
||||
}
|
||||
|
||||
export async function itemUpdate(id: string, input: UpdateCommentInput) {
|
||||
return apiClient.patch<Comment>(`/api/comments/${id}`, input)
|
||||
}
|
||||
|
||||
export async function itemDelete(id: string) {
|
||||
await apiClient.delete(`/api/comments/${id}`)
|
||||
return true
|
||||
}
|
||||
|
||||
export function eventsUrl(issueId: number) {
|
||||
return `${window.location.origin}/api/issues/${issueId}/comments/events`
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { apiClient } from '@/lib/api-client.ts'
|
||||
|
||||
export interface StoredFile {
|
||||
id: string
|
||||
fileName: string
|
||||
mimeType: string
|
||||
size: number
|
||||
/** null — the uploader deleted their account */
|
||||
uploaderId: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface IssueAttachment {
|
||||
issueId: number
|
||||
file: StoredFile
|
||||
/** When the file was attached to the issue (may differ from file.createdAt) */
|
||||
attachedAt: string
|
||||
}
|
||||
|
||||
/** The server serves only these types inline, everything else as a download */
|
||||
const INLINE_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']
|
||||
|
||||
export function isPreviewable(file: StoredFile) {
|
||||
return INLINE_IMAGE_TYPES.includes(file.mimeType)
|
||||
}
|
||||
|
||||
export function downloadUrl(fileId: string) {
|
||||
return `/api/files/${fileId}/download`
|
||||
}
|
||||
|
||||
// CLIENT REQUEST
|
||||
|
||||
/** Uploads a file without an owner; it must be attached afterwards or GC removes it */
|
||||
export async function fileUpload(file: File) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return apiClient.upload<StoredFile>('/api/files', form)
|
||||
}
|
||||
|
||||
export async function attachmentsList(issueId: number) {
|
||||
return apiClient.get<IssueAttachment[]>(`/api/issues/${issueId}/attachments`)
|
||||
}
|
||||
|
||||
export async function attachmentCreate(issueId: number, fileId: string) {
|
||||
return apiClient.post<IssueAttachment>(`/api/issues/${issueId}/attachments`, { fileId })
|
||||
}
|
||||
|
||||
export async function attachmentDelete(issueId: number, fileId: string) {
|
||||
await apiClient.delete(`/api/issues/${issueId}/attachments/${fileId}`)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { apiClient } from '@/lib/api-client.ts'
|
||||
import type { User } from '@/api/user.api.ts'
|
||||
|
||||
/** created — a pseudo-field: the issue creation record, both values are null */
|
||||
export type IssueChangeField =
|
||||
'created' | 'title' | 'content' | 'status' | 'priority' | 'assignee' | 'dueDate' | 'type' | 'tag'
|
||||
|
||||
/** Values are serialized: priority — number, dueDate — ISO date, assignee — user id, tag — one change per tag name */
|
||||
export interface IssueFieldChange {
|
||||
field: IssueChangeField
|
||||
oldValue: string | null
|
||||
newValue: string | null
|
||||
/** Only for field = assignee: null — unassigned or the user was deleted */
|
||||
oldUser?: User | null
|
||||
newUser?: User | null
|
||||
}
|
||||
|
||||
/** One save of the issue: who, when and which fields changed */
|
||||
export interface IssueHistoryEntry {
|
||||
/** changeId */
|
||||
id: string
|
||||
changedAt: string
|
||||
actorId: string | null
|
||||
/** null — system change or the actor deleted their account */
|
||||
actor: User | null
|
||||
changes: IssueFieldChange[]
|
||||
}
|
||||
|
||||
// CLIENT REQUEST
|
||||
|
||||
/** Oldest first */
|
||||
export async function itemsList(issueId: number) {
|
||||
return apiClient.get<IssueHistoryEntry[]>(`/api/issues/${issueId}/history`)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { apiClient } from '@/lib/api-client.ts'
|
||||
|
||||
export interface IssueType {
|
||||
name: string
|
||||
alias: 'TASK' | 'BUG' | 'FEATURE'
|
||||
color: string
|
||||
}
|
||||
|
||||
// CLIENT REQUEST
|
||||
|
||||
export async function issueTypesList() {
|
||||
return apiClient.get<IssueType[]>('/api/issue-types')
|
||||
}
|
||||
+72
-4
@@ -1,17 +1,28 @@
|
||||
import { apiClient, toQuery } from '@/lib/api-client.ts'
|
||||
import type { User } from '@/api/user.api.ts'
|
||||
import type { IssueType } from '@/api/issue-type.api.ts'
|
||||
|
||||
export interface IssueTag {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface Issue {
|
||||
id: number
|
||||
key: string
|
||||
projectId: string
|
||||
title: string
|
||||
content: string
|
||||
status: 'OPEN' | 'IN_PROGRESS' | 'DONE' | 'CANCELLED'
|
||||
type: IssueType['alias']
|
||||
priority: 0 | 1 | 2 | 3 | 4
|
||||
assigneeId: string
|
||||
reporterId: string
|
||||
assignee?: User
|
||||
reporter?: User
|
||||
tags: IssueTag[]
|
||||
dueDate: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
@@ -28,8 +39,12 @@ export interface CreateIssueInput {
|
||||
projectId: string
|
||||
content?: string
|
||||
status?: Issue['status']
|
||||
type?: Issue['type']
|
||||
priority?: Issue['priority']
|
||||
assignee?: string
|
||||
dueDate?: string | null
|
||||
/** Names, the full new list; names missing from the project's tags are created */
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export type UpdateIssueInput = Partial<CreateIssueInput>
|
||||
@@ -45,15 +60,43 @@ export interface IssueStat {
|
||||
byStatus: IssueStatByStatus[]
|
||||
}
|
||||
|
||||
/** Count per issue status; statuses without issues are 0 */
|
||||
export type IssueStatusCounts = Record<Issue['status'], number>
|
||||
|
||||
export interface DailyIssueStat {
|
||||
/** YYYY-MM-DD in the requested time zone */
|
||||
date: string
|
||||
/** Issues created during the day */
|
||||
created: number
|
||||
/** Transitions into each status during the day (creation into OPEN is not included) */
|
||||
transitions: IssueStatusCounts
|
||||
/** Issues in each status at the end of the day */
|
||||
byStatus: IssueStatusCounts
|
||||
}
|
||||
|
||||
export interface DailyIssueStats {
|
||||
from: string
|
||||
to: string
|
||||
timeZone: string
|
||||
days: DailyIssueStat[]
|
||||
}
|
||||
|
||||
export interface CalendarIssues {
|
||||
list: Issue[]
|
||||
/** The range holds more issues than returned — narrow the filter */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
// CLIENT REQUEST
|
||||
|
||||
export async function itemsList(params: {
|
||||
page: number
|
||||
limit: number
|
||||
text?: string | null
|
||||
status?: string | null
|
||||
status?: string | string[] | null
|
||||
assigneeId?: string | null
|
||||
projectId?: string | null
|
||||
hasDueDate?: boolean
|
||||
}) {
|
||||
const query = toQuery({
|
||||
page: params.page,
|
||||
@@ -61,11 +104,30 @@ export async function itemsList(params: {
|
||||
projectId: params.projectId,
|
||||
text: params.text,
|
||||
assigneeId: params.assigneeId,
|
||||
status: params.status,
|
||||
status: Array.isArray(params.status) ? params.status.join(',') : params.status,
|
||||
hasDueDate: params.hasDueDate === undefined ? undefined : String(params.hasDueDate),
|
||||
})
|
||||
return apiClient.get<{ list: Issue[]; meta: Meta }>(`/api/issues/list${query}`)
|
||||
}
|
||||
|
||||
/** Issues with dueDate in [from, to); from/to are ISO moments with an offset */
|
||||
export async function itemsCalendar(params: {
|
||||
from: string
|
||||
to: string
|
||||
projectId?: string | null
|
||||
assigneeId?: string | null
|
||||
status?: string[] | null
|
||||
}) {
|
||||
const query = toQuery({
|
||||
from: params.from,
|
||||
to: params.to,
|
||||
projectId: params.projectId,
|
||||
assigneeId: params.assigneeId,
|
||||
status: params.status?.join(','),
|
||||
})
|
||||
return apiClient.get<CalendarIssues>(`/api/issues/calendar${query}`)
|
||||
}
|
||||
|
||||
export async function itemGetByKey(ckey: string) {
|
||||
return apiClient.get<Issue>(`/api/issues/by-key/${ckey}`)
|
||||
}
|
||||
@@ -83,6 +145,12 @@ export async function itemDelete(id: number) {
|
||||
return true
|
||||
}
|
||||
|
||||
export async function itemStat() {
|
||||
return apiClient.get<IssueStat>('/api/issues/stats')
|
||||
export async function itemStat(projectId?: string) {
|
||||
const query = toQuery({ projectId: projectId })
|
||||
return apiClient.get<IssueStat>(`/api/issues/stats${query}`)
|
||||
}
|
||||
|
||||
export async function itemDailyStat(params: { projectId?: string; from?: string; to?: string; timeZone?: string }) {
|
||||
const query = toQuery(params)
|
||||
return apiClient.get<DailyIssueStats>(`/api/issues/stats/daily${query}`)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { apiClient } from '@/lib/api-client.ts'
|
||||
|
||||
export type DevicePlatform = 'ios' | 'android' | 'web'
|
||||
export type NotificationChannel = 'EMAIL' | 'PUSH'
|
||||
|
||||
export interface NotificationPreference {
|
||||
channel: NotificationChannel
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// CLIENT REQUEST
|
||||
|
||||
export async function deviceTokenRegister(token: string, platform: DevicePlatform = 'web') {
|
||||
return apiClient.post<void>('/api/notifications/device-tokens', { token, platform })
|
||||
}
|
||||
|
||||
export async function deviceTokenRemove(token: string) {
|
||||
return apiClient.delete('/api/notifications/device-tokens', { token })
|
||||
}
|
||||
|
||||
export async function preferencesList() {
|
||||
return apiClient.get<NotificationPreference[]>('/api/notifications/preferences')
|
||||
}
|
||||
|
||||
export async function preferenceSet(channel: NotificationChannel, enabled: boolean) {
|
||||
return apiClient.put<void>(`/api/notifications/preferences/${channel}`, { enabled })
|
||||
}
|
||||
|
||||
export async function testPushSend(title?: string, body?: string) {
|
||||
return apiClient.post<void>('/api/notifications/test-push', { title, body })
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { apiClient, toQuery } from '@/lib/api-client.ts'
|
||||
|
||||
export const TAG_NAME_MAX_LENGTH = 50
|
||||
/** Upper bound of the backend's list limit */
|
||||
export const TAGS_LIST_MAX_LIMIT = 500
|
||||
|
||||
export interface Tag {
|
||||
id: string
|
||||
projectId: string
|
||||
name: string
|
||||
color: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// CLIENT REQUEST
|
||||
|
||||
/** Sorted by name; q — case-insensitive substring of the name */
|
||||
export async function tagsList(projectId: string, params: { q?: string; limit?: number } = {}) {
|
||||
return apiClient.get<Tag[]>(`/api/projects/${projectId}/tags${toQuery(params)}`)
|
||||
}
|
||||
@@ -20,3 +20,12 @@ export async function itemsList(params: { page: number; limit: number }) {
|
||||
export async function itemGetById(id: string) {
|
||||
return apiClient.get<User>(`/api/users/${id}`)
|
||||
}
|
||||
|
||||
/** fileId — картинка, загруженная через fileUpload; прежний аватар сервер отдаст GC */
|
||||
export async function avatarSet(fileId: string) {
|
||||
return apiClient.put<User>('/api/users/me/avatar', { fileId })
|
||||
}
|
||||
|
||||
export async function avatarRemove() {
|
||||
return apiClient.delete('/api/users/me/avatar')
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { useBreadcrumbs } from '@/composables/useBreadcrumbs.ts'
|
||||
|
||||
const { breadcrumbs } = useBreadcrumbs()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-breadcrumbs v-if="breadcrumbs.length > 0" :items="breadcrumbs" density="compact" class="pa-0 ma-0">
|
||||
<template #divider>
|
||||
<v-icon icon="mdi-chevron-right" size="small" />
|
||||
</template>
|
||||
</v-breadcrumbs>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -1,21 +1,48 @@
|
||||
<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' },
|
||||
]
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Props {
|
||||
isAuth: boolean
|
||||
}
|
||||
|
||||
const { isAuth } = defineProps<Props>()
|
||||
|
||||
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: 'Games',
|
||||
value: 3,
|
||||
props: { prependIcon: 'mdi-view-dashboard', to: '/games', requiresAuth: false },
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-app-bar-nav-icon variant="text" density="comfortable" v-bind="props" />
|
||||
</template>
|
||||
|
||||
<v-list class="mt-1" width="200" density="compact">
|
||||
<v-list-item v-for="item in menu" :key="item.id" :to="item.to">
|
||||
<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="mdi-information-outline" />
|
||||
<v-icon :icon="item.props.prependIcon" />
|
||||
<div>{{ item.title }}</div>
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -53,7 +53,15 @@ const select = (value: unknown) => {
|
||||
<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-btn
|
||||
border
|
||||
class="border-opacity-25"
|
||||
rounded="circle"
|
||||
density="compact"
|
||||
icon
|
||||
variant="text"
|
||||
v-bind="activatorProps"
|
||||
>
|
||||
<v-avatar
|
||||
:image="value?.image ?? undefined"
|
||||
:text="currentInitials"
|
||||
@@ -78,7 +86,7 @@ const select = (value: unknown) => {
|
||||
>
|
||||
<v-list-item value="">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-avatar density="compact" border rounded="circle" text="?" />
|
||||
<v-avatar density="compact" class="border-opacity-25" border rounded="circle" text="?" />
|
||||
<div>Unassigned</div>
|
||||
</div>
|
||||
</v-list-item>
|
||||
@@ -91,7 +99,6 @@ const select = (value: unknown) => {
|
||||
rounded="circle"
|
||||
density="compact"
|
||||
border
|
||||
color="primary"
|
||||
/>
|
||||
<div>{{ user.name || user.email }}</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const backTo = computed(() => {
|
||||
return [...route.matched].reverse().find((record) => record.meta.backTo)?.meta.backTo
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
// vue-router keeps the previous in-app location in history.state.back;
|
||||
// it's null when the page was opened directly, so fall back to backTo
|
||||
if (window.history.state?.back) router.back()
|
||||
else if (backTo.value) router.push({ name: backTo.value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-btn v-if="backTo" icon="mdi-arrow-left" variant="text" density="compact" aria-label="Back" @click="goBack" />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,296 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useCommentStore } from '@/stores/comment.ts'
|
||||
import { useCommentEvents } from '@/composables/useCommentEvents.ts'
|
||||
import { useChatScroll } from '@/composables/useChatScroll.ts'
|
||||
import { authClient } from '@/lib/auth-client.ts'
|
||||
import type { Comment } from '@/api/comment.api.ts'
|
||||
|
||||
interface Props {
|
||||
issueId: number
|
||||
}
|
||||
|
||||
/** Consecutive messages of one author closer than this are grouped under one header */
|
||||
const GROUP_GAP_MS = 5 * 60 * 1000
|
||||
|
||||
const { issueId } = defineProps<Props>()
|
||||
|
||||
const session = authClient.useSession()
|
||||
const myId = computed(() => session.value.data?.user.id ?? null)
|
||||
|
||||
const commentStore = useCommentStore()
|
||||
const { comments, olderCursor, loading, loadingOlder } = storeToRefs(commentStore)
|
||||
|
||||
const scroller = useTemplateRef<HTMLElement>('scroller')
|
||||
const { atBottom, scrollToBottom, keepPosition, isUnderfilled } = useChatScroll(scroller, { onReachTop: loadOlder })
|
||||
|
||||
const draft = ref('')
|
||||
const sending = ref(false)
|
||||
const unread = ref(0)
|
||||
|
||||
const canSend = computed(() => draft.value.trim().length > 0 && !sending.value)
|
||||
|
||||
async function loadOlder() {
|
||||
if (!olderCursor.value || loading.value || loadingOlder.value) return
|
||||
await keepPosition(() => commentStore.loadOlder())
|
||||
// Short pages may not fill the container, so the user could never scroll to the top
|
||||
if (isUnderfilled()) await loadOlder()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => issueId,
|
||||
async (id) => {
|
||||
unread.value = 0
|
||||
await commentStore.load(id)
|
||||
await scrollToBottom()
|
||||
if (isUnderfilled()) await loadOlder()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
useCommentEvents(() => issueId, {
|
||||
onEvent: commentStore.applyEvent,
|
||||
onReconnect: () => commentStore.loadNewer(),
|
||||
})
|
||||
|
||||
// A new message at the end: follow it if the user is at the bottom or wrote it, otherwise count it.
|
||||
// Runs before the DOM update, so `atBottom` still reflects the position before the message appeared.
|
||||
watch(
|
||||
() => comments.value.at(-1)?.id,
|
||||
(id, prevId) => {
|
||||
if (!id || !prevId || id === prevId || loading.value) return
|
||||
if (atBottom.value || comments.value.at(-1)!.authorId === myId.value) scrollToBottom('smooth')
|
||||
else unread.value++
|
||||
},
|
||||
)
|
||||
|
||||
watch(atBottom, (value) => {
|
||||
if (value) unread.value = 0
|
||||
})
|
||||
|
||||
const timeFormat = new Intl.DateTimeFormat('ru-RU', { timeStyle: 'short' })
|
||||
const dayFormat = new Intl.DateTimeFormat('ru-RU', { dateStyle: 'long' })
|
||||
|
||||
function dayKey(date: Date) {
|
||||
return date.toDateString()
|
||||
}
|
||||
|
||||
function dayLabel(date: Date) {
|
||||
const today = new Date()
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(today.getDate() - 1)
|
||||
if (dayKey(date) === dayKey(today)) return 'Today'
|
||||
if (dayKey(date) === dayKey(yesterday)) return 'Yesterday'
|
||||
return dayFormat.format(date)
|
||||
}
|
||||
|
||||
function authorName(comment: Comment) {
|
||||
return comment.author ? comment.author.name || comment.author.email : 'Deleted user'
|
||||
}
|
||||
|
||||
function initials(comment: Comment) {
|
||||
return authorName(comment)[0]!.toUpperCase()
|
||||
}
|
||||
|
||||
const items = computed(() =>
|
||||
comments.value.map((comment, i) => {
|
||||
const prev = comments.value[i - 1]
|
||||
const date = new Date(comment.createdAt)
|
||||
const prevDate = prev ? new Date(prev.createdAt) : null
|
||||
const newDay = !prevDate || dayKey(prevDate) !== dayKey(date)
|
||||
const grouped =
|
||||
!!prev && !newDay && prev.authorId === comment.authorId && date.getTime() - prevDate!.getTime() < GROUP_GAP_MS
|
||||
|
||||
return {
|
||||
comment,
|
||||
own: comment.authorId !== null && comment.authorId === myId.value,
|
||||
time: timeFormat.format(date),
|
||||
day: newDay ? dayLabel(date) : null,
|
||||
showHeader: !grouped,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
async function send() {
|
||||
if (!canSend.value) return
|
||||
sending.value = true
|
||||
const created = await commentStore.create({ content: draft.value.trim() })
|
||||
sending.value = false
|
||||
if (created) {
|
||||
draft.value = ''
|
||||
scrollToBottom('smooth')
|
||||
}
|
||||
}
|
||||
|
||||
function onEnter(e: KeyboardEvent) {
|
||||
// Shift+Enter — new line; Enter while composing (IME) — confirms the input, not a send
|
||||
if (e.shiftKey || e.isComposing) return
|
||||
e.preventDefault()
|
||||
send()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat">
|
||||
<div class="chat__viewport">
|
||||
<div ref="scroller" class="chat__scroller px-2 py-3">
|
||||
<div v-if="loadingOlder" class="d-flex justify-center pb-2">
|
||||
<v-progress-circular indeterminate size="20" width="2" />
|
||||
</div>
|
||||
|
||||
<div v-if="loading && comments.length === 0" class="chat__placeholder">
|
||||
<v-progress-circular indeterminate size="24" width="2" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="comments.length === 0" class="chat__placeholder text-medium-emphasis">No comments yet</div>
|
||||
|
||||
<template v-for="item in items" :key="item.comment.id">
|
||||
<div v-if="item.day" class="chat__day text-medium-emphasis text-body-small">{{ item.day }}</div>
|
||||
|
||||
<div class="chat__row" :class="{ 'chat__row--own': item.own, 'chat__row--grouped': !item.showHeader }">
|
||||
<div v-if="!item.own" class="chat__avatar">
|
||||
<v-avatar
|
||||
v-if="item.showHeader"
|
||||
:image="item.comment.author?.image ?? undefined"
|
||||
:text="initials(item.comment)"
|
||||
color="primary"
|
||||
rounded="circle"
|
||||
density="compact"
|
||||
border
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-sheet border :color="item.own ? 'primary-darken-1' : 'surface-light'" class="chat__bubble px-4 py-2">
|
||||
<div v-if="item.showHeader && !item.own" class="font-weight-medium text-body-small text-primary">
|
||||
{{ authorName(item.comment) }}
|
||||
</div>
|
||||
|
||||
<div v-if="item.comment.deletedAt" class="text-medium-emphasis font-italic">Comment deleted</div>
|
||||
<div v-else class="chat__content">{{ item.comment.content }}</div>
|
||||
|
||||
<div class="chat__meta">
|
||||
<span v-if="item.comment.editedAt && !item.comment.deletedAt">edited · </span>{{ item.time }}
|
||||
</div>
|
||||
</v-sheet>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<v-fade-transition>
|
||||
<v-btn v-if="!atBottom" density="compact" class="chat__jump" icon @click="scrollToBottom('smooth')">
|
||||
<v-badge :model-value="unread > 0" :content="unread" color="primary" floating>
|
||||
<v-icon icon="mdi-chevron-down" />
|
||||
</v-badge>
|
||||
</v-btn>
|
||||
</v-fade-transition>
|
||||
</div>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<div class="pa-2">
|
||||
<v-textarea
|
||||
v-model="draft"
|
||||
placeholder="Write a comment…"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
rows="1"
|
||||
max-rows="6"
|
||||
auto-grow
|
||||
hide-details
|
||||
@keydown.enter="onEnter"
|
||||
>
|
||||
<template #append-inner>
|
||||
<v-btn
|
||||
icon="mdi-send"
|
||||
color="primary"
|
||||
variant="text"
|
||||
density="compact"
|
||||
:disabled="!canSend"
|
||||
:loading="sending"
|
||||
@click="send"
|
||||
/>
|
||||
</template>
|
||||
</v-textarea>
|
||||
<div class="text-medium-emphasis text-body-small mt-1 px-1">Enter — send, Shift + Enter — new line</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 20rem;
|
||||
|
||||
&__viewport {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&__scroller {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
&__day {
|
||||
align-self: center;
|
||||
margin: 12px 0 4px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.1);
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
|
||||
&--grouped {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&--own {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
&__bubble {
|
||||
max-width: min(75%, 640px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__content {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: 0.7rem;
|
||||
text-align: right;
|
||||
line-height: 1.2;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&__jump {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
label: string
|
||||
value: number | string
|
||||
alert?: boolean
|
||||
}
|
||||
|
||||
const { label, value, alert } = defineProps<Props>()
|
||||
const emit = defineEmits<{ click: [] }>()
|
||||
|
||||
function onClick() {
|
||||
emit('click')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card :border="!alert" variant="flat" :color="alert ? 'error' : undefined" @click="onClick">
|
||||
<v-card-text>
|
||||
<div class="text-label-large" :class="{ 'text-medium-emphasis': !alert }">{{ label }}</div>
|
||||
<div class="text-headline-large">{{ value }}</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
text?: string
|
||||
/** Shown instead of empty text, so there is still something to click */
|
||||
placeholder?: string
|
||||
/** Empty (or whitespace-only) text is not saved, the field stays open with an error */
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { placeholder: 'click to write text', required: false })
|
||||
|
||||
const isEmpty = computed(() => !props.text?.trim())
|
||||
const emit = defineEmits<{ changed: [text: string] }>()
|
||||
|
||||
const editing = ref(false)
|
||||
const draft = ref(props.text ?? '')
|
||||
const error = ref('')
|
||||
|
||||
watch(draft, () => {
|
||||
error.value = ''
|
||||
})
|
||||
|
||||
const startEdit = () => {
|
||||
draft.value = props.text ?? ''
|
||||
error.value = ''
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
if (props.required && !draft.value.trim()) {
|
||||
error.value = 'This field is required'
|
||||
return
|
||||
}
|
||||
emit('changed', draft.value)
|
||||
editing.value = false
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
draft.value = props.text ?? ''
|
||||
error.value = ''
|
||||
editing.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editable-text">
|
||||
<template v-if="!editing">
|
||||
<v-hover #default="{ isHovering, props }">
|
||||
<v-sheet
|
||||
v-bind="props"
|
||||
:class="isHovering ? 'px-0' : 'px-0'"
|
||||
class="cursor-pointer d-flex align-center ga-1"
|
||||
@click="startEdit"
|
||||
>
|
||||
<div class="text-uppercase text-title-large" :class="{ 'text-medium-emphasis': isEmpty }">
|
||||
{{ isEmpty ? placeholder : text }}
|
||||
</div>
|
||||
<v-icon v-if="isHovering || isEmpty" icon="mdi-pencil" size="small" />
|
||||
</v-sheet>
|
||||
</v-hover>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="d-flex ga-1 align-center">
|
||||
<v-text-field
|
||||
autofocus
|
||||
class="mb-1"
|
||||
hide-details="auto"
|
||||
:error-messages="error"
|
||||
v-model="draft"
|
||||
variant="outlined"
|
||||
@keyup.enter="save"
|
||||
@keyup.esc="cancel"
|
||||
/>
|
||||
|
||||
<v-btn density="compact" icon variant="text" @click="cancel">
|
||||
<v-icon icon="mdi-cancel" />
|
||||
</v-btn>
|
||||
<v-btn density="compact" icon color="primary" variant="flat" @click="save">
|
||||
<v-icon icon="mdi-check" />
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
//.title-sheet:hover {
|
||||
// background-color: rgba(var(--v-theme-on-surface), 0.06);
|
||||
//}
|
||||
|
||||
//.v-sheet {
|
||||
// transition: padding 0.2s ease-in-out;
|
||||
//}
|
||||
</style>
|
||||
@@ -1,13 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
/** localStorage key for the open/closed state; defaults to one derived from the title. */
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
const icons = ['mdi-chevron-right', 'mdi-chevron-down']
|
||||
const isOpen = ref(true)
|
||||
const { title } = defineProps<Props>()
|
||||
const { title, storageKey } = defineProps<Props>()
|
||||
|
||||
const key = computed(() => `expansion-panel:${storageKey ?? title}`)
|
||||
|
||||
function readStoredOpen(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(key.value) !== 'closed'
|
||||
} catch {
|
||||
// ignore storage errors (e.g. disabled in private mode)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const isOpen = ref(readStoredOpen())
|
||||
|
||||
watch(isOpen, (value) => {
|
||||
try {
|
||||
localStorage.setItem(key.value, value ? 'open' : 'closed')
|
||||
} catch {
|
||||
// ignore storage errors (e.g. disabled in private mode)
|
||||
}
|
||||
})
|
||||
|
||||
const icon = computed(() => {
|
||||
return isOpen.value ? icons[1] : icons[0]
|
||||
@@ -24,10 +46,18 @@ const open = () => {
|
||||
<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">
|
||||
<div v-if="isOpen" class="panel__body ml-6 mt-2">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
<style scoped lang="scss">
|
||||
// Lets the body fill the panel when the panel itself is stretched by its parent
|
||||
.panel__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef, watch } from 'vue'
|
||||
import {
|
||||
attachmentCreate,
|
||||
attachmentDelete,
|
||||
attachmentsList,
|
||||
downloadUrl,
|
||||
fileUpload,
|
||||
type IssueAttachment,
|
||||
isPreviewable,
|
||||
} from '@/api/file.api.ts'
|
||||
|
||||
interface Props {
|
||||
issueId: number
|
||||
}
|
||||
|
||||
interface PendingUpload {
|
||||
key: number
|
||||
name: string
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const { issueId } = defineProps<Props>()
|
||||
|
||||
const attachments = ref<IssueAttachment[]>([])
|
||||
const pending = ref<PendingUpload[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const dragOver = ref(false)
|
||||
const input = useTemplateRef<HTMLInputElement>('input')
|
||||
|
||||
let pendingKey = 0
|
||||
|
||||
const sizeFormat = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 1 })
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${sizeFormat.format(bytes / 1024)} KB`
|
||||
return `${sizeFormat.format(bytes / 1024 / 1024)} MB`
|
||||
}
|
||||
|
||||
function fileIcon(mimeType: string) {
|
||||
if (mimeType.startsWith('image/')) return 'mdi-file-image-outline'
|
||||
if (mimeType === 'application/pdf') return 'mdi-file-pdf-box'
|
||||
if (mimeType.startsWith('video/')) return 'mdi-file-video-outline'
|
||||
if (mimeType.startsWith('audio/')) return 'mdi-file-music-outline'
|
||||
if (mimeType.includes('zip') || mimeType.includes('compressed')) return 'mdi-folder-zip-outline'
|
||||
return 'mdi-file-outline'
|
||||
}
|
||||
|
||||
function errorMessage(e: unknown, fallback: string) {
|
||||
return e instanceof Error ? e.message : fallback
|
||||
}
|
||||
|
||||
watch(
|
||||
() => issueId,
|
||||
async (id) => {
|
||||
attachments.value = []
|
||||
pending.value = []
|
||||
error.value = null
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await attachmentsList(id)
|
||||
// The issue may have changed while the request was in flight
|
||||
if (issueId === id) attachments.value = list
|
||||
} catch (e) {
|
||||
if (issueId === id) error.value = errorMessage(e, 'error get list of attachments')
|
||||
} finally {
|
||||
if (issueId === id) loading.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function uploadOne(file: File) {
|
||||
const id = issueId
|
||||
const item: PendingUpload = { key: ++pendingKey, name: file.name, error: null }
|
||||
pending.value.push(item)
|
||||
try {
|
||||
// Two steps: upload the file, then attach it to the issue by id
|
||||
const stored = await fileUpload(file)
|
||||
const attachment = await attachmentCreate(id, stored.id)
|
||||
if (issueId !== id) return
|
||||
// Re-attaching is idempotent on the server, so replace instead of duplicating
|
||||
attachments.value = [...attachments.value.filter((a) => a.file.id !== attachment.file.id), attachment]
|
||||
pending.value = pending.value.filter((p) => p.key !== item.key)
|
||||
} catch (e) {
|
||||
const target = pending.value.find((p) => p.key === item.key)
|
||||
if (target) target.error = errorMessage(e, 'upload failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function upload(files: FileList | File[] | null | undefined) {
|
||||
if (!files?.length) return
|
||||
await Promise.all(Array.from(files).map(uploadOne))
|
||||
}
|
||||
|
||||
function openPicker() {
|
||||
input.value?.click()
|
||||
}
|
||||
|
||||
async function onInputChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
await upload(target.files)
|
||||
// Allow picking the same file again
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
async function onDrop(event: DragEvent) {
|
||||
dragOver.value = false
|
||||
await upload(event.dataTransfer?.files)
|
||||
}
|
||||
|
||||
function dismiss(key: number) {
|
||||
pending.value = pending.value.filter((p) => p.key !== key)
|
||||
}
|
||||
|
||||
async function remove(attachment: IssueAttachment) {
|
||||
try {
|
||||
await attachmentDelete(attachment.issueId, attachment.file.id)
|
||||
attachments.value = attachments.value.filter((a) => a.file.id !== attachment.file.id)
|
||||
} catch (e) {
|
||||
error.value = errorMessage(e, 'error delete attachment')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="d-flex flex-column ga-2">
|
||||
<div
|
||||
class="dropzone d-flex align-center justify-center ga-2 pa-3 text-medium-emphasis cursor-pointer"
|
||||
:class="{ 'dropzone--active': dragOver }"
|
||||
@click="openPicker"
|
||||
@dragenter.prevent="dragOver = true"
|
||||
@dragover.prevent="dragOver = true"
|
||||
@dragleave.prevent="dragOver = false"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<v-icon icon="mdi-paperclip" />
|
||||
<span>Drop files here or click to upload</span>
|
||||
</div>
|
||||
<input ref="input" type="file" multiple hidden @change="onInputChange" />
|
||||
|
||||
<v-alert v-if="error" type="error" density="compact" variant="tonal" closable @click:close="error = null">
|
||||
{{ error }}
|
||||
</v-alert>
|
||||
|
||||
<v-progress-linear v-if="loading" indeterminate />
|
||||
|
||||
<v-list v-if="attachments.length || pending.length" density="compact" class="pa-0">
|
||||
<v-list-item
|
||||
v-for="attachment in attachments"
|
||||
:key="attachment.file.id"
|
||||
:href="downloadUrl(attachment.file.id)"
|
||||
target="_blank"
|
||||
rounded
|
||||
>
|
||||
<template #prepend>
|
||||
<v-avatar v-if="isPreviewable(attachment.file)" rounded="sm" size="32" class="mr-3">
|
||||
<v-img :src="downloadUrl(attachment.file.id)" cover />
|
||||
</v-avatar>
|
||||
<v-icon v-else :icon="fileIcon(attachment.file.mimeType)" />
|
||||
</template>
|
||||
|
||||
<v-list-item-title>{{ attachment.file.fileName }}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ formatSize(attachment.file.size) }}</v-list-item-subtitle>
|
||||
|
||||
<template #append>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
size="small"
|
||||
variant="text"
|
||||
density="comfortable"
|
||||
title="Remove"
|
||||
@click.prevent.stop="remove(attachment)"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item v-for="item in pending" :key="item.key" rounded>
|
||||
<template #prepend>
|
||||
<v-icon v-if="item.error" icon="mdi-alert-circle-outline" color="error" />
|
||||
<v-progress-circular v-else indeterminate size="20" width="2" class="mr-8" />
|
||||
</template>
|
||||
|
||||
<v-list-item-title>{{ item.name }}</v-list-item-title>
|
||||
<v-list-item-subtitle :class="{ 'text-error': item.error }">
|
||||
{{ item.error ?? 'Uploading…' }}
|
||||
</v-list-item-subtitle>
|
||||
|
||||
<template v-if="item.error" #append>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
size="small"
|
||||
variant="text"
|
||||
density="comfortable"
|
||||
title="Dismiss"
|
||||
@click="dismiss(item.key)"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dropzone {
|
||||
border: 1px dashed rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 4px;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background-color 0.15s;
|
||||
|
||||
&:hover,
|
||||
&--active {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
background-color: rgba(var(--v-theme-primary), 0.04);
|
||||
}
|
||||
|
||||
// Otherwise dragleave fires when the cursor moves over the icon or the text
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,259 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { itemsList, type IssueFieldChange, type IssueHistoryEntry } from '@/api/history.api.ts'
|
||||
import type { User } from '@/api/user.api.ts'
|
||||
import { useStatusStore } from '@/stores/status.ts'
|
||||
|
||||
interface Props {
|
||||
issueId: number
|
||||
/** Changes when the issue is saved — the history is reloaded */
|
||||
version?: string
|
||||
}
|
||||
|
||||
const { issueId, version } = defineProps<Props>()
|
||||
|
||||
const FIELD_LABELS: Record<IssueFieldChange['field'], string> = {
|
||||
created: 'Created',
|
||||
title: 'Title',
|
||||
content: 'Details',
|
||||
status: 'Status',
|
||||
priority: 'Priority',
|
||||
assignee: 'Assignee',
|
||||
dueDate: 'Due Date',
|
||||
type: 'Type',
|
||||
tag: 'Tag',
|
||||
}
|
||||
|
||||
const PRIORITY_LABELS: Record<string, string> = {
|
||||
'0': 'Lowest',
|
||||
'1': 'Low',
|
||||
'2': 'Normal',
|
||||
'3': 'High',
|
||||
'4': 'Highest',
|
||||
}
|
||||
|
||||
const statusStore = useStatusStore()
|
||||
|
||||
const entries = ref<IssueHistoryEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
/** Change keys (entryId:field) with an expanded details diff */
|
||||
const expanded = reactive(new Set<string>())
|
||||
|
||||
async function load(id: number) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await itemsList(id)
|
||||
// The issue may have changed while the request was in flight
|
||||
if (id !== issueId) return
|
||||
entries.value = res
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'error get issue history'
|
||||
} finally {
|
||||
if (id === issueId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => issueId,
|
||||
(id) => {
|
||||
entries.value = []
|
||||
expanded.clear()
|
||||
load(id)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => version,
|
||||
() => load(issueId),
|
||||
)
|
||||
|
||||
const dateTimeFormat = new Intl.DateTimeFormat('ru-RU', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
|
||||
/** Newest first */
|
||||
const items = computed(() =>
|
||||
[...entries.value].reverse().map((entry) => ({
|
||||
entry,
|
||||
time: dateTimeFormat.format(new Date(entry.changedAt)),
|
||||
created: entry.changes.some((c) => c.field === 'created'),
|
||||
changes: entry.changes.filter((c) => c.field !== 'created'),
|
||||
})),
|
||||
)
|
||||
|
||||
function userName(user: User | null | undefined) {
|
||||
return user ? user.name || user.email : 'Deleted user'
|
||||
}
|
||||
|
||||
function initials(user: User | null) {
|
||||
return user ? (user.name || user.email || '?')[0]!.toUpperCase() : '?'
|
||||
}
|
||||
|
||||
function status(alias: string) {
|
||||
return statusStore.statuses?.find((el) => el.alias === alias)
|
||||
}
|
||||
|
||||
function formatValue(change: IssueFieldChange, value: string | null) {
|
||||
if (value === null || value === '') return 'None'
|
||||
switch (change.field) {
|
||||
case 'status':
|
||||
return status(value)?.name ?? value
|
||||
case 'priority':
|
||||
return PRIORITY_LABELS[value] ?? value
|
||||
case 'dueDate':
|
||||
return new Date(value).toLocaleDateString('ru-RU')
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function changeKey(entry: IssueHistoryEntry, change: IssueFieldChange) {
|
||||
return `${entry.id}:${change.field}`
|
||||
}
|
||||
|
||||
function toggle(key: string) {
|
||||
if (expanded.has(key)) expanded.delete(key)
|
||||
else expanded.add(key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="history px-2 py-3">
|
||||
<div v-if="loading && entries.length === 0" class="history__placeholder">
|
||||
<v-progress-circular indeterminate size="24" width="2" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error && entries.length === 0" class="history__placeholder text-error">{{ error }}</div>
|
||||
|
||||
<div v-else-if="entries.length === 0" class="history__placeholder text-medium-emphasis">No history yet</div>
|
||||
|
||||
<div v-for="item in items" :key="item.entry.id" class="history__entry">
|
||||
<v-avatar
|
||||
:image="item.entry.actor?.image ?? undefined"
|
||||
:text="initials(item.entry.actor)"
|
||||
color="primary"
|
||||
rounded="circle"
|
||||
density="compact"
|
||||
border
|
||||
/>
|
||||
|
||||
<div class="history__body">
|
||||
<div class="d-flex flex-wrap align-baseline ga-2">
|
||||
<span class="font-weight-medium">{{ userName(item.entry.actor) }}</span>
|
||||
<span v-if="item.created" class="text-medium-emphasis">created the issue</span>
|
||||
<span v-else class="text-medium-emphasis">made changes</span>
|
||||
<span class="text-medium-emphasis text-body-small ms-auto">{{ item.time }}</span>
|
||||
</div>
|
||||
|
||||
<div v-for="(change, idx) in item.changes" :key="idx" class="history__change">
|
||||
<div class="text-medium-emphasis">{{ FIELD_LABELS[change.field] }}:</div>
|
||||
|
||||
<div v-if="change.field === 'content'">
|
||||
<a class="history__toggle text-primary" @click="toggle(changeKey(item.entry, change))">
|
||||
{{ expanded.has(changeKey(item.entry, change)) ? 'Hide changes' : 'Show changes' }}
|
||||
</a>
|
||||
<div v-if="expanded.has(changeKey(item.entry, change))" class="d-flex flex-column ga-1 mt-1">
|
||||
<v-sheet border class="history__text history__text--old px-2 py-1">
|
||||
{{ formatValue(change, change.oldValue) }}
|
||||
</v-sheet>
|
||||
<v-sheet border class="history__text px-2 py-1">{{ formatValue(change, change.newValue) }}</v-sheet>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="change.field === 'assignee'" class="history__values">
|
||||
<span class="history__old">{{ change.oldValue ? userName(change.oldUser) : 'Unassigned' }}</span>
|
||||
<v-icon icon="mdi-arrow-right" size="small" />
|
||||
<span>{{ change.newValue ? userName(change.newUser) : 'Unassigned' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="change.field === 'status'" class="history__values">
|
||||
<v-chip
|
||||
v-if="change.oldValue"
|
||||
:color="status(change.oldValue)?.color"
|
||||
:text="formatValue(change, change.oldValue)"
|
||||
density="compact"
|
||||
variant="flat"
|
||||
label
|
||||
/>
|
||||
<span v-else class="history__old">None</span>
|
||||
<v-icon icon="mdi-arrow-right" size="small" />
|
||||
<v-chip
|
||||
:color="change.newValue ? status(change.newValue)?.color : undefined"
|
||||
:text="formatValue(change, change.newValue)"
|
||||
density="compact"
|
||||
variant="flat"
|
||||
label
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="history__values">
|
||||
<span class="history__old">{{ formatValue(change, change.oldValue) }}</span>
|
||||
<v-icon icon="mdi-arrow-right" size="small" />
|
||||
<span>{{ formatValue(change, change.newValue) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
&__placeholder {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
&__entry {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__change {
|
||||
display: grid;
|
||||
grid-template-columns: 6rem 1fr;
|
||||
column-gap: 0.5rem;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
&__values {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
&__old {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&__toggle {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__text {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
|
||||
&--old {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useTheme } from 'vuetify'
|
||||
import {
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart as ChartJS,
|
||||
type ChartData,
|
||||
type ChartOptions,
|
||||
Legend,
|
||||
LinearScale,
|
||||
Tooltip,
|
||||
} from 'chart.js'
|
||||
import { Bar } from 'vue-chartjs'
|
||||
import { useStatusStore } from '@/stores/status.ts'
|
||||
import type { DailyIssueStats, IssueStatusCounts } from '@/api/issue.api.ts'
|
||||
|
||||
ChartJS.register(BarElement, CategoryScale, LinearScale, Legend, Tooltip)
|
||||
|
||||
const FALLBACK_COLOR = '#9E9E9E'
|
||||
|
||||
interface Props {
|
||||
stats?: DailyIssueStats | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const theme = useTheme()
|
||||
const statusStore = useStatusStore()
|
||||
|
||||
const { statuses } = storeToRefs(statusStore)
|
||||
|
||||
function themeColor(name: string, fallback = FALLBACK_COLOR): string {
|
||||
const color = theme.current.value.colors[name]
|
||||
return typeof color === 'string' ? color : fallback
|
||||
}
|
||||
|
||||
// Vuetify generates on-* colors as short hex (#FFF / #000), so appending an alpha suffix is not enough
|
||||
function withAlpha(hex: string, alpha: number): string {
|
||||
const value = hex.replace('#', '')
|
||||
const full = value.length === 3 ? [...value].map((c) => c + c).join('') : value.slice(0, 6)
|
||||
const [r, g, b] = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16))
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
}
|
||||
|
||||
// Dates come as YYYY-MM-DD already in the user's time zone, so format them as UTC to avoid a day shift
|
||||
const dateFormat = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
|
||||
function formatDate(date: string) {
|
||||
return dateFormat.format(new Date(`${date}T00:00:00Z`))
|
||||
}
|
||||
|
||||
const days = computed(() => props.stats?.days ?? [])
|
||||
|
||||
// One stacked bar per day: created issues at the bottom, transitions into each status on top.
|
||||
// Status order comes from the API so a status always lands on the same place in the stack
|
||||
const chartData = computed<ChartData<'bar'>>(() => ({
|
||||
labels: days.value.map((day) => formatDate(day.date)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Created',
|
||||
data: days.value.map((day) => day.created),
|
||||
backgroundColor: themeColor('error'),
|
||||
},
|
||||
...(statuses.value ?? []).map((status) => ({
|
||||
label: `→ ${status.name}`,
|
||||
data: days.value.map((day) => day.transitions[status.alias as keyof IssueStatusCounts] ?? 0),
|
||||
backgroundColor: themeColor(status.color),
|
||||
})),
|
||||
],
|
||||
}))
|
||||
|
||||
const chartOptions = computed<ChartOptions<'bar'>>(() => {
|
||||
// Muted axis text and grid lines derived from on-surface, so they follow the theme
|
||||
const onSurface = themeColor('on-surface', '#000000')
|
||||
const textColor = withAlpha(onSurface, 0.6)
|
||||
const gridColor = withAlpha(onSurface, 0.08)
|
||||
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: { color: textColor, boxWidth: 10, boxHeight: 10 },
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
// A day usually touches only a couple of statuses — hide the empty rows
|
||||
filter: (item) => (item.parsed.y ?? 0) > 0,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
grid: { display: false },
|
||||
ticks: { color: textColor, maxRotation: 0, autoSkip: true, autoSkipPadding: 12 },
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
grid: { color: gridColor },
|
||||
border: { display: false },
|
||||
// Counts are integers — no 0.5 ticks on quiet periods
|
||||
ticks: { color: textColor, precision: 0 },
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="activity-chart">
|
||||
<bar :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.activity-chart {
|
||||
position: relative;
|
||||
height: 200px;
|
||||
}
|
||||
</style>
|
||||
@@ -2,17 +2,17 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Props {
|
||||
value?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{ changed: [value: string] }>()
|
||||
const emit = defineEmits<{ changed: [text: string] }>()
|
||||
|
||||
const editing = ref(false)
|
||||
const draft = ref(props.value ?? '')
|
||||
const draft = ref(props.text ?? '')
|
||||
|
||||
const startEdit = () => {
|
||||
draft.value = props.value ?? ''
|
||||
draft.value = props.text ?? ''
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const save = () => {
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
draft.value = props.value ?? ''
|
||||
draft.value = props.text ?? ''
|
||||
editing.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -30,24 +30,42 @@ const cancel = () => {
|
||||
<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 v-bind="props" border class="cursor-pointer d-flex ga-1 pa-4" @click="startEdit">
|
||||
<div class="content-sheet">
|
||||
{{ text ?? 'click to add content' }}
|
||||
</div>
|
||||
</v-sheet>
|
||||
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<v-textarea autofocus class="mb-1" hide-details v-model="draft" variant="outlined" auto-grow rows="4" />
|
||||
<v-textarea
|
||||
autofocus
|
||||
class="mb-1"
|
||||
hide-details
|
||||
v-model="draft"
|
||||
variant="outlined"
|
||||
auto-grow
|
||||
rows="4"
|
||||
max-rows="15"
|
||||
/>
|
||||
|
||||
<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="flat" text="Save" @click="save" />
|
||||
<v-btn density="compact" prepend-icon="mdi-cancel" text="Cancel" variant="text" @click="cancel" />
|
||||
<v-btn density="compact" prepend-icon="mdi-check" color="primary" variant="flat" @click="save" text="Save" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.content-sheet:hover {
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.06);
|
||||
.content-sheet {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.v-sheet {
|
||||
transition: padding 0.2s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
@@ -3,32 +3,39 @@ 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'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const issueStore = useIssueStore()
|
||||
const projectStore = useProjectStore()
|
||||
const { current } = storeToRefs(projectStore)
|
||||
|
||||
const emit = defineEmits<{ created: [issue: Issue] }>()
|
||||
|
||||
const showForm = ref(false)
|
||||
const newIssue = reactive({
|
||||
key: 'type',
|
||||
key: '',
|
||||
title: '',
|
||||
})
|
||||
|
||||
const onCreate = async () => {
|
||||
const projectId = projectStore.current?.id
|
||||
if (!projectId) return
|
||||
if (!current.value) return
|
||||
|
||||
const created = await issueStore.create({ ...newIssue, projectId })
|
||||
const created = await issueStore.create({ ...newIssue, projectId: current.value.id, key: current.value.key })
|
||||
if (!created) return
|
||||
|
||||
emit('created', created)
|
||||
newIssue.key = ''
|
||||
newIssue.title = ''
|
||||
showForm.value = false
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
showForm.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-list-item v-if="showForm && projectStore.current">
|
||||
<v-list-item v-if="showForm && current">
|
||||
<template #prepend>
|
||||
<v-menu v-if="false">
|
||||
<template v-slot:activator="{ props }">
|
||||
@@ -51,15 +58,16 @@ const onCreate = async () => {
|
||||
placeholder="Describe your issue"
|
||||
autofocus
|
||||
@keyup.enter="onCreate"
|
||||
@keyup.esc="onCancel"
|
||||
/>
|
||||
<v-btn density="compact" variant="tonal" text="Create" @click="onCreate" />
|
||||
<v-btn density="compact" variant="text" icon="mdi-close" @click="showForm = false" />
|
||||
<v-btn density="compact" variant="text" icon="mdi-close" @click="onCancel" />
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item v-else-if="showForm && !projectStore.current">
|
||||
<v-list-item v-else-if="showForm && !current">
|
||||
<div>
|
||||
<v-alert closable density="compact" @click:close="showForm = false">
|
||||
<v-alert closable density="compact" @click:close="onCancel">
|
||||
Select or create project
|
||||
<router-link class="text-decoration-none text-link text-capitalize" to="/settings/projects">HERE</router-link>.
|
||||
</v-alert>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Props {
|
||||
date?: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{ changed: [date: string] }>()
|
||||
|
||||
const editing = ref(false)
|
||||
const draft = ref<Date | null>(props.date ? new Date(props.date) : null)
|
||||
|
||||
const formatted = () => {
|
||||
if (!props.date) return 'click to add due date'
|
||||
return new Date(props.date).toLocaleDateString('ru-RU')
|
||||
}
|
||||
|
||||
const startEdit = () => {
|
||||
draft.value = props.date ? new Date(props.date) : null
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
if (!draft.value) return
|
||||
emit('changed', draft.value.toISOString())
|
||||
editing.value = false
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
draft.value = props.date ? new Date(props.date) : null
|
||||
editing.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="issue-due-date">
|
||||
<template v-if="!editing">
|
||||
<v-hover #default="{ isHovering, props }">
|
||||
<v-sheet v-bind="props" class="cursor-pointer d-flex align-center ga-1" @click="startEdit">
|
||||
<div>{{ formatted() }}</div>
|
||||
<v-icon v-if="isHovering" icon="mdi-pencil" size="small" />
|
||||
</v-sheet>
|
||||
</v-hover>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="d-flex ga-1 align-center">
|
||||
<v-date-input
|
||||
input-format="dd-mm-yyyy"
|
||||
autofocus
|
||||
hide-details
|
||||
prepend-icon=""
|
||||
v-model="draft"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
persistent-placeholder
|
||||
/>
|
||||
|
||||
<v-btn density="compact" icon variant="text" @click="cancel">
|
||||
<v-icon icon="mdi-cancel" />
|
||||
</v-btn>
|
||||
<v-btn density="compact" icon color="primary" variant="flat" @click="save">
|
||||
<v-icon icon="mdi-check" />
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
+184
-17
@@ -1,12 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import { computed, ref, useTemplateRef, watchEffect } from 'vue'
|
||||
import { useIssueStore } from '@/stores/issue.js'
|
||||
import { useSnackbarStore } from '@/stores/snackbar.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'
|
||||
import EditableText from '@/components/EditableText.vue'
|
||||
import IssueDate from '@/components/IssueDate.vue'
|
||||
import CommentsList from '@/components/CommentsList.vue'
|
||||
import HistoryList from '@/components/HistoryList.vue'
|
||||
import SplitHandle from '@/components/SplitHandle.vue'
|
||||
import { useSplitPane } from '@/composables/useSplitPane.ts'
|
||||
import FileUpload from '@/components/FileUpload.vue'
|
||||
import PriorityMenu from '@/components/PriorityMenu.vue'
|
||||
import IssueType from '@/components/IssueType.vue'
|
||||
import ObserversList from '@/components/ObserversList.vue'
|
||||
import TagsSelector from '@/components/TagsSelector.vue'
|
||||
|
||||
interface Props {
|
||||
ckey: string
|
||||
@@ -14,19 +26,27 @@ interface Props {
|
||||
|
||||
const emit = defineEmits<{
|
||||
statusChanged: [issue: Issue]
|
||||
typeChanged: [issue: Issue]
|
||||
assigneeChanged: [issue: Issue]
|
||||
priorityChanged: [issue: Issue]
|
||||
titleChanged: [issue: Issue]
|
||||
dueDateChanged: [issue: Issue]
|
||||
}>()
|
||||
|
||||
const issueStore = useIssueStore()
|
||||
const snackbarStore = useSnackbarStore()
|
||||
const issue = ref<Issue>()
|
||||
const activity = ref()
|
||||
const tagsSaving = ref(false)
|
||||
|
||||
const { ckey } = defineProps<Props>()
|
||||
|
||||
const splitContainer = useTemplateRef<HTMLElement>('splitContainer')
|
||||
const { gridTemplate, handleProps } = useSplitPane(splitContainer, { storageKey: 'issue-details', min: 0.3, max: 0.7 })
|
||||
|
||||
const createdAtFormatted = computed(() => {
|
||||
if (!issue.value?.createdAt) return ''
|
||||
return new Intl.DateTimeFormat('ru-RU', { dateStyle: 'medium', timeStyle: 'short' }).format(
|
||||
new Date(issue.value.createdAt),
|
||||
)
|
||||
return new Date(issue.value.createdAt).toLocaleString('ru-RU')
|
||||
})
|
||||
|
||||
async function fetch() {
|
||||
@@ -40,12 +60,40 @@ async function fetch() {
|
||||
|
||||
watchEffect(async () => fetch())
|
||||
|
||||
const onTitleChanged = async (title: string) => {
|
||||
if (!issue.value) return
|
||||
const updated = await issueStore.update(issue.value.id, { title })
|
||||
if (updated) {
|
||||
issue.value = updated
|
||||
emit('titleChanged', updated)
|
||||
}
|
||||
}
|
||||
|
||||
const onDueDateChanged = async (date: string) => {
|
||||
if (!issue.value) return
|
||||
const updated = await issueStore.update(issue.value.id, { dueDate: date })
|
||||
if (updated) {
|
||||
issue.value = updated
|
||||
emit('dueDateChanged', updated)
|
||||
}
|
||||
}
|
||||
|
||||
const onContentChanged = async (content: string) => {
|
||||
if (!issue.value) return
|
||||
const updated = await issueStore.update(issue.value.id, { content })
|
||||
if (updated) issue.value = updated
|
||||
}
|
||||
|
||||
const onTypeChanged = async (type: Issue['type']) => {
|
||||
if (!issue.value) return
|
||||
|
||||
const updated = await issueStore.update(issue.value.id, { type })
|
||||
if (updated) {
|
||||
issue.value = updated
|
||||
emit('typeChanged', updated)
|
||||
}
|
||||
}
|
||||
|
||||
const onStatusChanged = async (status: Issue['status']) => {
|
||||
if (!issue.value) return
|
||||
|
||||
@@ -56,6 +104,16 @@ const onStatusChanged = async (status: Issue['status']) => {
|
||||
}
|
||||
}
|
||||
|
||||
const onPriorityChanged = async (priority: Issue['priority']) => {
|
||||
if (!issue.value) return
|
||||
|
||||
const updated = await issueStore.update(issue.value.id, { priority })
|
||||
if (updated) {
|
||||
issue.value = updated
|
||||
emit('priorityChanged', updated)
|
||||
}
|
||||
}
|
||||
|
||||
const onAssigneeChanged = async (assignee: string) => {
|
||||
if (!issue.value) return
|
||||
|
||||
@@ -66,6 +124,19 @@ const onAssigneeChanged = async (assignee: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const onTagsChanged = async (tags: string[]) => {
|
||||
if (!issue.value) return
|
||||
|
||||
tagsSaving.value = true
|
||||
try {
|
||||
const updated = await issueStore.update(issue.value.id, { tags })
|
||||
if (updated) issue.value = updated
|
||||
else snackbarStore.add({ title: 'Failed to update tags', text: issueStore.error ?? '', color: 'error' })
|
||||
} finally {
|
||||
tagsSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
applyUpdate: (updated: Issue) => {
|
||||
if (issue.value && issue.value.id === updated.id) {
|
||||
@@ -76,45 +147,141 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="issue" class="d-flex flex-column ga-4 pa-4">
|
||||
<div v-if="issue" ref="splitContainer" class="pa-4 details">
|
||||
<div class="d-flex flex-column ga-4 details__main">
|
||||
<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="d-flex ga-2">
|
||||
<div class="text-medium-emphasis">Status:</div>
|
||||
<status-menu :value="issue.status" @changed="onStatusChanged" />
|
||||
</div>
|
||||
|
||||
<editable-text :text="issue.title" @changed="onTitleChanged" />
|
||||
|
||||
<expansion-panel title="Info">
|
||||
<v-card elevation="0" border>
|
||||
<v-card-text class="u-grid">
|
||||
<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">Type:</div>
|
||||
<issue-type :value="issue.type" :compact="false" @changed="onTypeChanged" />
|
||||
|
||||
<div class="text-medium-emphasis">Priority:</div>
|
||||
<priority-menu :value="issue.priority" :compact="false" @changed="onPriorityChanged" />
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="u-grid">
|
||||
<div class="text-medium-emphasis">Observers:</div>
|
||||
<observers-list />
|
||||
</v-card-text>
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="u-grid">
|
||||
<div class="text-medium-emphasis">Tags:</div>
|
||||
<tags-selector
|
||||
:value="issue.tags"
|
||||
:project-id="issue.projectId"
|
||||
:saving="tagsSaving"
|
||||
@changed="onTagsChanged"
|
||||
/>
|
||||
|
||||
<div class="text-medium-emphasis">Created At:</div>
|
||||
<div>{{ createdAtFormatted }}</div>
|
||||
|
||||
<div class="text-medium-emphasis">Due Date:</div>
|
||||
<issue-date :date="issue.dueDate" @changed="onDueDateChanged" />
|
||||
</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 title="Details" class="details__content">
|
||||
<issue-content :text="issue.content" @changed="onContentChanged" />
|
||||
</expansion-panel>
|
||||
|
||||
<expansion-panel title="Attachments">
|
||||
<file-upload :issue-id="issue.id" />
|
||||
</expansion-panel>
|
||||
</div>
|
||||
<split-handle v-bind="handleProps" />
|
||||
<div class="d-flex flex-column">
|
||||
<expansion-panel title="Activity" class="activity">
|
||||
<v-sheet class="activity__sheet">
|
||||
<v-btn-toggle class="align-self-start" border divided v-model="activity" mandatory="force" density="compact">
|
||||
<v-btn rounded="0" prepend-icon="mdi-chat" value="comment">Comments</v-btn>
|
||||
<v-btn rounded="0" prepend-icon="mdi-history" value="history">History</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
<div class="activity__window">
|
||||
<comments-list v-if="activity === 'comment'" :issue-id="issue.id" class="h-100" />
|
||||
<history-list
|
||||
v-else-if="activity === 'history'"
|
||||
:issue-id="issue.id"
|
||||
:version="issue.updatedAt"
|
||||
class="h-100"
|
||||
/>
|
||||
</div>
|
||||
</v-sheet>
|
||||
</expansion-panel>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.details {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: v-bind(gridTemplate);
|
||||
column-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.details > * {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
// Children keep their content height; only the Details panel shrinks to the space left and scrolls
|
||||
.details__main > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.details__content {
|
||||
flex-shrink: 1;
|
||||
min-height: 0;
|
||||
|
||||
:deep(.panel__body) {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.activity {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
&__sheet {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__window {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.u-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
column-gap: 1rem;
|
||||
row-gap: 0.75rem;
|
||||
row-gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -2,16 +2,19 @@
|
||||
import { computed } from 'vue'
|
||||
import AssigneeFilter from '@/components/AssigneeFilter.vue'
|
||||
import TextFilter from '@/components/TextFilter.vue'
|
||||
import StatusFilter from '@/components/StatusFilter.vue'
|
||||
|
||||
interface Props {
|
||||
text?: string | null
|
||||
assigneeId?: string | null
|
||||
status?: string[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { text: null, assigneeId: null })
|
||||
const props = withDefaults(defineProps<Props>(), { text: null, assigneeId: null, status: () => [] })
|
||||
const emit = defineEmits<{
|
||||
'update:text': [value: string | null]
|
||||
'update:assigneeId': [value: string | null]
|
||||
'update:status': [value: string[]]
|
||||
'update:clear': []
|
||||
}>()
|
||||
|
||||
@@ -20,19 +23,24 @@ const selectedAssignees = computed(() => (props.assigneeId ? [props.assigneeId]
|
||||
function onAssigneeChanged(value: string[]) {
|
||||
emit('update:assigneeId', value.at(-1) ?? null)
|
||||
}
|
||||
|
||||
function onStatusChanged(value: string[]) {
|
||||
emit('update:status', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter-grid ga-4 align-center">
|
||||
<text-filter :model-value="text" @update:model-value="emit('update:text', $event)" />
|
||||
<status-filter :status="status" @changed="onStatusChanged" />
|
||||
<assignee-filter :selected="selectedAssignees" @changed="onAssigneeChanged" />
|
||||
<v-btn prepend-icon="mdi-close" variant="tonal" @click="emit('update:clear')">Clear Filters</v-btn>
|
||||
<v-btn density="compact" variant="text" @click="emit('update:clear')">Clear Filters</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { ISSUE_LIST_FIELDS, useIssueListOptions } from '@/composables/useIssueListOptions.ts'
|
||||
|
||||
const visible = useIssueListOptions()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<v-menu width="15rem" :close-on-content-click="false">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn density="compact" icon="mdi-dots-horizontal" v-bind="props" />
|
||||
</template>
|
||||
|
||||
<v-list border density="compact">
|
||||
<v-list-item rounded="0" class="py-0" slim v-for="el in ISSUE_LIST_FIELDS" :key="el.alias">
|
||||
<v-list-item-title>
|
||||
<div class="menu-grid px-1">
|
||||
<div>{{ el.title }}</div>
|
||||
<v-switch
|
||||
v-model="visible[el.alias]"
|
||||
inset="material"
|
||||
size="x-small"
|
||||
color="success"
|
||||
true-icon="mdi-check"
|
||||
false-icon="mdi-close"
|
||||
hide-details
|
||||
/>
|
||||
</div>
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.menu-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { ArcElement, Chart as ChartJS, type ChartData, type ChartOptions, Tooltip } from 'chart.js'
|
||||
import { Doughnut } from 'vue-chartjs'
|
||||
import { useStatusStore } from '@/stores/status.ts'
|
||||
import type { IssueStat } from '@/api/issue.api.ts'
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip)
|
||||
|
||||
// Statuses whose color is not a theme color (e.g. "default") fall back to a neutral gray
|
||||
const FALLBACK_COLOR = '#9E9E9E'
|
||||
interface Props {
|
||||
stats?: IssueStat | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [status: string]
|
||||
}>()
|
||||
|
||||
const theme = useTheme()
|
||||
const statusStore = useStatusStore()
|
||||
|
||||
function themeColor(name: string, fallback = FALLBACK_COLOR): string {
|
||||
const color = theme.current.value.colors[name]
|
||||
return typeof color === 'string' ? color : fallback
|
||||
}
|
||||
|
||||
const { statuses } = storeToRefs(statusStore)
|
||||
|
||||
// Keep the status order from the API so a status always lands on the same segment
|
||||
const items = computed(() =>
|
||||
(statuses.value ?? []).map((status) => ({
|
||||
alias: status.alias,
|
||||
label: status.name,
|
||||
count: props.stats?.byStatus.find((el) => el.status === status.alias)?.count ?? 0,
|
||||
color: themeColor(status.color),
|
||||
})),
|
||||
)
|
||||
|
||||
const total = computed(() => props.stats?.total ?? 0)
|
||||
|
||||
const chartData = computed<ChartData<'doughnut'>>(() => ({
|
||||
labels: items.value.map((el) => el.label),
|
||||
datasets: [
|
||||
{
|
||||
data: items.value.map((el) => el.count),
|
||||
backgroundColor: items.value.map((el) => el.color),
|
||||
// 2px surface-colored gap between segments
|
||||
borderColor: themeColor('surface', '#FFFFFF'),
|
||||
borderWidth: 2,
|
||||
hoverOffset: 4,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const chartOptions = computed<ChartOptions<'doughnut'>>(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '72%',
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => ` ${ctx.parsed} (${Math.round((ctx.parsed / (total.value || 1)) * 100)}%)`,
|
||||
},
|
||||
},
|
||||
},
|
||||
onHover: (event, elements) => {
|
||||
const target = event.native?.target as HTMLElement | undefined
|
||||
if (target) target.style.cursor = elements.length ? 'pointer' : 'default'
|
||||
},
|
||||
onClick: (_event, elements) => {
|
||||
const item = elements[0] && items.value[elements[0].index]
|
||||
if (item) emit('select', item.alias)
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="status-chart">
|
||||
<div class="status-chart__canvas">
|
||||
<doughnut :data="chartData" :options="chartOptions" />
|
||||
<div class="status-chart__total">
|
||||
<div class="text-headline-medium">{{ total }}</div>
|
||||
<div class="text-label-medium">issues</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="d-flex flex-column ga-2">
|
||||
<li v-for="item in items" :key="item.alias" class="status-chart__legend-item" @click="emit('select', item.alias)">
|
||||
<span class="status-chart__swatch" :style="{ background: item.color }" />
|
||||
<span>{{ item.label }}</span>
|
||||
<span class="text-label-medium">{{ item.count }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.status-chart {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
|
||||
&__canvas {
|
||||
position: relative;
|
||||
width: 180px;
|
||||
height: 200px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Overlay sits on top of the canvas but must not swallow hover/click on the segments
|
||||
&__total {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
&__legend-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
column-gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useIssueTypeStore } from '@/stores/issue-type.ts'
|
||||
import type { Issue } from '@/api/issue.api.ts'
|
||||
|
||||
interface Props {
|
||||
value?: string
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { compact: true })
|
||||
const emit = defineEmits<{ changed: [value: Issue['type']] }>()
|
||||
|
||||
const issueTypeStore = useIssueTypeStore()
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
BUG: 'mdi-bug-outline',
|
||||
TASK: 'mdi-checkbox-outline',
|
||||
FEATURE: 'mdi-bookmark-outline',
|
||||
}
|
||||
const defaultIcon = 'mdi-star-outline'
|
||||
|
||||
const iconOf = (alias?: string) => (alias && icons[alias]) || defaultIcon
|
||||
|
||||
const current = computed(() => issueTypeStore.issueTypes?.find((el) => el.alias === props.value))
|
||||
const currentIcon = computed(() => iconOf(props.value))
|
||||
const currentColor = computed(() => current.value?.color ?? 'primary')
|
||||
const currentName = computed(() => current.value?.name ?? props.value ?? '')
|
||||
|
||||
const availableTypes = computed(() => issueTypeStore.issueTypes?.filter((el) => el.alias !== props.value))
|
||||
|
||||
const select = (value: unknown) => {
|
||||
if (Array.isArray(value)) {
|
||||
emit('changed', value[0])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props: activatorProps }">
|
||||
<v-btn
|
||||
v-if="props.compact"
|
||||
density="compact"
|
||||
:icon="currentIcon"
|
||||
:color="currentColor"
|
||||
:title="currentName"
|
||||
variant="text"
|
||||
v-bind="activatorProps"
|
||||
/>
|
||||
|
||||
<v-btn v-else density="comfortable" slim v-bind="activatorProps">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-icon :color="currentColor" :icon="currentIcon" />
|
||||
<span>{{ currentName }}</span>
|
||||
</div>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list border class="mt-1" select-strategy="single-leaf" density="compact" @update:selected="select">
|
||||
<v-list-item v-for="el in availableTypes" :key="el.alias" :value="el.alias">
|
||||
<div class="d-flex justify-start align-center ga-1">
|
||||
<v-icon size="small" :icon="iconOf(el.alias)" :color="el.color" />
|
||||
<div>{{ el.name }}</div>
|
||||
</div>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -4,6 +4,8 @@ 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'
|
||||
import { useIssueListOptions } from '@/composables/useIssueListOptions.ts'
|
||||
import IssueType from '@/components/IssueType.vue'
|
||||
|
||||
interface Props {
|
||||
issue: Issue
|
||||
@@ -13,18 +15,25 @@ interface Props {
|
||||
const { issue, selected } = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
statusChanged: [issue: Issue]
|
||||
typeChanged: [issue: Issue]
|
||||
priorityChanged: [issue: Issue]
|
||||
assigneeChanged: [issue: Issue]
|
||||
toggleSelect: [id: number]
|
||||
}>()
|
||||
|
||||
const issueStore = useIssueStore()
|
||||
const visible = useIssueListOptions()
|
||||
|
||||
const onStatusChanged = async (status: Issue['status']) => {
|
||||
const updated = await issueStore.update(issue.id, { status })
|
||||
if (updated) emit('statusChanged', updated)
|
||||
}
|
||||
|
||||
const onTypeChanged = async (type: Issue['type']) => {
|
||||
const updated = await issueStore.update(issue.id, { type })
|
||||
if (updated) emit('typeChanged', updated)
|
||||
}
|
||||
|
||||
const onPriorityChanged = async (priority: Issue['priority']) => {
|
||||
const updated = await issueStore.update(issue.id, { priority })
|
||||
if (updated) emit('priorityChanged', updated)
|
||||
@@ -39,17 +48,30 @@ const onAssigneeChanged = async (assignee: string) => {
|
||||
<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">
|
||||
<div class="d-flex ga-4 min-w-0 mr-4">
|
||||
<div class="flex-shrink-0">{{ issue.key }}</div>
|
||||
<div class="d-flex ga-4 align-center min-w-0">
|
||||
<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
|
||||
v-if="visible.title"
|
||||
class="flex-shrink-0"
|
||||
:class="{ 'text-decoration-line-through': issue.status === 'DONE' }"
|
||||
>
|
||||
{{ issue.title }}
|
||||
</div>
|
||||
<div
|
||||
v-if="visible.content"
|
||||
:class="{ 'text-decoration-line-through': issue.status === 'DONE' }"
|
||||
class="text-body-medium text-truncate min-w-0"
|
||||
:title="issue.content"
|
||||
>
|
||||
{{ issue.content }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div :class="{ 'text-decoration-line-through': issue.status === 'DONE' }">{{ issue.content }}</div>-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -61,18 +83,22 @@ const onAssigneeChanged = async (assignee: string) => {
|
||||
:model-value="selected"
|
||||
@update:model-value="emit('toggleSelect', issue.id)"
|
||||
/>
|
||||
<v-icon icon="mdi-square" />
|
||||
<issue-type v-if="visible.type" :value="issue.type" @changed="onTypeChanged" />
|
||||
</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" />
|
||||
<status-menu v-if="visible.status" :value="issue.status" @changed="onStatusChanged" />
|
||||
<priority-menu v-if="visible.priority" :value="issue.priority" @changed="onPriorityChanged" />
|
||||
<assignee-menu v-if="visible.assignee" :value="issue.assignee" @changed="onAssigneeChanged" />
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
<style scoped lang="scss">
|
||||
.min-w-0 {
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
text?: string
|
||||
label?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
text: 'text',
|
||||
color: 'primary',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="d-flex align-center ga-1">
|
||||
<v-chip v-if="props.label" size="small" :color="props.color" variant="flat" density="compact" :text="props.label" />
|
||||
<div>{{ props.text }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,282 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { EditorContent, useEditor, type Editor } from '@tiptap/vue-3'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { TaskItem, TaskList } from '@tiptap/extension-list'
|
||||
import { Placeholder } from '@tiptap/extensions'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
|
||||
interface Props {
|
||||
placeholder?: string
|
||||
/** false renders the markdown read-only, without the toolbar */
|
||||
editable?: boolean
|
||||
autofocus?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { placeholder: 'Write something…', editable: true, autofocus: false })
|
||||
|
||||
/** Markdown text */
|
||||
const model = defineModel<string>({ default: '' })
|
||||
|
||||
const editor = useEditor({
|
||||
content: model.value,
|
||||
contentType: 'markdown',
|
||||
editable: props.editable,
|
||||
autofocus: props.autofocus ? 'end' : false,
|
||||
extensions: [
|
||||
// Markdown has no underline, so it would be lost on save
|
||||
StarterKit.configure({ underline: false, link: { openOnClick: !props.editable } }),
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
Placeholder.configure({ placeholder: () => props.placeholder }),
|
||||
Markdown,
|
||||
],
|
||||
onUpdate: ({ editor }) => {
|
||||
model.value = editor.getMarkdown()
|
||||
},
|
||||
})
|
||||
|
||||
// External changes only: skip when the value came from the editor itself
|
||||
watch(model, (value) => {
|
||||
if (!editor.value || value === editor.value.getMarkdown()) return
|
||||
editor.value.commands.setContent(value, { contentType: 'markdown', emitUpdate: false })
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.editable,
|
||||
(editable) => editor.value?.setEditable(editable),
|
||||
)
|
||||
|
||||
interface ToolbarButton {
|
||||
icon: string
|
||||
title: string
|
||||
run: (editor: Editor) => void
|
||||
active?: (editor: Editor) => boolean
|
||||
disabled?: (editor: Editor) => boolean
|
||||
}
|
||||
|
||||
const toolbar: ToolbarButton[][] = [
|
||||
[
|
||||
{
|
||||
icon: 'mdi-format-bold',
|
||||
title: 'Bold',
|
||||
run: (e) => e.chain().focus().toggleBold().run(),
|
||||
active: (e) => e.isActive('bold'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-format-italic',
|
||||
title: 'Italic',
|
||||
run: (e) => e.chain().focus().toggleItalic().run(),
|
||||
active: (e) => e.isActive('italic'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-format-strikethrough',
|
||||
title: 'Strikethrough',
|
||||
run: (e) => e.chain().focus().toggleStrike().run(),
|
||||
active: (e) => e.isActive('strike'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-code-tags',
|
||||
title: 'Inline code',
|
||||
run: (e) => e.chain().focus().toggleCode().run(),
|
||||
active: (e) => e.isActive('code'),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
icon: 'mdi-format-header-2',
|
||||
title: 'Heading',
|
||||
run: (e) => e.chain().focus().toggleHeading({ level: 2 }).run(),
|
||||
active: (e) => e.isActive('heading', { level: 2 }),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-format-header-3',
|
||||
title: 'Subheading',
|
||||
run: (e) => e.chain().focus().toggleHeading({ level: 3 }).run(),
|
||||
active: (e) => e.isActive('heading', { level: 3 }),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
icon: 'mdi-format-list-bulleted',
|
||||
title: 'Bullet list',
|
||||
run: (e) => e.chain().focus().toggleBulletList().run(),
|
||||
active: (e) => e.isActive('bulletList'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-format-list-numbered',
|
||||
title: 'Numbered list',
|
||||
run: (e) => e.chain().focus().toggleOrderedList().run(),
|
||||
active: (e) => e.isActive('orderedList'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-format-list-checks',
|
||||
title: 'Task list',
|
||||
run: (e) => e.chain().focus().toggleTaskList().run(),
|
||||
active: (e) => e.isActive('taskList'),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
icon: 'mdi-format-quote-close',
|
||||
title: 'Quote',
|
||||
run: (e) => e.chain().focus().toggleBlockquote().run(),
|
||||
active: (e) => e.isActive('blockquote'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-code-block-braces',
|
||||
title: 'Code block',
|
||||
run: (e) => e.chain().focus().toggleCodeBlock().run(),
|
||||
active: (e) => e.isActive('codeBlock'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-minus',
|
||||
title: 'Horizontal rule',
|
||||
run: (e) => e.chain().focus().setHorizontalRule().run(),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
icon: 'mdi-undo',
|
||||
title: 'Undo',
|
||||
run: (e) => e.chain().focus().undo().run(),
|
||||
disabled: (e) => !e.can().undo(),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-redo',
|
||||
title: 'Redo',
|
||||
run: (e) => e.chain().focus().redo().run(),
|
||||
disabled: (e) => !e.can().redo(),
|
||||
},
|
||||
],
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-sheet class="markdown-editor" :class="{ 'markdown-editor--editable': editable }">
|
||||
<div v-if="editable && editor" class="toolbar d-flex flex-wrap align-center ga-1 pa-1">
|
||||
<template v-for="(group, i) in toolbar" :key="i">
|
||||
<v-divider v-if="i > 0" vertical class="mx-1" />
|
||||
<v-btn
|
||||
v-for="button in group"
|
||||
:key="button.icon"
|
||||
:title="button.title"
|
||||
:icon="button.icon"
|
||||
:active="button.active?.(editor) ?? false"
|
||||
:disabled="button.disabled?.(editor) ?? false"
|
||||
density="comfortable"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="button.run(editor)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<EditorContent :editor="editor" class="content" />
|
||||
</v-sheet>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.markdown-editor--editable {
|
||||
&:focus-within {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.content :deep(.tiptap) {
|
||||
min-height: 6rem;
|
||||
max-height: 30rem;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.content :deep(.tiptap) {
|
||||
outline: none;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
> * + * {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
ul[data-type='taskList'] {
|
||||
list-style: none;
|
||||
padding-left: 0.25rem;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
|
||||
> label {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
> div {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 3px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
padding-left: 0.75rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 4px;
|
||||
padding: 0.1em 0.3em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
|
||||
code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Props {
|
||||
isAuth: boolean
|
||||
}
|
||||
|
||||
const { isAuth } = defineProps<Props>()
|
||||
|
||||
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: 'Calendar',
|
||||
value: 3,
|
||||
props: { prependIcon: 'mdi-calendar', to: '/calendar', requiresAuth: true },
|
||||
},
|
||||
// {
|
||||
// title: 'Games',
|
||||
// value: 4,
|
||||
// props: { prependIcon: 'mdi-view-dashboard', to: '/games', requiresAuth: false },
|
||||
// },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-navigation-drawer rounded="0" width="200" persistent permanent class="pt-4">
|
||||
<template v-for="item in menu" :key="item.value">
|
||||
<v-list-item
|
||||
density="compact"
|
||||
rounded="0"
|
||||
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-navigation-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import {
|
||||
preferenceSet,
|
||||
preferencesList,
|
||||
testPushSend,
|
||||
type NotificationChannel,
|
||||
type NotificationPreference,
|
||||
} from '@/api/notification.api.ts'
|
||||
import { usePushNotifications } from '@/composables/usePushNotifications.ts'
|
||||
import { useSnackbarStore } from '@/stores/snackbar.ts'
|
||||
|
||||
const CHANNEL_LABELS: Record<NotificationChannel, string> = {
|
||||
EMAIL: 'Email',
|
||||
PUSH: 'Push',
|
||||
}
|
||||
|
||||
const snackbarStore = useSnackbarStore()
|
||||
const push = usePushNotifications()
|
||||
|
||||
const preferences = ref<NotificationPreference[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref<NotificationChannel | null>(null)
|
||||
|
||||
const permission = ref<NotificationPermission | 'unsupported'>(
|
||||
'Notification' in window ? Notification.permission : 'unsupported',
|
||||
)
|
||||
const deviceSaving = ref(false)
|
||||
const deviceEnabled = computed(() => !!push.installationId.value)
|
||||
|
||||
function showError(text: string, e: unknown) {
|
||||
snackbarStore.add({ title: text, text: e instanceof Error ? e.message : '', color: 'error' })
|
||||
}
|
||||
|
||||
async function loadPreferences() {
|
||||
loading.value = true
|
||||
try {
|
||||
preferences.value = await preferencesList()
|
||||
} catch (e) {
|
||||
showError('Failed to load notification settings', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePreference(preference: NotificationPreference, enabled: boolean | null) {
|
||||
const previous = preference.enabled
|
||||
preference.enabled = !!enabled
|
||||
saving.value = preference.channel
|
||||
try {
|
||||
await preferenceSet(preference.channel, preference.enabled)
|
||||
} catch (e) {
|
||||
preference.enabled = previous
|
||||
showError('Failed to save notification settings', e)
|
||||
} finally {
|
||||
saving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDevice(enabled: boolean | null) {
|
||||
deviceSaving.value = true
|
||||
try {
|
||||
if (enabled) permission.value = await push.enable()
|
||||
else await push.disable(true)
|
||||
} catch (e) {
|
||||
showError('Failed to change push in this browser', e)
|
||||
} finally {
|
||||
deviceSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const testSending = ref(false)
|
||||
|
||||
async function sendTestPush() {
|
||||
testSending.value = true
|
||||
try {
|
||||
await testPushSend()
|
||||
snackbarStore.add({
|
||||
title: 'Test push sent',
|
||||
text: 'It should arrive on all your devices shortly',
|
||||
color: 'success',
|
||||
})
|
||||
} catch (e) {
|
||||
showError('Failed to send test push', e)
|
||||
} finally {
|
||||
testSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadPreferences()
|
||||
|
||||
if (!navigator.permissions) return
|
||||
const perm = await navigator.permissions.query({ name: 'notifications' })
|
||||
perm.onchange = () => {
|
||||
permission.value = Notification.permission
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-sheet border class="pa-4 d-flex flex-column ga-2">
|
||||
<div class="text-uppercase text-title-large">Notifications</div>
|
||||
|
||||
<v-skeleton-loader v-if="loading && !preferences.length" type="list-item, list-item" />
|
||||
<div v-else class="d-flex flex-column">
|
||||
<v-switch
|
||||
inset="material"
|
||||
size="x-small"
|
||||
true-icon="mdi-check"
|
||||
false-icon="mdi-close"
|
||||
v-for="preference in preferences"
|
||||
:key="preference.channel"
|
||||
:model-value="preference.enabled"
|
||||
:label="CHANNEL_LABELS[preference.channel] ?? preference.channel"
|
||||
:loading="saving === preference.channel"
|
||||
:disabled="saving === preference.channel"
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
@update:model-value="togglePreference(preference, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-uppercase">Push in this browser</div>
|
||||
<div v-if="permission === 'unsupported'" class="text-medium-emphasis">
|
||||
This browser does not support push notifications
|
||||
</div>
|
||||
<div v-else-if="permission === 'denied'" class="text-medium-emphasis">
|
||||
Notifications are blocked — allow them in the browser site settings
|
||||
</div>
|
||||
<v-switch
|
||||
v-else
|
||||
inset="material"
|
||||
size="x-small"
|
||||
true-icon="mdi-check"
|
||||
false-icon="mdi-close"
|
||||
:model-value="deviceEnabled"
|
||||
:label="deviceEnabled ? 'Enabled' : 'Disabled'"
|
||||
:loading="deviceSaving"
|
||||
:disabled="deviceSaving"
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
@update:model-value="toggleDevice"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<v-btn
|
||||
class="mt-2"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
prepend-icon="mdi-bell-ring-outline"
|
||||
:loading="testSending"
|
||||
@click="sendTestPush"
|
||||
>
|
||||
Send test push
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-sheet>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { usePushNotifications } from '@/composables/usePushNotifications.ts'
|
||||
import NotificationsListElement from '@/components/NotificationsListElement.vue'
|
||||
|
||||
const status = ref<NotificationPermission | 'unsupported'>(
|
||||
'Notification' in window ? Notification.permission : 'unsupported',
|
||||
)
|
||||
const loading = ref(false)
|
||||
const push = usePushNotifications()
|
||||
|
||||
async function onClick() {
|
||||
loading.value = true
|
||||
try {
|
||||
status.value = await push.enable()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!navigator.permissions) return
|
||||
const perm = await navigator.permissions.query({ name: 'notifications' })
|
||||
perm.onchange = () => {
|
||||
status.value = Notification.permission
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="d-flex">
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn rounded="0" icon v-bind="props" class="">
|
||||
<v-badge bordered location="top right" color="error" content="9+" :offset-x="1" :offset-y="-3">
|
||||
<v-icon icon="mdi-bell" />
|
||||
</v-badge>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list width="15rem" border nav density="compact" class="pa-1">
|
||||
<v-list-item>
|
||||
<v-sheet class="d-flex align-center ga-2">
|
||||
<span v-if="status === 'granted' && push.installationId.value"> Уведомления включены </span>
|
||||
<span v-else-if="status === 'default' || status === 'granted'" class="d-flex flex-column align-center ga-1">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
density="compact"
|
||||
:disabled="loading"
|
||||
@click="onClick"
|
||||
text="Enable notifications"
|
||||
/>
|
||||
<span>And choose 'Allow'</span>
|
||||
</span>
|
||||
<span v-else-if="status === 'denied'">Notifications blocked</span>
|
||||
<span v-else>Браузер не поддерживает уведомления</span>
|
||||
</v-sheet>
|
||||
</v-list-item>
|
||||
<v-divider />
|
||||
<notifications-list-element v-for="(el, idx) in [1, 2, 3, 4, 5]" :key="idx" />
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<v-list-item>
|
||||
<v-sheet class="d-flex flex-column">
|
||||
<div>Title</div>
|
||||
<div>Subtitle</div>
|
||||
</v-sheet>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
const observers = [{ name: 'A' }, { name: 'B' }]
|
||||
|
||||
const remove = () => {
|
||||
console.log('remove')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="d-flex ga-1">
|
||||
<template v-for="(o, id) in observers" :key="id">
|
||||
<v-avatar :text="o.name" size="28" color="primary" rounded="circle" density="compact" border />
|
||||
</template>
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props: activatorProps }">
|
||||
<v-btn rounded="circle" density="compact" icon="mdi-plus" variant="tonal" v-bind="activatorProps" />
|
||||
</template>
|
||||
|
||||
<v-list>
|
||||
<v-list-item>a</v-list-item>
|
||||
<v-list-item>b</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -1,28 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { authClient } from '@/lib/auth-client.js'
|
||||
import { useSnackbarStore } from '@/stores/snackbar.ts'
|
||||
|
||||
const passwordData = reactive({
|
||||
const session = authClient.useSession()
|
||||
const snackbarStore = useSnackbarStore()
|
||||
|
||||
const email = computed(() => session.value.data?.user.email ?? '')
|
||||
|
||||
const err = ref('')
|
||||
const loading = ref(false)
|
||||
const reset = reactive({
|
||||
newPassword: '',
|
||||
currentPassword: '',
|
||||
OTP: '',
|
||||
showOTP: false,
|
||||
showPassword: false,
|
||||
})
|
||||
|
||||
const changePassword = async () => {
|
||||
await authClient.changePassword({
|
||||
newPassword: passwordData.newPassword,
|
||||
currentPassword: passwordData.currentPassword,
|
||||
revokeOtherSessions: true,
|
||||
const canReset = computed(() => reset.newPassword.length > 0 && reset.OTP.length === 6)
|
||||
|
||||
watch(
|
||||
() => reset.OTP,
|
||||
(val) => {
|
||||
if (val.length >= 1) err.value = ''
|
||||
},
|
||||
)
|
||||
|
||||
const sendOTP = async () => {
|
||||
if (!email.value) return
|
||||
loading.value = true
|
||||
const { data, error } = await authClient.emailOtp.requestPasswordReset({
|
||||
email: email.value,
|
||||
})
|
||||
loading.value = false
|
||||
|
||||
if (data?.success) {
|
||||
reset.showOTP = true
|
||||
err.value = ''
|
||||
}
|
||||
if (error) err.value = error.message ?? 'Failed to send code'
|
||||
}
|
||||
|
||||
const confirmPasswordReset = async () => {
|
||||
if (!canReset.value) return
|
||||
loading.value = true
|
||||
const { data, error } = await authClient.emailOtp.resetPassword({
|
||||
email: email.value,
|
||||
otp: reset.OTP,
|
||||
password: reset.newPassword,
|
||||
})
|
||||
loading.value = false
|
||||
|
||||
if (data?.success) {
|
||||
snackbarStore.add({ title: 'Password changed', text: '', color: 'success' })
|
||||
reset.newPassword = ''
|
||||
reset.OTP = ''
|
||||
reset.showOTP = false
|
||||
err.value = ''
|
||||
}
|
||||
if (error) {
|
||||
err.value = error.message ?? 'Failed to reset password'
|
||||
reset.OTP = ''
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
reset.newPassword = ''
|
||||
reset.OTP = ''
|
||||
reset.showOTP = false
|
||||
err.value = ''
|
||||
}
|
||||
</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" />
|
||||
<v-sheet border class="pa-4 d-flex flex-column ga-2">
|
||||
<div class="d-flex ga-2 align-center justify-space-between">
|
||||
<div class="text-uppercase text-title-large">Change password</div>
|
||||
<v-chip v-if="err" size="small" variant="flat" color="error" density="compact">{{ err }}</v-chip>
|
||||
</div>
|
||||
|
||||
<div v-if="reset.showOTP" class="d-flex flex-column ga-2">
|
||||
<div class="text-body-medium">Enter the code sent to {{ email }} and a new password</div>
|
||||
<v-text-field
|
||||
class="w-50"
|
||||
clearable
|
||||
bg-color="background"
|
||||
density="comfortable"
|
||||
placeholder="New password"
|
||||
prepend-inner-icon="mdi-lock"
|
||||
:type="reset.showPassword ? 'text' : 'password'"
|
||||
:append-inner-icon="reset.showPassword ? 'mdi-eye-off' : 'mdi-eye'"
|
||||
@click:append-inner="reset.showPassword = !reset.showPassword"
|
||||
v-model="reset.newPassword"
|
||||
/>
|
||||
<div class="d-flex ga-2 align-center">
|
||||
<div>CODE from E-mail:</div>
|
||||
<v-otp-input
|
||||
class="justify-start pa-0"
|
||||
color="background"
|
||||
density="comfortable"
|
||||
pattern="numeric"
|
||||
v-model="reset.OTP"
|
||||
@finish="confirmPasswordReset"
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex ga-1">
|
||||
<v-btn density="comfortable" text="Cancel" @click="cancel" />
|
||||
<v-btn
|
||||
prepend-icon="mdi-autorenew"
|
||||
density="comfortable"
|
||||
text="Resend Code"
|
||||
:disabled="loading"
|
||||
@click="sendOTP"
|
||||
/>
|
||||
<v-btn
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
prepend-icon="mdi-check-circle"
|
||||
text="Reset password"
|
||||
:loading="loading"
|
||||
:disabled="!canReset"
|
||||
@click="confirmPasswordReset"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="d-flex flex-column ga-2">
|
||||
<div class="text-body-medium">A confirmation code will be sent to {{ email }}</div>
|
||||
<div class="d-flex ga-1">
|
||||
<v-btn
|
||||
prepend-icon="mdi-email-fast-outline"
|
||||
density="comfortable"
|
||||
text="Send Code"
|
||||
:loading="loading"
|
||||
:disabled="!email"
|
||||
@click="sendOTP"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</v-sheet>
|
||||
</template>
|
||||
|
||||
@@ -12,31 +12,32 @@ enum Icons {
|
||||
|
||||
interface Props {
|
||||
value?: number
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), { compact: true })
|
||||
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 },
|
||||
{ icon: Icons.highest, value: 4, label: 'Highest' },
|
||||
{ icon: Icons.high, value: 3, label: 'High' },
|
||||
{ icon: Icons.eq, value: 2, label: 'Normal' },
|
||||
{ icon: Icons.low, value: 1, label: 'Low' },
|
||||
{ icon: Icons.lowest, value: 0, label: 'Lowest' },
|
||||
])
|
||||
|
||||
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))
|
||||
|
||||
const currentColor = computed(() => priorityColor(props.value))
|
||||
const currentIcon = computed(() => priorityList.value.find((el) => el.value === props.value)?.icon ?? Icons.eq)
|
||||
const currentLabel = computed(() => priorityList.value.find((el) => el.value === props.value)?.label ?? '')
|
||||
|
||||
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])
|
||||
@@ -45,16 +46,32 @@ const select = (value: unknown) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="">
|
||||
<div>
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props: activatorProps }">
|
||||
<v-btn density="compact" :icon="currentIcon" :color="currentColor" variant="text" v-bind="activatorProps" />
|
||||
<v-btn
|
||||
v-if="props.compact"
|
||||
density="compact"
|
||||
:icon="currentIcon"
|
||||
:color="currentColor"
|
||||
:title="currentLabel"
|
||||
variant="text"
|
||||
v-bind="activatorProps"
|
||||
/>
|
||||
|
||||
<v-btn v-else density="comfortable" slim v-bind="activatorProps">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-icon :color="currentColor" :icon="currentIcon" />
|
||||
{{ currentLabel }}
|
||||
</div>
|
||||
</v-btn>
|
||||
</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-list-item v-for="el in availablePriorities" :key="el.value" :value="el.value">
|
||||
<div class="d-flex justify-start align-center ga-1">
|
||||
<v-icon size="small" :icon="el.icon" :color="priorityColor(el.value)" />
|
||||
<div>{{ el.label }}</div>
|
||||
</div>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useProjectStore } from '@/stores/project.ts'
|
||||
import LabelText from '@/components/LabelText.vue'
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const { projects, current } = storeToRefs(projectStore)
|
||||
@@ -30,16 +31,16 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="projects.length" class="d-flex align-center ga-2">
|
||||
Project:
|
||||
<div v-if="projects.length" class="d-flex align-center ga-2 mr-2">
|
||||
<div>Current project:</div>
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn border density="comfortable" variant="flat" :text="currentProjectName" v-bind="props" />
|
||||
<v-btn density="comfortable" v-bind="props" slim>
|
||||
<label-text :label="current?.key" :text="currentProjectName" />
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list
|
||||
width="12rem"
|
||||
class="mt-1"
|
||||
border
|
||||
select-strategy="single-leaf"
|
||||
density="compact"
|
||||
@@ -49,8 +50,7 @@ onMounted(async () => {
|
||||
>
|
||||
<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 }}
|
||||
<label-text :label="project.key" :text="project.name" />
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
@@ -16,7 +16,7 @@ const currentInitials = computed(() => (user ? initials(user) : '?'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="">
|
||||
<div>
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-btn rounded="circle" density="compact" icon variant="text">
|
||||
<v-avatar
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { authClient } from '@/lib/auth-client.ts'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
interface Session {
|
||||
id: string
|
||||
@@ -13,6 +14,8 @@ interface Session {
|
||||
userAgent?: string | null | undefined
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const currentSession = authClient.useSession()
|
||||
const sessions = ref<Session[]>([])
|
||||
|
||||
const listSessions = async () => {
|
||||
@@ -24,40 +27,60 @@ const listSessions = async () => {
|
||||
sessions.value = result.data
|
||||
}
|
||||
|
||||
// signOut also clears the session cookie and the cached useSession state
|
||||
const signOutToSignIn = async () => {
|
||||
await authClient.signOut()
|
||||
router.push({ name: 'sign-in' })
|
||||
}
|
||||
|
||||
const revokeSession = async (token: string) => {
|
||||
await authClient.revokeSession({
|
||||
if (token === currentSession.value.data?.session.token) return signOutToSignIn()
|
||||
|
||||
const { error } = await authClient.revokeSession({
|
||||
token: token,
|
||||
})
|
||||
if (!error) await listSessions()
|
||||
}
|
||||
|
||||
const revokeOtherSessions = async () => {
|
||||
await authClient.revokeOtherSessions()
|
||||
const { error } = await authClient.revokeOtherSessions()
|
||||
if (!error) await listSessions()
|
||||
}
|
||||
|
||||
const revokeAllSessions = async () => {
|
||||
await authClient.revokeSessions()
|
||||
await signOutToSignIn()
|
||||
}
|
||||
|
||||
watchEffect(async () => {
|
||||
await listSessions()
|
||||
})
|
||||
onMounted(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-sheet border class="pa-4 d-flex flex-column ga-2">
|
||||
<div class="text-uppercase text-title-large">Active sessions</div>
|
||||
|
||||
<v-list border class="pa-0">
|
||||
<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" />
|
||||
<v-btn
|
||||
@click="revokeSession(session.token)"
|
||||
variant="flat"
|
||||
density="compact"
|
||||
color="error"
|
||||
prepend-icon="mdi-trash-can"
|
||||
text="Revoke"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div class="d-flex ga-2">
|
||||
<v-btn density="comfortable" @click="revokeOtherSessions">Revoke other sessions</v-btn>
|
||||
<v-btn color="error" variant="flat" density="comfortable" prepend-icon="mdi-skull" @click="revokeAllSessions">
|
||||
Revoke all sessions
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-sheet>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<v-row justify="center">
|
||||
<v-col cols="12" lg="4" md="6" sm="8">
|
||||
<slot />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<v-sheet class="pa-4">
|
||||
<slot />
|
||||
</v-sheet>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { authClient } from '@/lib/auth-client.js'
|
||||
import { redirectTarget } from '@/lib/redirect.ts'
|
||||
import SignForm from '@/components/SignForm.vue'
|
||||
|
||||
const REMEMBERED_EMAIL_KEY = 'rememberedEmail'
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const signInData = reactive({
|
||||
email: localStorage.getItem(REMEMBERED_EMAIL_KEY) ?? '',
|
||||
password: '',
|
||||
rememberMe: true,
|
||||
callbackURL: 'https://madsky.ru',
|
||||
})
|
||||
const err = ref('')
|
||||
|
||||
const signIn = async () => {
|
||||
const { data, error } = await authClient.signIn.email({
|
||||
email: signInData.email,
|
||||
password: signInData.password,
|
||||
rememberMe: signInData.rememberMe,
|
||||
callbackURL: '/',
|
||||
})
|
||||
|
||||
if (data) {
|
||||
if (signInData.rememberMe) {
|
||||
localStorage.setItem(REMEMBERED_EMAIL_KEY, signInData.email)
|
||||
} else {
|
||||
localStorage.removeItem(REMEMBERED_EMAIL_KEY)
|
||||
}
|
||||
router.push(redirectTarget(route.query.redirect))
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error.message) {
|
||||
err.value = error.message
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<sign-form>
|
||||
<div class="d-flex flex-column ga-2">
|
||||
<div class="d-flex ga-2 align-center justify-space-between">
|
||||
<div class="text-uppercase">Sign In</div>
|
||||
<v-chip v-if="err" size="small" variant="flat" color="error" density="compact">{{ err }}</v-chip>
|
||||
</div>
|
||||
<v-text-field
|
||||
bg-color="background"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-email"
|
||||
placeholder="E-mail"
|
||||
v-model="signInData.email"
|
||||
/>
|
||||
<v-text-field
|
||||
bg-color="background"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-lock"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
v-model="signInData.password"
|
||||
/>
|
||||
<div class="d-flex ga-2 align-center">
|
||||
<v-switch
|
||||
true-icon="mdi-check"
|
||||
false-icon="mdi-close"
|
||||
inset="material"
|
||||
color="info"
|
||||
hide-details
|
||||
size="x-small"
|
||||
v-model="signInData.rememberMe"
|
||||
label="Remember me"
|
||||
/>
|
||||
|
||||
<v-spacer />
|
||||
<div>
|
||||
<v-btn
|
||||
prepend-icon="mdi-login"
|
||||
density="comfortable"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
@click="signIn"
|
||||
text="Sign In"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sign-form>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { authClient } from '@/lib/auth-client.js'
|
||||
import { redirectTarget } from '@/lib/redirect.ts'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import SignForm from '@/components/SignForm.vue'
|
||||
|
||||
const REMEMBERED_EMAIL_KEY = 'rememberedEmail'
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const err = ref('')
|
||||
const signIn = reactive({
|
||||
email: localStorage.getItem(REMEMBERED_EMAIL_KEY) ?? '',
|
||||
OTP: '',
|
||||
rememberMe: true,
|
||||
showOTP: false,
|
||||
})
|
||||
|
||||
const sendOTP = async () => {
|
||||
const { data, error } = await authClient.emailOtp.sendVerificationOtp({
|
||||
email: signIn.email,
|
||||
type: 'sign-in',
|
||||
})
|
||||
|
||||
if (data?.success) {
|
||||
if (signIn.rememberMe) localStorage.setItem(REMEMBERED_EMAIL_KEY, signIn.email)
|
||||
signIn.showOTP = true
|
||||
err.value = ''
|
||||
}
|
||||
|
||||
if (error) {
|
||||
localStorage.removeItem(REMEMBERED_EMAIL_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => signIn.OTP,
|
||||
(val) => {
|
||||
if (val.length >= 1) err.value = ''
|
||||
},
|
||||
)
|
||||
|
||||
const checkOTP = async () => {
|
||||
const { data, error } = await authClient.signIn.emailOtp({
|
||||
email: signIn.email,
|
||||
otp: signIn.OTP,
|
||||
})
|
||||
if (data) router.push(redirectTarget(route.query.redirect))
|
||||
if (error) {
|
||||
if (error.status === 400 && error.message) err.value = error.message
|
||||
if (error.status === 403 && error.message) {
|
||||
err.value = error.message
|
||||
signIn.showOTP = false
|
||||
}
|
||||
signIn.OTP = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<sign-form>
|
||||
<div class="d-flex flex-column ga-2">
|
||||
<div class="d-flex ga-2 align-center justify-space-between">
|
||||
<div class="text-uppercase">Sign In</div>
|
||||
<v-chip v-if="err" size="small" variant="flat" color="error" density="compact">{{ err }}</v-chip>
|
||||
</div>
|
||||
<v-text-field
|
||||
clearable
|
||||
prepend-inner-icon="mdi-email"
|
||||
bg-color="background"
|
||||
placeholder="E-mail"
|
||||
v-model="signIn.email"
|
||||
/>
|
||||
<div v-if="signIn.showOTP">
|
||||
<v-otp-input density="comfortable" autofocus pattern="numeric" v-model="signIn.OTP" @finish="checkOTP" />
|
||||
<div class="d-flex ga-1 justify-end">
|
||||
<v-btn prepend-icon="mdi-autorenew" density="comfortable" text="Resend Code" @click="sendOTP" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex ga-1 justify-end" v-else>
|
||||
<v-btn prepend-icon="mdi-email-fast-outline" density="comfortable" @click="sendOTP" text="Send Code" />
|
||||
</div>
|
||||
</div>
|
||||
</sign-form>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { useSnackbarStore } from '@/stores/snackbar.ts'
|
||||
|
||||
const snackbarStore = useSnackbarStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-snackbar-queue
|
||||
ref="queue"
|
||||
v-model="snackbarStore.snackbarQueue"
|
||||
location="top end"
|
||||
timeout="5000"
|
||||
total-visible="5"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bouncy-slide-x-transition-enter-active,
|
||||
.bouncy-slide-x-transition-leave-active,
|
||||
.bouncy-slide-x-transition-move,
|
||||
.bouncy-slide-x-reverse-transition-enter-active,
|
||||
.bouncy-slide-x-reverse-transition-leave-active,
|
||||
.bouncy-slide-x-reverse-transition-move {
|
||||
transition: transform, opacity;
|
||||
transition-duration: 0.5s;
|
||||
transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
.bouncy-slide-x-transition-enter-from,
|
||||
.bouncy-slide-x-transition-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-30%);
|
||||
}
|
||||
.bouncy-slide-x-reverse-transition-enter-from,
|
||||
.bouncy-slide-x-reverse-transition-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
dragging?: boolean
|
||||
}
|
||||
|
||||
const { dragging = false } = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="split-handle"
|
||||
:class="{ 'split-handle--active': dragging }"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
tabindex="0"
|
||||
title="Drag to resize, double-click to reset"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.split-handle {
|
||||
position: relative;
|
||||
width: 9px;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
outline: none;
|
||||
|
||||
// Thin visible line in the middle of a wider grab area
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 4px;
|
||||
background: rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
&:hover::after,
|
||||
&:focus-visible::after,
|
||||
&--active::after {
|
||||
inset: 0 3px;
|
||||
background: rgb(var(--v-theme-primary));
|
||||
}
|
||||
}
|
||||
|
||||
:global(body.split-pane--dragging),
|
||||
:global(body.split-pane--dragging *) {
|
||||
cursor: col-resize !important;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { useStatusStore } from '@/stores/status.ts'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
status?: string[]
|
||||
}
|
||||
|
||||
const { status = [] } = defineProps<Props>()
|
||||
const emit = defineEmits<{ changed: [value: string[]] }>()
|
||||
const statusStore = useStatusStore()
|
||||
const selected = ref<string[]>([...status])
|
||||
|
||||
// Keep store order so the same filter always yields the same URL
|
||||
const onUpdate = (val: unknown) => {
|
||||
if (!Array.isArray(val)) return
|
||||
const order = statusStore.statuses?.map((el) => el.alias) ?? []
|
||||
emit(
|
||||
'changed',
|
||||
[...val].sort((a, b) => order.indexOf(a) - order.indexOf(b)),
|
||||
)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => status,
|
||||
(val) => {
|
||||
selected.value = [...val]
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-btn-toggle v-model="selected" multiple border density="compact" variant="flat" @update:model-value="onUpdate">
|
||||
<v-btn slim v-for="el in statusStore.statuses" :color="el.color" :value="el.alias" :key="el.alias" rounded="0">
|
||||
<div class="text-default">{{ el.name }}</div>
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -28,11 +28,12 @@ const select = (value: unknown) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="">
|
||||
<div>
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props: activatorProps }">
|
||||
<v-btn
|
||||
slim
|
||||
border
|
||||
:color="currentStatusColor"
|
||||
density="compact"
|
||||
variant="flat"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { IssueTag } from '@/api/issue.api.ts'
|
||||
|
||||
interface Props {
|
||||
tag?: Pick<IssueTag, 'name' | 'color'>
|
||||
name?: string
|
||||
}
|
||||
|
||||
const { tag, name } = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 'default' is the backend's "no color", it is not a theme color -->
|
||||
<v-chip
|
||||
:color="tag?.color === 'default' ? undefined : tag?.color"
|
||||
:text="tag?.name ?? name"
|
||||
density="compact"
|
||||
variant="flat"
|
||||
label
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import type { IssueTag } from '@/api/issue.api.ts'
|
||||
import { TAG_NAME_MAX_LENGTH, tagsList } from '@/api/tag.api.ts'
|
||||
import TagChip from '@/components/TagChip.vue'
|
||||
|
||||
interface Props {
|
||||
value: IssueTag[]
|
||||
projectId: string
|
||||
/** The list is sent as a whole, so a change made during a save would overwrite it */
|
||||
saving?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{ changed: [names: string[]] }>()
|
||||
|
||||
const search = ref('')
|
||||
const options = ref<string[]>([])
|
||||
const selected = computed(() => props.value.map((tag) => tag.name))
|
||||
|
||||
let requestId = 0
|
||||
|
||||
async function load() {
|
||||
// Ignore responses that arrive after the search has changed again
|
||||
const id = ++requestId
|
||||
try {
|
||||
const tags = await tagsList(props.projectId, { q: search.value.trim() })
|
||||
if (id === requestId) options.value = tags.map((tag) => tag.name)
|
||||
} catch (e) {
|
||||
console.error('Failed to load tags', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Also after a save: the issue may have just created a tag or released one back to the list
|
||||
watchDebounced([search, () => props.projectId, () => props.value], load, { debounce: 200, immediate: true })
|
||||
|
||||
const tagOf = (name: string) => props.value.find((tag) => tag.name === name)
|
||||
|
||||
const onSelected = (names: string[]) => {
|
||||
if (!props.saving) emit('changed', names)
|
||||
}
|
||||
|
||||
// A name missing from the project's tags is created by the backend; names differ by case only — it's the same tag
|
||||
const onEnter = (e: KeyboardEvent) => {
|
||||
const name = search.value.trim()
|
||||
if (e.isComposing || !name || props.saving) return
|
||||
|
||||
search.value = ''
|
||||
if (!selected.value.some((el) => el.toLowerCase() === name.toLowerCase())) {
|
||||
emit('changed', [...selected.value, name])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-autocomplete
|
||||
v-model:search="search"
|
||||
:model-value="selected"
|
||||
:items="options"
|
||||
:loading="saving"
|
||||
:maxlength="TAG_NAME_MAX_LENGTH"
|
||||
no-data-text="Press Enter to create"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
hide-selected
|
||||
clear-on-select
|
||||
multiple
|
||||
closable-chips
|
||||
@keydown.enter="onEnter"
|
||||
@update:model-value="onSelected"
|
||||
>
|
||||
<template #chip="{ item, props: chipProps }">
|
||||
<tag-chip v-bind="chipProps" :tag="tagOf(item)" :name="item" />
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -1,5 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { authClient } from '@/lib/auth-client.ts'
|
||||
import { resizeImage } from '@/lib/image-resize.ts'
|
||||
import { fileUpload } from '@/api/file.api.ts'
|
||||
import { avatarRemove, avatarSet } from '@/api/user.api.ts'
|
||||
import { useSnackbarStore } from '@/stores/snackbar.ts'
|
||||
import EditableText from '@/components/EditableText.vue'
|
||||
|
||||
interface Props {
|
||||
user?: {
|
||||
@@ -13,26 +19,126 @@ interface Props {
|
||||
}
|
||||
}
|
||||
|
||||
/** The largest avatar is 128px in the profile, so 512px covers HiDPI screens */
|
||||
const AVATAR_MAX_SIZE = 512
|
||||
/** The server shows only these types inline */
|
||||
const AVATAR_TYPES = 'image/png,image/jpeg,image/gif,image/webp'
|
||||
|
||||
const { user } = defineProps<Props>()
|
||||
|
||||
const snackbarStore = useSnackbarStore()
|
||||
const input = useTemplateRef<HTMLInputElement>('input')
|
||||
const avatarSaving = ref(false)
|
||||
|
||||
const avatar = computed(() => {
|
||||
return user?.image ?? undefined
|
||||
})
|
||||
|
||||
const createdAt = computed(() => {
|
||||
return user?.createdAt ? new Date(user.createdAt).toLocaleString('ru-RU') : ''
|
||||
})
|
||||
|
||||
function showError(text: string, e: unknown) {
|
||||
snackbarStore.add({ title: text, text: e instanceof Error ? e.message : '', color: 'error' })
|
||||
}
|
||||
|
||||
// The avatar is changed by our API, not better-auth, so its 30s session cookie cache
|
||||
// is stale: re-read the session from the DB, then let useSession() consumers refetch
|
||||
async function refreshSession() {
|
||||
await authClient.getSession({ query: { disableCookieCache: true } })
|
||||
authClient.$store.notify('$sessionSignal')
|
||||
}
|
||||
|
||||
async function saveName(name: string) {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed || trimmed === user?.name) return
|
||||
|
||||
const { error } = await authClient.updateUser({ name: trimmed })
|
||||
if (error) showError('Failed to change name', new Error(error.message))
|
||||
}
|
||||
|
||||
function openPicker() {
|
||||
input.value?.click()
|
||||
}
|
||||
|
||||
async function onInputChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
// Allow picking the same file again
|
||||
target.value = ''
|
||||
if (!file) return
|
||||
|
||||
avatarSaving.value = true
|
||||
try {
|
||||
const stored = await fileUpload(await resizeImage(file, AVATAR_MAX_SIZE))
|
||||
await avatarSet(stored.id)
|
||||
await refreshSession()
|
||||
} catch (e) {
|
||||
showError('Failed to change avatar', e)
|
||||
} finally {
|
||||
avatarSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAvatar() {
|
||||
avatarSaving.value = true
|
||||
try {
|
||||
await avatarRemove()
|
||||
await refreshSession()
|
||||
} catch (e) {
|
||||
showError('Failed to remove avatar', e)
|
||||
} finally {
|
||||
avatarSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<v-sheet border class="pa-4 d-flex flex-column ga-2">
|
||||
<div v-if="user" class="d-flex ga-4">
|
||||
<v-avatar size="x-large" color="info" :image="avatar" />
|
||||
<v-menu>
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-hover #default="{ isHovering, props: hoverProps }">
|
||||
<v-avatar v-bind="{ ...menuProps, ...hoverProps }" size="128" color="background" class="cursor-pointer">
|
||||
<!-- With a default slot v-avatar ignores its image prop, so the image goes in the slot -->
|
||||
<v-img v-if="avatar" :src="avatar" alt="" cover />
|
||||
<v-icon v-else icon="mdi-account" size="64" />
|
||||
<v-overlay
|
||||
:model-value="isHovering || avatarSaving"
|
||||
contained
|
||||
persistent
|
||||
scrim="black"
|
||||
class="align-center justify-center"
|
||||
>
|
||||
<v-progress-circular v-if="avatarSaving" indeterminate size="40" width="3" color="white" />
|
||||
<v-icon v-else icon="mdi-camera" size="40" color="white" />
|
||||
</v-overlay>
|
||||
</v-avatar>
|
||||
</v-hover>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<div>name: {{ user.name }}</div>
|
||||
<div>email: {{ user.email }}</div>
|
||||
<div>created at: {{ user.createdAt }}</div>
|
||||
<v-list density="compact">
|
||||
<v-list-item prepend-icon="mdi-upload" title="Upload photo" :disabled="avatarSaving" @click="openPicker" />
|
||||
<v-list-item
|
||||
v-if="avatar"
|
||||
prepend-icon="mdi-trash-can"
|
||||
title="Remove photo"
|
||||
base-color="error"
|
||||
:disabled="avatarSaving"
|
||||
@click="removeAvatar"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<input ref="input" type="file" :accept="AVATAR_TYPES" hidden @change="onInputChange" />
|
||||
|
||||
<div class="flex-grow-1">
|
||||
<editable-text class="mb-2" :text="user.name" placeholder="Enter your name" required @changed="saveName" />
|
||||
<div>E-mail: {{ user.email }}</div>
|
||||
<div>Created At: {{ createdAt }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<v-skeleton-loader v-else type="avatar, list-item-three-line" />
|
||||
</div>
|
||||
</v-sheet>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,38 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
export interface Breadcrumb {
|
||||
title: string
|
||||
to?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a breadcrumb trail from `meta.breadcrumb` on the matched route chain,
|
||||
* so pages don't need to know their own position in the hierarchy.
|
||||
*/
|
||||
export function useBreadcrumbs() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const breadcrumbs = computed<Breadcrumb[]>(() => {
|
||||
const matched = route.matched.filter((record) => {
|
||||
return record.meta.breadcrumb
|
||||
})
|
||||
|
||||
const paths = matched.map((record, index): Breadcrumb => {
|
||||
const { breadcrumb } = record.meta
|
||||
const title = typeof breadcrumb === 'function' ? breadcrumb(route) : (breadcrumb as string)
|
||||
const isLast = index === matched.length - 1
|
||||
const to = !isLast && record.name ? router.resolve({ name: record.name, params: route.params }).path : undefined
|
||||
|
||||
return { title, to, disabled: isLast }
|
||||
})
|
||||
|
||||
const value: Breadcrumb[] = []
|
||||
value.push({ title: 'Home', to: '/', disabled: paths.length === 0 })
|
||||
return value.concat(paths)
|
||||
})
|
||||
|
||||
return { breadcrumbs }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { nextTick, ref, type Ref } from 'vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
interface Options {
|
||||
/** Distance in px from an edge that still counts as "at the edge" */
|
||||
threshold?: number
|
||||
/** Called when the user scrolls to the top — load older messages here */
|
||||
onReachTop?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll behaviour of a chat container: tracks whether the user is at the bottom,
|
||||
* scrolls to the bottom, and keeps the visible messages in place while older ones
|
||||
* are prepended above them.
|
||||
*/
|
||||
export function useChatScroll(container: Ref<HTMLElement | null | undefined>, options: Options = {}) {
|
||||
const threshold = options.threshold ?? 64
|
||||
const atBottom = ref(true)
|
||||
|
||||
function distanceToBottom(el: HTMLElement) {
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight
|
||||
}
|
||||
|
||||
useEventListener(
|
||||
container,
|
||||
'scroll',
|
||||
() => {
|
||||
const el = container.value
|
||||
if (!el) return
|
||||
atBottom.value = distanceToBottom(el) <= threshold
|
||||
if (el.scrollTop <= threshold) options.onReachTop?.()
|
||||
},
|
||||
{ passive: true },
|
||||
)
|
||||
|
||||
async function scrollToBottom(behavior: ScrollBehavior = 'auto') {
|
||||
await nextTick()
|
||||
const el = container.value
|
||||
if (!el) return
|
||||
el.scrollTo({ top: el.scrollHeight, behavior })
|
||||
atBottom.value = true
|
||||
}
|
||||
|
||||
/** Runs `prepend` (which adds content above) without shifting what the user sees. */
|
||||
async function keepPosition(prepend: () => Promise<unknown>) {
|
||||
const el = container.value
|
||||
const prevHeight = el?.scrollHeight ?? 0
|
||||
const prevTop = el?.scrollTop ?? 0
|
||||
await prepend()
|
||||
await nextTick()
|
||||
if (el) el.scrollTop = prevTop + (el.scrollHeight - prevHeight)
|
||||
}
|
||||
|
||||
/** Whether the content is too short to scroll — there is no way to "reach the top" then. */
|
||||
function isUnderfilled() {
|
||||
const el = container.value
|
||||
return !!el && el.scrollHeight <= el.clientHeight + threshold
|
||||
}
|
||||
|
||||
return { atBottom, scrollToBottom, keepPosition, isUnderfilled }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from 'vue'
|
||||
import { COMMENT_EVENT_TYPES, type Comment, type CommentEventType, eventsUrl } from '@/api/comment.api.ts'
|
||||
|
||||
interface Options {
|
||||
onEvent: (type: CommentEventType, comment: Comment) => void
|
||||
/** Called when the stream comes back after a drop — events from the gap are not replayed. */
|
||||
onReconnect?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to the SSE stream of an issue's comments while `issueId` is set.
|
||||
* Reopens the stream when the issue changes and closes it on scope dispose.
|
||||
*/
|
||||
export function useCommentEvents(issueId: MaybeRefOrGetter<number | null | undefined>, options: Options) {
|
||||
const connected = ref(false)
|
||||
let source: EventSource | null = null
|
||||
|
||||
function close() {
|
||||
source?.close()
|
||||
source = null
|
||||
connected.value = false
|
||||
}
|
||||
|
||||
function open(id: number) {
|
||||
let dropped = false
|
||||
const es = new EventSource(eventsUrl(id), { withCredentials: true })
|
||||
|
||||
es.addEventListener('open', () => {
|
||||
connected.value = true
|
||||
if (dropped) options.onReconnect?.()
|
||||
dropped = false
|
||||
})
|
||||
|
||||
// EventSource reconnects by itself unless the server refused the stream (readyState CLOSED)
|
||||
es.addEventListener('error', () => {
|
||||
connected.value = false
|
||||
dropped = true
|
||||
})
|
||||
|
||||
for (const type of COMMENT_EVENT_TYPES) {
|
||||
es.addEventListener(type, (event) => {
|
||||
try {
|
||||
options.onEvent(type, JSON.parse((event as MessageEvent<string>).data) as Comment)
|
||||
} catch {
|
||||
// ignore malformed payloads
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
source = es
|
||||
}
|
||||
|
||||
watch(
|
||||
() => toValue(issueId),
|
||||
(id) => {
|
||||
close()
|
||||
if (id != null) open(id)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onScopeDispose(close)
|
||||
|
||||
return { connected, close }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createGlobalState, useLocalStorage } from '@vueuse/core'
|
||||
|
||||
export const ISSUE_LIST_FIELDS = [
|
||||
{ title: 'Type', alias: 'type' },
|
||||
{ title: 'Title', alias: 'title' },
|
||||
{ title: 'Content', alias: 'content' },
|
||||
{ title: 'Status', alias: 'status' },
|
||||
{ title: 'Priority', alias: 'priority' },
|
||||
{ title: 'Assignee', alias: 'assignee' },
|
||||
] as const
|
||||
|
||||
export type IssueListField = (typeof ISSUE_LIST_FIELDS)[number]['alias']
|
||||
|
||||
/** Persisted visibility of the issue list element fields, shared by every list element. */
|
||||
export const useIssueListOptions = createGlobalState(() =>
|
||||
useLocalStorage<Record<IssueListField, boolean>>(
|
||||
'issue-list:visible-fields',
|
||||
{ title: true, content: true, status: true, priority: true, assignee: true, type: true },
|
||||
// Fields added later stay visible for users who already have saved options
|
||||
{ mergeDefaults: true },
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
import { readonly, ref } from 'vue'
|
||||
import type { MessagePayload } from 'firebase/messaging'
|
||||
import { autoRequestPush, disablePush, enablePush, type PushOptions } from '@/lib/firebase.ts'
|
||||
import { deviceTokenRegister, deviceTokenRemove } from '@/api/notification.api.ts'
|
||||
import { useSnackbarStore } from '@/stores/snackbar.ts'
|
||||
|
||||
// FID этого браузера; null — пуши в этом браузере не подключены
|
||||
const installationId = ref<string | null>(null)
|
||||
|
||||
// Разрешение браузера после выключения в профиле остаётся granted, поэтому
|
||||
// помним выбор пользователя локально, иначе autoStart снова подпишет браузер
|
||||
const DISABLED_KEY = 'push-disabled'
|
||||
|
||||
function isDisabledLocally(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(DISABLED_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function setDisabledLocally(disabled: boolean) {
|
||||
try {
|
||||
if (disabled) localStorage.setItem(DISABLED_KEY, '1')
|
||||
else localStorage.removeItem(DISABLED_KEY)
|
||||
} catch {
|
||||
// хранилище недоступно — просто не запоминаем
|
||||
}
|
||||
}
|
||||
|
||||
// Одни и те же опции и для автостарта, и для кнопки: messaging стартует один раз,
|
||||
// и тот, кто стартовал первым, определяет колбэки
|
||||
export function usePushNotifications() {
|
||||
const snackbarStore = useSnackbarStore()
|
||||
|
||||
const options: PushOptions = {
|
||||
onInstallationId: (fid) => {
|
||||
installationId.value = fid
|
||||
deviceTokenRegister(fid).catch((e) => console.error('Failed to save push token', e))
|
||||
},
|
||||
onInstallationIdRemoved: (fid) => {
|
||||
installationId.value = null
|
||||
deviceTokenRemove(fid).catch((e) => console.error('Failed to remove push token', e))
|
||||
},
|
||||
onForeground: (payload: MessagePayload) => {
|
||||
const title = payload.notification?.title ?? payload.data?.title
|
||||
const body = payload.notification?.body ?? payload.data?.body
|
||||
if (!title && !body) return
|
||||
|
||||
snackbarStore.add({ title: title ?? '', text: body ?? '', color: payload.data?.color ?? 'default' })
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
installationId: readonly(installationId),
|
||||
enable: () => {
|
||||
setDisabledLocally(false)
|
||||
return enablePush(options)
|
||||
},
|
||||
autoStart: () => (isDisabledLocally() ? Promise.resolve() : autoRequestPush(options)),
|
||||
// Вызывать до signOut: удалить токен на бэкенде можно только пока есть сессия.
|
||||
// remember = true — пользователь сам выключил пуши в этом браузере, не включать их при следующем входе
|
||||
async disable(remember = false) {
|
||||
if (remember) setDisabledLocally(true)
|
||||
const fid = installationId.value
|
||||
if (fid) await deviceTokenRemove(fid).catch((e) => console.error('Failed to remove push token', e))
|
||||
await disablePush().catch((e) => console.error('Failed to unregister push', e))
|
||||
installationId.value = null
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { computed, onScopeDispose, ref, type Ref } from 'vue'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
|
||||
interface Options {
|
||||
/** localStorage key for the ratio; the ratio is not persisted without it */
|
||||
storageKey?: string
|
||||
/** Share of the container taken by the first pane, 0..1 */
|
||||
initial?: number
|
||||
min?: number
|
||||
max?: number
|
||||
/** Ratio change per arrow key press */
|
||||
step?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Two panes in a CSS grid split by a draggable handle.
|
||||
* Bind `gridTemplate` to `grid-template-columns` of the container and `handleProps` to the handle between the panes.
|
||||
*/
|
||||
export function useSplitPane(container: Ref<HTMLElement | null | undefined>, options: Options = {}) {
|
||||
const { storageKey, initial = 0.5, min = 0.2, max = 0.8, step = 0.02 } = options
|
||||
|
||||
const clamp = (value: number) => Math.min(max, Math.max(min, value))
|
||||
|
||||
const ratio = storageKey ? useLocalStorage(`split-pane:${storageKey}`, initial) : ref(initial)
|
||||
const dragging = ref(false)
|
||||
|
||||
// fr units share the space left after the auto-sized handle, so the ratio is exact
|
||||
const gridTemplate = computed(() => {
|
||||
const r = clamp(ratio.value)
|
||||
return `minmax(0, ${r}fr) auto minmax(0, ${1 - r}fr)`
|
||||
})
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
const el = container.value
|
||||
if (!el) return
|
||||
// Measure against the content box, so the container padding does not shift the handle off the pointer
|
||||
const rect = el.getBoundingClientRect()
|
||||
const style = getComputedStyle(el)
|
||||
const left = rect.left + parseFloat(style.paddingLeft)
|
||||
const width = rect.width - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)
|
||||
if (width <= 0) return
|
||||
ratio.value = clamp((e.clientX - left) / width)
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
dragging.value = false
|
||||
document.body.classList.remove('split-pane--dragging')
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', stopDrag)
|
||||
window.removeEventListener('pointercancel', stopDrag)
|
||||
}
|
||||
|
||||
function onPointerdown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
dragging.value = true
|
||||
// Disables text selection and keeps the resize cursor while the pointer is outside the handle
|
||||
document.body.classList.add('split-pane--dragging')
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', stopDrag)
|
||||
window.addEventListener('pointercancel', stopDrag)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowLeft') ratio.value = clamp(ratio.value - step)
|
||||
else if (e.key === 'ArrowRight') ratio.value = clamp(ratio.value + step)
|
||||
else return
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
ratio.value = initial
|
||||
}
|
||||
|
||||
onScopeDispose(stopDrag)
|
||||
|
||||
const handleProps = computed(() => ({
|
||||
dragging: dragging.value,
|
||||
'aria-valuenow': Math.round(clamp(ratio.value) * 100),
|
||||
'aria-valuemin': Math.round(min * 100),
|
||||
'aria-valuemax': Math.round(max * 100),
|
||||
onPointerdown,
|
||||
onKeydown,
|
||||
onDblclick: reset,
|
||||
}))
|
||||
|
||||
return { ratio, gridTemplate, dragging, handleProps, reset }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { watch } from 'vue'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { THEME_MODES, THEME_STORAGE_KEY, type ThemeMode } from '@/lib/theme.ts'
|
||||
|
||||
/** Persisted light/dark/system theme mode, cycled in that order on each call to `cycleThemeMode`. */
|
||||
export function useThemeMode() {
|
||||
const theme = useTheme()
|
||||
const mode = useStorage<ThemeMode>(THEME_STORAGE_KEY, 'system')
|
||||
|
||||
watch(mode, (value) => theme.change(value), { immediate: true })
|
||||
|
||||
function cycleThemeMode(e?: MouseEvent) {
|
||||
if (e) theme.setTransitionOrigin(e.target as HTMLElement)
|
||||
const nextIndex = (THEME_MODES.indexOf(mode.value) + 1) % THEME_MODES.length
|
||||
mode.value = THEME_MODES[nextIndex]
|
||||
}
|
||||
|
||||
return { mode, cycleThemeMode }
|
||||
}
|
||||
@@ -1,41 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { authClient } from '@/lib/auth-client.ts'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { computed } from 'vue'
|
||||
import AppBreadcrumbs from '@/components/AppBreadcrumbs.vue'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import { useThemeMode } from '@/composables/useThemeMode.ts'
|
||||
import { THEME_ICONS } from '@/lib/theme.ts'
|
||||
import NavigationMenu from '@/components/NavigationMenu.vue'
|
||||
import { usePushNotifications } from '@/composables/usePushNotifications.ts'
|
||||
import ProjectMenu from '@/components/ProjectMenu.vue'
|
||||
import NotificationsList from '@/components/NotificationsList.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const session = authClient.useSession()
|
||||
const theme = useTheme()
|
||||
const { mode: themeMode, cycleThemeMode } = useThemeMode()
|
||||
const push = usePushNotifications()
|
||||
|
||||
const title = 'APP'
|
||||
const version = '0.0.1'
|
||||
const title = 'Ulitka'
|
||||
|
||||
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 },
|
||||
},
|
||||
])
|
||||
/** Shown in the avatar until the user uploads a picture */
|
||||
const initial = computed(() => {
|
||||
const user = session.value.data?.user
|
||||
return (user?.name.trim() || user?.email || '?').charAt(0).toUpperCase()
|
||||
})
|
||||
|
||||
function goToSignIn() {
|
||||
router.push({ path: '/sign-in' })
|
||||
@@ -50,6 +40,8 @@ function goToProfile() {
|
||||
}
|
||||
|
||||
async function goToSignOut() {
|
||||
// Пока сессия жива — иначе бэкенд не даст удалить токен и пуши продолжат приходить в этот браузер
|
||||
await push.disable()
|
||||
await authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
@@ -59,94 +51,101 @@ async function goToSignOut() {
|
||||
})
|
||||
}
|
||||
|
||||
function toggleTheme(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
theme.setTransitionOrigin(target)
|
||||
theme.toggle()
|
||||
const goToSettings = () => {
|
||||
router.push({ path: '/settings' })
|
||||
}
|
||||
|
||||
const goToSettings = () => {
|
||||
router.push({ name: 'settings' })
|
||||
}
|
||||
const fluid = computed(() => {
|
||||
return route.meta.fluid !== undefined ? !!route.meta.fluid : true
|
||||
})
|
||||
</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-divider class="mx-1" vertical />
|
||||
<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 }}
|
||||
<v-app-bar density="compact" elevation="0" border rounded="0">
|
||||
<v-app-bar-title class="d-flex">
|
||||
<router-link class="text-decoration-none text-link font-weight-bold d-flex align-baseline ga-2" to="/">
|
||||
<v-icon icon="mdi-snail" />
|
||||
<div>{{ title }}</div>
|
||||
</router-link>
|
||||
<span>v{{ version }}</span>
|
||||
<v-divider class="mx-2" vertical />
|
||||
|
||||
<project-menu />
|
||||
</div>
|
||||
</v-app-bar-title>
|
||||
|
||||
<div class="d-flex ga-4 align-center" v-if="session.data">
|
||||
<div class="mr-2">
|
||||
Hello,
|
||||
<router-link class="text-decoration-none text-link text-capitalize" to="/profile">
|
||||
{{ session?.data.user.name }}
|
||||
</router-link>
|
||||
<div v-if="!session.data">
|
||||
<v-btn @click="goToSignIn">Sign In</v-btn>
|
||||
<v-btn @click="goToSignUp">Sign Up</v-btn>
|
||||
</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 />
|
||||
<div v-if="session.data" class="d-flex align-center">
|
||||
<project-menu />
|
||||
|
||||
<v-menu v-if="session.data">
|
||||
<notifications-list />
|
||||
|
||||
<v-btn rounded="0" :icon="THEME_ICONS[themeMode]" :title="`Theme: ${themeMode}`" @click="cycleThemeMode" />
|
||||
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn icon v-bind="props">
|
||||
<v-btn rounded="0" icon v-bind="props" class="mr-4">
|
||||
<v-icon icon="mdi-account" />
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list class="mt-1" width="200">
|
||||
<v-list-item @click="goToProfile">
|
||||
<v-list nav density="compact" class="pa-1">
|
||||
<v-list-item density="compact">
|
||||
<v-sheet border class="d-flex align-center ga-2 pa-2">
|
||||
<!-- No slot content: with a default slot v-avatar ignores image; text is used only without image -->
|
||||
<v-avatar rounded="circle" border :image="session.data.user.image ?? undefined" :text="initial" />
|
||||
<div>
|
||||
<div>{{ session.data.user.name }}</div>
|
||||
<div class="text-label-medium">{{ session.data.user.email }}</div>
|
||||
</div>
|
||||
</v-sheet>
|
||||
</v-list-item>
|
||||
<v-list-item rounded="0" density="compact" @click="goToProfile">
|
||||
<v-icon icon="mdi-account" />
|
||||
Profile
|
||||
</v-list-item>
|
||||
<v-list-item @click="goToSettings">
|
||||
<v-list-item rounded="0" density="compact" @click="goToSettings">
|
||||
<v-icon icon="mdi-cog" />
|
||||
settings
|
||||
</v-list-item>
|
||||
<v-list-item @click="goToSignOut">
|
||||
<v-divider class="my-1" />
|
||||
<v-list-item rounded="0" density="compact" @click="goToSignOut">
|
||||
<v-icon icon="mdi-logout" />
|
||||
Sign Out
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</v-app-bar>
|
||||
|
||||
<navigation-menu :is-auth="isAuth" />
|
||||
|
||||
<v-main>
|
||||
<v-container :fluid="false">
|
||||
<slot></slot>
|
||||
<v-container :fluid="fluid" class="layout-container">
|
||||
<div class="nav-row d-flex align-center ga-2 mb-2">
|
||||
<back-button />
|
||||
<app-breadcrumbs />
|
||||
</div>
|
||||
<div class="layout-content">
|
||||
<slot />
|
||||
</div>
|
||||
</v-container>
|
||||
</v-main>
|
||||
</v-layout>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
<style scoped lang="scss">
|
||||
.nav-row:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.layout-container {
|
||||
height: calc(100dvh - var(--v-layout-top) - var(--v-layout-bottom));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
+27
-3
@@ -1,19 +1,40 @@
|
||||
import router from '@/router'
|
||||
import { authClient } from '@/lib/auth-client.ts'
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
data: T | null
|
||||
code: number
|
||||
message?: string | string[]
|
||||
}
|
||||
|
||||
async function redirectToSignIn() {
|
||||
// Refetch so useSession() consumers drop the stale user
|
||||
authClient.$store.notify('$sessionSignal')
|
||||
|
||||
const route = router.currentRoute.value
|
||||
if (route.name === 'sign-in') return
|
||||
await router.push({ name: 'sign-in', query: { redirect: route.fullPath } })
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
// FormData needs the browser to set multipart Content-Type with its boundary
|
||||
const isForm = init?.body instanceof FormData
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
|
||||
headers: { ...(isForm ? {} : { 'Content-Type': 'application/json' }), ...(init?.headers ?? {}) },
|
||||
})
|
||||
|
||||
if (res.status === 204) return undefined as T
|
||||
|
||||
const body = (await res.json()) as ApiEnvelope<T>
|
||||
// Session expired or was revoked from another device
|
||||
if (res.status === 401) {
|
||||
await redirectToSignIn()
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
// A proxy (e.g. nginx 413) may answer with a non-JSON body
|
||||
const body = (await res.json().catch(() => ({}))) as Partial<ApiEnvelope<T>>
|
||||
|
||||
if (!res.ok) {
|
||||
const message = Array.isArray(body.message) ? body.message.join(', ') : body.message
|
||||
@@ -36,5 +57,8 @@ 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' }),
|
||||
put: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
upload: <T>(path: string, form: FormData) => request<T>(path, { method: 'POST', body: form }),
|
||||
delete: (path: string, body?: unknown) =>
|
||||
request<void>(path, { method: 'DELETE', body: body === undefined ? undefined : JSON.stringify(body) }),
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createAuthClient } from 'better-auth/vue'
|
||||
import { emailOTPClient } from 'better-auth/client/plugins'
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: window.location.origin,
|
||||
plugins: [],
|
||||
plugins: [emailOTPClient()],
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { initializeApp } from 'firebase/app'
|
||||
import {
|
||||
getMessaging,
|
||||
register,
|
||||
unregister,
|
||||
onRegistered,
|
||||
onUnregistered,
|
||||
onMessage,
|
||||
isSupported,
|
||||
type MessagePayload,
|
||||
type Unsubscribe,
|
||||
} from 'firebase/messaging'
|
||||
|
||||
export type PushOptions = {
|
||||
// Вызывается при каждой (пере)регистрации: первый register(), смена FID,
|
||||
// событие pushsubscriptionchange. Здесь отправляйте FID на бэкенд.
|
||||
onInstallationId: (installationId: string) => void
|
||||
// FID больше не активен (unregister) — его нужно удалить на бэкенде
|
||||
onInstallationIdRemoved?: (installationId: string) => void
|
||||
// Сообщения, пришедшие, пока вкладка открыта
|
||||
onForeground?: (payload: MessagePayload) => void
|
||||
}
|
||||
|
||||
const VAPID_KEY = 'BPr-D-K2uyc9GDNaIBg1JpnjWlNPw-btaKXowDWgvIUZ14l8IBk421x9iJDlGBB_rFSfsK6HCOOfvW0-l88yGPg'
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: 'AIzaSyAKY4nPht7-YsIQfhcECxVwZFx7iAnCQSM',
|
||||
authDomain: 'ulitka-c811b.firebaseapp.com',
|
||||
projectId: 'ulitka-c811b',
|
||||
storageBucket: 'ulitka-c811b.firebasestorage.app',
|
||||
messagingSenderId: '82243223520',
|
||||
appId: '1:82243223520:web:2c0e68358f8d5f2d2a7585',
|
||||
measurementId: 'G-CMDB21T1WG',
|
||||
}
|
||||
|
||||
const app = initializeApp(firebaseConfig)
|
||||
|
||||
let started = false
|
||||
let unsubscribers: Unsubscribe[] = []
|
||||
|
||||
export async function enablePush(options: PushOptions): Promise<NotificationPermission | 'unsupported'> {
|
||||
if (!(await isSupported())) return 'unsupported'
|
||||
|
||||
const result = await Notification.requestPermission()
|
||||
if (result === 'granted') await startMessaging(options)
|
||||
return result
|
||||
}
|
||||
|
||||
export async function autoRequestPush(options: PushOptions): Promise<void> {
|
||||
if (!(await isSupported())) return
|
||||
if (Notification.permission === 'granted') return startMessaging(options)
|
||||
}
|
||||
|
||||
// Отписывает браузер от FCM. Колбэк onInstallationIdRemoved сработает через onUnregistered.
|
||||
export async function disablePush(): Promise<void> {
|
||||
if (!started) return
|
||||
await unregister(getMessaging(app))
|
||||
unsubscribers.forEach((unsubscribe) => unsubscribe())
|
||||
unsubscribers = []
|
||||
started = false
|
||||
}
|
||||
|
||||
async function startMessaging({ onInstallationId, onInstallationIdRemoved, onForeground }: PushOptions): Promise<void> {
|
||||
if (started) return
|
||||
started = true
|
||||
|
||||
try {
|
||||
const messaging = getMessaging(app)
|
||||
unsubscribers = [
|
||||
onRegistered(messaging, onInstallationId),
|
||||
onUnregistered(messaging, (fid) => onInstallationIdRemoved?.(fid)),
|
||||
onMessage(messaging, (payload) => onForeground?.(payload)),
|
||||
]
|
||||
await register(messaging, { vapidKey: VAPID_KEY })
|
||||
} catch (err) {
|
||||
unsubscribers.forEach((unsubscribe) => unsubscribe())
|
||||
unsubscribers = []
|
||||
started = false
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Shrinks an image so its longest side is at most `maxSize` px before upload.
|
||||
* GIFs are kept as is (canvas would drop the animation), as are images the
|
||||
* browser cannot decode — the server decides what to do with those.
|
||||
*/
|
||||
export async function resizeImage(file: File, maxSize: number): Promise<File> {
|
||||
if (file.type === 'image/gif') return file
|
||||
|
||||
let bitmap: ImageBitmap
|
||||
try {
|
||||
bitmap = await createImageBitmap(file)
|
||||
} catch {
|
||||
return file
|
||||
}
|
||||
|
||||
const scale = Math.min(1, maxSize / Math.max(bitmap.width, bitmap.height))
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.round(bitmap.width * scale)
|
||||
canvas.height = Math.round(bitmap.height * scale)
|
||||
canvas.getContext('2d')?.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
|
||||
bitmap.close()
|
||||
|
||||
// Browsers without WebP encoding (Safari) fall back to PNG
|
||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/webp', 0.9))
|
||||
if (!blob || blob.size >= file.size) return file
|
||||
|
||||
const ext = blob.type === 'image/webp' ? 'webp' : 'png'
|
||||
return new File([blob], `${file.name.replace(/\.[^.]+$/, '')}.${ext}`, { type: blob.type })
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { LocationQueryValue } from 'vue-router'
|
||||
|
||||
/** Path to return to after sign-in; only same-origin paths are accepted to avoid open redirects */
|
||||
export function redirectTarget(redirect: LocationQueryValue | LocationQueryValue[] | undefined): string {
|
||||
const value = Array.isArray(redirect) ? redirect[0] : redirect
|
||||
return value && value.startsWith('/') && !value.startsWith('//') ? value : '/'
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export type ThemeMode = 'light' | 'dark' | 'system'
|
||||
|
||||
export const THEME_MODES: ThemeMode[] = ['light', 'dark', 'system']
|
||||
export const THEME_STORAGE_KEY = 'theme-mode'
|
||||
|
||||
export const THEME_ICONS: Record<ThemeMode, string> = {
|
||||
light: 'mdi-white-balance-sunny',
|
||||
dark: 'mdi-weather-night',
|
||||
system: 'mdi-theme-light-dark',
|
||||
}
|
||||
|
||||
export function isThemeMode(value: unknown): value is ThemeMode {
|
||||
return THEME_MODES.includes(value as ThemeMode)
|
||||
}
|
||||
|
||||
/** Read the persisted theme mode synchronously, before Vuetify is created, to avoid a flash of the wrong theme. */
|
||||
export function readStoredThemeMode(): ThemeMode {
|
||||
if (typeof localStorage === 'undefined') return 'system'
|
||||
const stored = localStorage.getItem(THEME_STORAGE_KEY)
|
||||
return isThemeMode(stored) ? stored : 'system'
|
||||
}
|
||||
+5
-1
@@ -14,6 +14,7 @@ import * as components from 'vuetify/components'
|
||||
import { VAvatarGroup } from 'vuetify/labs/VAvatarGroup'
|
||||
import * as directives from 'vuetify/directives'
|
||||
import { registerLayouts } from '@/layouts/register.ts'
|
||||
import { readStoredThemeMode } from '@/lib/theme.ts'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
@@ -24,7 +25,7 @@ const vuetify = createVuetify({
|
||||
components: { ...components, VAvatarGroup },
|
||||
directives,
|
||||
theme: {
|
||||
defaultTheme: 'dark', // 'system' | 'light' | 'dark'
|
||||
defaultTheme: readStoredThemeMode(),
|
||||
themes: {
|
||||
dark: {
|
||||
colors: {
|
||||
@@ -74,6 +75,9 @@ const vuetify = createVuetify({
|
||||
density: 'compact',
|
||||
hideDetails: true,
|
||||
},
|
||||
VCard: {
|
||||
elevation: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import CalendarView from '@/views/calendar/CalendarView.vue'
|
||||
|
||||
export const calendarRoutes: RouteRecordRaw = {
|
||||
path: '/calendar',
|
||||
component: CalendarView,
|
||||
meta: { requiresAuth: true },
|
||||
}
|
||||
+13
-8
@@ -1,19 +1,23 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createRouter, createWebHistory, type RouteLocationNormalizedLoaded } 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'
|
||||
import { calendarRoutes } from '@/router/calendar.ts'
|
||||
import SignInView from '@/views/users/SignInView.vue'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
layout?: 'Blank' | 'Default'
|
||||
/** Label shown in the breadcrumbs trail for this route segment. */
|
||||
breadcrumb?: string | ((route: RouteLocationNormalizedLoaded) => string)
|
||||
/** Named route the back button should navigate to, when deep-linking means history may be empty. */
|
||||
backTo?: string
|
||||
fluid?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,16 +27,18 @@ const router = createRouter({
|
||||
{
|
||||
path: '/',
|
||||
component: HomeView,
|
||||
meta: { layout: 'Default' },
|
||||
meta: { layout: 'Default', fluid: true },
|
||||
},
|
||||
{
|
||||
name: 'sign-in',
|
||||
path: '/sign-in',
|
||||
component: SignInView,
|
||||
meta: { breadcrumb: 'Sign-In' },
|
||||
},
|
||||
{
|
||||
path: '/sign-up',
|
||||
component: SignUpView,
|
||||
meta: { breadcrumb: 'Sign-up' },
|
||||
},
|
||||
{
|
||||
path: '/verify-email',
|
||||
@@ -41,11 +47,10 @@ const router = createRouter({
|
||||
{
|
||||
path: '/profile',
|
||||
component: ProfileView,
|
||||
meta: { requiresAuth: true },
|
||||
meta: { requiresAuth: true, breadcrumb: 'Profile' },
|
||||
},
|
||||
issuesRoutes,
|
||||
summaryRoutes,
|
||||
reportsRoutes,
|
||||
calendarRoutes,
|
||||
gamesRoutes,
|
||||
settingsRoutes,
|
||||
],
|
||||
|
||||
@@ -8,12 +8,20 @@ export const issuesRoutes: RouteRecordRaw = {
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'issues',
|
||||
component: IssuesView,
|
||||
meta: { breadcrumb: 'Issues' },
|
||||
},
|
||||
{
|
||||
path: ':ckey',
|
||||
name: 'issue-detail',
|
||||
component: IssueView,
|
||||
props: true,
|
||||
meta: {
|
||||
breadcrumb: (route) => route.params.ckey as string,
|
||||
backTo: 'issues',
|
||||
fluid: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
+10
-1
@@ -2,22 +2,31 @@ 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'
|
||||
import TagsSettingsView from '@/views/settings/TagsSettingsView.vue'
|
||||
|
||||
export const settingsRoutes: RouteRecordRaw = {
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: SettingsView,
|
||||
meta: { requiresAuth: true },
|
||||
meta: { requiresAuth: true, breadcrumb: 'Settings' },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'general-settings',
|
||||
component: GeneralSettingsView,
|
||||
meta: { breadcrumb: 'General' },
|
||||
},
|
||||
{
|
||||
path: 'projects',
|
||||
name: 'settings-projects',
|
||||
component: ProjectsListView,
|
||||
meta: { breadcrumb: 'Projects' },
|
||||
},
|
||||
{
|
||||
path: 'tags',
|
||||
name: 'settings-tags',
|
||||
component: TagsSettingsView,
|
||||
meta: { breadcrumb: 'Tags' },
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
type Comment,
|
||||
type CommentEventType,
|
||||
type CreateCommentInput,
|
||||
itemCreate,
|
||||
itemDelete,
|
||||
itemsList,
|
||||
itemUpdate,
|
||||
type UpdateCommentInput,
|
||||
} from '@/api/comment.api.ts'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
/**
|
||||
* Comments of the currently opened issue, oldest first. Loaded chat-style:
|
||||
* the latest page first, older pages are prepended on demand.
|
||||
*/
|
||||
export const useCommentStore = defineStore('comment', () => {
|
||||
const issueId = ref<number | null>(null)
|
||||
const comments = ref<Comment[]>([])
|
||||
/** id of the oldest loaded comment while older ones exist on the server */
|
||||
const olderCursor = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadingOlder = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/** Inserts or replaces a comment by id. New comments are always the newest — append. */
|
||||
function upsert(comment: Comment) {
|
||||
const index = comments.value.findIndex((c) => c.id === comment.id)
|
||||
if (index !== -1) comments.value[index] = comment
|
||||
else comments.value.push(comment)
|
||||
}
|
||||
|
||||
async function load(id: number) {
|
||||
issueId.value = id
|
||||
comments.value = []
|
||||
olderCursor.value = null
|
||||
loadingOlder.value = false
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await itemsList(id, { limit: PAGE_SIZE, direction: 'backward' })
|
||||
// The issue may have changed while the request was in flight
|
||||
if (issueId.value !== id) return
|
||||
comments.value = res.list
|
||||
olderCursor.value = res.nextCursor
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'error get list of comments'
|
||||
} finally {
|
||||
if (issueId.value === id) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOlder() {
|
||||
const id = issueId.value
|
||||
if (id === null || !olderCursor.value || loading.value || loadingOlder.value) return
|
||||
loadingOlder.value = true
|
||||
try {
|
||||
const res = await itemsList(id, { limit: PAGE_SIZE, cursor: olderCursor.value, direction: 'backward' })
|
||||
if (issueId.value !== id) return
|
||||
const known = new Set(comments.value.map((c) => c.id))
|
||||
comments.value.unshift(...res.list.filter((c) => !known.has(c.id)))
|
||||
olderCursor.value = res.nextCursor
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'error get list of comments'
|
||||
} finally {
|
||||
if (issueId.value === id) loadingOlder.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches comments newer than the last loaded one — e.g. after an SSE reconnect. */
|
||||
async function loadNewer() {
|
||||
const id = issueId.value
|
||||
if (id === null || loading.value) return
|
||||
let cursor = comments.value.at(-1)?.id
|
||||
if (!cursor) return load(id)
|
||||
try {
|
||||
do {
|
||||
const res = await itemsList(id, { limit: PAGE_SIZE, cursor, direction: 'forward' })
|
||||
if (issueId.value !== id) return
|
||||
res.list.forEach(upsert)
|
||||
cursor = res.nextCursor ?? undefined
|
||||
} while (cursor)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'error get list of comments'
|
||||
}
|
||||
}
|
||||
|
||||
async function create(input: CreateCommentInput) {
|
||||
if (issueId.value === null) return
|
||||
try {
|
||||
const comment = await itemCreate(issueId.value, input)
|
||||
// The SSE event for the same comment may come before or after — upsert dedupes
|
||||
if (comment.issueId === issueId.value) upsert(comment)
|
||||
return comment
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'error creating comment'
|
||||
}
|
||||
}
|
||||
|
||||
async function update(id: string, input: UpdateCommentInput) {
|
||||
try {
|
||||
const comment = await itemUpdate(id, input)
|
||||
if (comment.issueId === issueId.value) upsert(comment)
|
||||
return comment
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'error updating comment'
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
try {
|
||||
return await itemDelete(id)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'error deleting comment'
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies a server-sent event. Deleted comments stay in the list as placeholders. */
|
||||
function applyEvent(type: CommentEventType, comment: Comment) {
|
||||
if (comment.issueId !== issueId.value) return
|
||||
// Updates of comments older than the loaded window are irrelevant
|
||||
if (type !== 'comment.created' && !comments.value.some((c) => c.id === comment.id)) return
|
||||
upsert(comment)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
issueId.value = null
|
||||
comments.value = []
|
||||
olderCursor.value = null
|
||||
loading.value = false
|
||||
loadingOlder.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
issueId,
|
||||
comments,
|
||||
olderCursor,
|
||||
loading,
|
||||
loadingOlder,
|
||||
error,
|
||||
load,
|
||||
loadOlder,
|
||||
loadNewer,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
applyEvent,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { type IssueType, issueTypesList } from '@/api/issue-type.api.ts'
|
||||
|
||||
export const useIssueTypeStore = defineStore('issueType', () => {
|
||||
const issueTypes = ref<IssueType[]>()
|
||||
|
||||
async function list() {
|
||||
issueTypes.value = await issueTypesList()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
issueTypes.value = undefined
|
||||
}
|
||||
|
||||
return { issueTypes, list, reset }
|
||||
})
|
||||
+54
-4
@@ -3,7 +3,10 @@ import { ref } from 'vue'
|
||||
import {
|
||||
type CreateIssueInput,
|
||||
type Issue,
|
||||
type DailyIssueStats,
|
||||
itemCreate,
|
||||
itemDailyStat,
|
||||
itemsCalendar,
|
||||
itemDelete,
|
||||
itemGetByKey,
|
||||
itemsList,
|
||||
@@ -17,6 +20,7 @@ import {
|
||||
export const useIssueStore = defineStore('item', () => {
|
||||
const issues = ref<Issue[]>([])
|
||||
const stats = ref<IssueStat>()
|
||||
const dailyStats = ref<DailyIssueStats>()
|
||||
const error = ref<string | null>(null)
|
||||
const lastMeta = ref<Meta>({
|
||||
page: 1,
|
||||
@@ -28,9 +32,10 @@ export const useIssueStore = defineStore('item', () => {
|
||||
page: number
|
||||
limit: number
|
||||
text?: string | null
|
||||
status?: string | null
|
||||
status?: string | string[] | null
|
||||
assigneeId?: string | null
|
||||
projectId?: string | null
|
||||
hasDueDate?: boolean
|
||||
}) {
|
||||
try {
|
||||
const res = await itemsList(params)
|
||||
@@ -42,6 +47,14 @@ export const useIssueStore = defineStore('item', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function calendar(params: { from: string; to: string; projectId?: string | null }) {
|
||||
try {
|
||||
return await itemsCalendar(params)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'error get calendar issues'
|
||||
}
|
||||
}
|
||||
|
||||
async function getByKey(ckey: string) {
|
||||
try {
|
||||
return await itemGetByKey(ckey)
|
||||
@@ -75,9 +88,9 @@ export const useIssueStore = defineStore('item', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function getStat(): Promise<IssueStat | undefined> {
|
||||
async function getStat(projectId?: string | null): Promise<IssueStat | undefined> {
|
||||
try {
|
||||
stats.value = await itemStat()
|
||||
stats.value = await itemStat(projectId ?? undefined)
|
||||
|
||||
return stats.value
|
||||
} catch (e) {
|
||||
@@ -85,5 +98,42 @@ export const useIssueStore = defineStore('item', () => {
|
||||
}
|
||||
}
|
||||
|
||||
return { stats, list, getByKey, create, update, remove, getStat }
|
||||
let dailyStatsRequestId = 0
|
||||
|
||||
/** Stats for the last `days` days including today, in the user's local time zone */
|
||||
async function getDailyStat(projectId?: string | null, days = 30): Promise<DailyIssueStats | undefined> {
|
||||
// Ignore responses that arrive after the project or period has changed again
|
||||
const requestId = ++dailyStatsRequestId
|
||||
try {
|
||||
const res = await itemDailyStat({
|
||||
projectId: projectId ?? undefined,
|
||||
from: localDateDaysAgo(days - 1),
|
||||
// Day boundaries are computed on the backend in the user's local time zone; `to` defaults to today there
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
})
|
||||
if (requestId !== dailyStatsRequestId) return
|
||||
dailyStats.value = res
|
||||
|
||||
return dailyStats.value
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'error getting daily issue stat'
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
issues.value = []
|
||||
stats.value = undefined
|
||||
dailyStats.value = undefined
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return { stats, dailyStats, error, list, calendar, getByKey, create, update, remove, getStat, getDailyStat, reset }
|
||||
})
|
||||
|
||||
/** Local YYYY-MM-DD `days` days before today */
|
||||
function localDateDaysAgo(days: number): string {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - days)
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
@@ -96,6 +96,13 @@ export const useProjectStore = defineStore('project', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears loaded data on sign-out; the stored project id is kept and re-validated on next sign-in */
|
||||
function reset() {
|
||||
projects.value = []
|
||||
current.value = null
|
||||
error.value = null
|
||||
}
|
||||
|
||||
async function restoreCurrent() {
|
||||
const id = readStoredProjectId()
|
||||
if (!id) return
|
||||
@@ -118,5 +125,6 @@ export const useProjectStore = defineStore('project', () => {
|
||||
clearError,
|
||||
select,
|
||||
restoreCurrent,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Snackbar {
|
||||
title: string
|
||||
text: string
|
||||
color?: string
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export const useSnackbarStore = defineStore('snackbar', () => {
|
||||
const snackbarQueue = ref<Snackbar[]>([])
|
||||
|
||||
function add(snackbar: Snackbar) {
|
||||
snackbarQueue.value.push(snackbar)
|
||||
}
|
||||
|
||||
return {
|
||||
snackbarQueue,
|
||||
add,
|
||||
}
|
||||
})
|
||||
@@ -9,5 +9,9 @@ export const useStatusStore = defineStore('counter', () => {
|
||||
statuses.value = await statusesList()
|
||||
}
|
||||
|
||||
return { statuses, list }
|
||||
function reset() {
|
||||
statuses.value = undefined
|
||||
}
|
||||
|
||||
return { statuses, list, reset }
|
||||
})
|
||||
+259
-3
@@ -1,7 +1,263 @@
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useIssueStore } from '@/stores/issue.ts'
|
||||
import { useProjectStore } from '@/stores/project.ts'
|
||||
import { useStatusStore } from '@/stores/status.ts'
|
||||
import { authClient } from '@/lib/auth-client.ts'
|
||||
import type { Issue } from '@/api/issue.api.ts'
|
||||
import type { Project } from '@/api/project.api.ts'
|
||||
import DashboardCountCard from '@/components/DashboardCountCard.vue'
|
||||
import LabelText from '@/components/LabelText.vue'
|
||||
import IssueStatusChart from '@/components/IssueStatusChart.vue'
|
||||
import IssueActivityChart from '@/components/IssueActivityChart.vue'
|
||||
|
||||
const ACTIVE_STATUSES: Issue['status'][] = ['OPEN', 'IN_PROGRESS']
|
||||
|
||||
const ACTIVITY_PERIODS = [7, 30, 90]
|
||||
|
||||
const PRIORITY_ICONS: Record<number, string> = {
|
||||
4: 'mdi-chevron-double-up',
|
||||
3: 'mdi-chevron-up',
|
||||
2: 'mdi-equal',
|
||||
1: 'mdi-chevron-down',
|
||||
0: 'mdi-chevron-double-down',
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const session = authClient.useSession()
|
||||
|
||||
const issueStore = useIssueStore()
|
||||
const projectStore = useProjectStore()
|
||||
const statusStore = useStatusStore()
|
||||
|
||||
const { stats, dailyStats } = storeToRefs(issueStore)
|
||||
const { projects, current, currentProjectId } = storeToRefs(projectStore)
|
||||
|
||||
const currentUserId = computed(() => session.value.data?.user.id ?? null)
|
||||
|
||||
const myIssues = ref<Issue[]>([])
|
||||
const loadingMyIssues = ref(false)
|
||||
let myIssuesRequestId = 0
|
||||
|
||||
async function loadMyIssues(userId: string, projectId: string | null) {
|
||||
// Ignore responses that arrive after the user or project has changed again
|
||||
const requestId = ++myIssuesRequestId
|
||||
loadingMyIssues.value = true
|
||||
try {
|
||||
const res = await issueStore.list({
|
||||
page: 1,
|
||||
limit: 7,
|
||||
assigneeId: userId,
|
||||
projectId: projectId,
|
||||
status: ACTIVE_STATUSES,
|
||||
})
|
||||
if (requestId !== myIssuesRequestId) return
|
||||
myIssues.value = res.list
|
||||
} finally {
|
||||
if (requestId === myIssuesRequestId) loadingMyIssues.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[currentUserId, currentProjectId],
|
||||
([userId, projectId]) => {
|
||||
if (userId) void loadMyIssues(userId, projectId)
|
||||
else myIssues.value = []
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const myIssuesTitle = computed(() => (current.value ? `My issues · ${current.value.key}` : 'My issues'))
|
||||
|
||||
const myIssuesLink = computed(() => ({
|
||||
path: '/issues',
|
||||
query: currentUserId.value ? { assignee: currentUserId.value } : {},
|
||||
}))
|
||||
|
||||
watch(
|
||||
currentProjectId,
|
||||
(id) => {
|
||||
void issueStore.getStat(id)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const activityDays = ref(30)
|
||||
|
||||
watch(
|
||||
[currentProjectId, activityDays],
|
||||
([id, days]) => {
|
||||
void issueStore.getDailyStat(id, days)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function isOverdue(issue: Issue) {
|
||||
return !!issue.dueDate && new Date(issue.dueDate).getTime() < Date.now()
|
||||
}
|
||||
|
||||
const overdueCount = computed(() => myIssues.value.filter(isOverdue).length)
|
||||
|
||||
const sortedMyIssues = computed(() =>
|
||||
[...myIssues.value].sort((a, b) => {
|
||||
if (!a.dueDate && !b.dueDate) return 0
|
||||
if (!a.dueDate) return 1
|
||||
if (!b.dueDate) return -1
|
||||
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime()
|
||||
}),
|
||||
)
|
||||
|
||||
function statCount(status: Issue['status']) {
|
||||
return stats.value?.byStatus.find((el) => el.status === status)?.count ?? 0
|
||||
}
|
||||
|
||||
function statusMeta(status: string) {
|
||||
const found = statusStore.statuses?.find((el) => el.alias === status)
|
||||
return { label: found?.name ?? status, color: found?.color ?? 'grey' }
|
||||
}
|
||||
|
||||
function priorityIcon(priority: number) {
|
||||
return PRIORITY_ICONS[priority] ?? PRIORITY_ICONS[2]
|
||||
}
|
||||
|
||||
function priorityColor(priority: number) {
|
||||
if (priority >= 3) return 'error'
|
||||
if (priority === 2) return 'success'
|
||||
return 'primary'
|
||||
}
|
||||
|
||||
function goToIssue(issue: Issue) {
|
||||
router.push(`/issues/${issue.key}`)
|
||||
}
|
||||
|
||||
function openProject(project: Project) {
|
||||
projectStore.select(project)
|
||||
router.push('/issues')
|
||||
}
|
||||
|
||||
function onClickCard(status?: string) {
|
||||
router.push({ path: '/issues', query: { status } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>Home</div>
|
||||
<div>
|
||||
<div v-if="session.data" class="d-flex flex-column ga-2">
|
||||
<div class="dashboard_grid">
|
||||
<dashboard-count-card label="Total issues" :value="stats?.total ?? 0" @click="onClickCard()" />
|
||||
<dashboard-count-card label="Open" :value="statCount('OPEN')" @click="onClickCard('OPEN')" />
|
||||
<dashboard-count-card
|
||||
label="In Progress"
|
||||
:value="statCount('IN_PROGRESS')"
|
||||
@click="onClickCard('IN_PROGRESS')"
|
||||
/>
|
||||
<dashboard-count-card label="Overdue" :value="overdueCount" :alert="overdueCount > 0" />
|
||||
</div>
|
||||
|
||||
<v-row density="compact">
|
||||
<v-col cols="12" md="6">
|
||||
<v-card border>
|
||||
<v-card-title>Issues by status</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text>
|
||||
<issue-status-chart :stats="stats" @select="onClickCard" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-card border>
|
||||
<v-card-title class="d-flex align-center justify-space-between">
|
||||
<span>Issues activity</span>
|
||||
<v-btn-toggle border density="comfortable" size="small" v-model="activityDays" mandatory>
|
||||
<v-btn border="0" v-for="days in ACTIVITY_PERIODS" :key="days" :value="days">{{ days }}d</v-btn>
|
||||
</v-btn-toggle>
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text>
|
||||
<issue-activity-chart :stats="dailyStats" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row density="compact">
|
||||
<v-col cols="12" md="8">
|
||||
<v-card border>
|
||||
<v-card-title class="d-flex align-center justify-space-between">
|
||||
<span>{{ myIssuesTitle }}</span>
|
||||
<v-btn size="small" variant="text" :to="myIssuesLink">View all mine</v-btn>
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-list v-if="sortedMyIssues.length" density="compact">
|
||||
<v-list-item
|
||||
v-for="issue in sortedMyIssues"
|
||||
:key="issue.id"
|
||||
class="cursor-pointer"
|
||||
@click="goToIssue(issue)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon :icon="priorityIcon(issue.priority)" :color="priorityColor(issue.priority)" size="small" />
|
||||
</template>
|
||||
|
||||
<template #title>
|
||||
<div class="d-flex ga-2 align-center">
|
||||
<span class="text-medium-emphasis">{{ issue.key }}</span>
|
||||
<span>{{ issue.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #append>
|
||||
<div class="d-flex ga-2 align-center">
|
||||
<v-chip
|
||||
v-if="issue.dueDate"
|
||||
density="compact"
|
||||
variant="flat"
|
||||
:color="isOverdue(issue) ? 'error' : undefined"
|
||||
>
|
||||
{{ new Date(issue.dueDate).toLocaleDateString('ru-RU') }}
|
||||
</v-chip>
|
||||
<v-chip density="compact" variant="flat" :color="statusMeta(issue.status).color">
|
||||
{{ statusMeta(issue.status).label }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-card-text v-else class="text-medium-emphasis">
|
||||
{{ loadingMyIssues ? 'Загрузка…' : 'Нет активных задач' }}
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="4">
|
||||
<v-card border>
|
||||
<v-card-title class="d-flex align-center justify-space-between">
|
||||
<span>Projects</span>
|
||||
<v-btn size="small" variant="text" to="/settings/projects">All projects</v-btn>
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-list v-if="projects.length" density="compact">
|
||||
<v-list-item v-for="project in projects.slice(0, 7)" :key="project.id" @click="openProject(project)">
|
||||
<label-text :label="project.key" :text="project.name" />
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-card-text v-else class="text-medium-emphasis">Нет проектов</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
<style scoped lang="scss">
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.dashboard_grid {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
</style>
|
||||
@@ -1,32 +1,20 @@
|
||||
<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>
|
||||
<div class="d-flex flex-column ga-2">
|
||||
<div class="text-uppercase text-title-large">Issue: {{ ckey }}</div>
|
||||
<v-sheet border class="h-100 d-flex flex-column">
|
||||
<issue-details :ckey="ckey" />
|
||||
</v-sheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useIssueStore } from '@/stores/issue'
|
||||
import { useProjectStore } from '@/stores/project.ts'
|
||||
@@ -10,6 +10,9 @@ import type { Issue } from '@/api/issue.api.ts'
|
||||
import IssuesListElement from '@/components/IssuesListElement.vue'
|
||||
import IssueStats from '@/components/IssueStats.vue'
|
||||
import IssueFilter from '@/components/IssueFilter.vue'
|
||||
import SplitHandle from '@/components/SplitHandle.vue'
|
||||
import { useSplitPane } from '@/composables/useSplitPane.ts'
|
||||
import IssueListOptions from '@/components/IssueListOptions.vue'
|
||||
|
||||
const issueStore = useIssueStore()
|
||||
const issueDetailsRef = ref<InstanceType<typeof IssueDetails>>()
|
||||
@@ -27,6 +30,7 @@ const serverItems = ref<Issue[]>([])
|
||||
|
||||
const text = ref<string | null>(null)
|
||||
const assigneeId = ref<string | null>(null)
|
||||
const status = ref<string[]>([])
|
||||
const selected = ref<[string] | []>([])
|
||||
const batch = ref<number[]>([])
|
||||
const deleting = ref(false)
|
||||
@@ -36,6 +40,7 @@ async function fetch() {
|
||||
page: page.value,
|
||||
limit: itemsPerPage.value,
|
||||
text: text.value,
|
||||
status: status.value,
|
||||
assigneeId: assigneeId.value,
|
||||
projectId: currentProjectId.value,
|
||||
})
|
||||
@@ -45,69 +50,98 @@ async function fetch() {
|
||||
totalItems.value = res.meta.totalCount
|
||||
}
|
||||
|
||||
async function load() {
|
||||
syncQuery()
|
||||
await fetch()
|
||||
}
|
||||
|
||||
// The URL is the source of truth: handlers only write the query, this watcher loads the list
|
||||
function syncQuery() {
|
||||
const query: Record<string, string> = { page: String(page.value) }
|
||||
|
||||
if (text.value) query.text = text.value
|
||||
if (assigneeId.value) query.assignee = assigneeId.value
|
||||
if (status.value.length) query.status = status.value.join(',')
|
||||
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
|
||||
assigneeId.value = query.assignee ? String(query.assignee) : null
|
||||
selected.value = query.selected_issue ? [String(query.selected_issue)] : []
|
||||
[
|
||||
() => route.query.page,
|
||||
() => route.query.text,
|
||||
() => route.query.assignee,
|
||||
() => route.query.status,
|
||||
currentProjectId,
|
||||
],
|
||||
async ([qPage, qText, qAssignee, qStatus, projectId], old) => {
|
||||
// The route changes before this view unmounts when navigating away
|
||||
if (route.name !== 'issues') return
|
||||
|
||||
page.value = qPage ? Number(qPage) : 1
|
||||
text.value = qText ? String(qText) : null
|
||||
assigneeId.value = qAssignee ? String(qAssignee) : null
|
||||
status.value = qStatus ? String(qStatus).split(',') : []
|
||||
|
||||
if (old && projectId !== old[4]) {
|
||||
clearBatch()
|
||||
close()
|
||||
// Resetting the page changes the query and re-runs this watcher
|
||||
if (page.value !== 1) {
|
||||
page.value = 1
|
||||
syncQuery()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await fetch()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.query.selected_issue,
|
||||
(key) => {
|
||||
if (route.name !== 'issues') return
|
||||
selected.value = key ? [String(key)] : []
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(selected, () => syncQuery())
|
||||
|
||||
watch(currentProjectId, async () => {
|
||||
page.value = 1
|
||||
watch(currentProjectId, (projectId) => issueStore.getStat(projectId), { immediate: true })
|
||||
|
||||
clearBatch()
|
||||
close()
|
||||
await load()
|
||||
})
|
||||
|
||||
onMounted(async () => await load())
|
||||
const gridTemplate = computed(() => (selected.value.length ? '1fr 1fr' : '1fr'))
|
||||
const splitContainer = useTemplateRef<HTMLElement>('splitContainer')
|
||||
const split = useSplitPane(splitContainer, { storageKey: 'issues-list', min: 0.25, max: 0.75 })
|
||||
const gridTemplate = computed(() => (selected.value.length ? split.gridTemplate.value : '1fr'))
|
||||
const goToIssue = (key: string[]) => router.push(`/issues/${key[0]}`)
|
||||
|
||||
async function updatePage(val: number) {
|
||||
function updatePage(val: number) {
|
||||
page.value = val
|
||||
await load()
|
||||
syncQuery()
|
||||
}
|
||||
|
||||
async function onTextChanged(value: string | null) {
|
||||
function onTextChanged(value: string | null) {
|
||||
text.value = value
|
||||
page.value = 1
|
||||
await load()
|
||||
syncQuery()
|
||||
}
|
||||
|
||||
async function onAssigneeChanged(value: string | null) {
|
||||
function onAssigneeChanged(value: string | null) {
|
||||
assigneeId.value = value
|
||||
page.value = 1
|
||||
await load()
|
||||
syncQuery()
|
||||
}
|
||||
|
||||
async function onClear() {
|
||||
function onStatusChanged(value: string[]) {
|
||||
status.value = value
|
||||
page.value = 1
|
||||
syncQuery()
|
||||
}
|
||||
|
||||
function onClear() {
|
||||
text.value = null
|
||||
assigneeId.value = null
|
||||
status.value = []
|
||||
page.value = 1
|
||||
|
||||
await load()
|
||||
syncQuery()
|
||||
}
|
||||
|
||||
function close() {
|
||||
@@ -168,17 +202,19 @@ async function onDeleteBatch() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="d-flex flex-column ga-2">
|
||||
<div class="u-grid ga-1">
|
||||
<div class="u-height">
|
||||
<div class="d-flex flex-column ga-2 h-100">
|
||||
<div class="text-uppercase text-title-large">Issues</div>
|
||||
<issue-filter
|
||||
class="mb-2"
|
||||
:text="text"
|
||||
:assignee-id="assigneeId"
|
||||
:status="status"
|
||||
@update:text="onTextChanged"
|
||||
@update:assignee-id="onAssigneeChanged"
|
||||
@update:status="onStatusChanged"
|
||||
@update:clear="onClear"
|
||||
/>
|
||||
<div ref="splitContainer" class="u-grid">
|
||||
<div class="u-height">
|
||||
<v-list class="pa-0" density="compact" v-model:selected="selected" mandatory>
|
||||
<v-list-item>
|
||||
<template #prepend>
|
||||
@@ -198,7 +234,10 @@ async function onDeleteBatch() {
|
||||
</template>
|
||||
|
||||
<template #append>
|
||||
<div class="d-flex ga-4 align-center">
|
||||
<issue-stats />
|
||||
<issue-list-options />
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
@@ -210,6 +249,7 @@ async function onDeleteBatch() {
|
||||
:issue="el"
|
||||
:selected="batch.includes(el.id)"
|
||||
@status-changed="onIssueUpdated"
|
||||
@type-changed="onIssueUpdated"
|
||||
@priority-changed="onIssueUpdated"
|
||||
@assignee-changed="onIssueUpdated"
|
||||
@toggle-select="toggleBatchSelect"
|
||||
@@ -254,7 +294,9 @@ async function onDeleteBatch() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-sheet border v-if="selected.length" class="overflow-y-auto">
|
||||
<split-handle v-if="selected.length" v-bind="split.handleProps.value" />
|
||||
|
||||
<v-sheet border v-if="selected.length" class="d-flex flex-column">
|
||||
<div class="d-flex align-center ga-2 px-4 py-2">
|
||||
<div>Work item</div>
|
||||
<v-spacer />
|
||||
@@ -266,7 +308,11 @@ async function onDeleteBatch() {
|
||||
ref="issueDetailsRef"
|
||||
:ckey="selected[0]"
|
||||
@status-changed="onIssueUpdated"
|
||||
@type-changed="onIssueUpdated"
|
||||
@assignee-changed="onIssueUpdated"
|
||||
@priority-changed="onIssueUpdated"
|
||||
@title-changed="onIssueUpdated"
|
||||
@due-date-changed="onIssueUpdated"
|
||||
/>
|
||||
</v-sheet>
|
||||
</div>
|
||||
@@ -277,9 +323,15 @@ async function onDeleteBatch() {
|
||||
.u-grid {
|
||||
display: grid;
|
||||
grid-template-columns: v-bind(gridTemplate);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.u-grid > * {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.u-height {
|
||||
height: calc(100vh - 6rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,447 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { useIssueStore } from '@/stores/issue.ts'
|
||||
import { useProjectStore } from '@/stores/project.ts'
|
||||
import { useStatusStore } from '@/stores/status.ts'
|
||||
import type { Issue } from '@/api/issue.api.ts'
|
||||
|
||||
interface CalendarEvent {
|
||||
name: string
|
||||
start: string
|
||||
color: string
|
||||
issue: Issue
|
||||
}
|
||||
|
||||
interface CalendarRange {
|
||||
start: { date: string }
|
||||
end: { date: string }
|
||||
}
|
||||
|
||||
const UNSCHEDULED_LIMIT = 20
|
||||
/** Closed issues are not worth scheduling, so the side list shows only these */
|
||||
const UNSCHEDULED_STATUSES: Issue['status'][] = ['OPEN', 'IN_PROGRESS']
|
||||
const DRAG_TYPE = 'application/x-ulitka-issue'
|
||||
|
||||
const issueStore = useIssueStore()
|
||||
const statusStore = useStatusStore()
|
||||
const { currentProjectId } = storeToRefs(useProjectStore())
|
||||
const router = useRouter()
|
||||
|
||||
const calendarRef = ref<{ prev: () => void; next: () => void }>()
|
||||
const focus = ref(toLocalDate(new Date()))
|
||||
const range = ref<CalendarRange>()
|
||||
|
||||
const scheduled = ref<Issue[]>([])
|
||||
const truncated = ref(false)
|
||||
const loadingScheduled = ref(false)
|
||||
|
||||
const unscheduled = ref<Issue[]>([])
|
||||
const unscheduledPage = ref(1)
|
||||
const unscheduledTotalPages = ref(0)
|
||||
const unscheduledTotal = ref(0)
|
||||
const loadingUnscheduled = ref(false)
|
||||
const search = ref<string | null>(null)
|
||||
|
||||
/** Day picked on the calendar — target for the "+" buttons in the unscheduled list */
|
||||
const selectedDate = ref<string | null>(null)
|
||||
const dragOverDate = ref<string | null>(null)
|
||||
const dragOverPanel = ref(false)
|
||||
const savingIds = ref<number[]>([])
|
||||
|
||||
const title = computed(() => {
|
||||
const [year, month] = focus.value.split('-').map(Number)
|
||||
return new Date(year!, month! - 1, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
||||
})
|
||||
|
||||
const events = computed<CalendarEvent[]>(() =>
|
||||
scheduled.value
|
||||
.filter((issue) => issue.dueDate)
|
||||
.map((issue) => ({
|
||||
name: `${issue.key} ${issue.title}`,
|
||||
start: toLocalDate(new Date(issue.dueDate!)),
|
||||
color: statusColor(issue.status),
|
||||
issue,
|
||||
})),
|
||||
)
|
||||
|
||||
function statusColor(status: Issue['status']) {
|
||||
return statusStore.statuses?.find((el) => el.alias === status)?.color ?? 'primary'
|
||||
}
|
||||
|
||||
/** Local YYYY-MM-DD of a moment */
|
||||
function toLocalDate(date: Date): string {
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
/** Local midnight of a YYYY-MM-DD day, `shiftDays` days later */
|
||||
function localMidnight(date: string, shiftDays = 0): Date {
|
||||
const [year, month, day] = date.split('-').map(Number)
|
||||
return new Date(year!, month! - 1, day! + shiftDays)
|
||||
}
|
||||
|
||||
function formatDate(date: string) {
|
||||
return localMidnight(date).toLocaleDateString('ru-RU')
|
||||
}
|
||||
|
||||
let scheduledRequestId = 0
|
||||
|
||||
async function loadScheduled() {
|
||||
if (!range.value) return
|
||||
// The month view shows whole weeks (Mon–Sun), so request the visible grid, not just the month
|
||||
const start = localMidnight(range.value.start.date)
|
||||
start.setDate(start.getDate() - ((start.getDay() + 6) % 7))
|
||||
const end = localMidnight(range.value.end.date, 1)
|
||||
end.setDate(end.getDate() + ((8 - end.getDay()) % 7))
|
||||
|
||||
const requestId = ++scheduledRequestId
|
||||
loadingScheduled.value = true
|
||||
const res = await issueStore.calendar({
|
||||
from: start.toISOString(),
|
||||
to: end.toISOString(),
|
||||
projectId: currentProjectId.value,
|
||||
})
|
||||
if (requestId !== scheduledRequestId) return
|
||||
loadingScheduled.value = false
|
||||
scheduled.value = res?.list ?? []
|
||||
truncated.value = res?.truncated ?? false
|
||||
}
|
||||
|
||||
let unscheduledRequestId = 0
|
||||
|
||||
async function loadUnscheduled(page = 1) {
|
||||
const requestId = ++unscheduledRequestId
|
||||
loadingUnscheduled.value = true
|
||||
const res = await issueStore.list({
|
||||
page,
|
||||
limit: UNSCHEDULED_LIMIT,
|
||||
text: search.value,
|
||||
projectId: currentProjectId.value,
|
||||
status: UNSCHEDULED_STATUSES,
|
||||
hasDueDate: false,
|
||||
})
|
||||
if (requestId !== unscheduledRequestId) return
|
||||
loadingUnscheduled.value = false
|
||||
unscheduled.value = page === 1 ? res.list : [...unscheduled.value, ...res.list]
|
||||
unscheduledPage.value = page
|
||||
unscheduledTotalPages.value = res.meta.totalPages
|
||||
unscheduledTotal.value = res.meta.totalCount
|
||||
}
|
||||
|
||||
function onRangeChange(value: CalendarRange) {
|
||||
range.value = value
|
||||
loadScheduled()
|
||||
}
|
||||
|
||||
watch(currentProjectId, () => {
|
||||
loadScheduled()
|
||||
loadUnscheduled()
|
||||
})
|
||||
watchDebounced(search, () => loadUnscheduled(), { debounce: 300 })
|
||||
loadUnscheduled()
|
||||
|
||||
async function setDueDate(issue: Issue, date: string | null) {
|
||||
if (savingIds.value.includes(issue.id)) return
|
||||
if (date && issue.dueDate && toLocalDate(new Date(issue.dueDate)) === date) return
|
||||
|
||||
savingIds.value.push(issue.id)
|
||||
const updated = await issueStore.update(issue.id, {
|
||||
dueDate: date ? localMidnight(date).toISOString() : null,
|
||||
})
|
||||
savingIds.value = savingIds.value.filter((id) => id !== issue.id)
|
||||
if (!updated) return
|
||||
|
||||
const wasUnscheduled = unscheduled.value.some((el) => el.id === issue.id)
|
||||
unscheduled.value = unscheduled.value.filter((el) => el.id !== issue.id)
|
||||
scheduled.value = scheduled.value.filter((el) => el.id !== issue.id)
|
||||
|
||||
if (updated.dueDate) {
|
||||
scheduled.value.push(updated)
|
||||
if (wasUnscheduled) unscheduledTotal.value--
|
||||
} else if (UNSCHEDULED_STATUSES.includes(updated.status)) {
|
||||
unscheduled.value.unshift(updated)
|
||||
unscheduledTotal.value++
|
||||
}
|
||||
}
|
||||
|
||||
function findIssue(id: number) {
|
||||
return unscheduled.value.find((el) => el.id === id) ?? scheduled.value.find((el) => el.id === id)
|
||||
}
|
||||
|
||||
function onDragStart(e: DragEvent, issue: Issue) {
|
||||
if (!e.dataTransfer) return
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData(DRAG_TYPE, String(issue.id))
|
||||
e.dataTransfer.setData('text/plain', issue.key)
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
dragOverDate.value = null
|
||||
dragOverPanel.value = false
|
||||
}
|
||||
|
||||
function isIssueDrag(e: Event): e is DragEvent {
|
||||
return e instanceof DragEvent && !!e.dataTransfer?.types.includes(DRAG_TYPE)
|
||||
}
|
||||
|
||||
function onDayDragOver(e: Event, day: { date: string }) {
|
||||
if (!isIssueDrag(e)) return
|
||||
e.preventDefault()
|
||||
dragOverDate.value = day.date
|
||||
}
|
||||
|
||||
function onDayDrop(e: Event, day: { date: string }) {
|
||||
dragOverDate.value = null
|
||||
if (!isIssueDrag(e)) return
|
||||
const issue = findIssue(Number(e.dataTransfer?.getData(DRAG_TYPE)))
|
||||
if (issue) setDueDate(issue, day.date)
|
||||
}
|
||||
|
||||
function onPanelDragOver(e: DragEvent) {
|
||||
if (!isIssueDrag(e)) return
|
||||
e.preventDefault()
|
||||
dragOverPanel.value = true
|
||||
}
|
||||
|
||||
function onPanelDrop(e: DragEvent) {
|
||||
dragOverPanel.value = false
|
||||
const id = Number(e.dataTransfer?.getData(DRAG_TYPE))
|
||||
// Only calendar events can be dropped back to unschedule them
|
||||
const issue = scheduled.value.find((el) => el.id === id)
|
||||
if (issue) setDueDate(issue, null)
|
||||
}
|
||||
|
||||
function onDayClick(_e: Event, day: { date: string }) {
|
||||
selectedDate.value = selectedDate.value === day.date ? null : day.date
|
||||
}
|
||||
|
||||
function onEventClick(e: Event, scope: { event: Partial<CalendarEvent> }) {
|
||||
// Don't let the click reach the day cell and toggle the selection
|
||||
e.stopPropagation()
|
||||
if (scope.event.issue) router.push(`/issues/${scope.event.issue.key}`)
|
||||
}
|
||||
|
||||
function goToday() {
|
||||
focus.value = toLocalDate(new Date())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="calendar-layout">
|
||||
<div class="d-flex flex-column ga-2 calendar-main">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-btn density="comfortable" icon="mdi-chevron-left" @click="calendarRef?.prev()" />
|
||||
<v-btn density="comfortable" icon="mdi-chevron-right" @click="calendarRef?.next()" />
|
||||
<v-btn density="comfortable" @click="goToday">Today</v-btn>
|
||||
<div class="text-h6 ml-2">{{ title }}</div>
|
||||
<v-progress-circular v-if="loadingScheduled" indeterminate size="20" width="2" />
|
||||
<v-spacer />
|
||||
<v-chip v-if="truncated" color="warning" size="small" prepend-icon="mdi-alert">
|
||||
Not all issues are shown
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<v-sheet class="flex-grow-1" border>
|
||||
<v-calendar
|
||||
ref="calendarRef"
|
||||
v-model="focus"
|
||||
type="month"
|
||||
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
|
||||
:events="events"
|
||||
event-text-color="white"
|
||||
@change="onRangeChange"
|
||||
@click:day="onDayClick"
|
||||
@click:event="onEventClick"
|
||||
@dragover:day="onDayDragOver"
|
||||
@drop:day="onDayDrop"
|
||||
>
|
||||
<template #day-label="day">
|
||||
<div class="d-flex justify-center">
|
||||
<v-btn
|
||||
icon
|
||||
size="x-small"
|
||||
:variant="selectedDate === day.date ? 'flat' : day.present ? 'outlined' : 'text'"
|
||||
:color="selectedDate === day.date || day.present ? 'primary' : undefined"
|
||||
@click.stop="onDayClick($event, day)"
|
||||
>
|
||||
{{ day.day }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #day="day">
|
||||
<div
|
||||
class="day-overlay"
|
||||
:class="{
|
||||
'day-overlay--selected': selectedDate === day.date,
|
||||
'day-overlay--drop': dragOverDate === day.date,
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #event="{ event }">
|
||||
<div
|
||||
class="pl-1 text-truncate cursor-grab"
|
||||
:class="{ 'opacity-50': savingIds.includes(event.issue.id) }"
|
||||
:title="event.name"
|
||||
draggable="true"
|
||||
@dragstart.stop="onDragStart($event, event.issue)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<span
|
||||
class="font-weight-bold"
|
||||
:class="{ 'text-decoration-line-through': ['DONE', 'CANCELLED'].includes(event.issue.status) }"
|
||||
>
|
||||
{{ event.issue.key }}
|
||||
</span>
|
||||
{{ event.issue.title }}
|
||||
</div>
|
||||
</template>
|
||||
</v-calendar>
|
||||
</v-sheet>
|
||||
</div>
|
||||
|
||||
<v-card
|
||||
border
|
||||
class="d-flex flex-column calendar-side"
|
||||
:class="{ 'calendar-side--drop': dragOverPanel }"
|
||||
@dragover="onPanelDragOver"
|
||||
@dragleave.self="dragOverPanel = false"
|
||||
@drop="onPanelDrop"
|
||||
>
|
||||
<v-card-title class="d-flex align-center ga-2 text-subtitle-1">
|
||||
Unscheduled
|
||||
<v-chip size="x-small">{{ unscheduledTotal }}</v-chip>
|
||||
</v-card-title>
|
||||
|
||||
<div class="px-4 pb-2 d-flex flex-column ga-2">
|
||||
<v-text-field v-model="search" placeholder="Search" prepend-inner-icon="mdi-magnify" clearable />
|
||||
<div class="text-caption text-medium-emphasis d-flex align-center ga-1">
|
||||
<template v-if="selectedDate">
|
||||
<span>
|
||||
Selected: <b>{{ formatDate(selectedDate) }}</b> — press + to schedule
|
||||
</span>
|
||||
<v-btn density="compact" size="x-small" icon="mdi-close" variant="text" @click="selectedDate = null" />
|
||||
</template>
|
||||
<span v-else>Drag an issue onto a day, or pick a day and press +</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<div class="flex-grow-1 overflow-y-auto">
|
||||
<v-list class="pa-0">
|
||||
<v-list-item
|
||||
v-for="issue in unscheduled"
|
||||
:key="issue.id"
|
||||
class="cursor-grab ma-1 border bg-surface-light px-2 py-0"
|
||||
:class="{ 'opacity-50': savingIds.includes(issue.id) }"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, issue)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon icon="mdi-drag-vertical" size="small" />
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<div :title="issue.title">{{ issue.title }}</div>
|
||||
<router-link
|
||||
:to="`/issues/${issue.key}`"
|
||||
class="text-link text-decoration-none font-weight-bold text-label-medium"
|
||||
draggable="false"
|
||||
>
|
||||
{{ issue.key }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<template #append>
|
||||
<v-btn
|
||||
density="compact"
|
||||
icon="mdi-plus"
|
||||
variant="text"
|
||||
:disabled="!selectedDate"
|
||||
:loading="savingIds.includes(issue.id)"
|
||||
:title="selectedDate ? `Schedule for ${formatDate(selectedDate)}` : 'Pick a day on the calendar first'"
|
||||
@click="selectedDate && setDueDate(issue, selectedDate)"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<div v-if="!loadingUnscheduled && !unscheduled.length" class="pa-4 text-center text-medium-emphasis">
|
||||
No unscheduled issues
|
||||
</div>
|
||||
|
||||
<div v-if="unscheduledPage < unscheduledTotalPages || loadingUnscheduled" class="d-flex justify-center pa-2">
|
||||
<v-btn density="compact" :loading="loadingUnscheduled" @click="loadUnscheduled(unscheduledPage + 1)">
|
||||
Load more
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="dragOverPanel" class="text-caption text-center pa-2 text-primary">Drop here to remove the date</div>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.calendar-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 22rem;
|
||||
gap: 16px;
|
||||
height: calc(100vh - 140px);
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.calendar-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.calendar-side {
|
||||
min-height: 0;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&--drop {
|
||||
border-color: rgb(var(--v-theme-primary)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.day-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&--selected {
|
||||
background: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
&--drop {
|
||||
background: rgba(var(--v-theme-primary), 0.18);
|
||||
outline: 2px dashed rgb(var(--v-theme-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.cursor-grab {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.calendar-layout {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.calendar-main {
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.calendar-side {
|
||||
max-height: 60vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -21,13 +21,6 @@ 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 {
|
||||
@@ -124,7 +117,6 @@ async function onDelete(project: Project) {
|
||||
deletingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -138,7 +130,12 @@ async function onDelete(project: Project) {
|
||||
hover
|
||||
hide-default-footer
|
||||
:loading="loading"
|
||||
:headers="headers"
|
||||
:headers="[
|
||||
{ key: 'key', title: 'Key', sortable: false, width: '8rem' },
|
||||
{ key: 'name', title: 'Name', sortable: false },
|
||||
{ key: 'createdAt', title: 'Created', sortable: false },
|
||||
{ key: 'actions', title: '', sortable: false, align: 'end' as const },
|
||||
]"
|
||||
:items-per-page="itemsPerPage"
|
||||
:page="page"
|
||||
:items="serverItems"
|
||||
@@ -147,6 +144,10 @@ async function onDelete(project: Project) {
|
||||
@update:options="loadItems"
|
||||
@click:row="(_e: unknown, { item }: { item: Project }) => openIssues(item)"
|
||||
>
|
||||
<template #[`item.key`]="{ item }">
|
||||
<v-chip size="small" color="primary" variant="flat" density="compact" :text="item.key" />
|
||||
</template>
|
||||
|
||||
<template #[`item.createdAt`]="{ item }">
|
||||
{{ new Date(item.createdAt).toLocaleDateString('ru-RU') }}
|
||||
</template>
|
||||
|
||||
@@ -12,9 +12,10 @@ const tab = ref(0)
|
||||
<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-tab value="tags" to="/settings/tags">Tags</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<div class="pa-6">
|
||||
<div class="pa-4">
|
||||
<router-view />
|
||||
</div>
|
||||
</v-sheet>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useProjectStore } from '@/stores/project.ts'
|
||||
import { type Tag, TAGS_LIST_MAX_LIMIT, tagsList } from '@/api/tag.api.ts'
|
||||
import TagChip from '@/components/TagChip.vue'
|
||||
|
||||
const { currentProjectId } = storeToRefs(useProjectStore())
|
||||
|
||||
const items = ref<Tag[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function load(projectId: string | null) {
|
||||
items.value = []
|
||||
error.value = null
|
||||
if (!projectId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await tagsList(projectId, { limit: TAGS_LIST_MAX_LIMIT })
|
||||
// The project may have changed while the request was in flight
|
||||
if (projectId === currentProjectId.value) items.value = res
|
||||
} catch (e) {
|
||||
if (projectId === currentProjectId.value) error.value = e instanceof Error ? e.message : 'error get list of tags'
|
||||
} finally {
|
||||
if (projectId === currentProjectId.value) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(currentProjectId, load, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="d-flex flex-column ga-2">
|
||||
<v-alert v-if="error" type="error" density="compact" variant="flat">{{ error }}</v-alert>
|
||||
|
||||
<v-data-table
|
||||
density="compact"
|
||||
hide-default-footer
|
||||
:loading="loading"
|
||||
:headers="[
|
||||
{ key: 'name', title: 'Name', sortable: false },
|
||||
{ key: 'createdAt', title: 'Created', sortable: false },
|
||||
]"
|
||||
:items="items"
|
||||
:items-per-page="-1"
|
||||
:no-data-text="currentProjectId ? 'No tags yet' : 'Select a project'"
|
||||
item-value="id"
|
||||
>
|
||||
<template #[`item.name`]="{ item }">
|
||||
<tag-chip :tag="item" />
|
||||
</template>
|
||||
|
||||
<template #[`item.createdAt`]="{ item }">
|
||||
{{ new Date(item.createdAt).toLocaleDateString('ru-RU') }}
|
||||
</template>
|
||||
</v-data-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -3,20 +3,19 @@ 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'
|
||||
import NotificationSettings from '@/components/NotificationSettings.vue'
|
||||
|
||||
const session = authClient.useSession()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-sheet rounded border class="pa-4 d-flex flex-column ga-4">
|
||||
<div class="d-flex flex-column ga-2">
|
||||
<div class="text-headline-medium text-uppercase">profile</div>
|
||||
|
||||
<user-profile :user="session.data?.user" />
|
||||
|
||||
<password-change />
|
||||
|
||||
<notification-settings />
|
||||
<sessions-list />
|
||||
</v-sheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -1,44 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import { authClient } from '@/lib/auth-client.js'
|
||||
import { ref } from 'vue'
|
||||
import SignInOtp from '@/components/SignInOtp.vue'
|
||||
import SignInEmail from '@/components/SignInEmail.vue'
|
||||
import SignBlock from '@/components/SignBlock.vue'
|
||||
|
||||
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)
|
||||
}
|
||||
const tab = ref('otp')
|
||||
</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>
|
||||
<sign-block>
|
||||
<v-sheet>
|
||||
<v-tabs v-model="tab" color="primary" density="compact" align-tabs="center">
|
||||
<v-tab prepend-icon="mdi-key" value="otp">OTP</v-tab>
|
||||
<v-tab prepend-icon="mdi-mail" value="email">Email</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-tabs-window v-model="tab">
|
||||
<v-tabs-window-item value="otp">
|
||||
<sign-in-otp />
|
||||
</v-tabs-window-item>
|
||||
<v-tabs-window-item value="email">
|
||||
<sign-in-email />
|
||||
</v-tabs-window-item>
|
||||
</v-tabs-window>
|
||||
</v-sheet>
|
||||
</sign-block>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -1,12 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { authClient } from '@/lib/auth-client.js'
|
||||
import { reactive } from 'vue'
|
||||
import { reactive, ref } from 'vue'
|
||||
import SignForm from '@/components/SignForm.vue'
|
||||
import SignBlock from '@/components/SignBlock.vue'
|
||||
|
||||
const signUpData = reactive({
|
||||
name: 'Vadim',
|
||||
email: 'vadim.olonin@gmail.com',
|
||||
name: '',
|
||||
email: '', //vadim.olonin@gmail.com
|
||||
password: '',
|
||||
})
|
||||
const err = ref('')
|
||||
|
||||
const signUp = async () => {
|
||||
const { data, error } = await authClient.signUp.email({
|
||||
@@ -14,32 +17,56 @@ const signUp = async () => {
|
||||
email: signUpData.email,
|
||||
password: signUpData.password,
|
||||
// image: 'https://example.com/image.png',
|
||||
callbackURL: 'https://madsky.ru',
|
||||
callbackURL: window.location.origin,
|
||||
})
|
||||
|
||||
console.log(data)
|
||||
console.log(error)
|
||||
if (error) {
|
||||
if (error.message) {
|
||||
err.value = error.message
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-row>
|
||||
<v-col cols="4" offset="4">
|
||||
<sign-block>
|
||||
<sign-form>
|
||||
<div class="d-flex flex-column ga-2">
|
||||
<div class="d-flex ga-2 align-center justify-space-between">
|
||||
<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" />
|
||||
<v-chip v-if="err" size="small" variant="flat" color="error" density="compact">{{ err }}</v-chip>
|
||||
</div>
|
||||
<v-text-field
|
||||
bg-color="background"
|
||||
clearable
|
||||
placeholder="Name"
|
||||
prepend-inner-icon="mdi-account"
|
||||
v-model="signUpData.name"
|
||||
/>
|
||||
<v-text-field
|
||||
bg-color="background"
|
||||
clearable
|
||||
placeholder="E-mail"
|
||||
prepend-inner-icon="mdi-email"
|
||||
v-model="signUpData.email"
|
||||
/>
|
||||
<v-text-field
|
||||
bg-color="background"
|
||||
clearable
|
||||
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>
|
||||
<v-btn @click="signUp" variant="flat" color="primary" density="comfortable">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>
|
||||
</sign-form>
|
||||
</sign-block>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -1,35 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { authClient } from '@/lib/auth-client.js'
|
||||
import { reactive } from 'vue'
|
||||
import { reactive, ref } from 'vue'
|
||||
import SignForm from '@/components/SignForm.vue'
|
||||
import SignBlock from '@/components/SignBlock.vue'
|
||||
|
||||
const verifyData = reactive({
|
||||
email: 'vadim.olonin@gmail.com',
|
||||
email: '',
|
||||
})
|
||||
const err = ref('')
|
||||
|
||||
const verify = async () => {
|
||||
const { data, error } = await authClient.sendVerificationEmail({
|
||||
email: verifyData.email,
|
||||
callbackURL: 'https://madsky.ru',
|
||||
callbackURL: window.location.origin,
|
||||
})
|
||||
|
||||
console.log(data)
|
||||
console.log(error)
|
||||
|
||||
if (error) {
|
||||
if (error.message) {
|
||||
err.value = error.message
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-row>
|
||||
<v-col cols="4" offset="4">
|
||||
<sign-block>
|
||||
<sign-form>
|
||||
<div class="d-flex flex-column ga-2">
|
||||
<div class="d-flex ga-2 align-center justify-space-between">
|
||||
<div class="text-uppercase">verify email</div>
|
||||
<v-chip v-if="err" size="small" variant="flat" color="error" density="compact">{{ err }}</v-chip>
|
||||
</div>
|
||||
|
||||
<v-text-field placeholder="email" prepend-inner-icon="mdi-email" v-model="verifyData.email" />
|
||||
<v-text-field
|
||||
clearable
|
||||
placeholder="email"
|
||||
bg-color="background"
|
||||
prepend-inner-icon="mdi-email"
|
||||
v-model="verifyData.email"
|
||||
/>
|
||||
<div>
|
||||
<v-btn @click="verify">verify</v-btn>
|
||||
<v-btn @click="verify" color="primary" variant="flat" density="comfortable">VERIFY</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</sign-form>
|
||||
</sign-block>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
Reference in new issue
Block a user