Migrate to svelte for frontend (#9)

* Svelte: WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* Finished

* Remove redundant code

* Fix typo

* Re-add routes

* Form margin

* Mobile nicities

* Mobile changed

* Increase keepalvie

* Update Guild.svelte

* Update Whitelabel.svelte

* Whitelabel changes
This commit is contained in:
Ryan 2021-06-30 15:40:55 +01:00 committed by GitHub
parent 4aa0c773dd
commit aed0f28f13
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
149 changed files with 7586 additions and 9092 deletions

View File

@ -2,20 +2,20 @@ package api
import (
"context"
"fmt"
"github.com/TicketsBot/GoPanel/database"
"github.com/TicketsBot/GoPanel/rpc/cache"
"github.com/gin-gonic/gin"
"github.com/rxdn/gdl/objects/user"
"golang.org/x/sync/errgroup"
"strconv"
"sync"
)
type userData struct {
UserId uint64 `json:"id,string"`
Username string `json:"username"`
Discriminator string `json:"discriminator"`
Discriminator user.Discriminator `json:"discriminator"`
}
// TODO: Paginate
func GetBlacklistHandler(ctx *gin.Context) {
guildId := ctx.Keys["guildid"].(uint64)
@ -28,24 +28,26 @@ func GetBlacklistHandler(ctx *gin.Context) {
return
}
data := make(map[string]userData)
var lock sync.Mutex
data := make([]userData, len(blacklistedUsers))
group, _ := errgroup.WithContext(context.Background())
for _, userId := range blacklistedUsers {
for i, userId := range blacklistedUsers {
i := i
userId := userId
// TODO: Mass lookup
group.Go(func() error {
user, _ := cache.Instance.GetUser(userId)
lock.Lock()
// JS cant do big ints
data[strconv.FormatUint(userId, 10)] = userData{
Username: user.Username,
Discriminator: fmt.Sprintf("%04d", user.Discriminator),
userData := userData{
UserId: userId,
}
lock.Unlock()
user, ok := cache.Instance.GetUser(userId)
if ok {
userData.Username = user.Username
userData.Discriminator = user.Discriminator
}
data[i] = userData
return nil
})
}

View File

@ -1,64 +1,37 @@
package api
import (
"context"
"errors"
"fmt"
"github.com/TicketsBot/GoPanel/database"
"github.com/TicketsBot/GoPanel/rpc/cache"
"github.com/TicketsBot/GoPanel/utils"
"github.com/TicketsBot/common/permission"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v4"
"strconv"
)
func AddBlacklistHandler(ctx *gin.Context) {
guildId := ctx.Keys["guildid"].(uint64)
var data userData
if err := ctx.BindJSON(&data); err != nil {
ctx.AbortWithStatusJSON(400, gin.H{
"success": false,
"error": err.Error(),
})
return
}
parsedDiscrim, err := strconv.ParseInt(data.Discriminator, 10, 16)
id, err := strconv.ParseUint(ctx.Param("user"), 10, 64)
if err != nil {
ctx.AbortWithStatusJSON(400, gin.H{
"success": false,
"error": err.Error(),
})
ctx.JSON(400, utils.ErrorJson(err))
return
}
var targetId uint64
if err := cache.Instance.QueryRow(context.Background(), `select users.user_id from "users" where LOWER(users.data->>'Username')=LOWER($1) AND users.data->>'Discriminator'=$2;`, data.Username, strconv.FormatInt(parsedDiscrim, 10)).Scan(&targetId); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
ctx.AbortWithStatusJSON(404, gin.H{
"success": false,
"error": "user not found",
})
} else {
fmt.Println(err.Error())
ctx.AbortWithStatusJSON(500, gin.H{
"success": false,
"error": err.Error(),
})
}
permLevel, err := utils.GetPermissionLevel(guildId, id)
if err != nil {
ctx.JSON(500, utils.ErrorJson(err))
return
}
// TODO: Don't blacklist staff or guild owner
if err = database.Client.Blacklist.Add(guildId, targetId); err == nil {
ctx.JSON(200, gin.H{
"success": true,
"user_id": strconv.FormatUint(targetId, 10),
})
} else {
ctx.JSON(500, gin.H{
"success": false,
"error": err.Error(),
})
if permLevel > permission.Everyone {
ctx.JSON(400, utils.ErrorStr("You cannot blacklist staff members!"))
return
}
if err = database.Client.Blacklist.Add(guildId, id); err != nil {
ctx.JSON(500, utils.ErrorJson(err))
return
}
ctx.JSON(200, utils.SuccessResponse)
}

View File

@ -1,128 +0,0 @@
package api
import (
"context"
"fmt"
"github.com/TicketsBot/GoPanel/botcontext"
dbclient "github.com/TicketsBot/GoPanel/database"
"github.com/TicketsBot/GoPanel/rpc/cache"
"github.com/TicketsBot/GoPanel/utils"
"github.com/TicketsBot/database"
"github.com/apex/log"
"github.com/gin-gonic/gin"
"github.com/rxdn/gdl/rest"
"strconv"
)
const (
pageLimit = 30
)
func GetLogs(ctx *gin.Context) {
guildId := ctx.Keys["guildid"].(uint64)
botContext, err := botcontext.ContextForGuild(guildId)
if err != nil {
ctx.AbortWithStatusJSON(500, gin.H{
"success": false,
"error": err.Error(),
})
return
}
before, err := strconv.Atoi(ctx.Query("before"))
if before < 0 {
before = 0
}
// Get ticket ID from URL
var ticketId int
if utils.IsInt(ctx.Query("ticketid")) {
ticketId, _ = strconv.Atoi(ctx.Query("ticketid"))
}
var tickets []database.Ticket
// Get tickets from DB
if ticketId > 0 {
ticket, err := dbclient.Client.Tickets.Get(ticketId, guildId)
if err != nil {
ctx.AbortWithStatusJSON(500, gin.H{
"success": false,
"error": err.Error(),
})
return
}
if ticket.UserId != 0 && !ticket.Open {
tickets = append(tickets, ticket)
}
} else {
// make slice of user IDs to filter by
filteredIds := make([]uint64, 0)
// Add userid param to slice
filteredUserId, _ := strconv.ParseUint(ctx.Query("userid"), 10, 64)
if filteredUserId != 0 {
filteredIds = append(filteredIds, filteredUserId)
}
// Get username from URL
if username := ctx.Query("username"); username != "" {
// username -> user id
rows, err := cache.Instance.PgCache.Query(context.Background(), `select users.user_id from users where LOWER("data"->>'Username') LIKE LOWER($1) and exists(SELECT FROM members where members.guild_id=$2);`, fmt.Sprintf("%%%s%%", username), guildId)
defer rows.Close()
if err != nil {
log.Error(err.Error())
return
}
for rows.Next() {
var filteredId uint64
if err := rows.Scan(&filteredId); err != nil {
continue
}
if filteredId != 0 {
filteredIds = append(filteredIds, filteredId)
}
}
}
if ctx.Query("userid") != "" || ctx.Query("username") != "" {
tickets, err = dbclient.Client.Tickets.GetMemberClosedTickets(guildId, filteredIds, pageLimit, before)
} else {
tickets, err = dbclient.Client.Tickets.GetGuildClosedTickets(guildId, pageLimit, before)
}
if err != nil {
ctx.AbortWithStatusJSON(500, gin.H{
"success": false,
"error": err.Error(),
})
return
}
}
// Select 30 logs + format them
formattedLogs := make([]map[string]interface{}, 0)
for _, ticket := range tickets {
// get username
user, found := cache.Instance.GetUser(ticket.UserId)
if !found {
user, err = rest.GetUser(botContext.Token, botContext.RateLimiter, ticket.UserId)
if err != nil {
log.Error(err.Error())
}
go cache.Instance.StoreUser(user)
}
formattedLogs = append(formattedLogs, map[string]interface{}{
"ticketid": ticket.Id,
"userid": strconv.FormatUint(ticket.UserId, 10),
"username": user.Username,
})
}
ctx.JSON(200, formattedLogs)
}

View File

@ -121,16 +121,22 @@ func (d *multiPanelCreateData) doValidations(guildId uint64) (panels []database.
}
func (d *multiPanelCreateData) validateTitle() (err error) {
if len(d.Title) > 255 || len(d.Title) < 1 {
err = errors.New("embed title must be between 1 and 255 characters")
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) > 1024 || len(d.Title) < 1 {
err = errors.New("embed content must be between 1 and 1024 characters")
if len(d.Content) > 1024 {
err = errors.New("Embed content must be between 1 and 1024 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
}

View File

@ -12,7 +12,7 @@ import (
func MultiPanelList(ctx *gin.Context) {
type multiPanelResponse struct {
database.MultiPanel
Panels []database.Panel `json:"panels"`
Panels []int `json:"panels"`
}
guildId := ctx.Keys["guildid"].(uint64)
@ -33,13 +33,20 @@ func MultiPanelList(ctx *gin.Context) {
MultiPanel: multiPanel,
}
// TODO: Use a join
group.Go(func() error {
panels, err := dbclient.Client.MultiPanelTargets.GetPanels(multiPanel.Id)
if err != nil {
return err
}
data[i].Panels = panels
panelIds := make([]int, len(panels))
for i, panel := range panels {
panelIds[i] = panel.PanelId
}
data[i].Panels = panelIds
return nil
})
}

View File

@ -35,7 +35,8 @@ type panelBody struct {
Emote string `json:"emote"`
WelcomeMessage *string `json:"welcome_message"`
Mentions []string `json:"mentions"`
Teams []string `json:"teams"`
WithDefaultTeam bool `json:"default_team"`
Teams []database.SupportTeam `json:"teams"`
}
func CreatePanel(ctx *gin.Context) {
@ -120,7 +121,7 @@ func CreatePanel(ctx *gin.Context) {
TargetCategory: data.CategoryId,
ReactionEmote: emoji,
WelcomeMessage: data.WelcomeMessage,
WithDefaultTeam: utils.ContainsString(data.Teams, "default"),
WithDefaultTeam: data.WithDefaultTeam,
CustomId: customId,
}
@ -179,31 +180,22 @@ func CreatePanel(ctx *gin.Context) {
}
// returns (response_code, error)
func insertTeams(guildId uint64, panelId int, teamIds []string) (int, error) {
func insertTeams(guildId uint64, panelId int, teams []database.SupportTeam) (int, error) {
// insert teams
group, _ := errgroup.WithContext(context.Background())
for _, teamId := range teamIds {
if teamId == "default" {
continue // already handled
}
teamId, err := strconv.Atoi(teamId)
if err != nil {
return 400, err
}
for _, team := range teams {
group.Go(func() error {
// ensure team exists
exists, err := dbclient.Client.SupportTeam.Exists(teamId, guildId)
exists, err := dbclient.Client.SupportTeam.Exists(team.Id, guildId)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("team with id %d not found", teamId)
return fmt.Errorf("team with id %d not found", team.Id)
}
return dbclient.Client.PanelTeams.Add(panelId, teamId)
return dbclient.Client.PanelTeams.Add(panelId, team.Id)
})
}
@ -266,11 +258,23 @@ func (p *panelBody) doValidations(ctx *gin.Context, guildId uint64) bool {
}
func (p *panelBody) verifyTitle() bool {
return len(p.Title) > 0 && len(p.Title) <= 80
if len(p.Title) > 80 {
return false
} else if len(p.Title) == 0 { // Fill default
p.Title = "Open a ticket!"
}
return true
}
func (p *panelBody) verifyContent() bool {
return len(p.Content) > 0 && len(p.Content) < 1025
if len(p.Content) > 1024 {
return false
} else if len(p.Content) == 0 { // Fill default
p.Content = "By clicking the button, a ticket will be opened for you."
}
return true
}
func (p *panelBody) getEmoji() (emoji string, ok bool) {

View File

@ -1,7 +1,6 @@
package api
import (
"context"
"errors"
"github.com/TicketsBot/GoPanel/botcontext"
dbclient "github.com/TicketsBot/GoPanel/database"
@ -12,9 +11,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/rxdn/gdl/rest"
"github.com/rxdn/gdl/rest/request"
"golang.org/x/sync/errgroup"
"strconv"
"sync"
)
func UpdatePanel(ctx *gin.Context) {
@ -67,50 +64,41 @@ func UpdatePanel(ctx *gin.Context) {
return
}
var wouldHaveDuplicateEmote bool
premiumTier := rpc.PremiumClient.GetTierByGuildId(guildId, true, botContext.Token, botContext.RateLimiter)
{
var duplicateLock sync.Mutex
group, _ := errgroup.WithContext(context.Background())
for _, multiPanelId := range multiPanels {
multiPanelId := multiPanelId
group.Go(func() error {
// get the sub-panels of the multi-panel
subPanels, err := dbclient.Client.MultiPanelTargets.GetPanels(multiPanelId)
for _, multiPanel := range multiPanels {
panels, err := dbclient.Client.MultiPanelTargets.GetPanels(multiPanel.Id)
if err != nil {
return err
}
for _, subPanel := range subPanels {
if subPanel.MessageId == existing.MessageId {
continue
}
if subPanel.ReactionEmote == data.Emote {
duplicateLock.Lock()
wouldHaveDuplicateEmote = true
duplicateLock.Unlock()
break
}
}
return nil
})
}
if err := group.Wait(); err != nil {
ctx.JSON(500, utils.ErrorJson(err))
return
}
// TODO: Optimise this
panelIds := make([]int, len(panels))
for i, panel := range panels {
panelIds[i] = panel.PanelId
}
if wouldHaveDuplicateEmote {
ctx.JSON(400, utils.ErrorJson(errors.New("Changing the reaction emote to this value would cause a conflict in a multi-panel")))
data := multiPanelCreateData{
Title: multiPanel.Title,
Content: multiPanel.Content,
Colour: int32(multiPanel.Colour),
ChannelId: multiPanel.ChannelId,
Panels: panelIds,
}
messageId, err := data.sendEmbed(&botContext, premiumTier > premium.None, panels)
if err != nil {
ctx.JSON(500, utils.ErrorJson(err))
return
}
if err := dbclient.Client.MultiPanels.UpdateMessageId(multiPanel.Id, messageId); err != nil {
ctx.JSON(500, utils.ErrorJson(err))
return
}
}
// check if we need to update the message
shouldUpdateMessage := uint32(existing.Colour) != data.Colour ||
existing.ChannelId != data.ChannelId ||
@ -125,7 +113,6 @@ func UpdatePanel(ctx *gin.Context) {
// delete old message, ignoring error
_ = rest.DeleteMessage(botContext.Token, botContext.RateLimiter, existing.ChannelId, existing.MessageId)
premiumTier := rpc.PremiumClient.GetTierByGuildId(guildId, true, botContext.Token, botContext.RateLimiter)
newMessageId, err = data.sendEmbed(&botContext, existing.Title, existing.CustomId, existing.ReactionEmote, premiumTier > premium.None)
if err != nil {
var unwrapped request.RestError
@ -155,7 +142,7 @@ func UpdatePanel(ctx *gin.Context) {
TargetCategory: data.CategoryId,
ReactionEmote: emoji,
WelcomeMessage: data.WelcomeMessage,
WithDefaultTeam: utils.ContainsString(data.Teams, "default"),
WithDefaultTeam: data.WithDefaultTeam,
CustomId: existing.CustomId,
}

View File

@ -23,5 +23,6 @@ func PremiumHandler(ctx *gin.Context) {
ctx.JSON(200, gin.H{
"premium": premiumTier >= premium.Premium,
"tier": premiumTier,
})
}

View File

@ -2,10 +2,10 @@ package api
import (
"fmt"
"github.com/TicketsBot/GoPanel/app/http/session"
"github.com/TicketsBot/GoPanel/messagequeue"
"github.com/TicketsBot/GoPanel/utils"
"github.com/TicketsBot/GoPanel/utils/discord"
"github.com/gin-gonic/contrib/sessions"
"github.com/gin-gonic/gin"
"time"
)
@ -36,19 +36,22 @@ func ReloadGuildsHandler(ctx *gin.Context) {
return
}
store := sessions.Default(ctx)
if store == nil {
ctx.JSON(200, gin.H{
store, err := session.Store.Get(userId)
if err != nil {
if err == session.ErrNoSession {
ctx.JSON(401, gin.H{
"success": false,
"reauthenticate_required": true,
"auth": true,
})
} else {
ctx.JSON(500, utils.ErrorJson(err))
}
return
}
accessToken := store.Get("access_token").(string)
expiry := store.Get("expiry").(int64)
if expiry > (time.Now().UnixNano() / int64(time.Second)) {
res, err := discord.RefreshToken(store.Get("refresh_token").(string))
if store.Expiry > (time.Now().UnixNano() / int64(time.Second)) {
res, err := discord.RefreshToken(store.RefreshToken)
if err != nil { // Tell client to re-authenticate
ctx.JSON(200, gin.H{
"success": false,
@ -57,15 +60,17 @@ func ReloadGuildsHandler(ctx *gin.Context) {
return
}
accessToken = res.AccessToken
store.AccessToken = res.AccessToken
store.RefreshToken = res.RefreshToken
store.Expiry = (time.Now().UnixNano()/int64(time.Second))+int64(res.ExpiresIn)
store.Set("access_token", res.AccessToken)
store.Set("refresh_token", res.RefreshToken)
store.Set("expiry", (time.Now().UnixNano()/int64(time.Second))+int64(res.ExpiresIn))
store.Save()
if err := session.Store.Set(userId, store); err != nil {
ctx.JSON(500, utils.ErrorJson(err))
return
}
}
if err := utils.LoadGuilds(accessToken, userId); err != nil {
if err := utils.LoadGuilds(store.AccessToken, userId); err != nil {
ctx.JSON(500, utils.ErrorJson(err))
return
}

View File

@ -4,6 +4,7 @@ import (
"github.com/TicketsBot/GoPanel/botcontext"
"github.com/TicketsBot/GoPanel/utils"
"github.com/gin-gonic/gin"
"github.com/rxdn/gdl/objects/member"
)
func SearchMembers(ctx *gin.Context) {
@ -16,12 +17,18 @@ func SearchMembers(ctx *gin.Context) {
}
query := ctx.Query("query")
if len(query) == 0 || len(query) > 32 {
if len(query) > 32 {
ctx.JSON(400, utils.ErrorStr("Invalid query"))
return
}
members, err := botCtx.SearchMembers(guildId, query)
var members []member.Member
if query == "" {
members, err = botCtx.ListMembers(guildId)
} else {
members, err = botCtx.SearchMembers(guildId, query)
}
if err != nil {
ctx.JSON(500, utils.ErrorJson(err))
return

View File

@ -0,0 +1,31 @@
package api
import (
"github.com/TicketsBot/GoPanel/app/http/session"
"github.com/TicketsBot/GoPanel/utils"
"github.com/gin-gonic/gin"
)
func SessionHandler(ctx *gin.Context) {
userId := ctx.Keys["userid"].(uint64)
store, err := session.Store.Get(userId)
if err != nil {
if err == session.ErrNoSession {
ctx.JSON(404, gin.H{
"success": false,
"error": err.Error(),
"auth": true,
})
} else {
ctx.JSON(500, utils.ErrorJson(err))
}
return
}
ctx.JSON(200, gin.H{
"username": store.Name,
"avatar": store.Avatar,
})
}

View File

@ -30,18 +30,30 @@ func GetSettingsHandler(ctx *gin.Context) {
// prefix
group.Go(func() (err error) {
settings.Prefix, err = dbclient.Client.Prefix.Get(guildId)
if err == nil && settings.Prefix == "" {
settings.Prefix = "t!"
}
return
})
// welcome message
group.Go(func() (err error) {
settings.WelcomeMessaage, err = dbclient.Client.WelcomeMessages.Get(guildId)
if err == nil && settings.WelcomeMessaage == "" {
settings.WelcomeMessaage = "Thank you for contacting support.\nPlease describe your issue and await a response."
}
return
})
// ticket limit
group.Go(func() (err error) {
settings.TicketLimit, err = dbclient.Client.TicketLimit.Get(guildId)
if err == nil && settings.TicketLimit == 0 {
settings.TicketLimit = 5 // Set default
}
return
})

View File

@ -114,7 +114,7 @@ func formatMembers(guildId uint64, userIds, roleIds []uint64) ([]entity, error)
}
// map role ids to names
var data []entity
data := make([]entity, 0)
for _, roleId := range roleIds {
for _, role := range roles {
if roleId == role.Id {

View File

@ -86,7 +86,7 @@ func GetTicket(ctx *gin.Context) {
}
messagesFormatted = append(messagesFormatted, map[string]interface{}{
"username": message.Author.Username,
"author": message.Author,
"content": content,
})
}

View File

@ -2,15 +2,19 @@ package api
import (
"context"
"fmt"
"github.com/TicketsBot/GoPanel/database"
"github.com/TicketsBot/GoPanel/rpc/cache"
"github.com/gin-gonic/gin"
"github.com/rxdn/gdl/objects/user"
"golang.org/x/sync/errgroup"
"strconv"
)
func GetTickets(ctx *gin.Context) {
type WithUser struct {
TicketId int `json:"id"`
User *user.User `json:"user,omitempty"`
}
guildId := ctx.Keys["guildid"].(uint64)
tickets, err := database.Client.Tickets.GetGuildOpenTickets(guildId)
@ -22,7 +26,7 @@ func GetTickets(ctx *gin.Context) {
return
}
ticketsFormatted := make([]map[string]interface{}, len(tickets))
data := make([]WithUser, len(tickets))
group, _ := errgroup.WithContext(context.Background())
@ -31,29 +35,14 @@ func GetTickets(ctx *gin.Context) {
ticket := ticket
group.Go(func() error {
members, err := database.Client.TicketMembers.Get(guildId, ticket.Id)
if err != nil {
return err
user, ok := cache.Instance.GetUser(ticket.UserId)
data[i] = WithUser{
TicketId: ticket.Id,
}
membersFormatted := make([]map[string]interface{}, 0)
for _, userId := range members {
user, _ := cache.Instance.GetUser(userId)
membersFormatted = append(membersFormatted, map[string]interface{}{
"id": strconv.FormatUint(userId, 10),
"username": user.Username,
"discrim": fmt.Sprintf("%04d", user.Discriminator),
})
}
owner, _ := cache.Instance.GetUser(ticket.UserId)
ticketsFormatted[len(tickets) - 1 - i] = map[string]interface{}{
"ticketId": ticket.Id,
"username": owner.Username,
"discrim": fmt.Sprintf("%04d", owner.Discriminator),
"members": membersFormatted,
if ok {
data[i].User = &user
}
return nil
@ -68,5 +57,5 @@ func GetTickets(ctx *gin.Context) {
return
}
ctx.JSON(200, ticketsFormatted)
ctx.JSON(200, data)
}

View File

@ -1,36 +0,0 @@
package api
import (
"fmt"
"github.com/TicketsBot/GoPanel/config"
"github.com/TicketsBot/GoPanel/utils"
"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/contrib/sessions"
"github.com/gin-gonic/gin"
"strconv"
"time"
)
func TokenHandler(ctx *gin.Context) {
session := sessions.Default(ctx)
userId := utils.GetUserId(session)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"userid": strconv.FormatUint(userId, 10),
"timestamp": time.Now(),
})
str, err := token.SignedString([]byte(config.Conf.Server.Secret))
if err != nil {
fmt.Println(err.Error())
ctx.JSON(500, gin.H{
"success": false,
"error": err.Error(),
})
} else {
ctx.JSON(200, gin.H{
"success": true,
"token": str,
})
}
}

View File

@ -0,0 +1,68 @@
package api
import (
"errors"
"github.com/TicketsBot/GoPanel/database"
"github.com/TicketsBot/GoPanel/utils"
"github.com/TicketsBot/archiverclient"
"github.com/TicketsBot/common/permission"
"github.com/gin-gonic/gin"
"strconv"
)
func GetTranscriptHandler(ctx *gin.Context) {
guildId := ctx.Keys["guildid"].(uint64)
userId := ctx.Keys["userid"].(uint64)
// format ticket ID
ticketId, err := strconv.Atoi(ctx.Param("ticketId"))
if err != nil {
ctx.JSON(400, utils.ErrorStr("Invalid ticket ID"))
return
}
// get ticket object
ticket, err := database.Client.Tickets.Get(ticketId, guildId)
if err != nil {
// TODO: 500 error page
ctx.AbortWithStatusJSON(500, gin.H{
"success": false,
"error": err.Error(),
})
return
}
// Verify this is a valid ticket and it is closed
if ticket.UserId == 0 || ticket.Open {
ctx.JSON(404, utils.ErrorStr("Transcript not found"))
return
}
// Verify the user has permissions to be here
if ticket.UserId != userId {
permLevel, err := utils.GetPermissionLevel(guildId, userId)
if err != nil {
ctx.JSON(500, utils.ErrorJson(err))
return
}
if permLevel < permission.Support {
ctx.JSON(403, utils.ErrorStr("You do not have permission to view this transcript"))
return
}
}
// retrieve ticket messages from bucket
messages, err := utils.ArchiverClient.Get(guildId, ticketId)
if err != nil {
if errors.Is(err, archiverclient.ErrExpired) {
ctx.JSON(404, utils.ErrorStr("Transcript not found"))
} else {
ctx.JSON(500, utils.ErrorJson(err))
}
return
}
ctx.JSON(200, messages)
}

View File

@ -0,0 +1,247 @@
package api
import (
"errors"
"github.com/TicketsBot/GoPanel/botcontext"
dbclient "github.com/TicketsBot/GoPanel/database"
"github.com/TicketsBot/GoPanel/rpc/cache"
"github.com/TicketsBot/GoPanel/utils"
"github.com/TicketsBot/database"
"github.com/gin-gonic/gin"
"math"
"net/http"
"strconv"
)
const (
pageLimit = 30
)
type filterType uint8
const (
filterTypeNone filterType = iota
filterTypeTicketId
filterTypeUsername
filterTypeUserId
)
type transcript struct {
TicketId int `json:"ticket_id"`
Username string `json:"username"`
CloseReason *string `json:"close_reason"`
}
func ListTranscripts(ctx *gin.Context) {
guildId := ctx.Keys["guildid"].(uint64)
botContext, err := botcontext.ContextForGuild(guildId)
if err != nil {
ctx.AbortWithStatusJSON(500, gin.H{
"success": false,
"error": err.Error(),
})
return
}
// db functions will handle if 0
before, _ := strconv.Atoi(ctx.Query("before"))
after, _ := strconv.Atoi(ctx.Query("after"))
var tickets []database.TicketWithCloseReason
var status int
filterType := getFilterType(ctx)
switch filterType {
case filterTypeNone:
tickets, status, err = getTickets(guildId, before, after)
case filterTypeTicketId:
tickets, status, err = getTicketsByTicketId(guildId, ctx)
case filterTypeUsername:
tickets, status, err = getTicketsByUsername(guildId, before, after, ctx)
case filterTypeUserId:
tickets, status, err = getTicketsByUserId(guildId, before, after, ctx)
}
if err != nil {
ctx.JSON(status, utils.ErrorJson(err))
return
}
// Create a mapping user_id -> username so we can skip duplicates
usernames := make(map[uint64]string)
for _, ticket := range tickets {
if _, ok := usernames[ticket.UserId]; ok {
continue // don't fetch again
}
// check cache, for some reason botContext.GetUser doesn't do this
user, ok := cache.Instance.GetUser(ticket.UserId)
if ok {
usernames[ticket.UserId] = user.Username
} else {
user, err = botContext.GetUser(ticket.UserId)
if err != nil { // TODO: Log
usernames[ticket.UserId] = "Unknown User"
} else {
usernames[ticket.UserId] = user.Username
}
}
}
transcripts := make([]transcript, len(tickets))
for i, ticket := range tickets {
transcripts[i] = transcript{
TicketId: ticket.Id,
Username: usernames[ticket.UserId],
CloseReason: ticket.CloseReason,
}
}
ctx.JSON(200, transcripts)
}
func getFilterType(ctx *gin.Context) filterType {
if ctx.Query("ticketid") != "" {
return filterTypeTicketId
} else if ctx.Query("username") != "" {
return filterTypeUsername
} else if ctx.Query("userid") != "" {
return filterTypeUserId
} else {
return filterTypeNone
}
}
func getTickets(guildId uint64, before, after int) ([]database.TicketWithCloseReason, int, error) {
var tickets []database.TicketWithCloseReason
var err error
if before <= 0 && after <= 0 {
tickets, err = dbclient.Client.Tickets.GetGuildClosedTicketsBeforeWithCloseReason(guildId, pageLimit, math.MaxInt32)
} else if before > 0 {
tickets, err = dbclient.Client.Tickets.GetGuildClosedTicketsBeforeWithCloseReason(guildId, pageLimit, before)
} else { // after > 0
// returns in ascending order, must reverse
tickets, err = dbclient.Client.Tickets.GetGuildClosedTicketsAfterWithCloseReason(guildId, pageLimit, after)
if err == nil {
reverse(tickets)
}
}
status := http.StatusOK
if err != nil {
status = http.StatusInternalServerError
}
return tickets, status, err
}
// (tickets, statusCode, error)
func getTicketsByTicketId(guildId uint64, ctx *gin.Context) ([]database.TicketWithCloseReason, int, error) {
ticketId, err := strconv.Atoi(ctx.Query("ticketid"))
if err != nil {
return nil, 400, err
}
ticket, err := dbclient.Client.Tickets.Get(ticketId, guildId)
if err != nil {
return nil, http.StatusInternalServerError, err
}
if ticket.Id == 0 {
return nil, http.StatusNotFound, errors.New("ticket not found")
}
closeReason, ok, err := dbclient.Client.CloseReason.Get(guildId, ticketId)
if err != nil {
return nil, http.StatusInternalServerError, err
}
data := database.TicketWithCloseReason{
Ticket: ticket,
}
if ok {
data.CloseReason = &closeReason
}
return []database.TicketWithCloseReason{data}, http.StatusOK, nil
}
// (tickets, statusCode, error)
func getTicketsByUsername(guildId uint64, before, after int, ctx *gin.Context) ([]database.TicketWithCloseReason, int, error) {
username := ctx.Query("username")
botContext, err := botcontext.ContextForGuild(guildId)
if err != nil {
return nil, http.StatusInternalServerError, err
}
members, err := botContext.SearchMembers(guildId, username)
if err != nil {
return nil, http.StatusInternalServerError, err
}
userIds := make([]uint64, len(members)) // capped at 100
for i, member := range members {
userIds[i] = member.User.Id
}
var tickets []database.TicketWithCloseReason
if before <= 0 && after <= 0 {
tickets, err = dbclient.Client.Tickets.GetClosedByAnyBeforeWithCloseReason(guildId, userIds, math.MaxInt32, pageLimit)
} else if before > 0 {
tickets, err = dbclient.Client.Tickets.GetClosedByAnyBeforeWithCloseReason(guildId, userIds, before, pageLimit)
} else { // after > 0
// returns in ascending order, must reverse
tickets, err = dbclient.Client.Tickets.GetClosedByAnyAfterWithCloseReason(guildId, userIds, after, pageLimit)
if err == nil {
reverse(tickets)
}
}
if err != nil {
return nil, http.StatusInternalServerError, err
}
return tickets, http.StatusOK, nil
}
// (tickets, statusCode, error)
func getTicketsByUserId(guildId uint64, before, after int, ctx *gin.Context) ([]database.TicketWithCloseReason, int, error) {
userId, err := strconv.ParseUint(ctx.Query("userid"), 10, 64)
if err != nil {
return nil, 400, err
}
var tickets []database.TicketWithCloseReason
if before <= 0 && after <= 0 {
tickets, err = dbclient.Client.Tickets.GetClosedByAnyBeforeWithCloseReason(guildId, []uint64{userId}, math.MaxInt32, pageLimit)
} else if before > 0 {
tickets, err = dbclient.Client.Tickets.GetClosedByAnyBeforeWithCloseReason(guildId, []uint64{userId}, before, pageLimit)
} else { // after > 0
// returns in ascending order, must reverse
tickets, err = dbclient.Client.Tickets.GetClosedByAnyAfterWithCloseReason(guildId, []uint64{userId}, after, pageLimit)
if err == nil {
reverse(tickets)
}
}
if err != nil {
return nil, http.StatusInternalServerError, err
}
return tickets, http.StatusOK, nil
}
func reverse(slice []database.TicketWithCloseReason) {
if len(slice) == 0 {
return
}
for i, j := 0, len(slice)-1; i < j; i, j = i+1, j-1 {
slice[i], slice[j] = slice[j], slice[i]
}
}

View File

@ -1,19 +0,0 @@
package manage
import (
"github.com/TicketsBot/GoPanel/config"
"github.com/gin-gonic/contrib/sessions"
"github.com/gin-gonic/gin"
)
func BlacklistHandler(ctx *gin.Context) {
store := sessions.Default(ctx)
guildId := ctx.Keys["guildid"].(uint64)
ctx.HTML(200, "manage/blacklist", gin.H{
"name": store.Get("name").(string),
"guildId": guildId,
"avatar": store.Get("avatar").(string),
"baseUrl": config.Conf.Server.BaseUrl,
})
}

View File

@ -1,19 +0,0 @@
package manage
import (
"github.com/TicketsBot/GoPanel/config"
"github.com/gin-gonic/contrib/sessions"
"github.com/gin-gonic/gin"
)
func LogsHandler(ctx *gin.Context) {
store := sessions.Default(ctx)
guildId := ctx.Keys["guildid"].(uint64)
ctx.HTML(200, "manage/logs", gin.H{
"name": store.Get("name").(string),
"guildId": guildId,
"avatar": store.Get("avatar").(string),
"baseUrl": config.Conf.Server.BaseUrl,
})
}

View File

@ -1,98 +0,0 @@
package manage
import (
"errors"
"fmt"
"github.com/TicketsBot/GoPanel/config"
"github.com/TicketsBot/GoPanel/database"
"github.com/TicketsBot/GoPanel/rpc/cache"
"github.com/TicketsBot/GoPanel/utils"
"github.com/TicketsBot/archiverclient"
"github.com/TicketsBot/common/permission"
"github.com/gin-gonic/contrib/sessions"
"github.com/gin-gonic/gin"
"strconv"
)
var Archiver archiverclient.ArchiverClient
func LogViewHandler(ctx *gin.Context) {
store := sessions.Default(ctx)
if store == nil {
return
}
if utils.IsLoggedIn(store) {
userId := utils.GetUserId(store)
// Verify the guild exists
guildId, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
ctx.Redirect(302, config.Conf.Server.BaseUrl) // TODO: 404 Page
return
}
// Get object for selected guild
guild, _ := cache.Instance.GetGuild(guildId, false)
// format ticket ID
ticketId, err := strconv.Atoi(ctx.Param("ticket")); if err != nil {
ctx.Redirect(302, fmt.Sprintf("/manage/%d/logs", guild.Id))
return
}
// get ticket object
ticket, err := database.Client.Tickets.Get(ticketId, guildId)
if err != nil {
// TODO: 500 error page
ctx.AbortWithStatusJSON(500, gin.H{
"success": false,
"error": err.Error(),
})
return
}
// Verify this is a valid ticket and it is closed
if ticket.UserId == 0 || ticket.Open {
ctx.Redirect(302, fmt.Sprintf("/manage/%d/logs", guild.Id))
return
}
// Verify the user has permissions to be here
if ticket.UserId != userId {
permLevel, err := utils.GetPermissionLevel(guildId, userId)
if err != nil {
ctx.JSON(500, utils.ErrorJson(err))
return
}
if permLevel < permission.Support {
ctx.Redirect(302, config.Conf.Server.BaseUrl) // TODO: 403 Page
return
}
}
// retrieve ticket messages from bucket
messages, err := Archiver.Get(guildId, ticketId)
if err != nil {
if errors.Is(err, archiverclient.ErrExpired) {
ctx.String(200, "Failed to retrieve archive - please contact the developers quoting error code: ErrExpired") // TODO: Actual error page
return
}
ctx.String(500, fmt.Sprintf("Failed to retrieve archive - please contact the developers: %s", err.Error()))
return
}
// format to html
html, err := Archiver.Encode(messages, fmt.Sprintf("ticket-%d", ticketId))
if err != nil {
ctx.String(500, fmt.Sprintf("Failed to retrieve archive - please contact the developers: %s", err.Error()))
return
}
ctx.Data(200, gin.MIMEHTML, html)
} else {
ctx.Redirect(302, fmt.Sprintf("/login?noguilds&state=viewlog.%s.%s", ctx.Param("id"), ctx.Param("ticket")))
}
}

View File

@ -1,19 +0,0 @@
package manage
import (
"github.com/TicketsBot/GoPanel/config"
"github.com/gin-gonic/contrib/sessions"
"github.com/gin-gonic/gin"
)
func PanelHandler(ctx *gin.Context) {
store := sessions.Default(ctx)
guildId := ctx.Keys["guildid"].(uint64)
ctx.HTML(200, "manage/panels", gin.H{
"name": store.Get("name").(string),
"guildId": guildId,
"avatar": store.Get("avatar").(string),
"baseUrl": config.Conf.Server.BaseUrl,
})
}

View File

@ -1,19 +0,0 @@
package manage
import (
"github.com/TicketsBot/GoPanel/config"
"github.com/gin-gonic/contrib/sessions"
"github.com/gin-gonic/gin"
)
func SettingsHandler(ctx *gin.Context) {
store := sessions.Default(ctx)
guildId := ctx.Keys["guildid"].(uint64)
ctx.HTML(200, "manage/settings", gin.H{
"name": store.Get("name").(string),
"guildId": guildId,
"avatar": store.Get("avatar").(string),
"baseUrl": config.Conf.Server.BaseUrl,
})
}

View File

@ -1,19 +0,0 @@
package manage
import (
"github.com/TicketsBot/GoPanel/config"
"github.com/gin-gonic/contrib/sessions"
"github.com/gin-gonic/gin"
)
func TagsHandler(ctx *gin.Context) {
store := sessions.Default(ctx)
guildId := ctx.Keys["guildid"].(uint64)
ctx.HTML(200, "manage/tags", gin.H{
"name": store.Get("name").(string),
"guildId": guildId,
"avatar": store.Get("avatar").(string),
"baseUrl": config.Conf.Server.BaseUrl,
})
}

View File

@ -1,19 +0,0 @@
package manage
import (
"github.com/TicketsBot/GoPanel/config"
"github.com/gin-gonic/contrib/sessions"
"github.com/gin-gonic/gin"
)
func TicketListHandler(ctx *gin.Context) {
store := sessions.Default(ctx)
guildId := ctx.Keys["guildid"].(uint64)
ctx.HTML(200, "manage/ticketlist", gin.H{
"name": store.Get("name").(string),
"guildId": guildId,
"avatar": store.Get("avatar").(string),
"baseUrl": config.Conf.Server.BaseUrl,
})
}

View File

@ -1,20 +0,0 @@
package manage
import (
"github.com/TicketsBot/GoPanel/config"
"github.com/gin-gonic/contrib/sessions"
"github.com/gin-gonic/gin"
)
func TicketViewHandler(ctx *gin.Context) {
store := sessions.Default(ctx)
guildId := ctx.Keys["guildid"].(uint64)
ctx.HTML(200, "manage/ticketview", gin.H{
"name": store.Get("name").(string),
"guildId": guildId,
"avatar": store.Get("avatar").(string),
"baseUrl": config.Conf.Server.BaseUrl,
"ticketId": ctx.Param("ticketId"),
})
}

View File

@ -1,146 +0,0 @@
package manage
import (
"fmt"
"github.com/TicketsBot/GoPanel/botcontext"
"github.com/TicketsBot/GoPanel/rpc"
"github.com/TicketsBot/GoPanel/utils"
"github.com/TicketsBot/common/permission"
"github.com/TicketsBot/common/premium"
"github.com/gin-gonic/contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"strconv"
"sync"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
var SocketsLock sync.Mutex
var Sockets []*Socket
type (
Socket struct {
Ws *websocket.Conn
Guild string
Ticket int
}
WsEvent struct {
Type string
Data interface{}
}
AuthEvent struct {
Guild string
Ticket string
}
)
func WebChatWs(ctx *gin.Context) {
store := sessions.Default(ctx)
conn, err := upgrader.Upgrade(ctx.Writer, ctx.Request, nil)
if err != nil {
fmt.Println(err.Error())
return
}
socket := &Socket{
Ws: conn,
}
conn.SetCloseHandler(func(code int, text string) error {
i := -1
SocketsLock.Lock()
for index, element := range Sockets {
if element == socket {
i = index
break
}
}
if i != -1 {
Sockets = Sockets[:i+copy(Sockets[i:], Sockets[i+1:])]
}
SocketsLock.Unlock()
return nil
})
SocketsLock.Lock()
Sockets = append(Sockets, socket)
SocketsLock.Unlock()
userId := utils.GetUserId(store)
var guildId string
var guildIdParsed uint64
var ticket int
for {
var evnt WsEvent
err := conn.ReadJSON(&evnt)
if err != nil {
break
}
if guildId == "" && evnt.Type != "auth" {
conn.Close()
break
} else if evnt.Type == "auth" {
data := evnt.Data.(map[string]interface{})
guildId = data["guild"].(string)
ticket, err = strconv.Atoi(data["ticket"].(string))
if err != nil {
conn.Close()
break
}
socket.Guild = guildId
socket.Ticket = ticket
// Verify the guild exists
guildIdParsed, err = strconv.ParseUint(guildId, 10, 64)
if err != nil {
fmt.Println(err.Error())
conn.Close()
return
}
// Verify the user has permissions to be here
permLevel, err := utils.GetPermissionLevel(guildIdParsed, userId)
if err != nil {
fmt.Println(err.Error())
conn.Close()
return
}
if permLevel < permission.Admin {
fmt.Println(err.Error())
conn.Close()
return
}
botContext, err := botcontext.ContextForGuild(guildIdParsed)
if err != nil {
ctx.AbortWithStatusJSON(500, gin.H{
"success": false,
"error": err.Error(),
})
return
}
// Verify the guild is premium
premiumTier := rpc.PremiumClient.GetTierByGuildId(guildIdParsed, true, botContext.Token, botContext.RateLimiter)
if premiumTier == premium.None {
conn.Close()
return
}
}
}
}

View File

@ -2,14 +2,14 @@ package root
import (
"fmt"
"github.com/TicketsBot/GoPanel/app/http/session"
"github.com/TicketsBot/GoPanel/config"
"github.com/TicketsBot/GoPanel/utils"
"github.com/TicketsBot/GoPanel/utils/discord"
"github.com/apex/log"
"github.com/gin-gonic/contrib/sessions"
"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
"github.com/rxdn/gdl/rest"
"strings"
"strconv"
"time"
)
@ -33,63 +33,59 @@ type (
)
func CallbackHandler(ctx *gin.Context) {
store := sessions.Default(ctx)
if store == nil {
return
}
defer store.Save()
if utils.IsLoggedIn(store) && store.Get("has_guilds") == true {
ctx.Redirect(302, config.Conf.Server.BaseUrl)
return
}
code, ok := ctx.GetQuery("code")
if !ok {
utils.ErrorPage(ctx, 400, "Discord provided invalid Oauth2 code")
ctx.JSON(400, utils.ErrorStr("Discord provided invalid Oauth2 code"))
return
}
res, err := discord.AccessToken(code)
if err != nil {
utils.ErrorPage(ctx, 500, err.Error())
ctx.JSON(500, utils.ErrorJson(err))
return
}
store.Set("access_token", res.AccessToken)
store.Set("refresh_token", res.RefreshToken)
store.Set("expiry", (time.Now().UnixNano()/int64(time.Second))+int64(res.ExpiresIn))
// Get ID + name
currentUser, err := rest.GetCurrentUser(fmt.Sprintf("Bearer %s", res.AccessToken), nil)
if err != nil {
ctx.String(500, err.Error())
ctx.JSON(500, utils.ErrorJson(err))
return
}
store.Set("csrf", utils.RandString(32))
store.Set("userid", currentUser.Id)
store.Set("name", currentUser.Username)
store.Set("avatar", currentUser.AvatarUrl(256))
store.Save()
store := session.SessionData{
AccessToken: res.AccessToken,
Expiry: (time.Now().UnixNano()/int64(time.Second))+int64(res.ExpiresIn),
RefreshToken: res.RefreshToken,
Name: currentUser.Username,
Avatar: currentUser.AvatarUrl(256),
HasGuilds: false,
}
if err := utils.LoadGuilds(res.AccessToken, currentUser.Id); err == nil {
store.Set("has_guilds", true)
store.Save()
store.HasGuilds = true
} else {
log.Error(err.Error())
ctx.JSON(500, utils.ErrorJson(err))
return
}
handleRedirect(ctx)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"userid": strconv.FormatUint(currentUser.Id, 10),
"timestamp": time.Now(),
})
str, err := token.SignedString([]byte(config.Conf.Server.Secret))
if err != nil {
ctx.JSON(500, utils.ErrorJson(err))
return
}
func handleRedirect(ctx *gin.Context) {
state := strings.Split(ctx.Query("state"), ".")
if err := session.Store.Set(currentUser.Id, store); err != nil {
ctx.JSON(500, utils.ErrorJson(err))
return
}
if len(state) == 3 && state[0] == "viewlog" {
ctx.Redirect(302, fmt.Sprintf("%s/manage/%s/logs/view/%s", config.Conf.Server.BaseUrl, state[1], state[2]))
} else {
ctx.Redirect(302, config.Conf.Server.BaseUrl)
}
ctx.JSON(200, gin.H{
"success": true,
"token": str,
})
}

View File

@ -1,27 +0,0 @@
package root
import (
"fmt"
"github.com/TicketsBot/GoPanel/config"
"github.com/gin-gonic/contrib/sessions"
"github.com/gin-gonic/gin"
"net/url"
)
func IndexHandler(ctx *gin.Context) {
store := sessions.Default(ctx)
if _, hasGuilds := store.Get("has_guilds").(bool); !hasGuilds {
redirect := url.QueryEscape(config.Conf.Oauth.RedirectUri)
ctx.Redirect(302, fmt.Sprintf("https://discordapp.com/oauth2/authorize?response_type=code&redirect_uri=%s&scope=identify+guilds&client_id=%d&state=%s", redirect, config.Conf.Oauth.Id, ctx.Query("state")))
return
}
ctx.HTML(200, "main/index", gin.H{
"name": store.Get("name").(string),
"baseurl": config.Conf.Server.BaseUrl,
"avatar": store.Get("avatar").(string),
"referralShow": config.Conf.Referral.Show,
"referralLink": config.Conf.Referral.Link,
})
}

View File

@ -1,31 +0,0 @@
package root
import (
"fmt"
"github.com/TicketsBot/GoPanel/config"
"github.com/TicketsBot/GoPanel/utils"
"github.com/gin-gonic/contrib/sessions"
"github.com/gin-gonic/gin"
"net/url"
)
func LoginHandler(ctx *gin.Context) {
store := sessions.Default(ctx)
if store == nil {
return
}
defer store.Save()
if utils.IsLoggedIn(store) {
ctx.Redirect(302, config.Conf.Server.BaseUrl)
} else {
redirect := url.QueryEscape(config.Conf.Oauth.RedirectUri)
var guildsScope string
if _, noGuilds := ctx.GetQuery("noguilds"); !noGuilds {
guildsScope = "+guilds"
}
ctx.Redirect(302, fmt.Sprintf("https://discordapp.com/oauth2/authorize?response_type=code&redirect_uri=%s&scope=identify%s&client_id=%d&state=%s", redirect, guildsScope, config.Conf.Oauth.Id, ctx.Query("state")))
}
}

View File

@ -1,18 +1,18 @@
package root
import (
"github.com/gin-gonic/contrib/sessions"
"github.com/TicketsBot/GoPanel/app/http/session"
"github.com/TicketsBot/GoPanel/utils"
"github.com/gin-gonic/gin"
)
func LogoutHandler(ctx *gin.Context) {
store := sessions.Default(ctx)
if store == nil {
userId := ctx.Keys["userid"].(uint64)
if err := session.Store.Clear(userId); err != nil {
ctx.JSON(500, utils.ErrorJson(err))
return
}
defer store.Save()
store.Clear()
ctx.Redirect(302, "https://ticketsbot.net")
ctx.Status(204)
}

View File

@ -0,0 +1,193 @@
package root
import (
"encoding/json"
"fmt"
"github.com/TicketsBot/GoPanel/botcontext"
"github.com/TicketsBot/GoPanel/config"
"github.com/TicketsBot/GoPanel/rpc"
"github.com/TicketsBot/GoPanel/utils"
"github.com/TicketsBot/common/permission"
"github.com/TicketsBot/common/premium"
"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"net/http"
"strconv"
"sync"
"time"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return r.Header.Get("Origin") == config.Conf.Server.BaseUrl
},
}
var SocketsLock sync.RWMutex
var Sockets []*Socket
type (
Socket struct {
Ws *websocket.Conn
GuildId uint64
TicketId int
}
WsEvent struct {
Type string
Data json.RawMessage
}
AuthEvent struct {
GuildId uint64 `json:"guild_id,string"`
TicketId int `json:"ticket_id"`
Token string `json:"token"`
}
)
var timeout = time.Second * 60
func WebChatWs(ctx *gin.Context) {
conn, err := upgrader.Upgrade(ctx.Writer, ctx.Request, nil)
if err != nil {
fmt.Println(err.Error())
return
}
socket := &Socket{
Ws: conn,
}
SocketsLock.Lock()
Sockets = append(Sockets, socket)
SocketsLock.Unlock()
conn.SetCloseHandler(func(code int, text string) error {
i := -1
SocketsLock.Lock()
defer SocketsLock.Unlock()
for index, element := range Sockets {
if element == socket {
i = index
break
}
}
if i != -1 {
Sockets = Sockets[:i+copy(Sockets[i:], Sockets[i+1:])]
}
return nil
})
lastResponse := time.Now()
conn.SetPongHandler(func(a string) error {
lastResponse = time.Now()
return nil
})
go func() {
// We can let this func call the CloseHandler
for {
err := conn.WriteMessage(websocket.PingMessage, []byte("keepalive"))
if err != nil {
fmt.Println(err.Error())
conn.Close()
conn.CloseHandler()(1000, "")
return
}
time.Sleep(timeout / 2)
if time.Since(lastResponse) > timeout {
conn.Close()
conn.CloseHandler()(1000, "")
return
}
}
}()
for {
var event WsEvent
err := conn.ReadJSON(&event)
if err != nil {
break
}
if socket.GuildId == 0 && event.Type != "auth" {
conn.Close()
break
} else if event.Type == "auth" {
var authData AuthEvent
if err := json.Unmarshal(event.Data, &authData); err != nil {
conn.Close()
return
}
token, err := jwt.Parse(authData.Token, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(config.Conf.Server.Secret), nil
})
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
conn.Close()
return
}
userIdStr, ok := claims["userid"].(string)
if !ok {
conn.Close()
return
}
userId, err := strconv.ParseUint(userIdStr, 10, 64)
if err != nil {
conn.Close()
return
}
// Verify the user has permissions to be here
permLevel, err := utils.GetPermissionLevel(authData.GuildId, userId)
if err != nil {
fmt.Println(err.Error())
conn.Close()
return
}
if permLevel < permission.Admin {
fmt.Println(3)
conn.Close()
return
}
botContext, err := botcontext.ContextForGuild(authData.GuildId)
if err != nil {
ctx.AbortWithStatusJSON(500, gin.H{
"success": false,
"error": err.Error(),
})
return
}
// Verify the guild is premium
premiumTier := rpc.PremiumClient.GetTierByGuildId(authData.GuildId, true, botContext.Token, botContext.RateLimiter)
if premiumTier == premium.None {
fmt.Println(4)
conn.Close()
return
}
SocketsLock.Lock()
socket.GuildId = authData.GuildId
socket.TicketId = authData.TicketId
SocketsLock.Unlock()
}
}
}

View File

@ -1,44 +0,0 @@
package root
import (
"fmt"
"github.com/TicketsBot/GoPanel/config"
"github.com/TicketsBot/GoPanel/rpc"
"github.com/TicketsBot/common/premium"
"github.com/gin-gonic/contrib/sessions"
"github.com/gin-gonic/gin"
)
func WhitelabelHandler(ctx *gin.Context) {
store := sessions.Default(ctx)
if store == nil {
return
}
defer store.Save()
userId := store.Get("userid").(uint64)
premiumTier := rpc.PremiumClient.GetTierByUser(userId, false)
if premiumTier < premium.Whitelabel {
var isForced bool
for _, forced := range config.Conf.ForceWhitelabel {
if forced == userId {
isForced = true
break
}
}
if !isForced {
ctx.Redirect(302, fmt.Sprintf("%s/premium", config.Conf.Server.MainSite))
return
}
}
ctx.HTML(200, "main/whitelabel", gin.H{
"name": store.Get("name").(string),
"baseurl": config.Conf.Server.BaseUrl,
"avatar": store.Get("avatar").(string),
"referralShow": config.Conf.Referral.Show,
"referralLink": config.Conf.Referral.Link,
})
}

View File

@ -38,6 +38,10 @@ func AuthenticateToken(ctx *gin.Context) {
return
}
if ctx.Keys == nil {
ctx.Keys = make(map[string]interface{})
}
ctx.Keys["userid"] = parsedId
} else {
ctx.AbortWithStatusJSON(401, utils.ErrorStr("Token is invalid"))

View File

@ -0,0 +1,25 @@
package middleware
import (
"github.com/TicketsBot/GoPanel/config"
"github.com/gin-gonic/gin"
"net/http"
"strings"
)
func Cors(config config.Config) func(*gin.Context) {
methods := []string{http.MethodOptions, http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodPut, http.MethodDelete}
headers := []string{"x-tickets", "Content-Type", "Authorization"}
return func(ctx *gin.Context) {
ctx.Header("Access-Control-Allow-Origin", config.Server.BaseUrl)
ctx.Header("Access-Control-Allow-Methods", strings.Join(methods, ", "))
ctx.Header("Access-Control-Allow-Headers", strings.Join(headers, ", "))
ctx.Header("Access-Control-Allow-Credentials", "true")
ctx.Header("Access-Control-Max-Age", "600")
if ctx.Request.Method == http.MethodOptions {
ctx.AbortWithStatus(http.StatusNoContent)
}
}
}

View File

@ -1,25 +1,23 @@
package http
import (
"fmt"
"github.com/TicketsBot/GoPanel/app/http/endpoints/api"
api_autoclose "github.com/TicketsBot/GoPanel/app/http/endpoints/api/autoclose"
api_blacklist "github.com/TicketsBot/GoPanel/app/http/endpoints/api/blacklist"
api_logs "github.com/TicketsBot/GoPanel/app/http/endpoints/api/logs"
api_panels "github.com/TicketsBot/GoPanel/app/http/endpoints/api/panel"
api_settings "github.com/TicketsBot/GoPanel/app/http/endpoints/api/settings"
api_tags "github.com/TicketsBot/GoPanel/app/http/endpoints/api/tags"
api_team "github.com/TicketsBot/GoPanel/app/http/endpoints/api/team"
api_ticket "github.com/TicketsBot/GoPanel/app/http/endpoints/api/ticket"
api_transcripts "github.com/TicketsBot/GoPanel/app/http/endpoints/api/transcripts"
api_whitelabel "github.com/TicketsBot/GoPanel/app/http/endpoints/api/whitelabel"
"github.com/TicketsBot/GoPanel/app/http/endpoints/manage"
"github.com/TicketsBot/GoPanel/app/http/endpoints/root"
"github.com/TicketsBot/GoPanel/app/http/middleware"
"github.com/TicketsBot/GoPanel/app/http/session"
"github.com/TicketsBot/GoPanel/config"
"github.com/TicketsBot/GoPanel/utils"
"github.com/TicketsBot/common/permission"
"github.com/gin-contrib/multitemplate"
"github.com/gin-contrib/static"
"github.com/gin-gonic/contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/ulule/limiter/v3"
mgin "github.com/ulule/limiter/v3/drivers/middleware/gin"
@ -34,15 +32,7 @@ func StartServer() {
router := gin.Default()
// Sessions
store, err := sessions.NewRedisStore(
config.Conf.Server.Session.Threads,
"tcp", fmt.Sprintf("%s:%d", config.Conf.Redis.Host, config.Conf.Redis.Port),
config.Conf.Redis.Password,
[]byte(config.Conf.Server.Session.Secret))
if err != nil {
panic(err)
}
router.Use(sessions.Sessions("panel", store))
session.Store = session.NewRedisStore()
// Handle static asset requests
router.Use(static.Serve("/assets/", static.LocalFile("./public/static", false)))
@ -50,39 +40,18 @@ func StartServer() {
router.Use(gin.Recovery())
router.Use(createLimiter(600, time.Minute*10))
// Register templates
router.HTMLRender = createRenderer()
router.Use(middleware.Cors(config.Conf))
router.GET("/login", root.LoginHandler)
router.GET("/callback", root.CallbackHandler)
router.GET("/webchat", root.WebChatWs)
router.GET("/manage/:id/logs/view/:ticket", manage.LogViewHandler) // we check in the actual handler bc of a custom redirect
authorized := router.Group("/", middleware.AuthenticateCookie)
{
authorized.POST("/token", createLimiter(2, 10 * time.Second), middleware.VerifyXTicketsHeader, api.TokenHandler)
authenticateGuildAdmin := authorized.Group("/", middleware.AuthenticateGuild(false, permission.Admin))
authenticateGuildSupport := authorized.Group("/", middleware.AuthenticateGuild(false, permission.Support))
authorized.GET("/", root.IndexHandler)
authorized.GET("/whitelabel", root.WhitelabelHandler)
authorized.GET("/logout", root.LogoutHandler)
authenticateGuildAdmin.GET("/manage/:id/settings", manage.SettingsHandler)
authenticateGuildSupport.GET("/manage/:id/logs", manage.LogsHandler)
authenticateGuildSupport.GET("/manage/:id/blacklist", manage.BlacklistHandler)
authenticateGuildAdmin.GET("/manage/:id/panels", manage.PanelHandler)
authenticateGuildSupport.GET("/manage/:id/tags", manage.TagsHandler)
authenticateGuildSupport.GET("/manage/:id/teams", serveTemplate("manage/teams"))
authenticateGuildSupport.GET("/manage/:id/tickets", manage.TicketListHandler)
authenticateGuildSupport.GET("/manage/:id/tickets/view/:ticketId", manage.TicketViewHandler)
authorized.GET("/webchat", manage.WebChatWs)
}
router.POST("/callback", middleware.VerifyXTicketsHeader, root.CallbackHandler)
router.POST("/logout", middleware.VerifyXTicketsHeader, middleware.AuthenticateToken, root.LogoutHandler)
apiGroup := router.Group("/api", middleware.VerifyXTicketsHeader, middleware.AuthenticateToken)
{
apiGroup.GET("/session", api.SessionHandler)
}
guildAuthApiAdmin := apiGroup.Group("/:id", middleware.AuthenticateGuild(true, permission.Admin))
guildAuthApiSupport := apiGroup.Group("/:id", middleware.AuthenticateGuild(true, permission.Support))
{
@ -90,18 +59,18 @@ func StartServer() {
guildAuthApiSupport.GET("/premium", api.PremiumHandler)
guildAuthApiSupport.GET("/user/:user", api.UserHandler)
guildAuthApiSupport.GET("/roles", api.RolesHandler)
guildAuthApiSupport.GET("/members/search", createLimiter(10, time.Second * 30), createLimiter(75, time.Minute * 30), api.SearchMembers)
guildAuthApiSupport.GET("/members/search", createLimiter(5, time.Second), createLimiter(10, time.Second * 30), createLimiter(75, time.Minute * 30), api.SearchMembers)
guildAuthApiAdmin.GET("/settings", api_settings.GetSettingsHandler)
guildAuthApiAdmin.POST("/settings", api_settings.UpdateSettingsHandler)
guildAuthApiSupport.GET("/blacklist", api_blacklist.GetBlacklistHandler)
guildAuthApiSupport.PUT("/blacklist", api_blacklist.AddBlacklistHandler)
guildAuthApiSupport.POST("/blacklist/:user", api_blacklist.AddBlacklistHandler)
guildAuthApiSupport.DELETE("/blacklist/:user", api_blacklist.RemoveBlacklistHandler)
guildAuthApiAdmin.GET("/panels", api_panels.ListPanels)
guildAuthApiAdmin.PUT("/panels", api_panels.CreatePanel)
guildAuthApiAdmin.PUT("/panels/:panelid", api_panels.UpdatePanel)
guildAuthApiAdmin.POST("/panels", api_panels.CreatePanel)
guildAuthApiAdmin.PATCH("/panels/:panelid", api_panels.UpdatePanel)
guildAuthApiAdmin.DELETE("/panels/:panelid", api_panels.DeletePanel)
guildAuthApiAdmin.GET("/multipanels", api_panels.MultiPanelList)
@ -109,7 +78,8 @@ func StartServer() {
guildAuthApiAdmin.PATCH("/multipanels/:panelid", api_panels.MultiPanelUpdate)
guildAuthApiAdmin.DELETE("/multipanels/:panelid", api_panels.MultiPanelDelete)
guildAuthApiSupport.GET("/logs/", api_logs.GetLogs)
guildAuthApiSupport.GET("/transcripts", createLimiter(5, 5 * time.Second), createLimiter(20, time.Minute), api_transcripts.ListTranscripts)
guildAuthApiSupport.GET("/transcripts/:ticketId", createLimiter(10, 10 * time.Second), api_transcripts.GetTranscriptHandler)
guildAuthApiSupport.GET("/tickets", api_ticket.GetTickets)
guildAuthApiSupport.GET("/tickets/:ticketId", api_ticket.GetTicket)
@ -127,7 +97,7 @@ func StartServer() {
guildAuthApiAdmin.POST("/autoclose", api_autoclose.PostAutoClose)
guildAuthApiAdmin.GET("/team", api_team.GetTeams)
guildAuthApiAdmin.GET("/team/:teamid", createLimiter(5, time.Second * 15), api_team.GetMembers)
guildAuthApiAdmin.GET("/team/:teamid", createLimiter(10, time.Second * 30), api_team.GetMembers)
guildAuthApiAdmin.POST("/team", createLimiter(10, time.Minute), api_team.CreateTeam)
guildAuthApiAdmin.PUT("/team/:teamid/:snowflake", createLimiter(5, time.Second * 10), api_team.AddMember)
guildAuthApiAdmin.DELETE("/team/:teamid", api_team.DeleteTeam)
@ -141,18 +111,17 @@ func StartServer() {
userGroup.GET("/permissionlevel", api.GetPermissionLevel)
{
whitelabelGroup := userGroup.Group("/whitelabel", middleware.VerifyWhitelabel(false))
whitelabelApiGroup := userGroup.Group("/whitelabel", middleware.VerifyWhitelabel(true))
whitelabelGroup := userGroup.Group("/whitelabel", middleware.VerifyWhitelabel(true))
whitelabelGroup.GET("/", api_whitelabel.WhitelabelGet)
whitelabelApiGroup.GET("/errors", api_whitelabel.WhitelabelGetErrors)
whitelabelApiGroup.GET("/guilds", api_whitelabel.WhitelabelGetGuilds)
whitelabelApiGroup.GET("/public-key", api_whitelabel.WhitelabelGetPublicKey)
whitelabelApiGroup.POST("/public-key", api_whitelabel.WhitelabelPostPublicKey)
whitelabelApiGroup.POST("/create-interactions", api_whitelabel.GetWhitelabelCreateInteractions())
whitelabelGroup.GET("/errors", api_whitelabel.WhitelabelGetErrors)
whitelabelGroup.GET("/guilds", api_whitelabel.WhitelabelGetGuilds)
whitelabelGroup.GET("/public-key", api_whitelabel.WhitelabelGetPublicKey)
whitelabelGroup.POST("/public-key", api_whitelabel.WhitelabelPostPublicKey)
whitelabelGroup.POST("/create-interactions", api_whitelabel.GetWhitelabelCreateInteractions())
whitelabelApiGroup.POST("/", createLimiter(10, time.Minute), api_whitelabel.WhitelabelPost)
whitelabelApiGroup.POST("/status", createLimiter(1, time.Second*5), api_whitelabel.WhitelabelStatusPost)
whitelabelGroup.POST("/", createLimiter(10, time.Minute), api_whitelabel.WhitelabelPost)
whitelabelGroup.POST("/status", createLimiter(1, time.Second*5), api_whitelabel.WhitelabelStatusPost)
}
}
@ -163,82 +132,32 @@ func StartServer() {
func serveTemplate(templateName string) func(*gin.Context) {
return func(ctx *gin.Context) {
store := sessions.Default(ctx)
guildId := ctx.Keys["guildid"].(uint64)
userId := ctx.Keys["userid"].(uint64)
store, err := session.Store.Get(userId)
if err != nil {
if err == session.ErrNoSession {
ctx.JSON(401, gin.H{
"success": false,
"auth": true,
})
} else {
ctx.JSON(500, utils.ErrorJson(err))
}
return
}
ctx.HTML(200, templateName, gin.H{
"name": store.Get("name").(string),
"name": store.Name,
"guildId": guildId,
"avatar": store.Get("avatar").(string),
"avatar": store.Avatar,
"baseUrl": config.Conf.Server.BaseUrl,
})
}
}
func createRenderer() multitemplate.Renderer {
r := multitemplate.NewRenderer()
r = addMainTemplate(r, "index")
r = addMainTemplate(r, "whitelabel")
r = addManageTemplate(r, "blacklist")
r = addManageTemplate(r, "logs")
r = addManageTemplate(r, "modmaillogs")
r = addManageTemplate(r, "settings", "./public/templates/includes/substitutionmodal.tmpl")
r = addManageTemplate(r, "ticketlist")
r = addManageTemplate(r, "ticketview")
r = addManageTemplate(r, "panels", "./public/templates/includes/substitutionmodal.tmpl", "./public/templates/includes/paneleditmodal.tmpl", "./public/templates/includes/multipaneleditmodal.tmpl")
r = addManageTemplate(r, "tags")
r = addManageTemplate(r, "teams")
r = addErrorTemplate(r)
return r
}
func addMainTemplate(renderer multitemplate.Renderer, name string, extra ...string) multitemplate.Renderer {
files := []string{
"./public/templates/layouts/main.tmpl",
"./public/templates/includes/head.tmpl",
"./public/templates/includes/sidebar.tmpl",
"./public/templates/includes/loadingscreen.tmpl",
"./public/templates/includes/notifymodal.tmpl",
fmt.Sprintf("./public/templates/views/%s.tmpl", name),
}
files = append(files, extra...)
renderer.AddFromFiles(fmt.Sprintf("main/%s", name), files...)
return renderer
}
func addManageTemplate(renderer multitemplate.Renderer, name string, extra ...string) multitemplate.Renderer {
files := []string{
"./public/templates/layouts/manage.tmpl",
"./public/templates/includes/head.tmpl",
"./public/templates/includes/sidebar.tmpl",
"./public/templates/includes/navbar.tmpl",
"./public/templates/includes/loadingscreen.tmpl",
"./public/templates/includes/notifymodal.tmpl",
fmt.Sprintf("./public/templates/views/%s.tmpl", name),
}
files = append(files, extra...)
renderer.AddFromFiles(fmt.Sprintf("manage/%s", name), files...)
return renderer
}
func addErrorTemplate(renderer multitemplate.Renderer) multitemplate.Renderer {
files := []string{
"./public/templates/layouts/error.tmpl",
"./public/templates/includes/head.tmpl",
}
renderer.AddFromFiles("error", files...)
return renderer
}
func createLimiter(limit int64, period time.Duration) func(*gin.Context) {
store := memory.NewStore()
rate := limiter.Rate{

View File

@ -0,0 +1,54 @@
package session
import (
"encoding/json"
"errors"
"fmt"
"github.com/TicketsBot/GoPanel/messagequeue"
"github.com/go-redis/redis"
)
var ErrNoSession = errors.New("no session data found")
type RedisStore struct {
client *redis.Client
}
func NewRedisStore() *RedisStore {
return &RedisStore{
client: messagequeue.Client.Client,
}
}
var keyPrefix = "panel:session:"
func (s *RedisStore) Get(userId uint64) (SessionData, error) {
raw, err := s.client.Get(fmt.Sprintf("%s:%d", keyPrefix, userId)).Result()
if err != nil {
if err == redis.Nil {
err = ErrNoSession
}
return SessionData{}, err
}
var data SessionData
if err := json.Unmarshal([]byte(raw), &data); err != nil {
return SessionData{}, err
}
return data, nil
}
func (s *RedisStore) Set(userId uint64, data SessionData) error {
encoded, err := json.Marshal(data)
if err != nil {
return err
}
return s.client.Set(fmt.Sprintf("%s:%d", keyPrefix, userId), encoded, 0).Err()
}
func (s *RedisStore) Clear(userId uint64) error {
return s.client.Del(fmt.Sprintf("%s:%d", keyPrefix, userId)).Err()
}

View File

@ -0,0 +1,10 @@
package session
type SessionData struct {
AccessToken string `json:"access_token"`
Expiry int64 `json:"expiry"`
RefreshToken string `json:"refresh_token"`
Name string `json:"name"`
Avatar string `json:"avatar_hash"`
HasGuilds bool `json:"has_guilds"`
}

View File

@ -0,0 +1,9 @@
package session
type SessionStore interface {
Get(userId uint64) (SessionData, error)
Set(userId uint64, data SessionData) error
Clear(userId uint64) error
}
var Store SessionStore

View File

@ -112,3 +112,17 @@ func (ctx BotContext) SearchMembers(guildId uint64, query string) (members []mem
return
}
func (ctx BotContext) ListMembers(guildId uint64) (members []member.Member, err error) {
data := rest.ListGuildMembersData{
Limit: 100,
}
members, err = rest.ListGuildMembers(ctx.Token, ctx.RateLimiter, guildId, data)
if err == nil {
go cache.Instance.StoreMembers(members, guildId)
}
return
}

View File

@ -5,7 +5,7 @@ import (
"encoding/binary"
"fmt"
"github.com/TicketsBot/GoPanel/app/http"
"github.com/TicketsBot/GoPanel/app/http/endpoints/manage"
"github.com/TicketsBot/GoPanel/app/http/endpoints/root"
"github.com/TicketsBot/GoPanel/config"
"github.com/TicketsBot/GoPanel/database"
"github.com/TicketsBot/GoPanel/messagequeue"
@ -13,6 +13,7 @@ import (
"github.com/TicketsBot/GoPanel/rpc/cache"
"github.com/TicketsBot/GoPanel/utils"
"github.com/TicketsBot/archiverclient"
"github.com/TicketsBot/common/chatrelay"
"github.com/TicketsBot/common/premium"
"github.com/TicketsBot/worker/bot/i18n"
"github.com/apex/log"
@ -36,7 +37,7 @@ func main() {
database.ConnectToDatabase()
cache.Instance = cache.NewCache()
manage.Archiver = archiverclient.NewArchiverClientWithTimeout(config.Conf.Bot.ObjectStore, time.Second*15, []byte(config.Conf.Bot.AesKey))
utils.ArchiverClient = archiverclient.NewArchiverClientWithTimeout(config.Conf.Bot.ObjectStore, time.Second*15, []byte(config.Conf.Bot.AesKey))
utils.LoadEmoji()
if err := i18n.LoadMessages(database.Client); err != nil {
@ -48,7 +49,7 @@ func main() {
}
messagequeue.Client = messagequeue.NewRedisClient()
go Listen(messagequeue.Client)
go ListenChat(messagequeue.Client)
rpc.PremiumClient = premium.NewPremiumLookupClient(
premium.NewPatreonClient(config.Conf.Bot.PremiumLookupProxyUrl, config.Conf.Bot.PremiumLookupProxyKey),
@ -60,19 +61,19 @@ func main() {
http.StartServer()
}
func Listen(client messagequeue.RedisClient) {
ch := make(chan messagequeue.TicketMessage)
go client.ListenForMessages(ch)
func ListenChat(client messagequeue.RedisClient) {
ch := make(chan chatrelay.MessageData)
go chatrelay.Listen(client.Client, ch)
for decoded := range ch {
manage.SocketsLock.Lock()
for _, socket := range manage.Sockets {
if socket.Guild == decoded.GuildId && socket.Ticket == decoded.TicketId {
if err := socket.Ws.WriteJSON(decoded); err != nil {
for event := range ch {
root.SocketsLock.RLock()
for _, socket := range root.Sockets {
if socket.GuildId == event.Ticket.GuildId && socket.TicketId == event.Ticket.Id {
if err := socket.Ws.WriteJSON(event.Message); err != nil {
fmt.Println(err.Error())
}
}
}
manage.SocketsLock.Unlock()
root.SocketsLock.RUnlock()
}
}

View File

@ -18,7 +18,6 @@ type (
Bot Bot
Redis Redis
Cache Cache
Referral Referral
}
Server struct {
@ -119,7 +118,6 @@ func fromEnvvar() {
oauthId, _ := strconv.ParseUint(os.Getenv("OAUTH_ID"), 10, 64)
redisPort, _ := strconv.Atoi(os.Getenv("REDIS_PORT"))
redisThreads, _ := strconv.Atoi(os.Getenv("REDIS_THREADS"))
showReferral, _ := strconv.ParseBool(os.Getenv("REFERRAL_SHOW"))
Conf = Config{
Admins: admins,
@ -163,9 +161,5 @@ func fromEnvvar() {
Cache: Cache{
Uri: os.Getenv("CACHE_URI"),
},
Referral: Referral{
Show: showReferral,
Link: os.Getenv("REFERRAL_LINK"),
},
}
}

View File

@ -1,3 +1,13 @@
# Build
---
- CLIENT_ID
- REDIRECT_URI
- API_URL
- WS_URL
# Runtime
---
- ADMINS
- FORCED_WHITELABEL
- SERVER_ADDR
@ -22,5 +32,3 @@
- REDIS_PASSWORD
- REDIS_THREADS
- CACHE_URI
- REFERRAL_SHOW
- REFERRAL_LINK

4
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/node_modules/
/public/build
.DS_Store

876
frontend/package-lock.json generated Normal file
View File

@ -0,0 +1,876 @@
{
"name": "svelte-app",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@babel/code-frame": {
"version": "7.12.13",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.13.tgz",
"integrity": "sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g==",
"dev": true,
"requires": {
"@babel/highlight": "^7.12.13"
}
},
"@babel/helper-validator-identifier": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.0.tgz",
"integrity": "sha512-V3ts7zMSu5lfiwWDVWzRDGIN+lnCEUdaXgtVHJgLb1rGaA6jMrtB9EmE7L18foXJIE8Un/A/h6NJfGQp/e1J4A==",
"dev": true
},
"@babel/highlight": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.14.0.tgz",
"integrity": "sha512-YSCOwxvTYEIMSGaBQb5kDDsCopDdiUGsqpatp3fOlI4+2HQSkTmEVWnVuySdAC5EWCqSWWTv0ib63RjR7dTBdg==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.14.0",
"chalk": "^2.0.0",
"js-tokens": "^4.0.0"
}
},
"@fortawesome/fontawesome-common-types": {
"version": "0.2.35",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.35.tgz",
"integrity": "sha512-IHUfxSEDS9dDGqYwIW7wTN6tn/O8E0n5PcAHz9cAaBoZw6UpG20IG/YM3NNLaGPwPqgjBAFjIURzqoQs3rrtuw=="
},
"@fortawesome/free-regular-svg-icons": {
"version": "5.15.3",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.15.3.tgz",
"integrity": "sha512-q4/p8Xehy9qiVTdDWHL4Z+o5PCLRChePGZRTXkl+/Z7erDVL8VcZUuqzJjs6gUz6czss4VIPBRdCz6wP37/zMQ==",
"requires": {
"@fortawesome/fontawesome-common-types": "^0.2.35"
}
},
"@fortawesome/free-solid-svg-icons": {
"version": "5.15.3",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.3.tgz",
"integrity": "sha512-XPeeu1IlGYqz4VWGRAT5ukNMd4VHUEEJ7ysZ7pSSgaEtNvSo+FLurybGJVmiqkQdK50OkSja2bfZXOeyMGRD8Q==",
"requires": {
"@fortawesome/fontawesome-common-types": "^0.2.35"
}
},
"@polka/url": {
"version": "1.0.0-next.15",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.15.tgz",
"integrity": "sha512-15spi3V28QdevleWBNXE4pIls3nFZmBbUGrW9IVPwiQczuSb9n76TCB4bsk8TSel+I1OkHEdPhu5QKMfY6rQHA=="
},
"@rollup/plugin-commonjs": {
"version": "17.1.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-17.1.0.tgz",
"integrity": "sha512-PoMdXCw0ZyvjpCMT5aV4nkL0QywxP29sODQsSGeDpr/oI49Qq9tRtAsb/LbYbDzFlOydVEqHmmZWFtXJEAX9ew==",
"dev": true,
"requires": {
"@rollup/pluginutils": "^3.1.0",
"commondir": "^1.0.1",
"estree-walker": "^2.0.1",
"glob": "^7.1.6",
"is-reference": "^1.2.1",
"magic-string": "^0.25.7",
"resolve": "^1.17.0"
}
},
"@rollup/plugin-node-resolve": {
"version": "11.2.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.1.tgz",
"integrity": "sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==",
"dev": true,
"requires": {
"@rollup/pluginutils": "^3.1.0",
"@types/resolve": "1.17.1",
"builtin-modules": "^3.1.0",
"deepmerge": "^4.2.2",
"is-module": "^1.0.0",
"resolve": "^1.19.0"
}
},
"@rollup/plugin-replace": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz",
"integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==",
"dev": true,
"requires": {
"@rollup/pluginutils": "^3.1.0",
"magic-string": "^0.25.7"
}
},
"@rollup/pluginutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz",
"integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==",
"dev": true,
"requires": {
"@types/estree": "0.0.39",
"estree-walker": "^1.0.1",
"picomatch": "^2.2.2"
},
"dependencies": {
"estree-walker": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz",
"integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==",
"dev": true
}
}
},
"@types/estree": {
"version": "0.0.39",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz",
"integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==",
"dev": true
},
"@types/node": {
"version": "15.6.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-15.6.1.tgz",
"integrity": "sha512-7EIraBEyRHEe7CH+Fm1XvgqU6uwZN8Q7jppJGcqjROMT29qhAuuOxYB1uEY5UMYQKEmA5D+5tBnhdaPXSsLONA==",
"dev": true
},
"@types/resolve": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
"integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"dev": true,
"requires": {
"color-convert": "^1.9.0"
}
},
"anymatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
"integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
"dev": true,
"requires": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
}
},
"axios": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
"integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
"requires": {
"follow-redirects": "^1.10.0"
}
},
"balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
"binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
"dev": true
},
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"dev": true,
"requires": {
"fill-range": "^7.0.1"
}
},
"buffer-from": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
"integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==",
"dev": true
},
"builtin-modules": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.2.0.tgz",
"integrity": "sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==",
"dev": true
},
"chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"dev": true,
"requires": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
"supports-color": "^5.3.0"
}
},
"chokidar": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz",
"integrity": "sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==",
"dev": true,
"requires": {
"anymatch": "~3.1.1",
"braces": "~3.0.2",
"fsevents": "~2.3.1",
"glob-parent": "~5.1.0",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.5.0"
}
},
"color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"dev": true,
"requires": {
"color-name": "1.1.3"
}
},
"color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
"dev": true
},
"commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true
},
"commondir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
"integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=",
"dev": true
},
"concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
"dev": true
},
"console-clear": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/console-clear/-/console-clear-1.1.1.tgz",
"integrity": "sha512-pMD+MVR538ipqkG5JXeOEbKWS5um1H4LUUccUQG68qpeqBYbzYy79Gh55jkd2TtPdRfUaLWdv6LPP//5Zt0aPQ=="
},
"deepmerge": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
"integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==",
"dev": true
},
"escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
"dev": true
},
"estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true
},
"fa-svelte": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fa-svelte/-/fa-svelte-3.1.0.tgz",
"integrity": "sha512-RqBOWwt7sc+ta9GFjbu5GOwKFRzn3rMPPSqvSGpIwsfVnpMjiI5ttv84lwNsCMEYI6/lu/iH21HUcE3TLz8RGQ=="
},
"fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"dev": true,
"requires": {
"to-regex-range": "^5.0.1"
}
},
"follow-redirects": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.1.tgz",
"integrity": "sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg=="
},
"fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
"dev": true
},
"fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"optional": true
},
"function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
"dev": true
},
"get-port": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/get-port/-/get-port-3.2.0.tgz",
"integrity": "sha1-3Xzn3hh8Bsi/NTeWrHHgmfCYDrw="
},
"glob": {
"version": "7.1.7",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
"integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"requires": {
"is-glob": "^4.0.1"
}
},
"has": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
"dev": true,
"requires": {
"function-bind": "^1.1.1"
}
},
"has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
"dev": true
},
"inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
"dev": true,
"requires": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true
},
"is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"requires": {
"binary-extensions": "^2.0.0"
}
},
"is-core-module": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.4.0.tgz",
"integrity": "sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A==",
"dev": true,
"requires": {
"has": "^1.0.3"
}
},
"is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
"dev": true
},
"is-glob": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz",
"integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==",
"dev": true,
"requires": {
"is-extglob": "^2.1.1"
}
},
"is-module": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
"integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=",
"dev": true
},
"is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true
},
"is-reference": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
"integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==",
"dev": true,
"requires": {
"@types/estree": "*"
}
},
"jest-worker": {
"version": "26.6.2",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz",
"integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==",
"dev": true,
"requires": {
"@types/node": "*",
"merge-stream": "^2.0.0",
"supports-color": "^7.0.0"
},
"dependencies": {
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"requires": {
"has-flag": "^4.0.0"
}
}
}
},
"js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true
},
"kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
"integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="
},
"livereload": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/livereload/-/livereload-0.9.3.tgz",
"integrity": "sha512-q7Z71n3i4X0R9xthAryBdNGVGAO2R5X+/xXpmKeuPMrteg+W2U8VusTKV3YiJbXZwKsOlFlHe+go6uSNjfxrZw==",
"dev": true,
"requires": {
"chokidar": "^3.5.0",
"livereload-js": "^3.3.1",
"opts": ">= 1.2.0",
"ws": "^7.4.3"
}
},
"livereload-js": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-3.3.2.tgz",
"integrity": "sha512-w677WnINxFkuixAoUEXOStewzLYGI76XVag+0JWMMEyjJQKs0ibWZMxkTlB96Lm3EjZ7IeOxVziBEbtxVQqQZA==",
"dev": true
},
"local-access": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/local-access/-/local-access-1.1.0.tgz",
"integrity": "sha512-XfegD5pyTAfb+GY6chk283Ox5z8WexG56OvM06RWLpAc/UHozO8X6xAxEkIitZOtsSMM1Yr3DkHgW5W+onLhCw=="
},
"magic-string": {
"version": "0.25.7",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz",
"integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==",
"dev": true,
"requires": {
"sourcemap-codec": "^1.4.4"
}
},
"merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"dev": true
},
"mime": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.5.2.tgz",
"integrity": "sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg=="
},
"minimatch": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
}
},
"mri": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.1.6.tgz",
"integrity": "sha512-oi1b3MfbyGa7FJMP9GmLTttni5JoICpYBRlq+x5V16fZbLsnL9N3wFqqIm/nIG43FjUFkFh9Epzp/kzUGUnJxQ=="
},
"normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true
},
"once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
"dev": true,
"requires": {
"wrappy": "1"
}
},
"opts": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/opts/-/opts-2.0.2.tgz",
"integrity": "sha512-k41FwbcLnlgnFh69f4qdUfvDQ+5vaSDnVPFI/y5XuhKRq97EnVVneO9F1ESVCdiVu4fCS2L8usX3mU331hB7pg==",
"dev": true
},
"path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
"dev": true
},
"path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true
},
"picomatch": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz",
"integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==",
"dev": true
},
"popper.js": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
"integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ=="
},
"randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"dev": true,
"requires": {
"safe-buffer": "^5.1.0"
}
},
"readdirp": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz",
"integrity": "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==",
"dev": true,
"requires": {
"picomatch": "^2.2.1"
}
},
"require-relative": {
"version": "0.8.7",
"resolved": "https://registry.npmjs.org/require-relative/-/require-relative-0.8.7.tgz",
"integrity": "sha1-eZlTn8ngR6N5KPoZb44VY9q9Nt4=",
"dev": true
},
"resolve": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz",
"integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==",
"dev": true,
"requires": {
"is-core-module": "^2.2.0",
"path-parse": "^1.0.6"
}
},
"rollup": {
"version": "2.50.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.50.4.tgz",
"integrity": "sha512-mBQa9O6bdqur7a6R+TXcbdYgfO2arXlDG+rSrWfwAvsiumpJjD4OS23R9QuhItuz8ysWb8mZ91CFFDQUhJY+8Q==",
"dev": true,
"requires": {
"fsevents": "~2.3.1"
}
},
"rollup-plugin-css-only": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/rollup-plugin-css-only/-/rollup-plugin-css-only-3.1.0.tgz",
"integrity": "sha512-TYMOE5uoD76vpj+RTkQLzC9cQtbnJNktHPB507FzRWBVaofg7KhIqq1kGbcVOadARSozWF883Ho9KpSPKH8gqA==",
"dev": true,
"requires": {
"@rollup/pluginutils": "4"
},
"dependencies": {
"@rollup/pluginutils": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.1.0.tgz",
"integrity": "sha512-TrBhfJkFxA+ER+ew2U2/fHbebhLT/l/2pRk0hfj9KusXUuRXd2v0R58AfaZK9VXDQ4TogOSEmICVrQAA3zFnHQ==",
"dev": true,
"requires": {
"estree-walker": "^2.0.1",
"picomatch": "^2.2.2"
}
}
}
},
"rollup-plugin-livereload": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/rollup-plugin-livereload/-/rollup-plugin-livereload-2.0.0.tgz",
"integrity": "sha512-oC/8NqumGYuphkqrfszOHUUIwzKsaHBICw6QRwT5uD07gvePTS+HW+GFwu6f9K8W02CUuTvtIM9AWJrbj4wE1A==",
"dev": true,
"requires": {
"livereload": "^0.9.1"
}
},
"rollup-plugin-svelte": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/rollup-plugin-svelte/-/rollup-plugin-svelte-7.1.0.tgz",
"integrity": "sha512-vopCUq3G+25sKjwF5VilIbiY6KCuMNHP1PFvx2Vr3REBNMDllKHFZN2B9jwwC+MqNc3UPKkjXnceLPEjTjXGXg==",
"dev": true,
"requires": {
"require-relative": "^0.8.7",
"rollup-pluginutils": "^2.8.2"
}
},
"rollup-plugin-terser": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz",
"integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.10.4",
"jest-worker": "^26.2.1",
"serialize-javascript": "^4.0.0",
"terser": "^5.0.0"
}
},
"rollup-pluginutils": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz",
"integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==",
"dev": true,
"requires": {
"estree-walker": "^0.6.1"
},
"dependencies": {
"estree-walker": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz",
"integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==",
"dev": true
}
}
},
"sade": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.7.4.tgz",
"integrity": "sha512-y5yauMD93rX840MwUJr7C1ysLFBgMspsdTo4UVrDg3fXDvtwOyIqykhVAAm6fk/3au77773itJStObgK+LKaiA==",
"requires": {
"mri": "^1.1.0"
}
},
"safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"dev": true
},
"semiver": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/semiver/-/semiver-1.1.0.tgz",
"integrity": "sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg=="
},
"serialize-javascript": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
"integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
"dev": true,
"requires": {
"randombytes": "^2.1.0"
}
},
"sirv": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-1.0.12.tgz",
"integrity": "sha512-+jQoCxndz7L2tqQL4ZyzfDhky0W/4ZJip3XoOuxyQWnAwMxindLl3Xv1qT4x1YX/re0leShvTm8Uk0kQspGhBg==",
"requires": {
"@polka/url": "^1.0.0-next.15",
"mime": "^2.3.1",
"totalist": "^1.0.0"
}
},
"sirv-cli": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/sirv-cli/-/sirv-cli-1.0.12.tgz",
"integrity": "sha512-Rs5PvF3a48zuLmrl8vcqVv9xF/WWPES19QawVkpdzqx7vD5SMZS07+ece1gK4umbslXN43YeIksYtQM5csgIzQ==",
"requires": {
"console-clear": "^1.1.0",
"get-port": "^3.2.0",
"kleur": "^3.0.0",
"local-access": "^1.0.1",
"sade": "^1.6.0",
"semiver": "^1.0.0",
"sirv": "^1.0.12",
"tinydate": "^1.0.0"
}
},
"source-map": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
"integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==",
"dev": true
},
"source-map-support": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz",
"integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==",
"dev": true,
"requires": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
},
"dependencies": {
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true
}
}
},
"sourcemap-codec": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
"integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==",
"dev": true
},
"supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dev": true,
"requires": {
"has-flag": "^3.0.0"
}
},
"svelte": {
"version": "3.38.2",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-3.38.2.tgz",
"integrity": "sha512-q5Dq0/QHh4BLJyEVWGe7Cej5NWs040LWjMbicBGZ+3qpFWJ1YObRmUDZKbbovddLC9WW7THTj3kYbTOFmU9fbg==",
"dev": true
},
"svelte-click-outside": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/svelte-click-outside/-/svelte-click-outside-1.0.0.tgz",
"integrity": "sha512-TVDn5Vd8L0WI0Y9BFh/2I7judkIqYCbFKkGwGl/f8D0inwBFNyU0weKhrbJY4VQtYnWriq0NPl+mIYGisgALbw=="
},
"svelte-emoji-selector": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/svelte-emoji-selector/-/svelte-emoji-selector-1.0.1.tgz",
"integrity": "sha512-gGjDydt+79YQIdUyz/r1sHSkjLko2rb9qHNiBveC5RSl6rJ0mob4T5DrADRArjQ/HA8kNfEJFyqbnLoA+dyLqA==",
"requires": {
"@fortawesome/free-regular-svg-icons": "^5.10.1",
"@fortawesome/free-solid-svg-icons": "^5.10.1",
"fa-svelte": "^3.0.0",
"popper.js": "^1.15.0",
"svelte-click-outside": "^1.0.0",
"svelte-tabs": "^1.1.0"
}
},
"svelte-router-spa": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/svelte-router-spa/-/svelte-router-spa-6.0.2.tgz",
"integrity": "sha512-ySs/2TnjdLnvo0tHfdJsRPhPl0Mj4/h2qi0Zb8t4zC+BBBaCr6cZc7MtRfgzD4IMp80Nqe7ZXd/hCJuHSGtf5A==",
"requires": {
"url-params-parser": "^1.0.3"
}
},
"svelte-select": {
"version": "3.17.0",
"resolved": "https://registry.npmjs.org/svelte-select/-/svelte-select-3.17.0.tgz",
"integrity": "sha512-ITmX/XUiSdkaILmsTviKRkZPaXckM5/FA7Y8BhiUPoamaZG/ZDyOo6ydjFu9fDVFTbwoAUGUi6HBjs+ZdK2AwA=="
},
"svelte-tabs": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/svelte-tabs/-/svelte-tabs-1.1.0.tgz",
"integrity": "sha512-bCynxgET2uvqpB6xf/dVyqHjzmumRURQyh2QqXlrki8NxzO7h2WghF8qgpb5qeB5NTX1bMU+9Q5Hf5ey2WLaMg=="
},
"terser": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.7.0.tgz",
"integrity": "sha512-HP5/9hp2UaZt5fYkuhNBR8YyRcT8juw8+uFbAme53iN9hblvKnLUTKkmwJG6ocWpIKf8UK4DoeWG4ty0J6S6/g==",
"dev": true,
"requires": {
"commander": "^2.20.0",
"source-map": "~0.7.2",
"source-map-support": "~0.5.19"
}
},
"tinydate": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/tinydate/-/tinydate-1.3.0.tgz",
"integrity": "sha512-7cR8rLy2QhYHpsBDBVYnnWXm8uRTr38RoZakFSW7Bs7PzfMPNZthuMLkwqZv7MTu8lhQ91cOFYS5a7iFj2oR3w=="
},
"to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"requires": {
"is-number": "^7.0.0"
}
},
"totalist": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-1.1.0.tgz",
"integrity": "sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g=="
},
"url-params-parser": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/url-params-parser/-/url-params-parser-1.0.4.tgz",
"integrity": "sha512-0m6BqGpY2OetTZ3UPTLKkbTfUHigsX2YhrzORT9iYiyUJ/SP2WJ3cggg2YWtvMs36GPwK9Q44ffddyarniu2Tg=="
},
"wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
"dev": true
},
"ws": {
"version": "7.4.6",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz",
"integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==",
"dev": true
}
}
}

