Overhaul ticket list page

This commit is contained in:
rxdn 2024-09-08 17:27:51 +01:00
parent 733c397631
commit 90ba4cfd21
14 changed files with 778 additions and 86 deletions

View File

@ -1,24 +1,38 @@
package api package api
import ( import (
"context"
"github.com/TicketsBot/GoPanel/database" "github.com/TicketsBot/GoPanel/database"
"github.com/TicketsBot/GoPanel/rpc/cache" "github.com/TicketsBot/GoPanel/rpc/cache"
"github.com/TicketsBot/GoPanel/utils" "github.com/TicketsBot/GoPanel/utils"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/rxdn/gdl/objects/user" "github.com/rxdn/gdl/objects/user"
"time"
) )
type ticketResponse struct { type (
TicketId int `json:"id"` listTicketsResponse struct {
PanelTitle string `json:"panel_title"` Tickets []ticketData `json:"tickets"`
User *user.User `json:"user,omitempty"` PanelTitles map[int]string `json:"panel_titles"`
} ResolvedUsers map[uint64]user.User `json:"resolved_users"`
SelfId uint64 `json:"self_id,string"`
}
ticketData struct {
TicketId int `json:"id"`
PanelId *int `json:"panel_id"`
UserId uint64 `json:"user_id,string"`
ClaimedBy *uint64 `json:"claimed_by,string"`
OpenedAt time.Time `json:"opened_at"`
LastResponseTime *time.Time `json:"last_response_time"`
LastResponseIsStaff *bool `json:"last_response_is_staff"`
}
)
func GetTickets(ctx *gin.Context) { func GetTickets(ctx *gin.Context) {
userId := ctx.Keys["userid"].(uint64)
guildId := ctx.Keys["guildid"].(uint64) guildId := ctx.Keys["guildid"].(uint64)
tickets, err := database.Client.Tickets.GetGuildOpenTickets(ctx, guildId) tickets, err := database.Client.Tickets.GetGuildOpenTicketsWithMetadata(ctx, guildId)
if err != nil { if err != nil {
ctx.JSON(500, utils.ErrorJson(err)) ctx.JSON(500, utils.ErrorJson(err))
return return
@ -36,37 +50,38 @@ func GetTickets(ctx *gin.Context) {
} }
// Get user objects // Get user objects
userIds := make([]uint64, len(tickets)) userIds := make([]uint64, 0, int(float32(len(tickets))*1.5))
for i, ticket := range tickets { for _, ticket := range tickets {
userIds[i] = ticket.UserId userIds = append(userIds, ticket.Ticket.UserId)
if ticket.ClaimedBy != nil {
userIds = append(userIds, *ticket.ClaimedBy)
}
} }
users, err := cache.Instance.GetUsers(context.Background(), userIds) users, err := cache.Instance.GetUsers(ctx, userIds)
if err != nil { if err != nil {
ctx.JSON(500, utils.ErrorJson(err)) ctx.JSON(500, utils.ErrorJson(err))
return return
} }
data := make([]ticketResponse, len(tickets)) data := make([]ticketData, len(tickets))
for i, ticket := range tickets { for i, ticket := range tickets {
var user *user.User data[i] = ticketData{
if tmp, ok := users[ticket.UserId]; ok { TicketId: ticket.Id,
user = &tmp PanelId: ticket.PanelId,
} UserId: ticket.Ticket.UserId,
ClaimedBy: ticket.ClaimedBy,
panelTitle := "Unknown" OpenedAt: ticket.OpenTime,
if ticket.PanelId != nil { LastResponseTime: ticket.LastMessageTime,
if tmp, ok := panelTitles[*ticket.PanelId]; ok { LastResponseIsStaff: ticket.UserIsStaff,
panelTitle = tmp
}
}
data[i] = ticketResponse{
TicketId: ticket.Id,
PanelTitle: panelTitle,
User: user,
} }
} }
ctx.JSON(200, data) ctx.JSON(200, listTicketsResponse{
Tickets: data,
PanelTitles: panelTitles,
ResolvedUsers: users,
SelfId: userId,
})
} }

View File

@ -0,0 +1,211 @@
package api
import (
"context"
"errors"
"fmt"
"github.com/TicketsBot/GoPanel/botcontext"
"github.com/TicketsBot/GoPanel/database"
"github.com/TicketsBot/GoPanel/rpc"
"github.com/TicketsBot/GoPanel/utils"
"github.com/TicketsBot/GoPanel/utils/types"
"github.com/TicketsBot/common/premium"
"github.com/gin-gonic/gin"
"github.com/rxdn/gdl/objects/channel/embed"
"github.com/rxdn/gdl/rest"
"github.com/rxdn/gdl/rest/request"
"strconv"
)
type sendTagBody struct {
TagId string `json:"tag_id"`
}
func SendTag(ctx *gin.Context) {
guildId := ctx.Keys["guildid"].(uint64)
userId := ctx.Keys["userid"].(uint64)
botContext, err := botcontext.ContextForGuild(guildId)
if err != nil {
ctx.JSON(500, gin.H{
"success": false,
"error": err.Error(),
})
return
}
// Get ticket ID
ticketId, err := strconv.Atoi(ctx.Param("ticketId"))
if err != nil {
ctx.JSON(400, gin.H{
"success": false,
"error": "Invalid ticket ID",
})
return
}
var body sendTagBody
if err := ctx.BindJSON(&body); err != nil {
ctx.JSON(400, gin.H{
"success": false,
"error": "Tag is missing",
})
return
}
// Verify guild is premium
premiumTier, err := rpc.PremiumClient.GetTierByGuildId(ctx, guildId, true, botContext.Token, botContext.RateLimiter)
if err != nil {
ctx.JSON(500, utils.ErrorJson(err))
return
}
if premiumTier == premium.None {
ctx.JSON(402, gin.H{
"success": false,
"error": "Guild is not premium",
})
return
}
// Get ticket
ticket, err := database.Client.Tickets.Get(ctx, ticketId, guildId)
// Verify the ticket exists
if ticket.UserId == 0 {
ctx.JSON(404, gin.H{
"success": false,
"error": "Ticket not found",
})
return
}
// Verify the user has permission to send to this guild
if ticket.GuildId != guildId {
ctx.JSON(403, gin.H{
"success": false,
"error": "Guild ID doesn't match",
})
return
}
// Get tag
tag, ok, err := database.Client.Tag.Get(ctx, guildId, body.TagId)
if err != nil {
ctx.JSON(500, utils.ErrorJson(err))
return
}
if !ok {
ctx.JSON(404, gin.H{
"success": false,
"error": "Tag not found",
})
return
}
// Preferably send via a webhook
webhook, err := database.Client.Webhooks.Get(ctx, guildId, ticketId)
if err != nil {
ctx.JSON(500, gin.H{
"success": false,
"error": err.Error(),
})
return
}
settings, err := database.Client.Settings.Get(ctx, guildId)
if err != nil {
ctx.JSON(500, utils.ErrorStr("Failed to fetch settings"))
return
}
var embeds []*embed.Embed
if tag.Embed != nil {
embeds = []*embed.Embed{
types.NewCustomEmbed(tag.Embed.CustomEmbed, tag.Embed.Fields).IntoDiscordEmbed(),
}
}
if webhook.Id != 0 {
var webhookData rest.WebhookBody
if settings.AnonymiseDashboardResponses {
guild, err := botContext.GetGuild(context.Background(), guildId)
if err != nil {
ctx.JSON(500, utils.ErrorStr("Failed to fetch guild"))
return
}
webhookData = rest.WebhookBody{
Content: utils.ValueOrZero(tag.Content),
Embeds: embeds,
Username: guild.Name,
AvatarUrl: guild.IconUrl(),
}
} else {
user, err := botContext.GetUser(context.Background(), userId)
if err != nil {
ctx.JSON(500, utils.ErrorStr("Failed to fetch user"))
return
}
webhookData = rest.WebhookBody{
Content: utils.ValueOrZero(tag.Content),
Embeds: embeds,
Username: user.EffectiveName(),
AvatarUrl: user.AvatarUrl(256),
}
}
// TODO: Ratelimit
_, err = rest.ExecuteWebhook(ctx, webhook.Token, nil, webhook.Id, true, webhookData)
if err != nil {
// We can delete the webhook in this case
var unwrapped request.RestError
if errors.As(err, &unwrapped); unwrapped.StatusCode == 403 || unwrapped.StatusCode == 404 {
go database.Client.Webhooks.Delete(ctx, guildId, ticketId)
}
} else {
ctx.JSON(200, gin.H{
"success": true,
})
return
}
}
message := utils.ValueOrZero(tag.Content)
if !settings.AnonymiseDashboardResponses {
user, err := botContext.GetUser(context.Background(), userId)
if err != nil {
ctx.JSON(500, utils.ErrorStr("Failed to fetch user"))
return
}
message = fmt.Sprintf("**%s**: %s", user.EffectiveName(), message)
}
if len(message) > 2000 {
message = message[0:1999]
}
if ticket.ChannelId == nil {
ctx.JSON(404, gin.H{
"success": false,
"error": "Ticket channel ID is nil",
})
return
}
if _, err = rest.CreateMessage(ctx, botContext.Token, botContext.RateLimiter, *ticket.ChannelId, rest.CreateMessageData{Content: message, Embeds: embeds}); err != nil {
ctx.JSON(500, gin.H{
"success": false,
"error": err.Error(),
})
return
}
ctx.JSON(200, gin.H{
"success": true,
})
}

View File

@ -158,6 +158,7 @@ func StartServer(sm *livechat.SocketManager) {
guildAuthApiSupport.GET("/tickets", api_ticket.GetTickets) guildAuthApiSupport.GET("/tickets", api_ticket.GetTickets)
guildAuthApiSupport.GET("/tickets/:ticketId", api_ticket.GetTicket) guildAuthApiSupport.GET("/tickets/:ticketId", api_ticket.GetTicket)
guildAuthApiSupport.POST("/tickets/:ticketId", rl(middleware.RateLimitTypeGuild, 5, time.Second*5), api_ticket.SendMessage) guildAuthApiSupport.POST("/tickets/:ticketId", rl(middleware.RateLimitTypeGuild, 5, time.Second*5), api_ticket.SendMessage)
guildAuthApiSupport.POST("/tickets/:ticketId/tag", rl(middleware.RateLimitTypeGuild, 5, time.Second*5), api_ticket.SendTag)
guildAuthApiSupport.DELETE("/tickets/:ticketId", api_ticket.CloseTicket) guildAuthApiSupport.DELETE("/tickets/:ticketId", api_ticket.CloseTicket)
// Websockets do not support headers: so we must implement authentication over the WS connection // Websockets do not support headers: so we must implement authentication over the WS connection

View File

@ -4,6 +4,7 @@
--primary: #995DF3; --primary: #995DF3;
--primary-gradient: linear-gradient(71.3deg, #873ef5 0%, #995DF3 100%); --primary-gradient: linear-gradient(71.3deg, #873ef5 0%, #995DF3 100%);
--blue: #3472f7; --blue: #3472f7;
--background: #272727;
} }
html, body { html, body {

View File

@ -1,4 +1,4 @@
<button on:click isTrigger="1" class:fullWidth class:danger class:iconOnly {disabled} {type}> <button on:click isTrigger="1" class:fullWidth class:danger class:iconOnly class:shadow={!noShadow} {disabled} {type} bind:clientWidth>
{#if icon !== undefined} {#if icon !== undefined}
<i class="{icon}"></i> <i class="{icon}"></i>
{/if} {/if}
@ -16,6 +16,8 @@
export let type = "submit"; export let type = "submit";
export let danger = false; export let danger = false;
export let iconOnly = false; export let iconOnly = false;
export let noShadow = false;
export let clientWidth;
</script> </script>
<style> <style>
@ -37,6 +39,9 @@
cursor: pointer; cursor: pointer;
transition: background-color 150ms ease-in-out, border-color 150ms ease-in-out; transition: background-color 150ms ease-in-out, border-color 150ms ease-in-out;
}
button.shadow {
box-shadow: 0 4px 4px rgb(0 0 0 / 25%); box-shadow: 0 4px 4px rgb(0 0 0 / 25%);
} }

View File

@ -72,7 +72,6 @@
.card-body { .card-body {
display: flex; display: flex;
flex: 1; flex: 1;
min-height: 75px;
color: white; color: white;
margin: 10px 20px; margin: 10px 20px;

View File

@ -0,0 +1,98 @@
<section>
<Button on:click={toggleDropdown} bind:clientWidth={buttonWidth}>{label}</Button>
<div class="dropdown" bind:this={dropdown} style="min-width: {buttonWidth}px">
{#each options as option}
<div class="option">
<input type="checkbox" checked={selected.includes(option)} on:change={() => handleChange(option)} />
<span>{option}</span>
</div>
{/each}
</div>
</section>
<style>
.dropdown {
display: none;
position: absolute;
margin-top: 6px;
padding: 5px 10px;
background-color: var(--background);
border-radius: 4px;
border: 1px solid var(--primary);
box-shadow: 0 14px 14px rgba(0, 0, 0, 0.25);
}
.option {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
font-size: 18px;
}
input {
height: 16px;
width: 16px;
}
</style>
<script>
import Button from "./Button.svelte";
import { createEventDispatcher } from 'svelte';
export let label = "Select Columns";
export let options = [];
export let selected = [];
const dispatch = createEventDispatcher();
function handleChange(option) {
if (!selected) {
selected = [];
}
if (selected.includes(option)) {
selected = selected.filter((item) => item !== option);
} else {
selected = [...selected, option];
}
dispatch('change', selected)
}
let dropdown;
let buttonWidth;
function toggleDropdown() {
if (dropdown.style.display === 'block') {
dropdown.style.display = 'none';
} else {
dropdown.style.display = 'block';
}
}
document.addEventListener('click', (e) => {
let current = e.target;
let dropdownFound = false;
while (current) {
if (current.attributes) {
if (current.hasAttribute('istrigger')) {
dropdownFound = true;
break;
}
}
if (current === dropdown) {
dropdownFound = true;
break;
} else {
current = current.parentNode;
}
}
if (!dropdownFound) {
dropdown.style.display = 'none';
}
});
</script>

View File

@ -1,37 +1,97 @@
{#if tagSelectorModal}
<div class="modal" transition:fade>
<div class="modal-wrapper">
<Card footer footerRight fill={false}>
<span slot="title">Send Tag</span>
<div slot="body" class="modal-inner">
<Dropdown col2 label="Select a tag..." bind:value={selectedTag}>
{#each Object.keys(tags) as tag}
<option value={tag}>{tag}</option>
{/each}
</Dropdown>
</div>
<div slot="footer" style="gap: 12px">
<Button danger icon="fas fa-times" on:click={() => tagSelectorModal = false}>Close</Button>
<Button icon="fas fa-paper-plane" on:click={sendTag}>Send</Button>
</div>
</Card>
</div>
</div>
<div class="modal-backdrop" transition:fade>
</div>
{/if}
<div class="discord-container"> <div class="discord-container">
<div class="channel-header"> <div class="channel-header">
<span id="channel-name">#ticket-{ticketId}</span> <span id="channel-name">#ticket-{ticketId}</span>
</div> </div>
<div id="message-container" bind:this={container}> <div id="message-container" bind:this={container}>
{#each messages as message} {#each messages as message}
<div class="message"> <div class="message">
<b>{message.author.username}:</b> {message.content} <b>{message.author.username}:</b> {message.content}
</div> </div>
{/each} {/each}
</div> </div>
<div class="input-container"> <div class="input-container">
<form on:submit|preventDefault={sendMessage}> <form on:submit|preventDefault={sendMessage}>
<input type="text" class="message-input" bind:value={sendContent} disabled={!isPremium} <input type="text" class="message-input" bind:value={sendContent} disabled={!isPremium}
placeholder="{isPremium ? `Message #ticket-${ticketId}` : 'Premium users can receive messages in real-time and respond to tickets through the dashboard'}"> placeholder="{isPremium ? `Message #ticket-${ticketId}` : 'Premium users can receive messages in real-time and respond to tickets through the dashboard'}">
</form> {#if isPremium}
</div> <i class="fas fa-paper-plane send-button" on:click={sendMessage}/>
<div class="tag-selector">
<Button type="button" noShadow on:click={openTagSelector}>Select Tag</Button>
</div>
{/if}
</form>
</div>
</div> </div>
<script> <script>
import {createEventDispatcher} from "svelte"; import {createEventDispatcher} from "svelte";
import {fade} from "svelte/transition";
import Button from "./Button.svelte";
import Card from "./Card.svelte";
import Dropdown from "./form/Dropdown.svelte";
export let ticketId; export let ticketId;
export let isPremium = false; export let isPremium = false;
export let messages = []; export let messages = [];
export let container; export let container;
export let tags = [];
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let sendContent = ''; let sendContent = '';
let selectedTag;
function sendMessage() { function sendMessage() {
dispatch('send', sendContent); dispatch('send', {
type: 'message',
content: sendContent
});
sendContent = ''; sendContent = '';
} }
let tagSelectorModal = false;
function openTagSelector() {
tagSelectorModal = true;
window.scrollTo({top: 0, behavior: 'smooth'});
}
function sendTag() {
tagSelectorModal = false;
dispatch('send', {
type: 'tag',
tag_id: selectedTag
});
selectedTag = undefined;
}
</script> </script>
<style> <style>
@ -90,6 +150,7 @@
.message-input { .message-input {
display: flex; display: flex;
flex: 1;
font-size: 16px; font-size: 16px;
line-height: 24px; line-height: 24px;
@ -99,10 +160,69 @@
border-color: #2e3136 !important; border-color: #2e3136 !important;
background-color: #2e3136 !important; background-color: #2e3136 !important;
color: white !important; color: white !important;
width: 100%;
} }
.message-input:focus, .message-input:focus-visible { .message-input:focus, .message-input:focus-visible {
outline-width: 0; outline-width: 0;
} }
form {
display: flex;
flex-direction: row;
align-items: center;
}
.send-button {
margin-right: 8px;
cursor: pointer;
}
.tag-selector {
margin-right: 4px;
}
/** modal **/
.modal {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 999;
display: flex;
justify-content: center;
align-items: center;
}
.modal-wrapper {
display: flex;
width: 60%;
margin: 10% auto auto auto;
}
.modal-inner {
display: flex;
flex-direction: row;
justify-content: flex-start;
gap: 2%;
width: 100%;
}
@media only screen and (max-width: 1280px) {
.modal-wrapper {
width: 96%;
}
}
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 500;
background-color: #000;
opacity: .5;
}
</style> </style>

View File

@ -9,7 +9,7 @@
{/if} {/if}
</div> </div>
{/if} {/if}
<select id="input" class="form-input" on:change bind:value={value} {disabled}> <select id="input" class="form-input" on:change bind:value={value} {disabled} style="margin: 0">
<slot /> <slot />
</select> </select>
</div> </div>

View File

@ -94,3 +94,25 @@ export function checkForParamAndRewrite(param) {
return false; return false;
} }
const units = {
year : 24 * 60 * 60 * 1000 * 365,
month : 24 * 60 * 60 * 1000 * 365/12,
day : 24 * 60 * 60 * 1000,
hour : 60 * 60 * 1000,
minute: 60 * 1000,
second: 1000
};
// From https://stackoverflow.com/a/53800501
export function getRelativeTime(timestamp) {
const elapsed = timestamp - new Date();
// "Math.abs" accounts for both "past" & "future" scenarios
for (const u in units) {
if (Math.abs(elapsed) > units[u] || u === 'second') {
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
return rtf.format(Math.round(elapsed / units[u]), u)
}
}
}

View File

@ -1,19 +1,19 @@
<div class="parent"> <div class="parent">
<div class="content"> <div class="content">
<Card footer={false}> <Card footer={false}>
<span slot="title">Support Teams</span> <span slot="title">Ticket #{ticketId}</span>
<div slot="body" class="body-wrapper"> <div slot="body" class="body-wrapper">
<div class="section"> <div class="section">
<h2 class="section-title">Close Ticket</h2> <h2 class="section-title">Close Ticket</h2>
<form on:submit|preventDefault={closeTicket}> <form on:submit|preventDefault={closeTicket}>
<div class="row" style="max-height: 63px; align-items: flex-end"> <!-- hacky --> <div class="row" style="max-height: 63px; align-items: flex-end"> <!-- hacky -->
<div class="col-3" style="margin-bottom: 0 !important;"> <div class="col-2" style="margin-bottom: 0 !important;">
<Input label="Close Reason" placeholder="No reason specified" col1={true} bind:value={closeReason}/> <Input label="Close Reason" placeholder="No reason specified" col1={true} bind:value={closeReason}/>
</div> </div>
<div class="col-1"> <div class="col-3">
<div style="margin-left: 30px"> <div style="margin-left: 30px; margin-bottom: 0.5em">
<Button danger={true} icon="fas fa-lock">Close Ticket</Button> <Button danger={true} noShadow icon="fas fa-lock">Close Ticket</Button>
</div> </div>
</div> </div>
</div> </div>
@ -21,7 +21,7 @@
</div> </div>
<div class="section"> <div class="section">
<h2 class="section-title">View Ticket</h2> <h2 class="section-title">View Ticket</h2>
<DiscordMessages {ticketId} {isPremium} {messages} bind:container on:send={sendMessage} /> <DiscordMessages {ticketId} {isPremium} {tags} {messages} bind:container on:send={sendMessage} />
</div> </div>
</div> </div>
</Card> </Card>
@ -46,6 +46,7 @@
let closeReason = ''; let closeReason = '';
let messages = []; let messages = [];
let isPremium = false; let isPremium = false;
let tags = [];
let container; let container;
let WS_URL = env.WS_URL || 'ws://localhost:3000'; let WS_URL = env.WS_URL || 'ws://localhost:3000';
@ -69,16 +70,31 @@
} }
async function sendMessage(e) { async function sendMessage(e) {
let data = { if (e.detail.type === 'message') {
message: e.detail, let data = {
}; message: e.detail,
};
const res = await axios.post(`${API_URL}/api/${guildId}/tickets/${ticketId}`, data); const res = await axios.post(`${API_URL}/api/${guildId}/tickets/${ticketId}`, data);
if (res.status !== 200) { if (res.status !== 200) {
if (res.status === 429) { if (res.status === 429) {
notifyRatelimit(); notifyRatelimit();
} else { } else {
notifyError(res.data.error); notifyError(res.data.error);
}
}
} else if (e.detail.type === 'tag') {
let data = {
tag_id: e.detail.tag_id,
};
const res = await axios.post(`${API_URL}/api/${guildId}/tickets/${ticketId}/tag`, data);
if (res.status !== 200) {
if (res.status === 429) {
notifyRatelimit();
} else {
notifyError(res.data.error);
}
} }
} }
} }
@ -124,6 +140,16 @@
isPremium = res.data.premium; isPremium = res.data.premium;
} }
async function loadTags() {
const res = await axios.get(`${API_URL}/api/${guildId}/tags`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
tags = res.data;
}
withLoadingScreen(async () => { withLoadingScreen(async () => {
setDefaultHeaders(); setDefaultHeaders();
await Promise.all([ await Promise.all([
@ -135,6 +161,7 @@
if (isPremium) { if (isPremium) {
connectWebsocket(); connectWebsocket();
await loadTags();
} }
}); });
</script> </script>
@ -152,7 +179,6 @@
justify-content: space-between; justify-content: space-between;
width: 96%; width: 96%;
height: 100%; height: 100%;
margin-top: 30px;
} }
.body-wrapper { .body-wrapper {

View File

@ -1,27 +1,94 @@
<div class="content"> <main>
<Card footer={false}>
<span slot="title">
<i class="fas fa-filter"></i>
Filters
</span>
<div slot="body" class="filter-wrapper">
<div>
<label class="form-label">Show Columns</label>
<ColumnSelector
options={["ID", "Panel", "User", "Opened Time", "Claimed By", "Last Message Time", "Awaiting Response"]}
bind:selected={selectedColumns}
/>
</div>
<Dropdown col2 label="Sort Tickets By..." bind:value={sortMethod}>
<option value="id_desc">Ticket ID (Descending)</option>
<option value="unclaimed">Unclaimed & Awaiting Response First</option>
</Dropdown>
<Checkbox label="Only Show Unclaimed Tickets & Tickets Claimed By Me" bind:value={onlyShowMyTickets} />
</div>
</Card>
<Card footer={false}> <Card footer={false}>
<span slot="title">Open Tickets</span> <span slot="title">Open Tickets</span>
<div slot="body" class="body-wrapper"> <div slot="body" class="body-wrapper">
<table class="nice"> <table class="nice">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th class:visible={selectedColumns.includes('ID')}>ID</th>
<th>Panel</th> <th class:visible={selectedColumns.includes('Panel')}>Panel</th>
<th>User</th> <th class:visible={selectedColumns.includes('User')}>User</th>
<th>View</th> <th class:visible={selectedColumns.includes('Opened Time')}>Opened</th>
<th class:visible={selectedColumns.includes('Claimed By')}>Claimed By</th>
<th class:visible={selectedColumns.includes('Last Message Time')}>Last Message</th>
<th class:visible={selectedColumns.includes('Awaiting Response')}>Awaiting Response</th>
<th class="visible">View</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each tickets as ticket} {#each filtered as ticket}
{@const user = data.resolved_users[ticket.user_id]}
{@const claimer = ticket.claimed_by ? data.resolved_users[ticket.claimed_by] : null}
{@const panel_title = data.panel_titles[ticket.panel_id?.toString()]}
<tr> <tr>
<td>{ticket.id}</td> <td class:visible={selectedColumns.includes('ID')}>{ticket.id}</td>
<td>{ticket.panel_title}</td> <td class:visible={selectedColumns.includes('Panel')}>
{#if ticket.user !== undefined} {panel_title || 'Unknown Panel'}
<td>{ticket.user.username}</td> </td>
{:else}
<td>Unknown</td> <td class:visible={selectedColumns.includes('User')}>
{/if} {#if user}
<td> {user.global_name || user.username}
{:else}
Unknown
{/if}
</td>
<td class:visible={selectedColumns.includes('Opened Time')}>
{getRelativeTime(new Date(ticket.opened_at))}
</td>
<td class:visible={selectedColumns.includes('Claimed By')}>
{#if ticket.claimed_by === null}
<b>Unclaimed</b>
{:else if claimer}
{claimer.global_name || claimer.username}
{:else}
Unknown
{/if}
</td>
<td class:visible={selectedColumns.includes('Last Message Time')}>
{#if ticket.last_response_time}
{getRelativeTime(new Date(ticket.last_response_time))}
{:else}
Never
{/if}
</td>
<td class:visible={selectedColumns.includes('Awaiting Response')}>
{#if ticket.last_response_is_staff}
No
{:else}
<b>Yes</b>
{/if}
</td>
<td class="visible">
<Navigate to="/manage/{guildId}/tickets/view/{ticket.id}" styles="link"> <Navigate to="/manage/{guildId}/tickets/view/{ticket.id}" styles="link">
<Button type="button">View</Button> <Button type="button">View</Button>
</Navigate> </Navigate>
@ -32,21 +99,85 @@
</table> </table>
</div> </div>
</Card> </Card>
</div> </main>
<script> <script>
import Card from "../components/Card.svelte"; import Card from "../components/Card.svelte";
import {notifyError, withLoadingScreen} from '../js/util' import {getRelativeTime, notifyError, withLoadingScreen} from '../js/util'
import axios from "axios"; import axios from "axios";
import {API_URL} from "../js/constants"; import {API_URL} from "../js/constants";
import {setDefaultHeaders} from '../includes/Auth.svelte' import {setDefaultHeaders} from '../includes/Auth.svelte'
import Button from "../components/Button.svelte"; import Button from "../components/Button.svelte";
import {Navigate} from 'svelte-router-spa'; import {Navigate} from 'svelte-router-spa';
import ColumnSelector from "../components/ColumnSelector.svelte";
import Dropdown from "../components/form/Dropdown.svelte";
import Checkbox from "../components/form/Checkbox.svelte";
export let currentRoute; export let currentRoute;
let guildId = currentRoute.namedParams.id; let guildId = currentRoute.namedParams.id;
let tickets = []; let selectedColumns = ['ID', 'Panel', 'User', 'Claimed By', 'Last Message Time', 'Awaiting Response'];
let sortMethod = "unclaimed";
let onlyShowMyTickets = false;
let data = {
tickets: [],
panel_titles: {},
resolved_users: {}
};
let filtered = [];
function filterTickets() {
filtered = data.tickets.filter(ticket => {
if (onlyShowMyTickets) {
return ticket.claimed_by === null || ticket.claimed_by === data.self_id;
}
return true;
});
// Apply sort
if (sortMethod === 'id_desc') {
filtered.sort((a, b) => b.id - a.id);
} else if (sortMethod === 'unclaimed') {
filtered.sort((a, b) => {
// Place unclaimed tickets at the top. The priority of fields used for sorting is:
// 1. Unclaimed tickets, or tickets claimed by the current user
// 2. Awaiting Response
// 3. Last Response Time
// Unclaimed tickets at the top
if (a.claimed_by === null && b.claimed_by !== null) {
return -1;
}
if (a.claimed_by !== null && b.claimed_by === null) {
return 1;
}
if (a.claimed_by === data.self_id && b.claimed_by !== data.self_id) {
return -1;
}
if (a.claimed_by !== data.self_id && b.claimed_by === data.self_id) {
return 1;
}
// Among claimed tickets, those awaiting response at the top
if (!a.last_response_is_staff && b.last_response_is_staff) {
return -1;
}
if (a.last_response_is_staff && !b.last_response_is_staff) {
return 1;
}
// Among tickets not awaiting response, sort by last response time
const aLastResponseTime = new Date(a.last_response_time || 0);
const bLastResponseTime = new Date(b.last_response_time || 0);
return bLastResponseTime - aLastResponseTime;
});
}
}
async function loadTickets() { async function loadTickets() {
const res = await axios.get(`${API_URL}/api/${guildId}/tickets`); const res = await axios.get(`${API_URL}/api/${guildId}/tickets`);
@ -55,18 +186,56 @@
return; return;
} }
tickets = res.data; data = res.data;
filterTickets();
}
const columnStorageKey = 'ticket_list:selected_columns';
const sortOrderKey = 'ticket_list:sort_order';
const onlyMyTicketsKey = 'ticket_list:only_my_tickets';
$: selectedColumns, updateFilters();
$: sortMethod, updateFilters();
$: onlyShowMyTickets, updateFilters();
function updateFilters() {
window.localStorage.setItem(columnStorageKey, JSON.stringify(selectedColumns));
window.localStorage.setItem(sortOrderKey, sortMethod);
window.localStorage.setItem(onlyMyTicketsKey, JSON.stringify(onlyShowMyTickets));
filterTickets();
}
function loadFilterSettings() {
const columns = window.localStorage.getItem(columnStorageKey);
if (columns) {
selectedColumns = JSON.parse(columns);
}
const sortOrder = window.localStorage.getItem(sortOrderKey);
if (sortOrder) {
sortMethod = sortOrder;
}
const onlyMyTickets = window.localStorage.getItem(onlyMyTicketsKey);
if (onlyMyTickets) {
onlyShowMyTickets = JSON.parse(onlyMyTickets);
}
} }
withLoadingScreen(async () => { withLoadingScreen(async () => {
loadFilterSettings();
setDefaultHeaders(); setDefaultHeaders();
await loadTickets(); await loadTickets();
}); });
</script> </script>
<style> <style>
.content { main {
display: flex; display: flex;
flex-direction: column;
gap: 30px;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
@ -77,4 +246,27 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.filter-wrapper {
display: flex;
flex-direction: row;
gap: 1rem;
width: 100%;
height: 100%;
}
th, td {
display: none;
}
th.visible, td.visible {
display: table-cell;
}
@media only screen and (max-width: 1400px) {
.filter-wrapper {
flex-direction: column;
gap: 8px;
}
}
</style> </style>

2
go.mod
View File

@ -8,7 +8,7 @@ require (
github.com/BurntSushi/toml v1.2.1 github.com/BurntSushi/toml v1.2.1
github.com/TicketsBot/archiverclient v0.0.0-20240613013458-accc062facc2 github.com/TicketsBot/archiverclient v0.0.0-20240613013458-accc062facc2
github.com/TicketsBot/common v0.0.0-20240829163809-6f60869d8941 github.com/TicketsBot/common v0.0.0-20240829163809-6f60869d8941
github.com/TicketsBot/database v0.0.0-20240901155918-d0c56594a09a github.com/TicketsBot/database v0.0.0-20240908162702-a0592f669978
github.com/TicketsBot/logarchiver v0.0.0-20220326162808-cdf0310f5e1c github.com/TicketsBot/logarchiver v0.0.0-20220326162808-cdf0310f5e1c
github.com/TicketsBot/worker v0.0.0-20240829163848-84556c59ee72 github.com/TicketsBot/worker v0.0.0-20240829163848-84556c59ee72
github.com/apex/log v1.1.2 github.com/apex/log v1.1.2

2
go.sum
View File

@ -72,6 +72,8 @@ github.com/TicketsBot/database v0.0.0-20240829163737-86b657b7e506 h1:mU2wx9pyb72
github.com/TicketsBot/database v0.0.0-20240829163737-86b657b7e506/go.mod h1:tnSNh3i0tPw2xmTzF8JnQPPpKY9QdwbeH2d4xzYcqcM= github.com/TicketsBot/database v0.0.0-20240829163737-86b657b7e506/go.mod h1:tnSNh3i0tPw2xmTzF8JnQPPpKY9QdwbeH2d4xzYcqcM=
github.com/TicketsBot/database v0.0.0-20240901155918-d0c56594a09a h1:kNDfpVimz3kEBYpiIql1rJDDUHiBKZEdw+JLyH4Ne9w= github.com/TicketsBot/database v0.0.0-20240901155918-d0c56594a09a h1:kNDfpVimz3kEBYpiIql1rJDDUHiBKZEdw+JLyH4Ne9w=
github.com/TicketsBot/database v0.0.0-20240901155918-d0c56594a09a/go.mod h1:tnSNh3i0tPw2xmTzF8JnQPPpKY9QdwbeH2d4xzYcqcM= github.com/TicketsBot/database v0.0.0-20240901155918-d0c56594a09a/go.mod h1:tnSNh3i0tPw2xmTzF8JnQPPpKY9QdwbeH2d4xzYcqcM=
github.com/TicketsBot/database v0.0.0-20240908162702-a0592f669978 h1:31mE0z9PKGc2X8mImRq79x3/kosHoriWG5MLdf/lkJU=
github.com/TicketsBot/database v0.0.0-20240908162702-a0592f669978/go.mod h1:tnSNh3i0tPw2xmTzF8JnQPPpKY9QdwbeH2d4xzYcqcM=
github.com/TicketsBot/logarchiver v0.0.0-20220326162808-cdf0310f5e1c h1:OqGjFH6mbE6gd+NqI2ARJdtH3UUvhiAkD0r0fhGJK2s= github.com/TicketsBot/logarchiver v0.0.0-20220326162808-cdf0310f5e1c h1:OqGjFH6mbE6gd+NqI2ARJdtH3UUvhiAkD0r0fhGJK2s=
github.com/TicketsBot/logarchiver v0.0.0-20220326162808-cdf0310f5e1c/go.mod h1:jgi2OXQKsd5nUnTIRkwvPmeuD/i7OhN68LKMssuQY1c= github.com/TicketsBot/logarchiver v0.0.0-20220326162808-cdf0310f5e1c/go.mod h1:jgi2OXQKsd5nUnTIRkwvPmeuD/i7OhN68LKMssuQY1c=
github.com/TicketsBot/ttlcache v1.6.1-0.20200405150101-acc18e37b261 h1:NHD5GB6cjlkpZFjC76Yli2S63/J2nhr8MuE6KlYJpQM= github.com/TicketsBot/ttlcache v1.6.1-0.20200405150101-acc18e37b261 h1:NHD5GB6cjlkpZFjC76Yli2S63/J2nhr8MuE6KlYJpQM=