Ticket panel rework

This commit is contained in:
rxdn 2024-07-29 23:26:38 +01:00
parent ab733dde8d
commit 274e2bfa78
27 changed files with 1144 additions and 987 deletions

View File

@ -8,35 +8,31 @@ import (
"github.com/TicketsBot/GoPanel/rpc" "github.com/TicketsBot/GoPanel/rpc"
"github.com/TicketsBot/GoPanel/rpc/cache" "github.com/TicketsBot/GoPanel/rpc/cache"
"github.com/TicketsBot/GoPanel/utils" "github.com/TicketsBot/GoPanel/utils"
"github.com/TicketsBot/GoPanel/utils/types"
"github.com/TicketsBot/common/premium" "github.com/TicketsBot/common/premium"
"github.com/TicketsBot/database" "github.com/TicketsBot/database"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/rxdn/gdl/objects/channel" "github.com/rxdn/gdl/objects/channel"
"github.com/rxdn/gdl/rest/request" "github.com/rxdn/gdl/rest/request"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
) )
type multiPanelCreateData struct { type multiPanelCreateData struct {
Title string `json:"title"`
Content string `json:"content"`
Colour int32 `json:"colour"`
ChannelId uint64 `json:"channel_id,string"` ChannelId uint64 `json:"channel_id,string"`
SelectMenu bool `json:"select_menu"` SelectMenu bool `json:"select_menu"`
SelectMenuPlaceholder *string `json:"select_menu_placeholder,omitempty" validate:"omitempty,max=150"`
Panels []int `json:"panels"` Panels []int `json:"panels"`
ImageUrl *string `json:"image_url,omitempty"` Embed *types.CustomEmbed `json:"embed" validate:"omitempty,dive"`
ThumbnailUrl *string `json:"thumbnail_url,omitempty"`
} }
func (d *multiPanelCreateData) IntoMessageData(isPremium bool) multiPanelMessageData { func (d *multiPanelCreateData) IntoMessageData(isPremium bool) multiPanelMessageData {
return multiPanelMessageData{ return multiPanelMessageData{
ChannelId: d.ChannelId,
Title: d.Title,
Content: d.Content,
Colour: int(d.Colour),
SelectMenu: d.SelectMenu,
IsPremium: isPremium, IsPremium: isPremium,
ImageUrl: d.ImageUrl, ChannelId: d.ChannelId,
ThumbnailUrl: d.ThumbnailUrl, SelectMenu: d.SelectMenu,
SelectMenuPlaceholder: d.SelectMenuPlaceholder,
Embed: d.Embed.IntoDiscordEmbed(),
} }
} }
@ -49,6 +45,18 @@ func MultiPanelCreate(ctx *gin.Context) {
return return
} }
if err := validate.Struct(data); err != nil {
var validationErrors validator.ValidationErrors
if ok := errors.As(err, &validationErrors); !ok {
ctx.JSON(500, utils.ErrorStr("An error occurred while validating the panel"))
return
}
formatted := "Your input contained the following errors:\n" + utils.FormatValidationErrors(validationErrors)
ctx.JSON(400, utils.ErrorStr(formatted))
return
}
// validate body & get sub-panels // validate body & get sub-panels
panels, err := data.doValidations(guildId) panels, err := data.doValidations(guildId)
if err != nil { if err != nil {
@ -86,14 +94,17 @@ func MultiPanelCreate(ctx *gin.Context) {
return return
} }
dbEmbed, dbEmbedFields := data.Embed.IntoDatabaseStruct()
multiPanel := database.MultiPanel{ multiPanel := database.MultiPanel{
MessageId: messageId, MessageId: messageId,
ChannelId: data.ChannelId, ChannelId: data.ChannelId,
GuildId: guildId, GuildId: guildId,
Title: data.Title,
Content: data.Content,
Colour: int(data.Colour),
SelectMenu: data.SelectMenu, SelectMenu: data.SelectMenu,
SelectMenuPlaceholder: data.SelectMenuPlaceholder,
Embed: &database.CustomEmbedWithFields{
CustomEmbed: dbEmbed,
Fields: dbEmbedFields,
},
} }
multiPanel.Id, err = dbclient.Client.MultiPanels.Create(ctx, multiPanel) multiPanel.Id, err = dbclient.Client.MultiPanels.Create(ctx, multiPanel)
@ -123,10 +134,12 @@ func MultiPanelCreate(ctx *gin.Context) {
} }
func (d *multiPanelCreateData) doValidations(guildId uint64) (panels []database.Panel, err error) { func (d *multiPanelCreateData) doValidations(guildId uint64) (panels []database.Panel, err error) {
if err := validateEmbed(d.Embed); err != nil {
return nil, err
}
group, _ := errgroup.WithContext(context.Background()) group, _ := errgroup.WithContext(context.Background())
group.Go(d.validateTitle)
group.Go(d.validateContent)
group.Go(d.validateChannel(guildId)) group.Go(d.validateChannel(guildId))
group.Go(func() (e error) { group.Go(func() (e error) {
panels, e = d.validatePanels(guildId) panels, e = d.validatePanels(guildId)
@ -137,26 +150,6 @@ func (d *multiPanelCreateData) doValidations(guildId uint64) (panels []database.
return return
} }
func (d *multiPanelCreateData) validateTitle() (err error) {
if len(d.Title) > 255 {
err = errors.New("Embed title must be between 1 and 255 characters")
} else if len(d.Title) == 0 {
d.Title = "Click to open a ticket"
}
return
}
func (d *multiPanelCreateData) validateContent() (err error) {
if len(d.Content) > 4096 {
err = errors.New("Embed content must be between 1 and 4096 characters")
} else if len(d.Content) == 0 { // Fill default
d.Content = "Click on the button corresponding to the type of ticket you wish to open"
}
return
}
func (d *multiPanelCreateData) validateChannel(guildId uint64) func() error { func (d *multiPanelCreateData) validateChannel(guildId uint64) func() error {
return func() error { return func() error {
// TODO: Use proper context // TODO: Use proper context

View File

@ -13,46 +13,32 @@ import (
) )
type multiPanelMessageData struct { type multiPanelMessageData struct {
IsPremium bool
ChannelId uint64 ChannelId uint64
Title string
Content string
Colour int
SelectMenu bool SelectMenu bool
IsPremium bool SelectMenuPlaceholder *string
ImageUrl, ThumbnailUrl *string
Embed *embed.Embed
} }
func multiPanelIntoMessageData(panel database.MultiPanel, isPremium bool) multiPanelMessageData { func multiPanelIntoMessageData(panel database.MultiPanel, isPremium bool) multiPanelMessageData {
return multiPanelMessageData{ return multiPanelMessageData{
ChannelId: panel.ChannelId,
Title: panel.Title,
Content: panel.Content,
Colour: panel.Colour,
SelectMenu: panel.SelectMenu,
IsPremium: isPremium, IsPremium: isPremium,
ImageUrl: panel.ImageUrl,
ThumbnailUrl: panel.ThumbnailUrl, ChannelId: panel.ChannelId,
SelectMenu: panel.SelectMenu,
SelectMenuPlaceholder: panel.SelectMenuPlaceholder,
Embed: types.NewCustomEmbed(panel.Embed.CustomEmbed, panel.Embed.Fields).IntoDiscordEmbed(),
} }
} }
func (d *multiPanelMessageData) send(ctx *botcontext.BotContext, panels []database.Panel) (uint64, error) { func (d *multiPanelMessageData) send(ctx *botcontext.BotContext, panels []database.Panel) (uint64, error) {
e := embed.NewEmbed().
SetTitle(d.Title).
SetDescription(d.Content).
SetColor(d.Colour)
if d.ImageUrl != nil {
e.SetImage(*d.ImageUrl)
}
if d.ThumbnailUrl != nil {
e.SetThumbnail(*d.ThumbnailUrl)
}
if !d.IsPremium { if !d.IsPremium {
// TODO: Don't harcode // TODO: Don't harcode
e.SetFooter("Powered by ticketsbot.net", "https://ticketsbot.net/assets/img/logo.png") d.Embed.SetFooter("Powered by ticketsbot.net", "https://ticketsbot.net/assets/img/logo.png")
} }
var components []component.Component var components []component.Component
@ -68,13 +54,20 @@ func (d *multiPanelMessageData) send(ctx *botcontext.BotContext, panels []databa
} }
} }
var placeholder string
if d.SelectMenuPlaceholder == nil {
placeholder = "Select a topic..."
} else {
placeholder = *d.SelectMenuPlaceholder
}
components = []component.Component{ components = []component.Component{
component.BuildActionRow( component.BuildActionRow(
component.BuildSelectMenu( component.BuildSelectMenu(
component.SelectMenu{ component.SelectMenu{
CustomId: "multipanel", CustomId: "multipanel",
Options: options, Options: options,
Placeholder: "Select a topic...", Placeholder: placeholder,
MinValues: utils.IntPtr(1), MinValues: utils.IntPtr(1),
MaxValues: utils.IntPtr(1), MaxValues: utils.IntPtr(1),
Disabled: false, Disabled: false,
@ -116,7 +109,7 @@ func (d *multiPanelMessageData) send(ctx *botcontext.BotContext, panels []databa
} }
data := rest.CreateMessageData{ data := rest.CreateMessageData{
Embeds: []*embed.Embed{e}, Embeds: []*embed.Embed{d.Embed},
Components: components, Components: components,
} }

View File

@ -11,6 +11,7 @@ import (
"github.com/TicketsBot/common/premium" "github.com/TicketsBot/common/premium"
"github.com/TicketsBot/database" "github.com/TicketsBot/database"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/rxdn/gdl/rest" "github.com/rxdn/gdl/rest"
"github.com/rxdn/gdl/rest/request" "github.com/rxdn/gdl/rest/request"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
@ -53,6 +54,18 @@ func MultiPanelUpdate(c *gin.Context) {
return return
} }
if err := validate.Struct(data); err != nil {
var validationErrors validator.ValidationErrors
if ok := errors.As(err, &validationErrors); !ok {
c.JSON(500, utils.ErrorStr("An error occurred while validating the panel"))
return
}
formatted := "Your input contained the following errors:\n" + utils.FormatValidationErrors(validationErrors)
c.JSON(400, utils.ErrorStr(formatted))
return
}
// validate body & get sub-panels // validate body & get sub-panels
panels, err := data.doValidations(guildId) panels, err := data.doValidations(guildId)
if err != nil { if err != nil {
@ -115,17 +128,18 @@ func MultiPanelUpdate(c *gin.Context) {
} }
// update DB // update DB
dbEmbed, dbEmbedFields := data.Embed.IntoDatabaseStruct()
updated := database.MultiPanel{ updated := database.MultiPanel{
Id: multiPanel.Id, Id: multiPanel.Id,
MessageId: messageId, MessageId: messageId,
ChannelId: data.ChannelId, ChannelId: data.ChannelId,
GuildId: guildId, GuildId: guildId,
Title: data.Title,
Content: data.Content,
Colour: int(data.Colour),
SelectMenu: data.SelectMenu, SelectMenu: data.SelectMenu,
ImageUrl: data.ImageUrl, SelectMenuPlaceholder: data.SelectMenuPlaceholder,
ThumbnailUrl: data.ThumbnailUrl, Embed: &database.CustomEmbedWithFields{
CustomEmbed: dbEmbed,
Fields: dbEmbedFields,
},
} }
if err = dbclient.Client.MultiPanels.Update(c, multiPanel.Id, updated); err != nil { if err = dbclient.Client.MultiPanels.Update(c, multiPanel.Id, updated); err != nil {

View File

@ -95,14 +95,7 @@ func DeletePanel(ctx *gin.Context) {
return return
} }
messageData := multiPanelMessageData{ messageData := multiPanelIntoMessageData(multiPanel, premiumTier > premium.None)
Title: multiPanel.Title,
Content: multiPanel.Content,
Colour: multiPanel.Colour,
ChannelId: multiPanel.ChannelId,
SelectMenu: multiPanel.SelectMenu,
IsPremium: premiumTier > premium.None,
}
messageId, err := messageData.send(botContext, panels) messageId, err := messageData.send(botContext, panels)
if err != nil { if err != nil {

View File

@ -306,16 +306,7 @@ func UpdatePanel(ctx *gin.Context) {
return return
} }
messageData := multiPanelMessageData{ messageData := multiPanelIntoMessageData(multiPanel, premiumTier > premium.None)
Title: multiPanel.Title,
Content: multiPanel.Content,
Colour: multiPanel.Colour,
ChannelId: multiPanel.ChannelId,
SelectMenu: multiPanel.SelectMenu,
IsPremium: premiumTier > premium.None,
ImageUrl: multiPanel.ImageUrl,
ThumbnailUrl: multiPanel.ThumbnailUrl,
}
messageId, err := messageData.send(botContext, panels) messageId, err := messageData.send(botContext, panels)
if err != nil { if err != nil {

View File

@ -10,6 +10,7 @@ import (
"github.com/TicketsBot/GoPanel/botcontext" "github.com/TicketsBot/GoPanel/botcontext"
dbclient "github.com/TicketsBot/GoPanel/database" dbclient "github.com/TicketsBot/GoPanel/database"
"github.com/TicketsBot/GoPanel/utils" "github.com/TicketsBot/GoPanel/utils"
"github.com/TicketsBot/GoPanel/utils/types"
"github.com/TicketsBot/database" "github.com/TicketsBot/database"
"github.com/rxdn/gdl/objects/channel" "github.com/rxdn/gdl/objects/channel"
"github.com/rxdn/gdl/objects/guild" "github.com/rxdn/gdl/objects/guild"
@ -289,13 +290,7 @@ func validateNamingScheme(ctx PanelValidationContext) validation.ValidationFunc
func validateWelcomeMessage(ctx PanelValidationContext) validation.ValidationFunc { func validateWelcomeMessage(ctx PanelValidationContext) validation.ValidationFunc {
return func() error { return func() error {
wm := ctx.Data.WelcomeMessage return validateEmbed(ctx.Data.WelcomeMessage)
if wm == nil || wm.Title != nil || wm.Description != nil || len(wm.Fields) > 0 || wm.ImageUrl != nil || wm.ThumbnailUrl != nil {
return nil
}
return validation.NewInvalidInputError("Welcome message has no content")
} }
} }
@ -339,3 +334,11 @@ func validateAccessControlList(ctx PanelValidationContext) validation.Validation
return nil return nil
} }
} }
func validateEmbed(e *types.CustomEmbed) error {
if e == nil || e.Title != nil || e.Description != nil || len(e.Fields) > 0 || e.ImageUrl != nil || e.ThumbnailUrl != nil {
return nil
}
return validation.NewInvalidInputError("Your embed message does not contain any content")
}

View File

@ -1,6 +1,7 @@
package api package api
import ( import (
"errors"
"fmt" "fmt"
"github.com/TicketsBot/GoPanel/botcontext" "github.com/TicketsBot/GoPanel/botcontext"
dbclient "github.com/TicketsBot/GoPanel/database" dbclient "github.com/TicketsBot/GoPanel/database"
@ -59,8 +60,8 @@ func CreateTag(ctx *gin.Context) {
// TODO: Limit command amount // TODO: Limit command amount
if err := validate.Struct(data); err != nil { if err := validate.Struct(data); err != nil {
validationErrors, ok := err.(validator.ValidationErrors) var validationErrors validator.ValidationErrors
if !ok { if ok := errors.As(err, &validationErrors); !ok {
ctx.JSON(500, utils.ErrorStr("An error occurred while validating the integration")) ctx.JSON(500, utils.ErrorStr("An error occurred while validating the integration"))
return return
} }

View File

@ -36,7 +36,7 @@
<svelte:window bind:innerWidth /> <svelte:window bind:innerWidth />
<script> <script>
import {onMount} from "svelte"; import {afterUpdate, onMount} from "svelte";
import Tooltip from "svelte-tooltip"; import Tooltip from "svelte-tooltip";
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
@ -89,9 +89,6 @@
} }
onMount(() => { onMount(() => {
// content.addEventListener('DOMNodeInserted', updateIfExpanded);
// content.addEventListener('DOMNodeRemoved', updateIfExpanded);
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
updateIfExpanded(); updateIfExpanded();
setTimeout(updateIfExpanded, 300); // TODO: Move with transition height setTimeout(updateIfExpanded, 300); // TODO: Move with transition height
@ -101,6 +98,8 @@
if (defaultOpen || forceAlwaysOpen) toggle(true); if (defaultOpen || forceAlwaysOpen) toggle(true);
}); });
afterUpdate(updateIfExpanded);
</script> </script>
<style> <style>

View File

@ -1,7 +1,7 @@
{#if data} {#if data && appliedOverrides}
<form class="form-wrapper" on:submit|preventDefault> <form class="form-wrapper" on:submit|preventDefault>
<div class="row"> <div class="row">
<Colour col3 label="Embed Colour" bind:value={data.colour}/> <Colour col3 label="Embed Colour" on:change={updateColour} bind:value={tempColour}/>
<Input col3 label="Title" placeholder="Embed Title" bind:value={data.title}/> <Input col3 label="Title" placeholder="Embed Title" bind:value={data.title}/>
<Input col3 label="Title URL (Optional)" placeholder="https://example.com" bind:value={data.url}/> <Input col3 label="Title URL (Optional)" placeholder="https://example.com" bind:value={data.url}/>
</div> </div>
@ -37,8 +37,10 @@
<span slot="header">Footer</span> <span slot="header">Footer</span>
<div slot="content" class="row"> <div slot="content" class="row">
{#if footerPremiumOnly} {#if footerPremiumOnly}
<Input col3 label="Footer Text" placeholder="Footer Text" badge="Premium" bind:value={data.footer.text}/> <Input col3 label="Footer Text" placeholder="Footer Text" badge="Premium"
<Input col3 label="Footer Icon URL (Optional)" badge="Premium" placeholder="https://example.com/image.png" bind:value={data.footer.text}/>
<Input col3 label="Footer Icon URL (Optional)" badge="Premium"
placeholder="https://example.com/image.png"
bind:value={data.footer.icon_url}/> bind:value={data.footer.icon_url}/>
{:else} {:else}
<Input col3 label="Footer Text" placeholder="Footer Text" bind:value={data.footer.text}/> <Input col3 label="Footer Text" placeholder="Footer Text" bind:value={data.footer.text}/>
@ -66,8 +68,11 @@
bind:value={field.value}/> bind:value={field.value}/>
</div> </div>
{/each} {/each}
<div class="add-field-wrapper">
<Button type="button" icon="fas fa-plus" fullWidth on:click={addField}>Add Field</Button> <Button type="button" icon="fas fa-plus" fullWidth on:click={addField}>Add Field</Button>
</div> </div>
</div>
</Collapsible> </Collapsible>
</form> </form>
{/if} {/if}
@ -77,7 +82,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
height: 100%;
} }
.row { .row {
@ -86,7 +90,11 @@
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
width: 100%; width: 100%;
height: 100%; }
.add-field-wrapper {
width: 100%;
margin-top: 10px;
} }
</style> </style>
@ -98,12 +106,14 @@
import DateTimePicker from "./form/DateTimePicker.svelte"; import DateTimePicker from "./form/DateTimePicker.svelte";
import Checkbox from "./form/Checkbox.svelte"; import Checkbox from "./form/Checkbox.svelte";
import Button from "./Button.svelte"; import Button from "./Button.svelte";
import {onMount} from "svelte";
import {intToColour, colourToInt} from "../js/util";
export let data; export let data;
$: data = data ?? { $: data = data ?? {
fields: [], fields: [],
colour: '#2ECC71', colour: 0x2ECC71,
author: {}, author: {},
footer: {}, footer: {},
}; };
@ -119,4 +129,28 @@
data.fields.splice(i, 1); data.fields.splice(i, 1);
data = data; data = data;
} }
let tempColour = "#2ecc71";
function updateColour() {
data.colour = colourToInt(tempColour);
}
let appliedOverrides = false;
onMount(() => {
data.author = data.author ?? {};
data.footer = data.footer ?? {};
data.fields = data.fields ?? [];
if (!data.colour) {
data.colour = 0x2ECC71;
} else {
if (typeof data.colour === "string" && data.colour.startsWith('#')) {
data.colour = parseInt(data.colour.slice(1), 16)
}
tempColour = intToColour(data.colour);
}
appliedOverrides = true;
});
</script> </script>

View File

@ -24,8 +24,6 @@
selectedRaw = []; selectedRaw = [];
} }
console.log(selectedRaw)
if (isMulti) { if (isMulti) {
selected = selectedRaw.map((panel) => panel.panel_id); selected = selectedRaw.map((panel) => panel.panel_id);
} else { } else {

View File

@ -1,59 +1,41 @@
<form on:submit|preventDefault> <form on:submit|preventDefault>
<div class="row"> <Collapsible defaultOpen>
<Input col1={true} label="Panel Title" placeholder="Click to open a ticket" bind:value={data.title}/> <span slot="header">Properties</span>
</div> <div slot="content" class="col-1">
<div class="row">
<Textarea col1={true} label="Panel Content" bind:value={data.content}
placeholder="Click on the button corresponding to the type of ticket you wish to open. Let users know which button responds to which category. You are able to use emojis here."/>
</div>
<div class="row">
<div class="col-1-3">
<Colour col1={true} label="Panel Colour" on:change={updateColour} bind:value={tempColour}/>
</div>
<div class="col-2-3">
<ChannelDropdown col1 allowAnnouncementChannel {channels} label="Panel Channel" bind:value={data.channel_id}/>
</div>
</div>
<div class="row">
<div class="col-3-4" style="padding-right: 10px">
<PanelDropdown label="Panels" {panels} bind:selected={data.panels} />
</div>
<div class="col-1-4">
<Checkbox label="Use Dropdown Menu" bind:value={data.select_menu} />
</div>
</div>
<div class="row" style="justify-content: center; padding-top: 10px">
<div class="col-1"> <div class="col-1">
<Button icon="fas fa-sliders-h" fullWidth=true type="button" <ChannelDropdown col1 allowAnnouncementChannel {channels} label="Panel Channel"
on:click={toggleAdvancedSettings}>Toggle Advanced Settings bind:value={data.channel_id}/>
</Button>
</div> </div>
<div class="col-1" style="padding-right: 10px">
<PanelDropdown label="Panels (Minimum 2)" {panels} bind:selected={data.panels}/>
</div> </div>
<div class="row advanced-settings" class:advanced-settings-show={advancedSettings} <div class="col-1">
class:advanced-settings-hide={!advancedSettings} class:show-overflow={overflowShow}> <div class="row dropdown-menu-settings">
<div class="inner" class:inner-show={advancedSettings} class:absolute={advancedSettings && !overflowShow} > <Checkbox label="Use Dropdown Menu" bind:value={data.select_menu}/>
<div class="row"> <div class="placeholder-input">
<Input col1={true} label="Large Image URL" bind:value={data.image_url} placeholder="https://example.com/image.png" /> <Input label="Dropdown Menu Placeholder" col1 placeholder="Select a topic..."
</div> bind:value={data.select_menu_placeholder} disabled={!data.select_menu} />
<div class="row">
<Input col1={true} label="Small Image URL" bind:value={data.thumbnail_url} placeholder="https://example.com/image.png" />
</div> </div>
</div> </div>
</div> </div>
</div>
</Collapsible>
<Collapsible defaultOpen>
<span slot="header">Message</span>
<div slot="content" class="col-1">
<EmbedForm footerPremiumOnly={true} bind:data={data.embed}/>
</div>
</Collapsible>
</form> </form>
<script> <script>
import Input from "../form/Input.svelte";
import Textarea from "../form/Textarea.svelte";
import Colour from "../form/Colour.svelte";
import {colourToInt, intToColour} from "../../js/util";
import ChannelDropdown from "../ChannelDropdown.svelte"; import ChannelDropdown from "../ChannelDropdown.svelte";
import PanelDropdown from "../PanelDropdown.svelte"; import PanelDropdown from "../PanelDropdown.svelte";
import {onMount} from "svelte";
import Checkbox from "../form/Checkbox.svelte"; import Checkbox from "../form/Checkbox.svelte";
import Button from "../Button.svelte"; import Collapsible from "../Collapsible.svelte";
import EmbedForm from "../EmbedForm.svelte";
import Input from "../form/Input.svelte";
export let data; export let data;
@ -66,42 +48,17 @@
const firstChannel = channels[0]; const firstChannel = channels[0];
data = { data = {
colour: 0x7289da,
channels: firstChannel ? firstChannel.id : undefined, channels: firstChannel ? firstChannel.id : undefined,
panels: [], panels: [],
embed: {
title: 'Open a ticket!',
fields: [],
colour: 0x2ECC71,
author: {},
footer: {},
},
} }
} }
let mounted = false;
let advancedSettings = false;
let overflowShow = false;
function toggleAdvancedSettings() {
advancedSettings = !advancedSettings;
if (advancedSettings) {
setTimeout(() => {
overflowShow = true;
}, 300);
} else {
overflowShow = false;
}
}
let tempColour = '#7289da';
function updateColour() {
data.colour = colourToInt(tempColour);
}
function applyOverrides() {
tempColour = intToColour(data.colour);
}
onMount(() => {
if (!seedDefault) {
applyOverrides();
}
})
</script> </script>
<style> <style>
@ -109,7 +66,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
height: 100%;
} }
.row { .row {
@ -119,6 +75,15 @@
width: 100%; width: 100%;
} }
.dropdown-menu-settings {
gap: 10px;
margin-top: 10px;
}
.dropdown-menu-settings > .placeholder-input {
flex: 1;
}
@media only screen and (max-width: 950px) { @media only screen and (max-width: 950px) {
.row { .row {
flex-direction: column; flex-direction: column;
@ -140,24 +105,4 @@
width: 75%; width: 75%;
height: 100%; height: 100%;
} }
.inner {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
height: 100%;
width: 100%;
margin-top: 10px;
}
.absolute {
position: absolute;
}
.advanced-settings-show {
visibility: visible;
min-height: 142px;
}
</style> </style>

View File

@ -1,91 +0,0 @@
<div class="modal" transition:fade>
<div class="modal-wrapper">
<Card footer="{true}" footerRight="{true}" fill="{false}">
<span slot="title">Edit Multi-Panel</span>
<div slot="body" class="body-wrapper">
<MultiPanelCreationForm {guildId} {channels} {panels} bind:data seedDefault={false} />
</div>
<div slot="footer">
<Button danger={true} on:click={dispatchClose}>Cancel</Button>
<div style="margin-left: 12px">
<Button icon="fas fa-paper-plane" on:click={dispatchConfirm}>Submit</Button>
</div>
</div>
</Card>
</div>
</div>
<div class="modal-backdrop" transition:fade>
</div>
<svelte:window on:keydown={handleKeydown}/>
<script>
import {createEventDispatcher} from 'svelte';
import {fade} from 'svelte/transition'
import Card from "../Card.svelte";
import Button from "../Button.svelte";
import MultiPanelCreationForm from "./MultiPanelCreationForm.svelte";
export let guildId;
export let data;
export let channels = [];
export let panels = [];
const dispatch = createEventDispatcher();
function dispatchClose() {
dispatch('close', {});
}
// Dispatch with data
function dispatchConfirm() {
dispatch('confirm', data);
}
function handleKeydown(e) {
if (e.key === "Escape") {
dispatchClose();
}
}
</script>
<style>
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 501;
display: flex;
justify-content: center;
align-items: center;
}
.modal-wrapper {
display: flex;
width: 75%;
margin: 2% auto auto auto;
}
@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>

View File

@ -309,6 +309,10 @@
data.emote = data.emote; data.emote = data.emote;
if (!data.colour) {
data.colour = 0x2ECC71;
}
tempColour = intToColour(data.colour); tempColour = intToColour(data.colour);
} }

View File

@ -1,100 +0,0 @@
<div class="modal" transition:fade bind:this={modal}>
<div class="modal-wrapper">
<Card footer="{true}" footerRight="{true}" fill="{false}">
<span slot="title">Edit Panel</span>
<div slot="body" class="body-wrapper">
<PanelCreationForm {guildId} {channels} {roles} {emojis} {teams} {forms} {isPremium} bind:data={panel} seedDefault={false} />
</div>
<div slot="footer">
<Button danger={true} on:click={dispatchClose}>Cancel</Button>
<div style="margin-left: 12px">
<Button icon="fas fa-paper-plane" on:click={dispatchConfirm}>Submit</Button>
</div>
</div>
</Card>
</div>
</div>
<div class="modal-backdrop" transition:fade>
</div>
<svelte:window on:keydown={handleKeydown}/>
<script>
import {createEventDispatcher} from 'svelte';
import {fade} from 'svelte/transition'
import PanelCreationForm from "./PanelCreationForm.svelte";
import Card from "../Card.svelte";
import Button from "../Button.svelte";
export let modal;
export let guildId;
export let panel = {};
export let channels = [];
export let forms = [];
export let roles = [];
export let emojis = [];
export let teams = []
export let isPremium = false;
const dispatch = createEventDispatcher();
function dispatchClose() {
dispatch('close', {});
}
// Dispatch with data
function dispatchConfirm() {
let form_id = (panel.form_id === null || panel.form_id === "null") ? null : parseInt(panel.form_id);
let mapped = {...panel, form_id: form_id};
dispatch('confirm', mapped);
}
function handleKeydown(e) {
if (e.key === "Escape") {
dispatchClose();
}
}
</script>
<style>
.modal {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 501;
display: flex;
justify-content: center;
align-items: center;
}
.modal-wrapper {
display: flex;
width: 75%;
margin: 2% auto auto auto;
padding-bottom: 5%;
}
@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>

View File

@ -16,7 +16,7 @@
</NavElement> </NavElement>
{#if isAdmin} {#if isAdmin}
<NavElement icon="fas fa-mouse-pointer" link="/manage/{guildId}/panels" on:click={closeDropdown}>Reaction Panels</NavElement> <NavElement icon="fas fa-mouse-pointer" link="/manage/{guildId}/panels" on:click={closeDropdown}>Ticket Panels</NavElement>
<NavElement icon="fas fa-poll-h" link="/manage/{guildId}/forms" on:click={closeDropdown}>Forms</NavElement> <NavElement icon="fas fa-poll-h" link="/manage/{guildId}/forms" on:click={closeDropdown}>Forms</NavElement>
<NavElement icon="fas fa-users" link="/manage/{guildId}/teams" on:click={closeDropdown}>Staff Teams</NavElement> <NavElement icon="fas fa-users" link="/manage/{guildId}/teams" on:click={closeDropdown}>Staff Teams</NavElement>
<NavElement icon="fas fa-robot" link="/manage/{guildId}/integrations" on:click={closeDropdown}> <NavElement icon="fas fa-robot" link="/manage/{guildId}/integrations" on:click={closeDropdown}>

78
frontend/src/js/common.js Normal file
View File

@ -0,0 +1,78 @@
import axios from "axios";
import {API_URL} from "./constants";
export async function loadPremium(guildId, includeVoting = false) {
const res = await axios.get(`${API_URL}/api/${guildId}/premium?include_voting=${includeVoting}`);
if (res.status !== 200) {
throw new Error(`Failed to load premium status: ${res.data.error}`);
}
return res.data.premium;
}
export async function loadChannels(guildId) {
const res = await axios.get(`${API_URL}/api/${guildId}/channels`);
if (res.status !== 200) {
throw new Error(`Failed to load channels: ${res.data.error}`);
}
return res.data;
}
export async function loadPanels(guildId) {
const res = await axios.get(`${API_URL}/api/${guildId}/panels`);
if (res.status !== 200) {
throw new Error(`Failed to load panels: ${res.data.error}`);
}
// convert button_style and form_id to string
return res.data.map((p) => Object.assign({}, p, {
button_style: p.button_style.toString(),
form_id: p.form_id === null ? "null" : p.form_id
}));
}
export async function loadMultiPanels(guildId) {
const res = await axios.get(`${API_URL}/api/${guildId}/multipanels`);
if (res.status !== 200) {
throw new Error(`Failed to load multi-panels: ${res.data.error}`);
}
return res.data.data;
}
export async function loadTeams(guildId) {
const res = await axios.get(`${API_URL}/api/${guildId}/team`);
if (res.status !== 200) {
throw new Error(`Failed to load teams: ${res.data.error}`);
}
return res.data;
}
export async function loadRoles(guildId) {
const res = await axios.get(`${API_URL}/api/${guildId}/roles`);
if (res.status !== 200) {
throw new Error(`Failed to load roles: ${res.data.error}`);
}
return res.data.roles;
}
export async function loadEmojis(guildId) {
const res = await axios.get(`${API_URL}/api/${guildId}/emojis`);
if (res.status !== 200) {
throw new Error(`Failed to load emojis: ${res.data.error}`);
}
return res.data;
}
export async function loadForms(guildId) {
const res = await axios.get(`${API_URL}/api/${guildId}/forms`);
if (res.status !== 200) {
throw new Error(`Failed to load forms: ${res.data.error}`);
}
return res.data || [];
}

View File

@ -38,9 +38,59 @@ export function colourToInt(colour) {
} }
export function intToColour(i) { export function intToColour(i) {
return `#${i.toString(16)}` return `#${i.toString(16).padStart(6, '0')}`
} }
export function nullIfBlank(s) { export function nullIfBlank(s) {
return s === '' ? null : s; return s === '' ? null : s;
} }
export function setBlankStringsToNull(obj) {
// Set all blank strings in the object, including nested objects, to null
for (const key in obj) {
if (obj[key] === "" || obj[key] === "null") {
obj[key] = null;
} else if (typeof obj[key] === "object") {
setBlankStringsToNull(obj[key]);
}
}
}
export function removeBlankEmbedFields(obj) {
for (const key in obj) {
if (obj[key] === null || obj[key] === undefined) {
delete obj[key];
}
if (typeof obj[key] === "string" && obj[key] === "") {
delete obj[key];
}
if (typeof obj[key] === "object") {
removeBlankEmbedFields(obj[key]);
}
if (Array.isArray(obj[key]) && obj[key].length === 0) {
delete obj[key];
}
}
for (const key in obj) {
if (typeof obj[key] === "object" && Object.keys(obj[key]).length === 0) {
delete obj[key];
}
}
}
export function checkForParamAndRewrite(param) {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get(param) === "true") {
const newUrl = new URL(window.location.href);
newUrl.searchParams.delete(param);
window.history.pushState(null, '', newUrl.toString());
return true;
}
return false;
}

View File

@ -15,7 +15,7 @@ import Error404 from './views/Error404.svelte'
import Transcripts from './views/Transcripts.svelte' import Transcripts from './views/Transcripts.svelte'
import TranscriptView from './views/TranscriptView.svelte' import TranscriptView from './views/TranscriptView.svelte'
import Blacklist from './views/Blacklist.svelte' import Blacklist from './views/Blacklist.svelte'
import Panels from './views/Panels.svelte' import Panels from './views/panels/Panels.svelte'
import Tags from './views/Tags.svelte' import Tags from './views/Tags.svelte'
import Teams from './views/Teams.svelte' import Teams from './views/Teams.svelte'
import Tickets from './views/Tickets.svelte' import Tickets from './views/Tickets.svelte'
@ -30,6 +30,10 @@ import IntegrationCreate from "./views/integrations/Create.svelte";
import IntegrationConfigure from "./views/integrations/Configure.svelte"; import IntegrationConfigure from "./views/integrations/Configure.svelte";
import IntegrationActivate from "./views/integrations/Activate.svelte"; import IntegrationActivate from "./views/integrations/Activate.svelte";
import IntegrationManage from "./views/integrations/Manage.svelte"; import IntegrationManage from "./views/integrations/Manage.svelte";
import CreatePanel from "./views/panels/CreatePanel.svelte";
import CreateMultiPanel from "./views/panels/CreateMultiPanel.svelte";
import EditPanel from "./views/panels/EditPanel.svelte";
import EditMultiPanel from "./views/panels/EditMultiPanel.svelte";
export const routes = [ export const routes = [
{name: '/', component: Index, layout: IndexLayout}, {name: '/', component: Index, layout: IndexLayout},
@ -77,7 +81,36 @@ export const routes = [
} }
] ]
}, },
{name: 'panels', component: Panels, layout: ManageLayout}, {
name: 'panels',
nestedRoutes: [
{
name: 'index',
component: Panels,
layout: ManageLayout
},
{
name: 'create',
component: CreatePanel,
layout: ManageLayout
},
{
name: 'create-multi',
component: CreateMultiPanel,
layout: ManageLayout
},
{
name: 'edit/:panelid',
component: EditPanel,
layout: ManageLayout
},
{
name: 'edit-multi/:panelid',
component: EditMultiPanel,
layout: ManageLayout
}
]
},
{name: 'blacklist', component: Blacklist, layout: ManageLayout}, {name: 'blacklist', component: Blacklist, layout: ManageLayout},
{name: 'tags', component: Tags, layout: ManageLayout}, {name: 'tags', component: Tags, layout: ManageLayout},
{name: 'teams', component: Teams, layout: ManageLayout}, {name: 'teams', component: Teams, layout: ManageLayout},

View File

@ -1,490 +0,0 @@
{#if editModal}
<PanelEditModal bind:modal={editModalElement} {guildId} {channels} {roles} {emojis} {teams} {forms} {isPremium} bind:panel={editData}
on:close={() => editModal = false} on:confirm={submitEdit}/>
{/if}
{#if multiEditModal}
<MultiPanelEditModal {guildId} {channels} {panels} data={multiPanelEditData}
on:close={() => multiEditModal = false} on:confirm={submitMultiPanelEdit}/>
{/if}
{#if panelToDelete !== null}
<ConfirmationModal icon="fas fa-trash-can" isDangerous on:cancel={() => panelToDelete = null}
on:confirm={() => deletePanel(panelToDelete.panel_id)}>
<span slot="body">Are you sure you want to delete the panel {panelToDelete.title}?</span>
<span slot="confirm">Delete</span>
</ConfirmationModal>
{/if}
{#if multiPanelToDelete !== null}
<ConfirmationModal icon="fas fa-trash-can" isDangerous on:cancel={() => multiPanelToDelete = null}
on:confirm={() => deleteMultiPanel(multiPanelToDelete.id)}>
<span slot="body">Are you sure you want to delete the multi-panel {multiPanelToDelete.title}?</span>
<span slot="confirm">Delete</span>
</ConfirmationModal>
{/if}
<div class="wrapper">
<div class="col-main">
<div class="row">
<Card footer="{false}">
<span slot="title">Your Reaction Panels</span>
<div slot="body" class="card-body">
<p>Your panel quota: <b>{panels.length} / {isPremium ? '∞' : '3'}</b></p>
<table style="margin-top: 10px">
<thead>
<tr>
<th>Channel</th>
<th>Panel Title</th>
<th class="category-col">Ticket Channel Category</th>
<th>Resend</th>
<th>Edit</th>
<th>Delete</th>
</tr>
</thead>
<tbody>
{#each panels as panel}
<tr>
<td>#{channels.find((c) => c.id === panel.channel_id)?.name ?? 'Unknown Channel'}</td>
<td>{panel.title}</td>
<td class="category-col">{channels.find((c) => c.id === panel.category_id)?.name ?? 'Unknown Category'}</td>
<td>
<Button disabled={panel.force_disabled} on:click={() => resendPanel(panel.panel_id)}>Resend</Button>
</td>
<td>
<Button disabled={panel.force_disabled} on:click={() => openEditModal(panel.panel_id)}>Edit</Button>
</td>
<td>
<Button on:click={() => panelToDelete = panel}>Delete</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</Card>
</div>
<div class="row">
<Card footer="{false}">
<span slot="title">Create Panel</span>
<div slot="body" class="body-wrapper">
{#if !$loadingScreen}
<PanelCreationForm {guildId} {channels} {roles} {emojis} {teams} {forms} {isPremium} bind:data={panelCreateData}/>
<div style="display: flex; justify-content: center">
<div class="col-3">
<Button icon="fas fa-paper-plane" fullWidth={true} on:click={createPanel}>Submit</Button>
</div>
</div>
{/if}
</div>
</Card>
</div>
</div>
<div class="col-small">
<div class="row">
<Card footer="{false}">
<span slot="title">Your Multi-Panels</span>
<div slot="body" class="card-body">
<table style="margin-top: 10px">
<thead>
<tr>
<th>Embed Title</th>
<th>Resend</th>
<th>Edit</th>
<th>Delete</th>
</tr>
</thead>
<tbody>
{#each multiPanels as panel}
<tr>
<td>{panel.title}</td>
<td>
<Button on:click={() => resendMultiPanel(panel.id)}>Resend</Button>
</td>
<td>
<Button on:click={() => openMultiEditModal(panel.id)}>Edit</Button>
</td>
<td>
<Button on:click={() => multiPanelToDelete = panel}>Delete</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</Card>
</div>
<div class="row">
<Card footer={false}>
<span slot="title">Create Multi-Panel</span>
<div slot="body" class="card-body">
<p>Note: The panels which you wish to combine into a multi-panel must already exist</p>
{#if !$loadingScreen}
<div style="margin-top: 10px">
<MultiPanelCreationForm {guildId} {channels} {panels} bind:data={multiPanelCreateData}/>
<div style="display: flex; justify-content: center; margin-top: 2%">
<Button icon="fas fa-paper-plane" fullWidth={true} on:click={createMultiPanel}>Submit</Button>
</div>
</div>
{/if}
</div>
</Card>
</div>
</div>
</div>
<script>
import Card from "../components/Card.svelte";
import {notifyError, notifySuccess, withLoadingScreen} from "../js/util";
import {loadingScreen} from "../js/stores";
import axios from "axios";
import {API_URL} from "../js/constants";
import {setDefaultHeaders} from '../includes/Auth.svelte'
import Button from "../components/Button.svelte";
import PanelEditModal from "../components/manage/PanelEditModal.svelte";
import PanelCreationForm from "../components/manage/PanelCreationForm.svelte";
import MultiPanelCreationForm from '../components/manage/MultiPanelCreationForm.svelte';
import MultiPanelEditModal from "../components/manage/MultiPanelEditModal.svelte";
import ConfirmationModal from "../components/ConfirmationModal.svelte";
import {afterUpdate} from "svelte";
export let currentRoute;
export let params = {};
setDefaultHeaders()
let guildId = currentRoute.namedParams.id;
let channels = [];
let roles = [];
let emojis = [];
let teams = [];
let forms = [];
let panels = [];
let multiPanels = [];
let isPremium = false;
let editModal = false;
let multiEditModal = false;
let panelToDelete = null;
let multiPanelToDelete = null;
let panelCreateData;
let editData;
let multiPanelCreateData;
let multiPanelEditData;
let editModalElement;
function openEditModal(panelId) {
editData = panels.find((p) => p.panel_id === panelId);
editModal = true;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function openMultiEditModal(id) {
multiPanelEditData = multiPanels.find((mp) => mp.id === id);
multiEditModal = true;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function resendPanel(panelId) {
const res = await axios.post(`${API_URL}/api/${guildId}/panels/${panelId}`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
notifySuccess("Panel resent successfully");
}
async function deletePanel(panelId) {
const res = await axios.delete(`${API_URL}/api/${guildId}/panels/${panelId}`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
panels = panels.filter((p) => p.panel_id !== panelId);
panelToDelete = null;
}
async function resendMultiPanel(id) {
const res = await axios.post(`${API_URL}/api/${guildId}/multipanels/${id}`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
notifySuccess("Multipanel resent successfully")
}
async function deleteMultiPanel(id) {
const res = await axios.delete(`${API_URL}/api/${guildId}/multipanels/${id}`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
multiPanels = multiPanels.filter((p) => p.id !== id);
multiPanelToDelete = null;
}
async function createPanel() {
setBlankStringsToNull(panelCreateData);
const res = await axios.post(`${API_URL}/api/${guildId}/panels`, panelCreateData);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
await loadPanels();
notifySuccess('Panel created successfully');
}
async function submitEdit(e) {
let data = e.detail;
setBlankStringsToNull(data);
const res = await axios.patch(`${API_URL}/api/${guildId}/panels/${data.panel_id}`, data);
if (res.status !== 200) {
notifyError(res.data.error);
return;
} else {
editModal = false;
editData = {};
notifySuccess('Panel updated successfully');
}
await loadPanels();
}
function setBlankStringsToNull(obj) {
// Set all blank strings in the object, including nested objects, to null
for (const key in obj) {
if (obj[key] === "" || obj[key] === "null") {
obj[key] = null;
} else if (typeof obj[key] === "object") {
setBlankStringsToNull(obj[key]);
}
}
}
async function submitMultiPanelEdit(e) {
let data = e.detail;
const res = await axios.patch(`${API_URL}/api/${guildId}/multipanels/${data.id}`, data);
if (res.status !== 200) {
notifyError(res.data.error);
return;
} else {
multiEditModal = false;
multiPanelEditData = {};
notifySuccess('Multi-panel updated successfully');
}
await loadPanels();
}
async function createMultiPanel() {
const res = await axios.post(`${API_URL}/api/${guildId}/multipanels`, multiPanelCreateData);
if (res.status !== 200) {
notifyError(res.data.error);
} else {
notifySuccess('Multi-panel created successfully');
}
await loadMultiPanels();
}
async function loadPremium() {
const res = await axios.get(`${API_URL}/api/${guildId}/premium?include_voting=false`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
isPremium = res.data.premium;
}
async function loadChannels() {
const res = await axios.get(`${API_URL}/api/${guildId}/channels`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
channels = res.data;
}
async function loadPanels() {
const res = await axios.get(`${API_URL}/api/${guildId}/panels`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
// convert button_style and form_id to string
panels = res.data.map((p) => Object.assign({}, p, {
button_style: p.button_style.toString(),
form_id: p.form_id === null ? "null" : p.form_id
}));
}
async function loadMultiPanels() {
const res = await axios.get(`${API_URL}/api/${guildId}/multipanels`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
multiPanels = res.data.data;
}
async function loadTeams() {
const res = await axios.get(`${API_URL}/api/${guildId}/team`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
teams = res.data;
}
async function loadRoles() {
const res = await axios.get(`${API_URL}/api/${guildId}/roles`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
roles = res.data.roles;
}
async function loadEmojis() {
const res = await axios.get(`${API_URL}/api/${guildId}/emojis`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
emojis = res.data;
}
async function loadForms() {
const res = await axios.get(`${API_URL}/api/${guildId}/forms`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
forms = res.data || [];
}
withLoadingScreen(async () => {
await Promise.all([
loadPremium(),
loadChannels(),
loadTeams(),
loadForms(),
loadRoles(),
loadEmojis(),
loadPanels(),
loadMultiPanels()
]);
});
</script>
<style>
.wrapper {
display: flex;
flex-direction: row;
height: 100%;
width: 100%;
margin-top: 30px;
}
.body-wrapper {
display: flex;
flex-direction: column;
width: 100%;
}
.col-main {
display: flex;
flex-direction: column;
align-items: center;
width: 65%;
height: 100%;
}
.col-small {
display: flex;
flex-direction: column;
align-items: center;
width: 35%;
height: 100%;
}
.row {
display: flex;
width: 96%;
height: 100%;
margin-bottom: 2%;
}
.card-body {
width: 100%;
}
@media only screen and (max-width: 1100px) {
.wrapper {
flex-direction: column;
}
.col-main, .col-small {
width: 100%;
margin-bottom: 4%;
}
}
@media only screen and (max-width: 576px) {
.category-col {
display: none;
}
.row {
width: 100%;
}
}
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: left;
font-weight: normal;
border-bottom: 1px solid #dee2e6;
padding-left: 10px;
}
tr {
border-bottom: 1px solid #dee2e6;
}
tr:last-child {
border-bottom: none;
}
td {
padding: 10px 0 10px 10px;
}
</style>

View File

@ -0,0 +1,95 @@
<main>
<a href="/manage/{guildId}/panels" class="link">
<i class="fas fa-arrow-left"></i>
Back to Panels
</a>
<Card footer={false}>
<span slot="title">Create Multi-Panel</span>
<div slot="body" class="card-body">
<p>Note: The panels which you wish to combine into a multi-panel must already exist</p>
{#if !$loadingScreen}
<div style="margin-top: 10px">
<MultiPanelCreationForm {guildId} {channels} {panels} bind:data={multiPanelCreateData}/>
<div class="submit-wrapper">
<Button icon="fas fa-paper-plane" fullWidth={true} on:click={createMultiPanel}>Create
</Button>
</div>
</div>
{/if}
</div>
</Card>
</main>
<style>
main {
display: flex;
flex-direction: column;
padding: 2% 10% 4% 10%;
width: 100%;
row-gap: 1vh;
}
main > a {
display: flex;
align-items: center;
gap: 6px;
font-size: 18px;
}
.card-body {
display: flex;
flex-direction: column;
width: 100%;
}
.submit-wrapper {
margin: 1vh auto auto;
width: 30%;
}
</style>
<script>
import {loadingScreen} from "../../js/stores";
import MultiPanelCreationForm from "../../components/manage/MultiPanelCreationForm.svelte";
import Button from "../../components/Button.svelte";
import Card from "../../components/Card.svelte";
import {onMount} from "svelte";
import {notifyError, removeBlankEmbedFields, setBlankStringsToNull, withLoadingScreen} from "../../js/util";
import {loadChannels, loadPanels} from "../../js/common";
import axios from "axios";
import {API_URL} from "../../js/constants";
import {navigateTo} from "svelte-router-spa";
export let currentRoute;
let guildId = currentRoute.namedParams.id;
let channels = [];
let panels = [];
let multiPanelCreateData;
async function createMultiPanel() {
const data = structuredClone(multiPanelCreateData);
setBlankStringsToNull(data);
removeBlankEmbedFields(data);
const res = await axios.post(`${API_URL}/api/${guildId}/multipanels`, data);
if (res.status !== 200) {
notifyError(res.data.error);
} else {
navigateTo(`/manage/${guildId}/panels?created=true`)
}
}
onMount(async () => {
await withLoadingScreen(async () => {
await Promise.all([
loadChannels(guildId).then(r => channels = r).catch(e => notifyError(e)),
loadPanels(guildId).then(r => panels = r).catch(e => notifyError(e)),
])
});
});
</script>

View File

@ -0,0 +1,99 @@
<main>
<a href="/manage/{guildId}/panels" class="link">
<i class="fas fa-arrow-left"></i>
Back to Panels
</a>
<Card footer="{false}">
<span slot="title">Create Panel</span>
<div slot="body" class="body-wrapper">
{#if !$loadingScreen}
<PanelCreationForm {guildId} {channels} {roles} {emojis} {teams} {forms} {isPremium}
bind:data={panelCreateData}/>
<div class="submit-wrapper">
<Button icon="fas fa-paper-plane" fullWidth={true} on:click={createPanel}>Create</Button>
</div>
{/if}
</div>
</Card>
</main>
<style>
main {
display: flex;
flex-direction: column;
padding: 2% 10% 4% 10%;
width: 100%;
row-gap: 1vh;
}
main > a {
display: flex;
align-items: center;
gap: 6px;
font-size: 18px;
}
.body-wrapper {
display: flex;
flex-direction: column;
}
.submit-wrapper {
margin: 1vh auto auto;
width: 30%;
}
</style>
<script>
import {loadingScreen} from "../../js/stores";
import Button from "../../components/Button.svelte";
import Card from "../../components/Card.svelte";
import PanelCreationForm from "../../components/manage/PanelCreationForm.svelte";
import {setDefaultHeaders} from '../../includes/Auth.svelte'
import {notifyError, setBlankStringsToNull, withLoadingScreen} from "../../js/util";
import {onMount} from "svelte";
import {loadChannels, loadEmojis, loadForms, loadPremium, loadRoles, loadTeams} from "../../js/common";
import axios from "axios";
import {API_URL} from "../../js/constants";
import {Navigate, navigateTo} from "svelte-router-spa";
setDefaultHeaders();
export let currentRoute;
let guildId = currentRoute.namedParams.id;
let channels = [];
let roles = [];
let emojis = [];
let teams = [];
let forms = [];
let isPremium = false;
let panelCreateData;
async function createPanel() {
setBlankStringsToNull(panelCreateData);
const res = await axios.post(`${API_URL}/api/${guildId}/panels`, panelCreateData);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
navigateTo(`/manage/${guildId}/panels?created=true`);
}
onMount(async () => {
await withLoadingScreen(async () => {
await Promise.all([
loadChannels(guildId).then(r => channels = r).catch(e => notifyError(e)),
loadRoles(guildId).then(r => roles = r).catch(e => notifyError(e)),
loadEmojis(guildId).then(r => emojis = r).catch(e => notifyError(e)),
loadTeams(guildId).then(r => teams = r).catch(e => notifyError(e)),
loadForms(guildId).then(r => forms = r).catch(e => notifyError(e)),
loadPremium(guildId, false).then(r => isPremium = r).catch(e => notifyError(e)),
])
});
});
</script>

View File

@ -0,0 +1,104 @@
<main>
<a href="/manage/{guildId}/panels" class="link">
<i class="fas fa-arrow-left"></i>
Back to Panels
</a>
<Card footer={false}>
<span slot="title">Create Multi-Panel</span>
<div slot="body" class="card-body">
<p>Note: The panels which you wish to combine into a multi-panel must already exist</p>
{#if multiPanelData && !$loadingScreen}
<div style="margin-top: 10px">
<MultiPanelCreationForm {guildId} {channels} {panels} bind:data={multiPanelData} seedDefault={false} />
<div class="submit-wrapper">
<Button icon="fas fa-floppy-disk" fullWidth={true} on:click={editMultiPanel}>Save
</Button>
</div>
</div>
{/if}
</div>
</Card>
</main>
<style>
main {
display: flex;
flex-direction: column;
padding: 2% 10% 4% 10%;
width: 100%;
row-gap: 1vh;
}
main > a {
display: flex;
align-items: center;
gap: 6px;
font-size: 18px;
}
.card-body {
display: flex;
flex-direction: column;
width: 100%;
}
.submit-wrapper {
margin: 1vh auto auto;
width: 30%;
}
</style>
<script>
import {loadingScreen} from "../../js/stores";
import MultiPanelCreationForm from "../../components/manage/MultiPanelCreationForm.svelte";
import Button from "../../components/Button.svelte";
import Card from "../../components/Card.svelte";
import {onMount} from "svelte";
import {notifyError, removeBlankEmbedFields, setBlankStringsToNull, withLoadingScreen} from "../../js/util";
import {loadChannels, loadPanels, loadMultiPanels} from "../../js/common";
import axios from "axios";
import {API_URL} from "../../js/constants";
import {navigateTo} from "svelte-router-spa";
export let currentRoute;
let guildId = currentRoute.namedParams.id;
let multiPanelId = parseInt(currentRoute.namedParams.panelid);
let channels = [];
let panels = [];
let multiPanelData;
async function editMultiPanel() {
const data = structuredClone(multiPanelData);
setBlankStringsToNull(data);
removeBlankEmbedFields(data);
const res = await axios.patch(`${API_URL}/api/${guildId}/multipanels/${multiPanelId}`, data);
if (res.status !== 200) {
notifyError(res.data.error);
} else {
navigateTo(`/manage/${guildId}/panels?edited=true`)
}
}
onMount(async () => {
await withLoadingScreen(async () => {
let multiPanels = [];
await Promise.all([
loadChannels(guildId).then(r => channels = r).catch(e => notifyError(e)),
loadPanels(guildId).then(r => panels = r).catch(e => notifyError(e)),
loadMultiPanels(guildId).then(r => multiPanels = r).catch(e => notifyError(e))
]);
multiPanelData = multiPanels.find(mp => mp.id === multiPanelId);
if (!multiPanelData) {
navigateTo(`/manage/${guildId}/panels?notfound=true`)
}
});
});
</script>

View File

@ -0,0 +1,108 @@
<main>
<a href="/manage/{guildId}/panels" class="link">
<i class="fas fa-arrow-left"></i>
Back to Panels
</a>
<Card footer="{false}">
<span slot="title">Create Panel</span>
<div slot="body" class="body-wrapper">
{#if !$loadingScreen}
<PanelCreationForm {guildId} {channels} {roles} {emojis} {teams} {forms} {isPremium}
bind:data={panelData} seedDefault={false} />
<div class="submit-wrapper">
<Button icon="fas fa-floppy-disk" fullWidth={true} on:click={editPanel}>Save</Button>
</div>
{/if}
</div>
</Card>
</main>
<style>
main {
display: flex;
flex-direction: column;
padding: 2% 10% 4% 10%;
width: 100%;
row-gap: 1vh;
}
main > a {
display: flex;
align-items: center;
gap: 6px;
font-size: 18px;
}
.body-wrapper {
display: flex;
flex-direction: column;
}
.submit-wrapper {
margin: 1vh auto auto;
width: 30%;
}
</style>
<script>
import {loadingScreen} from "../../js/stores";
import Button from "../../components/Button.svelte";
import Card from "../../components/Card.svelte";
import PanelCreationForm from "../../components/manage/PanelCreationForm.svelte";
import {setDefaultHeaders} from '../../includes/Auth.svelte'
import {notifyError, notifySuccess, setBlankStringsToNull, withLoadingScreen} from "../../js/util";
import {onMount} from "svelte";
import {loadChannels, loadEmojis, loadForms, loadPanels, loadPremium, loadRoles, loadTeams} from "../../js/common";
import axios from "axios";
import {API_URL} from "../../js/constants";
import {Navigate, navigateTo} from "svelte-router-spa";
setDefaultHeaders();
export let currentRoute;
let guildId = currentRoute.namedParams.id;
let panelId = parseInt(currentRoute.namedParams.panelid);
let channels = [];
let roles = [];
let emojis = [];
let teams = [];
let forms = [];
let isPremium = false;
let panelData;
async function editPanel() {
setBlankStringsToNull(panelData);
const res = await axios.patch(`${API_URL}/api/${guildId}/panels/${panelId}`, panelData);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
navigateTo(`/manage/${guildId}/panels?edited=true`);
}
onMount(async () => {
await withLoadingScreen(async () => {
let panels = [];
await Promise.all([
loadChannels(guildId).then(r => channels = r).catch(e => notifyError(e)),
loadRoles(guildId).then(r => roles = r).catch(e => notifyError(e)),
loadEmojis(guildId).then(r => emojis = r).catch(e => notifyError(e)),
loadTeams(guildId).then(r => teams = r).catch(e => notifyError(e)),
loadForms(guildId).then(r => forms = r).catch(e => notifyError(e)),
loadPremium(guildId, false).then(r => isPremium = r).catch(e => notifyError(e)),
loadPanels(guildId).then(r => panels = r).catch(e => notifyError(e))
]);
panelData = panels.find(p => p.panel_id === panelId);
if (!panelData) {
navigateTo(`/manage/${guildId}/panels?notfound=true`);
}
});
});
</script>

View File

@ -0,0 +1,295 @@
{#if panelToDelete !== null}
<ConfirmationModal icon="fas fa-trash-can" isDangerous on:cancel={() => panelToDelete = null}
on:confirm={() => deletePanel(panelToDelete.panel_id)}>
<span slot="body">Are you sure you want to delete the panel {panelToDelete.title}?</span>
<span slot="confirm">Delete</span>
</ConfirmationModal>
{/if}
{#if multiPanelToDelete !== null}
<ConfirmationModal icon="fas fa-trash-can" isDangerous on:cancel={() => multiPanelToDelete = null}
on:confirm={() => deleteMultiPanel(multiPanelToDelete.id)}>
<span slot="body">Are you sure you want to delete the multi-panel
{multiPanelToDelete.embed?.title || "Open a ticket!"}?</span>
<span slot="confirm">Delete</span>
</ConfirmationModal>
{/if}
<div class="wrapper">
<div class="col">
<div class="row">
<Card footer="{false}">
<span slot="title">Ticket Panels</span>
<div slot="body" class="card-body panels">
<div class="controls">
<p>Your panel quota: <b>{panels.length} / {isPremium ? '∞' : '3'}</b></p>
<Navigate to="/manage/{guildId}/panels/create" styles="link">
<Button icon="fas fa-plus">New Panel</Button>
</Navigate>
</div>
<table style="margin-top: 10px">
<thead>
<tr>
<th>Channel</th>
<th class="max">Panel Title</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{#each panels as panel}
<tr>
<td>#{channels.find((c) => c.id === panel.channel_id)?.name ?? 'Unknown Channel'}</td>
<td class="max">{panel.title}</td>
<td>
<Button disabled={panel.force_disabled}
on:click={() => resendPanel(panel.panel_id)}>Resend
</Button>
</td>
<td>
<Navigate to="/manage/{guildId}/panels/edit/{panel.panel_id}" styles="link">
<Button disabled={panel.force_disabled}>Edit</Button>
</Navigate>
</td>
<td>
<Button danger on:click={() => panelToDelete = panel}>Delete</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</Card>
</div>
</div>
<div class="col">
<div class="row">
<Card footer="{false}">
<span slot="title">Multi-Panels</span>
<div slot="body" class="card-body">
<div class="controls">
<Navigate to="/manage/{guildId}/panels/create-multi" styles="link">
<Button icon="fas fa-plus">New Multi-Panel</Button>
</Navigate>
</div>
<table style="margin-top: 10px">
<thead>
<tr>
<th class="max">Panel Title</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{#each multiPanels as panel}
<tr>
<td class="max">{panel.title || 'Open a ticket!'}</td>
<td>
<Button on:click={() => resendMultiPanel(panel.id)}>Resend</Button>
</td>
<td>
<Navigate to="/manage/{guildId}/panels/edit-multi/{panel.id}" styles="link">
<Button>Edit</Button>
</Navigate>
</td>
<td>
<Button danger on:click={() => multiPanelToDelete = panel}>Delete</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</Card>
</div>
<div class="row">
</div>
</div>
</div>
<script>
import Card from "../../components/Card.svelte";
import {checkForParamAndRewrite, notifyError, notifySuccess, withLoadingScreen} from "../../js/util";
import axios from "axios";
import {API_URL} from "../../js/constants";
import {setDefaultHeaders} from '../../includes/Auth.svelte'
import Button from "../../components/Button.svelte";
import ConfirmationModal from "../../components/ConfirmationModal.svelte";
import {Navigate} from "svelte-router-spa";
import {loadChannels, loadMultiPanels, loadPanels, loadPremium} from "../../js/common";
export let currentRoute;
setDefaultHeaders()
let guildId = currentRoute.namedParams.id;
let channels = [];
let panels = [];
let multiPanels = [];
let isPremium = false;
let panelToDelete = null;
let multiPanelToDelete = null;
async function resendPanel(panelId) {
const res = await axios.post(`${API_URL}/api/${guildId}/panels/${panelId}`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
notifySuccess("Panel resent successfully");
}
async function deletePanel(panelId) {
const res = await axios.delete(`${API_URL}/api/${guildId}/panels/${panelId}`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
panels = panels.filter((p) => p.panel_id !== panelId);
panelToDelete = null;
}
async function resendMultiPanel(id) {
const res = await axios.post(`${API_URL}/api/${guildId}/multipanels/${id}`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
notifySuccess("Multipanel resent successfully")
}
async function deleteMultiPanel(id) {
const res = await axios.delete(`${API_URL}/api/${guildId}/multipanels/${id}`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
multiPanels = multiPanels.filter((p) => p.id !== id);
multiPanelToDelete = null;
}
withLoadingScreen(async () => {
await Promise.all([
loadChannels(guildId).then(r => channels = r).catch(e => notifyError(e)),
loadPremium(guildId, false).then(r => isPremium = r).catch(e => notifyError(e)),
loadPanels(guildId).then(r => panels = r).catch(e => notifyError(e)),
loadMultiPanels(guildId).then(r => multiPanels = r).catch(e => notifyError(e))
])
if (checkForParamAndRewrite("created")) {
notifySuccess("Panel created successfully");
}
if (checkForParamAndRewrite("edited")) {
notifySuccess("Panel edited successfully");
}
if (checkForParamAndRewrite("notfound")) {
notifyError("Panel not found");
}
});
</script>
<style>
.wrapper {
display: flex;
flex-direction: row;
height: 100%;
width: 100%;
margin-top: 30px;
}
.col {
display: flex;
flex-direction: column;
align-items: center;
width: 50%;
}
.row {
display: flex;
width: 96%;
margin-bottom: 2%;
}
.card-body {
width: 100%;
}
.card-body.panels {
display: flex;
flex-direction: column;
row-gap: 4%;
}
.card-body > .controls {
display: flex;
justify-content: right;
align-items: center;
gap: 2%;
}
.card-body.panels > .controls {
justify-content: space-between;
}
@media only screen and (max-width: 1100px) {
.wrapper {
flex-direction: column;
}
.col {
width: 100%;
}
}
@media only screen and (max-width: 576px) {
.row {
width: 100%;
}
}
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: left;
font-weight: normal;
border-bottom: 1px solid #dee2e6;
padding-left: 10px;
}
tr {
border-bottom: 1px solid #dee2e6;
}
tr:last-child {
border-bottom: none;
}
td {
padding: 10px;
}
th {
padding: 0 10px;
}
th:not(.max), td:not(.max) {
width: 0;
white-space: nowrap;
}
</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-20240710005307-9cc26f78d8e3 github.com/TicketsBot/common v0.0.0-20240710005307-9cc26f78d8e3
github.com/TicketsBot/database v0.0.0-20240720222825-35466fd5fc96 github.com/TicketsBot/database v0.0.0-20240729222446-2c671b9b9366
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-20240720223640-84817ecc3309 github.com/TicketsBot/worker v0.0.0-20240720223640-84817ecc3309
github.com/apex/log v1.1.2 github.com/apex/log v1.1.2

2
go.sum
View File

@ -54,6 +54,8 @@ github.com/TicketsBot/database v0.0.0-20240622123318-f2e43bd962cb/go.mod h1:gAtO
github.com/TicketsBot/database v0.0.0-20240715172214-86ee61a85834/go.mod h1:gAtOoQKZfCkQ4AoNWQUSl51Fnlqk+odzD/hZ1e1sXyI= github.com/TicketsBot/database v0.0.0-20240715172214-86ee61a85834/go.mod h1:gAtOoQKZfCkQ4AoNWQUSl51Fnlqk+odzD/hZ1e1sXyI=
github.com/TicketsBot/database v0.0.0-20240720222825-35466fd5fc96 h1:iOf8LxG3y7v5d5FErpc2i9gRzYN5wJzbd63iBTIllYY= github.com/TicketsBot/database v0.0.0-20240720222825-35466fd5fc96 h1:iOf8LxG3y7v5d5FErpc2i9gRzYN5wJzbd63iBTIllYY=
github.com/TicketsBot/database v0.0.0-20240720222825-35466fd5fc96/go.mod h1:gAtOoQKZfCkQ4AoNWQUSl51Fnlqk+odzD/hZ1e1sXyI= github.com/TicketsBot/database v0.0.0-20240720222825-35466fd5fc96/go.mod h1:gAtOoQKZfCkQ4AoNWQUSl51Fnlqk+odzD/hZ1e1sXyI=
github.com/TicketsBot/database v0.0.0-20240729222446-2c671b9b9366 h1:KC7NWVXk8xX2M4rQUVoHTuTjkXumGIDRrUS741lfcD4=
github.com/TicketsBot/database v0.0.0-20240729222446-2c671b9b9366/go.mod h1:gAtOoQKZfCkQ4AoNWQUSl51Fnlqk+odzD/hZ1e1sXyI=
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=

View File

@ -17,6 +17,12 @@ func (c Colour) MarshalJSON() ([]byte, error) {
} }
func (c *Colour) UnmarshalJSON(b []byte) error { func (c *Colour) UnmarshalJSON(b []byte) error {
// Try to parse as int first
if parsed, err := strconv.ParseUint(string(b), 10, 32); err == nil {
*c = Colour(parsed)
return nil
}
if len(b) < 2 { if len(b) < 2 {
return fmt.Errorf("invalid colour") return fmt.Errorf("invalid colour")
} }