all repos — stealth-developers @ fee480c17f290e9703225a6747c6f80fd4bfb8a2

feat(frontend): ticket list + home page
vi did:web:vt3e.cat
Fri, 08 May 2026 23:54:20 +0100
commit

fee480c17f290e9703225a6747c6f80fd4bfb8a2

parent

eadb53056a474cb5f5380d31375d4754d0892d9d

M frontend/index.htmlfrontend/index.html

@@ -1,13 +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> - <body> - <div id="app"></div> - <script type="module" src="/src/main.ts"></script> - </body> + <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> </html>
M frontend/src/App.vuefrontend/src/App.vue

@@ -3,7 +3,7 @@ import { RouterView, RouterLink } from 'vue-router'

import { useAuthStore } from './stores/auth' import { computed, onMounted } from 'vue' -import { getAvatar } from './utils/discord' +import { avatar } from './utils/discord' const authStore = useAuthStore()

@@ -24,7 +24,7 @@ </div>

<div class="user" v-if="user"> <div class="profile-picture"> - <img :src="getAvatar(user)" alt="Profile Picture" /> + <img :src="avatar(user)" alt="Profile Picture" /> </div> <div class="username">{{ user.nick || user.user.global_name || user.user.username }}</div> </div>

@@ -68,6 +68,7 @@ border-radius: 5rem;

background-color: hsla(var(--mantle)); text-decoration: none; color: hsla(var(--subtext1)); + font-weight: 550; &:hover { background-color: hsla(var(--surface1));

@@ -91,6 +92,7 @@ padding: 0.5rem;

border-radius: 2rem; background-color: hsla(var(--mantle)); gap: 0.5rem; + font-weight: 550; .profile-picture { flex: 0 0 auto;

@@ -112,7 +114,8 @@ main {

flex: 1; background-color: hsla(var(--mantle)); border-radius: 2rem; + height: 100%; + overflow-y: auto; } - } </style>
A frontend/src/components/UserChip.vue

@@ -0,0 +1,37 @@

+<script lang="ts" setup> +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); +</script> + +<template> + <div class="user-chip"> + <img :src="avatarUrl" alt="avatar" /> + <span>{{ userName }}</span> + </div> +</template> + +<style scoped> +.user-chip { + display: flex; + align-items: center; + gap: 0.5rem; + overflow: hidden; + + img { + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + flex-shrink: 0; + } + + span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} +</style>
M frontend/src/css/index.cssfrontend/src/css/index.css

@@ -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"); ::-webkit-scrollbar { width: 0.5rem; height: 6px;

@@ -44,16 +44,19 @@ }

