Compare commits

..
4 Commits
Author SHA1 Message Date
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
24 changed files with 176 additions and 93 deletions

No files matched your search

+26 -6
View File
@@ -1,7 +1,7 @@
<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 { useIssueTypeStore } from '@/stores/issue-type.ts'
@@ -25,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'],
{ {
@@ -61,10 +61,30 @@ const layout = computed(() => {
return layout ? `${layout}Layout` : 'DefaultLayout' return layout ? `${layout}Layout` : 'DefaultLayout'
}) })
onMounted(async () => { // Все эти ресурсы требуют авторизации: грузим при появлении сессии и чистим при выходе,
await Promise.all([statusStore.list(), issueTypeStore.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>
+1 -1
View File
@@ -2,7 +2,7 @@ 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'
/** Values are serialized: priority — number, dueDate — ISO date, assignee — user id */ /** Values are serialized: priority — number, dueDate — ISO date, assignee — user id */
export interface IssueFieldChange { export interface IssueFieldChange {
+1
View File
@@ -20,6 +20,7 @@ const FIELD_LABELS: Record<IssueFieldChange['field'], string> = {
priority: 'Priority', priority: 'Priority',
assignee: 'Assignee', assignee: 'Assignee',
dueDate: 'Due Date', dueDate: 'Due Date',
type: 'Type',
} }
const PRIORITY_LABELS: Record<string, string> = { const PRIORITY_LABELS: Record<string, string> = {
+1 -1
View File
@@ -83,7 +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 :value="issue.type" @changed="onTypeChanged" /> <issue-type v-if="visible.type" :value="issue.type" @changed="onTypeChanged" />
</div> </div>
</template> </template>
+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>
+41 -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,43 @@ 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" prepend-icon="mdi-bell-ring-outline"
:loading="testSending" :loading="testSending"
@click="sendTestPush" @click="sendTestPush"
> >
Send test push Send test push
</v-btn> </v-btn>
</div> </div>
</v-sheet>
</template> </template>
<style scoped lang="scss"></style> <style scoped lang="scss"></style>
+13 -6
View File
@@ -17,12 +17,19 @@ const changePassword = async () => {
</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="text-uppercase text-title-large">Change password</div>
<div class="d-flex ga-2"> <div class="d-flex flex-column ga-2">
<v-text-field placeholder="current password" v-model="passwordData.currentPassword" /> <v-text-field
<v-text-field placeholder="new password" v-model="passwordData.newPassword" /> clearable
<v-icon-btn variant="tonal" icon="mdi-check" @click="changePassword" /> bg-color="background"
placeholder="current password"
v-model="passwordData.currentPassword"
/>
<v-text-field clearable bg-color="background" placeholder="new password" v-model="passwordData.newPassword" />
<div>
<v-btn variant="tonal" prepend-icon="mdi-check" text="Update" @click="changePassword" />
</div>
</div> </div>
</v-sheet> </v-sheet>
</template> </template>
+16 -8
View File
@@ -44,20 +44,28 @@ watchEffect(async () => {
</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="mb-2">
<v-btn prepend-icon="mdi-skull" @click="revokeOtherSessions()">Revoke other sessions</v-btn>
</div>
</v-sheet>
</template> </template>
<style scoped lang="scss"></style> <style scoped lang="scss"></style>
+5
View File
@@ -1,8 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive } from 'vue' import { reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { authClient } from '@/lib/auth-client.js' import { authClient } from '@/lib/auth-client.js'
import { redirectTarget } from '@/lib/redirect.ts'
const REMEMBERED_EMAIL_KEY = 'rememberedEmail' const REMEMBERED_EMAIL_KEY = 'rememberedEmail'
const router = useRouter()
const route = useRoute()
const signInData = reactive({ const signInData = reactive({
email: localStorage.getItem(REMEMBERED_EMAIL_KEY) ?? '', email: localStorage.getItem(REMEMBERED_EMAIL_KEY) ?? '',
@@ -25,6 +29,7 @@ const signIn = async () => {
} else { } else {
localStorage.removeItem(REMEMBERED_EMAIL_KEY) localStorage.removeItem(REMEMBERED_EMAIL_KEY)
} }
router.push(redirectTarget(route.query.redirect))
} }
} }
</script> </script>
+4 -2
View File
@@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref, watch } from 'vue' import { reactive, ref, watch } from 'vue'
import { authClient } from '@/lib/auth-client.js' import { authClient } from '@/lib/auth-client.js'
import { useRouter } from 'vue-router' import { redirectTarget } from '@/lib/redirect.ts'
import { useRoute, useRouter } from 'vue-router'
const REMEMBERED_EMAIL_KEY = 'rememberedEmail' const REMEMBERED_EMAIL_KEY = 'rememberedEmail'
const router = useRouter() const router = useRouter()
const route = useRoute()
const otpError = ref('') const otpError = ref('')
const signIn = reactive({ const signIn = reactive({
@@ -43,7 +45,7 @@ const checkOTP = async () => {
email: signIn.email, email: signIn.email,
otp: signIn.OTP, otp: signIn.OTP,
}) })
if (data) router.push('/') if (data) router.push(redirectTarget(route.query.redirect))
if (error) { if (error) {
if (error.status === 400 && error.message) otpError.value = error.message if (error.status === 400 && error.message) otpError.value = error.message
if (error.status === 403 && error.message) { if (error.status === 403 && error.message) {
+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>
+5 -5
View File
@@ -21,18 +21,18 @@ const avatar = computed(() => {
</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-avatar size="x-large" color="info" :image="avatar" />
<div> <div>
<div>name: {{ user.name }}</div> <div>Name: {{ user.name }}</div>
<div>email: {{ user.email }}</div> <div>E-mail: {{ user.email }}</div>
<div>created at: {{ user.createdAt }}</div> <div>Created At: {{ user.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 },
), ),
+1 -1
View File
@@ -46,7 +46,7 @@ async function goToSignOut() {
} }
const goToSettings = () => { const goToSettings = () => {
router.push({ name: 'settings' }) router.push({ path: '/settings' })
} }
const fluid = computed(() => { const fluid = computed(() => {
+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 : '/'
}
+1 -1
View File
@@ -46,7 +46,7 @@ const router = createRouter({
{ {
path: '/profile', path: '/profile',
component: ProfileView, component: ProfileView,
meta: { requiresAuth: true }, meta: { requiresAuth: true, breadcrumb: 'Profile' },
}, },
issuesRoutes, issuesRoutes,
calendarRoutes, calendarRoutes,
+5 -1
View File
@@ -9,5 +9,9 @@ export const useIssueTypeStore = defineStore('issueType', () => {
issueTypes.value = await issueTypesList() issueTypes.value = await issueTypesList()
} }
return { issueTypes, list } 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, 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 }
}) })
+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"
+1 -1
View File
@@ -14,7 +14,7 @@ const tab = ref(0)
<v-tab value="projects" to="/settings/projects">Projects</v-tab> <v-tab value="projects" to="/settings/projects">Projects</v-tab>
</v-tabs> </v-tabs>
<div class="pa-6"> <div class="pa-4">
<router-view /> <router-view />
</div> </div>
</v-sheet> </v-sheet>
+2 -4
View File
@@ -9,19 +9,17 @@ 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>