Compare commits

..

2 Commits

Author SHA1 Message Date
Abhinav Raut
beee4bace6 chore(imap-logs): add more context to error logs 2025-05-20 00:13:50 +05:30
Abhinav Raut
a29c707795 fix(imap-email): ignore incoming emails from inbox email address 2025-05-20 00:09:57 +05:30
235 changed files with 4602 additions and 7041 deletions

View File

@@ -53,11 +53,6 @@ jobs:
- name: Configure app - name: Configure app
run: | run: |
cp config.sample.toml config.toml cp config.sample.toml config.toml
sed -i 's/host = "db"/host = "127.0.0.1"/' config.toml
sed -i 's/address = "redis:6379"/address = "localhost:6379"/' config.toml
- name: Run unit tests for frontend
run: cd frontend && pnpm test:run
- name: Install db schema and run tests - name: Install db schema and run tests
env: env:

View File

@@ -5,17 +5,18 @@
Open source, self-hosted customer support desk. Single binary app. Open source, self-hosted customer support desk. Single binary app.
![image](docs/docs/images/hero.png)
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/). Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/).
![image](https://github.com/user-attachments/assets/8e434a02-8b33-41c8-8433-3c98d1d5b834)
> **CAUTION:** This project is currently in **alpha**. Features and APIs may change and are not yet fully tested. > **CAUTION:** This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
## Features ## Features
- **Multi Shared Inbox** - **Multi Inbox**
Libredesk supports multiple shares inboxes, letting you manage conversations across teams effortlessly. Libredesk supports multiple inboxes, letting you manage conversations across teams effortlessly.
- **Granular Permissions** - **Granular Permissions**
Create custom roles with granular permissions for teams and individual agents. Create custom roles with granular permissions for teams and individual agents.
- **Smart Automation** - **Smart Automation**
@@ -30,14 +31,14 @@ Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live
Distribute workload with auto assignment rules. Auto-assign conversations based on agent capacity or custom criteria. Distribute workload with auto assignment rules. Auto-assign conversations based on agent capacity or custom criteria.
- **SLA Management** - **SLA Management**
Set and track response time targets. Get notified when conversations are at risk of breaching SLA commitments. Set and track response time targets. Get notified when conversations are at risk of breaching SLA commitments.
- **Custom attributes** - **Business Intelligence**
Create custom attributes for contacts or conversations such as the subscription plan or the date of their first purchase. Connect your favorite BI tools like Metabase and create custom dashboards and reports with your support data—without lock-ins.
- **AI-Assist** - **AI-Assisted Response Rewrite**
Instantly rewrite responses with AI to make them more friendly, professional, or polished. Instantly rewrite responses with AI to make them more friendly, professional, or polished.
- **Activity logs** - **Activity logs**
Track all actions performed by agents and admins—updates and key events across the system—for auditing and accountability. Track all actions performed by agents and admins—updates and key events across the system—for auditing and accountability.
- **Command Bar** - **Command Bar**
Opens with a simple shortcut (CTRL+K) and lets you quickly perform actions on conversations. Opens with a simple shortcut (CTRL+k) and lets you quickly perform actions on conversations.
And more checkout - [libredesk.io](https://libredesk.io) And more checkout - [libredesk.io](https://libredesk.io)
@@ -56,6 +57,8 @@ curl -LO https://github.com/abhinavxd/libredesk/raw/main/config.sample.toml
# Copy the config.sample.toml to config.toml and edit it as needed. # Copy the config.sample.toml to config.toml and edit it as needed.
cp config.sample.toml config.toml cp config.sample.toml config.toml
# Edit config.toml and find commented lines containing "docker compose". Replace the values in the lines below those comments with service names instead of IP addresses.
# Run the services in the background. # Run the services in the background.
docker compose up -d docker compose up -d
@@ -63,7 +66,7 @@ docker compose up -d
docker exec -it libredesk_app ./libredesk --set-system-user-password docker exec -it libredesk_app ./libredesk --set-system-user-password
``` ```
Go to `http://localhost:9000` and login with username `System` and the password you set using the `--set-system-user-password` command. Go to `http://localhost:9000` and login with username `System` and the password you set using the --set-system-user-password command.
See [installation docs](https://libredesk.io/docs/installation/) See [installation docs](https://libredesk.io/docs/installation/)

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"strconv" "strconv"
"strings"
"time" "time"
amodels "github.com/abhinavxd/libredesk/internal/auth/models" amodels "github.com/abhinavxd/libredesk/internal/auth/models"
@@ -10,7 +11,6 @@ import (
"github.com/abhinavxd/libredesk/internal/automation/models" "github.com/abhinavxd/libredesk/internal/automation/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/envelope"
medModels "github.com/abhinavxd/libredesk/internal/media/models"
"github.com/abhinavxd/libredesk/internal/stringutil" "github.com/abhinavxd/libredesk/internal/stringutil"
umodels "github.com/abhinavxd/libredesk/internal/user/models" umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
@@ -18,18 +18,6 @@ import (
"github.com/zerodha/fastglue" "github.com/zerodha/fastglue"
) )
type createConversationRequest struct {
InboxID int `json:"inbox_id" form:"inbox_id"`
AssignedAgentID int `json:"agent_id" form:"agent_id"`
AssignedTeamID int `json:"team_id" form:"team_id"`
Email string `json:"contact_email" form:"contact_email"`
FirstName string `json:"first_name" form:"first_name"`
LastName string `json:"last_name" form:"last_name"`
Subject string `json:"subject" form:"subject"`
Content string `json:"content" form:"content"`
Attachments []int `json:"attachments" form:"attachments"`
}
// handleGetAllConversations retrieves all conversations. // handleGetAllConversations retrieves all conversations.
func handleGetAllConversations(r *fastglue.Request) error { func handleGetAllConversations(r *fastglue.Request) error {
var ( var (
@@ -546,11 +534,33 @@ func handleUpdateContactCustomAttributes(r *fastglue.Request) error {
if err := app.user.UpdateCustomAttributes(conversation.ContactID, attributes); err != nil { if err := app.user.UpdateCustomAttributes(conversation.ContactID, attributes); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// Broadcast update.
app.conversation.BroadcastConversationUpdate(conversation.UUID, "contact.custom_attributes", attributes)
return r.SendEnvelope(true) return r.SendEnvelope(true)
} }
// handleDashboardCounts retrieves general dashboard counts for all users.
func handleDashboardCounts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
counts, err := app.conversation.GetDashboardCounts(0, 0)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(counts)
}
// handleDashboardCharts retrieves general dashboard chart data.
func handleDashboardCharts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
charts, err := app.conversation.GetDashboardChart(0, 0)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(charts)
}
// enforceConversationAccess fetches the conversation and checks if the user has access to it. // enforceConversationAccess fetches the conversation and checks if the user has access to it.
func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmodels.Conversation, error) { func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmodels.Conversation, error) {
conversation, err := app.conversation.GetConversation(0, uuid) conversation, err := app.conversation.GetConversation(0, uuid)
@@ -624,30 +634,34 @@ func handleCreateConversation(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
req = createConversationRequest{} inboxID = r.RequestCtx.PostArgs().GetUintOrZero("inbox_id")
assignedAgentID = r.RequestCtx.PostArgs().GetUintOrZero("agent_id")
assignedTeamID = r.RequestCtx.PostArgs().GetUintOrZero("team_id")
email = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("contact_email")))
firstName = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("first_name")))
lastName = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("last_name")))
subject = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("subject")))
content = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("content")))
to = []string{email}
) )
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding create conversation request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
to := []string{req.Email}
// Validate required fields // Validate required fields
if req.InboxID <= 0 { if inboxID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`inbox_id`"), nil, envelope.InputError)
} }
if req.Content == "" { if subject == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`subject`"), nil, envelope.InputError)
} }
if req.Email == "" { if content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`content`"), nil, envelope.InputError)
} }
if req.FirstName == "" { if email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`contact_email`"), nil, envelope.InputError)
} }
if !stringutil.ValidEmail(req.Email) { if firstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`first_name`"), nil, envelope.InputError)
}
if !stringutil.ValidEmail(email) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil, envelope.InputError)
} }
@@ -657,7 +671,7 @@ func handleCreateConversation(r *fastglue.Request) error {
} }
// Check if inbox exists and is enabled. // Check if inbox exists and is enabled.
inbox, err := app.inbox.GetDBRecord(req.InboxID) inbox, err := app.inbox.GetDBRecord(inboxID)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -667,11 +681,11 @@ func handleCreateConversation(r *fastglue.Request) error {
// Find or create contact. // Find or create contact.
contact := umodels.User{ contact := umodels.User{
Email: null.StringFrom(req.Email), Email: null.StringFrom(email),
SourceChannelID: null.StringFrom(req.Email), SourceChannelID: null.StringFrom(email),
FirstName: req.FirstName, FirstName: firstName,
LastName: req.LastName, LastName: lastName,
InboxID: req.InboxID, InboxID: inboxID,
} }
if err := app.user.CreateContact(&contact); err != nil { if err := app.user.CreateContact(&contact); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil)) return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil))
@@ -681,10 +695,10 @@ func handleCreateConversation(r *fastglue.Request) error {
conversationID, conversationUUID, err := app.conversation.CreateConversation( conversationID, conversationUUID, err := app.conversation.CreateConversation(
contact.ID, contact.ID,
contact.ContactChannelID, contact.ContactChannelID,
req.InboxID, inboxID,
"", /** last_message **/ "", /** last_message **/
time.Now(), /** last_message_at **/ time.Now(), /** last_message_at **/
req.Subject, subject,
true, /** append reference number to subject **/ true, /** append reference number to subject **/
) )
if err != nil { if err != nil {
@@ -692,19 +706,8 @@ func handleCreateConversation(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil)) return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil))
} }
// Prepare attachments.
var media = make([]medModels.Media, 0, len(req.Attachments))
for _, id := range req.Attachments {
m, err := app.media.Get(id, "")
if err != nil {
app.lo.Error("error fetching media", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.media}"), nil, envelope.GeneralError)
}
media = append(media, m)
}
// Send reply to the created conversation. // Send reply to the created conversation.
if err := app.conversation.SendReply(media, req.InboxID, auser.ID /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil { if err := app.conversation.SendReply(nil /**media**/, inboxID, auser.ID /**sender_id**/, conversationUUID, content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
// Delete the conversation if reply fails. // Delete the conversation if reply fails.
if err := app.conversation.DeleteConversation(conversationUUID); err != nil { if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err) app.lo.Error("error deleting conversation", "error", err)
@@ -713,11 +716,11 @@ func handleCreateConversation(r *fastglue.Request) error {
} }
// Assign the conversation to the agent or team. // Assign the conversation to the agent or team.
if req.AssignedAgentID > 0 { if assignedAgentID > 0 {
app.conversation.UpdateConversationUserAssignee(conversationUUID, req.AssignedAgentID, user) app.conversation.UpdateConversationUserAssignee(conversationUUID, assignedAgentID, user)
} }
if req.AssignedTeamID > 0 { if assignedTeamID > 0 {
app.conversation.UpdateConversationTeamAssignee(conversationUUID, req.AssignedTeamID, user) app.conversation.UpdateConversationTeamAssignee(conversationUUID, assignedTeamID, user)
} }
// Send the created conversation back to the client. // Send the created conversation back to the client.

View File

@@ -37,6 +37,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC) g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC)
g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage")) g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage"))
g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage")) g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage"))
g.POST("/api/v1/oidc/test", perm(handleTestOIDC, "oidc:manage"))
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage")) g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
g.PUT("/api/v1/oidc/{id}", perm(handleUpdateOIDC, "oidc:manage")) g.PUT("/api/v1/oidc/{id}", perm(handleUpdateOIDC, "oidc:manage"))
g.DELETE("/api/v1/oidc/{id}", perm(handleDeleteOIDC, "oidc:manage")) g.DELETE("/api/v1/oidc/{id}", perm(handleDeleteOIDC, "oidc:manage"))
@@ -158,9 +159,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.DELETE("/api/v1/roles/{id}", perm(handleDeleteRole, "roles:manage")) g.DELETE("/api/v1/roles/{id}", perm(handleDeleteRole, "roles:manage"))
// Reports. // Reports.
g.GET("/api/v1/reports/overview/sla", perm(handleOverviewSLA, "reports:manage")) g.GET("/api/v1/reports/overview/counts", perm(handleDashboardCounts, "reports:manage"))
g.GET("/api/v1/reports/overview/counts", perm(handleOverviewCounts, "reports:manage")) g.GET("/api/v1/reports/overview/charts", perm(handleDashboardCharts, "reports:manage"))
g.GET("/api/v1/reports/overview/charts", perm(handleOverviewCharts, "reports:manage"))
// Templates. // Templates.
g.GET("/api/v1/templates", perm(handleGetTemplates, "templates:manage")) g.GET("/api/v1/templates", perm(handleGetTemplates, "templates:manage"))

View File

@@ -35,7 +35,6 @@ import (
notifier "github.com/abhinavxd/libredesk/internal/notification" notifier "github.com/abhinavxd/libredesk/internal/notification"
emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email" emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email"
"github.com/abhinavxd/libredesk/internal/oidc" "github.com/abhinavxd/libredesk/internal/oidc"
"github.com/abhinavxd/libredesk/internal/report"
"github.com/abhinavxd/libredesk/internal/role" "github.com/abhinavxd/libredesk/internal/role"
"github.com/abhinavxd/libredesk/internal/search" "github.com/abhinavxd/libredesk/internal/search"
"github.com/abhinavxd/libredesk/internal/setting" "github.com/abhinavxd/libredesk/internal/setting"
@@ -824,20 +823,6 @@ func initActivityLog(db *sqlx.DB, i18n *i18n.I18n) *activitylog.Manager {
return m return m
} }
// initReport inits report manager.
func initReport(db *sqlx.DB, i18n *i18n.I18n) *report.Manager {
lo := initLogger("report")
m, err := report.New(report.Opts{
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing report manager: %v", err)
}
return m
}
// initLogger initializes a logf logger. // initLogger initializes a logf logger.
func initLogger(src string) *logf.Logger { func initLogger(src string) *logf.Logger {
lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env") lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env")

View File

@@ -81,7 +81,8 @@ func handleCreateMacro(r *fastglue.Request) error {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
if err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions); err != nil { err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions)
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -109,7 +110,7 @@ func handleUpdateMacro(r *fastglue.Request) error {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
if err = app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions); err != nil { if err = app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -274,17 +275,13 @@ func validateMacro(app *App, macro models.Macro) error {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
} }
if len(macro.VisibleWhen) == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`visible_when`"), nil)
}
var act []autoModels.RuleAction var act []autoModels.RuleAction
if err := json.Unmarshal(macro.Actions, &act); err != nil { if err := json.Unmarshal(macro.Actions, &act); err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), nil)
} }
for _, a := range act { for _, a := range act {
if len(a.Value) == 0 { if len(a.Value) == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", a.Type), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.emptyActionValue", "name", a.Type), nil)
} }
} }
return nil return nil

View File

@@ -23,7 +23,6 @@ import (
customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute" customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute"
"github.com/abhinavxd/libredesk/internal/macro" "github.com/abhinavxd/libredesk/internal/macro"
notifier "github.com/abhinavxd/libredesk/internal/notification" notifier "github.com/abhinavxd/libredesk/internal/notification"
"github.com/abhinavxd/libredesk/internal/report"
"github.com/abhinavxd/libredesk/internal/search" "github.com/abhinavxd/libredesk/internal/search"
"github.com/abhinavxd/libredesk/internal/sla" "github.com/abhinavxd/libredesk/internal/sla"
"github.com/abhinavxd/libredesk/internal/view" "github.com/abhinavxd/libredesk/internal/view"
@@ -91,7 +90,6 @@ type App struct {
activityLog *activitylog.Manager activityLog *activitylog.Manager
notifier *notifier.Service notifier *notifier.Service
customAttribute *customAttribute.Manager customAttribute *customAttribute.Manager
report *report.Manager
// Global state that stores data on an available app update. // Global state that stores data on an available app update.
update *AppUpdate update *AppUpdate
@@ -159,23 +157,13 @@ func main() {
settings := initSettings(db) settings := initSettings(db)
loadSettings(settings) loadSettings(settings)
// Fallback for config typo. Logs a warning but continues to work with the incorrect key.
// Uses 'message.message_outgoing_scan_interval' (correct key) as default key, falls back to the common typo.
msgOutgoingScanIntervalKey := "message.message_outgoing_scan_interval"
if ko.String(msgOutgoingScanIntervalKey) == "" {
if ko.String("message.message_outoing_scan_interval") != "" {
colorlog.Red("WARNING: typo in config key 'message.message_outoing_scan_interval' detected. Use 'message.message_outgoing_scan_interval' instead in your config.toml file. Support for this incorrect key will be removed in a future release.")
msgOutgoingScanIntervalKey = "message.message_outoing_scan_interval"
}
}
var ( var (
autoAssignInterval = ko.MustDuration("autoassigner.autoassign_interval") autoAssignInterval = ko.MustDuration("autoassigner.autoassign_interval")
unsnoozeInterval = ko.MustDuration("conversation.unsnooze_interval") unsnoozeInterval = ko.MustDuration("conversation.unsnooze_interval")
automationWorkers = ko.MustInt("automation.worker_count") automationWorkers = ko.MustInt("automation.worker_count")
messageOutgoingQWorkers = ko.MustDuration("message.outgoing_queue_workers") messageOutgoingQWorkers = ko.MustDuration("message.outgoing_queue_workers")
messageIncomingQWorkers = ko.MustDuration("message.incoming_queue_workers") messageIncomingQWorkers = ko.MustDuration("message.incoming_queue_workers")
messageOutgoingScanInterval = ko.MustDuration(msgOutgoingScanIntervalKey) messageOutgoingScanInterval = ko.MustDuration("message.message_outoing_scan_interval")
slaEvaluationInterval = ko.MustDuration("sla.evaluation_interval") slaEvaluationInterval = ko.MustDuration("sla.evaluation_interval")
lo = initLogger(appName) lo = initLogger(appName)
rdb = initRedis() rdb = initRedis()
@@ -236,7 +224,6 @@ func main() {
customAttribute: initCustomAttribute(db, i18n), customAttribute: initCustomAttribute(db, i18n),
authz: initAuthz(i18n), authz: initAuthz(i18n),
view: initView(db), view: initView(db),
report: initReport(db, i18n),
csat: initCSAT(db, i18n), csat: initCSAT(db, i18n),
search: initSearch(db, i18n), search: initSearch(db, i18n),
role: initRole(db, i18n), role: initRole(db, i18n),

View File

@@ -132,6 +132,7 @@ func handleSendMessage(r *fastglue.Request) error {
app = r.Context.(*App) app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
cuuid = r.RequestCtx.UserValue("cuuid").(string) cuuid = r.RequestCtx.UserValue("cuuid").(string)
media = []medModels.Media{}
req = messageReq{} req = messageReq{}
) )
@@ -152,7 +153,6 @@ func handleSendMessage(r *fastglue.Request) error {
} }
// Prepare attachments. // Prepare attachments.
var media = make([]medModels.Media, 0, len(req.Attachments))
for _, id := range req.Attachments { for _, id := range req.Attachments {
m, err := app.media.Get(id, "") m, err := app.media.Get(id, "")
if err != nil { if err != nil {
@@ -173,5 +173,6 @@ func handleSendMessage(r *fastglue.Request) error {
// Evaluate automation rules. // Evaluate automation rules.
app.automation.EvaluateConversationUpdateRules(cuuid, models.EventConversationMessageOutgoing) app.automation.EvaluateConversationUpdateRules(cuuid, models.EventConversationMessageOutgoing)
} }
return r.SendEnvelope(true) return r.SendEnvelope(true)
} }

View File

@@ -24,7 +24,7 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
} }
// Try to get user. // Try to get user.
user, err := app.user.GetAgentCachedOrLoad(userSession.ID) user, err := app.user.GetAgent(userSession.ID, "")
if err != nil { if err != nil {
return handler(r) return handler(r)
} }
@@ -54,7 +54,7 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
} }
// Set user in the request context. // Set user in the request context.
user, err := app.user.GetAgentCachedOrLoad(userSession.ID) user, err := app.user.GetAgent(userSession.ID, "")
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -92,8 +92,8 @@ func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequest
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.T("auth.invalidOrExpiredSession"), nil, envelope.GeneralError) return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.T("auth.invalidOrExpiredSession"), nil, envelope.GeneralError)
} }
// Get agent user from cache or load it. // Get user from DB.
user, err := app.user.GetAgentCachedOrLoad(sessUser.ID) user, err := app.user.GetAgent(sessUser.ID, "")
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }

View File

@@ -50,6 +50,18 @@ func handleGetOIDC(r *fastglue.Request) error {
return r.SendEnvelope(o) return r.SendEnvelope(o)
} }
// handleTestOIDC tests an OIDC provider URL by doing a discovery on the provider URL.
func handleTestOIDC(r *fastglue.Request) error {
var (
app = r.Context.(*App)
providerURL = string(r.RequestCtx.PostArgs().Peek("provider_url"))
)
if err := app.auth.TestProvider(providerURL); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleCreateOIDC creates a new OIDC record. // handleCreateOIDC creates a new OIDC record.
func handleCreateOIDC(r *fastglue.Request) error { func handleCreateOIDC(r *fastglue.Request) error {
var ( var (
@@ -60,11 +72,6 @@ func handleCreateOIDC(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError)
} }
// Test OIDC provider URL by performing a discovery.
if err := app.auth.TestProvider(req.ProviderURL); err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.oidc.Create(req); err != nil { if err := app.oidc.Create(req); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -91,11 +98,6 @@ func handleUpdateOIDC(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError)
} }
// Test OIDC provider URL by performing a discovery.
if err := app.auth.TestProvider(req.ProviderURL); err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.oidc.Update(id, req); err != nil { if err = app.oidc.Update(id, req); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }

View File

@@ -1,45 +0,0 @@
package main
import (
"strconv"
"github.com/zerodha/fastglue"
)
// handleOverviewCounts retrieves general dashboard counts for all users.
func handleOverviewCounts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
counts, err := app.report.GetOverViewCounts()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(counts)
}
// handleOverviewCharts retrieves general dashboard chart data.
func handleOverviewCharts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days")))
)
charts, err := app.report.GetOverviewChart(days)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(charts)
}
// handleOverviewSLA retrieves SLA data for the dashboard.
func handleOverviewSLA(r *fastglue.Request) error {
var (
app = r.Context.(*App)
days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days")))
)
sla, err := app.report.GetOverviewSLA(days)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(sla)
}

View File

@@ -29,7 +29,7 @@ func handleGetSLA(r *fastglue.Request) error {
) )
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string)) id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 { if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "SLA `id`"), nil, envelope.InputError)
} }
sla, err := app.sla.Get(id) sla, err := app.sla.Get(id)
@@ -54,7 +54,7 @@ func handleCreateSLA(r *fastglue.Request) error {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
if err := app.sla.Create(sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications); err != nil { if err := app.sla.Create(sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.Notifications); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -70,7 +70,7 @@ func handleUpdateSLA(r *fastglue.Request) error {
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string)) id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 { if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "SLA `id`"), nil, envelope.InputError)
} }
if err := r.Decode(&sla, "json"); err != nil { if err := r.Decode(&sla, "json"); err != nil {
@@ -81,11 +81,11 @@ func handleUpdateSLA(r *fastglue.Request) error {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
if err := app.sla.Update(id, sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications); err != nil { if err := app.sla.Update(id, sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.Notifications); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(true) return r.SendEnvelope("SLA updated successfully.")
} }
// handleDeleteSLA deletes the SLA with the given ID. // handleDeleteSLA deletes the SLA with the given ID.
@@ -95,7 +95,7 @@ func handleDeleteSLA(r *fastglue.Request) error {
) )
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string)) id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 { if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "SLA `id`"), nil, envelope.InputError)
} }
if err = app.sla.Delete(id); err != nil { if err = app.sla.Delete(id); err != nil {
@@ -108,80 +108,52 @@ func handleDeleteSLA(r *fastglue.Request) error {
// validateSLA validates the SLA policy and returns an envelope.Error if any validation fails. // validateSLA validates the SLA policy and returns an envelope.Error if any validation fails.
func validateSLA(app *App, sla *smodels.SLAPolicy) error { func validateSLA(app *App, sla *smodels.SLAPolicy) error {
if sla.Name == "" { if sla.Name == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA `name`"), nil)
} }
if sla.FirstResponseTime.String == "" && sla.NextResponseTime.String == "" && sla.ResolutionTime.String == "" { if sla.FirstResponseTime == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "At least one of `first_response_time`, `next_response_time`, or `resolution_time` must be provided."), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA `first_response_time`"), nil)
}
if sla.ResolutionTime == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA `resolution_time`"), nil)
} }
// Validate notifications if any. // Validate notifications if any
for _, n := range sla.Notifications { for _, n := range sla.Notifications {
if n.Type == "" { if n.Type == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`type`"), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA notification `type`"), nil)
} }
if n.TimeDelayType == "" { if n.TimeDelayType == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`time_delay_type`"), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA notification `time_delay_type`"), nil)
}
if n.Metric == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`metric`"), nil)
} }
if n.TimeDelayType != "immediately" { if n.TimeDelayType != "immediately" {
if n.TimeDelay == "" { if n.TimeDelay == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`time_delay`"), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA notification `time_delay`"), nil)
}
// Validate time delay duration.
td, err := time.ParseDuration(n.TimeDelay)
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`time_delay`"), nil)
}
if td.Minutes() < 1 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`time_delay`"), nil)
} }
} }
if len(n.Recipients) == 0 { if len(n.Recipients) == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`recipients`"), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "SLA notification `recipients`"), nil)
} }
} }
// Validate first response time duration string if not empty. // Validate time duration strings
if sla.FirstResponseTime.String != "" { frt, err := time.ParseDuration(sla.FirstResponseTime)
frt, err := time.ParseDuration(sla.FirstResponseTime.String)
if err != nil { if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil)
} }
if frt.Minutes() < 1 { if frt.Minutes() < 1 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil)
} }
}
// Validate resolution time duration string if not empty. rt, err := time.ParseDuration(sla.ResolutionTime)
if sla.ResolutionTime.String != "" {
rt, err := time.ParseDuration(sla.ResolutionTime.String)
if err != nil { if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil)
} }
if rt.Minutes() < 1 { if rt.Minutes() < 1 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil) return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil)
} }
// Compare with first response time if both are present.
if sla.FirstResponseTime.String != "" {
frt, _ := time.ParseDuration(sla.FirstResponseTime.String)
if frt > rt { if frt > rt {
return envelope.NewError(envelope.InputError, app.i18n.T("sla.firstResponseTimeAfterResolution"), nil) return envelope.NewError(envelope.InputError, app.i18n.T("sla.firstResponseTimeAfterResolution"), nil)
} }
}
}
// Validate next response time duration string if not empty.
if sla.NextResponseTime.String != "" {
nrt, err := time.ParseDuration(sla.NextResponseTime.String)
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`next_response_time`"), nil)
}
if nrt.Minutes() < 1 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`next_response_time`"), nil)
}
}
return nil return nil
} }

View File

