web: break ticket detail view up
vi did:web:vt3e.cat
Wed, 01 Jul 2026 01:34:43 +0100
7 files changed,
786 insertions(+),
707 deletions(-)
jump to
A
apps/web/src/components/Tickets/AttachmentComponent.vue
@@ -0,0 +1,106 @@
+<script setup lang="ts"> +import { computed } from "vue"; +import type { SanitisedTicketAttachment } from "@stealth-developers/api"; + +const props = defineProps<{ + attachment: SanitisedTicketAttachment; +}>(); + +const fileType = computed(() => props.attachment.fileType || ""); +const fileSize = computed(() => props.attachment.fileSize || 0); +const fileName = computed(() => props.attachment.fileName || "Unknown"); + +const url = computed(() => `https://api.media.vt3e.cat/sd-prod/${props.attachment.key}`); + +const formatSize = (bytes: number) => { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]!; +}; +</script> + +<template> + <div v-if="fileType.startsWith('video/')" class="attachment-card video"> + <video :src="url" controls class="attachment-media"></video> + </div> + <a v-else :href="url" target="_blank" class="attachment-card link"> + <img + v-if="fileType.startsWith('image/')" + :src="url" + :alt="fileName" + class="attachment-media" + /> + <div class="attachment-file" v-else> + <div class="file-info"> + <span class="filename" :title="fileName">{{ fileName }}</span> + <span class="size">{{ formatSize(fileSize) }}</span> + </div> + </div> + </a> +</template> + +<style scoped> +.attachment-card { + display: flex; + flex-direction: column; + border-radius: 0.75rem; + overflow: hidden; + background-color: hsla(var(--surface0) / 0.3); + border: 1px solid hsla(var(--surface0) / 0.5); + max-width: 100%; + + &.link { + text-decoration: none; + transition: border-color var(--transition); + + &:hover { + border-color: hsla(var(--accent) / 0.5); + } + } + + .attachment-media { + display: block; + max-width: 100%; + max-height: 160px; + object-fit: cover; + } + + img.attachment-media { + min-width: 160px; + } + + video.attachment-media { + max-width: 300px; + } + + .attachment-file { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + color: hsl(var(--text)); + + .file-info { + display: flex; + flex-direction: column; + min-width: 0; + + .filename { + font-weight: 600; + font-size: 0.9rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 180px; + } + + .size { + color: hsl(var(--subtext0)); + font-size: 0.75rem; + } + } + } +} +</style>
A
apps/web/src/components/Tickets/TicketAttachments.vue
@@ -0,0 +1,149 @@
+<script setup lang="ts"> +import type { SanitisedTicketAttachment } from "@stealth-developers/api"; +import AttachmentCard from "./AttachmentComponent.vue"; + +defineProps<{ + attachments: SanitisedTicketAttachment[]; +}>(); +</script> + +<template> + <div class="ticket-attachments"> + <div class="attachments-header"> + <span class="label">Attachments</span> + </div> + + <div class="attachments-grid" v-if="attachments.length > 0"> + <AttachmentCard + v-for="att in attachments" + :key="att.id" + :attachment="att" + /> + </div> + <div v-else class="no-attachments">No attachments uploaded yet.</div> + </div> +</template> + +<style scoped> +.ticket-attachments { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; + background-color: hsl(var(--crust)); + border-radius: 1.5rem; + + .attachments-header { + font-weight: 700; + font-size: 1.1rem; + color: hsl(var(--text)); + } + + .no-attachments { + color: hsl(var(--subtext0)); + font-style: italic; + font-size: 0.9rem; + } + + .ticket-attachments-upload { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 1rem; + + .upload-btn { + display: inline-block; + padding: 0.5rem 1.2rem; + background-color: hsla(var(--surface0) / 0.8); + color: hsl(var(--accent)); + border-radius: 5rem; + cursor: pointer; + font-weight: 600; + user-select: none; + + &:hover { + background-color: hsla(var(--accent) / 1); + color: hsl(var(--on-accent)); + } + + &.highlighted { + position: relative; + overflow: hidden; + border: 2px solid hsl(var(--accent)); + + &::after { + content: ""; + position: absolute; + top: -50%; + left: -60%; + width: 20%; + height: 200%; + background: white; + opacity: 0.3; + transform: rotate(30deg); + animation: shimmer 2s infinite; + filter: blur(4px); + } + } + + .hidden-input { + display: none; + } + } + + .upload-status { + font-size: 0.85rem; + min-width: 140px; + font-variant-numeric: tabular-nums; + opacity: 0; + + &.visible { + opacity: 1; + } + + .uploading { + color: hsl(var(--accent)); + font-weight: 600; + } + .error { + color: hsl(var(--red)); + font-weight: 600; + } + } + + .progress-bar-container { + width: 180px; + height: 6px; + background-color: hsla(var(--surface1)); + border-radius: 5rem; + overflow: hidden; + opacity: 0; + + &.visible { + opacity: 1; + } + + .progress-bar { + height: 100%; + background-color: hsl(var(--accent)); + transition: width 0.1s ease; + } + } + } +} + +.attachments-grid { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +@keyframes shimmer { + 0% { + left: -60%; + } + 100% { + left: 150%; + } +} +</style>
A
apps/web/src/components/Tickets/TicketComments.vue
@@ -0,0 +1,56 @@
+<script setup lang="ts"> +import type { SanitisedTicketComment } from "@stealth-developers/api"; +import UserChip from "@/components/UserChip.vue"; + +defineProps<{ + comments: SanitisedTicketComment[]; +}>(); +</script> + +<template> + <div class="ticket-comments" v-if="comments.length > 0"> + <h2>Comments</h2> + <div class="comments-list"> + <div v-for="comment in comments" :key="comment.id" class="comment"> + <UserChip :user="comment.actors?.author" /> + <p class="comment-text">{{ comment.content }}</p> + </div> + </div> + </div> +</template> + +<style scoped> +.ticket-comments { + display: flex; + flex-direction: column; + background-color: hsl(var(--crust)); + border-radius: 1.5rem; + overflow: hidden; + margin-bottom: 0.5rem; + + h2 { + padding: 0.5rem 1rem; + font-size: 1.5rem; + font-weight: 800; + color: hsl(var(--text)); + } + + .comments-list { + display: flex; + flex-direction: column; + background-color: hsl(var(--crust)); + + .comment { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.5rem 1rem; + + &:hover { + background-color: hsla(var(--surface0) / 0.25); + transition: none; + } + } + } +} +</style>
A
apps/web/src/components/Tickets/TicketMessages.vue
@@ -0,0 +1,307 @@
+<script setup lang="ts"> +import { computed } from "vue"; +import type { + SanitisedActor, + SanitisedTicketAttachment, + SanitisedTicketMessage, +} from "@stealth-developers/api"; +import TimeAgo from "@/components/TimeAgo.vue"; +import AttachmentCard from "./AttachmentComponent.vue"; + +const props = defineProps<{ + messages: SanitisedTicketMessage[]; + attachments: SanitisedTicketAttachment[]; + actorMap: Record<string, SanitisedActor>; +}>(); + +const getAvatarUrl = (actor: SanitisedActor) => { + if (!actor) return "https://cdn.discordapp.com/embed/avatars/0.png"; + if (actor.avatar?.startsWith("https")) return actor.avatar; + if (actor.discordId && actor.avatar) { + return `https://cdn.discordapp.com/avatars/${actor.discordId}/${actor.avatar}.png`; + } + return `https://cdn.discordapp.com/embed/avatars/${Number(actor.discordId || 0) % 5}.png`; +}; + +const parseContent = (content: string) => { + const regex = /<@!?(\d+)>/g; + const tokens: Array<{ type: "text" | "mention"; value: string; displayName?: string }> = []; + let lastIndex = 0; + let match; + + while ((match = regex.exec(content)) !== null) { + const matchIndex = match.index; + if (matchIndex > lastIndex) { + tokens.push({ type: "text", value: content.substring(lastIndex, matchIndex) }); + } + + const userId = match[1]; + const actor = props.actorMap[userId!]; + tokens.push({ + type: "mention", + value: match[0], + displayName: actor ? actor.displayName || actor.username! : `@${userId}`, + }); + + lastIndex = regex.lastIndex; + } + + if (lastIndex < content.length) { + tokens.push({ type: "text", value: content.substring(lastIndex) }); + } + + return tokens; +}; + +const groupedMessages = computed(() => { + const groups: SanitisedTicketMessage[][] = []; + let currentGroup: SanitisedTicketMessage[] = []; + + for (const msg of props.messages) { + if (currentGroup.length === 0) { + currentGroup.push(msg); + } else { + const lastMsg = currentGroup[currentGroup.length - 1]!; + if ( + lastMsg.actorId === msg.actorId && + lastMsg.actorRole === msg.actorRole && + lastMsg.isPrivate === msg.isPrivate + ) { + currentGroup.push(msg); + } else { + groups.push(currentGroup); + currentGroup = [msg]; + } + } + } + + if (currentGroup.length > 0) { + groups.push(currentGroup); + } + return groups; +}); + +const attachmentsByMessageId = computed(() => { + const map: Record<string, SanitisedTicketAttachment[]> = {}; + for (const att of props.attachments) { + const msgId = att.messageId || att.ticketMessageId; + if (msgId) { + if (!map[msgId]) { + map[msgId] = []; + } + map[msgId].push(att); + } + } + return map; +}); +</script> + +<template> + <div class="ticket-messages"> + <h2>Messages</h2> + <div class="messages-container" v-if="groupedMessages.length > 0"> + <div + v-for="(group, index) in groupedMessages" + :key="index" + class="message-group" + :class="{ 'private-note': group[0]?.isPrivate }" + > + <div class="group-header"> + <img :src="getAvatarUrl(group[0]!.actors.author!)" class="author-avatar" alt="" /> + <div class="author-info"> + <span class="author-name"> + {{ + group[0]?.actors?.author?.displayName || + group[0]?.actors?.author?.username || + "System" + }} + </span> + <span class="author-role" :class="group[0]?.actorRole"> + {{ group[0]?.actorRole }} + </span> + <span class="private-badge" v-if="group[0]?.isPrivate"> Private </span> + </div> + <span class="group-time"> + <TimeAgo :time="group[0]!.createdAt!" /> + </span> + </div> + + <div class="group-messages"> + <div v-for="msg in group" :key="msg.id" class="message-item"> + <div class="message-text"> + <template v-for="(token, tokenIdx) in parseContent(msg.content)" :key="tokenIdx"> + <span v-if="token.type === 'mention'" class="mention"> + {{ token.displayName }} + </span> + <span v-else class="text-content">{{ token.value }}</span> + </template> + </div> + + <div + class="message-attachments" + v-if="attachmentsByMessageId[msg.id] && attachmentsByMessageId[msg.id].length > 0" + > + <div class="attachments-grid"> + <AttachmentCard + v-for="att in attachmentsByMessageId[msg.id]" + :key="att.id" + :attachment="att" + /> + </div> + </div> + </div> + </div> + </div> + </div> + <div v-else class="no-messages">No messages in this ticket.</div> + </div> +</template> + +<style scoped> +.ticket-messages { + display: flex; + flex-direction: column; + background-color: hsl(var(--crust)); + border-radius: 1.5rem; + overflow: hidden; + + h2 { + padding: 0.5rem 1rem; + font-size: 1.5rem; + font-weight: 800; + color: hsl(var(--text)); + } + + .messages-container { + display: flex; + flex-direction: column; + } + + .no-messages { + color: hsl(var(--subtext0)); + font-style: italic; + font-size: 0.95rem; + padding: 1rem 0; + } +} + +.message-group { + display: flex; + flex-direction: column; + padding: 1rem; + transition: var(--transition); + + &:hover { + background-color: hsla(var(--surface0) / 0.25); + transition: none; + } + + &.private-note { + background-color: hsla(var(--yellow) / 0.05); + border-color: hsla(var(--yellow) / 0.2); + + .private-badge { + background-color: hsla(var(--yellow) / 0.15); + color: hsl(var(--yellow)); + font-weight: 700; + } + } + + .group-header { + display: flex; + align-items: center; + gap: 0.75rem; + + .author-avatar { + width: 2.25rem; + height: 2.25rem; + border-radius: 50%; + object-fit: cover; + background-color: hsla(var(--surface0)); + } + + .author-info { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + + .author-name { + font-weight: 700; + color: hsl(var(--text)); + } + + .author-role { + font-size: 0.7rem; + font-weight: 700; + padding: 0.15rem 0.4rem; + border-radius: 0.25rem; + text-transform: uppercase; + background-color: hsla(var(--surface1)); + color: hsl(var(--subtext1)); + + &.claimant, + &.moderator, + &.staff, + &.admin { + background-color: hsla(var(--blue) / 0.15); + color: hsl(var(--blue)); + } + &.subject, + &.user, + &.opener { + background-color: hsla(var(--green) / 0.15); + color: hsl(var(--green)); + } + } + + .private-badge { + font-size: 0.7rem; + padding: 0.15rem 0.4rem; + border-radius: 0.25rem; + } + } + + .group-time { + margin-left: auto; + font-size: 0.8rem; + color: hsl(var(--subtext0)); + } + } + + .group-messages { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-left: 3rem; + + .message-item { + .message-text { + white-space: pre-wrap; + word-break: break-word; + line-height: 1.5; + font-size: 0.95rem; + color: hsl(var(--text)); + + .mention { + background-color: hsla(var(--accent) / 0.15); + color: hsl(var(--accent)); + font-weight: 700; + padding: 0.1px; + border-radius: 0.25rem; + } + } + + .message-attachments { + margin-top: 0.5rem; + } + } + } +} + +.attachments-grid { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} +</style>
A
apps/web/src/components/Tickets/TicketMeta.vue
@@ -0,0 +1,92 @@
+<script setup lang="ts"> +import type { SanitisedTicket } from "@stealth-developers/api"; +import UserChip from "@/components/UserChip.vue"; + +defineProps<{ + ticket: SanitisedTicket; +}>(); + +const formatDate = (dateValue: string | Date | null) => { + if (!dateValue) return "/"; + const date = new Date(dateValue); + return date.toLocaleString(); +}; +</script> + +<template> + <div class="ticket-meta-grid"> + <div class="meta-row"> + <span class="meta-label">Opened By</span> + <div class="meta-value"> + <UserChip :user="ticket.subjects.opener" /> + <span class="meta-date">at {{ formatDate(ticket.openedAt) }}</span> + </div> + </div> + + <div class="meta-row"> + <span class="meta-label">Subject</span> + <div class="meta-value"> + <UserChip :user="ticket.subjects.subject" /> + </div> + </div> + + <div class="meta-row" v-if="ticket.subjects.claimedBy"> + <span class="meta-label">Claimed By</span> + <div class="meta-value"> + <UserChip :user="ticket.subjects.claimedBy" /> + <span class="meta-date" v-if="ticket.claimedAt"> + at {{ formatDate(ticket.claimedAt) }} + </span> + </div> + </div> + + <div class="meta-row" v-if="ticket.subjects.closedBy"> + <span class="meta-label">Closed By</span> + <div class="meta-value"> + <UserChip :user="ticket.subjects.closedBy" /> + <span class="meta-date" v-if="ticket.closedAt"> + at {{ formatDate(ticket.closedAt) }} + </span> + </div> + </div> + </div> +</template> + +<style scoped> +.ticket-meta-grid { + display: flex; + justify-content: space-between; + align-items: stretch; + flex-wrap: wrap; + gap: 0.5rem; + + .meta-row { + flex: 1 1 250px; + display: flex; + flex-direction: column; + background-color: hsl(var(--crust)); + padding: 1rem; + gap: 0.4rem; + border-radius: 1.5rem; + + .meta-label { + font-size: 0.75rem; + font-weight: 900; + text-transform: uppercase; + color: hsl(var(--subtext0)); + } + + .meta-value { + display: flex; + flex-direction: column; + gap: 0.2rem; + align-items: flex-start; + + .meta-date { + font-size: 0.8rem; + color: hsl(var(--subtext1)); + } + } + } +} +</style>
A
apps/web/src/components/Tickets/TicketReasons.vue
@@ -0,0 +1,62 @@
+<script setup lang="ts"> +import type { SanitisedTicket } from "@stealth-developers/api"; + +defineProps<{ + ticket: SanitisedTicket; +}>(); +</script> + +<template> + <div + class="ticket-reasons" + v-if="ticket.openReason || ticket.closeReason || ticket.privateReason" + > + <div class="reason-card" v-if="ticket.openReason"> + <span class="reason-label">Open Reason</span> + <p class="reason-value">{{ ticket.openReason }}</p> + </div> + <div class="reason-card" v-if="ticket.closeReason"> + <span class="reason-label">Close Reason</span> + <p class="reason-value">{{ ticket.closeReason }}</p> + </div> + <div class="reason-card private" v-if="ticket.privateReason"> + <span class="reason-label">Private Reason</span> + <p class="reason-value">{{ ticket.privateReason }}</p> + </div> + </div> +</template> + +<style scoped> +.ticket-reasons { + display: grid; + grid-template-columns: 1fr; + gap: 0.5rem; + + @media (min-width: 768px) { + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + } + + .reason-card { + display: flex; + flex-direction: column; + gap: 0.4rem; + background-color: hsl(var(--crust)); + padding: 1rem; + border-radius: 1.5rem; + + .reason-label { + font-size: 0.75rem; + font-weight: 900; + text-transform: uppercase; + color: hsl(var(--subtext0)); + } + + .reason-value { + font-size: 0.95rem; + color: hsl(var(--text)); + white-space: pre-wrap; + line-height: 1.5; + } + } +} +</style>
M
apps/web/src/views/TicketDetailView.vue
→
apps/web/src/views/TicketDetailView.vue
@@ -10,9 +10,12 @@
import { computed, ref, watch } from "vue"; import { useRoute } from "vue-router"; -import TimeAgo from "@/components/TimeAgo.vue"; -import UserChip from "@/components/UserChip.vue"; import { client } from "@/stores/api"; +import TicketMeta from "@/components/Tickets/TicketMeta.vue"; +import TicketReasons from "@/components/Tickets/TicketReasons.vue"; +import TicketAttachments from "@/components/Tickets/TicketAttachments.vue"; +import TicketComments from "@/components/Tickets/TicketComments.vue"; +import TicketMessages from "@/components/Tickets/TicketMessages.vue"; const route = useRoute();@@ -100,115 +103,6 @@ }
return map; }); - -const parseContent = (content: string) => { - const regex = /<@!?(\d+)>/g; - const tokens: Array<{ type: "text" | "mention"; value: string; displayName?: string }> = []; - let lastIndex = 0; - let match; - - while ((match = regex.exec(content)) !== null) { - const matchIndex = match.index; - if (matchIndex > lastIndex) { - tokens.push({ type: "text", value: content.substring(lastIndex, matchIndex) }); - } - - const userId = match[1]; - const actor = actorMap.value[userId!]; - tokens.push({ - type: "mention", - value: match[0], - displayName: actor ? actor.displayName || actor.username! : `@${userId}`, - }); - - lastIndex = regex.lastIndex; - } - - if (lastIndex < content.length) - tokens.push({ type: "text", value: content.substring(lastIndex) }); - - return tokens; -}; - -const groupedMessages = computed(() => { - const groups: SanitisedTicketMessage[][] = []; - let currentGroup: SanitisedTicketMessage[] = []; - - for (const msg of messages.value) { - if (currentGroup.length === 0) { - currentGroup.push(msg); - } else { - const lastMsg = currentGroup[currentGroup.length - 1]!; - if ( - lastMsg.actorId === msg.actorId && - lastMsg.actorRole === msg.actorRole && - lastMsg.isPrivate === msg.isPrivate - ) { - currentGroup.push(msg); - } else { - groups.push(currentGroup); - currentGroup = [msg]; - } - } - } - - if (currentGroup.length > 0) { - groups.push(currentGroup); - } - return groups; -}); - -const formatDate = (dateValue: string | Date | null) => { - if (!dateValue) return "/"; - const date = new Date(dateValue); - return date.toLocaleString(); -}; - -const formatSize = (bytes: number) => { - if (bytes === 0) return "0 B"; - const k = 1024; - const sizes = ["B", "KB", "MB", "GB", "TB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; -}; - -const getAvatarUrl = (actor: SanitisedActor) => { - if (!actor) return "https://cdn.discordapp.com/embed/avatars/0.png"; - if (actor.avatar?.startsWith("https")) return actor.avatar; - if (actor.discordId && actor.avatar) { - return `https://cdn.discordapp.com/avatars/${actor.discordId}/${actor.avatar}.png`; - } - return `https://cdn.discordapp.com/embed/avatars/${Number(actor.discordId || 0) % 5}.png`; -}; - -const getAttachmentUrl = (att: SanitisedTicketAttachment) => { - return `https://api.media.vt3e.cat/sd-prod/${att.key}`; -}; - -const formattedAttachments = computed(() => { - return attachments.value.map((att) => ({ - ...att, - id: att.id, - fileName: att.fileName, - fileType: att.fileType || "", - fileSize: att.fileSize || 0, - url: getAttachmentUrl(att), - })); -}); - -const attachmentsByMessageId = computed(() => { - const map: Record<string, typeof formattedAttachments.value> = {}; - for (const att of formattedAttachments.value) { - const msgId = att.messageId || att.ticketMessageId; - if (msgId) { - if (!map[msgId]) { - map[msgId] = []; - } - map[msgId].push(att); - } - } - return map; -}); </script> <template>@@ -219,174 +113,18 @@ <h1>Ticket #{{ ticket.localId }}</h1>
<span :class="['status', ticket.status]">{{ ticket.status }}</span> </div> - <div class="ticket-meta-grid"> - <div class="meta-row"> - <span class="meta-label">Opened By</span> - <div class="meta-value"> - <UserChip :user="ticket.subjects.opener" /> - <span class="meta-date">at {{ formatDate(ticket.openedAt) }}</span> - </div> - </div> - - <div class="meta-row"> - <span class="meta-label">Subject</span> - <div class="meta-value"> - <UserChip :user="ticket.subjects.subject" /> - </div> - </div> - - <div class="meta-row" v-if="ticket.subjects.claimedBy"> - <span class="meta-label">Claimed By</span> - <div class="meta-value"> - <UserChip :user="ticket.subjects.claimedBy" /> - <span class="meta-date" v-if="ticket.claimedAt" - >at {{ formatDate(ticket.claimedAt) }}</span - > - </div> - </div> - - <div class="meta-row" v-if="ticket.subjects.closedBy"> - <span class="meta-label">Closed By</span> - <div class="meta-value"> - <UserChip :user="ticket.subjects.closedBy" /> - <span class="meta-date" v-if="ticket.closedAt" - >at {{ formatDate(ticket.closedAt) }}</span - > - </div> - </div> - </div> - - <div - class="ticket-reasons" - v-if="ticket.openReason || ticket.closeReason || ticket.privateReason" - > - <div class="reason-card" v-if="ticket.openReason"> - <span class="reason-label">Open Reason</span> - <p class="reason-value">{{ ticket.openReason }}</p> - </div> - <div class="reason-card" v-if="ticket.closeReason"> - <span class="reason-label">Close Reason</span> - <p class="reason-value">{{ ticket.closeReason }}</p> - </div> - <div class="reason-card private" v-if="ticket.privateReason"> - <span class="reason-label">Private Reason</span> - <p class="reason-value">{{ ticket.privateReason }}</p> - </div> - </div> - - <div class="ticket-attachments"> - <div class="attachments-header"> - <span class="label">Attachments</span> - </div> - - <div class="attachments-grid" v-if="formattedAttachments.length > 0"> - <template v-for="att in formattedAttachments" :key="att.id"> - <div v-if="att.fileType.startsWith('video/')" class="attachment-card video"> - <video :src="att.url" controls class="attachment-media"></video> - </div> - <a v-else :href="att.url" target="_blank" class="attachment-card link"> - <img - v-if="att.fileType.startsWith('image/')" - :src="att.url" - :alt="att.fileName" - class="attachment-media" - /> - <div class="attachment-file" v-else> - <div class="file-icon">📁</div> - <div class="file-info"> - <span class="filename" :title="att.fileName">{{ att.fileName }}</span> - <span class="size">{{ formatSize(att.fileSize) }}</span> - </div> - </div> - </a> - </template> - </div> - <div v-else class="no-attachments">No attachments uploaded yet.</div> - </div> + <TicketMeta :ticket="ticket" /> + <TicketReasons :ticket="ticket" /> + <TicketAttachments :attachments="attachments" /> </div> - <div class="ticket-comments" v-if="comments.length > 0"> - <h2>Comments</h2> - <div class="comments-list"> - <div v-for="comment in comments" :key="comment.id" class="comment"> - <UserChip :user="comment.actors?.author" /> - <p class="comment-text">{{ comment.content }}</p> - </div> - </div> - </div> - - <div class="ticket-messages"> - <h2>Messages</h2> - <div class="messages-container" v-if="groupedMessages.length > 0"> - <div - v-for="(group, index) in groupedMessages" - :key="index" - class="message-group" - :class="{ 'private-note': group[0]?.isPrivate }" - > - <div class="group-header"> - <img :src="getAvatarUrl(group[0]!.actors.author!)" class="author-avatar" alt="" /> - <div class="author-info"> - <span class="author-name"> - {{ - group[0]?.actors?.author?.displayName || - group[0]?.actors?.author?.username || - "System" - }} - </span> - <span class="author-role" :class="group[0]?.actorRole"> - {{ group[0]?.actorRole }} - </span> - <span class="private-badge" v-if="group[0]?.isPrivate"> Private </span> - </div> - <span class="group-time"> - <TimeAgo :time="group[0]!.createdAt!" /> - </span> - </div> + <TicketComments :comments="comments" /> - <div class="group-messages"> - <div v-for="msg in group" :key="msg.id" class="message-item"> - <div class="message-text"> - <template v-for="(token, tokenIdx) in parseContent(msg.content)" :key="tokenIdx"> - <span v-if="token.type === 'mention'" class="mention"> - {{ token.displayName }} - </span> - <span v-else class="text-content">{{ token.value }}</span> - </template> - </div> - - <div - class="message-attachments" - v-if="attachmentsByMessageId[msg.id] && attachmentsByMessageId[msg.id].length > 0" - > - <div class="attachments-grid"> - <template v-for="att in attachmentsByMessageId[msg.id]" :key="att.id"> - <div v-if="att.fileType.startsWith('video/')" class="attachment-card video"> - <video :src="att.url" controls class="attachment-media"></video> - </div> - <a v-else :href="att.url" target="_blank" class="attachment-card link"> - <img - v-if="att.fileType.startsWith('image/')" - :src="att.url" - :alt="att.fileName" - class="attachment-media" - /> - <div class="attachment-file" v-else> - <div class="file-info"> - <span class="filename" :title="att.fileName">{{ att.fileName }}</span> - <span class="size">{{ formatSize(att.fileSize) }}</span> - </div> - </div> - </a> - </template> - </div> - </div> - </div> - </div> - </div> - </div> - <div v-else class="no-messages">No messages in this ticket.</div> - </div> + <TicketMessages + :messages="messages" + :attachments="attachments" + :actorMap="actorMap" + /> </div> <div class="loading-state" v-else>@@ -439,437 +177,6 @@ background-color: hsla(var(--red) / 0.2);
color: hsl(var(--red)); } } - } - - .ticket-meta-grid { - display: flex; - justify-content: space-between; - align-items: stretch; - flex-wrap: wrap; - gap: 0.5rem; - - .meta-row { - flex: 1 1 250px; - display: flex; - flex-direction: column; - background-color: hsl(var(--crust)); - padding: 1rem; - gap: 0.4rem; - border-radius: 1.5rem; - - .meta-label { - font-size: 0.75rem; - font-weight: 900; - text-transform: uppercase; - color: hsl(var(--subtext0)); - } - - .meta-value { - display: flex; - flex-direction: column; - gap: 0.2rem; - align-items: flex-start; - - .meta-date { - font-size: 0.8rem; - color: hsl(var(--subtext1)); - } - } - } - } - - .ticket-reasons { - display: grid; - grid-template-columns: 1fr; - gap: 0.5rem; - - @media (min-width: 768px) { - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - } - - .reason-card { - display: flex; - flex-direction: column; - gap: 0.4rem; - background-color: hsl(var(--crust)); - padding: 1rem; - border-radius: 1.5rem; - - .reason-label { - font-size: 0.75rem; - font-weight: 900; - text-transform: uppercase; - color: hsl(var(--subtext0)); - } - - .reason-value { - font-size: 0.95rem; - color: hsl(var(--text)); - white-space: pre-wrap; - line-height: 1.5; - } - } - } -} - -.ticket-attachments { - display: flex; - flex-direction: column; - gap: 1rem; - - padding: 1rem; - background-color: hsl(var(--crust)); - border-radius: 1.5rem; - - .attachments-header { - font-weight: 700; - font-size: 1.1rem; - color: hsl(var(--text)); - } - - .no-attachments { - color: hsl(var(--subtext0)); - font-style: italic; - font-size: 0.9rem; - } - - .ticket-attachments-upload { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 1rem; - - .upload-btn { - display: inline-block; - padding: 0.5rem 1.2rem; - background-color: hsla(var(--surface0) / 0.8); - color: hsl(var(--accent)); - border-radius: 5rem; - cursor: pointer; - font-weight: 600; - user-select: none; - - &:hover { - background-color: hsla(var(--accent) / 1); - color: hsl(var(--on-accent)); - } - - &.highlighted { - position: relative; - overflow: hidden; - border: 2px solid hsl(var(--accent)); - - &::after { - content: ""; - position: absolute; - top: -50%; - left: -60%; - width: 20%; - height: 200%; - background: white; - opacity: 0.3; - transform: rotate(30deg); - animation: shimmer 2s infinite; - filter: blur(4px); - } - } - - .hidden-input { - display: none; - } - } - - .upload-status { - font-size: 0.85rem; - min-width: 140px; - font-variant-numeric: tabular-nums; - opacity: 0; - - &.visible { - opacity: 1; - } - - .uploading { - color: hsl(var(--accent)); - font-weight: 600; - } - .error { - color: hsl(var(--red)); - font-weight: 600; - } - } - - .progress-bar-container { - width: 180px; - height: 6px; - background-color: hsla(var(--surface1)); - border-radius: 5rem; - overflow: hidden; - opacity: 0; - - &.visible { - opacity: 1; - } - - .progress-bar { - height: 100%; - background-color: hsl(var(--accent)); - transition: width 0.1s ease; - } - } - } -} - -.attachments-grid { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - - .attachment-card { - display: flex; - flex-direction: column; - border-radius: 0.75rem; - overflow: hidden; - background-color: hsla(var(--surface0) / 0.3); - border: 1px solid hsla(var(--surface0) / 0.5); - max-width: 100%; - - &.link { - text-decoration: none; - transition: border-color var(--transition); - - &:hover { - border-color: hsla(var(--accent) / 0.5); - } - } - - .attachment-media { - display: block; - max-width: 100%; - max-height: 160px; - object-fit: cover; - } - - img.attachment-media { - min-width: 160px; - } - - video.attachment-media { - max-width: 300px; - } - - .attachment-file { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.75rem 1rem; - color: hsl(var(--text)); - - .file-info { - display: flex; - flex-direction: column; - min-width: 0; - - .filename { - font-weight: 600; - font-size: 0.9rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 180px; - } - - .size { - color: hsl(var(--subtext0)); - font-size: 0.75rem; - } - } - } - } -} - -.ticket-comments { - display: flex; - flex-direction: column; - background-color: hsl(var(--crust)); - border-radius: 1.5rem; - overflow: hidden; - margin-bottom: 0.5rem; - - h2 { - padding: 0.5rem 1rem; - font-size: 1.5rem; - font-weight: 800; - color: hsl(var(--text)); - } - - .comments-list { - display: flex; - flex-direction: column; - background-color: hsl(var(--crust)); - - .comment { - display: flex; - flex-direction: column; - gap: 0.75rem; - padding: 0.5rem 1rem; - - &:hover { - background-color: hsla(var(--surface0) / 0.25); - transition: none; - } - } - } -} - -.ticket-messages { - display: flex; - flex-direction: column; - - background-color: hsl(var(--crust)); - border-radius: 1.5rem; - overflow: hidden; - - h2 { - padding: 0.5rem 1rem; - font-size: 1.5rem; - font-weight: 800; - color: hsl(var(--text)); - } - - .messages-container { - display: flex; - flex-direction: column; - } - - .no-messages { - color: hsl(var(--subtext0)); - font-style: italic; - font-size: 0.95rem; - padding: 1rem 0; - } -} - -.message-group { - display: flex; - flex-direction: column; - padding: 1rem; - transition: var(--transition); - - &:hover { - background-color: hsla(var(--surface0) / 0.25); - transition: none; - } - - &.private-note { - background-color: hsla(var(--yellow) / 0.05); - border-color: hsla(var(--yellow) / 0.2); - - .private-badge { - background-color: hsla(var(--yellow) / 0.15); - color: hsl(var(--yellow)); - font-weight: 700; - } - } - - .group-header { - display: flex; - align-items: center; - gap: 0.75rem; - - .author-avatar { - width: 2.25rem; - height: 2.25rem; - border-radius: 50%; - object-fit: cover; - background-color: hsla(var(--surface0)); - } - - .author-info { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; - - .author-name { - font-weight: 700; - color: hsl(var(--text)); - } - - .author-role { - font-size: 0.7rem; - font-weight: 700; - padding: 0.15rem 0.4rem; - border-radius: 0.25rem; - text-transform: uppercase; - background-color: hsla(var(--surface1)); - color: hsl(var(--subtext1)); - - &.claimant, - &.moderator, - &.staff, - &.admin { - background-color: hsla(var(--blue) / 0.15); - color: hsl(var(--blue)); - } - &.subject, - &.user, - &.opener { - background-color: hsla(var(--green) / 0.15); - color: hsl(var(--green)); - } - } - - .private-badge { - font-size: 0.7rem; - padding: 0.15rem 0.4rem; - border-radius: 0.25rem; - } - } - - .group-time { - margin-left: auto; - font-size: 0.8rem; - color: hsl(var(--subtext0)); - } - } - - .group-messages { - display: flex; - flex-direction: column; - gap: 0.5rem; - padding-left: 3rem; - - .message-item { - .message-text { - white-space: pre-wrap; - word-break: break-word; - line-height: 1.5; - font-size: 0.95rem; - color: hsl(var(--text)); - - .mention { - background-color: hsla(var(--accent) / 0.15); - color: hsl(var(--accent)); - font-weight: 700; - padding: 0.1; - border-radius: 0.25rem; - } - } - - .message-attachments { - margin-top: 0.5rem; - } - } - } -} - -@keyframes shimmer { - 0% { - left: -60%; - } - 100% { - left: 150%; } } </style>