Compare commits

...
15 Commits
Author SHA1 Message Date
vadim c96ef32fe9 tags
Gitea Actions / Deploy (push) Successful in 32s
2026-11-01 11:19:50 +03:00
vadim 10ad1569fb favicon
Gitea Actions / Deploy (push) Successful in 8s
2026-10-29 09:57:17 +03:00
vadim 609fe517d7 wip 2026-10-26 21:48:43 +03:00
vadim 76bababa91 ui
Gitea Actions / Deploy (push) Failing after 35s
2026-10-25 21:49:56 +03:00
vadim 696166d68a fix imports
Gitea Actions / Deploy (push) Successful in 28s
2026-10-25 21:12:27 +03:00
vadim b9c5f64989 ui fixes
Gitea Actions / Deploy (push) Successful in 1m4s
2026-10-25 20:59:28 +03:00
vadim f0706432d4 avatar fix
Gitea Actions / Deploy (push) Successful in 29s
sessions fix
password change
sign-in fix
2026-10-23 19:25:11 +03:00
vadim 63861449cf small fixes
Gitea Actions / Deploy (push) Successful in 1m26s
2026-10-21 15:49:22 +03:00
vadim ff0008d346 list view options 2026-10-21 13:55:01 +03:00
vadim 81dd976d8f fix history 2026-10-21 13:05:05 +03:00
vadim 9303a66aa8 fix get resources after login 2026-10-21 12:56:57 +03:00
vadim 327f59104d auth
Gitea Actions / Deploy (push) Successful in 31s
2026-10-20 10:59:22 +03:00
vadim 2b94bcc20e auth
Gitea Actions / Deploy (push) Successful in 27s
2026-10-19 17:24:21 +03:00
vadim 7639a54c50 issue types
Gitea Actions / Deploy (push) Successful in 27s
2026-10-18 20:52:26 +03:00
vadim cc490f8d4a small fixes 2026-10-18 20:07:21 +03:00
60 changed files with 2451 additions and 479 deletions

No files matched your search

+1 -1
View File
@@ -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>
+812 -190
View File
File diff suppressed because it is too large. Load diff
+8 -2
View File
@@ -16,8 +16,14 @@
}, },
"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", "firebase": "^13.0.0",
"pinia": "^4.0.3", "pinia": "^4.0.3",
@@ -44,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

