Compare commits

...
3 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
17 changed files with 288 additions and 16 deletions

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<link rel="icon" href="/favicon_.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 627 B

+3 -2
View File
@@ -2,9 +2,10 @@ import { apiClient } from '@/lib/api-client.ts'
import type { User } from '@/api/user.api.ts'
/** created — a pseudo-field: the issue creation record, both values are null */
export type IssueChangeField = 'created' | 'title' | 'content' | 'status' | 'priority' | 'assignee' | 'dueDate' | 'type'
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 {
field: IssueChangeField
oldValue: string | null
+10
View File
@@ -2,9 +2,16 @@ import { apiClient, toQuery } from '@/lib/api-client.ts'
import type { User } from '@/api/user.api.ts'
import type { IssueType } from '@/api/issue-type.api.ts'
export interface IssueTag {
id: string
name: string
color: string
}
export interface Issue {
id: number
key: string
projectId: string
title: string
content: string
status: 'OPEN' | 'IN_PROGRESS' | 'DONE' | 'CANCELLED'
@@ -14,6 +21,7 @@ export interface Issue {
reporterId: string
assignee?: User
reporter?: User
tags: IssueTag[]
dueDate: string | null
createdAt: string
updatedAt: string
@@ -35,6 +43,8 @@ export interface CreateIssueInput {
priority?: Issue['priority']
assignee?: string
dueDate?: string | null
/** Names, the full new list; names missing from the project's tags are created */
tags?: string[]
}
export type UpdateIssueInput = Partial<CreateIssueInput>
+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)}`)
}
+2 -1
View File
@@ -21,6 +21,7 @@ const FIELD_LABELS: Record<IssueFieldChange['field'], string> = {
assignee: 'Assignee',
dueDate: 'Due Date',
type: 'Type',
tag: 'Tag',
}
const PRIORITY_LABELS: Record<string, string> = {
@@ -145,7 +146,7 @@ function toggle(key: string) {
<span class="text-medium-emphasis text-body-small ms-auto">{{ item.time }}</span>
</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 v-if="change.field === 'content'">
+41 -3
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref, useTemplateRef, watchEffect } from 'vue'
import { useIssueStore } from '@/stores/issue.js'
import { useSnackbarStore } from '@/stores/snackbar.ts'
import ExpansionPanel from '@/components/ExpansionPanel.vue'
import IssueContent from '@/components/IssueContent.vue'
import type { Issue } from '@/api/issue.api.ts'
@@ -16,6 +17,8 @@ import { useSplitPane } from '@/composables/useSplitPane.ts'
import FileUpload from '@/components/FileUpload.vue'
import PriorityMenu from '@/components/PriorityMenu.vue'
import IssueType from '@/components/IssueType.vue'
import ObserversList from '@/components/ObserversList.vue'
import TagsSelector from '@/components/TagsSelector.vue'
interface Props {
ckey: string
@@ -31,8 +34,10 @@ const emit = defineEmits<{
}>()
const issueStore = useIssueStore()
const snackbarStore = useSnackbarStore()
const issue = ref<Issue>()
const activity = ref()
const tagsSaving = ref(false)
const { ckey } = defineProps<Props>()
@@ -119,6 +124,19 @@ const onAssigneeChanged = async (assignee: string) => {
}
}
const onTagsChanged = async (tags: string[]) => {
if (!issue.value) return
tagsSaving.value = true
try {
const updated = await issueStore.update(issue.value.id, { tags })
if (updated) issue.value = updated
else snackbarStore.add({ title: 'Failed to update tags', text: issueStore.error ?? '', color: 'error' })
} finally {
tagsSaving.value = false
}
}
defineExpose({
applyUpdate: (updated: Issue) => {
if (issue.value && issue.value.id === updated.id) {
@@ -133,6 +151,11 @@ defineExpose({
<div class="d-flex flex-column ga-4 details__main">
<div>{{ ckey }}</div>
<div class="d-flex ga-2">
<div class="text-medium-emphasis">Status:</div>
<status-menu :value="issue.status" @changed="onStatusChanged" />
</div>
<editable-text :text="issue.title" @changed="onTitleChanged" />
<expansion-panel title="Info">
@@ -144,14 +167,29 @@ defineExpose({
<div class="text-medium-emphasis">Assignee:</div>
<assignee-menu :value="issue.assignee" :compact="false" @changed="onAssigneeChanged" />
<div class="text-medium-emphasis">Status:</div>
<status-menu :value="issue.status" @changed="onStatusChanged" />
<div class="text-medium-emphasis">Type:</div>
<issue-type :value="issue.type" :compact="false" @changed="onTypeChanged" />
<div class="text-medium-emphasis">Priority:</div>
<priority-menu :value="issue.priority" :compact="false" @changed="onPriorityChanged" />
</v-card-text>
<v-divider />
<v-card-text class="u-grid">
<div class="text-medium-emphasis">Observers:</div>
<observers-list />
</v-card-text>
<v-divider />
<v-card-text class="u-grid">
<div class="text-medium-emphasis">Tags:</div>
<tags-selector
:value="issue.tags"
:project-id="issue.projectId"
:saving="tagsSaving"
@changed="onTagsChanged"
/>
<div class="text-medium-emphasis">Created At:</div>
<div>{{ createdAtFormatted }}</div>
+2 -2
View File
@@ -50,8 +50,8 @@ const select = (value: unknown) => {
v-bind="activatorProps"
/>
<v-btn v-else density="compact" class="px-1" variant="text" v-bind="activatorProps">
<div class="d-flex align-center ga-1">
<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>
+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>
+6 -5
View File
@@ -46,7 +46,7 @@ const select = (value: unknown) => {
</script>
<template>
<div class="">
<div>
<v-menu>
<template v-slot:activator="{ props: activatorProps }">
<v-btn
@@ -54,14 +54,15 @@ const select = (value: unknown) => {
density="compact"
:icon="currentIcon"
:color="currentColor"
:title="currentLabel"
variant="text"
v-bind="activatorProps"
/>
<v-btn v-else density="compact" class="px-1" variant="text" v-bind="activatorProps">
<div class="d-flex align-center ga-1">
<v-icon :color="currentColor" size="x-large" :icon="currentIcon"></v-icon>
<span>{{ currentLabel }}</span>
<v-btn v-else density="comfortable" slim v-bind="activatorProps">
<div class="d-flex align-center ga-2">
<v-icon :color="currentColor" :icon="currentIcon" />
{{ currentLabel }}
</div>
</v-btn>
</template>
+1 -1
View File
@@ -16,7 +16,7 @@ const currentInitials = computed(() => (user ? initials(user) : '?'))
</script>
<template>
<div class="">
<div>
<div class="d-flex align-center ga-2">
<v-btn rounded="circle" density="compact" icon variant="text">
<v-avatar
+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>
+7
View File
@@ -2,6 +2,7 @@ import type { RouteRecordRaw } from 'vue-router'
import ProjectsListView from '@/views/project/ProjectsListView.vue'
import SettingsView from '@/views/settings/SettingsView.vue'
import GeneralSettingsView from '@/views/settings/GeneralSettingsView.vue'
import TagsSettingsView from '@/views/settings/TagsSettingsView.vue'
export const settingsRoutes: RouteRecordRaw = {
path: '/settings',
@@ -21,5 +22,11 @@ export const settingsRoutes: RouteRecordRaw = {
component: ProjectsListView,
meta: { breadcrumb: 'Projects' },
},
{
path: 'tags',
name: 'settings-tags',
component: TagsSettingsView,
meta: { breadcrumb: 'Tags' },
},
],
}
+1 -1
View File
@@ -127,7 +127,7 @@ export const useIssueStore = defineStore('item', () => {
error.value = null
}
return { stats, dailyStats, list, calendar, getByKey, create, update, remove, getStat, getDailyStat, reset }
return { stats, dailyStats, error, list, calendar, getByKey, create, update, remove, getStat, getDailyStat, reset }
})
/** Local YYYY-MM-DD `days` days before today */
+1
View File
@@ -12,6 +12,7 @@ const tab = ref(0)
<v-tabs v-model="tab">
<v-tab value="common" to="/settings">General</v-tab>
<v-tab value="projects" to="/settings/projects">Projects</v-tab>
<v-tab value="tags" to="/settings/tags">Tags</v-tab>
</v-tabs>
<div class="pa-4">
+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>