mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-06 06:53:20 +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