feat(frontend): render messages; also format everything
jump to
@@ -1,6 +1,6 @@
{ - "$schema": "./node_modules/oxfmt/configuration_schema.json", - "semi": false, - "singleQuote": true, - "useTabs": true + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "semi": false, + "singleQuote": true, + "useTabs": true }
@@ -10,17 +10,17 @@ // configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup export default defineConfigWithVueTs( - { - name: 'app/files-to-lint', - files: ['**/*.{vue,ts,mts,tsx}'], - }, + { + name: 'app/files-to-lint', + files: ['**/*.{vue,ts,mts,tsx}'], + }, - globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']), + globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']), - ...pluginVue.configs['flat/essential'], - vueTsConfigs.recommended, + ...pluginVue.configs['flat/essential'], + vueTsConfigs.recommended, - ...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'), + ...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'), - skipFormatting, + skipFormatting, )
@@ -1,16 +1,16 @@
<!doctype html> <html lang=""> - <head> - <meta charset="UTF-8" /> - <link rel="icon" href="/favicon.ico" /> - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>Vite App</title> + <head> + <meta charset="UTF-8" /> + <link rel="icon" href="/favicon.ico" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Vite App</title> - <link rel="preconnect" href="https://fonts.googleapis.com" /> - <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> - </head> - <body> - <div id="app"></div> - <script type="module" src="/src/main.ts"></script> - </body> + <link rel="preconnect" href="https://fonts.googleapis.com" /> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> + </head> + <body> + <div id="app"></div> + <script type="module" src="/src/main.ts"></script> + </body> </html>
@@ -1,45 +1,45 @@
{ - "name": "frontend", - "version": "0.0.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "run-p type-check \"build-only {@}\" --", - "preview": "vite preview", - "build-only": "vite build", - "type-check": "vue-tsc --build", - "lint": "run-s lint:*", - "lint:oxlint": "oxlint . --fix", - "lint:eslint": "eslint . --fix --cache", - "format": "oxfmt src/" - }, - "dependencies": { - "discord-api-types": "0.38.3", - "pinia": "^3.0.4", - "vue": "^3.5.32", - "vue-router": "^5.0.4" - }, - "devDependencies": { - "@tsconfig/node24": "^24.0.4", - "@types/node": "^24.12.2", - "@vitejs/plugin-vue": "^6.0.6", - "@vue/eslint-config-typescript": "^14.7.0", - "@vue/tsconfig": "^0.9.1", - "eslint": "^10.2.1", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-oxlint": "~1.60.0", - "eslint-plugin-vue": "~10.8.0", - "jiti": "^2.6.1", - "npm-run-all2": "^8.0.4", - "oxfmt": "^0.45.0", - "oxlint": "~1.60.0", - "typescript": "~6.0.0", - "vite": "^8.0.8", - "vite-plugin-vue-devtools": "^8.1.1", - "vue-tsc": "^3.2.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + "name": "frontend", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "run-p type-check \"build-only {@}\" --", + "preview": "vite preview", + "build-only": "vite build", + "type-check": "vue-tsc --build", + "lint": "run-s lint:*", + "lint:oxlint": "oxlint . --fix", + "lint:eslint": "eslint . --fix --cache", + "format": "oxfmt src/" + }, + "dependencies": { + "discord-api-types": "0.38.3", + "pinia": "^3.0.4", + "vue": "^3.5.32", + "vue-router": "^5.0.4" + }, + "devDependencies": { + "@tsconfig/node24": "^24.0.4", + "@types/node": "^24.12.2", + "@vitejs/plugin-vue": "^6.0.6", + "@vue/eslint-config-typescript": "^14.7.0", + "@vue/tsconfig": "^0.9.1", + "eslint": "^10.2.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-oxlint": "~1.60.0", + "eslint-plugin-vue": "~10.8.0", + "jiti": "^2.6.1", + "npm-run-all2": "^8.0.4", + "oxfmt": "^0.45.0", + "oxlint": "~1.60.0", + "typescript": "~6.0.0", + "vite": "^8.0.8", + "vite-plugin-vue-devtools": "^8.1.1", + "vue-tsc": "^3.2.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }
@@ -10,33 +10,33 @@
const user = computed(() => authStore.user) onMounted(async () => { - await authStore.getUser() + await authStore.getUser() }) </script> <template> - <div class="layout"> - <aside class="sidebar"> - <div class="links"> - <RouterLink to="/">Home</RouterLink> - <RouterLink to="/tickets">Tickets</RouterLink> - </div> + <div class="layout"> + <aside class="sidebar"> + <div class="links"> + <RouterLink to="/">Home</RouterLink> + <RouterLink to="/tickets">Tickets</RouterLink> + </div> - <div class="user" v-if="user"> - <div class="profile-picture"> - <img :src="avatar(user)" alt="Profile Picture" /> - </div> - <div class="username">{{ user.nick || user.user.global_name || user.user.username }}</div> - </div> - </aside> - <main class="main"> - <RouterView v-slot="{ Component }"> - <KeepAlive include="TicketsView"> - <component :is="Component" /> - </KeepAlive> - </RouterView> - </main> - </div> + <div class="user" v-if="user"> + <div class="profile-picture"> + <img :src="avatar(user)" alt="Profile Picture" /> + </div> + <div class="username">{{ user.nick || user.user.global_name || user.user.username }}</div> + </div> + </aside> + <main class="main"> + <RouterView v-slot="{ Component }"> + <KeepAlive include="TicketsView"> + <component :is="Component" /> + </KeepAlive> + </RouterView> + </main> + </div> </template> <style scoped>@@ -67,7 +67,7 @@ align-items: stretch;
gap: 0.5rem; a { - padding: 0.5rem 1rem ; + padding: 0.5rem 1rem; border-radius: 5rem; background-color: hsla(var(--mantle)); text-decoration: none;@@ -78,7 +78,7 @@ &:hover {
background-color: hsla(var(--surface1)); } &:active { - filter: brightness(0.75) + filter: brightness(0.75); } &.router-link-active {
@@ -1,10 +1,10 @@
<script lang="ts" setup> -import type { User } from '@/types'; -import { avatar, name } from '@/utils/discord'; -const props = defineProps<{ user: User }>(); +import type { User } from '@/types' +import { avatar, name } from '@/utils/discord' +const props = defineProps<{ user: User }>() -const avatarUrl = avatar(props.user); -const userName = name(props.user); +const avatarUrl = avatar(props.user) +const userName = name(props.user) </script> <template>
@@ -0,0 +1,243 @@
+<script lang="ts" setup> +import type { Message, User } from '@/types' +import { avatar, name } from '@/utils/discord' +import { computed } from 'vue' + +const props = defineProps<{ messages: Message[] }>() + +const firstMessage = computed(() => props.messages[0]!) +const avatarUrl = computed(() => avatar(firstMessage.value.author)) +const userName = computed(() => name(firstMessage.value.author)) + +const formatDate = (dateString: string) => { + const date = new Date(dateString) + 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] +} + +type ParsedContent = { type: 'text'; content: string } | { type: 'mention'; user: User } + +const parseContent = (msg: Message): ParsedContent[] => { + if (!msg.content) return [] + + const regex = /<@!?(\d+)>/g + const parts: ParsedContent[] = [] + let lastIndex = 0 + let match + + console.log(msg.mentions) + + while ((match = regex.exec(msg.content)) !== null) { + if (match.index > lastIndex) { + parts.push({ type: 'text', content: msg.content.substring(lastIndex, match.index) }) + } + + const id = match[1] + const user = msg.mentions?.[id!] + + if (user) { + parts.push({ type: 'mention', user }) + } else { + parts.push({ type: 'text', content: match[0] }) + } + + lastIndex = regex.lastIndex + } + + if (lastIndex < msg.content.length) { + parts.push({ type: 'text', content: msg.content.substring(lastIndex) }) + } + + return parts +} +</script> + +<template> + <div v-if="messages.length > 0" :class="['message', firstMessage.authorType]"> + <img :src="avatarUrl" alt="avatar" class="avatar" /> + <div class="message-content"> + <div class="message-header"> + <span class="author-name">{{ userName }}</span> + <span class="timestamp">{{ formatDate(firstMessage.createdAt) }}</span> + <span v-if="firstMessage.authorType === 'system'" class="badge system">System</span> + <span v-else-if="firstMessage.authorType === 'staff'" class="badge staff">Staff</span> + </div> + <div class="bodies"> + <div v-for="msg in messages" :key="msg.id" class="message-part"> + <div class="body" v-if="msg.content"> + <template v-for="(part, idx) in parseContent(msg)" :key="idx"> + <span v-if="part.type === 'text'">{{ part.content }}</span> + <span v-else-if="part.type === 'mention'" class="inline-mention"> + @{{ name(part.user) }} + </span> + </template> + </div> + <div class="attachments" v-if="msg.attachments && msg.attachments.length > 0"> + <template v-for="att in msg.attachments" :key="att.id"> + <div v-if="att.contentType.startsWith('video/')" class="attachment"> + <video :src="att.url" controls class="attachment-media"></video> + </div> + <a v-else :href="att.url" target="_blank" class="attachment link-attachment"> + <img + v-if="att.contentType.startsWith('image/')" + :src="att.url" + :alt="att.fileName" + class="attachment-media" + /> + <div class="attachment-file" v-else> + <span class="filename">{{ att.fileName }}</span> + <span class="size">{{ formatSize(att.size) }}</span> + </div> + </a> + </template> + </div> + </div> + </div> + </div> + </div> +</template> + +<style scoped> +.message { + display: flex; + gap: 1rem; + padding: 1rem; + border-bottom: 1px solid hsla(var(--surface0) / 0.5); + transition: none; + + &:last-child { + border-bottom: none; + } + + &:hover { + background-color: hsla(var(--surface0) / 0.3); + } + + .avatar { + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + flex-shrink: 0; + } + + .message-content { + display: flex; + flex-direction: column; + + .message-header { + display: flex; + align-items: center; + gap: 0.5rem; + + .author-name { + font-weight: bold; + color: hsl(var(--text)); + } + + .timestamp { + font-size: 0.8rem; + color: hsl(var(--subtext0)); + } + + .badge { + font-size: 0.7rem; + padding: 0.1rem 0.4rem; + border-radius: 0.25rem; + text-transform: uppercase; + font-weight: bold; + + &.system { + background-color: hsla(var(--surface1)); + color: hsl(var(--subtext1)); + } + &.staff { + background-color: hsla(var(--accent) / 0.2); + color: hsl(var(--accent)); + } + } + } + + .bodies { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .message-part { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .body { + color: hsl(var(--text)); + white-space: pre-wrap; + word-break: break-word; + + .inline-mention { + display: inline-flex; + align-items: center; + background-color: hsla(var(--accent) / 0.15); + color: hsl(var(--accent)); + font-weight: 500; + padding: 0 0.5ch; + border-radius: 1rem; + margin: 0 0.1rem; + user-select: none; + + &:hover { + background-color: hsla(var(--accent) / 0.25); + } + } + } + + .attachments { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + + .attachment { + display: block; + border-radius: 1rem; + overflow: hidden; + + .attachment-media { + display: block; + max-width: 100%; + max-height: 300px; + object-fit: contain; + } + + .attachment-file { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + color: hsl(var(--text)); + + .filename { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 200px; + } + + .size { + color: hsl(var(--subtext0)); + font-size: 0.8rem; + white-space: nowrap; + } + } + } + } + } +} +</style>
@@ -6,83 +6,83 @@ license eupl-1.2
*/ @media (prefers-color-scheme: dark) { - :root { - color-scheme: dark; + :root { + color-scheme: dark; - /* ui */ - --text: 64 58.621% 94.314%; - --subtext1: 152 20.588% 73.333%; - --subtext0: 160 16.667% 64.706%; - --overlay2: 171 12.217% 56.667%; - --overlay1: 182 10.121% 48.431%; - --overlay0: 194 10.66% 38.627%; - --surface2: 193 10.843% 32.549%; - --surface1: 197 11.29% 24.314%; - --surface0: 200 12.245% 19.216%; - --base: 202 13.58% 15.882%; - --mantle: 200 13.846% 12.745%; - --crust: 203 14.815% 10.588%; + /* ui */ + --text: 64 58.621% 94.314%; + --subtext1: 152 20.588% 73.333%; + --subtext0: 160 16.667% 64.706%; + --overlay2: 171 12.217% 56.667%; + --overlay1: 182 10.121% 48.431%; + --overlay0: 194 10.66% 38.627%; + --surface2: 193 10.843% 32.549%; + --surface1: 197 11.29% 24.314%; + --surface0: 200 12.245% 19.216%; + --base: 202 13.58% 15.882%; + --mantle: 200 13.846% 12.745%; + --crust: 203 14.815% 10.588%; - /* accents */ - --red: 358 85.507% 72.941%; - --orange: 16 87.97% 73.922%; - --yellow: 36 82.301% 77.843%; - --lime: 72 52.381% 79.412%; - --green: 90 46.154% 79.608%; - --aqua: 149 46.154% 79.608%; - --skye: 167 50.495% 80.196%; - --snow: 194 52.381% 79.412%; - --blue: 216 62.105% 81.373%; - --purple: 263 69.231% 84.706%; - --pink: 316 68% 85.294%; - --cherry: 326 68.966% 88.627%; - } + /* accents */ + --red: 358 85.507% 72.941%; + --orange: 16 87.97% 73.922%; + --yellow: 36 82.301% 77.843%; + --lime: 72 52.381% 79.412%; + --green: 90 46.154% 79.608%; + --aqua: 149 46.154% 79.608%; + --skye: 167 50.495% 80.196%; + --snow: 194 52.381% 79.412%; + --blue: 216 62.105% 81.373%; + --purple: 263 69.231% 84.706%; + --pink: 316 68% 85.294%; + --cherry: 326 68.966% 88.627%; + } - :root { - --shadow: var(--crust); - --on-accent: var(--crust); - } + :root { + --shadow: var(--crust); + --on-accent: var(--crust); + } } @media (prefers-color-scheme: light) { - :root { - color-scheme: light; + :root { + color-scheme: light; - /* ui */ - --text: 203 14.815% 10.588%; - --subtext1: 195 13.333% 29.412%; - --subtext0: 193 12.299% 36.667%; - --overlay2: 192 11.607% 43.922%; - --overlay1: 193 10.843% 51.176%; - --overlay0: 194 13.744% 58.627%; - --surface2: 193 16.471% 66.667%; - --surface1: 195 19.048% 75.294%; - --surface0: 203 22.5% 84.314%; - --base: 210 33.333% 88.235%; - --mantle: 209 29.412% 83.333%; - --crust: 212 29.412% 80%; + /* ui */ + --text: 203 14.815% 10.588%; + --subtext1: 195 13.333% 29.412%; + --subtext0: 193 12.299% 36.667%; + --overlay2: 192 11.607% 43.922%; + --overlay1: 193 10.843% 51.176%; + --overlay0: 194 13.744% 58.627%; + --surface2: 193 16.471% 66.667%; + --surface1: 195 19.048% 75.294%; + --surface0: 203 22.5% 84.314%; + --base: 210 33.333% 88.235%; + --mantle: 209 29.412% 83.333%; + --crust: 212 29.412% 80%; - /* accents */ - --red: 359 40.845% 58.235%; - --orange: 19 40.952% 58.824%; - --yellow: 36 37.674% 57.843%; - --lime: 68 28.251% 56.275%; - --green: 89 24.887% 56.667%; - --aqua: 149 21.659% 57.451%; - --skye: 169 18.584% 55.686%; - --snow: 194 20.188% 58.235%; - --blue: 214 25.683% 64.118%; - --purple: 263 27.848% 69.02%; - --pink: 318 29.333% 70.588%; - --cherry: 326 26.316% 73.922%; - } + /* accents */ + --red: 359 40.845% 58.235%; + --orange: 19 40.952% 58.824%; + --yellow: 36 37.674% 57.843%; + --lime: 68 28.251% 56.275%; + --green: 89 24.887% 56.667%; + --aqua: 149 21.659% 57.451%; + --skye: 169 18.584% 55.686%; + --snow: 194 20.188% 58.235%; + --blue: 214 25.683% 64.118%; + --purple: 263 27.848% 69.02%; + --pink: 318 29.333% 70.588%; + --cherry: 326 26.316% 73.922%; + } - :root { - --shadow: var(--subtext1); - --on-accent: var(--text); - } + :root { + --shadow: var(--subtext1); + --on-accent: var(--text); + } } :root { - --accent: var(--pink); + --accent: var(--pink); }
@@ -1,5 +1,5 @@
-@import url("./evergarden.css"); -@import url("https://fonts.googleapis.com/css2?family=Google+Sans+Flex:opsz,slnt,wdth,wght,GRAD,ROND@6..144,-10..0,25..151,1..1000,0..100,0..100&display=swap"); +@import url('./evergarden.css'); +@import url('https://fonts.googleapis.com/css2?family=Google+Sans+Flex:opsz,slnt,wdth,wght,GRAD,ROND@6..144,-10..0,25..151,1..1000,0..100,0..100&display=swap'); ::-webkit-scrollbar { width: 0.5rem; height: 6px;@@ -45,15 +45,15 @@
body { color: hsl(var(--text)); - font-family: "Google Sans Flex", sans-serif; + font-family: 'Google Sans Flex', sans-serif; font-optical-sizing: auto; font-weight: 500; font-style: normal; font-variation-settings: - "slnt" 0, - "wdth" 100, - "GRAD" 0, - "ROND" 100; + 'slnt' 0, + 'wdth' 100, + 'GRAD' 0, + 'ROND' 100; background-color: hsl(var(--crust));@@ -102,7 +102,7 @@ h1,
h2, h3, h4 { - font-variation-settings: "ROND" 0; + font-variation-settings: 'ROND' 0; } .main {
@@ -1,25 +1,25 @@
-import { createRouter, createWebHistory } from "vue-router"; -import HomeView from "../views/HomeView.vue"; +import { createRouter, createWebHistory } from 'vue-router' +import HomeView from '../views/HomeView.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { - path: "/", - name: "home", + path: '/', + name: 'home', component: HomeView, }, { - path: "/tickets", - name: "tickets", - component: () => import("../views/TicketsView.vue"), + path: '/tickets', + name: 'tickets', + component: () => import('../views/TicketsView.vue'), }, { - path: "/tickets/:id", - name: "ticket-detail", - component: () => import("../views/TicketDetailView.vue"), + path: '/tickets/:id', + name: 'ticket-detail', + component: () => import('../views/TicketDetailView.vue'), }, ], -}); +}) -export default router; +export default router
@@ -1,19 +1,19 @@
-import { ref } from "vue"; -import { defineStore } from "pinia"; -import type { User } from "@/types"; +import { ref } from 'vue' +import { defineStore } from 'pinia' +import type { User } from '@/types' -export const useAuthStore = defineStore("auth", () => { - const isAuthenticated = ref(false); - const user = ref<User | null>(null); +export const useAuthStore = defineStore('auth', () => { + const isAuthenticated = ref(false) + const user = ref<User | null>(null) async function getUser() { try { - const response = await fetch("/api/me"); - const data = await response.json(); - user.value = data.profile; - isAuthenticated.value = true; + const response = await fetch('/api/me') + const data = await response.json() + user.value = data.profile + isAuthenticated.value = true } catch { - window.location.href = "/api/login"; + window.location.href = '/api/login' } }@@ -21,5 +21,5 @@ return {
isAuthenticated, user, getUser, - }; -}); + } +})
@@ -1,43 +1,60 @@
-import type { APIGuildMember } from "discord-api-types/v10"; -export type User = APIGuildMember; +import type { APIGuildMember } from 'discord-api-types/v10' +export type User = APIGuildMember + +export type Attachment = { + id: string + ownerType: 'ticket' | 'ticket_message' | 'bug' + ownerId: number + authorId: string | null + fileName: string + contentType: string + size: number + bucket: string + key: string + url: string + uploadedAt: string +} export type Ticket = { - id: number; - anonymousId: string; - ticketNumber: number; - guildId: string; - channelId: string; + id: number + anonymousId: string + ticketNumber: number + guildId: string + channelId: string - type: "general"; - status: "open" | "archived" | "closed"; - topic: string | null; + type: 'general' + status: 'open' | 'archived' | 'closed' + topic: string | null - authorId: User; - openedBy: User; - addedUsers: string[]; + authorId: User + openedBy: User + addedUsers: string[] - claimedBy: User | null; - claimedAt: string | null; - closedAt: string; - closedBy: User | "reporter" | null; - closeReason: string | null; - privateReason: string | null; + claimedBy: User | null + claimedAt: string | null + closedAt: string + closedBy: User | 'reporter' | null + closeReason: string | null + privateReason: string | null - thankedAt: string | null; - warnedAt: string | null; + thankedAt: string | null + warnedAt: string | null - createdAt: string; -}; + createdAt: string + attachments?: Attachment[] +} export type Message = { - id: number; - ticketId: number; - messageId: string | null; - authorId: string; - authorType: "system" | "staff" | "reporter"; - content: string; - createdAt: string; - editedAt: string | null; - deletedAt: string | null; - author: User; -}; + id: number + ticketId: number + messageId: string | null + authorId: string + authorType: 'system' | 'staff' | 'reporter' + content: string + createdAt: string + editedAt: string | null + deletedAt: string | null + author: User + attachments?: Attachment[] + mentions?: Record<string, User> +}
@@ -1,14 +1,14 @@
-import { type APIGuildMember, CDNRoutes, ImageFormat, RouteBases } from "discord-api-types/v10"; +import { type APIGuildMember, CDNRoutes, ImageFormat, RouteBases } from 'discord-api-types/v10' export function avatar(member: APIGuildMember) { - const _avatar = member.avatar ?? member.user.avatar; + const _avatar = member.avatar ?? member.user.avatar if (!_avatar) - return "https://cdn.discordapp.com/icons/895998762630127616/36c6e14d0933eb338826599b632df818.webp?size=80&quality=lossless"; + return 'https://cdn.discordapp.com/icons/895998762630127616/36c6e14d0933eb338826599b632df818.webp?size=80&quality=lossless' - const parts = [RouteBases.cdn, CDNRoutes.userAvatar(member.user.id, _avatar, ImageFormat.WebP)]; - return parts.join(""); + const parts = [RouteBases.cdn, CDNRoutes.userAvatar(member.user.id, _avatar, ImageFormat.WebP)] + return parts.join('') } export function name(member: APIGuildMember) { - return member.nick ?? member.user.global_name ?? member.user.username; + return member.nick ?? member.user.global_name ?? member.user.username }
@@ -1,49 +1,49 @@
<script setup lang="ts"> -import { onMounted, ref } from 'vue'; -import type { User } from '@/types'; -import UserChip from '@/components/UserChip.vue'; +import { onMounted, ref } from 'vue' +import type { User } from '@/types' +import UserChip from '@/components/UserChip.vue' type StatsOverall = { - global: { mean: number; median: number; count: number }; - hourly: { hour: number; mean: number; median: number; count: number }[]; -}; + global: { mean: number; median: number; count: number } + hourly: { hour: number; mean: number; median: number; count: number }[] +} type LeaderboardRow = { - rank: number; - user: User; - modId: string; - mean: number; - median: number; - count: number; -}; + rank: number + user: User + modId: string + mean: number + median: number + count: number +} -const overallStats = ref<StatsOverall | null>(null); -const leaderboard = ref<LeaderboardRow[]>([]); -const daysFilter = ref<number | ''>(''); +const overallStats = ref<StatsOverall | null>(null) +const leaderboard = ref<LeaderboardRow[]>([]) +const daysFilter = ref<number | ''>('') const fetchStats = async () => { - const params = daysFilter.value ? `?days=${daysFilter.value}` : ''; + const params = daysFilter.value ? `?days=${daysFilter.value}` : '' const [overallRes, leaderboardRes] = await Promise.all([ fetch(`/api/stats/overall${params}`), - fetch(`/api/stats/leaderboard${params}`) - ]); + fetch(`/api/stats/leaderboard${params}`), + ]) - if (overallRes.ok) overallStats.value = await overallRes.json(); - if (leaderboardRes.ok) leaderboard.value = await leaderboardRes.json(); -}; + if (overallRes.ok) overallStats.value = await overallRes.json() + if (leaderboardRes.ok) leaderboard.value = await leaderboardRes.json() +} -onMounted(fetchStats); +onMounted(fetchStats) const formatMs = (ms: number) => { - const secs = Math.floor(ms / 1000); - if (secs < 60) return `${secs}s`; - const mins = Math.floor(secs / 60); - if (mins < 60) return `${mins}m ${secs % 60}s`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}h ${mins % 60}m`; - const days = Math.floor(hours / 24); - return `${days}d ${hours % 24}h`; -}; + const secs = Math.floor(ms / 1000) + if (secs < 60) return `${secs}s` + const mins = Math.floor(secs / 60) + if (mins < 60) return `${mins}m ${secs % 60}s` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours}h ${mins % 60}m` + const days = Math.floor(hours / 24) + return `${days}d ${hours % 24}h` +} </script> <template>@@ -51,11 +51,7 @@ <div class="stats-header">
<h1>Ticket Statistics</h1> <div class="filter"> <label for="daysFilter">Timeframe</label> - <select - id="daysFilter" - v-model="daysFilter" - @change="fetchStats" - > + <select id="daysFilter" v-model="daysFilter" @change="fetchStats"> <option value="">All time</option> <option value="1">Past 24 hours</option> <option value="7">Past 7 days</option>@@ -95,7 +91,7 @@ </tr>
</thead> <tbody> <tr v-for="row in overallStats.hourly" :key="row.hour"> - <td>{{ row.hour.toString().padStart(2, "0") }}:00</td> + <td>{{ row.hour.toString().padStart(2, '0') }}:00</td> <td>{{ row.count }}</td> <td>{{ formatMs(row.mean) }}</td> <td>{{ formatMs(row.median) }}</td>@@ -168,13 +164,14 @@ outline: none;
appearance: none; transition: border-color 0.2s; - background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23a6accd%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E"); + background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23a6accd%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E'); background-repeat: no-repeat; background-position: right 0.75rem top 50%; background-size: 0.65rem auto; } - select:hover, select:focus { + select:hover, + select:focus { border-color: hsla(var(--accent) / 0.5); } }@@ -233,7 +230,8 @@ width: 100%;
border-collapse: collapse; white-space: nowrap; - th, td { + th, + td { text-align: left; padding: 1rem; border-bottom: 1px solid hsl(var(--surface0));@@ -264,7 +262,8 @@ text-overflow: ellipsis;
white-space: nowrap; } -h1, h2 { +h1, +h2 { color: hsl(var(--text)); } </style>
@@ -1,27 +1,86 @@
<script setup lang="ts"> -import type { Ticket, Message } from '@/types'; -import { onMounted, ref } from 'vue'; -import { useRoute } from 'vue-router'; -import UserChip from '@/components/UserChip.vue'; +import type { Ticket, Message, User } from '@/types' +import { onMounted, ref, computed } from 'vue' +import { useRoute } from 'vue-router' +import UserChip from '@/components/UserChip.vue' +import UserMessage from '@/components/UserMessage.vue' const route = useRoute() -const ticketId = route.params.id; +const ticketId = route.params.id -const ticket = ref<Ticket | null>(null); -const messages = ref<Message[]>([]); +const ticket = ref<Ticket | null>(null) +const messages = ref<Message[]>([]) +const users = computed(() => { + const messageAuthors = messages.value + .map((msg) => msg.authorId) + .filter((id, index, arr) => arr.indexOf(id) === index) + return messageAuthors.reduce( + (acc, id) => { + acc[id] = messages.value.find((msg) => msg.authorId === id)!.author + return acc + }, + {} as Record<string, User>, + ) +}) onMounted(async () => { - const ticketRes = await fetch(`/api/tickets/${ticketId}`).then(res => res.json()); + const ticketRes = await fetch(`/api/tickets/${ticketId}`).then((res) => res.json()) ticket.value = ticketRes.ticket - messages.value = ticketRes.messages; + messages.value = ticketRes.messages +}) + +const groupedMessages = computed(() => { + const groups: Message[][] = [] + let currentGroup: Message[] = [] + + for (const rawMsg of messages.value) { + const mentionMap: Record<string, User> = {} + const regex = /<@!?([^>]+)>/g + let match + + while ((match = regex.exec(rawMsg.content)) !== null) { + const userId = match[1] + const user = users.value[userId!] + if (user) mentionMap[userId!] = user + } + + const msg: Message = { + ...rawMsg, + mentions: mentionMap, + } + + if (currentGroup.length === 0) { + currentGroup.push(msg) + } else { + const lastMsg = currentGroup[currentGroup.length - 1]! + if (lastMsg.authorId === msg.authorId && lastMsg.authorType === msg.authorType) { + currentGroup.push(msg) + } else { + groups.push(currentGroup) + currentGroup = [msg] + } + } + } + + if (currentGroup.length > 0) { + groups.push(currentGroup) + } + return groups }) const formatDate = (dateString: string | null) => { - if (!dateString) return '/'; - const date = new Date(dateString); - return date.toLocaleString(); -}; + if (!dateString) return '/' + const date = new Date(dateString) + 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] +} </script> <template>@@ -57,12 +116,40 @@ <div class="label">Private Reason</div>
<div class="value">{{ ticket.privateReason }}</div> </div> </div> + + <div class="ticket-attachments" v-if="ticket.attachments && ticket.attachments.length > 0"> + <div class="label">Attachments</div> + <div class="attachments"> + <template v-for="att in ticket.attachments" :key="att.id"> + <div v-if="att.contentType.startsWith('video/')" class="attachment"> + <video :src="att.url" controls class="attachment-media"></video> + </div> + <a v-else :href="att.url" target="_blank" class="attachment link-attachment"> + <img + v-if="att.contentType.startsWith('image/')" + :src="att.url" + :alt="att.fileName" + class="attachment-media" + /> + <div class="attachment-file" v-else> + <span class="filename">{{ att.fileName }}</span> + <span class="size">{{ formatSize(att.size) }}</span> + </div> + </a> + </template> + </div> + </div> + </div> + + <div class="ticket-messages"> + <UserMessage v-for="(group, index) in groupedMessages" :key="index" :messages="group" /> </div> </template> <style scoped> .ticket-head { margin: -1rem; + margin-bottom: 0; padding: 1rem; border-bottom: 2px solid hsla(var(--surface0));@@ -130,7 +217,6 @@
.reason { display: flex; flex-direction: column; - gap: 0.5rem; .label { font-weight: bold;@@ -143,5 +229,71 @@ white-space: pre-wrap;
} } } + + .ticket-attachments { + margin-top: 1.5rem; + + .label { + font-weight: bold; + color: hsl(var(--subtext0)); + margin-bottom: 0.5rem; + } + + .attachments { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + + .attachment { + display: block; + border-radius: 0.5rem; + overflow: hidden; + background-color: hsla(var(--surface0) / 0.5); + border: 1px solid hsla(var(--surface1)); + + &.link-attachment { + text-decoration: none; + transition: border-color 0.2s; + + &:hover { + border-color: hsla(var(--accent) / 0.5); + } + } + + .attachment-media { + display: block; + max-width: 100%; + max-height: 200px; + object-fit: contain; + } + + .attachment-file { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + color: hsl(var(--text)); + + .filename { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 200px; + } + + .size { + color: hsl(var(--subtext0)); + font-size: 0.8rem; + white-space: nowrap; + } + } + } + } + } +} + +.ticket-messages { + margin: 0 -1rem; } </style>
@@ -1,57 +1,57 @@
<script setup lang="ts"> -import { onMounted, ref } from 'vue'; -import type { User } from '@/types'; -import UserChip from '@/components/UserChip.vue'; +import { onMounted, ref } from 'vue' +import type { User } from '@/types' +import UserChip from '@/components/UserChip.vue' type Ticket = { - id: number; - anonymousId: string; - ticketNumber: number; - guildId: string; - channelId: string; + id: number + anonymousId: string + ticketNumber: number + guildId: string + channelId: string - type: "general" - status: "open" | "archived" | "closed" + type: 'general' + status: 'open' | 'archived' | 'closed' topic: string | null - authorId: User; - openedBy: User; + authorId: User + openedBy: User addedUsers: string[] - claimedBy: User | null; - claimedAt: string | null; - closedAt: string; - closedBy: User | "reporter" | null - closeReason: string | null; - privateReason: string | null; + claimedBy: User | null + claimedAt: string | null + closedAt: string + closedBy: User | 'reporter' | null + closeReason: string | null + privateReason: string | null - thankedAt: string | null; - warnedAt: string | null; + thankedAt: string | null + warnedAt: string | null - createdAt: string; + createdAt: string } -const tickets = ref<Ticket[]>([]); +const tickets = ref<Ticket[]>([]) onMounted(async () => { - const response = await fetch('/api/tickets'); - const data = await response.json(); - tickets.value = data; -}); + const response = await fetch('/api/tickets') + const data = await response.json() + tickets.value = data +}) const formatTimeAgo = (dateString: string | null) => { - if (!dateString) return ''; - const now = new Date(); - const ticketDate = new Date(dateString); - const diff = now.getTime() - ticketDate.getTime(); - const seconds = Math.floor(diff / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - if (days > 0) return `${days}d`; - if (hours > 0) return `${hours}h`; - if (minutes > 0) return `${minutes}m`; - return `${seconds}s`; -}; + if (!dateString) return '' + const now = new Date() + const ticketDate = new Date(dateString) + const diff = now.getTime() - ticketDate.getTime() + const seconds = Math.floor(diff / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) + if (days > 0) return `${days}d` + if (hours > 0) return `${hours}h` + if (minutes > 0) return `${minutes}m` + return `${seconds}s` +} </script> <template>@@ -66,9 +66,7 @@ <div class="time-ago">
<template v-if="ticket.status === 'open'"> for {{ formatTimeAgo(ticket.createdAt) }} </template> - <template v-else> - {{ formatTimeAgo(ticket.closedAt) }} ago - </template> + <template v-else> {{ formatTimeAgo(ticket.closedAt) }} ago </template> </div> </div>@@ -82,18 +80,19 @@ </div>
<div class="keypair"> <span class="key">Closed by</span> - <span class="value" > - <UserChip v-if="ticket.closedBy" :user="ticket.closedBy === 'reporter' ? ticket.openedBy : ticket.closedBy" /> + <span class="value"> + <UserChip + v-if="ticket.closedBy" + :user="ticket.closedBy === 'reporter' ? ticket.openedBy : ticket.closedBy" + /> <span v-else>/</span> </span> </div> - </div> <div class="footer"> <RouterLink :to="`/tickets/${ticket.id}`">View Ticket</RouterLink> </div> - </div> </div> </template>@@ -124,11 +123,12 @@ font-weight: bold;
color: hsl(var(--subtext1)); &::before { - content: "#" + content: '#'; } } - .status, .time-ago { + .status, + .time-ago { font-weight: bold; color: hsl(var(--subtext0));@@ -187,7 +187,6 @@ }
&:active { filter: brightness(0.9); } - } } }
@@ -1,18 +1,18 @@
{ - "extends": "@vue/tsconfig/tsconfig.dom.json", - "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], - "exclude": ["src/**/__tests__/*"], - "compilerOptions": { - // Extra safety for array and object lookups, but may have false positives. - "noUncheckedIndexedAccess": true, + "extends": "@vue/tsconfig/tsconfig.dom.json", + "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], + "exclude": ["src/**/__tests__/*"], + "compilerOptions": { + // Extra safety for array and object lookups, but may have false positives. + "noUncheckedIndexedAccess": true, - // Path mapping for cleaner imports. - "paths": { - "@/*": ["./src/*"] - }, + // Path mapping for cleaner imports. + "paths": { + "@/*": ["./src/*"] + }, - // `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking. - // Specified here to keep it out of the root directory. - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo" - } + // `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking. + // Specified here to keep it out of the root directory. + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo" + } }
@@ -1,11 +1,11 @@
{ - "files": [], - "references": [ - { - "path": "./tsconfig.node.json" - }, - { - "path": "./tsconfig.app.json" - } - ] + "files": [], + "references": [ + { + "path": "./tsconfig.node.json" + }, + { + "path": "./tsconfig.app.json" + } + ] }
@@ -1,27 +1,27 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping. { - "extends": "@tsconfig/node24/tsconfig.json", - "include": [ - "vite.config.*", - "vitest.config.*", - "cypress.config.*", - "playwright.config.*", - "eslint.config.*" - ], - "compilerOptions": { - // Most tools use transpilation instead of Node.js's native type-stripping. - // Bundler mode provides a smoother developer experience. - "module": "preserve", - "moduleResolution": "bundler", + "extends": "@tsconfig/node24/tsconfig.json", + "include": [ + "vite.config.*", + "vitest.config.*", + "cypress.config.*", + "playwright.config.*", + "eslint.config.*" + ], + "compilerOptions": { + // Most tools use transpilation instead of Node.js's native type-stripping. + // Bundler mode provides a smoother developer experience. + "module": "preserve", + "moduleResolution": "bundler", - // Include Node.js types and avoid accidentally including other `@types/*` packages. - "types": ["node"], + // Include Node.js types and avoid accidentally including other `@types/*` packages. + "types": ["node"], - // Disable emitting output during `vue-tsc --build`, which is used for type-checking only. - "noEmit": true, + // Disable emitting output during `vue-tsc --build`, which is used for type-checking only. + "noEmit": true, - // `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking. - // Specified here to keep it out of the root directory. - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo" - } + // `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking. + // Specified here to keep it out of the root directory. + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo" + } }