all repos — stealth-developers @ 7630fcbf36d37076f1fd7e64a755a7d73fda56be

feat(frontend): init ticket detail view
vi did:web:vt3e.cat
Sat, 09 May 2026 00:22:57 +0100
commit

7630fcbf36d37076f1fd7e64a755a7d73fda56be

parent

fee480c17f290e9703225a6747c6f80fd4bfb8a2

M frontend/src/App.vuefrontend/src/App.vue

@@ -30,7 +30,11 @@ <div class="username">{{ user.nick || user.user.global_name || user.user.username }}</div>

</div> </aside> <main class="main"> - <RouterView /> + <RouterView v-slot="{ Component }"> + <KeepAlive include="TicketsView"> + <component :is="Component" /> + </KeepAlive> + </RouterView> </main> </div> </template>
M frontend/src/router/index.tsfrontend/src/router/index.ts

@@ -13,13 +13,11 @@ {

path: "/tickets", name: "tickets", component: () => import("../views/TicketsView.vue"), - children: [ - { - path: "/tickets/:id", - name: "ticket-detail", - component: () => import("../views/TicketDetailView.vue"), - }, - ], + }, + { + path: "/tickets/:id", + name: "ticket-detail", + component: () => import("../views/TicketDetailView.vue"), }, ], });
M frontend/src/types.tsfrontend/src/types.ts

@@ -1,2 +1,43 @@

import type { APIGuildMember } from "discord-api-types/v10"; export type User = APIGuildMember; + +export type Ticket = { + id: number; + anonymousId: string; + ticketNumber: number; + guildId: string; + channelId: string; + + type: "general"; + status: "open" | "archived" | "closed"; + topic: string | null; + + authorId: User; + openedBy: User; + addedUsers: string[]; + + claimedBy: User | null; + claimedAt: string | null; + closedAt: string; + closedBy: User | "reporter" | null; + closeReason: string | null; + privateReason: string | null; + + thankedAt: string | null; + warnedAt: string | null; + + createdAt: string; +}; + +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; +};
M frontend/src/views/TicketDetailView.vuefrontend/src/views/TicketDetailView.vue

@@ -1,3 +1,147 @@

+<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'; + +const route = useRoute() +const ticketId = route.params.id; + +const ticket = ref<Ticket | null>(null); +const messages = ref<Message[]>([]); + +onMounted(async () => { + const ticketRes = await fetch(`/api/tickets/${ticketId}`).then(res => res.json()); + ticket.value = ticketRes.ticket + messages.value = ticketRes.messages; +}) + +const formatDate = (dateString: string | null) => { + if (!dateString) return '/'; + const date = new Date(dateString); + return date.toLocaleString(); +}; + +</script> + <template> - ticket + <div class="ticket-head" v-if="ticket"> + <div class="ticket-heading"> + <h1>Ticket #{{ ticket.id }}</h1> + <p :class="['status', ticket.status]">{{ ticket.status }}</p> + </div> + <div class="ticket-meta-table"> + <div class="meta-row"> + <div class="meta-label">Opened</div> + <div class="meta-value"> + <UserChip :user="ticket.authorId" /> + <span class="meta-date">at {{ formatDate(ticket.createdAt) }}</span> + </div> + </div> + <div class="meta-row" v-if="ticket.closedBy"> + <div class="meta-label">Closed</div> + <div class="meta-value"> + <UserChip :user="ticket.closedBy === 'reporter' ? ticket.authorId : ticket.closedBy" /> + <span class="meta-date">at {{ formatDate(ticket.closedAt) }}</span> + </div> + </div> + </div> + + <div class="ticket-reasons" v-if="ticket.closeReason || ticket.privateReason"> + <div class="reason" v-if="ticket.closeReason"> + <div class="label">Public Reason</div> + <div class="value">{{ ticket.closeReason }}</div> + </div> + <div class="reason" v-if="ticket.privateReason"> + <div class="label">Private Reason</div> + <div class="value">{{ ticket.privateReason }}</div> + </div> + </div> + </div> </template> + +<style scoped> +.ticket-head { + margin: -1rem; + padding: 1rem; + border-bottom: 2px solid hsla(var(--surface0)); + + .ticket-heading { + display: flex; + align-items: center; + gap: 1rem; + + h1 { + font-size: 2rem; + } + + p { + font-size: 0.85rem; + padding: 0.1rem 0.5rem; + border-radius: 5rem; + font-weight: bold; + color: hsla(var(--on-accent)); + + &.open { + background: hsla(var(--green)); + } + &.archived { + background: hsla(var(--red)); + } + } + } + + .ticket-meta-table { + display: grid; + grid-template-columns: auto 1fr; + row-gap: 1ch; + column-gap: 1ch; + margin-top: 1.5rem; + + .meta-row { + display: contents; + } + + .meta-label { + font-weight: bold; + color: hsl(var(--subtext0)); + padding-top: 0.2rem; + } + + .meta-value { + display: flex; + flex-direction: column; + gap: 0.2rem; + align-items: flex-start; + + .meta-date { + color: hsl(var(--subtext0)); + font-size: 0.85rem; + } + } + } + + .ticket-reasons { + display: flex; + flex-direction: column; + gap: 1ch; + margin-top: 1.5rem; + + .reason { + display: flex; + flex-direction: column; + gap: 0.5rem; + + .label { + font-weight: bold; + color: hsl(var(--subtext0)); + } + .value { + color: hsl(var(--subtext1)); + padding-left: 1rem; + white-space: pre-wrap; + } + } + } +} +</style>