body { color: hsl(var(--text)); - font-family: - "Google Sans Flex", - system-ui, - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - Roboto, - 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; + background-color: hsl(var(--crust)); - /*font-family: monospace;*/ + font-size: 15px; line-height: 1.5; text-rendering: optimizeLegibility;

@@ -71,7 +74,6 @@ *::after {

box-sizing: border-box; margin: 0; padding: 0; - font-weight: normal; scrollbar-width: thin; scrollbar-color: hsla(var(--overlay1) / 1) transparent;

@@ -95,3 +97,20 @@ ::selection {

background-color: hsla(var(--crust) / 1); color: hsl(var(--accent)); } + +h1, +h2, +h3, +h4 { + font-variation-settings: "ROND" 0; +} + +.main { + padding: 1rem; + border-radius: 2rem; + + h1 { + font-size: 3.5rem; + font-weight: 950; + } +}
M frontend/src/router/index.tsfrontend/src/router/index.ts

@@ -12,7 +12,14 @@ },

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

@@ -1,11 +1,10 @@

import { ref } from "vue"; import { defineStore } from "pinia"; - -import type { RESTGetAPIGuildMemberResult } from "discord-api-types/rest/v10"; +import type { User } from "@/types"; export const useAuthStore = defineStore("auth", () => { const isAuthenticated = ref(false); - const user = ref<RESTGetAPIGuildMemberResult | null>(null); + const user = ref<User | null>(null); async function getUser() { try {
A frontend/src/types.ts

@@ -0,0 +1,2 @@

+import type { APIGuildMember } from "discord-api-types/v10"; +export type User = APIGuildMember;
M frontend/src/utils/discord.tsfrontend/src/utils/discord.ts

@@ -1,15 +1,14 @@

-import { - type RESTGetAPIGuildMemberResult, - CDNRoutes, - ImageFormat, - RouteBases, -} from "discord-api-types/rest/v10"; +import { type APIGuildMember, CDNRoutes, ImageFormat, RouteBases } from "discord-api-types/v10"; -export function getAvatar(member: RESTGetAPIGuildMemberResult) { - const avatar = member.avatar ?? member.user.avatar; - if (!avatar) +export function avatar(member: APIGuildMember) { + const _avatar = member.avatar ?? member.user.avatar; + if (!_avatar) 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)]; + 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; +}
M frontend/src/views/HomeView.vuefrontend/src/views/HomeView.vue

@@ -1,4 +1,270 @@

-<script setup lang="ts"></script> +<script setup lang="ts"> +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 }[]; +}; + +type LeaderboardRow = { + 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 fetchStats = async () => { + const params = daysFilter.value ? `?days=${daysFilter.value}` : ''; + const [overallRes, leaderboardRes] = await Promise.all([ + fetch(`/api/stats/overall${params}`), + fetch(`/api/stats/leaderboard${params}`) + ]); + + if (overallRes.ok) overallStats.value = await overallRes.json(); + if (leaderboardRes.ok) leaderboard.value = await leaderboardRes.json(); +}; + +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`; +}; +</script> <template> + <div class="stats-header"> + <h1>Ticket Statistics</h1> + <div class="filter"> + <label for="daysFilter">Timeframe</label> + <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> + <option value="30">Past 30 days</option> + <option value="90">Past 90 days</option> + </select> + </div> + </div> + + <div v-if="overallStats" class="overall-stats"> + <div class="stat-card"> + <h3>Total Closed</h3> + <p>{{ overallStats.global.count }}</p> + </div> + <div class="stat-card"> + <h3>Mean TTC</h3> + <p>{{ formatMs(overallStats.global.mean) }}</p> + </div> + <div class="stat-card"> + <h3>Median TTC</h3> + <p>{{ formatMs(overallStats.global.median) }}</p> + </div> + </div> + + <div class="stats-layout"> + <section v-if="overallStats && overallStats.hourly.length" class="hourly-stats"> + <h2>Hourly Stats</h2> + <div class="table-container"> + <table> + <thead> + <tr> + <th>Hour</th> + <th>Closed</th> + <th>Mean TTC</th> + <th>Median TTC</th> + </tr> + </thead> + <tbody> + <tr v-for="row in overallStats.hourly" :key="row.hour"> + <td>{{ row.hour.toString().padStart(2, "0") }}:00</td> + <td>{{ row.count }}</td> + <td>{{ formatMs(row.mean) }}</td> + <td>{{ formatMs(row.median) }}</td> + </tr> + </tbody> + </table> + </div> + </section> + + <section v-if="leaderboard.length" class="leaderboard"> + <h2>Moderator Leaderboard</h2> + <div class="table-container"> + <table> + <thead> + <tr> + <th>User</th> + <th>Closed</th> + <th>Median TTC</th> + <th>Mean TTC</th> + </tr> + </thead> + <tbody> + <tr v-for="row in leaderboard" :key="row.modId"> + <td class="user-cell"> + <div class="user-content"> + <UserChip v-if="row.user" :user="row.user" /> + <span v-else>{{ row.modId }}</span> + </div> + </td> + <td>{{ row.count }}</td> + <td>{{ formatMs(row.median) }}</td> + <td>{{ formatMs(row.mean) }}</td> + </tr> + </tbody> + </table> + </div> + </section> + </div> </template> + +<style scoped> +.stats-header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 2rem; + + .filter { + display: flex; + gap: 0.75rem; + align-items: center; + + label { + font-weight: bold; + color: hsl(var(--subtext0)); + } + + select { + padding: 0.5rem 2.5rem 0.5rem 1rem; + border-radius: 0.5rem; + border: 2px solid hsl(var(--surface0)); + background-color: hsl(var(--base)); + color: hsl(var(--text)); + font-size: 1rem; + font-family: inherit; + cursor: pointer; + 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-repeat: no-repeat; + background-position: right 0.75rem top 50%; + background-size: 0.65rem auto; + } + + select:hover, select:focus { + border-color: hsla(var(--accent) / 0.5); + } + } +} + +.overall-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.stat-card { + background-color: hsl(var(--base)); + padding: 1.5rem; + border-radius: 1rem; + text-align: center; + + h3 { + color: hsl(var(--subtext0)); + margin: 0 0 0.5rem 0; + font-size: 1.1rem; + } + + p { + color: hsl(var(--text)); + font-size: 1.5rem; + font-weight: bold; + margin: 0; + } +} + +.stats-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + + /* Prevent grid items from stretching the grid and causing page-level overflow */ + > section { + min-width: 0; + } + + @media (max-width: 1024px) { + grid-template-columns: 1fr; + } +} + +.table-container { + background-color: hsl(var(--base)); + border-radius: 1rem; + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + white-space: nowrap; + + th, td { + text-align: left; + padding: 1rem; + border-bottom: 1px solid hsl(var(--surface0)); + } + + th { + color: hsl(var(--subtext0)); + font-weight: bold; + } + + td { + color: hsl(var(--text)); + } + + tr:last-child td { + border-bottom: none; + } +} + +.user-cell { + width: 100%; +} + +.user-content { + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +h1, h2 { + color: hsl(var(--text)); +} +</style>
A frontend/src/views/TicketDetailView.vue

@@ -0,0 +1,3 @@

+<template> + ticket +</template>
A frontend/src/views/TicketsView.vue

@@ -0,0 +1,194 @@

+<script setup lang="ts"> +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; + + 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; +} + +const tickets = ref<Ticket[]>([]); +onMounted(async () => { + 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`; +}; +</script> + +<template> + <h1>Tickets</h1> + + <div class="ticket-container"> + <div v-for="ticket in tickets" :key="ticket.id" class="ticket"> + <div class="head"> + <span class="id">{{ ticket.ticketNumber }}</span> + <span :class="['status', ticket.status]">{{ ticket.status }}</span> + <div class="time-ago"> + <template v-if="ticket.status === 'open'"> + for {{ formatTimeAgo(ticket.createdAt) }} + </template> + <template v-else> + {{ formatTimeAgo(ticket.closedAt) }} ago + </template> + </div> + </div> + + <div class="details"> + <div class="keypair"> + <span class="key">Opened by</span> + <span class="value"> + <UserChip :user="ticket.openedBy" /> + </span> + </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 v-else>/</span> + </span> + </div> + + </div> + + <div class="footer"> + <RouterLink :to="`/tickets/${ticket.id}`">View Ticket</RouterLink> + </div> + + </div> + </div> +</template> + +<style scoped> +.ticket-container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); + gap: 0.5rem; +} + +.ticket { + display: flex; + flex-direction: column; + background-color: hsl(var(--base)); + border-radius: 1rem; + overflow: hidden; + + .head { + display: flex; + align-items: center; + gap: 1ch; + padding: 0.5rem; + background-color: hsla(var(--surface0) / 0.75); + + .id { + font-weight: bold; + color: hsl(var(--subtext1)); + + &::before { + content: "#" + } + } + + .status, .time-ago { + font-weight: bold; + color: hsl(var(--subtext0)); + + &.open { + color: hsl(var(--green)); + } + &.archived { + color: hsl(var(--subtext0)); + } + } + } + + .details { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + + align-items: center; + row-gap: 0.5ch; + column-gap: 1ch; + padding: 0.5rem; + + .keypair { + display: contents; + + .key { + font-weight: bolder; + color: hsl(var(--subtext0)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .value { + color: hsl(var(--subtext1)); + font-weight: bold; + overflow: hidden; + } + } + } + + .footer { + display: flex; + justify-content: flex-end; + gap: 1ch; + padding: 0 0.5rem 0.5rem 0.5rem; + + a { + background-color: hsla(var(--accent) / 0.75); + color: hsl(var(--on-accent)); + padding: 0.3rem 1rem; + border-radius: 0.5rem; + text-decoration: none; + + &:hover { + filter: brightness(1.1); + } + &:active { + filter: brightness(0.9); + } + + } + } +} +</style>