28
frontend/package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "svelte-app",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "rollup -c",
"dev": "rollup -c -w",
"start": "sirv public -s --no-clear --host 0.0.0.0"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^17.0.0",
"@rollup/plugin-node-resolve": "^11.0.0",
"@rollup/plugin-replace": "^2.4.2",
"rollup": "^2.3.4",
"rollup-plugin-css-only": "^3.1.0",
"rollup-plugin-livereload": "^2.0.0",
"rollup-plugin-svelte": "^7.0.0",
"rollup-plugin-terser": "^7.0.0",
"svelte": "^3.0.0"
},
"dependencies": {
"axios": "^0.21.1",
"sirv-cli": "^1.0.0",
"svelte-emoji-selector": "^1.0.1",
"svelte-router-spa": "^6.0.2",
"svelte-select": "^3.17.0"
}
}

View File

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 964 B

After

Width:  |  Height:  |  Size: 964 B

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,64 @@
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans&display=swap');
html, body {
position: relative;
width: 100%;
height: 100%;
}
body, h1, .h1, h2, .h2, h3, .h3, h4, .h4, h5, .h5, h6, .h6, p, .navbar, .brand, .btn-simple, .alert, a, .td-name, td, button.close {
font-family: 'Noto Sans', sans-serif !important;
font-weight: 400 !important;
}
h1, h2, h3, h4, h5, h6, p, span {
margin: 0;
}
body {
line-height: 1.5;
font-size: 1rem;
font-weight: 400;
background-color: #121212 !important;
color: white;
margin: 0;
padding: 0 !important;
box-sizing: border-box;
/*font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;*/
}
label {
display: block;
}
input, button, select, textarea {
font-family: inherit;
font-size: inherit;
-webkit-padding: 0.4em 0;
padding: 0.4em;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 2px;
}
input:disabled {
color: #ccc;
}
button {
color: #333;
background-color: #f4f4f4;
outline: none;
}
button:disabled {
color: #999;
}
button:not(:disabled):active {
background-color: #ddd;
}
button:focus {
border-color: #666;
}

