This commit is contained in:
vadim committed 2026-09-25 15:19:27 +03:00
1 parent 30e507f5cd
commit 4a124a2bb7
10 files changed
+280 -31

No files matched your search

+64 -4
View File
@@ -14,7 +14,7 @@
"pinia": "^4.0.3", "pinia": "^4.0.3",
"vue": "^3.5.41", "vue": "^3.5.41",
"vue-router": "^5.2.0", "vue-router": "^5.2.0",
"vuetify": "^4.1.11" "vuetify": "^4.2.2"
}, },
"devDependencies": { "devDependencies": {
"@mdi/font": "^7.4.47", "@mdi/font": "^7.4.47",
@@ -2578,6 +2578,63 @@
} }
} }
}, },
"node_modules/@vuetify/v0": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@vuetify/v0/-/v0-1.2.3.tgz",
"integrity": "sha512-8qpYa88KxKrvi4jQcz+Zg4WH04un48Lt7Tj0Qq+LqvbUvHi+H177vWqBn9tQWgpxBVs1RXrTz5zw0Cy6txJHlw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/johnleider"
},
{
"type": "opencollective",
"url": "https://opencollective.com/vuetify"
}
],
"license": "MIT",
"peerDependencies": {
"@adobe/leonardo-contrast-colors": ">=1.0.0",
"@ant-design/colors": ">=7.0.0",
"@flagsmith/flagsmith": ">=11.0.0",
"@floating-ui/dom": ">=1.8.0",
"@js-temporal/polyfill": ">=0.5.0",
"@material/material-color-utilities": ">=0.3.0",
"launchdarkly-js-client-sdk": "^3.0.0",
"posthog-js": "^1.0.0",
"vue": ">=3.5.0 || >=3.6.0-0",
"vue-i18n": ">=10.0.0"
},
"peerDependenciesMeta": {
"@adobe/leonardo-contrast-colors": {
"optional": true
},
"@ant-design/colors": {
"optional": true
},
"@flagsmith/flagsmith": {
"optional": true
},
"@floating-ui/dom": {
"optional": true
},
"@js-temporal/polyfill": {
"optional": true
},
"@material/material-color-utilities": {
"optional": true
},
"launchdarkly-js-client-sdk": {
"optional": true
},
"posthog-js": {
"optional": true
},
"vue-i18n": {
"optional": true
}
}
},
"node_modules/@vueuse/core": { "node_modules/@vueuse/core": {
"version": "14.4.0", "version": "14.4.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.4.0.tgz", "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.4.0.tgz",
@@ -6385,10 +6442,13 @@
} }
}, },
"node_modules/vuetify": { "node_modules/vuetify": {
"version": "4.1.11", "version": "4.2.2",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.1.11.tgz", "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.2.2.tgz",
"integrity": "sha512-pMzGsjha58Lbg9n/icMXCxcQN9rTczcOdMq3wMU1Qe+cfufl28eX4VUUzLwSsDgmLjTtUGsgZIkyYZFQy+SmIw==", "integrity": "sha512-6PYw9V5bA1ug+r1gWOs+ztMnz4qJDue3WLiz5VJN/y1blW9e/oeh0D8eCw7jqc8ufjm8XSN7hf6x2odJXtzw0Q==",
"license": "MIT", "license": "MIT",
"dependencies": {
"@vuetify/v0": "^1.2.1"
},
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/johnleider" "url": "https://github.com/sponsors/johnleider"
+1 -1
View File
@@ -21,7 +21,7 @@
"pinia": "^4.0.3", "pinia": "^4.0.3",
"vue": "^3.5.41", "vue": "^3.5.41",
"vue-router": "^5.2.0", "vue-router": "^5.2.0",
"vuetify": "^4.1.11" "vuetify": "^4.2.2"
}, },
"devDependencies": { "devDependencies": {
"@mdi/font": "^7.4.47", "@mdi/font": "^7.4.47",
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts"></script>
<template>
<v-select
variant="outlined"
clearable
:items="[
{ id: 1, image: 'A' },
{ id: 2, image: 'B' },
{ id: 3, image: 'C' },
{ id: 4, image: 'D' },
]"
>
<template #item>
<div>a</div>
</template>
</v-select>
</template>
<style scoped lang="scss"></style>
+148
View File
@@ -0,0 +1,148 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { itemsList } from '@/api/user.api.ts'
import type { User } from '@/api/user.api.ts'
interface Props {
selected?: string[]
}
const props = withDefaults(defineProps<Props>(), { selected: () => [] })
const emit = defineEmits<{ changed: [value: string[]] }>()
const PAGE_SIZE = 20
const AVATAR_LIMIT = 5
const users = ref<User[]>([])
const page = ref(0)
const hasMore = ref(true)
const totalCount = ref(0)
const hoveredId = ref<string | null>(null)
const avatarUsers = computed(() => users.value.slice(0, AVATAR_LIMIT))
const extraCount = computed(() => Math.max(totalCount.value - AVATAR_LIMIT, 0))
function initials(user: Pick<User, 'name' | 'email'>) {
return (user.name || user.email || '?')[0]!.toUpperCase()
}
function isSelected(id: string) {
return props.selected.includes(id)
}
function toggle(id: string) {
const next = isSelected(id) ? props.selected.filter((el) => el !== id) : [...props.selected, id]
emit('changed', next)
}
function onMenuSelect(value: unknown) {
if (Array.isArray(value)) {
emit('changed', value.filter((el): el is string => typeof el === 'string'))
}
}
async function loadMore({ done }: { done: (status: 'ok' | 'empty' | 'error') => void }) {
if (!hasMore.value) {
done('empty')
return
}
try {
const nextPage = page.value + 1
const res = await itemsList({ page: nextPage, limit: PAGE_SIZE })
users.value.push(...res.list)
page.value = nextPage
totalCount.value = res.meta.totalCount
hasMore.value = nextPage < res.meta.totalPages
done(hasMore.value ? 'ok' : 'empty')
} catch {
done('error')
}
}
onMounted(() => {
loadMore({ done: () => {} })
})
</script>
<template>
<div class="d-flex align-center">
<v-avatar
v-for="(user, index) in avatarUsers"
:key="user.id"
rounded="circle"
border
density="compact"
:image="user.image ?? undefined"
:text="initials(user)"
class="stack-avatar cursor-pointer"
:class="{ 'bg-primary': isSelected(user.id) }"
:style="{ zIndex: hoveredId === user.id ? avatarUsers.length + 1 : avatarUsers.length - index }"
@mouseenter="hoveredId = user.id"
@mouseleave="hoveredId = null"
@click="toggle(user.id)"
/>
<v-menu>
<template v-slot:activator="{ props: activatorProps }">
<v-avatar
rounded="circle"
border
density="compact"
variant="tonal"
class="stack-avatar cursor-pointer"
:style="{ zIndex: hoveredId === 'more' ? avatarUsers.length + 1 : 0 }"
v-bind="activatorProps"
@mouseenter="hoveredId = 'more'"
@mouseleave="hoveredId = null"
>
<span v-if="extraCount > 0" class="text-caption">+{{ extraCount }}</span>
<v-icon v-else icon="mdi-dots-horizontal" size="small" />
</v-avatar>
</template>
<v-infinite-scroll height="16rem" @load="loadMore">
<v-list
border
width="12rem"
class="mt-1"
select-strategy="leaf"
density="compact"
:selected="selected"
@update:selected="onMenuSelect"
>
<v-list-item v-for="user in users" :key="user.id" :value="user.id">
<div class="d-flex align-center ga-2">
<v-avatar
:image="user.image ?? undefined"
:text="initials(user)"
rounded="circle"
density="compact"
border
color="primary"
/>
<div>{{ user.name || user.email }}</div>
</div>
</v-list-item>
</v-list>
<template #empty />
</v-infinite-scroll>
</v-menu>
</div>
</template>
<style scoped lang="scss">
.stack-avatar {
position: relative;
transition: transform 0.15s ease;
&:not(:first-child) {
margin-left: -0.75rem;
}
&:hover {
transform: translateY(-0.25rem);
}
}
</style>
+9 -20
View File
@@ -1,33 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import AssigneeFilter from '@/components/AssigneeFilter.vue'
const text = ref<string | null>(null) import AssigneeFilterAvatar from '@/components/AssigneeFilterAvatar.vue'
import TextFilter from '@/components/TextFilter.vue'
async function onSearch() {
console.log('search')
}
</script> </script>
<template> <template>
<div class="u-grid2 ga-2"> <div class="u-grid2 ga-1 align-center">
<v-text-field <text-filter />
v-model="text" <assignee-filter />
clearable <assignee-filter-avatar />
placeholder="Search name..." <v-btn variant="text">clear filters</v-btn>
@keydown.enter.prevent="onSearch"
@click:clear="onSearch"
/>
<v-select variant="outlined" clearable :items="['abc', 'def']"></v-select>
<!-- <v-btn>filter</v-btn>-->
<!-- <v-btn variant="plain">clear filters</v-btn>-->
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.u-grid2 { .u-grid2 {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: 1fr 10rem auto auto;
} }
</style> </style>
+2 -1
View File
@@ -30,7 +30,8 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div v-if="projects.length" class=""> <div v-if="projects.length" class="d-flex align-center ga-2">
Project:
<v-menu> <v-menu>
<template v-slot:activator="{ props }"> <template v-slot:activator="{ props }">
<v-btn border density="comfortable" variant="flat" :text="currentProjectName" v-bind="props" /> <v-btn border density="comfortable" variant="flat" :text="currentProjectName" v-bind="props" />
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { ref } from 'vue'
const text = ref<string | null>(null)
function onSearch() {
console.log('onSearch', text.value)
}
function onClear() {
text.value = ''
console.log('onClear', text.value)
}
</script>
<template>
<v-text-field
v-model="text"
clearable
prepend-inner-icon="mdi-text"
placeholder="Search..."
@keydown.enter.prevent="onSearch"
@click:clear="onClear"
/>
</template>
<style scoped lang="scss"></style>
+4 -2
View File
@@ -91,18 +91,20 @@ const goToSettings = () => {
</v-list> </v-list>
</v-menu> </v-menu>
</template> </template>
<v-divider class="mx-1" vertical />
<v-app-bar-title> <v-app-bar-title>
<div class="d-flex ga-2 align-center"> <div class="d-flex ga-2 align-center">
<router-link class="text-decoration-none text-link font-weight-bold" to="/"> <router-link class="text-decoration-none text-link font-weight-bold" to="/">
{{ title }} {{ title }}
</router-link> </router-link>
<span>v{{ version }}</span> <span>v{{ version }}</span>
<v-divider class="mx-2" vertical />
<project-menu />
</div> </div>
</v-app-bar-title> </v-app-bar-title>
<div class="d-flex ga-4 align-center" v-if="session.data"> <div class="d-flex ga-4 align-center" v-if="session.data">
<project-menu />
<div class="mr-2"> <div class="mr-2">
Hello, Hello,
<router-link class="text-decoration-none text-link text-capitalize" to="/profile"> <router-link class="text-decoration-none text-link text-capitalize" to="/profile">
+3 -2
View File
@@ -11,6 +11,7 @@ import '@mdi/font/css/materialdesignicons.css'
import { createVuetify } from 'vuetify' import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components' import * as components from 'vuetify/components'
import { VAvatarGroup } from 'vuetify/labs/VAvatarGroup'
import * as directives from 'vuetify/directives' import * as directives from 'vuetify/directives'
import { registerLayouts } from '@/layouts/register.ts' import { registerLayouts } from '@/layouts/register.ts'
@@ -20,7 +21,7 @@ app.use(createPinia())
app.use(router) app.use(router)
const vuetify = createVuetify({ const vuetify = createVuetify({
components, components: { ...components, VAvatarGroup },
directives, directives,
theme: { theme: {
defaultTheme: 'dark', // 'system' | 'light' | 'dark' defaultTheme: 'dark', // 'system' | 'light' | 'dark'
@@ -41,7 +42,7 @@ const vuetify = createVuetify({
}, },
defaults: { defaults: {
global: { global: {
rounded: 'md', rounded: 'lg',
}, },
VBtn: { VBtn: {
variant: 'tonal', variant: 'tonal',
+2 -1
View File
@@ -9,6 +9,7 @@ import IssueCreate from '@/components/IssueCreate.vue'
import type { Issue } from '@/api/issue.api.ts' import type { Issue } from '@/api/issue.api.ts'
import IssuesListElement from '@/components/IssuesListElement.vue' import IssuesListElement from '@/components/IssuesListElement.vue'
import IssueStats from '@/components/IssueStats.vue' import IssueStats from '@/components/IssueStats.vue'
import IssueFilter from '@/components/IssueFilter.vue'
const issueStore = useIssueStore() const issueStore = useIssueStore()
const issueDetailsRef = ref<InstanceType<typeof IssueDetails>>() const issueDetailsRef = ref<InstanceType<typeof IssueDetails>>()
@@ -150,8 +151,8 @@ async function onDeleteBatch() {
<div class="d-flex flex-column ga-2"> <div class="d-flex flex-column ga-2">
<div class="u-grid ga-1"> <div class="u-grid ga-1">
<div class="u-height"> <div class="u-height">
<issue-filter class="mb-2" />
<v-list class="pa-0" density="compact" v-model:selected="selected" mandatory> <v-list class="pa-0" density="compact" v-model:selected="selected" mandatory>
<!-- <issue-filter />-->
<v-list-item> <v-list-item>
<template #prepend> <template #prepend>
<div class="mr-2" @click.stop> <div class="mr-2" @click.stop>