mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-10-23 05:11:57 +00:00
more.
This commit is contained in:
12
Makefile
12
Makefile
@@ -5,12 +5,20 @@ VERSION := $(shell git describe --tags)
|
||||
BUILDSTR := ${VERSION} (Commit: ${LAST_COMMIT_DATE} (${LAST_COMMIT}), Build: $(shell date +"%Y-%m-%d %H:%M:%S %z"))
|
||||
|
||||
BIN_ARTEMIS := artemis.bin
|
||||
DIST := dist
|
||||
STATIC := frontend/dist
|
||||
GOPATH ?= $(HOME)/go
|
||||
STUFFBIN ?= $(GOPATH)/bin/stuffbin
|
||||
|
||||
$(STUFFBIN):
|
||||
@echo "Installing stuffbin."
|
||||
go install github.com/knadh/stuffbin/...
|
||||
.PHONY: $(STUFFBIN)
|
||||
|
||||
.PHONY: $(BIN_ARTEMIS)
|
||||
$(BIN_ARTEMIS):
|
||||
$(BIN_ARTEMIS): $(STUFFBIN)
|
||||
CGO_ENABLED=0 go build -a -ldflags="-X 'main.buildVersion=${BUILDSTR}' -X 'main.buildDate=${LAST_COMMIT_DATE}' -s -w" -o ${BIN_ARTEMIS} cmd/*.go
|
||||
@echo "Build successful. Current build version: $(VERSION)"
|
||||
$(STUFFBIN) -a stuff -in ${BIN_ARTEMIS} -out ${BIN_ARTEMIS} ${STATIC}
|
||||
|
||||
test:
|
||||
@go test -v ./...
|
||||
|
@@ -206,3 +206,12 @@ func handleAssigneeStats(r *fastglue.Request) error {
|
||||
}
|
||||
return r.SendEnvelope(stats)
|
||||
}
|
||||
|
||||
func handleNewConversationsStats(r *fastglue.Request) error {
|
||||
var app = r.Context.(*App)
|
||||
stats, err := app.conversationMgr.GetNewConversationsStats()
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(http.StatusInternalServerError, "Error fetching conversation stats", nil, "")
|
||||
}
|
||||
return r.SendEnvelope(stats)
|
||||
}
|
||||
|
@@ -1,6 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/ws"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
@@ -13,6 +18,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
g.GET("/api/conversations/assigned", auth(handleGetAssignedConversations, "conversations.assigned"))
|
||||
g.GET("/api/conversations/unassigned", auth(handleGetUnassignedConversations, "conversations.unassigned"))
|
||||
g.GET("/api/conversations/assignee/stats", auth(handleAssigneeStats))
|
||||
g.GET("/api/conversations/new/stats", auth(handleNewConversationsStats))
|
||||
g.GET("/api/conversation/{conversation_uuid}", auth(handleGetConversation))
|
||||
g.PUT("/api/conversation/{conversation_uuid}/last-seen", auth(handleUpdateAssigneeLastSeen))
|
||||
g.GET("/api/conversation/{conversation_uuid}/participants", auth(handleGetConversationParticipants))
|
||||
@@ -26,7 +32,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
g.GET("/api/message/{message_uuid}/retry", auth(handleRetryMessage))
|
||||
g.GET("/api/message/{message_uuid}", auth(handleGetMessage))
|
||||
g.GET("/api/canned-responses", auth(handleGetCannedResponses))
|
||||
g.GET("/api/attachment/{conversation_uuid}", auth(handleGetAttachment))
|
||||
g.POST("/api/upload", auth(handleFileUpload))
|
||||
g.POST("/api/upload/view/{file_uuid}", auth(handleViewFile))
|
||||
g.GET("/api/users/me", auth(handleGetCurrentUser))
|
||||
g.GET("/api/users", auth(handleGetUsers))
|
||||
g.GET("/api/teams", auth(handleGetTeams))
|
||||
@@ -34,4 +41,60 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
g.GET("/api/ws", auth(func(r *fastglue.Request) error {
|
||||
return handleWS(r, hub)
|
||||
}))
|
||||
|
||||
|
||||
g.GET("/", sess(noAuthPage(serveIndexPage)))
|
||||
g.GET("/dashboard", sess(authPage(serveIndexPage)))
|
||||
g.GET("/conversations", sess(authPage(serveIndexPage)))
|
||||
g.GET("/conversations/{all:*}", sess(authPage(serveIndexPage)))
|
||||
g.GET("/account/profile", sess(authPage(serveIndexPage)))
|
||||
g.GET("/assets/{all:*}", serveStaticFiles)
|
||||
}
|
||||
|
||||
// serveIndexPage serves app's default index page.
|
||||
func serveIndexPage(r *fastglue.Request) error {
|
||||
app := r.Context.(*App)
|
||||
|
||||
// Add no-caching headers.
|
||||
r.RequestCtx.Response.Header.Add("Cache-Control", "no-store, no-cache, must-revalidate, post-check=0, pre-check=0")
|
||||
r.RequestCtx.Response.Header.Add("Pragma", "no-cache")
|
||||
r.RequestCtx.Response.Header.Add("Expires", "-1")
|
||||
|
||||
// Serve the index.html file from the Stuffbin archive.
|
||||
file, err := app.fs.Get("/frontend/dist/index.html")
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(http.StatusNotFound, "Page not found", nil, "InputException")
|
||||
}
|
||||
r.RequestCtx.Response.Header.Set("Content-Type", "text/html")
|
||||
r.RequestCtx.SetBody(file.ReadBytes())
|
||||
return nil
|
||||
}
|
||||
|
||||
// serveStaticFiles serves static files from the Stuffbin archive.
|
||||
func serveStaticFiles(r *fastglue.Request) error {
|
||||
fmt.Println("static here.")
|
||||
app := r.Context.(*App)
|
||||
|
||||
// Get the requested path
|
||||
filePath := string(r.RequestCtx.Path())
|
||||
|
||||
fmt.Println("file path ", filePath)
|
||||
|
||||
// Serve the file from the Stuffbin archive
|
||||
finalPath := filepath.Join("frontend/dist", filePath)
|
||||
fmt.Println("final path ", finalPath)
|
||||
file, err := app.fs.Get(finalPath)
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(http.StatusNotFound, "File not found", nil, "InputException")
|
||||
}
|
||||
|
||||
// Detect and set the appropriate Content-Type
|
||||
ext := filepath.Ext(filePath)
|
||||
contentType := mime.TypeByExtension(ext)
|
||||
if contentType == "" {
|
||||
contentType = http.DetectContentType(file.ReadBytes())
|
||||
}
|
||||
r.RequestCtx.Response.Header.Set("Content-Type", contentType)
|
||||
r.RequestCtx.SetBody(file.ReadBytes())
|
||||
return nil
|
||||
}
|
||||
|
34
cmd/init.go
34
cmd/init.go
@@ -31,6 +31,7 @@ import (
|
||||
"github.com/knadh/koanf/providers/posflag"
|
||||
"github.com/knadh/koanf/providers/rawbytes"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
flag "github.com/spf13/pflag"
|
||||
"github.com/vividvilla/simplesessions"
|
||||
sessredisstore "github.com/vividvilla/simplesessions/stores/goredis"
|
||||
@@ -73,6 +74,39 @@ func initConstants() consts {
|
||||
}
|
||||
}
|
||||
|
||||
// initFS initializes the stuffbin FileSystem.
|
||||
func initFS() stuffbin.FileSystem {
|
||||
var files = []string{
|
||||
"frontend/dist:/dist",
|
||||
}
|
||||
|
||||
// Get self executable path.
|
||||
path, err := os.Executable()
|
||||
if err != nil {
|
||||
log.Fatalf("error initializing FS: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("path -> ", path)
|
||||
|
||||
// Load embedded files in the executable.
|
||||
fs, err := stuffbin.UnStuff(path)
|
||||
|
||||
if err != nil {
|
||||
if err == stuffbin.ErrNoID {
|
||||
// The embed failed or the binary's already unstuffed or running in local / dev mode, use the local filesystem.
|
||||
log.Println("unstuff failed, using local FS")
|
||||
fs, err = stuffbin.NewLocalFS("/", files...)
|
||||
if err != nil {
|
||||
log.Fatalf("error initializing local FS: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Fatalf("error initializing FS: %v", err)
|
||||
}
|
||||
}
|
||||
fmt.Println(fs.List())
|
||||
return fs
|
||||
}
|
||||
|
||||
// initSessionManager initializes and returns a simplesessions.Manager instance.
|
||||
func initSessionManager(rd *redis.Client) *simplesessions.Manager {
|
||||
ttl := ko.Duration("app.session.cookie_ttl")
|
||||
|
@@ -18,9 +18,11 @@ import (
|
||||
"github.com/abhinavxd/artemis/internal/message"
|
||||
"github.com/abhinavxd/artemis/internal/tag"
|
||||
"github.com/abhinavxd/artemis/internal/team"
|
||||
"github.com/abhinavxd/artemis/internal/upload"
|
||||
"github.com/abhinavxd/artemis/internal/user"
|
||||
"github.com/abhinavxd/artemis/internal/ws"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/vividvilla/simplesessions"
|
||||
"github.com/zerodha/fastglue"
|
||||
@@ -40,6 +42,7 @@ const (
|
||||
// App is the global app context which is passed and injected in the http handlers.
|
||||
type App struct {
|
||||
constants consts
|
||||
fs stuffbin.FileSystem
|
||||
lo *logf.Logger
|
||||
cntctMgr *contact.Manager
|
||||
userMgr *user.Manager
|
||||
@@ -49,12 +52,16 @@ type App struct {
|
||||
msgMgr *message.Manager
|
||||
rbac *uauth.Engine
|
||||
inboxMgr *inbox.Manager
|
||||
uploadMgr *upload.Manager
|
||||
attachmentMgr *attachment.Manager
|
||||
cannedRespMgr *cannedresp.Manager
|
||||
conversationMgr *conversation.Manager
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Load stuffbin fs.
|
||||
fs := initFS()
|
||||
|
||||
// Load command line flags into Koanf.
|
||||
initFlags()
|
||||
|
||||
@@ -107,6 +114,7 @@ func main() {
|
||||
// Init the app
|
||||
var app = &App{
|
||||
lo: lo,
|
||||
fs: fs,
|
||||
cntctMgr: cntctMgr,
|
||||
inboxMgr: inboxMgr,
|
||||
userMgr: userMgr,
|
||||
|
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/vividvilla/simplesessions"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
@@ -62,3 +63,91 @@ func auth(handler fastglue.FastRequestHandler, perms ...string) fastglue.FastReq
|
||||
return r.SendErrorEnvelope(http.StatusUnauthorized, "invalid or expired session", nil, "PermissionException")
|
||||
}
|
||||
}
|
||||
|
||||
// authPage middleware makes sure user is logged in to access the page
|
||||
// else redirects to login page.
|
||||
func authPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
return func(r *fastglue.Request) error {
|
||||
// Check if user is logged in. If logged in return next handler.
|
||||
userID, ok := getAuthUserFromSess(r)
|
||||
if ok && userID > 0 {
|
||||
return handler(r)
|
||||
}
|
||||
nextURI := r.RequestCtx.QueryArgs().Peek("next")
|
||||
if len(nextURI) == 0 {
|
||||
nextURI = r.RequestCtx.RequestURI()
|
||||
}
|
||||
return r.RedirectURI("/", fasthttp.StatusFound, map[string]interface{}{
|
||||
"next": string(nextURI),
|
||||
}, "")
|
||||
}
|
||||
}
|
||||
|
||||
// getAuthUserFromSess retrives authUser from request context set by the sess() middleware.
|
||||
func getAuthUserFromSess(r *fastglue.Request) (int, bool) {
|
||||
userID, ok := r.RequestCtx.UserValue("user_id").(int)
|
||||
if userID == 0 || !ok {
|
||||
return userID, false
|
||||
}
|
||||
return userID, true
|
||||
}
|
||||
|
||||
func sess(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
return func(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
sess, err = app.sessMgr.Acquire(r, r, nil)
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
app.lo.Error("error acquiring session", "error", err)
|
||||
return r.SendErrorEnvelope(http.StatusUnauthorized, "invalid or expired session", nil, "PermissionException")
|
||||
}
|
||||
|
||||
// User email in session?
|
||||
email, err := sess.String(sess.Get("user_email"))
|
||||
if err != nil && (err != simplesessions.ErrInvalidSession && err != simplesessions.ErrFieldNotFound) {
|
||||
app.lo.Error("error fetching session session", "error", err)
|
||||
return r.SendErrorEnvelope(http.StatusUnauthorized, "invalid or expired session", nil, "PermissionException")
|
||||
}
|
||||
|
||||
// User ID in session?
|
||||
userID, err := sess.Int(sess.Get("user_id"))
|
||||
if err != nil && (err != simplesessions.ErrInvalidSession && err != simplesessions.ErrFieldNotFound) {
|
||||
app.lo.Error("error fetching session session", "error", err)
|
||||
return r.SendErrorEnvelope(http.StatusUnauthorized, "invalid or expired session", nil, "PermissionException")
|
||||
}
|
||||
|
||||
userUUID, err := sess.String(sess.Get("user_uuid"))
|
||||
if err != nil && (err != simplesessions.ErrInvalidSession && err != simplesessions.ErrFieldNotFound) {
|
||||
app.lo.Error("error fetching session session", "error", err)
|
||||
return r.SendErrorEnvelope(http.StatusUnauthorized, "invalid or expired session", nil, "PermissionException")
|
||||
}
|
||||
|
||||
if email != "" && userID > 0 {
|
||||
// Set both in request context so they can be accessed in the handlers.
|
||||
r.RequestCtx.SetUserValue("user_email", email)
|
||||
r.RequestCtx.SetUserValue("user_id", userID)
|
||||
r.RequestCtx.SetUserValue("user_uuid", userUUID)
|
||||
}
|
||||
|
||||
return handler(r)
|
||||
}
|
||||
}
|
||||
|
||||
func noAuthPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
return func(r *fastglue.Request) error {
|
||||
_, ok := getAuthUserFromSess(r)
|
||||
if !ok {
|
||||
return handler(r)
|
||||
}
|
||||
|
||||
// User is logged in direct if `next` is available else redirect to the dashboard.
|
||||
nextURI := string(r.RequestCtx.QueryArgs().Peek("next"))
|
||||
if len(nextURI) == 0 {
|
||||
nextURI = "/dashboard"
|
||||
}
|
||||
|
||||
return r.RedirectURI(nextURI, fasthttp.StatusFound, nil, "")
|
||||
}
|
||||
}
|
||||
|
72
cmd/upload.go
Normal file
72
cmd/upload.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
func handleFileUpload(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
form, err = r.RequestCtx.MultipartForm()
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
app.lo.Error("error parsing media form data.", "error", err)
|
||||
return r.SendErrorEnvelope(http.StatusInternalServerError, "Error parsing data", nil, "GeneralException")
|
||||
}
|
||||
|
||||
if files, ok := form.File["files"]; !ok || len(files) == 0 {
|
||||
return r.SendErrorEnvelope(http.StatusBadRequest, "File not found", nil, "InputException")
|
||||
}
|
||||
|
||||
// Read file into the memory
|
||||
file, err := form.File["files"][0].Open()
|
||||
srcFileName := form.File["files"][0].Filename
|
||||
srcContentType := form.File["files"][0].Header.Get("Content-Type")
|
||||
srcFileSize := strconv.FormatInt(form.File["files"][0].Size, 10)
|
||||
if err != nil {
|
||||
app.lo.Error("reading file into the memory", "error", err)
|
||||
return r.SendErrorEnvelope(http.StatusInternalServerError, "Error reading file", nil, "GeneralException")
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcFileName)), ".")
|
||||
|
||||
// Checking if file type is allowed.
|
||||
if !slices.Contains(app.constants.AllowedFileUploadExtensions, "*") {
|
||||
if !slices.Contains(app.constants.AllowedFileUploadExtensions, ext) {
|
||||
return r.SendErrorEnvelope(http.StatusBadRequest, "Unsupported file type", nil, "GeneralException")
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the ptr.
|
||||
file.Seek(0, 0)
|
||||
url, err := app.uploadMgr.Upload(srcFileName, srcContentType, file)
|
||||
if err != nil {
|
||||
app.lo.Error("error uploading file", "error", err)
|
||||
return r.SendErrorEnvelope(http.StatusInternalServerError, "Error uploading file", nil, "GeneralException")
|
||||
}
|
||||
|
||||
return r.SendEnvelope(map[string]string{
|
||||
"url": url,
|
||||
"content_type": srcContentType,
|
||||
"name": srcFileName,
|
||||
"size": srcFileSize,
|
||||
})
|
||||
}
|
||||
|
||||
func handleViewFile(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("file_uuid").(string)
|
||||
)
|
||||
url := app.uploadMgr.Store.GetURL(uuid)
|
||||
r.RequestCtx.Response.Header.Set("Location", url)
|
||||
return r.Redirect(url, http.StatusFound, nil, "")
|
||||
}
|
Binary file not shown.
@@ -3,7 +3,7 @@
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<!-- <link rel="icon" href="/favicon.ico"> -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
|
@@ -15,6 +15,7 @@
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.8.2",
|
||||
"@heroicons/vue": "^2.1.1",
|
||||
"@radix-icons/vue": "^1.0.0",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
@@ -28,9 +29,11 @@
|
||||
"@tiptap/vue-3": "^2.4.0",
|
||||
"@unovis/ts": "^1.4.1",
|
||||
"@unovis/vue": "^1.4.1",
|
||||
"@vee-validate/zod": "^4.13.1",
|
||||
"@vue/reactivity": "^3.4.15",
|
||||
"@vue/runtime-core": "^3.4.15",
|
||||
"@vueuse/core": "^10.11.0",
|
||||
"add": "^2.0.6",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"codeflask": "^1.4.1",
|
||||
@@ -38,17 +41,22 @@
|
||||
"install": "^0.13.0",
|
||||
"lucide-vue-next": "^0.378.0",
|
||||
"npm": "^10.4.0",
|
||||
"npx": "^10.2.2",
|
||||
"pinia": "^2.1.7",
|
||||
"qs": "^6.12.1",
|
||||
"radix-vue": "^1.8.0",
|
||||
"shadcn-vue": "latest",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"textarea": "^0.3.0",
|
||||
"tiptap-extension-resize-image": "^1.1.5",
|
||||
"vee-validate": "^4.13.1",
|
||||
"vue": "^3.4.15",
|
||||
"vue-draggable-resizable": "^3.0.0",
|
||||
"vue-i18n": "9",
|
||||
"vue-letter": "^0.2.0",
|
||||
"vue-router": "^4.2.5"
|
||||
"vue-router": "^4.2.5",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/vue": "^4.1.2",
|
||||
|
@@ -2,7 +2,7 @@
|
||||
<Toaster />
|
||||
<TooltipProvider :delay-duration=200>
|
||||
<div class="bg-background text-foreground">
|
||||
<div v-if="$route.path !== '/login'">
|
||||
<div v-if="$route.path !== '/'">
|
||||
<ResizablePanelGroup direction="horizontal" auto-save-id="app.vue.resizable.panel">
|
||||
<ResizablePanel id="resize-panel-1" collapsible :default-size="10" :collapsed-size="1" :min-size="7"
|
||||
:max-size="20" :class="cn(isCollapsed && 'min-w-[50px] transition-all duration-200 ease-in-out')"
|
||||
|
@@ -33,17 +33,23 @@ const sendMessage = (uuid, data) => http.post(`/api/conversation/${uuid}/message
|
||||
const getConversation = (uuid) => http.get(`/api/conversation/${uuid}`)
|
||||
const getConversationParticipants = (uuid) => http.get(`/api/conversation/${uuid}/participants`)
|
||||
const getCannedResponses = () => http.get('/api/canned-responses')
|
||||
const getAssigneeStats = () => http.get('/api/conversations/assignee/stats')
|
||||
|
||||
const getAssignedConversations = (page, preDefinedFilter) => http.get(`/api/conversations/assigned?page=${page}&predefinedfilter=${preDefinedFilter}`)
|
||||
const getUnassignedConversations = (page, preDefinedFilter) => http.get(`/api/conversations/unassigned?page=${page}&predefinedfilter=${preDefinedFilter}`)
|
||||
const getAllConversations = (page, preDefinedFilter) => http.get(`/api/conversations/all?page=${page}&predefinedfilter=${preDefinedFilter}`)
|
||||
|
||||
const uploadAttachment = (data) => http.post('/api/attachment', data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
const uploadFile = (data) => http.post('/api/attachment', data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
const getAssigneeStats = () => http.get('/api/conversations/assignee/stats')
|
||||
const getNewConversationsStats = () => http.get('/api/conversations/new/stats');
|
||||
|
||||
|
||||
|
||||
export default {
|
||||
login,
|
||||
@@ -58,12 +64,14 @@ export default {
|
||||
getConversationParticipants,
|
||||
getMessage,
|
||||
getMessages,
|
||||
getNewConversationsStats,
|
||||
sendMessage,
|
||||
getCurrentUser,
|
||||
updateAssignee,
|
||||
updateStatus,
|
||||
updatePriority,
|
||||
upsertTags,
|
||||
uploadFile,
|
||||
retryMessage,
|
||||
updateAssigneeLastSeen,
|
||||
getCannedResponses,
|
||||
|
@@ -5,11 +5,11 @@
|
||||
// App default font-size.
|
||||
// Default: 16px, 15px looked very wide.
|
||||
:root {
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.tab-container-default {
|
||||
padding: 2rem 2rem;
|
||||
.page-content {
|
||||
padding: 1.3rem 1.3rem;
|
||||
}
|
||||
|
||||
.bg-success {
|
||||
@@ -20,86 +20,71 @@
|
||||
color: #fff; /* example text color for success */
|
||||
}
|
||||
|
||||
$editorContainerId: 'editor-container';
|
||||
|
||||
|
||||
// Theme.
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
|
||||
--foreground: 224 71.4% 4.1%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--card-foreground: 224 71.4% 4.1%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--popover-foreground: 224 71.4% 4.1%;
|
||||
|
||||
--primary: 220.9 39.3% 11%;
|
||||
--primary-foreground: 210 20% 98%;
|
||||
|
||||
--secondary: 220 14.3% 95.9%;
|
||||
--secondary-foreground: 220.9 39.3% 11%;
|
||||
|
||||
--muted: 220 14.3% 95.9%;
|
||||
--muted-foreground: 220 8.9% 46.1%;
|
||||
|
||||
--accent: 220 14.3% 95.9%;
|
||||
--accent-foreground: 220.9 39.3% 11%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border:214.3 31.8% 91.4%;
|
||||
--input:214.3 31.8% 91.4%;
|
||||
--ring:222.2 84% 4.9%;
|
||||
--radius: 0.25rem;
|
||||
--destructive-foreground: 210 20% 98%;
|
||||
|
||||
--border: 220 13% 91%;
|
||||
--input: 220 13% 91%;
|
||||
--ring: 224 71.4% 4.1%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.dark {
|
||||
--background:222.2 84% 4.9%;
|
||||
--foreground:210 40% 98%;
|
||||
|
||||
--card:222.2 84% 4.9%;
|
||||
--card-foreground:210 40% 98%;
|
||||
|
||||
--popover:222.2 84% 4.9%;
|
||||
--popover-foreground:210 40% 98%;
|
||||
|
||||
--primary:210 40% 98%;
|
||||
--primary-foreground:222.2 47.4% 11.2%;
|
||||
|
||||
--secondary:217.2 32.6% 17.5%;
|
||||
--secondary-foreground:210 40% 98%;
|
||||
|
||||
--muted:217.2 32.6% 17.5%;
|
||||
--muted-foreground:215 20.2% 65.1%;
|
||||
|
||||
--accent:217.2 32.6% 17.5%;
|
||||
--accent-foreground:210 40% 98%;
|
||||
|
||||
--destructive:0 62.8% 30.6%;
|
||||
--destructive-foreground:210 40% 98%;
|
||||
|
||||
--border:217.2 32.6% 17.5%;
|
||||
--input:217.2 32.6% 17.5%;
|
||||
--ring:212.7 26.8% 83.9;
|
||||
--background: 224 71.4% 4.1%;
|
||||
--foreground: 210 20% 98%;
|
||||
|
||||
--card: 224 71.4% 4.1%;
|
||||
--card-foreground: 210 20% 98%;
|
||||
|
||||
--popover: 224 71.4% 4.1%;
|
||||
--popover-foreground: 210 20% 98%;
|
||||
|
||||
--primary: 210 20% 98%;
|
||||
--primary-foreground: 220.9 39.3% 11%;
|
||||
|
||||
--secondary: 215 27.9% 16.9%;
|
||||
--secondary-foreground: 210 20% 98%;
|
||||
|
||||
--muted: 215 27.9% 16.9%;
|
||||
--muted-foreground: 217.9 10.6% 64.9%;
|
||||
|
||||
--accent: 215 27.9% 16.9%;
|
||||
--accent-foreground: 210 20% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 20% 98%;
|
||||
|
||||
--border: 215 27.9% 16.9%;
|
||||
--input: 215 27.9% 16.9%;
|
||||
--ring: 216 12.2% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// @layer base {
|
||||
// * {
|
||||
// @apply border-border;
|
||||
// }
|
||||
// body {
|
||||
// @apply bg-background text-foreground;
|
||||
// }
|
||||
// }
|
||||
|
||||
// charts
|
||||
@layer base {
|
||||
:root {
|
||||
|
@@ -16,13 +16,7 @@ const currentPage = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="md:hidden">
|
||||
<VPImage alt="Forms" width="1280" height="1214" class="block" :image="{
|
||||
dark: '/examples/forms-dark.png',
|
||||
light: '/examples/forms-light.png',
|
||||
}" />
|
||||
</div>
|
||||
<div class="hidden space-y-6 md:block tab-container-default">
|
||||
<div class="space-y-6 md:block page-content">
|
||||
<div class="space-y-0.5">
|
||||
<h2 class="text-2xl font-bold tracking-tight">
|
||||
Profile settings
|
||||
|
74
frontend/src/components/account/AvatarUploader.vue
Normal file
74
frontend/src/components/account/AvatarUploader.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
avatarURL: {
|
||||
type: String,
|
||||
default: '',
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change', 'onAvatarDelete'])
|
||||
const fileInput = ref(null)
|
||||
|
||||
const handleImageUpload = (event) => {
|
||||
const file = event.target.files[0];
|
||||
emit('change', {
|
||||
file: file,
|
||||
url: file ? URL.createObjectURL(file) : null,
|
||||
});
|
||||
}
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInput.value.click();
|
||||
}
|
||||
|
||||
const onAvatarDelete = () => {
|
||||
fileInput.value.value = null;
|
||||
emit('onAvatarDelete');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl font-semibold">
|
||||
Public avatar
|
||||
</div>
|
||||
<p class="text-lg text-muted-foreground">
|
||||
Change your avatar here
|
||||
</p>
|
||||
<div class="flex flex-row gap-5">
|
||||
<Avatar class="size-20">
|
||||
<AvatarImage :src="avatarURL" alt="Current avatar" />
|
||||
<AvatarFallback></AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex flex-col gap-2">
|
||||
<FormField v-slot="{ componentField }" name="file">
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel class="font-semi-bold">Upload new avatar</FormLabel>
|
||||
<FormControl>
|
||||
<Input ref="fileInput" type="file"
|
||||
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
|
||||
@change="handleImageUpload" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<Button variant="destructive" class="w-32" @click="onAvatarDelete">
|
||||
Remove avatar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import { h, ref } from 'vue'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import * as z from 'zod'
|
||||
import { vAutoAnimate } from '@formkit/auto-animate/vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import AvatarUploader from '@/components/account/AvatarUploader.vue'
|
||||
|
||||
const formSchema = toTypedSchema(z.object({
|
||||
username: z.string().min(2).max(50),
|
||||
bio: z.string().optional(),
|
||||
file: z.any().optional(),
|
||||
}))
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const { isFieldDirty, handleSubmit } = useForm({
|
||||
validationSchema: formSchema,
|
||||
})
|
||||
|
||||
const avatarImageFile = ref(null)
|
||||
const avatarImageURL = ref(null)
|
||||
|
||||
const onAvatarImageChange = ({ file, url }) => {
|
||||
avatarImageFile.value = file
|
||||
avatarImageURL.value = url
|
||||
|
||||
}
|
||||
|
||||
const onAvatarImageDelete = () => {
|
||||
avatarImageURL.value = null
|
||||
avatarImageFile.value = null
|
||||
}
|
||||
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
toast({
|
||||
title: 'You submitted the following values:',
|
||||
description: h('pre', { class: 'mt-2 w-[340px] rounded-md bg-slate-950 p-4' }, h('code', { class: 'text-white' }, JSON.stringify(values, null, 2))),
|
||||
})
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="w-2/3 space-y-6" @submit="onSubmit">
|
||||
<AvatarUploader @change="onAvatarImageChange" @onAvatarDelete="onAvatarImageDelete" :avatarURL=userStore.userAvatar />
|
||||
<FormField v-slot="{ componentField }" name="bio">
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>Bio</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="Tell us a little bit about yourself" class="resize-none" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<Button type="submit">
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
</template>
|
||||
|
@@ -16,22 +16,6 @@ const sidebarNavItems = [
|
||||
title: 'Profile',
|
||||
href: '/account/profile',
|
||||
},
|
||||
{
|
||||
title: 'Account',
|
||||
href: '/account/account',
|
||||
},
|
||||
{
|
||||
title: 'Appearance',
|
||||
href: '/account/appearance',
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
href: '/account/notifications',
|
||||
},
|
||||
{
|
||||
title: 'Display',
|
||||
href: '/account/display',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
|
@@ -1,65 +1,51 @@
|
||||
<template>
|
||||
<div class="h-screen">
|
||||
<div class="px-3 pb-2 border-b-2 rounded-b-lg shadow-md">
|
||||
<div class="flex justify-between mt-3">
|
||||
<h3 class="scroll-m-20 text-2xl font-medium flex gap-x-2">
|
||||
Conversations
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex justify-between px-3 py-2 border-b-2 rounded-b-lg shadow-md">
|
||||
|
||||
<!-- Search -->
|
||||
<!-- <div class="relative mx-auto my-3">
|
||||
<Input id="search" type="text" placeholder="Search message or reference number"
|
||||
class="pl-10 bg-[#F0F2F5]" />
|
||||
<span class="absolute start-1 inset-y-0 flex items-center justify-center px-2">
|
||||
<Search class="size-6 text-muted-foreground" />
|
||||
</span>
|
||||
</div> -->
|
||||
|
||||
<div class="flex justify-between mt-5">
|
||||
<Tabs v-model:model-value="conversationType">
|
||||
<TabsList class="w-full flex justify-evenly">
|
||||
<TabsTrigger value="assigned" class="w-full">
|
||||
Assigned
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="unassigned" class="w-full">
|
||||
Unassigned
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="all" class="w-full">
|
||||
All
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div class="space-x-2">
|
||||
<div class="w-[8rem]">
|
||||
<Select @update:modelValue="handleFilterChange" v-model="predefinedFilter">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a filter" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<!-- <SelectLabel>Status</SelectLabel> -->
|
||||
<SelectItem value="status_all">
|
||||
All
|
||||
</SelectItem>
|
||||
<SelectItem value="status_open">
|
||||
Open
|
||||
</SelectItem>
|
||||
<SelectItem value="status_processing">
|
||||
Processing
|
||||
</SelectItem>
|
||||
<SelectItem value="status_spam">
|
||||
Spam
|
||||
</SelectItem>
|
||||
<SelectItem value="status_resolved">
|
||||
Resolved
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Tabs v-model:model-value="conversationType">
|
||||
<TabsList class="w-full flex justify-evenly">
|
||||
<TabsTrigger value="assigned" class="w-full">
|
||||
Assigned
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="unassigned" class="w-full">
|
||||
Unassigned
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="all" class="w-full">
|
||||
All
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div class="space-x-2">
|
||||
<div class="w-[8rem]">
|
||||
<Select @update:modelValue="handleFilterChange" v-model="predefinedFilter">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a filter" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<!-- <SelectLabel>Status</SelectLabel> -->
|
||||
<SelectItem value="status_all">
|
||||
All
|
||||
</SelectItem>
|
||||
<SelectItem value="status_open">
|
||||
Open
|
||||
</SelectItem>
|
||||
<SelectItem value="status_processing">
|
||||
Processing
|
||||
</SelectItem>
|
||||
<SelectItem value="status_spam">
|
||||
Spam
|
||||
</SelectItem>
|
||||
<SelectItem value="status_resolved">
|
||||
Resolved
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<Error :errorMessage="conversationStore.conversations.errorMessage"></Error>
|
||||
|
@@ -13,5 +13,5 @@ const dataDonut = [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DonutChart index="name" :category="'total'" :data="dataDonut" :value-formatter="valueFormatter" />
|
||||
<DonutChart index="name" :category="'total'" :data="dataDonut" :value-formatter="valueFormatter" type="pie" />
|
||||
</template>
|
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
import { LineChart } from '@/components/ui/chart-line'
|
||||
|
||||
defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LineChart :data="data" index="date" :categories="['new_conversations']" :y-formatter="(tick, i) => {
|
||||
return typeof tick === 'number'
|
||||
? `${new Intl.NumberFormat('us').format(tick).toString()}`
|
||||
: ''
|
||||
}" />
|
||||
</template>
|
18
frontend/src/components/ui/form/FormControl.vue
Normal file
18
frontend/src/components/ui/form/FormControl.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import { Slot } from "radix-vue";
|
||||
import { useFormField } from "./useFormField";
|
||||
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Slot
|
||||
:id="formItemId"
|
||||
:aria-describedby="
|
||||
!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`
|
||||
"
|
||||
:aria-invalid="!!error"
|
||||
>
|
||||
<slot />
|
||||
</Slot>
|
||||
</template>
|
19
frontend/src/components/ui/form/FormDescription.vue
Normal file
19
frontend/src/components/ui/form/FormDescription.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
import { useFormField } from "./useFormField";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const { formDescriptionId } = useFormField();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
:id="formDescriptionId"
|
||||
:class="cn('text-sm text-muted-foreground', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
19
frontend/src/components/ui/form/FormItem.vue
Normal file
19
frontend/src/components/ui/form/FormItem.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
import { provide } from "vue";
|
||||
import { useId } from "radix-vue";
|
||||
import { FORM_ITEM_INJECTION_KEY } from "./injectionKeys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const id = useId();
|
||||
provide(FORM_ITEM_INJECTION_KEY, id);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('space-y-2', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
23
frontend/src/components/ui/form/FormLabel.vue
Normal file
23
frontend/src/components/ui/form/FormLabel.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import { useFormField } from "./useFormField";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const props = defineProps({
|
||||
for: { type: String, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const { error, formItemId } = useFormField();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
:class="cn(error && 'text-destructive', props.class)"
|
||||
:for="formItemId"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
16
frontend/src/components/ui/form/FormMessage.vue
Normal file
16
frontend/src/components/ui/form/FormMessage.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
import { ErrorMessage } from "vee-validate";
|
||||
import { toValue } from "vue";
|
||||
import { useFormField } from "./useFormField";
|
||||
|
||||
const { name, formMessageId } = useFormField();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ErrorMessage
|
||||
:id="formMessageId"
|
||||
as="p"
|
||||
:name="toValue(name)"
|
||||
class="text-[0.8rem] font-medium text-destructive"
|
||||
/>
|
||||
</template>
|
11
frontend/src/components/ui/form/index.js
Normal file
11
frontend/src/components/ui/form/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
Form,
|
||||
Field as FormField,
|
||||
FieldArray as FormFieldArray,
|
||||
} from "vee-validate";
|
||||
export { default as FormItem } from "./FormItem.vue";
|
||||
export { default as FormLabel } from "./FormLabel.vue";
|
||||
export { default as FormControl } from "./FormControl.vue";
|
||||
export { default as FormMessage } from "./FormMessage.vue";
|
||||
export { default as FormDescription } from "./FormDescription.vue";
|
||||
export { FORM_ITEM_INJECTION_KEY } from "./injectionKeys";
|
1
frontend/src/components/ui/form/injectionKeys.js
Normal file
1
frontend/src/components/ui/form/injectionKeys.js
Normal file
@@ -0,0 +1 @@
|
||||
export const FORM_ITEM_INJECTION_KEY = Symbol();
|
35
frontend/src/components/ui/form/useFormField.js
Normal file
35
frontend/src/components/ui/form/useFormField.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
FieldContextKey,
|
||||
useFieldError,
|
||||
useIsFieldDirty,
|
||||
useIsFieldTouched,
|
||||
useIsFieldValid,
|
||||
} from "vee-validate";
|
||||
import { inject } from "vue";
|
||||
import { FORM_ITEM_INJECTION_KEY } from "./injectionKeys";
|
||||
|
||||
export function useFormField() {
|
||||
const fieldContext = inject(FieldContextKey);
|
||||
const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY);
|
||||
const fieldState = {
|
||||
valid: useIsFieldValid(),
|
||||
isDirty: useIsFieldDirty(),
|
||||
isTouched: useIsFieldTouched(),
|
||||
error: useFieldError(),
|
||||
};
|
||||
|
||||
if (!fieldContext)
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
|
||||
const { name } = fieldContext;
|
||||
const id = fieldItemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
}
|
29
frontend/src/components/ui/textarea/Textarea.vue
Normal file
29
frontend/src/components/ui/textarea/Textarea.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import { useVModel } from "@vueuse/core";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
defaultValue: { type: [String, Number], required: false },
|
||||
modelValue: { type: [String, Number], required: false },
|
||||
});
|
||||
|
||||
const emits = defineEmits(["update:modelValue"]);
|
||||
|
||||
const modelValue = useVModel(props, "modelValue", emits, {
|
||||
passive: true,
|
||||
defaultValue: props.defaultValue,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
v-model="modelValue"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
1
frontend/src/components/ui/textarea/index.js
Normal file
1
frontend/src/components/ui/textarea/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Textarea } from "./Textarea.vue";
|
@@ -7,7 +7,8 @@ import AccountView from "@/views/AccountView.vue"
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/dashboard'
|
||||
name: "login",
|
||||
component: UserLoginView
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
@@ -20,16 +21,18 @@ const routes = [
|
||||
component: ConversationsView,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: "login",
|
||||
component: UserLoginView
|
||||
},
|
||||
{
|
||||
path: '/account/:page?',
|
||||
name: 'account',
|
||||
component: AccountView,
|
||||
props: true,
|
||||
beforeEnter: (to, from, next) => {
|
||||
if (!to.params.page) {
|
||||
next({ ...to, params: { ...to.params, page: 'profile' } });
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
@@ -23,14 +23,17 @@ export const useUserStore = defineStore('user', () => {
|
||||
return userFirstName.value + " " + userLastName.value
|
||||
})
|
||||
|
||||
const getCurrentUser = () => {
|
||||
return api.getCurrentUser().then((resp) => {
|
||||
const getCurrentUser = async () => {
|
||||
try {
|
||||
const resp = await api.getCurrentUser();
|
||||
if (resp.data.data) {
|
||||
userAvatar.value = resp.data.data.avatar_url
|
||||
userFirstName.value = resp.data.data.first_name
|
||||
userLastName.value = resp.data.data.last_name
|
||||
userAvatar.value = resp.data.data.avatar_url;
|
||||
userFirstName.value = resp.data.data.first_name;
|
||||
userLastName.value = resp.data.data.last_name;
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error fetching current user:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return { userFirstName, userLastName, userAvatar, getFullName, setAvatar, setFirstName, setLastName, getCurrentUser }
|
||||
|
@@ -1,9 +1,10 @@
|
||||
<script setup>
|
||||
import Account from '@/components/account/Account.vue'
|
||||
import Profile from '@/components/account/Profile.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Account>
|
||||
yo
|
||||
<Profile/>
|
||||
</Account>
|
||||
</template>
|
||||
|
@@ -6,9 +6,11 @@ import api from '@/api';
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
import CountCards from '@/components/dashboard/agent/CountCards.vue'
|
||||
import ConversationsOverTime from '@/components/dashboard/agent/ConversationsOverTime.vue';
|
||||
|
||||
const { toast } = useToast()
|
||||
const counts = ref({})
|
||||
const newConversationsStats = ref([])
|
||||
const userStore = useUserStore()
|
||||
const agentCountCardsLabels = {
|
||||
total_assigned: "Total Assigned",
|
||||
@@ -18,10 +20,11 @@ const agentCountCardsLabels = {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getStats()
|
||||
getCardStats()
|
||||
getnewConversationsStatsStats()
|
||||
})
|
||||
|
||||
const getStats = () => {
|
||||
const getCardStats = () => {
|
||||
api.getAssigneeStats().then((resp) => {
|
||||
counts.value = resp.data.data
|
||||
}).catch((err) => {
|
||||
@@ -32,24 +35,48 @@ const getStats = () => {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const getnewConversationsStatsStats = () => {
|
||||
api.getNewConversationsStats().then((resp) => {
|
||||
newConversationsStats.value = resp.data.data
|
||||
}).catch((err) => {
|
||||
toast({
|
||||
title: 'Uh oh! Something went wrong.',
|
||||
description: err.response.data.message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function getGreeting () {
|
||||
const now = new Date();
|
||||
const hours = now.getHours();
|
||||
|
||||
if (hours >= 5 && hours < 12) {
|
||||
return "Good morning";
|
||||
} else if (hours >= 12 && hours < 17) {
|
||||
return "Good afternoon";
|
||||
} else if (hours >= 17 && hours < 21) {
|
||||
return "Good evening";
|
||||
} else {
|
||||
return "Good night";
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tab-container-default">
|
||||
<div class="page-content">
|
||||
<div v-if="userStore.getFullName">
|
||||
<h4 class="scroll-m-20 text-2xl font-semibold tracking-tight">
|
||||
<p>Good morning, {{ userStore.getFullName }}</p>
|
||||
<p>{{ getGreeting() }}, {{ userStore.getFullName }}</p>
|
||||
<p class="text-xl text-muted-foreground">🌤️ {{ format(new Date(), "EEEE, MMMM d HH:mm a") }}</p>
|
||||
</h4>
|
||||
</div>
|
||||
<CountCards :counts="counts" :labels="agentCountCardsLabels" />
|
||||
<!-- <div class="w-1/2 flex flex-col items-center justify-between">
|
||||
<LineChart :data="data" index="year" :categories="['Export Growth Rate', 'Import Growth Rate']"
|
||||
:y-formatter="(tick, i) => {
|
||||
return typeof tick === 'number'
|
||||
? `$ ${new Intl.NumberFormat('us').format(tick).toString()}`
|
||||
: ''
|
||||
}" />
|
||||
</div> -->
|
||||
<!-- <AssignedByStatusDonut /> -->
|
||||
<div class="flex my-10">
|
||||
<ConversationsOverTime class="flex-1" :data=newConversationsStats />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@@ -17,5 +17,5 @@ export default defineConfig({
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
3409
frontend/yarn.lock
3409
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
1
go.mod
1
go.mod
@@ -45,6 +45,7 @@ require (
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
|
||||
github.com/klauspost/compress v1.17.8 // indirect
|
||||
github.com/knadh/koanf/maps v0.1.1 // indirect
|
||||
github.com/knadh/stuffbin v1.3.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
|
2
go.sum
2
go.sum
@@ -103,6 +103,8 @@ github.com/knadh/koanf/v2 v2.1.1 h1:/R8eXqasSTsmDCsAyYj+81Wteg8AqrV9CP6gvsTsOmM=
|
||||
github.com/knadh/koanf/v2 v2.1.1/go.mod h1:4mnTRbZCK+ALuBXHZMjDfG9y714L7TykVnZkXbMU3Es=
|
||||
github.com/knadh/smtppool v1.1.0 h1:J7RB3PpNQW/STnJ6JXlNZLfuNsgJu2VILV+CHWnc/j8=
|
||||
github.com/knadh/smtppool v1.1.0/go.mod h1:3DJHouXAgPDBz0kC50HukOsdapYSwIEfJGwuip46oCA=
|
||||
github.com/knadh/stuffbin v1.3.0 h1:HaVSuYV+KnrlCHl7DrLNyOCgpTU2K8x5Hb+J4Ck3gww=
|
||||
github.com/knadh/stuffbin v1.3.0/go.mod h1:yVCFaWaKPubSNibBsTAJ939q2ABHudJQxRWZWV5yh+4=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
|
@@ -83,6 +83,7 @@ type queries struct {
|
||||
GetConversationsUUIDs string `query:"get-conversations-uuids"`
|
||||
GetAssignedConversations *sqlx.Stmt `query:"get-assigned-conversations"`
|
||||
GetAssigneeStats *sqlx.Stmt `query:"get-assignee-stats"`
|
||||
GetNewConversationsStats *sqlx.Stmt `query:"get-new-conversations-stats"`
|
||||
InsertConverstionParticipant *sqlx.Stmt `query:"insert-conversation-participant"`
|
||||
InsertConversation *sqlx.Stmt `query:"insert-conversation"`
|
||||
UpdateFirstReplyAt *sqlx.Stmt `query:"update-first-reply-at"`
|
||||
@@ -442,6 +443,18 @@ func (c *Manager) GetAssigneeStats(userID int) (models.ConversationCounts, error
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (c *Manager) GetNewConversationsStats() ([]models.NewConversationsStats, error) {
|
||||
var stats []models.NewConversationsStats
|
||||
if err := c.q.GetNewConversationsStats.Select(&stats); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return stats, err
|
||||
}
|
||||
c.lo.Error("error fetching new conversation stats", "error", err)
|
||||
return stats, err
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (t *Manager) AddTags(convUUID string, tagIDs []int) error {
|
||||
// Delete tags that have been removed.
|
||||
if _, err := t.q.DeleteTags.Exec(convUUID, pq.Array(tagIDs)); err != nil {
|
||||
|
@@ -50,3 +50,8 @@ type ConversationCounts struct {
|
||||
AwaitingResponseCount int `db:"awaiting_response_count" json:"awaiting_response_count"`
|
||||
CreatedTodayCount int `db:"created_today_count" json:"created_today_count"`
|
||||
}
|
||||
|
||||
type NewConversationsStats struct {
|
||||
Date time.Time `db:"date" json:"date"`
|
||||
NewConversations int `db:"new_conversations" json:"new_conversations"`
|
||||
}
|
||||
|
@@ -205,6 +205,17 @@ WHERE
|
||||
assigned_user_id = $1;
|
||||
|
||||
|
||||
-- name: get-new-conversations-stats
|
||||
SELECT
|
||||
DATE_TRUNC('day', created_at) AS date,
|
||||
COUNT(*) AS new_conversations
|
||||
FROM
|
||||
conversations
|
||||
GROUP BY
|
||||
date
|
||||
ORDER BY
|
||||
date;
|
||||
|
||||
-- name: update-first-reply-at
|
||||
UPDATE conversations
|
||||
SET first_reply_at = $2
|
||||
|
8
internal/upload/queries.sql
Normal file
8
internal/upload/queries.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- name: insert-upload
|
||||
INSERT INTO uploads
|
||||
(filename)
|
||||
VALUES($1)
|
||||
RETURNING id;
|
||||
|
||||
-- name: delete-upload
|
||||
DELETE FROM uploads WHERE id = $1;
|
80
internal/upload/upload.go
Normal file
80
internal/upload/upload.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package upload
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/attachment"
|
||||
"github.com/abhinavxd/artemis/internal/dbutil"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
var (
|
||||
// Embedded filesystem
|
||||
//go:embed queries.sql
|
||||
efs embed.FS
|
||||
|
||||
uriUploads = "/upload/%s"
|
||||
)
|
||||
|
||||
// Manager is the uploads manager.
|
||||
type Manager struct {
|
||||
Store attachment.Store
|
||||
lo *logf.Logger
|
||||
queries queries
|
||||
appBaseURL string
|
||||
}
|
||||
|
||||
type Opts struct {
|
||||
Lo *logf.Logger
|
||||
DB *sqlx.DB
|
||||
AppBaseURL string
|
||||
}
|
||||
|
||||
// New creates a new attachment manager instance.
|
||||
func New(store attachment.Store, opt Opts) (*Manager, error) {
|
||||
var q queries
|
||||
|
||||
// Scan SQL file
|
||||
if err := dbutil.ScanSQLFile("queries.sql", &q, opt.DB, efs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Manager{
|
||||
queries: q,
|
||||
Store: store,
|
||||
lo: opt.Lo,
|
||||
appBaseURL: opt.AppBaseURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type queries struct {
|
||||
Insert *sqlx.Stmt `query:"insert-upload"`
|
||||
Delete *sqlx.Stmt `query:"delete-upload"`
|
||||
}
|
||||
|
||||
// Upload inserts the attachment details into the db and uploads the attachment.
|
||||
func (m *Manager) Upload(fileName, contentType string, content io.ReadSeeker) (string, error) {
|
||||
var uuid string
|
||||
|
||||
if err := m.queries.Insert.QueryRow(fileName).Scan(&uuid); err != nil {
|
||||
m.lo.Error("error inserting upload", "error", err)
|
||||
return uuid, err
|
||||
}
|
||||
|
||||
if _, err := m.Store.Put(uuid, contentType, content); err != nil {
|
||||
m.Delete(uuid)
|
||||
return uuid, err
|
||||
}
|
||||
|
||||
return m.appBaseURL + fmt.Sprintf(uriUploads, uuid), nil
|
||||
}
|
||||
|
||||
// AttachMessage attaches given attachments to a message.
|
||||
func (m *Manager) Delete(uuid string) error {
|
||||
if err := m.Store.Delete(uuid); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
Reference in New Issue
Block a user