View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width,initial-scale=1'>
<title>Tickets Dashboard</title>
<link rel='icon' type='image/ico' href='/favicon.ico'>
<link rel='stylesheet' href='/global.css'>
<link rel='stylesheet' href='/build/bundle.css'>
<script defer src='/build/bundle.js'></script>
</head>
<body>
</body>
</html>

86
frontend/rollup.config.js Normal file
View File

@ -0,0 +1,86 @@
import svelte from 'rollup-plugin-svelte';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import livereload from 'rollup-plugin-livereload';
import {terser} from 'rollup-plugin-terser';
import css from 'rollup-plugin-css-only';
import replace from "@rollup/plugin-replace";
const production = !process.env.ROLLUP_WATCH;
function serve() {
let server;
function toExit() {
if (server) server.kill(0);
}
return {
writeBundle() {
if (server) return;
server = require('child_process').spawn('npm', ['run', 'start', '--', '--dev'], {
stdio: ['ignore', 'inherit', 'inherit'],
shell: true
});
process.on('SIGTERM', toExit);
process.on('exit', toExit);
}
};
}
export default {
input: 'src/main.js',
output: {
sourcemap: true,
format: 'iife',
name: 'app',
file: 'public/build/bundle.js'
},
plugins: [
svelte({
compilerOptions: {
// enable run-time checks when not in production
dev: !production
}
}),
// we'll extract any component CSS out into
// a separate file - better for performance
css({output: 'bundle.css'}),
// If you have external dependencies installed from
// npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration -
// consult the documentation for details:
// https://github.com/rollup/plugins/tree/master/packages/commonjs
resolve({
browser: true,
dedupe: ['svelte']
}),
commonjs(),
replace({
env: JSON.stringify({
CLIENT_ID: process.env.CLIENT_ID,
REDIRECT_URI: process.env.REDIRECT_URI,
API_URL: process.env.API_URL,
WS_URL: process.env.WS_URL,
})
}),
// In dev mode, call `npm run start` once
// the bundle has been generated
!production && serve(),
// Watch the `public` directory and refresh the
// browser on changes when not in production
!production && livereload('public'),
// If we're building for production (npm run build
// instead of npm run dev), minify
production && terser()
],
watch: {
clearScreen: false
}
};

