all repos — stealth-developers @ a2754f3952a99c48fc44ae7b0284c732b9b7cb05

web: render components & embeds & markdown
vi did:web:vt3e.cat
Wed, 01 Jul 2026 03:19:52 +0100
commit

a2754f3952a99c48fc44ae7b0284c732b9b7cb05

parent

744ff75068cea815025a45050f0958013852d911

M apps/web/package.jsonapps/web/package.json

@@ -17,6 +17,7 @@ },

"dependencies": { "@elysia/eden": "^1.4.10", "@stealth-developers/api": "workspace:*", + "discord-api-types": "^0.38.49", "pinia": "^3.0.4", "vue": "beta", "vue-router": "^5.1.0"
M apps/web/src/components/Tickets/AttachmentComponent.vueapps/web/src/components/Tickets/AttachmentComponent.vue

@@ -1,6 +1,7 @@

<script setup lang="ts"> +import type { SanitisedTicketAttachment } from "@stealth-developers/api"; + import { computed } from "vue"; -import type { SanitisedTicketAttachment } from "@stealth-developers/api"; const props = defineProps<{ attachment: SanitisedTicketAttachment;

@@ -26,12 +27,7 @@ <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" - /> + <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>
A apps/web/src/components/Tickets/MessageGroup.vue

@@ -0,0 +1,154 @@

+<script setup lang="ts"> +import type { + SanitisedActor, + SanitisedTicketAttachment, + SanitisedTicketMessage, +} from "@stealth-developers/api"; + +import { computed } from "vue"; + +import TimeAgo from "@/components/TimeAgo.vue"; + +import MessageItem from "./MessageItem.vue"; + +const props = defineProps<{ + group: SanitisedTicketMessage[]; + actorMap: Record<string, SanitisedActor>; + attachmentsByMessageId: Record<string, SanitisedTicketAttachment[]>; +}>(); + +const author = computed(() => props.group[0]?.actors?.author); + +const getAvatarUrl = (actor?: SanitisedActor | null) => { + 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`; +}; +</script> + +<template> + <div class="message-group" :class="{ 'private-note': group[0]?.isPrivate }"> + <div class="group-header"> + <img :src="getAvatarUrl(author)" class="author-avatar" alt="" /> + <div class="author-info"> + <span class="author-name"> + {{ author?.displayName || 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"> + <MessageItem + v-for="msg in group" + :key="msg.id" + :message="msg" + :attachments="attachmentsByMessageId[msg.id] || []" + :actorMap="actorMap" + /> + </div> + </div> +</template> + +<style scoped> +.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; + } +} +</style>
A apps/web/src/components/Tickets/MessageItem.vue

@@ -0,0 +1,716 @@

+<script setup lang="ts"> +import type { + SanitisedActor, + SanitisedTicketAttachment, + SanitisedTicketMessage, +} from "@stealth-developers/api"; +import type { + APIActionRowComponent, + APIButtonComponent, + APIComponentInMessageActionRow, + APIContainerComponent, + APIEmbed, + APIFileComponent, + APIMediaGalleryComponent, + APIMessageComponent, + APISectionComponent, + APISelectMenuComponent, + APITextDisplayComponent, + APIThumbnailComponent, +} from "discord-api-types/v10"; + +import { type VNode, h } from "vue"; + +import AttachmentCard from "./AttachmentComponent.vue"; +const props = defineProps<{ + message: SanitisedTicketMessage & { embeds?: APIEmbed[] }; + attachments: SanitisedTicketAttachment[]; + actorMap: Record<string, SanitisedActor>; +}>(); + +function renderMarkdown(text: string) { + if (!text) return ""; + + let html = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); + + html = html.replace(/&lt;@!?&?(\d+)&gt;/g, (match, id) => { + const actor = props.actorMap[id]; + const name = actor ? actor.displayName || actor.username : `@${id}`; + return `<span class="mention">@${name}</span>`; + }); + + // channels + html = html.replace(/&lt;#(\d+)&gt;/g, (match, id) => { + return `<span class="mention">#${id}</span>`; + }); + + // codeblocks + const codeBlocks: string[] = []; + html = html.replace(/```(?:[a-z0-9]+)?\n([\s\S]*?)```/gi, (match, code) => { + codeBlocks.push(`<pre><code>${code}</code></pre>`); + return `__CODE_BLOCK_${codeBlocks.length - 1}__`; + }); + + const links: string[] = []; + + // links + html = html.replace( + /\[([^\]]+)\]\((?:&lt;)?(https?:\/\/[^)\s]+?)(?:&gt;)?\)/g, + (_, text, url) => { + links.push(`<a href="${url}" target="_blank" rel="noopener noreferrer">${text}</a>`); + return `__LINK_${links.length - 1}__`; + }, + ); + + html = html.replace(/(?:&lt;)?(https?:\/\/[^\s<>&]+)(?:&gt;)?/g, (_, url) => { + links.push(`<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`); + return `__LINK_${links.length - 1}__`; + }); + + // formatting + html = html.replace(/`([^`]+)`/g, "<code>$1</code>"); + html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>"); + html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>"); + html = html.replace(/__([^_]+)__/g, "<u>$1</u>"); + html = html.replace(/~~([^~]+)~~/g, "<del>$1</del>"); + html = html.replace(/\|\|(.+?)\|\|/g, '<span class="spoiler">$1</span>'); + + // headings + html = html.replace(/^### (.*$)/gim, "<h3>$1</h3>"); + html = html.replace(/^## (.*$)/gim, "<h2>$1</h2>"); + html = html.replace(/^# (.*$)/gim, "<h1>$1</h1>"); + + // newlines + html = html.replace(/\n/g, "<br>"); + + // restore code blocks + codeBlocks.forEach((block, index) => { + html = html.replace(`__CODE_BLOCK_${index}__`, block); + }); + + links.forEach((link, i) => { + html = html.replace(`__LINK_${i}__`, link); + }); + + return html; +} + +const DiscordButton = (p: { comp: APIButtonComponent }) => { + const c = p.comp; + const styleClass = + ["primary", "secondary", "success", "danger", "link"][c.style - 1] || "primary"; + + const isLink = c.style === 5; + const url = isLink && "url" in c ? c.url : undefined; + + const emoji = "emoji" in c ? c.emoji : undefined; + const label = "label" in c ? c.label : undefined; + + return h( + isLink ? "a" : "button", + { + class: ["discord-button", styleClass], + href: url, + disabled: c.disabled, + target: isLink ? "_blank" : undefined, + }, + [ + emoji?.name ? h("span", { class: "btn-emoji" }, emoji.name) : null, + label ? h("span", { class: "btn-label" }, label) : null, + ], + ); +}; + +const DiscordSelect = (p: { comp: APISelectMenuComponent }) => { + const c = p.comp; + const options = c.type === 3 && "options" in c ? c.options : []; + + return h("select", { class: "discord-select", disabled: c.disabled }, [ + h("option", { value: "", disabled: true, selected: true }, c.placeholder || "Make a selection"), + ...options.map((opt) => h("option", { value: opt.value }, opt.label)), + ]); +}; + +const DiscordActionRow = (p: { comp: APIActionRowComponent<APIComponentInMessageActionRow> }) => { + const c = p.comp; + return h( + "div", + { class: "action-row" }, + (c.components || []).map((child) => { + if (child.type === 2) return h(DiscordButton, { comp: child as APIButtonComponent }); + if ([3, 5, 6, 7, 8].includes(child.type)) + return h(DiscordSelect, { comp: child as APISelectMenuComponent }); + return null; + }), + ); +}; + +const DiscordMediaGallery = (p: { comp: APIMediaGalleryComponent }) => { + const c = p.comp; + return h( + "div", + { class: "media-gallery" }, + (c.items || []).map((item) => { + const typedItem = item as { media?: { url: string }; url?: string }; + return h("img", { class: "gallery-img", src: typedItem.media?.url ?? typedItem.url }); + }), + ); +}; + +const DiscordFile = (p: { comp: APIFileComponent }) => { + const c = p.comp; + const typedFile = c as APIFileComponent & { file?: { name?: string } }; + return h("div", { class: "discord-file" }, [ + h("span", { class: "file-icon" }, "📄"), + h("span", { class: "file-name" }, typedFile.file?.name || "Attachment"), + ]); +}; + +const DiscordSeparator = () => h("div", { class: "discord-separator" }); + +const DiscordTextDisplay = (p: { comp: APITextDisplayComponent }) => { + return h("div", { + class: "text-display", + innerHTML: renderMarkdown(p.comp.content), + }); +}; + +const DiscordSection = (p: { comp: APISectionComponent }) => { + const c = p.comp; + return h("div", { class: "discord-section" }, [ + h( + "div", + { class: "section-text" }, + (c.components || []).map((td) => + h(DiscordTextDisplay, { comp: td as APITextDisplayComponent }), + ), + ), + c.accessory + ? h("div", { class: "section-accessory" }, [ + c.accessory.type === 2 + ? h(DiscordButton, { comp: c.accessory as APIButtonComponent }) + : c.accessory.type === 11 + ? h("img", { + class: "section-thumbnail", + src: (c.accessory as APIThumbnailComponent).media.url, + }) + : null, + ]) + : null, + ]); +}; + +const DiscordComponentRenderer = (p: { comp: APIMessageComponent }): VNode | null => { + const c = p.comp; + switch (c.type) { + case 1: + return h(DiscordActionRow, { + comp: c as APIActionRowComponent<APIComponentInMessageActionRow>, + }); + case 9: + return h(DiscordSection, { comp: c as APISectionComponent }); + case 10: + return h(DiscordTextDisplay, { comp: c as APITextDisplayComponent }); + case 12: + return h(DiscordMediaGallery, { comp: c as APIMediaGalleryComponent }); + case 13: + return h(DiscordFile, { comp: c as APIFileComponent }); + case 14: + return h(DiscordSeparator); + case 17: { + const container = c as APIContainerComponent; + return h( + "div", + { + class: "discord-container", + style: + container.accent_color != null + ? { borderLeftColor: `#${container.accent_color.toString(16).padStart(6, "0")}` } + : {}, + }, + (container.components || []).map((child) => + h(DiscordComponentRenderer, { comp: child as APIMessageComponent }), + ), + ); + } + default: + return null; + } +}; + +const DiscordEmbedRenderer = (p: { embed: APIEmbed }) => { + const e = p.embed; + const colorStyle = + e.color != null ? { borderLeftColor: `#${e.color.toString(16).padStart(6, "0")}` } : {}; + + const authorNode = e.author + ? h("div", { class: "embed-author" }, [ + e.author.icon_url ? h("img", { class: "embed-author-icon", src: e.author.icon_url }) : null, + e.author.url + ? h( + "a", + { class: "embed-author-name", href: e.author.url, target: "_blank" }, + e.author.name, + ) + : h("span", { class: "embed-author-name" }, e.author.name), + ]) + : null; + + const titleNode = e.title + ? h( + e.url ? "a" : "div", + { class: "embed-title", href: e.url, target: e.url ? "_blank" : undefined }, + e.title, + ) + : null; + + const descriptionNode = e.description + ? h("div", { + class: "embed-description text-display", + innerHTML: renderMarkdown(e.description), + }) + : null; + + const fieldsNode = + e.fields && e.fields.length > 0 + ? h( + "div", + { class: "embed-fields" }, + e.fields.map((f) => + h("div", { class: ["embed-field", f.inline ? "inline" : ""] }, [ + h("div", { + class: "embed-field-name text-display", + innerHTML: renderMarkdown(f.name), + }), + h("div", { + class: "embed-field-value text-display", + innerHTML: renderMarkdown(f.value), + }), + ]), + ), + ) + : null; + + const imageNode = e.image ? h("img", { class: "embed-image", src: e.image.url }) : null; + + const footerNode = + e.footer || e.timestamp + ? h("div", { class: "embed-footer" }, [ + e.footer?.icon_url + ? h("img", { class: "embed-footer-icon", src: e.footer.icon_url }) + : null, + h( + "span", + { class: "embed-footer-text" }, + [ + e.footer?.text, + e.footer?.text && e.timestamp ? " • " : "", + e.timestamp + ? new Date(e.timestamp).toLocaleString(undefined, { + dateStyle: "short", + timeStyle: "short", + }) + : "", + ].join(""), + ), + ]) + : null; + + const thumbnailNode = e.thumbnail + ? h("img", { class: "embed-thumbnail", src: e.thumbnail.url }) + : null; + + const contentCol = h("div", { class: "embed-content" }, [ + authorNode, + titleNode, + descriptionNode, + fieldsNode, + imageNode, + footerNode, + ]); + + return h("div", { class: "discord-embed", style: colorStyle }, [ + h("div", { class: "embed-inner" }, [contentCol, thumbnailNode]), + ]); +}; +</script> + +<template> + <div class="message-item" @click="console.log(message)"> + <div class="message-text" v-if="message.content" v-html="renderMarkdown(message.content)"></div> + + <div class="message-embeds" v-if="message.embeds && message.embeds.length > 0"> + <DiscordEmbedRenderer + v-for="(embed, index) in message.embeds" + :key="index" + :embed="embed as APIEmbed" + /> + </div> + + <div class="message-components" v-if="message.components && message.components.length > 0"> + <DiscordComponentRenderer + v-for="comp in message.components" + :key="(comp as any).id || comp.type" + :comp="comp as APIMessageComponent" + /> + </div> + + <div class="message-attachments" v-if="attachments && attachments.length > 0"> + <div class="attachments-grid"> + <AttachmentCard v-for="att in attachments" :key="att.id" :attachment="att" /> + </div> + </div> + </div> +</template> + +<style scoped> +.message-item { + display: flex; + flex-direction: column; +} + +.message-text, +:deep(.text-display) { + white-space: pre-wrap; + word-break: break-word; + line-height: 1.5; + font-size: 0.95rem; + color: hsl(var(--text)); +} + +.message-text :deep(.mention), +:deep(.text-display .mention) { + background-color: hsla(var(--accent) / 0.15); + color: hsl(var(--accent)); + font-weight: 700; + padding: 0.1rem 0.25rem; + border-radius: 0.25rem; +} + +:deep(.text-display h1) { + font-size: 1.5rem; + margin: 0.5rem 0; + font-weight: bold; +} +:deep(.text-display h2) { + font-size: 1.25rem; + margin: 0.5rem 0; + font-weight: bold; +} +:deep(.text-display h3) { + font-size: 1.1rem; + margin: 0.5rem 0; + font-weight: bold; +} +:deep(.text-display strong) { + font-weight: bold; +} +:deep(.text-display em) { + font-style: italic; +} +:deep(.text-display u) { + text-decoration: underline; +} +:deep(.text-display del) { + text-decoration: line-through; + opacity: 0.7; +} +:deep(.text-display pre) { + background-color: hsl(var(--mantle)); + padding: 0.5rem; + border-radius: 0.25rem; + overflow-x: auto; + margin: 0.25rem 0; + border: 1px solid hsl(var(--surface1)); +} +:deep(.text-display code) { + background-color: hsl(var(--mantle)); + padding: 0.1rem 0.3rem; + border-radius: 0.25rem; + font-family: monospace; + font-size: 0.85em; +} +:deep(.text-display .spoiler) { + background-color: hsl(var(--surface2)); + color: transparent; + border-radius: 0.25rem; + cursor: pointer; + padding: 0 0.2rem; +} +:deep(.text-display .spoiler:hover), +:deep(.text-display .spoiler:active) { + background-color: hsl(var(--surface1)); + color: hsl(var(--text)); +} + +.message-components, +.message-embeds { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + gap: 0.5rem; + margin-top: 0.5rem; +} + +:deep(.discord-embed) { + background-color: hsl(var(--surface0)); + border-radius: 0.25rem; + border-left: 4px solid hsl(var(--surface2)); + padding: 0.75rem 1rem; + max-width: 520px; + width: 100%; + display: flex; + flex-direction: column; +} +:deep(.embed-inner) { + display: flex; + gap: 1rem; +} +:deep(.embed-content) { + display: flex; + flex-direction: column; + gap: 0.5rem; + flex: 1; + min-width: 0; +} +:deep(.embed-thumbnail) { + width: 80px; + height: 80px; + border-radius: 0.25rem; + object-fit: cover; + flex-shrink: 0; +} +:deep(.embed-author) { + display: flex; + align-items: center; + gap: 0.5rem; +} +:deep(.embed-author-icon) { + width: 24px; + height: 24px; + border-radius: 50%; + object-fit: cover; +} +:deep(.embed-author-name) { + font-weight: 600; + font-size: 0.9rem; + color: hsl(var(--text)); + text-decoration: none; +} +:deep(.embed-author-name[href]:hover) { + text-decoration: underline; +} +:deep(.embed-title) { + font-weight: 700; + font-size: 1rem; + color: hsl(var(--text)); + text-decoration: none; +} +:deep(.embed-title[href]) { + color: hsl(var(--blue)); +} +:deep(.embed-title[href]:hover) { + text-decoration: underline; +} +:deep(.embed-description) { + font-size: 0.9rem; +} +:deep(.embed-fields) { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.25rem; +} +:deep(.embed-field) { + flex: 1 1 100%; + display: flex; + flex-direction: column; +} +:deep(.embed-field.inline) { + flex: 1 1 calc(33.33% - 0.5rem); + min-width: 120px; +} +:deep(.embed-field-name) { + font-weight: 600; + font-size: 0.85rem; +} +:deep(.embed-field-value) { + font-size: 0.85rem; +} +:deep(.embed-image) { + max-width: 100%; + max-height: 400px; + border-radius: 0.25rem; + margin-top: 0.5rem; + object-fit: contain; +} +:deep(.embed-footer) { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.25rem; +} +:deep(.embed-footer-icon) { + width: 20px; + height: 20px; + border-radius: 50%; + object-fit: cover; +} +:deep(.embed-footer-text) { + font-size: 0.75rem; + color: hsl(var(--text) / 0.7); +} + +:deep(.discord-container) { + background-color: hsl(var(--surface0)); + border-radius: 0.5rem; + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +:deep(.discord-section) { + display: flex; + gap: 1rem; + align-items: flex-start; + justify-content: space-between; +} + +:deep(.section-text) { + display: flex; + flex-direction: column; + gap: 0.25rem; + flex: 1; +} + +:deep(.section-accessory) { + flex-shrink: 0; +} + +:deep(.section-thumbnail) { + width: 4rem; + height: 4rem; + border-radius: 0.25rem; + object-fit: cover; +} + +:deep(.action-row) { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +:deep(.discord-button) { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 1rem; + border-radius: 0.25rem; + font-weight: 500; + font-size: 0.9rem; + border: none; + text-decoration: none; +} + +:deep(.discord-button:disabled) { + opacity: 0.5; + cursor: not-allowed; +} + +:deep(.discord-button.primary) { + background-color: hsl(var(--blue)); + color: hsl(var(--on-accent)); +} + +:deep(.discord-button.secondary) { + background-color: hsl(var(--surface1)); + color: hsl(var(--text)); +} + +:deep(.discord-button.success) { + background-color: hsl(var(--green)); + color: hsl(var(--on-accent)); +} + +:deep(.discord-button.danger) { + background-color: hsl(var(--red)); + color: hsl(var(--on-accent)); +} + +:deep(.discord-button.link) { + background-color: hsl(var(--surface1)); + color: hsl(var(--accent)); + text-decoration: 2px underline; +} +:deep(.discord-button.link:hover:not(:disabled)) { + background-color: hsl(var(--surface2)); +} + +:deep(.btn-emoji) { + font-size: 1.1em; +} + +:deep(.discord-select) { + flex: 1; + min-width: 200px; + background-color: hsl(var(--surface1)); + color: hsl(var(--text)); + border: 1px solid hsl(var(--surface2)); + padding: 0.5rem; + border-radius: 0.25rem; + outline: none; + cursor: pointer; +} + +:deep(.media-gallery) { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0.5rem; + border-radius: 0.5rem; + overflow: hidden; +} + +:deep(.gallery-img) { + width: 100%; + height: 100%; + object-fit: cover; + aspect-ratio: 16 / 9; + border-radius: 0.25rem; + background-color: hsl(var(--surface1)); +} + +:deep(.discord-file) { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + background-color: hsl(var(--surface1)); + border: 1px solid hsl(var(--surface2)); + border-radius: 0.5rem; +} +:deep(.file-icon) { + font-size: 1.5rem; +} +:deep(.file-name) { + font-weight: 500; + font-size: 0.9rem; +} + +:deep(.discord-separator) { + height: 1px; + background-color: hsl(var(--surface2)); + margin: 0.25rem 0; +} + +.message-attachments { + margin-top: 0.5rem; +} + +.attachments-grid { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} +</style>
M apps/web/src/components/Tickets/TicketAttachments.vueapps/web/src/components/Tickets/TicketAttachments.vue

@@ -1,5 +1,6 @@

<script setup lang="ts"> import type { SanitisedTicketAttachment } from "@stealth-developers/api"; + import AttachmentCard from "./AttachmentComponent.vue"; defineProps<{

@@ -14,11 +15,7 @@ <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" - /> + <AttachmentCard v-for="att in attachments" :key="att.id" :attachment="att" /> </div> <div v-else class="no-attachments">No attachments uploaded yet.</div> </div>
M apps/web/src/components/Tickets/TicketComments.vueapps/web/src/components/Tickets/TicketComments.vue

@@ -1,5 +1,6 @@

<script setup lang="ts"> import type { SanitisedTicketComment } from "@stealth-developers/api"; + import UserChip from "@/components/UserChip.vue"; defineProps<{
M apps/web/src/components/Tickets/TicketMessages.vueapps/web/src/components/Tickets/TicketMessages.vue

@@ -1,12 +1,13 @@

<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"; + +import { computed } from "vue"; + +import MessageGroup from "./MessageGroup.vue"; const props = defineProps<{ messages: SanitisedTicketMessage[];

@@ -14,45 +15,6 @@ 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[] = [];

@@ -100,58 +62,13 @@ <template>

<div class="ticket-messages"> <h2>Messages</h2> <div class="messages-container" v-if="groupedMessages.length > 0"> - <div + <MessageGroup 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> + :group="group" + :actorMap="props.actorMap" + :attachmentsByMessageId="attachmentsByMessageId" + /> </div> <div v-else class="no-messages">No messages in this ticket.</div> </div>

@@ -183,125 +100,5 @@ 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>
M apps/web/src/components/Tickets/TicketMeta.vueapps/web/src/components/Tickets/TicketMeta.vue

@@ -1,5 +1,6 @@

<script setup lang="ts"> import type { SanitisedTicket } from "@stealth-developers/api"; + import UserChip from "@/components/UserChip.vue"; defineProps<{

@@ -44,9 +45,7 @@ <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> + <span class="meta-date" v-if="ticket.closedAt"> at {{ formatDate(ticket.closedAt) }} </span> </div> </div> </div>
M apps/web/src/views/TicketDetailView.vueapps/web/src/views/TicketDetailView.vue

@@ -10,12 +10,12 @@

import { computed, ref, watch } from "vue"; import { useRoute } from "vue-router"; -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"; +import TicketMeta from "@/components/Tickets/TicketMeta.vue"; +import TicketReasons from "@/components/Tickets/TicketReasons.vue"; +import { client } from "@/stores/api"; const route = useRoute();

@@ -120,11 +120,7 @@ </div>

<TicketComments :comments="comments" /> - <TicketMessages - :messages="messages" - :attachments="attachments" - :actorMap="actorMap" - /> + <TicketMessages :messages="messages" :attachments="attachments" :actorMap="actorMap" /> </div> <div class="loading-state" v-else>
M bun.lockbun.lock

@@ -87,6 +87,7 @@ "version": "0.0.0",

"dependencies": { "@elysia/eden": "^1.4.10", "@stealth-developers/api": "workspace:*", + "discord-api-types": "^0.38.49", "pinia": "^3.0.4", "vue": "beta", "vue-router": "^5.1.0",