Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c6f21ef78 | ||
|
|
0ce91bd0e5 | ||
|
|
bb4ddf3f36 | ||
|
|
4a124a2bb7 | ||
|
|
30e507f5cd |
No files matched your search
Generated
+64
-4
@@ -14,7 +14,7 @@
|
||||
"pinia": "^4.0.3",
|
||||
"vue": "^3.5.41",
|
||||
"vue-router": "^5.2.0",
|
||||
"vuetify": "^4.1.11"
|
||||
"vuetify": "^4.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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": {
|
||||
"version": "14.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.4.0.tgz",
|
||||
@@ -6385,10 +6442,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vuetify": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.1.11.tgz",
|
||||
"integrity": "sha512-pMzGsjha58Lbg9n/icMXCxcQN9rTczcOdMq3wMU1Qe+cfufl28eX4VUUzLwSsDgmLjTtUGsgZIkyYZFQy+SmIw==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.2.2.tgz",
|
||||
"integrity": "sha512-6PYw9V5bA1ug+r1gWOs+ztMnz4qJDue3WLiz5VJN/y1blW9e/oeh0D8eCw7jqc8ufjm8XSN7hf6x2odJXtzw0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vuetify/v0": "^1.2.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/johnleider"
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
"pinia": "^4.0.3",
|
||||
"vue": "^3.5.41",
|
||||
"vue-router": "^5.2.0",
|
||||
"vuetify": "^4.1.11"
|
||||
"vuetify": "^4.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mdi/font": "^7.4.47",
|
||||
|
||||
+10
-3
@@ -51,11 +51,18 @@ export async function itemsList(params: {
|
||||
page: number
|
||||
limit: number
|
||||
text?: string | null
|
||||
status?: string | null
|
||||
assigneeId?: string | null
|
||||
projectId?: string | null
|
||||
}) {
|
||||
// `text` (free-text search) has no backend support yet — kept in the
|
||||
// signature so callers don't need to change, but not sent over the wire.
|
||||
const query = toQuery({ page: params.page, limit: params.limit, projectId: params.projectId })
|
||||
const query = toQuery({
|
||||
page: params.page,
|
||||
limit: params.limit,
|
||||
projectId: params.projectId,
|
||||
text: params.text,
|
||||
assigneeId: params.assigneeId,
|
||||
status: params.status,
|
||||
})
|
||||
return apiClient.get<{ list: Issue[]; meta: Meta }>(`/api/issues/list${query}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<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')
|
||||
}
|
||||
}
|
||||
|
||||
const hover = (userId: string) => {
|
||||
if (hoveredId.value === userId) {
|
||||
return { zIndex: 2 }
|
||||
}
|
||||
if (props.selected.includes(userId)) {
|
||||
return { zIndex: 1 }
|
||||
}
|
||||
|
||||
return { zIndex: 0 }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMore({ done: () => {} })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="d-flex align-center">
|
||||
<v-avatar
|
||||
v-for="user 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="hover(user.id)"
|
||||
@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"
|
||||
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(0rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -59,7 +59,7 @@ const select = (value: unknown) => {
|
||||
:text="currentInitials"
|
||||
rounded="circle"
|
||||
density="compact"
|
||||
color="primary"
|
||||
:class="{ 'bg-primary': value }"
|
||||
border
|
||||
/>
|
||||
</v-btn>
|
||||
@@ -78,7 +78,7 @@ const select = (value: unknown) => {
|
||||
>
|
||||
<v-list-item value="">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-avatar density="compact" border rounded="circle" icon="mdi-account" />
|
||||
<v-avatar density="compact" border rounded="circle" text="?" />
|
||||
<div>Unassigned</div>
|
||||
</div>
|
||||
</v-list-item>
|
||||
|
||||
@@ -36,11 +36,11 @@ const cancel = () => {
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<v-textarea class="mb-1" hide-details v-model="draft" variant="outlined" auto-grow rows="4" />
|
||||
<v-textarea autofocus class="mb-1" hide-details v-model="draft" variant="outlined" auto-grow rows="4" />
|
||||
|
||||
<div class="d-flex justify-end ga-1">
|
||||
<v-btn density="compact" variant="text" text="Cancel" @click="cancel" />
|
||||
<v-btn density="compact" color="primary" variant="tonal" text="Save" @click="save" />
|
||||
<v-btn density="compact" color="primary" variant="flat" text="Save" @click="save" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -1,33 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import AssigneeFilter from '@/components/AssigneeFilter.vue'
|
||||
import TextFilter from '@/components/TextFilter.vue'
|
||||
|
||||
const text = ref<string | null>(null)
|
||||
interface Props {
|
||||
text?: string | null
|
||||
assigneeId?: string | null
|
||||
}
|
||||
|
||||
async function onSearch() {
|
||||
console.log('search')
|
||||
const props = withDefaults(defineProps<Props>(), { text: null, assigneeId: null })
|
||||
const emit = defineEmits<{
|
||||
'update:text': [value: string | null]
|
||||
'update:assigneeId': [value: string | null]
|
||||
'update:clear': []
|
||||
}>()
|
||||
|
||||
const selectedAssignees = computed(() => (props.assigneeId ? [props.assigneeId] : []))
|
||||
|
||||
function onAssigneeChanged(value: string[]) {
|
||||
emit('update:assigneeId', value.at(-1) ?? null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="u-grid2 ga-2">
|
||||
<v-text-field
|
||||
v-model="text"
|
||||
clearable
|
||||
placeholder="Search name..."
|
||||
@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 class="filter-grid ga-4 align-center">
|
||||
<text-filter :model-value="text" @update:model-value="emit('update:text', $event)" />
|
||||
<assignee-filter :selected="selectedAssignees" @changed="onAssigneeChanged" />
|
||||
<v-btn prepend-icon="mdi-close" variant="tonal" @click="emit('update:clear')">Clear Filters</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.u-grid2 {
|
||||
.filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: 1fr auto auto;
|
||||
}
|
||||
</style>
|
||||
@@ -30,7 +30,8 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="projects.length" class="">
|
||||
<div v-if="projects.length" class="d-flex align-center ga-2">
|
||||
Project:
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn border density="comfortable" variant="flat" :text="currentProjectName" v-bind="props" />
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { modelValue: null })
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>()
|
||||
|
||||
const text = ref(props.modelValue)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
text.value = value
|
||||
},
|
||||
)
|
||||
|
||||
function onSearch() {
|
||||
emit('update:modelValue', text.value || null)
|
||||
}
|
||||
|
||||
function onClear() {
|
||||
text.value = null
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
</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>
|
||||
@@ -91,18 +91,20 @@ const goToSettings = () => {
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<v-divider class="mx-1" vertical />
|
||||
<v-app-bar-title>
|
||||
<div class="d-flex ga-2 align-center">
|
||||
<router-link class="text-decoration-none text-link font-weight-bold" to="/">
|
||||
{{ title }}
|
||||
</router-link>
|
||||
<span>v{{ version }}</span>
|
||||
<v-divider class="mx-2" vertical />
|
||||
|
||||
<project-menu />
|
||||
</div>
|
||||
</v-app-bar-title>
|
||||
|
||||
<div class="d-flex ga-4 align-center" v-if="session.data">
|
||||
<project-menu />
|
||||
<div class="mr-2">
|
||||
Hello,
|
||||
<router-link class="text-decoration-none text-link text-capitalize" to="/profile">
|
||||
|
||||
+3
-2
@@ -11,6 +11,7 @@ import '@mdi/font/css/materialdesignicons.css'
|
||||
|
||||
import { createVuetify } from 'vuetify'
|
||||
import * as components from 'vuetify/components'
|
||||
import { VAvatarGroup } from 'vuetify/labs/VAvatarGroup'
|
||||
import * as directives from 'vuetify/directives'
|
||||
import { registerLayouts } from '@/layouts/register.ts'
|
||||
|
||||
@@ -20,7 +21,7 @@ app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
const vuetify = createVuetify({
|
||||
components,
|
||||
components: { ...components, VAvatarGroup },
|
||||
directives,
|
||||
theme: {
|
||||
defaultTheme: 'dark', // 'system' | 'light' | 'dark'
|
||||
@@ -41,7 +42,7 @@ const vuetify = createVuetify({
|
||||
},
|
||||
defaults: {
|
||||
global: {
|
||||
rounded: 'md',
|
||||
rounded: 'lg',
|
||||
},
|
||||
VBtn: {
|
||||
variant: 'tonal',
|
||||
|
||||
+8
-1
@@ -24,7 +24,14 @@ export const useIssueStore = defineStore('item', () => {
|
||||
totalPages: 0,
|
||||
})
|
||||
|
||||
async function list(params: { page: number; limit: number; text?: string | null; projectId?: string | null }) {
|
||||
async function list(params: {
|
||||
page: number
|
||||
limit: number
|
||||
text?: string | null
|
||||
status?: string | null
|
||||
assigneeId?: string | null
|
||||
projectId?: string | null
|
||||
}) {
|
||||
try {
|
||||
const res = await itemsList(params)
|
||||
issues.value = res.list
|
||||
|
||||
@@ -9,6 +9,7 @@ import IssueCreate from '@/components/IssueCreate.vue'
|
||||
import type { Issue } from '@/api/issue.api.ts'
|
||||
import IssuesListElement from '@/components/IssuesListElement.vue'
|
||||
import IssueStats from '@/components/IssueStats.vue'
|
||||
import IssueFilter from '@/components/IssueFilter.vue'
|
||||
|
||||
const issueStore = useIssueStore()
|
||||
const issueDetailsRef = ref<InstanceType<typeof IssueDetails>>()
|
||||
@@ -25,6 +26,7 @@ const totalPages = ref(1)
|
||||
const serverItems = ref<Issue[]>([])
|
||||
|
||||
const text = ref<string | null>(null)
|
||||
const assigneeId = ref<string | null>(null)
|
||||
const selected = ref<[string] | []>([])
|
||||
const batch = ref<number[]>([])
|
||||
const deleting = ref(false)
|
||||
@@ -34,6 +36,7 @@ async function fetch() {
|
||||
page: page.value,
|
||||
limit: itemsPerPage.value,
|
||||
text: text.value,
|
||||
assigneeId: assigneeId.value,
|
||||
projectId: currentProjectId.value,
|
||||
})
|
||||
|
||||
@@ -47,14 +50,11 @@ async function load() {
|
||||
await fetch()
|
||||
}
|
||||
|
||||
// async function onSearch() {
|
||||
// await updatePage(1)
|
||||
// }
|
||||
|
||||
function syncQuery() {
|
||||
const query: Record<string, string> = { page: String(page.value) }
|
||||
|
||||
if (text.value) query.text = text.value
|
||||
if (assigneeId.value) query.assignee = assigneeId.value
|
||||
if (selected.value.length) query.selected_issue = selected.value[0]
|
||||
|
||||
router.replace({ query })
|
||||
@@ -65,6 +65,7 @@ watch(
|
||||
(query) => {
|
||||
page.value = query.page ? Number(query.page) : 1
|
||||
text.value = query.text ? String(query.text) : null
|
||||
assigneeId.value = query.assignee ? String(query.assignee) : null
|
||||
selected.value = query.selected_issue ? [String(query.selected_issue)] : []
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -89,6 +90,26 @@ async function updatePage(val: number) {
|
||||
await load()
|
||||
}
|
||||
|
||||
async function onTextChanged(value: string | null) {
|
||||
text.value = value
|
||||
page.value = 1
|
||||
await load()
|
||||
}
|
||||
|
||||
async function onAssigneeChanged(value: string | null) {
|
||||
assigneeId.value = value
|
||||
page.value = 1
|
||||
await load()
|
||||
}
|
||||
|
||||
async function onClear() {
|
||||
text.value = null
|
||||
assigneeId.value = null
|
||||
page.value = 1
|
||||
|
||||
await load()
|
||||
}
|
||||
|
||||
function close() {
|
||||
selected.value = []
|
||||
}
|
||||
@@ -150,8 +171,15 @@ async function onDeleteBatch() {
|
||||
<div class="d-flex flex-column ga-2">
|
||||
<div class="u-grid ga-1">
|
||||
<div class="u-height">
|
||||
<issue-filter
|
||||
class="mb-2"
|
||||
:text="text"
|
||||
:assignee-id="assigneeId"
|
||||
@update:text="onTextChanged"
|
||||
@update:assignee-id="onAssigneeChanged"
|
||||
@update:clear="onClear"
|
||||
/>
|
||||
<v-list class="pa-0" density="compact" v-model:selected="selected" mandatory>
|
||||
<!-- <issue-filter />-->
|
||||
<v-list-item>
|
||||
<template #prepend>
|
||||
<div class="mr-2" @click.stop>
|
||||
|
||||
Reference in new issue
Block a user