View File

@ -0,0 +1,117 @@
// @ts-check
/** This script modifies the project to support TS code in .svelte files like:
<script lang="ts">
export let name: string;
</script>
As well as validating the code for CI.
*/
/** To work on this script:
rm -rf test-template template && git clone sveltejs/template test-template && node scripts/setupTypeScript.js test-template
*/
const fs = require("fs")
const path = require("path")
const { argv } = require("process")
const projectRoot = argv[2] || path.join(__dirname, "..")
// Add deps to pkg.json
const packageJSON = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"))
packageJSON.devDependencies = Object.assign(packageJSON.devDependencies, {
"svelte-check": "^1.0.0",
"svelte-preprocess": "^4.0.0",
"@rollup/plugin-typescript": "^8.0.0",
"typescript": "^4.0.0",
"tslib": "^2.0.0",
"@tsconfig/svelte": "^1.0.0"
})
// Add script for checking
packageJSON.scripts = Object.assign(packageJSON.scripts, {
"validate": "svelte-check"
})
// Write the package JSON
fs.writeFileSync(path.join(projectRoot, "package.json"), JSON.stringify(packageJSON, null, " "))
// mv src/main.js to main.ts - note, we need to edit rollup.config.js for this too
const beforeMainJSPath = path.join(projectRoot, "src", "main.js")
const afterMainTSPath = path.join(projectRoot, "src", "main.ts")
fs.renameSync(beforeMainJSPath, afterMainTSPath)
// Switch the app.svelte file to use TS
const appSveltePath = path.join(projectRoot, "src", "App.svelte")
let appFile = fs.readFileSync(appSveltePath, "utf8")
appFile = appFile.replace("<script>", '<script lang="ts">')
appFile = appFile.replace("export let name;", 'export let name: string;')
fs.writeFileSync(appSveltePath, appFile)
// Edit rollup config
const rollupConfigPath = path.join(projectRoot, "rollup.config.js")
let rollupConfig = fs.readFileSync(rollupConfigPath, "utf8")
// Edit imports
rollupConfig = rollupConfig.replace(`'rollup-plugin-terser';`, `'rollup-plugin-terser';
import sveltePreprocess from 'svelte-preprocess';
import typescript from '@rollup/plugin-typescript';`)
// Replace name of entry point
rollupConfig = rollupConfig.replace(`'src/main.js'`, `'src/main.ts'`)
// Add preprocessor
rollupConfig = rollupConfig.replace(
'compilerOptions:',
'preprocess: sveltePreprocess({ sourceMap: !production }),\n\t\t\tcompilerOptions:'
);
// Add TypeScript
rollupConfig = rollupConfig.replace(
'commonjs(),',
'commonjs(),\n\t\ttypescript({\n\t\t\tsourceMap: !production,\n\t\t\tinlineSources: !production\n\t\t}),'
);
fs.writeFileSync(rollupConfigPath, rollupConfig)
// Add TSConfig
const tsconfig = `{
"extends": "@tsconfig/svelte/tsconfig.json",
"include": ["src/**/*"],
"exclude": ["node_modules/*", "__sapper__/*", "public/*"]
}`
const tsconfigPath = path.join(projectRoot, "tsconfig.json")
fs.writeFileSync(tsconfigPath, tsconfig)
// Delete this script, but not during testing
if (!argv[2]) {
// Remove the script
fs.unlinkSync(path.join(__filename))
// Check for Mac's DS_store file, and if it's the only one left remove it
const remainingFiles = fs.readdirSync(path.join(__dirname))
if (remainingFiles.length === 1 && remainingFiles[0] === '.DS_store') {
fs.unlinkSync(path.join(__dirname, '.DS_store'))
}
// Check if the scripts folder is empty
if (fs.readdirSync(path.join(__dirname)).length === 0) {
// Remove the scripts folder
fs.rmdirSync(path.join(__dirname))
}
}
// Adds the extension recommendation
fs.mkdirSync(path.join(projectRoot, ".vscode"), { recursive: true })
fs.writeFileSync(path.join(projectRoot, ".vscode", "extensions.json"), `{
"recommendations": ["svelte.svelte-vscode"]
}
`)
console.log("Converted to TypeScript.")
if (fs.existsSync(path.join(projectRoot, "node_modules"))) {
console.log("\nYou will need to re-run your dependency manager to get started.")
}