@@ -72,27 +72,16 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
status = string(r.RequestCtx.PostArgs().Peek("status")) status = string(r.RequestCtx.PostArgs().Peek("status"))
ip = realip.FromRequest(r.RequestCtx) ip = realip.FromRequest(r.RequestCtx)
) )
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Same status?
if agent.AvailabilityStatus == status {
return r.SendEnvelope(true)
}
// Update availability status. // Update availability status.
if err := app.user.UpdateAvailability(auser.ID, status); err != nil { if err := app.user.UpdateAvailability(auser.ID, status); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// Skip activity log if agent returns online from away (to avoid spam). // Create activity log.
if !(agent.AvailabilityStatus == models.Away && status == models.Online) {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, status, ip, "", 0); err != nil { if err := app.activityLog.UserAvailability(auser.ID, auser.Email, status, ip, "", 0); err != nil {
app.lo.Error("error creating activity log", "error", err) app.lo.Error("error creating activity log", "error", err)
} }
}
return r.SendEnvelope(true) return r.SendEnvelope(true)
} }
@@ -156,11 +145,6 @@ func handleCreateAgent(r *fastglue.Request) error {
if user.Email.String == "" { if user.Email.String == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
} }
user.Email = null.StringFrom(strings.TrimSpace(strings.ToLower(user.Email.String)))
if !stringutil.ValidEmail(user.Email.String) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
}
if user.Roles == nil { if user.Roles == nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
@@ -170,6 +154,7 @@ func handleCreateAgent(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
} }
// Right now, only agents can be created.
if err := app.user.CreateAgent(&user); err != nil { if err := app.user.CreateAgent(&user); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -218,9 +203,9 @@ func handleUpdateAgent(r *fastglue.Request) error {
user = models.User{} user = models.User{}
auser = r.RequestCtx.UserValue("user").(amodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx) ip = realip.FromRequest(r.RequestCtx)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
) )
if id == 0 { id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
} }
@@ -231,11 +216,6 @@ func handleUpdateAgent(r *fastglue.Request) error {
if user.Email.String == "" { if user.Email.String == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
} }
user.Email = null.StringFrom(strings.TrimSpace(strings.ToLower(user.Email.String)))
if !stringutil.ValidEmail(user.Email.String) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
}
if user.Roles == nil { if user.Roles == nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
@@ -256,9 +236,6 @@ func handleUpdateAgent(r *fastglue.Request) error {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// Invalidate authz cache.
defer app.authz.InvalidateUserCache(id)
// Create activity log if user availability status changed. // Create activity log if user availability status changed.
if oldAvailabilityStatus != user.AvailabilityStatus { if oldAvailabilityStatus != user.AvailabilityStatus {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, user.AvailabilityStatus, ip, user.Email.String, id); err != nil { if err := app.activityLog.UserAvailability(auser.ID, auser.Email, user.AvailabilityStatus, ip, user.Email.String, id); err != nil {

View File

@@ -1,116 +1,80 @@
# App.
[app] [app]
# Log level: info, debug, warn, error, fatal
log_level = "debug" log_level = "debug"
# Environment: dev, prod.
# Setting to "dev" will enable color logging in terminal.
env = "dev" env = "dev"
# Whether to automatically check for application updates on start up, app updates are shown as a banner in the admin panel.
check_updates = true check_updates = true
# HTTP server. # HTTP server.
[app.server] [app.server]
# Address to bind the HTTP server to.
address = "0.0.0.0:9000" address = "0.0.0.0:9000"
# Unix socket path (leave empty to use TCP address instead)
socket = "" socket = ""
# Do NOT disable secure cookies in production environment if you don't know exactly what you're doing! # Do NOT disable secure cookies in production environment if you don't know
# exactly what you're doing!
disable_secure_cookies = false disable_secure_cookies = false
# Request read and write timeouts.
read_timeout = "5s" read_timeout = "5s"
write_timeout = "5s" write_timeout = "5s"
# Maximum request body size in bytes (100MB) max_body_size = 500000000
# If you are using proxy, you may need to configure them to allow larger request bodies.
max_body_size = 104857600
# Size of the read buffer for incoming requests
read_buffer_size = 4096 read_buffer_size = 4096
# Keepalive settings.
keepalive_timeout = "10s" keepalive_timeout = "10s"
# File upload provider to use, either `fs` or `s3`. # File upload provider to use, either `fs` or `s3`.
[upload] [upload]
provider = "fs" provider = "fs"
# Filesystem provider. # Filesytem provider.
[upload.fs] [upload.fs]
# Directory where uploaded files are stored, make sure this directory exists and is writable by the application.
upload_path = 'uploads' upload_path = 'uploads'
# S3 provider. # S3 provider.
[upload.s3] [upload.s3]
# S3 endpoint URL (required only for non-AWS S3-compatible providers like MinIO).
# Leave empty to use default AWS endpoints.
url = "" url = ""
# AWS S3 credentials, keep empty to use attached IAM roles.
access_key = "" access_key = ""
secret_key = "" secret_key = ""
# AWS region, e.g., "us-east-1", "eu-west-1", etc.
region = "ap-south-1" region = "ap-south-1"
# S3 bucket name where files will be stored. bucket = "bucket"
bucket = "bucket-name"
# Optional prefix path within the S3 bucket where files will be stored.
# Example, if set to "uploads/media", files will be stored under that path.
# Useful for organizing files inside a shared bucket.
bucket_path = "" bucket_path = ""
# S3 signed URL expiry duration (e.g., "30m", "1h") expiry = "6h"
expiry = "30m"
# Postgres. # Postgres.
[db] [db]
# If running locally, use `localhost`. # If using docker compose, use the service name as the host. e.g. db
host = "db" host = "127.0.0.1"
# Database port, default is 5432.
port = 5432 port = 5432
# Update the following values with your database credentials. # Update the following values with your database credentials.
user = "libredesk" user = "libredesk"
password = "libredesk" password = "libredesk"
database = "libredesk" database = "libredesk"
ssl_mode = "disable" ssl_mode = "disable"
# Maximum number of open database connections
max_open = 30 max_open = 30
# Maximum number of idle connections in the pool
max_idle = 30 max_idle = 30
# Maximum time a connection can be reused before being closed
max_lifetime = "300s" max_lifetime = "300s"
# Redis. # Redis.
[redis] [redis]
# If running locally, use `localhost:6379`. # If using docker compose, use the service name as the host. e.g. redis:6379
address = "redis:6379" address = "127.0.0.1:6379"
password = "" password = ""
db = 0 db = 0
[message] [message]
# Number of workers processing outgoing message queue
outgoing_queue_workers = 10 outgoing_queue_workers = 10
# Number of workers processing incoming message queue
incoming_queue_workers = 10 incoming_queue_workers = 10
# How often to scan for outgoing messages to process, keep it low to process messages quickly. message_outoing_scan_interval = "50ms"
message_outgoing_scan_interval = "50ms"
# Maximum number of messages that can be queued for incoming processing
incoming_queue_size = 5000 incoming_queue_size = 5000
# Maximum number of messages that can be queued for outgoing processing
outgoing_queue_size = 5000 outgoing_queue_size = 5000
[notification] [notification]
# Number of concurrent notification workers
concurrency = 2 concurrency = 2
# Maximum number of notifications that can be queued
queue_size = 2000 queue_size = 2000
[automation] [automation]
# Number of workers processing automation rules
worker_count = 10 worker_count = 10
[autoassigner] [autoassigner]
# How often to run automatic conversation assignment
autoassign_interval = "5m" autoassign_interval = "5m"
[conversation] [conversation]
# How often to check for conversations to unsnooze
unsnooze_interval = "5m" unsnooze_interval = "5m"
[sla] [sla]
# How often to evaluate SLA compliance for conversations
evaluation_interval = "5m" evaluation_interval = "5m"

View File

@@ -4,10 +4,9 @@ Libredesk is a monorepo with a Go backend and a Vue.js frontend. The frontend us
### Pre-requisites ### Pre-requisites
- go - `go`
- nodejs (if you are working on the frontend) and `pnpm` - `nodejs` (if you are working on the frontend) and `pnpm`
- redis - Postgres database (>= 13)
- postgres database (>= 13)
### First time setup ### First time setup

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

View File

@@ -1,17 +1,13 @@
# Introduction # Introduction
Libredesk is an open-source, self-hosted customer support desk — single binary app. Libredesk is an open source, self-hosted customer support desk. Single binary app.
<div style="border: 1px solid #ccc; padding: 2px; border-radius: 6px; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); background-color: #f9f9f9;">
<div style="border: 1px solid #ccc; padding: 1px; border-radius:5px; box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.1); background-color: #fff;">
<a href="https://libredesk.io"> <a href="https://libredesk.io">
<img src="images/hero.png" alt="libredesk UI screenshot" style="display: block; margin: 0 auto; max-width: 100%; border-radius: 4px;" /> <img src="https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-HvmxvOkalQSLp4qVezdTXaCd3dB4Rm.png" alt="libredesk screenshot" style="display: block; margin: 0 auto;">
</a> </a>
</div> </div>
## Developers ## Developers
Libredesk is a free and open source software licensed under AGPLv3. If you are interested in contributing, check out the [GitHub repository](https://github.com/abhinavxd/libredesk) and refer to the [developer setup](developer-setup.md). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
Libredesk is licensed under AGPLv3. Contributions are welcome.
- Source code: [GitHub](https://github.com/abhinavxd/libredesk)
- Setup guide: [Developer setup](developer-setup.md)
- Stack: Go backend, Vue 3 frontend (Shadcn UI)

View File

@@ -27,6 +27,8 @@ curl -LO https://github.com/abhinavxd/libredesk/raw/main/config.sample.toml
# Copy the config.sample.toml to config.toml and edit it as needed. # Copy the config.sample.toml to config.toml and edit it as needed.
cp config.sample.toml config.toml cp config.sample.toml config.toml
# Edit config.toml and find commented lines containing "docker compose". Replace the values in the lines below those comments with service names instead of IP addresses.
# Run the services in the background. # Run the services in the background.
docker compose up -d docker compose up -d

View File

@@ -1,6 +1,6 @@
# Templating # Templating
Templating in outgoing emails allows you to personalize content by embedding dynamic expressions like `{{ .Recipient.FullName }}`. These expressions reference fields from the conversation, contact, recipient, and author objects. Templating in outgoing emails allows you to personalize content by embedding dynamic expressions like `{{ .Recipient.FullName }}`. These expressions reference fields from the conversation, contact, and recipient objects.
## Outgoing Email Template Expressions ## Outgoing Email Template Expressions
@@ -12,11 +12,9 @@ If you want to customize the look of outgoing emails, you can do so in the Admin
|---------------------------------|--------------------------------------------------------| |---------------------------------|--------------------------------------------------------|
| {{ .Conversation.ReferenceNumber }} | The unique reference number of the conversation | | {{ .Conversation.ReferenceNumber }} | The unique reference number of the conversation |
| {{ .Conversation.Subject }} | The subject of the conversation | | {{ .Conversation.Subject }} | The subject of the conversation |
| {{ .Conversation.Priority }} | The priority level of the conversation |
| {{ .Conversation.UUID }} | The unique identifier of the conversation | | {{ .Conversation.UUID }} | The unique identifier of the conversation |
### Contact Variables ### Contact Variables
| Variable | Value | | Variable | Value |
|------------------------------|------------------------------------| |------------------------------|------------------------------------|
| {{ .Contact.FirstName }} | First name of the contact/customer | | {{ .Contact.FirstName }} | First name of the contact/customer |
@@ -25,7 +23,6 @@ If you want to customize the look of outgoing emails, you can do so in the Admin
| {{ .Contact.Email }} | Email address of the contact/customer | | {{ .Contact.Email }} | Email address of the contact/customer |
### Recipient Variables ### Recipient Variables
| Variable | Value | | Variable | Value |
|--------------------------------|-----------------------------------| |--------------------------------|-----------------------------------|
| {{ .Recipient.FirstName }} | First name of the recipient | | {{ .Recipient.FirstName }} | First name of the recipient |
@@ -33,28 +30,14 @@ If you want to customize the look of outgoing emails, you can do so in the Admin
| {{ .Recipient.FullName }} | Full name of the recipient | | {{ .Recipient.FullName }} | Full name of the recipient |
| {{ .Recipient.Email }} | Email address of the recipient | | {{ .Recipient.Email }} | Email address of the recipient |
### Author Variables
| Variable | Value |
|------------------------------|-----------------------------------|
| {{ .Author.FirstName }} | First name of the message author |
| {{ .Author.LastName }} | Last name of the message author |
| {{ .Author.FullName }} | Full name of the message author |
| {{ .Author.Email }} | Email address of the message author |
### Example outgoing email template ### Example outgoing email template
```html ```html
Dear {{ .Recipient.FirstName }}, Dear {{ .Recipient.FirstName }}
{{ template "content" . }} {{ template "content" . }}
Best regards, Best regards,
{{ .Author.FullName }}
---
Reference: {{ .Conversation.ReferenceNumber }}
``` ```
Here, the `{{ template "content" . }}` serves as a placeholder for the body of the outgoing email. It will be replaced with the actual email content at the time of sending. Here, the `{{ template "content" . }}` serves as a placeholder for the body of the outgoing email. It will be replaced with the actual email content at the time of sending.
Similarly, the `{{ .Recipient.FirstName }}` expression will dynamically insert the recipient's first name when the email is sent. Similarly, the `{{ .Recipient.FirstName }}` expression will dynamically insert the recipient's first name when the email is sent.

View File

@@ -1,11 +1,13 @@
site_name: LibreDesk Docs site_name: Libredesk Documentation
theme: theme:
name: material name: material
language: en language: en
font: font:
text: Source Sans Pro text: Source Sans Pro
code: Roboto Mono code: Roboto Mono
weights: [400, 700] weights:
- 400
- 700
direction: ltr direction: ltr
palette: palette:
primary: white primary: white
@@ -14,7 +16,7 @@ theme:
- navigation.indexes - navigation.indexes
- navigation.sections - navigation.sections
- content.code.copy - content.code.copy
extra: extra:
search: search:
language: en language: en
@@ -28,9 +30,9 @@ nav:
- Introduction: index.md - Introduction: index.md
- Getting Started: - Getting Started:
- Installation: installation.md - Installation: installation.md
- Upgrade Guide: upgrade.md - Upgrade: upgrade.md
- Email Templates: templating.md - Templating: templating.md
- SSO Setup: sso.md - SSO: sso.md
- Contributions: - Contributors:
- Developer Setup: developer-setup.md - Developer setup: developer-setup.md
- Translate Libredesk: translations.md - Translations: translations.md

View File

@@ -132,7 +132,7 @@ describe('Login Component', () => {
// Check if loading state is shown // Check if loading state is shown
cy.contains('Logging in...').should('be.visible') cy.contains('Logging in...').should('be.visible')
cy.get('.animate-spin').should('be.visible') cy.get('svg.animate-spin').should('be.visible')
// Wait for API call to finish // Wait for API call to finish
cy.wait('@slowLogin') cy.wait('@slowLogin')

View File

@@ -6,7 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap" <link
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap"
rel="stylesheet"> rel="stylesheet">
</head> </head>

View File

@@ -7,8 +7,6 @@
"dev": "pnpm exec vite", "dev": "pnpm exec vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'", "test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'",
"test:e2e:ci": "cypress run --e2e --headless", "test:e2e:ci": "cypress run --e2e --headless",
"test:e2e:dev": "start-server-and-test 'vite dev --port 4173' http://localhost:4173 'cypress open --e2e'", "test:e2e:dev": "start-server-and-test 'vite dev --port 4173' http://localhost:4173 'cypress open --e2e'",
@@ -35,7 +33,7 @@
"@tiptap/vue-3": "^2.4.0", "@tiptap/vue-3": "^2.4.0",
"@unovis/ts": "^1.4.4", "@unovis/ts": "^1.4.4",
"@unovis/vue": "^1.4.4", "@unovis/vue": "^1.4.4",
"@vee-validate/zod": "^4.15.0", "@vee-validate/zod": "^4.13.2",
"@vueuse/core": "^12.4.0", "@vueuse/core": "^12.4.0",
"axios": "^1.8.2", "axios": "^1.8.2",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
@@ -49,7 +47,7 @@
"radix-vue": "^1.9.17", "radix-vue": "^1.9.17",
"reka-ui": "^2.2.0", "reka-ui": "^2.2.0",
"tailwind-merge": "^2.3.0", "tailwind-merge": "^2.3.0",
"vee-validate": "^4.15.0", "vee-validate": "^4.13.2",
"vue": "^3.4.37", "vue": "^3.4.37",
"vue-dompurify-html": "^5.2.0", "vue-dompurify-html": "^5.2.0",
"vue-i18n": "9", "vue-i18n": "9",
@@ -59,7 +57,7 @@
"vue-sonner": "^1.3.0", "vue-sonner": "^1.3.0",
"vue3-emoji-picker": "^1.1.8", "vue3-emoji-picker": "^1.1.8",
"vuedraggable": "^4.1.0", "vuedraggable": "^4.1.0",
"zod": "^3.24.1" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.3.3", "@rushstack/eslint-patch": "^1.3.3",
@@ -76,8 +74,7 @@
"start-server-and-test": "^2.0.3", "start-server-and-test": "^2.0.3",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vite": "^5.4.19", "vite": "^5.4.18"
"vitest": "^3.2.2"
}, },
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a" "packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"
} }

529
frontend/pnpm-lock.yaml generated
View File

@@ -60,7 +60,7 @@ importers:
specifier: ^1.4.4 specifier: ^1.4.4
version: 1.5.0(@unovis/ts@1.5.0)(vue@3.5.13(typescript@5.7.3)) version: 1.5.0(@unovis/ts@1.5.0)(vue@3.5.13(typescript@5.7.3))
'@vee-validate/zod': '@vee-validate/zod':
specifier: ^4.15.0 specifier: ^4.13.2
version: 4.15.0(vue@3.5.13(typescript@5.7.3))(zod@3.24.1) version: 4.15.0(vue@3.5.13(typescript@5.7.3))(zod@3.24.1)
'@vueuse/core': '@vueuse/core':
specifier: ^12.4.0 specifier: ^12.4.0
@@ -102,7 +102,7 @@ importers:
specifier: ^2.3.0 specifier: ^2.3.0
version: 2.6.0 version: 2.6.0
vee-validate: vee-validate:
specifier: ^4.15.0 specifier: ^4.13.2
version: 4.15.0(vue@3.5.13(typescript@5.7.3)) version: 4.15.0(vue@3.5.13(typescript@5.7.3))
vue: vue:
specifier: ^3.4.37 specifier: ^3.4.37
@@ -132,7 +132,7 @@ importers:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0(vue@3.5.13(typescript@5.7.3)) version: 4.1.0(vue@3.5.13(typescript@5.7.3))
zod: zod:
specifier: ^3.24.1 specifier: ^3.23.8
version: 3.24.1 version: 3.24.1
devDependencies: devDependencies:
'@rushstack/eslint-patch': '@rushstack/eslint-patch':
@@ -140,7 +140,7 @@ importers:
version: 1.10.5 version: 1.10.5
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: ^5.0.3 specifier: ^5.0.3
version: 5.2.1(vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3)) version: 5.2.1(vite@5.4.18(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))
'@vue/eslint-config-prettier': '@vue/eslint-config-prettier':
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.0.0(eslint@8.57.1)(prettier@3.4.2) version: 8.0.0(eslint@8.57.1)(prettier@3.4.2)
@@ -178,11 +178,8 @@ importers:
specifier: ^1.0.7 specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.17) version: 1.0.7(tailwindcss@3.4.17)
vite: vite:
specifier: ^5.4.19 specifier: ^5.4.18
version: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0) version: 5.4.18(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
vitest:
specifier: ^3.2.2
version: 3.2.2(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
packages: packages:
@@ -648,103 +645,103 @@ packages:
'@remirror/core-constants@3.0.0': '@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
'@rollup/rollup-android-arm-eabi@4.41.1': '@rollup/rollup-android-arm-eabi@4.40.1':
resolution: {integrity: sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw==} resolution: {integrity: sha512-kxz0YeeCrRUHz3zyqvd7n+TVRlNyTifBsmnmNPtk3hQURUyG9eAB+usz6DAwagMusjx/zb3AjvDUvhFGDAexGw==}
cpu: [arm] cpu: [arm]
os: [android] os: [android]
'@rollup/rollup-android-arm64@4.41.1': '@rollup/rollup-android-arm64@4.40.1':
resolution: {integrity: sha512-DXdQe1BJ6TK47ukAoZLehRHhfKnKg9BjnQYUu9gzhI8Mwa1d2fzxA1aw2JixHVl403bwp1+/o/NhhHtxWJBgEA==} resolution: {integrity: sha512-PPkxTOisoNC6TpnDKatjKkjRMsdaWIhyuMkA4UsBXT9WEZY4uHezBTjs6Vl4PbqQQeu6oION1w2voYZv9yquCw==}
cpu: [arm64] cpu: [arm64]
os: [android] os: [android]
'@rollup/rollup-darwin-arm64@4.41.1': '@rollup/rollup-darwin-arm64@4.40.1':
resolution: {integrity: sha512-5afxvwszzdulsU2w8JKWwY8/sJOLPzf0e1bFuvcW5h9zsEg+RQAojdW0ux2zyYAz7R8HvvzKCjLNJhVq965U7w==} resolution: {integrity: sha512-VWXGISWFY18v/0JyNUy4A46KCFCb9NVsH+1100XP31lud+TzlezBbz24CYzbnA4x6w4hx+NYCXDfnvDVO6lcAA==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@rollup/rollup-darwin-x64@4.41.1': '@rollup/rollup-darwin-x64@4.40.1':
resolution: {integrity: sha512-egpJACny8QOdHNNMZKf8xY0Is6gIMz+tuqXlusxquWu3F833DcMwmGM7WlvCO9sB3OsPjdC4U0wHw5FabzCGZg==} resolution: {integrity: sha512-nIwkXafAI1/QCS7pxSpv/ZtFW6TXcNUEHAIA9EIyw5OzxJZQ1YDrX+CL6JAIQgZ33CInl1R6mHet9Y/UZTg2Bw==}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@rollup/rollup-freebsd-arm64@4.41.1': '@rollup/rollup-freebsd-arm64@4.40.1':
resolution: {integrity: sha512-DBVMZH5vbjgRk3r0OzgjS38z+atlupJ7xfKIDJdZZL6sM6wjfDNo64aowcLPKIx7LMQi8vybB56uh1Ftck/Atg==} resolution: {integrity: sha512-BdrLJ2mHTrIYdaS2I99mriyJfGGenSaP+UwGi1kB9BLOCu9SR8ZpbkmmalKIALnRw24kM7qCN0IOm6L0S44iWw==}
cpu: [arm64] cpu: [arm64]
os: [freebsd] os: [freebsd]
'@rollup/rollup-freebsd-x64@4.41.1': '@rollup/rollup-freebsd-x64@4.40.1':
resolution: {integrity: sha512-3FkydeohozEskBxNWEIbPfOE0aqQgB6ttTkJ159uWOFn42VLyfAiyD9UK5mhu+ItWzft60DycIN1Xdgiy8o/SA==} resolution: {integrity: sha512-VXeo/puqvCG8JBPNZXZf5Dqq7BzElNJzHRRw3vjBE27WujdzuOPecDPc/+1DcdcTptNBep3861jNq0mYkT8Z6Q==}
cpu: [x64] cpu: [x64]
os: [freebsd] os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.41.1': '@rollup/rollup-linux-arm-gnueabihf@4.40.1':
resolution: {integrity: sha512-wC53ZNDgt0pqx5xCAgNunkTzFE8GTgdZ9EwYGVcg+jEjJdZGtq9xPjDnFgfFozQI/Xm1mh+D9YlYtl+ueswNEg==} resolution: {integrity: sha512-ehSKrewwsESPt1TgSE/na9nIhWCosfGSFqv7vwEtjyAqZcvbGIg4JAcV7ZEh2tfj/IlfBeZjgOXm35iOOjadcg==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@rollup/rollup-linux-arm-musleabihf@4.41.1': '@rollup/rollup-linux-arm-musleabihf@4.40.1':
resolution: {integrity: sha512-jwKCca1gbZkZLhLRtsrka5N8sFAaxrGz/7wRJ8Wwvq3jug7toO21vWlViihG85ei7uJTpzbXZRcORotE+xyrLA==} resolution: {integrity: sha512-m39iO/aaurh5FVIu/F4/Zsl8xppd76S4qoID8E+dSRQvTyZTOI2gVk3T4oqzfq1PtcvOfAVlwLMK3KRQMaR8lg==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@rollup/rollup-linux-arm64-gnu@4.41.1': '@rollup/rollup-linux-arm64-gnu@4.40.1':
resolution: {integrity: sha512-g0UBcNknsmmNQ8V2d/zD2P7WWfJKU0F1nu0k5pW4rvdb+BIqMm8ToluW/eeRmxCared5dD76lS04uL4UaNgpNA==} resolution: {integrity: sha512-Y+GHnGaku4aVLSgrT0uWe2o2Rq8te9hi+MwqGF9r9ORgXhmHK5Q71N757u0F8yU1OIwUIFy6YiJtKjtyktk5hg==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@rollup/rollup-linux-arm64-musl@4.41.1': '@rollup/rollup-linux-arm64-musl@4.40.1':
resolution: {integrity: sha512-XZpeGB5TKEZWzIrj7sXr+BEaSgo/ma/kCgrZgL0oo5qdB1JlTzIYQKel/RmhT6vMAvOdM2teYlAaOGJpJ9lahg==} resolution: {integrity: sha512-jEwjn3jCA+tQGswK3aEWcD09/7M5wGwc6+flhva7dsQNRZZTe30vkalgIzV4tjkopsTS9Jd7Y1Bsj6a4lzz8gQ==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@rollup/rollup-linux-loongarch64-gnu@4.41.1': '@rollup/rollup-linux-loongarch64-gnu@4.40.1':
resolution: {integrity: sha512-bkCfDJ4qzWfFRCNt5RVV4DOw6KEgFTUZi2r2RuYhGWC8WhCA8lCAJhDeAmrM/fdiAH54m0mA0Vk2FGRPyzI+tw==} resolution: {integrity: sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
'@rollup/rollup-linux-powerpc64le-gnu@4.41.1': '@rollup/rollup-linux-powerpc64le-gnu@4.40.1':
resolution: {integrity: sha512-3mr3Xm+gvMX+/8EKogIZSIEF0WUu0HL9di+YWlJpO8CQBnoLAEL/roTCxuLncEdgcfJcvA4UMOf+2dnjl4Ut1A==} resolution: {integrity: sha512-BvvA64QxZlh7WZWqDPPdt0GH4bznuL6uOO1pmgPnnv86rpUpc8ZxgZwcEgXvo02GRIZX1hQ0j0pAnhwkhwPqWg==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
'@rollup/rollup-linux-riscv64-gnu@4.41.1': '@rollup/rollup-linux-riscv64-gnu@4.40.1':
resolution: {integrity: sha512-3rwCIh6MQ1LGrvKJitQjZFuQnT2wxfU+ivhNBzmxXTXPllewOF7JR1s2vMX/tWtUYFgphygxjqMl76q4aMotGw==} resolution: {integrity: sha512-EQSP+8+1VuSulm9RKSMKitTav89fKbHymTf25n5+Yr6gAPZxYWpj3DzAsQqoaHAk9YX2lwEyAf9S4W8F4l3VBQ==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
'@rollup/rollup-linux-riscv64-musl@4.41.1': '@rollup/rollup-linux-riscv64-musl@4.40.1':
resolution: {integrity: sha512-LdIUOb3gvfmpkgFZuccNa2uYiqtgZAz3PTzjuM5bH3nvuy9ty6RGc/Q0+HDFrHrizJGVpjnTZ1yS5TNNjFlklw==} resolution: {integrity: sha512-n/vQ4xRZXKuIpqukkMXZt9RWdl+2zgGNx7Uda8NtmLJ06NL8jiHxUawbwC+hdSq1rrw/9CghCpEONor+l1e2gA==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
'@rollup/rollup-linux-s390x-gnu@4.41.1': '@rollup/rollup-linux-s390x-gnu@4.40.1':
resolution: {integrity: sha512-oIE6M8WC9ma6xYqjvPhzZYk6NbobIURvP/lEbh7FWplcMO6gn7MM2yHKA1eC/GvYwzNKK/1LYgqzdkZ8YFxR8g==} resolution: {integrity: sha512-h8d28xzYb98fMQKUz0w2fMc1XuGzLLjdyxVIbhbil4ELfk5/orZlSTpF/xdI9C8K0I8lCkq+1En2RJsawZekkg==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
'@rollup/rollup-linux-x64-gnu@4.41.1': '@rollup/rollup-linux-x64-gnu@4.40.1':
resolution: {integrity: sha512-cWBOvayNvA+SyeQMp79BHPK8ws6sHSsYnK5zDcsC3Hsxr1dgTABKjMnMslPq1DvZIp6uO7kIWhiGwaTdR4Og9A==} resolution: {integrity: sha512-XiK5z70PEFEFqcNj3/zRSz/qX4bp4QIraTy9QjwJAb/Z8GM7kVUsD0Uk8maIPeTyPCP03ChdI+VVmJriKYbRHQ==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@rollup/rollup-linux-x64-musl@4.41.1': '@rollup/rollup-linux-x64-musl@4.40.1':
resolution: {integrity: sha512-y5CbN44M+pUCdGDlZFzGGBSKCA4A/J2ZH4edTYSSxFg7ce1Xt3GtydbVKWLlzL+INfFIZAEg1ZV6hh9+QQf9YQ==} resolution: {integrity: sha512-2BRORitq5rQ4Da9blVovzNCMaUlyKrzMSvkVR0D4qPuOy/+pMCrh1d7o01RATwVy+6Fa1WBw+da7QPeLWU/1mQ==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@rollup/rollup-win32-arm64-msvc@4.41.1': '@rollup/rollup-win32-arm64-msvc@4.40.1':
resolution: {integrity: sha512-lZkCxIrjlJlMt1dLO/FbpZbzt6J/A8p4DnqzSa4PWqPEUUUnzXLeki/iyPLfV0BmHItlYgHUqJe+3KiyydmiNQ==} resolution: {integrity: sha512-b2bcNm9Kbde03H+q+Jjw9tSfhYkzrDUf2d5MAd1bOJuVplXvFhWz7tRtWvD8/ORZi7qSCy0idW6tf2HgxSXQSg==}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.41.1': '@rollup/rollup-win32-ia32-msvc@4.40.1':
resolution: {integrity: sha512-+psFT9+pIh2iuGsxFYYa/LhS5MFKmuivRsx9iPJWNSGbh2XVEjk90fmpUEjCnILPEPJnikAU6SFDiEUyOv90Pg==} resolution: {integrity: sha512-DfcogW8N7Zg7llVEfpqWMZcaErKfsj9VvmfSyRjCyo4BI3wPEfrzTtJkZG6gKP/Z92wFm6rz2aDO7/JfiR/whA==}
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
'@rollup/rollup-win32-x64-msvc@4.41.1': '@rollup/rollup-win32-x64-msvc@4.40.1':
resolution: {integrity: sha512-Wq2zpapRYLfi4aKxf2Xff0tN+7slj2d4R87WEzqw7ZLsVvO5zwYCIuEGSZYiK41+GlwUo1HiR+GdkLEJnCKTCw==} resolution: {integrity: sha512-ECyOuDeH3C1I8jH2MK1RtBJW+YPMvSfT0a5NN0nHfQYnDSJ6tUiZH3gzwVP5/Kfh/+Tt7tpWVF9LXNTnhTJ3kA==}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -962,9 +959,6 @@ packages:
'@tiptap/pm': ^2.7.0 '@tiptap/pm': ^2.7.0
vue: ^3.0.0 vue: ^3.0.0
'@types/chai@5.2.2':
resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==}
'@types/d3-array@3.2.1': '@types/d3-array@3.2.1':
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
@@ -1073,9 +1067,6 @@ packages:
'@types/dagre@0.7.52': '@types/dagre@0.7.52':
resolution: {integrity: sha512-XKJdy+OClLk3hketHi9Qg6gTfe1F3y+UFnHxKA2rn9Dw+oXa4Gb378Ztz9HlMgZKSxpPmn4BNVh9wgkpvrK1uw==} resolution: {integrity: sha512-XKJdy+OClLk3hketHi9Qg6gTfe1F3y+UFnHxKA2rn9Dw+oXa4Gb378Ztz9HlMgZKSxpPmn4BNVh9wgkpvrK1uw==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.7': '@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
@@ -1184,35 +1175,6 @@ packages:
vite: ^5.0.0 || ^6.0.0 vite: ^5.0.0 || ^6.0.0
vue: ^3.2.25 vue: ^3.2.25
'@vitest/expect@3.2.2':
resolution: {integrity: sha512-ipHw0z669vEMjzz3xQE8nJX1s0rQIb7oEl4jjl35qWTwm/KIHERIg/p/zORrjAaZKXfsv7IybcNGHwhOOAPMwQ==}
'@vitest/mocker@3.2.2':
resolution: {integrity: sha512-jKojcaRyIYpDEf+s7/dD3LJt53c0dPfp5zCPXz9H/kcGrSlovU/t1yEaNzM9oFME3dcd4ULwRI/x0Po1Zf+LTw==}
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@3.2.2':
resolution: {integrity: sha512-FY4o4U1UDhO9KMd2Wee5vumwcaHw7Vg4V7yR4Oq6uK34nhEJOmdRYrk3ClburPRUA09lXD/oXWZ8y/Sdma0aUQ==}
'@vitest/runner@3.2.2':
resolution: {integrity: sha512-GYcHcaS3ejGRZYed2GAkvsjBeXIEerDKdX3orQrBJqLRiea4NSS9qvn9Nxmuy1IwIB+EjFOaxXnX79l8HFaBwg==}
'@vitest/snapshot@3.2.2':
resolution: {integrity: sha512-aMEI2XFlR1aNECbBs5C5IZopfi5Lb8QJZGGpzS8ZUHML5La5wCbrbhLOVSME68qwpT05ROEEOAZPRXFpxZV2wA==}
'@vitest/spy@3.2.2':
resolution: {integrity: sha512-6Utxlx3o7pcTxvp0u8kUiXtRFScMrUg28KjB3R2hon7w4YqOFAEA9QwzPVVS1QNL3smo4xRNOpNZClRVfpMcYg==}
'@vitest/utils@3.2.2':
resolution: {integrity: sha512-qJYMllrWpF/OYfWHP32T31QCaLa3BAzT/n/8mNGhPdVcjY+JYazQFO1nsJvXU12Kp1xMpNY4AGuljPTNjQve6A==}
'@vue/compiler-core@3.5.13': '@vue/compiler-core@3.5.13':
resolution: {integrity: sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==} resolution: {integrity: sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==}
@@ -1358,10 +1320,6 @@ packages:
resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
astral-regex@2.0.0: astral-regex@2.0.0:
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1447,10 +1405,6 @@ packages:
buffer@5.7.1: buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
cachedir@2.4.0: cachedir@2.4.0:
resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==} resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -1481,18 +1435,10 @@ packages:
caseless@0.12.0: caseless@0.12.0:
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
chai@5.2.0:
resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==}
engines: {node: '>=12'}
chalk@4.1.2: chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
check-error@2.1.1:
resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==}
engines: {node: '>= 16'}
check-more-types@2.24.0: check-more-types@2.24.0:
resolution: {integrity: sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==} resolution: {integrity: sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -1791,23 +1737,10 @@ packages:
supports-color: supports-color:
optional: true optional: true
debug@4.4.1:
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
decode-uri-component@0.2.2: decode-uri-component@0.2.2:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'} engines: {node: '>=0.10'}
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
deep-is@0.1.4: deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -1889,9 +1822,6 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
es-object-atoms@1.0.0: es-object-atoms@1.0.0:
resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1985,9 +1915,6 @@ packages:
estree-walker@2.0.2: estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
esutils@2.0.3: esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2010,10 +1937,6 @@ packages:
resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==} resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==}
engines: {node: '>=4'} engines: {node: '>=4'}
expect-type@1.2.1:
resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==}
engines: {node: '>=12.0.0'}
extend@3.0.2: extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
@@ -2048,14 +1971,6 @@ packages:
fd-slicer@1.1.0: fd-slicer@1.1.0:
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
fdir@6.4.5:
resolution: {integrity: sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
figures@3.2.0: figures@3.2.0:
resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2464,9 +2379,6 @@ packages:
resolution: {integrity: sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==} resolution: {integrity: sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==}
engines: {node: '>=10'} engines: {node: '>=10'}
loupe@3.1.3:
resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
lru-cache@10.4.3: lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -2664,13 +2576,6 @@ packages:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'} engines: {node: '>=8'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pathval@2.0.0:
resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==}
engines: {node: '>= 14.16'}
pause-stream@0.0.11: pause-stream@0.0.11:
resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==}
@@ -2694,10 +2599,6 @@ packages:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
picomatch@4.0.2:
resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
engines: {node: '>=12'}
pify@2.3.0: pify@2.3.0:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2939,8 +2840,8 @@ packages:
robust-predicates@3.0.2: robust-predicates@3.0.2:
resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
rollup@4.41.1: rollup@4.40.1:
resolution: {integrity: sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw==} resolution: {integrity: sha512-C5VvvgCCyfyotVITIAv+4efVytl5F7wt+/I2i9q9GZcEXW9BP52YYOXC58igUi+LFZVHukErIIqQSWwv/M3WRw==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true hasBin: true
@@ -2999,9 +2900,6 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@3.0.7: signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@@ -3052,17 +2950,11 @@ packages:
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
hasBin: true hasBin: true
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
start-server-and-test@2.0.9: start-server-and-test@2.0.9:
resolution: {integrity: sha512-DDceIvc4wdpr+z3Aqkot2QMho8TcUBh5qH0wEHDpEexBTzlheOcmh53d3dExABY4J5C7qS2UbSXqRWLtxpbWIQ==} resolution: {integrity: sha512-DDceIvc4wdpr+z3Aqkot2QMho8TcUBh5qH0wEHDpEexBTzlheOcmh53d3dExABY4J5C7qS2UbSXqRWLtxpbWIQ==}
engines: {node: '>=16'} engines: {node: '>=16'}
hasBin: true hasBin: true
std-env@3.9.0:
resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==}
stream-combiner@0.0.4: stream-combiner@0.0.4:
resolution: {integrity: sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==} resolution: {integrity: sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==}
@@ -3164,31 +3056,9 @@ packages:
through@2.3.8: through@2.3.8:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
tinyglobby@0.2.14:
resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
engines: {node: '>=12.0.0'}
tinypool@1.1.0:
resolution: {integrity: sha512-7CotroY9a8DKsKprEy/a14aCCm8jYVmR7aFy4fpkZM8sdpNJbKkixuNjgM50yCmip2ezc8z4N7k3oe2+rfRJCQ==}
engines: {node: ^18.0.0 || >=20.0.0}
tinyqueue@2.0.3: tinyqueue@2.0.3:
resolution: {integrity: sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==} resolution: {integrity: sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==}
tinyrainbow@2.0.0:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'}
tinyspy@4.0.3:
resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==}
engines: {node: '>=14.0.0'}
tippy.js@6.3.7: tippy.js@6.3.7:
resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==}
@@ -3294,13 +3164,8 @@ packages:
resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==}
engines: {'0': node >=0.6.0} engines: {'0': node >=0.6.0}
vite-node@3.2.2: vite@5.4.18:
resolution: {integrity: sha512-Xj/jovjZvDXOq2FgLXu8NsY4uHUMWtzVmMC2LkCu9HWdr9Qu1Is5sanX3Z4jOFKdohfaWDnEJWp9pRP0vVpAcA==} resolution: {integrity: sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
vite@5.4.19:
resolution: {integrity: sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==}
engines: {node: ^18.0.0 || >=20.0.0} engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@@ -3330,34 +3195,6 @@ packages:
terser: terser:
optional: true optional: true
vitest@3.2.2:
resolution: {integrity: sha512-fyNn/Rp016Bt5qvY0OQvIUCwW2vnaEBLxP42PmKbNIoasSYjML+8xyeADOPvBe+Xfl/ubIw4og7Lt9jflRsCNw==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/debug': ^4.1.12
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
'@vitest/browser': 3.2.2
'@vitest/ui': 3.2.2
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@types/debug':
optional: true
'@types/node':
optional: true
'@vitest/browser':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
vt-pbf@3.1.3: vt-pbf@3.1.3:
resolution: {integrity: sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==} resolution: {integrity: sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==}
@@ -3439,11 +3276,6 @@ packages:
engines: {node: '>= 8'} engines: {node: '>= 8'}
hasBin: true hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
word-wrap@1.2.5: word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -3922,64 +3754,64 @@ snapshots:
'@remirror/core-constants@3.0.0': {} '@remirror/core-constants@3.0.0': {}
'@rollup/rollup-android-arm-eabi@4.41.1': '@rollup/rollup-android-arm-eabi@4.40.1':
optional: true optional: true
'@rollup/rollup-android-arm64@4.41.1': '@rollup/rollup-android-arm64@4.40.1':
optional: true optional: true
'@rollup/rollup-darwin-arm64@4.41.1': '@rollup/rollup-darwin-arm64@4.40.1':
optional: true optional: true
'@rollup/rollup-darwin-x64@4.41.1': '@rollup/rollup-darwin-x64@4.40.1':
optional: true optional: true
'@rollup/rollup-freebsd-arm64@4.41.1': '@rollup/rollup-freebsd-arm64@4.40.1':
optional: true optional: true
'@rollup/rollup-freebsd-x64@4.41.1': '@rollup/rollup-freebsd-x64@4.40.1':
optional: true optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.41.1': '@rollup/rollup-linux-arm-gnueabihf@4.40.1':
optional: true optional: true
'@rollup/rollup-linux-arm-musleabihf@4.41.1': '@rollup/rollup-linux-arm-musleabihf@4.40.1':
optional: true optional: true
'@rollup/rollup-linux-arm64-gnu@4.41.1': '@rollup/rollup-linux-arm64-gnu@4.40.1':
optional: true optional: true
'@rollup/rollup-linux-arm64-musl@4.41.1': '@rollup/rollup-linux-arm64-musl@4.40.1':
optional: true optional: true
'@rollup/rollup-linux-loongarch64-gnu@4.41.1': '@rollup/rollup-linux-loongarch64-gnu@4.40.1':
optional: true optional: true
'@rollup/rollup-linux-powerpc64le-gnu@4.41.1': '@rollup/rollup-linux-powerpc64le-gnu@4.40.1':
optional: true optional: true
'@rollup/rollup-linux-riscv64-gnu@4.41.1': '@rollup/rollup-linux-riscv64-gnu@4.40.1':
optional: true optional: true
'@rollup/rollup-linux-riscv64-musl@4.41.1': '@rollup/rollup-linux-riscv64-musl@4.40.1':
optional: true optional: true
'@rollup/rollup-linux-s390x-gnu@4.41.1': '@rollup/rollup-linux-s390x-gnu@4.40.1':
optional: true optional: true
'@rollup/rollup-linux-x64-gnu@4.41.1': '@rollup/rollup-linux-x64-gnu@4.40.1':
optional: true optional: true
'@rollup/rollup-linux-x64-musl@4.41.1': '@rollup/rollup-linux-x64-musl@4.40.1':
optional: true optional: true
'@rollup/rollup-win32-arm64-msvc@4.41.1': '@rollup/rollup-win32-arm64-msvc@4.40.1':
optional: true optional: true
'@rollup/rollup-win32-ia32-msvc@4.41.1': '@rollup/rollup-win32-ia32-msvc@4.40.1':
optional: true optional: true
'@rollup/rollup-win32-x64-msvc@4.41.1': '@rollup/rollup-win32-x64-msvc@4.40.1':
optional: true optional: true
'@rushstack/eslint-patch@1.10.5': {} '@rushstack/eslint-patch@1.10.5': {}
@@ -4207,10 +4039,6 @@ snapshots:
'@tiptap/pm': 2.11.2 '@tiptap/pm': 2.11.2
vue: 3.5.13(typescript@5.7.3) vue: 3.5.13(typescript@5.7.3)
'@types/chai@5.2.2':
dependencies:
'@types/deep-eql': 4.0.2
'@types/d3-array@3.2.1': {} '@types/d3-array@3.2.1': {}
'@types/d3-axis@3.0.6': '@types/d3-axis@3.0.6':
@@ -4342,8 +4170,6 @@ snapshots:
'@types/dagre@0.7.52': {} '@types/dagre@0.7.52': {}
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.7': {} '@types/estree@1.0.7': {}
'@types/geojson@7946.0.15': {} '@types/geojson@7946.0.15': {}
@@ -4492,52 +4318,11 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- vue - vue
'@vitejs/plugin-vue@5.2.1(vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))': '@vitejs/plugin-vue@5.2.1(vite@5.4.18(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))':
dependencies: dependencies:
vite: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0) vite: 5.4.18(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
vue: 3.5.13(typescript@5.7.3) vue: 3.5.13(typescript@5.7.3)
'@vitest/expect@3.2.2':
dependencies:
'@types/chai': 5.2.2
'@vitest/spy': 3.2.2
'@vitest/utils': 3.2.2
chai: 5.2.0
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.2(vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))':
dependencies:
'@vitest/spy': 3.2.2
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
vite: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
'@vitest/pretty-format@3.2.2':
dependencies:
tinyrainbow: 2.0.0
'@vitest/runner@3.2.2':
dependencies:
'@vitest/utils': 3.2.2
pathe: 2.0.3
'@vitest/snapshot@3.2.2':
dependencies:
'@vitest/pretty-format': 3.2.2
magic-string: 0.30.17
pathe: 2.0.3
'@vitest/spy@3.2.2':
dependencies:
tinyspy: 4.0.3
'@vitest/utils@3.2.2':
dependencies:
'@vitest/pretty-format': 3.2.2
loupe: 3.1.3
tinyrainbow: 2.0.0
'@vue/compiler-core@3.5.13': '@vue/compiler-core@3.5.13':
dependencies: dependencies:
'@babel/parser': 7.26.5 '@babel/parser': 7.26.5
@@ -4735,8 +4520,6 @@ snapshots:
assert-plus@1.0.0: {} assert-plus@1.0.0: {}
assertion-error@2.0.1: {}
astral-regex@2.0.0: {} astral-regex@2.0.0: {}
async@3.2.6: {} async@3.2.6: {}
@@ -4821,8 +4604,6 @@ snapshots:
base64-js: 1.5.1 base64-js: 1.5.1
ieee754: 1.2.1 ieee754: 1.2.1
cac@6.7.14: {}
cachedir@2.4.0: {} cachedir@2.4.0: {}
call-bind-apply-helpers@1.0.1: call-bind-apply-helpers@1.0.1:
@@ -4848,21 +4629,11 @@ snapshots:
caseless@0.12.0: {} caseless@0.12.0: {}
chai@5.2.0:
dependencies:
assertion-error: 2.0.1
check-error: 2.1.1
deep-eql: 5.0.2
loupe: 3.1.3
pathval: 2.0.0
chalk@4.1.2: chalk@4.1.2:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
supports-color: 7.2.0 supports-color: 7.2.0
check-error@2.1.1: {}
check-more-types@2.24.0: {} check-more-types@2.24.0: {}
chokidar@3.6.0: chokidar@3.6.0:
@@ -5217,15 +4988,9 @@ snapshots:
optionalDependencies: optionalDependencies:
supports-color: 8.1.1 supports-color: 8.1.1
debug@4.4.1:
dependencies:
ms: 2.1.3
decode-uri-component@0.2.2: decode-uri-component@0.2.2:
optional: true optional: true
deep-eql@5.0.2: {}
deep-is@0.1.4: {} deep-is@0.1.4: {}
defu@6.1.4: {} defu@6.1.4: {}
@@ -5295,8 +5060,6 @@ snapshots:
es-errors@1.3.0: {} es-errors@1.3.0: {}
es-module-lexer@1.7.0: {}
es-object-atoms@1.0.0: es-object-atoms@1.0.0:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -5444,10 +5207,6 @@ snapshots:
estree-walker@2.0.2: {} estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.7
esutils@2.0.3: {} esutils@2.0.3: {}
event-stream@3.3.4: event-stream@3.3.4:
@@ -5490,8 +5249,6 @@ snapshots:
dependencies: dependencies:
pify: 2.3.0 pify: 2.3.0
expect-type@1.2.1: {}
extend@3.0.2: {} extend@3.0.2: {}
extract-zip@2.0.1(supports-color@8.1.1): extract-zip@2.0.1(supports-color@8.1.1):
@@ -5530,10 +5287,6 @@ snapshots:
dependencies: dependencies:
pend: 1.2.0 pend: 1.2.0
fdir@6.4.5(picomatch@4.0.2):
optionalDependencies:
picomatch: 4.0.2
figures@3.2.0: figures@3.2.0:
dependencies: dependencies:
escape-string-regexp: 1.0.5 escape-string-regexp: 1.0.5
@@ -5916,8 +5669,6 @@ snapshots:
slice-ansi: 4.0.0 slice-ansi: 4.0.0
wrap-ansi: 6.2.0 wrap-ansi: 6.2.0
loupe@3.1.3: {}
lru-cache@10.4.3: {} lru-cache@10.4.3: {}
lucide-vue-next@0.378.0(vue@3.5.13(typescript@5.7.3)): lucide-vue-next@0.378.0(vue@3.5.13(typescript@5.7.3)):
@@ -6107,10 +5858,6 @@ snapshots:
path-type@4.0.0: {} path-type@4.0.0: {}
pathe@2.0.3: {}
pathval@2.0.0: {}
pause-stream@0.0.11: pause-stream@0.0.11:
dependencies: dependencies:
through: 2.3.8 through: 2.3.8
@@ -6130,8 +5877,6 @@ snapshots:
picomatch@2.3.1: {} picomatch@2.3.1: {}
picomatch@4.0.2: {}
pify@2.3.0: {} pify@2.3.0: {}
pinia@2.3.0(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3)): pinia@2.3.0(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3)):
@@ -6411,30 +6156,30 @@ snapshots:
robust-predicates@3.0.2: {} robust-predicates@3.0.2: {}
rollup@4.41.1: rollup@4.40.1:
dependencies: dependencies:
'@types/estree': 1.0.7 '@types/estree': 1.0.7
optionalDependencies: optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.41.1 '@rollup/rollup-android-arm-eabi': 4.40.1
'@rollup/rollup-android-arm64': 4.41.1 '@rollup/rollup-android-arm64': 4.40.1
'@rollup/rollup-darwin-arm64': 4.41.1 '@rollup/rollup-darwin-arm64': 4.40.1
'@rollup/rollup-darwin-x64': 4.41.1 '@rollup/rollup-darwin-x64': 4.40.1
'@rollup/rollup-freebsd-arm64': 4.41.1 '@rollup/rollup-freebsd-arm64': 4.40.1
'@rollup/rollup-freebsd-x64': 4.41.1 '@rollup/rollup-freebsd-x64': 4.40.1
'@rollup/rollup-linux-arm-gnueabihf': 4.41.1 '@rollup/rollup-linux-arm-gnueabihf': 4.40.1
'@rollup/rollup-linux-arm-musleabihf': 4.41.1 '@rollup/rollup-linux-arm-musleabihf': 4.40.1
'@rollup/rollup-linux-arm64-gnu': 4.41.1 '@rollup/rollup-linux-arm64-gnu': 4.40.1
'@rollup/rollup-linux-arm64-musl': 4.41.1 '@rollup/rollup-linux-arm64-musl': 4.40.1
'@rollup/rollup-linux-loongarch64-gnu': 4.41.1 '@rollup/rollup-linux-loongarch64-gnu': 4.40.1
'@rollup/rollup-linux-powerpc64le-gnu': 4.41.1 '@rollup/rollup-linux-powerpc64le-gnu': 4.40.1
'@rollup/rollup-linux-riscv64-gnu': 4.41.1 '@rollup/rollup-linux-riscv64-gnu': 4.40.1
'@rollup/rollup-linux-riscv64-musl': 4.41.1 '@rollup/rollup-linux-riscv64-musl': 4.40.1
'@rollup/rollup-linux-s390x-gnu': 4.41.1 '@rollup/rollup-linux-s390x-gnu': 4.40.1
'@rollup/rollup-linux-x64-gnu': 4.41.1 '@rollup/rollup-linux-x64-gnu': 4.40.1
'@rollup/rollup-linux-x64-musl': 4.41.1 '@rollup/rollup-linux-x64-musl': 4.40.1
'@rollup/rollup-win32-arm64-msvc': 4.41.1 '@rollup/rollup-win32-arm64-msvc': 4.40.1
'@rollup/rollup-win32-ia32-msvc': 4.41.1 '@rollup/rollup-win32-ia32-msvc': 4.40.1
'@rollup/rollup-win32-x64-msvc': 4.41.1 '@rollup/rollup-win32-x64-msvc': 4.40.1
fsevents: 2.3.3 fsevents: 2.3.3
rope-sequence@1.3.4: {} rope-sequence@1.3.4: {}
@@ -6500,8 +6245,6 @@ snapshots:
side-channel-map: 1.0.1 side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2 side-channel-weakmap: 1.0.2
siginfo@2.0.0: {}
signal-exit@3.0.7: {} signal-exit@3.0.7: {}
signal-exit@4.1.0: {} signal-exit@4.1.0: {}
@@ -6554,8 +6297,6 @@ snapshots:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
tweetnacl: 0.14.5 tweetnacl: 0.14.5
stackback@0.0.2: {}
start-server-and-test@2.0.9: start-server-and-test@2.0.9:
dependencies: dependencies:
arg: 5.0.2 arg: 5.0.2
@@ -6569,8 +6310,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
std-env@3.9.0: {}
stream-combiner@0.0.4: stream-combiner@0.0.4:
dependencies: dependencies:
duplexer: 0.1.2 duplexer: 0.1.2
@@ -6606,7 +6345,7 @@ snapshots:
stylus@0.57.0: stylus@0.57.0:
dependencies: dependencies:
css: 3.0.0 css: 3.0.0
debug: 4.4.1 debug: 4.4.0(supports-color@8.1.1)
glob: 7.2.3 glob: 7.2.3
safer-buffer: 2.1.2 safer-buffer: 2.1.2
sax: 1.2.4 sax: 1.2.4
@@ -6699,23 +6438,8 @@ snapshots:
through@2.3.8: {} through@2.3.8: {}
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
tinyglobby@0.2.14:
dependencies:
fdir: 6.4.5(picomatch@4.0.2)
picomatch: 4.0.2
tinypool@1.1.0: {}
tinyqueue@2.0.3: {} tinyqueue@2.0.3: {}
tinyrainbow@2.0.0: {}
tinyspy@4.0.3: {}
tippy.js@6.3.7: tippy.js@6.3.7:
dependencies: dependencies:
'@popperjs/core': 2.11.8 '@popperjs/core': 2.11.8
@@ -6804,73 +6528,17 @@ snapshots:
core-util-is: 1.0.2 core-util-is: 1.0.2
extsprintf: 1.3.0 extsprintf: 1.3.0
vite-node@3.2.2(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0): vite@5.4.18(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0):
dependencies:
cac: 6.7.14
debug: 4.4.1
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0):
dependencies: dependencies:
esbuild: 0.21.5 esbuild: 0.21.5
postcss: 8.4.49 postcss: 8.4.49
rollup: 4.41.1 rollup: 4.40.1
optionalDependencies: optionalDependencies:
'@types/node': 22.10.5 '@types/node': 22.10.5
fsevents: 2.3.3 fsevents: 2.3.3
sass: 1.83.1 sass: 1.83.1
stylus: 0.57.0 stylus: 0.57.0
vitest@3.2.2(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0):
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.2
'@vitest/mocker': 3.2.2(vite@5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))
'@vitest/pretty-format': 3.2.2
'@vitest/runner': 3.2.2
'@vitest/snapshot': 3.2.2
'@vitest/spy': 3.2.2
'@vitest/utils': 3.2.2
chai: 5.2.0
debug: 4.4.1
expect-type: 1.2.1
magic-string: 0.30.17
pathe: 2.0.3
picomatch: 4.0.2
std-env: 3.9.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.14
tinypool: 1.1.0
tinyrainbow: 2.0.0
vite: 5.4.19(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
vite-node: 3.2.2(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.10.5
transitivePeerDependencies:
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
vt-pbf@3.1.3: vt-pbf@3.1.3:
dependencies: dependencies:
'@mapbox/point-geometry': 0.1.0 '@mapbox/point-geometry': 0.1.0
@@ -6966,11 +6634,6 @@ snapshots:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
word-wrap@1.2.5: {} word-wrap@1.2.5: {}
wrap-ansi@6.2.0: wrap-ansi@6.2.0:

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="flex w-full h-screen text-foreground"> <div class="flex w-full h-screen">
<!-- Icon sidebar always visible --> <!-- Icon sidebar always visible -->
<SidebarProvider style="--sidebar-width: 3rem" class="w-auto z-50"> <SidebarProvider style="--sidebar-width: 3rem" class="w-auto z-50">
<ShadcnSidebar collapsible="none" class="border-r"> <ShadcnSidebar collapsible="none" class="border-r">
@@ -8,64 +8,38 @@
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<Tooltip>
<TooltipTrigger as-child>
<SidebarMenuButton asChild :isActive="route.path.startsWith('/inboxes')"> <SidebarMenuButton asChild :isActive="route.path.startsWith('/inboxes')">
<router-link :to="{ name: 'inboxes' }"> <router-link :to="{ name: 'inboxes' }">
<Inbox /> <Inbox />
</router-link> </router-link>
</SidebarMenuButton> </SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{{ t('globals.terms.inbox', 2) }}</p>
</TooltipContent>
</Tooltip>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem v-if="userStore.can('contacts:read_all')"> <SidebarMenuItem>
<Tooltip> <SidebarMenuButton
<TooltipTrigger as-child> asChild
<SidebarMenuButton asChild :isActive="route.path.startsWith('/contacts')"> :isActive="route.path.startsWith('/contacts')"
v-if="userStore.can('contacts:read_all')"
>
<router-link :to="{ name: 'contacts' }"> <router-link :to="{ name: 'contacts' }">
<BookUser /> <BookUser />
</router-link> </router-link>
</SidebarMenuButton> </SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{{ t('globals.terms.contact', 2) }}</p>
</TooltipContent>
</Tooltip>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem v-if="userStore.hasReportTabPermissions"> <SidebarMenuItem v-if="userStore.hasReportTabPermissions">
<Tooltip>
<TooltipTrigger as-child>
<SidebarMenuButton asChild :isActive="route.path.startsWith('/reports')"> <SidebarMenuButton asChild :isActive="route.path.startsWith('/reports')">
<router-link :to="{ name: 'reports' }"> <router-link :to="{ name: 'reports' }">
<FileLineChart /> <FileLineChart />
</router-link> </router-link>
</SidebarMenuButton> </SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{{ t('globals.terms.report', 2) }}</p>
</TooltipContent>
</Tooltip>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem v-if="userStore.hasAdminTabPermissions"> <SidebarMenuItem v-if="userStore.hasAdminTabPermissions">
<Tooltip>
<TooltipTrigger as-child>
<SidebarMenuButton asChild :isActive="route.path.startsWith('/admin')"> <SidebarMenuButton asChild :isActive="route.path.startsWith('/admin')">
<router-link <router-link
:to="{ :to="{ name: userStore.can('general_settings:manage') ? 'general' : 'admin' }"
name: userStore.can('general_settings:manage') ? 'general' : 'admin'
}"
> >
<Shield /> <Shield />
</router-link> </router-link>
</SidebarMenuButton> </SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{{ t('globals.terms.admin') }}</p>
</TooltipContent>
</Tooltip>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
@@ -106,7 +80,7 @@
<Command /> <Command />
<!-- Create conversation dialog --> <!-- Create conversation dialog -->
<CreateConversation v-model="openCreateConversationDialog" v-if="openCreateConversationDialog" /> <CreateConversation v-model="openCreateConversationDialog" />
</template> </template>
<script setup> <script setup>
@@ -148,7 +122,6 @@ import {
SidebarMenuItem, SidebarMenuItem,
SidebarProvider SidebarProvider
} from '@/components/ui/sidebar' } from '@/components/ui/sidebar'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import SidebarNavUser from '@/components/sidebar/SidebarNavUser.vue' import SidebarNavUser from '@/components/sidebar/SidebarNavUser.vue'
const route = useRoute() const route = useRoute()

View File

@@ -1,7 +1,9 @@
<template> <template>
<TooltipProvider :delay-duration="150"> <TooltipProvider :delay-duration="150">
<div class="!font-jakarta">
<Toaster class="pointer-events-auto" position="top-center" richColors /> <Toaster class="pointer-events-auto" position="top-center" richColors />
<RouterView /> <RouterView />
</div>
</TooltipProvider> </TooltipProvider>
</template> </template>

View File

@@ -113,6 +113,7 @@ const createOIDC = (data) =>
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}) })
const testOIDC = (data) => http.post('/api/v1/oidc/test', data)
const getAllEnabledOIDC = () => http.get('/api/v1/oidc/enabled') const getAllEnabledOIDC = () => http.get('/api/v1/oidc/enabled')
const getAllOIDC = () => http.get('/api/v1/oidc') const getAllOIDC = () => http.get('/api/v1/oidc')
const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`) const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`)
@@ -230,11 +231,7 @@ const updateConversationCustomAttribute = (uuid, data) => http.put(`/api/v1/conv
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}) })
const createConversation = (data) => http.post('/api/v1/conversations', data, { const createConversation = (data) => http.post('/api/v1/conversations', data)
headers: {
'Content-Type': 'application/json'
}
})
const updateConversationStatus = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/status`, data) const updateConversationStatus = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/status`, data)
const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data) const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data)
const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`) const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`)
@@ -280,8 +277,7 @@ const uploadMedia = (data) =>
} }
}) })
const getOverviewCounts = () => http.get('/api/v1/reports/overview/counts') const getOverviewCounts = () => http.get('/api/v1/reports/overview/counts')
const getOverviewCharts = (params) => http.get('/api/v1/reports/overview/charts', { params }) const getOverviewCharts = () => http.get('/api/v1/reports/overview/charts')
const getOverviewSLA = (params) => http.get('/api/v1/reports/overview/sla', { params })
const getLanguage = (lang) => http.get(`/api/v1/lang/${lang}`) const getLanguage = (lang) => http.get(`/api/v1/lang/${lang}`)
const createInbox = (data) => const createInbox = (data) =>
http.post('/api/v1/inboxes', data, { http.post('/api/v1/inboxes', data, {
@@ -360,7 +356,6 @@ export default {
getViewConversations, getViewConversations,
getOverviewCharts, getOverviewCharts,
getOverviewCounts, getOverviewCounts,
getOverviewSLA,
getConversationParticipants, getConversationParticipants,
getConversationMessage, getConversationMessage,
getConversationMessages, getConversationMessages,
@@ -407,6 +402,7 @@ export default {
getAllEnabledOIDC, getAllEnabledOIDC,
getOIDC, getOIDC,
updateOIDC, updateOIDC,
testOIDC,
deleteOIDC, deleteOIDC,
getTemplate, getTemplate,
getTemplates, getTemplates,

View File

@@ -13,20 +13,12 @@
min-height: 100%; min-height: 100%;
overflow: hidden; overflow: hidden;
margin: 0; margin: 0;
@apply bg-background text-foreground;
}
@media (max-width: 768px) { @media (max-width: 768px) {
html,
body {
overflow-x: auto; overflow-x: auto;
} }
} }
* {
@apply border-border;
}
.native-html { .native-html {
p { p {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
@@ -69,39 +61,10 @@
} }
} }
} }
:root { }
--sidebar-background: 0 0% 100%;
--sidebar-foreground: 240 5.9% 10%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
: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);
}
// Theme.
@layer base {
:root { :root {
--background: 0 0% 100%; --background: 0 0% 100%;
--foreground: 240 10% 3.9%; --foreground: 240 10% 3.9%;
@@ -134,7 +97,7 @@
} }
.dark { .dark {
--background: 240 5.9% 10%; --background: 240 10% 3.9%;
--foreground: 0 0% 98%; --foreground: 0 0% 98%;
--card: 240 10% 3.9%; --card: 240 10% 3.9%;
@@ -164,8 +127,64 @@
} }
} }
@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;
}
.message-bubble { .message-bubble {
@apply flex flex-col px-4 pt-2 pb-3 w-fit min-w-[30%] max-w-full border overflow-x-auto rounded shadow-sm; @apply flex flex-col px-4 pt-2 pb-3 w-fit min-w-[30%] max-w-full border overflow-x-auto rounded-xl;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
table { table {
width: 100% !important; width: 100% !important;
table-layout: fixed !important; table-layout: fixed !important;
@@ -181,7 +200,7 @@
} }
.box { .box {
@apply border shadow rounded; @apply border shadow rounded-lg;
} }
// Scrollbar start // Scrollbar start
@@ -208,9 +227,84 @@
// End Scrollbar // End Scrollbar
.code-editor { .code-editor {
@apply rounded border shadow h-[65vh] min-h-[250px] w-full relative; @apply rounded-md border shadow h-[65vh] min-h-[250px] w-full relative;
} }
.ql-container {
margin: 0 !important;
}
.ql-container .ql-editor {
height: 300px !important;
border-radius: var(--radius) !important;
@apply rounded-lg rounded-t-none;
}
.ql-toolbar {
@apply rounded-t-lg;
}
.blinking-dot {
display: inline-block;
width: 8px;
height: 8px;
background-color: red;
border-radius: 50%;
animation: blink 2s infinite;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
// Sidebar start
@layer base {
:root {
--sidebar-background: 0 0% 96%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
a[data-active='true'] {
background-color: hsl(var(--sidebar-background)) !important;
color: hsl(var(--sidebar-accent-foreground)) !important;
font-weight: 500;
transition:
background-color 0.2s,
color 0.2s;
}
a[data-active='false']:hover {
background-color: hsl(var(--sidebar-accent)) !important;
color: hsl(var(--sidebar-accent-foreground)) !important;
font-weight: 500;
transition:
background-color 0.2s,
color 0.2s;
}
// Sidebar end
.show-quoted-text { .show-quoted-text {
blockquote { blockquote {
@apply block; @apply block;
@@ -223,6 +317,37 @@
} }
} }
.dot-loader {
display: inline-flex;
align-items: center;
}
.dot {
width: 4px;
height: 4px;
border-radius: 50%;
background-color: currentColor;
margin: 0 2px;
animation: dot-flashing 1s infinite linear alternate;
}
.dot:nth-child(2) {
animation-delay: 0.2s;
}
.dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes dot-flashing {
0% {
opacity: 0.2;
}
100% {
opacity: 1;
}
}
[data-radix-popper-content-wrapper] { [data-radix-popper-content-wrapper] {
z-index: 9999 !important; z-index: 9999 !important;
} }

View File

@@ -1,24 +0,0 @@
<template>
<Button
variant="ghost"
@click.prevent="onClose"
size="xs"
class="text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 w-6 h-6 p-0"
>
<slot>
<X size="16" />
</slot>
</Button>
</template>
<script setup>
import { Button } from '@/components/ui/button'
import { X } from 'lucide-vue-next'
defineProps({
onClose: {
type: Function,
required: true
}
})
</script>

View File

@@ -1,61 +0,0 @@
<template>
<ComboBox
:model-value="normalizedValue"
@update:model-value="$emit('update:modelValue', $event)"
:items="items"
:placeholder="placeholder"
>
<!-- Items -->
<template #item="{ item }">
<div class="flex items-center gap-2">
<!--USER -->
<Avatar v-if="type === 'user'" class="w-7 h-7">
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
<AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
</Avatar>
<!-- Others -->
<span v-else-if="item.emoji">{{ item.emoji }}</span>
<span>{{ item.label }}</span>
</div>
</template>
<!-- Selected -->
<template #selected="{ selected }">
<div class="flex items-center gap-2">
<div v-if="selected" class="flex items-center gap-2">
<!--USER -->
<Avatar v-if="type === 'user'" class="w-7 h-7">
<AvatarImage :src="selected.avatar_url || ''" :alt="selected.label.slice(0, 2)" />
<AvatarFallback>{{ selected.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
</Avatar>
<!-- Others -->
<span v-else-if="selected.emoji">{{ selected.emoji }}</span>
<span>{{ selected.label }}</span>
</div>
<span v-else>{{ placeholder }}</span>
</div>
</template>
</ComboBox>
</template>
<script setup>
import { computed } from 'vue'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
const props = defineProps({
modelValue: [String, Number, Object],
placeholder: String,
items: Array,
type: {
type: String
}
})
// Convert to str.
const normalizedValue = computed(() => String(props.modelValue || ''))
defineEmits(['update:modelValue'])
</script>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<div class="rounded border shadow"> <div class="rounded-md border shadow">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id"> <TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">

View File

@@ -11,12 +11,8 @@
<!-- Field --> <!-- Field -->
<div class="flex-1"> <div class="flex-1">
<Select v-model="modelFilter.field"> <Select v-model="modelFilter.field">
<SelectTrigger> <SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
<SelectValue <SelectValue :placeholder="t('form.field.selectField')" />
:placeholder="
t('globals.messages.select', { name: t('globals.terms.field').toLowerCase() })
"
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
@@ -31,12 +27,8 @@
<!-- Operator --> <!-- Operator -->
<div class="flex-1"> <div class="flex-1">
<Select v-model="modelFilter.operator" v-if="modelFilter.field"> <Select v-model="modelFilter.operator" v-if="modelFilter.field">
<SelectTrigger> <SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
<SelectValue <SelectValue :placeholder="t('form.field.selectOperator')" />
:placeholder="
t('globals.messages.select', { name: t('globals.terms.operator').toLowerCase() })
"
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
@@ -52,46 +44,79 @@
<div class="flex-1"> <div class="flex-1">
<div v-if="modelFilter.field && modelFilter.operator"> <div v-if="modelFilter.field && modelFilter.operator">
<template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'"> <template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
<SelectComboBox <ComboBox
v-if=" v-if="getFieldOptions(modelFilter).length > 0"
getFieldOptions(modelFilter).length > 0 &&
modelFilter.field === 'assigned_user_id'
"
v-model="modelFilter.value" v-model="modelFilter.value"
:items="getFieldOptions(modelFilter)" :items="getFieldOptions(modelFilter)"
:placeholder="t('globals.messages.select', { name: '' })" :placeholder="t('form.field.select')"
type="user" >
/> <template #item="{ item }">
<div v-if="modelFilter.field === 'assigned_user_id'">
<div class="flex items-center gap-1">
<Avatar class="w-6 h-6">
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
<AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }} </AvatarFallback>
</Avatar>
<span>{{ item.label }}</span>
</div>
</div>
<div v-else-if="modelFilter.field === 'assigned_team_id'">
<div class="flex items-center gap-2 ml-2">
<span>{{ item.emoji }}</span>
<span>{{ item.label }}</span>
</div>
</div>
<div v-else>
{{ item.label }}
</div>
</template>
<SelectComboBox <template #selected="{ selected }">
v-else-if=" <div v-if="!selected">{{ $t('form.field.selectValue') }}</div>
getFieldOptions(modelFilter).length > 0 && <div v-if="modelFilter.field === 'assigned_user_id'">
modelFilter.field === 'assigned_team_id' <div class="flex items-center gap-2">
" <div v-if="selected" class="flex items-center gap-1">
v-model="modelFilter.value" <Avatar class="w-6 h-6">
:items="getFieldOptions(modelFilter)" <AvatarImage
:placeholder="t('globals.messages.select', { name: '' })" :src="selected.avatar_url || ''"
type="team" :alt="selected.label.slice(0, 2)"
/> />
<AvatarFallback>{{
<SelectComboBox selected.label.slice(0, 2).toUpperCase()
v-else-if="getFieldOptions(modelFilter).length > 0" }}</AvatarFallback>
v-model="modelFilter.value" </Avatar>
:items="getFieldOptions(modelFilter)" <span>{{ selected.label }}</span>
:placeholder="t('globals.messages.select', { name: '' })" </div>
/> </div>
</div>
<div v-else-if="modelFilter.field === 'assigned_team_id'">
<div class="flex items-center gap-2">
<span v-if="selected">
{{ selected.emoji }}
<span>{{ selected.label }}</span>
</span>
</div>
</div>
<div v-else-if="selected">
{{ selected.label }}
</div>
</template>
</ComboBox>
<Input <Input
v-else v-else
v-model="modelFilter.value" v-model="modelFilter.value"
:placeholder="t('globals.terms.value')" class="bg-transparent hover:bg-slate-100"
:placeholder="t('form.field.value')"
type="text" type="text"
/> />
</template> </template>
</div> </div>
</div> </div>
</div> </div>
<CloseButton :onClose="() => removeFilter(index)" />
<button @click="removeFilter(index)" class="p-1 hover:bg-slate-100 rounded">
<X class="w-4 h-4 text-slate-500" />
</button>
</div> </div>
<div class="flex items-center justify-between pt-3"> <div class="flex items-center justify-between pt-3">
@@ -104,8 +129,8 @@
}} }}
</Button> </Button>
<div class="flex gap-2" v-if="showButtons"> <div class="flex gap-2" v-if="showButtons">
<Button variant="ghost" @click="clearFilters">{{ $t('globals.messages.reset') }}</Button> <Button variant="ghost" @click="clearFilters">{{ $t('globals.buttons.reset') }}</Button>
<Button @click="applyFilters">{{ $t('globals.messages.apply') }}</Button> <Button @click="applyFilters">{{ $t('globals.buttons.apply') }}</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -121,12 +146,12 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from '@/components/ui/select' } from '@/components/ui/select'
import { Plus } from 'lucide-vue-next' import { Plus, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import CloseButton from '@/components/button/CloseButton.vue' import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
const props = defineProps({ const props = defineProps({
fields: { fields: {

View File

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="flex flex-col p-4 border rounded shadow-sm hover:shadow transition-colors cursor-pointer max-w-xs" class="flex flex-col p-4 border rounded-lg shadow-sm hover:shadow transition-colors cursor-pointer max-w-xs"
@click="handleClick"> @click="handleClick">
<div class="flex items-center mb-2"> <div class="flex items-center mb-2">
<component :is="icon" size="24" class="mr-2 text-primary" /> <component :is="icon" size="24" class="mr-2 text-primary" />
@@ -11,7 +11,7 @@
</template> </template>
<script setup> <script setup>
import { defineEmits } from 'vue' import { defineProps, defineEmits } from 'vue'
const props = defineProps({ const props = defineProps({
title: String, title: String,

View File

@@ -1,8 +1,8 @@
<template> <template>
<div v-if="!isHidden"> <div v-if="!isHidden">
<div class="flex items-center space-x-4 h-12 px-2"> <div class="flex items-center space-x-4 h-12 px-2">
<SidebarTrigger class="cursor-pointer" /> <SidebarTrigger class="cursor-pointer w-4 h-4" />
<span class="text-xl font-semibold"> <span class="text-xl font-semibold text-gray-800">
{{ title }} {{ title }}
</span> </span>
</div> </div>

View File

@@ -14,6 +14,7 @@ import {
SidebarHeader, SidebarHeader,
SidebarInset, SidebarInset,
SidebarMenu, SidebarMenu,
SidebarSeparator,
SidebarMenuAction, SidebarMenuAction,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
@@ -27,10 +28,10 @@ import {
ChevronRight, ChevronRight,
EllipsisVertical, EllipsisVertical,
User, User,
UserSearch,
UsersRound,
Search, Search,
Plus, Plus
CircleDashed,
List
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { import {
DropdownMenu, DropdownMenu,
@@ -40,7 +41,7 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { filterNavItems } from '@/utils/nav-permissions' import { filterNavItems } from '@/utils/nav-permissions'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { computed, ref, watch } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
@@ -54,14 +55,6 @@ const route = useRoute()
const { t } = useI18n() const { t } = useI18n()
const emit = defineEmits(['createView', 'editView', 'deleteView', 'createConversation']) const emit = defineEmits(['createView', 'editView', 'deleteView', 'createConversation'])
const isActiveParent = (parentHref) => {
return route.path.startsWith(parentHref)
}
const isInboxRoute = (path) => {
return path.startsWith('/inboxes')
}
const openCreateViewDialog = () => { const openCreateViewDialog = () => {
emit('createView') emit('createView')
} }
@@ -78,27 +71,14 @@ const filteredAdminNavItems = computed(() => filterNavItems(adminNavItems, userS
const filteredReportsNavItems = computed(() => filterNavItems(reportsNavItems, userStore.can)) const filteredReportsNavItems = computed(() => filterNavItems(reportsNavItems, userStore.can))
const filteredContactsNavItems = computed(() => filterNavItems(contactNavItems, userStore.can)) const filteredContactsNavItems = computed(() => filterNavItems(contactNavItems, userStore.can))
// For auto opening admin collapsibles when a child route is active const isActiveParent = (parentHref) => {
const openAdminCollapsible = ref(null) return route.path.startsWith(parentHref)
const toggleAdminCollapsible = (titleKey) => { }
openAdminCollapsible.value = openAdminCollapsible.value === titleKey ? null : titleKey
const isInboxRoute = (path) => {
return path.startsWith('/inboxes')
} }
// Watch for route changes and update the active collapsible
watch(
[() => route.path, filteredAdminNavItems],
() => {
const activeItem = filteredAdminNavItems.value.find((item) => {
if (!item.children) return isActiveParent(item.href)
return item.children.some((child) => isActiveParent(child.href))
})
if (activeItem) {
openAdminCollapsible.value = activeItem.titleKey
}
},
{ immediate: true }
)
// Sidebar open state in local storage
const sidebarOpen = useStorage('mainSidebarOpen', true) const sidebarOpen = useStorage('mainSidebarOpen', true)
const teamInboxOpen = useStorage('teamInboxOpen', true) const teamInboxOpen = useStorage('teamInboxOpen', true)
const viewInboxOpen = useStorage('viewInboxOpen', true) const viewInboxOpen = useStorage('viewInboxOpen', true)
@@ -118,25 +98,24 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarHeader> <SidebarHeader>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<div class="px-1"> <SidebarMenuButton :isActive="isActiveParent('/contacts')" asChild>
<div>
<span class="font-semibold text-xl"> <span class="font-semibold text-xl">
{{ t('globals.terms.contact', 2) }} {{ t('globals.terms.contact', 2) }}
</span> </span>
</div> </div>
</SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarHeader> </SidebarHeader>
<SidebarSeparator />
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarGroup>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem v-for="item in filteredContactsNavItems" :key="item.titleKey"> <SidebarMenuItem v-for="item in filteredContactsNavItems" :key="item.titleKey">
<SidebarMenuButton :isActive="isActiveParent(item.href)" asChild> <SidebarMenuButton :isActive="isActiveParent(item.href)" asChild>
<router-link :to="item.href"> <router-link :to="item.href">
<span>{{ <span>{{ t(item.titleKey) }}</span>
t('globals.messages.all', {
name: t(item.titleKey, 2).toLowerCase()
})
}}</span>
</router-link> </router-link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
@@ -158,14 +137,17 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarHeader> <SidebarHeader>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<div class="px-1"> <SidebarMenuButton :isActive="isActiveParent('/reports/overview')" asChild>
<div>
<span class="font-semibold text-xl"> <span class="font-semibold text-xl">
{{ t('globals.terms.report', 2) }} {{ t('navigation.reports') }}
</span> </span>
</div> </div>
</SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarHeader> </SidebarHeader>
<SidebarSeparator />
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarGroup>
<SidebarMenu> <SidebarMenu>
@@ -189,18 +171,21 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarHeader> <SidebarHeader>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<div class="flex flex-col items-start justify-between w-full px-1"> <SidebarMenuButton :isActive="isActiveParent('/admin')" asChild>
<div class="flex items-center justify-between w-full">
<span class="font-semibold text-xl"> <span class="font-semibold text-xl">
{{ t('globals.terms.admin') }} {{ t('navigation.admin') }}
</span> </span>
</div>
<!-- App version --> <!-- App version -->
<div class="text-xs text-muted-foreground"> <div class="text-xs text-muted-foreground ml-2">
({{ settingsStore.settings['app.version'] }}) ({{ settingsStore.settings['app.version'] }})
</div> </div>
</div> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarHeader> </SidebarHeader>
<SidebarSeparator />
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarGroup>
<SidebarMenu> <SidebarMenu>
@@ -218,8 +203,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<Collapsible <Collapsible
v-else v-else
class="group/collapsible" class="group/collapsible"
:open="openAdminCollapsible === item.titleKey" :default-open="isActiveParent(item.href)"
@update:open="toggleAdminCollapsible(item.titleKey)"
> >
<CollapsibleTrigger as-child> <CollapsibleTrigger as-child>
<SidebarMenuButton :isActive="isActiveParent(item.href)"> <SidebarMenuButton :isActive="isActiveParent(item.href)">
@@ -255,14 +239,17 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarHeader> <SidebarHeader>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<div class="px-1"> <SidebarMenuButton :isActive="isActiveParent('/account/profile')" asChild>
<div>
<span class="font-semibold text-xl"> <span class="font-semibold text-xl">
{{ t('globals.terms.account') }} {{ t('navigation.account') }}
</span> </span>
</div> </div>
</SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarHeader> </SidebarHeader>
<SidebarSeparator />
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarGroup>
<SidebarMenu> <SidebarMenu>
@@ -289,20 +276,28 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarHeader> <SidebarHeader>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<div class="flex items-center justify-between w-full px-1"> <SidebarMenuButton asChild>
<div class="flex items-center justify-between w-full">
<div class="font-semibold text-xl"> <div class="font-semibold text-xl">
<span>{{ t('globals.terms.inbox') }}</span> <span>{{ t('navigation.inbox') }}</span>
</div> </div>
<div class="mr-1 mt-1 hover:scale-110 transition-transform"> <div class="ml-auto">
<div class="flex items-center space-x-2">
<router-link :to="{ name: 'search' }"> <router-link :to="{ name: 'search' }">
<Search size="18" stroke-width="2.5" /> <button
class="flex items-center bg-accent p-2 rounded-full hover:scale-110 transition-transform duration-100"
>
<Search size="15" stroke-width="2.5" />
</button>
</router-link> </router-link>
</div> </div>
</div> </div>
</div>
</SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarHeader> </SidebarHeader>
<SidebarSeparator />
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarGroup>
<SidebarMenu> <SidebarMenu>
@@ -324,7 +319,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/assigned')"> <SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/assigned')">
<router-link :to="{ name: 'inbox', params: { type: 'assigned' } }"> <router-link :to="{ name: 'inbox', params: { type: 'assigned' } }">
<User /> <User />
<span>{{ t('globals.terms.myInbox') }}</span> <span>{{ t('navigation.myInbox') }}</span>
</router-link> </router-link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
@@ -332,9 +327,9 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/unassigned')"> <SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/unassigned')">
<router-link :to="{ name: 'inbox', params: { type: 'unassigned' } }"> <router-link :to="{ name: 'inbox', params: { type: 'unassigned' } }">
<CircleDashed /> <UserSearch />
<span> <span>
{{ t('globals.terms.unassigned') }} {{ t('navigation.unassigned') }}
</span> </span>
</router-link> </router-link>
</SidebarMenuButton> </SidebarMenuButton>
@@ -343,9 +338,9 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/all')"> <SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/all')">
<router-link :to="{ name: 'inbox', params: { type: 'all' } }"> <router-link :to="{ name: 'inbox', params: { type: 'all' } }">
<List /> <UsersRound />
<span> <span>
{{ t('globals.messages.all') }} {{ t('navigation.all') }}
</span> </span>
</router-link> </router-link>
</SidebarMenuButton> </SidebarMenuButton>
@@ -364,7 +359,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<router-link to="#"> <router-link to="#">
<!-- <Users /> --> <!-- <Users /> -->
<span> <span>
{{ t('globals.terms.teamInbox', 2) }} {{ t('navigation.teamInboxes') }}
</span> </span>
<ChevronRight <ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
@@ -393,18 +388,18 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<!-- Views --> <!-- Views -->
<Collapsible class="group/collapsible" defaultOpen v-model:open="viewInboxOpen"> <Collapsible class="group/collapsible" defaultOpen v-model:open="viewInboxOpen">
<SidebarMenuItem> <SidebarMenuItem>
<CollapsibleTrigger asChild> <CollapsibleTrigger as-child>
<SidebarMenuButton asChild> <SidebarMenuButton asChild>
<router-link to="#" class="group/item !p-2"> <router-link to="#" class="group/item">
<!-- <SlidersHorizontal /> --> <!-- <SlidersHorizontal /> -->
<span> <span>
{{ t('globals.terms.view', 2) }} {{ t('navigation.views') }}
</span> </span>
<div> <div>
<Plus <Plus
size="18" size="18"
@click.stop="openCreateViewDialog" @click.stop="openCreateViewDialog"
class="rounded cursor-pointer opacity-0 transition-all duration-200 group-hover/item:opacity-100 hover:bg-gray-200 hover:shadow-sm text-gray-600 hover:text-gray-800 transform hover:scale-105 active:scale-100 p-1" class="rounded-lg cursor-pointer opacity-0 transition-all duration-200 group-hover/item:opacity-100 hover:bg-gray-200 hover:shadow-sm text-gray-600 hover:text-gray-800 transform hover:scale-105 active:scale-100 p-1"
/> />
</div> </div>
<ChevronRight <ChevronRight
@@ -432,10 +427,10 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem @click="() => editView(view)"> <DropdownMenuItem @click="() => editView(view)">
<span>{{ t('globals.messages.edit') }}</span> <span>{{ t('globals.buttons.edit') }}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem @click="() => deleteView(view)"> <DropdownMenuItem @click="() => deleteView(view)">
<span>{{ t('globals.messages.delete') }}</span> <span>{{ t('globals.buttons.delete') }}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -2,12 +2,12 @@
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>
<SidebarMenuButton <SidebarMenuButton
size="md" size="lg"
class="p-0" class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0"
> >
<Avatar class="h-8 w-8 rounded relative overflow-visible"> <Avatar class="h-8 w-8 rounded-lg relative overflow-visible">
<AvatarImage :src="userStore.avatar" alt="U" class="rounded" /> <AvatarImage :src="userStore.avatar" alt="" class="rounded-lg" />
<AvatarFallback class="rounded"> <AvatarFallback class="rounded-lg">
{{ userStore.getInitials }} {{ userStore.getInitials }}
</AvatarFallback> </AvatarFallback>
<div <div
@@ -30,73 +30,59 @@
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
class="w-[--radix-dropdown-menu-trigger-width] min-w-56" class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
side="bottom" side="bottom"
:side-offset="4" :side-offset="4"
> >
<DropdownMenuLabel class="font-normal space-y-2 px-2"> <DropdownMenuLabel class="p-0 font-normal space-y-1">
<!-- User header --> <div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<div class="flex items-center gap-2 py-1.5 text-left text-sm"> <Avatar class="h-8 w-8 rounded-lg">
<Avatar class="h-8 w-8 rounded">
<AvatarImage :src="userStore.avatar" alt="U" /> <AvatarImage :src="userStore.avatar" alt="U" />
<AvatarFallback class="rounded"> <AvatarFallback class="rounded-lg">
{{ userStore.getInitials }} {{ userStore.getInitials }}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div class="flex-1 flex flex-col leading-tight"> <div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ userStore.getFullName }}</span> <span class="truncate font-semibold">{{ userStore.getFullName }}</span>
<span class="truncate text-xs text-muted-foreground">{{ userStore.email }}</span> <span class="truncate text-xs">{{ userStore.email }}</span>
</div> </div>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<!-- Dark-mode toggle --> <!-- Away switch is checked with 'away_manual' or 'away_and_reassigning' -->
<div class="flex items-center justify-between text-sm"> <div class="flex items-center gap-2 px-1 text-left text-sm justify-between">
<div class="flex items-center gap-2">
<Moon v-if="mode === 'dark'" size="16" class="text-muted-foreground" />
<Sun v-else size="16" class="text-muted-foreground" />
<span class="text-muted-foreground">{{ t('navigation.darkMode') }}</span>
</div>
<Switch
:checked="mode === 'dark'"
@update:checked="(val) => (mode = val ? 'dark' : 'light')"
/>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 space-y-3">
<!-- Away toggle -->
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('navigation.away') }}</span> <span class="text-muted-foreground">{{ t('navigation.away') }}</span>
<Switch <Switch
:checked=" :checked="
['away_manual', 'away_and_reassigning'].includes( ['away_manual', 'away_and_reassigning'].includes(userStore.user.availability_status)
userStore.user.availability_status
)
" "
@update:checked=" @update:checked="
(val) => userStore.updateUserAvailability(val ? 'away_manual' : 'online') (val) => {
const newStatus = val ? 'away_manual' : 'online'
userStore.updateUserAvailability(newStatus)
}
" "
/> />
</div> </div>
<!-- Reassign toggle --> <!-- Reassign Replies Switch is checked with 'away_and_reassigning' -->
<div class="flex items-center justify-between text-sm"> <div class="flex items-center gap-2 px-1 text-left text-sm justify-between">
<span class="text-muted-foreground">{{ t('navigation.reassignReplies') }}</span> <span class="text-muted-foreground">{{ t('navigation.reassignReplies') }}</span>
<Switch <Switch
:checked="userStore.user.availability_status === 'away_and_reassigning'" :checked="userStore.user.availability_status === 'away_and_reassigning'"
@update:checked=" @update:checked="
(val) => (val) => {
userStore.updateUserAvailability(val ? 'away_and_reassigning' : 'away_manual') const newStatus = val ? 'away_and_reassigning' : 'away_manual'
userStore.updateUserAvailability(newStatus)
}
" "
/> />
</div> </div>
</div> </div>
</div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem @click.prevent="router.push({ name: 'account' })"> <DropdownMenuItem @click.prevent="router.push({ name: 'account' })">
<CircleUserRound size="18" class="mr-2" /> <CircleUserRound size="18" class="mr-2" />
{{ t('globals.terms.account') }} {{ t('navigation.account') }}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -122,13 +108,10 @@ import {
import { SidebarMenuButton } from '@/components/ui/sidebar' import { SidebarMenuButton } from '@/components/ui/sidebar'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { ChevronsUpDown, CircleUserRound, LogOut, Moon, Sun } from 'lucide-vue-next' import { ChevronsUpDown, CircleUserRound, LogOut } from 'lucide-vue-next'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useColorMode } from '@vueuse/core'
const mode = useColorMode()
const userStore = useUserStore() const userStore = useUserStore()
const router = useRouter() const router = useRouter()
const { t } = useI18n() const { t } = useI18n()

View File

@@ -1,41 +1,24 @@
<template> <template>
<table class="min-w-full table-fixed divide-y divide-border"> <table class="min-w-full table-fixed divide-y divide-gray-200">
<thead class="bg-muted"> <thead class="bg-gray-50">
<tr> <tr>
<th <th
v-for="(header, index) in headers" v-for="(header, index) in headers"
:key="index" :key="index"
scope="col" scope="col"
class="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
> >
{{ header }} {{ header }}
</th> </th>
<th v-if="showDelete" scope="col" class="relative px-6 py-3"></th> <th scope="col" class="relative px-6 py-3"></th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-background divide-y divide-border"> <tbody class="bg-white divide-y divide-gray-200">
<!-- Loading State --> <template v-if="data.length === 0">
<template v-if="loading">
<tr v-for="i in skeletonRows" :key="`skeleton-${i}`" class="hover:bg-accent">
<td
v-for="(header, index) in headers"
:key="`skeleton-cell-${index}`"
class="px-6 py-3 text-sm font-medium text-foreground whitespace-normal break-words"
>
<Skeleton class="h-4 w-[85%]" />
</td>
<td v-if="showDelete" class="px-6 py-4 text-sm text-muted-foreground">
<Skeleton class="h-8 w-8 rounded" />
</td>
</tr>
</template>
<!-- No Results State -->
<template v-else-if="data.length === 0">
<tr> <tr>
<td :colspan="headers.length + (showDelete ? 1 : 0)" class="px-6 py-12 text-center"> <td :colspan="headers.length + 1" class="px-6 py-12 text-center">
<div class="flex flex-col items-center space-y-4"> <div class="flex flex-col items-center space-y-4">
<span class="text-md text-muted-foreground"> <span class="text-md text-gray-500">
{{ {{
$t('globals.messages.noResults', { $t('globals.messages.noResults', {
name: $t('globals.terms.result', 2).toLowerCase() name: $t('globals.terms.result', 2).toLowerCase()
@@ -46,18 +29,16 @@
</td> </td>
</tr> </tr>
</template> </template>
<!-- Data Rows -->
<template v-else> <template v-else>
<tr v-for="(item, index) in data" :key="index" class="hover:bg-accent"> <tr v-for="(item, index) in data" :key="index">
<td <td
v-for="key in keys" v-for="key in keys"
:key="key" :key="key"
class="px-6 py-4 text-sm font-medium text-foreground whitespace-normal break-words" class="px-6 py-4 text-sm font-medium text-gray-900 whitespace-normal break-words"
> >
{{ item[key] }} {{ item[key] }}
</td> </td>
<td v-if="showDelete" class="px-6 py-4 text-sm text-muted-foreground"> <td class="px-6 py-4 text-sm text-gray-500" v-if="showDelete">
<Button size="xs" variant="ghost" @click.prevent="deleteItem(item)"> <Button size="xs" variant="ghost" @click.prevent="deleteItem(item)">
<Trash2 class="h-4 w-4" /> <Trash2 class="h-4 w-4" />
</Button> </Button>
@@ -70,9 +51,8 @@
<script setup> <script setup>
import { Trash2 } from 'lucide-vue-next' import { Trash2 } from 'lucide-vue-next'
import { defineEmits } from 'vue' import { defineProps, defineEmits } from 'vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
defineProps({ defineProps({
headers: { headers: {
@@ -93,14 +73,6 @@ defineProps({
showDelete: { showDelete: {
type: Boolean, type: Boolean,
default: true default: true
},
loading: {
type: Boolean,
default: false
},
skeletonRows: {
type: Number,
default: 5
} }
}) })

View File

@@ -14,7 +14,7 @@
<!-- Delete Icon --> <!-- Delete Icon -->
<X <X
class="absolute top-1 right-1 rounded-full p-1 shadow-md z-10 opacity-0 group-hover:opacity-100 transition-opacity" class="absolute top-1 right-1 bg-white rounded-full p-1 shadow-md z-10 opacity-0 group-hover:opacity-100 transition-opacity"
size="20" size="20"
@click.stop="emit('remove')" @click.stop="emit('remove')"
v-if="src" v-if="src"

View File

@@ -1,16 +1,25 @@
<script setup> <script setup>
import { Primitive } from 'reka-ui' import { Primitive } from 'radix-vue'
import { cn } from '@/lib/utils'
import { buttonVariants } from '.' import { buttonVariants } from '.'
import { Loader2 } from 'lucide-vue-next' import { cn } from '@/lib/utils'
import { ref, computed } from 'vue'
import { DotLoader } from '@/components/ui/loader'
const props = defineProps({ const props = defineProps({
variant: { type: null, required: false }, variant: { type: null, required: false },
size: { type: null, required: false }, size: { type: null, required: false },
class: { type: null, required: false }, class: { type: null, required: false },
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: null, required: false, default: 'button' }, as: { type: null, required: false, default: 'button' },
isLoading: { type: Boolean, required: false, default: false }, isLoading: { type: Boolean, required: false, default: false }
disabled: { type: Boolean, required: false, default: false } })
const isDisabled = ref(false)
const computedClass = computed(() => {
return cn(buttonVariants({ variant: props.variant, size: props.size }), props.class, {
'cursor-not-allowed opacity-50': props.isLoading || isDisabled.value
})
}) })
</script> </script>
@@ -18,22 +27,10 @@ const props = defineProps({
<Primitive <Primitive
:as="as" :as="as"
:as-child="asChild" :as-child="asChild"
:class=" :class="computedClass"
cn( :disabled="isLoading || isDisabled"
buttonVariants({ variant, size }),
'relative',
{ 'text-transparent': isLoading },
props.class
)
"
:disabled="isLoading || disabled"
> >
<slot /> <DotLoader v-if="isLoading" />
<span <slot v-else />
v-if="isLoading"
class="absolute inset-0 flex items-center justify-center pointer-events-none text-background"
>
<Loader2 class="h-5 w-5 animate-spin" />
</span>
</Primitive> </Primitive>
</template> </template>

View File

@@ -1,34 +1,31 @@
import { cva } from 'class-variance-authority'; import { cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'; export { default as Button } from './Button.vue'
export const buttonVariants = cva( export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', '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: { variants: {
variant: { variant: {
default: default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
'bg-primary text-primary-foreground shadow hover:bg-primary/90', destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground', ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline', link: 'text-primary underline-offset-4 hover:underline'
}, },
size: { size: {
default: 'h-9 px-4 py-2', default: 'h-9 px-4 py-2',
xs: 'h-7 rounded px-2', xs: 'h-7 rounded px-2',
sm: 'h-8 rounded-md px-3 text-xs', sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8', lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9', icon: 'h-9 w-9'
}, }
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: 'default',
size: 'default', size: 'default'
}, }
}, }
); )

View File

@@ -1,116 +0,0 @@
<template>
<div class="flex items-center gap-2">
<Select v-model="selectedDays" @update:model-value="handleFilterChange">
<SelectTrigger class="w-[140px] h-8 text-xs">
<SelectValue
:placeholder="
t('globals.messages.select', {
name: t('globals.terms.day', 2)
})
"
/>
</SelectTrigger>
<SelectContent class="text-xs">
<SelectItem value="0">{{ $t('globals.terms.today') }}</SelectItem>
<SelectItem value="1">
{{
$t('globals.messages.lastNItems', {
n: 1,
name: t('globals.terms.day', 1).toLowerCase()
})
}}
</SelectItem>
<SelectItem value="2">
{{
$t('globals.messages.lastNItems', {
n: 2,
name: t('globals.terms.day', 2).toLowerCase()
})
}}
</SelectItem>
<SelectItem value="7">
{{
$t('globals.messages.lastNItems', {
n: 7,
name: t('globals.terms.day', 2).toLowerCase()
})
}}
</SelectItem>
<SelectItem value="30">
{{
$t('globals.messages.lastNItems', {
n: 30,
name: t('globals.terms.day', 2).toLowerCase()
})
}}
</SelectItem>
<SelectItem value="90">
{{
$t('globals.messages.lastNItems', {
n: 90,
name: t('globals.terms.day', 2).toLowerCase()
})
}}
</SelectItem>
<SelectItem value="custom">
{{
$t('globals.messages.custom', {
name: t('globals.terms.day', 2).toLowerCase()
})
}}
</SelectItem>
</SelectContent>
</Select>
<div v-if="selectedDays === 'custom'" class="flex items-center gap-2">
<Input
v-model="customDaysInput"
type="number"
min="1"
max="365"
class="w-20 h-8"
@blur="handleCustomDaysChange"
@keyup.enter="handleCustomDaysChange"
/>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
const { t } = useI18n()
const emit = defineEmits(['filterChange'])
const selectedDays = ref('30')
const customDaysInput = ref('')
const handleFilterChange = (value) => {
if (value === 'custom') {
customDaysInput.value = '30'
emit('filterChange', 30)
} else {
emit('filterChange', parseInt(value))
}
}
const handleCustomDaysChange = () => {
const days = parseInt(customDaysInput.value)
if (days && days > 0 && days <= 365) {
emit('filterChange', days)
} else {
customDaysInput.value = '30'
emit('filterChange', 30)
}
}
handleFilterChange(selectedDays.value)
</script>

View File

@@ -1 +0,0 @@
export { default as DateFilter } from './DateFilter.vue'

View File

@@ -1,19 +1,19 @@
<script setup> <script setup>
import { useVModel } from '@vueuse/core'; import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps({
defaultValue: { type: [String, Number], required: false }, defaultValue: { type: [String, Number], required: false },
modelValue: { type: [String, Number], required: false }, modelValue: { type: [String, Number], required: false },
class: { type: null, required: false }, class: { type: null, required: false }
}); })
const emits = defineEmits(['update:modelValue']); const emits = defineEmits(['update:modelValue'])
const modelValue = useVModel(props, 'modelValue', emits, { const modelValue = useVModel(props, 'modelValue', emits, {
passive: true, passive: true,
defaultValue: props.defaultValue, defaultValue: props.defaultValue
}); })
</script> </script>
<template> <template>
@@ -22,7 +22,7 @@ const modelValue = useVModel(props, 'modelValue', emits, {
:class=" :class="
cn( cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', 'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
props.class, props.class
) )
" "
/> />

View File

@@ -1 +1 @@
export { default as Input } from './Input.vue'; export { default as Input } from './Input.vue'

View File

@@ -1,11 +1,7 @@
<template> <template>
<span class="inline-flex items-center"> <span class="dot-loader">
<span class="w-1 h-1 rounded-full bg-current mx-0.5 animate-dot-flashing"></span> <span class="dot"></span>
<span <span class="dot"></span>
class="w-1 h-1 rounded-full bg-current mx-0.5 animate-dot-flashing [animation-delay:0.2s]" <span class="dot"></span>
></span>
<span
class="w-1 h-1 rounded-full bg-current mx-0.5 animate-dot-flashing [animation-delay:0.4s]"
></span>
</span> </span>
</template> </template>

View File

@@ -3,7 +3,7 @@
<!-- Tags visible to the user --> <!-- Tags visible to the user -->
<div class="flex gap-2 flex-wrap items-center px-3"> <div class="flex gap-2 flex-wrap items-center px-3">
<TagsInputItem v-for="tagValue in tags" :key="tagValue" :value="tagValue"> <TagsInputItem v-for="tagValue in tags" :key="tagValue" :value="tagValue">
<TagsInputItemText /> <TagsInputItemText/>
<TagsInputItemDelete /> <TagsInputItemDelete />
</TagsInputItem> </TagsInputItem>
</div> </div>
@@ -23,7 +23,6 @@
:class="tags.length > 0 ? 'mt-2' : ''" :class="tags.length > 0 ? 'mt-2' : ''"
@keydown.enter.prevent @keydown.enter.prevent
@blur="handleBlur" @blur="handleBlur"
@click="open = true"
/> />
</ComboboxInput> </ComboboxInput>
</ComboboxAnchor> </ComboboxAnchor>
@@ -100,13 +99,10 @@ const open = ref(false)
const searchTerm = ref('') const searchTerm = ref('')
// Get all options that are not already selected and match the search term // Get all options that are not already selected and match the search term
// If not search term is provided, return all available options
const filteredOptions = computed(() => { const filteredOptions = computed(() => {
const available = props.items.filter((item) => !tags.value.includes(item.value)) return props.items.filter(
(item) =>
if (!searchTerm.value) return available !tags.value.includes(item.value) &&
return available.filter((item) =>
item.label.toLowerCase().includes(searchTerm.value.toLowerCase()) item.label.toLowerCase().includes(searchTerm.value.toLowerCase())
) )
}) })
@@ -131,8 +127,6 @@ const handleSelect = (event) => {
// Custom filter function to filter items based on the search term // Custom filter function to filter items based on the search term
const filterFunc = (remainingItemValues, term) => { const filterFunc = (remainingItemValues, term) => {
const remainingItems = props.items.filter((item) => remainingItemValues.includes(item.value)) const remainingItems = props.items.filter((item) => remainingItemValues.includes(item.value))
return remainingItems return remainingItems.filter((item) => item.label.toLowerCase().includes(term.toLowerCase())).map(item => item.value)
.filter((item) => item.label.toLowerCase().includes(term.toLowerCase()))
.map((item) => item.value)
} }
</script> </script>

View File

@@ -1,17 +1,21 @@
<script setup> <script setup>
import { reactiveOmit } from '@vueuse/core'; import { computed } from 'vue'
import { Separator } from 'reka-ui'; import { Separator } from 'radix-vue'
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps({
orientation: { type: String, required: false, default: 'horizontal' }, orientation: { type: String, required: false },
decorative: { type: Boolean, required: false, default: true }, decorative: { type: Boolean, required: false },
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: null, required: false }, as: { type: null, required: false },
class: { type: null, required: false }, class: { type: null, required: false }
}); })
const delegatedProps = reactiveOmit(props, 'class'); const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script> </script>
<template> <template>
@@ -20,8 +24,8 @@ const delegatedProps = reactiveOmit(props, 'class');
:class=" :class="
cn( cn(
'shrink-0 bg-border', 'shrink-0 bg-border',
props.orientation === 'horizontal' ? 'h-px w-full' : 'w-px h-full', props.orientation === 'vertical' ? 'w-px h-full' : 'h-px w-full',
props.class, props.class
) )
" "
/> />

View File

@@ -1 +1 @@
export { default as Separator } from './Separator.vue'; export { default as Separator } from './Separator.vue'

View File

@@ -1,14 +1,14 @@
<script setup> <script setup>
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'; import { DialogRoot, useForwardPropsEmits } from 'radix-vue'
const props = defineProps({ const props = defineProps({
open: { type: Boolean, required: false }, open: { type: Boolean, required: false },
defaultOpen: { type: Boolean, required: false }, defaultOpen: { type: Boolean, required: false },
modal: { type: Boolean, required: false }, modal: { type: Boolean, required: false }
}); })
const emits = defineEmits(['update:open']); const emits = defineEmits(['update:open'])
const forwarded = useForwardPropsEmits(props, emits); const forwarded = useForwardPropsEmits(props, emits)
</script> </script>
<template> <template>

View File

@@ -1,10 +1,10 @@
<script setup> <script setup>
import { DialogClose } from 'reka-ui'; import { DialogClose } from 'radix-vue'
const props = defineProps({ const props = defineProps({
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: null, required: false }, as: { type: null, required: false }
}); })
</script> </script>
<template> <template>

View File

@@ -1,19 +1,19 @@
<script setup> <script setup>
import { reactiveOmit } from '@vueuse/core'; import { computed } from 'vue'
import { Cross2Icon } from '@radix-icons/vue';
import { import {
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogOverlay, DialogOverlay,
DialogPortal, DialogPortal,
useForwardPropsEmits, useForwardPropsEmits
} from 'reka-ui'; } from 'radix-vue'
import { cn } from '@/lib/utils'; import { Cross2Icon } from '@radix-icons/vue'
import { sheetVariants } from '.'; import { sheetVariants } from '.'
import { cn } from '@/lib/utils'
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false
}); })
const props = defineProps({ const props = defineProps({
class: { type: null, required: false }, class: { type: null, required: false },
@@ -22,8 +22,8 @@ const props = defineProps({
trapFocus: { type: Boolean, required: false }, trapFocus: { type: Boolean, required: false },
disableOutsidePointerEvents: { type: Boolean, required: false }, disableOutsidePointerEvents: { type: Boolean, required: false },
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: null, required: false }, as: { type: null, required: false }
}); })
const emits = defineEmits([ const emits = defineEmits([
'escapeKeyDown', 'escapeKeyDown',
@@ -31,12 +31,16 @@ const emits = defineEmits([
'focusOutside', 'focusOutside',
'interactOutside', 'interactOutside',
'openAutoFocus', 'openAutoFocus',
'closeAutoFocus', 'closeAutoFocus'
]); ])
const delegatedProps = reactiveOmit(props, 'class', 'side'); const delegatedProps = computed(() => {
const { class: _, side, ...delegated } = props
const forwarded = useForwardPropsEmits(delegatedProps, emits); return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script> </script>
<template> <template>

View File

@@ -1,15 +1,19 @@
<script setup> <script setup>
import { reactiveOmit } from '@vueuse/core'; import { computed } from 'vue'
import { DialogDescription } from 'reka-ui'; import { DialogDescription } from 'radix-vue'
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps({
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: null, required: false }, as: { type: null, required: false },
class: { type: null, required: false }, class: { type: null, required: false }
}); })
const delegatedProps = reactiveOmit(props, 'class'); const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script> </script>
<template> <template>

View File

@@ -1,20 +1,13 @@
<script setup> <script setup>
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps({
class: { type: null, required: false }, class: { type: null, required: false }
}); })
</script> </script>
<template> <template>
<div <div :class="cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2', props.class)">
:class="
cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class,
)
"
>
<slot /> <slot />
</div> </div>
</template> </template>

View File

@@ -1,15 +1,13 @@
<script setup> <script setup>
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps({
class: { type: null, required: false }, class: { type: null, required: false }
}); })
</script> </script>
<template> <template>
<div <div :class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)">
:class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
>
<slot /> <slot />
</div> </div>
</template> </template>

View File

@@ -1,15 +1,19 @@
<script setup> <script setup>
import { reactiveOmit } from '@vueuse/core'; import { computed } from 'vue'
import { DialogTitle } from 'reka-ui'; import { DialogTitle } from 'radix-vue'
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps({
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: null, required: false }, as: { type: null, required: false },
class: { type: null, required: false }, class: { type: null, required: false }
}); })
const delegatedProps = reactiveOmit(props, 'class'); const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script> </script>
<template> <template>

View File

@@ -1,10 +1,10 @@
<script setup> <script setup>
import { DialogTrigger } from 'reka-ui'; import { DialogTrigger } from 'radix-vue'
const props = defineProps({ const props = defineProps({
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: null, required: false }, as: { type: null, required: false }
}); })
</script> </script>
<template> <template>

View File

@@ -1,13 +1,13 @@
import { cva } from 'class-variance-authority'; import { cva } from 'class-variance-authority'
export { default as Sheet } from './Sheet.vue'; export { default as Sheet } from './Sheet.vue'
export { default as SheetClose } from './SheetClose.vue'; export { default as SheetTrigger } from './SheetTrigger.vue'
export { default as SheetContent } from './SheetContent.vue'; export { default as SheetClose } from './SheetClose.vue'
export { default as SheetDescription } from './SheetDescription.vue'; export { default as SheetContent } from './SheetContent.vue'
export { default as SheetFooter } from './SheetFooter.vue'; export { default as SheetHeader } from './SheetHeader.vue'
export { default as SheetHeader } from './SheetHeader.vue'; export { default as SheetTitle } from './SheetTitle.vue'
export { default as SheetTitle } from './SheetTitle.vue'; export { default as SheetDescription } from './SheetDescription.vue'
export { default as SheetTrigger } from './SheetTrigger.vue'; export { default as SheetFooter } from './SheetFooter.vue'
export const sheetVariants = cva( export const sheetVariants = cva(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500', 'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
@@ -19,11 +19,11 @@ export const sheetVariants = cva(
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right: right:
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm', 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm'
}, }
}, },
defaultVariants: { defaultVariants: {
side: 'right', side: 'right'
}, }
}, }
); )

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import { cn } from '@/lib/utils';
import { Sheet, SheetContent } from '@/components/ui/sheet'; import { Sheet, SheetContent } from '@/components/ui/sheet';
import { cn } from '@/lib/utils';
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from './utils'; import { SIDEBAR_WIDTH_MOBILE, useSidebar } from './utils';
defineOptions({ defineOptions({
@@ -12,6 +12,7 @@ const props = defineProps({
variant: { type: String, required: false, default: 'sidebar' }, variant: { type: String, required: false, default: 'sidebar' },
collapsible: { type: String, required: false, default: 'offcanvas' }, collapsible: { type: String, required: false, default: 'offcanvas' },
class: { type: null, required: false }, class: { type: null, required: false },
collapseOnMobile: { type: Boolean, required: false, default: true },
}); });
const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
@@ -32,7 +33,7 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
</div> </div>
<Sheet <Sheet
v-else-if="isMobile" v-else-if="isMobile && collapseOnMobile"
:open="openMobile" :open="openMobile"
v-bind="$attrs" v-bind="$attrs"
@update:open="setOpenMobile" @update:open="setOpenMobile"
@@ -54,7 +55,7 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
<div <div
v-else v-else
class="group peer hidden md:block" :class="cn('group peer', collapseOnMobile ? 'hidden md:block' : 'block')"
:data-state="state" :data-state="state"
:data-collapsible="state === 'collapsed' ? collapsible : ''" :data-collapsible="state === 'collapsed' ? collapsible : ''"
:data-variant="variant" :data-variant="variant"
@@ -76,7 +77,8 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
<div <div
:class=" :class="
cn( cn(
'duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex', 'duration-200 fixed inset-y-0 z-10 h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex',
collapseOnMobile ? 'hidden' : '',
side === 'left' side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]' ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]', : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',

View File

@@ -1,12 +1,12 @@
<script setup> <script setup lang="ts">
import { Primitive } from 'reka-ui'; import type { PrimitiveProps } from 'radix-vue'
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Primitive } from 'radix-vue'
const props = defineProps({ const props = defineProps<PrimitiveProps & {
asChild: { type: Boolean, required: false }, class?: HTMLAttributes['class']
as: { type: null, required: false }, }>()
class: { type: null, required: false },
});
</script> </script>
<template> <template>
@@ -14,14 +14,12 @@ const props = defineProps({
data-sidebar="group-action" data-sidebar="group-action"
:as="as" :as="as"
:as-child="asChild" :as-child="asChild"
:class=" :class="cn(
cn(
'absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', 'absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'after:absolute after:-inset-2 after:md:hidden', 'after:absolute after:-inset-2 after:md:hidden',
'group-data-[collapsible=icon]:hidden', 'group-data-[collapsible=icon]:hidden',
props.class, props.class,
) )"
"
> >
<slot /> <slot />
</Primitive> </Primitive>

View File

@@ -1,13 +1,17 @@
<script setup> <script setup lang="ts">
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps<{
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
</script> </script>
<template> <template>
<div data-sidebar="group-content" :class="cn('w-full text-sm', props.class)"> <div
data-sidebar="group-content"
:class="cn('w-full text-sm', props.class)"
>
<slot /> <slot />
</div> </div>
</template> </template>

View File

@@ -1,12 +1,12 @@
<script setup> <script setup lang="ts">
import { Primitive } from 'reka-ui'; import type { PrimitiveProps } from 'radix-vue'
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Primitive } from 'radix-vue'
const props = defineProps({ const props = defineProps<PrimitiveProps & {
asChild: { type: Boolean, required: false }, class?: HTMLAttributes['class']
as: { type: null, required: false }, }>()
class: { type: null, required: false },
});
</script> </script>
<template> <template>
@@ -14,13 +14,10 @@ const props = defineProps({
data-sidebar="group-label" data-sidebar="group-label"
:as="as" :as="as"
:as-child="asChild" :as-child="asChild"
:class=" :class="cn(
cn(
'duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', 'duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0', 'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
props.class, props.class)"
)
"
> >
<slot /> <slot />
</Primitive> </Primitive>

View File

@@ -1,9 +1,10 @@
<script setup> <script setup lang="ts">
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps<{
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
</script> </script>
<template> <template>

View File

@@ -1,21 +1,20 @@
<script setup> <script setup lang="ts">
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps<{
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
</script> </script>
<template> <template>
<Input <Input
data-sidebar="input" data-sidebar="input"
:class=" :class="cn(
cn(
'h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring', 'h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring',
props.class, props.class,
) )"
"
> >
<slot /> <slot />
</Input> </Input>

View File

@@ -1,20 +1,19 @@
<script setup> <script setup lang="ts">
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps<{
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
</script> </script>
<template> <template>
<main <main
:class=" :class="cn(
cn(
'relative flex min-h-svh flex-1 flex-col bg-background', 'relative flex min-h-svh flex-1 flex-col bg-background',
'peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow', 'peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow',
props.class, props.class,
) )"
"
> >
<slot /> <slot />
</main> </main>

View File

@@ -1,9 +1,10 @@
<script setup> <script setup lang="ts">
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps<{
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
</script> </script>
<template> <template>

View File

@@ -1,31 +1,30 @@
<script setup> <script setup lang="ts">
import { Primitive } from 'reka-ui'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'
import { Primitive, type PrimitiveProps } from 'radix-vue'
const props = defineProps({ const props = withDefaults(defineProps<PrimitiveProps & {
asChild: { type: Boolean, required: false }, showOnHover?: boolean
as: { type: null, required: false, default: 'button' }, class?: HTMLAttributes['class']
showOnHover: { type: Boolean, required: false }, }>(), {
class: { type: null, required: false }, as: 'button',
}); })
</script> </script>
<template> <template>
<Primitive <Primitive
data-sidebar="menu-action" data-sidebar="menu-action"
:class=" :class="cn(
cn(
'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0', 'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0',
'after:absolute after:-inset-2 after:md:hidden', 'after:absolute after:-inset-2 after:md:hidden',
'peer-data-[size=sm]/menu-button:top-1', 'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5', 'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5', 'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden', 'group-data-[collapsible=icon]:hidden',
showOnHover && showOnHover
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0', && 'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0',
props.class, props.class,
) )"
"
:as="as" :as="as"
:as-child="asChild" :as-child="asChild"
> >

View File

@@ -1,16 +1,16 @@
<script setup> <script setup lang="ts">
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps<{
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
</script> </script>
<template> <template>
<div <div
data-sidebar="menu-badge" data-sidebar="menu-badge"
:class=" :class="cn(
cn(
'absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none', 'absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none',
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground', 'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
'peer-data-[size=sm]/menu-button:top-1', 'peer-data-[size=sm]/menu-button:top-1',
@@ -18,8 +18,7 @@ const props = defineProps({
'peer-data-[size=lg]/menu-button:top-2.5', 'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden', 'group-data-[collapsible=icon]:hidden',
props.class, props.class,
) )"
"
> >
<slot /> <slot />
</div> </div>

View File

@@ -1,40 +1,31 @@
<script setup> <script setup lang="ts">
import { computed } from 'vue'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { import { type Component, computed } from 'vue'
Tooltip, import SidebarMenuButtonChild, { type SidebarMenuButtonProps } from './SidebarMenuButtonChild.vue'
TooltipContent, import { useSidebar } from './utils'
TooltipTrigger,
} from '@/components/ui/tooltip';
import SidebarMenuButtonChild from './SidebarMenuButtonChild.vue';
import { useSidebar } from './utils';
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}); })
const props = defineProps({ const props = withDefaults(defineProps<SidebarMenuButtonProps & {
variant: { type: null, required: false, default: 'default' }, tooltip?: string | Component
size: { type: null, required: false, default: 'default' }, }>(), {
isActive: { type: Boolean, required: false }, as: 'button',
class: { type: null, required: false }, variant: 'default',
asChild: { type: Boolean, required: false }, size: 'default',
as: { type: null, required: false, default: 'button' }, })
tooltip: { type: null, required: false },
});
const { isMobile, state } = useSidebar(); const { isMobile, state } = useSidebar()
const delegatedProps = computed(() => { const delegatedProps = computed(() => {
const { tooltip, ...delegated } = props; const { tooltip, ...delegated } = props
return delegated; return delegated
}); })
</script> </script>
<template> <template>
<SidebarMenuButtonChild <SidebarMenuButtonChild v-if="!tooltip" v-bind="{ ...delegatedProps, ...$attrs }">
v-if="!tooltip"
v-bind="{ ...delegatedProps, ...$attrs }"
>
<slot /> <slot />
</SidebarMenuButtonChild> </SidebarMenuButtonChild>

View File

@@ -1,16 +1,21 @@
<script setup> <script setup lang="ts">
import { Primitive } from 'reka-ui'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'
import { sidebarMenuButtonVariants } from '.'; import { Primitive, type PrimitiveProps } from 'radix-vue'
import { type SidebarMenuButtonVariants, sidebarMenuButtonVariants } from '.'
const props = defineProps({ export interface SidebarMenuButtonProps extends PrimitiveProps {
variant: { type: null, required: false, default: 'default' }, variant?: SidebarMenuButtonVariants['variant']
size: { type: null, required: false, default: 'default' }, size?: SidebarMenuButtonVariants['size']
isActive: { type: Boolean, required: false }, isActive?: boolean
class: { type: null, required: false }, class?: HTMLAttributes['class']
asChild: { type: Boolean, required: false }, }
as: { type: null, required: false, default: 'button' },
}); const props = withDefaults(defineProps<SidebarMenuButtonProps>(), {
as: 'button',
variant: 'default',
size: 'default',
})
</script> </script>
<template> <template>

View File

@@ -1,9 +1,10 @@
<script setup> <script setup lang="ts">
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps<{
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
</script> </script>
<template> <template>

View File

@@ -1,16 +1,16 @@
<script setup> <script setup lang="ts">
import { computed } from 'vue'; import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'
import { Skeleton } from '@/components/ui/skeleton'; import { computed, type HTMLAttributes } from 'vue'
const props = defineProps({ const props = defineProps<{
showIcon: { type: Boolean, required: false }, showIcon?: boolean
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
const width = computed(() => { const width = computed(() => {
return `${Math.floor(Math.random() * 40) + 50}%`; return `${Math.floor(Math.random() * 40) + 50}%`
}); })
</script> </script>
<template> <template>

View File

@@ -1,21 +1,20 @@
<script setup> <script setup lang="ts">
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps<{
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
</script> </script>
<template> <template>
<ul <ul
data-sidebar="menu-badge" data-sidebar="menu-badge"
:class=" :class="cn(
cn(
'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5', 'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5',
'group-data-[collapsible=icon]:hidden', 'group-data-[collapsible=icon]:hidden',
props.class, props.class,
) )"
"
> >
<slot /> <slot />
</ul> </ul>

View File

@@ -1,14 +1,17 @@
<script setup> <script setup lang="ts">
import { Primitive } from 'reka-ui'; import type { PrimitiveProps } from 'radix-vue'
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Primitive } from 'radix-vue'
const props = defineProps({ const props = withDefaults(defineProps<PrimitiveProps & {
asChild: { type: Boolean, required: false }, size?: 'sm' | 'md'
as: { type: null, required: false, default: 'a' }, isActive?: boolean
size: { type: String, required: false, default: 'md' }, class?: HTMLAttributes['class']
isActive: { type: Boolean, required: false }, }>(), {
class: { type: null, required: false }, as: 'a',
}); size: 'md',
})
</script> </script>
<template> <template>
@@ -18,16 +21,14 @@ const props = defineProps({
:as-child="asChild" :as-child="asChild"
:data-size="size" :data-size="size"
:data-active="isActive" :data-active="isActive"
:class=" :class="cn(
cn(
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground', 'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground', 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
size === 'sm' && 'text-xs', size === 'sm' && 'text-xs',
size === 'md' && 'text-sm', size === 'md' && 'text-sm',
'group-data-[collapsible=icon]:hidden', 'group-data-[collapsible=icon]:hidden',
props.class, props.class,
) )"
"
> >
<slot /> <slot />
</Primitive> </Primitive>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"></script> <script setup lang="ts">
</script>
<template> <template>
<li> <li>

View File

@@ -1,64 +1,57 @@
<script setup> <script setup lang="ts">
import { useEventListener, useMediaQuery, useVModel } from '@vueuse/core'; import { cn } from '@/lib/utils'
import { TooltipProvider } from 'reka-ui'; import { useEventListener, useMediaQuery, useVModel } from '@vueuse/core'
import { computed, ref } from 'vue'; import { TooltipProvider } from 'radix-vue'
import { cn } from '@/lib/utils'; import { computed, type HTMLAttributes, type Ref, ref } from 'vue'
import { import { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from './utils'
provideSidebarContext,
SIDEBAR_COOKIE_MAX_AGE,
SIDEBAR_COOKIE_NAME,
SIDEBAR_KEYBOARD_SHORTCUT,
SIDEBAR_WIDTH,
SIDEBAR_WIDTH_ICON,
} from './utils';
const props = defineProps({ const props = withDefaults(defineProps<{
defaultOpen: { type: Boolean, required: false, default: true }, defaultOpen?: boolean
open: { type: Boolean, required: false, default: undefined }, open?: boolean
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>(), {
defaultOpen: true,
open: undefined,
})
const emits = defineEmits(['update:open']); const emits = defineEmits<{
'update:open': [open: boolean]
}>()
const isMobile = useMediaQuery('(max-width: 768px)'); const isMobile = useMediaQuery('(max-width: 768px)')
const openMobile = ref(false); const openMobile = ref(false)
const open = useVModel(props, 'open', emits, { const open = useVModel(props, 'open', emits, {
defaultValue: props.defaultOpen ?? false, defaultValue: props.defaultOpen ?? false,
passive: props.open === undefined, passive: (props.open === undefined) as false,
}); }) as Ref<boolean>
function setOpen(value) { function setOpen(value: boolean) {
open.value = value; // emits('update:open', value) open.value = value // emits('update:open', value)
// This sets the cookie to keep the sidebar state. // This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
} }
function setOpenMobile(value) { function setOpenMobile(value: boolean) {
openMobile.value = value; openMobile.value = value
} }
// Helper to toggle the sidebar. // Helper to toggle the sidebar.
function toggleSidebar() { function toggleSidebar() {
return isMobile.value return isMobile.value ? setOpenMobile(!openMobile.value) : setOpen(!open.value)
? setOpenMobile(!openMobile.value)
: setOpen(!open.value);
} }
useEventListener('keydown', (event) => { useEventListener('keydown', (event: KeyboardEvent) => {
if ( if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.key === SIDEBAR_KEYBOARD_SHORTCUT && event.preventDefault()
(event.metaKey || event.ctrlKey) toggleSidebar()
) {
event.preventDefault();
toggleSidebar();
} }
}); })
// We add a state so that we can do data-state="expanded" or "collapsed". // We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes. // This makes it easier to style the sidebar with Tailwind classes.
const state = computed(() => (open.value ? 'expanded' : 'collapsed')); const state = computed(() => open.value ? 'expanded' : 'collapsed')
provideSidebarContext({ provideSidebarContext({
state, state,
@@ -68,7 +61,7 @@ provideSidebarContext({
openMobile, openMobile,
setOpenMobile, setOpenMobile,
toggleSidebar, toggleSidebar,
}); })
</script> </script>
<template> <template>
@@ -78,12 +71,7 @@ provideSidebarContext({
'--sidebar-width': SIDEBAR_WIDTH, '--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON, '--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
}" }"
:class=" :class="cn('group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar', props.class)"
cn(
'group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar',
props.class,
)
"
v-bind="$attrs" v-bind="$attrs"
> >
<slot /> <slot />

View File

@@ -1,12 +1,13 @@
<script setup> <script setup lang="ts">
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { useSidebar } from './utils'; import { cn } from '@/lib/utils'
import { useSidebar } from './utils'
const props = defineProps({ const props = defineProps<{
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
const { toggleSidebar } = useSidebar(); const { toggleSidebar } = useSidebar()
</script> </script>
<template> <template>
@@ -15,8 +16,7 @@ const { toggleSidebar } = useSidebar();
aria-label="Toggle Sidebar" aria-label="Toggle Sidebar"
:tabindex="-1" :tabindex="-1"
title="Toggle Sidebar" title="Toggle Sidebar"
:class=" :class="cn(
cn(
'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex', 'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex',
'[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize', '[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize', '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
@@ -24,8 +24,7 @@ const { toggleSidebar } = useSidebar();
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2', '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2', '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
props.class, props.class,
) )"
"
@click="toggleSidebar" @click="toggleSidebar"
> >
<slot /> <slot />

View File

@@ -1,10 +1,11 @@
<script setup> <script setup lang="ts">
import { cn } from '@/lib/utils'; import type { HTMLAttributes } from 'vue'
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps<{
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
</script> </script>
<template> <template>

View File

@@ -1,14 +1,15 @@
<script setup> <script setup lang="ts">
import { ViewVerticalIcon } from '@radix-icons/vue'; import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'
import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'
import { useSidebar } from './utils'; import { PanelLeft } from 'lucide-vue-next'
import { useSidebar } from './utils'
const props = defineProps({ const props = defineProps<{
class: { type: null, required: false }, class?: HTMLAttributes['class']
}); }>()
const { toggleSidebar } = useSidebar(); const { toggleSidebar } = useSidebar()
</script> </script>
<template> <template>
@@ -19,7 +20,7 @@ const { toggleSidebar } = useSidebar();
:class="cn('h-7 w-7', props.class)" :class="cn('h-7 w-7', props.class)"
@click="toggleSidebar" @click="toggleSidebar"
> >
<ViewVerticalIcon /> <PanelLeft />
<span class="sr-only">Toggle Sidebar</span> <span class="sr-only">Toggle Sidebar</span>
</Button> </Button>
</template> </template>

View File

@@ -1,49 +0,0 @@
import { cva } from 'class-variance-authority';
export { default as Sidebar } from './Sidebar.vue';
export { default as SidebarContent } from './SidebarContent.vue';
export { default as SidebarFooter } from './SidebarFooter.vue';
export { default as SidebarGroup } from './SidebarGroup.vue';
export { default as SidebarGroupAction } from './SidebarGroupAction.vue';
export { default as SidebarGroupContent } from './SidebarGroupContent.vue';
export { default as SidebarGroupLabel } from './SidebarGroupLabel.vue';
export { default as SidebarHeader } from './SidebarHeader.vue';
export { default as SidebarInput } from './SidebarInput.vue';
export { default as SidebarInset } from './SidebarInset.vue';
export { default as SidebarMenu } from './SidebarMenu.vue';
export { default as SidebarMenuAction } from './SidebarMenuAction.vue';
export { default as SidebarMenuBadge } from './SidebarMenuBadge.vue';
export { default as SidebarMenuButton } from './SidebarMenuButton.vue';
export { default as SidebarMenuItem } from './SidebarMenuItem.vue';
export { default as SidebarMenuSkeleton } from './SidebarMenuSkeleton.vue';
export { default as SidebarMenuSub } from './SidebarMenuSub.vue';
export { default as SidebarMenuSubButton } from './SidebarMenuSubButton.vue';
export { default as SidebarMenuSubItem } from './SidebarMenuSubItem.vue';
export { default as SidebarProvider } from './SidebarProvider.vue';
export { default as SidebarRail } from './SidebarRail.vue';
export { default as SidebarSeparator } from './SidebarSeparator.vue';
export { default as SidebarTrigger } from './SidebarTrigger.vue';
export { useSidebar } from './utils';
export const sidebarMenuButtonVariants = cva(
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
{
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:!p-0',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);

View File

@@ -1,10 +0,0 @@
import { createContext } from 'reka-ui';
export const SIDEBAR_COOKIE_NAME = 'sidebar:state';
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
export const SIDEBAR_WIDTH = '16rem';
export const SIDEBAR_WIDTH_MOBILE = '18rem';
export const SIDEBAR_WIDTH_ICON = '3rem';
export const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
export const [useSidebar, provideSidebarContext] = createContext('Sidebar');

View File

@@ -1,9 +1,9 @@
<script setup> <script setup>
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'
const props = defineProps({ const props = defineProps({
class: { type: null, required: false }, class: { type: null, required: false }
}); })
</script> </script>
<template> <template>

View File

@@ -1 +1 @@
export { default as Skeleton } from './Skeleton.vue'; export { default as Skeleton } from './Skeleton.vue'

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui'; import { TooltipRoot, useForwardPropsEmits } from 'radix-vue'
const props = defineProps({ const props = defineProps({
defaultOpen: { type: Boolean, required: false }, defaultOpen: { type: Boolean, required: false },
@@ -8,11 +8,11 @@ const props = defineProps({
disableHoverableContent: { type: Boolean, required: false }, disableHoverableContent: { type: Boolean, required: false },
disableClosingTrigger: { type: Boolean, required: false }, disableClosingTrigger: { type: Boolean, required: false },
disabled: { type: Boolean, required: false }, disabled: { type: Boolean, required: false },
ignoreNonKeyboardFocus: { type: Boolean, required: false }, ignoreNonKeyboardFocus: { type: Boolean, required: false }
}); })
const emits = defineEmits(['update:open']); const emits = defineEmits(['update:open'])
const forwarded = useForwardPropsEmits(props, emits); const forwarded = useForwardPropsEmits(props, emits)
</script> </script>
<template> <template>

View File

@@ -1,14 +1,13 @@
<script setup> <script setup>
import { reactiveOmit } from '@vueuse/core'; import { computed } from 'vue'
import { TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui'; import { TooltipContent, TooltipPortal, useForwardPropsEmits } from 'radix-vue'
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false
}); })
const props = defineProps({ const props = defineProps({
forceMount: { type: Boolean, required: false },
ariaLabel: { type: String, required: false }, ariaLabel: { type: String, required: false },
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: null, required: false }, as: { type: null, required: false },
@@ -22,16 +21,18 @@ const props = defineProps({
arrowPadding: { type: Number, required: false }, arrowPadding: { type: Number, required: false },
sticky: { type: String, required: false }, sticky: { type: String, required: false },
hideWhenDetached: { type: Boolean, required: false }, hideWhenDetached: { type: Boolean, required: false },
positionStrategy: { type: String, required: false }, class: { type: null, required: false }
updatePositionStrategy: { type: String, required: false }, })
class: { type: null, required: false },
});
const emits = defineEmits(['escapeKeyDown', 'pointerDownOutside']); const emits = defineEmits(['escapeKeyDown', 'pointerDownOutside'])
const delegatedProps = reactiveOmit(props, 'class'); const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
const forwarded = useForwardPropsEmits(delegatedProps, emits); return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script> </script>
<template> <template>
@@ -41,7 +42,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
:class=" :class="
cn( cn(
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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', 'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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',
props.class, props.class
) )
" "
> >

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { TooltipProvider } from 'reka-ui'; import { TooltipProvider } from 'radix-vue'
const props = defineProps({ const props = defineProps({
delayDuration: { type: Number, required: false }, delayDuration: { type: Number, required: false },
@@ -7,8 +7,8 @@ const props = defineProps({
disableHoverableContent: { type: Boolean, required: false }, disableHoverableContent: { type: Boolean, required: false },
disableClosingTrigger: { type: Boolean, required: false }, disableClosingTrigger: { type: Boolean, required: false },
disabled: { type: Boolean, required: false }, disabled: { type: Boolean, required: false },
ignoreNonKeyboardFocus: { type: Boolean, required: false }, ignoreNonKeyboardFocus: { type: Boolean, required: false }
}); })
</script> </script>
<template> <template>

View File

@@ -1,11 +1,10 @@
<script setup> <script setup>
import { TooltipTrigger } from 'reka-ui'; import { TooltipTrigger } from 'radix-vue'
const props = defineProps({ const props = defineProps({
reference: { type: null, required: false },
asChild: { type: Boolean, required: false }, asChild: { type: Boolean, required: false },
as: { type: null, required: false }, as: { type: null, required: false }
}); })
</script> </script>
<template> <template>

View File

@@ -1,4 +1,4 @@
export { default as Tooltip } from './Tooltip.vue'; export { default as Tooltip } from './Tooltip.vue'
export { default as TooltipContent } from './TooltipContent.vue'; export { default as TooltipContent } from './TooltipContent.vue'
export { default as TooltipProvider } from './TooltipProvider.vue'; export { default as TooltipTrigger } from './TooltipTrigger.vue'
export { default as TooltipTrigger } from './TooltipTrigger.vue'; export { default as TooltipProvider } from './TooltipProvider.vue'

View File

@@ -1,22 +1,18 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useUsersStore } from '@/stores/users' import { useUsersStore } from '@/stores/users'
import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig' import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig'
import { useI18n } from 'vue-i18n'
export function useActivityLogFilters () { export function useActivityLogFilters () {
const uStore = useUsersStore() const uStore = useUsersStore()
const { t } = useI18n()
const activityLogListFilters = computed(() => ({ const activityLogListFilters = computed(() => ({
actor_id: { actor_id: {
label: t('globals.terms.actor'), label: 'Actor',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: uStore.options options: uStore.options
}, },
activity_type: { activity_type: {
label: t('globals.messages.type', { label: 'Activity type',
name: t('globals.terms.activityLog')
}),
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: [{ options: [{

View File

@@ -6,7 +6,6 @@ import { useTeamStore } from '@/stores/team'
import { useSlaStore } from '@/stores/sla' import { useSlaStore } from '@/stores/sla'
import { useCustomAttributeStore } from '@/stores/customAttributes' import { useCustomAttributeStore } from '@/stores/customAttributes'
import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig' import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig'
import { useI18n } from 'vue-i18n'
export function useConversationFilters () { export function useConversationFilters () {
const cStore = useConversationStore() const cStore = useConversationStore()
@@ -15,7 +14,6 @@ export function useConversationFilters () {
const tStore = useTeamStore() const tStore = useTeamStore()
const slaStore = useSlaStore() const slaStore = useSlaStore()
const customAttributeStore = useCustomAttributeStore() const customAttributeStore = useCustomAttributeStore()
const { t } = useI18n()
const customAttributeDataTypeToFieldType = { const customAttributeDataTypeToFieldType = {
'text': FIELD_TYPE.TEXT, 'text': FIELD_TYPE.TEXT,
@@ -37,35 +35,31 @@ export function useConversationFilters () {
const conversationsListFilters = computed(() => ({ const conversationsListFilters = computed(() => ({
status_id: { status_id: {
label: t('globals.terms.status'), label: 'Status',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: cStore.statusOptions options: cStore.statusOptions
}, },
priority_id: { priority_id: {
label: t('globals.terms.priority'), label: 'Priority',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: cStore.priorityOptions options: cStore.priorityOptions
}, },
assigned_team_id: { assigned_team_id: {
label: t('globals.messages.assign', { label: 'Assigned team',
name: t('globals.terms.team').toLowerCase()
}),
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: tStore.options options: tStore.options
}, },
assigned_user_id: { assigned_user_id: {
label: t('globals.messages.assign', { label: 'Assigned user',
name: t('globals.terms.agent').toLowerCase()
}),
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: uStore.options options: uStore.options
}, },
inbox_id: { inbox_id: {
label: t('globals.terms.inbox'), label: 'Inbox',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: iStore.options options: iStore.options
@@ -91,50 +85,46 @@ export function useConversationFilters () {
const newConversationFilters = computed(() => ({ const newConversationFilters = computed(() => ({
contact_email: { contact_email: {
label: t('globals.terms.email'), label: 'Email',
type: FIELD_TYPE.TEXT, type: FIELD_TYPE.TEXT,
operators: FIELD_OPERATORS.TEXT operators: FIELD_OPERATORS.TEXT
}, },
content: { content: {
label: t('globals.terms.content'), label: 'Content',
type: FIELD_TYPE.TEXT, type: FIELD_TYPE.TEXT,
operators: FIELD_OPERATORS.TEXT operators: FIELD_OPERATORS.TEXT
}, },
subject: { subject: {
label: t('globals.terms.subject'), label: 'Subject',
type: FIELD_TYPE.TEXT, type: FIELD_TYPE.TEXT,
operators: FIELD_OPERATORS.TEXT operators: FIELD_OPERATORS.TEXT
}, },
status: { status: {
label: t('globals.terms.status'), label: 'Status',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: cStore.statusOptions options: cStore.statusOptions
}, },
priority: { priority: {
label: t('globals.terms.priority'), label: 'Priority',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: cStore.priorityOptions options: cStore.priorityOptions
}, },
assigned_team: { assigned_team: {
label: t('globals.messages.assign', { label: 'Assigned team',
name: t('globals.terms.team').toLowerCase()
}),
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: tStore.options options: tStore.options
}, },
assigned_user: { assigned_user: {
label: t('globals.messages.assign', { label: 'Assigned agent',
name: t('globals.terms.agent').toLowerCase()
}),
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: uStore.options options: uStore.options
}, },
inbox: { inbox: {
label: t('globals.terms.inbox'), label: 'Inbox',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: iStore.options options: iStore.options
@@ -143,55 +133,51 @@ export function useConversationFilters () {
const conversationFilters = computed(() => ({ const conversationFilters = computed(() => ({
status: { status: {
label: t('globals.terms.status'), label: 'Status',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: cStore.statusOptions options: cStore.statusOptions
}, },
priority: { priority: {
label: t('globals.terms.priority'), label: 'Priority',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: cStore.priorityOptions options: cStore.priorityOptions
}, },
assigned_team: { assigned_team: {
label: t('globals.messages.assign', { label: 'Assigned team',
name: t('globals.terms.team').toLowerCase()
}),
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: tStore.options options: tStore.options
}, },
assigned_user: { assigned_user: {
label: t('globals.messages.assign', { label: 'Assigned agent',
name: t('globals.terms.agent').toLowerCase()
}),
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: uStore.options options: uStore.options
}, },
hours_since_created: { hours_since_created: {
label: t('globals.messages.hoursSinceCreated'), label: 'Hours since created',
type: FIELD_TYPE.NUMBER, type: FIELD_TYPE.NUMBER,
operators: FIELD_OPERATORS.NUMBER operators: FIELD_OPERATORS.NUMBER
}, },
hours_since_first_reply: { hours_since_first_reply: {
label: t('globals.messages.hoursSinceFirstReply'), label: 'Hours since first reply',
type: FIELD_TYPE.NUMBER, type: FIELD_TYPE.NUMBER,
operators: FIELD_OPERATORS.NUMBER operators: FIELD_OPERATORS.NUMBER
}, },
hours_since_last_reply: { hours_since_last_reply: {
label: t('globals.messages.hoursSinceLastReply'), label: 'Hours since last reply',
type: FIELD_TYPE.NUMBER, type: FIELD_TYPE.NUMBER,
operators: FIELD_OPERATORS.NUMBER operators: FIELD_OPERATORS.NUMBER
}, },
hours_since_resolved: { hours_since_resolved: {
label: t('globals.messages.hoursSinceResolved'), label: 'Hours since resolved',
type: FIELD_TYPE.NUMBER, type: FIELD_TYPE.NUMBER,
operators: FIELD_OPERATORS.NUMBER operators: FIELD_OPERATORS.NUMBER
}, },
inbox: { inbox: {
label: t('globals.terms.inbox'), label: 'Inbox',
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT, operators: FIELD_OPERATORS.SELECT,
options: iStore.options options: iStore.options
@@ -200,122 +186,86 @@ export function useConversationFilters () {
const conversationActions = computed(() => ({ const conversationActions = computed(() => ({
assign_team: { assign_team: {
label: t('globals.messages.assign', { label: 'Assign to team',
name: t('globals.terms.team').toLowerCase()
}),
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
options: tStore.options options: tStore.options
}, },
assign_user: { assign_user: {
label: t('globals.messages.assign', { label: 'Assign to user',
name: t('globals.terms.agent').toLowerCase()
}),
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
options: uStore.options options: uStore.options
}, },
set_status: { set_status: {
label: t('globals.messages.set', { label: 'Set status',
name: t('globals.terms.status').toLowerCase()
}),
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
options: cStore.statusOptionsNoSnooze options: cStore.statusOptionsNoSnooze
}, },
set_priority: { set_priority: {
label: t('globals.messages.set', { label: 'Set priority',
name: t('globals.terms.priority').toLowerCase()
}),
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
options: cStore.priorityOptions options: cStore.priorityOptions
}, },
send_private_note: { send_private_note: {
label: t('globals.messages.send', { label: 'Send private note',
name: t('globals.terms.privateNote').toLowerCase()
}),
type: FIELD_TYPE.RICHTEXT type: FIELD_TYPE.RICHTEXT
}, },
send_reply: { send_reply: {
label: t('globals.messages.send', { label: 'Send reply',
name: t('globals.terms.reply').toLowerCase()
}),
type: FIELD_TYPE.RICHTEXT type: FIELD_TYPE.RICHTEXT
}, },
send_csat: { send_csat: {
label: t('globals.messages.send', { label: 'Send CSAT',
name: t('globals.terms.csat').toLowerCase()
}),
}, },
set_sla: { set_sla: {
label: t('globals.messages.set', { label: 'Set SLA',
name: t('globals.terms.sla').toLowerCase()
}),
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
options: slaStore.options options: slaStore.options
}, },
add_tags: { add_tags: {
label: t('globals.messages.add', { label: 'Add tags',
name: t('globals.terms.tag', 2).toLowerCase()
}),
type: FIELD_TYPE.TAG type: FIELD_TYPE.TAG
}, },
set_tags: { set_tags: {
label: t('globals.messages.set', { label: 'Set tags',
name: t('globals.terms.tag', 2).toLowerCase()
}),
type: FIELD_TYPE.TAG type: FIELD_TYPE.TAG
}, },
remove_tags: { remove_tags: {
label: t('globals.messages.remove', { label: 'Remove tags',
name: t('globals.terms.tag', 2).toLowerCase()
}),
type: FIELD_TYPE.TAG type: FIELD_TYPE.TAG
} }
})) }))
const macroActions = computed(() => ({ const macroActions = computed(() => ({
assign_team: { assign_team: {
label: t('globals.messages.assign', { label: 'Assign to team',
name: t('globals.terms.team').toLowerCase()
}),
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
options: tStore.options options: tStore.options
}, },
assign_user: { assign_user: {
label: t('globals.messages.assign', { label: 'Assign to user',
name: t('globals.terms.agent').toLowerCase()
}),
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
options: uStore.options options: uStore.options
}, },
set_status: { set_status: {
label: t('globals.messages.set', { label: 'Set status',
name: t('globals.terms.status').toLowerCase()
}),
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
options: cStore.statusOptionsNoSnooze options: cStore.statusOptionsNoSnooze
}, },
set_priority: { set_priority: {
label: t('globals.messages.set', { label: 'Set priority',
name: t('globals.terms.priority').toLowerCase()
}),
type: FIELD_TYPE.SELECT, type: FIELD_TYPE.SELECT,
options: cStore.priorityOptions options: cStore.priorityOptions
}, },
add_tags: { add_tags: {
label: t('globals.messages.add', { label: 'Add tags',
name: t('globals.terms.tag', 2).toLowerCase()
}),
type: FIELD_TYPE.TAG type: FIELD_TYPE.TAG
}, },
set_tags: { set_tags: {
label: t('globals.messages.set', { label: 'Set tags',
name: t('globals.terms.tag', 2).toLowerCase()
}),
type: FIELD_TYPE.TAG type: FIELD_TYPE.TAG
}, },
remove_tags: { remove_tags: {
label: t('globals.messages.remove', { label: 'Remove tags',
name: t('globals.terms.tag', 2).toLowerCase()
}),
type: FIELD_TYPE.TAG type: FIELD_TYPE.TAG
} }
})) }))

View File

@@ -1,142 +0,0 @@
import { ref, readonly } from 'vue'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { handleHTTPError } from '@/utils/http'
import api from '@/api'
/**
* Composable for handling file uploads
* @param {Object} options - Configuration options
* @param {Function} options.onFileUploadSuccess - Callback when file upload succeeds (uploadedFile)
* @param {Function} options.onUploadError - Optional callback when file upload fails (file, error)
* @param {string} options.linkedModel - The linked model for the upload
* @param {Array} options.mediaFiles - Optional external array to manage files (if not provided, internal array is used)
*/
export function useFileUpload (options = {}) {
const {
onFileUploadSuccess,
onUploadError,
linkedModel,
mediaFiles: externalMediaFiles
} = options
const emitter = useEmitter()
const uploadingFiles = ref([])
const isUploading = ref(false)
const internalMediaFiles = ref([])
// Use external mediaFiles if provided, otherwise use internal
const mediaFiles = externalMediaFiles || internalMediaFiles
/**
* Handles the file upload process when files are selected.
* Uploads each file to the server and adds them to the mediaFiles array.
* @param {Event} event - The file input change event containing selected files
*/
const handleFileUpload = (event) => {
const files = Array.from(event.target.files)
uploadingFiles.value = files
isUploading.value = true
for (const file of files) {
api
.uploadMedia({
files: file,
inline: false,
linked_model: linkedModel
})
.then((resp) => {
const uploadedFile = resp.data.data
// Add to media files array
if (Array.isArray(mediaFiles.value)) {
mediaFiles.value.push(uploadedFile)
} else {
mediaFiles.push(uploadedFile)
}
// Remove from uploading list
uploadingFiles.value = uploadingFiles.value.filter((f) => f.name !== file.name)
// Call success callback
if (onFileUploadSuccess) {
onFileUploadSuccess(uploadedFile)
}
// Update uploading state
if (uploadingFiles.value.length === 0) {
isUploading.value = false
}
})
.catch((error) => {
uploadingFiles.value = uploadingFiles.value.filter((f) => f.name !== file.name)
// Call error callback or show default toast
if (onUploadError) {
onUploadError(file, error)
} else {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
// Update uploading state
if (uploadingFiles.value.length === 0) {
isUploading.value = false
}
})
}
}
/**
* Handles the file delete event.
* Removes the file from the mediaFiles array.
* @param {String} uuid - The UUID of the file to delete
*/
const handleFileDelete = (uuid) => {
if (Array.isArray(mediaFiles.value)) {
mediaFiles.value = [
...mediaFiles.value.filter((item) => item.uuid !== uuid)
]
} else {
const index = mediaFiles.findIndex((item) => item.uuid === uuid)
if (index > -1) {
mediaFiles.splice(index, 1)
}
}
}
/**
* Upload files programmatically (without event)
* @param {File[]} files - Array of files to upload
*/
const uploadFiles = (files) => {
const mockEvent = { target: { files } }
handleFileUpload(mockEvent)
}
/**
* Clear all media files
*/
const clearMediaFiles = () => {
if (Array.isArray(mediaFiles.value)) {
mediaFiles.value = []
} else {
mediaFiles.length = 0
}
}
return {
// State
uploadingFiles: readonly(uploadingFiles),
isUploading: readonly(isUploading),
mediaFiles: externalMediaFiles ? readonly(mediaFiles) : readonly(internalMediaFiles),
// Methods
handleFileUpload,
handleFileDelete,
uploadFiles,
clearMediaFiles
}
}

View File

@@ -1,6 +1,6 @@
export const reportsNavItems = [ export const reportsNavItems = [
{ {
titleKey: 'globals.terms.overview', titleKey: 'navigation.overview',
href: '/reports/overview', href: '/reports/overview',
permission: 'reports:manage' permission: 'reports:manage'
} }
@@ -8,125 +8,125 @@ export const reportsNavItems = [
export const adminNavItems = [ export const adminNavItems = [
{ {
titleKey: 'globals.terms.workspace', titleKey: 'navigation.workspace',
children: [ children: [
{ {
titleKey: 'globals.terms.general', titleKey: 'navigation.generalSettings',
href: '/admin/general', href: '/admin/general',
permission: 'general_settings:manage' permission: 'general_settings:manage'
}, },
{ {
titleKey: 'globals.terms.businessHour', titleKey: 'navigation.businessHours',
href: '/admin/business-hours', href: '/admin/business-hours',
permission: 'business_hours:manage' permission: 'business_hours:manage'
}, },
{ {
titleKey: 'globals.terms.slaPolicy', titleKey: 'navigation.slaPolicies',
href: '/admin/sla', href: '/admin/sla',
permission: 'sla:manage' permission: 'sla:manage'
} }
] ]
}, },
{ {
titleKey: 'globals.terms.conversation', titleKey: 'navigation.conversations',
children: [ children: [
{ {
titleKey: 'globals.terms.tag', titleKey: 'navigation.tags',
href: '/admin/conversations/tags', href: '/admin/conversations/tags',
permission: 'tags:manage' permission: 'tags:manage'
}, },
{ {
titleKey: 'globals.terms.macro', titleKey: 'navigation.macros',
href: '/admin/conversations/macros', href: '/admin/conversations/macros',
permission: 'macros:manage' permission: 'macros:manage'
}, },
{ {
titleKey: 'globals.terms.status', titleKey: 'navigation.statuses',
href: '/admin/conversations/statuses', href: '/admin/conversations/statuses',
permission: 'status:manage' permission: 'status:manage'
} }
] ]
}, },
{ {
titleKey: 'globals.terms.inbox', titleKey: 'navigation.inboxes',
children: [ children: [
{ {
titleKey: 'globals.terms.inbox', titleKey: 'navigation.inboxes',
href: '/admin/inboxes', href: '/admin/inboxes',
permission: 'inboxes:manage' permission: 'inboxes:manage'
} }
] ]
}, },
{ {
titleKey: 'globals.terms.teammate', titleKey: 'navigation.teammates',
children: [ children: [
{ {
titleKey: 'globals.terms.agent', titleKey: 'navigation.agents',
href: '/admin/teams/agents', href: '/admin/teams/agents',
permission: 'users:manage' permission: 'users:manage'
}, },
{ {
titleKey: 'globals.terms.team', titleKey: 'navigation.teams',
href: '/admin/teams/teams', href: '/admin/teams/teams',
permission: 'teams:manage' permission: 'teams:manage'
}, },
{ {
titleKey: 'globals.terms.role', titleKey: 'navigation.roles',
href: '/admin/teams/roles', href: '/admin/teams/roles',
permission: 'roles:manage' permission: 'roles:manage'
}, },
{ {
titleKey: 'globals.terms.activityLog', titleKey: 'navigation.activityLog',
href: '/admin/teams/activity-log', href: '/admin/teams/activity-log',
permission: 'activity_logs:manage' permission: 'activity_logs:manage'
} }
] ]
}, },
{ {
titleKey: 'globals.terms.automation', titleKey: 'navigation.automations',
children: [ children: [
{ {
titleKey: 'globals.terms.automation', titleKey: 'navigation.automations',
href: '/admin/automations', href: '/admin/automations',
permission: 'automations:manage' permission: 'automations:manage'
} }
] ]
}, },
{ {
titleKey: 'globals.terms.customAttribute', titleKey: 'navigation.customAttributes',
children: [ children: [
{ {
titleKey: 'globals.terms.customAttribute', titleKey: 'navigation.customAttributes',
href: '/admin/custom-attributes', href: '/admin/custom-attributes',
permission: 'custom_attributes:manage' permission: 'custom_attributes:manage'
} }
] ]
}, },
{ {
titleKey: 'globals.terms.notification', titleKey: 'navigation.notifications',
children: [ children: [
{ {
titleKey: 'globals.terms.email', titleKey: 'navigation.email',
href: '/admin/notification', href: '/admin/notification',
permission: 'notification_settings:manage' permission: 'notification_settings:manage'
} }
] ]
}, },
{ {
titleKey: 'globals.terms.template', titleKey: 'navigation.templates',
children: [ children: [
{ {
titleKey: 'globals.terms.template', titleKey: 'navigation.templates',
href: '/admin/templates', href: '/admin/templates',
permission: 'templates:manage' permission: 'templates:manage'
} }
] ]
}, },
{ {
titleKey: 'globals.terms.security', titleKey: 'navigation.security',
children: [ children: [
{ {
titleKey: 'globals.terms.sso', titleKey: 'navigation.sso',
href: '/admin/sso', href: '/admin/sso',
permission: 'oidc:manage' permission: 'oidc:manage'
} }
@@ -136,14 +136,15 @@ export const adminNavItems = [
export const accountNavItems = [ export const accountNavItems = [
{ {
titleKey: 'globals.terms.profile', titleKey: 'navigation.profile',
href: '/account/profile', href: '/account/profile',
}, description: 'Update your profile'
}
] ]
export const contactNavItems = [ export const contactNavItems = [
{ {
titleKey: 'globals.terms.contact', titleKey: 'navigation.allContacts',
href: '/contacts', href: '/contacts',
} }
] ]

View File

@@ -44,7 +44,7 @@
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem :value="'activity_logs.created_at'"> <SelectItem :value="'activity_logs.created_at'">
{{ t('globals.terms.createdAt') }} {{ t('form.field.createdAt') }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -63,20 +63,35 @@
</Popover> </Popover>
</div> </div>
<div v-if="loading" class="w-full">
<div class="flex border-b border-border p-4 font-medium bg-gray-50">
<div class="flex-1 text-muted-foreground">{{ t('form.field.name') }}</div>
<div class="w-[200px] text-muted-foreground">{{ t('form.field.date') }}</div>
<div class="w-[150px] text-muted-foreground">{{ t('globals.terms.ipAddress') }}</div>
</div>
<div v-for="i in perPage" :key="i" class="flex border-b border-border py-3 px-4">
<div class="flex-1">
<Skeleton class="h-4 w-[90%]" />
</div>
<div class="w-[200px]">
<Skeleton class="h-4 w-[120px]" />
</div>
<div class="w-[150px]">
<Skeleton class="h-4 w-[100px]" />
</div>
</div>
</div>
<template v-else>
<div class="w-full overflow-x-auto"> <div class="w-full overflow-x-auto">
<SimpleTable <SimpleTable
:headers="[ :headers="[t('form.field.name'), t('form.field.timestamp'), t('globals.terms.ipAddress')]"
t('globals.terms.name'),
t('globals.terms.timestamp'),
t('globals.terms.ipAddress')
]"
:keys="['activity_description', 'created_at', 'ip']" :keys="['activity_description', 'created_at', 'ip']"
:data="activityLogs" :data="activityLogs"
:showDelete="false" :showDelete="false"
:loading="loading"
:skeletonRows="15"
/> />
</div> </div>
</template>
</div> </div>
<!-- TODO: deduplicate this code, copied from contacts list --> <!-- TODO: deduplicate this code, copied from contacts list -->
@@ -148,6 +163,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { Skeleton } from '@/components/ui/skeleton'
import SimpleTable from '@/components/table/SimpleTable.vue' import SimpleTable from '@/components/table/SimpleTable.vue'
import { import {
Pagination, Pagination,

View File

@@ -12,7 +12,7 @@
<div class="space-y-4 flex-2"> <div class="space-y-4 flex-2">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<h3 class="text-lg font-semibold text-gray-900 dark:text-foreground"> <h3 class="text-lg font-semibold text-gray-900">
{{ props.initialValues.first_name }} {{ props.initialValues.last_name }} {{ props.initialValues.first_name }} {{ props.initialValues.last_name }}
</h3> </h3>
<Badge :class="['px-2 rounded-full text-xs font-medium', availabilityStatus.color]"> <Badge :class="['px-2 rounded-full text-xs font-medium', availabilityStatus.color]">
@@ -24,8 +24,8 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Clock class="w-5 h-5 text-gray-400" /> <Clock class="w-5 h-5 text-gray-400" />
<div> <div>
<p class="text-sm text-gray-500">{{ $t('globals.terms.lastActive') }}</p> <p class="text-sm text-gray-500">{{ $t('form.field.lastActive') }}</p>
<p class="text-sm font-medium text-gray-700 dark:text-foreground"> <p class="text-sm font-medium text-gray-700">
{{ {{
props.initialValues.last_active_at props.initialValues.last_active_at
? format(new Date(props.initialValues.last_active_at), 'PPpp') ? format(new Date(props.initialValues.last_active_at), 'PPpp')
@@ -37,8 +37,8 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<LogIn class="w-5 h-5 text-gray-400" /> <LogIn class="w-5 h-5 text-gray-400" />
<div> <div>
<p class="text-sm text-gray-500">{{ $t('globals.terms.lastLogin') }}</p> <p class="text-sm text-gray-500">{{ $t('form.field.lastLogin') }}</p>
<p class="text-sm font-medium text-gray-700 dark:text-foreground"> <p class="text-sm font-medium text-gray-700">
{{ {{
props.initialValues.last_login_at props.initialValues.last_login_at
? format(new Date(props.initialValues.last_login_at), 'PPpp') ? format(new Date(props.initialValues.last_login_at), 'PPpp')
@@ -55,7 +55,7 @@
<!-- Form Fields --> <!-- Form Fields -->
<FormField v-slot="{ field }" name="first_name"> <FormField v-slot="{ field }" name="first_name">
<FormItem v-auto-animate> <FormItem v-auto-animate>
<FormLabel>{{ $t('globals.terms.firstName') }}</FormLabel> <FormLabel>{{ $t('form.field.firstName') }}</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="" v-bind="field" /> <Input type="text" placeholder="" v-bind="field" />
</FormControl> </FormControl>
@@ -65,7 +65,7 @@
<FormField v-slot="{ field }" name="last_name"> <FormField v-slot="{ field }" name="last_name">
<FormItem> <FormItem>
<FormLabel>{{ $t('globals.terms.lastName') }}</FormLabel> <FormLabel>{{ $t('form.field.lastName') }}</FormLabel>
<FormControl> <FormControl>
<Input type="text" placeholder="" v-bind="field" /> <Input type="text" placeholder="" v-bind="field" />
</FormControl> </FormControl>
@@ -75,7 +75,7 @@
<FormField v-slot="{ field }" name="email"> <FormField v-slot="{ field }" name="email">
<FormItem v-auto-animate> <FormItem v-auto-animate>
<FormLabel>{{ $t('globals.terms.email') }}</FormLabel> <FormLabel>{{ $t('form.field.email') }}</FormLabel>
<FormControl> <FormControl>
<Input type="email" placeholder="" v-bind="field" /> <Input type="email" placeholder="" v-bind="field" />
</FormControl> </FormControl>
@@ -85,11 +85,11 @@
<FormField v-slot="{ componentField, handleChange }" name="teams"> <FormField v-slot="{ componentField, handleChange }" name="teams">
<FormItem v-auto-animate> <FormItem v-auto-animate>
<FormLabel>{{ $t('globals.terms.team', 2) }}</FormLabel> <FormLabel>{{ $t('form.field.teams') }}</FormLabel>
<FormControl> <FormControl>
<SelectTag <SelectTag
:items="teamOptions" :items="teamOptions"
:placeholder="t('globals.messages.select', { name: t('globals.terms.team', 2) })" :placeholder="t('form.field.selectTeams')"
v-model="componentField.modelValue" v-model="componentField.modelValue"
@update:modelValue="handleChange" @update:modelValue="handleChange"
/> />
@@ -100,15 +100,11 @@
<FormField v-slot="{ componentField, handleChange }" name="roles"> <FormField v-slot="{ componentField, handleChange }" name="roles">
<FormItem v-auto-animate> <FormItem v-auto-animate>
<FormLabel>{{ $t('globals.terms.role', 2) }}</FormLabel> <FormLabel>{{ $t('form.field.roles') }}</FormLabel>
<FormControl> <FormControl>
<SelectTag <SelectTag
:items="roleOptions" :items="roleOptions"
:placeholder=" :placeholder="t('form.field.selectRoles')"
t('globals.messages.select', {
name: $t('globals.terms.role', 2)
})
"
v-model="componentField.modelValue" v-model="componentField.modelValue"
@update:modelValue="handleChange" @update:modelValue="handleChange"
/> />
@@ -119,14 +115,14 @@
<FormField v-slot="{ componentField }" name="availability_status" v-if="!isNewForm"> <FormField v-slot="{ componentField }" name="availability_status" v-if="!isNewForm">
<FormItem> <FormItem>
<FormLabel>{{ t('globals.terms.availabilityStatus') }}</FormLabel> <FormLabel>{{ t('form.field.availabilityStatus') }}</FormLabel>
<FormControl> <FormControl>
<Select v-bind="componentField" v-model="componentField.modelValue"> <Select v-bind="componentField" v-model="componentField.modelValue">
<SelectTrigger> <SelectTrigger>
<SelectValue <SelectValue
:placeholder=" :placeholder="
t('globals.messages.select', { t('form.field.select', {
name: t('globals.terms.availabilityStatus') name: t('form.field.availabilityStatus')
}) })
" "
/> />
@@ -136,7 +132,7 @@
<SelectItem value="active_group">{{ t('globals.terms.active') }}</SelectItem> <SelectItem value="active_group">{{ t('globals.terms.active') }}</SelectItem>
<SelectItem value="away_manual">{{ t('globals.terms.away') }}</SelectItem> <SelectItem value="away_manual">{{ t('globals.terms.away') }}</SelectItem>
<SelectItem value="away_and_reassigning"> <SelectItem value="away_and_reassigning">
{{ t('globals.terms.awayReassigning') }} {{ t('form.field.awayReassigning') }}
</SelectItem> </SelectItem>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
@@ -148,7 +144,7 @@
<FormField v-slot="{ field }" name="new_password" v-if="!isNewForm"> <FormField v-slot="{ field }" name="new_password" v-if="!isNewForm">
<FormItem v-auto-animate> <FormItem v-auto-animate>
<FormLabel>{{ t('globals.terms.setPassword') }}</FormLabel> <FormLabel>{{ t('form.field.setPassword') }}</FormLabel>
<FormControl> <FormControl>
<Input type="password" placeholder="" v-bind="field" /> <Input type="password" placeholder="" v-bind="field" />
</FormControl> </FormControl>
@@ -161,7 +157,7 @@
<FormControl> <FormControl>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Checkbox :checked="value" @update:checked="handleChange" /> <Checkbox :checked="value" @update:checked="handleChange" />
<Label>{{ $t('globals.terms.sendWelcomeEmail') }}</Label> <Label>{{ $t('form.field.sendWelcomeEmail') }}</Label>
</div> </div>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -174,7 +170,7 @@
<Checkbox :checked="value" @update:checked="handleChange" /> <Checkbox :checked="value" @update:checked="handleChange" />
</FormControl> </FormControl>
<div class="space-y-1 leading-none"> <div class="space-y-1 leading-none">
<FormLabel> {{ $t('globals.terms.enabled') }} </FormLabel> <FormLabel> {{ $t('form.field.enabled') }} </FormLabel>
<FormMessage /> <FormMessage />
</div> </div>
</FormItem> </FormItem>
@@ -254,7 +250,7 @@ const availabilityStatus = computed(() => {
if (status === 'active_group') return { text: t('globals.terms.active'), color: 'bg-green-500' } if (status === 'active_group') return { text: t('globals.terms.active'), color: 'bg-green-500' }
if (status === 'away_manual') return { text: t('globals.terms.away'), color: 'bg-yellow-500' } if (status === 'away_manual') return { text: t('globals.terms.away'), color: 'bg-yellow-500' }
if (status === 'away_and_reassigning') if (status === 'away_and_reassigning')
return { text: t('globals.terms.awayReassigning'), color: 'bg-orange-500' } return { text: t('form.field.awayReassigning'), color: 'bg-orange-500' }
return { text: t('globals.terms.offline'), color: 'bg-gray-400' } return { text: t('globals.terms.offline'), color: 'bg-gray-400' }
}) })

View File

@@ -6,7 +6,7 @@ export const createColumns = (t) => [
{ {
accessorKey: 'first_name', accessorKey: 'first_name',
header: function () { header: function () {
return h('div', { class: 'text-center' }, t('globals.terms.firstName')) return h('div', { class: 'text-center' }, t('form.field.firstName'))
}, },
cell: function ({ row }) { cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('first_name')) return h('div', { class: 'text-center font-medium' }, row.getValue('first_name'))
@@ -15,7 +15,7 @@ export const createColumns = (t) => [
{ {
accessorKey: 'last_name', accessorKey: 'last_name',
header: function () { header: function () {
return h('div', { class: 'text-center' }, t('globals.terms.lastName')) return h('div', { class: 'text-center' }, t('form.field.lastName'))
}, },
cell: function ({ row }) { cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('last_name')) return h('div', { class: 'text-center font-medium' }, row.getValue('last_name'))
@@ -24,7 +24,7 @@ export const createColumns = (t) => [
{ {
accessorKey: 'enabled', accessorKey: 'enabled',
header: function () { header: function () {
return h('div', { class: 'text-center' }, t('globals.terms.enabled')) return h('div', { class: 'text-center' }, t('form.field.enabled'))
}, },
cell: function ({ row }) { cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('enabled') ? t('globals.messages.yes') : t('globals.messages.no')) return h('div', { class: 'text-center font-medium' }, row.getValue('enabled') ? t('globals.messages.yes') : t('globals.messages.no'))
@@ -33,7 +33,7 @@ export const createColumns = (t) => [
{ {
accessorKey: 'email', accessorKey: 'email',
header: function () { header: function () {
return h('div', { class: 'text-center' }, t('globals.terms.email')) return h('div', { class: 'text-center' }, t('form.field.email'))
}, },
cell: function ({ row }) { cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('email')) return h('div', { class: 'text-center font-medium' }, row.getValue('email'))
@@ -42,7 +42,7 @@ export const createColumns = (t) => [
{ {
accessorKey: 'created_at', accessorKey: 'created_at',
header: function () { header: function () {
return h('div', { class: 'text-center' }, t('globals.terms.createdAt')) return h('div', { class: 'text-center' }, t('form.field.createdAt'))
}, },
cell: function ({ row }) { cell: function ({ row }) {
return h( return h(
@@ -55,7 +55,7 @@ export const createColumns = (t) => [
{ {
accessorKey: 'updated_at', accessorKey: 'updated_at',
header: function () { header: function () {
return h('div', { class: 'text-center' }, t('globals.terms.updatedAt')) return h('div', { class: 'text-center' }, t('form.field.updatedAt'))
}, },
cell: function ({ row }) { cell: function ({ row }) {
return h( return h(

View File

@@ -8,10 +8,10 @@
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem @click="editUser(props.user.id)">{{ <DropdownMenuItem @click="editUser(props.user.id)">{{
$t('globals.messages.edit') $t('globals.buttons.edit')
}}</DropdownMenuItem> }}</DropdownMenuItem>
<DropdownMenuItem @click="() => (alertOpen = true)">{{ <DropdownMenuItem @click="() => (alertOpen = true)">{{
$t('globals.messages.delete') $t('globals.buttons.delete')
}}</DropdownMenuItem> }}</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -20,12 +20,12 @@
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle> <AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
<AlertDialogDescription>{{ $t('admin.agent.deleteConfirmation') }}</AlertDialogDescription> <AlertDialogDescription>{{ $t('admin.user.deleteConfirmation') }}</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>{{ $t('globals.messages.cancel') }}</AlertDialogCancel> <AlertDialogCancel>{{ $t('globals.buttons.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="handleDelete">{{ <AlertDialogAction @click="handleDelete">{{
$t('globals.messages.delete') $t('globals.buttons.delete')
}}</AlertDialogAction> }}</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

View File

@@ -32,22 +32,18 @@ export const createFormSchema = (t) => z.object({
teams: z.array(z.string()).default([]), teams: z.array(z.string()).default([]),
roles: z.array(z.string()).min(1, t('globals.messages.selectAtLeastOne', { roles: z.array(z.string()).min(1, t('globals.messages.pleaseSelectAtLeastOne', {
name: t('globals.terms.role') name: t('globals.terms.role')
})), })),
new_password: z new_password: z
.string() .string()
.min(10, { .regex(/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[\W_]).{10,72}$/, {
message: t('globals.messages.strongPassword', { min: 10, max: 72 }) message: t('globals.messages.strongPassword', {
min: 10,
max: 72,
}) })
.max(72, {
message: t('globals.messages.strongPassword', { min: 10, max: 72 })
}) })
.refine(val => /[a-z]/.test(val), t('globals.messages.strongPassword', { min: 10, max: 72 }))
.refine(val => /[A-Z]/.test(val), t('globals.messages.strongPassword', { min: 10, max: 72 }))
.refine(val => /\d/.test(val), t('globals.messages.strongPassword', { min: 10, max: 72 }))
.refine(val => /[\W_]/.test(val), t('globals.messages.strongPassword', { min: 10, max: 72 }))
.optional(), .optional(),
enabled: z.boolean().optional().default(true), enabled: z.boolean().optional().default(true),
availability_status: z.string().optional().default('offline'), availability_status: z.string().optional().default('offline'),

View File

@@ -1,378 +0,0 @@
import { describe, test, expect } from 'vitest'
import { createFormSchema } from './formSchema'
const mockT = (key, params) => `${key} ${JSON.stringify(params || {})}`
const schema = createFormSchema(mockT)
const validForm = {
first_name: 'John',
email: 'test@test.com',
roles: ['admin'],
new_password: 'Password123!'
}
describe('Form Schema', () => {
// Valid cases
test('valid complete form', () => {
expect(() => schema.parse(validForm)).not.toThrow()
})
test('valid minimal form', () => {
expect(() => schema.parse({
first_name: 'Jo',
email: 'a@b.co',
roles: ['user']
})).not.toThrow()
})
// First name tests
test('first_name too short', () => {
expect(() => schema.parse({ ...validForm, first_name: 'J' })).toThrow()
})
test('first_name too long', () => {
expect(() => schema.parse({ ...validForm, first_name: 'a'.repeat(51) })).toThrow()
})
test('first_name missing', () => {
const { first_name, ...form } = validForm
expect(() => schema.parse(form)).toThrow()
})
test('first_name empty string', () => {
expect(() => schema.parse({ ...validForm, first_name: '' })).toThrow()
})
test('first_name null', () => {
expect(() => schema.parse({ ...validForm, first_name: null })).toThrow()
})
// Email tests
test('invalid email format', () => {
expect(() => schema.parse({ ...validForm, email: 'invalid' })).toThrow()
})
test('email missing @', () => {
expect(() => schema.parse({ ...validForm, email: 'test.com' })).toThrow()
})
test('email missing domain', () => {
expect(() => schema.parse({ ...validForm, email: 'test@' })).toThrow()
})
test('email empty', () => {
expect(() => schema.parse({ ...validForm, email: '' })).toThrow()
})
test('email missing', () => {
const { email, ...form } = validForm
expect(() => schema.parse(form)).toThrow()
})
// Roles tests
test('roles empty array', () => {
expect(() => schema.parse({ ...validForm, roles: [] })).toThrow()
})
test('roles missing', () => {
const { roles, ...form } = validForm
expect(() => schema.parse(form)).toThrow()
})
test('roles multiple values', () => {
expect(() => schema.parse({ ...validForm, roles: ['admin', 'user', 'moderator'] })).not.toThrow()
})
// Password tests
test('password too short', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Pass1!' })).toThrow()
})
test('password too long', () => {
expect(() => schema.parse({ ...validForm, new_password: 'P'.repeat(73) + 'a1!' })).toThrow()
})
test('password missing uppercase', () => {
expect(() => schema.parse({ ...validForm, new_password: 'password123!' })).toThrow()
})
test('password missing lowercase', () => {
expect(() => schema.parse({ ...validForm, new_password: 'PASSWORD123!' })).toThrow()
})
test('password missing number', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password!@#$' })).toThrow()
})
test('password missing special char', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123' })).toThrow()
})
test('password only special chars', () => {
expect(() => schema.parse({ ...validForm, new_password: '!@#$%^&*()' })).toThrow()
})
test('password unicode special chars', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123ñ' })).not.toThrow()
})
test('password underscore as special char', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123_' })).not.toThrow()
})
test('password exactly 10 chars', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password1!' })).not.toThrow()
})
test('password exactly 72 chars', () => {
expect(() => schema.parse({ ...validForm, new_password: 'P'.repeat(69) + 'a1!' })).not.toThrow()
})
// Optional fields
test('last_name optional', () => {
expect(() => schema.parse(validForm)).not.toThrow()
expect(() => schema.parse({ ...validForm, last_name: 'Doe' })).not.toThrow()
expect(() => schema.parse({ ...validForm, last_name: '' })).not.toThrow()
})
test('send_welcome_email optional', () => {
expect(() => schema.parse({ ...validForm, send_welcome_email: true })).not.toThrow()
expect(() => schema.parse({ ...validForm, send_welcome_email: false })).not.toThrow()
})
test('enabled defaults to true', () => {
const result = schema.parse(validForm)
expect(result.enabled).toBe(true)
})
test('availability_status defaults to offline', () => {
const result = schema.parse(validForm)
expect(result.availability_status).toBe('offline')
})
test('teams defaults to empty array', () => {
const result = schema.parse(validForm)
expect(result.teams).toEqual([])
})
test('teams with values', () => {
expect(() => schema.parse({ ...validForm, teams: ['team1', 'team2'] })).not.toThrow()
})
// Edge cases
test('undefined values', () => {
expect(() => schema.parse({
first_name: undefined,
email: 'test@test.com',
roles: ['admin']
})).toThrow()
})
test('null values', () => {
expect(() => schema.parse({
first_name: null,
email: 'test@test.com',
roles: ['admin']
})).toThrow()
})
test('number as string field', () => {
expect(() => schema.parse({ ...validForm, first_name: 123 })).toThrow()
})
test('string as boolean field', () => {
expect(() => schema.parse({ ...validForm, enabled: 'true' })).toThrow()
})
test('string as array field', () => {
expect(() => schema.parse({ ...validForm, roles: 'admin' })).toThrow()
})
test('empty object', () => {
expect(() => schema.parse({})).toThrow()
})
test('extra unknown fields ignored', () => {
expect(() => schema.parse({
...validForm,
unknown_field: 'value',
another_field: 123
})).not.toThrow()
})
})
// Password regex validation tests
describe('Password Regex Validation', () => {
// Lowercase tests
test('lowercase - single letter', () => {
expect(() => schema.parse({ ...validForm, new_password: 'PASSWORD123!a' })).not.toThrow()
})
test('lowercase - multiple letters', () => {
expect(() => schema.parse({ ...validForm, new_password: 'PASSWORDabc123!' })).not.toThrow()
})
test('lowercase - accented characters', () => {
expect(() => schema.parse({ ...validForm, new_password: 'PASSWORD123!ñ' })).toThrow()
})
test('lowercase - none', () => {
expect(() => schema.parse({ ...validForm, new_password: 'PASSWORD123!' })).toThrow()
})
// Uppercase tests
test('uppercase - single letter', () => {
expect(() => schema.parse({ ...validForm, new_password: 'passwordA123!' })).not.toThrow()
})
test('uppercase - multiple letters', () => {
expect(() => schema.parse({ ...validForm, new_password: 'passwordABC123!' })).not.toThrow()
})
test('uppercase - accented characters', () => {
expect(() => schema.parse({ ...validForm, new_password: 'passwordÑ123!' })).toThrow()
})
test('uppercase - none', () => {
expect(() => schema.parse({ ...validForm, new_password: 'password123!' })).toThrow()
})
// Digit tests
test('digit - single number', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password1!' })).not.toThrow()
})
test('digit - multiple numbers', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123!' })).not.toThrow()
})
test('digit - zero', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password0!' })).not.toThrow()
})
test('digit - none', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password!' })).toThrow()
})
// Special character tests
test('special - common symbols', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123!' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123@' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123#' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123$' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123%' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123^' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123&' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123*' })).not.toThrow()
})
test('special - brackets and parentheses', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123(' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123)' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123[' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123]' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123{' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123}' })).not.toThrow()
})
test('special - punctuation', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123.' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123,' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123;' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123:' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123?' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123/' })).not.toThrow()
})
test('special - quotes and backslash', () => {
expect(() => schema.parse({ ...validForm, new_password: "Password123'" })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123"' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123\\' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123|' })).not.toThrow()
})
test('special - math symbols', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123+' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123-' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123=' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123<' })).not.toThrow()
expect(() => schema.parse({ ...validForm, new_password: 'Password123>' })).not.toThrow()
})
test('special - underscore', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123_' })).not.toThrow()
})
test('special - space', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123 ' })).not.toThrow()
})
test('special - none', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123' })).toThrow()
})
// Combination edge cases
test('only uppercase and special', () => {
expect(() => schema.parse({ ...validForm, new_password: 'PASSWORD!@#$%^&*()' })).toThrow()
})
test('only lowercase and digits', () => {
expect(() => schema.parse({ ...validForm, new_password: 'password123456' })).toThrow()
})
test('whitespace only special char', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123 ' })).not.toThrow()
})
test('tab as special char', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123\t' })).not.toThrow()
})
test('newline as special char', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password123\n' })).not.toThrow()
})
})
// Password validation - passing cases
describe('Password Valid Cases', () => {
test('exact minimum length with all requirements', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Password1!' })).not.toThrow()
})
test('exact maximum length with all requirements', () => {
expect(() => schema.parse({ ...validForm, new_password: 'P'.repeat(67) + 'ass1!' })).not.toThrow()
})
test('multiple of each requirement', () => {
expect(() => schema.parse({ ...validForm, new_password: 'PASSWORDpassword123456!@#$%^&*()' })).not.toThrow()
})
test('mixed case throughout', () => {
expect(() => schema.parse({ ...validForm, new_password: 'PaSSwoRD123!@#' })).not.toThrow()
})
test('numbers at start', () => {
expect(() => schema.parse({ ...validForm, new_password: '123Password!' })).not.toThrow()
})
test('special chars at start', () => {
expect(() => schema.parse({ ...validForm, new_password: '!@#Password123' })).not.toThrow()
})
test('all character types mixed', () => {
expect(() => schema.parse({ ...validForm, new_password: 'P@ssw0rd123!' })).not.toThrow()
})
test('unicode characters', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Påssw0rd123!' })).not.toThrow()
})
test('long valid password', () => {
expect(() => schema.parse({ ...validForm, new_password: 'ThisIsAVeryLongPasswordWith123!SpecialChars' })).not.toThrow()
})
test('password with spaces', () => {
expect(() => schema.parse({ ...validForm, new_password: 'Pass Word 123!' })).not.toThrow()
})
})

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="space-y-5 rounded" :class="{ 'box p-5': actions.length > 0 }"> <div class="space-y-5 rounded-lg" :class="{ 'box p-5': actions.length > 0 }">
<div class="space-y-5"> <div class="space-y-5">
<div v-for="(action, index) in actions" :key="index" class="space-y-5"> <div v-for="(action, index) in actions" :key="index" class="space-y-5">
<div v-if="index > 0"> <div v-if="index > 0">
@@ -16,7 +16,7 @@
@update:modelValue="(value) => handleFieldChange(value, index)" @update:modelValue="(value) => handleFieldChange(value, index)"
> >
<SelectTrigger class="m-auto"> <SelectTrigger class="m-auto">
<SelectValue :placeholder="t('globals.messages.select', { name: t('globals.terms.action').toLowerCase() })" /> <SelectValue :placeholder="t('form.field.selectAction')" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
@@ -40,7 +40,7 @@
<SelectTag <SelectTag
v-model="action.value" v-model="action.value"
:items="tagsStore.tagNames.map((tag) => ({ label: tag, value: tag }))" :items="tagsStore.tagNames.map((tag) => ({ label: tag, value: tag }))"
:placeholder="t('globals.messages.select', { name: t('globals.terms.tag', 2).toLowerCase() })" :placeholder="t('form.field.selectTag')"
/> />
</div> </div>
@@ -48,17 +48,63 @@
class="w-48" class="w-48"
v-if="action.type && conversationActions[action.type]?.type === 'select'" v-if="action.type && conversationActions[action.type]?.type === 'select'"
> >
<SelectComboBox <ComboBox
v-model="action.value[0]" v-model="action.value[0]"
:items="conversationActions[action.type]?.options" :items="conversationActions[action.type]?.options"
:placeholder="t('globals.messages.select', { name: '' })" :placeholder="t('form.field.select')"
@select="handleValueChange($event, index)" @select="handleValueChange($event, index)"
:type="action.type === 'assign_team' ? 'team' : 'user'" >
<template #item="{ item }">
<div class="flex items-center gap-2 ml-2">
<Avatar v-if="action.type === 'assign_user'" class="w-7 h-7">
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
<AvatarFallback>
{{ item.label.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span v-if="action.type === 'assign_team'">
{{ item.emoji }}
</span>
<span>{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div v-if="action.type === 'assign_team'">
<div v-if="selected" class="flex items-center gap-2">
{{ selected.emoji }}
<span>{{ selected.label }}</span>
</div>
<span v-else>{{ $t('form.field.selectTeam') }}</span>
</div>
<div v-else-if="action.type === 'assign_user'" class="flex items-center gap-2">
<div v-if="selected" class="flex items-center gap-2">
<Avatar class="w-7 h-7">
<AvatarImage
:src="selected.avatar_url ?? ''"
:alt="selected.label.slice(0, 2)"
/> />
<AvatarFallback>
{{ selected.label.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span>{{ selected.label }}</span>
</div>
<span v-else>{{ $t('form.field.selectUser') }}</span>
</div>
<span v-else>
<span v-if="!selected"> {{ $t('form.field.select') }}</span>
<span v-else>{{ selected.label }} </span>
</span>
</template>
</ComboBox>
</div> </div>
</div> </div>
<CloseButton :onClose="() => removeAction(index)" /> <div class="cursor-pointer" @click.prevent="removeAction(index)">
<X size="16" />
</div>
</div> </div>
<div <div
@@ -66,10 +112,9 @@
v-if="action.type && conversationActions[action.type]?.type === 'richtext'" v-if="action.type && conversationActions[action.type]?.type === 'richtext'"
> >
<Editor <Editor
:autoFocus="false"
v-model:htmlContent="action.value[0]" v-model:htmlContent="action.value[0]"
@update:htmlContent="(value) => handleEditorChange(value, index)" @update:htmlContent="(value) => handleEditorChange(value, index)"
:placeholder="t('editor.newLine') + t('editor.send') + t('editor.ctrlK')" :placeholder="t('editor.placeholder')"
/> />
</div> </div>
</div> </div>
@@ -88,7 +133,7 @@
<script setup> <script setup>
import { toRefs } from 'vue' import { toRefs } from 'vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import CloseButton from '@/components/button/CloseButton.vue' import { X } from 'lucide-vue-next'
import { useTagStore } from '@/stores/tag' import { useTagStore } from '@/stores/tag'
import { import {
Select, Select,
@@ -98,12 +143,13 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from '@/components/ui/select' } from '@/components/ui/select'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { SelectTag } from '@/components/ui/select' import { SelectTag } from '@/components/ui/select'
import { useConversationFilters } from '@/composables/useConversationFilters' import { useConversationFilters } from '@/composables/useConversationFilters'
import { getTextFromHTML } from '@/utils/strings.js' import { getTextFromHTML } from '@/utils/strings.js'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Editor from '@/components/editor/TextEditor.vue' import Editor from '@/features/conversation/ConversationTextEditor.vue'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
const props = defineProps({ const props = defineProps({
actions: { actions: {

View File

@@ -23,7 +23,7 @@
</RadioGroup> </RadioGroup>
</div> </div>
<div class="space-y-5 rounded" :class="{ 'box p-5': ruleGroup.rules?.length > 0 }"> <div class="space-y-5 rounded-lg" :class="{ 'box p-5': ruleGroup.rules?.length > 0 }">
<div class="space-y-5"> <div class="space-y-5">
<div v-for="(rule, index) in ruleGroup.rules" :key="rule" class="space-y-5"> <div v-for="(rule, index) in ruleGroup.rules" :key="rule" class="space-y-5">
<div v-if="index > 0"> <div v-if="index > 0">
@@ -37,7 +37,7 @@
@update:modelValue="(value) => handleFieldChange(value, index)" @update:modelValue="(value) => handleFieldChange(value, index)"
> >
<SelectTrigger class="w-56"> <SelectTrigger class="w-56">
<SelectValue :placeholder="t('globals.messages.select', { name: t('globals.terms.field').toLowerCase() })" /> <SelectValue :placeholder="t('form.field.selectField')" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
@@ -65,7 +65,7 @@
@update:modelValue="(value) => handleOperatorChange(value, index)" @update:modelValue="(value) => handleOperatorChange(value, index)"
> >
<SelectTrigger class="w-56"> <SelectTrigger class="w-56">
<SelectValue :placeholder="t('globals.messages.select', { name: t('globals.terms.operator').toLowerCase() })" /> <SelectValue :placeholder="t('form.field.selectOperator')" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
@@ -85,7 +85,7 @@
<!-- Plain text input --> <!-- Plain text input -->
<Input <Input
type="text" type="text"
:placeholder="t('globals.messages.set', { name: t('globals.terms.value').toLowerCase() })" :placeholder="t('form.field.setValue')"
v-if="inputType(index) === 'text'" v-if="inputType(index) === 'text'"
v-model="rule.value" v-model="rule.value"
@update:modelValue="(value) => handleValueChange(value, index)" @update:modelValue="(value) => handleValueChange(value, index)"
@@ -94,7 +94,7 @@
<!-- Number input --> <!-- Number input -->
<Input <Input
type="number" type="number"
:placeholder="t('globals.messages.set', { name: t('globals.terms.value').toLowerCase() })" :placeholder="t('form.field.setValue')"
v-if="inputType(index) === 'number'" v-if="inputType(index) === 'number'"
v-model="rule.value" v-model="rule.value"
@update:modelValue="(value) => handleValueChange(value, index)" @update:modelValue="(value) => handleValueChange(value, index)"
@@ -102,12 +102,59 @@
<!-- Select input --> <!-- Select input -->
<div v-if="inputType(index) === 'select'"> <div v-if="inputType(index) === 'select'">
<SelectComboBox <ComboBox
v-model="rule.value" v-model="rule.value"
:items="getFieldOptions(rule.field, rule.field_type)" :items="getFieldOptions(rule.field, rule.field_type)"
@select="handleValueChange($event, index)" @select="handleValueChange($event, index)"
:type="rule.field === 'assigned_user' ? 'user' : 'team'" >
<template #item="{ item }">
<div class="flex items-center gap-2 ml-2">
<Avatar v-if="rule.field === 'assigned_user'" class="w-7 h-7">
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
<AvatarFallback>
{{ item.label.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span v-if="rule.field === 'assigned_team'">
{{ item.emoji }}
</span>
<span>{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div v-if="rule?.field === 'assigned_team'">
<div v-if="selected" class="flex items-center gap-2">
{{ selected.emoji }}
<span>{{ selected.label }}</span>
</div>
<span v-else>{{ $t('form.field.selectTeam') }}</span>
</div>
<div
v-else-if="rule?.field === 'assigned_user'"
class="flex items-center gap-2"
>
<div v-if="selected" class="flex items-center gap-2">
<Avatar class="w-7 h-7">
<AvatarImage
:src="selected.avatar_url || ''"
:alt="selected.label.slice(0, 2)"
/> />
<AvatarFallback>
{{ selected.label.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span>{{ selected.label }}</span>
</div>
<span v-else>{{ $t('form.field.selectUser') }}</span>
</div>
<span v-else>
<span v-if="!selected"> {{ $t('form.field.select') }}</span>
<span v-else>{{ selected.label }} </span>
</span>
</template>
</ComboBox>
</div> </div>
<!-- Tag input --> <!-- Tag input -->
@@ -124,7 +171,7 @@
<TagsInputItemText /> <TagsInputItemText />
<TagsInputItemDelete /> <TagsInputItemDelete />
</TagsInputItem> </TagsInputItem>
<TagsInputInput :placeholder="t('globals.messages.select', { name: t('globals.terms.value').toLowerCase() })" /> <TagsInputInput :placeholder="t('form.field.selectValue')" />
</TagsInput> </TagsInput>
<p class="text-xs text-gray-500 mt-1"> <p class="text-xs text-gray-500 mt-1">
{{ $t('globals.messages.pressEnterToSelectAValue') }} {{ $t('globals.messages.pressEnterToSelectAValue') }}
@@ -134,7 +181,7 @@
<!-- Date input --> <!-- Date input -->
<Input <Input
type="date" type="date"
:placeholder="t('globals.messages.set', { name: t('globals.terms.value').toLowerCase() })" :placeholder="t('form.field.setValue')"
v-if="inputType(index) === 'date'" v-if="inputType(index) === 'date'"
v-model="rule.value" v-model="rule.value"
@update:modelValue="(value) => handleValueChange(value, index)" @update:modelValue="(value) => handleValueChange(value, index)"
@@ -147,7 +194,7 @@
v-if="inputType(index) === 'boolean'" v-if="inputType(index) === 'boolean'"
> >
<SelectTrigger> <SelectTrigger>
<SelectValue :placeholder="t('globals.messages.select', { name: t('globals.terms.value').toLowerCase() })" /> <SelectValue :placeholder="t('form.field.selectValue')" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
@@ -162,7 +209,9 @@
<div v-else class="flex-1"></div> <div v-else class="flex-1"></div>
<!-- Remove condition --> <!-- Remove condition -->
<CloseButton :onClose="() => removeCondition(index)" /> <div class="cursor-pointer mt-2" @click.prevent="removeCondition(index)">
<X size="16" />
</div>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
@@ -193,7 +242,6 @@ import { toRefs, computed, watch } from 'vue'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import CloseButton from '@/components/button/CloseButton.vue'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -210,11 +258,13 @@ import {
TagsInputItemDelete, TagsInputItemDelete,
TagsInputItemText TagsInputItemText
} from '@/components/ui/tags-input' } from '@/components/ui/tags-input'
import { X } from 'lucide-vue-next'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useConversationFilters } from '@/composables/useConversationFilters' import { useConversationFilters } from '@/composables/useConversationFilters'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
const props = defineProps({ const props = defineProps({
ruleGroup: { ruleGroup: {

View File

@@ -7,8 +7,8 @@
{{ rule.name }} {{ rule.name }}
</div> </div>
<div class="mb-1"> <div class="mb-1">
<Badge v-if="rule.enabled" class="text-[9px]">{{ $t('globals.terms.enabled') }}</Badge> <Badge v-if="rule.enabled" class="text-[9px]">{{ $t('form.field.enabled') }}</Badge>
<Badge v-else variant="secondary">{{ $t('globals.terms.disabled') }}</Badge> <Badge v-else variant="secondary">{{ $t('form.field.disabled') }}</Badge>
</div> </div>
</span> </span>
</div> </div>
@@ -21,16 +21,16 @@
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem @click="navigateToEditRule(rule.id)"> <DropdownMenuItem @click="navigateToEditRule(rule.id)">
<span>{{ $t('globals.messages.edit') }}</span> <span>{{ $t('globals.buttons.edit') }}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem @click="() => (alertOpen = true)"> <DropdownMenuItem @click="() => (alertOpen = true)">
<span>{{ $t('globals.messages.delete') }}</span> <span>{{ $t('globals.buttons.delete') }}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem @click="$emit('toggle-rule', rule.id)" v-if="rule.enabled"> <DropdownMenuItem @click="$emit('toggle-rule', rule.id)" v-if="rule.enabled">
<span>{{ $t('globals.messages.disable') }}</span> <span>{{ $t('globals.buttons.disable') }}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem @click="$emit('toggle-rule', rule.id)" v-else> <DropdownMenuItem @click="$emit('toggle-rule', rule.id)" v-else>
<span>{{ $t('globals.messages.enable') }}</span> <span>{{ $t('globals.buttons.enable') }}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -44,17 +44,13 @@
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle> <AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{{ {{ $t('admin.automation.deleteConfirmation') }}
$t('globals.messages.deletionConfirmation', {
name: $t('globals.terms.automationRule').toLowerCase()
})
}}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>{{ $t('globals.messages.cancel') }}</AlertDialogCancel> <AlertDialogCancel>{{ $t('globals.buttons.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="handleDelete">{{ <AlertDialogAction @click="handleDelete">{{
$t('globals.messages.delete') $t('globals.buttons.delete')
}}</AlertDialogAction> }}</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

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