all repos — stealth-developers @ a78eed8b66665f02ed3de6a3c6749c475061b59e

feat(frontend): load more tickets; add recent tickets to sidebar
vi did:web:vt3e.cat
Wed, 20 May 2026 01:06:36 +0100
commit

a78eed8b66665f02ed3de6a3c6749c475061b59e

parent

01e69610c3bee859668126b571a524d38a75639b

M pkgs/frontend/src/App.vuepkgs/frontend/src/App.vue

@@ -1,26 +1,30 @@

<script setup lang="ts"> import { RouterView, RouterLink, useRoute } from 'vue-router' -import { useAuthStore } from './stores/auth' +import { useStateStore } from './stores/state' import { computed, onMounted, ref } from 'vue' -const authStore = useAuthStore() +const stateStore = useStateStore() const route = useRoute() -const user = computed(() => authStore.user) +const user = computed(() => stateStore.user) const sidebarOpen = ref(false) +const isDesktop = computed(() => window.innerWidth >= 768) +const viewedTickets = computed(() => { + const x = [...stateStore.viewedTickets] + return x.reverse() +}) const toggleSidebar = () => { sidebarOpen.value = !sidebarOpen.value } - const closeSidebar = () => { sidebarOpen.value = false } onMounted(async () => { - await authStore.getUser() + await stateStore.getUser() }) </script>

@@ -30,7 +34,16 @@ <div class="sidebar-overlay" :class="{ 'is-open': sidebarOpen }" @click="closeSidebar"></div>

<aside class="sidebar" :class="{ 'is-open': sidebarOpen }"> <div class="links" @click="closeSidebar"> <RouterLink to="/">Home</RouterLink> - <RouterLink to="/tickets">Tickets</RouterLink> + <RouterLink to="/tickets" v-if="!isDesktop">Tickets</RouterLink> + + <div class="tickets desktop" v-if="isDesktop"> + <RouterLink to="/tickets">Tickets</RouterLink> + <TransitionGroup name="fade"> + <RouterLink v-for="ticket in viewedTickets" :key="ticket" :to="`/tickets/${ticket}`" class="ticket-link"> + Ticket #{{ ticket }} + </RouterLink> + </TransitionGroup> + </div> </div> <div class="user" v-if="user">

@@ -94,6 +107,9 @@ flex-direction: column;

align-items: stretch; gap: 0.5rem; + overflow: auto; + border-radius: 1rem; + a { padding: 0.5rem 1rem; border-radius: 5rem;

@@ -101,6 +117,7 @@ background-color: hsla(var(--mantle));

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

@@ -257,5 +274,63 @@ main {

border-radius: 1.5rem; } } +} + + +.fade-enter-from, +.fade-leave-to { + transform: translateY(-1rem); + filter: blur(1rem); + opacity: 0; +} + +div.tickets.desktop { + display: inline-flex; + flex-direction: column; + gap: 0.25rem; + position: relative; + z-index: 1; + + > a:first-child { + z-index: 10; + } + + &:has(.router-link-exact-active) :first-child { + background-color: hsla(var(--surface0)); + } + +} + +div.tickets.desktop > a.ticket-link { + display: inline; + position: relative; + margin-left: 1.5rem; + background-color: transparent; + padding: 0.25rem 1rem; + + &::before { + content: ''; + position: absolute; + top: -1rem; + bottom: 50%; + left: -1rem; + width: 1rem; + border-left: 2px solid hsla(var(--base)); + border-bottom: 2px solid hsla(var(--base)); + border-bottom-left-radius: 0.7rem; + pointer-events: none; + } + + &:not(:last-child)::after { + content: ''; + position: absolute; + top: -1rem; + bottom: -0.5rem; + left: -1rem; + border-left: 2px solid hsla(var(--base)); + z-index: -2; + pointer-events: none; + } + } </style>
M pkgs/frontend/src/router/index.tspkgs/frontend/src/router/index.ts

@@ -1,49 +1,45 @@