6
frontend/src/App.svelte Normal file
View File

@ -0,0 +1,6 @@
<Router {routes} />
<script>
import { Router } from 'svelte-router-spa'
import { routes } from './routes'
</script>

View File

@ -0,0 +1,68 @@
<button on:click isTrigger="1" class:fullWidth class:danger {disabled} {type}>
{#if icon !== undefined}
<i class="{icon}"></i>
{/if}
<span class="content">
<slot />
</span>
</button>
<script>
export let icon;
export let fullWidth = false;
export let disabled = false;
export let type = "submit";
export let danger = false;
</script>
<style>
button {
display: flex;
justify-content: center;
align-items: center;
text-align: center;
color: white;
background-color: #3472f7;
border-color: #3472f7;
border-width: 2px;
border-radius: .25rem;
margin: 0;
cursor: pointer;
transition: background-color 150ms ease-in-out, border-color 150ms ease-in-out;
box-shadow: 0 4px 4px rgb(0 0 0 / 25%);
}
button:active, button:hover:enabled {
background-color: #0062cc;
border-color: #0062cc;
}
button:disabled {
background-color: #6c757d;
border-color: #6c757d;
cursor: default;
}
.content {
margin-left: 5px;
margin-right: 5px;
}
.fullWidth {
width: 100%;
}
.danger {
background-color: #dc3545 !important;
border-color: #dc3545 !important;
}
.danger:hover:enabled, .danger:active {
background-color: #c32232 !important;
border-color: #c32232 !important;
}
</style>

View File

@ -0,0 +1,137 @@
<script>
export let footer = true;
export let fill = true;
export let footerRight = false;
export let dropdown = false;
export let ref;
let dropdownActive = false;
</script>
<div class="card" class:fill>
<div class="card-header" class:dropdown on:click={() => dropdownActive = dropdown && !dropdownActive}>
<h4 class="card-title">
<slot name="title">
No Title :(
</slot>
</h4>
</div>
<div class="card-body" class:dropdown class:dropdownActive class:dropdownInactive={dropdown && !dropdownActive} {ref}>
<div class="inner" class:dropdown>
<slot name="body">
No Content :(
</slot>
</div>
</div>
{#if footer}
<div class="card-footer">
<div class="footer-content" class:footerRight>
<slot name="footer" />
</div>
</div>
{/if}
</div>
<style>
.card {
display: flex;
flex-direction: column;
background-color: #272727 !important;
width: 100%;
border-radius: 5px;
box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25);
transition: all .3s ease-in-out;
}
.fill {
height: 100%;
}
.card-title {
color: white;
font-size: 22px;
font-weight: bolder;
padding: 10px 20px;
margin: 0;
}
.card-header {
display: flex;
border-bottom: 1px solid rgba(0, 0, 0, .125);
}
.card-header.dropdown {
cursor: pointer;
user-select: none;
}
.card-body {
display: flex;
flex: 1;
min-height: 75px;
color: white;
margin: 10px 20px;
}
.inner {
display: flex;
height: 100%;
width: 100%;
}
.inner.dropdown {
position: absolute;
}
.card-body.dropdown {
position: relative;
transition: min-height .3s ease-in-out, margin-top .3s ease-in-out, margin-bottom .3s ease-in-out;
}
.card-body.dropdownInactive {
height: 0;
visibility: hidden;
margin: 0;
flex: unset;
min-height: 0 !important;
}
.card-body.dropdownActive {
visibility: visible;
min-height: auto;
overflow: hidden;
}
.card-footer {
display: flex;
color: white;
border-top: 1px solid rgba(0, 0, 0, .125);
padding: 10px 20px;
}
.footer-content {
display: flex;
align-items: center;
height: 100%;
width: 100%;
}
.footerRight {
flex-direction: row-reverse;
}
:global(div [slot=footer]) {
display: flex;
flex-direction: row;
}
.inner > * {
width: 100%;
}
</style>

View File

@ -0,0 +1,22 @@
<Dropdown col1={col1} col2={col2} col3={col3} col4={col4} bind:value={value} label={label}>
{#each channels as channel}
{#if channel.type === 4}
<option value={channel.id}>
{channel.name}
</option>
{/if}
{/each}
</Dropdown>
<script>
import Dropdown from "./form/Dropdown.svelte"
export let value;
export let label;
export let channels;
export let col1 = false;
export let col2 = false;
export let col3 = false;
export let col4 = false;
</script>

View File

@ -0,0 +1,22 @@
<Dropdown {col1} {col2} {col3} {col4} bind:value label={label}>
{#each channels as channel}
{#if channel.type === 0}
<option value="{channel.id}">
#{channel.name}
</option>
{/if}
{/each}
</Dropdown>
<script>
import Dropdown from "./form/Dropdown.svelte"
export let value;
export let label;
export let channels = [];
export let col1 = false;
export let col2 = false;
export let col3 = false;
export let col4 = false;
</script>

View File

@ -0,0 +1,108 @@
<div class="discord-container">
<div class="channel-header">
<span id="channel-name">#ticket-{ticketId}</span>
</div>
<div id="message-container" bind:this={container}>
{#each messages as message}
<div class="message">
<b>{message.author.username}:</b> {message.content}
</div>
{/each}
</div>
<div class="input-container">
<form on:submit|preventDefault={sendMessage}>
<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 response to tickets through the dashboard'}">
</form>
</div>
</div>
<script>
import {createEventDispatcher} from "svelte";
export let ticketId;
export let isPremium = false;
export let messages = [];
export let container;
const dispatch = createEventDispatcher();
let sendContent = '';
function sendMessage() {
dispatch('send', sendContent);
sendContent = '';
}
</script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Catamaran:wght@300;400;500;600;700;800&display=swap');
.discord-container {
display: flex;
flex-direction: column;
background-color: #2e3136;
border-radius: 4px;
height: 80vh;
max-height: 100vh;
margin: 0;
padding: 0;
font-family: 'Catamaran', sans-serif !important;
}
.channel-header {
display: flex;
align-items: center;
background-color: #1e2124;
height: 5vh;
width: 100%;
border-radius: 4px 4px 0 0;
position: relative;
text-align: center;
}
#channel-name {
color: white;
font-weight: bold;
padding-left: 20px;
}
#message-container {
display: flex;
flex-direction: column;
flex: 1;
position: relative;
overflow-y: scroll;
overflow-wrap: break-word;
}
.message {
color: white !important;
padding-left: 20px;
}
#message-container:last-child {
margin-bottom: 5px;
}
.message-input {
display: flex;
font-size: 16px;
line-height: 24px;
height: 40px;
padding: 8px;
border-color: #2e3136 !important;
background-color: #2e3136 !important;
color: white !important;
width: 100%;
}
.message-input:focus, .message-input:focus-visible {
outline-width: 0;
}
</style>

View File

@ -0,0 +1,108 @@
<div class="guild-badge" on:click={goto(guild.id)}>
<div class="guild-icon-bg">
{#if guild.icon === undefined || guild.icon === ""}
<i class="fas fa-question guild-icon-fa"></i>
{:else}
<img class="guild-icon" src="{getIconUrl()}" alt="Guild Icon"/>
{/if}
</div>
<div>
<span class="guild-name">
{guild.name}
</span>
</div>
</div>
<script>
import axios from 'axios';
import {API_URL} from "../js/constants";
import {notifyError} from "../js/util";
export let guild;
function isAnimated() {
if (guild.icon === undefined || guild.icon === "") {
return false;
} else {
return guild.icon.startsWith('a_')
}
}
function getIconUrl() {
if (isAnimated()) {
return `https:\/\/cdn.discordapp.com/icons/${guild.id}/${guild.icon}.gif?size=256`
} else {
return `https:\/\/cdn.discordapp.com/icons/${guild.id}/${guild.icon}.webp?size=256`
}
}
async function goto(guildId) {
const permissionLevels = await getPermissionLevel(guildId);
if (permissionLevels[guildId] === 2) {
window.location.href = `/manage/${guildId}/settings`;
} else {
window.location.href = `/manage/${guildId}/transcripts`;
}
}
async function getPermissionLevel(guildId) {
const res = await axios.get(`${API_URL}/user/permissionlevel?guilds=${guildId}`);
if (res.status !== 200 || !res.data.success) {
notifyError(res.data.error);
return;
}
return res.data.levels;
}
</script>
<style>
:global(.guild-badge) {
display: flex;
align-items: center;
box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25);
width: 33%;
background-color: #121212;
height: 100px;
margin-bottom: 10px;
border-radius: 10px;
cursor: pointer;
}
@media (max-width: 950px) {
:global(.guild-badge) {
width: 100%;
}
}
:global(.guild-icon-bg) {
height: 80px;
width: 80px;
background-color: #272727;
border-radius: 50%;
margin-left: 10px;
}
:global(.guild-icon) {
height: 80px;
width: 80px;
border-radius: 50%;
}
:global(.guild-icon-fa) {
border-radius: 50%;
color: white;
font-size: 60px !important;
width: 80px;
height: 80px;
text-align: center;
margin-top: 10px;
}
:global(.guild-name) {
color: white !important;
padding-left: 10px;
}
</style>

View File

@ -0,0 +1,15 @@
<div class="guild-badge" on:click={invite}>
<div class="guild-icon-bg">
<i class="fas fa-plus fa-2x guild-icon-fa"></i>
</div>
<div>
<span class="guild-name">Invite to your server</span>
</div>
</div>
<script>
function invite() {
window.location.href = `https://invite.ticketsbot.net`;
}
</script>

View File

@ -0,0 +1,17 @@
<Radio label="Naming Scheme" col4=true>
<div class="radio-row">
<input class="radio-input" type=radio bind:group={value} value="id">
<label class="radio-label">#ticket-1</label>
</div>
<div class="radio-row">
<input class="radio-input" type=radio bind:group={value} value="username">
<label class="radio-label">#ticket-ryan</label>
</div>
</Radio>
<script>
import Radio from "./form/Radio.svelte";
export let value;
</script>

View File

@ -0,0 +1,41 @@
<div class="nav-element">
{#if link}
<Navigate to="{link}" styles="link row" on:click>
<div class="icon">
<i class="{icon}"></i>
</div>
<slot/>
</Navigate>
{:else}
<a class="link row" on:click>
<div class="icon">
<i class="{icon}"></i>
</div>
<slot/>
</a>
{/if}
</div>
<script>
export let icon;
export let link;
import {Navigate} from 'svelte-router-spa'
</script>
<style>
.nav-element {
padding: 20px 0 20px 15px;
}
:global(.link) {
display: flex;
color: inherit;
text-decoration: none;
cursor: pointer;
}
.icon {
width: 24px;
}
</style>

View File

@ -0,0 +1,28 @@
<label class="form-label">{label}</label>
<div class="multiselect-super">
<Select placeholder="Select..." items={panels} optionIdentifier="panel_id" getOptionLabel={labelMapper}
getSelectionLabel={labelMapper} bind:selectedValue={panelsRaw}
on:select={update} isMulti={true}/>
</div>
<script>
import Select from 'svelte-select';
export let label;
export let panels = [];
export let selected = [];
let panelsRaw = [];
function labelMapper(panel) {
return panel.title;
}
function update() {
if (panelsRaw === undefined) {
panelsRaw = [];
}
selected = panelsRaw.map((panel) => panel.panel_id);
}
</script>

View File

@ -0,0 +1,21 @@
<div class:col-1={col1} class:col-2={col2} class:col-3={col3} class:col-4={col4}>
<label for="input" class="form-label">{label}</label>
<input id="input" class="form-checkbox" type=checkbox bind:checked={value} on:change>
</div>
<script>
export let value;
export let label;
export let col1 = false;
export let col2 = false;
export let col3 = false;
export let col4 = false;
</script>
<style>
.form-checkbox {
height: 40px;
width: 40px;
}
</style>

View File

@ -0,0 +1,20 @@
<div class:col-1={col1} class:col-2={col2} class:col-3={col3} class:col-4={col4}>
<label for="input" class="form-label">{label}</label>
<input id="input" class="form-input" type="color" on:input on:change bind:value={value}>
</div>
<style>
input {
width: 100%;
}
</style>
<script>
export let value;
export let label;
export let col1 = false;
export let col2 = false;
export let col3 = false;
export let col4 = false;
</script>

View File

@ -0,0 +1,22 @@
<div class:col-1={col1} class:col-2={col2} class:col-3={col3} class:col-4={col4}>
<label for="input" class="form-label">{label}</label>
<select id="input" class="form-input" on:change bind:value={value}>
<slot />
</select>
</div>
<style>
select {
width: 100%;
}
</style>
<script>
export let value;
export let label;
export let col1 = false;
export let col2 = false;
export let col3 = false;
export let col4 = false;
</script>

View File

@ -0,0 +1,54 @@
<div class:col-1={col1} class:col-2={col2} class:col-3={col3} class:col-4={col4}>
<label for="input" class="form-label">{label}</label>
<div class="wrapper">
<input id="input" class="form-input" readonly placeholder="{placeholder}" disabled="{disabled}" bind:value={value}>
{#if !disabled}
<EmojiSelector on:emoji={onUpdate}/>
{/if}
</div>
</div>
<script>
export let value;
export let label;
export let placeholder;
export let disabled = false;
export let col1 = false;
export let col2 = false;
export let col3 = false;
export let col4 = false;
import EmojiSelector from 'svelte-emoji-selector'
function onUpdate(e) {
value = e.detail;
}
</script>
<style>
input {
width: 100%;
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.wrapper {
display: flex;
flex-direction: row;
width: 100%;
}
:global(.svelte-emoji-picker__trigger) {
border-bottom-left-radius: 0;
border-top-left-radius: 0;
background-color: #272727;
border-color: #2e3136 !important;
border-left: none;
color: white;
}
:global(.svelte-emoji-picker__trigger:active) {
background-color: #2e3136 !important;
}
</style>

View File

@ -0,0 +1,25 @@
<div class:col-1={col1} class:col-2={col2} class:col-3={col3} class:col-4={col4}>
{#if label !== undefined}
<label for="input" class="form-label">{label}</label>
{/if}
<input id="input" class="form-input" placeholder="{placeholder}" disabled="{disabled}" on:input on:change
bind:value={value}>
</div>
<script>
export let value;
export let label;
export let placeholder;
export let disabled = false;
export let col1 = false;
export let col2 = false;
export let col3 = false;
export let col4 = false;
</script>
<style>
input {
width: 100%;
}
</style>

View File

@ -0,0 +1,285 @@
<!--
Based upon https://svelte.dev/repl/c7094fb1004b440482d2a88f4d1d7ef5?version=3.14.0
Heavily ammended
-->
<script>
import {fly} from 'svelte/transition';
export let values = {};
export let selected = [];
let filtered = [];
let input,
inputValue = '',
options = [],
activeOption,
showOptions = false,
first = true,
slot
const iconClearPath = 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z';
function updateFiltered() {
filtered = Object.entries(values).filter(([_, name]) => name.includes(inputValue));
}
updateFiltered();
/*afterUpdate(() => {
let newOptions = [];
slot.querySelectorAll('option').forEach(o => {
o.selected && !value.includes(o.value) && (value = [...value, o.value]);
newOptions = [...newOptions, {value: o.value, name: o.textContent}]
});
value && (selected = newOptions.reduce((obj, op) => value.includes(op.value) ? {
...obj,
[op.value]: op
} : obj, {}));
first = false;
options = newOptions;
});
$: if (!first) value = Object.values(selected).map(o => o.value);
$: filtered = options.filter(o => inputValue ? o.name.toLowerCase().includes(inputValue.toLowerCase()) : o);
$: if (activeOption && !filtered.includes(activeOption) || !activeOption && inputValue) activeOption = filtered[0];*/
function add(value) {
selected = [...selected, value];
}
function remove(value) {
selected = selected.filter((e) => e !== value);
}
function optionsVisibility(show) {
if (typeof show === 'boolean') {
showOptions = show;
show && input.focus();
} else {
showOptions = !showOptions;
}
if (!showOptions) {
activeOption = undefined;
}
}
function handleKeyup(e) {
updateFiltered();
/*if (e.keyCode === 13) {
Object.keys(selected).includes(activeOption.value) ? remove(activeOption.value) : add(activeOption);
inputValue = '';
}
if ([38, 40].includes(e.keyCode)) { // up and down arrows
const increment = e.keyCode === 38 ? -1 : 1;
const calcIndex = filtered.indexOf(activeOption) + increment;
activeOption = calcIndex < 0 ? filtered[filtered.length - 1]
: calcIndex === filtered.length ? filtered[0]
: filtered[calcIndex];
}*/
}
function handleBlur(e) {
optionsVisibility(false);
}
function handleTokenClick(e) {
if (e.target.closest('.token-remove')) {
e.stopPropagation();
remove(e.target.closest('.token').dataset.id);
} else {
updateFiltered();
optionsVisibility(true);
}
}
function toggle(id) {
if (isSelected(id)) {
selected = selected.filter((e) => e !== id);
} else {
$: selected.push(id);
}
}
function isSelected(value) {
return selected.find((option) => option === value);
}
</script>
<style>
.multiselect {
background-color: #2e3136;
border-color: #2e3136;
border-radius: 4px;
position: relative;
width: 100%
}
.tokens {
align-items: center;
display: flex;
flex-wrap: wrap;
position: relative;
padding: 5px 10px;
}
.tokens::after {
background: none repeat scroll 0 0 transparent;
bottom: -1px;
content: "";
display: block;
height: 2px;
left: 50%;
position: absolute;
background: hsl(45, 100%, 51%);
transition: width 0.3s ease 0s, left 0.3s ease 0s;
width: 0;
}
.tokens.showOptions::after {
width: 100%;
left: 0;
}
.token {
align-items: center;
background-color: #272727;
border-radius: 1.25rem;
display: flex;
margin: .25rem .5rem .25rem 0;
max-height: 1.3rem;
padding: .25rem .5rem .25rem .5rem;
transition: background-color .3s;
white-space: nowrap;
}
.token-remove, .remove-all {
align-items: center;
background-color: #3472f7;
transition: background-color .3s;
border-radius: 50%;
display: flex;
justify-content: center;
height: 1.25rem;
margin-left: .25rem;
min-width: 1.25rem;
}
.token-remove:hover, .remove-all:hover {
background-color: #0062cc;
cursor: pointer;
}
.actions {
align-items: center;
display: flex;
flex: 1;
min-width: 15rem;
}
input {
border: none;
color: white;
line-height: 1.5rem;
margin: 0;
outline: none;
padding: 0;
width: 100%;
}
.dropdown-arrow path {
fill: hsl(0, 0%, 70%);
}
.multiselect:hover .dropdown-arrow path {
fill: hsl(0, 0%, 50%);
}
.icon-clear path {
fill: white;
}
.options {
box-shadow: 0 2px 4px rgba(0, 0, 0, .1), 0 -2px 4px rgba(0, 0, 0, .1);
left: 0;
list-style: none;
margin-block-end: 0;
margin-block-start: 0;
max-height: 300px;
overflow-y: scroll;
overflow-x: hidden;
padding-inline-start: 0;
position: absolute;
top: calc(100% + 1px);
width: 100%;
}
li {
background-color: #2e3136;
color: white;
cursor: pointer;
padding: .5rem;
}
li.selected {
background-color: #121212;
}
li.selected:hover {
background-color: #121212;
}
li:hover {
background-color: #272727;
}
li:last-child {
border-bottom-left-radius: .2rem;
border-bottom-right-radius: .2rem;
}
.hidden {
display: none;
}
.search {
background-color: #2e3136;
caret-color: white;
}
</style>
<div class="multiselect">
<div class="tokens" class:showOptions on:click={handleTokenClick}>
{selected}
{#each selected as value}
value
<div class="token" data-id="{value}">
<span>{values[selected]}</span>
<div class="token-remove" title="Remove {values[selected]}">
<svg class="icon-clear" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24">
<path d="{iconClearPath}"/>
</svg>
</div>
</div>
{/each}
<div class="actions">
<input class="search" autocomplete="off" bind:value={inputValue} bind:this={input}
on:keyup={handleKeyup} on:blur={handleBlur}>
<svg class="dropdown-arrow" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18">
<path d="M5 8l4 4 4-4z"></path>
</svg>
</div>
</div>
<select bind:this={slot} type="multiple" class="hidden">
<slot/>
</select>
{#if showOptions}
<ul class="options" transition:fly="{{duration: 200, y: 5}}">
{#each filtered as option}
<li class:selected={isSelected[option[0]]} data-value="{option[0]}" on:click={toggle(option[0])}>{option[1]}</li>
{/each}
</ul>
{/if}
</div>

View File

@ -0,0 +1,32 @@
<div class:col-1={col1} class:col-2={col2} class:col-3={col3} class:col-4={col4}>
<label for="input" class="form-label">{label}</label>
<input
id="input" class="form-input" type="number"
min={min} max={max} bind:value={value}
on:input={validateMax} on:change={validateMin}>
</div>
<script>
export let value;
export let label;
export let min;
export let max;
export let col1 = false;
export let col2 = false;
export let col3 = false;
export let col4 = false;
function validateMax() {
if (value > max) {
value = max;
}
}
// If we validateMin on input, the user can never backspace to enter a number
function validateMin() {
if (value < min) {
value = min;
}
}
</script>

View File

@ -0,0 +1,38 @@
<div class:col-1={col1} class:col-2={col2} class:col-3={col3} class:col-4={col4}>
<label class="form-label">{label}</label>
<slot />
</div>
<script>
export let label;
export let col1 = false;
export let col2 = false;
export let col3 = false;
export let col4 = false;
</script>
<style>
select {
width: 100%;
}
:global(.radio-row) {
display: flex;
flex-direction: row;
align-items: center;
}
:global(.radio-label) {
display: flex;
align-items: center;
text-transform: uppercase;
font-size: 12px;
margin: 0 0 7px 5px;
}
:global(.radio-input) {
display: flex;
align-items: center;
}
</style>

View File

@ -0,0 +1,31 @@
{#if label !== undefined}
<label class="form-label">{label}</label>
{/if}
<div class="multiselect-super">
<Select placeholder="Search..." optionIdentifier="id" items={roles}
bind:selectedValue={value} getOptionLabel={labelMapper} getSelectionLabel={labelMapper}/>
</div>
<script>
import Select from 'svelte-select';
import axios from "axios";
import {onMount} from 'svelte'
import {setDefaultHeaders} from '../../includes/Auth.svelte'
import {API_URL} from "../../js/constants";
import {notifyError, notifyRatelimit} from "../../js/util";
export let label;
export let roles = [];
export let guildId;
export let value;
function labelMapper(role) {
return role.name;
}
onMount(() => {
setDefaultHeaders();
})
</script>

View File

@ -0,0 +1,22 @@
<div class:col-1={col1} class:col-2={col2} class:col-3={col3} class:col-4={col4}>
<label for="input" class="form-label">{label}</label>
<textarea id="input" class="form-input" placeholder="{placeholder}" bind:value on:change on:input></textarea>
</div>
<script>
export let value;
export let label;
export let placeholder;
export let col1 = false;
export let col2 = false;
export let col3 = false;
export let col4 = false;
</script>
<style>
textarea {
width: 100%;
min-height: 100px;
}
</style>

View File

@ -0,0 +1,45 @@
{#if label !== undefined}
<label class="form-label">{label}</label>
{/if}
<div class="multiselect-super">
<Select placeholder="Search..." loadOptionsInterval={500} {loadOptions} optionIdentifier="id"
bind:selectedValue={value} getOptionLabel={labelMapper} getSelectionLabel={labelMapper}/>
</div>
<script>
import Select from 'svelte-select';
import axios from "axios";
import {onMount} from 'svelte'
import {setDefaultHeaders} from '../../includes/Auth.svelte'
import {API_URL} from "../../js/constants";
import {notifyError, notifyRatelimit} from "../../js/util";
export let label;
export let guildId;
export let value;
async function loadOptions(filterText) {
const res = await axios.get(`${API_URL}/api/${guildId}/members/search?query=${filterText}`)
if (res.status !== 200) {
if (res.status === 429) {
notifyRatelimit();
} else {
notifyError(res.data.error);
}
return {cancelled: true}
}
return res.data.map((m) => m.user);
}
function labelMapper(user) {
return `${user.username}#${user.discriminator}`
}
onMount(() => {
setDefaultHeaders();
})
</script>

View File

@ -0,0 +1,42 @@
<Card footer="{false}">
<span slot="title">
Auto Close
</span>
<div slot="body" class="body-wrapper">
<div class="alert danger">
<span class="alert-text">
<span>
This feature is currently disabled. Discord will soon be releasing message threads,
which will incorporate their own auto-close behaviour. Thank you for your patience.
</span>
</span>
</div>
</div>
</Card>
<style>
.body-wrapper {
width: 100%;
}
.alert {
display: flex;
justify-content: center;
width: 100%;
border-radius: 4px;
padding: 10px 0;
}
.danger {
background-color: #fc727a;
}
.alert-text {
width: 95%
}
</style>
<script>
import Card from "../Card.svelte";
</script>

View File

@ -0,0 +1,103 @@
<Card footer="{false}" fill="{false}">
<span slot="title">
Claim Settings
</span>
<div slot="body" class="body-wrapper">
<form class="settings-form" on:submit|preventDefault={updateSettings}>
<div class="row">
<Checkbox label="SUPPORT REPS CAN VIEW CLAIMED TICKETS" col2=true bind:value={data.support_can_view} on:change={validateView} />
<Checkbox label="SUPPORT REPS CAN TYPE IN CLAIMED TICKETS" col2=true bind:value={data.support_can_type} on:change={validateType} />
</div>
<div class="row">
<div class="col-1">
<Button icon="fas fa-paper-plane" fullWidth=true>Submit</Button>
</div>
</div>
</form>
</div>
</Card>
<script>
export let guildId;
import Card from "../Card.svelte";
import Checkbox from "../form/Checkbox.svelte";
import {setDefaultHeaders} from '../../includes/Auth.svelte'
import axios from "axios";
import {notifyError, notifySuccess, withLoadingScreen} from "../../js/util";
import {API_URL} from "../../js/constants";
import Button from "../Button.svelte";
setDefaultHeaders();
let data = {
support_can_view: true,
support_can_type: true,
};
function validateView() {
if (!data.support_can_view && data.support_can_type) {
data.support_can_type = false;
}
}
function validateType() {
if (!data.support_can_view && data.support_can_type) {
data.support_can_view = true;
}
}
async function updateSettings() {
const res = await axios.post(`${API_URL}/api/${guildId}/claimsettings`, data);
if (res.status === 200 && res.data.success) {
notifySuccess("Your settings have been saved.");
} else {
notifyError(res.data.error);
}
}
async function loadData() {
const res = await axios.get(`${API_URL}/api/${guildId}/claimsettings`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
data = res.data;
}
withLoadingScreen(async () => {
await loadData();
});
</script>
<style>
.body-wrapper {
display: flex;
width: 100%;
height: 100%;
}
.row {
display: flex;
justify-content: space-between;
width: 100%;
height: 100%;
}
.settings-form {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
@media only screen and (max-width: 900px) {
.row {
flex-direction: column;
justify-content: center;
}
}
</style>

View File

@ -0,0 +1,75 @@
<form on:submit|preventDefault>
<div class="row">
<Input col1={true} label="Panel Title" placeholder="Click to open a ticket" bind:value={data.title}/>
</div>
<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={true} {channels} label="Panel Channel" bind:value={data.channel_id}/>
</div>
</div>
<div class="row">
<div class="col-1">
<PanelDropdown label="Panels" bind:panels bind:selected={data.panels} />
</div>
</div>
</form>
<script>
import Input from "../form/Input.svelte";
import Textarea from "../form/Textarea.svelte";
import Colour from "../form/Colour.svelte";
import {colourToInt} from "../../js/util";
import ChannelDropdown from "../ChannelDropdown.svelte";
import PanelDropdown from "../PanelDropdown.svelte";
export let data = {};
export let guildId;
export let channels = [];
export let panels = [];
export let seedDefault = true;
if (seedDefault) {
data = {
colour: 0x7289da,
channels: channels[0].id,
panels: [],
}
}
let tempColour = '#7289da';
function updateColour() {
data.colour = colourToInt(tempColour);
}
</script>
<style>
form {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
.row {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
height: 100%;
}
@media only screen and (max-width: 950px) {
.row {
flex-direction: column;
}
}
</style>

View File

@ -0,0 +1,82 @@
<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>
<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);
}
</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%;
}
@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

@ -0,0 +1,284 @@
<form class="settings-form" on:submit|preventDefault>
<div class="row">
<div class="col-1-3">
<Input label="Panel Title" placeholder="Open a ticket!" col1=true bind:value={data.title}/>
</div>
<div class="col-2-3">
<Textarea col1=true label="Panel Content" placeholder="By clicking the button, a ticket will be opened for you."
bind:value={data.content}/>
</div>
</div>
<div class="row">
<Colour col4=true label="Panel Colour" on:change={updateColour} bind:value={tempColour}/>
<ChannelDropdown label="Panel Channel" col4=true {channels} bind:value={data.channel_id}/>
<CategoryDropdown label="Ticket Category" col4=true {channels} bind:value={data.category_id}/>
<EmojiInput label="Button Emoji" col4=true bind:value={data.emote}/>
</div>
<div class="row" style="justify-content: center">
<div class="col-3">
<Button icon="fas fa-sliders-h" fullWidth=true type="button"
on:click={toggleAdvancedSettings}>Toggle Advanced Settings
</Button>
</div>
</div>
<div class="row advanced-settings" class:advanced-settings-show={advancedSettings}
class:advanced-settings-hide={!advancedSettings} class:show-overflow={overflowShow}>
<div class="inner" class:inner-show={advancedSettings}>
<div class="row">
<Textarea col1=true bind:value={data.welcome_message} label="Welcome Message"
placeholder="If blank, your server's default welcome message will be used"
on:input={handleWelcomeMessageUpdate}/>
</div>
<div class="row">
<div class="col-2">
<label class="form-label">Mention On Open</label>
<div class="multiselect-super">
<Select items={mentionValues} bind:selectedValue={mentionsRaw} on:select={updateMentions} isMulti={true}/>
</div>
</div>
<div class="col-2">
<label class="form-label">Support Teams</label>
<div class="multiselect-super">
<Select items={teamsItems} bind:selectedValue={teamsRaw} on:select={updateTeams} isMulti={true}/>
</div>
</div>
</div>
</div>
</div>
</form>
<script>
import Input from "../form/Input.svelte";
import Textarea from "../form/Textarea.svelte";
import Colour from "../form/Colour.svelte";
import Button from "../Button.svelte";
import ChannelDropdown from "../ChannelDropdown.svelte";
import {createEventDispatcher, onMount} from 'svelte';
import {colourToInt} from "../../js/util";
import {setDefaultHeaders} from "../../includes/Auth.svelte";
import CategoryDropdown from "../CategoryDropdown.svelte";
import EmojiInput from "../form/EmojiInput.svelte";
import Select from 'svelte-select';
export let guildId;
export let seedDefault = true;
const dispatch = createEventDispatcher();
let tempColour = '#2ECC71';
export let data;
if (seedDefault) {
data = {
//title: 'Open a ticket!',
//content: 'By clicking the button, a ticket will be opened for you.',
colour: 0x2ECC71,
emote: '📩',
welcome_message: null,
mentions: [],
default_team: true,
teams: [],
};
}
export let channels = [];
export let roles = [];
export let teams = [];
let advancedSettings = false;
let overflowShow = false;
// Oh my
// TODO: Clean up
let mentionValues = [{value: 'user', label: 'Ticket Opener'}];
let mentionsRaw = [];
function updateMentions() {
if (mentionsRaw === undefined) {
mentionsRaw = [];
}
data.mentions = mentionsRaw.map((option) => option.value);
}
let teamsItems = [{value: 'default', label: 'Default'}];
let teamsRaw = [];
if (seedDefault) {
teamsRaw = [{value: 'default', label: 'Default'}];
}
function updateTeams() {
if (teamsRaw === undefined) {
data.teams = [];
} else {
data.default_team = teamsRaw.find((option) => option.value === 'default') !== undefined;
data.teams = teamsRaw
.filter((option) => option.value !== 'default')
.map((option) => teams.find((team) => team.id == option.value));
}
}
function toggleAdvancedSettings() {
advancedSettings = !advancedSettings;
if (advancedSettings) {
setTimeout(() => {
overflowShow = true;
}, 300);
} else {
overflowShow = false;
}
}
function handleWelcomeMessageUpdate() {
if (data.welcome_message === "") {
data.welcome_message = null;
}
}
function updateColour() {
data.colour = colourToInt(tempColour);
}
function updateMentionValues() {
mentionValues = [{value: 'user', label: 'Ticket Opener'}];
$: roles.forEach((role) => mentionValues.push({value: role.id, label: role.name}));
}
function updateTeamsItems() {
teamsItems = [{value: 'default', label: 'Default'}];
$: teams.forEach((team) => teamsItems.push({value: team.id, label: team.name}));
}
function applyOverrides() {
if (data.default_team === true) {
$: teamsRaw.push({value: 'default', label: 'Default'});
}
if (data.teams) {
$: data.teams.forEach((team) => teamsRaw.push({value: team.id.toString(), label: team.name}));
}
if (data.mentions) {
$: data.mentions.forEach((id) => mentionsRaw.push(mentionValues.find((val) => val.value === id)));
}
}
onMount(() => {
updateMentionValues();
updateTeamsItems();
if (seedDefault) {
data.channel_id = channels.find((c) => c.type === 0).id;
data.category_id = channels.find((c) => c.type === 4).id;
} else {
applyOverrides();
}
})
</script>
<style>
.row {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
margin-bottom: 10px;
}
form {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
:global(.col-1-3) {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 32%;
height: 100%;
}
:global(.col-2-3) {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 64%;
height: 100%;
}
@media only screen and (max-width: 950px) {
.row {
flex-direction: column;
justify-content: center;
}
:global(.col-1-3, .col-2-3) {
width: 100% !important;
}
}
.advanced-settings {
transition: min-height .3s ease-in-out, margin-top .3s ease-in-out, margin-bottom .3s ease-in-out;
position: relative;
overflow: hidden;
}
.advanced-settings-hide {
height: 0;
visibility: hidden;
margin: 0;
flex: unset;
min-height: 0 !important;
}
.advanced-settings-show {
visibility: visible;
min-height: 250px;
margin-bottom: 10px;
}
.show-overflow {
overflow: visible;
}
.inner {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
position: absolute;
height: 100%;
width: 100%;
}
:global(.multiselect-super) {
display: flex;
width: 100%;
height: 100%;
--background: #2e3136;
--border: #2e3136;
--borderRadius: 4px;
--itemHoverBG: #121212;
--listBackground: #2e3136;
--itemColor: white;
--multiItemBG: #272727;
--multiItemActiveBG: #272727;
--multiClearFill: #272727;
--multiClearHoverFill: #272727;
--inputColor: white;
--inputFontSize: 16px;
}
:global(.multiselect-super > .selectContainer) {
width: 100%;
}
:global(.selectContainer > .multiSelect, .selectContainer > .multiSelect > input) {
cursor: pointer;
}
</style>

View File

@ -0,0 +1,83 @@
<div class="modal" transition:fade>
<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} {teams} 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>
<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 guildId;
export let panel = {};
export let channels = [];
export let roles = [];
export let teams = [];
const dispatch = createEventDispatcher();
function dispatchClose() {
dispatch('close', {});
}
// Dispatch with data
function dispatchConfirm() {
dispatch('confirm', panel);
}
</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%;
}
@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

@ -0,0 +1,216 @@
<Card footer="{false}" fill="{false}">
<span slot="title">
Settings
</span>
<div slot="body" class="body-wrapper">
<form class="settings-form" on:submit|preventDefault={updateSettings}>
<div class="row">
<Input label="prefix (max len. 8)" placeholder="t!" col4=true bind:value={data.prefix} />
<Number label="per user ticket limit" col4=true min=1 max=10 bind:value={data.ticket_limit} />
<Checkbox label="allow users to close tickets" col4=true bind:value={data.users_can_close}/>
<Checkbox label="ticket close confirmation" col4=true bind:value={data.close_confirmation}/>
</div>
<div class="row">
<Textarea label="welcome message" placeholder="Thanks for opening a ticket!" col1=true bind:value={data.welcome_message} />
</div>
<div class="row">
<ChannelDropdown label="Archive Channel" col2=true channels={channels} bind:value={data.archive_channel} />
<CategoryDropdown label="Channel Category" col2=true channels={channels} bind:value={data.category} />
</div>
<div class="row">
<NamingScheme col4=true bind:value={data.naming_scheme} />
</div>
<div class="row">
<div class="col-1">
<Button icon="fas fa-paper-plane" fullWidth=true>Submit</Button>
</div>
</div>
</form>
</div>
</Card>
<script>
import ChannelDropdown from "../ChannelDropdown.svelte";
export let guildId;
import Card from "../Card.svelte";
import Input from "../form/Input.svelte";
import Number from "../form/Number.svelte";
import Checkbox from "../form/Checkbox.svelte";
import Textarea from "../form/Textarea.svelte";
import {setDefaultHeaders} from '../../includes/Auth.svelte'
import axios from "axios";
import {notify, notifyError, notifySuccess, withLoadingScreen} from "../../js/util";
import {API_URL} from "../../js/constants";
import CategoryDropdown from "../CategoryDropdown.svelte";
import Button from "../Button.svelte";
import NamingScheme from "../NamingScheme.svelte";
setDefaultHeaders();
let channels = [];
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;
}
let data = {
ticket_limit: 5,
users_can_close: true,
close_confirmation: true,
};
async function updateSettings() {
const res = await axios.post(`${API_URL}/api/${guildId}/settings`, data);
if (res.status === 200) {
if (showValidations(res.data)) {
notifySuccess('Your settings have been saved.');
} else {
// Load valid data
await loadData();
}
} else {
notifyError(res.data.error);
}
}
function showValidations(data) {
let success = true;
if (!data.prefix) {
success = false;
notify("Warning", "Your prefix has not been saved.\nPrefixes must be between 1 - 8 characters in length.")
}
if (!data.welcome_message) {
success = false;
notify("Warning", "Your welcome message has not been saved.\nWelcome messages must be between 1 - 1000 characters in length.")
}
if (!data.ticket_limit) {
success = false;
notify("Warning", "Your ticket limit has not been saved.\nTicket limits must be in the range 1 - 10.")
}
if (!data.archive_channel) {
success = false;
notify("Warning", "Your archive channel has not been saved.")
}
if (!data.category) {
success = false;
notify("Warning", "Your channel category has not been saved.")
}
if (!data.naming_scheme) {
success = false;
notify("Warning", "Your archive channel has not been saved.")
}
return success;
}
async function loadData() {
const res = await axios.get(`${API_URL}/api/${guildId}/settings`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
data = res.data;
// Overrides
if (data.archive_channel === "0") {
let first = channels.find((c) => c.type === 0);
if (first !== undefined) {
data.archive_channel = first.id;
}
}
if (data.category === "0") {
let first = channels.find((c) => c.type === 4);
if (first !== undefined) {
data.category = first.id;
}
}
}
withLoadingScreen(async () => {
await loadChannels();
await loadData();
});
</script>
<style>
:global(.body-wrapper) {
display: flex;
width: 100%;
height: 100%;
}
.row {
display: flex;
justify-content: space-between;
width: 100%;
height: 100%;
}
.settings-form {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
@media only screen and (max-width: 950px) {
.row {
flex-direction: column;
justify-content: center;
}
:global(.col-4, .col-3, .col-2) {
width: 100% !important;
}
}
:global(.col-1) {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
height: 100%;
}
:global(.col-2) {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 49%;
height: 100%;
}
:global(.col-3) {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 31%;
height: 100%;
}
:global(.col-4) {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 23%;
height: 100%;
}
</style>

View File

@ -0,0 +1,49 @@
<script context="module">
import axios from 'axios';
import {API_URL, OAUTH} from "../js/constants";
const _tokenKey = 'token';
export function getToken() {
let token = window.localStorage.getItem(_tokenKey);
if (token == null) {
redirectLogin();
return;
}
return token;
}
export function setToken(token) {
window.localStorage.setItem(_tokenKey, token);
}
export function redirectLogin() {
// TODO: State
window.location.href = `https://discordapp.com/oauth2/authorize?response_type=code&redirect_uri=${OAUTH.redirectUri}&scope=identify%20guilds&client_id=${OAUTH.clientId}&state=`
}
export function clearLocalStorage() {
window.localStorage.clear();
}
export function setDefaultHeaders() {
axios.defaults.headers.common['Authorization'] = getToken();
axios.defaults.headers.common['x-tickets'] = 'true'; // arbitrary header name and value
axios.defaults.validateStatus = (s) => true;
}
function addRefreshInterceptor() {
axios.interceptors.response.use(async (res) => { // we set validateStatus to false
if (res.status === 401) {
await _refreshToken();
}
return res;
}, async (err) => {
if (err.response.status === 401) {
await _refreshToken();
}
return err.response;
});
}
</script>

View File

@ -0,0 +1,27 @@
<svelte:head>
<title>Tickets | A Discord Support Manager Bot</title>
<!-- Meta -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="Management panel for the Discord Tickets bot">
<link rel="shortcut icon" href="/assets/img/favicon.ico" type="image/x-icon">
<link rel="icon" href="/assets/img/favicon.ico" type="image/x-icon">
<!-- Custom CSS -->
<!--<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,400,600,700" rel="stylesheet">-->
<!-- Icons -->
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.2/css/all.css">
<!-- GA -->
<script async src='https://www.google-analytics.com/analytics.js'
type="80be96f83bbfbba3d4097e23-text/javascript"></script>
<script type="80be96f83bbfbba3d4097e23-text/javascript">
window.ga=window.ga||function(){(ga.q=ga.q||[]).push(arguments)};ga.l=+new Date;
ga('create', 'UA-161945537-1', 'auto');
ga('send', 'pageview');
</script>
</svelte:head>

View File

@ -0,0 +1,85 @@
{#if $loadingScreen}
<div id="loading-container" out:fade>
<div class="loader">Loading...</div>
</div>
{/if}
<script>
import {loadingScreen} from '../js/stores'
import { fade } from 'svelte/transition';
</script>
<style>
#loading-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #121212;
z-index: 1001;
display: flex;
align-items: center;
}
.loader,
.loader:before,
.loader:after {
background: #ffffff;
-webkit-animation: load1 1s infinite ease-in-out;
animation: load1 1s infinite ease-in-out;
width: 1em;
height: 4em;
}
.loader {
color: #ffffff;
text-indent: -9999em;
margin: 88px auto;
position: relative;
font-size: 11px;
-webkit-transform: translateZ(0);
-ms-transform: translateZ(0);
transform: translateZ(0);
-webkit-animation-delay: -0.16s;
animation-delay: -0.16s;
}
.loader:before,
.loader:after {
position: absolute;
top: 0;
content: '';
}
.loader:before {
left: -1.5em;
-webkit-animation-delay: -0.32s;
animation-delay: -0.32s;
}
.loader:after {
left: 1.5em;
}
@-webkit-keyframes load1 {
0%,
80%,
100% {
box-shadow: 0 0;
height: 4em;
}
40% {
box-shadow: 0 -2em;
height: 5em;
}
}
@keyframes load1 {
0%,
80%,
100% {
box-shadow: 0 0;
height: 4em;
}
40% {
box-shadow: 0 -2em;
height: 5em;
}
}
</style>

View File

@ -0,0 +1,115 @@
<div class="navbar" class:dropdown>
<div class="wrapper" class:dropdown>
<div>
<div class="burger-menu">
<NavElement icon="fas fa-bars" on:click={dropdownNav}>Menu</NavElement>
</div>
<div class="nav-section" class:dropdown>
<!-- on:click required to close dropdown again -->
<NavElement icon="fas fa-cogs" link="/manage/{guildId}/settings" on:click={closeDropdown}>Settings</NavElement>
<NavElement icon="fas fa-copy" link="/manage/{guildId}/transcripts" on:click={closeDropdown}>Transcripts</NavElement>
<NavElement icon="fas fa-mouse-pointer" link="/manage/{guildId}/panels" on:click={closeDropdown}>Reaction Panels</NavElement>
<NavElement icon="fas fa-users" link="/manage/{guildId}/teams" on:click={closeDropdown}>Teams</NavElement>
<NavElement icon="fas fa-ticket-alt" link="/manage/{guildId}/tickets" on:click={closeDropdown}>Tickets</NavElement>
<NavElement icon="fas fa-ban" link="/manage/{guildId}/blacklist" on:click={closeDropdown}>Blacklist</NavElement>
<NavElement icon="fas fa-tags" link="/manage/{guildId}/tags" on:click={closeDropdown}>Tags</NavElement>
</div>
</div>
<div>
<div class="nav-section" class:dropdown>
<NavElement icon="fas fa-server" link="/#">Servers</NavElement>
<NavElement icon="fas fa-sign-out-alt" link="/logout">Logout</NavElement>
</div>
</div>
</div>
</div>
<script>
export let guildId;
export let dropdown = false;
import NavElement from "../components/NavElement.svelte";
function dropdownNav() {
dropdown = !dropdown;
}
function closeDropdown() {
dropdown = false;
}
</script>
<style>
.navbar {
display: flex;
justify-content: center;
width: 100%;
background-color: #272727;
}
.wrapper {
display: flex;
width: 98%;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.nav-section {
display: flex;
flex-direction: row;
}
.burger-menu {
display: none;
}
@media only screen and (max-width: 950px) {
.nav-section {
display: none;
}
.burger-menu {
display: flex;
}
.nav-section.dropdown {
display: flex;
flex-direction: column;
}
.wrapper {
flex-direction: column;
align-items: flex-start;
overflow: hidden;
}
.dropdown {
transition-property: height;
}
.navbar {
position: relative;
height: 49px;
transition: all .3s ease-in-out;
overflow: hidden;
}
.navbar.dropdown, .wrapper.dropdown {
height: 100%;
overflow: hidden;
}
.wrapper.dropdown {
position: absolute;
}
:global(.nav-element) {
padding: 15px 0 10px 15px !important;
}
:global(.super-container.dropdown) {
display: none;
}
}
</style>

View File

@ -0,0 +1,96 @@
{#if $notifyModal}
<div class="modal" transition:fade>
<div class="modal-wrapper" bind:this={wrapper}>
<Card footer="{true}" footerRight="{true}" fill="{false}">
<span slot="title">{$notifyTitle}</span>
<span slot="body">{$notifyMessage}</span>
<div slot="footer">
<Button on:click={closeNotificationModal}>
Close
</Button>
</div>
</Card>
</div>
</div>
<div class="modal-backdrop" transition:fade>
</div>
{/if}
<script>
import {notifyMessage, notifyModal, notifyTitle} from "../js/stores";
import {closeNotificationModal} from "../js/util";
import {fade} from 'svelte/transition'
import Card from '../components/Card.svelte'
import Button from '../components/Button.svelte'
let wrapper;
document.addEventListener('click', (e) => {
if (!notifyModal) {
return;
}
let current = e.target;
let wrapperFound = false;
while (current) {
if (current.attributes) {
if (current.hasAttribute('istrigger')) {
wrapperFound = true;
break;
}
}
if (current === wrapper) {
wrapperFound = true;
break;
} else {
current = current.parentNode;
}
}
if (!wrapperFound) {
closeNotificationModal();
}
});
</script>
<style>
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 999;
display: flex;
justify-content: center;
align-items: center;
}
.modal-wrapper {
display: flex;
width: 50%;
}
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 998;
background-color: #000;
opacity: .5;
}
.footer {
display: flex;
width: 100%;
height: 100%;
}
</style>

View File

@ -0,0 +1,168 @@
<script>
export let referralShow;
export let referralLink;
export let name;
import {Navigate} from 'svelte-router-spa'
export let avatar;
</script>
<div class="sidebar">
<div class="sidebar-container" id="sidebar-nav">
<div class="inner">
<Navigate to="/" styles="sidebar-link">
<div class="sidebar-element">
<i class="fas fa-server sidebar-icon"></i>
<span class="sidebar-text">Servers</span>
</div>
</Navigate>
<Navigate to="/whitelabel" styles="sidebar-link">
<div class="sidebar-element">
<i class="fas fa-edit sidebar-icon"></i>
<span class="sidebar-text">Whitelabel</span>
</div>
</Navigate>
{#if referralShow}
<div class="sidebar-element ref">
<a class="ref-wrapper" href="{referralLink}">
<img src="/assets/img/custom.png" id="custom-image" alt="Ref Logo"/>
</a>
</div>
{/if}
</div>
</div>
<div class="sidebar-container">
<div class="sidebar-element">
<Navigate to="/logout" onclick="clearLocalStorage();" styles="sidebar-link">
<i class="sidebar-icon fas fa-sign-out-alt sidebar-icon"></i>
<span class="sidebar-text">Logout</span>
</Navigate>
</div>
<div class="sidebar-element user-element">
<a class="sidebar-link">
<i id="avatar-sidebar" style="background: url('{avatar}') center center;"></i>
{#if name !== undefined}
<span class="sidebar-text">{name}</span>
{/if}
</a>
</div>
</div>
</div>
<style>
.sidebar {
display: flex;
flex-direction: column;
height: 100%;
width: 16.6%;
background-color: #272727;
float: left;
background-size: cover;
overflow-x: hidden !important;
min-width: 250px;
}
.sidebar-container {
margin-bottom: 2%;
}
.sidebar-element {
display: flex;
align-items: center;
width: 100%;
cursor: pointer;
padding: 5px 0 5px 0;
}
.sidebar-element:hover {
background-color: #121212;
transition: background-color 0.5s ease;
}
#custom-image {
max-height: 70px;
max-width: 90%;
}
:global(.sidebar-link) {
display: flex;
align-items: center;
color: white !important;
font-size: 18px;
margin-left: 4%;
text-decoration: none;
}
.sidebar-text {
margin-left: 4%;
display: flex;
align-items: center;
}
#sidebar-nav {
display: flex;
flex: 1;
}
.ref {
display: flex;
justify-content: center;
}
.ref-wrapper {
display: flex;
justify-content: center;
padding: 10px 0;
margin: 0 !important
}
#avatar-sidebar {
width: 32px;
height: 32px;
display: block;
background-size: cover !important;
border-radius: 50%;
}
@media (max-width: 950px) {
.sidebar {
flex-direction: row;
width: 100%;
height: unset;
min-width: unset;
}
.ref {
display: none;
}
.sidebar-container {
margin-bottom: unset;
}
.sidebar-element {
width: unset;
padding: 20px 15px;
}
.sidebar-link {
width: unset;
margin-left: unset;
}
.inner {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
}
.user-element {
display: none;
}
}
</style>

View File

@ -0,0 +1,7 @@
export const API_URL = env.API_URL || "http://172.26.50.75:3000"
export const PLACEHOLDER_DOCS_URL = "https://docs.ticketsbot.net/setup/placeholders.html"
export const OAUTH = {
clientId: env.CLIENT_ID || "700742994386747404",
redirectUri: env.REDIRECT_URI || "http://localhost:5000/callback"
}

24
frontend/src/js/stores.js Normal file
View File

@ -0,0 +1,24 @@
import {get, writable} from "svelte/store";
const loadingCount = writable(0);
export const loadingScreen = writable(true);
export const dropdown = writable(false);
export function addLoadingScreenTicket() {
loadingCount.update(n => n + 1);
loadingScreen.set(true);
}
export function removeLoadingScreenTicket() {
loadingCount.update(n => n - 1);
if (get(loadingCount) === 0) {
loadingScreen.set(false);
}
}
export const notifyModal = writable(false);
export const notifyTitle = writable("");
export const notifyMessage = writable("");
export const isErrorPage = writable(false);
export const errorMessage = writable("");

42
frontend/src/js/util.js Normal file
View File

@ -0,0 +1,42 @@
import * as Stores from './stores'
import {navigateTo} from "svelte-router-spa";
export async function withLoadingScreen(func) {
Stores.addLoadingScreenTicket();
await func();
Stores.removeLoadingScreenTicket();
}
export function errorPage(message) {
navigateTo(`/error?message=${encodeURIComponent(message)}`)
}
export function notify(title, message) {
Stores.notifyTitle.set(title);
Stores.notifyMessage.set(message);
Stores.notifyModal.set(true);
}
export function notifyError(message) {
notify('Error', message);
}
export function notifySuccess(message) {
notify('Success', message);
}
export function notifyRatelimit() {
notifyError("You're doing that too fast: please wait a few seconds and try again");
}
export function closeNotificationModal() {
Stores.notifyModal.set(false);
}
export function colourToInt(colour) {
return parseInt(`0x${colour.slice(1)}`);
}
export function intToColour(i) {
return `#${i.toString(16)}`
}

View File

@ -0,0 +1,73 @@
<div class="wrapper">
<div class="card-wrapper">
<Card footer=true footerRight=true>
<span slot="title">
Error
</span>
<div slot="body">
<Route {currentRoute} {params}/>
</div>
<span slot="footer">
<span class="buttons">
<span style="margin-right: 20px">
<Button icon="fas fa-arrow-left" on:click={back}>Back</Button>
</span>
<Button icon="fas fa-home" on:click={home}>Home</Button>
</span>
</span>
</Card>
</div>
</div>
<style>
:global(body) {
padding: 0 !important;
}
.wrapper {
background-color: #121212;
margin: 0 !important;
padding: 0 !important;
display: flex;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
}
.card-wrapper {
display: flex;
width: 50%;
height: 30%;
}
.buttons {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
}
</style>
<script>
import Head from '../includes/Head.svelte'
import Card from '../components/Card.svelte'
import Button from "../components/Button.svelte";
import {Route} from 'svelte-router-spa'
export let currentRoute;
export let params = {};
function back() {
window.history.back()
}
function home() {
window.location.href = '/'
}
</script>

View File

@ -0,0 +1,87 @@
<Head/>
<div class="wrapper">
<Sidebar referralShow=true referralLink="https://www.digitalocean.com?refcode=371f56712ea4" name="{name}"
avatar="{avatar}"/>
<div class="super-container">
<LoadingScreen/>
<NotifyModal/>
<div class="content-container" class:hide={$loadingScreen}>
<Route {currentRoute} {params}/>
</div>
</div>
</div>
<script>
import {Route} from 'svelte-router-spa'
import Head from '../includes/Head.svelte'
import Sidebar from '../includes/Sidebar.svelte'
import LoadingScreen from '../includes/LoadingScreen.svelte'
import NotifyModal from '../includes/NotifyModal.svelte'
import axios from "axios";
import {API_URL} from '../js/constants'
import {notifyError} from '../js/util'
import {loadingScreen} from "../js/stores"
import {redirectLogin, setDefaultHeaders} from '../includes/Auth.svelte'
export let currentRoute;
export let params = {};
setDefaultHeaders()
let name;
let avatar;
async function loadData() {
const res = await axios.get(`${API_URL}/api/session`);
if (res.status !== 200) {
if (res.data.auth === true) {
redirectLogin();
}
notifyError(res.data.error);
return;
}
name = res.data.username;
avatar = res.data.avatar;
}
loadData();
</script>
<style>
body {
padding: 0 !important;
}
.wrapper {
display: flex;
width: 100%;
height: 100%;
margin: 0 !important;
padding: 0 !important;
}
.super-container {
display: flex;
width: 100%;
height: 100%;
}
.content-container {
display: flex;
width: 100%;
height: 100%;
}
.hide {
visibility: hidden;
}
@media (max-width: 950px) {
.wrapper {
flex-direction: column;
}
}
</style>

View File

@ -0,0 +1,50 @@
<Head/>
<div class="wrapper">
<Navbar guildId={guildId} bind:dropdown={$dropdown}/>
<div class="super-container" class:dropdown={$dropdown}>
<LoadingScreen/>
<div class="content-container" class:hide={$loadingScreen}>
<Route {currentRoute} {params}/>
</div>
<NotifyModal/>
</div>
</div>
<style>
body {
padding: 0 !important;
}
.wrapper {
margin: 0 !important;
padding: 0 !important;
width: 100%;
height: 100%;
}
.content-container {
display: flex;
width: 100%;
height: 100%;
}
.hide {
visibility: hidden;
}
</style>
<script>
export let currentRoute;
export let params = {};
let guildId = currentRoute.namedParams.id
import Head from '../includes/Head.svelte'
import LoadingScreen from '../includes/LoadingScreen.svelte'
import NotifyModal from '../includes/NotifyModal.svelte'
import Navbar from '../includes/Navbar.svelte'
import {Route} from 'svelte-router-spa'
import {loadingScreen, dropdown} from '../js/stores'
</script>

View File

@ -0,0 +1,19 @@
<Head/>
<NotifyModal/>
<Route {currentRoute} {params}/>
<style>
body {
padding: 0 !important;
}
</style>
<script>
export let currentRoute;
export let params = {};
import Head from '../includes/Head.svelte'
import NotifyModal from '../includes/NotifyModal.svelte'
import {Route} from 'svelte-router-spa'
</script>

7
frontend/src/main.js Normal file
View File

@ -0,0 +1,7 @@
import App from './App.svelte';
const app = new App({
target: document.body
});
export default app;

83
frontend/src/routes.js Normal file
View File

@ -0,0 +1,83 @@
import IndexLayout from './layouts/IndexLayout.svelte'
import ManageLayout from './layouts/ManageLayout.svelte'
import ErrorLayout from './layouts/ErrorPage.svelte'
import TranscriptViewLayout from './layouts/TranscriptViewLayout.svelte'
import Index from './views/Index.svelte'
import LoginCallback from './views/LoginCallback.svelte'
import Login from './views/Login.svelte'
import Logout from './views/Logout.svelte'
import Whitelabel from './views/Whitelabel.svelte'
import Settings from './views/Settings.svelte'
import Error from './views/Error.svelte'
import Error404 from './views/Error404.svelte'
import Transcripts from './views/Transcripts.svelte'
import TranscriptView from './views/TranscriptView.svelte'
import Blacklist from './views/Blacklist.svelte'
import Panels from './views/Panels.svelte'
import Tags from './views/Tags.svelte'
import Teams from './views/Teams.svelte'
import Tickets from './views/Tickets.svelte'
import TicketView from './views/TicketView.svelte'
export const routes = [
{name: '/', component: Index, layout: IndexLayout},
{name: '404', path: '404', component: Error404, layout: ErrorLayout},
{name: '/callback', component: LoginCallback},
{name: '/login', component: Login},
{name: '/logout', component: Logout},
{name: '/error', component: Error, layout: ErrorLayout},
{name: '/whitelabel', component: Whitelabel, layout: IndexLayout},
{
name: 'manage/:id',
nestedRoutes: [
{name: 'index', component: Error404, layout: ErrorLayout},
{name: 'settings', component: Settings, layout: ManageLayout},
{
name: 'transcripts',
nestedRoutes: [
{
name: 'index',
component: Transcripts,
layout: ManageLayout,
},
{
name: 'view/:ticketid',
component: TranscriptView, // just to test
layout: TranscriptViewLayout,
}
]
},
// Backwards compatibility
{
name: 'logs',
nestedRoutes: [
{
name: 'view/:ticketid',
component: TranscriptView,
layout: TranscriptViewLayout,
}
]
},
{name: 'panels', component: Panels, layout: ManageLayout},
{name: 'blacklist', component: Blacklist, layout: ManageLayout},
{name: 'tags', component: Tags, layout: ManageLayout},
{name: 'teams', component: Teams, layout: ManageLayout},
{
name: 'tickets',
nestedRoutes: [
{
name: 'index',
component: Tickets,
layout: ManageLayout,
},
{
name: 'view/:ticketid',
component: TicketView,
layout: ManageLayout,
}
]
},
],
}
]

View File

@ -0,0 +1,172 @@
<div class="parent">
<div class="content">
<div class="main-col">
<Card footer={false}>
<span slot="title">Blacklisted Users</span>
<div slot="body" class="body-wrapper">
<table class="nice">
<thead>
<tr>
<th>Username</th>
<th>User ID</th>
<th>Remove</th>
</tr>
</thead>
<tbody>
{#each blacklistedUsers as user}
<tr>
{#if user.username !== '' && user.discriminator !== ''}
<td>{user.username}#{user.discriminator}</td>
{:else}
<td>Unknown</td>
{/if}
<td>{user.id}</td>
<td>
<Button type="button" on:click={() => removeBlacklist(user)}>Remove</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</Card>
</div>
<div class="right-col">
<Card footer={false}>
<span slot="title">Blacklist A User</span>
<div slot="body" class="body-wrapper">
<form class="body-wrapper" on:submit|preventDefault={addBlacklist}>
<div class="row" style="flex-direction: column">
<UserSelect {guildId} label="User" bind:value={addUser}/>
</div>
<div class="row" style="justify-content: center">
<div class="col-2">
<Button fullWidth={true} icon="fas fa-plus"
disabled={addUser === undefined || addUser === ''}>Blacklist</Button>
</div>
</div>
</form>
</div>
</Card>
</div>
</div>
</div>
<script>
import Card from "../components/Card.svelte";
import UserSelect from "../components/form/UserSelect.svelte";
import {notifyError, notifySuccess, withLoadingScreen} from '../js/util'
import Button from "../components/Button.svelte";
import axios from "axios";
import {API_URL} from "../js/constants";
import {setDefaultHeaders} from '../includes/Auth.svelte'
export let currentRoute;
let guildId = currentRoute.namedParams.id;
let addUser;
let blacklistedUsers = [];
async function addBlacklist() {
const res = await axios.post(`${API_URL}/api/${guildId}/blacklist/${addUser.id}`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
notifySuccess(`${addUser.username}#${addUser.discriminator} has been blacklisted`);
blacklistedUsers = [...blacklistedUsers, {
id: addUser.id,
username: addUser.username,
discriminator: addUser.discriminator,
}];
}
async function removeBlacklist(user) {
const res = await axios.delete(`${API_URL}/api/${guildId}/blacklist/${user.id}`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
notifySuccess(`${user.username}#${user.discriminator} has been removed from the blacklist`);
blacklistedUsers = blacklistedUsers.filter((u) => u.id !== user.id);
}
async function loadUsers() {
const res = await axios.get(`${API_URL}/api/${guildId}/blacklist`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
blacklistedUsers = res.data;
}
withLoadingScreen(async () => {
setDefaultHeaders();
await loadUsers();
});
</script>
<style>
.parent {
display: flex;
justify-content: center;
width: 100%;
height: 100%;
}
.content {
display: flex;
justify-content: space-between;
width: 96%;
height: 100%;
margin-top: 30px;
}
.main-col {
display: flex;
flex-direction: column;
width: 64%;
height: 100%;
}
.right-col {
display: flex;
flex-direction: column;
width: 34%;
height: 100%;
}
.body-wrapper {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
.row {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
margin-bottom: 2%;
}
@media only screen and (max-width: 950px) {
.content {
flex-direction: column-reverse;
}
.main-col {
width: 100%;
margin-top: 4%;
}
.right-col {
width: 100%;
}
}
</style>

View File

@ -0,0 +1,6 @@
{message}
<script>
const urlParams = new URLSearchParams(window.location.search);
const message = urlParams.get('message');
</script>

Some files were not shown because too many files have changed in this diff Show More