vitepress-theme-pm 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/dist/plugin.mjs +184 -0
- package/package.json +45 -0
- package/src/Layout.vue +15 -0
- package/src/cli.mjs +103 -0
- package/src/components/Board.vue +200 -0
- package/src/components/BoardCard.vue +73 -0
- package/src/components/BoardColumn.vue +90 -0
- package/src/components/MarkdownBody.vue +105 -0
- package/src/components/ProgressBar.vue +30 -0
- package/src/components/TagEditor.vue +65 -0
- package/src/components/TicketDetail.vue +257 -0
- package/src/composables/useDragDrop.ts +47 -0
- package/src/composables/useMarkdown.ts +24 -0
- package/src/composables/useTicketWriter.ts +32 -0
- package/src/index.ts +12 -0
- package/src/plugins/markdownWriter.ts +223 -0
- package/src/styles/board.css +40 -0
- package/src/types.ts +21 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Ticket } from '../types'
|
|
3
|
+
import { countCheckboxes } from '../composables/useMarkdown'
|
|
4
|
+
import { computed } from 'vue'
|
|
5
|
+
import ProgressBar from './ProgressBar.vue'
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
ticket: Ticket
|
|
9
|
+
color: string
|
|
10
|
+
selected: boolean
|
|
11
|
+
ticketPrefix: string
|
|
12
|
+
}>()
|
|
13
|
+
|
|
14
|
+
defineEmits<{
|
|
15
|
+
select: []
|
|
16
|
+
dragstart: [e: DragEvent]
|
|
17
|
+
dragend: [e: DragEvent]
|
|
18
|
+
}>()
|
|
19
|
+
|
|
20
|
+
const priorityColors: Record<string, string> = {
|
|
21
|
+
critical: '#f56565',
|
|
22
|
+
high: '#ed8936',
|
|
23
|
+
medium: '#ecc94b',
|
|
24
|
+
low: '#68d391',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const checks = computed(() => countCheckboxes(props.ticket.body))
|
|
28
|
+
const displayId = computed(() =>
|
|
29
|
+
props.ticketPrefix ? `${props.ticketPrefix}-${props.ticket.id}` : String(props.ticket.id)
|
|
30
|
+
)
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<div
|
|
35
|
+
draggable="true"
|
|
36
|
+
:style="{
|
|
37
|
+
background: selected ? '#2d3748' : '#1a202c',
|
|
38
|
+
border: selected ? `2px solid ${color}` : '1px solid #2d3748',
|
|
39
|
+
borderRadius: '7px',
|
|
40
|
+
padding: '10px 12px',
|
|
41
|
+
cursor: 'grab',
|
|
42
|
+
userSelect: 'none',
|
|
43
|
+
transition: 'border-color 0.15s',
|
|
44
|
+
}"
|
|
45
|
+
@click="$emit('select')"
|
|
46
|
+
@dragstart="$emit('dragstart', $event)"
|
|
47
|
+
@dragend="$emit('dragend', $event)"
|
|
48
|
+
>
|
|
49
|
+
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 4px">
|
|
50
|
+
<span style="font-size: 10px; color: #4a5568; font-family: monospace; font-weight: 600">{{ displayId }}</span>
|
|
51
|
+
<div
|
|
52
|
+
:style="{
|
|
53
|
+
width: '6px',
|
|
54
|
+
height: '6px',
|
|
55
|
+
borderRadius: '50%',
|
|
56
|
+
background: priorityColors[ticket.priority] || '#718096',
|
|
57
|
+
flexShrink: 0,
|
|
58
|
+
}"
|
|
59
|
+
/>
|
|
60
|
+
</div>
|
|
61
|
+
<div style="font-size: 13px; font-weight: 600; color: #e2e8f0; line-height: 1.3">{{ ticket.title }}</div>
|
|
62
|
+
<div v-if="ticket.tags.length" style="display: flex; flex-wrap: wrap; gap: 3px; margin-top: 6px">
|
|
63
|
+
<span
|
|
64
|
+
v-for="tag in ticket.tags"
|
|
65
|
+
:key="tag"
|
|
66
|
+
style="font-size: 10px; padding: 1px 6px; border-radius: 8px; background: #2d3748; color: #718096"
|
|
67
|
+
>{{ tag }}</span>
|
|
68
|
+
</div>
|
|
69
|
+
<div v-if="checks.total > 0" style="margin-top: 6px">
|
|
70
|
+
<ProgressBar :done="checks.done" :total="checks.total" :color="color" />
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</template>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Ticket, Column } from '../types'
|
|
3
|
+
import BoardCard from './BoardCard.vue'
|
|
4
|
+
|
|
5
|
+
defineProps<{
|
|
6
|
+
column: Column
|
|
7
|
+
tickets: Ticket[]
|
|
8
|
+
selectedId: number | null
|
|
9
|
+
ticketPrefix: string
|
|
10
|
+
isOver: boolean
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
defineEmits<{
|
|
14
|
+
select: [id: number]
|
|
15
|
+
dragstart: [e: DragEvent, id: number]
|
|
16
|
+
dragend: [e: DragEvent]
|
|
17
|
+
dragover: [e: DragEvent]
|
|
18
|
+
dragleave: []
|
|
19
|
+
drop: [e: DragEvent]
|
|
20
|
+
}>()
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<div
|
|
25
|
+
:style="{
|
|
26
|
+
flex: 1,
|
|
27
|
+
minWidth: '190px',
|
|
28
|
+
display: 'flex',
|
|
29
|
+
flexDirection: 'column',
|
|
30
|
+
margin: '0 4px',
|
|
31
|
+
borderRadius: '8px',
|
|
32
|
+
padding: '6px',
|
|
33
|
+
background: isOver ? column.color + '0d' : 'transparent',
|
|
34
|
+
border: isOver ? `2px dashed ${column.color}55` : '2px solid transparent',
|
|
35
|
+
transition: 'all 0.15s',
|
|
36
|
+
}"
|
|
37
|
+
@dragover.prevent="$emit('dragover', $event)"
|
|
38
|
+
@dragleave="$emit('dragleave')"
|
|
39
|
+
@drop.prevent="$emit('drop', $event)"
|
|
40
|
+
>
|
|
41
|
+
<div
|
|
42
|
+
:style="{
|
|
43
|
+
display: 'flex',
|
|
44
|
+
justifyContent: 'space-between',
|
|
45
|
+
alignItems: 'center',
|
|
46
|
+
padding: '4px 8px',
|
|
47
|
+
marginBottom: '8px',
|
|
48
|
+
borderBottom: `2px solid ${column.color}`,
|
|
49
|
+
}"
|
|
50
|
+
>
|
|
51
|
+
<span
|
|
52
|
+
:style="{
|
|
53
|
+
fontSize: '11px',
|
|
54
|
+
fontWeight: 700,
|
|
55
|
+
color: column.color,
|
|
56
|
+
textTransform: 'uppercase',
|
|
57
|
+
letterSpacing: '1px',
|
|
58
|
+
}"
|
|
59
|
+
>{{ column.label }}</span>
|
|
60
|
+
<span
|
|
61
|
+
:style="{
|
|
62
|
+
fontSize: '11px',
|
|
63
|
+
color: column.color,
|
|
64
|
+
background: column.color + '18',
|
|
65
|
+
padding: '1px 7px',
|
|
66
|
+
borderRadius: '10px',
|
|
67
|
+
fontWeight: 600,
|
|
68
|
+
}"
|
|
69
|
+
>{{ tickets.length }}</span>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div class="board-column-cards" style="flex: 1; display: flex; flex-direction: column; gap: 6px; overflow-y: auto; padding-bottom: 12px">
|
|
73
|
+
<BoardCard
|
|
74
|
+
v-for="ticket in tickets"
|
|
75
|
+
:key="ticket.id"
|
|
76
|
+
:ticket="ticket"
|
|
77
|
+
:color="column.color"
|
|
78
|
+
:selected="selectedId === ticket.id"
|
|
79
|
+
:ticket-prefix="ticketPrefix"
|
|
80
|
+
@select="$emit('select', ticket.id)"
|
|
81
|
+
@dragstart="$emit('dragstart', $event, ticket.id)"
|
|
82
|
+
@dragend="$emit('dragend', $event)"
|
|
83
|
+
/>
|
|
84
|
+
<div
|
|
85
|
+
v-if="tickets.length === 0"
|
|
86
|
+
style="padding: 24px; text-align: center; color: rgba(45, 55, 72, 0.4); font-size: 12px; border: 1px dashed #2d3748; border-radius: 8px"
|
|
87
|
+
>Drop here</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</template>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{
|
|
3
|
+
text: string
|
|
4
|
+
}>()
|
|
5
|
+
|
|
6
|
+
const emit = defineEmits<{
|
|
7
|
+
checkboxToggle: [index: number]
|
|
8
|
+
}>()
|
|
9
|
+
|
|
10
|
+
interface ParsedLine {
|
|
11
|
+
type: 'checkbox' | 'heading2' | 'heading3' | 'list' | 'empty' | 'code-fence' | 'paragraph'
|
|
12
|
+
content: string
|
|
13
|
+
checked?: boolean
|
|
14
|
+
checkboxIndex?: number
|
|
15
|
+
indent?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseLines(text: string): ParsedLine[] {
|
|
19
|
+
const lines = text.split('\n')
|
|
20
|
+
let ci = -1
|
|
21
|
+
return lines.map(line => {
|
|
22
|
+
const cb = line.match(/^(\s*)- \[([ x])\] (.+)$/)
|
|
23
|
+
if (cb) {
|
|
24
|
+
ci++
|
|
25
|
+
return {
|
|
26
|
+
type: 'checkbox' as const,
|
|
27
|
+
content: cb[3],
|
|
28
|
+
checked: cb[2] === 'x',
|
|
29
|
+
checkboxIndex: ci,
|
|
30
|
+
indent: cb[1].length,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (line.startsWith('## ')) return { type: 'heading2' as const, content: line.slice(3) }
|
|
34
|
+
if (line.startsWith('### ')) return { type: 'heading3' as const, content: line.slice(4) }
|
|
35
|
+
if (line.startsWith('```')) return { type: 'code-fence' as const, content: '' }
|
|
36
|
+
if (line.startsWith('- ')) return { type: 'list' as const, content: line.slice(2) }
|
|
37
|
+
if (line.trim() === '') return { type: 'empty' as const, content: '' }
|
|
38
|
+
return { type: 'paragraph' as const, content: line }
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<template>
|
|
44
|
+
<div v-if="!text" style="color: #4a5568; font-size: 13px; font-style: italic">No content.</div>
|
|
45
|
+
<div v-else>
|
|
46
|
+
<template v-for="(line, i) in parseLines(text)" :key="i">
|
|
47
|
+
<button
|
|
48
|
+
v-if="line.type === 'checkbox'"
|
|
49
|
+
style="display: flex; align-items: flex-start; gap: 8px; padding: 3px 0; background: none; border: none; cursor: pointer; text-align: left; width: 100%"
|
|
50
|
+
:style="{ paddingLeft: 12 + (line.indent || 0) * 12 + 'px' }"
|
|
51
|
+
@click="emit('checkboxToggle', line.checkboxIndex!)"
|
|
52
|
+
>
|
|
53
|
+
<div
|
|
54
|
+
:style="{
|
|
55
|
+
width: '15px',
|
|
56
|
+
height: '15px',
|
|
57
|
+
borderRadius: '3px',
|
|
58
|
+
flexShrink: 0,
|
|
59
|
+
marginTop: '1px',
|
|
60
|
+
border: line.checked ? 'none' : '2px solid #4a5568',
|
|
61
|
+
background: line.checked ? '#2d5a2d' : 'transparent',
|
|
62
|
+
display: 'flex',
|
|
63
|
+
alignItems: 'center',
|
|
64
|
+
justifyContent: 'center',
|
|
65
|
+
fontSize: '9px',
|
|
66
|
+
color: '#6bcb6b',
|
|
67
|
+
}"
|
|
68
|
+
>
|
|
69
|
+
<span v-if="line.checked">✓</span>
|
|
70
|
+
</div>
|
|
71
|
+
<span
|
|
72
|
+
:style="{
|
|
73
|
+
fontSize: '13px',
|
|
74
|
+
color: line.checked ? '#6bcb6b' : '#cbd5e0',
|
|
75
|
+
opacity: line.checked ? 0.75 : 1,
|
|
76
|
+
lineHeight: '1.4',
|
|
77
|
+
}"
|
|
78
|
+
>{{ line.content }}</span>
|
|
79
|
+
</button>
|
|
80
|
+
|
|
81
|
+
<h2
|
|
82
|
+
v-else-if="line.type === 'heading2'"
|
|
83
|
+
style="font-size: 18px; font-weight: 700; color: #e2e8f0; margin: 24px 0 8px 0; padding-bottom: 6px; border-bottom: 1px solid #2d3748"
|
|
84
|
+
>{{ line.content }}</h2>
|
|
85
|
+
|
|
86
|
+
<h3
|
|
87
|
+
v-else-if="line.type === 'heading3'"
|
|
88
|
+
style="font-size: 15px; font-weight: 600; color: #a0aec0; margin: 16px 0 6px 0"
|
|
89
|
+
>{{ line.content }}</h3>
|
|
90
|
+
|
|
91
|
+
<div
|
|
92
|
+
v-else-if="line.type === 'list'"
|
|
93
|
+
style="font-size: 13px; color: #cbd5e0; padding: 2px 0 2px 16px; line-height: 1.6"
|
|
94
|
+
>• {{ line.content }}</div>
|
|
95
|
+
|
|
96
|
+
<div v-else-if="line.type === 'empty'" style="height: 8px" />
|
|
97
|
+
<div v-else-if="line.type === 'code-fence'" style="height: 0" />
|
|
98
|
+
|
|
99
|
+
<p
|
|
100
|
+
v-else
|
|
101
|
+
style="font-size: 14px; color: #cbd5e0; margin: 3px 0; line-height: 1.7"
|
|
102
|
+
>{{ line.content }}</p>
|
|
103
|
+
</template>
|
|
104
|
+
</div>
|
|
105
|
+
</template>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const props = defineProps<{
|
|
3
|
+
done: number
|
|
4
|
+
total: number
|
|
5
|
+
color?: string
|
|
6
|
+
}>()
|
|
7
|
+
|
|
8
|
+
const percentage = computed(() =>
|
|
9
|
+
props.total === 0 ? 0 : Math.round((props.done / props.total) * 100)
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
import { computed } from 'vue'
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<div style="display: flex; align-items: center; gap: 6px">
|
|
17
|
+
<div style="flex: 1; height: 4px; background: var(--board-border, #2d3748); border-radius: 2px; overflow: hidden">
|
|
18
|
+
<div
|
|
19
|
+
:style="{
|
|
20
|
+
width: percentage + '%',
|
|
21
|
+
height: '100%',
|
|
22
|
+
background: color || '#718096',
|
|
23
|
+
borderRadius: '2px',
|
|
24
|
+
transition: 'width 0.3s',
|
|
25
|
+
}"
|
|
26
|
+
/>
|
|
27
|
+
</div>
|
|
28
|
+
<span style="font-size: 10px; color: var(--board-text-muted, #718096)">{{ done }}/{{ total }}</span>
|
|
29
|
+
</div>
|
|
30
|
+
</template>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, nextTick } from 'vue'
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
tags: string[]
|
|
6
|
+
}>()
|
|
7
|
+
|
|
8
|
+
const emit = defineEmits<{
|
|
9
|
+
add: [tag: string]
|
|
10
|
+
remove: [tag: string]
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
const tagInput = ref('')
|
|
14
|
+
const inputRef = ref<HTMLInputElement | null>(null)
|
|
15
|
+
|
|
16
|
+
function addTag() {
|
|
17
|
+
const t = tagInput.value.trim().toLowerCase()
|
|
18
|
+
if (t) {
|
|
19
|
+
emit('add', t)
|
|
20
|
+
tagInput.value = ''
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function onKeydown(e: KeyboardEvent) {
|
|
25
|
+
if (e.key === 'Enter') {
|
|
26
|
+
e.preventDefault()
|
|
27
|
+
addTag()
|
|
28
|
+
}
|
|
29
|
+
if (e.key === 'Backspace' && tagInput.value === '') {
|
|
30
|
+
// Remove last tag on backspace in empty input
|
|
31
|
+
emit('remove', '')
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function focusInput() {
|
|
36
|
+
inputRef.value?.focus()
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<div
|
|
42
|
+
style="display: flex; flex-wrap: wrap; align-items: center; gap: 4px; padding: 4px 8px; background: #0d1117; border: 1px solid #2d3748; border-radius: 6px; min-height: 32px; cursor: text"
|
|
43
|
+
@click="focusInput"
|
|
44
|
+
>
|
|
45
|
+
<span
|
|
46
|
+
v-for="tag in tags"
|
|
47
|
+
:key="tag"
|
|
48
|
+
style="display: inline-flex; align-items: center; gap: 3px; font-size: 11px; padding: 2px 8px; border-radius: 10px; background: #2d3748; color: #a0aec0; white-space: nowrap"
|
|
49
|
+
>
|
|
50
|
+
{{ tag }}
|
|
51
|
+
<button
|
|
52
|
+
style="background: none; border: none; color: #718096; cursor: pointer; font-size: 12px; padding: 0; line-height: 1; display: flex; align-items: center"
|
|
53
|
+
@click.stop="emit('remove', tag)"
|
|
54
|
+
>×</button>
|
|
55
|
+
</span>
|
|
56
|
+
<input
|
|
57
|
+
ref="inputRef"
|
|
58
|
+
v-model="tagInput"
|
|
59
|
+
placeholder="Add tag..."
|
|
60
|
+
style="flex: 1; min-width: 60px; font-size: 12px; padding: 2px 0; background: transparent; border: none; color: #e2e8f0; outline: none"
|
|
61
|
+
@keydown="onKeydown"
|
|
62
|
+
@blur="addTag"
|
|
63
|
+
>
|
|
64
|
+
</div>
|
|
65
|
+
</template>
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, watch, nextTick, computed, onMounted, onUnmounted } from 'vue'
|
|
3
|
+
import type { Ticket, Column } from '../types'
|
|
4
|
+
import { countCheckboxes, toggleCheckbox } from '../composables/useMarkdown'
|
|
5
|
+
import MarkdownBody from './MarkdownBody.vue'
|
|
6
|
+
import ProgressBar from './ProgressBar.vue'
|
|
7
|
+
import TagEditor from './TagEditor.vue'
|
|
8
|
+
|
|
9
|
+
const props = withDefaults(defineProps<{
|
|
10
|
+
ticket: Ticket
|
|
11
|
+
columns: Column[]
|
|
12
|
+
ticketPrefix: string
|
|
13
|
+
createMode?: boolean
|
|
14
|
+
}>(), {
|
|
15
|
+
createMode: false,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const emit = defineEmits<{
|
|
19
|
+
close: []
|
|
20
|
+
update: [id: number, patch: Partial<Ticket>]
|
|
21
|
+
delete: [id: number]
|
|
22
|
+
create: []
|
|
23
|
+
}>()
|
|
24
|
+
|
|
25
|
+
const priorityOptions = ['critical', 'high', 'medium', 'low'] as const
|
|
26
|
+
const priorityColors: Record<string, string> = {
|
|
27
|
+
critical: '#f56565',
|
|
28
|
+
high: '#ed8936',
|
|
29
|
+
medium: '#ecc94b',
|
|
30
|
+
low: '#68d391',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const editing = ref(false)
|
|
34
|
+
const draft = ref(props.ticket.body)
|
|
35
|
+
const editTitle = ref(false)
|
|
36
|
+
const titleDraft = ref(props.ticket.title)
|
|
37
|
+
const titleRef = ref<HTMLInputElement | null>(null)
|
|
38
|
+
|
|
39
|
+
const col = computed(() => props.columns.find(c => c.key === props.ticket.status))
|
|
40
|
+
const checks = computed(() => countCheckboxes(props.ticket.body))
|
|
41
|
+
const displayId = computed(() =>
|
|
42
|
+
props.ticketPrefix ? `${props.ticketPrefix}-${props.ticket.id}` : String(props.ticket.id)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
watch(() => props.ticket.id, () => {
|
|
46
|
+
draft.value = props.ticket.body
|
|
47
|
+
titleDraft.value = props.ticket.title
|
|
48
|
+
editing.value = false
|
|
49
|
+
editTitle.value = false
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
watch(editTitle, (val) => {
|
|
53
|
+
if (val) nextTick(() => titleRef.value?.focus())
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
function onEscape(e: KeyboardEvent) {
|
|
57
|
+
if (e.key === 'Escape' && !editTitle.value && !editing.value) emit('close')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
onMounted(() => {
|
|
61
|
+
document.addEventListener('keydown', onEscape)
|
|
62
|
+
if (props.createMode) {
|
|
63
|
+
editTitle.value = true
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
onUnmounted(() => document.removeEventListener('keydown', onEscape))
|
|
67
|
+
|
|
68
|
+
function saveBody() {
|
|
69
|
+
emit('update', props.ticket.id, { body: draft.value })
|
|
70
|
+
editing.value = false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function saveTitle() {
|
|
74
|
+
if (titleDraft.value.trim()) {
|
|
75
|
+
emit('update', props.ticket.id, { title: titleDraft.value.trim() })
|
|
76
|
+
}
|
|
77
|
+
editTitle.value = false
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function onCheckboxToggle(idx: number) {
|
|
81
|
+
emit('update', props.ticket.id, { body: toggleCheckbox(props.ticket.body, idx) })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function addTag(tag: string) {
|
|
85
|
+
if (tag && !props.ticket.tags.includes(tag)) {
|
|
86
|
+
emit('update', props.ticket.id, { tags: [...props.ticket.tags, tag] })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function removeTag(tag: string) {
|
|
91
|
+
if (!tag) {
|
|
92
|
+
if (props.ticket.tags.length > 0) {
|
|
93
|
+
emit('update', props.ticket.id, { tags: props.ticket.tags.slice(0, -1) })
|
|
94
|
+
}
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
emit('update', props.ticket.id, { tags: props.ticket.tags.filter(t => t !== tag) })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function onBackdropClick(e: MouseEvent) {
|
|
101
|
+
if ((e.target as HTMLElement).classList.contains('ticket-modal-backdrop')) {
|
|
102
|
+
emit('close')
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
</script>
|
|
106
|
+
|
|
107
|
+
<template>
|
|
108
|
+
<!-- Modal backdrop -->
|
|
109
|
+
<div
|
|
110
|
+
class="ticket-modal-backdrop"
|
|
111
|
+
style="position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(2px)"
|
|
112
|
+
@click="onBackdropClick"
|
|
113
|
+
>
|
|
114
|
+
<!-- Modal container -->
|
|
115
|
+
<div style="width: 90vw; max-width: 960px; height: 80vh; max-height: 700px; background: #0d1117; border: 1px solid #2d3748; border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 24px 48px rgba(0,0,0,0.4)">
|
|
116
|
+
|
|
117
|
+
<!-- Header bar -->
|
|
118
|
+
<div style="display: flex; align-items: center; padding: 16px 24px; border-bottom: 1px solid #2d3748; flex-shrink: 0; background: #171923; gap: 12px">
|
|
119
|
+
<!-- Ticket ID badge -->
|
|
120
|
+
<span v-if="!createMode" style="font-size: 13px; font-weight: 700; color: #718096; font-family: monospace; white-space: nowrap; flex-shrink: 0">{{ displayId }}</span>
|
|
121
|
+
<span v-else style="font-size: 13px; font-weight: 700; color: #6bcb6b; font-family: monospace; white-space: nowrap; flex-shrink: 0">NEW</span>
|
|
122
|
+
|
|
123
|
+
<!-- Title (selectable text, not click-to-edit) -->
|
|
124
|
+
<div v-if="editTitle" style="flex: 1; min-width: 0">
|
|
125
|
+
<input
|
|
126
|
+
ref="titleRef"
|
|
127
|
+
v-model="titleDraft"
|
|
128
|
+
style="width: 100%; font-size: 18px; font-weight: 700; color: #e2e8f0; background: #0d1117; border: 1px solid #e6a817; border-radius: 4px; padding: 4px 10px; outline: none"
|
|
129
|
+
@blur="saveTitle"
|
|
130
|
+
@keydown.enter="saveTitle"
|
|
131
|
+
@keydown.escape.stop="editTitle = false"
|
|
132
|
+
>
|
|
133
|
+
</div>
|
|
134
|
+
<h2
|
|
135
|
+
v-else
|
|
136
|
+
style="margin: 0; font-size: 18px; font-weight: 700; color: #e2e8f0; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap"
|
|
137
|
+
>{{ ticket.title }}</h2>
|
|
138
|
+
|
|
139
|
+
<!-- Edit title button -->
|
|
140
|
+
<button
|
|
141
|
+
v-if="!editTitle"
|
|
142
|
+
title="Edit title"
|
|
143
|
+
style="background: none; border: 1px solid #2d3748; color: #718096; cursor: pointer; font-size: 13px; padding: 4px 8px; border-radius: 4px; flex-shrink: 0; line-height: 1"
|
|
144
|
+
@click="titleDraft = ticket.title; editTitle = true"
|
|
145
|
+
>✎</button>
|
|
146
|
+
|
|
147
|
+
<!-- Close button -->
|
|
148
|
+
<button
|
|
149
|
+
style="background: none; border: none; color: #718096; cursor: pointer; font-size: 20px; padding: 4px 8px; flex-shrink: 0; line-height: 1"
|
|
150
|
+
@click="emit('close')"
|
|
151
|
+
>×</button>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<!-- Two-column body -->
|
|
155
|
+
<div style="flex: 1; display: flex; overflow: hidden">
|
|
156
|
+
|
|
157
|
+
<!-- Left: Description -->
|
|
158
|
+
<div style="flex: 1; overflow-y: auto; padding: 24px; min-width: 0">
|
|
159
|
+
<div v-if="checks.total > 0" style="margin-bottom: 16px">
|
|
160
|
+
<ProgressBar :done="checks.done" :total="checks.total" :color="col?.color || '#718096'" />
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px">
|
|
164
|
+
<span style="font-size: 12px; color: #718096; text-transform: uppercase; letter-spacing: 1px; font-weight: 700">Description</span>
|
|
165
|
+
<button
|
|
166
|
+
v-if="!editing"
|
|
167
|
+
style="font-size: 12px; color: #e6a817; background: none; border: 1px solid rgba(230, 168, 23, 0.27); border-radius: 4px; padding: 3px 12px; cursor: pointer"
|
|
168
|
+
@click="draft = ticket.body; editing = true"
|
|
169
|
+
>Edit</button>
|
|
170
|
+
<div v-else style="display: flex; gap: 6px">
|
|
171
|
+
<button
|
|
172
|
+
style="font-size: 12px; color: #6bcb6b; background: rgba(107, 203, 107, 0.09); border: 1px solid rgba(107, 203, 107, 0.27); border-radius: 4px; padding: 3px 12px; cursor: pointer"
|
|
173
|
+
@click="saveBody"
|
|
174
|
+
>Save</button>
|
|
175
|
+
<button
|
|
176
|
+
style="font-size: 12px; color: #718096; background: none; border: 1px solid #2d3748; border-radius: 4px; padding: 3px 12px; cursor: pointer"
|
|
177
|
+
@click="editing = false"
|
|
178
|
+
>Cancel</button>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<textarea
|
|
183
|
+
v-if="editing"
|
|
184
|
+
v-model="draft"
|
|
185
|
+
placeholder="Markdown here... Use - [ ] for checkboxes"
|
|
186
|
+
style="width: 100%; min-height: 300px; padding: 12px; font-size: 13px; background: #171923; color: #e2e8f0; border: 1px solid rgba(230, 168, 23, 0.27); border-radius: 6px; resize: vertical; font-family: 'JetBrains Mono', monospace; line-height: 1.6; outline: none; box-sizing: border-box"
|
|
187
|
+
/>
|
|
188
|
+
<MarkdownBody v-else :text="ticket.body" @checkbox-toggle="onCheckboxToggle" />
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<!-- Right: Metadata sidebar -->
|
|
192
|
+
<div style="width: 280px; flex-shrink: 0; border-left: 1px solid #2d3748; overflow-y: auto; padding: 24px; background: #171923">
|
|
193
|
+
|
|
194
|
+
<!-- Status -->
|
|
195
|
+
<div style="margin-bottom: 20px">
|
|
196
|
+
<label style="display: block; font-size: 11px; color: #718096; text-transform: uppercase; letter-spacing: 1px; font-weight: 700; margin-bottom: 6px">Status</label>
|
|
197
|
+
<div style="position: relative">
|
|
198
|
+
<select
|
|
199
|
+
:value="ticket.status"
|
|
200
|
+
style="width: 100%; appearance: none; font-size: 13px; padding: 7px 32px 7px 10px; background: #0d1117; border: 1px solid #2d3748; border-radius: 6px; color: #e2e8f0; outline: none; cursor: pointer"
|
|
201
|
+
@change="emit('update', ticket.id, { status: ($event.target as HTMLSelectElement).value })"
|
|
202
|
+
>
|
|
203
|
+
<option
|
|
204
|
+
v-for="c in columns"
|
|
205
|
+
:key="c.key"
|
|
206
|
+
:value="c.key"
|
|
207
|
+
>{{ c.label }}</option>
|
|
208
|
+
</select>
|
|
209
|
+
<div style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); pointer-events: none; color: #718096; font-size: 10px">▼</div>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<!-- Priority -->
|
|
214
|
+
<div style="margin-bottom: 20px">
|
|
215
|
+
<label style="display: block; font-size: 11px; color: #718096; text-transform: uppercase; letter-spacing: 1px; font-weight: 700; margin-bottom: 6px">Priority</label>
|
|
216
|
+
<div style="position: relative">
|
|
217
|
+
<select
|
|
218
|
+
:value="ticket.priority"
|
|
219
|
+
style="width: 100%; appearance: none; font-size: 13px; padding: 7px 32px 7px 10px; background: #0d1117; border: 1px solid #2d3748; border-radius: 6px; color: #e2e8f0; outline: none; cursor: pointer"
|
|
220
|
+
@change="emit('update', ticket.id, { priority: ($event.target as HTMLSelectElement).value as any })"
|
|
221
|
+
>
|
|
222
|
+
<option v-for="p in priorityOptions" :key="p" :value="p">{{ p.charAt(0).toUpperCase() + p.slice(1) }}</option>
|
|
223
|
+
</select>
|
|
224
|
+
<div style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); pointer-events: none; color: #718096; font-size: 10px">▼</div>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<!-- Tags -->
|
|
229
|
+
<div style="margin-bottom: 20px">
|
|
230
|
+
<label style="display: block; font-size: 11px; color: #718096; text-transform: uppercase; letter-spacing: 1px; font-weight: 700; margin-bottom: 6px">Tags</label>
|
|
231
|
+
<TagEditor :tags="ticket.tags" @add="addTag" @remove="removeTag" />
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<!-- Ticket ID -->
|
|
235
|
+
<div v-if="!createMode" style="margin-bottom: 20px">
|
|
236
|
+
<label style="display: block; font-size: 11px; color: #718096; text-transform: uppercase; letter-spacing: 1px; font-weight: 700; margin-bottom: 6px">ID</label>
|
|
237
|
+
<span style="font-size: 13px; color: #4a5568; font-family: monospace">{{ displayId }}</span>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<!-- Create / Delete -->
|
|
241
|
+
<div style="padding-top: 20px; border-top: 1px solid #2d3748">
|
|
242
|
+
<button
|
|
243
|
+
v-if="createMode"
|
|
244
|
+
style="font-size: 12px; color: #6bcb6b; background: rgba(107, 203, 107, 0.09); border: 1px solid rgba(107, 203, 107, 0.27); border-radius: 6px; padding: 6px 14px; cursor: pointer; width: 100%; font-weight: 600"
|
|
245
|
+
@click="emit('create')"
|
|
246
|
+
>Create ticket</button>
|
|
247
|
+
<button
|
|
248
|
+
v-else
|
|
249
|
+
style="font-size: 12px; color: #f56565; background: none; border: 1px solid rgba(245, 101, 101, 0.27); border-radius: 6px; padding: 6px 14px; cursor: pointer; width: 100%"
|
|
250
|
+
@click="emit('delete', ticket.id)"
|
|
251
|
+
>Delete ticket</button>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
</template>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
export function useDragDrop(onDrop: (ticketId: string, targetColumn: string) => void) {
|
|
4
|
+
const dragOverColumn = ref<string | null>(null)
|
|
5
|
+
|
|
6
|
+
function handleDragStart(e: DragEvent, ticketId: string) {
|
|
7
|
+
if (!e.dataTransfer) return
|
|
8
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
9
|
+
e.dataTransfer.setData('text/plain', ticketId)
|
|
10
|
+
requestAnimationFrame(() => {
|
|
11
|
+
const el = e.target as HTMLElement
|
|
12
|
+
el.style.opacity = '0.4'
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function handleDragEnd(e: DragEvent) {
|
|
17
|
+
const el = e.target as HTMLElement
|
|
18
|
+
el.style.opacity = '1'
|
|
19
|
+
dragOverColumn.value = null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function handleDragOver(e: DragEvent, columnKey: string) {
|
|
23
|
+
e.preventDefault()
|
|
24
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
|
|
25
|
+
dragOverColumn.value = columnKey
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function handleDragLeave() {
|
|
29
|
+
dragOverColumn.value = null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function handleDrop(e: DragEvent, columnKey: string) {
|
|
33
|
+
e.preventDefault()
|
|
34
|
+
const ticketId = e.dataTransfer?.getData('text/plain')
|
|
35
|
+
if (ticketId) onDrop(ticketId, columnKey)
|
|
36
|
+
dragOverColumn.value = null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
dragOverColumn,
|
|
41
|
+
handleDragStart,
|
|
42
|
+
handleDragEnd,
|
|
43
|
+
handleDragOver,
|
|
44
|
+
handleDragLeave,
|
|
45
|
+
handleDrop,
|
|
46
|
+
}
|
|
47
|
+
}
|