-import { createRouter, createWebHistory } from "vue-router"; -import HomeView from "@/views/HomeView.vue"; -import { useAuthStore } from "@/stores/auth"; +import { createRouter, createWebHistory } from 'vue-router' +import HomeView from '@/views/HomeView.vue' +import { useStateStore } from '@/stores/state' 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'), meta: { requiresAuth: true, }, }, { - path: "/tickets/:id", - name: "Ticket Details", - component: () => import("../views/TicketDetailView.vue"), + path: '/tickets/:id', + name: 'Ticket Details', + component: () => import('../views/TicketDetailView.vue'), meta: { requiresAuth: true, }, }, { - path: "/login", - name: "Login", - component: () => import("../views/LoginView.vue"), + path: '/login', + name: 'Login', + component: () => import('../views/LoginView.vue'), }, ], -}); +}) -router.beforeEach(async (to, _from, next) => { - const authStore = useAuthStore(); - await authStore.waitForAuth(); - const isAuthenticated = authStore.isAuthenticated; +router.beforeEach(async (to) => { + const stateStore = useStateStore() + await stateStore.waitForAuth() + const isAuthenticated = stateStore.isAuthenticated - if (to.meta.requiresAuth && !isAuthenticated) { - next({ name: "Login" }); - } else { - next(); - } -}); + if (to.meta.requiresAuth && !isAuthenticated) return { name: 'Login' } +}) -export default router; +export default router
M pkgs/frontend/src/stores/auth.tspkgs/frontend/src/stores/state.ts

@@ -2,10 +2,12 @@ import { ref } from 'vue'

