Compare commits
20
Commits
0fe2c70848
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c96ef32fe9 | ||
|
|
10ad1569fb | ||
|
|
609fe517d7 | ||
|
|
76bababa91 | ||
|
|
696166d68a | ||
|
|
b9c5f64989 | ||
|
|
f0706432d4 | ||
|
|
63861449cf | ||
|
|
ff0008d346 | ||
|
|
81dd976d8f | ||
|
|
9303a66aa8 | ||
|
|
327f59104d | ||
|
|
2b94bcc20e | ||
|
|
7639a54c50 | ||
|
|
cc490f8d4a | ||
|
|
6af9993c4f | ||
|
|
2d4b8ea9cf | ||
|
|
a0c557f9b2 | ||
|
|
66a6400cda | ||
|
|
b2444a8118 |
No files matched your search
@@ -1,4 +1,4 @@
|
|||||||
name: Gitea Actions Demo
|
name: Gitea Actions
|
||||||
run-name: Deploy 🚀
|
run-name: Deploy 🚀
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<html lang="">
|
<html lang="">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<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">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Vite App</title>
|
<title>Vite App</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Generated
+1856
-194
File diff suppressed because it is too large.
Load diff
+9
-2
@@ -16,9 +16,16 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/roboto": "^5.3.0",
|
"@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",
|
"@vueuse/core": "^14.4.0",
|
||||||
"better-auth": "^1.7.1",
|
"better-auth": "^1.7.13",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"firebase": "^13.0.0",
|
||||||
"pinia": "^4.0.3",
|
"pinia": "^4.0.3",
|
||||||
"vue": "^3.5.41",
|
"vue": "^3.5.41",
|
||||||
"vue-chartjs": "^5.3.4",
|
"vue-chartjs": "^5.3.4",
|
||||||
@@ -43,7 +50,7 @@
|
|||||||
"sass-embedded": "^1.103.1",
|
"sass-embedded": "^1.103.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"unplugin-fonts": "^2.0.0",
|
"unplugin-fonts": "^2.0.0",
|
||||||
"vite": "^8.2.2",
|
"vite": "^8.3.5",
|
||||||
"vite-plugin-vue-devtools": "^8.2.1",
|
"vite-plugin-vue-devtools": "^8.2.1",
|
||||||
"vue-eslint-parser": "^10.4.1",
|
"vue-eslint-parser": "^10.4.1",
|
||||||
"vue-tsc": "^3.3.11"
|
"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 })
|
||||||
|
})
|
||||||
+43
-5
@@ -1,23 +1,31 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useEventSource } from '@vueuse/core'
|
import { useEventSource } from '@vueuse/core'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { computed, onMounted, watch } from 'vue'
|
import { computed, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useStatusStore } from '@/stores/status.ts'
|
import { useStatusStore } from '@/stores/status.ts'
|
||||||
|
import { useIssueTypeStore } from '@/stores/issue-type.ts'
|
||||||
import { useIssueStore } from '@/stores/issue.ts'
|
import { useIssueStore } from '@/stores/issue.ts'
|
||||||
import { useProjectStore } from '@/stores/project.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 route = useRoute()
|
||||||
const issueStore = useIssueStore()
|
const issueStore = useIssueStore()
|
||||||
const statusStore = useStatusStore()
|
const statusStore = useStatusStore()
|
||||||
|
const issueTypeStore = useIssueTypeStore()
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
|
const session = authClient.useSession()
|
||||||
|
const push = usePushNotifications()
|
||||||
|
|
||||||
const { currentProjectId } = storeToRefs(projectStore)
|
const { currentProjectId } = storeToRefs(projectStore)
|
||||||
|
|
||||||
interface IssueSseEvent {
|
interface IssueSseEvent {
|
||||||
type: string
|
type: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, event } = useEventSource<string[], IssueSseEvent>(
|
const { event, open, close } = useEventSource<string[], IssueSseEvent>(
|
||||||
`${window.location.origin}/api/issues/events`,
|
`${window.location.origin}/api/issues/events`,
|
||||||
['issue.created', 'issue.updated', 'issue.deleted'],
|
['issue.created', 'issue.updated', 'issue.deleted'],
|
||||||
{
|
{
|
||||||
@@ -39,15 +47,44 @@ watch(event, () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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 = computed(() => {
|
||||||
const layout = route?.meta?.layout
|
const layout = route?.meta?.layout
|
||||||
return layout ? `${layout}Layout` : 'DefaultLayout'
|
return layout ? `${layout}Layout` : 'DefaultLayout'
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
// Все эти ресурсы требуют авторизации: грузим при появлении сессии и чистим при выходе,
|
||||||
await statusStore.list()
|
// иначе запросы, ушедшие до логина, падают с 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)
|
await issueStore.getStat(currentProjectId.value)
|
||||||
})
|
} catch (e) {
|
||||||
|
console.error('Failed to load dictionaries', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -58,6 +95,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</router-view>
|
</router-view>
|
||||||
</component>
|
</component>
|
||||||
|
<snackbar-queue />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
@@ -2,9 +2,10 @@ import { apiClient } from '@/lib/api-client.ts'
|
|||||||
import type { User } from '@/api/user.api.ts'
|
import type { User } from '@/api/user.api.ts'
|
||||||
|
|
||||||
/** created — a pseudo-field: the issue creation record, both values are null */
|
/** created — a pseudo-field: the issue creation record, both values are null */
|
||||||
export type IssueChangeField = 'created' | 'title' | 'content' | 'status' | 'priority' | 'assignee' | 'dueDate'
|
export type IssueChangeField =
|
||||||
|
'created' | 'title' | 'content' | 'status' | 'priority' | 'assignee' | 'dueDate' | 'type' | 'tag'
|
||||||
|
|
||||||
/** Values are serialized: priority — number, dueDate — ISO date, assignee — user id */
|
/** Values are serialized: priority — number, dueDate — ISO date, assignee — user id, tag — one change per tag name */
|
||||||
export interface IssueFieldChange {
|
export interface IssueFieldChange {
|
||||||
field: IssueChangeField
|
field: IssueChangeField
|
||||||
oldValue: string | null
|
oldValue: string | null
|
||||||
|
|||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -1,17 +1,27 @@
|
|||||||
import { apiClient, toQuery } from '@/lib/api-client.ts'
|
import { apiClient, toQuery } from '@/lib/api-client.ts'
|
||||||
import type { User } from '@/api/user.api.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 {
|
export interface Issue {
|
||||||
id: number
|
id: number
|
||||||
key: string
|
key: string
|
||||||
|
projectId: string
|
||||||
title: string
|
title: string
|
||||||
content: string
|
content: string
|
||||||
status: 'OPEN' | 'IN_PROGRESS' | 'DONE' | 'CANCELLED'
|
status: 'OPEN' | 'IN_PROGRESS' | 'DONE' | 'CANCELLED'
|
||||||
|
type: IssueType['alias']
|
||||||
priority: 0 | 1 | 2 | 3 | 4
|
priority: 0 | 1 | 2 | 3 | 4
|
||||||
assigneeId: string
|
assigneeId: string
|
||||||
reporterId: string
|
reporterId: string
|
||||||
assignee?: User
|
assignee?: User
|
||||||
reporter?: User
|
reporter?: User
|
||||||
|
tags: IssueTag[]
|
||||||
dueDate: string | null
|
dueDate: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
@@ -29,9 +39,12 @@ export interface CreateIssueInput {
|
|||||||
projectId: string
|
projectId: string
|
||||||
content?: string
|
content?: string
|
||||||
status?: Issue['status']
|
status?: Issue['status']
|
||||||
|
type?: Issue['type']
|
||||||
priority?: Issue['priority']
|
priority?: Issue['priority']
|
||||||
assignee?: string
|
assignee?: string
|
||||||
dueDate?: string | null
|
dueDate?: string | null
|
||||||
|
/** Names, the full new list; names missing from the project's tags are created */
|
||||||
|
tags?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateIssueInput = Partial<CreateIssueInput>
|
export type UpdateIssueInput = Partial<CreateIssueInput>
|
||||||
|
|||||||
@@ -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) {
|
export async function itemGetById(id: string) {
|
||||||
return apiClient.get<User>(`/api/users/${id}`)
|
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')
|
||||||
|
}
|
||||||
@@ -99,7 +99,6 @@ const select = (value: unknown) => {
|
|||||||
rounded="circle"
|
rounded="circle"
|
||||||
density="compact"
|
density="compact"
|
||||||
border
|
border
|
||||||
color="primary"
|
|
||||||
/>
|
/>
|
||||||
<div>{{ user.name || user.email }}</div>
|
<div>{{ user.name || user.email }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,34 +1,51 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
text?: string
|
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 = defineProps<Props>()
|
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 emit = defineEmits<{ changed: [text: string] }>()
|
||||||
|
|
||||||
const editing = ref(false)
|
const editing = ref(false)
|
||||||
const draft = ref(props.text ?? '')
|
const draft = ref(props.text ?? '')
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
watch(draft, () => {
|
||||||
|
error.value = ''
|
||||||
|
})
|
||||||
|
|
||||||
const startEdit = () => {
|
const startEdit = () => {
|
||||||
draft.value = props.text ?? ''
|
draft.value = props.text ?? ''
|
||||||
|
error.value = ''
|
||||||
editing.value = true
|
editing.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
|
if (props.required && !draft.value.trim()) {
|
||||||
|
error.value = 'This field is required'
|
||||||
|
return
|
||||||
|
}
|
||||||
emit('changed', draft.value)
|
emit('changed', draft.value)
|
||||||
editing.value = false
|
editing.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const cancel = () => {
|
const cancel = () => {
|
||||||
draft.value = props.text ?? ''
|
draft.value = props.text ?? ''
|
||||||
|
error.value = ''
|
||||||
editing.value = false
|
editing.value = false
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="issue-title">
|
<div class="editable-text">
|
||||||
<template v-if="!editing">
|
<template v-if="!editing">
|
||||||
<v-hover #default="{ isHovering, props }">
|
<v-hover #default="{ isHovering, props }">
|
||||||
<v-sheet
|
<v-sheet
|
||||||
@@ -37,17 +54,26 @@ const cancel = () => {
|
|||||||
class="cursor-pointer d-flex align-center ga-1"
|
class="cursor-pointer d-flex align-center ga-1"
|
||||||
@click="startEdit"
|
@click="startEdit"
|
||||||
>
|
>
|
||||||
<div class="text-uppercase text-title-large">
|
<div class="text-uppercase text-title-large" :class="{ 'text-medium-emphasis': isEmpty }">
|
||||||
{{ text ?? 'click to write text' }}
|
{{ isEmpty ? placeholder : text }}
|
||||||
</div>
|
</div>
|
||||||
<v-icon v-if="isHovering" icon="mdi-pencil" size="small" />
|
<v-icon v-if="isHovering || isEmpty" icon="mdi-pencil" size="small" />
|
||||||
</v-sheet>
|
</v-sheet>
|
||||||
</v-hover>
|
</v-hover>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="d-flex ga-1 align-center">
|
<div class="d-flex ga-1 align-center">
|
||||||
<v-text-field autofocus class="mb-1" hide-details v-model="draft" variant="outlined" @keyup.enter="save" />
|
<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-btn density="compact" icon variant="text" @click="cancel">
|
||||||
<v-icon icon="mdi-cancel" />
|
<v-icon icon="mdi-cancel" />
|
||||||
@@ -20,6 +20,8 @@ const FIELD_LABELS: Record<IssueFieldChange['field'], string> = {
|
|||||||
priority: 'Priority',
|
priority: 'Priority',
|
||||||
assignee: 'Assignee',
|
assignee: 'Assignee',
|
||||||
dueDate: 'Due Date',
|
dueDate: 'Due Date',
|
||||||
|
type: 'Type',
|
||||||
|
tag: 'Tag',
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRIORITY_LABELS: Record<string, string> = {
|
const PRIORITY_LABELS: Record<string, string> = {
|
||||||
@@ -144,7 +146,7 @@ function toggle(key: string) {
|
|||||||
<span class="text-medium-emphasis text-body-small ms-auto">{{ item.time }}</span>
|
<span class="text-medium-emphasis text-body-small ms-auto">{{ item.time }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-for="change in item.changes" :key="change.field" class="history__change">
|
<div v-for="(change, idx) in item.changes" :key="idx" class="history__change">
|
||||||
<div class="text-medium-emphasis">{{ FIELD_LABELS[change.field] }}:</div>
|
<div class="text-medium-emphasis">{{ FIELD_LABELS[change.field] }}:</div>
|
||||||
|
|
||||||
<div v-if="change.field === 'content'">
|
<div v-if="change.field === 'content'">
|
||||||
|
|||||||
@@ -30,19 +30,13 @@ const cancel = () => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="issue-content">
|
<div class="issue-content">
|
||||||
<template v-if="!editing">
|
<template v-if="!editing">
|
||||||
<v-hover #default="{ isHovering, props }">
|
|
||||||
<v-sheet
|
<v-sheet v-bind="props" border class="cursor-pointer d-flex ga-1 pa-4" @click="startEdit">
|
||||||
v-bind="props"
|
|
||||||
:class="isHovering ? 'px-0' : 'px-0'"
|
|
||||||
class="cursor-pointer d-flex ga-1"
|
|
||||||
@click="startEdit"
|
|
||||||
>
|
|
||||||
<div class="content-sheet">
|
<div class="content-sheet">
|
||||||
{{ text ?? 'click to add content' }}
|
{{ text ?? 'click to add content' }}
|
||||||
</div>
|
</div>
|
||||||
<v-icon v-if="isHovering" icon="mdi-pencil" size="small" />
|
|
||||||
</v-sheet>
|
</v-sheet>
|
||||||
</v-hover>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -58,12 +52,8 @@ const cancel = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="d-flex justify-end ga-1">
|
<div class="d-flex justify-end ga-1">
|
||||||
<v-btn density="compact" icon variant="text" @click="cancel">
|
<v-btn density="compact" prepend-icon="mdi-cancel" text="Cancel" variant="text" @click="cancel" />
|
||||||
<v-icon icon="mdi-cancel" />
|
<v-btn density="compact" prepend-icon="mdi-check" color="primary" variant="flat" @click="save" text="Save" />
|
||||||
</v-btn>
|
|
||||||
<v-btn density="compact" icon color="primary" variant="flat" @click="save">
|
|
||||||
<v-icon icon="mdi-check" />
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,10 +65,6 @@ const cancel = () => {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
//.content-sheet:hover {
|
|
||||||
//background-color: rgba(var(--v-theme-on-surface), 0.06);
|
|
||||||
//}
|
|
||||||
|
|
||||||
.v-sheet {
|
.v-sheet {
|
||||||
transition: padding 0.2s ease-in-out;
|
transition: padding 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ const onCreate = async () => {
|
|||||||
newIssue.title = ''
|
newIssue.title = ''
|
||||||
showForm.value = false
|
showForm.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
showForm.value = false
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -54,15 +58,16 @@ const onCreate = async () => {
|
|||||||
placeholder="Describe your issue"
|
placeholder="Describe your issue"
|
||||||
autofocus
|
autofocus
|
||||||
@keyup.enter="onCreate"
|
@keyup.enter="onCreate"
|
||||||
|
@keyup.esc="onCancel"
|
||||||
/>
|
/>
|
||||||
<v-btn density="compact" variant="tonal" text="Create" @click="onCreate" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item v-else-if="showForm && !current">
|
<v-list-item v-else-if="showForm && !current">
|
||||||
<div>
|
<div>
|
||||||
<v-alert closable density="compact" @click:close="showForm = false">
|
<v-alert closable density="compact" @click:close="onCancel">
|
||||||
Select or create project
|
Select or create project
|
||||||
<router-link class="text-decoration-none text-link text-capitalize" to="/settings/projects">HERE</router-link>.
|
<router-link class="text-decoration-none text-link text-capitalize" to="/settings/projects">HERE</router-link>.
|
||||||
</v-alert>
|
</v-alert>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, useTemplateRef, watchEffect } from 'vue'
|
import { computed, ref, useTemplateRef, watchEffect } from 'vue'
|
||||||
import { useIssueStore } from '@/stores/issue.js'
|
import { useIssueStore } from '@/stores/issue.js'
|
||||||
|
import { useSnackbarStore } from '@/stores/snackbar.ts'
|
||||||
import ExpansionPanel from '@/components/ExpansionPanel.vue'
|
import ExpansionPanel from '@/components/ExpansionPanel.vue'
|
||||||
import IssueContent from '@/components/IssueContent.vue'
|
import IssueContent from '@/components/IssueContent.vue'
|
||||||
import type { Issue } from '@/api/issue.api.ts'
|
import type { Issue } from '@/api/issue.api.ts'
|
||||||
import StatusMenu from '@/components/StatusMenu.vue'
|
import StatusMenu from '@/components/StatusMenu.vue'
|
||||||
import AssigneeMenu from '@/components/AssigneeMenu.vue'
|
import AssigneeMenu from '@/components/AssigneeMenu.vue'
|
||||||
import ReporterInfo from '@/components/ReporterInfo.vue'
|
import ReporterInfo from '@/components/ReporterInfo.vue'
|
||||||
import IssueTitle from '@/components/IssueTitle.vue'
|
import EditableText from '@/components/EditableText.vue'
|
||||||
import IssueDate from '@/components/IssueDate.vue'
|
import IssueDate from '@/components/IssueDate.vue'
|
||||||
import CommentsList from '@/components/CommentsList.vue'
|
import CommentsList from '@/components/CommentsList.vue'
|
||||||
import HistoryList from '@/components/HistoryList.vue'
|
import HistoryList from '@/components/HistoryList.vue'
|
||||||
@@ -15,6 +16,9 @@ import SplitHandle from '@/components/SplitHandle.vue'
|
|||||||
import { useSplitPane } from '@/composables/useSplitPane.ts'
|
import { useSplitPane } from '@/composables/useSplitPane.ts'
|
||||||
import FileUpload from '@/components/FileUpload.vue'
|
import FileUpload from '@/components/FileUpload.vue'
|
||||||
import PriorityMenu from '@/components/PriorityMenu.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 {
|
interface Props {
|
||||||
ckey: string
|
ckey: string
|
||||||
@@ -22,6 +26,7 @@ interface Props {
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
statusChanged: [issue: Issue]
|
statusChanged: [issue: Issue]
|
||||||
|
typeChanged: [issue: Issue]
|
||||||
assigneeChanged: [issue: Issue]
|
assigneeChanged: [issue: Issue]
|
||||||
priorityChanged: [issue: Issue]
|
priorityChanged: [issue: Issue]
|
||||||
titleChanged: [issue: Issue]
|
titleChanged: [issue: Issue]
|
||||||
@@ -29,8 +34,10 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const issueStore = useIssueStore()
|
const issueStore = useIssueStore()
|
||||||
|
const snackbarStore = useSnackbarStore()
|
||||||
const issue = ref<Issue>()
|
const issue = ref<Issue>()
|
||||||
const activity = ref()
|
const activity = ref()
|
||||||
|
const tagsSaving = ref(false)
|
||||||
|
|
||||||
const { ckey } = defineProps<Props>()
|
const { ckey } = defineProps<Props>()
|
||||||
|
|
||||||
@@ -77,6 +84,16 @@ const onContentChanged = async (content: string) => {
|
|||||||
if (updated) issue.value = updated
|
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']) => {
|
const onStatusChanged = async (status: Issue['status']) => {
|
||||||
if (!issue.value) return
|
if (!issue.value) return
|
||||||
|
|
||||||
@@ -107,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({
|
defineExpose({
|
||||||
applyUpdate: (updated: Issue) => {
|
applyUpdate: (updated: Issue) => {
|
||||||
if (issue.value && issue.value.id === updated.id) {
|
if (issue.value && issue.value.id === updated.id) {
|
||||||
@@ -121,22 +151,46 @@ defineExpose({
|
|||||||
<div class="d-flex flex-column ga-4 details__main">
|
<div class="d-flex flex-column ga-4 details__main">
|
||||||
<div>{{ ckey }}</div>
|
<div>{{ ckey }}</div>
|
||||||
|
|
||||||
<issue-title :text="issue.title" @changed="onTitleChanged" />
|
<div class="d-flex ga-2">
|
||||||
|
|
||||||
<v-card elevation="0" border>
|
|
||||||
<v-card-text class="u-grid">
|
|
||||||
<div class="text-medium-emphasis">Status:</div>
|
<div class="text-medium-emphasis">Status:</div>
|
||||||
<status-menu :value="issue.status" @changed="onStatusChanged" />
|
<status-menu :value="issue.status" @changed="onStatusChanged" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="text-medium-emphasis">Priority:</div>
|
<editable-text :text="issue.title" @changed="onTitleChanged" />
|
||||||
<priority-menu :value="issue.priority" :compact="false" @changed="onPriorityChanged" />
|
|
||||||
|
|
||||||
|
<expansion-panel title="Info">
|
||||||
|
<v-card elevation="0" border>
|
||||||
|
<v-card-text class="u-grid">
|
||||||
<div class="text-medium-emphasis">Reporter:</div>
|
<div class="text-medium-emphasis">Reporter:</div>
|
||||||
<reporter-info :user="issue.reporter" />
|
<reporter-info :user="issue.reporter" />
|
||||||
|
|
||||||
<div class="text-medium-emphasis">Assignee:</div>
|
<div class="text-medium-emphasis">Assignee:</div>
|
||||||
<assignee-menu :value="issue.assignee" :compact="false" @changed="onAssigneeChanged" />
|
<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 class="text-medium-emphasis">Created At:</div>
|
||||||
<div>{{ createdAtFormatted }}</div>
|
<div>{{ createdAtFormatted }}</div>
|
||||||
|
|
||||||
@@ -144,6 +198,7 @@ defineExpose({
|
|||||||
<issue-date :date="issue.dueDate" @changed="onDueDateChanged" />
|
<issue-date :date="issue.dueDate" @changed="onDueDateChanged" />
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
</expansion-panel>
|
||||||
|
|
||||||
<expansion-panel title="Details" class="details__content">
|
<expansion-panel title="Details" class="details__content">
|
||||||
<issue-content :text="issue.content" @changed="onContentChanged" />
|
<issue-content :text="issue.content" @changed="onContentChanged" />
|
||||||
@@ -227,6 +282,6 @@ defineExpose({
|
|||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
column-gap: 1rem;
|
column-gap: 1rem;
|
||||||
row-gap: 1.5rem;
|
row-gap: 0.5rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -3,19 +3,18 @@ import { computed } from 'vue'
|
|||||||
import AssigneeFilter from '@/components/AssigneeFilter.vue'
|
import AssigneeFilter from '@/components/AssigneeFilter.vue'
|
||||||
import TextFilter from '@/components/TextFilter.vue'
|
import TextFilter from '@/components/TextFilter.vue'
|
||||||
import StatusFilter from '@/components/StatusFilter.vue'
|
import StatusFilter from '@/components/StatusFilter.vue'
|
||||||
import ProjectMenu from '@/components/ProjectMenu.vue'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
text?: string | null
|
text?: string | null
|
||||||
assigneeId?: string | null
|
assigneeId?: string | null
|
||||||
status?: 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<{
|
const emit = defineEmits<{
|
||||||
'update:text': [value: string | null]
|
'update:text': [value: string | null]
|
||||||
'update:assigneeId': [value: string | null]
|
'update:assigneeId': [value: string | null]
|
||||||
'update:status': [value: string | null]
|
'update:status': [value: string[]]
|
||||||
'update:clear': []
|
'update:clear': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -25,14 +24,13 @@ function onAssigneeChanged(value: string[]) {
|
|||||||
emit('update:assigneeId', value.at(-1) ?? null)
|
emit('update:assigneeId', value.at(-1) ?? null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onStatusChanged(alias: string) {
|
function onStatusChanged(value: string[]) {
|
||||||
emit('update:status', alias)
|
emit('update:status', value)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="filter-grid ga-4 align-center">
|
<div class="filter-grid ga-4 align-center">
|
||||||
<project-menu />
|
|
||||||
<text-filter :model-value="text" @update:model-value="emit('update:text', $event)" />
|
<text-filter :model-value="text" @update:model-value="emit('update:text', $event)" />
|
||||||
<status-filter :status="status" @changed="onStatusChanged" />
|
<status-filter :status="status" @changed="onStatusChanged" />
|
||||||
<assignee-filter :selected="selectedAssignees" @changed="onAssigneeChanged" />
|
<assignee-filter :selected="selectedAssignees" @changed="onAssigneeChanged" />
|
||||||
@@ -43,6 +41,6 @@ function onStatusChanged(alias: string) {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.filter-grid {
|
.filter-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr auto auto auto;
|
grid-template-columns: 1fr auto auto auto;
|
||||||
}
|
}
|
||||||
</style>
|
</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,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 { useIssueStore } from '@/stores/issue.ts'
|
||||||
import type { Issue } from '@/api/issue.api.ts'
|
import type { Issue } from '@/api/issue.api.ts'
|
||||||
import AssigneeMenu from '@/components/AssigneeMenu.vue'
|
import AssigneeMenu from '@/components/AssigneeMenu.vue'
|
||||||
|
import { useIssueListOptions } from '@/composables/useIssueListOptions.ts'
|
||||||
|
import IssueType from '@/components/IssueType.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
issue: Issue
|
issue: Issue
|
||||||
@@ -13,18 +15,25 @@ interface Props {
|
|||||||
const { issue, selected } = defineProps<Props>()
|
const { issue, selected } = defineProps<Props>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
statusChanged: [issue: Issue]
|
statusChanged: [issue: Issue]
|
||||||
|
typeChanged: [issue: Issue]
|
||||||
priorityChanged: [issue: Issue]
|
priorityChanged: [issue: Issue]
|
||||||
assigneeChanged: [issue: Issue]
|
assigneeChanged: [issue: Issue]
|
||||||
toggleSelect: [id: number]
|
toggleSelect: [id: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const issueStore = useIssueStore()
|
const issueStore = useIssueStore()
|
||||||
|
const visible = useIssueListOptions()
|
||||||
|
|
||||||
const onStatusChanged = async (status: Issue['status']) => {
|
const onStatusChanged = async (status: Issue['status']) => {
|
||||||
const updated = await issueStore.update(issue.id, { status })
|
const updated = await issueStore.update(issue.id, { status })
|
||||||
if (updated) emit('statusChanged', updated)
|
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 onPriorityChanged = async (priority: Issue['priority']) => {
|
||||||
const updated = await issueStore.update(issue.id, { priority })
|
const updated = await issueStore.update(issue.id, { priority })
|
||||||
if (updated) emit('priorityChanged', updated)
|
if (updated) emit('priorityChanged', updated)
|
||||||
@@ -39,15 +48,29 @@ const onAssigneeChanged = async (assignee: string) => {
|
|||||||
<template>
|
<template>
|
||||||
<v-list-item class="bg-on-surface-variant" :value="issue.key">
|
<v-list-item class="bg-on-surface-variant" :value="issue.key">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="d-flex ga-4">
|
<div class="d-flex ga-4 min-w-0 mr-4">
|
||||||
<div>{{ issue.key }}</div>
|
<div class="flex-shrink-0">{{ issue.key }}</div>
|
||||||
<div class="d-flex ga-1 align-center">
|
<div class="d-flex ga-4 align-center min-w-0">
|
||||||
<v-icon
|
<v-icon
|
||||||
v-if="[3, 4].includes(issue.priority) && ['OPEN', 'IN_PROGRESS'].includes(issue.status)"
|
v-if="[3, 4].includes(issue.priority) && ['OPEN', 'IN_PROGRESS'].includes(issue.status)"
|
||||||
icon="mdi-alert-box"
|
icon="mdi-alert-box"
|
||||||
color="error"
|
color="error"
|
||||||
/>
|
/>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -60,17 +83,22 @@ const onAssigneeChanged = async (assignee: string) => {
|
|||||||
:model-value="selected"
|
:model-value="selected"
|
||||||
@update:model-value="emit('toggleSelect', issue.id)"
|
@update:model-value="emit('toggleSelect', issue.id)"
|
||||||
/>
|
/>
|
||||||
|
<issue-type v-if="visible.type" :value="issue.type" @changed="onTypeChanged" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #append>
|
<template #append>
|
||||||
<div class="d-flex ga-4 align-center">
|
<div class="d-flex ga-4 align-center">
|
||||||
<status-menu :value="issue.status" @changed="onStatusChanged" />
|
<status-menu v-if="visible.status" :value="issue.status" @changed="onStatusChanged" />
|
||||||
<priority-menu :value="issue.priority" @changed="onPriorityChanged" />
|
<priority-menu v-if="visible.priority" :value="issue.priority" @changed="onPriorityChanged" />
|
||||||
<assignee-menu :value="issue.assignee" @changed="onAssigneeChanged" />
|
<assignee-menu v-if="visible.assignee" :value="issue.assignee" @changed="onAssigneeChanged" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss"></style>
|
<style scoped lang="scss">
|
||||||
|
.min-w-0 {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
</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>
|
||||||
@@ -23,11 +23,11 @@ const menu = ref([
|
|||||||
value: 3,
|
value: 3,
|
||||||
props: { prependIcon: 'mdi-calendar', to: '/calendar', requiresAuth: true },
|
props: { prependIcon: 'mdi-calendar', to: '/calendar', requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
title: 'Games',
|
// title: 'Games',
|
||||||
value: 4,
|
// value: 4,
|
||||||
props: { prependIcon: 'mdi-view-dashboard', to: '/games', requiresAuth: false },
|
// props: { prependIcon: 'mdi-view-dashboard', to: '/games', requiresAuth: false },
|
||||||
},
|
// },
|
||||||
])
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
<script setup lang="ts">
|
||||||
import { reactive } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import { authClient } from '@/lib/auth-client.js'
|
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: '',
|
newPassword: '',
|
||||||
currentPassword: '',
|
OTP: '',
|
||||||
|
showOTP: false,
|
||||||
|
showPassword: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const changePassword = async () => {
|
const canReset = computed(() => reset.newPassword.length > 0 && reset.OTP.length === 6)
|
||||||
await authClient.changePassword({
|
|
||||||
newPassword: passwordData.newPassword,
|
watch(
|
||||||
currentPassword: passwordData.currentPassword,
|
() => reset.OTP,
|
||||||
revokeOtherSessions: true,
|
(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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-sheet>
|
<v-sheet border class="pa-4 d-flex flex-column ga-2">
|
||||||
<div class="text-uppercase">Change password</div>
|
<div class="d-flex ga-2 align-center justify-space-between">
|
||||||
<div class="d-flex ga-2">
|
<div class="text-uppercase text-title-large">Change password</div>
|
||||||
<v-text-field placeholder="current password" v-model="passwordData.currentPassword" />
|
<v-chip v-if="err" size="small" variant="flat" color="error" density="compact">{{ err }}</v-chip>
|
||||||
<v-text-field placeholder="new password" v-model="passwordData.newPassword" />
|
</div>
|
||||||
<v-icon-btn variant="tonal" icon="mdi-check" @click="changePassword" />
|
|
||||||
|
<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>
|
</div>
|
||||||
</v-sheet>
|
</v-sheet>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const select = (value: unknown) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="">
|
<div>
|
||||||
<v-menu>
|
<v-menu>
|
||||||
<template v-slot:activator="{ props: activatorProps }">
|
<template v-slot:activator="{ props: activatorProps }">
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -54,14 +54,15 @@ const select = (value: unknown) => {
|
|||||||
density="compact"
|
density="compact"
|
||||||
:icon="currentIcon"
|
:icon="currentIcon"
|
||||||
:color="currentColor"
|
:color="currentColor"
|
||||||
|
:title="currentLabel"
|
||||||
variant="text"
|
variant="text"
|
||||||
v-bind="activatorProps"
|
v-bind="activatorProps"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<v-btn v-else density="compact" class="px-1" variant="text" v-bind="activatorProps">
|
<v-btn v-else density="comfortable" slim v-bind="activatorProps">
|
||||||
<div class="d-flex align-center ga-1">
|
<div class="d-flex align-center ga-2">
|
||||||
<v-icon :color="currentColor" size="x-large" :icon="currentIcon"></v-icon>
|
<v-icon :color="currentColor" :icon="currentIcon" />
|
||||||
<span>{{ currentLabel }}</span>
|
{{ currentLabel }}
|
||||||
</div>
|
</div>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="projects.length" class="d-flex align-center ga-2">
|
<div v-if="projects.length" class="d-flex align-center ga-2 mr-2">
|
||||||
<div>Current project:</div>
|
<div>Current project:</div>
|
||||||
<v-menu>
|
<v-menu>
|
||||||
<template v-slot:activator="{ props }">
|
<template v-slot:activator="{ props }">
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const currentInitials = computed(() => (user ? initials(user) : '?'))
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="">
|
<div>
|
||||||
<div class="d-flex align-center ga-2">
|
<div class="d-flex align-center ga-2">
|
||||||
<v-btn rounded="circle" density="compact" icon variant="text">
|
<v-btn rounded="circle" density="compact" icon variant="text">
|
||||||
<v-avatar
|
<v-avatar
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { authClient } from '@/lib/auth-client.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 {
|
interface Session {
|
||||||
id: string
|
id: string
|
||||||
@@ -13,6 +14,8 @@ interface Session {
|
|||||||
userAgent?: string | null | undefined
|
userAgent?: string | null | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const currentSession = authClient.useSession()
|
||||||
const sessions = ref<Session[]>([])
|
const sessions = ref<Session[]>([])
|
||||||
|
|
||||||
const listSessions = async () => {
|
const listSessions = async () => {
|
||||||
@@ -24,40 +27,60 @@ const listSessions = async () => {
|
|||||||
sessions.value = result.data
|
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) => {
|
const revokeSession = async (token: string) => {
|
||||||
await authClient.revokeSession({
|
if (token === currentSession.value.data?.session.token) return signOutToSignIn()
|
||||||
|
|
||||||
|
const { error } = await authClient.revokeSession({
|
||||||
token: token,
|
token: token,
|
||||||
})
|
})
|
||||||
|
if (!error) await listSessions()
|
||||||
}
|
}
|
||||||
|
|
||||||
const revokeOtherSessions = async () => {
|
const revokeOtherSessions = async () => {
|
||||||
await authClient.revokeOtherSessions()
|
const { error } = await authClient.revokeOtherSessions()
|
||||||
|
if (!error) await listSessions()
|
||||||
}
|
}
|
||||||
|
|
||||||
const revokeAllSessions = async () => {
|
const revokeAllSessions = async () => {
|
||||||
await authClient.revokeSessions()
|
await authClient.revokeSessions()
|
||||||
|
await signOutToSignIn()
|
||||||
}
|
}
|
||||||
|
|
||||||
watchEffect(async () => {
|
onMounted(listSessions)
|
||||||
await listSessions()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<v-sheet border class="pa-4 d-flex flex-column ga-2">
|
||||||
<div class="text-uppercase">Active sessions</div>
|
<div class="text-uppercase text-title-large">Active sessions</div>
|
||||||
<div class="mb-2">
|
|
||||||
<v-btn prepend-icon="mdi-skull" @click="revokeOtherSessions()">revoke other sessions</v-btn>
|
<v-list border class="pa-0">
|
||||||
</div>
|
|
||||||
<v-list border density="compact">
|
|
||||||
<v-list-item class="hover:bg-primary-300" v-for="session in sessions" :key="session.id">
|
<v-list-item class="hover:bg-primary-300" v-for="session in sessions" :key="session.id">
|
||||||
<div class="text-truncate">{{ session.userAgent }}</div>
|
<div class="text-truncate">{{ session.userAgent }}</div>
|
||||||
<template #append>
|
<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>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</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>
|
</div>
|
||||||
|
</v-sheet>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss"></style>
|
<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>
|
||||||
@@ -3,33 +3,34 @@ import { useStatusStore } from '@/stores/status.ts'
|
|||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
status?: string | null
|
status?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const { status } = defineProps<Props>()
|
const { status = [] } = defineProps<Props>()
|
||||||
const emit = defineEmits<{ changed: [value: string] }>()
|
const emit = defineEmits<{ changed: [value: string[]] }>()
|
||||||
const statusStore = useStatusStore()
|
const statusStore = useStatusStore()
|
||||||
const selected = ref(status)
|
const selected = ref<string[]>([...status])
|
||||||
const onUpdate = (val: unknown) => emit('changed', val as string)
|
|
||||||
|
// 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(
|
watch(
|
||||||
() => status,
|
() => status,
|
||||||
(val) => {
|
(val) => {
|
||||||
selected.value = val
|
selected.value = [...val]
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-btn-toggle
|
<v-btn-toggle v-model="selected" multiple border density="compact" variant="flat" @update:model-value="onUpdate">
|
||||||
v-model="selected"
|
|
||||||
divided
|
|
||||||
border
|
|
||||||
size="default"
|
|
||||||
density="comfortable"
|
|
||||||
variant="text"
|
|
||||||
@update:model-value="onUpdate"
|
|
||||||
>
|
|
||||||
<v-btn slim v-for="el in statusStore.statuses" :color="el.color" :value="el.alias" :key="el.alias" rounded="0">
|
<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>
|
<div class="text-default">{{ el.name }}</div>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const select = (value: unknown) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="">
|
<div>
|
||||||
<v-menu>
|
<v-menu>
|
||||||
<template v-slot:activator="{ props: activatorProps }">
|
<template v-slot:activator="{ props: activatorProps }">
|
||||||
<v-btn
|
<v-btn
|
||||||
|
|||||||
@@ -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">
|
<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 {
|
interface Props {
|
||||||
user?: {
|
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 { user } = defineProps<Props>()
|
||||||
|
|
||||||
|
const snackbarStore = useSnackbarStore()
|
||||||
|
const input = useTemplateRef<HTMLInputElement>('input')
|
||||||
|
const avatarSaving = ref(false)
|
||||||
|
|
||||||
const avatar = computed(() => {
|
const avatar = computed(() => {
|
||||||
return user?.image ?? undefined
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<v-sheet border class="pa-4 d-flex flex-column ga-2">
|
||||||
<div v-if="user" class="d-flex ga-4">
|
<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>
|
<v-list density="compact">
|
||||||
<div>name: {{ user.name }}</div>
|
<v-list-item prepend-icon="mdi-upload" title="Upload photo" :disabled="avatarSaving" @click="openPicker" />
|
||||||
<div>email: {{ user.email }}</div>
|
<v-list-item
|
||||||
<div>created at: {{ user.createdAt }}</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
<v-skeleton-loader v-else type="avatar, list-item-three-line" />
|
<v-skeleton-loader v-else type="avatar, list-item-three-line" />
|
||||||
</div>
|
</v-sheet>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss"></style>
|
<style scoped lang="scss"></style>
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,16 +7,26 @@ import BackButton from '@/components/BackButton.vue'
|
|||||||
import { useThemeMode } from '@/composables/useThemeMode.ts'
|
import { useThemeMode } from '@/composables/useThemeMode.ts'
|
||||||
import { THEME_ICONS } from '@/lib/theme.ts'
|
import { THEME_ICONS } from '@/lib/theme.ts'
|
||||||
import NavigationMenu from '@/components/NavigationMenu.vue'
|
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 router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const session = authClient.useSession()
|
const session = authClient.useSession()
|
||||||
const { mode: themeMode, cycleThemeMode } = useThemeMode()
|
const { mode: themeMode, cycleThemeMode } = useThemeMode()
|
||||||
|
const push = usePushNotifications()
|
||||||
|
|
||||||
const title = 'Ulitka'
|
const title = 'Ulitka'
|
||||||
|
|
||||||
const isAuth = computed(() => !!session.value.data)
|
const isAuth = computed(() => !!session.value.data)
|
||||||
|
|
||||||
|
/** 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() {
|
function goToSignIn() {
|
||||||
router.push({ path: '/sign-in' })
|
router.push({ path: '/sign-in' })
|
||||||
}
|
}
|
||||||
@@ -30,6 +40,8 @@ function goToProfile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function goToSignOut() {
|
async function goToSignOut() {
|
||||||
|
// Пока сессия жива — иначе бэкенд не даст удалить токен и пуши продолжат приходить в этот браузер
|
||||||
|
await push.disable()
|
||||||
await authClient.signOut({
|
await authClient.signOut({
|
||||||
fetchOptions: {
|
fetchOptions: {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -40,7 +52,7 @@ async function goToSignOut() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const goToSettings = () => {
|
const goToSettings = () => {
|
||||||
router.push({ name: 'settings' })
|
router.push({ path: '/settings' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const fluid = computed(() => {
|
const fluid = computed(() => {
|
||||||
@@ -63,9 +75,13 @@ const fluid = computed(() => {
|
|||||||
<v-btn @click="goToSignUp">Sign Up</v-btn>
|
<v-btn @click="goToSignUp">Sign Up</v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="session.data" class="d-flex align-center">
|
||||||
|
<project-menu />
|
||||||
|
|
||||||
|
<notifications-list />
|
||||||
|
|
||||||
<v-btn rounded="0" :icon="THEME_ICONS[themeMode]" :title="`Theme: ${themeMode}`" @click="cycleThemeMode" />
|
<v-btn rounded="0" :icon="THEME_ICONS[themeMode]" :title="`Theme: ${themeMode}`" @click="cycleThemeMode" />
|
||||||
|
|
||||||
<div v-if="session.data">
|
|
||||||
<v-menu>
|
<v-menu>
|
||||||
<template v-slot:activator="{ props }">
|
<template v-slot:activator="{ props }">
|
||||||
<v-btn rounded="0" icon v-bind="props" class="mr-4">
|
<v-btn rounded="0" icon v-bind="props" class="mr-4">
|
||||||
@@ -75,7 +91,8 @@ const fluid = computed(() => {
|
|||||||
<v-list nav density="compact" class="pa-1">
|
<v-list nav density="compact" class="pa-1">
|
||||||
<v-list-item density="compact">
|
<v-list-item density="compact">
|
||||||
<v-sheet border class="d-flex align-center ga-2 pa-2">
|
<v-sheet border class="d-flex align-center ga-2 pa-2">
|
||||||
<v-avatar rounded="circle" color="primary">A</v-avatar>
|
<!-- 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>
|
||||||
<div>{{ session.data.user.name }}</div>
|
<div>{{ session.data.user.name }}</div>
|
||||||
<div class="text-label-medium">{{ session.data.user.email }}</div>
|
<div class="text-label-medium">{{ session.data.user.email }}</div>
|
||||||
|
|||||||
+21
-1
@@ -1,9 +1,21 @@
|
|||||||
|
import router from '@/router'
|
||||||
|
import { authClient } from '@/lib/auth-client.ts'
|
||||||
|
|
||||||
interface ApiEnvelope<T> {
|
interface ApiEnvelope<T> {
|
||||||
data: T | null
|
data: T | null
|
||||||
code: number
|
code: number
|
||||||
message?: string | string[]
|
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> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
// FormData needs the browser to set multipart Content-Type with its boundary
|
// FormData needs the browser to set multipart Content-Type with its boundary
|
||||||
const isForm = init?.body instanceof FormData
|
const isForm = init?.body instanceof FormData
|
||||||
@@ -15,6 +27,12 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
|
|
||||||
if (res.status === 204) return undefined as T
|
if (res.status === 204) return undefined as 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
|
// A proxy (e.g. nginx 413) may answer with a non-JSON body
|
||||||
const body = (await res.json().catch(() => ({}))) as Partial<ApiEnvelope<T>>
|
const body = (await res.json().catch(() => ({}))) as Partial<ApiEnvelope<T>>
|
||||||
|
|
||||||
@@ -39,6 +57,8 @@ export const apiClient = {
|
|||||||
get: <T>(path: string) => request<T>(path),
|
get: <T>(path: string) => request<T>(path),
|
||||||
post: <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
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) }),
|
patch: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||||
|
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 }),
|
upload: <T>(path: string, form: FormData) => request<T>(path, { method: 'POST', body: form }),
|
||||||
delete: (path: string) => request<void>(path, { method: 'DELETE' }),
|
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 { createAuthClient } from 'better-auth/vue'
|
||||||
|
import { emailOTPClient } from 'better-auth/client/plugins'
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
baseURL: window.location.origin,
|
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 : '/'
|
||||||
|
}
|
||||||
+5
-3
@@ -4,11 +4,11 @@ import { issuesRoutes } from '@/router/issues.ts'
|
|||||||
import HomeView from '@/views/HomeView.vue'
|
import HomeView from '@/views/HomeView.vue'
|
||||||
import VerifyEmailView from '@/views/users/VerifyEmailView.vue'
|
import VerifyEmailView from '@/views/users/VerifyEmailView.vue'
|
||||||
import SignUpView from '@/views/users/SignUpView.vue'
|
import SignUpView from '@/views/users/SignUpView.vue'
|
||||||
import SignInView from '@/views/users/SignInView.vue'
|
|
||||||
import { authClient } from '@/lib/auth-client.ts'
|
import { authClient } from '@/lib/auth-client.ts'
|
||||||
import { gamesRoutes } from '@/router/games.ts'
|
import { gamesRoutes } from '@/router/games.ts'
|
||||||
import { settingsRoutes } from '@/router/settings.ts'
|
import { settingsRoutes } from '@/router/settings.ts'
|
||||||
import { calendarRoutes } from '@/router/calendar.ts'
|
import { calendarRoutes } from '@/router/calendar.ts'
|
||||||
|
import SignInView from '@/views/users/SignInView.vue'
|
||||||
|
|
||||||
declare module 'vue-router' {
|
declare module 'vue-router' {
|
||||||
interface RouteMeta {
|
interface RouteMeta {
|
||||||
@@ -27,16 +27,18 @@ const router = createRouter({
|
|||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: HomeView,
|
component: HomeView,
|
||||||
meta: { layout: 'Default', fluid: false },
|
meta: { layout: 'Default', fluid: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'sign-in',
|
name: 'sign-in',
|
||||||
path: '/sign-in',
|
path: '/sign-in',
|
||||||
component: SignInView,
|
component: SignInView,
|
||||||
|
meta: { breadcrumb: 'Sign-In' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/sign-up',
|
path: '/sign-up',
|
||||||
component: SignUpView,
|
component: SignUpView,
|
||||||
|
meta: { breadcrumb: 'Sign-up' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/verify-email',
|
path: '/verify-email',
|
||||||
@@ -45,7 +47,7 @@ const router = createRouter({
|
|||||||
{
|
{
|
||||||
path: '/profile',
|
path: '/profile',
|
||||||
component: ProfileView,
|
component: ProfileView,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true, breadcrumb: 'Profile' },
|
||||||
},
|
},
|
||||||
issuesRoutes,
|
issuesRoutes,
|
||||||
calendarRoutes,
|
calendarRoutes,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { RouteRecordRaw } from 'vue-router'
|
|||||||
import ProjectsListView from '@/views/project/ProjectsListView.vue'
|
import ProjectsListView from '@/views/project/ProjectsListView.vue'
|
||||||
import SettingsView from '@/views/settings/SettingsView.vue'
|
import SettingsView from '@/views/settings/SettingsView.vue'
|
||||||
import GeneralSettingsView from '@/views/settings/GeneralSettingsView.vue'
|
import GeneralSettingsView from '@/views/settings/GeneralSettingsView.vue'
|
||||||
|
import TagsSettingsView from '@/views/settings/TagsSettingsView.vue'
|
||||||
|
|
||||||
export const settingsRoutes: RouteRecordRaw = {
|
export const settingsRoutes: RouteRecordRaw = {
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
@@ -21,5 +22,11 @@ export const settingsRoutes: RouteRecordRaw = {
|
|||||||
component: ProjectsListView,
|
component: ProjectsListView,
|
||||||
meta: { breadcrumb: 'Projects' },
|
meta: { breadcrumb: 'Projects' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'tags',
|
||||||
|
name: 'settings-tags',
|
||||||
|
component: TagsSettingsView,
|
||||||
|
meta: { breadcrumb: 'Tags' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
+8
-1
@@ -120,7 +120,14 @@ export const useIssueStore = defineStore('item', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { stats, dailyStats, list, calendar, getByKey, create, update, remove, getStat, getDailyStat }
|
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 */
|
/** Local YYYY-MM-DD `days` days before today */
|
||||||
|
|||||||
@@ -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() {
|
async function restoreCurrent() {
|
||||||
const id = readStoredProjectId()
|
const id = readStoredProjectId()
|
||||||
if (!id) return
|
if (!id) return
|
||||||
@@ -118,5 +125,6 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
clearError,
|
clearError,
|
||||||
select,
|
select,
|
||||||
restoreCurrent,
|
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()
|
statuses.value = await statusesList()
|
||||||
}
|
}
|
||||||
|
|
||||||
return { statuses, list }
|
function reset() {
|
||||||
|
statuses.value = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return { statuses, list, reset }
|
||||||
})
|
})
|
||||||
@@ -10,7 +10,6 @@ import type { Issue } from '@/api/issue.api.ts'
|
|||||||
import type { Project } from '@/api/project.api.ts'
|
import type { Project } from '@/api/project.api.ts'
|
||||||
import DashboardCountCard from '@/components/DashboardCountCard.vue'
|
import DashboardCountCard from '@/components/DashboardCountCard.vue'
|
||||||
import LabelText from '@/components/LabelText.vue'
|
import LabelText from '@/components/LabelText.vue'
|
||||||
import ProjectMenu from '@/components/ProjectMenu.vue'
|
|
||||||
import IssueStatusChart from '@/components/IssueStatusChart.vue'
|
import IssueStatusChart from '@/components/IssueStatusChart.vue'
|
||||||
import IssueActivityChart from '@/components/IssueActivityChart.vue'
|
import IssueActivityChart from '@/components/IssueActivityChart.vue'
|
||||||
|
|
||||||
@@ -47,7 +46,13 @@ async function loadMyIssues(userId: string, projectId: string | null) {
|
|||||||
const requestId = ++myIssuesRequestId
|
const requestId = ++myIssuesRequestId
|
||||||
loadingMyIssues.value = true
|
loadingMyIssues.value = true
|
||||||
try {
|
try {
|
||||||
const res = await issueStore.list({ page: 1, limit: 50, assigneeId: userId, projectId, status: ACTIVE_STATUSES })
|
const res = await issueStore.list({
|
||||||
|
page: 1,
|
||||||
|
limit: 7,
|
||||||
|
assigneeId: userId,
|
||||||
|
projectId: projectId,
|
||||||
|
status: ACTIVE_STATUSES,
|
||||||
|
})
|
||||||
if (requestId !== myIssuesRequestId) return
|
if (requestId !== myIssuesRequestId) return
|
||||||
myIssues.value = res.list
|
myIssues.value = res.list
|
||||||
} finally {
|
} finally {
|
||||||
@@ -140,8 +145,6 @@ function onClickCard(status?: string) {
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="session.data" class="d-flex flex-column ga-2">
|
<div v-if="session.data" class="d-flex flex-column ga-2">
|
||||||
<project-menu />
|
|
||||||
|
|
||||||
<div class="dashboard_grid">
|
<div class="dashboard_grid">
|
||||||
<dashboard-count-card label="Total issues" :value="stats?.total ?? 0" @click="onClickCard()" />
|
<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="Open" :value="statCount('OPEN')" @click="onClickCard('OPEN')" />
|
||||||
@@ -236,7 +239,7 @@ function onClickCard(status?: string) {
|
|||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-divider />
|
<v-divider />
|
||||||
<v-list v-if="projects.length" density="compact">
|
<v-list v-if="projects.length" density="compact">
|
||||||
<v-list-item v-for="project in projects.slice(0, 6)" :key="project.id" @click="openProject(project)">
|
<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" />
|
<label-text :label="project.key" :text="project.name" />
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ const { ckey } = defineProps<Props>()
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<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">
|
<v-sheet border class="h-100 d-flex flex-column">
|
||||||
<issue-details :ckey="ckey" />
|
<issue-details :ckey="ckey" />
|
||||||
</v-sheet>
|
</v-sheet>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss"></style>
|
<style scoped lang="scss"></style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue'
|
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useIssueStore } from '@/stores/issue'
|
import { useIssueStore } from '@/stores/issue'
|
||||||
import { useProjectStore } from '@/stores/project.ts'
|
import { useProjectStore } from '@/stores/project.ts'
|
||||||
@@ -12,6 +12,7 @@ import IssueStats from '@/components/IssueStats.vue'
|
|||||||
import IssueFilter from '@/components/IssueFilter.vue'
|
import IssueFilter from '@/components/IssueFilter.vue'
|
||||||
import SplitHandle from '@/components/SplitHandle.vue'
|
import SplitHandle from '@/components/SplitHandle.vue'
|
||||||
import { useSplitPane } from '@/composables/useSplitPane.ts'
|
import { useSplitPane } from '@/composables/useSplitPane.ts'
|
||||||
|
import IssueListOptions from '@/components/IssueListOptions.vue'
|
||||||
|
|
||||||
const issueStore = useIssueStore()
|
const issueStore = useIssueStore()
|
||||||
const issueDetailsRef = ref<InstanceType<typeof IssueDetails>>()
|
const issueDetailsRef = ref<InstanceType<typeof IssueDetails>>()
|
||||||
@@ -29,7 +30,7 @@ const serverItems = ref<Issue[]>([])
|
|||||||
|
|
||||||
const text = ref<string | null>(null)
|
const text = ref<string | null>(null)
|
||||||
const assigneeId = ref<string | null>(null)
|
const assigneeId = ref<string | null>(null)
|
||||||
const status = ref<string | null>(null)
|
const status = ref<string[]>([])
|
||||||
const selected = ref<[string] | []>([])
|
const selected = ref<[string] | []>([])
|
||||||
const batch = ref<number[]>([])
|
const batch = ref<number[]>([])
|
||||||
const deleting = ref(false)
|
const deleting = ref(false)
|
||||||
@@ -49,84 +50,98 @@ async function fetch() {
|
|||||||
totalItems.value = res.meta.totalCount
|
totalItems.value = res.meta.totalCount
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
// The URL is the source of truth: handlers only write the query, this watcher loads the list
|
||||||
syncQuery()
|
|
||||||
await fetch()
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncQuery() {
|
function syncQuery() {
|
||||||
const query: Record<string, string> = { page: String(page.value) }
|
const query: Record<string, string> = { page: String(page.value) }
|
||||||
|
|
||||||
if (text.value) query.text = text.value
|
if (text.value) query.text = text.value
|
||||||
if (assigneeId.value) query.assignee = assigneeId.value
|
if (assigneeId.value) query.assignee = assigneeId.value
|
||||||
if (status.value) query.status = status.value
|
if (status.value.length) query.status = status.value.join(',')
|
||||||
if (selected.value.length) query.selected_issue = selected.value[0]
|
if (selected.value.length) query.selected_issue = selected.value[0]
|
||||||
|
|
||||||
router.replace({ query })
|
router.replace({ query })
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.query,
|
[
|
||||||
(query) => {
|
() => route.query.page,
|
||||||
page.value = query.page ? Number(query.page) : 1
|
() => route.query.text,
|
||||||
text.value = query.text ? String(query.text) : null
|
() => route.query.assignee,
|
||||||
assigneeId.value = query.assignee ? String(query.assignee) : null
|
() => route.query.status,
|
||||||
status.value = query.status ? String(query.status) : null
|
currentProjectId,
|
||||||
selected.value = query.selected_issue ? [String(query.selected_issue)] : []
|
],
|
||||||
|
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 },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(selected, () => syncQuery())
|
watch(selected, () => syncQuery())
|
||||||
|
|
||||||
watch(currentProjectId, async () => {
|
watch(currentProjectId, (projectId) => issueStore.getStat(projectId), { immediate: true })
|
||||||
page.value = 1
|
|
||||||
|
|
||||||
clearBatch()
|
|
||||||
close()
|
|
||||||
await load()
|
|
||||||
await issueStore.getStat(currentProjectId.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await load()
|
|
||||||
await issueStore.getStat(currentProjectId.value)
|
|
||||||
})
|
|
||||||
const splitContainer = useTemplateRef<HTMLElement>('splitContainer')
|
const splitContainer = useTemplateRef<HTMLElement>('splitContainer')
|
||||||
const split = useSplitPane(splitContainer, { storageKey: 'issues-list', min: 0.25, max: 0.75 })
|
const split = useSplitPane(splitContainer, { storageKey: 'issues-list', min: 0.25, max: 0.75 })
|
||||||
const gridTemplate = computed(() => (selected.value.length ? split.gridTemplate.value : '1fr'))
|
const gridTemplate = computed(() => (selected.value.length ? split.gridTemplate.value : '1fr'))
|
||||||
const goToIssue = (key: string[]) => router.push(`/issues/${key[0]}`)
|
const goToIssue = (key: string[]) => router.push(`/issues/${key[0]}`)
|
||||||
|
|
||||||
async function updatePage(val: number) {
|
function updatePage(val: number) {
|
||||||
page.value = val
|
page.value = val
|
||||||
await load()
|
syncQuery()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onTextChanged(value: string | null) {
|
function onTextChanged(value: string | null) {
|
||||||
text.value = value
|
text.value = value
|
||||||
page.value = 1
|
page.value = 1
|
||||||
await load()
|
syncQuery()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onAssigneeChanged(value: string | null) {
|
function onAssigneeChanged(value: string | null) {
|
||||||
assigneeId.value = value
|
assigneeId.value = value
|
||||||
page.value = 1
|
page.value = 1
|
||||||
await load()
|
syncQuery()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onStatusChanged(value: string | null) {
|
function onStatusChanged(value: string[]) {
|
||||||
status.value = value
|
status.value = value
|
||||||
page.value = 1
|
page.value = 1
|
||||||
await load()
|
syncQuery()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onClear() {
|
function onClear() {
|
||||||
text.value = null
|
text.value = null
|
||||||
assigneeId.value = null
|
assigneeId.value = null
|
||||||
status.value = null
|
status.value = []
|
||||||
page.value = 1
|
page.value = 1
|
||||||
|
syncQuery()
|
||||||
await load()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
@@ -188,6 +203,7 @@ async function onDeleteBatch() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="d-flex flex-column ga-2 h-100">
|
<div class="d-flex flex-column ga-2 h-100">
|
||||||
|
<div class="text-uppercase text-title-large">Issues</div>
|
||||||
<issue-filter
|
<issue-filter
|
||||||
:text="text"
|
:text="text"
|
||||||
:assignee-id="assigneeId"
|
:assignee-id="assigneeId"
|
||||||
@@ -220,7 +236,7 @@ async function onDeleteBatch() {
|
|||||||
<template #append>
|
<template #append>
|
||||||
<div class="d-flex ga-4 align-center">
|
<div class="d-flex ga-4 align-center">
|
||||||
<issue-stats />
|
<issue-stats />
|
||||||
<v-btn density="compact" icon="mdi-dots-horizontal"></v-btn>
|
<issue-list-options />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
@@ -233,6 +249,7 @@ async function onDeleteBatch() {
|
|||||||
:issue="el"
|
:issue="el"
|
||||||
:selected="batch.includes(el.id)"
|
:selected="batch.includes(el.id)"
|
||||||
@status-changed="onIssueUpdated"
|
@status-changed="onIssueUpdated"
|
||||||
|
@type-changed="onIssueUpdated"
|
||||||
@priority-changed="onIssueUpdated"
|
@priority-changed="onIssueUpdated"
|
||||||
@assignee-changed="onIssueUpdated"
|
@assignee-changed="onIssueUpdated"
|
||||||
@toggle-select="toggleBatchSelect"
|
@toggle-select="toggleBatchSelect"
|
||||||
@@ -291,6 +308,7 @@ async function onDeleteBatch() {
|
|||||||
ref="issueDetailsRef"
|
ref="issueDetailsRef"
|
||||||
:ckey="selected[0]"
|
:ckey="selected[0]"
|
||||||
@status-changed="onIssueUpdated"
|
@status-changed="onIssueUpdated"
|
||||||
|
@type-changed="onIssueUpdated"
|
||||||
@assignee-changed="onIssueUpdated"
|
@assignee-changed="onIssueUpdated"
|
||||||
@priority-changed="onIssueUpdated"
|
@priority-changed="onIssueUpdated"
|
||||||
@title-changed="onIssueUpdated"
|
@title-changed="onIssueUpdated"
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ interface CalendarRange {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const UNSCHEDULED_LIMIT = 20
|
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 DRAG_TYPE = 'application/x-ulitka-issue'
|
||||||
|
|
||||||
const issueStore = useIssueStore()
|
const issueStore = useIssueStore()
|
||||||
@@ -118,6 +120,7 @@ async function loadUnscheduled(page = 1) {
|
|||||||
limit: UNSCHEDULED_LIMIT,
|
limit: UNSCHEDULED_LIMIT,
|
||||||
text: search.value,
|
text: search.value,
|
||||||
projectId: currentProjectId.value,
|
projectId: currentProjectId.value,
|
||||||
|
status: UNSCHEDULED_STATUSES,
|
||||||
hasDueDate: false,
|
hasDueDate: false,
|
||||||
})
|
})
|
||||||
if (requestId !== unscheduledRequestId) return
|
if (requestId !== unscheduledRequestId) return
|
||||||
@@ -158,7 +161,7 @@ async function setDueDate(issue: Issue, date: string | null) {
|
|||||||
if (updated.dueDate) {
|
if (updated.dueDate) {
|
||||||
scheduled.value.push(updated)
|
scheduled.value.push(updated)
|
||||||
if (wasUnscheduled) unscheduledTotal.value--
|
if (wasUnscheduled) unscheduledTotal.value--
|
||||||
} else {
|
} else if (UNSCHEDULED_STATUSES.includes(updated.status)) {
|
||||||
unscheduled.value.unshift(updated)
|
unscheduled.value.unshift(updated)
|
||||||
unscheduledTotal.value++
|
unscheduledTotal.value++
|
||||||
}
|
}
|
||||||
@@ -334,21 +337,27 @@ function goToday() {
|
|||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="issue in unscheduled"
|
v-for="issue in unscheduled"
|
||||||
:key="issue.id"
|
:key="issue.id"
|
||||||
class="cursor-grab"
|
class="cursor-grab ma-1 border bg-surface-light px-2 py-0"
|
||||||
:class="{ 'opacity-50': savingIds.includes(issue.id) }"
|
:class="{ 'opacity-50': savingIds.includes(issue.id) }"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@dragstart="onDragStart($event, issue)"
|
@dragstart="onDragStart($event, issue)"
|
||||||
@dragend="onDragEnd"
|
@dragend="onDragEnd"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-icon icon="mdi-drag-vertical" size="small" class="mr-1" />
|
<v-icon icon="mdi-drag-vertical" size="small" />
|
||||||
</template>
|
</template>
|
||||||
<v-list-item-title>
|
|
||||||
<router-link :to="`/issues/${issue.key}`" class="text-link font-weight-bold mr-1" draggable="false">
|
<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 }}
|
{{ issue.key }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<span :title="issue.title">{{ issue.title }}</span>
|
</div>
|
||||||
</v-list-item-title>
|
|
||||||
<template #append>
|
<template #append>
|
||||||
<v-btn
|
<v-btn
|
||||||
density="compact"
|
density="compact"
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ const tab = ref(0)
|
|||||||
<v-tabs v-model="tab">
|
<v-tabs v-model="tab">
|
||||||
<v-tab value="common" to="/settings">General</v-tab>
|
<v-tab value="common" to="/settings">General</v-tab>
|
||||||
<v-tab value="projects" to="/settings/projects">Projects</v-tab>
|
<v-tab value="projects" to="/settings/projects">Projects</v-tab>
|
||||||
|
<v-tab value="tags" to="/settings/tags">Tags</v-tab>
|
||||||
</v-tabs>
|
</v-tabs>
|
||||||
|
|
||||||
<div class="pa-6">
|
<div class="pa-4">
|
||||||
<router-view />
|
<router-view />
|
||||||
</div>
|
</div>
|
||||||
</v-sheet>
|
</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 SessionsList from '@/components/SessionsList.vue'
|
||||||
import UserProfile from '@/components/UserProfile.vue'
|
import UserProfile from '@/components/UserProfile.vue'
|
||||||
import PasswordChange from '@/components/PasswordChange.vue'
|
import PasswordChange from '@/components/PasswordChange.vue'
|
||||||
|
import NotificationSettings from '@/components/NotificationSettings.vue'
|
||||||
|
|
||||||
const session = authClient.useSession()
|
const session = authClient.useSession()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
<div class="text-headline-medium text-uppercase">profile</div>
|
||||||
|
|
||||||
<user-profile :user="session.data?.user" />
|
<user-profile :user="session.data?.user" />
|
||||||
|
|
||||||
<password-change />
|
<password-change />
|
||||||
|
<notification-settings />
|
||||||
<sessions-list />
|
<sessions-list />
|
||||||
</v-sheet>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss"></style>
|
<style scoped lang="scss"></style>
|
||||||
@@ -1,54 +1,30 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { authClient } from '@/lib/auth-client.js'
|
import SignInOtp from '@/components/SignInOtp.vue'
|
||||||
|
import SignInEmail from '@/components/SignInEmail.vue'
|
||||||
|
import SignBlock from '@/components/SignBlock.vue'
|
||||||
|
|
||||||
const REMEMBERED_EMAIL_KEY = 'rememberedEmail'
|
const tab = ref('otp')
|
||||||
|
|
||||||
const signInData = reactive({
|
|
||||||
email: localStorage.getItem(REMEMBERED_EMAIL_KEY) ?? '',
|
|
||||||
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: '/',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
if (signInData.rememberMe) {
|
|
||||||
localStorage.setItem(REMEMBERED_EMAIL_KEY, signInData.email)
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem(REMEMBERED_EMAIL_KEY)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(data)
|
|
||||||
console.log(error)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-row>
|
<sign-block>
|
||||||
<v-col cols="12" md="6" sm="8" offset-sm="2" offset-md="3" offset="0">
|
<v-sheet>
|
||||||
<div class="d-flex flex-column ga-2">
|
<v-tabs v-model="tab" color="primary" density="compact" align-tabs="center">
|
||||||
<div class="text-uppercase">sign in</div>
|
<v-tab prepend-icon="mdi-key" value="otp">OTP</v-tab>
|
||||||
<v-text-field prepend-inner-icon="mdi-email" placeholder="E-mail" v-model="signInData.email" />
|
<v-tab prepend-icon="mdi-mail" value="email">Email</v-tab>
|
||||||
<v-text-field prepend-inner-icon="mdi-lock" placeholder="Password" v-model="signInData.password" />
|
</v-tabs>
|
||||||
<div class="d-flex ga-2 align-center">
|
|
||||||
<v-switch color="info" v-model="signInData.rememberMe" label="Remember me" />
|
<v-tabs-window v-model="tab">
|
||||||
<v-spacer />
|
<v-tabs-window-item value="otp">
|
||||||
<div>
|
<sign-in-otp />
|
||||||
<v-btn prepend-icon="mdi-login" density="comfortable" color="primary" @click="signIn">SIGN IN</v-btn>
|
</v-tabs-window-item>
|
||||||
</div>
|
<v-tabs-window-item value="email">
|
||||||
</div>
|
<sign-in-email />
|
||||||
</div>
|
</v-tabs-window-item>
|
||||||
</v-col>
|
</v-tabs-window>
|
||||||
</v-row>
|
</v-sheet>
|
||||||
|
</sign-block>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss"></style>
|
<style scoped lang="scss"></style>
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { authClient } from '@/lib/auth-client.js'
|
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({
|
const signUpData = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '', //vadim.olonin@gmail.com
|
||||||
password: '',
|
password: '',
|
||||||
})
|
})
|
||||||
|
const err = ref('')
|
||||||
|
|
||||||
const signUp = async () => {
|
const signUp = async () => {
|
||||||
const { data, error } = await authClient.signUp.email({
|
const { data, error } = await authClient.signUp.email({
|
||||||
@@ -14,32 +17,56 @@ const signUp = async () => {
|
|||||||
email: signUpData.email,
|
email: signUpData.email,
|
||||||
password: signUpData.password,
|
password: signUpData.password,
|
||||||
// image: 'https://example.com/image.png',
|
// image: 'https://example.com/image.png',
|
||||||
callbackURL: 'https://madsky.ru',
|
callbackURL: window.location.origin,
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(data)
|
if (error) {
|
||||||
console.log(error)
|
if (error.message) {
|
||||||
|
err.value = error.message
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-row>
|
<sign-block>
|
||||||
<v-col cols="12" md="6" sm="8" offset-sm="2" offset-md="3" offset="0">
|
<sign-form>
|
||||||
<div class="d-flex flex-column ga-2">
|
<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>
|
<div class="text-uppercase">sign up</div>
|
||||||
<v-text-field placeholder="Name" prepend-inner-icon="mdi-account" v-model="signUpData.name" />
|
<v-chip v-if="err" size="small" variant="flat" color="error" density="compact">{{ err }}</v-chip>
|
||||||
<v-text-field placeholder="E-mail" prepend-inner-icon="mdi-email" v-model="signUpData.email" />
|
</div>
|
||||||
<v-text-field placeholder="Password" prepend-inner-icon="mdi-lock" v-model="signUpData.password" />
|
<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 class="d-flex ga-2 align-center">
|
||||||
<div>
|
<div>
|
||||||
<v-btn @click="signUp" color="primary" density="comfortable">SIGN UP</v-btn>
|
<v-btn @click="signUp" variant="flat" color="primary" density="comfortable">SIGN UP</v-btn>
|
||||||
</div>
|
</div>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<router-link to="/verify-email" class="text-warning text-decoration-none"> Verify Email </router-link>
|
<router-link to="/verify-email" class="text-warning text-decoration-none"> Verify Email </router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-col>
|
</sign-form>
|
||||||
</v-row>
|
</sign-block>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss"></style>
|
<style scoped lang="scss"></style>
|
||||||
@@ -1,35 +1,53 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { authClient } from '@/lib/auth-client.js'
|
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({
|
const verifyData = reactive({
|
||||||
email: '',
|
email: '',
|
||||||
})
|
})
|
||||||
|
const err = ref('')
|
||||||
|
|
||||||
const verify = async () => {
|
const verify = async () => {
|
||||||
const { data, error } = await authClient.sendVerificationEmail({
|
const { data, error } = await authClient.sendVerificationEmail({
|
||||||
email: verifyData.email,
|
email: verifyData.email,
|
||||||
callbackURL: 'https://madsky.ru',
|
callbackURL: window.location.origin,
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(data)
|
console.log(data)
|
||||||
console.log(error)
|
console.log(error)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (error.message) {
|
||||||
|
err.value = error.message
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-row>
|
<sign-block>
|
||||||
<v-col cols="12" md="6" sm="8" offset-sm="2" offset-md="3" offset="0">
|
<sign-form>
|
||||||
<div class="d-flex flex-column ga-2">
|
<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>
|
<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>
|
<div>
|
||||||
<v-btn @click="verify" color="primary" density="comfortable">VERIFY</v-btn>
|
<v-btn @click="verify" color="primary" variant="flat" density="comfortable">VERIFY</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-col>
|
</sign-form>
|
||||||
</v-row>
|
</sign-block>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss"></style>
|
<style scoped lang="scss"></style>
|
||||||
Reference in new issue
Block a user