+28 -6
View File
@@ -1,9 +1,10 @@
<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 { authClient } from '@/lib/auth-client.ts'
@@ -13,6 +14,7 @@ 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 session = authClient.useSession()
const push = usePushNotifications() const push = usePushNotifications()
@@ -23,7 +25,7 @@ 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'],
{ {
@@ -59,10 +61,30 @@ const layout = computed(() => {
return layout ? `${layout}Layout` : 'DefaultLayout' return layout ? `${layout}Layout` : 'DefaultLayout'
}) })
onMounted(async () => { // Все эти ресурсы требуют авторизации: грузим при появлении сессии и чистим при выходе,
await statusStore.list() // иначе запросы, ушедшие до логина, падают с 401 и больше не повторяются
await issueStore.getStat(currentProjectId.value) watch(
}) () => session.value.data?.user.id,
async (userId) => {
if (!userId) {
close()
statusStore.reset()
issueTypeStore.reset()
issueStore.reset()
projectStore.reset()
return
}
open()
try {
await Promise.all([statusStore.list(), issueTypeStore.list()])
await issueStore.getStat(currentProjectId.value)
} catch (e) {
console.error('Failed to load dictionaries', e)
}
},
{ immediate: true },
)
</script> </script>
<template> <template>
+3 -2
View File
@@ -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
+13
View File
@@ -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')
}
+13
View File
@@ -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>
+21
View File
@@ -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)}`)
}
+9
View File
@@ -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')
}
-1
View File
@@ -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" />
+3 -1
View File
@@ -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'">
+5 -19
View File
@@ -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;
} }
+7 -2
View File
@@ -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>
+74 -19
View File
@@ -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,29 +151,54 @@ 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">
<div class="text-medium-emphasis">Status:</div>
<status-menu :value="issue.status" @changed="onStatusChanged" />
</div>
<v-card elevation="0" border> <editable-text :text="issue.title" @changed="onTitleChanged" />
<v-card-text class="u-grid">
<div class="text-medium-emphasis">Status:</div>
<status-menu :value="issue.status" @changed="onStatusChanged" />
<div class="text-medium-emphasis">Priority:</div> <expansion-panel title="Info">
<priority-menu :value="issue.priority" :compact="false" @changed="onPriorityChanged" /> <v-card elevation="0" border>
<v-card-text class="u-grid">
<div class="text-medium-emphasis">Reporter:</div>
<reporter-info :user="issue.reporter" />
<div class="text-medium-emphasis">Reporter:</div> <div class="text-medium-emphasis">Assignee:</div>
<reporter-info :user="issue.reporter" /> <assignee-menu :value="issue.assignee" :compact="false" @changed="onAssigneeChanged" />
<div class="text-medium-emphasis">Assignee:</div> <div class="text-medium-emphasis">Type:</div>
<assignee-menu :value="issue.assignee" :compact="false" @changed="onAssigneeChanged" /> <issue-type :value="issue.type" :compact="false" @changed="onTypeChanged" />
<div class="text-medium-emphasis">Created At:</div> <div class="text-medium-emphasis">Priority:</div>
<div>{{ createdAtFormatted }}</div> <priority-menu :value="issue.priority" :compact="false" @changed="onPriorityChanged" />
</v-card-text>
<div class="text-medium-emphasis">Due Date:</div> <v-divider />
<issue-date :date="issue.dueDate" @changed="onDueDateChanged" />
</v-card-text> <v-card-text class="u-grid">
</v-card> <div class="text-medium-emphasis">Observers:</div>
<observers-list />
</v-card-text>
<v-divider />
<v-card-text class="u-grid">
<div class="text-medium-emphasis">Tags:</div>
<tags-selector
:value="issue.tags"
:project-id="issue.projectId"
:saving="tagsSaving"
@changed="onTagsChanged"
/>
<div class="text-medium-emphasis">Created At:</div>
<div>{{ createdAtFormatted }}</div>
<div class="text-medium-emphasis">Due Date:</div>
<issue-date :date="issue.dueDate" @changed="onDueDateChanged" />
</v-card-text>
</v-card>
</expansion-panel>
<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>
+73
View File
@@ -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>
+13 -1
View File
@@ -5,6 +5,7 @@ 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 { useIssueListOptions } from '@/composables/useIssueListOptions.ts'
import IssueType from '@/components/IssueType.vue'
interface Props { interface Props {
issue: Issue issue: Issue
@@ -14,6 +15,7 @@ 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]
@@ -27,6 +29,11 @@ const onStatusChanged = async (status: Issue['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)
@@ -49,7 +56,11 @@ const onAssigneeChanged = async (assignee: string) => {
icon="mdi-alert-box" icon="mdi-alert-box"
color="error" color="error"
/> />
<div v-if="visible.title" class="flex-shrink-0" :class="{ 'text-decoration-line-through': issue.status === 'DONE' }"> <div
v-if="visible.title"
class="flex-shrink-0"
:class="{ 'text-decoration-line-through': issue.status === 'DONE' }"
>
{{ issue.title }} {{ issue.title }}
</div> </div>
<div <div
@@ -72,6 +83,7 @@ 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>
+282
View File
@@ -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>
+5 -5
View File
@@ -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>
+42 -30
View File
@@ -99,12 +99,16 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div> <v-sheet border class="pa-4 d-flex flex-column ga-2">
<div class="text-uppercase">Notifications</div> <div class="text-uppercase text-title-large">Notifications</div>
<v-skeleton-loader v-if="loading && !preferences.length" type="list-item, list-item" /> <v-skeleton-loader v-if="loading && !preferences.length" type="list-item, list-item" />
<div v-else class="d-flex flex-column"> <div v-else class="d-flex flex-column">
<v-switch <v-switch
inset="material"
size="x-small"
true-icon="mdi-check"
false-icon="mdi-close"
v-for="preference in preferences" v-for="preference in preferences"
:key="preference.channel" :key="preference.channel"
:model-value="preference.enabled" :model-value="preference.enabled"
@@ -118,36 +122,44 @@ onMounted(async () => {
/> />
</div> </div>
<div class="text-uppercase mt-4">Push in this browser</div> <div>
<div v-if="permission === 'unsupported'" class="text-medium-emphasis"> <div class="text-uppercase">Push in this browser</div>
This browser does not support push notifications <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>
<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
:model-value="deviceEnabled"
:label="deviceEnabled ? 'Enabled' : 'Disabled'"
:loading="deviceSaving"
:disabled="deviceSaving"
color="primary"
density="compact"
hide-details
@update:model-value="toggleDevice"
/>
<v-btn <div>
class="mt-2" <v-btn
variant="tonal" class="mt-2"
size="small" variant="tonal"
prepend-icon="mdi-bell-ring-outline" density="comfortable"
:loading="testSending" prepend-icon="mdi-bell-ring-outline"
@click="sendTestPush" :loading="testSending"
> @click="sendTestPush"
Send test push >
</v-btn> Send test push
</div> </v-btn>
</div>
</v-sheet>
</template> </template>
<style scoped lang="scss"></style> <style scoped lang="scss"></style>
+66
View File
@@ -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>
+27
View File
@@ -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>
+128 -14
View File
@@ -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>
+6 -5
View File
@@ -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>
-40
View File
@@ -1,40 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { usePushNotifications } from '@/composables/usePushNotifications.ts'
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="mr-2">
<span v-if="status === 'granted' && push.installationId.value">Уведомления включены</span>
<button v-else-if="status === 'default' || status === 'granted'" :disabled="loading" @click="onClick">
Включить уведомления
</button>
<span v-else-if="status === 'denied'">Уведомления заблокированы.</span>
<span v-else>Браузер не поддерживает уведомления</span>
</div>
</template>
<style scoped lang="scss"></style>
+1 -1
View File
@@ -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
+37 -14
View File
@@ -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> <div class="d-flex ga-2">
<v-btn density="comfortable" @click="revokeOtherSessions">Revoke other sessions</v-btn>
<v-btn color="error" variant="flat" density="comfortable" prepend-icon="mdi-skull" @click="revokeAllSessions">
Revoke all sessions
</v-btn>
</div>
</v-sheet>
</template> </template>
<style scoped lang="scss"></style> <style scoped lang="scss"></style>
+11
View File
@@ -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>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts"></script>
<template>
<v-sheet class="pa-4">
<slot />
</v-sheet>
</template>
<style scoped lang="scss"></style>
+95
View File
@@ -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>
+88
View File
@@ -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>
+1 -1
View File
@@ -8,7 +8,7 @@ const snackbarStore = useSnackbarStore()
<v-snackbar-queue <v-snackbar-queue
ref="queue" ref="queue"
v-model="snackbarStore.snackbarQueue" v-model="snackbarStore.snackbarQueue"
location="bottom end" location="top end"
timeout="5000" timeout="5000"
total-visible="5" total-visible="5"
/> />
+5 -11
View File
@@ -15,7 +15,10 @@ const selected = ref<string[]>([...status])
const onUpdate = (val: unknown) => { const onUpdate = (val: unknown) => {
if (!Array.isArray(val)) return if (!Array.isArray(val)) return
const order = statusStore.statuses?.map((el) => el.alias) ?? [] const order = statusStore.statuses?.map((el) => el.alias) ?? []
emit('changed', [...val].sort((a, b) => order.indexOf(a) - order.indexOf(b))) emit(
'changed',
[...val].sort((a, b) => order.indexOf(a) - order.indexOf(b)),
)
} }
watch( watch(
@@ -27,16 +30,7 @@ watch(
</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
multiple
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>
+23
View File
@@ -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>
+80
View File
@@ -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>
+114 -8
View File
@@ -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>
+2 -1
View File
@@ -1,6 +1,7 @@
import { createGlobalState, useLocalStorage } from '@vueuse/core' import { createGlobalState, useLocalStorage } from '@vueuse/core'
export const ISSUE_LIST_FIELDS = [ export const ISSUE_LIST_FIELDS = [
{ title: 'Type', alias: 'type' },
{ title: 'Title', alias: 'title' }, { title: 'Title', alias: 'title' },
{ title: 'Content', alias: 'content' }, { title: 'Content', alias: 'content' },
{ title: 'Status', alias: 'status' }, { title: 'Status', alias: 'status' },
@@ -14,7 +15,7 @@ export type IssueListField = (typeof ISSUE_LIST_FIELDS)[number]['alias']
export const useIssueListOptions = createGlobalState(() => export const useIssueListOptions = createGlobalState(() =>
useLocalStorage<Record<IssueListField, boolean>>( useLocalStorage<Record<IssueListField, boolean>>(
'issue-list:visible-fields', 'issue-list:visible-fields',
{ title: true, content: true, status: true, priority: true, assignee: true }, { title: true, content: true, status: true, priority: true, assignee: true, type: true },
// Fields added later stay visible for users who already have saved options // Fields added later stay visible for users who already have saved options
{ mergeDefaults: true }, { mergeDefaults: true },
), ),
+11 -4
View File
@@ -7,9 +7,9 @@ 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 PushEnable from '@/components/PushEnable.vue'
import { usePushNotifications } from '@/composables/usePushNotifications.ts' import { usePushNotifications } from '@/composables/usePushNotifications.ts'
import ProjectMenu from '@/components/ProjectMenu.vue' import ProjectMenu from '@/components/ProjectMenu.vue'
import NotificationsList from '@/components/NotificationsList.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -21,6 +21,12 @@ 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' })
} }
@@ -46,7 +52,7 @@ async function goToSignOut() {
} }
const goToSettings = () => { const goToSettings = () => {
router.push({ name: 'settings' }) router.push({ path: '/settings' })
} }
const fluid = computed(() => { const fluid = computed(() => {
@@ -72,7 +78,7 @@ const fluid = computed(() => {
<div v-if="session.data" class="d-flex align-center"> <div v-if="session.data" class="d-flex align-center">
<project-menu /> <project-menu />
<push-enable /> <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" />
@@ -85,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>
+18
View File
@@ -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>>
+2 -1
View File
@@ -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()],
}) })
+29
View File
@@ -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 })
}
+7
View File
@@ -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 : '/'
}
+4 -2
View File
@@ -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 {
@@ -33,10 +33,12 @@ const router = createRouter({
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,
+7
View File
@@ -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' },
},
], ],
} }
+17
View File
@@ -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
View File
@@ -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 */
+8
View File
@@ -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,
} }
}) })
+5 -1
View File
@@ -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 }
}) })
+8 -2
View File
@@ -46,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 {
@@ -233,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>
+6 -3
View File
@@ -9,9 +9,12 @@ const { ckey } = defineProps<Props>()
</script> </script>
<template> <template>
<v-sheet border class="h-100 d-flex flex-column"> <div class="d-flex flex-column ga-2">
<issue-details :ckey="ckey" /> <div class="text-uppercase text-title-large">Issue: {{ ckey }}</div>
</v-sheet> <v-sheet border class="h-100 d-flex flex-column">
<issue-details :ckey="ckey" />
</v-sheet>
</div>
</template> </template>
<style scoped lang="scss"></style> <style scoped lang="scss"></style>
+3
View File
@@ -203,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"
@@ -248,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"
@@ -306,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"
+12 -6
View File
@@ -337,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"
+2 -1
View File
@@ -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>
+62
View File
@@ -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>
+2 -8
View File
@@ -9,19 +9,13 @@ 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" />
<v-divider />
<password-change /> <password-change />
<v-divider />
<notification-settings /> <notification-settings />
<sessions-list /> <sessions-list />
</v-sheet> </div>
</template> </template>
<style scoped lang="scss"></style> <style scoped lang="scss"></style>
+22 -46
View File
@@ -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>
+41 -14
View File
@@ -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="text-uppercase">sign up</div> <div class="d-flex ga-2 align-center justify-space-between">
<v-text-field placeholder="Name" prepend-inner-icon="mdi-account" v-model="signUpData.name" /> <div class="text-uppercase">sign up</div>
<v-text-field placeholder="E-mail" prepend-inner-icon="mdi-email" v-model="signUpData.email" /> <v-chip v-if="err" size="small" variant="flat" color="error" density="compact">{{ err }}</v-chip>
<v-text-field placeholder="Password" prepend-inner-icon="mdi-lock" v-model="signUpData.password" /> </div>
<v-text-field
bg-color="background"
clearable
placeholder="Name"
prepend-inner-icon="mdi-account"
v-model="signUpData.name"
/>
<v-text-field
bg-color="background"
clearable
placeholder="E-mail"
prepend-inner-icon="mdi-email"
v-model="signUpData.email"
/>
<v-text-field
bg-color="background"
clearable
placeholder="Password"
prepend-inner-icon="mdi-lock"
v-model="signUpData.password"
/>
<div class="d-flex ga-2 align-center"> <div 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>
+27 -9
View File
@@ -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="text-uppercase">verify email</div> <div class="d-flex ga-2 align-center justify-space-between">
<div class="text-uppercase">verify email</div>
<v-chip v-if="err" size="small" variant="flat" color="error" density="compact">{{ err }}</v-chip>
</div>
<v-text-field placeholder="email" prepend-inner-icon="mdi-email" v-model="verifyData.email" /> <v-text-field
clearable
placeholder="email"
bg-color="background"
prepend-inner-icon="mdi-email"
v-model="verifyData.email"
/>
<div> <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>