import { defineStore } from 'pinia' import type { MeResponse, User } from '@/types' -export const useAuthStore = defineStore('auth', () => { +export const useStateStore = defineStore('state', () => { const isAuthenticated = ref(false) const isLoading = ref(true) const user = ref<User | null>(null) + + const viewedTickets = ref<string[]>([]) async function getUser() { try {

@@ -18,6 +20,10 @@

isLoading.value = false } + function addViewedTicket(ticket: string) { + if (!viewedTickets.value.includes(ticket)) viewedTickets.value.push(ticket) + } + async function waitForAuth() { while (isLoading.value) { await getUser()

@@ -29,5 +35,7 @@ isAuthenticated,

user, getUser, waitForAuth, + viewedTickets, + addViewedTicket, } })
A pkgs/frontend/src/utils.ts

@@ -0,0 +1,18 @@

+export const formatTimeAgo = (dateString: string | null): string => { + if (!dateString) return '' + + const diffInSeconds = Math.floor((Date.now() - new Date(dateString).getTime()) / 1000) + + if (diffInSeconds < 1) return 'now' + + const intervals = [ + { label: 'd', seconds: 86_400 }, + { label: 'h', seconds: 3_600 }, + { label: 'm', seconds: 60 }, + { label: 's', seconds: 1 }, + ] + + const interval = intervals.find((i) => diffInSeconds >= i.seconds) + + return interval ? `${Math.floor(diffInSeconds / interval.seconds)}${interval.label}` : 'now' +}
M pkgs/frontend/src/views/HomeView.vuepkgs/frontend/src/views/HomeView.vue

@@ -2,9 +2,9 @@ <script setup lang="ts">

import { onMounted, ref } from 'vue' import type { LeaderboardStat, OverallStatsResponse } from '@/types' import UserChip from '@/components/UserChip.vue' -import { useAuthStore } from '@/stores/auth' +import { useStateStore } from '@/stores/state' -const authStore = useAuthStore() +const stateStore = useStateStore() type StatsOverall = OverallStatsResponse

@@ -54,7 +54,7 @@ </select>

</div> </div> - <div v-if="!authStore.isAuthenticated" class="login-prompt"> + <div v-if="!stateStore.isAuthenticated" class="login-prompt"> <p>Log in to view your tickets.</p> <a href="/api/login" class="login-button">Login</a> </div>
M pkgs/frontend/src/views/TicketDetailView.vuepkgs/frontend/src/views/TicketDetailView.vue

@@ -1,12 +1,16 @@

<script setup lang="ts"> -import { onMounted, ref, computed } from 'vue' +import { ref, computed, watch } from 'vue' import { useRoute } from 'vue-router' + import type { Ticket, Message, User } from '@/types' import UserChip from '@/components/UserChip.vue' import UserMessage from '@/components/UserMessage.vue' +import { useStateStore } from '@/stores/state' const route = useRoute() -const ticketId = route.params.id +const stateStore = useStateStore() + +const ticketId = computed(() => route.params.id as string) const ticket = ref<Ticket | null>(null) const messages = ref<Message[]>([])

@@ -23,11 +27,13 @@ {} as Record<string, User>,

) }) -onMounted(async () => { - const ticketRes = await fetch(`/api/tickets/${ticketId}`).then((res) => res.json()) + +watch(ticketId, async (newTicketId: string) => { + const ticketRes = await fetch(`/api/tickets/${newTicketId}`).then((res) => res.json()) ticket.value = ticketRes.ticket messages.value = ticketRes.messages -}) + stateStore.addViewedTicket(newTicketId) +}, { immediate: true }) const groupedMessages = computed(() => { const groups: Message[][] = []
M pkgs/frontend/src/views/TicketsView.vuepkgs/frontend/src/views/TicketsView.vue

@@ -1,28 +1,43 @@

<script setup lang="ts"> -import { onMounted, ref } from 'vue' +import { onMounted, ref, shallowRef } from 'vue' + import type { Ticket } from '@/types' +import { formatTimeAgo } from '@/utils' import UserChip from '@/components/UserChip.vue' -const tickets = ref<Ticket[]>([]) -onMounted(async () => { - const response = await fetch('/api/tickets') +const isLoading = ref(false) +const tickets = shallowRef<Ticket[]>([]) +const cursor = ref<string | null>(null) +const sentinel = ref<HTMLDivElement | null>(null) + +onMounted(() => { + fetchTickets() + + const observer = new IntersectionObserver((entries) => { + if (entries[0]?.isIntersecting && !isLoading.value) fetchTickets() + }, { threshold: 0.5 }) + + if (sentinel.value) observer.observe(sentinel.value) +}) + +async function fetchTickets() { + if (isLoading.value) return + isLoading.value = true + + const url = new URL('/api/tickets', window.location.origin) + if (cursor.value) url.searchParams.set('cursor', cursor.value) + url.searchParams.set('limit', '50') + + const response = await fetch(url.toString()) const data = await response.json() - tickets.value = data.tickets -}) + + // TODO global var for dev + const isDev = window.location.protocol === 'http:' + if (isDev) await new Promise(resolve => setTimeout(resolve, 1500)) -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` + tickets.value = [...tickets.value, ...data.tickets] + cursor.value = data.cursor + isLoading.value = false } </script>

@@ -66,7 +81,34 @@ <div class="footer">

<RouterLink :to="`/tickets/${ticket.number}`">View Ticket</RouterLink> </div> </div> + + <template v-if="isLoading"> + <div v-for="i in 10" :key="i" class="ticket skeleton-container"> + <div class="head"> + <div class="skeleton skeleton-id">f</div> + <div class="skeleton skeleton-status">f</div> + <div class="skeleton skeleton-time">f</div> + </div> + + <div class="details"> + <div class="keypair"> + <span class="key">Opened by</span> + <div class="skeleton skeleton-chip"></div> + </div> + <div class="keypair"> + <span class="key">Closed by</span> + <div class="skeleton skeleton-chip"></div> + </div> + </div> + + <div class="footer"> + <div class="skeleton skeleton-button">View Ticket</div> + </div> + </div> + </template> </div> + + <div class="sentinel" ref="sentinel"></div> </template> <style scoped>

@@ -89,6 +131,7 @@ align-items: center;

gap: 1ch; padding: 0.5rem; background-color: hsla(var(--surface0) / 0.75); + line-height: 1; .id { font-weight: bold;

@@ -149,9 +192,10 @@

a { background-color: hsla(var(--accent) / 0.75); color: hsl(var(--on-accent)); - padding: 0.3rem 1rem; + padding: 0.5rem 1rem; border-radius: 0.5rem; text-decoration: none; + line-height: 1; &:hover { filter: brightness(1.1);

@@ -161,5 +205,55 @@ filter: brightness(0.9);

} } } +} + +.skeleton { + background-color: hsla(var(--surface0) / 0.5); + background-image: linear-gradient( + 90deg, + transparent, + hsla(var(--surface1) / 0.5), + transparent + ); + background-size: 200% 100%; + animation: shimmer 2s infinite linear; + border-radius: 10rem; +} + +.skeleton-id { width: 4ch; } +.skeleton-status { width: 9ch; } +.skeleton-time { width: 10ch; } + +.skeleton-container { + pointer-events: none; + user-select: none; + + .head { + * { + color: transparent !important; + } + } + + .skeleton-chip { + width: 120px; + height: 24px; + border-radius: 5rem; + } + + .skeleton-button { + line-height: 1; + border-radius: 0.5rem; + padding: 0.5rem 1rem; + color: transparent; + } + + details .keypair { + height: 1.5rem; + } +} + +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } } </style>