mirror of
				https://github.com/abhinavxd/libredesk.git
				synced 2025-11-03 21:43:35 +00:00 
			
		
		
		
	starting again
This commit is contained in:
		
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
node_modules
 | 
			
		||||
config.toml
 | 
			
		||||
artemis.bin
 | 
			
		||||
							
								
								
									
										22
									
								
								Makefile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								Makefile
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
			
		||||
# Git version for injecting into Go bins.
 | 
			
		||||
LAST_COMMIT := $(shell git rev-parse --short HEAD)
 | 
			
		||||
LAST_COMMIT_DATE := $(shell git show -s --format=%ci ${LAST_COMMIT})
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
.PHONY: $(BIN_ARTEMIS)
 | 
			
		||||
$(BIN_ARTEMIS):
 | 
			
		||||
	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)"
 | 
			
		||||
 | 
			
		||||
test:
 | 
			
		||||
	@go test -v ./...
 | 
			
		||||
.PHONY: test
 | 
			
		||||
 | 
			
		||||
clean:
 | 
			
		||||
	@go clean
 | 
			
		||||
	-@rm -f ${BIN_ARTEMIS}
 | 
			
		||||
.PHONY: clean
 | 
			
		||||
							
								
								
									
										32
									
								
								cmd/agents.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								cmd/agents.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,32 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	"github.com/zerodha/fastglue"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func handleGetAgents(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app = r.Context.(*App)
 | 
			
		||||
	)
 | 
			
		||||
	agents, err := app.userDB.GetAgents()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return r.SendEnvelope(agents)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func handleGetAgentProfile(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app          = r.Context.(*App)
 | 
			
		||||
		userEmail, _ = r.RequestCtx.UserValue("user_email").(string)
 | 
			
		||||
	)
 | 
			
		||||
	agents, err := app.userDB.GetAgent(userEmail)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return r.SendEnvelope(agents)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										18
									
								
								cmd/canned_responses.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								cmd/canned_responses.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	"github.com/zerodha/fastglue"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func handleGetCannedResponses(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app = r.Context.(*App)
 | 
			
		||||
	)
 | 
			
		||||
	c, err := app.cannedResp.GetAllCannedResponses()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return r.SendErrorEnvelope(http.StatusInternalServerError, "Error fetching canned responses", nil, "")
 | 
			
		||||
	}
 | 
			
		||||
	return r.SendEnvelope(c)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										75
									
								
								cmd/conversation.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								cmd/conversation.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,75 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	"github.com/zerodha/fastglue"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func handleGetConversations(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app = r.Context.(*App)
 | 
			
		||||
	)
 | 
			
		||||
	conversations, err := app.conversations.GetConversations()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return r.SendEnvelope(conversations)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func handleGetConversation(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app  = r.Context.(*App)
 | 
			
		||||
		uuid = r.RequestCtx.UserValue("uuid").(string)
 | 
			
		||||
	)
 | 
			
		||||
	conversation, err := app.conversations.GetConversation(uuid)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return r.SendEnvelope(conversation)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func handleUpdateAssignee(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app          = r.Context.(*App)
 | 
			
		||||
		p            = r.RequestCtx.PostArgs()
 | 
			
		||||
		uuid         = r.RequestCtx.UserValue("uuid").(string)
 | 
			
		||||
		assigneeType = r.RequestCtx.UserValue("assignee_type").(string)
 | 
			
		||||
		assigneeUUID = p.Peek("assignee_uuid")
 | 
			
		||||
	)
 | 
			
		||||
	if err := app.conversations.UpdateAssignee(uuid, assigneeUUID, assigneeType); err != nil {
 | 
			
		||||
		return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return r.SendEnvelope("ok")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func handleUpdatePriority(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app      = r.Context.(*App)
 | 
			
		||||
		p        = r.RequestCtx.PostArgs()
 | 
			
		||||
		uuid     = r.RequestCtx.UserValue("uuid").(string)
 | 
			
		||||
		priority = p.Peek("priority")
 | 
			
		||||
	)
 | 
			
		||||
	if err := app.conversations.UpdatePriority(uuid, priority); err != nil {
 | 
			
		||||
		return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return r.SendEnvelope("ok")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func handleUpdateStatus(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app    = r.Context.(*App)
 | 
			
		||||
		p      = r.RequestCtx.PostArgs()
 | 
			
		||||
		uuid   = r.RequestCtx.UserValue("uuid").(string)
 | 
			
		||||
		status = p.Peek("status")
 | 
			
		||||
	)
 | 
			
		||||
	if err := app.conversations.UpdateStatus(uuid, status); err != nil {
 | 
			
		||||
		return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return r.SendEnvelope("ok")
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										26
									
								
								cmd/handlers.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								cmd/handlers.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"github.com/knadh/koanf/v2"
 | 
			
		||||
	"github.com/zerodha/fastglue"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func initHandlers(g *fastglue.Fastglue, app *App, ko *koanf.Koanf) {
 | 
			
		||||
	g.POST("/api/login", handleLogin)
 | 
			
		||||
	g.GET("/api/logout", handleLogout)
 | 
			
		||||
	g.GET("/api/conversations", authSession(handleGetConversations))
 | 
			
		||||
	g.GET("/api/conversation/{uuid}", authSession(handleGetConversation))
 | 
			
		||||
	g.GET("/api/conversation/{uuid}/messages", authSession(handleGetMessages))
 | 
			
		||||
	g.PUT("/api/conversation/{uuid}/assignee/{assignee_type}", authSession(handleUpdateAssignee))
 | 
			
		||||
	g.PUT("/api/conversation/{uuid}/priority", authSession(handleUpdatePriority))
 | 
			
		||||
	g.PUT("/api/conversation/{uuid}/status", authSession(handleUpdateStatus))
 | 
			
		||||
 | 
			
		||||
	g.POST("/api/conversation/{uuid}/tags", authSession(handleUpsertConvTag))
 | 
			
		||||
 | 
			
		||||
	g.GET("/api/profile", authSession(handleGetAgentProfile))
 | 
			
		||||
	g.GET("/api/canned_responses", authSession(handleGetCannedResponses))
 | 
			
		||||
	g.POST("/api/media", authSession(handleMediaUpload))
 | 
			
		||||
	g.GET("/api/agents", authSession(handleGetAgents))
 | 
			
		||||
	g.GET("/api/teams", authSession(handleGetTeams))
 | 
			
		||||
	g.GET("/api/tags", authSession(handleGetTags))
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										167
									
								
								cmd/init.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										167
									
								
								cmd/init.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,167 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"log"
 | 
			
		||||
	"os"
 | 
			
		||||
 | 
			
		||||
	"github.com/abhinavxd/artemis/internal/cannedresp"
 | 
			
		||||
	"github.com/abhinavxd/artemis/internal/conversations"
 | 
			
		||||
	"github.com/abhinavxd/artemis/internal/media"
 | 
			
		||||
	"github.com/abhinavxd/artemis/internal/media/stores/s3"
 | 
			
		||||
	"github.com/abhinavxd/artemis/internal/tags"
 | 
			
		||||
	user "github.com/abhinavxd/artemis/internal/userdb"
 | 
			
		||||
	"github.com/go-redis/redis/v8"
 | 
			
		||||
	"github.com/jmoiron/sqlx"
 | 
			
		||||
	"github.com/knadh/koanf/providers/posflag"
 | 
			
		||||
	"github.com/knadh/koanf/v2"
 | 
			
		||||
	flag "github.com/spf13/pflag"
 | 
			
		||||
	"github.com/vividvilla/simplesessions"
 | 
			
		||||
	sessredisstore "github.com/vividvilla/simplesessions/stores/goredis"
 | 
			
		||||
	"github.com/zerodha/logf"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// consts holds the app constants.
 | 
			
		||||
type consts struct {
 | 
			
		||||
	ChatReferenceNumberPattern   string
 | 
			
		||||
	AllowedMediaUploadExtensions []string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func initFlags() {
 | 
			
		||||
	f := flag.NewFlagSet("config", flag.ContinueOnError)
 | 
			
		||||
 | 
			
		||||
	// Registering `--help` handler.
 | 
			
		||||
	f.Usage = func() {
 | 
			
		||||
		fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0])
 | 
			
		||||
		flag.PrintDefaults()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Register the commandline flags and parse them.
 | 
			
		||||
	f.StringSlice("config", []string{"config.toml"},
 | 
			
		||||
		"path to one or more config files (will be merged in order)")
 | 
			
		||||
	f.Bool("version", false, "show current version of the build")
 | 
			
		||||
 | 
			
		||||
	if err := f.Parse(os.Args[1:]); err != nil {
 | 
			
		||||
		log.Fatalf("loading flags: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := ko.Load(posflag.Provider(f, ".", ko), nil); err != nil {
 | 
			
		||||
		log.Fatalf("loading config: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func initConstants(ko *koanf.Koanf) consts {
 | 
			
		||||
	return consts{
 | 
			
		||||
		ChatReferenceNumberPattern:   ko.String("app.constants.chat_reference_number_pattern"),
 | 
			
		||||
		AllowedMediaUploadExtensions: ko.Strings("app.constants.allowed_media_upload_extensions"),
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// initSessionManager initializes and returns a simplesessions.Manager instance.
 | 
			
		||||
func initSessionManager(rd *redis.Client, ko *koanf.Koanf) *simplesessions.Manager {
 | 
			
		||||
	ttl := ko.Duration("app.session.cookie_ttl")
 | 
			
		||||
	s := simplesessions.New(simplesessions.Options{
 | 
			
		||||
		CookieName:       ko.MustString("app.session.cookie_name"),
 | 
			
		||||
		CookiePath:       ko.MustString("app.session.cookie_path"),
 | 
			
		||||
		IsSecureCookie:   ko.Bool("app.session.cookie_secure"),
 | 
			
		||||
		DisableAutoSet:   ko.Bool("app.session.cookie_disable_auto_set"),
 | 
			
		||||
		IsHTTPOnlyCookie: true,
 | 
			
		||||
		CookieLifetime:   ttl,
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Initialize a Redis pool for session storage.
 | 
			
		||||
	st := sessredisstore.New(context.TODO(), rd)
 | 
			
		||||
 | 
			
		||||
	// Prefix backend session keys with cookie name.
 | 
			
		||||
	st.SetPrefix(ko.MustString("app.session.cookie_name") + ":")
 | 
			
		||||
	// Set TTL in backend if its set.
 | 
			
		||||
	if ttl > 0 {
 | 
			
		||||
		st.SetTTL(ttl)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	s.UseStore(st)
 | 
			
		||||
	s.RegisterGetCookie(simpleSessGetCookieCB)
 | 
			
		||||
	s.RegisterSetCookie(simpleSessSetCookieCB)
 | 
			
		||||
	return s
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func initUserDB(DB *sqlx.DB, lo *logf.Logger, ko *koanf.Koanf) *user.UserDB {
 | 
			
		||||
	udb, err := user.New(user.Opts{
 | 
			
		||||
		DB:         DB,
 | 
			
		||||
		Lo:         lo,
 | 
			
		||||
		BcryptCost: ko.MustInt("app.user.password_bcypt_cost"),
 | 
			
		||||
	})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatalf("error initializing userdb: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	return udb
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func initConversations(db *sqlx.DB, lo *logf.Logger, ko *koanf.Koanf) *conversations.Conversations {
 | 
			
		||||
	c, err := conversations.New(conversations.Opts{
 | 
			
		||||
		DB: db,
 | 
			
		||||
		Lo: lo,
 | 
			
		||||
	})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatalf("error initializing conversations: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	return c
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func initTags(db *sqlx.DB, lo *logf.Logger) *tags.Tags {
 | 
			
		||||
	t, err := tags.New(tags.Opts{
 | 
			
		||||
		DB: db,
 | 
			
		||||
		Lo: lo,
 | 
			
		||||
	})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatalf("error initializing tags: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	return t
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func initCannedResponse(db *sqlx.DB, lo *logf.Logger) *cannedresp.CannedResp {
 | 
			
		||||
	c, err := cannedresp.New(cannedresp.Opts{
 | 
			
		||||
		DB: db,
 | 
			
		||||
		Lo: lo,
 | 
			
		||||
	})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatalf("error initializing canned responses: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	return c
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func initMediaManager(ko *koanf.Koanf, db *sqlx.DB) *media.Manager {
 | 
			
		||||
	var (
 | 
			
		||||
		manager *media.Manager
 | 
			
		||||
		store   media.Store
 | 
			
		||||
		err     error
 | 
			
		||||
	)
 | 
			
		||||
	// First init the store.
 | 
			
		||||
	switch s := ko.MustString("app.media_store"); s {
 | 
			
		||||
	case "s3":
 | 
			
		||||
		store, err = s3.New(s3.Opt{
 | 
			
		||||
			URL:        ko.String("s3.url"),
 | 
			
		||||
			PublicURL:  ko.String("s3.public_url"),
 | 
			
		||||
			AccessKey:  ko.String("s3.access_key"),
 | 
			
		||||
			SecretKey:  ko.String("s3.secret_key"),
 | 
			
		||||
			Region:     ko.String("s3.region"),
 | 
			
		||||
			Bucket:     ko.String("s3.bucket"),
 | 
			
		||||
			BucketPath: ko.String("s3.bucket_path"),
 | 
			
		||||
			BucketType: ko.String("s3.bucket_type"),
 | 
			
		||||
			Expiry:     ko.Duration("s3.expiry"),
 | 
			
		||||
		})
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatalf("error initializing s3 %v", err)
 | 
			
		||||
		}
 | 
			
		||||
	default:
 | 
			
		||||
		log.Fatal("media store not available.")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	manager, err = media.New(store, db)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatalf("initializing media manager %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return manager
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										75
									
								
								cmd/login.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								cmd/login.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,75 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	"github.com/valyala/fasthttp"
 | 
			
		||||
	"github.com/zerodha/fastglue"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func handleLogin(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app      = r.Context.(*App)
 | 
			
		||||
		p        = r.RequestCtx.PostArgs()
 | 
			
		||||
		email    = string(p.Peek("email"))
 | 
			
		||||
		password = p.Peek("password")
 | 
			
		||||
	)
 | 
			
		||||
	user, err := app.userDB.Login(email, password)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "GeneralException")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	sess, err := app.sess.Acquire(r, r, nil)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		app.lo.Error("error acquiring session", "error", err)
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError,
 | 
			
		||||
			"Error acquiring session.", nil, "GeneralException")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Set email in the session.
 | 
			
		||||
	if err := sess.Set("user_email", email); err != nil {
 | 
			
		||||
		app.lo.Error("error setting session", "error", err)
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError,
 | 
			
		||||
			"Error setting session.", nil, "GeneralException")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Set user DB ID in the session.
 | 
			
		||||
	if err := sess.Set("user_id", user.ID); err != nil {
 | 
			
		||||
		app.lo.Error("error setting session", "error", err)
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError,
 | 
			
		||||
			"Error setting session.", nil, "GeneralException")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := sess.Commit(); err != nil {
 | 
			
		||||
		app.lo.Error("error comitting session", "error", err)
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError,
 | 
			
		||||
			"Error commiting session.", nil, "GeneralException")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	agent, err := app.userDB.GetAgent(email)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		app.lo.Error("fetching agent", "error", err)
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError,
 | 
			
		||||
			"Error fetching agent.", nil, "GeneralException")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return r.SendEnvelope(agent)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func handleLogout(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app = r.Context.(*App)
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	sess, err := app.sess.Acquire(r, r, nil)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		app.lo.Error("error acquiring session", "error", err)
 | 
			
		||||
		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError,
 | 
			
		||||
			"Error acquiring session.", nil, "GeneralException")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := sess.Clear(); err != nil {
 | 
			
		||||
		app.lo.Error("error clearing session", "error", err)
 | 
			
		||||
	}
 | 
			
		||||
	return r.SendEnvelope("ok")
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										82
									
								
								cmd/main.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								cmd/main.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,82 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"log"
 | 
			
		||||
 | 
			
		||||
	"github.com/abhinavxd/artemis/internal/cannedresp"
 | 
			
		||||
	"github.com/abhinavxd/artemis/internal/conversations"
 | 
			
		||||
	"github.com/abhinavxd/artemis/internal/initz"
 | 
			
		||||
	"github.com/abhinavxd/artemis/internal/media"
 | 
			
		||||
	"github.com/abhinavxd/artemis/internal/tags"
 | 
			
		||||
	user "github.com/abhinavxd/artemis/internal/userdb"
 | 
			
		||||
	"github.com/knadh/koanf/v2"
 | 
			
		||||
	"github.com/valyala/fasthttp"
 | 
			
		||||
	"github.com/vividvilla/simplesessions"
 | 
			
		||||
	"github.com/zerodha/fastcache/v4"
 | 
			
		||||
	"github.com/zerodha/fastglue"
 | 
			
		||||
	"github.com/zerodha/logf"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var (
 | 
			
		||||
	ko = koanf.New(".")
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// App is the global app context which is passed and injected everywhere.
 | 
			
		||||
type App struct {
 | 
			
		||||
	constants     consts
 | 
			
		||||
	lo            *logf.Logger
 | 
			
		||||
	conversations *conversations.Conversations
 | 
			
		||||
	userDB        *user.UserDB
 | 
			
		||||
	sess          *simplesessions.Manager
 | 
			
		||||
	mediaManager  *media.Manager
 | 
			
		||||
	tags          *tags.Tags
 | 
			
		||||
	cannedResp    *cannedresp.CannedResp
 | 
			
		||||
	fc            *fastcache.FastCache
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func main() {
 | 
			
		||||
	// Command line flags.
 | 
			
		||||
	initFlags()
 | 
			
		||||
 | 
			
		||||
	// Load the config file into Koanf.
 | 
			
		||||
	initz.Config(ko)
 | 
			
		||||
 | 
			
		||||
	lo := initz.Logger(ko.MustString("app.log_level"), ko.MustString("app.env"))
 | 
			
		||||
	rd := initz.Redis(ko)
 | 
			
		||||
	db := initz.DB(ko)
 | 
			
		||||
 | 
			
		||||
	// Init the app.
 | 
			
		||||
	var app = &App{
 | 
			
		||||
		lo:            &lo,
 | 
			
		||||
		constants:     initConstants(ko),
 | 
			
		||||
		conversations: initConversations(db, &lo, ko),
 | 
			
		||||
		sess:          initSessionManager(rd, ko),
 | 
			
		||||
		userDB:        initUserDB(db, &lo, ko),
 | 
			
		||||
		mediaManager:  initMediaManager(ko, db),
 | 
			
		||||
		tags:          initTags(db, &lo),
 | 
			
		||||
		cannedResp:    initCannedResponse(db, &lo),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// HTTP server.
 | 
			
		||||
	g := fastglue.NewGlue()
 | 
			
		||||
	g.SetContext(app)
 | 
			
		||||
 | 
			
		||||
	// Handlers.
 | 
			
		||||
	initHandlers(g, app, ko)
 | 
			
		||||
 | 
			
		||||
	s := &fasthttp.Server{
 | 
			
		||||
		Name:                 ko.MustString("app.server.name"),
 | 
			
		||||
		ReadTimeout:          ko.MustDuration("app.server.read_timeout"),
 | 
			
		||||
		WriteTimeout:         ko.MustDuration("app.server.write_timeout"),
 | 
			
		||||
		MaxRequestBodySize:   ko.MustInt("app.server.max_body_size"),
 | 
			
		||||
		MaxKeepaliveDuration: ko.MustDuration("app.server.keepalive_timeout"),
 | 
			
		||||
		ReadBufferSize:       ko.MustInt("app.server.max_body_size"),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Start the HTTP server
 | 
			
		||||
	log.Printf("server listening on %s %s", ko.String("app.server.address"), ko.String("app.server.socket"))
 | 
			
		||||
	if err := g.ListenAndServe(ko.String("app.server.address"), ko.String("server.socket"), s); err != nil {
 | 
			
		||||
		log.Fatalf("error starting frontend server: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	log.Println("bye")
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										55
									
								
								cmd/media.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								cmd/media.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,55 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"slices"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"github.com/zerodha/fastglue"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func handleMediaUpload(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")
 | 
			
		||||
 | 
			
		||||
	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.AllowedMediaUploadExtensions, "*") {
 | 
			
		||||
		if !slices.Contains(app.constants.AllowedMediaUploadExtensions, ext) {
 | 
			
		||||
			return r.SendErrorEnvelope(http.StatusBadRequest, "Unsupported file type", nil, "GeneralException")
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Reset the ptr.
 | 
			
		||||
	file.Seek(0, 0)
 | 
			
		||||
	fileURL, err := app.mediaManager.UploadMedia(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(fileURL)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								cmd/messages.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								cmd/messages.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	"github.com/zerodha/fastglue"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func handleGetMessages(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app              = r.Context.(*App)
 | 
			
		||||
		uuid = r.RequestCtx.UserValue("uuid").(string)
 | 
			
		||||
	)
 | 
			
		||||
	messages, err := app.conversations.GetMessages(uuid)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return r.SendEnvelope(messages)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										48
									
								
								cmd/middlewares.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								cmd/middlewares.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,48 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	"github.com/vividvilla/simplesessions"
 | 
			
		||||
	"github.com/zerodha/fastglue"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func authSession(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
 | 
			
		||||
	return func(r *fastglue.Request) error {
 | 
			
		||||
		var (
 | 
			
		||||
			app       = r.Context.(*App)
 | 
			
		||||
			sess, err = app.sess.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.String(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")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if email != ""  && userID != ""{
 | 
			
		||||
			// Set both in request context so they can be accessed in the handlers later.
 | 
			
		||||
			r.RequestCtx.SetUserValue("user_email", email)
 | 
			
		||||
			r.RequestCtx.SetUserValue("user_id", userID)
 | 
			
		||||
			return handler(r)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := sess.Clear(); err != nil {
 | 
			
		||||
			return r.SendErrorEnvelope(http.StatusUnauthorized, "invalid or expired session", nil, "PermissionException")
 | 
			
		||||
		}
 | 
			
		||||
		return r.SendErrorEnvelope(http.StatusUnauthorized, "invalid or expired session", nil, "PermissionException")
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										80
									
								
								cmd/session.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								cmd/session.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,80 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"errors"
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	"github.com/valyala/fasthttp"
 | 
			
		||||
	"github.com/zerodha/fastglue"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// getRequestCookie returns fashttp.Cookie for the given name.
 | 
			
		||||
func getRequestCookie(name string, r *fastglue.Request) (*fasthttp.Cookie, error) {
 | 
			
		||||
	// Cookie value.
 | 
			
		||||
	val := r.RequestCtx.Request.Header.Cookie(name)
 | 
			
		||||
	if len(val) == 0 {
 | 
			
		||||
		return nil, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	c := fasthttp.AcquireCookie()
 | 
			
		||||
	if err := c.ParseBytes(val); err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return c, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// simpleSessGetCookieCB is the simplessesions callback for retrieving the session cookie
 | 
			
		||||
// from a fastglue request.
 | 
			
		||||
func simpleSessGetCookieCB(name string, r interface{}) (*http.Cookie, error) {
 | 
			
		||||
	req, ok := r.(*fastglue.Request)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		return nil, errors.New("session callback doesn't have fastglue.Request")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create fast http cookie and parse it from cookie bytes.
 | 
			
		||||
	c, err := getRequestCookie(name, req)
 | 
			
		||||
	if c == nil {
 | 
			
		||||
		if err == nil {
 | 
			
		||||
			return nil, http.ErrNoCookie
 | 
			
		||||
		} else {
 | 
			
		||||
			return nil, err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Convert fasthttp cookie to net http cookie.
 | 
			
		||||
	return &http.Cookie{
 | 
			
		||||
		Name:     name,
 | 
			
		||||
		Value:    string(c.Value()),
 | 
			
		||||
		Path:     string(c.Path()),
 | 
			
		||||
		Domain:   string(c.Domain()),
 | 
			
		||||
		Expires:  c.Expire(),
 | 
			
		||||
		Secure:   c.Secure(),
 | 
			
		||||
		HttpOnly: c.HTTPOnly(),
 | 
			
		||||
		SameSite: http.SameSite(c.SameSite()),
 | 
			
		||||
	}, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// simpleSessSetCookieCB is the simplessesions callback for setting the session cookie
 | 
			
		||||
// to a fastglue request.
 | 
			
		||||
func simpleSessSetCookieCB(c *http.Cookie, w interface{}) error {
 | 
			
		||||
	req, ok := w.(*fastglue.Request)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		return errors.New("session callback doesn't have fastglue.Request")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fc := fasthttp.AcquireCookie()
 | 
			
		||||
	defer fasthttp.ReleaseCookie(fc)
 | 
			
		||||
 | 
			
		||||
	fc.SetKey(c.Name)
 | 
			
		||||
	fc.SetValue(c.Value)
 | 
			
		||||
	fc.SetPath(c.Path)
 | 
			
		||||
	fc.SetDomain(c.Domain)
 | 
			
		||||
	fc.SetExpire(c.Expires)
 | 
			
		||||
	fc.SetSecure(c.Secure)
 | 
			
		||||
	fc.SetHTTPOnly(c.HttpOnly)
 | 
			
		||||
	fc.SetSameSite(fasthttp.CookieSameSite(c.SameSite))
 | 
			
		||||
 | 
			
		||||
	req.RequestCtx.Response.Header.SetCookie(fc)
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										40
									
								
								cmd/tags.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								cmd/tags.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,40 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	"github.com/zerodha/fastglue"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func handleUpsertConvTag(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app     = r.Context.(*App)
 | 
			
		||||
		p       = r.RequestCtx.PostArgs()
 | 
			
		||||
		uuid    = r.RequestCtx.UserValue("uuid").(string)
 | 
			
		||||
		tagJSON = p.Peek("tag_ids")
 | 
			
		||||
		tagIDs  = []int{}
 | 
			
		||||
	)
 | 
			
		||||
	err := json.Unmarshal(tagJSON, &tagIDs)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		app.lo.Error("unmarshalling tag ids", "error", err)
 | 
			
		||||
		return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := app.tags.UpsertConvTag(uuid, tagIDs); err != nil {
 | 
			
		||||
		return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return r.SendEnvelope("ok")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func handleGetTags(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app = r.Context.(*App)
 | 
			
		||||
	)
 | 
			
		||||
	t, err := app.tags.GetAllTags()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "")
 | 
			
		||||
	}
 | 
			
		||||
	return r.SendEnvelope(t)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										18
									
								
								cmd/teams.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								cmd/teams.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	"github.com/zerodha/fastglue"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func handleGetTeams(r *fastglue.Request) error {
 | 
			
		||||
	var (
 | 
			
		||||
		app              = r.Context.(*App)
 | 
			
		||||
	)
 | 
			
		||||
	teams, err := app.userDB.GetTeams()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "")
 | 
			
		||||
	}
 | 
			
		||||
	return r.SendEnvelope(teams)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										0
									
								
								frontend/.env
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								frontend/.env
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										30
									
								
								frontend/.eslintrc.cjs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								frontend/.eslintrc.cjs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
			
		||||
/* eslint-env node */
 | 
			
		||||
require('@rushstack/eslint-patch/modern-module-resolution')
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
  root: true,
 | 
			
		||||
  'extends': [
 | 
			
		||||
    'plugin:vue/vue3-essential',
 | 
			
		||||
    'eslint:recommended',
 | 
			
		||||
    '@vue/eslint-config-prettier/skip-formatting'
 | 
			
		||||
  ],
 | 
			
		||||
  overrides: [
 | 
			
		||||
    {
 | 
			
		||||
      files: [
 | 
			
		||||
        '**/__tests__/*.{cy,spec}.{js,ts,jsx,tsx}',
 | 
			
		||||
        'cypress/e2e/**/*.{cy,spec}.{js,ts,jsx,tsx}',
 | 
			
		||||
        'cypress/support/**/*.{js,ts,jsx,tsx}'
 | 
			
		||||
      ],
 | 
			
		||||
      rules: {
 | 
			
		||||
        'vue/multi-word-component-names': 'off',
 | 
			
		||||
      },
 | 
			
		||||
      'extends': [
 | 
			
		||||
        'plugin:cypress/recommended'
 | 
			
		||||
      ]
 | 
			
		||||
    },
 | 
			
		||||
    
 | 
			
		||||
  ],
 | 
			
		||||
  parserOptions: {
 | 
			
		||||
    ecmaVersion: 'latest'
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										30
									
								
								frontend/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								frontend/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
			
		||||
# Logs
 | 
			
		||||
logs
 | 
			
		||||
*.log
 | 
			
		||||
npm-debug.log*
 | 
			
		||||
yarn-debug.log*
 | 
			
		||||
yarn-error.log*
 | 
			
		||||
pnpm-debug.log*
 | 
			
		||||
lerna-debug.log*
 | 
			
		||||
 | 
			
		||||
node_modules
 | 
			
		||||
.DS_Store
 | 
			
		||||
dist
 | 
			
		||||
dist-ssr
 | 
			
		||||
coverage
 | 
			
		||||
*.local
 | 
			
		||||
 | 
			
		||||
/cypress/videos/
 | 
			
		||||
/cypress/screenshots/
 | 
			
		||||
 | 
			
		||||
# Editor directories and files
 | 
			
		||||
.vscode/*
 | 
			
		||||
!.vscode/extensions.json
 | 
			
		||||
.idea
 | 
			
		||||
*.suo
 | 
			
		||||
*.ntvs*
 | 
			
		||||
*.njsproj
 | 
			
		||||
*.sln
 | 
			
		||||
*.sw?
 | 
			
		||||
 | 
			
		||||
*.tsbuildinfo
 | 
			
		||||
							
								
								
									
										8
									
								
								frontend/.prettierrc.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								frontend/.prettierrc.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
			
		||||
{
 | 
			
		||||
  "$schema": "https://json.schemastore.org/prettierrc",
 | 
			
		||||
  "semi": false,
 | 
			
		||||
  "tabWidth": 2,
 | 
			
		||||
  "singleQuote": true,
 | 
			
		||||
  "printWidth": 100,
 | 
			
		||||
  "trailingComma": "none"
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										8
									
								
								frontend/.vscode/extensions.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								frontend/.vscode/extensions.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
			
		||||
{
 | 
			
		||||
  "recommendations": [
 | 
			
		||||
    "Vue.volar",
 | 
			
		||||
    "Vue.vscode-typescript-vue-plugin",
 | 
			
		||||
    "dbaeumer.vscode-eslint",
 | 
			
		||||
    "esbenp.prettier-vscode"
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										57
									
								
								frontend/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								frontend/README.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,57 @@
 | 
			
		||||
# frontend-tailwind
 | 
			
		||||
 | 
			
		||||
This template should help get you started developing with Vue 3 in Vite.
 | 
			
		||||
 | 
			
		||||
## Recommended IDE Setup
 | 
			
		||||
 | 
			
		||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
 | 
			
		||||
 | 
			
		||||
## Customize configuration
 | 
			
		||||
 | 
			
		||||
See [Vite Configuration Reference](https://vitejs.dev/config/).
 | 
			
		||||
 | 
			
		||||
## Project Setup
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
npm install
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Compile and Hot-Reload for Development
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
npm run dev
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Compile and Minify for Production
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
npm run build
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Run Headed Component Tests with [Cypress Component Testing](https://on.cypress.io/component)
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
npm run test:unit:dev # or `npm run test:unit` for headless testing
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Run End-to-End Tests with [Cypress](https://www.cypress.io/)
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
npm run test:e2e:dev
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
This runs the end-to-end tests against the Vite development server.
 | 
			
		||||
It is much faster than the production build.
 | 
			
		||||
 | 
			
		||||
But it's still recommended to test the production build with `test:e2e` before deploying (e.g. in CI environments):
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
npm run build
 | 
			
		||||
npm run test:e2e
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Lint with [ESLint](https://eslint.org/)
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
npm run lint
 | 
			
		||||
```
 | 
			
		||||
							
								
								
									
										16
									
								
								frontend/components.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								frontend/components.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
			
		||||
{
 | 
			
		||||
  "$schema": "https://shadcn-vue.com/schema.json",
 | 
			
		||||
  "style": "new-york",
 | 
			
		||||
  "typescript": false,
 | 
			
		||||
  "tailwind": {
 | 
			
		||||
    "config": "tailwind.config.js",
 | 
			
		||||
    "css": "src/assets/styles/main.scss",
 | 
			
		||||
    "baseColor": "gray",
 | 
			
		||||
    "cssVariables": true
 | 
			
		||||
  },
 | 
			
		||||
  "framework": "vite",
 | 
			
		||||
  "aliases": {
 | 
			
		||||
    "components": "@/components",
 | 
			
		||||
    "utils": "@/lib/utils"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										15
									
								
								frontend/cypress.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								frontend/cypress.config.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
import { defineConfig } from 'cypress'
 | 
			
		||||
 | 
			
		||||
export default defineConfig({
 | 
			
		||||
  e2e: {
 | 
			
		||||
    specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}',
 | 
			
		||||
    baseUrl: 'http://localhost:4173'
 | 
			
		||||
  },
 | 
			
		||||
  component: {
 | 
			
		||||
    specPattern: 'src/**/__tests__/*.{cy,spec}.{js,ts,jsx,tsx}',
 | 
			
		||||
    devServer: {
 | 
			
		||||
      framework: 'vue',
 | 
			
		||||
      bundler: 'vite'
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
							
								
								
									
										8
									
								
								frontend/cypress/e2e/example.cy.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								frontend/cypress/e2e/example.cy.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
			
		||||
// https://on.cypress.io/api
 | 
			
		||||
 | 
			
		||||
describe('My First Test', () => {
 | 
			
		||||
  it('visits the app root url', () => {
 | 
			
		||||
    cy.visit('/')
 | 
			
		||||
    cy.contains('h1', 'You did it!')
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
							
								
								
									
										8
									
								
								frontend/cypress/e2e/jsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								frontend/cypress/e2e/jsconfig.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
			
		||||
{
 | 
			
		||||
  "compilerOptions": {
 | 
			
		||||
    "target": "es5",
 | 
			
		||||
    "lib": ["es5", "dom"],
 | 
			
		||||
    "types": ["cypress"]
 | 
			
		||||
  },
 | 
			
		||||
  "include": ["./**/*", "../support/**/*"]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										5
									
								
								frontend/cypress/fixtures/example.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								frontend/cypress/fixtures/example.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "Using fixtures to represent data",
 | 
			
		||||
  "email": "hello@cypress.io",
 | 
			
		||||
  "body": "Fixtures are a great way to mock data for responses to routes"
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										25
									
								
								frontend/cypress/support/commands.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								frontend/cypress/support/commands.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,25 @@
 | 
			
		||||
// ***********************************************
 | 
			
		||||
// This example commands.js shows you how to
 | 
			
		||||
// create various custom commands and overwrite
 | 
			
		||||
// existing commands.
 | 
			
		||||
//
 | 
			
		||||
// For more comprehensive examples of custom
 | 
			
		||||
// commands please read more here:
 | 
			
		||||
// https://on.cypress.io/custom-commands
 | 
			
		||||
// ***********************************************
 | 
			
		||||
//
 | 
			
		||||
//
 | 
			
		||||
// -- This is a parent command --
 | 
			
		||||
// Cypress.Commands.add('login', (email, password) => { ... })
 | 
			
		||||
//
 | 
			
		||||
//
 | 
			
		||||
// -- This is a child command --
 | 
			
		||||
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
 | 
			
		||||
//
 | 
			
		||||
//
 | 
			
		||||
// -- This is a dual command --
 | 
			
		||||
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
 | 
			
		||||
//
 | 
			
		||||
//
 | 
			
		||||
// -- This will overwrite an existing command --
 | 
			
		||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
 | 
			
		||||
							
								
								
									
										12
									
								
								frontend/cypress/support/component-index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								frontend/cypress/support/component-index.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html>
 | 
			
		||||
  <head>
 | 
			
		||||
    <meta charset="utf-8">
 | 
			
		||||
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
 | 
			
		||||
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
 | 
			
		||||
    <title>Components App</title>
 | 
			
		||||
  </head>
 | 
			
		||||
  <body>
 | 
			
		||||
    <div data-cy-root></div>
 | 
			
		||||
  </body>
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										30
									
								
								frontend/cypress/support/component.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								frontend/cypress/support/component.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
			
		||||
// ***********************************************************
 | 
			
		||||
// This example support/component.js is processed and
 | 
			
		||||
// loaded automatically before your test files.
 | 
			
		||||
//
 | 
			
		||||
// This is a great place to put global configuration and
 | 
			
		||||
// behavior that modifies Cypress.
 | 
			
		||||
//
 | 
			
		||||
// You can change the location of this file or turn off
 | 
			
		||||
// automatically serving support files with the
 | 
			
		||||
// 'supportFile' configuration option.
 | 
			
		||||
//
 | 
			
		||||
// You can read more here:
 | 
			
		||||
// https://on.cypress.io/configuration
 | 
			
		||||
// ***********************************************************
 | 
			
		||||
 | 
			
		||||
// Import commands.js using ES2015 syntax:
 | 
			
		||||
import './commands'
 | 
			
		||||
 | 
			
		||||
// Alternatively you can use CommonJS syntax:
 | 
			
		||||
// require('./commands')
 | 
			
		||||
 | 
			
		||||
// Import global styles
 | 
			
		||||
import '@/assets/main.css'
 | 
			
		||||
 | 
			
		||||
import { mount } from 'cypress/vue'
 | 
			
		||||
 | 
			
		||||
Cypress.Commands.add('mount', mount)
 | 
			
		||||
 | 
			
		||||
// Example use:
 | 
			
		||||
// cy.mount(MyComponent)
 | 
			
		||||
							
								
								
									
										20
									
								
								frontend/cypress/support/e2e.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								frontend/cypress/support/e2e.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
// ***********************************************************
 | 
			
		||||
// This example support/index.js is processed and
 | 
			
		||||
// loaded automatically before your test files.
 | 
			
		||||
//
 | 
			
		||||
// This is a great place to put global configuration and
 | 
			
		||||
// behavior that modifies Cypress.
 | 
			
		||||
//
 | 
			
		||||
// You can change the location of this file or turn off
 | 
			
		||||
// automatically serving support files with the
 | 
			
		||||
// 'supportFile' configuration option.
 | 
			
		||||
//
 | 
			
		||||
// You can read more here:
 | 
			
		||||
// https://on.cypress.io/configuration
 | 
			
		||||
// ***********************************************************
 | 
			
		||||
 | 
			
		||||
// Import commands.js using ES2015 syntax:
 | 
			
		||||
import './commands'
 | 
			
		||||
 | 
			
		||||
// Alternatively you can use CommonJS syntax:
 | 
			
		||||
// require('./commands')
 | 
			
		||||
							
								
								
									
										20
									
								
								frontend/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								frontend/index.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html lang="en">
 | 
			
		||||
 | 
			
		||||
<head>
 | 
			
		||||
  <meta charset="UTF-8">
 | 
			
		||||
  <link rel="icon" href="/favicon.ico">
 | 
			
		||||
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
			
		||||
  <!-- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> -->
 | 
			
		||||
  <link rel="preconnect" href="https://fonts.googleapis.com">
 | 
			
		||||
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 | 
			
		||||
  <link href="https://fonts.googleapis.com/css2?family=Inter&display=swap" rel="stylesheet">
 | 
			
		||||
  <title>Vite App</title>
 | 
			
		||||
</head>
 | 
			
		||||
 | 
			
		||||
<body>
 | 
			
		||||
  <div id="app"></div>
 | 
			
		||||
  <script type="module" src="/src/main.js"></script>
 | 
			
		||||
</body>
 | 
			
		||||
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										8
									
								
								frontend/jsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								frontend/jsconfig.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
			
		||||
{
 | 
			
		||||
  "compilerOptions": {
 | 
			
		||||
    "paths": {
 | 
			
		||||
      "@/*": ["./src/*"]
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "exclude": ["node_modules", "dist"]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										63
									
								
								frontend/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								frontend/package.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,63 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "frontend-tailwind",
 | 
			
		||||
  "version": "0.0.0",
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "type": "module",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "dev": "bunx --bun vite",
 | 
			
		||||
    "build": "vite build",
 | 
			
		||||
    "preview": "vite preview",
 | 
			
		||||
    "test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'",
 | 
			
		||||
    "test:e2e:dev": "start-server-and-test 'vite dev --port 4173' http://localhost:4173 'cypress open --e2e'",
 | 
			
		||||
    "test:unit": "cypress run --component",
 | 
			
		||||
    "test:unit:dev": "cypress open --component",
 | 
			
		||||
    "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
 | 
			
		||||
    "format": "prettier --write src/"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@heroicons/vue": "^2.1.1",
 | 
			
		||||
    "@radix-icons/vue": "^1.0.0",
 | 
			
		||||
    "@tailwindcss/typography": "^0.5.10",
 | 
			
		||||
    "@tiptap/extension-placeholder": "^2.4.0",
 | 
			
		||||
    "@tiptap/pm": "^2.4.0",
 | 
			
		||||
    "@tiptap/starter-kit": "^2.4.0",
 | 
			
		||||
    "@tiptap/vue-3": "^2.4.0",
 | 
			
		||||
    "@unovis/ts": "^1.4.1",
 | 
			
		||||
    "@unovis/vue": "^1.4.1",
 | 
			
		||||
    "@vue/reactivity": "^3.4.15",
 | 
			
		||||
    "@vue/runtime-core": "^3.4.15",
 | 
			
		||||
    "@vueuse/core": "^10.9.0",
 | 
			
		||||
    "class-variance-authority": "^0.7.0",
 | 
			
		||||
    "clsx": "^2.1.1",
 | 
			
		||||
    "codeflask": "^1.4.1",
 | 
			
		||||
    "date-fns": "^3.6.0",
 | 
			
		||||
    "lucide-vue-next": "^0.378.0",
 | 
			
		||||
    "npm": "^10.4.0",
 | 
			
		||||
    "pinia": "^2.1.7",
 | 
			
		||||
    "qs": "^6.12.1",
 | 
			
		||||
    "radix-vue": "^1.8.0",
 | 
			
		||||
    "tailwind-merge": "^2.3.0",
 | 
			
		||||
    "tailwindcss-animate": "^1.0.7",
 | 
			
		||||
    "vue": "^3.4.15",
 | 
			
		||||
    "vue-letter": "^0.2.0",
 | 
			
		||||
    "vue-router": "^4.2.5"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@iconify/vue": "^4.1.2",
 | 
			
		||||
    "@rushstack/eslint-patch": "^1.3.3",
 | 
			
		||||
    "@vitejs/plugin-vue": "^5.0.3",
 | 
			
		||||
    "@vue/eslint-config-prettier": "^8.0.0",
 | 
			
		||||
    "autoprefixer": "latest",
 | 
			
		||||
    "cypress": "^13.6.3",
 | 
			
		||||
    "eslint": "^8.49.0",
 | 
			
		||||
    "eslint-plugin-cypress": "^2.15.1",
 | 
			
		||||
    "eslint-plugin-vue": "^9.17.0",
 | 
			
		||||
    "postcss": "^8.4.38",
 | 
			
		||||
    "prettier": "^3.0.3",
 | 
			
		||||
    "sass": "^1.70.0",
 | 
			
		||||
    "start-server-and-test": "^2.0.3",
 | 
			
		||||
    "tailwindcss": "latest",
 | 
			
		||||
    "vite": "^5.0.11"
 | 
			
		||||
  },
 | 
			
		||||
  "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										6
									
								
								frontend/postcss.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								frontend/postcss.config.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
			
		||||
export default {
 | 
			
		||||
  plugins: {
 | 
			
		||||
    tailwindcss: {},
 | 
			
		||||
    autoprefixer: {},
 | 
			
		||||
  },
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								frontend/public/favicon.ico
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend/public/favicon.ico
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 4.2 KiB  | 
							
								
								
									
										79
									
								
								frontend/src/App.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								frontend/src/App.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,79 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="bg-background text-foreground">
 | 
			
		||||
    <div v-if="$route.path !== '/login'">
 | 
			
		||||
      <ResizablePanelGroup direction="horizontal" auto-save-id="app.vue.resizable.panel">
 | 
			
		||||
        <ResizablePanel id="resize-panel-1" collapsible :default-size="10" :collapsed-size="1" :min-size="3"
 | 
			
		||||
          :max-size="20" :class="cn(isCollapsed && 'min-w-[50px] transition-all duration-200 ease-in-out')"
 | 
			
		||||
          @expand="toggleNav(false)" @collapse="toggleNav(true)">
 | 
			
		||||
          <NavBar :is-collapsed="isCollapsed" :links="navLinks" />
 | 
			
		||||
        </ResizablePanel>
 | 
			
		||||
        <ResizableHandle id="resize-handle-1" with-handle />
 | 
			
		||||
        <ResizablePanel id="resize-panel-2">
 | 
			
		||||
          <div class="w-full h-screen">
 | 
			
		||||
            <RouterView />
 | 
			
		||||
          </div>
 | 
			
		||||
        </ResizablePanel>
 | 
			
		||||
      </ResizablePanelGroup>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div v-else>
 | 
			
		||||
      <RouterView />
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup>
 | 
			
		||||
import { ref, onMounted } from "vue"
 | 
			
		||||
import { RouterView, useRouter } from 'vue-router'
 | 
			
		||||
import { cn } from '@/lib/utils'
 | 
			
		||||
import api from '@/api';
 | 
			
		||||
import { useUserStore } from '@/stores/user'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
import NavBar from './components/NavBar.vue'
 | 
			
		||||
import {
 | 
			
		||||
  ResizableHandle,
 | 
			
		||||
  ResizablePanel,
 | 
			
		||||
  ResizablePanelGroup,
 | 
			
		||||
} from '@/components/ui/resizable'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// State
 | 
			
		||||
const isCollapsed = ref(false)
 | 
			
		||||
const navLinks = [
 | 
			
		||||
  {
 | 
			
		||||
    title: 'Dashboard',
 | 
			
		||||
    component: 'dashboard',
 | 
			
		||||
    label: '',
 | 
			
		||||
    icon: 'lucide:layout-dashboard',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    title: 'Conversations',
 | 
			
		||||
    component: 'conversations',
 | 
			
		||||
    label: '',
 | 
			
		||||
    icon: 'lucide:message-circle-more',
 | 
			
		||||
  },
 | 
			
		||||
]
 | 
			
		||||
const userStore = useUserStore()
 | 
			
		||||
const router = useRouter()
 | 
			
		||||
 | 
			
		||||
// Functions, methods
 | 
			
		||||
function toggleNav (v) {
 | 
			
		||||
  isCollapsed.value = v
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
  api.getAgentProfile().then((resp) => {
 | 
			
		||||
    if (resp.data.data) {
 | 
			
		||||
      userStore.$patch((state) => {
 | 
			
		||||
        state.userAvatar = resp.data.data.avatar_url
 | 
			
		||||
        state.userFirstName = resp.data.data.first_name
 | 
			
		||||
        state.userLastName = resp.data.data.last_name
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
  }).catch((error) => {
 | 
			
		||||
    if (error.response.status === 401) {
 | 
			
		||||
      router.push("/login")
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										47
									
								
								frontend/src/api/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								frontend/src/api/index.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,47 @@
 | 
			
		||||
import axios from 'axios';
 | 
			
		||||
import qs from 'qs';
 | 
			
		||||
 | 
			
		||||
const http = axios.create({
 | 
			
		||||
    timeout: 10000,
 | 
			
		||||
    responseType: "json",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Request interceptor.
 | 
			
		||||
http.interceptors.request.use((request) => {
 | 
			
		||||
    // Set content type for POST/PUT requests.
 | 
			
		||||
    if ((request.method === "post" || request.method === "put") && !request.headers["Content-Type"]) {
 | 
			
		||||
        request.headers["Content-Type"] = "application/x-www-form-urlencoded"
 | 
			
		||||
        request.data = qs.stringify(request.data)
 | 
			
		||||
    }
 | 
			
		||||
    return request
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const login = (data) => http.post(`/api/login`, data);
 | 
			
		||||
const getTeams = () => http.get("/api/teams")
 | 
			
		||||
const getAgents = () => http.get("/api/agents")
 | 
			
		||||
const getAgentProfile = () => http.get("/api/profile")
 | 
			
		||||
const getTags = () => http.get("/api/tags")
 | 
			
		||||
const upsertTags = (uuid, data) => http.post(`/api/conversation/${uuid}/tags`, data);
 | 
			
		||||
const updateAssignee = (uuid, assignee_type, data) => http.put(`/api/conversation/${uuid}/assignee/${assignee_type}`, data);
 | 
			
		||||
const updateStatus = (uuid, data) => http.put(`/api/conversation/${uuid}/status`, data);
 | 
			
		||||
const updatePriority = (uuid, data) => http.put(`/api/conversation/${uuid}/priority`, data);
 | 
			
		||||
const getMessages = (uuid) => http.get(`/api/conversation/${uuid}/messages`);
 | 
			
		||||
const getConversation = (uuid) => http.get(`/api/conversation/${uuid}`);
 | 
			
		||||
const getConversations = () => http.get('/api/conversations');
 | 
			
		||||
const getCannedResponses = () => http.get('/api/canned_responses');
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
    login,
 | 
			
		||||
    getTags,
 | 
			
		||||
    getTeams,
 | 
			
		||||
    getAgents,
 | 
			
		||||
    getConversation,
 | 
			
		||||
    getConversations,
 | 
			
		||||
    getMessages,
 | 
			
		||||
    getAgentProfile,
 | 
			
		||||
    updateAssignee,
 | 
			
		||||
    updateStatus,
 | 
			
		||||
    updatePriority,
 | 
			
		||||
    upsertTags,
 | 
			
		||||
    getCannedResponses,
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										143
									
								
								frontend/src/assets/styles/main.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										143
									
								
								frontend/src/assets/styles/main.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,143 @@
 | 
			
		||||
@tailwind base;
 | 
			
		||||
@tailwind components;
 | 
			
		||||
@tailwind utilities;
 | 
			
		||||
 | 
			
		||||
// App default font-size.
 | 
			
		||||
// Default: 16px, 15px looked very wide.
 | 
			
		||||
:root {
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tab-container-default {
 | 
			
		||||
  padding: 20px 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Theme.
 | 
			
		||||
@layer base {
 | 
			
		||||
  :root {
 | 
			
		||||
    --background: 0 0% 100%;
 | 
			
		||||
    --foreground: 20 14.3% 4.1%;
 | 
			
		||||
  
 | 
			
		||||
    --card: 0 0% 100%;
 | 
			
		||||
    --card-foreground: 20 14.3% 4.1%;
 | 
			
		||||
  
 | 
			
		||||
    --popover: 0 0% 100%;
 | 
			
		||||
    --popover-foreground: 20 14.3% 4.1%;
 | 
			
		||||
  
 | 
			
		||||
    --primary: 24 9.8% 10%;
 | 
			
		||||
    --primary-foreground: 60 9.1% 97.8%;
 | 
			
		||||
  
 | 
			
		||||
    --secondary: 60 4.8% 95.9%;
 | 
			
		||||
    --secondary-foreground: 24 9.8% 10%;
 | 
			
		||||
  
 | 
			
		||||
    --muted: 60 4.8% 95.9%;
 | 
			
		||||
    --muted-foreground: 25 5.3% 44.7%;
 | 
			
		||||
  
 | 
			
		||||
    --accent: 60 4.8% 95.9%;
 | 
			
		||||
    --accent-foreground: 24 9.8% 10%;
 | 
			
		||||
  
 | 
			
		||||
    --destructive: 0 84.2% 60.2%;
 | 
			
		||||
    --destructive-foreground: 60 9.1% 97.8%;
 | 
			
		||||
  
 | 
			
		||||
    --border:20 5.9% 90%;
 | 
			
		||||
    --input:20 5.9% 90%;
 | 
			
		||||
    --ring:20 14.3% 4.1%;
 | 
			
		||||
    --radius: 0.25rem;
 | 
			
		||||
  }
 | 
			
		||||
   
 | 
			
		||||
  .dark {
 | 
			
		||||
    --background:20 14.3% 4.1%;
 | 
			
		||||
    --foreground:60 9.1% 97.8%;
 | 
			
		||||
  
 | 
			
		||||
    --card:20 14.3% 4.1%;
 | 
			
		||||
    --card-foreground:60 9.1% 97.8%;
 | 
			
		||||
  
 | 
			
		||||
    --popover:20 14.3% 4.1%;
 | 
			
		||||
    --popover-foreground:60 9.1% 97.8%;
 | 
			
		||||
  
 | 
			
		||||
    --primary:60 9.1% 97.8%;
 | 
			
		||||
    --primary-foreground:24 9.8% 10%;
 | 
			
		||||
  
 | 
			
		||||
    --secondary:12 6.5% 15.1%;
 | 
			
		||||
    --secondary-foreground:60 9.1% 97.8%;
 | 
			
		||||
  
 | 
			
		||||
    --muted:12 6.5% 15.1%;
 | 
			
		||||
    --muted-foreground:24 5.4% 63.9%;
 | 
			
		||||
  
 | 
			
		||||
    --accent:12 6.5% 15.1%;
 | 
			
		||||
    --accent-foreground:60 9.1% 97.8%;
 | 
			
		||||
  
 | 
			
		||||
    --destructive:0 62.8% 30.6%;
 | 
			
		||||
    --destructive-foreground:60 9.1% 97.8%;
 | 
			
		||||
  
 | 
			
		||||
    --border:12 6.5% 15.1%;
 | 
			
		||||
    --input:12 6.5% 15.1%;
 | 
			
		||||
    --ring:24 5.7% 82.9%;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@layer base {
 | 
			
		||||
  * {
 | 
			
		||||
    @apply border-border;
 | 
			
		||||
  }
 | 
			
		||||
  body {
 | 
			
		||||
    @apply bg-background text-foreground;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// charts
 | 
			
		||||
@layer base {
 | 
			
		||||
  :root {
 | 
			
		||||
    --vis-tooltip-background-color: none !important;
 | 
			
		||||
    --vis-tooltip-border-color: none !important;
 | 
			
		||||
    --vis-tooltip-text-color: none !important;
 | 
			
		||||
    --vis-tooltip-shadow-color: none !important;
 | 
			
		||||
    --vis-tooltip-backdrop-filter: none !important;
 | 
			
		||||
    --vis-tooltip-padding: none !important;
 | 
			
		||||
    --vis-primary-color: var(--primary);
 | 
			
		||||
    --vis-secondary-color: 160 81% 40%;
 | 
			
		||||
    --vis-text-color: var(--muted-foreground);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Shake animation
 | 
			
		||||
@keyframes shake {
 | 
			
		||||
  0% {
 | 
			
		||||
    transform: translateX(0);
 | 
			
		||||
  }
 | 
			
		||||
  15% {
 | 
			
		||||
    transform: translateX(-5px);
 | 
			
		||||
  }
 | 
			
		||||
  25% {
 | 
			
		||||
    transform: translateX(5px);
 | 
			
		||||
  }
 | 
			
		||||
  35% {
 | 
			
		||||
    transform: translateX(-5px);
 | 
			
		||||
  }
 | 
			
		||||
  45% {
 | 
			
		||||
    transform: translateX(5px);
 | 
			
		||||
  }
 | 
			
		||||
  55% {
 | 
			
		||||
    transform: translateX(-5px);
 | 
			
		||||
  }
 | 
			
		||||
  65% {
 | 
			
		||||
    transform: translateX(5px);
 | 
			
		||||
  }
 | 
			
		||||
  75% {
 | 
			
		||||
    transform: translateX(-5px);
 | 
			
		||||
  }
 | 
			
		||||
  85% {
 | 
			
		||||
    transform: translateX(5px);
 | 
			
		||||
  }
 | 
			
		||||
  95% {
 | 
			
		||||
    transform: translateX(-5px);
 | 
			
		||||
  }
 | 
			
		||||
  100% {
 | 
			
		||||
    transform: translateX(0);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.animate-shake {
 | 
			
		||||
  animation: shake 0.5s infinite;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										17
									
								
								frontend/src/components/AssignedByStatusDonut.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								frontend/src/components/AssignedByStatusDonut.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { DonutChart } from '@/components/ui/chart-donut'
 | 
			
		||||
const valueFormatter = (tick) => typeof tick === 'number' ? `$ ${new Intl.NumberFormat('us').format(tick).toString()}` : ''
 | 
			
		||||
const dataDonut = [
 | 
			
		||||
     { name: 'Jan', total: Math.floor(Math.random() * 2000) + 500, predicted: Math.floor(Math.random() * 2000) + 500 },
 | 
			
		||||
     { name: 'Feb', total: Math.floor(Math.random() * 2000) + 500, predicted: Math.floor(Math.random() * 2000) + 500 },
 | 
			
		||||
     { name: 'Mar', total: Math.floor(Math.random() * 2000) + 500, predicted: Math.floor(Math.random() * 2000) + 500 },
 | 
			
		||||
     { name: 'Apr', total: Math.floor(Math.random() * 2000) + 500, predicted: Math.floor(Math.random() * 2000) + 500 },
 | 
			
		||||
     { name: 'May', total: Math.floor(Math.random() * 2000) + 500, predicted: Math.floor(Math.random() * 2000) + 500 },
 | 
			
		||||
     { name: 'Jun', total: Math.floor(Math.random() * 2000) + 500, predicted: Math.floor(Math.random() * 2000) + 500 },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
     <DonutChart index="name" :category="'total'" :data="dataDonut" :value-formatter="valueFormatter" />
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										7
									
								
								frontend/src/components/ConversationEmpty.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								frontend/src/components/ConversationEmpty.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <h1 class="h-screen flex items-center justify-center">
 | 
			
		||||
        <div class="flex flex-row items-center justify-center">
 | 
			
		||||
            <p>Select a conversation from the left panel.</p>
 | 
			
		||||
        </div>
 | 
			
		||||
    </h1>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										71
									
								
								frontend/src/components/ConversationList.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								frontend/src/components/ConversationList.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,71 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div class="h-screen text-left">
 | 
			
		||||
        <Error :errorMessage="conversationStore.conversations.errorMessage"></Error>
 | 
			
		||||
        <div v-if="!conversationStore.conversations.loading">
 | 
			
		||||
            <ScrollArea>
 | 
			
		||||
                <div class="border-b flex items-center cursor-pointer p-2 flex-row"
 | 
			
		||||
                    v-for="conversation in conversationStore.conversations.data" :key="conversation.uuid"
 | 
			
		||||
                    @click="router.push('/conversations/' + conversation.uuid)">
 | 
			
		||||
                    <div>
 | 
			
		||||
                        <Avatar class="size-[55px]">
 | 
			
		||||
                            <AvatarImage :src=conversation.contact_avatar_url />
 | 
			
		||||
                            <AvatarFallback>
 | 
			
		||||
                                {{ conversation.contact_first_name.substring(0, 2).toUpperCase() }}
 | 
			
		||||
                            </AvatarFallback>
 | 
			
		||||
                        </Avatar>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="ml-3 w-full">
 | 
			
		||||
                        <div class="flex justify-between">
 | 
			
		||||
                            <div>
 | 
			
		||||
                                <p class="text-base font-normal">
 | 
			
		||||
                                    {{ conversation.contact_first_name + ' ' + conversation.contact_last_name }}
 | 
			
		||||
                                </p>
 | 
			
		||||
                            </div>
 | 
			
		||||
                            <div>
 | 
			
		||||
                                <span class="text-xs text-muted-foreground">
 | 
			
		||||
                                    {{ format(conversation.updated_at, 'h:mm a') }}
 | 
			
		||||
                                </span>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div>
 | 
			
		||||
                            <p class="text-gray-600 max-w-xs text-sm dark:text-white">
 | 
			
		||||
                                {{ conversation.last_message }}
 | 
			
		||||
                            </p>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            </ScrollArea>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div v-else>
 | 
			
		||||
            <div class="flex items-center gap-5 p-6 border-b" v-for="index in 10" :key="index">
 | 
			
		||||
                <Skeleton class="h-12 w-12 rounded-full" />
 | 
			
		||||
                <div class="space-y-2">
 | 
			
		||||
                    <Skeleton class="h-4 w-[250px]" />
 | 
			
		||||
                    <Skeleton class="h-4 w-[200px]" />
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup>
 | 
			
		||||
import { onMounted } from 'vue'
 | 
			
		||||
import { useRouter } from 'vue-router'
 | 
			
		||||
import { useConversationStore } from '@/stores/conversation'
 | 
			
		||||
import { format } from 'date-fns'
 | 
			
		||||
 | 
			
		||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
 | 
			
		||||
import { Error } from '@/components/ui/error'
 | 
			
		||||
import { ScrollArea } from '@/components/ui/scroll-area'
 | 
			
		||||
import { Skeleton } from '@/components/ui/skeleton'
 | 
			
		||||
 | 
			
		||||
// Stores, states.
 | 
			
		||||
const conversationStore = useConversationStore()
 | 
			
		||||
const router = useRouter()
 | 
			
		||||
 | 
			
		||||
// Functions, methods.
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
    conversationStore.fetchConversations()
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										376
									
								
								frontend/src/components/ConversationSideBar.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										376
									
								
								frontend/src/components/ConversationSideBar.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,376 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="p-3">
 | 
			
		||||
    <div>
 | 
			
		||||
      <Avatar class="size-20">
 | 
			
		||||
        <AvatarImage :src=conversationStore.conversation.data.contact_avatar_url />
 | 
			
		||||
        <AvatarFallback>
 | 
			
		||||
          {{ conversationStore.conversation.data.contact_first_name.toUpperCase().substring(0, 2) }}
 | 
			
		||||
        </AvatarFallback>
 | 
			
		||||
      </Avatar>
 | 
			
		||||
      <h4 class="text-l ">
 | 
			
		||||
        {{ conversationStore.conversation.data.contact_first_name + ' ' +
 | 
			
		||||
          conversationStore.conversation.data.contact_last_name }}
 | 
			
		||||
      </h4>
 | 
			
		||||
      <p class="text-sm text-muted-foreground flex gap-2 mt-1" v-if="conversationStore.conversation.data.contact_email">
 | 
			
		||||
        <Mail class="size-3 mt-1"></Mail>
 | 
			
		||||
        {{ conversationStore.conversation.data.contact_email }}
 | 
			
		||||
      </p>
 | 
			
		||||
      <p class="text-sm text-muted-foreground flex gap-2 mt-1"
 | 
			
		||||
        v-if="conversationStore.conversation.data.contact_phone_number">
 | 
			
		||||
        <Phone class="size-3 mt-1"></Phone>
 | 
			
		||||
        {{ conversationStore.conversation.data.contact_phone_number }}
 | 
			
		||||
      </p>
 | 
			
		||||
    </div>
 | 
			
		||||
    <Accordion type="single" collapsible class="border-t mt-4">
 | 
			
		||||
      <AccordionItem :value="actionAccordion.title">
 | 
			
		||||
        <AccordionTrigger>
 | 
			
		||||
          <p>{{ actionAccordion.title }}</p>
 | 
			
		||||
        </AccordionTrigger>
 | 
			
		||||
        <AccordionContent>
 | 
			
		||||
 | 
			
		||||
          <!-- Assigned agent -->
 | 
			
		||||
          <!-- <Select v-model="conversationStore.conversation.data.assigned_agent_uuid"
 | 
			
		||||
            @update:modelValue="handleAssignedAgentChange" id="select-agent">
 | 
			
		||||
            <SelectTrigger class="mb-3">
 | 
			
		||||
              <SelectValue placeholder="Assigned agent" />
 | 
			
		||||
            </SelectTrigger>
 | 
			
		||||
            <SelectContent>
 | 
			
		||||
              <SelectGroup>
 | 
			
		||||
                <SelectLabel>Assigned agent</SelectLabel>
 | 
			
		||||
                <SelectItem :value="agent.uuid" v-for="(agent) in agents" :key="agent.uuid">
 | 
			
		||||
                  {{ agent.first_name + ' ' + agent.last_name }}
 | 
			
		||||
                </SelectItem>
 | 
			
		||||
              </SelectGroup>
 | 
			
		||||
            </SelectContent>
 | 
			
		||||
          </Select> -->
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
          <div class="mb-3">
 | 
			
		||||
            <Popover v-model:open="agentSelectDropdownOpen">
 | 
			
		||||
              <PopoverTrigger as-child>
 | 
			
		||||
                <Button variant="outline" role="combobox" :aria-expanded="agentSelectDropdownOpen"
 | 
			
		||||
                  class="w-full justify-between">
 | 
			
		||||
                  {{ conversationStore.conversation.data.assigned_agent_uuid
 | 
			
		||||
                    ? agents.find((agent) => agent.uuid ===
 | 
			
		||||
                      conversationStore.conversation.data.assigned_agent_uuid)?.first_name + ' ' + agents.find((agent) =>
 | 
			
		||||
                        agent.uuid === conversationStore.conversation.data.assigned_agent_uuid)?.last_name
 | 
			
		||||
                    : "Select agent..." }}
 | 
			
		||||
 | 
			
		||||
                  <CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
 | 
			
		||||
                </Button>
 | 
			
		||||
              </PopoverTrigger>
 | 
			
		||||
              <PopoverContent class="p-0 PopoverContent">
 | 
			
		||||
                <Command @update:modelValue="handleAssignedAgentChange">
 | 
			
		||||
                  <CommandInput class="h-9" placeholder="Search agent..." />
 | 
			
		||||
                  <CommandEmpty>No agent found.</CommandEmpty>
 | 
			
		||||
                  <CommandList>
 | 
			
		||||
                    <CommandGroup>
 | 
			
		||||
                      <CommandItem v-for="agent in agents" :key="agent.uuid"
 | 
			
		||||
                        :value="agent.first_name + ' ' + agent.last_name" @select="(ev) => {
 | 
			
		||||
                          if (typeof ev.detail.value === 'string') {
 | 
			
		||||
                            conversationStore.conversation.data.assigned_agent_uuid = ev.detail.value
 | 
			
		||||
                          }
 | 
			
		||||
                          agentSelectDropdownOpen = false
 | 
			
		||||
                        }">
 | 
			
		||||
                        {{ agent.first_name + ' ' + agent.last_name }}
 | 
			
		||||
                        <CheckIcon :class="cn(
 | 
			
		||||
                          'ml-auto h-4 w-4',
 | 
			
		||||
                          conversationStore.conversation.data.assigned_agent_uuid === agent.uuid ? 'opacity-100' : 'opacity-0',
 | 
			
		||||
                        )" />
 | 
			
		||||
                      </CommandItem>
 | 
			
		||||
                    </CommandGroup>
 | 
			
		||||
                  </CommandList>
 | 
			
		||||
                </Command>
 | 
			
		||||
              </PopoverContent>
 | 
			
		||||
            </Popover>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Assigned agent end -->
 | 
			
		||||
 | 
			
		||||
          <!-- Assigned team  -->
 | 
			
		||||
          <!-- <Select v-model="conversationStore.conversation.data.assigned_team_uuid"
 | 
			
		||||
            @update:modelValue="handleAssignedTeamChange">
 | 
			
		||||
            <SelectTrigger class="mb-3">
 | 
			
		||||
              <SelectValue placeholder="Assigned team" />
 | 
			
		||||
            </SelectTrigger>
 | 
			
		||||
            <SelectContent>
 | 
			
		||||
              <SelectGroup>
 | 
			
		||||
                <SelectLabel>Assigned team</SelectLabel>
 | 
			
		||||
                <SelectItem :value="team.uuid" v-for="(team) in teams" :key="team.uuid">
 | 
			
		||||
                  {{ team.name }}
 | 
			
		||||
                </SelectItem>
 | 
			
		||||
              </SelectGroup>
 | 
			
		||||
            </SelectContent>
 | 
			
		||||
          </Select> -->
 | 
			
		||||
 | 
			
		||||
          <div class="mb-3">
 | 
			
		||||
            <Popover v-model:open="teamSelectDropdownOpen">
 | 
			
		||||
              <PopoverTrigger as-child>
 | 
			
		||||
                <Button variant="outline" role="combobox" :aria-expanded="teamSelectDropdownOpen"
 | 
			
		||||
                  class="w-full justify-between">
 | 
			
		||||
                  {{ conversationStore.conversation.data.assigned_team_uuid
 | 
			
		||||
                    ? teams.find((team) => team.uuid ===
 | 
			
		||||
                      conversationStore.conversation.data.assigned_team_uuid)?.name
 | 
			
		||||
                    : "Select team..." }}
 | 
			
		||||
                  <CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
 | 
			
		||||
                </Button>
 | 
			
		||||
              </PopoverTrigger>
 | 
			
		||||
              <PopoverContent class="p-0 PopoverContent">
 | 
			
		||||
                <Command @update:modelValue="handleAssignedTeamChange">
 | 
			
		||||
                  <CommandInput class="h-9" placeholder="Search team..." />
 | 
			
		||||
                  <CommandEmpty>No team found.</CommandEmpty>
 | 
			
		||||
                  <CommandList>
 | 
			
		||||
                    <CommandGroup>
 | 
			
		||||
                      <CommandItem v-for="team in teams" :key="team.uuid" :value="team.name" @select="(ev) => {
 | 
			
		||||
                        if (ev.detail.value) {
 | 
			
		||||
                          const selectedTeamName = ev.detail.value;
 | 
			
		||||
                          const selectedTeam = teams.find(team => team.name === selectedTeamName);
 | 
			
		||||
                          if (selectedTeam) {
 | 
			
		||||
                            conversationStore.conversation.data.assigned_team_uuid = selectedTeam.uuid;
 | 
			
		||||
                          }
 | 
			
		||||
                        }
 | 
			
		||||
                        teamSelectDropdownOpen = false
 | 
			
		||||
                      }">
 | 
			
		||||
                        {{ team.name }}
 | 
			
		||||
                        <CheckIcon :class="cn(
 | 
			
		||||
                          'ml-auto h-4 w-4',
 | 
			
		||||
                          conversationStore.conversation.data.assigned_team_uuid === team.uuid ? 'opacity-100' : 'opacity-0',
 | 
			
		||||
                        )" />
 | 
			
		||||
                      </CommandItem>
 | 
			
		||||
                    </CommandGroup>
 | 
			
		||||
                  </CommandList>
 | 
			
		||||
                </Command>
 | 
			
		||||
              </PopoverContent>
 | 
			
		||||
            </Popover>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- assigned team end -->
 | 
			
		||||
 | 
			
		||||
          <!-- Priority  -->
 | 
			
		||||
          <Select v-model="conversationStore.conversation.data.priority" @update:modelValue="handlePriorityChange">
 | 
			
		||||
            <SelectTrigger class="mb-3">
 | 
			
		||||
              <SelectValue placeholder="Priority" class="font-medium" />
 | 
			
		||||
            </SelectTrigger>
 | 
			
		||||
            <SelectContent>
 | 
			
		||||
              <SelectGroup>
 | 
			
		||||
                <SelectLabel>Priority</SelectLabel>
 | 
			
		||||
                <SelectItem value="Low">
 | 
			
		||||
                  Low
 | 
			
		||||
                </SelectItem>
 | 
			
		||||
                <SelectItem value="Medium">
 | 
			
		||||
                  Medium
 | 
			
		||||
                </SelectItem>
 | 
			
		||||
                <SelectItem value="High">
 | 
			
		||||
                  High
 | 
			
		||||
                </SelectItem>
 | 
			
		||||
              </SelectGroup>
 | 
			
		||||
            </SelectContent>
 | 
			
		||||
          </Select>
 | 
			
		||||
          <!-- Priority -->
 | 
			
		||||
 | 
			
		||||
          <!-- Tags -->
 | 
			
		||||
          <TagsInput class="px-0 gap-0 w-full" :model-value="tagsSelected" @update:modelValue="handleUpsertTags">
 | 
			
		||||
            <div class="flex gap-2 flex-wrap items-center px-3">
 | 
			
		||||
              <TagsInputItem v-for="item in tagsSelected" :key="item" :value="item">
 | 
			
		||||
                <TagsInputItemText />
 | 
			
		||||
                <TagsInputItemDelete />
 | 
			
		||||
              </TagsInputItem>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <ComboboxRoot v-model="tagsSelected" v-model:open="tagDropdownOpen" v-model:searchTerm="tagSearchTerm"
 | 
			
		||||
              class="w-full">
 | 
			
		||||
              <ComboboxAnchor as-child>
 | 
			
		||||
                <ComboboxInput placeholder="Add tags..." as-child>
 | 
			
		||||
                  <TagsInputInput class="w-full px-3" :class="tagsSelected.length > 0 ? 'mt-2' : ''"
 | 
			
		||||
                    @keydown.enter.prevent />
 | 
			
		||||
                </ComboboxInput>
 | 
			
		||||
              </ComboboxAnchor>
 | 
			
		||||
 | 
			
		||||
              <ComboboxPortal>
 | 
			
		||||
                <CommandList position="popper"
 | 
			
		||||
                  class="w-[--radix-popper-anchor-width] rounded-md mt-2 border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2">
 | 
			
		||||
                  <CommandEmpty />
 | 
			
		||||
                  <CommandGroup>
 | 
			
		||||
                    <CommandItem v-for="ftag in tagsFiltered" :key="ftag.value" :value="ftag.label" @select.prevent="(ev) => {
 | 
			
		||||
                      if (typeof ev.detail.value === 'string') {
 | 
			
		||||
                        tagSearchTerm = ''
 | 
			
		||||
                        tagsSelected.push(ev.detail.value)
 | 
			
		||||
                        tagDropdownOpen = false
 | 
			
		||||
                      }
 | 
			
		||||
 | 
			
		||||
                      if (tagsFiltered.length === 0) {
 | 
			
		||||
                        tagDropdownOpen = false
 | 
			
		||||
                      }
 | 
			
		||||
                    }">
 | 
			
		||||
                      {{ ftag.label }}
 | 
			
		||||
                    </CommandItem>
 | 
			
		||||
                  </CommandGroup>
 | 
			
		||||
                </CommandList>
 | 
			
		||||
              </ComboboxPortal>
 | 
			
		||||
            </ComboboxRoot>
 | 
			
		||||
          </TagsInput>
 | 
			
		||||
          <!-- Tags end -->
 | 
			
		||||
 | 
			
		||||
        </AccordionContent>
 | 
			
		||||
      </AccordionItem>
 | 
			
		||||
    </Accordion>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    <Accordion type="single" collapsible>
 | 
			
		||||
      <AccordionItem :value="infoAccordion.title">
 | 
			
		||||
        <AccordionTrigger>
 | 
			
		||||
          <p>{{ infoAccordion.title }}</p>
 | 
			
		||||
        </AccordionTrigger>
 | 
			
		||||
        <AccordionContent>
 | 
			
		||||
 | 
			
		||||
          <div class="flex flex-col gap-1 mb-5">
 | 
			
		||||
            <p class="font-medium">Initiated at</p>
 | 
			
		||||
            <p>
 | 
			
		||||
              {{ format(conversationStore.conversation.data.created_at, "PPpp") }}
 | 
			
		||||
            </p>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div class="flex flex-col gap-1 mb-5">
 | 
			
		||||
            <p class="font-medium">
 | 
			
		||||
              Resolved at
 | 
			
		||||
            </p>
 | 
			
		||||
            <p v-if="conversationStore.conversation.data.resolved_at">
 | 
			
		||||
              {{ format(conversationStore.conversation.data.resolved_at, "PPpp") }}
 | 
			
		||||
            </p>
 | 
			
		||||
            <p v-else>
 | 
			
		||||
              -
 | 
			
		||||
            </p>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div class="flex flex-col gap-1 mb-5">
 | 
			
		||||
            <p class="font-medium">
 | 
			
		||||
              Closed at
 | 
			
		||||
            </p>
 | 
			
		||||
            <p v-if="conversationStore.conversation.data.closed_at">
 | 
			
		||||
              {{ format(conversationStore.conversation.data.closed_at, "PPpp") }}
 | 
			
		||||
            </p>
 | 
			
		||||
            <p v-else>
 | 
			
		||||
              -
 | 
			
		||||
            </p>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
          <div class="flex flex-col gap-1 mb-5">
 | 
			
		||||
            <p class="font-medium">SLA</p>
 | 
			
		||||
            <p>48 hours remaining</p>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
        </AccordionContent>
 | 
			
		||||
      </AccordionItem>
 | 
			
		||||
    </Accordion>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup>
 | 
			
		||||
import { ref, onMounted, computed } from 'vue'
 | 
			
		||||
import { useConversationStore } from '@/stores/conversation'
 | 
			
		||||
import { format } from 'date-fns'
 | 
			
		||||
import api from '@/api';
 | 
			
		||||
 | 
			
		||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
 | 
			
		||||
import {
 | 
			
		||||
  Select,
 | 
			
		||||
  SelectContent,
 | 
			
		||||
  SelectGroup,
 | 
			
		||||
  SelectItem,
 | 
			
		||||
  SelectLabel,
 | 
			
		||||
  SelectTrigger,
 | 
			
		||||
  SelectValue,
 | 
			
		||||
} from '@/components/ui/select'
 | 
			
		||||
import { CaretSortIcon, CheckIcon } from '@radix-icons/vue'
 | 
			
		||||
import { cn } from '@/lib/utils'
 | 
			
		||||
import { Button } from '@/components/ui/button'
 | 
			
		||||
import {
 | 
			
		||||
  Popover,
 | 
			
		||||
  PopoverContent,
 | 
			
		||||
  PopoverTrigger,
 | 
			
		||||
} from '@/components/ui/popover'
 | 
			
		||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
 | 
			
		||||
import { ComboboxAnchor, ComboboxInput, ComboboxPortal, ComboboxRoot } from 'radix-vue'
 | 
			
		||||
import { CommandEmpty, CommandGroup, CommandInput, Command, CommandItem, CommandList } from '@/components/ui/command'
 | 
			
		||||
import { TagsInput, TagsInputInput, TagsInputItem, TagsInputItemDelete, TagsInputItemText } from '@/components/ui/tags-input'
 | 
			
		||||
import { Mail, Phone } from "lucide-vue-next"
 | 
			
		||||
 | 
			
		||||
// Stores, states.
 | 
			
		||||
const conversationStore = useConversationStore();
 | 
			
		||||
const agents = ref([])
 | 
			
		||||
const teams = ref([])
 | 
			
		||||
const agentSelectDropdownOpen = ref(false)
 | 
			
		||||
const teamSelectDropdownOpen = ref(false)
 | 
			
		||||
const tags = ref([])
 | 
			
		||||
const tagIDMap = {}
 | 
			
		||||
const tagsSelected = ref(conversationStore.conversation.data.tags)
 | 
			
		||||
const tagDropdownOpen = ref(false)
 | 
			
		||||
const tagSearchTerm = ref('')
 | 
			
		||||
const teamSearchTerm = ref('')
 | 
			
		||||
const tagsFiltered = computed(() => tags.value.filter(i => !tagsSelected.value.includes(i.label)))
 | 
			
		||||
// const agentsFiltered = computed(() => tags.value.filter(i => !tagsSelected.value.includes(i.label)))
 | 
			
		||||
 | 
			
		||||
const actionAccordion = {
 | 
			
		||||
  "title": "Action"
 | 
			
		||||
}
 | 
			
		||||
const infoAccordion = {
 | 
			
		||||
  "title": "Information"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Functions, methods.
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
  api.getAgents().then((resp) => {
 | 
			
		||||
    agents.value = resp.data.data;
 | 
			
		||||
  }).catch(error => {
 | 
			
		||||
    console.log(error)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  api.getTeams().then((resp) => {
 | 
			
		||||
    teams.value = resp.data.data;
 | 
			
		||||
  }).catch(error => {
 | 
			
		||||
    console.log(error)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  api.getTags().then(async (resp) => {
 | 
			
		||||
    let dt = resp.data.data
 | 
			
		||||
    dt.forEach(item => {
 | 
			
		||||
      tags.value.push({
 | 
			
		||||
        label: item.name,
 | 
			
		||||
        value: item.id,
 | 
			
		||||
      })
 | 
			
		||||
      tagIDMap[item.name] = item.id
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const handleAssignedAgentChange = (v) => {
 | 
			
		||||
  conversationStore.updateAssignee("agent", {
 | 
			
		||||
    "assignee_uuid": v
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
const handleAssignedTeamChange = (v) => {
 | 
			
		||||
  conversationStore.updateAssignee("team", {
 | 
			
		||||
    "assignee_uuid": v
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const handlePriorityChange = (v) => {
 | 
			
		||||
  conversationStore.updatePriority(v)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const handleUpsertTags = () => {
 | 
			
		||||
  let tagIDs = tagsSelected.value.map((tag) => {
 | 
			
		||||
    if (tag in tagIDMap) {
 | 
			
		||||
      return tagIDMap[tag]
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
  conversationStore.upsertTags({
 | 
			
		||||
    "tag_ids": JSON.stringify(tagIDs)
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style></style>
 | 
			
		||||
							
								
								
									
										121
									
								
								frontend/src/components/ConversationThread.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								frontend/src/components/ConversationThread.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,121 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div class="h-screen" v-if="conversationStore.conversation.data">
 | 
			
		||||
        <div class="h-14 border-b px-4">
 | 
			
		||||
            <div class="flex flex-row justify-between items-center pt-2">
 | 
			
		||||
                <div class="flex h-5 items-center space-x-4 text-sm">
 | 
			
		||||
                    <TooltipProvider :delay-duration=200>
 | 
			
		||||
                        <Tooltip>
 | 
			
		||||
                            <TooltipTrigger>#{{ conversationStore.conversation.data.reference_number }}
 | 
			
		||||
                            </TooltipTrigger>
 | 
			
		||||
                            <TooltipContent>
 | 
			
		||||
                                <p>Reference number</p>
 | 
			
		||||
                            </TooltipContent>
 | 
			
		||||
                        </Tooltip>
 | 
			
		||||
                    </TooltipProvider>
 | 
			
		||||
                    <Separator orientation="vertical" />
 | 
			
		||||
                    <TooltipProvider :delay-duration=200>
 | 
			
		||||
                        <Tooltip>
 | 
			
		||||
                            <TooltipTrigger>
 | 
			
		||||
                                <Badge :variant="getBadgeVariant">{{ conversationStore.conversation.data.status }}
 | 
			
		||||
                                </Badge>
 | 
			
		||||
                            </TooltipTrigger>
 | 
			
		||||
                            <TooltipContent>
 | 
			
		||||
                                <p>Status</p>
 | 
			
		||||
                            </TooltipContent>
 | 
			
		||||
                        </Tooltip>
 | 
			
		||||
                    </TooltipProvider>
 | 
			
		||||
                    <Separator orientation="vertical" />
 | 
			
		||||
                    <TooltipProvider :delay-duration=200>
 | 
			
		||||
                        <Tooltip>
 | 
			
		||||
                            <TooltipTrigger>
 | 
			
		||||
                                <Badge variant="default">{{ conversationStore.conversation.data.priority }}
 | 
			
		||||
                                </Badge>
 | 
			
		||||
                            </TooltipTrigger>
 | 
			
		||||
                            <TooltipContent>
 | 
			
		||||
                                <p>Priority</p>
 | 
			
		||||
                            </TooltipContent>
 | 
			
		||||
                        </Tooltip>
 | 
			
		||||
                    </TooltipProvider>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div>
 | 
			
		||||
                    <DropdownMenu>
 | 
			
		||||
                        <DropdownMenuTrigger>
 | 
			
		||||
                            <Icon icon="lucide:ellipsis-vertical" class="mt-2 size-6"></Icon>
 | 
			
		||||
                        </DropdownMenuTrigger>
 | 
			
		||||
                        <DropdownMenuContent>
 | 
			
		||||
                            <DropdownMenuItem @click="handleUpdateStatus('Open')">
 | 
			
		||||
                                <span>Open</span>
 | 
			
		||||
                            </DropdownMenuItem>
 | 
			
		||||
                            <DropdownMenuItem @click="handleUpdateStatus('Processing')">
 | 
			
		||||
                                <span>Processing</span>
 | 
			
		||||
                            </DropdownMenuItem>
 | 
			
		||||
                            <DropdownMenuItem @click="handleUpdateStatus('Spam')">
 | 
			
		||||
                                <span>Mark as spam</span>
 | 
			
		||||
                            </DropdownMenuItem>
 | 
			
		||||
                            <DropdownMenuItem @click="handleUpdateStatus('Resolved')">
 | 
			
		||||
                                <span>Resolve</span>
 | 
			
		||||
                            </DropdownMenuItem>
 | 
			
		||||
                        </DropdownMenuContent>
 | 
			
		||||
                    </DropdownMenu>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="flex flex-col h-screen">
 | 
			
		||||
            <Error :error-message="conversationStore.messages.errorMessage"></Error>
 | 
			
		||||
            <ScrollArea v-if="conversationStore.messages.data" class="flex-1">
 | 
			
		||||
                <MessageBubble v-for="message in conversationStore.messages.data" :key="message.uuid" :message="message"
 | 
			
		||||
                    :conversation="conversationStore.conversation.data" />
 | 
			
		||||
            </ScrollArea>
 | 
			
		||||
            <TextEditor @send="sendMessage" :identifier="conversationStore.conversation.data.uuid"
 | 
			
		||||
                :canned-responses="cannedResponsesStore.responses"></TextEditor>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed, onMounted } from 'vue';
 | 
			
		||||
import { useConversationStore } from '@/stores/conversation'
 | 
			
		||||
import { useCannedResponses } from '@/stores/canned_responses'
 | 
			
		||||
 | 
			
		||||
import { Separator } from '@/components/ui/separator'
 | 
			
		||||
import { Error } from '@/components/ui/error'
 | 
			
		||||
import { Badge } from '@/components/ui/badge'
 | 
			
		||||
import {
 | 
			
		||||
    DropdownMenu,
 | 
			
		||||
    DropdownMenuContent,
 | 
			
		||||
    DropdownMenuItem,
 | 
			
		||||
    DropdownMenuTrigger,
 | 
			
		||||
} from '@/components/ui/dropdown-menu'
 | 
			
		||||
import { ScrollArea } from '@/components/ui/scroll-area'
 | 
			
		||||
import {
 | 
			
		||||
    Tooltip,
 | 
			
		||||
    TooltipContent,
 | 
			
		||||
    TooltipProvider,
 | 
			
		||||
    TooltipTrigger
 | 
			
		||||
} from '@/components/ui/tooltip'
 | 
			
		||||
import MessageBubble from './MessageBubble.vue'
 | 
			
		||||
import TextEditor from './TextEditor.vue'
 | 
			
		||||
import { Icon } from '@iconify/vue'
 | 
			
		||||
 | 
			
		||||
// Store, state.
 | 
			
		||||
const conversationStore = useConversationStore()
 | 
			
		||||
const cannedResponsesStore = useCannedResponses()
 | 
			
		||||
 | 
			
		||||
// Functions, methods.
 | 
			
		||||
const sendMessage = (message) => {
 | 
			
		||||
    // TODO: Create msg.
 | 
			
		||||
    console.log(message)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const getBadgeVariant = computed(() => {
 | 
			
		||||
    return conversationStore.conversation.data.status == "Spam" ? "destructive" : "default"
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const handleUpdateStatus = (status) => {
 | 
			
		||||
    conversationStore.updateStatus(status)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
    cannedResponsesStore.fetchAll()
 | 
			
		||||
})
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										67
									
								
								frontend/src/components/MessageBubble.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								frontend/src/components/MessageBubble.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,67 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div class="flex flex-col" v-if="message.type === 'incoming' || message.type === 'outgoing'">
 | 
			
		||||
        <div class="self-start w-11/12 flex px-5 py-3 mt-2 ">
 | 
			
		||||
            <Avatar class="mt-1">
 | 
			
		||||
                <AvatarImage :src=getAvatar />
 | 
			
		||||
                <AvatarFallback>
 | 
			
		||||
                    {{ message.first_name.toUpperCase().substring(0, 2) }}
 | 
			
		||||
                </AvatarFallback>
 | 
			
		||||
            </Avatar>
 | 
			
		||||
            <div class="ml-5">
 | 
			
		||||
                <div class="flex gap-3">
 | 
			
		||||
                    <p class="font-medium">
 | 
			
		||||
                        {{ message.first_name + ' ' + message.last_name }}
 | 
			
		||||
                    </p>
 | 
			
		||||
                    <p class="text-muted-foreground text-xs mt-1">
 | 
			
		||||
                        {{ format(message.updated_at, 'h:mm a') }}
 | 
			
		||||
                    </p>
 | 
			
		||||
                </div>
 | 
			
		||||
                <Letter :html=message.content class="mb-1" />
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="self-start flex px-5 py-3 mt-2 bg-[#FFF7E6] opacity-80" v-if="message.type === 'internal_note'">
 | 
			
		||||
        <Avatar class="mt-1">
 | 
			
		||||
            <AvatarImage :src=getAvatar />
 | 
			
		||||
            <AvatarFallback>
 | 
			
		||||
                {{ message.first_name.toUpperCase().substring(0, 2) }}
 | 
			
		||||
            </AvatarFallback>
 | 
			
		||||
        </Avatar>
 | 
			
		||||
        <div class="ml-5">
 | 
			
		||||
            <div class="flex gap-3">
 | 
			
		||||
                <p class="font-medium">
 | 
			
		||||
                    {{ message.first_name + ' ' + message.last_name }}
 | 
			
		||||
                </p>
 | 
			
		||||
                <div class="flex items-center text-xs text-muted-foreground">
 | 
			
		||||
                    {{ format(message.updated_at, 'h:mm a') }}
 | 
			
		||||
                    <LockKeyhole class="w-4 h-4 ml-2" />
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <Letter :html=message.content class="mb-1" />
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="text-center p-2" v-if="message.type === 'activity'">
 | 
			
		||||
        <div class="text-sm text-muted-foreground">
 | 
			
		||||
            {{ message.content }}
 | 
			
		||||
            <span>{{ format(message.updated_at, 'h:mm a') }}</span>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed } from "vue"
 | 
			
		||||
import { format } from 'date-fns'
 | 
			
		||||
 | 
			
		||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
 | 
			
		||||
import { Letter } from 'vue-letter'
 | 
			
		||||
import { LockKeyhole } from "lucide-vue-next"
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
    message: Object,
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const getAvatar = computed(() => {
 | 
			
		||||
    return props.message.avatar_url ? props.message.avatar_url : ''
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										77
									
								
								frontend/src/components/NavBar.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								frontend/src/components/NavBar.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,77 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { RouterLink, useRoute } from 'vue-router'
 | 
			
		||||
import { cn } from '@/lib/utils'
 | 
			
		||||
 | 
			
		||||
import { Icon } from '@iconify/vue'
 | 
			
		||||
import { buttonVariants } from '@/components/ui/button'
 | 
			
		||||
import {
 | 
			
		||||
    Tooltip,
 | 
			
		||||
    TooltipContent,
 | 
			
		||||
    TooltipTrigger,
 | 
			
		||||
    TooltipProvider
 | 
			
		||||
} from '@/components/ui/tooltip'
 | 
			
		||||
 | 
			
		||||
defineProps({
 | 
			
		||||
    isCollapsed: Boolean,
 | 
			
		||||
    links: Array,
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const route = useRoute();
 | 
			
		||||
 | 
			
		||||
const getButtonVariant = (title) => {
 | 
			
		||||
    return route.name === title.toLowerCase() ? "default" : "ghost"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
    <div :data-collapsed="isCollapsed" class="group flex flex-col gap-4 py-2 data-[collapsed=true]:py-2">
 | 
			
		||||
        <nav class="grid gap-1 px-2 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2">
 | 
			
		||||
            <template v-for="(link, index) of links">
 | 
			
		||||
 | 
			
		||||
                <!-- Collapsed -->
 | 
			
		||||
                <router-link :to="{ name: link.component }" v-if="isCollapsed" :key="`1-${index}`">
 | 
			
		||||
                    <TooltipProvider :delay-duration=10>
 | 
			
		||||
                        <Tooltip>
 | 
			
		||||
                            <TooltipTrigger as-child>
 | 
			
		||||
                                <span :class="cn(
 | 
			
		||||
                                    buttonVariants({ variant: getButtonVariant(link.title), size: 'icon' }),
 | 
			
		||||
                                    'h-9 w-9',
 | 
			
		||||
                                    link.variant === getButtonVariant(link.title)
 | 
			
		||||
                                    && 'dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white',
 | 
			
		||||
                                )">
 | 
			
		||||
                                    <Icon :icon="link.icon" class="size-4" />
 | 
			
		||||
                                    <span class="sr-only">{{ link.title }}</span>
 | 
			
		||||
                                </span>
 | 
			
		||||
                            </TooltipTrigger>
 | 
			
		||||
                            <TooltipContent side="right" class="flex items-center gap-4">
 | 
			
		||||
                                {{ link.title }}
 | 
			
		||||
                                <span v-if="link.label" class="ml-auto text-muted-foreground">
 | 
			
		||||
                                    {{ link.label }}
 | 
			
		||||
                                </span>
 | 
			
		||||
                            </TooltipContent>
 | 
			
		||||
                        </Tooltip>
 | 
			
		||||
                    </TooltipProvider>
 | 
			
		||||
                </router-link>
 | 
			
		||||
 | 
			
		||||
                <!-- Expanded -->
 | 
			
		||||
                <router-link v-else :to="{ name: link.component }" :key="`2-${index}`" :class="cn(
 | 
			
		||||
                    buttonVariants({ variant: getButtonVariant(link.title), size: 'sm' }),
 | 
			
		||||
                    link.variant === getButtonVariant(link.title)
 | 
			
		||||
                    && 'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
 | 
			
		||||
                    'justify-start',
 | 
			
		||||
                )">
 | 
			
		||||
                    <Icon :icon="link.icon" class="mr-2 size-4" />
 | 
			
		||||
                    {{ link.title }}
 | 
			
		||||
                    <span v-if="link.label" :class="cn(
 | 
			
		||||
                        'ml-auto',
 | 
			
		||||
                        link.variant === getButtonVariant(link.title)
 | 
			
		||||
                        && 'text-background dark:text-white',
 | 
			
		||||
                    )">
 | 
			
		||||
                        {{ link.label }}
 | 
			
		||||
                    </span>
 | 
			
		||||
                </router-link>
 | 
			
		||||
            </template>
 | 
			
		||||
        </nav>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										243
									
								
								frontend/src/components/TextEditor.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										243
									
								
								frontend/src/components/TextEditor.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,243 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div v-if="showResponses && filteredCannedResponses.length > 0"
 | 
			
		||||
        class="w-full drop-shadow-sm overflow-hidden p-2 border-t">
 | 
			
		||||
        <ScrollArea>
 | 
			
		||||
            <ul class="space-y-2 max-h-96">
 | 
			
		||||
                <li v-for="(response, index) in filteredCannedResponses" :key="response.id"
 | 
			
		||||
                    @click="selectResponse(response.content)" class="cursor-pointer rounded p-1"
 | 
			
		||||
                    :class="{ 'bg-secondary': cannedResponseIndex === index }"
 | 
			
		||||
                    :ref="el => cannedResponseRefItems.push(el)">
 | 
			
		||||
                    <span class="font-semibold">{{ response.title }}</span> - {{ response.content }}
 | 
			
		||||
                </li>
 | 
			
		||||
            </ul>
 | 
			
		||||
        </ScrollArea>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="relative w-auto rounded-none border-y mb-[49px] fullscreen">
 | 
			
		||||
        <div class="flex justify-between bg-[#F5F5F4] dark:bg-white">
 | 
			
		||||
            <Tabs default-value="account">
 | 
			
		||||
                <TabsList>
 | 
			
		||||
                    <TabsTrigger value="account">
 | 
			
		||||
                        Reply
 | 
			
		||||
                    </TabsTrigger>
 | 
			
		||||
                    <TabsTrigger value="password">
 | 
			
		||||
                        Internal note
 | 
			
		||||
                    </TabsTrigger>
 | 
			
		||||
                </TabsList>
 | 
			
		||||
            </Tabs>
 | 
			
		||||
            <!-- <Toggle class="px-2 py-2 bg-white" variant="outline" @click="toggleFullScreen">
 | 
			
		||||
                <Fullscreen class="w-full h-full" />
 | 
			
		||||
            </Toggle> -->
 | 
			
		||||
        </div>
 | 
			
		||||
        <EditorContent :editor="editor" @keyup="checkTrigger" @keydown="navigateResponses" />
 | 
			
		||||
        <div class="flex justify-between items-center border h-14 p-1 px-2">
 | 
			
		||||
            <div class="flex justify-items-start gap-2">
 | 
			
		||||
                <Toggle class="px-2 py-2 " variant="outline" @click="applyBold" :pressed="isBold">
 | 
			
		||||
                    <Bold class="w-full h-full" />
 | 
			
		||||
                </Toggle>
 | 
			
		||||
                <Toggle class="px-2 py-2 " variant="outline" @click="applyItalic" :pressed="isItalic">
 | 
			
		||||
                    <Italic class="w-full h-full" />
 | 
			
		||||
                </Toggle>
 | 
			
		||||
                <!-- File Upload -->
 | 
			
		||||
                <input type="files" class="hidden" ref="fileInput" multiple>
 | 
			
		||||
                <Toggle class="px-2 py-2" variant="outline" @click="handleFileUpload">
 | 
			
		||||
                    <Paperclip class="w-full h-full" />
 | 
			
		||||
                </Toggle>
 | 
			
		||||
            </div>
 | 
			
		||||
            <Button class="h-8 w-6 px-8 " @click="handleSend" :disabled="!hasText">
 | 
			
		||||
                Send
 | 
			
		||||
            </Button>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup>
 | 
			
		||||
import { ref, watchEffect, onUnmounted, onMounted, computed, watch, nextTick } from "vue"
 | 
			
		||||
import { Button } from '@/components/ui/button'
 | 
			
		||||
import { useEditor, EditorContent } from '@tiptap/vue-3'
 | 
			
		||||
import Placeholder from "@tiptap/extension-placeholder"
 | 
			
		||||
import StarterKit from '@tiptap/starter-kit'
 | 
			
		||||
import { Toggle } from '@/components/ui/toggle'
 | 
			
		||||
import { Paperclip, Bold, Italic } from "lucide-vue-next"
 | 
			
		||||
import { ScrollArea } from '@/components/ui/scroll-area'
 | 
			
		||||
import {
 | 
			
		||||
    Tabs,
 | 
			
		||||
    TabsList,
 | 
			
		||||
    TabsTrigger,
 | 
			
		||||
} from '@/components/ui/tabs'
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits(['send'])
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
    identifier: String,  // Unique identifier for the editor could be the uuid of conversation.
 | 
			
		||||
    cannedResponses: Array
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const editor = ref(useEditor({
 | 
			
		||||
    content: '',
 | 
			
		||||
    extensions: [
 | 
			
		||||
        StarterKit,
 | 
			
		||||
        Placeholder.configure({
 | 
			
		||||
            placeholder: "Type a message...",
 | 
			
		||||
            keyboardShortcuts: {
 | 
			
		||||
                'Control-b': () => applyBold(),
 | 
			
		||||
                'Control-i': () => applyItalic(),
 | 
			
		||||
            }
 | 
			
		||||
        }),
 | 
			
		||||
    ],
 | 
			
		||||
    autofocus: true,
 | 
			
		||||
    editorProps: {
 | 
			
		||||
        attributes: {
 | 
			
		||||
            class: "outline-none",
 | 
			
		||||
        },
 | 
			
		||||
    },
 | 
			
		||||
}))
 | 
			
		||||
 | 
			
		||||
const saveEditorContent = () => {
 | 
			
		||||
    if (editor.value && props.identifier) {
 | 
			
		||||
        // Skip single `/`
 | 
			
		||||
        if (editor.value.getText() === "/") {
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
        const content = editor.value.getHTML()
 | 
			
		||||
        localStorage.setItem(getDraftLocalStorageKey(), content)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const cannedResponseRefItems = ref([])
 | 
			
		||||
const inputText = ref('')
 | 
			
		||||
const isBold = ref(false)
 | 
			
		||||
const isItalic = ref(false)
 | 
			
		||||
const fileInput = ref(null)
 | 
			
		||||
const cannedResponseIndex = ref(0)
 | 
			
		||||
const contentSaverInterval = setInterval(saveEditorContent, 200)
 | 
			
		||||
const showResponses = ref(false)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
watchEffect(() => {
 | 
			
		||||
    if (editor.value) {
 | 
			
		||||
        isBold.value = editor.value.isActive('bold')
 | 
			
		||||
        isItalic.value = editor.value.isActive('italic')
 | 
			
		||||
    }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
watchEffect(() => {
 | 
			
		||||
    if (editor.value) {
 | 
			
		||||
        inputText.value = editor.value.getText()
 | 
			
		||||
    }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
watch(cannedResponseIndex, () => {
 | 
			
		||||
    nextTick(() => {
 | 
			
		||||
        if (cannedResponseRefItems.value[cannedResponseIndex.value]) {
 | 
			
		||||
            cannedResponseRefItems.value[cannedResponseIndex.value].scrollIntoView({ behavior: 'smooth', block: 'nearest' })
 | 
			
		||||
        }
 | 
			
		||||
    })
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
    if (editor.value) {
 | 
			
		||||
        const draftContent = localStorage.getItem(getDraftLocalStorageKey())
 | 
			
		||||
        editor.value.commands.setContent(draftContent)
 | 
			
		||||
    }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
onUnmounted(() => {
 | 
			
		||||
    clearInterval(contentSaverInterval)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const filteredCannedResponses = computed(() => {
 | 
			
		||||
    if (inputText.value.startsWith('/')) {
 | 
			
		||||
        const searchQuery = inputText.value.slice(1).toLowerCase()
 | 
			
		||||
        return props.cannedResponses.filter(response => response.title.toLowerCase().includes(searchQuery))
 | 
			
		||||
    }
 | 
			
		||||
    return []
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const hasText = computed(() => {
 | 
			
		||||
    if (editor.value) {
 | 
			
		||||
        return editor.value.getText().length === 0 ? false : true
 | 
			
		||||
    }
 | 
			
		||||
    return false
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const getDraftLocalStorageKey = () => {
 | 
			
		||||
    return `content.${props.identifier}`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const navigateResponses = (event) => {
 | 
			
		||||
    if (!showResponses.value) return
 | 
			
		||||
 | 
			
		||||
    switch (event.key) {
 | 
			
		||||
        case 'ArrowDown':
 | 
			
		||||
            event.preventDefault()
 | 
			
		||||
            cannedResponseIndex.value = (cannedResponseIndex.value + 1) % filteredCannedResponses.value.length
 | 
			
		||||
            break
 | 
			
		||||
        case 'ArrowUp':
 | 
			
		||||
            event.preventDefault()
 | 
			
		||||
            cannedResponseIndex.value = (cannedResponseIndex.value - 1 + filteredCannedResponses.value.length) % filteredCannedResponses.value.length
 | 
			
		||||
            break
 | 
			
		||||
        case 'Enter':
 | 
			
		||||
            selectResponse(filteredCannedResponses.value[cannedResponseIndex.value].content)
 | 
			
		||||
            break
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const checkTrigger = (e) => {
 | 
			
		||||
    if (e.key === "/")
 | 
			
		||||
        showResponses.value = true
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const selectResponse = (message) => {
 | 
			
		||||
    editor.value.commands.setContent(message)
 | 
			
		||||
    showResponses.value = false
 | 
			
		||||
    editor.value.chain().focus()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const handleSend = () => {
 | 
			
		||||
    emit('send', editor.value.getHTML())
 | 
			
		||||
    editor.value.commands.clearContent()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const applyBold = () => {
 | 
			
		||||
    editor.value.chain().focus().toggleBold().run()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const applyItalic = () => {
 | 
			
		||||
    editor.value.chain().focus().toggleItalic().run()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const handleFileUpload = (event) => {
 | 
			
		||||
    const files = Array.from(event.target.files)
 | 
			
		||||
    console.log(files)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function toggleFullScreen () {
 | 
			
		||||
    const editorElement = editor.value?.editorView?.dom
 | 
			
		||||
    if (editorElement && editorElement.requestFullscreen) {
 | 
			
		||||
        editorElement.requestFullscreen().catch(err => {
 | 
			
		||||
            console.error(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`)
 | 
			
		||||
        })
 | 
			
		||||
    } else {
 | 
			
		||||
        console.error("Fullscreen API is not available.")
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
<style lang="scss">
 | 
			
		||||
// Moving placeholder to the top
 | 
			
		||||
.tiptap p.is-editor-empty:first-child::before {
 | 
			
		||||
    content: attr(data-placeholder);
 | 
			
		||||
    float: left;
 | 
			
		||||
    color: #adb5bd;
 | 
			
		||||
    pointer-events: none;
 | 
			
		||||
    height: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Editor height
 | 
			
		||||
.ProseMirror {
 | 
			
		||||
    min-height: 150px;
 | 
			
		||||
    overflow: scroll;
 | 
			
		||||
    padding: 10px 10px;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										24
									
								
								frontend/src/components/ui/accordion/Accordion.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								frontend/src/components/ui/accordion/Accordion.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,24 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { AccordionRoot, useForwardPropsEmits } from "radix-vue";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  collapsible: { type: Boolean, required: false },
 | 
			
		||||
  disabled: { type: Boolean, required: false },
 | 
			
		||||
  dir: { type: String, required: false },
 | 
			
		||||
  orientation: { type: String, required: false },
 | 
			
		||||
  asChild: { type: Boolean, required: false },
 | 
			
		||||
  as: { type: null, required: false },
 | 
			
		||||
  type: { type: null, required: false },
 | 
			
		||||
  modelValue: { type: null, required: false },
 | 
			
		||||
  defaultValue: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
const emits = defineEmits(["update:modelValue"]);
 | 
			
		||||
 | 
			
		||||
const forwarded = useForwardPropsEmits(props, emits);
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <AccordionRoot v-bind="forwarded">
 | 
			
		||||
    <slot />
 | 
			
		||||
  </AccordionRoot>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										28
									
								
								frontend/src/components/ui/accordion/AccordionContent.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								frontend/src/components/ui/accordion/AccordionContent.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed } from "vue";
 | 
			
		||||
import { AccordionContent } from "radix-vue";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  asChild: { type: Boolean, required: false },
 | 
			
		||||
  as: { type: null, required: false },
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const delegatedProps = computed(() => {
 | 
			
		||||
  const { class: _, ...delegated } = props;
 | 
			
		||||
 | 
			
		||||
  return delegated;
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <AccordionContent
 | 
			
		||||
    v-bind="delegatedProps"
 | 
			
		||||
    class="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
 | 
			
		||||
  >
 | 
			
		||||
    <div :class="cn('pb-4 pt-0', props.class)">
 | 
			
		||||
      <slot />
 | 
			
		||||
    </div>
 | 
			
		||||
  </AccordionContent>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										27
									
								
								frontend/src/components/ui/accordion/AccordionItem.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								frontend/src/components/ui/accordion/AccordionItem.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed } from "vue";
 | 
			
		||||
import { AccordionItem, useForwardProps } from "radix-vue";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  disabled: { type: Boolean, required: false },
 | 
			
		||||
  value: { type: String, required: true },
 | 
			
		||||
  asChild: { type: Boolean, required: false },
 | 
			
		||||
  as: { type: null, required: false },
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const delegatedProps = computed(() => {
 | 
			
		||||
  const { class: _, ...delegated } = props;
 | 
			
		||||
 | 
			
		||||
  return delegated;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const forwardedProps = useForwardProps(delegatedProps);
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <AccordionItem v-bind="forwardedProps" :class="cn('border-b', props.class)">
 | 
			
		||||
    <slot />
 | 
			
		||||
  </AccordionItem>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										39
									
								
								frontend/src/components/ui/accordion/AccordionTrigger.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								frontend/src/components/ui/accordion/AccordionTrigger.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,39 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed } from "vue";
 | 
			
		||||
import { AccordionHeader, AccordionTrigger } from "radix-vue";
 | 
			
		||||
import { ChevronDownIcon } from "@radix-icons/vue";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  asChild: { type: Boolean, required: false },
 | 
			
		||||
  as: { type: null, required: false },
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const delegatedProps = computed(() => {
 | 
			
		||||
  const { class: _, ...delegated } = props;
 | 
			
		||||
 | 
			
		||||
  return delegated;
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <AccordionHeader class="flex">
 | 
			
		||||
    <AccordionTrigger
 | 
			
		||||
      v-bind="delegatedProps"
 | 
			
		||||
      :class="
 | 
			
		||||
        cn(
 | 
			
		||||
          'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
 | 
			
		||||
          props.class
 | 
			
		||||
        )
 | 
			
		||||
      "
 | 
			
		||||
    >
 | 
			
		||||
      <slot />
 | 
			
		||||
      <slot name="icon">
 | 
			
		||||
        <ChevronDownIcon
 | 
			
		||||
          class="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200"
 | 
			
		||||
        />
 | 
			
		||||
      </slot>
 | 
			
		||||
    </AccordionTrigger>
 | 
			
		||||
  </AccordionHeader>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										4
									
								
								frontend/src/components/ui/accordion/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								frontend/src/components/ui/accordion/index.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,4 @@
 | 
			
		||||
export { default as Accordion } from "./Accordion.vue";
 | 
			
		||||
export { default as AccordionContent } from "./AccordionContent.vue";
 | 
			
		||||
export { default as AccordionItem } from "./AccordionItem.vue";
 | 
			
		||||
export { default as AccordionTrigger } from "./AccordionTrigger.vue";
 | 
			
		||||
							
								
								
									
										17
									
								
								frontend/src/components/ui/alert-dialog/AlertDialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								frontend/src/components/ui/alert-dialog/AlertDialog.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { AlertDialogRoot, useForwardPropsEmits } from "radix-vue";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  open: { type: Boolean, required: false },
 | 
			
		||||
  defaultOpen: { type: Boolean, required: false },
 | 
			
		||||
});
 | 
			
		||||
const emits = defineEmits(["update:open"]);
 | 
			
		||||
 | 
			
		||||
const forwarded = useForwardPropsEmits(props, emits);
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <AlertDialogRoot v-bind="forwarded">
 | 
			
		||||
    <slot />
 | 
			
		||||
  </AlertDialogRoot>
 | 
			
		||||
</template>
 | 
			
		||||
@@ -0,0 +1,27 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed } from "vue";
 | 
			
		||||
import { AlertDialogAction } from "radix-vue";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
import { buttonVariants } from "@/components/ui/button";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  asChild: { type: Boolean, required: false },
 | 
			
		||||
  as: { type: null, required: false },
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const delegatedProps = computed(() => {
 | 
			
		||||
  const { class: _, ...delegated } = props;
 | 
			
		||||
 | 
			
		||||
  return delegated;
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <AlertDialogAction
 | 
			
		||||
    v-bind="delegatedProps"
 | 
			
		||||
    :class="cn(buttonVariants(), props.class)"
 | 
			
		||||
  >
 | 
			
		||||
    <slot />
 | 
			
		||||
  </AlertDialogAction>
 | 
			
		||||
</template>
 | 
			
		||||
@@ -0,0 +1,29 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed } from "vue";
 | 
			
		||||
import { AlertDialogCancel } from "radix-vue";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
import { buttonVariants } from "@/components/ui/button";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  asChild: { type: Boolean, required: false },
 | 
			
		||||
  as: { type: null, required: false },
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const delegatedProps = computed(() => {
 | 
			
		||||
  const { class: _, ...delegated } = props;
 | 
			
		||||
 | 
			
		||||
  return delegated;
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <AlertDialogCancel
 | 
			
		||||
    v-bind="delegatedProps"
 | 
			
		||||
    :class="
 | 
			
		||||
      cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', props.class)
 | 
			
		||||
    "
 | 
			
		||||
  >
 | 
			
		||||
    <slot />
 | 
			
		||||
  </AlertDialogCancel>
 | 
			
		||||
</template>
 | 
			
		||||
@@ -0,0 +1,54 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed } from "vue";
 | 
			
		||||
import {
 | 
			
		||||
  AlertDialogContent,
 | 
			
		||||
  AlertDialogOverlay,
 | 
			
		||||
  AlertDialogPortal,
 | 
			
		||||
  useForwardPropsEmits,
 | 
			
		||||
} from "radix-vue";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  forceMount: { type: Boolean, required: false },
 | 
			
		||||
  trapFocus: { type: Boolean, required: false },
 | 
			
		||||
  disableOutsidePointerEvents: { type: Boolean, required: false },
 | 
			
		||||
  asChild: { type: Boolean, required: false },
 | 
			
		||||
  as: { type: null, required: false },
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
const emits = defineEmits([
 | 
			
		||||
  "escapeKeyDown",
 | 
			
		||||
  "pointerDownOutside",
 | 
			
		||||
  "focusOutside",
 | 
			
		||||
  "interactOutside",
 | 
			
		||||
  "openAutoFocus",
 | 
			
		||||
  "closeAutoFocus",
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
const delegatedProps = computed(() => {
 | 
			
		||||
  const { class: _, ...delegated } = props;
 | 
			
		||||
 | 
			
		||||
  return delegated;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <AlertDialogPortal>
 | 
			
		||||
    <AlertDialogOverlay
 | 
			
		||||
      class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
 | 
			
		||||
    />
 | 
			
		||||
    <AlertDialogContent
 | 
			
		||||
      v-bind="forwarded"
 | 
			
		||||
      :class="
 | 
			
		||||
        cn(
 | 
			
		||||
          'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
 | 
			
		||||
          props.class
 | 
			
		||||
        )
 | 
			
		||||
      "
 | 
			
		||||
    >
 | 
			
		||||
      <slot />
 | 
			
		||||
    </AlertDialogContent>
 | 
			
		||||
  </AlertDialogPortal>
 | 
			
		||||
</template>
 | 
			
		||||
@@ -0,0 +1,26 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed } from "vue";
 | 
			
		||||
import { AlertDialogDescription } from "radix-vue";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  asChild: { type: Boolean, required: false },
 | 
			
		||||
  as: { type: null, required: false },
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const delegatedProps = computed(() => {
 | 
			
		||||
  const { class: _, ...delegated } = props;
 | 
			
		||||
 | 
			
		||||
  return delegated;
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <AlertDialogDescription
 | 
			
		||||
    v-bind="delegatedProps"
 | 
			
		||||
    :class="cn('text-sm text-muted-foreground', props.class)"
 | 
			
		||||
  >
 | 
			
		||||
    <slot />
 | 
			
		||||
  </AlertDialogDescription>
 | 
			
		||||
</template>
 | 
			
		||||
@@ -0,0 +1,20 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div
 | 
			
		||||
    :class="
 | 
			
		||||
      cn(
 | 
			
		||||
        'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
 | 
			
		||||
        props.class
 | 
			
		||||
      )
 | 
			
		||||
    "
 | 
			
		||||
  >
 | 
			
		||||
    <slot />
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
@@ -0,0 +1,15 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div
 | 
			
		||||
    :class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
 | 
			
		||||
  >
 | 
			
		||||
    <slot />
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										26
									
								
								frontend/src/components/ui/alert-dialog/AlertDialogTitle.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								frontend/src/components/ui/alert-dialog/AlertDialogTitle.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed } from "vue";
 | 
			
		||||
import { AlertDialogTitle } from "radix-vue";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  asChild: { type: Boolean, required: false },
 | 
			
		||||
  as: { type: null, required: false },
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const delegatedProps = computed(() => {
 | 
			
		||||
  const { class: _, ...delegated } = props;
 | 
			
		||||
 | 
			
		||||
  return delegated;
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <AlertDialogTitle
 | 
			
		||||
    v-bind="delegatedProps"
 | 
			
		||||
    :class="cn('text-lg font-semibold', props.class)"
 | 
			
		||||
  >
 | 
			
		||||
    <slot />
 | 
			
		||||
  </AlertDialogTitle>
 | 
			
		||||
</template>
 | 
			
		||||
@@ -0,0 +1,14 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { AlertDialogTrigger } from "radix-vue";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  asChild: { type: Boolean, required: false },
 | 
			
		||||
  as: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <AlertDialogTrigger v-bind="props">
 | 
			
		||||
    <slot />
 | 
			
		||||
  </AlertDialogTrigger>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										9
									
								
								frontend/src/components/ui/alert-dialog/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								frontend/src/components/ui/alert-dialog/index.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
			
		||||
export { default as AlertDialog } from "./AlertDialog.vue";
 | 
			
		||||
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue";
 | 
			
		||||
export { default as AlertDialogContent } from "./AlertDialogContent.vue";
 | 
			
		||||
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue";
 | 
			
		||||
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue";
 | 
			
		||||
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue";
 | 
			
		||||
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue";
 | 
			
		||||
export { default as AlertDialogAction } from "./AlertDialogAction.vue";
 | 
			
		||||
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue";
 | 
			
		||||
							
								
								
									
										15
									
								
								frontend/src/components/ui/alert/Alert.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								frontend/src/components/ui/alert/Alert.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { alertVariants } from ".";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
  variant: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div :class="cn(alertVariants({ variant }), props.class)" role="alert">
 | 
			
		||||
    <slot />
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										13
									
								
								frontend/src/components/ui/alert/AlertDescription.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								frontend/src/components/ui/alert/AlertDescription.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div :class="cn('text-sm [&_p]:leading-relaxed', props.class)">
 | 
			
		||||
    <slot />
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										13
									
								
								frontend/src/components/ui/alert/AlertTitle.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								frontend/src/components/ui/alert/AlertTitle.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <h5 :class="cn('mb-1 font-medium leading-none tracking-tight', props.class)">
 | 
			
		||||
    <slot />
 | 
			
		||||
  </h5>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										21
									
								
								frontend/src/components/ui/alert/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								frontend/src/components/ui/alert/index.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
import { cva } from "class-variance-authority";
 | 
			
		||||
 | 
			
		||||
export { default as Alert } from "./Alert.vue";
 | 
			
		||||
export { default as AlertTitle } from "./AlertTitle.vue";
 | 
			
		||||
export { default as AlertDescription } from "./AlertDescription.vue";
 | 
			
		||||
 | 
			
		||||
export const alertVariants = cva(
 | 
			
		||||
  "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
 | 
			
		||||
  {
 | 
			
		||||
    variants: {
 | 
			
		||||
      variant: {
 | 
			
		||||
        default: "bg-background text-foreground",
 | 
			
		||||
        destructive:
 | 
			
		||||
          "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    defaultVariants: {
 | 
			
		||||
      variant: "default",
 | 
			
		||||
    },
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										17
									
								
								frontend/src/components/ui/avatar/Avatar.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								frontend/src/components/ui/avatar/Avatar.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { AvatarRoot } from "radix-vue";
 | 
			
		||||
import { avatarVariant } from ".";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
  size: { type: null, required: false, default: "sm" },
 | 
			
		||||
  shape: { type: null, required: false, default: "circle" },
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <AvatarRoot :class="cn(avatarVariant({ size, shape }), props.class)">
 | 
			
		||||
    <slot />
 | 
			
		||||
  </AvatarRoot>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										15
									
								
								frontend/src/components/ui/avatar/AvatarFallback.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								frontend/src/components/ui/avatar/AvatarFallback.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { AvatarFallback } from "radix-vue";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  delayMs: { type: Number, required: false },
 | 
			
		||||
  asChild: { type: Boolean, required: false },
 | 
			
		||||
  as: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <AvatarFallback v-bind="props">
 | 
			
		||||
    <slot />
 | 
			
		||||
  </AvatarFallback>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										13
									
								
								frontend/src/components/ui/avatar/AvatarImage.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								frontend/src/components/ui/avatar/AvatarImage.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { AvatarImage } from "radix-vue";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  src: { type: String, required: true },
 | 
			
		||||
  asChild: { type: Boolean, required: false },
 | 
			
		||||
  as: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <AvatarImage v-bind="props" class="h-full w-full object-cover" />
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										22
									
								
								frontend/src/components/ui/avatar/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								frontend/src/components/ui/avatar/index.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
			
		||||
import { cva } from "class-variance-authority";
 | 
			
		||||
 | 
			
		||||
export { default as Avatar } from "./Avatar.vue";
 | 
			
		||||
export { default as AvatarImage } from "./AvatarImage.vue";
 | 
			
		||||
export { default as AvatarFallback } from "./AvatarFallback.vue";
 | 
			
		||||
 | 
			
		||||
export const avatarVariant = cva(
 | 
			
		||||
  "inline-flex items-center justify-center font-normal text-foreground select-none shrink-0 bg-secondary overflow-hidden",
 | 
			
		||||
  {
 | 
			
		||||
    variants: {
 | 
			
		||||
      size: {
 | 
			
		||||
        sm: "h-10 w-10 text-xs",
 | 
			
		||||
        base: "h-16 w-16 text-2xl",
 | 
			
		||||
        lg: "h-32 w-32 text-5xl",
 | 
			
		||||
      },
 | 
			
		||||
      shape: {
 | 
			
		||||
        circle: "rounded-full",
 | 
			
		||||
        square: "rounded-md",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										15
									
								
								frontend/src/components/ui/badge/Badge.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								frontend/src/components/ui/badge/Badge.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { badgeVariants } from ".";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  variant: { type: null, required: false },
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div :class="cn(badgeVariants({ variant }), props.class)">
 | 
			
		||||
    <slot />
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										23
									
								
								frontend/src/components/ui/badge/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								frontend/src/components/ui/badge/index.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
import { cva } from "class-variance-authority";
 | 
			
		||||
 | 
			
		||||
export { default as Badge } from "./Badge.vue";
 | 
			
		||||
 | 
			
		||||
export const badgeVariants = cva(
 | 
			
		||||
  "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
 | 
			
		||||
  {
 | 
			
		||||
    variants: {
 | 
			
		||||
      variant: {
 | 
			
		||||
        default:
 | 
			
		||||
          "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
 | 
			
		||||
        secondary:
 | 
			
		||||
          "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
 | 
			
		||||
        destructive:
 | 
			
		||||
          "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
 | 
			
		||||
        outline: "text-foreground",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    defaultVariants: {
 | 
			
		||||
      variant: "default",
 | 
			
		||||
    },
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										23
									
								
								frontend/src/components/ui/button/Button.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								frontend/src/components/ui/button/Button.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { Primitive } from "radix-vue";
 | 
			
		||||
import { buttonVariants } from ".";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  variant: { type: null, required: false },
 | 
			
		||||
  size: { type: null, required: false },
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
  asChild: { type: Boolean, required: false },
 | 
			
		||||
  as: { type: null, required: false, default: "button" },
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <Primitive
 | 
			
		||||
    :as="as"
 | 
			
		||||
    :as-child="asChild"
 | 
			
		||||
    :class="cn(buttonVariants({ variant, size }), props.class)"
 | 
			
		||||
  >
 | 
			
		||||
    <slot />
 | 
			
		||||
  </Primitive>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										34
									
								
								frontend/src/components/ui/button/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								frontend/src/components/ui/button/index.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,34 @@
 | 
			
		||||
import { cva } from "class-variance-authority";
 | 
			
		||||
 | 
			
		||||
export { default as Button } from "./Button.vue";
 | 
			
		||||
 | 
			
		||||
export const buttonVariants = cva(
 | 
			
		||||
  "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
 | 
			
		||||
  {
 | 
			
		||||
    variants: {
 | 
			
		||||
      variant: {
 | 
			
		||||
        default:
 | 
			
		||||
          "bg-primary text-primary-foreground shadow hover:bg-primary/90",
 | 
			
		||||
        destructive:
 | 
			
		||||
          "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
 | 
			
		||||
        outline:
 | 
			
		||||
          "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
 | 
			
		||||
        secondary:
 | 
			
		||||
          "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
 | 
			
		||||
        ghost: "hover:bg-accent hover:text-accent-foreground",
 | 
			
		||||
        link: "text-primary underline-offset-4 hover:underline",
 | 
			
		||||
      },
 | 
			
		||||
      size: {
 | 
			
		||||
        default: "h-9 px-4 py-2",
 | 
			
		||||
        xs: "h-7 rounded px-2",
 | 
			
		||||
        sm: "h-8 rounded-md px-3 text-xs",
 | 
			
		||||
        lg: "h-10 rounded-md px-8",
 | 
			
		||||
        icon: "h-9 w-9",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    defaultVariants: {
 | 
			
		||||
      variant: "default",
 | 
			
		||||
      size: "default",
 | 
			
		||||
    },
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										17
									
								
								frontend/src/components/ui/card/Card.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								frontend/src/components/ui/card/Card.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div
 | 
			
		||||
    :class="
 | 
			
		||||
      cn('rounded-xl border bg-card text-card-foreground shadow', props.class)
 | 
			
		||||
    "
 | 
			
		||||
  >
 | 
			
		||||
    <slot />
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										13
									
								
								frontend/src/components/ui/card/CardContent.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								frontend/src/components/ui/card/CardContent.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div :class="cn('p-6 pt-0', props.class)">
 | 
			
		||||
    <slot />
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										13
									
								
								frontend/src/components/ui/card/CardDescription.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								frontend/src/components/ui/card/CardDescription.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <p :class="cn('text-sm text-muted-foreground', props.class)">
 | 
			
		||||
    <slot />
 | 
			
		||||
  </p>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										13
									
								
								frontend/src/components/ui/card/CardFooter.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								frontend/src/components/ui/card/CardFooter.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div :class="cn('flex items-center p-6 pt-0', props.class)">
 | 
			
		||||
    <slot />
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										13
									
								
								frontend/src/components/ui/card/CardHeader.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								frontend/src/components/ui/card/CardHeader.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div :class="cn('flex flex-col gap-y-1.5 p-6', props.class)">
 | 
			
		||||
    <slot />
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										13
									
								
								frontend/src/components/ui/card/CardTitle.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								frontend/src/components/ui/card/CardTitle.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <h3 :class="cn('font-semibold leading-none tracking-tight', props.class)">
 | 
			
		||||
    <slot />
 | 
			
		||||
  </h3>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										6
									
								
								frontend/src/components/ui/card/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								frontend/src/components/ui/card/index.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
			
		||||
export { default as Card } from "./Card.vue";
 | 
			
		||||
export { default as CardHeader } from "./CardHeader.vue";
 | 
			
		||||
export { default as CardTitle } from "./CardTitle.vue";
 | 
			
		||||
export { default as CardDescription } from "./CardDescription.vue";
 | 
			
		||||
export { default as CardContent } from "./CardContent.vue";
 | 
			
		||||
export { default as CardFooter } from "./CardFooter.vue";
 | 
			
		||||
							
								
								
									
										86
									
								
								frontend/src/components/ui/chart-donut/DonutChart.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								frontend/src/components/ui/chart-donut/DonutChart.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,86 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { VisDonut, VisSingleContainer } from '@unovis/vue';
 | 
			
		||||
import { Donut } from '@unovis/ts';
 | 
			
		||||
import { computed, ref } from 'vue';
 | 
			
		||||
import { useMounted } from '@vueuse/core';
 | 
			
		||||
import { ChartSingleTooltip, defaultColors } from '@/components/ui/chart';
 | 
			
		||||
import { cn } from '@/lib/utils';
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
    data: { type: Array, required: true },
 | 
			
		||||
    colors: { type: Array, required: false },
 | 
			
		||||
    index: { type: null, required: true },
 | 
			
		||||
    margin: { type: null, required: false, default: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) },
 | 
			
		||||
    showLegend: { type: Boolean, required: false, default: true },
 | 
			
		||||
    showTooltip: { type: Boolean, required: false, default: true },
 | 
			
		||||
    filterOpacity: { type: Number, required: false, default: 0.2 },
 | 
			
		||||
    category: { type: String, required: true },
 | 
			
		||||
    type: { type: String, required: false, default: 'donut' },
 | 
			
		||||
    sortFunction: { type: Function, required: false, default: () => undefined },
 | 
			
		||||
    valueFormatter: { type: Function, required: false, default: (tick) => `${tick}` },
 | 
			
		||||
    customTooltip: { type: null, required: false }
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
const category = computed(() => props.category);
 | 
			
		||||
const index = computed(() => props.index);
 | 
			
		||||
 | 
			
		||||
const isMounted = useMounted();
 | 
			
		||||
const activeSegmentKey = ref();
 | 
			
		||||
const colors = computed(() => props.colors?.length ? props.colors : defaultColors(props.data.filter((d) => d[props.category]).filter(Boolean).length));
 | 
			
		||||
const legendItems = computed(() => props.data.map((item, i) => ({
 | 
			
		||||
  name: item[props.index],
 | 
			
		||||
  color: colors.value[i],
 | 
			
		||||
  inactive: false
 | 
			
		||||
})));
 | 
			
		||||
 | 
			
		||||
const totalValue = computed(() => props.data.reduce((prev, curr) => {
 | 
			
		||||
  return prev + curr[props.category];
 | 
			
		||||
}, 0))
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div :class="cn('w-full h-48 flex flex-col items-end', $attrs.class ?? '')">
 | 
			
		||||
    <VisSingleContainer
 | 
			
		||||
      :style="{ height: isMounted ? '100%' : 'auto' }"
 | 
			
		||||
      :margin="{ left: 20, right: 20 }"
 | 
			
		||||
      :data="data"
 | 
			
		||||
    >
 | 
			
		||||
      <ChartSingleTooltip
 | 
			
		||||
        :selector="Donut.selectors.segment"
 | 
			
		||||
        :index="category"
 | 
			
		||||
        :items="legendItems"
 | 
			
		||||
        :value-formatter="valueFormatter"
 | 
			
		||||
        :custom-tooltip="customTooltip"
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <VisDonut
 | 
			
		||||
        :value="(d) => d[category]"
 | 
			
		||||
        :sort-function="sortFunction"
 | 
			
		||||
        :color="colors"
 | 
			
		||||
        :arc-width="type === 'donut' ? 20 : 0"
 | 
			
		||||
        :show-background="false"
 | 
			
		||||
        :central-label="type === 'donut' ? valueFormatter(totalValue) : ''"
 | 
			
		||||
        :events="{
 | 
			
		||||
          [Donut.selectors.segment]: {
 | 
			
		||||
            click: (d, ev, i, elements) => {
 | 
			
		||||
              if (d?.data?.[index] === activeSegmentKey) {
 | 
			
		||||
                activeSegmentKey = undefined;
 | 
			
		||||
                elements.forEach((el) => (el.style.opacity = '1'));
 | 
			
		||||
              } else {
 | 
			
		||||
                activeSegmentKey = d?.data?.[index];
 | 
			
		||||
                elements.forEach(
 | 
			
		||||
                  (el) => (el.style.opacity = `${filterOpacity}`),
 | 
			
		||||
                );
 | 
			
		||||
                elements[i].style.opacity = '1';
 | 
			
		||||
              }
 | 
			
		||||
            },
 | 
			
		||||
          },
 | 
			
		||||
        }"
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <slot />
 | 
			
		||||
    </VisSingleContainer>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										1
									
								
								frontend/src/components/ui/chart-donut/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/src/components/ui/chart-donut/index.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
export { default as DonutChart } from "./DonutChart.vue";
 | 
			
		||||
							
								
								
									
										124
									
								
								frontend/src/components/ui/chart-line/LineChart.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								frontend/src/components/ui/chart-line/LineChart.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,124 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { CurveType } from "@unovis/ts";
 | 
			
		||||
import { VisAxis, VisLine, VisXYContainer } from "@unovis/vue";
 | 
			
		||||
import { Axis, Line } from "@unovis/ts";
 | 
			
		||||
import { computed, ref } from "vue";
 | 
			
		||||
import { useMounted } from "@vueuse/core";
 | 
			
		||||
import {
 | 
			
		||||
  ChartCrosshair,
 | 
			
		||||
  ChartLegend,
 | 
			
		||||
  defaultColors,
 | 
			
		||||
} from "@/components/ui/chart";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  data: { type: Array, required: true },
 | 
			
		||||
  categories: { type: Array, required: true },
 | 
			
		||||
  index: { type: null, required: true },
 | 
			
		||||
  colors: { type: Array, required: false },
 | 
			
		||||
  margin: {
 | 
			
		||||
    type: null,
 | 
			
		||||
    required: false,
 | 
			
		||||
    default: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
 | 
			
		||||
  },
 | 
			
		||||
  filterOpacity: { type: Number, required: false, default: 0.2 },
 | 
			
		||||
  xFormatter: { type: Function, required: false },
 | 
			
		||||
  yFormatter: { type: Function, required: false },
 | 
			
		||||
  showXAxis: { type: Boolean, required: false, default: true },
 | 
			
		||||
  showYAxis: { type: Boolean, required: false, default: true },
 | 
			
		||||
  showTooltip: { type: Boolean, required: false, default: true },
 | 
			
		||||
  showLegend: { type: Boolean, required: false, default: true },
 | 
			
		||||
  showGridLine: { type: Boolean, required: false, default: true },
 | 
			
		||||
  customTooltip: { type: null, required: false },
 | 
			
		||||
  curveType: { type: String, required: false, default: CurveType.MonotoneX },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const emits = defineEmits(["legendItemClick"]);
 | 
			
		||||
 | 
			
		||||
const index = computed(() => props.index);
 | 
			
		||||
const colors = computed(() =>
 | 
			
		||||
  props.colors?.length ? props.colors : defaultColors(props.categories.length),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const legendItems = ref(
 | 
			
		||||
  props.categories.map((category, i) => ({
 | 
			
		||||
    name: category,
 | 
			
		||||
    color: colors.value[i],
 | 
			
		||||
    inactive: false,
 | 
			
		||||
  })),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const isMounted = useMounted();
 | 
			
		||||
 | 
			
		||||
function handleLegendItemClick(d, i) {
 | 
			
		||||
  emits("legendItemClick", d, i);
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div
 | 
			
		||||
    :class="cn('w-full h-[400px] flex flex-col items-end', $attrs.class ?? '')"
 | 
			
		||||
  >
 | 
			
		||||
    <ChartLegend
 | 
			
		||||
      v-if="showLegend"
 | 
			
		||||
      v-model:items="legendItems"
 | 
			
		||||
      @legend-item-click="handleLegendItemClick"
 | 
			
		||||
    />
 | 
			
		||||
 | 
			
		||||
    <VisXYContainer
 | 
			
		||||
      :margin="{ left: 20, right: 20 }"
 | 
			
		||||
      :data="data"
 | 
			
		||||
      :style="{ height: isMounted ? '100%' : 'auto' }"
 | 
			
		||||
    >
 | 
			
		||||
      <ChartCrosshair
 | 
			
		||||
        v-if="showTooltip"
 | 
			
		||||
        :colors="colors"
 | 
			
		||||
        :items="legendItems"
 | 
			
		||||
        :index="index"
 | 
			
		||||
        :custom-tooltip="customTooltip"
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <template v-for="(category, i) in categories" :key="category">
 | 
			
		||||
        <VisLine
 | 
			
		||||
          :x="(d, i) => i"
 | 
			
		||||
          :y="(d) => d[category]"
 | 
			
		||||
          :curve-type="curveType"
 | 
			
		||||
          :color="colors[i]"
 | 
			
		||||
          :attributes="{
 | 
			
		||||
            [Line.selectors.line]: {
 | 
			
		||||
              opacity: legendItems.find((item) => item.name === category)
 | 
			
		||||
                ?.inactive
 | 
			
		||||
                ? filterOpacity
 | 
			
		||||
                : 1,
 | 
			
		||||
            },
 | 
			
		||||
          }"
 | 
			
		||||
        />
 | 
			
		||||
      </template>
 | 
			
		||||
 | 
			
		||||
      <VisAxis
 | 
			
		||||
        v-if="showXAxis"
 | 
			
		||||
        type="x"
 | 
			
		||||
        :tick-format="xFormatter ?? ((v) => data[v]?.[index])"
 | 
			
		||||
        :grid-line="false"
 | 
			
		||||
        :tick-line="false"
 | 
			
		||||
        tick-text-color="hsl(var(--vis-text-color))"
 | 
			
		||||
      />
 | 
			
		||||
      <VisAxis
 | 
			
		||||
        v-if="showYAxis"
 | 
			
		||||
        type="y"
 | 
			
		||||
        :tick-line="false"
 | 
			
		||||
        :tick-format="yFormatter"
 | 
			
		||||
        :domain-line="false"
 | 
			
		||||
        :grid-line="showGridLine"
 | 
			
		||||
        :attributes="{
 | 
			
		||||
          [Axis.selectors.grid]: {
 | 
			
		||||
            class: 'text-muted',
 | 
			
		||||
          },
 | 
			
		||||
        }"
 | 
			
		||||
        tick-text-color="hsl(var(--vis-text-color))"
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <slot />
 | 
			
		||||
    </VisXYContainer>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										1
									
								
								frontend/src/components/ui/chart-line/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/src/components/ui/chart-line/index.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
export { default as LineChart } from "./LineChart.vue";
 | 
			
		||||
							
								
								
									
										45
									
								
								frontend/src/components/ui/chart/ChartCrosshair.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								frontend/src/components/ui/chart/ChartCrosshair.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,45 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { VisCrosshair, VisTooltip } from "@unovis/vue";
 | 
			
		||||
import { omit } from "@unovis/ts";
 | 
			
		||||
import { createApp } from "vue";
 | 
			
		||||
import { ChartTooltip } from ".";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  colors: { type: Array, required: true, default: () => [] },
 | 
			
		||||
  index: { type: String, required: true },
 | 
			
		||||
  items: { type: Array, required: true },
 | 
			
		||||
  customTooltip: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Use weakmap to store reference to each datapoint for Tooltip
 | 
			
		||||
const wm = new WeakMap();
 | 
			
		||||
function template(d) {
 | 
			
		||||
  if (wm.has(d)) {
 | 
			
		||||
    return wm.get(d);
 | 
			
		||||
  } else {
 | 
			
		||||
    const componentDiv = document.createElement("div");
 | 
			
		||||
    const omittedData = Object.entries(omit(d, [props.index])).map(
 | 
			
		||||
      ([key, value]) => {
 | 
			
		||||
        const legendReference = props.items.find((i) => i.name === key);
 | 
			
		||||
        return { ...legendReference, value };
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
    const TooltipComponent = props.customTooltip ?? ChartTooltip;
 | 
			
		||||
    createApp(TooltipComponent, {
 | 
			
		||||
      title: d[props.index].toString(),
 | 
			
		||||
      data: omittedData,
 | 
			
		||||
    }).mount(componentDiv);
 | 
			
		||||
    wm.set(d, componentDiv.innerHTML);
 | 
			
		||||
    return componentDiv.innerHTML;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function color(d, i) {
 | 
			
		||||
  return props.colors[i] ?? "transparent";
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <VisTooltip :horizontal-shift="20" :vertical-shift="20" />
 | 
			
		||||
  <VisCrosshair :template="template" :color="color" />
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										55
									
								
								frontend/src/components/ui/chart/ChartLegend.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								frontend/src/components/ui/chart/ChartLegend.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,55 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { VisBulletLegend } from "@unovis/vue";
 | 
			
		||||
import { BulletLegend } from "@unovis/ts";
 | 
			
		||||
import { nextTick, onMounted, ref } from "vue";
 | 
			
		||||
import { buttonVariants } from "@/components/ui/button";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  items: { type: Array, required: true, default: () => [] },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const emits = defineEmits(["legendItemClick", "update:items"]);
 | 
			
		||||
 | 
			
		||||
const elRef = ref();
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
  const selector = `.${BulletLegend.selectors.item}`;
 | 
			
		||||
  nextTick(() => {
 | 
			
		||||
    const elements = elRef.value?.querySelectorAll(selector);
 | 
			
		||||
    const classes = buttonVariants({ variant: "ghost", size: "xs" }).split(" ");
 | 
			
		||||
 | 
			
		||||
    elements?.forEach((el) =>
 | 
			
		||||
      el.classList.add(...classes, "!inline-flex", "!mr-2"),
 | 
			
		||||
    );
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
function onLegendItemClick(d, i) {
 | 
			
		||||
  emits("legendItemClick", d, i);
 | 
			
		||||
  const isBulletActive = !props.items[i].inactive;
 | 
			
		||||
  const isFilterApplied = props.items.some((i) => i.inactive);
 | 
			
		||||
  if (isFilterApplied && isBulletActive) {
 | 
			
		||||
    // reset filter
 | 
			
		||||
    emits(
 | 
			
		||||
      "update:items",
 | 
			
		||||
      props.items.map((item) => ({ ...item, inactive: false })),
 | 
			
		||||
    );
 | 
			
		||||
  } else {
 | 
			
		||||
    // apply selection, set other item as inactive
 | 
			
		||||
    emits(
 | 
			
		||||
      "update:items",
 | 
			
		||||
      props.items.map((item) =>
 | 
			
		||||
        item.name === d.name
 | 
			
		||||
          ? { ...d, inactive: false }
 | 
			
		||||
          : { ...item, inactive: true },
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div ref="elRef" class="w-max">
 | 
			
		||||
    <VisBulletLegend :items="items" :on-legend-item-click="onLegendItemClick" />
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										63
									
								
								frontend/src/components/ui/chart/ChartSingleTooltip.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								frontend/src/components/ui/chart/ChartSingleTooltip.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,63 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { VisTooltip } from '@unovis/vue';
 | 
			
		||||
import { omit } from '@unovis/ts';
 | 
			
		||||
import { createApp } from 'vue';
 | 
			
		||||
import { ChartTooltip } from '.';
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
    selector: { type: String, required: true },
 | 
			
		||||
    index: { type: String, required: true },
 | 
			
		||||
    items: { type: Array, required: false },
 | 
			
		||||
    valueFormatter: { type: Function, required: false, default: (tick) => `${tick}` },
 | 
			
		||||
    customTooltip: { type: null, required: false }
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Use weakmap to store reference to each datapoint for Tooltip
 | 
			
		||||
const wm = new WeakMap();
 | 
			
		||||
function template(d, i, elements) {
 | 
			
		||||
  if (props.index in d) {
 | 
			
		||||
    if (wm.has(d)) {
 | 
			
		||||
      return wm.get(d);
 | 
			
		||||
    } else
 | 
			
		||||
    {
 | 
			
		||||
      const componentDiv = document.createElement('div');
 | 
			
		||||
      const omittedData = Object.entries(omit(d, [props.index])).map(([key, value]) => {
 | 
			
		||||
        const legendReference = props.items?.find((i) => i.name === key);
 | 
			
		||||
        return { ...legendReference, value: props.valueFormatter(value) };
 | 
			
		||||
      });
 | 
			
		||||
      const TooltipComponent = props.customTooltip ?? ChartTooltip;
 | 
			
		||||
      createApp(TooltipComponent, { title: d[props.index], data: omittedData }).mount(componentDiv);
 | 
			
		||||
      wm.set(d, componentDiv.innerHTML);
 | 
			
		||||
      return componentDiv.innerHTML;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  else
 | 
			
		||||
  {
 | 
			
		||||
    const data = d.data;
 | 
			
		||||
 | 
			
		||||
    if (wm.has(data)) {
 | 
			
		||||
      return wm.get(data);
 | 
			
		||||
    } else
 | 
			
		||||
    {
 | 
			
		||||
      const style = getComputedStyle(elements[i]);
 | 
			
		||||
      const omittedData = [{ name: data.name, value: props.valueFormatter(data[props.index]), color: style.fill }];
 | 
			
		||||
      const componentDiv = document.createElement('div');
 | 
			
		||||
      const TooltipComponent = props.customTooltip ?? ChartTooltip;
 | 
			
		||||
      createApp(TooltipComponent, { title: d[props.index], data: omittedData }).mount(componentDiv);
 | 
			
		||||
      wm.set(d, componentDiv.innerHTML);
 | 
			
		||||
      return componentDiv.innerHTML;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <VisTooltip
 | 
			
		||||
    :horizontal-shift="20"
 | 
			
		||||
    :vertical-shift="20"
 | 
			
		||||
    :triggers="{
 | 
			
		||||
      [selector]: template,
 | 
			
		||||
    }"
 | 
			
		||||
  />
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										36
									
								
								frontend/src/components/ui/chart/ChartTooltip.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								frontend/src/components/ui/chart/ChartTooltip.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,36 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
 | 
			
		||||
 | 
			
		||||
defineProps({
 | 
			
		||||
  title: { type: String, required: false },
 | 
			
		||||
  data: { type: Array, required: true },
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <Card class="text-sm">
 | 
			
		||||
    <CardHeader v-if="title" class="p-3 border-b">
 | 
			
		||||
      <CardTitle>
 | 
			
		||||
        {{ title }}
 | 
			
		||||
      </CardTitle>
 | 
			
		||||
    </CardHeader>
 | 
			
		||||
    <CardContent class="p-3 min-w-[180px] flex flex-col gap-1">
 | 
			
		||||
      <div v-for="(item, key) in data" :key="key" class="flex justify-between">
 | 
			
		||||
        <div class="flex items-center">
 | 
			
		||||
          <span class="w-2.5 h-2.5 mr-2">
 | 
			
		||||
            <svg width="100%" height="100%" viewBox="0 0 30 30">
 | 
			
		||||
              <path
 | 
			
		||||
                d=" M 15 15 m -14, 0 a 14,14 0 1,1 28,0 a 14,14 0 1,1 -28,0"
 | 
			
		||||
                :stroke="item.color"
 | 
			
		||||
                :fill="item.color"
 | 
			
		||||
                stroke-width="1"
 | 
			
		||||
              />
 | 
			
		||||
            </svg>
 | 
			
		||||
          </span>
 | 
			
		||||
          <span>{{ item.name }}</span>
 | 
			
		||||
        </div>
 | 
			
		||||
        <span class="font-semibold ml-4">{{ item.value }}</span>
 | 
			
		||||
      </div>
 | 
			
		||||
    </CardContent>
 | 
			
		||||
  </Card>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										23
									
								
								frontend/src/components/ui/chart/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								frontend/src/components/ui/chart/index.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
export { default as ChartTooltip } from "./ChartTooltip.vue";
 | 
			
		||||
export { default as ChartSingleTooltip } from "./ChartSingleTooltip.vue";
 | 
			
		||||
export { default as ChartLegend } from "./ChartLegend.vue";
 | 
			
		||||
export { default as ChartCrosshair } from "./ChartCrosshair.vue";
 | 
			
		||||
 | 
			
		||||
export function defaultColors(count = 3) {
 | 
			
		||||
  const quotient = Math.floor(count / 2);
 | 
			
		||||
  const remainder = count % 2;
 | 
			
		||||
 | 
			
		||||
  const primaryCount = quotient + remainder;
 | 
			
		||||
  const secondaryCount = quotient;
 | 
			
		||||
  return [
 | 
			
		||||
    ...Array.from(Array(primaryCount).keys()).map(
 | 
			
		||||
      (i) => `hsl(var(--vis-primary-color) / ${1 - (1 / primaryCount) * i})`,
 | 
			
		||||
    ),
 | 
			
		||||
    ...Array.from(Array(secondaryCount).keys()).map(
 | 
			
		||||
      (i) =>
 | 
			
		||||
        `hsl(var(--vis-secondary-color) / ${1 - (1 / secondaryCount) * i})`,
 | 
			
		||||
    ),
 | 
			
		||||
  ];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export * from "./interface";
 | 
			
		||||
							
								
								
									
										1
									
								
								frontend/src/components/ui/chart/interface.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/src/components/ui/chart/interface.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
export {};
 | 
			
		||||
							
								
								
									
										51
									
								
								frontend/src/components/ui/command/Command.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								frontend/src/components/ui/command/Command.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,51 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed } from "vue";
 | 
			
		||||
import { ComboboxRoot, useForwardPropsEmits } from "radix-vue";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  modelValue: { type: null, required: false, default: "" },
 | 
			
		||||
  defaultValue: { type: null, required: false },
 | 
			
		||||
  open: { type: Boolean, required: false, default: true },
 | 
			
		||||
  defaultOpen: { type: Boolean, required: false },
 | 
			
		||||
  searchTerm: { type: String, required: false },
 | 
			
		||||
  multiple: { type: Boolean, required: false },
 | 
			
		||||
  disabled: { type: Boolean, required: false },
 | 
			
		||||
  name: { type: String, required: false },
 | 
			
		||||
  dir: { type: String, required: false },
 | 
			
		||||
  filterFunction: { type: Function, required: false },
 | 
			
		||||
  displayValue: { type: Function, required: false },
 | 
			
		||||
  resetSearchTermOnBlur: { type: Boolean, required: false },
 | 
			
		||||
  asChild: { type: Boolean, required: false },
 | 
			
		||||
  as: { type: null, required: false },
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const emits = defineEmits([
 | 
			
		||||
  "update:modelValue",
 | 
			
		||||
  "update:open",
 | 
			
		||||
  "update:searchTerm",
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
const delegatedProps = computed(() => {
 | 
			
		||||
  const { class: _, ...delegated } = props;
 | 
			
		||||
 | 
			
		||||
  return delegated;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <ComboboxRoot
 | 
			
		||||
    v-bind="forwarded"
 | 
			
		||||
    :class="
 | 
			
		||||
      cn(
 | 
			
		||||
        'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
 | 
			
		||||
        props.class
 | 
			
		||||
      )
 | 
			
		||||
    "
 | 
			
		||||
  >
 | 
			
		||||
    <slot />
 | 
			
		||||
  </ComboboxRoot>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										26
									
								
								frontend/src/components/ui/command/CommandDialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								frontend/src/components/ui/command/CommandDialog.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { useForwardPropsEmits } from "radix-vue";
 | 
			
		||||
import Command from "./Command.vue";
 | 
			
		||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  open: { type: Boolean, required: false },
 | 
			
		||||
  defaultOpen: { type: Boolean, required: false },
 | 
			
		||||
  modal: { type: Boolean, required: false },
 | 
			
		||||
});
 | 
			
		||||
const emits = defineEmits(["update:open"]);
 | 
			
		||||
 | 
			
		||||
const forwarded = useForwardPropsEmits(props, emits);
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <Dialog v-bind="forwarded">
 | 
			
		||||
    <DialogContent class="overflow-hidden p-0 shadow-lg">
 | 
			
		||||
      <Command
 | 
			
		||||
        class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
 | 
			
		||||
      >
 | 
			
		||||
        <slot />
 | 
			
		||||
      </Command>
 | 
			
		||||
    </DialogContent>
 | 
			
		||||
  </Dialog>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										26
									
								
								frontend/src/components/ui/command/CommandEmpty.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								frontend/src/components/ui/command/CommandEmpty.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed } from "vue";
 | 
			
		||||
import { ComboboxEmpty } from "radix-vue";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  asChild: { type: Boolean, required: false },
 | 
			
		||||
  as: { type: null, required: false },
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const delegatedProps = computed(() => {
 | 
			
		||||
  const { class: _, ...delegated } = props;
 | 
			
		||||
 | 
			
		||||
  return delegated;
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <ComboboxEmpty
 | 
			
		||||
    v-bind="delegatedProps"
 | 
			
		||||
    :class="cn('py-6 text-center text-sm', props.class)"
 | 
			
		||||
  >
 | 
			
		||||
    <slot />
 | 
			
		||||
  </ComboboxEmpty>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										38
									
								
								frontend/src/components/ui/command/CommandGroup.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								frontend/src/components/ui/command/CommandGroup.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,38 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed } from "vue";
 | 
			
		||||
import { ComboboxGroup, ComboboxLabel } from "radix-vue";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  asChild: { type: Boolean, required: false },
 | 
			
		||||
  as: { type: null, required: false },
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
  heading: { type: String, required: false },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const delegatedProps = computed(() => {
 | 
			
		||||
  const { class: _, ...delegated } = props;
 | 
			
		||||
 | 
			
		||||
  return delegated;
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <ComboboxGroup
 | 
			
		||||
    v-bind="delegatedProps"
 | 
			
		||||
    :class="
 | 
			
		||||
      cn(
 | 
			
		||||
        'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
 | 
			
		||||
        props.class
 | 
			
		||||
      )
 | 
			
		||||
    "
 | 
			
		||||
  >
 | 
			
		||||
    <ComboboxLabel
 | 
			
		||||
      v-if="heading"
 | 
			
		||||
      class="px-2 py-1.5 text-xs font-medium text-muted-foreground"
 | 
			
		||||
    >
 | 
			
		||||
      {{ heading }}
 | 
			
		||||
    </ComboboxLabel>
 | 
			
		||||
    <slot />
 | 
			
		||||
  </ComboboxGroup>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										43
									
								
								frontend/src/components/ui/command/CommandInput.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								frontend/src/components/ui/command/CommandInput.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,43 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed } from "vue";
 | 
			
		||||
import { MagnifyingGlassIcon } from "@radix-icons/vue";
 | 
			
		||||
import { ComboboxInput, useForwardProps } from "radix-vue";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
defineOptions({
 | 
			
		||||
  inheritAttrs: false,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  type: { type: String, required: false },
 | 
			
		||||
  disabled: { type: Boolean, required: false },
 | 
			
		||||
  autoFocus: { type: Boolean, required: false },
 | 
			
		||||
  asChild: { type: Boolean, required: false },
 | 
			
		||||
  as: { type: null, required: false },
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const delegatedProps = computed(() => {
 | 
			
		||||
  const { class: _, ...delegated } = props;
 | 
			
		||||
 | 
			
		||||
  return delegated;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const forwardedProps = useForwardProps(delegatedProps);
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="flex items-center border-b px-3" cmdk-input-wrapper>
 | 
			
		||||
    <MagnifyingGlassIcon class="mr-2 h-4 w-4 shrink-0 opacity-50" />
 | 
			
		||||
    <ComboboxInput
 | 
			
		||||
      v-bind="{ ...forwardedProps, ...$attrs }"
 | 
			
		||||
      auto-focus
 | 
			
		||||
      :class="
 | 
			
		||||
        cn(
 | 
			
		||||
          'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
 | 
			
		||||
          props.class
 | 
			
		||||
        )
 | 
			
		||||
      "
 | 
			
		||||
    />
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										36
									
								
								frontend/src/components/ui/command/CommandItem.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								frontend/src/components/ui/command/CommandItem.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,36 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed } from "vue";
 | 
			
		||||
import { ComboboxItem, useForwardPropsEmits } from "radix-vue";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  value: { type: null, required: true },
 | 
			
		||||
  disabled: { type: Boolean, required: false },
 | 
			
		||||
  asChild: { type: Boolean, required: false },
 | 
			
		||||
  as: { type: null, required: false },
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
const emits = defineEmits(["select"]);
 | 
			
		||||
 | 
			
		||||
const delegatedProps = computed(() => {
 | 
			
		||||
  const { class: _, ...delegated } = props;
 | 
			
		||||
 | 
			
		||||
  return delegated;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <ComboboxItem
 | 
			
		||||
    v-bind="forwarded"
 | 
			
		||||
    :class="
 | 
			
		||||
      cn(
 | 
			
		||||
        'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
 | 
			
		||||
        props.class
 | 
			
		||||
      )
 | 
			
		||||
    "
 | 
			
		||||
  >
 | 
			
		||||
    <slot />
 | 
			
		||||
  </ComboboxItem>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										53
									
								
								frontend/src/components/ui/command/CommandList.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								frontend/src/components/ui/command/CommandList.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,53 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed } from "vue";
 | 
			
		||||
import { ComboboxContent, useForwardPropsEmits } from "radix-vue";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  forceMount: { type: Boolean, required: false },
 | 
			
		||||
  position: { type: String, required: false },
 | 
			
		||||
  bodyLock: { type: Boolean, required: false },
 | 
			
		||||
  dismissable: { type: Boolean, required: false, default: false },
 | 
			
		||||
  side: { type: null, required: false },
 | 
			
		||||
  sideOffset: { type: Number, required: false },
 | 
			
		||||
  align: { type: null, required: false },
 | 
			
		||||
  alignOffset: { type: Number, required: false },
 | 
			
		||||
  avoidCollisions: { type: Boolean, required: false },
 | 
			
		||||
  collisionBoundary: { type: null, required: false },
 | 
			
		||||
  collisionPadding: { type: [Number, Object], required: false },
 | 
			
		||||
  arrowPadding: { type: Number, required: false },
 | 
			
		||||
  sticky: { type: String, required: false },
 | 
			
		||||
  hideWhenDetached: { type: Boolean, required: false },
 | 
			
		||||
  updatePositionStrategy: { type: String, required: false },
 | 
			
		||||
  prioritizePosition: { type: Boolean, required: false },
 | 
			
		||||
  asChild: { type: Boolean, required: false },
 | 
			
		||||
  as: { type: null, required: false },
 | 
			
		||||
  disableOutsidePointerEvents: { type: Boolean, required: false },
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
const emits = defineEmits([
 | 
			
		||||
  "escapeKeyDown",
 | 
			
		||||
  "pointerDownOutside",
 | 
			
		||||
  "focusOutside",
 | 
			
		||||
  "interactOutside",
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
const delegatedProps = computed(() => {
 | 
			
		||||
  const { class: _, ...delegated } = props;
 | 
			
		||||
 | 
			
		||||
  return delegated;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <ComboboxContent
 | 
			
		||||
    v-bind="forwarded"
 | 
			
		||||
    :class="cn('max-h-[300px] overflow-y-auto overflow-x-hidden', props.class)"
 | 
			
		||||
  >
 | 
			
		||||
    <div role="presentation">
 | 
			
		||||
      <slot />
 | 
			
		||||
    </div>
 | 
			
		||||
  </ComboboxContent>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										26
									
								
								frontend/src/components/ui/command/CommandSeparator.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								frontend/src/components/ui/command/CommandSeparator.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed } from "vue";
 | 
			
		||||
import { ComboboxSeparator } from "radix-vue";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  asChild: { type: Boolean, required: false },
 | 
			
		||||
  as: { type: null, required: false },
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const delegatedProps = computed(() => {
 | 
			
		||||
  const { class: _, ...delegated } = props;
 | 
			
		||||
 | 
			
		||||
  return delegated;
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <ComboboxSeparator
 | 
			
		||||
    v-bind="delegatedProps"
 | 
			
		||||
    :class="cn('-mx-1 h-px bg-border', props.class)"
 | 
			
		||||
  >
 | 
			
		||||
    <slot />
 | 
			
		||||
  </ComboboxSeparator>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										17
									
								
								frontend/src/components/ui/command/CommandShortcut.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								frontend/src/components/ui/command/CommandShortcut.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  class: { type: null, required: false },
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <span
 | 
			
		||||
    :class="
 | 
			
		||||
      cn('ml-auto text-xs tracking-widest text-muted-foreground', props.class)
 | 
			
		||||
    "
 | 
			
		||||
  >
 | 
			
		||||
    <slot />
 | 
			
		||||
  </span>
 | 
			
		||||
</template>
 | 
			
		||||
							
								
								
									
										9
									
								
								frontend/src/components/ui/command/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								frontend/src/components/ui/command/index.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
			
		||||
export { default as Command } from "./Command.vue";
 | 
			
		||||
export { default as CommandDialog } from "./CommandDialog.vue";
 | 
			
		||||
export { default as CommandEmpty } from "./CommandEmpty.vue";
 | 
			
		||||
export { default as CommandGroup } from "./CommandGroup.vue";
 | 
			
		||||
export { default as CommandInput } from "./CommandInput.vue";
 | 
			
		||||
export { default as CommandItem } from "./CommandItem.vue";
 | 
			
		||||
export { default as CommandList } from "./CommandList.vue";
 | 
			
		||||
export { default as CommandSeparator } from "./CommandSeparator.vue";
 | 
			
		||||
export { default as CommandShortcut } from "./CommandShortcut.vue";
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user