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.
@@ -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">&#10003;</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
+ >&bull; {{ 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
+ >&times;</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
+ >&#9998;</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
+ >&times;</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">&#9660;</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">&#9660;</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
+ }